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
+1
View File
@@ -21,6 +21,7 @@ export default function RootLayout() {
<Stack.Screen name="courses" options={{ title: 'Courses' }} /> <Stack.Screen name="courses" options={{ title: 'Courses' }} />
<Stack.Screen name="study" options={{ title: 'Study' }} /> <Stack.Screen name="study" options={{ title: 'Study' }} />
<Stack.Screen name="quiz" options={{ title: 'Quiz' }} /> <Stack.Screen name="quiz" options={{ title: 'Quiz' }} />
<Stack.Screen name="bibliography" options={{ title: 'Bibliography' }} />
<Stack.Screen name="settings" options={{ title: 'Settings' }} /> <Stack.Screen name="settings" options={{ title: 'Settings' }} />
</Stack> </Stack>
<StatusBar style="auto" /> <StatusBar style="auto" />
+220
View File
@@ -0,0 +1,220 @@
import * as Linking from 'expo-linking';
import { Stack, useLocalSearchParams } from 'expo-router';
import { useCallback, useState } from 'react';
import { ActivityIndicator, Pressable, ScrollView, StyleSheet, 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 } from '@/lib/db';
import { downloadText } from '@/lib/download';
import {
detectCitations,
lookupLinksFor,
resolveCitation,
toBibtex,
toRis,
type Citation,
type LookupLinks,
type RefMetadata,
} from '@/lib/enrich';
const ACCENT = '#3c87f7';
/** A detected citation paired with its (best-effort) resolved metadata. */
interface BibEntry {
citation: Citation;
meta?: RefMetadata;
}
export default function BibliographyScreen() {
const theme = useTheme();
const { courseId } = useLocalSearchParams<{ courseId?: string }>();
const [entries, setEntries] = useState<BibEntry[] | null>(null);
const [building, setBuilding] = useState(false);
// Collect citations across the course's (or all) transcripts, then resolve
// each via the metadata APIs. Detection is local; lookups send only ids.
const build = useCallback(async () => {
setBuilding(true);
setEntries(null);
try {
const repo = getRepo();
const metas = courseId ? await repo.listByCourse(courseId) : await repo.list();
// Detect citations across every transcript body. Dedupe by kind+value so
// a reference cited in several lectures shows once.
const byKey = new Map<string, Citation>();
for (const m of metas) {
const full = await repo.get(m.id);
if (!full) continue;
for (const c of detectCitations(full.segments)) {
const key = `${c.kind}:${c.value}`;
if (!byKey.has(key)) byKey.set(key, c);
}
}
const citations = [...byKey.values()];
// Seed the list immediately (links work without lookups), then resolve.
setEntries(citations.map((citation) => ({ citation })));
const resolved = await Promise.all(
citations.map(async (citation) => {
try {
return { citation, meta: await resolveCitation(citation) };
} catch {
return { citation };
}
}),
);
setEntries(resolved);
} finally {
setBuilding(false);
}
}, [courseId]);
// Resolved metadata feeds the exporters; fall back to the raw citation text
// for entries that couldn't be looked up so nothing is silently dropped.
const exportable = (entries ?? [])
.map((e) => e.meta ?? fallbackMeta(e.citation))
.filter((m): m is RefMetadata => m !== undefined);
const exportBibtex = () => {
if (exportable.length === 0) return;
downloadText('bibliography.bib', 'application/x-bibtex', toBibtex(exportable));
};
const exportRis = () => {
if (exportable.length === 0) return;
downloadText('bibliography.ris', 'application/x-research-info-systems', toRis(exportable));
};
const openLink = (url: string) => {
void Linking.openURL(url);
};
return (
<ThemedView style={styles.fill}>
<Stack.Screen options={{ title: 'Bibliography' }} />
<ScrollView contentContainerStyle={styles.content}>
<ThemedText type="small" themeColor="textSecondary">
{courseId
? 'References detected across this courses lectures.'
: 'References detected across all lectures.'}
</ThemedText>
<View style={styles.row}>
<Pressable
onPress={() => void build()}
disabled={building}
style={({ pressed }) => [
styles.btn,
{ backgroundColor: ACCENT, opacity: building ? 0.6 : pressed ? 0.85 : 1 },
]}>
<ThemedText style={styles.btnText}>
{building ? 'Scanning…' : entries ? 'Re-scan' : 'Build bibliography'}
</ThemedText>
</Pressable>
{entries && entries.length > 0 && (
<>
<Pressable
onPress={exportBibtex}
style={({ pressed }) => [
styles.btn,
{ backgroundColor: theme.backgroundElement, opacity: pressed ? 0.7 : 1 },
]}>
<ThemedText type="smallBold">Export BibTeX</ThemedText>
</Pressable>
<Pressable
onPress={exportRis}
style={({ pressed }) => [
styles.btn,
{ backgroundColor: theme.backgroundElement, opacity: pressed ? 0.7 : 1 },
]}>
<ThemedText type="smallBold">Export RIS</ThemedText>
</Pressable>
</>
)}
</View>
{building && entries === null && <ActivityIndicator style={styles.pad} />}
{entries && entries.length === 0 && (
<ThemedText type="small" themeColor="textSecondary" style={styles.pad}>
No references found.
</ThemedText>
)}
{entries?.map((e, i) => {
const links = lookupLinksFor(e.citation, e.meta);
return (
<ThemedView key={`${e.citation.kind}:${e.citation.value}:${i}`} type="backgroundElement" style={styles.card}>
{e.meta?.title ? (
<ThemedText type="smallBold">{e.meta.title}</ThemedText>
) : (
<ThemedText type="smallBold">{e.citation.raw}</ThemedText>
)}
{(e.meta?.authors?.length || e.meta?.year) && (
<ThemedText type="small" themeColor="textSecondary">
{[e.meta?.authors?.join(', '), e.meta?.year].filter(Boolean).join(' · ')}
</ThemedText>
)}
<ThemedText type="small" themeColor="textSecondary">
{e.meta?.source ?? e.citation.kind}
</ThemedText>
<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>
</ThemedView>
);
})}
</ScrollView>
</ThemedView>
);
}
/** Minimal metadata so an unresolved citation still exports something useful. */
function fallbackMeta(c: Citation): RefMetadata {
return { title: c.raw, source: c.kind };
}
/** 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 },
content: { padding: Spacing.three, gap: Spacing.two, maxWidth: MaxContentWidth, width: '100%', alignSelf: 'center' },
row: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.two, alignItems: 'center' },
btn: { paddingHorizontal: Spacing.three, paddingVertical: Spacing.two, borderRadius: Spacing.two, alignItems: 'center' },
btnText: { color: '#fff', fontWeight: '700', fontSize: 14 },
card: { padding: Spacing.three, borderRadius: Spacing.three, gap: 2 },
linkChips: { flexDirection: 'row', flexWrap: 'wrap', gap: Spacing.one, marginTop: Spacing.one },
linkChip: { paddingHorizontal: Spacing.two, paddingVertical: 2, borderRadius: 999 },
pad: { paddingVertical: Spacing.four, textAlign: 'center' },
});
+6 -1
View File
@@ -1,4 +1,4 @@
import { Stack, useFocusEffect } from 'expo-router'; import { Link, Stack, useFocusEffect } from 'expo-router';
import { useCallback, useState } from 'react'; import { useCallback, useState } from 'react';
import { Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native'; import { Pressable, ScrollView, StyleSheet, TextInput, View } from 'react-native';
@@ -105,6 +105,11 @@ export default function CoursesScreen() {
</ThemedText> </ThemedText>
</View> </View>
<View style={styles.actions}> <View style={styles.actions}>
<Link href={{ pathname: '/bibliography', params: { courseId: c.id } }} asChild>
<Pressable hitSlop={8}>
<ThemedText type="small" themeColor="textSecondary">Bibliography</ThemedText>
</Pressable>
</Link>
<Pressable <Pressable
onPress={() => { onPress={() => {
setEditing(c.id); setEditing(c.id);
+214
View File
@@ -1,3 +1,4 @@
import * as Linking from 'expo-linking';
import { Link, Stack, useLocalSearchParams } from 'expo-router'; import { Link, Stack, useLocalSearchParams } from 'expo-router';
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { import {
@@ -16,6 +17,17 @@ import { MaxContentWidth, Spacing } from '@/constants/theme';
import { useTheme } from '@/hooks/use-theme'; import { useTheme } from '@/hooks/use-theme';
import { getRepo, type Transcript } from '@/lib/db'; import { getRepo, type Transcript } from '@/lib/db';
import { downloadText } from '@/lib/download'; 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 { EXPORT_META, formatTranscript, type ExportFormat } from '@/lib/export';
import { formatClock } from '@/lib/format'; import { formatClock } from '@/lib/format';
import { import {
@@ -54,6 +66,16 @@ export default function TranscriptScreen() {
const [creatingCards, setCreatingCards] = useState(false); const [creatingCards, setCreatingCards] = useState(false);
const [cardsAdded, setCardsAdded] = useState<number | null>(null); 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); const scrollRef = useRef<ScrollView | null>(null);
// Y offset of each rendered segment row, captured via onLayout, for scroll-to. // Y offset of each rendered segment row, captured via onLayout, for scroll-to.
const segmentYs = useRef<Record<number, number>>({}); 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 === undefined) return <Centered><ActivityIndicator /></Centered>;
if (transcript === null) if (transcript === null)
return ( return (
@@ -344,6 +403,129 @@ export default function TranscriptScreen() {
)} )}
</ThemedView> </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) => ( {segments.map((s, i) => (
<View <View
key={i} key={i}
@@ -404,6 +586,32 @@ function Centered({ children }: { children: React.ReactNode }) {
return <ThemedView style={[styles.fill, styles.centered]}>{children}</ThemedView>; 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({ const styles = StyleSheet.create({
fill: { flex: 1 }, fill: { flex: 1 },
centered: { alignItems: 'center', justifyContent: 'center' }, centered: { alignItems: 'center', justifyContent: 'center' },
@@ -419,6 +627,12 @@ const styles = StyleSheet.create({
aidSection: { gap: Spacing.one, marginTop: Spacing.one }, aidSection: { gap: Spacing.one, marginTop: Spacing.one },
aidLine: { flexDirection: 'row', gap: Spacing.two, alignItems: 'flex-start', paddingVertical: 2 }, aidLine: { flexDirection: 'row', gap: Spacing.two, alignItems: 'flex-start', paddingVertical: 2 },
flex: { flex: 1 }, 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' }, segRow: { flexDirection: 'row', gap: Spacing.two, paddingVertical: Spacing.one, paddingHorizontal: Spacing.two, borderRadius: Spacing.two, alignItems: 'flex-start' },
ts: { paddingTop: 4, minWidth: 52 }, ts: { paddingTop: 4, minWidth: 52 },
segText: { flex: 1, fontSize: 16, lineHeight: 24, padding: 0 }, segText: { flex: 1, fontSize: 16, lineHeight: 24, padding: 0 },
+78
View File
@@ -0,0 +1,78 @@
import { describe, it, expect } from 'vitest';
import { toBibtex, toRis } from './bib';
import type { RefMetadata } from './types';
describe('toBibtex', () => {
it('returns "" for []', () => {
expect(toBibtex([])).toBe('');
});
it('emits an @article with a first-author+year key', () => {
const refs: RefMetadata[] = [
{
title: 'Attention Is All You Need',
authors: ['Ashish Vaswani', 'Noam Shazeer'],
year: 2017,
source: 'Crossref',
url: 'https://doi.org/10.x',
},
];
const out = toBibtex(refs);
expect(out).toContain('@article{vaswani2017,');
expect(out).toContain('title = {Attention Is All You Need}');
expect(out).toContain('author = {Ashish Vaswani and Noam Shazeer}');
expect(out).toContain('year = {2017}');
expect(out).toContain('url = {https://doi.org/10.x}');
});
it('uses @misc and ref<index> key when no authors', () => {
const out = toBibtex([{ title: 'A book', source: 'Open Library' }]);
expect(out).toContain('@misc{ref0,');
expect(out).toContain('title = {A book}');
});
it('omits missing fields', () => {
const out = toBibtex([{ source: 'X' }]);
expect(out).toContain('@misc{ref0,');
expect(out).not.toContain('title =');
expect(out).not.toContain('year =');
});
it('brace-escapes special characters in values', () => {
const out = toBibtex([{ title: 'a {b} c', source: 'X' }]);
expect(out).toContain('title = {a \\{b\\} c}');
});
});
describe('toRis', () => {
it('returns "" for []', () => {
expect(toRis([])).toBe('');
});
it('emits a JOUR record with one AU line per author', () => {
const out = toRis([
{
title: 'T',
authors: ['A One', 'B Two'],
year: 2020,
source: 'X',
url: 'http://u',
},
]);
expect(out).toContain('TY - JOUR');
expect(out).toContain('TI - T');
expect(out).toContain('AU - A One');
expect(out).toContain('AU - B Two');
expect(out).toContain('PY - 2020');
expect(out).toContain('UR - http://u');
expect(out.split('\n')).toContain('ER - ');
});
it('uses GEN and omits missing fields', () => {
const out = toRis([{ source: 'X' }]);
expect(out).toContain('TY - GEN');
expect(out).not.toContain('TI -');
expect(out).not.toContain('AU -');
expect(out).toContain('ER - ');
});
});
+64
View File
@@ -0,0 +1,64 @@
// Pure bibliography serialization: BibTeX and RIS. No network, no I/O.
import type { RefMetadata } from './types';
/** Serialize references to BibTeX. Handles missing fields and []. */
export function toBibtex(refs: RefMetadata[]): string {
return refs.map((r, i) => bibEntry(r, i)).join('\n\n');
}
function bibEntry(r: RefMetadata, index: number): string {
const type = r.authors && r.authors.length ? 'article' : 'misc';
const key = bibKey(r, index);
const fields: string[] = [];
if (r.title) fields.push(` title = {${brace(r.title)}}`);
if (r.authors && r.authors.length) {
fields.push(` author = {${brace(r.authors.join(' and '))}}`);
}
if (r.year !== undefined) fields.push(` year = {${r.year}}`);
if (r.url) fields.push(` url = {${brace(r.url)}}`);
return `@${type}{${key},\n${fields.join(',\n')}\n}`;
}
function bibKey(r: RefMetadata, index: number): string {
const first = r.authors && r.authors[0];
if (first) {
// Last token of the first author's name, alphanumerics only.
const tokens = first.trim().split(/[\s,]+/);
const surname = (tokens[tokens.length - 1] ?? first)
.replace(/[^A-Za-z0-9]/g, '')
.toLowerCase();
if (surname) return `${surname}${r.year ?? ''}`;
}
return `ref${index}`;
}
/** Escape characters special to BibTeX brace-delimited values. */
function brace(s: string): string {
// Inside `{...}` we still must escape a stray backslash and the braces.
return s.replace(/\\/g, '\\\\').replace(/[{}]/g, (m) => `\\${m}`);
}
// ---------------------------------------------------------------------------
// RIS
// ---------------------------------------------------------------------------
/** Serialize references to RIS. Handles missing fields and []. */
export function toRis(refs: RefMetadata[]): string {
return refs.map(risEntry).join('\n\n');
}
function risEntry(r: RefMetadata): string {
const ty = r.authors && r.authors.length ? 'JOUR' : 'GEN';
const lines: string[] = [`TY - ${ty}`];
if (r.title) lines.push(`TI - ${r.title}`);
if (r.authors) {
for (const a of r.authors) lines.push(`AU - ${a}`);
}
if (r.year !== undefined) lines.push(`PY - ${r.year}`);
if (r.url) lines.push(`UR - ${r.url}`);
lines.push('ER - ');
return lines.join('\n');
}
+84
View File
@@ -0,0 +1,84 @@
import { describe, it, expect } from 'vitest';
import { detectCitations } from './citations';
import type { Segment } from '../types';
const seg = (start: number, text: string, id?: string): Segment => ({
id,
start,
end: start + 1,
text,
});
describe('detectCitations', () => {
it('returns [] for empty input', () => {
expect(detectCitations([])).toEqual([]);
});
it('extracts a bare DOI', () => {
const out = detectCitations([seg(0, 'See 10.1038/nphys1170 for details.')]);
const doi = out.find((c) => c.kind === 'doi');
expect(doi?.value).toBe('10.1038/nphys1170');
});
it('extracts a DOI from a doi.org URL and a doi: prefix', () => {
const a = detectCitations([seg(0, 'https://doi.org/10.1145/3292500.3330701')]);
expect(a[0]!.value).toBe('10.1145/3292500.3330701');
const b = detectCitations([seg(0, 'doi: 10.1000/xyz123')]);
expect(b[0]!.value).toBe('10.1000/xyz123');
});
it('trims trailing punctuation from a DOI', () => {
const out = detectCitations([seg(0, '(see 10.1038/nphys1170).')]);
expect(out[0]!.value).toBe('10.1038/nphys1170');
});
it('extracts an explicit arXiv id', () => {
const out = detectCitations([seg(0, 'Read arXiv:2103.00020 today.')]);
const ax = out.find((c) => c.kind === 'arxiv');
expect(ax?.value).toBe('2103.00020');
});
it('extracts a bare arXiv id', () => {
const out = detectCitations([seg(0, 'the paper 1706.03762 introduced it')]);
const ax = out.find((c) => c.kind === 'arxiv');
expect(ax?.value).toBe('1706.03762');
});
it('extracts ISBN-13 and ISBN-10, normalized without hyphens', () => {
const a = detectCitations([seg(0, 'ISBN 978-0-13-468599-1')]);
expect(a.find((c) => c.kind === 'isbn')?.value).toBe('9780134685991');
const b = detectCitations([seg(0, 'ISBN-10: 0-262-03384-4')]);
expect(b.find((c) => c.kind === 'isbn')?.value).toBe('0262033844');
});
it('extracts author-year (parenthesized)', () => {
const out = detectCitations([seg(0, 'As Vaswani (2017) showed,')]);
const ay = out.find((c) => c.kind === 'author-year');
expect(ay?.value).toBe('Vaswani (2017)');
});
it('extracts "Author et al., Year"', () => {
const out = detectCitations([seg(0, 'per Smith et al., 2021 this holds')]);
const ay = out.find((c) => c.kind === 'author-year');
expect(ay?.value).toBe('Smith et al. (2021)');
});
it('dedupes by value', () => {
const out = detectCitations([
seg(0, '10.1038/nphys1170'),
seg(1, 'again 10.1038/nphys1170'),
]);
expect(out.filter((c) => c.kind === 'doi')).toHaveLength(1);
});
it('tags sourceSegmentId and start', () => {
const out = detectCitations([seg(9, 'arXiv:2103.00020', 'segA')]);
expect(out[0]!.sourceSegmentId).toBe('segA');
expect(out[0]!.start).toBe(9);
});
it('does not treat plain prose years as citations', () => {
const out = detectCitations([seg(0, 'In 2017 the weather was nice.')]);
expect(out.filter((c) => c.kind === 'author-year')).toHaveLength(0);
});
});
+165
View File
@@ -0,0 +1,165 @@
// Pure citation extraction over lecture segments. Regex-only; no network.
// Detects DOIs, arXiv ids, ISBN-10/13, and "Author (Year)" mentions.
import type { Segment } from '../types';
import type { Citation } from './types';
/** Detect and normalize citations across all segments. Deduped by value. */
export function detectCitations(segments: Segment[]): Citation[] {
if (!segments.length) return [];
const out: Citation[] = [];
const seen = new Set<string>();
const push = (c: Citation) => {
const key = `${c.kind}:${c.value.toLowerCase()}`;
if (seen.has(key)) return;
seen.add(key);
out.push(c);
};
for (const seg of segments) {
const text = seg.text ?? '';
if (!text) continue;
for (const c of detectDois(text)) push(tag(c, seg));
for (const c of detectArxiv(text)) push(tag(c, seg));
for (const c of detectIsbn(text)) push(tag(c, seg));
for (const c of detectAuthorYear(text)) push(tag(c, seg));
}
return out;
}
function tag(
c: Omit<Citation, 'sourceSegmentId' | 'start'>,
seg: Segment,
): Citation {
return { ...c, sourceSegmentId: seg.id, start: seg.start };
}
// ---------------------------------------------------------------------------
// DOI
// ---------------------------------------------------------------------------
function detectDois(text: string): Omit<Citation, 'sourceSegmentId' | 'start'>[] {
const out: Omit<Citation, 'sourceSegmentId' | 'start'>[] = [];
// DOIs start with 10.<registrant>/<suffix>. Allow an optional doi: / URL prefix.
const re = /\b(?:doi:\s*|https?:\/\/(?:dx\.)?doi\.org\/)?(10\.\d{4,9}\/[^\s"<>]+)/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(text))) {
// Trim common trailing punctuation that isn't part of the DOI.
const value = m[1]!.replace(/[.,;)\]]+$/, '');
out.push({ kind: 'doi', value, raw: m[0] });
}
return out;
}
// ---------------------------------------------------------------------------
// arXiv
// ---------------------------------------------------------------------------
function detectArxiv(text: string): Omit<Citation, 'sourceSegmentId' | 'start'>[] {
const out: Omit<Citation, 'sourceSegmentId' | 'start'>[] = [];
// Explicit "arXiv:NNNN.NNNNN" (with optional version) — most reliable.
const reExplicit = /\barxiv:\s*(\d{4}\.\d{4,5}(?:v\d+)?)\b/gi;
let m: RegExpExecArray | null;
while ((m = reExplicit.exec(text))) {
out.push({ kind: 'arxiv', value: m[1]!, raw: m[0] });
}
// Bare "NNNN.NNNNN" — only when not already captured as part of an arXiv: hit.
const reBare = /(?<![\w.])(\d{4}\.\d{4,5}(?:v\d+)?)(?![\w.])/g;
while ((m = reBare.exec(text))) {
const value = m[1]!;
// Skip if this position is the tail of an explicit arXiv match.
const preceding = text.slice(Math.max(0, m.index - 8), m.index).toLowerCase();
if (preceding.includes('arxiv')) continue;
out.push({ kind: 'arxiv', value, raw: m[0] });
}
return out;
}
// ---------------------------------------------------------------------------
// ISBN
// ---------------------------------------------------------------------------
function detectIsbn(text: string): Omit<Citation, 'sourceSegmentId' | 'start'>[] {
const out: Omit<Citation, 'sourceSegmentId' | 'start'>[] = [];
// ISBN-13 (978/979 prefix) and ISBN-10, hyphens/spaces allowed internally.
const re =
/\bISBN(?:-1[03])?:?\s*((?:97[89][-\s]?)?(?:\d[-\s]?){9}[\dXx])\b/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text))) {
const value = m[1]!.replace(/[-\s]/g, '').toUpperCase();
if (value.length === 10 || value.length === 13) {
out.push({ kind: 'isbn', value, raw: m[0] });
}
}
return out;
}
// ---------------------------------------------------------------------------
// Author-year
// ---------------------------------------------------------------------------
function detectAuthorYear(
text: string,
): Omit<Citation, 'sourceSegmentId' | 'start'>[] {
const out: Omit<Citation, 'sourceSegmentId' | 'start'>[] = [];
// "Smith (2020)", "Smith and Jones (2019)", "Smith et al., 2021",
// "Smith et al. (2021)". Author = Capitalized word(s).
const re =
/\b([A-Z][a-z]+(?:[-\s][A-Z][a-z]+)*(?:\s+(?:and|&)\s+[A-Z][a-z]+)?(?:\s+et al\.?)?)\s*[,(]?\s*\(?(\b(?:18|19|20)\d{2})\)?/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text))) {
const author = stripLeadingStopword(m[1]!.trim().replace(/\s+/g, ' '));
const year = m[2]!;
// Require either a parenthesized year or an "et al"/"and" multi-author cue
// to avoid matching plain prose like "World War 2 1945".
const cue = m[0];
const looksLikeCitation =
/\(\s*(?:18|19|20)\d{2}\s*\)/.test(cue) ||
/et al/i.test(cue) ||
/\band\b|&/.test(cue);
if (!looksLikeCitation) continue;
const value = `${author} (${year})`;
out.push({ kind: 'author-year', value, raw: m[0].trim() });
}
return out;
}
// Common capitalized sentence-starters that can be mistaken for a surname when
// they immediately precede the real author (e.g. "As Vaswani (2017)").
const LEADING_STOPWORDS = new Set([
'As',
'In',
'By',
'See',
'Per',
'From',
'After',
'Before',
'And',
'The',
'This',
'That',
'These',
'Those',
'Both',
'When',
'While',
'Since',
]);
function stripLeadingStopword(author: string): string {
const tokens = author.split(' ');
// Only strip when something real remains and the next token is itself a name.
if (tokens.length > 1 && tokens[0] && LEADING_STOPWORDS.has(tokens[0])) {
return tokens.slice(1).join(' ');
}
return author;
}
+145
View File
@@ -0,0 +1,145 @@
import { describe, it, expect } from 'vitest';
import { detectEvents } from './dates';
import type { Segment } from '../types';
const seg = (start: number, text: string, id?: string): Segment => ({
id,
start,
end: start + 1,
text,
});
// Anchor = 2026-06-13 (Saturday) 00:00 UTC. All assertions derive from this.
const ANCHOR = Date.UTC(2026, 5, 13, 0, 0, 0, 0);
const utc = (y: number, mo: number, d: number, h = 0, mi = 0) =>
Date.UTC(y, mo, d, h, mi, 0, 0);
describe('detectEvents', () => {
it('returns [] for empty input', () => {
expect(detectEvents([], ANCHOR)).toEqual([]);
});
it('returns [] for non-finite anchor', () => {
expect(detectEvents([seg(0, 'exam tomorrow')], Number.NaN)).toEqual([]);
});
it('resolves ISO YYYY-MM-DD to UTC midnight', () => {
const out = detectEvents([seg(0, 'The exam is on 2026-06-20.')], ANCHOR);
expect(out).toHaveLength(1);
expect(out[0]!.dateMs).toBe(utc(2026, 5, 20));
expect(out[0]!.allDay).toBe(true);
expect(out[0]!.confidence).toBeGreaterThanOrEqual(0.9);
});
it('resolves "March 3" using the anchor year (no roll within 6mo)', () => {
const out = detectEvents([seg(0, 'deadline on March 3')], ANCHOR);
expect(out).toHaveLength(1);
// March 3 2026 is ~3mo before June 13 2026 -> stays in the anchor year.
expect(out[0]!.dateMs).toBe(utc(2026, 2, 3));
});
it('rolls a date more than 6 months before the anchor to next year', () => {
// Anchor late in the year: "January 5" resolves ~10mo back -> roll to next.
const novAnchor = Date.UTC(2026, 10, 1); // 2026-11-01
const out = detectEvents([seg(0, 'deadline on January 5')], novAnchor);
expect(out[0]!.dateMs).toBe(utc(2027, 0, 5));
});
it('keeps an explicit year even if far past', () => {
const out = detectEvents([seg(0, 'paper from March 3, 2020')], ANCHOR);
expect(out[0]!.dateMs).toBe(utc(2020, 2, 3));
});
it('resolves day-first "3 March"', () => {
const out = detectEvents([seg(0, 'submission due 20 July')], ANCHOR);
expect(out[0]!.dateMs).toBe(utc(2026, 6, 20));
});
it('does NOT roll a date within the next 6 months', () => {
const out = detectEvents([seg(0, 'exam on June 20')], ANCHOR);
expect(out[0]!.dateMs).toBe(utc(2026, 5, 20));
});
it('resolves "on the 14th" within the anchor month', () => {
const out = detectEvents([seg(0, 'assignment due on the 14th')], ANCHOR);
expect(out[0]!.dateMs).toBe(utc(2026, 5, 14));
});
it('resolves relative "tomorrow" from the anchor', () => {
const out = detectEvents([seg(0, 'quiz tomorrow')], ANCHOR);
expect(out[0]!.dateMs).toBe(utc(2026, 5, 14));
expect(out[0]!.confidence).toBeLessThan(0.9); // relative
});
it('resolves "today" to the anchor day', () => {
const out = detectEvents([seg(0, 'homework due today')], ANCHOR);
expect(out[0]!.dateMs).toBe(utc(2026, 5, 13));
});
it('resolves "next week" as +7 days', () => {
const out = detectEvents([seg(0, 'the exam is next week')], ANCHOR);
expect(out[0]!.dateMs).toBe(utc(2026, 5, 20));
});
it('resolves "in 3 days" and "in 2 weeks"', () => {
const a = detectEvents([seg(0, 'due in 3 days')], ANCHOR);
expect(a[0]!.dateMs).toBe(utc(2026, 5, 16));
const b = detectEvents([seg(0, 'due in 2 weeks')], ANCHOR);
expect(b[0]!.dateMs).toBe(utc(2026, 5, 27));
});
it('resolves "next Monday" to the Monday of the following week', () => {
// Anchor Sat 2026-06-13. Next Monday = 2026-06-22 (skip the 15th).
const out = detectEvents([seg(0, 'exam next Monday')], ANCHOR);
expect(out[0]!.dateMs).toBe(utc(2026, 5, 22));
});
it('captures a clock time and sets allDay=false', () => {
const out = detectEvents([seg(0, 'exam on June 20 at 3pm')], ANCHOR);
expect(out[0]!.allDay).toBe(false);
expect(out[0]!.dateMs).toBe(utc(2026, 5, 20, 15, 0));
});
it('parses 24h time "14:00"', () => {
const out = detectEvents([seg(0, 'meeting 2026-06-20 14:00')], ANCHOR);
expect(out[0]!.allDay).toBe(false);
expect(out[0]!.dateMs).toBe(utc(2026, 5, 20, 14, 0));
});
it('uses the surrounding clause as the title', () => {
const out = detectEvents(
[seg(0, 'Welcome everyone. The final exam is on June 20. See you.')],
ANCHOR,
);
expect(out[0]!.title.toLowerCase()).toContain('final exam');
});
it('boosts confidence when a deadline keyword is present', () => {
const withKw = detectEvents([seg(0, 'the exam is on June 20')], ANCHOR)[0]!;
const without = detectEvents([seg(0, 'we met on June 20')], ANCHOR)[0]!;
expect(withKw.confidence).toBeGreaterThan(without.confidence);
});
it('tags sourceSegmentId and start', () => {
const out = detectEvents([seg(42, 'exam on June 20', 'segX')], ANCHOR);
expect(out[0]!.sourceSegmentId).toBe('segX');
expect(out[0]!.start).toBe(42);
});
it('dedupes identical (dateMs + title)', () => {
const out = detectEvents(
[seg(0, 'exam on June 20'), seg(1, 'exam on June 20')],
ANCHOR,
);
expect(out).toHaveLength(1);
});
it('does not read the wall clock (output depends only on the anchor)', () => {
const a = detectEvents([seg(0, 'quiz tomorrow')], ANCHOR);
const other = Date.UTC(2030, 0, 1);
const b = detectEvents([seg(0, 'quiz tomorrow')], other);
expect(a[0]!.dateMs).toBe(utc(2026, 5, 14));
expect(b[0]!.dateMs).toBe(Date.UTC(2030, 0, 2));
});
});
+407
View File
@@ -0,0 +1,407 @@
// Pure deadline/date detection over lecture segments. NO wall-clock reads:
// every timestamp is derived from `anchorMs` (the lecture date) plus explicit
// year/month/day/hour components found in the text. Constructing Date objects
// from those explicit components is fine; calling Date.now() / new Date() with
// no args is NOT.
import type { Segment } from '../types';
import type { CandidateEvent } from './types';
const MONTHS: Record<string, number> = {
january: 0,
february: 1,
march: 2,
april: 3,
may: 4,
june: 5,
july: 6,
august: 7,
september: 8,
october: 9,
november: 10,
december: 11,
jan: 0,
feb: 1,
mar: 2,
apr: 3,
jun: 5,
jul: 6,
aug: 7,
sep: 8,
sept: 8,
oct: 9,
nov: 10,
dec: 11,
};
const WEEKDAYS: Record<string, number> = {
sunday: 0,
monday: 1,
tuesday: 2,
wednesday: 3,
thursday: 4,
friday: 5,
saturday: 6,
};
const DEADLINE_WORDS = [
'exam',
'deadline',
'due',
'assignment',
'midterm',
'final',
'quiz',
'submit',
'submission',
'project',
'homework',
'test',
];
const SIX_MONTHS_MS = 1000 * 60 * 60 * 24 * 30 * 6;
interface TimeOfDay {
hour: number;
minute: number;
}
/** A partial match produced by a detector before titling/dating. */
interface RawMatch {
/** Absolute resolved instant in ms. */
dateMs: number;
/** Whether a clock time was found (=> not all-day). */
hasTime: boolean;
/** Matched substring (for `raw`). */
matched: string;
/** Index of the match within the segment text (for clause extraction). */
index: number;
/** Base confidence before deadline-keyword boosting. */
baseConfidence: number;
}
/**
* Detect candidate dates/deadlines across all segments.
*
* @param segments lecture segments (text + timing).
* @param anchorMs the lecture date in ms; relative dates anchor here and
* absolute dates borrow this year when none is written.
*/
export function detectEvents(segments: Segment[], anchorMs: number): CandidateEvent[] {
if (!segments.length || !Number.isFinite(anchorMs)) return [];
const out: CandidateEvent[] = [];
const seen = new Set<string>();
for (const seg of segments) {
const text = seg.text ?? '';
if (!text) continue;
const matches = [
...detectIso(text, anchorMs),
...detectMonthName(text, anchorMs),
...detectOrdinalOnly(text, anchorMs),
...detectRelative(text, anchorMs),
];
for (const m of matches) {
const title = clauseAround(text, m.index, m.matched.length);
const hasDeadlineWord = DEADLINE_WORDS.some((w) =>
title.toLowerCase().includes(w),
);
let confidence = m.baseConfidence;
if (hasDeadlineWord) confidence = Math.min(1, confidence + 0.1);
else confidence = Math.max(0, confidence - 0.2);
const key = `${m.dateMs}|${title.toLowerCase()}`;
if (seen.has(key)) continue;
seen.add(key);
out.push({
title,
dateMs: m.dateMs,
allDay: !m.hasTime,
sourceSegmentId: seg.id,
start: seg.start,
raw: m.matched.trim(),
confidence: Number(confidence.toFixed(2)),
});
}
}
return out;
}
// ---------------------------------------------------------------------------
// Absolute detectors
// ---------------------------------------------------------------------------
/** ISO `YYYY-MM-DD`, optionally followed by a clock time. */
function detectIso(text: string, anchorMs: number): RawMatch[] {
const out: RawMatch[] = [];
const re = /\b(\d{4})-(\d{2})-(\d{2})\b/g;
let m: RegExpExecArray | null;
while ((m = re.exec(text))) {
const year = Number(m[1]);
const month = Number(m[2]) - 1;
const day = Number(m[3]);
if (month < 0 || month > 11 || day < 1 || day > 31) continue;
const time = findTimeNear(text, m.index + m[0].length);
const dateMs = buildUtc(year, month, day, time);
out.push({
dateMs,
hasTime: !!time,
matched: m[0],
index: m.index,
baseConfidence: 0.9,
});
}
return out;
}
/** `March 3`, `March 3, 2026`, `3 March`, `on March 3rd`. */
function detectMonthName(text: string, anchorMs: number): RawMatch[] {
const out: RawMatch[] = [];
const monthAlt = Object.keys(MONTHS).join('|');
// Month-first: "March 3", "March 3rd, 2026"
const reMD = new RegExp(
`\\b(${monthAlt})\\.?\\s+(\\d{1,2})(?:st|nd|rd|th)?(?:,?\\s+(\\d{4}))?`,
'gi',
);
let m: RegExpExecArray | null;
while ((m = reMD.exec(text))) {
const month = MONTHS[m[1]!.toLowerCase()];
const day = Number(m[2]);
if (month === undefined || day < 1 || day > 31) continue;
const explicitYear = m[3] ? Number(m[3]) : undefined;
pushResolved(out, text, m, month, day, explicitYear, anchorMs);
}
// Day-first: "3 March", "3rd March 2026"
const reDM = new RegExp(
`\\b(\\d{1,2})(?:st|nd|rd|th)?\\s+(${monthAlt})\\.?(?:,?\\s+(\\d{4}))?`,
'gi',
);
while ((m = reDM.exec(text))) {
const day = Number(m[1]);
const month = MONTHS[m[2]!.toLowerCase()];
if (month === undefined || day < 1 || day > 31) continue;
const explicitYear = m[3] ? Number(m[3]) : undefined;
pushResolved(out, text, m, month, day, explicitYear, anchorMs);
}
return out;
}
function pushResolved(
out: RawMatch[],
text: string,
m: RegExpExecArray,
month: number,
day: number,
explicitYear: number | undefined,
anchorMs: number,
): void {
const time = findTimeNear(text, m.index + m[0].length);
let year = explicitYear ?? utcYear(anchorMs);
let dateMs = buildUtc(year, month, day, time);
if (explicitYear === undefined && dateMs < anchorMs - SIX_MONTHS_MS) {
year += 1;
dateMs = buildUtc(year, month, day, time);
}
out.push({
dateMs,
hasTime: !!time,
matched: m[0],
index: m.index,
baseConfidence: 0.9,
});
}
/** `on the 14th` — day-of-month only, month/year from the anchor. */
function detectOrdinalOnly(text: string, anchorMs: number): RawMatch[] {
const out: RawMatch[] = [];
const re = /\bon the (\d{1,2})(?:st|nd|rd|th)\b/gi;
let m: RegExpExecArray | null;
while ((m = re.exec(text))) {
const day = Number(m[1]);
if (day < 1 || day > 31) continue;
const month = utcMonth(anchorMs);
const year = utcYear(anchorMs);
const time = findTimeNear(text, m.index + m[0].length);
let dateMs = buildUtc(year, month, day, time);
// "on the 14th" means the upcoming 14th; if already well past, roll a month.
if (dateMs < anchorMs - SIX_MONTHS_MS) {
dateMs = buildUtc(year + 1, month, day, time);
}
out.push({
dateMs,
hasTime: !!time,
matched: m[0],
index: m.index,
baseConfidence: 0.85,
});
}
return out;
}
// ---------------------------------------------------------------------------
// Relative detectors (anchored purely to anchorMs)
// ---------------------------------------------------------------------------
function detectRelative(text: string, anchorMs: number): RawMatch[] {
const out: RawMatch[] = [];
const dayMs = 1000 * 60 * 60 * 24;
const add = (offsetDays: number, m: RegExpExecArray) => {
const time = findTimeNear(text, m.index + m[0].length);
const dateMs = applyTime(anchorMs + offsetDays * dayMs, time);
out.push({
dateMs,
hasTime: !!time,
matched: m[0],
index: m.index,
baseConfidence: 0.6,
});
};
let m: RegExpExecArray | null;
const reToday = /\btoday\b/gi;
while ((m = reToday.exec(text))) add(0, m);
const reTomorrow = /\btomorrow\b/gi;
while ((m = reTomorrow.exec(text))) add(1, m);
const reNextWeek = /\bnext week\b/gi;
while ((m = reNextWeek.exec(text))) add(7, m);
// "in N days" / "in N weeks"
const reInN = /\bin (\d{1,3}) (day|days|week|weeks)\b/gi;
while ((m = reInN.exec(text))) {
const n = Number(m[1]);
const unit = m[2]!.toLowerCase();
const days = unit.startsWith('week') ? n * 7 : n;
add(days, m);
}
// "next Monday".."next Friday" (and weekend days)
const reNextDow = /\bnext (sunday|monday|tuesday|wednesday|thursday|friday|saturday)\b/gi;
while ((m = reNextDow.exec(text))) {
const target = WEEKDAYS[m[1]!.toLowerCase()]!;
const anchorDow = utcDow(anchorMs);
// Days until the target weekday in the FOLLOWING week (1..7, never 0).
let delta = (target - anchorDow + 7) % 7;
if (delta === 0) delta = 7;
delta += 7; // "next" => skip the current week's occurrence.
add(delta, m);
}
return out;
}
// ---------------------------------------------------------------------------
// Time-of-day extraction
// ---------------------------------------------------------------------------
/** Look for a clock time shortly after the date phrase (within ~16 chars). */
function findTimeNear(text: string, from: number): TimeOfDay | undefined {
const window = text.slice(from, from + 18);
return parseTime(window);
}
function parseTime(s: string): TimeOfDay | undefined {
// "at 3pm", "at 3:30 pm", "3pm"
const re12 = /\b(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)\b/i;
const m12 = re12.exec(s);
if (m12) {
let hour = Number(m12[1]);
const minute = m12[2] ? Number(m12[2]) : 0;
const mer = m12[3]!.toLowerCase();
if (hour === 12) hour = 0;
if (mer === 'pm') hour += 12;
if (hour >= 0 && hour <= 23 && minute >= 0 && minute <= 59) {
return { hour, minute };
}
}
// 24h "14:00", "at 09:30"
const re24 = /\b(?:at\s+)?([01]?\d|2[0-3]):([0-5]\d)\b/;
const m24 = re24.exec(s);
if (m24) {
return { hour: Number(m24[1]), minute: Number(m24[2]) };
}
return undefined;
}
// ---------------------------------------------------------------------------
// Pure UTC helpers (constructed only from explicit components / anchorMs)
// ---------------------------------------------------------------------------
function buildUtc(
year: number,
month: number,
day: number,
time: TimeOfDay | undefined,
): number {
return Date.UTC(year, month, day, time?.hour ?? 0, time?.minute ?? 0, 0, 0);
}
/** Replace the time-of-day on a relative instant, keeping its UTC calendar day. */
function applyTime(ms: number, time: TimeOfDay | undefined): number {
if (!time) return ms;
const d = new Date(ms);
return Date.UTC(
d.getUTCFullYear(),
d.getUTCMonth(),
d.getUTCDate(),
time.hour,
time.minute,
0,
0,
);
}
function utcYear(ms: number): number {
return new Date(ms).getUTCFullYear();
}
function utcMonth(ms: number): number {
return new Date(ms).getUTCMonth();
}
function utcDow(ms: number): number {
return new Date(ms).getUTCDay();
}
// ---------------------------------------------------------------------------
// Clause extraction for titles
// ---------------------------------------------------------------------------
/** Extract a short clause around the matched date as the event title. */
function clauseAround(text: string, index: number, length: number): string {
// Bound to the surrounding sentence by splitting on sentence punctuation.
const before = text.slice(0, index);
const after = text.slice(index + length);
const startBreak = Math.max(
before.lastIndexOf('. '),
before.lastIndexOf('? '),
before.lastIndexOf('! '),
before.lastIndexOf('; '),
before.lastIndexOf(', '),
);
const afterBreakRel = firstBreak(after);
const start = startBreak >= 0 ? startBreak + 2 : 0;
const end =
index + length + (afterBreakRel >= 0 ? afterBreakRel : after.length);
return text.slice(start, end).trim().replace(/\s+/g, ' ');
}
function firstBreak(s: string): number {
const idxs = ['. ', '? ', '! ', '; ', ', ']
.map((p) => s.indexOf(p))
.filter((i) => i >= 0);
if (!idxs.length) return -1;
return Math.min(...idxs);
}
+100
View File
@@ -0,0 +1,100 @@
import { describe, it, expect } from 'vitest';
import { toIcs } from './ics';
const utc = (y: number, mo: number, d: number, h = 0, mi = 0) =>
Date.UTC(y, mo, d, h, mi, 0, 0);
describe('toIcs', () => {
it('produces a valid empty calendar for []', () => {
const ics = toIcs([]);
expect(ics).toContain('BEGIN:VCALENDAR');
expect(ics).toContain('VERSION:2.0');
expect(ics).toContain('END:VCALENDAR');
expect(ics).not.toContain('BEGIN:VEVENT');
});
it('uses CRLF line endings', () => {
const ics = toIcs([]);
expect(ics).toContain('\r\n');
expect(ics.endsWith('\r\n')).toBe(true);
// No bare LF without a preceding CR.
expect(/[^\r]\n/.test(ics)).toBe(false);
});
it('emits an all-day VEVENT with VALUE=DATE', () => {
const ics = toIcs([
{ title: 'Final exam', dateMs: utc(2026, 5, 20), allDay: true },
]);
expect(ics).toContain('BEGIN:VEVENT');
expect(ics).toContain('DTSTART;VALUE=DATE:20260620');
expect(ics).toContain('SUMMARY:Final exam');
});
it('emits a timed VEVENT with a UTC DTSTART', () => {
const ics = toIcs([
{ title: 'Meeting', dateMs: utc(2026, 5, 20, 14, 30), allDay: false },
]);
expect(ics).toContain('DTSTART:20260620T143000Z');
});
it('derives DTSTAMP from the event dateMs', () => {
const ics = toIcs([
{ title: 'X', dateMs: utc(2026, 5, 20, 9, 0), allDay: true },
]);
expect(ics).toContain('DTSTAMP:20260620T090000Z');
});
it('UID is deterministic (no randomness) and stable across calls', () => {
const ev = { title: 'Exam', dateMs: utc(2026, 5, 20), allDay: true };
const a = toIcs([ev]);
const b = toIcs([ev]);
expect(a).toBe(b);
const uid = a.split('\r\n').find((l) => l.startsWith('UID:'));
expect(uid).toBeDefined();
expect(uid).toContain('@wisp');
});
it('changes UID when title or date changes', () => {
const a = toIcs([{ title: 'A', dateMs: utc(2026, 5, 20), allDay: true }]);
const b = toIcs([{ title: 'B', dateMs: utc(2026, 5, 20), allDay: true }]);
const uidA = a.split('\r\n').find((l) => l.startsWith('UID:'));
const uidB = b.split('\r\n').find((l) => l.startsWith('UID:'));
expect(uidA).not.toBe(uidB);
});
it('escapes comma, semicolon, backslash and newline in SUMMARY', () => {
const ics = toIcs([
{
title: 'a, b; c \\ d\ne',
dateMs: utc(2026, 5, 20),
allDay: true,
},
]);
const line = ics
.replace(/\r\n /g, '') // unfold first
.split('\r\n')
.find((l) => l.startsWith('SUMMARY:'));
expect(line).toBe('SUMMARY:a\\, b\\; c \\\\ d\\ne');
});
it('folds lines longer than 75 octets with a leading space', () => {
const long = 'X'.repeat(200);
const ics = toIcs([{ title: long, dateMs: utc(2026, 5, 20), allDay: true }]);
for (const line of ics.split('\r\n')) {
// Each physical line must be <=75 octets.
expect(Buffer.byteLength(line, 'utf8')).toBeLessThanOrEqual(75);
}
// Unfolding restores the SUMMARY content.
const unfolded = ics.replace(/\r\n /g, '');
expect(unfolded).toContain(`SUMMARY:${long}`);
});
it('emits one VEVENT per event', () => {
const ics = toIcs([
{ title: 'A', dateMs: utc(2026, 5, 20), allDay: true },
{ title: 'B', dateMs: utc(2026, 5, 21), allDay: true },
]);
const count = ics.split('BEGIN:VEVENT').length - 1;
expect(count).toBe(2);
});
});
+142
View File
@@ -0,0 +1,142 @@
// Pure RFC 5545 iCalendar serialization. Deterministic (no randomness, no
// wall-clock): UID and DTSTAMP are derived from the event itself, so the same
// input always yields byte-identical output. CRLF line endings, 75-octet
// folding.
interface IcsEvent {
title: string;
dateMs: number;
allDay: boolean;
}
/** Serialize events to a VCALENDAR string. Valid (and non-empty) even for []. */
export function toIcs(events: IcsEvent[]): string {
const lines: string[] = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Wisp//Enrich//EN',
'CALSCALE:GREGORIAN',
];
for (const ev of events) {
const stamp = formatUtc(ev.dateMs);
lines.push('BEGIN:VEVENT');
lines.push(`UID:${uidFor(ev)}`);
lines.push(`DTSTAMP:${stamp}`);
if (ev.allDay) {
lines.push(`DTSTART;VALUE=DATE:${formatDate(ev.dateMs)}`);
} else {
lines.push(`DTSTART:${stamp}`);
}
lines.push(`SUMMARY:${escapeText(ev.title)}`);
lines.push('END:VEVENT');
}
lines.push('END:VCALENDAR');
return lines.map(foldLine).join('\r\n') + '\r\n';
}
// ---------------------------------------------------------------------------
// Deterministic UID derivation (FNV-1a hash of title + dateMs)
// ---------------------------------------------------------------------------
function uidFor(ev: IcsEvent): string {
const h = fnv1a(`${ev.title}|${ev.dateMs}`);
return `${h}@wisp`;
}
function fnv1a(s: string): string {
let hash = 0x811c9dc5;
for (let i = 0; i < s.length; i++) {
hash ^= s.charCodeAt(i);
// 32-bit FNV prime multiply via shifts, kept unsigned.
hash = Math.imul(hash, 0x01000193) >>> 0;
}
return hash.toString(16).padStart(8, '0');
}
// ---------------------------------------------------------------------------
// Date formatting from dateMs only (UTC)
// ---------------------------------------------------------------------------
function formatDate(ms: number): string {
const d = new Date(ms);
return (
pad(d.getUTCFullYear(), 4) + pad(d.getUTCMonth() + 1) + pad(d.getUTCDate())
);
}
function formatUtc(ms: number): string {
const d = new Date(ms);
return (
pad(d.getUTCFullYear(), 4) +
pad(d.getUTCMonth() + 1) +
pad(d.getUTCDate()) +
'T' +
pad(d.getUTCHours()) +
pad(d.getUTCMinutes()) +
pad(d.getUTCSeconds()) +
'Z'
);
}
function pad(n: number, width = 2): string {
return String(n).padStart(width, '0');
}
// ---------------------------------------------------------------------------
// RFC 5545 text escaping + line folding
// ---------------------------------------------------------------------------
/** Escape backslash, semicolon, comma, and newlines per RFC 5545 §3.3.11. */
function escapeText(s: string): string {
return s
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r\n|\n|\r/g, '\\n');
}
/**
* Fold a content line to <=75 octets, continuation lines starting with a
* single space. UTF-8 aware: never splits a multi-byte character across the
* fold boundary, so every emitted line is valid UTF-8.
*/
function foldLine(line: string): string {
if (utf8Len(line) <= 75) return line;
const chunks: string[] = [];
// First line: 75 octets. Continuation lines: 74 octets (1 leading space).
let limit = 75;
let current = '';
let currentBytes = 0;
for (const ch of line) {
const w = utf8Len(ch);
if (currentBytes + w > limit) {
chunks.push(current);
current = '';
currentBytes = 0;
limit = 74;
}
current += ch;
currentBytes += w;
}
if (current) chunks.push(current);
return chunks.map((c, i) => (i === 0 ? c : ' ' + c)).join('\r\n');
}
/** Number of UTF-8 octets a string encodes to. */
function utf8Len(s: string): number {
let n = 0;
for (const ch of s) {
const cp = ch.codePointAt(0)!;
if (cp < 0x80) n += 1;
else if (cp < 0x800) n += 2;
else if (cp < 0x10000) n += 3;
else n += 4;
}
return n;
}
+11
View File
@@ -0,0 +1,11 @@
// Phase 4 enrichment barrel: pure detection/serialization + network lookups.
// Pure modules contain NO transcript text in any outbound URL; the network
// module sends only the bare identifier/topic. See each file's header.
export * from './types';
export * from './dates';
export * from './citations';
export * from './ics';
export * from './bib';
export * from './links';
export * from './lookup';
+77
View File
@@ -0,0 +1,77 @@
import { describe, it, expect } from 'vitest';
import { lookupLinksFor, bookLinksFor } from './links';
import type { Citation, RefMetadata } from './types';
const cite = (kind: Citation['kind'], value: string): Citation => ({
kind,
value,
raw: value,
});
describe('lookupLinksFor', () => {
it('builds a DOI resolver link', () => {
const l = lookupLinksFor(cite('doi', '10.1038/nphys1170'));
expect(l.doi).toBe('https://doi.org/10.1038%2Fnphys1170');
});
it('builds an arXiv abstract link', () => {
const l = lookupLinksFor(cite('arxiv', '1706.03762'));
expect(l.arxiv).toBe('https://arxiv.org/abs/1706.03762');
});
it('points openLibrary at the ISBN page for isbn citations', () => {
const l = lookupLinksFor(cite('isbn', '9780134685991'));
expect(l.openLibrary).toBe('https://openlibrary.org/isbn/9780134685991');
});
it('always includes wikipedia/googleBooks search links', () => {
const l = lookupLinksFor(cite('author-year', 'Vaswani (2017)'));
expect(l.wikipedia).toContain(
'https://en.wikipedia.org/wiki/Special:Search?search=',
);
expect(l.googleBooks).toContain('https://www.google.com/search?tbm=bks&q=');
});
it('always includes annasArchive and libgen search links', () => {
const l = lookupLinksFor(cite('doi', '10.1/x'));
expect(l.annasArchive).toContain('https://annas-archive.org/search?q=');
expect(l.libgen).toContain('https://libgen.is/search.php?req=');
});
it('prefers resolved metadata title for the search query, URL-encoded', () => {
const meta: RefMetadata = {
title: 'Attention Is All You Need',
source: 'Crossref',
};
const l = lookupLinksFor(cite('arxiv', '1706.03762'), meta);
const q = encodeURIComponent('Attention Is All You Need');
expect(l.wikipedia).toContain(q);
expect(l.annasArchive).toContain(q);
});
it('falls back to the citation value when no metadata title', () => {
const l = lookupLinksFor(cite('author-year', 'Smith et al. (2021)'));
const q = encodeURIComponent('Smith et al. (2021)');
expect(l.googleBooks).toContain(q);
});
});
describe('bookLinksFor', () => {
it('builds the full search set from a free-text query (no doi/arxiv)', () => {
const l = bookLinksFor('Introduction to Algorithms');
const q = encodeURIComponent('Introduction to Algorithms');
expect(l.doi).toBeUndefined();
expect(l.arxiv).toBeUndefined();
expect(l.openLibrary).toBe(`https://openlibrary.org/search?q=${q}`);
expect(l.wikipedia).toContain(q);
expect(l.googleBooks).toContain(q);
expect(l.annasArchive).toContain(q);
expect(l.libgen).toContain(q);
});
it('URL-encodes special characters', () => {
const l = bookLinksFor('C&A: a/b test');
expect(l.libgen).toContain(encodeURIComponent('C&A: a/b test'));
expect(l.libgen).not.toContain(' ');
});
});
+47
View File
@@ -0,0 +1,47 @@
// Pure outbound-link builders. NO network here — these only construct URLs.
// Privacy: the only data placed in a URL is the extracted citation value or a
// resolved title — never transcript text. Anna's Archive / LibGen appear as
// SEARCH urls only; nothing in this app fetches or proxies them.
import type { Citation, LookupLinks, RefMetadata } from './types';
/** Build lookup links for a detected citation, optionally enriched by metadata. */
export function lookupLinksFor(c: Citation, meta?: RefMetadata): LookupLinks {
const query = (meta?.title || c.value).trim();
const links: LookupLinks = {
...searchLinks(query),
};
if (c.kind === 'doi') {
links.doi = `https://doi.org/${encodeURIComponent(c.value)}`;
}
if (c.kind === 'arxiv') {
links.arxiv = `https://arxiv.org/abs/${encodeURIComponent(c.value)}`;
}
if (c.kind === 'isbn') {
// Direct ISBN page beats a free-text Open Library search.
links.openLibrary = `https://openlibrary.org/isbn/${encodeURIComponent(
c.value,
)}`;
}
return links;
}
/** Build the same set of links from a free-text query (no DOI/arXiv). */
export function bookLinksFor(query: string): LookupLinks {
return searchLinks(query.trim());
}
/** The query-driven search links shared by both entry points. */
function searchLinks(query: string): LookupLinks {
const q = encodeURIComponent(query);
return {
wikipedia: `https://en.wikipedia.org/wiki/Special:Search?search=${q}`,
googleBooks: `https://www.google.com/search?tbm=bks&q=${q}`,
openLibrary: `https://openlibrary.org/search?q=${q}`,
annasArchive: `https://annas-archive.org/search?q=${q}`,
libgen: `https://libgen.is/search.php?req=${q}`,
};
}
+290
View File
@@ -0,0 +1,290 @@
// NETWORK metadata client for Phase 4 enrichment.
//
// PRIVACY: every request below sends ONLY the extracted citation id (DOI /
// arXiv id / ISBN) or the topic string — never any transcript text. Detection
// already happened locally; this module just resolves a bare identifier to
// public metadata.
//
// All endpoints are CORS-friendly, legitimate, metadata-only APIs (Crossref,
// OpenAlex, Open Library, Wikipedia REST). Their responses include the
// `Access-Control-Allow-Origin` header, so they are readable even under our
// COEP `require-corp` / COOP `same-origin` isolation (CORS responses satisfy
// require-corp without needing CORP headers). We never fetch or proxy any
// shadow-library content here — those are search-URL only and live in links.ts.
//
// Every function is defensive: ~8s AbortController timeout, try/catch that
// resolves to `undefined`, and never throws. Results are memoised in an
// in-memory Map for the session.
import type { Citation, RefMetadata } from './types';
/** Per-session metadata cache, keyed by `${kind}:${value}` / `wiki:${topic}`. */
const cache = new Map<string, RefMetadata | undefined>();
const wikiCache = new Map<
string,
{ title: string; extract: string; url: string } | undefined
>();
const TIMEOUT_MS = 8000;
/** GET JSON with an abort timeout. Returns parsed JSON or undefined on any
* failure (network, non-2xx, timeout, parse). Never throws. */
async function fetchJson(url: string): Promise<unknown | undefined> {
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS);
try {
const res = await fetch(url, {
method: 'GET',
headers: { Accept: 'application/json' },
signal: controller.signal,
});
if (!res.ok) return undefined;
return (await res.json()) as unknown;
} catch {
return undefined;
} finally {
clearTimeout(timer);
}
}
/** Narrow an unknown to a plain record for safe property access. */
function asRecord(v: unknown): Record<string, unknown> | undefined {
return typeof v === 'object' && v !== null
? (v as Record<string, unknown>)
: undefined;
}
function asString(v: unknown): string | undefined {
return typeof v === 'string' && v.length > 0 ? v : undefined;
}
function asNumber(v: unknown): number | undefined {
return typeof v === 'number' && Number.isFinite(v) ? v : undefined;
}
// ---------------------------------------------------------------------------
// Crossref (DOI)
// ---------------------------------------------------------------------------
function parseCrossref(json: unknown, value: string): RefMetadata | undefined {
const root = asRecord(json);
const message = asRecord(root?.message);
if (!message) return undefined;
const titleArr = Array.isArray(message.title) ? message.title : [];
const title = asString(titleArr[0]);
const authors: string[] = [];
if (Array.isArray(message.author)) {
for (const a of message.author) {
const rec = asRecord(a);
if (!rec) continue;
const given = asString(rec.given);
const family = asString(rec.family);
const name = [given, family].filter(Boolean).join(' ').trim();
if (name) authors.push(name);
}
}
// Year: prefer published, then published-print/online, then issued.
const year =
crossrefYear(message.published) ??
crossrefYear(message['published-print']) ??
crossrefYear(message['published-online']) ??
crossrefYear(message.issued);
return {
title,
authors: authors.length ? authors : undefined,
year,
source: 'Crossref',
url: `https://doi.org/${value}`,
};
}
/** Crossref date parts live at `<field>['date-parts'][0][0]`. */
function crossrefYear(field: unknown): number | undefined {
const rec = asRecord(field);
const parts = rec?.['date-parts'];
if (!Array.isArray(parts)) return undefined;
const first = parts[0];
if (!Array.isArray(first)) return undefined;
return asNumber(first[0]);
}
// ---------------------------------------------------------------------------
// OpenAlex (arXiv, and DOI fallback)
// ---------------------------------------------------------------------------
function parseOpenAlex(
json: unknown,
source: string,
url: string,
): RefMetadata | undefined {
const work = asRecord(json);
if (!work) return undefined;
const title = asString(work.title) ?? asString(work.display_name);
const authors: string[] = [];
if (Array.isArray(work.authorships)) {
for (const a of work.authorships) {
const rec = asRecord(a);
const author = asRecord(rec?.author);
const name = asString(author?.display_name);
if (name) authors.push(name);
}
}
const year = asNumber(work.publication_year);
// OpenAlex returns an error object (with no title) for unknown ids.
if (!title && !authors.length && year === undefined) return undefined;
return {
title,
authors: authors.length ? authors : undefined,
year,
source,
url,
};
}
// ---------------------------------------------------------------------------
// Open Library (ISBN)
// ---------------------------------------------------------------------------
function parseOpenLibrary(
json: unknown,
value: string,
): RefMetadata | undefined {
const book = asRecord(json);
if (!book) return undefined;
const title = asString(book.title);
// Open Library's /isbn/{}.json gives `by_statement` (a free-text credit)
// rather than structured authors; surface it as a single author when present.
const byStatement = asString(book.by_statement);
const authors = byStatement ? [byStatement] : undefined;
// publish_date is free-text (e.g. "March 2004"); pull a 4-digit year out.
let year: number | undefined;
const publishDate = asString(book.publish_date);
if (publishDate) {
const m = publishDate.match(/\b(\d{4})\b/);
if (m) year = asNumber(Number(m[1]));
}
if (!title && !authors && year === undefined) return undefined;
return {
title,
authors,
year,
source: 'Open Library',
url: `https://openlibrary.org/isbn/${value}`,
};
}
// ---------------------------------------------------------------------------
// Public API
// ---------------------------------------------------------------------------
/**
* Resolve a detected citation to public metadata.
*
* Sends ONLY the citation's identifier (DOI / arXiv id / ISBN) to a
* metadata-only API — never any transcript text. Returns `undefined` on any
* failure or for `author-year` citations (the UI offers search links instead).
* Never throws.
*/
export async function resolveCitation(
c: Citation,
): Promise<RefMetadata | undefined> {
const key = `${c.kind}:${c.value}`;
if (cache.has(key)) return cache.get(key);
let result: RefMetadata | undefined;
switch (c.kind) {
case 'doi': {
// Crossref is the authoritative DOI registry and is CORS-enabled.
const json = await fetchJson(
`https://api.crossref.org/works/${encodeURIComponent(c.value)}`,
);
result = parseCrossref(json, c.value);
// Fallback to OpenAlex if Crossref had nothing (e.g. DataCite DOI).
if (!result) {
const alt = await fetchJson(
`https://api.openalex.org/works/doi:${encodeURIComponent(c.value)}`,
);
result = parseOpenAlex(alt, 'OpenAlex', `https://doi.org/${c.value}`);
}
break;
}
case 'arxiv': {
// OpenAlex resolves arXiv ids over CORS, avoiding arXiv's
// non-CORS Atom export API.
const json = await fetchJson(
`https://api.openalex.org/works/arxiv:${encodeURIComponent(c.value)}`,
);
result = parseOpenAlex(
json,
'OpenAlex',
`https://arxiv.org/abs/${c.value}`,
);
break;
}
case 'isbn': {
const json = await fetchJson(
`https://openlibrary.org/isbn/${encodeURIComponent(c.value)}.json`,
);
result = parseOpenLibrary(json, c.value);
break;
}
case 'author-year':
// No reliable id to resolve; UI falls back to search links.
result = undefined;
break;
}
cache.set(key, result);
return result;
}
/**
* Fetch a one-paragraph Wikipedia summary for a topic.
*
* Sends ONLY the topic string to Wikipedia's REST summary endpoint (CORS-
* enabled) — never any transcript text. Returns `undefined` on 404 / any
* failure. Never throws.
*/
export async function wikipediaSummary(
topic: string,
): Promise<{ title: string; extract: string; url: string } | undefined> {
const key = `wiki:${topic}`;
if (wikiCache.has(key)) return wikiCache.get(key);
let result: { title: string; extract: string; url: string } | undefined;
const json = await fetchJson(
`https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(
topic,
)}`,
);
const root = asRecord(json);
if (root) {
const title = asString(root.title);
const extract = asString(root.extract);
const contentUrls = asRecord(root.content_urls);
const desktop = asRecord(contentUrls?.desktop);
const url = asString(desktop?.page);
if (title && extract && url) {
result = { title, extract, url };
}
}
wikiCache.set(key, result);
return result;
}
+55
View File
@@ -0,0 +1,55 @@
// Shared types for Phase 4 enrichment: dates -> calendar, references -> lookups.
// All detection is pure over Segment text; network lookups send only the
// extracted topic/DOI/ISBN string — never the transcript itself.
/** A detected date/deadline the user can confirm into their calendar. */
export interface CandidateEvent {
/** Short event title (the surrounding clause, trimmed). */
title: string;
/** Resolved absolute time, ms since epoch. */
dateMs: number;
/** All-day if no specific time was detected. */
allDay: boolean;
/** Source segment for jump-to-lecture. */
sourceSegmentId?: string;
/** Audio start time of the source segment. */
start?: number;
/** The matched text. */
raw: string;
/** 0..1 detection confidence (relative dates are lower). */
confidence: number;
}
/** A detected citation/reference in the lecture. */
export interface Citation {
kind: 'doi' | 'arxiv' | 'isbn' | 'author-year';
/** Normalized id (DOI, arXiv id, ISBN-13/10) or the raw author-year string. */
value: string;
sourceSegmentId?: string;
start?: number;
raw: string;
}
/** Resolved metadata for a citation, from a legitimate metadata API. */
export interface RefMetadata {
title?: string;
authors?: string[];
year?: number;
/** Which API answered, e.g. 'Crossref' | 'OpenAlex' | 'Open Library'. */
source: string;
/** Canonical URL (DOI resolver, work page, etc.). */
url?: string;
}
/** Outbound links for finding a reference/book. Legit sources first; the
* shadow-library links (Anna's Archive / LibGen) are search URLs only. */
export interface LookupLinks {
doi?: string;
arxiv?: string;
openAlex?: string;
openLibrary?: string;
googleBooks?: string;
wikipedia?: string;
annasArchive?: string;
libgen?: string;
}