From 8db59d4bbe457b8194eccb2bb2b1cbd07d371b3d Mon Sep 17 00:00:00 2001 From: Nils Briggen Date: Sun, 14 Jun 2026 13:54:38 +0200 Subject: [PATCH] feat(phase0): courses, migrations, persisted audio, stable segment IDs, course-aware UI 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 --- src/app/_layout.tsx | 1 + src/app/courses.tsx | 112 ++++++ src/app/index.tsx | 123 +++++-- src/app/transcript/[id].tsx | 26 +- src/components/PreCaptureSheet.tsx | 171 +++++++++ src/lib/db/repo.native.ts | 545 ++++++++++++++++++++++++++--- src/lib/db/repo.test.ts | 228 ++++++++++-- src/lib/db/repo.ts | 65 +++- src/lib/db/repo.web.ts | 221 ++++++++++-- src/lib/db/schema.ts | 69 ++++ src/lib/types.ts | 6 + src/stores/coursesStore.ts | 43 +++ src/stores/transcribeStore.ts | 27 ++ src/stores/transcriptsStore.ts | 32 +- 14 files changed, 1538 insertions(+), 131 deletions(-) create mode 100644 src/app/courses.tsx create mode 100644 src/components/PreCaptureSheet.tsx create mode 100644 src/stores/coursesStore.ts diff --git a/src/app/_layout.tsx b/src/app/_layout.tsx index a03fa4c..c234166 100644 --- a/src/app/_layout.tsx +++ b/src/app/_layout.tsx @@ -16,6 +16,7 @@ export default function RootLayout() { + diff --git a/src/app/courses.tsx b/src/app/courses.tsx new file mode 100644 index 0000000..97b8115 --- /dev/null +++ b/src/app/courses.tsx @@ -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(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 ( + + + + + Group lectures by course. Deleting a course moves its transcripts to Unsorted (nothing is lost). + + + + + void add()} style={[styles.addBtn, { opacity: name.trim() ? 1 : 0.5 }]}> + Add + + + + {items.length === 0 && ( + No courses yet. + )} + + {items.map((c) => ( + + {editing === c.id ? ( + + + { + if (editName.trim()) await rename(c.id, editName.trim()); + setEditing(null); + }} + style={styles.addBtn}> + Save + + + ) : ( + + {c.name} + + { + setEditing(c.id); + setEditName(c.name); + }} + hitSlop={8}> + Rename + + void remove(c.id)} hitSlop={8}> + Delete + + + + )} + + ))} + + + ); +} + +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' }, +}); diff --git a/src/app/index.tsx b/src/app/index.tsx index 6ee9b5c..cfa01cc 100644 --- a/src/app/index.tsx +++ b/src/app/index.tsx @@ -1,8 +1,7 @@ import { Link, useFocusEffect, useRouter } from 'expo-router'; -import { useCallback } from 'react'; +import { useCallback, useState } from 'react'; import { ActivityIndicator, - Platform, Pressable, ScrollView, StyleSheet, @@ -10,39 +9,57 @@ import { View, } from 'react-native'; +import { PreCaptureSheet, type PreCaptureResult } from '@/components/PreCaptureSheet'; import { ThemedText } from '@/components/themed-text'; import { ThemedView } from '@/components/themed-view'; import { MaxContentWidth, Spacing } from '@/constants/theme'; import { useTheme } from '@/hooks/use-theme'; +import type { TranscriptMeta } from '@/lib/db'; import { formatClock } from '@/lib/format'; import { MODELS } from '@/lib/models/catalog'; -import { pickAudio } from '@/lib/pickAudio'; -import type { TranscriptMeta } from '@/lib/db'; +import { pickAudio, type PickedAudio } from '@/lib/pickAudio'; +import { useCourses } from '@/stores/coursesStore'; import { useTranscribe } from '@/stores/transcribeStore'; -import { useTranscripts } from '@/stores/transcriptsStore'; +import { useTranscripts, type CourseFilter } from '@/stores/transcriptsStore'; export default function LibraryScreen() { const theme = useTheme(); const router = useRouter(); - const { items, loading, query, setQuery, refresh, remove } = useTranscripts(); + const { items, loading, query, setQuery, refresh, remove, courseFilter, setCourseFilter } = + useTranscripts(); + const courses = useCourses((s) => s.items); + const refreshCourses = useCourses((s) => s.refresh); const job = useTranscribe(); + const [picked, setPicked] = useState(null); - // Refresh the list whenever the screen regains focus (e.g. after a transcribe). useFocusEffect( useCallback(() => { void refresh(); - }, [refresh]), + void refreshCourses(); + }, [refresh, refreshCourses]), ); const busy = job.status === 'loading' || job.status === 'transcribing'; const onNew = useCallback(async () => { if (busy) return; - const picked = await pickAudio(); - if (!picked) return; - const id = await useTranscribe.getState().start(picked, { title: picked.name }); - if (id) router.push({ pathname: '/transcript/[id]', params: { id } }); - }, [busy, router]); + const p = await pickAudio(); + if (p) setPicked(p); // opens the pre-capture sheet + }, [busy]); + + const onStart = useCallback( + async (r: PreCaptureResult) => { + const p = picked; + setPicked(null); + if (!p) return; + const id = await useTranscribe.getState().start(p, r); + if (id) router.push({ pathname: '/transcript/[id]', params: { id } }); + }, + [picked, router], + ); + + const courseName = (cid?: string | null) => + cid ? courses.find((c) => c.id === cid)?.name ?? 'Course' : null; return ( @@ -54,13 +71,18 @@ export default function LibraryScreen() { Private transcription — runs on your device, nothing uploaded. - - - - ⚙ Settings - - - + + + + Courses + + + + + ⚙ Settings + + + Transcription failed - - {job.error} - + {job.error} )} + + void setCourseFilter('all')} /> + void setCourseFilter(null)} /> + {courses.map((c) => ( + void setCourseFilter(c.id)} /> + ))} + + void setQuery(t)} @@ -95,18 +123,42 @@ export default function LibraryScreen() { ) : items.length === 0 ? ( - {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.'} ) : ( items.map((t) => ( - router.push({ pathname: '/transcript/[id]', params: { id: t.id } })} onDelete={() => void remove(t.id)} /> + router.push({ pathname: '/transcript/[id]', params: { id: t.id } })} + onDelete={() => void remove(t.id)} + /> )) )} + + void onStart(r)} + onCancel={() => setPicked(null)} + /> ); } +function FilterChip({ label, active, onPress }: { label: string; active: boolean; onPress: () => void }) { + const theme = useTheme(); + return ( + + {label} + + ); +} + function ActiveJob() { const { stage, progress, partial, cancel } = useTranscribe(); const pct = Math.round(progress * 100); @@ -138,8 +190,18 @@ function ProgressBar({ value }: { value: number }) { ); } -function TranscriptRow({ item, onOpen, onDelete }: { item: TranscriptMeta; onOpen: () => void; onDelete: () => void }) { - const date = new Date(item.createdAt).toLocaleDateString(); +function TranscriptRow({ + item, + courseName, + onOpen, + onDelete, +}: { + item: TranscriptMeta; + courseName: string | null; + onOpen: () => void; + onDelete: () => void; +}) { + const date = new Date(item.lectureDate ?? item.createdAt).toLocaleDateString(); return ( [pressed && styles.pressed]}> @@ -152,7 +214,8 @@ function TranscriptRow({ item, onOpen, onDelete }: { item: TranscriptMeta; onOpe - {date} · {formatClock(item.durationSec)} · {MODELS[item.modelId]?.label ?? item.modelId} · {item.segmentCount} segments + {courseName ? `${courseName} · ` : ''} + {date} · {formatClock(item.durationSec)} · {item.segmentCount} segments @@ -170,10 +233,14 @@ const styles = StyleSheet.create({ }, flex: { flex: 1 }, headerRow: { flexDirection: 'row', alignItems: 'flex-start', gap: Spacing.two }, + headerLinks: { flexDirection: 'row', gap: Spacing.three, alignItems: 'center' }, newButton: { paddingVertical: Spacing.three, borderRadius: Spacing.three, alignItems: 'center' }, newButtonText: { color: '#fff', fontWeight: '700', fontSize: 16 }, card: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.two }, rowBetween: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: Spacing.two }, + filterBar: { gap: Spacing.two, paddingVertical: Spacing.one, paddingRight: Spacing.three }, + filterChip: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.one, borderRadius: 999 }, + chipActive: { color: '#fff', fontWeight: '700' }, search: { borderRadius: Spacing.two, paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, fontSize: 15 }, track: { height: 6, borderRadius: 3, backgroundColor: '#88888833', overflow: 'hidden' }, bar: { height: 6, borderRadius: 3, backgroundColor: '#3c87f7' }, diff --git a/src/app/transcript/[id].tsx b/src/app/transcript/[id].tsx index c4ed9a4..e17cbb6 100644 --- a/src/app/transcript/[id].tsx +++ b/src/app/transcript/[id].tsx @@ -32,10 +32,30 @@ export default function TranscriptScreen() { const [saving, setSaving] = useState(false); const [currentTime, setCurrentTime] = useState(0); - // The just-transcribed audio is playable in-session (object URL on web). - const audioUrl = useTranscribe((s) => (s.lastTranscriptId === id ? s.audioUrl : undefined)); + // Prefer the just-transcribed in-session audio; otherwise load the persisted + // source media so playback/scrub works after a reload (ROADMAP Phase 0). + const sessionAudioUrl = useTranscribe((s) => (s.lastTranscriptId === id ? s.audioUrl : undefined)); + const [persistedUrl, setPersistedUrl] = useState(undefined); + const audioUrl = sessionAudioUrl ?? persistedUrl; const audioRef = useRef(null); + useEffect(() => { + if (sessionAudioUrl) return; // already have in-session audio + let url: string | undefined; + let alive = true; + void getRepo() + .getMediaUrl(id) + .then((u) => { + if (!alive) return; + url = u; + setPersistedUrl(u); + }); + return () => { + alive = false; + if (url && Platform.OS === 'web' && url.startsWith('blob:')) URL.revokeObjectURL(url); + }; + }, [id, sessionAudioUrl]); + useEffect(() => { let alive = true; void getRepo() @@ -124,7 +144,7 @@ export default function TranscriptScreen() { {!audioUrl && ( - Tip: audio playback is available right after transcribing. Re-import the file to scrub along. + No source audio stored for this transcript — playback unavailable. )} diff --git a/src/components/PreCaptureSheet.tsx b/src/components/PreCaptureSheet.tsx new file mode 100644 index 0000000..65bda4c --- /dev/null +++ b/src/components/PreCaptureSheet.tsx @@ -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(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 ( + + + + + New transcription + + Title + + + Course + + setCourseId(null)} + style={[styles.chip, { backgroundColor: courseId === null ? '#3c87f7' : theme.backgroundElement }]}> + Unsorted + + {courses.map((c) => ( + setCourseId(c.id)} + style={[styles.chip, { backgroundColor: courseId === c.id ? '#3c87f7' : theme.backgroundElement }]}> + {c.name} + + ))} + + + + void addCourse()} style={[styles.addBtn, { opacity: newCourse.trim() ? 1 : 0.5 }]}> + Add + + + + Lecture date + + + Language (blank = auto-detect) + + + + + Cancel + + + onStart({ + title: title.trim() || defaultTitle, + courseId, + lectureDate: parseYmd(dateStr), + language: language.trim() || undefined, + }) + } + style={[styles.btn, { backgroundColor: '#3c87f7' }]}> + Start + + + + + + + ); +} + +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' }, +}); diff --git a/src/lib/db/repo.native.ts b/src/lib/db/repo.native.ts index 3fd690b..8a5dbac 100644 --- a/src/lib/db/repo.native.ts +++ b/src/lib/db/repo.native.ts @@ -5,23 +5,38 @@ // list()/search() can be answered from columns alone without parsing every // body. Search uses a LIKE over the lowercased 'searchText' column. // -// API reference: https://docs.expo.dev/versions/v56.0.0/sdk/sqlite/ -// We use openDatabaseAsync + the async methods (execAsync/runAsync/getAllAsync). +// Schema evolution is handled by a real PRAGMA user_version migration runner +// (see MIGRATIONS below), so devices created by the old single-table code +// migrate forward in place rather than being wiped. +// +// API reference (read the EXACT versioned docs before touching this file): +// https://docs.expo.dev/versions/v56.0.0/sdk/sqlite/ +// https://docs.expo.dev/versions/v56.0.0/sdk/filesystem/ +// We use openDatabaseAsync + the async methods (execAsync/runAsync/getAllAsync) +// and the new object-oriented expo-file-system API (File/Directory/Paths). import * as SQLite from 'expo-sqlite'; +import { Directory, File, Paths } from 'expo-file-system'; import type { Segment } from '../types'; import { newId } from '../ids'; import { parseDraft, + parseCourseDraft, zSegment, type TranscriptDraft, type TranscriptMeta, type Transcript, + type TranscriptPatch, + type Course, + type CourseDraft, } from './schema'; -import type { StorageRepo } from './repo'; +import type { MediaInput, StorageRepo } from './repo'; + +// --------------------------------------------------------------------------- +// Row shapes (as returned by getAllAsync). SQLite has no boolean/undefined: +// optional text columns come back as string | null, booleans as 0/1 integers. +// --------------------------------------------------------------------------- -// Row shape as returned by getAllAsync from the metadata columns. SQLite has no -// boolean/undefined: optional language comes back as string | null. interface MetaRow { id: string; title: string; @@ -31,13 +46,40 @@ interface MetaRow { segmentCount: number; modelId: string; language: string | null; + // v2 columns + courseId: string | null; + lectureDate: number | null; + instructor: string | null; + location: string | null; + /** JSON-encoded string[] or null. */ + tags: string | null; + lectureNumber: number | null; + /** 0/1 integer. */ + hasMedia: number | null; } -// A meta row plus the JSON body, for get(). +// A meta row plus the JSON body, for get()/update(). interface FullRow extends MetaRow { json: string; } +interface CourseRow { + id: string; + name: string; + code: string | null; + instructor: string | null; + term: string | null; + color: string | null; + createdAt: number; + updatedAt: number; +} + +interface MediaRow { + transcriptId: string; + path: string; + mime: string; +} + // --------------------------------------------------------------------------- // Pure derivation helpers // --------------------------------------------------------------------------- @@ -47,9 +89,17 @@ function deriveSearchText(title: string, segments: Segment[]): string { return [title, ...segments.map((s) => s.text)].join('\n').toLowerCase(); } -/** Map a metadata row to the public TranscriptMeta (drop null language). */ +/** + * Ensure every segment has a stable id, assigning newId('s_') to any that + * lack one. Returns a new array (does not mutate the input segments). + */ +function withSegmentIds(segments: Segment[]): Segment[] { + return segments.map((s) => (s.id ? s : { ...s, id: newId('s_') })); +} + +/** Map a metadata row to the public TranscriptMeta. */ function rowToMeta(r: MetaRow): TranscriptMeta { - return { + const meta: TranscriptMeta = { id: r.id, title: r.title, createdAt: r.createdAt, @@ -57,8 +107,163 @@ function rowToMeta(r: MetaRow): TranscriptMeta { durationSec: r.durationSec, modelId: r.modelId as TranscriptMeta['modelId'], segmentCount: r.segmentCount, - ...(r.language !== null ? { language: r.language } : {}), + // Keep courseId as null (the "Unsorted" sentinel) rather than dropping it. + courseId: r.courseId, + hasMedia: r.hasMedia === 1, }; + if (r.language !== null) meta.language = r.language; + if (r.lectureDate !== null) meta.lectureDate = r.lectureDate; + if (r.instructor !== null) meta.instructor = r.instructor; + if (r.location !== null) meta.location = r.location; + if (r.lectureNumber !== null) meta.lectureNumber = r.lectureNumber; + if (r.tags !== null) { + try { + const parsed = JSON.parse(r.tags) as unknown; + if (Array.isArray(parsed)) meta.tags = parsed as string[]; + } catch { + // Corrupt tags JSON => treat as absent rather than throwing on read. + } + } + return meta; +} + +function rowToCourse(r: CourseRow): Course { + const course: Course = { + id: r.id, + name: r.name, + createdAt: r.createdAt, + updatedAt: r.updatedAt, + }; + if (r.code !== null) course.code = r.code; + if (r.instructor !== null) course.instructor = r.instructor; + if (r.term !== null) course.term = r.term; + if (r.color !== null) course.color = r.color; + return course; +} + +// --------------------------------------------------------------------------- +// 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; + +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 => { + 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 { + 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 | undefined; function getDb(): Promise { - // Open once and reuse; create the table on first open. The async open returns - // a promise we cache so concurrent callers share a single connection. + // Open once and reuse; run migrations on first open. The async open returns a + // promise we cache so concurrent callers share a single connection. if (!dbPromise) { dbPromise = (async () => { const db = await SQLite.openDatabaseAsync('wisp.db'); - await 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); - `); + await runMigrations(db); return db; })(); } @@ -96,13 +287,33 @@ function getDb(): Promise { // The metadata columns we select for list()/search(), kept in one place. const META_COLUMNS = - 'id, title, createdAt, updatedAt, durationSec, segmentCount, modelId, language'; + 'id, title, createdAt, updatedAt, durationSec, segmentCount, modelId, language, ' + + 'courseId, lectureDate, instructor, location, tags, lectureNumber, hasMedia'; + +// --------------------------------------------------------------------------- +// Media filesystem helpers (expo-file-system, SDK 56 object API) +// --------------------------------------------------------------------------- +// +// TODO(native-untested): the media filesystem paths below cannot be exercised +// off-device (expo-file-system has no Node/jsdom backend), so they are +// type-checked and modeled on the SDK 56 docs but NOT runtime-tested here. + +/** The /media/ directory, created on demand. */ +function mediaDir(): Directory { + return new Directory(Paths.document, 'media'); +} + +/** The persisted media file for a transcript (named by its id). */ +function mediaFile(transcriptId: string): File { + return new File(mediaDir(), transcriptId); +} // --------------------------------------------------------------------------- // Repo implementation // --------------------------------------------------------------------------- export const repo: StorageRepo = { + // --- transcripts --------------------------------------------------------- async list(): Promise { const db = await getDb(); const rows = await db.getAllAsync( @@ -118,14 +329,18 @@ export const repo: StorageRepo = { [id], ); if (!row) return undefined; - // The JSON body is the authoritative full Transcript (already validated on - // write), so we return it directly rather than reconstructing from columns. + // The JSON body is the authoritative full Transcript (already validated and + // segment-id-stamped on write), so we return it directly. return JSON.parse(row.json) as Transcript; }, async create(draft: TranscriptDraft): Promise { const valid = parseDraft(draft); const now = Date.now(); + // Assign a stable id to every segment that lacks one before storing. + const segments = withSegmentIds(valid.segments); + const courseId = valid.courseId ?? null; + const transcript: Transcript = { id: newId('t_'), title: valid.title, @@ -133,16 +348,27 @@ export const repo: StorageRepo = { updatedAt: now, durationSec: valid.durationSec, modelId: valid.modelId, + segmentCount: segments.length, + segments, + courseId, + hasMedia: false, ...(valid.language !== undefined ? { language: valid.language } : {}), - segmentCount: valid.segments.length, - segments: valid.segments, + ...(valid.lectureDate !== undefined ? { lectureDate: valid.lectureDate } : {}), + ...(valid.instructor !== undefined ? { instructor: valid.instructor } : {}), + ...(valid.location !== undefined ? { location: valid.location } : {}), + ...(valid.tags !== undefined ? { tags: valid.tags } : {}), + ...(valid.lectureNumber !== undefined + ? { lectureNumber: valid.lectureNumber } + : {}), }; const db = await getDb(); await db.runAsync( `INSERT INTO transcripts - (id, json, searchText, createdAt, updatedAt, durationSec, segmentCount, modelId, language) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`, + (id, json, searchText, createdAt, updatedAt, durationSec, segmentCount, + modelId, language, courseId, lectureDate, instructor, location, tags, + lectureNumber, hasMedia) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [ transcript.id, JSON.stringify(transcript), @@ -154,15 +380,19 @@ export const repo: StorageRepo = { transcript.modelId, // expo-sqlite accepts null but not undefined for params. transcript.language ?? null, + courseId, + transcript.lectureDate ?? null, + transcript.instructor ?? null, + transcript.location ?? null, + transcript.tags !== undefined ? JSON.stringify(transcript.tags) : null, + transcript.lectureNumber ?? null, + transcript.hasMedia ? 1 : 0, ], ); return transcript; }, - async update( - id: string, - patch: Partial>, - ): Promise { + async update(id: string, patch: TranscriptPatch): Promise { const db = await getDb(); const existingRow = await db.getFirstAsync( `SELECT ${META_COLUMNS}, json FROM transcripts WHERE id = ?`, @@ -173,24 +403,36 @@ export const repo: StorageRepo = { } const existing = JSON.parse(existingRow.json) as Transcript; - // Re-validate segments on update so non-finite times can never slip in. + // Re-validate segments on update (so non-finite times can never slip in) + // and stamp ids onto any that are missing one. const nextSegments = patch.segments !== undefined - ? patch.segments.map((s) => zSegment.parse(s)) + ? withSegmentIds(patch.segments.map((s) => zSegment.parse(s))) : existing.segments; const nextTitle = patch.title !== undefined ? patch.title : existing.title; + // Apply only provided patch fields; leave everything else as-is. const updated: Transcript = { ...existing, title: nextTitle, segments: nextSegments, segmentCount: nextSegments.length, updatedAt: Date.now(), + ...(patch.courseId !== undefined ? { courseId: patch.courseId } : {}), + ...(patch.lectureDate !== undefined ? { lectureDate: patch.lectureDate } : {}), + ...(patch.instructor !== undefined ? { instructor: patch.instructor } : {}), + ...(patch.location !== undefined ? { location: patch.location } : {}), + ...(patch.tags !== undefined ? { tags: patch.tags } : {}), + ...(patch.lectureNumber !== undefined + ? { lectureNumber: patch.lectureNumber } + : {}), }; await db.runAsync( `UPDATE transcripts - SET json = ?, searchText = ?, updatedAt = ?, segmentCount = ?, title = ? + SET json = ?, searchText = ?, updatedAt = ?, segmentCount = ?, title = ?, + courseId = ?, lectureDate = ?, instructor = ?, location = ?, tags = ?, + lectureNumber = ? WHERE id = ?`, [ JSON.stringify(updated), @@ -198,6 +440,12 @@ export const repo: StorageRepo = { updated.updatedAt, updated.segmentCount, updated.title, + updated.courseId ?? null, + updated.lectureDate ?? null, + updated.instructor ?? null, + updated.location ?? null, + updated.tags !== undefined ? JSON.stringify(updated.tags) : null, + updated.lectureNumber ?? null, id, ], ); @@ -206,6 +454,8 @@ export const repo: StorageRepo = { async remove(id: string): Promise { const db = await getDb(); + // Delete persisted media first so we never orphan a file. + await this.removeMedia(id); await db.runAsync(`DELETE FROM transcripts WHERE id = ?`, [id]); }, @@ -230,4 +480,219 @@ export const repo: StorageRepo = { ); return rows.map(rowToMeta); }, + + async listByCourse(courseId: string | null): Promise { + 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( + `SELECT ${META_COLUMNS} FROM transcripts + WHERE courseId IS NULL + ORDER BY createdAt DESC`, + ); + return rows.map(rowToMeta); + } + const rows = await db.getAllAsync( + `SELECT ${META_COLUMNS} FROM transcripts + WHERE courseId = ? + ORDER BY createdAt DESC`, + [courseId], + ); + return rows.map(rowToMeta); + }, + + async reassign(transcriptId: string, courseId: string | null): Promise { + const db = await getDb(); + const row = await db.getFirstAsync( + `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 { + const db = await getDb(); + const rows = await db.getAllAsync( + `SELECT id, name, code, instructor, term, color, createdAt, updatedAt + FROM courses ORDER BY name ASC`, + ); + return rows.map(rowToCourse); + }, + + async createCourse(draft: CourseDraft): Promise { + 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): Promise { + const db = await getDb(); + const existing = await db.getFirstAsync( + `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 { + 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 { + const db = await getDb(); + + // Ensure /media/ exists. + const dir = mediaDir(); + if (!dir.exists) { + dir.create({ intermediates: true }); + } + + // Write the bytes into /, 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 { + const db = await getDb(); + const row = await db.getFirstAsync( + `SELECT transcriptId, path, mime FROM media WHERE transcriptId = ?`, + [transcriptId], + ); + return row ? row.path : undefined; + }, + + async removeMedia(transcriptId: string): Promise { + const db = await getDb(); + const row = await db.getFirstAsync( + `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, + ]); + }, }; diff --git a/src/lib/db/repo.test.ts b/src/lib/db/repo.test.ts index bfbd50a..5d62fd2 100644 --- a/src/lib/db/repo.test.ts +++ b/src/lib/db/repo.test.ts @@ -4,11 +4,58 @@ // under vitest. We test the Dexie repo as the reference implementation of the // StorageRepo contract; the native expo-sqlite repo (native-only) is NOT // imported here because expo-sqlite cannot load outside a device/simulator. +// +// IMPORTANT: repo.web.ts is *not* statically imported. To exercise the v1->v2 +// migration we must first seed a v1-shaped DB with a raw Dexie connection, and +// only THEN import repo.web (whose module-eval opens the DB at v2, triggering +// the upgrade). The dynamically-imported repo is stashed in a module-level +// binding so all subsequent tests reuse it. import 'fake-indexeddb/auto'; -import { describe, it, expect, beforeEach } from 'vitest'; -import { repo } from './repo.web'; -import { parseDraft, type TranscriptDraft } from './schema'; +import Dexie from 'dexie'; +import { describe, it, expect, beforeAll, beforeEach } from 'vitest'; +import { + parseDraft, + type TranscriptDraft, + type StorageRepo, +} from './index'; + +// Populated by the migration beforeAll (which seeds v1 then imports repo.web). +let repo: StorageRepo; + +// The v1 record we seed: NO segment ids, NO courseId, but WITH a searchText +// column (exactly the legacy shape the v1 repo persisted). +const V1_ID = 't_legacy'; +const v1Row = { + id: V1_ID, + title: 'Legacy Lecture', + createdAt: 1000, + updatedAt: 1000, + durationSec: 42.5, + modelId: 'tiny.en' as const, + segmentCount: 2, + segments: [ + { start: 0, end: 2, text: 'legacy hello' }, + { start: 2, end: 5, text: 'legacy world', confidence: 0.8 }, + ], + searchText: 'legacy lecture\nlegacy hello\nlegacy world', +}; + +beforeAll(async () => { + // 1) Seed a v1 DB with a raw Dexie connection (only the v1 store/schema). + const v1 = new Dexie('wisp'); + v1.version(1).stores({ transcripts: 'id, createdAt' }); + await v1.open(); + await v1.table('transcripts').put(v1Row); + v1.close(); + + // 2) Now import repo.web, which opens 'wisp' at v2 and runs the upgrade. + const mod = await import('./repo.web'); + repo = mod.repo; + + // Stub object-URL creation so getMediaUrl returns a string under Node. + globalThis.URL.createObjectURL = () => 'blob:mock'; +}); // Helper: a minimal valid draft, overridable per test. function makeDraft(over: Partial = {}): TranscriptDraft { @@ -24,31 +71,76 @@ function makeDraft(over: Partial = {}): TranscriptDraft { }; } -// Wipe storage between tests so ordering/search assertions are deterministic. -beforeEach(async () => { - const all = await repo.list(); - await Promise.all(all.map((m) => repo.remove(m.id))); +describe('v1 -> v2 migration (no data loss)', () => { + it('keeps the legacy transcript, backfills segment ids, leaves it Unsorted', async () => { + const fetched = await repo.get(V1_ID); + expect(fetched).toBeDefined(); + + // Identity-preserving fields survive untouched. + expect(fetched!.title).toBe('Legacy Lecture'); + expect(fetched!.durationSec).toBe(42.5); + + // Segment text/timing is identical to the v1 body... + expect(fetched!.segments.map((s) => s.text)).toEqual([ + 'legacy hello', + 'legacy world', + ]); + expect(fetched!.segments.map((s) => ({ start: s.start, end: s.end }))).toEqual([ + { start: 0, end: 2 }, + { start: 2, end: 5 }, + ]); + expect(fetched!.segments[1]!.confidence).toBe(0.8); + + // ...but every segment now carries a backfilled stable id. + for (const s of fetched!.segments) { + expect(s.id).toMatch(/^s_/); + } + + // No courseId was set by v1 => the row is "Unsorted" (null/undefined). + expect(fetched!.courseId == null).toBe(true); + + // And it shows up under the Unsorted bucket. + const unsorted = await repo.listByCourse(null); + expect(unsorted.map((m) => m.id)).toContain(V1_ID); + }); }); describe('StorageRepo (Dexie web impl)', () => { - it('create -> get round-trips segments and metadata', async () => { + // Wipe transcripts/courses/media between tests so ordering/search/course + // assertions are deterministic. (Runs after the migration beforeAll.) + beforeEach(async () => { + const all = await repo.list(); + await Promise.all(all.map((m) => repo.remove(m.id))); + const courses = await repo.listCourses(); + await Promise.all(courses.map((c) => repo.deleteCourse(c.id))); + }); + + it('create -> get round-trips segments and metadata, assigns segment ids', async () => { const created = await repo.create(makeDraft()); expect(created.id).toMatch(/^t_/); expect(created.createdAt).toBeTypeOf('number'); expect(created.updatedAt).toBe(created.createdAt); expect(created.segmentCount).toBe(2); + // courseId defaults to null ("Unsorted") when absent. + expect(created.courseId).toBeNull(); + expect(created.hasMedia).toBe(false); + // Every segment got a stable id assigned on write. + for (const s of created.segments) { + expect(s.id).toMatch(/^s_/); + } const fetched = await repo.get(created.id); expect(fetched).toBeDefined(); expect(fetched!.title).toBe('My Recording'); expect(fetched!.durationSec).toBe(12.5); expect(fetched!.modelId).toBe('tiny.en'); - // Segments survive the round-trip exactly. - expect(fetched!.segments).toEqual([ + // Text/timing survive the round-trip exactly. + expect(fetched!.segments.map((s) => ({ start: s.start, end: s.end, text: s.text }))).toEqual([ { start: 0, end: 2, text: 'hello world' }, - { start: 2, end: 5, text: 'second segment', confidence: 0.9 }, + { start: 2, end: 5, text: 'second segment' }, ]); + expect(fetched!.segments[1]!.confidence).toBe(0.9); }); it('get returns undefined for an unknown id', async () => { @@ -57,7 +149,6 @@ describe('StorageRepo (Dexie web impl)', () => { it('list returns metadatas newest-first (without segments)', async () => { const a = await repo.create(makeDraft({ title: 'first' })); - // Force distinct, increasing createdAt timestamps. await new Promise((r) => setTimeout(r, 2)); const b = await repo.create(makeDraft({ title: 'second' })); await new Promise((r) => setTimeout(r, 2)); @@ -65,12 +156,12 @@ describe('StorageRepo (Dexie web impl)', () => { const metas = await repo.list(); expect(metas.map((m) => m.id)).toEqual([c.id, b.id, a.id]); - // Metas omit the segment body. expect((metas[0] as unknown as Record).segments).toBeUndefined(); expect(metas[0]!.segmentCount).toBe(2); }); - it('update changes title + segments and re-derives searchText for search', async () => { + it('update merges title/segments/courseId and re-ids segments + re-derives search', async () => { + const course = await repo.createCourse({ name: 'Physics' }); const created = await repo.create( makeDraft({ title: 'Old Title', segments: [{ start: 0, end: 1, text: 'apple' }] }), ); @@ -79,6 +170,7 @@ describe('StorageRepo (Dexie web impl)', () => { const updated = await repo.update(created.id, { title: 'Brand New Title', + courseId: course.id, segments: [ { start: 0, end: 1, text: 'banana' }, { start: 1, end: 2, text: 'cherry' }, @@ -86,16 +178,19 @@ describe('StorageRepo (Dexie web impl)', () => { }); expect(updated.title).toBe('Brand New Title'); + expect(updated.courseId).toBe(course.id); expect(updated.segmentCount).toBe(2); expect(updated.updatedAt).toBeGreaterThan(originalUpdatedAt); + // New segments got ids. + for (const s of updated.segments) { + expect(s.id).toMatch(/^s_/); + } // Old text is no longer findable... expect(await repo.search('apple')).toHaveLength(0); // ...and the new text + new title are. - const byText = await repo.search('cherry'); - expect(byText.map((m) => m.id)).toEqual([created.id]); - const byTitle = await repo.search('brand new'); - expect(byTitle.map((m) => m.id)).toEqual([created.id]); + expect((await repo.search('cherry')).map((m) => m.id)).toEqual([created.id]); + expect((await repo.search('brand new')).map((m) => m.id)).toEqual([created.id]); }); it('update throws for an unknown id', async () => { @@ -110,24 +205,106 @@ describe('StorageRepo (Dexie web impl)', () => { }), ); - // Title match, different case. expect((await repo.search('meeting')).map((m) => m.id)).toEqual([t.id]); - // Segment-text match, different case. expect((await repo.search('budget')).map((m) => m.id)).toEqual([t.id]); - // Non-match. expect(await repo.search('zzz-not-present')).toHaveLength(0); }); - it('remove deletes a transcript', async () => { + it('remove deletes a transcript (and its media)', async () => { const created = await repo.create(makeDraft()); + await repo.putMedia(created.id, { data: new ArrayBuffer(4), mime: 'audio/mpeg' }); expect(await repo.get(created.id)).toBeDefined(); await repo.remove(created.id); expect(await repo.get(created.id)).toBeUndefined(); expect(await repo.list()).toHaveLength(0); + // Media is gone too: no playable URL remains. + expect(await repo.getMediaUrl(created.id)).toBeUndefined(); }); - it('parseDraft rejects a NaN duration', () => { + // --- courses ------------------------------------------------------------- + + it('listByCourse splits Unsorted vs a course', async () => { + const course = await repo.createCourse({ name: 'Bio 101' }); + const inCourse = await repo.create(makeDraft({ title: 'lecture', courseId: course.id })); + const unsorted = await repo.create(makeDraft({ title: 'loose note' })); + + const courseRows = await repo.listByCourse(course.id); + expect(courseRows.map((m) => m.id)).toEqual([inCourse.id]); + + const unsortedRows = await repo.listByCourse(null); + expect(unsortedRows.map((m) => m.id)).toEqual([unsorted.id]); + }); + + it('createCourse / listCourses (alphabetical) / updateCourse', async () => { + const zebra = await repo.createCourse({ name: 'Zebra Studies', code: 'ZOO-1' }); + const apple = await repo.createCourse({ name: 'Apple Theory' }); + + expect(apple.id).toMatch(/^c_/); + expect(apple.createdAt).toBeTypeOf('number'); + + // Alphabetical by name. + const listed = await repo.listCourses(); + expect(listed.map((c) => c.name)).toEqual(['Apple Theory', 'Zebra Studies']); + + const renamed = await repo.updateCourse(zebra.id, { name: 'Aardvark Studies' }); + expect(renamed.name).toBe('Aardvark Studies'); + expect(renamed.code).toBe('ZOO-1'); // unspecified fields preserved + expect(renamed.updatedAt).toBeGreaterThanOrEqual(zebra.updatedAt); + + const relisted = await repo.listCourses(); + expect(relisted.map((c) => c.name)).toEqual(['Aardvark Studies', 'Apple Theory']); + }); + + it('deleteCourse reassigns its transcripts to Unsorted, then removes the course', async () => { + const course = await repo.createCourse({ name: 'Doomed' }); + const t = await repo.create(makeDraft({ title: 'orphan-to-be', courseId: course.id })); + + await repo.deleteCourse(course.id); + + // The course is gone. + expect((await repo.listCourses()).map((c) => c.id)).not.toContain(course.id); + // The transcript survives, now Unsorted. + const fetched = await repo.get(t.id); + expect(fetched).toBeDefined(); + expect(fetched!.courseId).toBeNull(); + expect((await repo.listByCourse(null)).map((m) => m.id)).toContain(t.id); + }); + + it('reassign moves a transcript between courses and to Unsorted', async () => { + const course = await repo.createCourse({ name: 'Chemistry' }); + const t = await repo.create(makeDraft()); + expect(t.courseId).toBeNull(); + + await repo.reassign(t.id, course.id); + expect((await repo.listByCourse(course.id)).map((m) => m.id)).toEqual([t.id]); + + await repo.reassign(t.id, null); + expect((await repo.listByCourse(course.id))).toHaveLength(0); + expect((await repo.listByCourse(null)).map((m) => m.id)).toContain(t.id); + }); + + // --- media --------------------------------------------------------------- + + it('putMedia flips hasMedia true and getMediaUrl returns a url; removeMedia flips it false', async () => { + const t = await repo.create(makeDraft()); + expect(t.hasMedia).toBe(false); + + await repo.putMedia(t.id, { data: new ArrayBuffer(8), mime: 'audio/mpeg' }); + + const afterPut = await repo.get(t.id); + expect(afterPut!.hasMedia).toBe(true); + expect(await repo.getMediaUrl(t.id)).toBe('blob:mock'); + + await repo.removeMedia(t.id); + const afterRemove = await repo.get(t.id); + expect(afterRemove!.hasMedia).toBe(false); + expect(await repo.getMediaUrl(t.id)).toBeUndefined(); + }); + + // --- validation gatekeeping --------------------------------------------- + + it('parseDraft rejects a NaN/Infinity duration', () => { expect(() => parseDraft(makeDraft({ durationSec: NaN }))).toThrow(); expect(() => parseDraft(makeDraft({ durationSec: Infinity }))).toThrow(); }); @@ -142,9 +319,6 @@ describe('StorageRepo (Dexie web impl)', () => { }); it('create rejects an invalid draft (defense in depth)', async () => { - // create() calls parseDraft internally, so bad input never persists. - await expect( - repo.create(makeDraft({ durationSec: -1 })), - ).rejects.toThrow(); + await expect(repo.create(makeDraft({ durationSec: -1 }))).rejects.toThrow(); }); }); diff --git a/src/lib/db/repo.ts b/src/lib/db/repo.ts index 9c3f4ab..b405748 100644 --- a/src/lib/db/repo.ts +++ b/src/lib/db/repo.ts @@ -5,9 +5,27 @@ // and never on a specific backend. The correct implementation is selected at // build time via platform file extensions (see repoImpl.web.ts / repoImpl.native.ts). -import type { TranscriptDraft, TranscriptMeta, Transcript } from './schema'; +import type { + TranscriptDraft, + TranscriptMeta, + Transcript, + TranscriptPatch, + Course, + CourseDraft, +} from './schema'; + +/** Source of audio to persist for a transcript (web passes data, native a uri). */ +export interface MediaInput { + /** Raw bytes (web import path). */ + data?: ArrayBuffer; + /** A file uri to copy into app storage (native import path). */ + uri?: string; + /** MIME type, e.g. 'audio/mpeg'. */ + mime: string; +} export interface StorageRepo { + // --- transcripts --------------------------------------------------------- /** All transcript metadatas, newest first (by createdAt desc). */ list(): Promise; @@ -18,16 +36,14 @@ export interface StorageRepo { create(draft: TranscriptDraft): Promise; /** - * Patch a transcript's title and/or segments. Re-derives any denormalized - * fields (searchText, segmentCount, updatedAt) and re-validates segments. + * Patch a transcript's mutable fields (title/segments/course+lecture meta). + * Re-derives denormalized fields (searchText, segmentCount, updatedAt), + * re-validates segments, and assigns ids to any segments missing one. * Rejects if the id does not exist. */ - update( - id: string, - patch: Partial>, - ): Promise; + update(id: string, patch: TranscriptPatch): Promise; - /** Delete by id. No-op if absent. */ + /** Delete by id (also removes its persisted media). No-op if absent. */ remove(id: string): Promise; /** @@ -35,4 +51,37 @@ export interface StorageRepo { * Returns metadatas, newest first. */ search(query: string): Promise; + + /** Transcripts in a course (pass null for "Unsorted"), newest first. */ + listByCourse(courseId: string | null): Promise; + + /** Move a transcript to a course (or null for Unsorted). */ + reassign(transcriptId: string, courseId: string | null): Promise; + + // --- courses ------------------------------------------------------------- + /** All courses, alphabetical by name. */ + listCourses(): Promise; + + /** Validate + create a course. */ + createCourse(draft: CourseDraft): Promise; + + /** Patch a course's fields. Rejects if absent. */ + updateCourse(id: string, patch: Partial): Promise; + + /** Delete a course; its transcripts are reassigned to Unsorted (courseId=null). */ + deleteCourse(id: string): Promise; + + // --- media (persisted source audio) ------------------------------------- + /** Store (or replace) the source audio for a transcript. */ + putMedia(transcriptId: string, input: MediaInput): Promise; + + /** + * 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; + + /** Delete the persisted audio for a transcript. No-op if absent. */ + removeMedia(transcriptId: string): Promise; } diff --git a/src/lib/db/repo.web.ts b/src/lib/db/repo.web.ts index 5e733a2..be1e752 100644 --- a/src/lib/db/repo.web.ts +++ b/src/lib/db/repo.web.ts @@ -1,29 +1,45 @@ // Web storage implementation backed by Dexie (IndexedDB). // -// We keep ONE object store ('transcripts') keyed by id. Each record holds the -// full Transcript plus a derived, lowercased 'searchText' (title + all segment -// text) used for case-insensitive substring search. Search is done in-memory -// (filter over all rows) because IndexedDB has no native substring index; -// transcript counts are small enough on-device that this is fine. +// Schema (v2): +// transcripts: full Transcript + derived lowercase 'searchText' (title + all +// segment text), keyed by id, indexed by createdAt + courseId. +// courses: stored Course rows, indexed by name. +// media: persisted source audio { transcriptId, blob, mime }. +// +// Search is done in-memory (filter over all rows) because IndexedDB has no +// native substring index; transcript counts are small enough on-device that +// this is fine. searchText / blobs are never returned to callers. import Dexie from 'dexie'; import type { Segment } from '../types'; import { newId } from '../ids'; import { parseDraft, + parseCourseDraft, zSegment, type TranscriptDraft, type TranscriptMeta, type Transcript, + type TranscriptPatch, + type Course, + type CourseDraft, } from './schema'; -import type { StorageRepo } from './repo'; +import type { StorageRepo, MediaInput } from './repo'; -// The shape persisted in IndexedDB: a full Transcript plus the derived -// searchText column. searchText is never returned to callers. +// The shape persisted in the 'transcripts' store: a full Transcript plus the +// derived searchText column. searchText is never returned to callers. interface StoredTranscript extends Transcript { searchText: string; } +// The shape persisted in the 'media' store. The blob/mime never leak to +// callers; only a hasMedia flag on the transcript + an object URL are exposed. +interface StoredMedia { + transcriptId: string; + blob: Blob; + mime: string; +} + // --------------------------------------------------------------------------- // Pure derivation helpers (shared by create/update within this file) // --------------------------------------------------------------------------- @@ -35,6 +51,11 @@ function deriveSearchText(title: string, segments: Segment[]): string { return [title, ...segments.map((s) => s.text)].join('\n').toLowerCase(); } +/** Assign a stable id to every segment that lacks one (mutating a fresh copy). */ +function withSegmentIds(segments: Segment[]): Segment[] { + return segments.map((s) => (s.id ? s : { ...s, id: newId('s_') })); +} + /** Strip the derived searchText, returning the public Transcript view. */ function toTranscript(row: StoredTranscript): Transcript { const { searchText: _searchText, ...rest } = row; @@ -52,16 +73,35 @@ function toMeta(row: StoredTranscript): TranscriptMeta { // --------------------------------------------------------------------------- class WispDexie extends Dexie { - // Only 'id' and 'createdAt' need to be indexed: id is the primary key, - // createdAt powers the newest-first ordering. searchText is matched by an - // in-memory filter, so it does not need an index. transcripts!: Dexie.Table; + courses!: Dexie.Table; + media!: Dexie.Table; constructor() { super('wisp'); + // v1: original single store. Kept so an existing on-disk v1 DB upgrades + // cleanly rather than being recreated (which would lose data). this.version(1).stores({ transcripts: 'id, createdAt', }); + // v2: add courseId index + courses/media stores, and backfill segment ids. + this.version(2) + .stores({ + transcripts: 'id, createdAt, courseId', + courses: 'id, name', + media: 'transcriptId', + }) + .upgrade(async (tx) => { + // Backfill: every pre-existing transcript gets stable ids on any + // segment that lacks one. courseId stays undefined => "Unsorted". + // No other data is touched, so existing transcripts keep everything. + await tx + .table('transcripts') + .toCollection() + .modify((row) => { + row.segments = withSegmentIds(row.segments); + }); + }); } } @@ -72,6 +112,7 @@ const db = new WispDexie(); // --------------------------------------------------------------------------- export const repo: StorageRepo = { + // --- transcripts --------------------------------------------------------- async list(): Promise { // Sort by createdAt then reverse for newest-first. const rows = await db.transcripts.orderBy('createdAt').reverse().toArray(); @@ -87,6 +128,8 @@ export const repo: StorageRepo = { // Validate+throw before doing anything else. const valid = parseDraft(draft); const now = Date.now(); + // Assign a stable id to every segment that lacks one before storing. + const segments = withSegmentIds(valid.segments); const stored: StoredTranscript = { id: newId('t_'), title: valid.title, @@ -96,28 +139,36 @@ export const repo: StorageRepo = { modelId: valid.modelId, // Preserve optional language only when present (avoid storing undefined). ...(valid.language !== undefined ? { language: valid.language } : {}), - segmentCount: valid.segments.length, - segments: valid.segments, - searchText: deriveSearchText(valid.title, valid.segments), + segmentCount: segments.length, + segments, + // Course/lecture metadata. courseId defaults to null ("Unsorted"). + courseId: valid.courseId ?? null, + ...(valid.lectureDate !== undefined ? { lectureDate: valid.lectureDate } : {}), + ...(valid.instructor !== undefined ? { instructor: valid.instructor } : {}), + ...(valid.location !== undefined ? { location: valid.location } : {}), + ...(valid.tags !== undefined ? { tags: valid.tags } : {}), + ...(valid.lectureNumber !== undefined + ? { lectureNumber: valid.lectureNumber } + : {}), + hasMedia: false, + searchText: deriveSearchText(valid.title, segments), }; await db.transcripts.put(stored); return toTranscript(stored); }, - async update( - id: string, - patch: Partial>, - ): Promise { + async update(id: string, patch: TranscriptPatch): Promise { const existing = await db.transcripts.get(id); if (!existing) { throw new Error(`transcript not found: ${id}`); } // Re-validate segments on every write so an update can never persist - // non-finite times that a create() would have rejected. + // non-finite times that a create() would have rejected, and ensure every + // segment carries a stable id. const nextSegments = patch.segments !== undefined - ? patch.segments.map((s) => zSegment.parse(s)) + ? withSegmentIds(patch.segments.map((s) => zSegment.parse(s))) : existing.segments; const nextTitle = patch.title !== undefined ? patch.title : existing.title; @@ -125,6 +176,15 @@ export const repo: StorageRepo = { ...existing, title: nextTitle, segments: nextSegments, + // Apply only the provided course/lecture meta fields. + ...(patch.courseId !== undefined ? { courseId: patch.courseId } : {}), + ...(patch.lectureDate !== undefined ? { lectureDate: patch.lectureDate } : {}), + ...(patch.instructor !== undefined ? { instructor: patch.instructor } : {}), + ...(patch.location !== undefined ? { location: patch.location } : {}), + ...(patch.tags !== undefined ? { tags: patch.tags } : {}), + ...(patch.lectureNumber !== undefined + ? { lectureNumber: patch.lectureNumber } + : {}), // Re-derive denormalized fields so search() and list() stay correct. segmentCount: nextSegments.length, searchText: deriveSearchText(nextTitle, nextSegments), @@ -135,7 +195,11 @@ export const repo: StorageRepo = { }, async remove(id: string): Promise { - 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 { @@ -147,4 +211,119 @@ export const repo: StorageRepo = { : rows; return matched.map(toMeta); }, + + async listByCourse(courseId: string | null): Promise { + 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 { + const existing = await db.transcripts.get(transcriptId); + if (!existing) return; + await db.transcripts.put({ + ...existing, + courseId, + updatedAt: Date.now(), + }); + }, + + // --- courses ------------------------------------------------------------- + async listCourses(): Promise { + // 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 { + 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): Promise { + 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 { + // 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 { + // 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 { + const row = await db.media.get(transcriptId); + if (!row) return undefined; + return URL.createObjectURL(row.blob); + }, + + async removeMedia(transcriptId: string): Promise { + 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 }); + } + }); + }, }; diff --git a/src/lib/db/schema.ts b/src/lib/db/schema.ts index 4ed4ca8..a25eb02 100644 --- a/src/lib/db/schema.ts +++ b/src/lib/db/schema.ts @@ -37,6 +37,8 @@ const zFiniteNonNeg = zFinite.refine((n) => n >= 0, { * - confidence: optional, finite and within [0, 1] */ export const zSegment = z.object({ + // Stable id assigned by the repo on write (engine output omits it). + id: z.string().optional(), start: zFiniteNonNeg, end: zFiniteNonNeg, text: z.string(), @@ -80,6 +82,15 @@ export const zTranscriptDraft = z.object({ modelId: zModelId, language: z.string().optional(), segments: z.array(zSegment), + // --- Phase 0 course/lecture metadata (all optional) --- + /** Owning course id, or null for "Unsorted". */ + courseId: z.string().nullable().optional(), + /** When the lecture happened, ms since epoch. */ + lectureDate: zFiniteNonNeg.optional(), + instructor: z.string().optional(), + location: z.string().optional(), + tags: z.array(z.string()).optional(), + lectureNumber: zFiniteNonNeg.optional(), }); /** Inferred TS type for a validated draft. */ @@ -93,6 +104,38 @@ export function parseDraft(input: unknown): TranscriptDraft { return zTranscriptDraft.parse(input); } +// --------------------------------------------------------------------------- +// Course (Phase 0) +// --------------------------------------------------------------------------- + +/** Unsaved course shape handed to createCourse(). */ +export const zCourseDraft = z.object({ + name: z.string().min(1), + code: z.string().optional(), + instructor: z.string().optional(), + term: z.string().optional(), + /** UI accent color (hex), optional. */ + color: z.string().optional(), +}); +export type CourseDraft = z.infer; + +/** A stored course. */ +export interface Course { + id: string; + name: string; + code?: string; + instructor?: string; + term?: string; + color?: string; + createdAt: number; + updatedAt: number; +} + +/** Validate an unknown input as a CourseDraft, throwing on violation. */ +export function parseCourseDraft(input: unknown): CourseDraft { + return zCourseDraft.parse(input); +} + // --------------------------------------------------------------------------- // Stored types (output of the repo) // --------------------------------------------------------------------------- @@ -114,9 +157,35 @@ export interface TranscriptMeta { language?: string; /** Number of segments in the body (denormalized for list views). */ segmentCount: number; + // --- Phase 0 course/lecture metadata --- + /** Owning course id; null/undefined => "Unsorted". */ + courseId?: string | null; + /** Lecture date, ms since epoch. */ + lectureDate?: number; + instructor?: string; + location?: string; + tags?: string[]; + lectureNumber?: number; + /** True when source audio has been persisted (so it can be replayed). */ + hasMedia?: boolean; } /** A full stored transcript: metadata plus the segment body. */ export interface Transcript extends TranscriptMeta { segments: Segment[]; } + +/** The mutable fields update() accepts. */ +export type TranscriptPatch = Partial< + Pick< + Transcript, + | 'title' + | 'segments' + | 'courseId' + | 'lectureDate' + | 'instructor' + | 'location' + | 'tags' + | 'lectureNumber' + > +>; diff --git a/src/lib/types.ts b/src/lib/types.ts index 0a4a470..a30886c 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -12,6 +12,12 @@ export const WHISPER_SAMPLE_RATE = 16000 as const; * pipeline offsets and stitches them into absolute time. */ export interface Segment { + /** + * Stable per-segment id, assigned by the storage layer on create()/update(). + * Engine output omits it; it is the durable anchor for citations, vectors, + * flashcards and calendar links (see ROADMAP Phase 0/1). + */ + id?: string; /** Inclusive start time in seconds. */ start: number; /** Exclusive end time in seconds. */ diff --git a/src/stores/coursesStore.ts b/src/stores/coursesStore.ts new file mode 100644 index 0000000..878c6dc --- /dev/null +++ b/src/stores/coursesStore.ts @@ -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; + createCourse: (draft: CourseDraft) => Promise; + rename: (id: string, name: string) => Promise; + remove: (id: string) => Promise; +} + +export const useCourses = create((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(); + }, +})); diff --git a/src/stores/transcribeStore.ts b/src/stores/transcribeStore.ts index 8aa715c..90b58ce 100644 --- a/src/stores/transcribeStore.ts +++ b/src/stores/transcribeStore.ts @@ -17,6 +17,8 @@ interface StartOptions { title?: string; language?: string; translate?: boolean; + courseId?: string | null; + lectureDate?: number; } interface TranscribeState { @@ -88,8 +90,22 @@ export const useTranscribe = create((set, get) => ({ modelId, language: opts?.language, segments, + courseId: opts?.courseId ?? null, + ...(opts?.lectureDate !== undefined ? { lectureDate: opts.lectureDate } : {}), }); + // Persist the source audio so it can be replayed after a reload. Best-effort: + // a quota/unsupported failure must not lose the transcript itself. + try { + await getRepo().putMedia(saved.id, { + data: input.data, + uri: input.uri, + mime: guessMime(input.name), + }); + } catch (e) { + console.warn('[wisp] could not persist source audio:', e); + } + set({ status: 'done', progress: 1, partial: segments, lastTranscriptId: saved.id }); return saved.id; } catch (err) { @@ -118,3 +134,14 @@ function defaultTitle(): string { const pad = (n: number) => String(n).padStart(2, '0'); return `Transcript ${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}`; } + +/** Best-effort MIME from a file name extension (for persisted media). */ +function guessMime(name?: string): string { + const ext = (name?.split('.').pop() || '').toLowerCase(); + const map: Record = { + 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'; +} diff --git a/src/stores/transcriptsStore.ts b/src/stores/transcriptsStore.ts index 7b4c0e6..3cc8977 100644 --- a/src/stores/transcriptsStore.ts +++ b/src/stores/transcriptsStore.ts @@ -1,31 +1,45 @@ -// Library state: the saved transcripts list + search, backed by the local -// StorageRepo (Dexie on web, expo-sqlite on native). UI/session state only — -// the record of truth lives in the repo. +// Library state: the saved transcripts list + search + course filter, backed by +// the local StorageRepo. UI/session state only — the repo is the record of truth. import { create } from 'zustand'; import { getRepo, type TranscriptMeta } from '@/lib/db'; +/** 'all' = every transcript · null = Unsorted · string = a specific course id. */ +export type CourseFilter = 'all' | null | string; + interface TranscriptsState { items: TranscriptMeta[]; loading: boolean; query: string; + courseFilter: CourseFilter; refresh: () => Promise; setQuery: (q: string) => Promise; + setCourseFilter: (f: CourseFilter) => Promise; remove: (id: string) => Promise; rename: (id: string, title: string) => Promise; + reassign: (id: string, courseId: string | null) => Promise; } export const useTranscripts = create((set, get) => ({ items: [], loading: false, query: '', + courseFilter: 'all', refresh: async () => { set({ loading: true }); try { const repo = getRepo(); const q = get().query.trim(); - const items = q ? await repo.search(q) : await repo.list(); + const cf = get().courseFilter; + let items: TranscriptMeta[]; + if (q) { + // Search across everything, then narrow to the active course filter. + items = await repo.search(q); + if (cf !== 'all') items = items.filter((t) => (t.courseId ?? null) === cf); + } else { + items = cf === 'all' ? await repo.list() : await repo.listByCourse(cf); + } set({ items }); } finally { set({ loading: false }); @@ -37,6 +51,11 @@ export const useTranscripts = create((set, get) => ({ await get().refresh(); }, + setCourseFilter: async (f) => { + set({ courseFilter: f }); + await get().refresh(); + }, + remove: async (id) => { await getRepo().remove(id); await get().refresh(); @@ -46,4 +65,9 @@ export const useTranscripts = create((set, get) => ({ await getRepo().update(id, { title }); await get().refresh(); }, + + reassign: async (id, courseId) => { + await getRepo().reassign(id, courseId); + await get().refresh(); + }, }));