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();
+ },
}));