97aee3e4b3
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>
642 lines
25 KiB
TypeScript
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' },
|
|
});
|