Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8db59d4bbe | |||
| 30dd2d5b75 |
+11
-4
@@ -20,6 +20,7 @@ name: CI
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
tags: ['v*']
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
@@ -71,9 +72,13 @@ jobs:
|
||||
# with docker create/cp and copy it into the running `wisp` web container so
|
||||
# it is downloadable at https://wisp.briggen.dev/wisp.apk.
|
||||
build-apk:
|
||||
needs: [test, deploy-web]
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/master'
|
||||
# ONLY on version tags (v*). The native build is heavy (NDK/whisper.cpp) and
|
||||
# slow on the shared prod runner; building it on every push would peg the box
|
||||
# (and degrade the live web apps). Cut a release with: git tag v1.2.3 && git push --tags
|
||||
needs: [test]
|
||||
if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v')
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- name: Stage signing secrets as files
|
||||
@@ -92,13 +97,15 @@ jobs:
|
||||
--secret id=ks_alias,src="$RUNNER_TEMP/sec/ks_alias" \
|
||||
--secret id=ks_keypass,src="$RUNNER_TEMP/sec/ks_keypass" \
|
||||
-f docker/android.Dockerfile -t wisp-android:ci --load .
|
||||
- name: Extract + publish APK to /wisp.apk
|
||||
- name: Extract APK + publish to the host (served at /wisp.apk via the compose volume)
|
||||
run: |
|
||||
cid=$(docker create wisp-android:ci)
|
||||
docker cp "$cid:/workspace/android/app/build/outputs/apk/release/app-release.apk" ./app-release.apk
|
||||
docker rm "$cid"
|
||||
ls -lh app-release.apk
|
||||
docker cp ./app-release.apk wisp:/usr/share/nginx/html/wisp.apk
|
||||
# Stream the APK into a host-mounted dir so it survives web container
|
||||
# recreation (the wisp container mounts /srv/wisp/wisp.apk read-only).
|
||||
docker run --rm -i -v /srv/wisp:/out alpine:3 sh -c 'mkdir -p /out && cat > /out/wisp.apk' < ./app-release.apk
|
||||
- name: Cleanup staged secrets
|
||||
if: always()
|
||||
run: rm -rf "$RUNNER_TEMP/sec"
|
||||
|
||||
@@ -27,6 +27,12 @@ services:
|
||||
- "traefik.http.routers.wisp.entrypoints=websecure"
|
||||
- "traefik.http.routers.wisp.tls.certresolver=letsencrypt"
|
||||
- "traefik.http.services.wisp.loadbalancer.server.port=8080"
|
||||
# The downloadable Android APK, published to the host by the build-apk CI job
|
||||
# (or a local build). Mounted read-only so it survives web container rebuilds.
|
||||
# NOTE: /srv/wisp/wisp.apk must exist on the host before `up` (else Docker
|
||||
# creates a directory there). The publish step creates it.
|
||||
volumes:
|
||||
- /srv/wisp/wisp.apk:/usr/share/nginx/html/wisp.apk:ro
|
||||
networks:
|
||||
- proxy
|
||||
|
||||
|
||||
@@ -97,7 +97,9 @@ RUN --mount=type=secret,id=ks_b64 \
|
||||
ANDROID_KEY_PASSWORD="$(cat /run/secrets/ks_keypass)" \
|
||||
bash scripts/ci-android-sign.sh \
|
||||
&& cd android \
|
||||
&& ./gradlew assembleRelease --no-daemon -x lint --stacktrace
|
||||
&& ./gradlew assembleRelease \
|
||||
-PreactNativeArchitectures=arm64-v8a \
|
||||
--no-daemon --max-workers=2 -x lint --stacktrace
|
||||
|
||||
# The signed APK (handy for `docker create` + `docker cp` extraction in CI).
|
||||
# /workspace/android/app/build/outputs/apk/release/app-release.apk
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function RootLayout() {
|
||||
<Stack>
|
||||
<Stack.Screen name="index" options={{ title: 'Wisp' }} />
|
||||
<Stack.Screen name="transcript/[id]" options={{ title: 'Transcript' }} />
|
||||
<Stack.Screen name="courses" options={{ title: 'Courses' }} />
|
||||
<Stack.Screen name="settings" options={{ title: 'Settings' }} />
|
||||
</Stack>
|
||||
<StatusBar style="auto" />
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
import { Stack, useFocusEffect } from 'expo-router';
|
||||
import { useCallback, useState } from 'react';
|
||||
import { Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { MaxContentWidth, Spacing } from '@/constants/theme';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { useCourses } from '@/stores/coursesStore';
|
||||
|
||||
export default function CoursesScreen() {
|
||||
const theme = useTheme();
|
||||
const { items, refresh, createCourse, rename, remove } = useCourses();
|
||||
const [name, setName] = useState('');
|
||||
const [editing, setEditing] = useState<string | null>(null);
|
||||
const [editName, setEditName] = useState('');
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void refresh();
|
||||
}, [refresh]),
|
||||
);
|
||||
|
||||
const add = async () => {
|
||||
const n = name.trim();
|
||||
if (!n) return;
|
||||
await createCourse({ name: n });
|
||||
setName('');
|
||||
};
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.fill}>
|
||||
<Stack.Screen options={{ title: 'Courses' }} />
|
||||
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
Group lectures by course. Deleting a course moves its transcripts to Unsorted (nothing is lost).
|
||||
</ThemedText>
|
||||
|
||||
<View style={styles.row}>
|
||||
<TextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
placeholder="New course name"
|
||||
placeholderTextColor={theme.textSecondary}
|
||||
style={[styles.input, styles.flex, { color: theme.text, backgroundColor: theme.backgroundElement }]}
|
||||
/>
|
||||
<Pressable onPress={() => void add()} style={[styles.addBtn, { opacity: name.trim() ? 1 : 0.5 }]}>
|
||||
<ThemedText style={styles.white}>Add</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{items.length === 0 && (
|
||||
<ThemedText type="small" themeColor="textSecondary" style={styles.pad}>No courses yet.</ThemedText>
|
||||
)}
|
||||
|
||||
{items.map((c) => (
|
||||
<ThemedView key={c.id} type="backgroundElement" style={styles.card}>
|
||||
{editing === c.id ? (
|
||||
<View style={styles.row}>
|
||||
<TextInput
|
||||
value={editName}
|
||||
onChangeText={setEditName}
|
||||
autoFocus
|
||||
style={[styles.input, styles.flex, { color: theme.text, backgroundColor: theme.background }]}
|
||||
/>
|
||||
<Pressable
|
||||
onPress={async () => {
|
||||
if (editName.trim()) await rename(c.id, editName.trim());
|
||||
setEditing(null);
|
||||
}}
|
||||
style={styles.addBtn}>
|
||||
<ThemedText style={styles.white}>Save</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
) : (
|
||||
<View style={styles.rowBetween}>
|
||||
<ThemedText type="smallBold" style={styles.flex} numberOfLines={1}>{c.name}</ThemedText>
|
||||
<View style={styles.actions}>
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
setEditing(c.id);
|
||||
setEditName(c.name);
|
||||
}}
|
||||
hitSlop={8}>
|
||||
<ThemedText type="small" themeColor="textSecondary">Rename</ThemedText>
|
||||
</Pressable>
|
||||
<Pressable onPress={() => void remove(c.id)} hitSlop={8}>
|
||||
<ThemedText type="small" themeColor="textSecondary">Delete</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</ThemedView>
|
||||
))}
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
fill: { flex: 1 },
|
||||
content: { padding: Spacing.three, gap: Spacing.two, maxWidth: MaxContentWidth, width: '100%', alignSelf: 'center' },
|
||||
row: { flexDirection: 'row', gap: Spacing.two, alignItems: 'center' },
|
||||
rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: Spacing.two },
|
||||
flex: { flex: 1 },
|
||||
input: { borderRadius: Spacing.two, paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, fontSize: 15 },
|
||||
addBtn: { backgroundColor: '#3c87f7', paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, borderRadius: Spacing.two },
|
||||
white: { color: '#fff', fontWeight: '700' },
|
||||
card: { padding: Spacing.three, borderRadius: Spacing.three },
|
||||
actions: { flexDirection: 'row', gap: Spacing.three },
|
||||
pad: { paddingVertical: Spacing.four, textAlign: 'center' },
|
||||
});
|
||||
+92
-25
@@ -1,8 +1,7 @@
|
||||
import { Link, useFocusEffect, useRouter } from 'expo-router';
|
||||
import { useCallback } from 'react';
|
||||
import { useCallback, useState } from 'react';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Platform,
|
||||
Pressable,
|
||||
ScrollView,
|
||||
StyleSheet,
|
||||
@@ -10,39 +9,57 @@ import {
|
||||
View,
|
||||
} from 'react-native';
|
||||
|
||||
import { PreCaptureSheet, type PreCaptureResult } from '@/components/PreCaptureSheet';
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { MaxContentWidth, Spacing } from '@/constants/theme';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import type { TranscriptMeta } from '@/lib/db';
|
||||
import { formatClock } from '@/lib/format';
|
||||
import { MODELS } from '@/lib/models/catalog';
|
||||
import { pickAudio } from '@/lib/pickAudio';
|
||||
import type { TranscriptMeta } from '@/lib/db';
|
||||
import { pickAudio, type PickedAudio } from '@/lib/pickAudio';
|
||||
import { useCourses } from '@/stores/coursesStore';
|
||||
import { useTranscribe } from '@/stores/transcribeStore';
|
||||
import { useTranscripts } from '@/stores/transcriptsStore';
|
||||
import { useTranscripts, type CourseFilter } from '@/stores/transcriptsStore';
|
||||
|
||||
export default function LibraryScreen() {
|
||||
const theme = useTheme();
|
||||
const router = useRouter();
|
||||
const { items, loading, query, setQuery, refresh, remove } = useTranscripts();
|
||||
const { items, loading, query, setQuery, refresh, remove, courseFilter, setCourseFilter } =
|
||||
useTranscripts();
|
||||
const courses = useCourses((s) => s.items);
|
||||
const refreshCourses = useCourses((s) => s.refresh);
|
||||
const job = useTranscribe();
|
||||
const [picked, setPicked] = useState<PickedAudio | null>(null);
|
||||
|
||||
// Refresh the list whenever the screen regains focus (e.g. after a transcribe).
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void refresh();
|
||||
}, [refresh]),
|
||||
void refreshCourses();
|
||||
}, [refresh, refreshCourses]),
|
||||
);
|
||||
|
||||
const busy = job.status === 'loading' || job.status === 'transcribing';
|
||||
|
||||
const onNew = useCallback(async () => {
|
||||
if (busy) return;
|
||||
const picked = await pickAudio();
|
||||
if (!picked) return;
|
||||
const id = await useTranscribe.getState().start(picked, { title: picked.name });
|
||||
const p = await pickAudio();
|
||||
if (p) setPicked(p); // opens the pre-capture sheet
|
||||
}, [busy]);
|
||||
|
||||
const onStart = useCallback(
|
||||
async (r: PreCaptureResult) => {
|
||||
const p = picked;
|
||||
setPicked(null);
|
||||
if (!p) return;
|
||||
const id = await useTranscribe.getState().start(p, r);
|
||||
if (id) router.push({ pathname: '/transcript/[id]', params: { id } });
|
||||
}, [busy, router]);
|
||||
},
|
||||
[picked, router],
|
||||
);
|
||||
|
||||
const courseName = (cid?: string | null) =>
|
||||
cid ? courses.find((c) => c.id === cid)?.name ?? 'Course' : null;
|
||||
|
||||
return (
|
||||
<ThemedView style={styles.fill}>
|
||||
@@ -54,13 +71,18 @@ export default function LibraryScreen() {
|
||||
Private transcription — runs on your device, nothing uploaded.
|
||||
</ThemedText>
|
||||
</View>
|
||||
<Link href="/settings" asChild>
|
||||
<Pressable hitSlop={10}>
|
||||
<ThemedText type="link" themeColor="textSecondary">
|
||||
⚙ Settings
|
||||
</ThemedText>
|
||||
<View style={styles.headerLinks}>
|
||||
<Link href="/courses" asChild>
|
||||
<Pressable hitSlop={8}>
|
||||
<ThemedText type="link" themeColor="textSecondary">Courses</ThemedText>
|
||||
</Pressable>
|
||||
</Link>
|
||||
<Link href="/settings" asChild>
|
||||
<Pressable hitSlop={8}>
|
||||
<ThemedText type="link" themeColor="textSecondary">⚙ Settings</ThemedText>
|
||||
</Pressable>
|
||||
</Link>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<Pressable
|
||||
@@ -77,12 +99,18 @@ export default function LibraryScreen() {
|
||||
{job.status === 'error' && (
|
||||
<ThemedView type="backgroundElement" style={styles.card}>
|
||||
<ThemedText type="smallBold">Transcription failed</ThemedText>
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{job.error}
|
||||
</ThemedText>
|
||||
<ThemedText type="small" themeColor="textSecondary">{job.error}</ThemedText>
|
||||
</ThemedView>
|
||||
)}
|
||||
|
||||
<ScrollView horizontal showsHorizontalScrollIndicator={false} contentContainerStyle={styles.filterBar}>
|
||||
<FilterChip label="All" active={courseFilter === 'all'} onPress={() => void setCourseFilter('all')} />
|
||||
<FilterChip label="Unsorted" active={courseFilter === null} onPress={() => void setCourseFilter(null)} />
|
||||
{courses.map((c) => (
|
||||
<FilterChip key={c.id} label={c.name} active={courseFilter === c.id} onPress={() => void setCourseFilter(c.id)} />
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
<TextInput
|
||||
value={query}
|
||||
onChangeText={(t) => void setQuery(t)}
|
||||
@@ -95,18 +123,42 @@ export default function LibraryScreen() {
|
||||
<ActivityIndicator style={styles.pad} />
|
||||
) : items.length === 0 ? (
|
||||
<ThemedText type="small" themeColor="textSecondary" style={styles.pad}>
|
||||
{query ? 'No matches.' : 'No transcripts yet. Pick an audio or video file to begin.'}
|
||||
{query ? 'No matches.' : 'No transcripts here yet. Pick an audio or video file to begin.'}
|
||||
</ThemedText>
|
||||
) : (
|
||||
items.map((t) => (
|
||||
<TranscriptRow key={t.id} item={t} onOpen={() => router.push({ pathname: '/transcript/[id]', params: { id: t.id } })} onDelete={() => void remove(t.id)} />
|
||||
<TranscriptRow
|
||||
key={t.id}
|
||||
item={t}
|
||||
courseName={courseName(t.courseId)}
|
||||
onOpen={() => router.push({ pathname: '/transcript/[id]', params: { id: t.id } })}
|
||||
onDelete={() => void remove(t.id)}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</ScrollView>
|
||||
|
||||
<PreCaptureSheet
|
||||
visible={picked !== null}
|
||||
defaultTitle={picked?.name ?? 'Recording'}
|
||||
onStart={(r) => void onStart(r)}
|
||||
onCancel={() => setPicked(null)}
|
||||
/>
|
||||
</ThemedView>
|
||||
);
|
||||
}
|
||||
|
||||
function FilterChip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) {
|
||||
const theme = useTheme();
|
||||
return (
|
||||
<Pressable
|
||||
onPress={onPress}
|
||||
style={[styles.filterChip, { backgroundColor: active ? '#3c87f7' : theme.backgroundElement }]}>
|
||||
<ThemedText type="small" style={active ? styles.chipActive : undefined}>{label}</ThemedText>
|
||||
</Pressable>
|
||||
);
|
||||
}
|
||||
|
||||
function ActiveJob() {
|
||||
const { stage, progress, partial, cancel } = useTranscribe();
|
||||
const pct = Math.round(progress * 100);
|
||||
@@ -138,8 +190,18 @@ function ProgressBar({ value }: { value: number }) {
|
||||
);
|
||||
}
|
||||
|
||||
function TranscriptRow({ item, onOpen, onDelete }: { item: TranscriptMeta; onOpen: () => void; onDelete: () => void }) {
|
||||
const date = new Date(item.createdAt).toLocaleDateString();
|
||||
function TranscriptRow({
|
||||
item,
|
||||
courseName,
|
||||
onOpen,
|
||||
onDelete,
|
||||
}: {
|
||||
item: TranscriptMeta;
|
||||
courseName: string | null;
|
||||
onOpen: () => void;
|
||||
onDelete: () => void;
|
||||
}) {
|
||||
const date = new Date(item.lectureDate ?? item.createdAt).toLocaleDateString();
|
||||
return (
|
||||
<Pressable onPress={onOpen} style={({ pressed }) => [pressed && styles.pressed]}>
|
||||
<ThemedView type="backgroundElement" style={styles.card}>
|
||||
@@ -152,7 +214,8 @@ function TranscriptRow({ item, onOpen, onDelete }: { item: TranscriptMeta; onOpe
|
||||
</Pressable>
|
||||
</View>
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
{date} · {formatClock(item.durationSec)} · {MODELS[item.modelId]?.label ?? item.modelId} · {item.segmentCount} segments
|
||||
{courseName ? `${courseName} · ` : ''}
|
||||
{date} · {formatClock(item.durationSec)} · {item.segmentCount} segments
|
||||
</ThemedText>
|
||||
</ThemedView>
|
||||
</Pressable>
|
||||
@@ -170,10 +233,14 @@ const styles = StyleSheet.create({
|
||||
},
|
||||
flex: { flex: 1 },
|
||||
headerRow: { flexDirection: 'row', alignItems: 'flex-start', gap: Spacing.two },
|
||||
headerLinks: { flexDirection: 'row', gap: Spacing.three, alignItems: 'center' },
|
||||
newButton: { paddingVertical: Spacing.three, borderRadius: Spacing.three, alignItems: 'center' },
|
||||
newButtonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
|
||||
card: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.two },
|
||||
rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: Spacing.two },
|
||||
filterBar: { gap: Spacing.two, paddingVertical: Spacing.one, paddingRight: Spacing.three },
|
||||
filterChip: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.one, borderRadius: 999 },
|
||||
chipActive: { color: '#fff', fontWeight: '700' },
|
||||
search: { borderRadius: Spacing.two, paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, fontSize: 15 },
|
||||
track: { height: 6, borderRadius: 3, backgroundColor: '#88888833', overflow: 'hidden' },
|
||||
bar: { height: 6, borderRadius: 3, backgroundColor: '#3c87f7' },
|
||||
|
||||
@@ -32,10 +32,30 @@ export default function TranscriptScreen() {
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [currentTime, setCurrentTime] = useState(0);
|
||||
|
||||
// The just-transcribed audio is playable in-session (object URL on web).
|
||||
const audioUrl = useTranscribe((s) => (s.lastTranscriptId === id ? s.audioUrl : undefined));
|
||||
// Prefer the just-transcribed in-session audio; otherwise load the persisted
|
||||
// source media so playback/scrub works after a reload (ROADMAP Phase 0).
|
||||
const sessionAudioUrl = useTranscribe((s) => (s.lastTranscriptId === id ? s.audioUrl : undefined));
|
||||
const [persistedUrl, setPersistedUrl] = useState<string | undefined>(undefined);
|
||||
const audioUrl = sessionAudioUrl ?? persistedUrl;
|
||||
const audioRef = useRef<HTMLAudioElement | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionAudioUrl) return; // already have in-session audio
|
||||
let url: string | undefined;
|
||||
let alive = true;
|
||||
void getRepo()
|
||||
.getMediaUrl(id)
|
||||
.then((u) => {
|
||||
if (!alive) return;
|
||||
url = u;
|
||||
setPersistedUrl(u);
|
||||
});
|
||||
return () => {
|
||||
alive = false;
|
||||
if (url && Platform.OS === 'web' && url.startsWith('blob:')) URL.revokeObjectURL(url);
|
||||
};
|
||||
}, [id, sessionAudioUrl]);
|
||||
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
void getRepo()
|
||||
@@ -124,7 +144,7 @@ export default function TranscriptScreen() {
|
||||
|
||||
{!audioUrl && (
|
||||
<ThemedText type="small" themeColor="textSecondary">
|
||||
Tip: audio playback is available right after transcribing. Re-import the file to scrub along.
|
||||
No source audio stored for this transcript — playback unavailable.
|
||||
</ThemedText>
|
||||
)}
|
||||
|
||||
|
||||
@@ -0,0 +1,171 @@
|
||||
// Modal shown before a transcription starts: capture title + course + lecture
|
||||
// date + language so the recording lands organized (ROADMAP Phase 0).
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Modal, Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native';
|
||||
|
||||
import { ThemedText } from '@/components/themed-text';
|
||||
import { ThemedView } from '@/components/themed-view';
|
||||
import { Spacing } from '@/constants/theme';
|
||||
import { useTheme } from '@/hooks/use-theme';
|
||||
import { useCourses } from '@/stores/coursesStore';
|
||||
|
||||
export interface PreCaptureResult {
|
||||
title: string;
|
||||
courseId: string | null;
|
||||
lectureDate?: number;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
interface Props {
|
||||
visible: boolean;
|
||||
defaultTitle: string;
|
||||
onStart: (r: PreCaptureResult) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function todayYmd(): string {
|
||||
const d = new Date();
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
||||
}
|
||||
|
||||
function parseYmd(s: string): number | undefined {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(s.trim());
|
||||
if (!m) return undefined;
|
||||
const t = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime();
|
||||
return Number.isFinite(t) ? t : undefined;
|
||||
}
|
||||
|
||||
export function PreCaptureSheet({ visible, defaultTitle, onStart, onCancel }: Props) {
|
||||
const theme = useTheme();
|
||||
const courses = useCourses((s) => s.items);
|
||||
const refresh = useCourses((s) => s.refresh);
|
||||
const createCourse = useCourses((s) => s.createCourse);
|
||||
|
||||
const [title, setTitle] = useState(defaultTitle);
|
||||
const [courseId, setCourseId] = useState<string | null>(null);
|
||||
const [dateStr, setDateStr] = useState(todayYmd());
|
||||
const [language, setLanguage] = useState('');
|
||||
const [newCourse, setNewCourse] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
if (visible) {
|
||||
setTitle(defaultTitle);
|
||||
void refresh();
|
||||
}
|
||||
}, [visible, defaultTitle, refresh]);
|
||||
|
||||
const addCourse = async () => {
|
||||
const name = newCourse.trim();
|
||||
if (!name) return;
|
||||
const c = await createCourse({ name });
|
||||
setCourseId(c.id);
|
||||
setNewCourse('');
|
||||
};
|
||||
|
||||
const inputStyle = [styles.input, { color: theme.text, backgroundColor: theme.backgroundElement }];
|
||||
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onCancel}>
|
||||
<View style={styles.backdrop}>
|
||||
<ThemedView style={styles.sheet}>
|
||||
<ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
|
||||
<ThemedText type="subtitle">New transcription</ThemedText>
|
||||
|
||||
<ThemedText type="small" themeColor="textSecondary">Title</ThemedText>
|
||||
<TextInput
|
||||
value={title}
|
||||
onChangeText={setTitle}
|
||||
placeholder="Title"
|
||||
placeholderTextColor={theme.textSecondary}
|
||||
style={inputStyle}
|
||||
/>
|
||||
|
||||
<ThemedText type="small" themeColor="textSecondary">Course</ThemedText>
|
||||
<View style={styles.chips}>
|
||||
<Pressable
|
||||
onPress={() => setCourseId(null)}
|
||||
style={[styles.chip, { backgroundColor: courseId === null ? '#3c87f7' : theme.backgroundElement }]}>
|
||||
<ThemedText type="small" style={courseId === null ? styles.chipActive : undefined}>Unsorted</ThemedText>
|
||||
</Pressable>
|
||||
{courses.map((c) => (
|
||||
<Pressable
|
||||
key={c.id}
|
||||
onPress={() => setCourseId(c.id)}
|
||||
style={[styles.chip, { backgroundColor: courseId === c.id ? '#3c87f7' : theme.backgroundElement }]}>
|
||||
<ThemedText type="small" style={courseId === c.id ? styles.chipActive : undefined}>{c.name}</ThemedText>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<TextInput
|
||||
value={newCourse}
|
||||
onChangeText={setNewCourse}
|
||||
placeholder="+ New course name"
|
||||
placeholderTextColor={theme.textSecondary}
|
||||
style={[inputStyle, styles.flex]}
|
||||
/>
|
||||
<Pressable onPress={() => void addCourse()} style={[styles.addBtn, { opacity: newCourse.trim() ? 1 : 0.5 }]}>
|
||||
<ThemedText type="small" style={styles.chipActive}>Add</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ThemedText type="small" themeColor="textSecondary">Lecture date</ThemedText>
|
||||
<TextInput
|
||||
value={dateStr}
|
||||
onChangeText={setDateStr}
|
||||
placeholder="YYYY-MM-DD"
|
||||
placeholderTextColor={theme.textSecondary}
|
||||
autoCapitalize="none"
|
||||
style={inputStyle}
|
||||
/>
|
||||
|
||||
<ThemedText type="small" themeColor="textSecondary">Language (blank = auto-detect)</ThemedText>
|
||||
<TextInput
|
||||
value={language}
|
||||
onChangeText={setLanguage}
|
||||
placeholder="e.g. en, de"
|
||||
placeholderTextColor={theme.textSecondary}
|
||||
autoCapitalize="none"
|
||||
style={inputStyle}
|
||||
/>
|
||||
|
||||
<View style={styles.actions}>
|
||||
<Pressable onPress={onCancel} style={[styles.btn, { backgroundColor: theme.backgroundElement }]}>
|
||||
<ThemedText>Cancel</ThemedText>
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={() =>
|
||||
onStart({
|
||||
title: title.trim() || defaultTitle,
|
||||
courseId,
|
||||
lectureDate: parseYmd(dateStr),
|
||||
language: language.trim() || undefined,
|
||||
})
|
||||
}
|
||||
style={[styles.btn, { backgroundColor: '#3c87f7' }]}>
|
||||
<ThemedText style={styles.startText}>Start</ThemedText>
|
||||
</Pressable>
|
||||
</View>
|
||||
</ScrollView>
|
||||
</ThemedView>
|
||||
</View>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
backdrop: { flex: 1, justifyContent: 'flex-end', backgroundColor: '#00000066' },
|
||||
sheet: { maxHeight: '88%', borderTopLeftRadius: Spacing.four, borderTopRightRadius: Spacing.four },
|
||||
content: { padding: Spacing.four, gap: Spacing.two, maxWidth: 800, width: '100%', alignSelf: 'center' },
|
||||
input: { borderRadius: Spacing.two, paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, fontSize: 15 },
|
||||
chips: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.two },
|
||||
chip: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.one, borderRadius: 999 },
|
||||
chipActive: { color: '#fff', fontWeight: '700' },
|
||||
row: { flexDirection: 'row', gap: Spacing.two, alignItems: 'center' },
|
||||
flex: { flex: 1 },
|
||||
addBtn: { backgroundColor: '#3c87f7', paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, borderRadius: Spacing.two },
|
||||
actions: { flexDirection: 'row', gap: Spacing.two, marginTop: Spacing.three },
|
||||
btn: { flex: 1, paddingVertical: Spacing.three, borderRadius: Spacing.three, alignItems: 'center' },
|
||||
startText: { color: '#fff', fontWeight: '700' },
|
||||
});
|
||||
+496
-31
@@ -5,23 +5,38 @@
|
||||
// list()/search() can be answered from columns alone without parsing every
|
||||
// body. Search uses a LIKE over the lowercased 'searchText' column.
|
||||
//
|
||||
// API reference: https://docs.expo.dev/versions/v56.0.0/sdk/sqlite/
|
||||
// We use openDatabaseAsync + the async methods (execAsync/runAsync/getAllAsync).
|
||||
// Schema evolution is handled by a real PRAGMA user_version migration runner
|
||||
// (see MIGRATIONS below), so devices created by the old single-table code
|
||||
// migrate forward in place rather than being wiped.
|
||||
//
|
||||
// API reference (read the EXACT versioned docs before touching this file):
|
||||
// https://docs.expo.dev/versions/v56.0.0/sdk/sqlite/
|
||||
// https://docs.expo.dev/versions/v56.0.0/sdk/filesystem/
|
||||
// We use openDatabaseAsync + the async methods (execAsync/runAsync/getAllAsync)
|
||||
// and the new object-oriented expo-file-system API (File/Directory/Paths).
|
||||
|
||||
import * as SQLite from 'expo-sqlite';
|
||||
import { Directory, File, Paths } from 'expo-file-system';
|
||||
import type { Segment } from '../types';
|
||||
import { newId } from '../ids';
|
||||
import {
|
||||
parseDraft,
|
||||
parseCourseDraft,
|
||||
zSegment,
|
||||
type TranscriptDraft,
|
||||
type TranscriptMeta,
|
||||
type Transcript,
|
||||
type TranscriptPatch,
|
||||
type Course,
|
||||
type CourseDraft,
|
||||
} from './schema';
|
||||
import type { StorageRepo } from './repo';
|
||||
import type { MediaInput, StorageRepo } from './repo';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Row shapes (as returned by getAllAsync). SQLite has no boolean/undefined:
|
||||
// optional text columns come back as string | null, booleans as 0/1 integers.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Row shape as returned by getAllAsync from the metadata columns. SQLite has no
|
||||
// boolean/undefined: optional language comes back as string | null.
|
||||
interface MetaRow {
|
||||
id: string;
|
||||
title: string;
|
||||
@@ -31,13 +46,40 @@ interface MetaRow {
|
||||
segmentCount: number;
|
||||
modelId: string;
|
||||
language: string | null;
|
||||
// v2 columns
|
||||
courseId: string | null;
|
||||
lectureDate: number | null;
|
||||
instructor: string | null;
|
||||
location: string | null;
|
||||
/** JSON-encoded string[] or null. */
|
||||
tags: string | null;
|
||||
lectureNumber: number | null;
|
||||
/** 0/1 integer. */
|
||||
hasMedia: number | null;
|
||||
}
|
||||
|
||||
// A meta row plus the JSON body, for get().
|
||||
// A meta row plus the JSON body, for get()/update().
|
||||
interface FullRow extends MetaRow {
|
||||
json: string;
|
||||
}
|
||||
|
||||
interface CourseRow {
|
||||
id: string;
|
||||
name: string;
|
||||
code: string | null;
|
||||
instructor: string | null;
|
||||
term: string | null;
|
||||
color: string | null;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
interface MediaRow {
|
||||
transcriptId: string;
|
||||
path: string;
|
||||
mime: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure derivation helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -47,9 +89,17 @@ function deriveSearchText(title: string, segments: Segment[]): string {
|
||||
return [title, ...segments.map((s) => s.text)].join('\n').toLowerCase();
|
||||
}
|
||||
|
||||
/** Map a metadata row to the public TranscriptMeta (drop null language). */
|
||||
/**
|
||||
* Ensure every segment has a stable id, assigning newId('s_') to any that
|
||||
* lack one. Returns a new array (does not mutate the input segments).
|
||||
*/
|
||||
function withSegmentIds(segments: Segment[]): Segment[] {
|
||||
return segments.map((s) => (s.id ? s : { ...s, id: newId('s_') }));
|
||||
}
|
||||
|
||||
/** Map a metadata row to the public TranscriptMeta. */
|
||||
function rowToMeta(r: MetaRow): TranscriptMeta {
|
||||
return {
|
||||
const meta: TranscriptMeta = {
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
createdAt: r.createdAt,
|
||||
@@ -57,22 +107,57 @@ function rowToMeta(r: MetaRow): TranscriptMeta {
|
||||
durationSec: r.durationSec,
|
||||
modelId: r.modelId as TranscriptMeta['modelId'],
|
||||
segmentCount: r.segmentCount,
|
||||
...(r.language !== null ? { language: r.language } : {}),
|
||||
// Keep courseId as null (the "Unsorted" sentinel) rather than dropping it.
|
||||
courseId: r.courseId,
|
||||
hasMedia: r.hasMedia === 1,
|
||||
};
|
||||
if (r.language !== null) meta.language = r.language;
|
||||
if (r.lectureDate !== null) meta.lectureDate = r.lectureDate;
|
||||
if (r.instructor !== null) meta.instructor = r.instructor;
|
||||
if (r.location !== null) meta.location = r.location;
|
||||
if (r.lectureNumber !== null) meta.lectureNumber = r.lectureNumber;
|
||||
if (r.tags !== null) {
|
||||
try {
|
||||
const parsed = JSON.parse(r.tags) as unknown;
|
||||
if (Array.isArray(parsed)) meta.tags = parsed as string[];
|
||||
} catch {
|
||||
// Corrupt tags JSON => treat as absent rather than throwing on read.
|
||||
}
|
||||
}
|
||||
return meta;
|
||||
}
|
||||
|
||||
function rowToCourse(r: CourseRow): Course {
|
||||
const course: Course = {
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
createdAt: r.createdAt,
|
||||
updatedAt: r.updatedAt,
|
||||
};
|
||||
if (r.code !== null) course.code = r.code;
|
||||
if (r.instructor !== null) course.instructor = r.instructor;
|
||||
if (r.term !== null) course.term = r.term;
|
||||
if (r.color !== null) course.color = r.color;
|
||||
return course;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy, memoized database handle + schema bootstrap
|
||||
// Migration runner
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Each entry migrates the DB from version (index) to (index + 1). On first
|
||||
// getDb() we read PRAGMA user_version and apply pending steps in order, each
|
||||
// wrapped in a transaction, then bump user_version. Steps are written to be
|
||||
// idempotent (IF NOT EXISTS / pragma_table_info guards) so they also no-op on
|
||||
// DBs the original single-table code already created at version 0.
|
||||
|
||||
let dbPromise: Promise<SQLite.SQLiteDatabase> | undefined;
|
||||
const TARGET_VERSION = 2;
|
||||
|
||||
function getDb(): Promise<SQLite.SQLiteDatabase> {
|
||||
// Open once and reuse; create the table on first open. The async open returns
|
||||
// a promise we cache so concurrent callers share a single connection.
|
||||
if (!dbPromise) {
|
||||
dbPromise = (async () => {
|
||||
const db = await SQLite.openDatabaseAsync('wisp.db');
|
||||
type Migration = (db: SQLite.SQLiteDatabase) => Promise<void>;
|
||||
|
||||
const MIGRATIONS: Migration[] = [
|
||||
// ---- v0 -> v1: the original transcripts table -------------------------
|
||||
async (db) => {
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS transcripts (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
@@ -88,6 +173,112 @@ function getDb(): Promise<SQLite.SQLiteDatabase> {
|
||||
CREATE INDEX IF NOT EXISTS idx_transcripts_createdAt
|
||||
ON transcripts (createdAt);
|
||||
`);
|
||||
},
|
||||
|
||||
// ---- v1 -> v2: course/lecture metadata, courses + media tables --------
|
||||
async (db) => {
|
||||
// Add the new transcript metadata columns. We guard each ADD COLUMN with a
|
||||
// pragma_table_info check so this step is safe even if a column already
|
||||
// exists (e.g. a DB that the old code happened to create with extras).
|
||||
const existing = await db.getAllAsync<{ name: string }>(
|
||||
`SELECT name FROM pragma_table_info('transcripts')`,
|
||||
);
|
||||
const have = new Set(existing.map((c) => c.name));
|
||||
const addColumn = async (name: string, decl: string): Promise<void> => {
|
||||
if (!have.has(name)) {
|
||||
await db.execAsync(`ALTER TABLE transcripts ADD COLUMN ${decl}`);
|
||||
}
|
||||
};
|
||||
await addColumn('courseId', 'courseId TEXT');
|
||||
await addColumn('lectureDate', 'lectureDate INTEGER');
|
||||
await addColumn('instructor', 'instructor TEXT');
|
||||
await addColumn('location', 'location TEXT');
|
||||
await addColumn('tags', 'tags TEXT');
|
||||
await addColumn('lectureNumber', 'lectureNumber REAL');
|
||||
await addColumn('hasMedia', 'hasMedia INTEGER DEFAULT 0');
|
||||
|
||||
// Courses table + name index for alphabetical listing.
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS courses (
|
||||
id TEXT PRIMARY KEY NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
code TEXT,
|
||||
instructor TEXT,
|
||||
term TEXT,
|
||||
color TEXT,
|
||||
createdAt INTEGER,
|
||||
updatedAt INTEGER
|
||||
);
|
||||
CREATE INDEX IF NOT EXISTS courses_name ON courses (name);
|
||||
`);
|
||||
|
||||
// Media table: one persisted source-audio file per transcript.
|
||||
await db.execAsync(`
|
||||
CREATE TABLE IF NOT EXISTS media (
|
||||
transcriptId TEXT PRIMARY KEY NOT NULL,
|
||||
path TEXT NOT NULL,
|
||||
mime TEXT NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// Backfill: assign stable ids to any segments in existing bodies that lack
|
||||
// one, so older transcripts gain the durable anchors the new code expects.
|
||||
const rows = await db.getAllAsync<{ id: string; json: string }>(
|
||||
`SELECT id, json FROM transcripts`,
|
||||
);
|
||||
for (const row of rows) {
|
||||
let transcript: Transcript;
|
||||
try {
|
||||
transcript = JSON.parse(row.json) as Transcript;
|
||||
} catch {
|
||||
continue; // Skip unparseable bodies rather than aborting the migration.
|
||||
}
|
||||
const segments = transcript.segments ?? [];
|
||||
const needsIds = segments.some((s) => !s.id);
|
||||
if (!needsIds) continue;
|
||||
const next: Transcript = {
|
||||
...transcript,
|
||||
segments: withSegmentIds(segments),
|
||||
};
|
||||
await db.runAsync(`UPDATE transcripts SET json = ? WHERE id = ?`, [
|
||||
JSON.stringify(next),
|
||||
row.id,
|
||||
]);
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
async function runMigrations(db: SQLite.SQLiteDatabase): Promise<void> {
|
||||
const result = await db.getFirstAsync<{ user_version: number }>(
|
||||
`PRAGMA user_version`,
|
||||
);
|
||||
let current = result?.user_version ?? 0;
|
||||
while (current < TARGET_VERSION) {
|
||||
const migrate = MIGRATIONS[current];
|
||||
if (!migrate) break; // Defensive: no step for this version.
|
||||
await db.withTransactionAsync(async () => {
|
||||
await migrate(db);
|
||||
});
|
||||
current += 1;
|
||||
// PRAGMA user_version does not accept bound params, so interpolate the
|
||||
// integer directly (it is a controlled loop counter, never user input).
|
||||
await db.execAsync(`PRAGMA user_version = ${current}`);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Lazy, memoized database handle + schema bootstrap
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let dbPromise: Promise<SQLite.SQLiteDatabase> | undefined;
|
||||
|
||||
function getDb(): Promise<SQLite.SQLiteDatabase> {
|
||||
// Open once and reuse; run migrations on first open. The async open returns a
|
||||
// promise we cache so concurrent callers share a single connection.
|
||||
if (!dbPromise) {
|
||||
dbPromise = (async () => {
|
||||
const db = await SQLite.openDatabaseAsync('wisp.db');
|
||||
await runMigrations(db);
|
||||
return db;
|
||||
})();
|
||||
}
|
||||
@@ -96,13 +287,33 @@ function getDb(): Promise<SQLite.SQLiteDatabase> {
|
||||
|
||||
// The metadata columns we select for list()/search(), kept in one place.
|
||||
const META_COLUMNS =
|
||||
'id, title, createdAt, updatedAt, durationSec, segmentCount, modelId, language';
|
||||
'id, title, createdAt, updatedAt, durationSec, segmentCount, modelId, language, ' +
|
||||
'courseId, lectureDate, instructor, location, tags, lectureNumber, hasMedia';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Media filesystem helpers (expo-file-system, SDK 56 object API)
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// TODO(native-untested): the media filesystem paths below cannot be exercised
|
||||
// off-device (expo-file-system has no Node/jsdom backend), so they are
|
||||
// type-checked and modeled on the SDK 56 docs but NOT runtime-tested here.
|
||||
|
||||
/** The <documentDirectory>/media/ directory, created on demand. */
|
||||
function mediaDir(): Directory {
|
||||
return new Directory(Paths.document, 'media');
|
||||
}
|
||||
|
||||
/** The persisted media file for a transcript (named by its id). */
|
||||
function mediaFile(transcriptId: string): File {
|
||||
return new File(mediaDir(), transcriptId);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Repo implementation
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const repo: StorageRepo = {
|
||||
// --- transcripts ---------------------------------------------------------
|
||||
async list(): Promise<TranscriptMeta[]> {
|
||||
const db = await getDb();
|
||||
const rows = await db.getAllAsync<MetaRow>(
|
||||
@@ -118,14 +329,18 @@ export const repo: StorageRepo = {
|
||||
[id],
|
||||
);
|
||||
if (!row) return undefined;
|
||||
// The JSON body is the authoritative full Transcript (already validated on
|
||||
// write), so we return it directly rather than reconstructing from columns.
|
||||
// The JSON body is the authoritative full Transcript (already validated and
|
||||
// segment-id-stamped on write), so we return it directly.
|
||||
return JSON.parse(row.json) as Transcript;
|
||||
},
|
||||
|
||||
async create(draft: TranscriptDraft): Promise<Transcript> {
|
||||
const valid = parseDraft(draft);
|
||||
const now = Date.now();
|
||||
// Assign a stable id to every segment that lacks one before storing.
|
||||
const segments = withSegmentIds(valid.segments);
|
||||
const courseId = valid.courseId ?? null;
|
||||
|
||||
const transcript: Transcript = {
|
||||
id: newId('t_'),
|
||||
title: valid.title,
|
||||
@@ -133,16 +348,27 @@ export const repo: StorageRepo = {
|
||||
updatedAt: now,
|
||||
durationSec: valid.durationSec,
|
||||
modelId: valid.modelId,
|
||||
segmentCount: segments.length,
|
||||
segments,
|
||||
courseId,
|
||||
hasMedia: false,
|
||||
...(valid.language !== undefined ? { language: valid.language } : {}),
|
||||
segmentCount: valid.segments.length,
|
||||
segments: valid.segments,
|
||||
...(valid.lectureDate !== undefined ? { lectureDate: valid.lectureDate } : {}),
|
||||
...(valid.instructor !== undefined ? { instructor: valid.instructor } : {}),
|
||||
...(valid.location !== undefined ? { location: valid.location } : {}),
|
||||
...(valid.tags !== undefined ? { tags: valid.tags } : {}),
|
||||
...(valid.lectureNumber !== undefined
|
||||
? { lectureNumber: valid.lectureNumber }
|
||||
: {}),
|
||||
};
|
||||
|
||||
const db = await getDb();
|
||||
await db.runAsync(
|
||||
`INSERT INTO transcripts
|
||||
(id, json, searchText, createdAt, updatedAt, durationSec, segmentCount, modelId, language)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
(id, json, searchText, createdAt, updatedAt, durationSec, segmentCount,
|
||||
modelId, language, courseId, lectureDate, instructor, location, tags,
|
||||
lectureNumber, hasMedia)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
transcript.id,
|
||||
JSON.stringify(transcript),
|
||||
@@ -154,15 +380,19 @@ export const repo: StorageRepo = {
|
||||
transcript.modelId,
|
||||
// expo-sqlite accepts null but not undefined for params.
|
||||
transcript.language ?? null,
|
||||
courseId,
|
||||
transcript.lectureDate ?? null,
|
||||
transcript.instructor ?? null,
|
||||
transcript.location ?? null,
|
||||
transcript.tags !== undefined ? JSON.stringify(transcript.tags) : null,
|
||||
transcript.lectureNumber ?? null,
|
||||
transcript.hasMedia ? 1 : 0,
|
||||
],
|
||||
);
|
||||
return transcript;
|
||||
},
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
patch: Partial<Pick<Transcript, 'title' | 'segments'>>,
|
||||
): Promise<Transcript> {
|
||||
async update(id: string, patch: TranscriptPatch): Promise<Transcript> {
|
||||
const db = await getDb();
|
||||
const existingRow = await db.getFirstAsync<FullRow>(
|
||||
`SELECT ${META_COLUMNS}, json FROM transcripts WHERE id = ?`,
|
||||
@@ -173,24 +403,36 @@ export const repo: StorageRepo = {
|
||||
}
|
||||
const existing = JSON.parse(existingRow.json) as Transcript;
|
||||
|
||||
// Re-validate segments on update so non-finite times can never slip in.
|
||||
// Re-validate segments on update (so non-finite times can never slip in)
|
||||
// and stamp ids onto any that are missing one.
|
||||
const nextSegments =
|
||||
patch.segments !== undefined
|
||||
? patch.segments.map((s) => zSegment.parse(s))
|
||||
? withSegmentIds(patch.segments.map((s) => zSegment.parse(s)))
|
||||
: existing.segments;
|
||||
const nextTitle = patch.title !== undefined ? patch.title : existing.title;
|
||||
|
||||
// Apply only provided patch fields; leave everything else as-is.
|
||||
const updated: Transcript = {
|
||||
...existing,
|
||||
title: nextTitle,
|
||||
segments: nextSegments,
|
||||
segmentCount: nextSegments.length,
|
||||
updatedAt: Date.now(),
|
||||
...(patch.courseId !== undefined ? { courseId: patch.courseId } : {}),
|
||||
...(patch.lectureDate !== undefined ? { lectureDate: patch.lectureDate } : {}),
|
||||
...(patch.instructor !== undefined ? { instructor: patch.instructor } : {}),
|
||||
...(patch.location !== undefined ? { location: patch.location } : {}),
|
||||
...(patch.tags !== undefined ? { tags: patch.tags } : {}),
|
||||
...(patch.lectureNumber !== undefined
|
||||
? { lectureNumber: patch.lectureNumber }
|
||||
: {}),
|
||||
};
|
||||
|
||||
await db.runAsync(
|
||||
`UPDATE transcripts
|
||||
SET json = ?, searchText = ?, updatedAt = ?, segmentCount = ?, title = ?
|
||||
SET json = ?, searchText = ?, updatedAt = ?, segmentCount = ?, title = ?,
|
||||
courseId = ?, lectureDate = ?, instructor = ?, location = ?, tags = ?,
|
||||
lectureNumber = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
JSON.stringify(updated),
|
||||
@@ -198,6 +440,12 @@ export const repo: StorageRepo = {
|
||||
updated.updatedAt,
|
||||
updated.segmentCount,
|
||||
updated.title,
|
||||
updated.courseId ?? null,
|
||||
updated.lectureDate ?? null,
|
||||
updated.instructor ?? null,
|
||||
updated.location ?? null,
|
||||
updated.tags !== undefined ? JSON.stringify(updated.tags) : null,
|
||||
updated.lectureNumber ?? null,
|
||||
id,
|
||||
],
|
||||
);
|
||||
@@ -206,6 +454,8 @@ export const repo: StorageRepo = {
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
// Delete persisted media first so we never orphan a file.
|
||||
await this.removeMedia(id);
|
||||
await db.runAsync(`DELETE FROM transcripts WHERE id = ?`, [id]);
|
||||
},
|
||||
|
||||
@@ -230,4 +480,219 @@ export const repo: StorageRepo = {
|
||||
);
|
||||
return rows.map(rowToMeta);
|
||||
},
|
||||
|
||||
async listByCourse(courseId: string | null): Promise<TranscriptMeta[]> {
|
||||
const db = await getDb();
|
||||
if (courseId === null) {
|
||||
// "Unsorted": courseId IS NULL covers both null and never-set (the column
|
||||
// defaults to NULL for rows written before v2).
|
||||
const rows = await db.getAllAsync<MetaRow>(
|
||||
`SELECT ${META_COLUMNS} FROM transcripts
|
||||
WHERE courseId IS NULL
|
||||
ORDER BY createdAt DESC`,
|
||||
);
|
||||
return rows.map(rowToMeta);
|
||||
}
|
||||
const rows = await db.getAllAsync<MetaRow>(
|
||||
`SELECT ${META_COLUMNS} FROM transcripts
|
||||
WHERE courseId = ?
|
||||
ORDER BY createdAt DESC`,
|
||||
[courseId],
|
||||
);
|
||||
return rows.map(rowToMeta);
|
||||
},
|
||||
|
||||
async reassign(transcriptId: string, courseId: string | null): Promise<void> {
|
||||
const db = await getDb();
|
||||
const row = await db.getFirstAsync<FullRow>(
|
||||
`SELECT ${META_COLUMNS}, json FROM transcripts WHERE id = ?`,
|
||||
[transcriptId],
|
||||
);
|
||||
if (!row) {
|
||||
throw new Error(`transcript not found: ${transcriptId}`);
|
||||
}
|
||||
const transcript = JSON.parse(row.json) as Transcript;
|
||||
const updatedAt = Date.now();
|
||||
const next: Transcript = { ...transcript, courseId, updatedAt };
|
||||
await db.runAsync(
|
||||
`UPDATE transcripts SET courseId = ?, updatedAt = ?, json = ? WHERE id = ?`,
|
||||
[courseId, updatedAt, JSON.stringify(next), transcriptId],
|
||||
);
|
||||
},
|
||||
|
||||
// --- courses -------------------------------------------------------------
|
||||
async listCourses(): Promise<Course[]> {
|
||||
const db = await getDb();
|
||||
const rows = await db.getAllAsync<CourseRow>(
|
||||
`SELECT id, name, code, instructor, term, color, createdAt, updatedAt
|
||||
FROM courses ORDER BY name ASC`,
|
||||
);
|
||||
return rows.map(rowToCourse);
|
||||
},
|
||||
|
||||
async createCourse(draft: CourseDraft): Promise<Course> {
|
||||
const valid = parseCourseDraft(draft);
|
||||
const now = Date.now();
|
||||
const course: Course = {
|
||||
id: newId('c_'),
|
||||
name: valid.name,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
...(valid.code !== undefined ? { code: valid.code } : {}),
|
||||
...(valid.instructor !== undefined ? { instructor: valid.instructor } : {}),
|
||||
...(valid.term !== undefined ? { term: valid.term } : {}),
|
||||
...(valid.color !== undefined ? { color: valid.color } : {}),
|
||||
};
|
||||
const db = await getDb();
|
||||
await db.runAsync(
|
||||
`INSERT INTO courses
|
||||
(id, name, code, instructor, term, color, createdAt, updatedAt)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
course.id,
|
||||
course.name,
|
||||
course.code ?? null,
|
||||
course.instructor ?? null,
|
||||
course.term ?? null,
|
||||
course.color ?? null,
|
||||
course.createdAt,
|
||||
course.updatedAt,
|
||||
],
|
||||
);
|
||||
return course;
|
||||
},
|
||||
|
||||
async updateCourse(id: string, patch: Partial<CourseDraft>): Promise<Course> {
|
||||
const db = await getDb();
|
||||
const existing = await db.getFirstAsync<CourseRow>(
|
||||
`SELECT id, name, code, instructor, term, color, createdAt, updatedAt
|
||||
FROM courses WHERE id = ?`,
|
||||
[id],
|
||||
);
|
||||
if (!existing) {
|
||||
throw new Error(`course not found: ${id}`);
|
||||
}
|
||||
const current = rowToCourse(existing);
|
||||
// Validate only the patched fields by merging onto the existing draft view.
|
||||
const merged = parseCourseDraft({
|
||||
name: patch.name ?? current.name,
|
||||
...(patch.code !== undefined ? { code: patch.code } : current.code !== undefined ? { code: current.code } : {}),
|
||||
...(patch.instructor !== undefined
|
||||
? { instructor: patch.instructor }
|
||||
: current.instructor !== undefined
|
||||
? { instructor: current.instructor }
|
||||
: {}),
|
||||
...(patch.term !== undefined ? { term: patch.term } : current.term !== undefined ? { term: current.term } : {}),
|
||||
...(patch.color !== undefined ? { color: patch.color } : current.color !== undefined ? { color: current.color } : {}),
|
||||
});
|
||||
const updated: Course = {
|
||||
id: current.id,
|
||||
name: merged.name,
|
||||
createdAt: current.createdAt,
|
||||
updatedAt: Date.now(),
|
||||
...(merged.code !== undefined ? { code: merged.code } : {}),
|
||||
...(merged.instructor !== undefined ? { instructor: merged.instructor } : {}),
|
||||
...(merged.term !== undefined ? { term: merged.term } : {}),
|
||||
...(merged.color !== undefined ? { color: merged.color } : {}),
|
||||
};
|
||||
await db.runAsync(
|
||||
`UPDATE courses
|
||||
SET name = ?, code = ?, instructor = ?, term = ?, color = ?, updatedAt = ?
|
||||
WHERE id = ?`,
|
||||
[
|
||||
updated.name,
|
||||
updated.code ?? null,
|
||||
updated.instructor ?? null,
|
||||
updated.term ?? null,
|
||||
updated.color ?? null,
|
||||
updated.updatedAt,
|
||||
id,
|
||||
],
|
||||
);
|
||||
return updated;
|
||||
},
|
||||
|
||||
async deleteCourse(id: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
// First reassign this course's transcripts to Unsorted, THEN delete the
|
||||
// course row, so we never leave dangling courseId references.
|
||||
await db.withTransactionAsync(async () => {
|
||||
await db.runAsync(
|
||||
`UPDATE transcripts SET courseId = NULL WHERE courseId = ?`,
|
||||
[id],
|
||||
);
|
||||
await db.runAsync(`DELETE FROM courses WHERE id = ?`, [id]);
|
||||
});
|
||||
},
|
||||
|
||||
// --- media (persisted source audio) -------------------------------------
|
||||
// TODO(native-untested): these filesystem paths are type-checked against the
|
||||
// SDK 56 expo-file-system API but cannot be run off-device here.
|
||||
async putMedia(transcriptId: string, input: MediaInput): Promise<void> {
|
||||
const db = await getDb();
|
||||
|
||||
// Ensure <documentDirectory>/media/ exists.
|
||||
const dir = mediaDir();
|
||||
if (!dir.exists) {
|
||||
dir.create({ intermediates: true });
|
||||
}
|
||||
|
||||
// Write the bytes into <media>/<transcriptId>, replacing any prior file.
|
||||
const dest = mediaFile(transcriptId);
|
||||
if (dest.exists) {
|
||||
dest.delete();
|
||||
}
|
||||
if (input.data !== undefined) {
|
||||
// Raw bytes: create then write the buffer.
|
||||
dest.create();
|
||||
dest.write(new Uint8Array(input.data));
|
||||
} else if (input.uri !== undefined) {
|
||||
// Copy the source file into app storage at the destination path.
|
||||
const src = new File(input.uri);
|
||||
await src.copy(dest);
|
||||
} else {
|
||||
throw new Error('putMedia requires either input.data or input.uri');
|
||||
}
|
||||
|
||||
// Upsert the media row and flag the transcript as having media.
|
||||
await db.runAsync(
|
||||
`INSERT INTO media (transcriptId, path, mime) VALUES (?, ?, ?)
|
||||
ON CONFLICT(transcriptId) DO UPDATE SET path = excluded.path, mime = excluded.mime`,
|
||||
[transcriptId, dest.uri, input.mime],
|
||||
);
|
||||
await db.runAsync(`UPDATE transcripts SET hasMedia = 1 WHERE id = ?`, [
|
||||
transcriptId,
|
||||
]);
|
||||
},
|
||||
|
||||
async getMediaUrl(transcriptId: string): Promise<string | undefined> {
|
||||
const db = await getDb();
|
||||
const row = await db.getFirstAsync<MediaRow>(
|
||||
`SELECT transcriptId, path, mime FROM media WHERE transcriptId = ?`,
|
||||
[transcriptId],
|
||||
);
|
||||
return row ? row.path : undefined;
|
||||
},
|
||||
|
||||
async removeMedia(transcriptId: string): Promise<void> {
|
||||
const db = await getDb();
|
||||
const row = await db.getFirstAsync<MediaRow>(
|
||||
`SELECT transcriptId, path, mime FROM media WHERE transcriptId = ?`,
|
||||
[transcriptId],
|
||||
);
|
||||
if (!row) return; // No-op if absent.
|
||||
|
||||
// Delete the file on disk (best-effort: tolerate an already-missing file).
|
||||
try {
|
||||
const file = new File(row.path);
|
||||
if (file.exists) file.delete();
|
||||
} catch {
|
||||
// Ignore filesystem errors so the DB row is still cleaned up below.
|
||||
}
|
||||
|
||||
await db.runAsync(`DELETE FROM media WHERE transcriptId = ?`, [transcriptId]);
|
||||
await db.runAsync(`UPDATE transcripts SET hasMedia = 0 WHERE id = ?`, [
|
||||
transcriptId,
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
||||
+201
-27
@@ -4,11 +4,58 @@
|
||||
// under vitest. We test the Dexie repo as the reference implementation of the
|
||||
// StorageRepo contract; the native expo-sqlite repo (native-only) is NOT
|
||||
// imported here because expo-sqlite cannot load outside a device/simulator.
|
||||
//
|
||||
// IMPORTANT: repo.web.ts is *not* statically imported. To exercise the v1->v2
|
||||
// migration we must first seed a v1-shaped DB with a raw Dexie connection, and
|
||||
// only THEN import repo.web (whose module-eval opens the DB at v2, triggering
|
||||
// the upgrade). The dynamically-imported repo is stashed in a module-level
|
||||
// binding so all subsequent tests reuse it.
|
||||
import 'fake-indexeddb/auto';
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { repo } from './repo.web';
|
||||
import { parseDraft, type TranscriptDraft } from './schema';
|
||||
import Dexie from 'dexie';
|
||||
import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
|
||||
import {
|
||||
parseDraft,
|
||||
type TranscriptDraft,
|
||||
type StorageRepo,
|
||||
} from './index';
|
||||
|
||||
// Populated by the migration beforeAll (which seeds v1 then imports repo.web).
|
||||
let repo: StorageRepo;
|
||||
|
||||
// The v1 record we seed: NO segment ids, NO courseId, but WITH a searchText
|
||||
// column (exactly the legacy shape the v1 repo persisted).
|
||||
const V1_ID = 't_legacy';
|
||||
const v1Row = {
|
||||
id: V1_ID,
|
||||
title: 'Legacy Lecture',
|
||||
createdAt: 1000,
|
||||
updatedAt: 1000,
|
||||
durationSec: 42.5,
|
||||
modelId: 'tiny.en' as const,
|
||||
segmentCount: 2,
|
||||
segments: [
|
||||
{ start: 0, end: 2, text: 'legacy hello' },
|
||||
{ start: 2, end: 5, text: 'legacy world', confidence: 0.8 },
|
||||
],
|
||||
searchText: 'legacy lecture\nlegacy hello\nlegacy world',
|
||||
};
|
||||
|
||||
beforeAll(async () => {
|
||||
// 1) Seed a v1 DB with a raw Dexie connection (only the v1 store/schema).
|
||||
const v1 = new Dexie('wisp');
|
||||
v1.version(1).stores({ transcripts: 'id, createdAt' });
|
||||
await v1.open();
|
||||
await v1.table('transcripts').put(v1Row);
|
||||
v1.close();
|
||||
|
||||
// 2) Now import repo.web, which opens 'wisp' at v2 and runs the upgrade.
|
||||
const mod = await import('./repo.web');
|
||||
repo = mod.repo;
|
||||
|
||||
// Stub object-URL creation so getMediaUrl returns a string under Node.
|
||||
globalThis.URL.createObjectURL = () => 'blob:mock';
|
||||
});
|
||||
|
||||
// Helper: a minimal valid draft, overridable per test.
|
||||
function makeDraft(over: Partial<TranscriptDraft> = {}): TranscriptDraft {
|
||||
@@ -24,31 +71,76 @@ function makeDraft(over: Partial<TranscriptDraft> = {}): TranscriptDraft {
|
||||
};
|
||||
}
|
||||
|
||||
// Wipe storage between tests so ordering/search assertions are deterministic.
|
||||
beforeEach(async () => {
|
||||
const all = await repo.list();
|
||||
await Promise.all(all.map((m) => repo.remove(m.id)));
|
||||
describe('v1 -> v2 migration (no data loss)', () => {
|
||||
it('keeps the legacy transcript, backfills segment ids, leaves it Unsorted', async () => {
|
||||
const fetched = await repo.get(V1_ID);
|
||||
expect(fetched).toBeDefined();
|
||||
|
||||
// Identity-preserving fields survive untouched.
|
||||
expect(fetched!.title).toBe('Legacy Lecture');
|
||||
expect(fetched!.durationSec).toBe(42.5);
|
||||
|
||||
// Segment text/timing is identical to the v1 body...
|
||||
expect(fetched!.segments.map((s) => s.text)).toEqual([
|
||||
'legacy hello',
|
||||
'legacy world',
|
||||
]);
|
||||
expect(fetched!.segments.map((s) => ({ start: s.start, end: s.end }))).toEqual([
|
||||
{ start: 0, end: 2 },
|
||||
{ start: 2, end: 5 },
|
||||
]);
|
||||
expect(fetched!.segments[1]!.confidence).toBe(0.8);
|
||||
|
||||
// ...but every segment now carries a backfilled stable id.
|
||||
for (const s of fetched!.segments) {
|
||||
expect(s.id).toMatch(/^s_/);
|
||||
}
|
||||
|
||||
// No courseId was set by v1 => the row is "Unsorted" (null/undefined).
|
||||
expect(fetched!.courseId == null).toBe(true);
|
||||
|
||||
// And it shows up under the Unsorted bucket.
|
||||
const unsorted = await repo.listByCourse(null);
|
||||
expect(unsorted.map((m) => m.id)).toContain(V1_ID);
|
||||
});
|
||||
});
|
||||
|
||||
describe('StorageRepo (Dexie web impl)', () => {
|
||||
it('create -> get round-trips segments and metadata', async () => {
|
||||
// Wipe transcripts/courses/media between tests so ordering/search/course
|
||||
// assertions are deterministic. (Runs after the migration beforeAll.)
|
||||
beforeEach(async () => {
|
||||
const all = await repo.list();
|
||||
await Promise.all(all.map((m) => repo.remove(m.id)));
|
||||
const courses = await repo.listCourses();
|
||||
await Promise.all(courses.map((c) => repo.deleteCourse(c.id)));
|
||||
});
|
||||
|
||||
it('create -> get round-trips segments and metadata, assigns segment ids', async () => {
|
||||
const created = await repo.create(makeDraft());
|
||||
|
||||
expect(created.id).toMatch(/^t_/);
|
||||
expect(created.createdAt).toBeTypeOf('number');
|
||||
expect(created.updatedAt).toBe(created.createdAt);
|
||||
expect(created.segmentCount).toBe(2);
|
||||
// courseId defaults to null ("Unsorted") when absent.
|
||||
expect(created.courseId).toBeNull();
|
||||
expect(created.hasMedia).toBe(false);
|
||||
// Every segment got a stable id assigned on write.
|
||||
for (const s of created.segments) {
|
||||
expect(s.id).toMatch(/^s_/);
|
||||
}
|
||||
|
||||
const fetched = await repo.get(created.id);
|
||||
expect(fetched).toBeDefined();
|
||||
expect(fetched!.title).toBe('My Recording');
|
||||
expect(fetched!.durationSec).toBe(12.5);
|
||||
expect(fetched!.modelId).toBe('tiny.en');
|
||||
// Segments survive the round-trip exactly.
|
||||
expect(fetched!.segments).toEqual([
|
||||
// Text/timing survive the round-trip exactly.
|
||||
expect(fetched!.segments.map((s) => ({ start: s.start, end: s.end, text: s.text }))).toEqual([
|
||||
{ start: 0, end: 2, text: 'hello world' },
|
||||
{ start: 2, end: 5, text: 'second segment', confidence: 0.9 },
|
||||
{ start: 2, end: 5, text: 'second segment' },
|
||||
]);
|
||||
expect(fetched!.segments[1]!.confidence).toBe(0.9);
|
||||
});
|
||||
|
||||
it('get returns undefined for an unknown id', async () => {
|
||||
@@ -57,7 +149,6 @@ describe('StorageRepo (Dexie web impl)', () => {
|
||||
|
||||
it('list returns metadatas newest-first (without segments)', async () => {
|
||||
const a = await repo.create(makeDraft({ title: 'first' }));
|
||||
// Force distinct, increasing createdAt timestamps.
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
const b = await repo.create(makeDraft({ title: 'second' }));
|
||||
await new Promise((r) => setTimeout(r, 2));
|
||||
@@ -65,12 +156,12 @@ describe('StorageRepo (Dexie web impl)', () => {
|
||||
|
||||
const metas = await repo.list();
|
||||
expect(metas.map((m) => m.id)).toEqual([c.id, b.id, a.id]);
|
||||
// Metas omit the segment body.
|
||||
expect((metas[0] as unknown as Record<string, unknown>).segments).toBeUndefined();
|
||||
expect(metas[0]!.segmentCount).toBe(2);
|
||||
});
|
||||
|
||||
it('update changes title + segments and re-derives searchText for search', async () => {
|
||||
it('update merges title/segments/courseId and re-ids segments + re-derives search', async () => {
|
||||
const course = await repo.createCourse({ name: 'Physics' });
|
||||
const created = await repo.create(
|
||||
makeDraft({ title: 'Old Title', segments: [{ start: 0, end: 1, text: 'apple' }] }),
|
||||
);
|
||||
@@ -79,6 +170,7 @@ describe('StorageRepo (Dexie web impl)', () => {
|
||||
|
||||
const updated = await repo.update(created.id, {
|
||||
title: 'Brand New Title',
|
||||
courseId: course.id,
|
||||
segments: [
|
||||
{ start: 0, end: 1, text: 'banana' },
|
||||
{ start: 1, end: 2, text: 'cherry' },
|
||||
@@ -86,16 +178,19 @@ describe('StorageRepo (Dexie web impl)', () => {
|
||||
});
|
||||
|
||||
expect(updated.title).toBe('Brand New Title');
|
||||
expect(updated.courseId).toBe(course.id);
|
||||
expect(updated.segmentCount).toBe(2);
|
||||
expect(updated.updatedAt).toBeGreaterThan(originalUpdatedAt);
|
||||
// New segments got ids.
|
||||
for (const s of updated.segments) {
|
||||
expect(s.id).toMatch(/^s_/);
|
||||
}
|
||||
|
||||
// Old text is no longer findable...
|
||||
expect(await repo.search('apple')).toHaveLength(0);
|
||||
// ...and the new text + new title are.
|
||||
const byText = await repo.search('cherry');
|
||||
expect(byText.map((m) => m.id)).toEqual([created.id]);
|
||||
const byTitle = await repo.search('brand new');
|
||||
expect(byTitle.map((m) => m.id)).toEqual([created.id]);
|
||||
expect((await repo.search('cherry')).map((m) => m.id)).toEqual([created.id]);
|
||||
expect((await repo.search('brand new')).map((m) => m.id)).toEqual([created.id]);
|
||||
});
|
||||
|
||||
it('update throws for an unknown id', async () => {
|
||||
@@ -110,24 +205,106 @@ describe('StorageRepo (Dexie web impl)', () => {
|
||||
}),
|
||||
);
|
||||
|
||||
// Title match, different case.
|
||||
expect((await repo.search('meeting')).map((m) => m.id)).toEqual([t.id]);
|
||||
// Segment-text match, different case.
|
||||
expect((await repo.search('budget')).map((m) => m.id)).toEqual([t.id]);
|
||||
// Non-match.
|
||||
expect(await repo.search('zzz-not-present')).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('remove deletes a transcript', async () => {
|
||||
it('remove deletes a transcript (and its media)', async () => {
|
||||
const created = await repo.create(makeDraft());
|
||||
await repo.putMedia(created.id, { data: new ArrayBuffer(4), mime: 'audio/mpeg' });
|
||||
expect(await repo.get(created.id)).toBeDefined();
|
||||
|
||||
await repo.remove(created.id);
|
||||
expect(await repo.get(created.id)).toBeUndefined();
|
||||
expect(await repo.list()).toHaveLength(0);
|
||||
// Media is gone too: no playable URL remains.
|
||||
expect(await repo.getMediaUrl(created.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
it('parseDraft rejects a NaN duration', () => {
|
||||
// --- courses -------------------------------------------------------------
|
||||
|
||||
it('listByCourse splits Unsorted vs a course', async () => {
|
||||
const course = await repo.createCourse({ name: 'Bio 101' });
|
||||
const inCourse = await repo.create(makeDraft({ title: 'lecture', courseId: course.id }));
|
||||
const unsorted = await repo.create(makeDraft({ title: 'loose note' }));
|
||||
|
||||
const courseRows = await repo.listByCourse(course.id);
|
||||
expect(courseRows.map((m) => m.id)).toEqual([inCourse.id]);
|
||||
|
||||
const unsortedRows = await repo.listByCourse(null);
|
||||
expect(unsortedRows.map((m) => m.id)).toEqual([unsorted.id]);
|
||||
});
|
||||
|
||||
it('createCourse / listCourses (alphabetical) / updateCourse', async () => {
|
||||
const zebra = await repo.createCourse({ name: 'Zebra Studies', code: 'ZOO-1' });
|
||||
const apple = await repo.createCourse({ name: 'Apple Theory' });
|
||||
|
||||
expect(apple.id).toMatch(/^c_/);
|
||||
expect(apple.createdAt).toBeTypeOf('number');
|
||||
|
||||
// Alphabetical by name.
|
||||
const listed = await repo.listCourses();
|
||||
expect(listed.map((c) => c.name)).toEqual(['Apple Theory', 'Zebra Studies']);
|
||||
|
||||
const renamed = await repo.updateCourse(zebra.id, { name: 'Aardvark Studies' });
|
||||
expect(renamed.name).toBe('Aardvark Studies');
|
||||
expect(renamed.code).toBe('ZOO-1'); // unspecified fields preserved
|
||||
expect(renamed.updatedAt).toBeGreaterThanOrEqual(zebra.updatedAt);
|
||||
|
||||
const relisted = await repo.listCourses();
|
||||
expect(relisted.map((c) => c.name)).toEqual(['Aardvark Studies', 'Apple Theory']);
|
||||
});
|
||||
|
||||
it('deleteCourse reassigns its transcripts to Unsorted, then removes the course', async () => {
|
||||
const course = await repo.createCourse({ name: 'Doomed' });
|
||||
const t = await repo.create(makeDraft({ title: 'orphan-to-be', courseId: course.id }));
|
||||
|
||||
await repo.deleteCourse(course.id);
|
||||
|
||||
// The course is gone.
|
||||
expect((await repo.listCourses()).map((c) => c.id)).not.toContain(course.id);
|
||||
// The transcript survives, now Unsorted.
|
||||
const fetched = await repo.get(t.id);
|
||||
expect(fetched).toBeDefined();
|
||||
expect(fetched!.courseId).toBeNull();
|
||||
expect((await repo.listByCourse(null)).map((m) => m.id)).toContain(t.id);
|
||||
});
|
||||
|
||||
it('reassign moves a transcript between courses and to Unsorted', async () => {
|
||||
const course = await repo.createCourse({ name: 'Chemistry' });
|
||||
const t = await repo.create(makeDraft());
|
||||
expect(t.courseId).toBeNull();
|
||||
|
||||
await repo.reassign(t.id, course.id);
|
||||
expect((await repo.listByCourse(course.id)).map((m) => m.id)).toEqual([t.id]);
|
||||
|
||||
await repo.reassign(t.id, null);
|
||||
expect((await repo.listByCourse(course.id))).toHaveLength(0);
|
||||
expect((await repo.listByCourse(null)).map((m) => m.id)).toContain(t.id);
|
||||
});
|
||||
|
||||
// --- media ---------------------------------------------------------------
|
||||
|
||||
it('putMedia flips hasMedia true and getMediaUrl returns a url; removeMedia flips it false', async () => {
|
||||
const t = await repo.create(makeDraft());
|
||||
expect(t.hasMedia).toBe(false);
|
||||
|
||||
await repo.putMedia(t.id, { data: new ArrayBuffer(8), mime: 'audio/mpeg' });
|
||||
|
||||
const afterPut = await repo.get(t.id);
|
||||
expect(afterPut!.hasMedia).toBe(true);
|
||||
expect(await repo.getMediaUrl(t.id)).toBe('blob:mock');
|
||||
|
||||
await repo.removeMedia(t.id);
|
||||
const afterRemove = await repo.get(t.id);
|
||||
expect(afterRemove!.hasMedia).toBe(false);
|
||||
expect(await repo.getMediaUrl(t.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
// --- validation gatekeeping ---------------------------------------------
|
||||
|
||||
it('parseDraft rejects a NaN/Infinity duration', () => {
|
||||
expect(() => parseDraft(makeDraft({ durationSec: NaN }))).toThrow();
|
||||
expect(() => parseDraft(makeDraft({ durationSec: Infinity }))).toThrow();
|
||||
});
|
||||
@@ -142,9 +319,6 @@ describe('StorageRepo (Dexie web impl)', () => {
|
||||
});
|
||||
|
||||
it('create rejects an invalid draft (defense in depth)', async () => {
|
||||
// create() calls parseDraft internally, so bad input never persists.
|
||||
await expect(
|
||||
repo.create(makeDraft({ durationSec: -1 })),
|
||||
).rejects.toThrow();
|
||||
await expect(repo.create(makeDraft({ durationSec: -1 }))).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
+57
-8
@@ -5,9 +5,27 @@
|
||||
// and never on a specific backend. The correct implementation is selected at
|
||||
// build time via platform file extensions (see repoImpl.web.ts / repoImpl.native.ts).
|
||||
|
||||
import type { TranscriptDraft, TranscriptMeta, Transcript } from './schema';
|
||||
import type {
|
||||
TranscriptDraft,
|
||||
TranscriptMeta,
|
||||
Transcript,
|
||||
TranscriptPatch,
|
||||
Course,
|
||||
CourseDraft,
|
||||
} from './schema';
|
||||
|
||||
/** Source of audio to persist for a transcript (web passes data, native a uri). */
|
||||
export interface MediaInput {
|
||||
/** Raw bytes (web import path). */
|
||||
data?: ArrayBuffer;
|
||||
/** A file uri to copy into app storage (native import path). */
|
||||
uri?: string;
|
||||
/** MIME type, e.g. 'audio/mpeg'. */
|
||||
mime: string;
|
||||
}
|
||||
|
||||
export interface StorageRepo {
|
||||
// --- transcripts ---------------------------------------------------------
|
||||
/** All transcript metadatas, newest first (by createdAt desc). */
|
||||
list(): Promise<TranscriptMeta[]>;
|
||||
|
||||
@@ -18,16 +36,14 @@ export interface StorageRepo {
|
||||
create(draft: TranscriptDraft): Promise<Transcript>;
|
||||
|
||||
/**
|
||||
* Patch a transcript's title and/or segments. Re-derives any denormalized
|
||||
* fields (searchText, segmentCount, updatedAt) and re-validates segments.
|
||||
* Patch a transcript's mutable fields (title/segments/course+lecture meta).
|
||||
* Re-derives denormalized fields (searchText, segmentCount, updatedAt),
|
||||
* re-validates segments, and assigns ids to any segments missing one.
|
||||
* Rejects if the id does not exist.
|
||||
*/
|
||||
update(
|
||||
id: string,
|
||||
patch: Partial<Pick<Transcript, 'title' | 'segments'>>,
|
||||
): Promise<Transcript>;
|
||||
update(id: string, patch: TranscriptPatch): Promise<Transcript>;
|
||||
|
||||
/** Delete by id. No-op if absent. */
|
||||
/** Delete by id (also removes its persisted media). No-op if absent. */
|
||||
remove(id: string): Promise<void>;
|
||||
|
||||
/**
|
||||
@@ -35,4 +51,37 @@ export interface StorageRepo {
|
||||
* Returns metadatas, newest first.
|
||||
*/
|
||||
search(query: string): Promise<TranscriptMeta[]>;
|
||||
|
||||
/** Transcripts in a course (pass null for "Unsorted"), newest first. */
|
||||
listByCourse(courseId: string | null): Promise<TranscriptMeta[]>;
|
||||
|
||||
/** Move a transcript to a course (or null for Unsorted). */
|
||||
reassign(transcriptId: string, courseId: string | null): Promise<void>;
|
||||
|
||||
// --- courses -------------------------------------------------------------
|
||||
/** All courses, alphabetical by name. */
|
||||
listCourses(): Promise<Course[]>;
|
||||
|
||||
/** Validate + create a course. */
|
||||
createCourse(draft: CourseDraft): Promise<Course>;
|
||||
|
||||
/** Patch a course's fields. Rejects if absent. */
|
||||
updateCourse(id: string, patch: Partial<CourseDraft>): Promise<Course>;
|
||||
|
||||
/** Delete a course; its transcripts are reassigned to Unsorted (courseId=null). */
|
||||
deleteCourse(id: string): Promise<void>;
|
||||
|
||||
// --- media (persisted source audio) -------------------------------------
|
||||
/** Store (or replace) the source audio for a transcript. */
|
||||
putMedia(transcriptId: string, input: MediaInput): Promise<void>;
|
||||
|
||||
/**
|
||||
* A playable URL for the persisted audio (object URL on web, file uri on
|
||||
* native), or undefined if no media is stored. Callers should revoke web
|
||||
* object URLs when done.
|
||||
*/
|
||||
getMediaUrl(transcriptId: string): Promise<string | undefined>;
|
||||
|
||||
/** Delete the persisted audio for a transcript. No-op if absent. */
|
||||
removeMedia(transcriptId: string): Promise<void>;
|
||||
}
|
||||
|
||||
+199
-20
@@ -1,29 +1,45 @@
|
||||
// Web storage implementation backed by Dexie (IndexedDB).
|
||||
//
|
||||
// We keep ONE object store ('transcripts') keyed by id. Each record holds the
|
||||
// full Transcript plus a derived, lowercased 'searchText' (title + all segment
|
||||
// text) used for case-insensitive substring search. Search is done in-memory
|
||||
// (filter over all rows) because IndexedDB has no native substring index;
|
||||
// transcript counts are small enough on-device that this is fine.
|
||||
// Schema (v2):
|
||||
// transcripts: full Transcript + derived lowercase 'searchText' (title + all
|
||||
// segment text), keyed by id, indexed by createdAt + courseId.
|
||||
// courses: stored Course rows, indexed by name.
|
||||
// media: persisted source audio { transcriptId, blob, mime }.
|
||||
//
|
||||
// Search is done in-memory (filter over all rows) because IndexedDB has no
|
||||
// native substring index; transcript counts are small enough on-device that
|
||||
// this is fine. searchText / blobs are never returned to callers.
|
||||
|
||||
import Dexie from 'dexie';
|
||||
import type { Segment } from '../types';
|
||||
import { newId } from '../ids';
|
||||
import {
|
||||
parseDraft,
|
||||
parseCourseDraft,
|
||||
zSegment,
|
||||
type TranscriptDraft,
|
||||
type TranscriptMeta,
|
||||
type Transcript,
|
||||
type TranscriptPatch,
|
||||
type Course,
|
||||
type CourseDraft,
|
||||
} from './schema';
|
||||
import type { StorageRepo } from './repo';
|
||||
import type { StorageRepo, MediaInput } from './repo';
|
||||
|
||||
// The shape persisted in IndexedDB: a full Transcript plus the derived
|
||||
// searchText column. searchText is never returned to callers.
|
||||
// The shape persisted in the 'transcripts' store: a full Transcript plus the
|
||||
// derived searchText column. searchText is never returned to callers.
|
||||
interface StoredTranscript extends Transcript {
|
||||
searchText: string;
|
||||
}
|
||||
|
||||
// The shape persisted in the 'media' store. The blob/mime never leak to
|
||||
// callers; only a hasMedia flag on the transcript + an object URL are exposed.
|
||||
interface StoredMedia {
|
||||
transcriptId: string;
|
||||
blob: Blob;
|
||||
mime: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure derivation helpers (shared by create/update within this file)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -35,6 +51,11 @@ function deriveSearchText(title: string, segments: Segment[]): string {
|
||||
return [title, ...segments.map((s) => s.text)].join('\n').toLowerCase();
|
||||
}
|
||||
|
||||
/** Assign a stable id to every segment that lacks one (mutating a fresh copy). */
|
||||
function withSegmentIds(segments: Segment[]): Segment[] {
|
||||
return segments.map((s) => (s.id ? s : { ...s, id: newId('s_') }));
|
||||
}
|
||||
|
||||
/** Strip the derived searchText, returning the public Transcript view. */
|
||||
function toTranscript(row: StoredTranscript): Transcript {
|
||||
const { searchText: _searchText, ...rest } = row;
|
||||
@@ -52,16 +73,35 @@ function toMeta(row: StoredTranscript): TranscriptMeta {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class WispDexie extends Dexie {
|
||||
// Only 'id' and 'createdAt' need to be indexed: id is the primary key,
|
||||
// createdAt powers the newest-first ordering. searchText is matched by an
|
||||
// in-memory filter, so it does not need an index.
|
||||
transcripts!: Dexie.Table<StoredTranscript, string>;
|
||||
courses!: Dexie.Table<Course, string>;
|
||||
media!: Dexie.Table<StoredMedia, string>;
|
||||
|
||||
constructor() {
|
||||
super('wisp');
|
||||
// v1: original single store. Kept so an existing on-disk v1 DB upgrades
|
||||
// cleanly rather than being recreated (which would lose data).
|
||||
this.version(1).stores({
|
||||
transcripts: 'id, createdAt',
|
||||
});
|
||||
// v2: add courseId index + courses/media stores, and backfill segment ids.
|
||||
this.version(2)
|
||||
.stores({
|
||||
transcripts: 'id, createdAt, courseId',
|
||||
courses: 'id, name',
|
||||
media: 'transcriptId',
|
||||
})
|
||||
.upgrade(async (tx) => {
|
||||
// Backfill: every pre-existing transcript gets stable ids on any
|
||||
// segment that lacks one. courseId stays undefined => "Unsorted".
|
||||
// No other data is touched, so existing transcripts keep everything.
|
||||
await tx
|
||||
.table<StoredTranscript, string>('transcripts')
|
||||
.toCollection()
|
||||
.modify((row) => {
|
||||
row.segments = withSegmentIds(row.segments);
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,6 +112,7 @@ const db = new WispDexie();
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export const repo: StorageRepo = {
|
||||
// --- transcripts ---------------------------------------------------------
|
||||
async list(): Promise<TranscriptMeta[]> {
|
||||
// Sort by createdAt then reverse for newest-first.
|
||||
const rows = await db.transcripts.orderBy('createdAt').reverse().toArray();
|
||||
@@ -87,6 +128,8 @@ export const repo: StorageRepo = {
|
||||
// Validate+throw before doing anything else.
|
||||
const valid = parseDraft(draft);
|
||||
const now = Date.now();
|
||||
// Assign a stable id to every segment that lacks one before storing.
|
||||
const segments = withSegmentIds(valid.segments);
|
||||
const stored: StoredTranscript = {
|
||||
id: newId('t_'),
|
||||
title: valid.title,
|
||||
@@ -96,28 +139,36 @@ export const repo: StorageRepo = {
|
||||
modelId: valid.modelId,
|
||||
// Preserve optional language only when present (avoid storing undefined).
|
||||
...(valid.language !== undefined ? { language: valid.language } : {}),
|
||||
segmentCount: valid.segments.length,
|
||||
segments: valid.segments,
|
||||
searchText: deriveSearchText(valid.title, valid.segments),
|
||||
segmentCount: segments.length,
|
||||
segments,
|
||||
// Course/lecture metadata. courseId defaults to null ("Unsorted").
|
||||
courseId: valid.courseId ?? null,
|
||||
...(valid.lectureDate !== undefined ? { lectureDate: valid.lectureDate } : {}),
|
||||
...(valid.instructor !== undefined ? { instructor: valid.instructor } : {}),
|
||||
...(valid.location !== undefined ? { location: valid.location } : {}),
|
||||
...(valid.tags !== undefined ? { tags: valid.tags } : {}),
|
||||
...(valid.lectureNumber !== undefined
|
||||
? { lectureNumber: valid.lectureNumber }
|
||||
: {}),
|
||||
hasMedia: false,
|
||||
searchText: deriveSearchText(valid.title, segments),
|
||||
};
|
||||
await db.transcripts.put(stored);
|
||||
return toTranscript(stored);
|
||||
},
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
patch: Partial<Pick<Transcript, 'title' | 'segments'>>,
|
||||
): Promise<Transcript> {
|
||||
async update(id: string, patch: TranscriptPatch): Promise<Transcript> {
|
||||
const existing = await db.transcripts.get(id);
|
||||
if (!existing) {
|
||||
throw new Error(`transcript not found: ${id}`);
|
||||
}
|
||||
|
||||
// Re-validate segments on every write so an update can never persist
|
||||
// non-finite times that a create() would have rejected.
|
||||
// non-finite times that a create() would have rejected, and ensure every
|
||||
// segment carries a stable id.
|
||||
const nextSegments =
|
||||
patch.segments !== undefined
|
||||
? patch.segments.map((s) => zSegment.parse(s))
|
||||
? withSegmentIds(patch.segments.map((s) => zSegment.parse(s)))
|
||||
: existing.segments;
|
||||
const nextTitle = patch.title !== undefined ? patch.title : existing.title;
|
||||
|
||||
@@ -125,6 +176,15 @@ export const repo: StorageRepo = {
|
||||
...existing,
|
||||
title: nextTitle,
|
||||
segments: nextSegments,
|
||||
// Apply only the provided course/lecture meta fields.
|
||||
...(patch.courseId !== undefined ? { courseId: patch.courseId } : {}),
|
||||
...(patch.lectureDate !== undefined ? { lectureDate: patch.lectureDate } : {}),
|
||||
...(patch.instructor !== undefined ? { instructor: patch.instructor } : {}),
|
||||
...(patch.location !== undefined ? { location: patch.location } : {}),
|
||||
...(patch.tags !== undefined ? { tags: patch.tags } : {}),
|
||||
...(patch.lectureNumber !== undefined
|
||||
? { lectureNumber: patch.lectureNumber }
|
||||
: {}),
|
||||
// Re-derive denormalized fields so search() and list() stay correct.
|
||||
segmentCount: nextSegments.length,
|
||||
searchText: deriveSearchText(nextTitle, nextSegments),
|
||||
@@ -135,7 +195,11 @@ export const repo: StorageRepo = {
|
||||
},
|
||||
|
||||
async remove(id: string): Promise<void> {
|
||||
// Also delete any persisted media for this transcript.
|
||||
await db.transaction('rw', db.transcripts, db.media, async () => {
|
||||
await db.transcripts.delete(id);
|
||||
await db.media.delete(id);
|
||||
});
|
||||
},
|
||||
|
||||
async search(query: string): Promise<TranscriptMeta[]> {
|
||||
@@ -147,4 +211,119 @@ export const repo: StorageRepo = {
|
||||
: rows;
|
||||
return matched.map(toMeta);
|
||||
},
|
||||
|
||||
async listByCourse(courseId: string | null): Promise<TranscriptMeta[]> {
|
||||
const rows = await db.transcripts.orderBy('createdAt').reverse().toArray();
|
||||
const matched = rows.filter((r) =>
|
||||
courseId === null
|
||||
? // "Unsorted": courseId null or undefined.
|
||||
r.courseId == null
|
||||
: r.courseId === courseId,
|
||||
);
|
||||
return matched.map(toMeta);
|
||||
},
|
||||
|
||||
async reassign(transcriptId: string, courseId: string | null): Promise<void> {
|
||||
const existing = await db.transcripts.get(transcriptId);
|
||||
if (!existing) return;
|
||||
await db.transcripts.put({
|
||||
...existing,
|
||||
courseId,
|
||||
updatedAt: Date.now(),
|
||||
});
|
||||
},
|
||||
|
||||
// --- courses -------------------------------------------------------------
|
||||
async listCourses(): Promise<Course[]> {
|
||||
// Alphabetical by name (case-insensitive for stable UX).
|
||||
const rows = await db.courses.toArray();
|
||||
return rows.sort((a, b) =>
|
||||
a.name.localeCompare(b.name, undefined, { sensitivity: 'base' }),
|
||||
);
|
||||
},
|
||||
|
||||
async createCourse(draft: CourseDraft): Promise<Course> {
|
||||
const valid = parseCourseDraft(draft);
|
||||
const now = Date.now();
|
||||
const course: Course = {
|
||||
id: newId('c_'),
|
||||
name: valid.name,
|
||||
...(valid.code !== undefined ? { code: valid.code } : {}),
|
||||
...(valid.instructor !== undefined ? { instructor: valid.instructor } : {}),
|
||||
...(valid.term !== undefined ? { term: valid.term } : {}),
|
||||
...(valid.color !== undefined ? { color: valid.color } : {}),
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
await db.courses.put(course);
|
||||
return course;
|
||||
},
|
||||
|
||||
async updateCourse(id: string, patch: Partial<CourseDraft>): Promise<Course> {
|
||||
const existing = await db.courses.get(id);
|
||||
if (!existing) {
|
||||
throw new Error(`course not found: ${id}`);
|
||||
}
|
||||
const updated: Course = {
|
||||
...existing,
|
||||
// Apply only the provided fields.
|
||||
...(patch.name !== undefined ? { name: patch.name } : {}),
|
||||
...(patch.code !== undefined ? { code: patch.code } : {}),
|
||||
...(patch.instructor !== undefined ? { instructor: patch.instructor } : {}),
|
||||
...(patch.term !== undefined ? { term: patch.term } : {}),
|
||||
...(patch.color !== undefined ? { color: patch.color } : {}),
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
await db.courses.put(updated);
|
||||
return updated;
|
||||
},
|
||||
|
||||
async deleteCourse(id: string): Promise<void> {
|
||||
// First reassign this course's transcripts to "Unsorted" (courseId=null),
|
||||
// THEN delete the course row, so no transcript is ever orphaned.
|
||||
await db.transaction('rw', db.transcripts, db.courses, async () => {
|
||||
const now = Date.now();
|
||||
const owned = await db.transcripts
|
||||
.filter((r) => r.courseId === id)
|
||||
.toArray();
|
||||
await Promise.all(
|
||||
owned.map((r) =>
|
||||
db.transcripts.put({ ...r, courseId: null, updatedAt: now }),
|
||||
),
|
||||
);
|
||||
await db.courses.delete(id);
|
||||
});
|
||||
},
|
||||
|
||||
// --- media (persisted source audio) -------------------------------------
|
||||
async putMedia(transcriptId: string, input: MediaInput): Promise<void> {
|
||||
// Web import path: bytes arrive in input.data. Wrap them in a Blob tagged
|
||||
// with the declared mime so getMediaUrl can hand back an object URL.
|
||||
const blob = new Blob(input.data !== undefined ? [input.data] : [], {
|
||||
type: input.mime,
|
||||
});
|
||||
await db.transaction('rw', db.transcripts, db.media, async () => {
|
||||
await db.media.put({ transcriptId, blob, mime: input.mime });
|
||||
const existing = await db.transcripts.get(transcriptId);
|
||||
if (existing) {
|
||||
await db.transcripts.put({ ...existing, hasMedia: true });
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
async getMediaUrl(transcriptId: string): Promise<string | undefined> {
|
||||
const row = await db.media.get(transcriptId);
|
||||
if (!row) return undefined;
|
||||
return URL.createObjectURL(row.blob);
|
||||
},
|
||||
|
||||
async removeMedia(transcriptId: string): Promise<void> {
|
||||
await db.transaction('rw', db.transcripts, db.media, async () => {
|
||||
await db.media.delete(transcriptId);
|
||||
const existing = await db.transcripts.get(transcriptId);
|
||||
if (existing) {
|
||||
await db.transcripts.put({ ...existing, hasMedia: false });
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
|
||||
@@ -37,6 +37,8 @@ const zFiniteNonNeg = zFinite.refine((n) => n >= 0, {
|
||||
* - confidence: optional, finite and within [0, 1]
|
||||
*/
|
||||
export const zSegment = z.object({
|
||||
// Stable id assigned by the repo on write (engine output omits it).
|
||||
id: z.string().optional(),
|
||||
start: zFiniteNonNeg,
|
||||
end: zFiniteNonNeg,
|
||||
text: z.string(),
|
||||
@@ -80,6 +82,15 @@ export const zTranscriptDraft = z.object({
|
||||
modelId: zModelId,
|
||||
language: z.string().optional(),
|
||||
segments: z.array(zSegment),
|
||||
// --- Phase 0 course/lecture metadata (all optional) ---
|
||||
/** Owning course id, or null for "Unsorted". */
|
||||
courseId: z.string().nullable().optional(),
|
||||
/** When the lecture happened, ms since epoch. */
|
||||
lectureDate: zFiniteNonNeg.optional(),
|
||||
instructor: z.string().optional(),
|
||||
location: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
lectureNumber: zFiniteNonNeg.optional(),
|
||||
});
|
||||
|
||||
/** Inferred TS type for a validated draft. */
|
||||
@@ -93,6 +104,38 @@ export function parseDraft(input: unknown): TranscriptDraft {
|
||||
return zTranscriptDraft.parse(input);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Course (Phase 0)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Unsaved course shape handed to createCourse(). */
|
||||
export const zCourseDraft = z.object({
|
||||
name: z.string().min(1),
|
||||
code: z.string().optional(),
|
||||
instructor: z.string().optional(),
|
||||
term: z.string().optional(),
|
||||
/** UI accent color (hex), optional. */
|
||||
color: z.string().optional(),
|
||||
});
|
||||
export type CourseDraft = z.infer<typeof zCourseDraft>;
|
||||
|
||||
/** A stored course. */
|
||||
export interface Course {
|
||||
id: string;
|
||||
name: string;
|
||||
code?: string;
|
||||
instructor?: string;
|
||||
term?: string;
|
||||
color?: string;
|
||||
createdAt: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
/** Validate an unknown input as a CourseDraft, throwing on violation. */
|
||||
export function parseCourseDraft(input: unknown): CourseDraft {
|
||||
return zCourseDraft.parse(input);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stored types (output of the repo)
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -114,9 +157,35 @@ export interface TranscriptMeta {
|
||||
language?: string;
|
||||
/** Number of segments in the body (denormalized for list views). */
|
||||
segmentCount: number;
|
||||
// --- Phase 0 course/lecture metadata ---
|
||||
/** Owning course id; null/undefined => "Unsorted". */
|
||||
courseId?: string | null;
|
||||
/** Lecture date, ms since epoch. */
|
||||
lectureDate?: number;
|
||||
instructor?: string;
|
||||
location?: string;
|
||||
tags?: string[];
|
||||
lectureNumber?: number;
|
||||
/** True when source audio has been persisted (so it can be replayed). */
|
||||
hasMedia?: boolean;
|
||||
}
|
||||
|
||||
/** A full stored transcript: metadata plus the segment body. */
|
||||
export interface Transcript extends TranscriptMeta {
|
||||
segments: Segment[];
|
||||
}
|
||||
|
||||
/** The mutable fields update() accepts. */
|
||||
export type TranscriptPatch = Partial<
|
||||
Pick<
|
||||
Transcript,
|
||||
| 'title'
|
||||
| 'segments'
|
||||
| 'courseId'
|
||||
| 'lectureDate'
|
||||
| 'instructor'
|
||||
| 'location'
|
||||
| 'tags'
|
||||
| 'lectureNumber'
|
||||
>
|
||||
>;
|
||||
|
||||
@@ -12,6 +12,12 @@ export const WHISPER_SAMPLE_RATE = 16000 as const;
|
||||
* pipeline offsets and stitches them into absolute time.
|
||||
*/
|
||||
export interface Segment {
|
||||
/**
|
||||
* Stable per-segment id, assigned by the storage layer on create()/update().
|
||||
* Engine output omits it; it is the durable anchor for citations, vectors,
|
||||
* flashcards and calendar links (see ROADMAP Phase 0/1).
|
||||
*/
|
||||
id?: string;
|
||||
/** Inclusive start time in seconds. */
|
||||
start: number;
|
||||
/** Exclusive end time in seconds. */
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// Courses list state, backed by the local StorageRepo. UI/session state only.
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { getRepo, type Course, type CourseDraft } from '@/lib/db';
|
||||
|
||||
interface CoursesState {
|
||||
items: Course[];
|
||||
loading: boolean;
|
||||
refresh: () => Promise<void>;
|
||||
createCourse: (draft: CourseDraft) => Promise<Course>;
|
||||
rename: (id: string, name: string) => Promise<void>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useCourses = create<CoursesState>((set, get) => ({
|
||||
items: [],
|
||||
loading: false,
|
||||
|
||||
refresh: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
set({ items: await getRepo().listCourses() });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
}
|
||||
},
|
||||
|
||||
createCourse: async (draft) => {
|
||||
const course = await getRepo().createCourse(draft);
|
||||
await get().refresh();
|
||||
return course;
|
||||
},
|
||||
|
||||
rename: async (id, name) => {
|
||||
await getRepo().updateCourse(id, { name });
|
||||
await get().refresh();
|
||||
},
|
||||
|
||||
remove: async (id) => {
|
||||
await getRepo().deleteCourse(id);
|
||||
await get().refresh();
|
||||
},
|
||||
}));
|
||||
@@ -17,6 +17,8 @@ interface StartOptions {
|
||||
title?: string;
|
||||
language?: string;
|
||||
translate?: boolean;
|
||||
courseId?: string | null;
|
||||
lectureDate?: number;
|
||||
}
|
||||
|
||||
interface TranscribeState {
|
||||
@@ -88,8 +90,22 @@ export const useTranscribe = create<TranscribeState>((set, get) => ({
|
||||
modelId,
|
||||
language: opts?.language,
|
||||
segments,
|
||||
courseId: opts?.courseId ?? null,
|
||||
...(opts?.lectureDate !== undefined ? { lectureDate: opts.lectureDate } : {}),
|
||||
});
|
||||
|
||||
// Persist the source audio so it can be replayed after a reload. Best-effort:
|
||||
// a quota/unsupported failure must not lose the transcript itself.
|
||||
try {
|
||||
await getRepo().putMedia(saved.id, {
|
||||
data: input.data,
|
||||
uri: input.uri,
|
||||
mime: guessMime(input.name),
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('[wisp] could not persist source audio:', e);
|
||||
}
|
||||
|
||||
set({ status: 'done', progress: 1, partial: segments, lastTranscriptId: saved.id });
|
||||
return saved.id;
|
||||
} catch (err) {
|
||||
@@ -118,3 +134,14 @@ function defaultTitle(): string {
|
||||
const pad = (n: number) => String(n).padStart(2, '0');
|
||||
return `Transcript ${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
/** Best-effort MIME from a file name extension (for persisted media). */
|
||||
function guessMime(name?: string): string {
|
||||
const ext = (name?.split('.').pop() || '').toLowerCase();
|
||||
const map: Record<string, string> = {
|
||||
mp3: 'audio/mpeg', m4a: 'audio/mp4', aac: 'audio/aac', wav: 'audio/wav',
|
||||
ogg: 'audio/ogg', oga: 'audio/ogg', flac: 'audio/flac', webm: 'audio/webm',
|
||||
mp4: 'video/mp4', mov: 'video/quicktime', mkv: 'video/x-matroska',
|
||||
};
|
||||
return map[ext] || 'application/octet-stream';
|
||||
}
|
||||
|
||||
@@ -1,31 +1,45 @@
|
||||
// Library state: the saved transcripts list + search, backed by the local
|
||||
// StorageRepo (Dexie on web, expo-sqlite on native). UI/session state only —
|
||||
// the record of truth lives in the repo.
|
||||
// Library state: the saved transcripts list + search + course filter, backed by
|
||||
// the local StorageRepo. UI/session state only — the repo is the record of truth.
|
||||
import { create } from 'zustand';
|
||||
|
||||
import { getRepo, type TranscriptMeta } from '@/lib/db';
|
||||
|
||||
/** 'all' = every transcript · null = Unsorted · string = a specific course id. */
|
||||
export type CourseFilter = 'all' | null | string;
|
||||
|
||||
interface TranscriptsState {
|
||||
items: TranscriptMeta[];
|
||||
loading: boolean;
|
||||
query: string;
|
||||
courseFilter: CourseFilter;
|
||||
refresh: () => Promise<void>;
|
||||
setQuery: (q: string) => Promise<void>;
|
||||
setCourseFilter: (f: CourseFilter) => Promise<void>;
|
||||
remove: (id: string) => Promise<void>;
|
||||
rename: (id: string, title: string) => Promise<void>;
|
||||
reassign: (id: string, courseId: string | null) => Promise<void>;
|
||||
}
|
||||
|
||||
export const useTranscripts = create<TranscriptsState>((set, get) => ({
|
||||
items: [],
|
||||
loading: false,
|
||||
query: '',
|
||||
courseFilter: 'all',
|
||||
|
||||
refresh: async () => {
|
||||
set({ loading: true });
|
||||
try {
|
||||
const repo = getRepo();
|
||||
const q = get().query.trim();
|
||||
const items = q ? await repo.search(q) : await repo.list();
|
||||
const cf = get().courseFilter;
|
||||
let items: TranscriptMeta[];
|
||||
if (q) {
|
||||
// Search across everything, then narrow to the active course filter.
|
||||
items = await repo.search(q);
|
||||
if (cf !== 'all') items = items.filter((t) => (t.courseId ?? null) === cf);
|
||||
} else {
|
||||
items = cf === 'all' ? await repo.list() : await repo.listByCourse(cf);
|
||||
}
|
||||
set({ items });
|
||||
} finally {
|
||||
set({ loading: false });
|
||||
@@ -37,6 +51,11 @@ export const useTranscripts = create<TranscriptsState>((set, get) => ({
|
||||
await get().refresh();
|
||||
},
|
||||
|
||||
setCourseFilter: async (f) => {
|
||||
set({ courseFilter: f });
|
||||
await get().refresh();
|
||||
},
|
||||
|
||||
remove: async (id) => {
|
||||
await getRepo().remove(id);
|
||||
await get().refresh();
|
||||
@@ -46,4 +65,9 @@ export const useTranscripts = create<TranscriptsState>((set, get) => ({
|
||||
await getRepo().update(id, { title });
|
||||
await get().refresh();
|
||||
},
|
||||
|
||||
reassign: async (id, courseId) => {
|
||||
await getRepo().reassign(id, courseId);
|
||||
await get().refresh();
|
||||
},
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user