Compare commits

..

2 Commits

Author SHA1 Message Date
NilsBriggen 8db59d4bbe feat(phase0): courses, migrations, persisted audio, stable segment IDs, course-aware UI
CI / test (push) Successful in 16s
CI / build-apk (push) Has been skipped
CI / deploy-web (push) Successful in 29s
Data layer (Zod-gated, behind StorageRepo; web Dexie v2 + native expo-sqlite
user_version migrations, both with no-data-loss segment-id backfill):
- Course entity + courseId/lectureDate/instructor/location/tags/lectureNumber on
  transcripts; listCourses/createCourse/updateCourse/deleteCourse, listByCourse,
  reassign. Stable per-segment ids assigned on create/update + backfilled.
- Persisted source audio (Dexie blob / expo-file-system) via putMedia/getMediaUrl;
  the editor replays it after reload.

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 13:54:38 +02:00
NilsBriggen 30dd2d5b75 ci(apk): arm64-only, tag-triggered, persistent /wisp.apk via host mount
- android.Dockerfile: build arm64-v8a only (--max-workers=2) → ~48MB APK, ~4x
  less compile (the 4-ABI build timed out on the shared prod runner).
- ci.yml: build-apk runs only on version tags (v*), timeout 120m — normal pushes
  no longer peg the box; web still auto-deploys on every master push.
- wisp.compose.yml: mount /srv/wisp/wisp.apk read-only so the download survives
  web container rebuilds.

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