feat(phase4): enrichment — dates->calendar, citations->references, book/paper links
CI / test (push) Successful in 16s
CI / build-apk (push) Has been skipped
CI / deploy-web (push) Successful in 29s

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>
This commit is contained in:
2026-06-14 15:48:13 +02:00
parent 40858e0025
commit 97aee3e4b3
17 changed files with 2106 additions and 1 deletions
+214
View File
@@ -1,3 +1,4 @@
import * as Linking from 'expo-linking';
import { Link, Stack, useLocalSearchParams } from 'expo-router';
import { useEffect, useMemo, useRef, useState } from 'react';
import {
@@ -16,6 +17,17 @@ 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 {
@@ -54,6 +66,16 @@ export default function TranscriptScreen() {
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>>({});
@@ -214,6 +236,43 @@ export default function TranscriptScreen() {
}
};
// 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 (
@@ -344,6 +403,129 @@ export default function TranscriptScreen() {
)}
</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}
@@ -404,6 +586,32 @@ 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' },
@@ -419,6 +627,12 @@ const styles = StyleSheet.create({
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 },