Files
wisp/src/app/transcript/[id].tsx
T
NilsBriggen 97aee3e4b3
CI / test (push) Successful in 16s
CI / build-apk (push) Has been skipped
CI / deploy-web (push) Successful in 29s
feat(phase4): enrichment — dates->calendar, citations->references, book/paper links
All detection pure/on-device; lookups send only the id/topic (never transcript):
- src/lib/enrich pure modules: dates (absolute + relative, anchored to lectureDate),
  citations (DOI/arXiv/ISBN/author-year), ics (RFC5545), bib (BibTeX/RIS), links
  (legit + Anna's Archive/LibGen search URLs) — 60 tests.
- lookup.ts: CORS-friendly metadata clients (Crossref/OpenAlex/Open Library) +
  Wikipedia summary; AbortController timeouts, session cache, never throws.
- UI: transcript "Dates & references" (add to calendar .ics, look up references +
  open DOI/OpenLibrary/Google Books/Wikipedia/Anna's Archive/LibGen); per-course
  Bibliography screen with BibTeX/RIS export.

Not going to app stores, so Anna's Archive/LibGen links are included per request
(search URLs only — never fetched/proxied). 275 tests green, 0 tsc errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-14 15:48:13 +02:00

642 lines
25 KiB
TypeScript

import * as Linking from 'expo-linking';
import { Link, Stack, useLocalSearchParams } from 'expo-router';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
ActivityIndicator,
Platform,
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 { getRepo, type Transcript } from '@/lib/db';
import { downloadText } from '@/lib/download';
import {
detectCitations,
detectEvents,
lookupLinksFor,
resolveCitation,
toIcs,
type CandidateEvent,
type Citation,
type LookupLinks,
type RefMetadata,
} from '@/lib/enrich';
import { EXPORT_META, formatTranscript, type ExportFormat } from '@/lib/export';
import { formatClock } from '@/lib/format';
import {
cardsFromGlossary,
glossary,
summarize,
type GlossaryEntry,
type SourcedSentence,
} from '@/lib/learn';
import type { Segment } from '@/lib/types';
import { useTranscribe } from '@/stores/transcribeStore';
const ACCENT = '#3c87f7';
export default function TranscriptScreen() {
const theme = useTheme();
const { id, t } = useLocalSearchParams<{ id: string; t?: string }>();
// Deep-link target time (seconds) from a search hit, if any.
const jumpTo = useMemo(() => {
const n = Number(t);
return Number.isFinite(n) && n >= 0 ? n : null;
}, [t]);
const [transcript, setTranscript] = useState<Transcript | null | undefined>(undefined);
const [title, setTitle] = useState('');
const [segments, setSegments] = useState<Segment[]>([]);
const [dirty, setDirty] = useState(false);
const [saving, setSaving] = useState(false);
const [currentTime, setCurrentTime] = useState(0);
// Study aids (deterministic, computed on demand — no storage, no model).
const [aids, setAids] = useState<{
summary: SourcedSentence[];
glossary: GlossaryEntry[];
} | null>(null);
const [creatingCards, setCreatingCards] = useState(false);
const [cardsAdded, setCardsAdded] = useState<number | null>(null);
// Dates & references (Phase 4) — detected on demand, nothing persisted.
// `enrichment === null` means "not scanned yet".
const [enrichment, setEnrichment] = useState<{
events: CandidateEvent[];
citations: Citation[];
} | null>(null);
// Per-citation resolved metadata + in-flight flags, keyed by list index.
const [refMeta, setRefMeta] = useState<Record<number, RefMetadata>>({});
const [refLoading, setRefLoading] = useState<Record<number, boolean>>({});
const scrollRef = useRef<ScrollView | null>(null);
// Y offset of each rendered segment row, captured via onLayout, for scroll-to.
const segmentYs = useRef<Record<number, number>>({});
// Ensure we only auto-jump once per (id, t) deep-link.
const jumpedRef = useRef(false);
// 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()
.get(id)
.then((t) => {
if (!alive) return;
setTranscript(t ?? null);
setTitle(t?.title ?? '');
setSegments(t?.segments ?? []);
});
return () => {
alive = false;
};
}, [id]);
// Web-only <audio> element for click-to-seek playback.
useEffect(() => {
if (Platform.OS !== 'web' || !audioUrl) return;
const el = new Audio(audioUrl);
audioRef.current = el;
const onTime = () => setCurrentTime(el.currentTime);
el.addEventListener('timeupdate', onTime);
return () => {
el.pause();
el.removeEventListener('timeupdate', onTime);
audioRef.current = null;
};
}, [audioUrl]);
const activeIndex = useMemo(
() => segments.findIndex((s) => currentTime >= s.start && currentTime < s.end),
[segments, currentTime],
);
const seek = (time: number) => {
const el = audioRef.current;
if (!el) return;
el.currentTime = time;
void el.play();
};
// Reset the one-shot jump guard whenever the deep-link target changes.
useEffect(() => {
jumpedRef.current = false;
}, [id, jumpTo]);
// Deep-link jump: once the transcript is loaded and we have a finite `t`,
// seek the audio (if present), highlight the matching segment, and scroll it
// into view. Works even without audio (scroll/highlight by time only).
useEffect(() => {
if (jumpTo === null || jumpedRef.current) return;
if (!transcript || segments.length === 0) return;
const target = segments.findIndex((s) => jumpTo >= s.start && jumpTo < s.end);
const idx = target >= 0 ? target : nearestIndex(segments, jumpTo);
if (idx < 0) return;
jumpedRef.current = true;
// Drive the highlight by time even if audio isn't available.
setCurrentTime(jumpTo);
if (audioRef.current) seek(jumpTo);
// Scroll once the row's Y offset has been measured (next frames).
const scrollToIdx = () => {
const y = segmentYs.current[idx];
if (y === undefined) return false;
scrollRef.current?.scrollTo({ y: Math.max(0, y - Spacing.four), animated: true });
return true;
};
if (!scrollToIdx()) {
// Layout may not be measured yet; retry on the next ticks.
const timers = [requestAnimationFrame(() => { if (!scrollToIdx()) setTimeout(scrollToIdx, 120); })];
return () => timers.forEach(cancelAnimationFrame);
}
}, [jumpTo, transcript, segments, audioUrl]);
const editSegment = (i: number, text: string) => {
setSegments((prev) => prev.map((s, idx) => (idx === i ? { ...s, text } : s)));
setDirty(true);
};
const save = async () => {
setSaving(true);
try {
await getRepo().update(id, { title, segments });
setDirty(false);
} finally {
setSaving(false);
}
};
const onExport = (fmt: ExportFormat) => {
const meta = EXPORT_META[fmt];
const content = formatTranscript(segments, fmt, { title });
const safeName = (title || 'transcript').replace(/[^\w.-]+/g, '_');
downloadText(`${safeName}.${meta.ext}`, meta.mime, content);
};
// Compute summary + glossary on demand (pure helpers, nothing persisted).
const generateAids = () => {
setAids({ summary: summarize(segments), glossary: glossary(segments) });
setCardsAdded(null);
};
// Seeds derived from the current glossary — drives the "Create N flashcards" label.
const cardSeeds = useMemo(
() => (aids ? cardsFromGlossary(aids.glossary) : []),
[aids],
);
const createCards = async () => {
if (cardSeeds.length === 0 || creatingCards) return;
setCreatingCards(true);
try {
const created = await getRepo().createFlashcards(
cardSeeds.map((s) => ({
transcriptId: id,
courseId: transcript?.courseId ?? null,
segmentId: s.segmentId,
start: s.start,
front: s.front,
back: s.back,
})),
);
setCardsAdded(created.length);
} finally {
setCreatingCards(false);
}
};
// Scan segments for deadlines + citations. Dates are anchored to the lecture
// date when known, else the transcript's creation time. Pure & local.
const scanEnrichment = () => {
const anchorMs = transcript?.lectureDate ?? transcript?.createdAt ?? Date.now();
setEnrichment({
events: detectEvents(segments, anchorMs),
citations: detectCitations(segments),
});
setRefMeta({});
setRefLoading({});
};
// Download a calendar file for one or more events.
const exportIcs = (events: CandidateEvent[]) => {
if (events.length === 0) return;
const ics = toIcs(
events.map((e) => ({ title: e.title, dateMs: e.dateMs, allDay: e.allDay })),
);
downloadText('event.ics', 'text/calendar', ics);
};
// Network lookup for a single citation (sends only the id string, never text).
const lookupRef = async (i: number, c: Citation) => {
if (refLoading[i]) return;
setRefLoading((prev) => ({ ...prev, [i]: true }));
try {
const meta = await resolveCitation(c);
if (meta) setRefMeta((prev) => ({ ...prev, [i]: meta }));
} finally {
setRefLoading((prev) => ({ ...prev, [i]: false }));
}
};
const openLink = (url: string) => {
void Linking.openURL(url);
};
if (transcript === undefined) return <Centered><ActivityIndicator /></Centered>;
if (transcript === null)
return (
<Centered>
<ThemedText type="small" themeColor="textSecondary">Transcript not found.</ThemedText>
</Centered>
);
return (
<ThemedView style={styles.fill}>
<Stack.Screen options={{ title: title || 'Transcript' }} />
<ScrollView ref={scrollRef} contentContainerStyle={styles.content}>
<TextInput
value={title}
onChangeText={(next) => {
setTitle(next);
setDirty(true);
}}
style={[styles.title, { color: theme.text }]}
placeholder="Title"
placeholderTextColor={theme.textSecondary}
/>
{!audioUrl && (
<ThemedText type="small" themeColor="textSecondary">
No source audio stored for this transcript playback unavailable.
</ThemedText>
)}
<View style={styles.exportRow}>
{(Object.keys(EXPORT_META) as ExportFormat[]).map((fmt) => (
<Pressable
key={fmt}
onPress={() => onExport(fmt)}
style={({ pressed }) => [styles.chip, { backgroundColor: theme.backgroundElement, opacity: pressed ? 0.7 : 1 }]}>
<ThemedText type="small">{EXPORT_META[fmt].label}</ThemedText>
</Pressable>
))}
</View>
{/* Study aids — deterministic helpers, computed on demand. */}
<ThemedView type="backgroundElement" style={styles.aidsCard}>
<ThemedText type="smallBold">Study aids</ThemedText>
<View style={styles.aidsRow}>
<Pressable
onPress={generateAids}
disabled={segments.length === 0}
style={({ pressed }) => [
styles.aidBtn,
{ backgroundColor: ACCENT, opacity: segments.length === 0 ? 0.5 : pressed ? 0.85 : 1 },
]}>
<ThemedText style={styles.aidBtnText}>
{aids ? 'Regenerate summary & glossary' : 'Generate summary & glossary'}
</ThemedText>
</Pressable>
<Link href={{ pathname: '/quiz', params: { id } }} asChild>
<Pressable
style={({ pressed }) => [
styles.aidBtn,
{ backgroundColor: theme.backgroundSelected, opacity: pressed ? 0.85 : 1 },
]}>
<ThemedText style={styles.aidBtnTextAlt}>Quiz this lecture</ThemedText>
</Pressable>
</Link>
</View>
{aids && (
<>
{aids.summary.length > 0 ? (
<View style={styles.aidSection}>
<ThemedText type="smallBold" themeColor="textSecondary">Summary</ThemedText>
{aids.summary.map((s, i) => (
<Pressable key={`sum-${i}`} onPress={() => seek(s.start)} hitSlop={4}>
<View style={styles.aidLine}>
<ThemedText type="code" themeColor="textSecondary" style={styles.ts}>
{formatClock(s.start)}
</ThemedText>
<ThemedText type="small" style={styles.flex}>{s.text}</ThemedText>
</View>
</Pressable>
))}
</View>
) : (
<ThemedText type="small" themeColor="textSecondary">No summary could be derived.</ThemedText>
)}
{aids.glossary.length > 0 ? (
<View style={styles.aidSection}>
<ThemedText type="smallBold" themeColor="textSecondary">Glossary</ThemedText>
{aids.glossary.map((g, i) => (
<Pressable key={`glo-${i}`} onPress={() => seek(g.start)} hitSlop={4}>
<View style={styles.aidLine}>
<ThemedText type="code" themeColor="textSecondary" style={styles.ts}>
{formatClock(g.start)}
</ThemedText>
<ThemedText type="small" style={styles.flex}>
<ThemedText type="smallBold">{g.term}</ThemedText>
{` — ${g.definition}`}
</ThemedText>
</View>
</Pressable>
))}
</View>
) : (
<ThemedText type="small" themeColor="textSecondary">No glossary terms found.</ThemedText>
)}
{cardSeeds.length > 0 && (
<Pressable
onPress={() => void createCards()}
disabled={creatingCards}
style={({ pressed }) => [
styles.aidBtn,
{ backgroundColor: ACCENT, opacity: creatingCards ? 0.6 : pressed ? 0.85 : 1 },
]}>
<ThemedText style={styles.aidBtnText}>
{creatingCards ? 'Adding…' : `Create ${cardSeeds.length} flashcards`}
</ThemedText>
</Pressable>
)}
{cardsAdded !== null && (
<ThemedText type="small" themeColor="textSecondary">
Added {cardsAdded} {cardsAdded === 1 ? 'card' : 'cards'}.
</ThemedText>
)}
</>
)}
</ThemedView>
{/* Dates & references — detected on demand (pure/local detection). */}
<ThemedView type="backgroundElement" style={styles.aidsCard}>
<ThemedText type="smallBold">Dates & references</ThemedText>
<Pressable
onPress={scanEnrichment}
disabled={segments.length === 0}
style={({ pressed }) => [
styles.aidBtn,
{ backgroundColor: ACCENT, opacity: segments.length === 0 ? 0.5 : pressed ? 0.85 : 1 },
]}>
<ThemedText style={styles.aidBtnText}>
{enrichment ? 'Re-scan for dates & references' : 'Scan for dates & references'}
</ThemedText>
</Pressable>
{enrichment && (
<>
{/* --- Dates --- */}
<View style={styles.aidSection}>
<View style={styles.refHeader}>
<ThemedText type="smallBold" themeColor="textSecondary">Dates</ThemedText>
{enrichment.events.length > 0 && (
<Pressable onPress={() => exportIcs(enrichment.events)} hitSlop={6}>
<ThemedText type="linkPrimary">Add all ({enrichment.events.length})</ThemedText>
</Pressable>
)}
</View>
{enrichment.events.length === 0 ? (
<ThemedText type="small" themeColor="textSecondary">No dates found.</ThemedText>
) : (
enrichment.events.map((e, i) => (
<View key={`evt-${i}`} style={styles.refRow}>
<Pressable
onPress={() => e.start !== undefined && seek(e.start)}
disabled={e.start === undefined}
hitSlop={4}
style={styles.flex}>
<ThemedText type="small">{e.title}</ThemedText>
<ThemedText type="small" themeColor="textSecondary">
{formatEventDate(e)}
{e.start !== undefined ? ` · ${formatClock(e.start)}` : ''}
</ThemedText>
</Pressable>
<Pressable onPress={() => exportIcs([e])} hitSlop={6}>
<ThemedText type="link" themeColor="textSecondary">Add to calendar</ThemedText>
</Pressable>
</View>
))
)}
</View>
{/* --- References --- */}
<View style={styles.aidSection}>
<ThemedText type="smallBold" themeColor="textSecondary">References</ThemedText>
{enrichment.citations.length === 0 ? (
<ThemedText type="small" themeColor="textSecondary">No references found.</ThemedText>
) : (
enrichment.citations.map((c, i) => {
const meta = refMeta[i];
const links = lookupLinksFor(c, meta);
return (
<View key={`cit-${i}`} style={styles.citCard}>
<View style={styles.refRow}>
<Pressable
onPress={() => c.start !== undefined && seek(c.start)}
disabled={c.start === undefined}
hitSlop={4}
style={styles.flex}>
<ThemedText type="small">{c.raw}</ThemedText>
{c.start !== undefined && (
<ThemedText type="code" themeColor="textSecondary">
{formatClock(c.start)}
</ThemedText>
)}
</Pressable>
<Pressable
onPress={() => void lookupRef(i, c)}
disabled={refLoading[i]}
hitSlop={6}>
{refLoading[i] ? (
<ActivityIndicator size="small" />
) : (
<ThemedText type="link" themeColor="textSecondary">
{meta ? 'Looked up' : 'Look up'}
</ThemedText>
)}
</Pressable>
</View>
{meta && (
<View style={styles.metaBox}>
{meta.title && <ThemedText type="smallBold">{meta.title}</ThemedText>}
{(meta.authors?.length || meta.year) && (
<ThemedText type="small" themeColor="textSecondary">
{[meta.authors?.join(', '), meta.year].filter(Boolean).join(' · ')}
</ThemedText>
)}
<ThemedText type="small" themeColor="textSecondary">{meta.source}</ThemedText>
</View>
)}
<View style={styles.linkChips}>
{linkEntries(links).map(([label, url]) => (
<Pressable
key={label}
onPress={() => openLink(url)}
style={({ pressed }) => [
styles.linkChip,
{ backgroundColor: theme.backgroundSelected, opacity: pressed ? 0.7 : 1 },
]}>
<ThemedText type="small">{label}</ThemedText>
</Pressable>
))}
</View>
</View>
);
})
)}
</View>
</>
)}
</ThemedView>
{segments.map((s, i) => (
<View
key={i}
onLayout={(e) => {
segmentYs.current[i] = e.nativeEvent.layout.y;
}}
style={[styles.segRow, i === activeIndex && { backgroundColor: theme.backgroundSelected }]}>
<Pressable onPress={() => seek(s.start)} hitSlop={6}>
<ThemedText type="code" themeColor="textSecondary" style={styles.ts}>
{formatClock(s.start)}
</ThemedText>
</Pressable>
<TextInput
value={s.text}
onChangeText={(next) => editSegment(i, next)}
multiline
style={[styles.segText, { color: theme.text }]}
/>
</View>
))}
{segments.length === 0 && (
<ThemedText type="small" themeColor="textSecondary">This transcript has no segments.</ThemedText>
)}
</ScrollView>
{dirty && (
<Pressable
onPress={() => void save()}
disabled={saving}
style={({ pressed }) => [styles.saveButton, { opacity: saving ? 0.6 : pressed ? 0.85 : 1 }]}>
<ThemedText style={styles.saveText}>{saving ? 'Saving…' : 'Save changes'}</ThemedText>
</Pressable>
)}
</ThemedView>
);
}
/** Index of the segment whose start is closest to `time` (fallback for jumps
* that don't land inside any segment's [start, end) window). */
function nearestIndex(segments: Segment[], time: number): number {
if (segments.length === 0) return -1;
let best = 0;
let bestDist = Infinity;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (!seg) continue;
const dist = Math.abs(seg.start - time);
if (dist < bestDist) {
bestDist = dist;
best = i;
}
}
return best;
}
function Centered({ children }: { children: React.ReactNode }) {
return <ThemedView style={[styles.fill, styles.centered]}>{children}</ThemedView>;
}
/** Localized date for a candidate event — date-only when it's an all-day item. */
function formatEventDate(e: CandidateEvent): string {
const d = new Date(e.dateMs);
return e.allDay
? d.toLocaleDateString()
: d.toLocaleString([], { dateStyle: 'medium', timeStyle: 'short' });
}
/** Ordered, labeled, defined link entries from a LookupLinks bag. */
function linkEntries(links: LookupLinks): [string, string][] {
const order: [keyof LookupLinks, string][] = [
['doi', 'DOI'],
['arxiv', 'arXiv'],
['openAlex', 'OpenAlex'],
['openLibrary', 'Open Library'],
['googleBooks', 'Google Books'],
['wikipedia', 'Wikipedia'],
['annasArchive', "Anna's Archive"],
['libgen', 'LibGen'],
];
return order
.map(([key, label]) => [label, links[key]] as const)
.filter((pair): pair is [string, string] => typeof pair[1] === 'string' && pair[1].length > 0)
.map(([label, url]) => [label, url]);
}
const styles = StyleSheet.create({
fill: { flex: 1 },
centered: { alignItems: 'center', justifyContent: 'center' },
content: { padding: Spacing.three, gap: Spacing.two, maxWidth: MaxContentWidth, width: '100%', alignSelf: 'center', paddingBottom: Spacing.six },
title: { fontSize: 22, fontWeight: '700', paddingVertical: Spacing.two },
exportRow: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.two, marginBottom: Spacing.two },
chip: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.one, borderRadius: 999 },
aidsCard: { padding: Spacing.three, borderRadius: Spacing.three, gap: Spacing.two, marginBottom: Spacing.two },
aidsRow: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.two },
aidBtn: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, borderRadius: Spacing.two, alignItems: 'center' },
aidBtnText: { color: '#fff', fontWeight: '700', fontSize: 14 },
aidBtnTextAlt: { fontWeight: '700', fontSize: 14 },
aidSection: { gap: Spacing.one, marginTop: Spacing.one },
aidLine: { flexDirection: 'row', gap: Spacing.two, alignItems: 'flex-start', paddingVertical: 2 },
flex: { flex: 1 },
refHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', gap: Spacing.two },
refRow: { flexDirection: 'row', gap: Spacing.two, alignItems: 'flex-start', paddingVertical: 2 },
citCard: { gap: Spacing.one, paddingVertical: Spacing.one },
metaBox: { gap: 2, paddingLeft: Spacing.two },
linkChips: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.one },
linkChip: { paddingHorizontal: Spacing.two, paddingVertical: 2, borderRadius: 999 },
segRow: { flexDirection: 'row', gap: Spacing.two, paddingVertical: Spacing.one, paddingHorizontal: Spacing.two, borderRadius: Spacing.two, alignItems: 'flex-start' },
ts: { paddingTop: 4, minWidth: 52 },
segText: { flex: 1, fontSize: 16, lineHeight: 24, padding: 0 },
saveButton: { position: 'absolute', bottom: Spacing.three, right: Spacing.three, left: Spacing.three, maxWidth: MaxContentWidth, alignSelf: 'center', backgroundColor: '#3c87f7', paddingVertical: Spacing.three, borderRadius: Spacing.three, alignItems: 'center' },
saveText: { color: '#fff', fontWeight: '700' },
});