feat(phase0): courses, migrations, persisted audio, stable segment IDs, course-aware UI
CI / test (push) Successful in 16s
CI / build-apk (push) Has been skipped
CI / deploy-web (push) Successful in 29s

Data layer (Zod-gated, behind StorageRepo; web Dexie v2 + native expo-sqlite
user_version migrations, both with no-data-loss segment-id backfill):
- Course entity + courseId/lectureDate/instructor/location/tags/lectureNumber on
  transcripts; listCourses/createCourse/updateCourse/deleteCourse, listByCourse,
  reassign. Stable per-segment ids assigned on create/update + backfilled.
- Persisted source audio (Dexie blob / expo-file-system) via putMedia/getMediaUrl;
  the editor replays it after reload.

UI/stores:
- Pre-capture sheet (title + course + lecture date + language) before transcribing.
- Course-aware library home (All/Unsorted/per-course filter) + Courses screen.
- coursesStore; transcriptsStore course filter + reassign; transcribeStore threads
  course/date and persists media.

16 web-repo tests incl. v1->v2 migration no-data-loss; 148 total green; web export OK.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 13:54:38 +02:00
parent 30dd2d5b75
commit 8db59d4bbe
14 changed files with 1538 additions and 131 deletions
+1
View File
@@ -16,6 +16,7 @@ export default function RootLayout() {
<Stack> <Stack>
<Stack.Screen name="index" options={{ title: 'Wisp' }} /> <Stack.Screen name="index" options={{ title: 'Wisp' }} />
<Stack.Screen name="transcript/[id]" options={{ title: 'Transcript' }} /> <Stack.Screen name="transcript/[id]" options={{ title: 'Transcript' }} />
<Stack.Screen name="courses" options={{ title: 'Courses' }} />
<Stack.Screen name="settings" options={{ title: 'Settings' }} /> <Stack.Screen name="settings" options={{ title: 'Settings' }} />
</Stack> </Stack>
<StatusBar style="auto" /> <StatusBar style="auto" />
+112
View File
@@ -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' },
});
+95 -28
View File
@@ -1,8 +1,7 @@
import { Link, useFocusEffect, useRouter } from 'expo-router'; import { Link, useFocusEffect, useRouter } from 'expo-router';
import { useCallback } from 'react'; import { useCallback, useState } from 'react';
import { import {
ActivityIndicator, ActivityIndicator,
Platform,
Pressable, Pressable,
ScrollView, ScrollView,
StyleSheet, StyleSheet,
@@ -10,39 +9,57 @@ import {
View, View,
} from 'react-native'; } from 'react-native';
import { PreCaptureSheet, type PreCaptureResult } from '@/components/PreCaptureSheet';
import { ThemedText } from '@/components/themed-text'; import { ThemedText } from '@/components/themed-text';
import { ThemedView } from '@/components/themed-view'; import { ThemedView } from '@/components/themed-view';
import { MaxContentWidth, Spacing } from '@/constants/theme'; import { MaxContentWidth, Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/hooks/use-theme';
import type { TranscriptMeta } from '@/lib/db';
import { formatClock } from '@/lib/format'; import { formatClock } from '@/lib/format';
import { MODELS } from '@/lib/models/catalog'; import { MODELS } from '@/lib/models/catalog';
import { pickAudio } from '@/lib/pickAudio'; import { pickAudio, type PickedAudio } from '@/lib/pickAudio';
import type { TranscriptMeta } from '@/lib/db'; import { useCourses } from '@/stores/coursesStore';
import { useTranscribe } from '@/stores/transcribeStore'; import { useTranscribe } from '@/stores/transcribeStore';
import { useTranscripts } from '@/stores/transcriptsStore'; import { useTranscripts, type CourseFilter } from '@/stores/transcriptsStore';
export default function LibraryScreen() { export default function LibraryScreen() {
const theme = useTheme(); const theme = useTheme();
const router = useRouter(); 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 job = useTranscribe();
const [picked, setPicked] = useState<PickedAudio | null>(null);
// Refresh the list whenever the screen regains focus (e.g. after a transcribe).
useFocusEffect( useFocusEffect(
useCallback(() => { useCallback(() => {
void refresh(); void refresh();
}, [refresh]), void refreshCourses();
}, [refresh, refreshCourses]),
); );
const busy = job.status === 'loading' || job.status === 'transcribing'; const busy = job.status === 'loading' || job.status === 'transcribing';
const onNew = useCallback(async () => { const onNew = useCallback(async () => {
if (busy) return; if (busy) return;
const picked = await pickAudio(); const p = await pickAudio();
if (!picked) return; if (p) setPicked(p); // opens the pre-capture sheet
const id = await useTranscribe.getState().start(picked, { title: picked.name }); }, [busy]);
if (id) router.push({ pathname: '/transcript/[id]', params: { id } });
}, [busy, router]); 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 } });
},
[picked, router],
);
const courseName = (cid?: string | null) =>
cid ? courses.find((c) => c.id === cid)?.name ?? 'Course' : null;
return ( return (
<ThemedView style={styles.fill}> <ThemedView style={styles.fill}>
@@ -54,13 +71,18 @@ export default function LibraryScreen() {
Private transcription runs on your device, nothing uploaded. Private transcription runs on your device, nothing uploaded.
</ThemedText> </ThemedText>
</View> </View>
<Link href="/settings" asChild> <View style={styles.headerLinks}>
<Pressable hitSlop={10}> <Link href="/courses" asChild>
<ThemedText type="link" themeColor="textSecondary"> <Pressable hitSlop={8}>
Settings <ThemedText type="link" themeColor="textSecondary">Courses</ThemedText>
</ThemedText> </Pressable>
</Pressable> </Link>
</Link> <Link href="/settings" asChild>
<Pressable hitSlop={8}>
<ThemedText type="link" themeColor="textSecondary"> Settings</ThemedText>
</Pressable>
</Link>
</View>
</View> </View>
<Pressable <Pressable
@@ -77,12 +99,18 @@ export default function LibraryScreen() {
{job.status === 'error' && ( {job.status === 'error' && (
<ThemedView type="backgroundElement" style={styles.card}> <ThemedView type="backgroundElement" style={styles.card}>
<ThemedText type="smallBold">Transcription failed</ThemedText> <ThemedText type="smallBold">Transcription failed</ThemedText>
<ThemedText type="small" themeColor="textSecondary"> <ThemedText type="small" themeColor="textSecondary">{job.error}</ThemedText>
{job.error}
</ThemedText>
</ThemedView> </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 <TextInput
value={query} value={query}
onChangeText={(t) => void setQuery(t)} onChangeText={(t) => void setQuery(t)}
@@ -95,18 +123,42 @@ export default function LibraryScreen() {
<ActivityIndicator style={styles.pad} /> <ActivityIndicator style={styles.pad} />
) : items.length === 0 ? ( ) : items.length === 0 ? (
<ThemedText type="small" themeColor="textSecondary" style={styles.pad}> <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> </ThemedText>
) : ( ) : (
items.map((t) => ( 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> </ScrollView>
<PreCaptureSheet
visible={picked !== null}
defaultTitle={picked?.name ?? 'Recording'}
onStart={(r) => void onStart(r)}
onCancel={() => setPicked(null)}
/>
</ThemedView> </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() { function ActiveJob() {
const { stage, progress, partial, cancel } = useTranscribe(); const { stage, progress, partial, cancel } = useTranscribe();
const pct = Math.round(progress * 100); 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 }) { function TranscriptRow({
const date = new Date(item.createdAt).toLocaleDateString(); item,
courseName,
onOpen,
onDelete,
}: {
item: TranscriptMeta;
courseName: string | null;
onOpen: () => void;
onDelete: () => void;
}) {
const date = new Date(item.lectureDate ?? item.createdAt).toLocaleDateString();
return ( return (
<Pressable onPress={onOpen} style={({ pressed }) => [pressed && styles.pressed]}> <Pressable onPress={onOpen} style={({ pressed }) => [pressed && styles.pressed]}>
<ThemedView type="backgroundElement" style={styles.card}> <ThemedView type="backgroundElement" style={styles.card}>
@@ -152,7 +214,8 @@ function TranscriptRow({ item, onOpen, onDelete }: { item: TranscriptMeta; onOpe
</Pressable> </Pressable>
</View> </View>
<ThemedText type="small" themeColor="textSecondary"> <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> </ThemedText>
</ThemedView> </ThemedView>
</Pressable> </Pressable>
@@ -170,10 +233,14 @@ const styles = StyleSheet.create({
}, },
flex: { flex: 1 }, flex: { flex: 1 },
headerRow: { flexDirection: 'row', alignItems: 'flex-start', gap: Spacing.two }, 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' }, newButton: { paddingVertical: Spacing.three, borderRadius: Spacing.three, alignItems: 'center' },
newButtonText: { color: '#fff', fontWeight: '700', fontSize: 16 }, newButtonText: { color: '#fff', fontWeight: '700', fontSize: 16 },
card: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.two }, card: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.two },
rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', 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 }, search: { borderRadius: Spacing.two, paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, fontSize: 15 },
track: { height: 6, borderRadius: 3, backgroundColor: '#88888833', overflow: 'hidden' }, track: { height: 6, borderRadius: 3, backgroundColor: '#88888833', overflow: 'hidden' },
bar: { height: 6, borderRadius: 3, backgroundColor: '#3c87f7' }, bar: { height: 6, borderRadius: 3, backgroundColor: '#3c87f7' },
+23 -3
View File
@@ -32,10 +32,30 @@ export default function TranscriptScreen() {
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
const [currentTime, setCurrentTime] = useState(0); const [currentTime, setCurrentTime] = useState(0);
// The just-transcribed audio is playable in-session (object URL on web). // Prefer the just-transcribed in-session audio; otherwise load the persisted
const audioUrl = useTranscribe((s) => (s.lastTranscriptId === id ? s.audioUrl : undefined)); // 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); 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(() => { useEffect(() => {
let alive = true; let alive = true;
void getRepo() void getRepo()
@@ -124,7 +144,7 @@ export default function TranscriptScreen() {
{!audioUrl && ( {!audioUrl && (
<ThemedText type="small" themeColor="textSecondary"> <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> </ThemedText>
)} )}
+171
View File
@@ -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' },
});
+505 -40
View File
@@ -5,23 +5,38 @@
// list()/search() can be answered from columns alone without parsing every // list()/search() can be answered from columns alone without parsing every
// body. Search uses a LIKE over the lowercased 'searchText' column. // body. Search uses a LIKE over the lowercased 'searchText' column.
// //
// API reference: https://docs.expo.dev/versions/v56.0.0/sdk/sqlite/ // Schema evolution is handled by a real PRAGMA user_version migration runner
// We use openDatabaseAsync + the async methods (execAsync/runAsync/getAllAsync). // (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 * as SQLite from 'expo-sqlite';
import { Directory, File, Paths } from 'expo-file-system';
import type { Segment } from '../types'; import type { Segment } from '../types';
import { newId } from '../ids'; import { newId } from '../ids';
import { import {
parseDraft, parseDraft,
parseCourseDraft,
zSegment, zSegment,
type TranscriptDraft, type TranscriptDraft,
type TranscriptMeta, type TranscriptMeta,
type Transcript, type Transcript,
type TranscriptPatch,
type Course,
type CourseDraft,
} from './schema'; } 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 { interface MetaRow {
id: string; id: string;
title: string; title: string;
@@ -31,13 +46,40 @@ interface MetaRow {
segmentCount: number; segmentCount: number;
modelId: string; modelId: string;
language: string | null; 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 { interface FullRow extends MetaRow {
json: string; 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 // Pure derivation helpers
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -47,9 +89,17 @@ function deriveSearchText(title: string, segments: Segment[]): string {
return [title, ...segments.map((s) => s.text)].join('\n').toLowerCase(); 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 { function rowToMeta(r: MetaRow): TranscriptMeta {
return { const meta: TranscriptMeta = {
id: r.id, id: r.id,
title: r.title, title: r.title,
createdAt: r.createdAt, createdAt: r.createdAt,
@@ -57,8 +107,163 @@ function rowToMeta(r: MetaRow): TranscriptMeta {
durationSec: r.durationSec, durationSec: r.durationSec,
modelId: r.modelId as TranscriptMeta['modelId'], modelId: r.modelId as TranscriptMeta['modelId'],
segmentCount: r.segmentCount, 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;
}
// ---------------------------------------------------------------------------
// 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.
const TARGET_VERSION = 2;
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,
json TEXT NOT NULL,
searchText TEXT NOT NULL,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
durationSec REAL NOT NULL,
segmentCount INTEGER NOT NULL,
modelId TEXT NOT NULL,
language TEXT
);
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}`);
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -68,26 +273,12 @@ function rowToMeta(r: MetaRow): TranscriptMeta {
let dbPromise: Promise<SQLite.SQLiteDatabase> | undefined; let dbPromise: Promise<SQLite.SQLiteDatabase> | undefined;
function getDb(): Promise<SQLite.SQLiteDatabase> { function getDb(): Promise<SQLite.SQLiteDatabase> {
// Open once and reuse; create the table on first open. The async open returns // Open once and reuse; run migrations on first open. The async open returns a
// a promise we cache so concurrent callers share a single connection. // promise we cache so concurrent callers share a single connection.
if (!dbPromise) { if (!dbPromise) {
dbPromise = (async () => { dbPromise = (async () => {
const db = await SQLite.openDatabaseAsync('wisp.db'); const db = await SQLite.openDatabaseAsync('wisp.db');
await db.execAsync(` await runMigrations(db);
CREATE TABLE IF NOT EXISTS transcripts (
id TEXT PRIMARY KEY NOT NULL,
json TEXT NOT NULL,
searchText TEXT NOT NULL,
createdAt INTEGER NOT NULL,
updatedAt INTEGER NOT NULL,
durationSec REAL NOT NULL,
segmentCount INTEGER NOT NULL,
modelId TEXT NOT NULL,
language TEXT
);
CREATE INDEX IF NOT EXISTS idx_transcripts_createdAt
ON transcripts (createdAt);
`);
return db; return db;
})(); })();
} }
@@ -96,13 +287,33 @@ function getDb(): Promise<SQLite.SQLiteDatabase> {
// The metadata columns we select for list()/search(), kept in one place. // The metadata columns we select for list()/search(), kept in one place.
const META_COLUMNS = 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 // Repo implementation
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const repo: StorageRepo = { export const repo: StorageRepo = {
// --- transcripts ---------------------------------------------------------
async list(): Promise<TranscriptMeta[]> { async list(): Promise<TranscriptMeta[]> {
const db = await getDb(); const db = await getDb();
const rows = await db.getAllAsync<MetaRow>( const rows = await db.getAllAsync<MetaRow>(
@@ -118,14 +329,18 @@ export const repo: StorageRepo = {
[id], [id],
); );
if (!row) return undefined; if (!row) return undefined;
// The JSON body is the authoritative full Transcript (already validated on // The JSON body is the authoritative full Transcript (already validated and
// write), so we return it directly rather than reconstructing from columns. // segment-id-stamped on write), so we return it directly.
return JSON.parse(row.json) as Transcript; return JSON.parse(row.json) as Transcript;
}, },
async create(draft: TranscriptDraft): Promise<Transcript> { async create(draft: TranscriptDraft): Promise<Transcript> {
const valid = parseDraft(draft); const valid = parseDraft(draft);
const now = Date.now(); 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 = { const transcript: Transcript = {
id: newId('t_'), id: newId('t_'),
title: valid.title, title: valid.title,
@@ -133,16 +348,27 @@ export const repo: StorageRepo = {
updatedAt: now, updatedAt: now,
durationSec: valid.durationSec, durationSec: valid.durationSec,
modelId: valid.modelId, modelId: valid.modelId,
segmentCount: segments.length,
segments,
courseId,
hasMedia: false,
...(valid.language !== undefined ? { language: valid.language } : {}), ...(valid.language !== undefined ? { language: valid.language } : {}),
segmentCount: valid.segments.length, ...(valid.lectureDate !== undefined ? { lectureDate: valid.lectureDate } : {}),
segments: valid.segments, ...(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(); const db = await getDb();
await db.runAsync( await db.runAsync(
`INSERT INTO transcripts `INSERT INTO transcripts
(id, json, searchText, createdAt, updatedAt, durationSec, segmentCount, modelId, language) (id, json, searchText, createdAt, updatedAt, durationSec, segmentCount,
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, modelId, language, courseId, lectureDate, instructor, location, tags,
lectureNumber, hasMedia)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
[ [
transcript.id, transcript.id,
JSON.stringify(transcript), JSON.stringify(transcript),
@@ -154,15 +380,19 @@ export const repo: StorageRepo = {
transcript.modelId, transcript.modelId,
// expo-sqlite accepts null but not undefined for params. // expo-sqlite accepts null but not undefined for params.
transcript.language ?? null, 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; return transcript;
}, },
async update( async update(id: string, patch: TranscriptPatch): Promise<Transcript> {
id: string,
patch: Partial<Pick<Transcript, 'title' | 'segments'>>,
): Promise<Transcript> {
const db = await getDb(); const db = await getDb();
const existingRow = await db.getFirstAsync<FullRow>( const existingRow = await db.getFirstAsync<FullRow>(
`SELECT ${META_COLUMNS}, json FROM transcripts WHERE id = ?`, `SELECT ${META_COLUMNS}, json FROM transcripts WHERE id = ?`,
@@ -173,24 +403,36 @@ export const repo: StorageRepo = {
} }
const existing = JSON.parse(existingRow.json) as Transcript; 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 = const nextSegments =
patch.segments !== undefined patch.segments !== undefined
? patch.segments.map((s) => zSegment.parse(s)) ? withSegmentIds(patch.segments.map((s) => zSegment.parse(s)))
: existing.segments; : existing.segments;
const nextTitle = patch.title !== undefined ? patch.title : existing.title; const nextTitle = patch.title !== undefined ? patch.title : existing.title;
// Apply only provided patch fields; leave everything else as-is.
const updated: Transcript = { const updated: Transcript = {
...existing, ...existing,
title: nextTitle, title: nextTitle,
segments: nextSegments, segments: nextSegments,
segmentCount: nextSegments.length, segmentCount: nextSegments.length,
updatedAt: Date.now(), 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( await db.runAsync(
`UPDATE transcripts `UPDATE transcripts
SET json = ?, searchText = ?, updatedAt = ?, segmentCount = ?, title = ? SET json = ?, searchText = ?, updatedAt = ?, segmentCount = ?, title = ?,
courseId = ?, lectureDate = ?, instructor = ?, location = ?, tags = ?,
lectureNumber = ?
WHERE id = ?`, WHERE id = ?`,
[ [
JSON.stringify(updated), JSON.stringify(updated),
@@ -198,6 +440,12 @@ export const repo: StorageRepo = {
updated.updatedAt, updated.updatedAt,
updated.segmentCount, updated.segmentCount,
updated.title, 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, id,
], ],
); );
@@ -206,6 +454,8 @@ export const repo: StorageRepo = {
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
const db = await getDb(); 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]); await db.runAsync(`DELETE FROM transcripts WHERE id = ?`, [id]);
}, },
@@ -230,4 +480,219 @@ export const repo: StorageRepo = {
); );
return rows.map(rowToMeta); 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
View File
@@ -4,11 +4,58 @@
// under vitest. We test the Dexie repo as the reference implementation of the // under vitest. We test the Dexie repo as the reference implementation of the
// StorageRepo contract; the native expo-sqlite repo (native-only) is NOT // StorageRepo contract; the native expo-sqlite repo (native-only) is NOT
// imported here because expo-sqlite cannot load outside a device/simulator. // 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 'fake-indexeddb/auto';
import { describe, it, expect, beforeEach } from 'vitest'; import Dexie from 'dexie';
import { repo } from './repo.web'; import { describe, it, expect, beforeAll, beforeEach } from 'vitest';
import { parseDraft, type TranscriptDraft } from './schema'; 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. // Helper: a minimal valid draft, overridable per test.
function makeDraft(over: Partial<TranscriptDraft> = {}): TranscriptDraft { 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. describe('v1 -> v2 migration (no data loss)', () => {
beforeEach(async () => { it('keeps the legacy transcript, backfills segment ids, leaves it Unsorted', async () => {
const all = await repo.list(); const fetched = await repo.get(V1_ID);
await Promise.all(all.map((m) => repo.remove(m.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)', () => { 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()); const created = await repo.create(makeDraft());
expect(created.id).toMatch(/^t_/); expect(created.id).toMatch(/^t_/);
expect(created.createdAt).toBeTypeOf('number'); expect(created.createdAt).toBeTypeOf('number');
expect(created.updatedAt).toBe(created.createdAt); expect(created.updatedAt).toBe(created.createdAt);
expect(created.segmentCount).toBe(2); 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); const fetched = await repo.get(created.id);
expect(fetched).toBeDefined(); expect(fetched).toBeDefined();
expect(fetched!.title).toBe('My Recording'); expect(fetched!.title).toBe('My Recording');
expect(fetched!.durationSec).toBe(12.5); expect(fetched!.durationSec).toBe(12.5);
expect(fetched!.modelId).toBe('tiny.en'); expect(fetched!.modelId).toBe('tiny.en');
// Segments survive the round-trip exactly. // Text/timing survive the round-trip exactly.
expect(fetched!.segments).toEqual([ expect(fetched!.segments.map((s) => ({ start: s.start, end: s.end, text: s.text }))).toEqual([
{ start: 0, end: 2, text: 'hello world' }, { 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 () => { 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 () => { it('list returns metadatas newest-first (without segments)', async () => {
const a = await repo.create(makeDraft({ title: 'first' })); const a = await repo.create(makeDraft({ title: 'first' }));
// Force distinct, increasing createdAt timestamps.
await new Promise((r) => setTimeout(r, 2)); await new Promise((r) => setTimeout(r, 2));
const b = await repo.create(makeDraft({ title: 'second' })); const b = await repo.create(makeDraft({ title: 'second' }));
await new Promise((r) => setTimeout(r, 2)); await new Promise((r) => setTimeout(r, 2));
@@ -65,12 +156,12 @@ describe('StorageRepo (Dexie web impl)', () => {
const metas = await repo.list(); const metas = await repo.list();
expect(metas.map((m) => m.id)).toEqual([c.id, b.id, a.id]); 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] as unknown as Record<string, unknown>).segments).toBeUndefined();
expect(metas[0]!.segmentCount).toBe(2); 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( const created = await repo.create(
makeDraft({ title: 'Old Title', segments: [{ start: 0, end: 1, text: 'apple' }] }), 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, { const updated = await repo.update(created.id, {
title: 'Brand New Title', title: 'Brand New Title',
courseId: course.id,
segments: [ segments: [
{ start: 0, end: 1, text: 'banana' }, { start: 0, end: 1, text: 'banana' },
{ start: 1, end: 2, text: 'cherry' }, { start: 1, end: 2, text: 'cherry' },
@@ -86,16 +178,19 @@ describe('StorageRepo (Dexie web impl)', () => {
}); });
expect(updated.title).toBe('Brand New Title'); expect(updated.title).toBe('Brand New Title');
expect(updated.courseId).toBe(course.id);
expect(updated.segmentCount).toBe(2); expect(updated.segmentCount).toBe(2);
expect(updated.updatedAt).toBeGreaterThan(originalUpdatedAt); 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... // Old text is no longer findable...
expect(await repo.search('apple')).toHaveLength(0); expect(await repo.search('apple')).toHaveLength(0);
// ...and the new text + new title are. // ...and the new text + new title are.
const byText = await repo.search('cherry'); expect((await repo.search('cherry')).map((m) => m.id)).toEqual([created.id]);
expect(byText.map((m) => m.id)).toEqual([created.id]); expect((await repo.search('brand new')).map((m) => m.id)).toEqual([created.id]);
const byTitle = await repo.search('brand new');
expect(byTitle.map((m) => m.id)).toEqual([created.id]);
}); });
it('update throws for an unknown id', async () => { 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]); 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]); expect((await repo.search('budget')).map((m) => m.id)).toEqual([t.id]);
// Non-match.
expect(await repo.search('zzz-not-present')).toHaveLength(0); 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()); 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(); expect(await repo.get(created.id)).toBeDefined();
await repo.remove(created.id); await repo.remove(created.id);
expect(await repo.get(created.id)).toBeUndefined(); expect(await repo.get(created.id)).toBeUndefined();
expect(await repo.list()).toHaveLength(0); 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: NaN }))).toThrow();
expect(() => parseDraft(makeDraft({ durationSec: Infinity }))).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 () => { 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
View File
@@ -5,9 +5,27 @@
// and never on a specific backend. The correct implementation is selected at // 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). // 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 { export interface StorageRepo {
// --- transcripts ---------------------------------------------------------
/** All transcript metadatas, newest first (by createdAt desc). */ /** All transcript metadatas, newest first (by createdAt desc). */
list(): Promise<TranscriptMeta[]>; list(): Promise<TranscriptMeta[]>;
@@ -18,16 +36,14 @@ export interface StorageRepo {
create(draft: TranscriptDraft): Promise<Transcript>; create(draft: TranscriptDraft): Promise<Transcript>;
/** /**
* Patch a transcript's title and/or segments. Re-derives any denormalized * Patch a transcript's mutable fields (title/segments/course+lecture meta).
* fields (searchText, segmentCount, updatedAt) and re-validates segments. * 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. * Rejects if the id does not exist.
*/ */
update( update(id: string, patch: TranscriptPatch): Promise<Transcript>;
id: string,
patch: Partial<Pick<Transcript, 'title' | 'segments'>>,
): 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>; remove(id: string): Promise<void>;
/** /**
@@ -35,4 +51,37 @@ export interface StorageRepo {
* Returns metadatas, newest first. * Returns metadatas, newest first.
*/ */
search(query: string): Promise<TranscriptMeta[]>; 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>;
} }
+200 -21
View File
@@ -1,29 +1,45 @@
// Web storage implementation backed by Dexie (IndexedDB). // Web storage implementation backed by Dexie (IndexedDB).
// //
// We keep ONE object store ('transcripts') keyed by id. Each record holds the // Schema (v2):
// full Transcript plus a derived, lowercased 'searchText' (title + all segment // transcripts: full Transcript + derived lowercase 'searchText' (title + all
// text) used for case-insensitive substring search. Search is done in-memory // segment text), keyed by id, indexed by createdAt + courseId.
// (filter over all rows) because IndexedDB has no native substring index; // courses: stored Course rows, indexed by name.
// transcript counts are small enough on-device that this is fine. // 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 Dexie from 'dexie';
import type { Segment } from '../types'; import type { Segment } from '../types';
import { newId } from '../ids'; import { newId } from '../ids';
import { import {
parseDraft, parseDraft,
parseCourseDraft,
zSegment, zSegment,
type TranscriptDraft, type TranscriptDraft,
type TranscriptMeta, type TranscriptMeta,
type Transcript, type Transcript,
type TranscriptPatch,
type Course,
type CourseDraft,
} from './schema'; } from './schema';
import type { StorageRepo } from './repo'; import type { StorageRepo, MediaInput } from './repo';
// The shape persisted in IndexedDB: a full Transcript plus the derived // The shape persisted in the 'transcripts' store: a full Transcript plus the
// searchText column. searchText is never returned to callers. // derived searchText column. searchText is never returned to callers.
interface StoredTranscript extends Transcript { interface StoredTranscript extends Transcript {
searchText: string; 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) // 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(); 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. */ /** Strip the derived searchText, returning the public Transcript view. */
function toTranscript(row: StoredTranscript): Transcript { function toTranscript(row: StoredTranscript): Transcript {
const { searchText: _searchText, ...rest } = row; const { searchText: _searchText, ...rest } = row;
@@ -52,16 +73,35 @@ function toMeta(row: StoredTranscript): TranscriptMeta {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
class WispDexie extends Dexie { 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>; transcripts!: Dexie.Table<StoredTranscript, string>;
courses!: Dexie.Table<Course, string>;
media!: Dexie.Table<StoredMedia, string>;
constructor() { constructor() {
super('wisp'); 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({ this.version(1).stores({
transcripts: 'id, createdAt', 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 = { export const repo: StorageRepo = {
// --- transcripts ---------------------------------------------------------
async list(): Promise<TranscriptMeta[]> { async list(): Promise<TranscriptMeta[]> {
// Sort by createdAt then reverse for newest-first. // Sort by createdAt then reverse for newest-first.
const rows = await db.transcripts.orderBy('createdAt').reverse().toArray(); const rows = await db.transcripts.orderBy('createdAt').reverse().toArray();
@@ -87,6 +128,8 @@ export const repo: StorageRepo = {
// Validate+throw before doing anything else. // Validate+throw before doing anything else.
const valid = parseDraft(draft); const valid = parseDraft(draft);
const now = Date.now(); const now = Date.now();
// Assign a stable id to every segment that lacks one before storing.
const segments = withSegmentIds(valid.segments);
const stored: StoredTranscript = { const stored: StoredTranscript = {
id: newId('t_'), id: newId('t_'),
title: valid.title, title: valid.title,
@@ -96,28 +139,36 @@ export const repo: StorageRepo = {
modelId: valid.modelId, modelId: valid.modelId,
// Preserve optional language only when present (avoid storing undefined). // Preserve optional language only when present (avoid storing undefined).
...(valid.language !== undefined ? { language: valid.language } : {}), ...(valid.language !== undefined ? { language: valid.language } : {}),
segmentCount: valid.segments.length, segmentCount: segments.length,
segments: valid.segments, segments,
searchText: deriveSearchText(valid.title, valid.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); await db.transcripts.put(stored);
return toTranscript(stored); return toTranscript(stored);
}, },
async update( async update(id: string, patch: TranscriptPatch): Promise<Transcript> {
id: string,
patch: Partial<Pick<Transcript, 'title' | 'segments'>>,
): Promise<Transcript> {
const existing = await db.transcripts.get(id); const existing = await db.transcripts.get(id);
if (!existing) { if (!existing) {
throw new Error(`transcript not found: ${id}`); throw new Error(`transcript not found: ${id}`);
} }
// Re-validate segments on every write so an update can never persist // 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 = const nextSegments =
patch.segments !== undefined patch.segments !== undefined
? patch.segments.map((s) => zSegment.parse(s)) ? withSegmentIds(patch.segments.map((s) => zSegment.parse(s)))
: existing.segments; : existing.segments;
const nextTitle = patch.title !== undefined ? patch.title : existing.title; const nextTitle = patch.title !== undefined ? patch.title : existing.title;
@@ -125,6 +176,15 @@ export const repo: StorageRepo = {
...existing, ...existing,
title: nextTitle, title: nextTitle,
segments: nextSegments, 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. // Re-derive denormalized fields so search() and list() stay correct.
segmentCount: nextSegments.length, segmentCount: nextSegments.length,
searchText: deriveSearchText(nextTitle, nextSegments), searchText: deriveSearchText(nextTitle, nextSegments),
@@ -135,7 +195,11 @@ export const repo: StorageRepo = {
}, },
async remove(id: string): Promise<void> { async remove(id: string): Promise<void> {
await db.transcripts.delete(id); // 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[]> { async search(query: string): Promise<TranscriptMeta[]> {
@@ -147,4 +211,119 @@ export const repo: StorageRepo = {
: rows; : rows;
return matched.map(toMeta); 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 });
}
});
},
}; };
+69
View File
@@ -37,6 +37,8 @@ const zFiniteNonNeg = zFinite.refine((n) => n >= 0, {
* - confidence: optional, finite and within [0, 1] * - confidence: optional, finite and within [0, 1]
*/ */
export const zSegment = z.object({ export const zSegment = z.object({
// Stable id assigned by the repo on write (engine output omits it).
id: z.string().optional(),
start: zFiniteNonNeg, start: zFiniteNonNeg,
end: zFiniteNonNeg, end: zFiniteNonNeg,
text: z.string(), text: z.string(),
@@ -80,6 +82,15 @@ export const zTranscriptDraft = z.object({
modelId: zModelId, modelId: zModelId,
language: z.string().optional(), language: z.string().optional(),
segments: z.array(zSegment), 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. */ /** Inferred TS type for a validated draft. */
@@ -93,6 +104,38 @@ export function parseDraft(input: unknown): TranscriptDraft {
return zTranscriptDraft.parse(input); 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) // Stored types (output of the repo)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -114,9 +157,35 @@ export interface TranscriptMeta {
language?: string; language?: string;
/** Number of segments in the body (denormalized for list views). */ /** Number of segments in the body (denormalized for list views). */
segmentCount: number; 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. */ /** A full stored transcript: metadata plus the segment body. */
export interface Transcript extends TranscriptMeta { export interface Transcript extends TranscriptMeta {
segments: Segment[]; segments: Segment[];
} }
/** The mutable fields update() accepts. */
export type TranscriptPatch = Partial<
Pick<
Transcript,
| 'title'
| 'segments'
| 'courseId'
| 'lectureDate'
| 'instructor'
| 'location'
| 'tags'
| 'lectureNumber'
>
>;
+6
View File
@@ -12,6 +12,12 @@ export const WHISPER_SAMPLE_RATE = 16000 as const;
* pipeline offsets and stitches them into absolute time. * pipeline offsets and stitches them into absolute time.
*/ */
export interface Segment { 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. */ /** Inclusive start time in seconds. */
start: number; start: number;
/** Exclusive end time in seconds. */ /** Exclusive end time in seconds. */
+43
View File
@@ -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();
},
}));
+27
View File
@@ -17,6 +17,8 @@ interface StartOptions {
title?: string; title?: string;
language?: string; language?: string;
translate?: boolean; translate?: boolean;
courseId?: string | null;
lectureDate?: number;
} }
interface TranscribeState { interface TranscribeState {
@@ -88,8 +90,22 @@ export const useTranscribe = create<TranscribeState>((set, get) => ({
modelId, modelId,
language: opts?.language, language: opts?.language,
segments, 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 }); set({ status: 'done', progress: 1, partial: segments, lastTranscriptId: saved.id });
return saved.id; return saved.id;
} catch (err) { } catch (err) {
@@ -118,3 +134,14 @@ function defaultTitle(): string {
const pad = (n: number) => String(n).padStart(2, '0'); 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())}`; 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';
}
+28 -4
View File
@@ -1,31 +1,45 @@
// Library state: the saved transcripts list + search, backed by the local // Library state: the saved transcripts list + search + course filter, backed by
// StorageRepo (Dexie on web, expo-sqlite on native). UI/session state only — // the local StorageRepo. UI/session state only — the repo is the record of truth.
// the record of truth lives in the repo.
import { create } from 'zustand'; import { create } from 'zustand';
import { getRepo, type TranscriptMeta } from '@/lib/db'; 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 { interface TranscriptsState {
items: TranscriptMeta[]; items: TranscriptMeta[];
loading: boolean; loading: boolean;
query: string; query: string;
courseFilter: CourseFilter;
refresh: () => Promise<void>; refresh: () => Promise<void>;
setQuery: (q: string) => Promise<void>; setQuery: (q: string) => Promise<void>;
setCourseFilter: (f: CourseFilter) => Promise<void>;
remove: (id: string) => Promise<void>; remove: (id: string) => Promise<void>;
rename: (id: string, title: 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) => ({ export const useTranscripts = create<TranscriptsState>((set, get) => ({
items: [], items: [],
loading: false, loading: false,
query: '', query: '',
courseFilter: 'all',
refresh: async () => { refresh: async () => {
set({ loading: true }); set({ loading: true });
try { try {
const repo = getRepo(); const repo = getRepo();
const q = get().query.trim(); 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 }); set({ items });
} finally { } finally {
set({ loading: false }); set({ loading: false });
@@ -37,6 +51,11 @@ export const useTranscripts = create<TranscriptsState>((set, get) => ({
await get().refresh(); await get().refresh();
}, },
setCourseFilter: async (f) => {
set({ courseFilter: f });
await get().refresh();
},
remove: async (id) => { remove: async (id) => {
await getRepo().remove(id); await getRepo().remove(id);
await get().refresh(); await get().refresh();
@@ -46,4 +65,9 @@ export const useTranscripts = create<TranscriptsState>((set, get) => ({
await getRepo().update(id, { title }); await getRepo().update(id, { title });
await get().refresh(); await get().refresh();
}, },
reassign: async (id, courseId) => {
await getRepo().reassign(id, courseId);
await get().refresh();
},
})); }));