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>
This commit is contained in:
@@ -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 },
|
||||
|
||||
Reference in New Issue
Block a user