diff --git a/.gitignore b/.gitignore index 9320ece..557612f 100644 --- a/.gitignore +++ b/.gitignore @@ -42,3 +42,6 @@ playwright-report/ npm-debug.* yarn-debug.* yarn-error.* + +# Sandcastle agent work/logs +.sandcastle/ diff --git a/app/settings.tsx b/app/settings.tsx index c4e119b..e08078e 100644 --- a/app/settings.tsx +++ b/app/settings.tsx @@ -1,49 +1,24 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { - Alert, Platform, ScrollView, Text, TouchableOpacity, View, useWindowDimensions, -} from 'react-native'; +import { ScrollView, Text, TouchableOpacity, View, useWindowDimensions } from 'react-native'; import { Ionicon } from '../src/components/Ionicon'; -import { DEFAULT_DATA_SOURCES_SETTINGS, DataSourcesSettings, useDataSources } from '../src/context/data-sources'; +import { createDefaultDataSourceSettings, type DataSourcesSettings, useDataSources } from '../src/context/data-sources'; import { useSession } from '../src/context/session'; import { useColors, useUISettings } from '../src/context/ui-settings'; import { parseWithAI } from '../src/entities/ai-parser'; -import { UploadedFile, addUpload, getUploads, removeUpload, waitForUploadMutations } from '../src/entities/providers/file-upload'; import { Category, CATEGORIES } from '../src/settings/constants'; +import { useFilesSettingsCategory } from '../src/settings/files-settings-category'; import { AISection } from '../src/settings/renderers/AISection'; import { DataSection } from '../src/settings/renderers/DataSection'; import { DisplaySection } from '../src/settings/renderers/DisplaySection'; import { FilesSection } from '../src/settings/renderers/FilesSection'; import { VoiceSection } from '../src/settings/renderers/VoiceSection'; import { createStyles } from '../src/settings/styles'; +import { useVoiceSettingsCategory } from '../src/settings/voice-settings-category'; import { createAppDataWriteToken, - getAppDataItem, isAppDataWriteTokenCurrent, - resetStoredAppData, - setAppDataItem, } from '../src/storage/app-data'; -import { DEFAULT_STT_SETTINGS, STT_SETTINGS_KEY, STTSettings } from '../src/stt/index'; - -function confirmDeleteAllData(): Promise { - const message = 'This deletes uploads, pasted content, AI parsed files, saved settings, API keys, source URLs, cached SRD data, and the current session on this device.'; - - if (Platform.OS === 'web' && typeof window !== 'undefined') { - return Promise.resolve(window.confirm(`Delete all local app data?\n\n${message}`)); - } - - return new Promise((resolve) => { - Alert.alert( - 'Delete all local app data?', - message, - [ - { text: 'Cancel', style: 'cancel', onPress: () => resolve(false) }, - { text: 'Delete All Data', style: 'destructive', onPress: () => resolve(true) }, - ], - { cancelable: true, onDismiss: () => resolve(false) }, - ); - }); -} export default function SettingsScreen() { const C = useColors(); @@ -52,59 +27,45 @@ export default function SettingsScreen() { const styles = useMemo(() => createStyles(C, isWide), [C, isWide]); const [category, setCategory] = useState('display'); - const [sttSettings, setSttSettings] = useState(DEFAULT_STT_SETTINGS); - const [voiceSaved, setVoiceSaved] = useState(false); const [dataSaved, setDataSaved] = useState(false); - const [deleteAllPending, setDeleteAllPending] = useState(false); - const [deleteAllStatus, setDeleteAllStatus] = useState(''); const { cardSize, setCardSize, colorScheme, setColorScheme, resetUISettings } = useUISettings(); const { settings: ds, update: updateDs, bumpUploads, reset: resetDataSources } = useDataSources(); const { stop: stopSession } = useSession(); + const { + sttSettings, + setSttSettings, + saveVoice, + voiceSaved, + isWebSpeech, + resetVoiceSettings, + } = useVoiceSettingsCategory(); const [dsLocal, setDsLocal] = useState(ds); - const voiceSavedTimer = useRef | null>(null); const dataSavedTimer = useRef | null>(null); - - const [uploads, setUploads] = useState([]); - const [removingUploadId, setRemovingUploadId] = useState(null); - const [pasteFileName, setPasteFileName] = useState(''); - const [pasteContent, setPasteContent] = useState(''); const [aiContent, setAiContent] = useState(''); const [aiParsing, setAiParsing] = useState(false); const [aiResult, setAiResult] = useState(''); - const refreshUploads = useCallback(async () => { - setUploads(await getUploads()); - bumpUploads(); - }, [bumpUploads]); + const resetAfterDeleteAll = useCallback(() => { + resetDataSources(); + resetUISettings(); + resetVoiceSettings(); + setDsLocal(createDefaultDataSourceSettings()); + setAiContent(''); + setAiResult(''); + setDataSaved(false); + }, [resetDataSources, resetUISettings, resetVoiceSettings]); - useEffect(() => { - const token = createAppDataWriteToken(); - getAppDataItem(STT_SETTINGS_KEY, token).then((raw) => { - if (raw) { - try { - setSttSettings({ ...DEFAULT_STT_SETTINGS, ...(JSON.parse(raw) as Partial) }); - } catch (parseErr) { - console.warn('[dnd-ref] Failed to parse STT settings:', parseErr); - } - } - }); - refreshUploads(); - return () => { - if (voiceSavedTimer.current) clearTimeout(voiceSavedTimer.current); - if (dataSavedTimer.current) clearTimeout(dataSavedTimer.current); - }; - }, [refreshUploads]); + const filesCategory = useFilesSettingsCategory({ + bumpUploads, + stopSession, + onDeleteAllDataReset: resetAfterDeleteAll, + }); - useEffect(() => { setDsLocal(ds); }, [ds]); + useEffect(() => () => { + if (dataSavedTimer.current) clearTimeout(dataSavedTimer.current); + }, []); - const saveVoice = async () => { - const token = createAppDataWriteToken(); - const saved = await setAppDataItem(STT_SETTINGS_KEY, JSON.stringify(sttSettings), { token }); - if (!saved || !isAppDataWriteTokenCurrent(token)) return; - setVoiceSaved(true); - if (voiceSavedTimer.current) clearTimeout(voiceSavedTimer.current); - voiceSavedTimer.current = setTimeout(() => setVoiceSaved(false), 2000); - }; + useEffect(() => { setDsLocal(ds); }, [ds]); const saveData = async () => { await updateDs(dsLocal); @@ -113,67 +74,6 @@ export default function SettingsScreen() { dataSavedTimer.current = setTimeout(() => setDataSaved(false), 2000); }; - const pickFilesWeb = () => { - const input = document.createElement('input'); - input.type = 'file'; - input.multiple = true; - input.accept = '.md,.txt,.json'; - input.onchange = async () => { - const files = Array.from(input.files ?? []); - input.onchange = null; - await Promise.all(files.map((f) => f.text().then((text) => addUpload(f.name, text)))); - await refreshUploads(); - }; - input.click(); - }; - - const handlePasteAdd = async () => { - const name = pasteFileName.trim() || 'Pasted Content.md'; - if (!pasteContent.trim()) return; - await addUpload(name, pasteContent); - setPasteFileName(''); - setPasteContent(''); - await refreshUploads(); - }; - - const handleDeleteUpload = async (id: string) => { - setRemovingUploadId(id); - try { - await removeUpload(id); - await refreshUploads(); - } finally { - setRemovingUploadId((current) => (current === id ? null : current)); - } - }; - - const handleDeleteAllData = async () => { - const confirmed = await confirmDeleteAllData(); - if (!confirmed) return; - - setDeleteAllPending(true); - setDeleteAllStatus(''); - try { - stopSession(); - await resetStoredAppData({ beforeClear: waitForUploadMutations }); - resetDataSources(); - resetUISettings(); - setSttSettings(DEFAULT_STT_SETTINGS); - setDsLocal(DEFAULT_DATA_SOURCES_SETTINGS); - setUploads([]); - setPasteFileName(''); - setPasteContent(''); - setAiContent(''); - setAiResult(''); - setVoiceSaved(false); - setDataSaved(false); - setDeleteAllStatus('All local app data was deleted.'); - } catch (e: unknown) { - setDeleteAllStatus(`Delete failed: ${e instanceof Error ? e.message : String(e)}`); - } finally { - setDeleteAllPending(false); - } - }; - const handleAIParse = async () => { if (!aiContent.trim() || !dsLocal.aiApiKey) return; const token = createAppDataWriteToken(); @@ -183,9 +83,7 @@ export default function SettingsScreen() { const entities = await parseWithAI(aiContent, dsLocal.aiApiKey); if (!isAppDataWriteTokenCurrent(token)) return; const name = `AI Parsed ${new Date().toLocaleDateString()}.json`; - await addUpload(name, JSON.stringify(entities)); - if (!isAppDataWriteTokenCurrent(token)) return; - await refreshUploads(); + await filesCategory.saveUpload(name, JSON.stringify(entities)); if (!isAppDataWriteTokenCurrent(token)) return; setAiResult(`Found ${entities.length} entities. Saved as "${name}".`); setAiContent(''); @@ -196,8 +94,6 @@ export default function SettingsScreen() { } }; - const isWebSpeech = Platform.OS === 'web'; - const renderContent = () => { switch (category) { case 'display': @@ -209,18 +105,18 @@ export default function SettingsScreen() { case 'files': return ( ); diff --git a/package.json b/package.json index bea3f36..ffa74ab 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "proxy:dev": "wrangler dev --config workers/cors-proxy/wrangler.toml", "proxy:deploy": "wrangler deploy --config workers/cors-proxy/wrangler.toml", "lint": "eslint .", + "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest" }, diff --git a/src/components/CardGrid.tsx b/src/components/CardGrid.tsx index 20a6382..5740fb0 100644 --- a/src/components/CardGrid.tsx +++ b/src/components/CardGrid.tsx @@ -1,53 +1,14 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { Animated, ScrollView, StyleSheet, Text, View, useWindowDimensions } from 'react-native'; -import { EntityCard } from './EntityCard'; -import { CardState, useSession } from '../context/session'; -import { CARD_SIZE_CONFIGS, useColors, useUISettings } from '../context/ui-settings'; +import { useSession } from '../context/session'; +import { useColors, useUISettings } from '../context/ui-settings'; +import { computeReferenceCardLayout, type ReferenceCardPosition } from '../reference-card-layout'; import { Colors, F } from '../theme'; +import { EntityCard } from './EntityCard'; -const GRID_PAD = 5; -const CARD_MARGIN = 5; -const MIN_CARD_WIDTH = 230; -const MAX_CARD_WIDTH = 380; - -interface CardPos { x: number; y: number } interface AnimPair { left: Animated.Value; top: Animated.Value } -function computePositions( - cards: CardState[], - heights: Record, - columns: number, - cardWidth: number, - xOffset: number, -): { positions: Record; totalHeight: number } { - const colWidth = cardWidth + 2 * CARD_MARGIN; - - const rowHeights: number[] = []; - for (let i = 0; i < cards.length; i++) { - const row = Math.floor(i / columns); - const h = heights[cards[i].instanceId] ?? 200; - rowHeights[row] = Math.max(rowHeights[row] ?? 0, h); - } - - const rowY: number[] = [GRID_PAD]; - for (let r = 0; r < rowHeights.length; r++) { - rowY[r + 1] = rowY[r] + rowHeights[r]; - } - - const positions: Record = {}; - for (let i = 0; i < cards.length; i++) { - const col = i % columns; - const row = Math.floor(i / columns); - positions[cards[i].instanceId] = { - x: xOffset + GRID_PAD + col * colWidth, - y: rowY[row], - }; - } - - return { positions, totalHeight: rowY[rowHeights.length] + GRID_PAD }; -} - const SPRING_CONFIG = { friction: 22, tension: 55, useNativeDriver: false } as const; export function CardGrid() { @@ -55,23 +16,25 @@ export function CardGrid() { const { cards, status, pin, unpin, dismiss } = useSession(); const { cardSize } = useUISettings(); const { width, height: winHeight } = useWindowDimensions(); - const config = CARD_SIZE_CONFIGS[cardSize]; - const preferredColumns = width > winHeight ? config.landscapeCols : config.portraitCols; - const readableColumns = Math.max(1, Math.floor((width - 2 * GRID_PAD) / (MIN_CARD_WIDTH + 2 * CARD_MARGIN))); - const columns = Math.min(preferredColumns, readableColumns); - const gridWidth = Math.min(width, columns * (MAX_CARD_WIDTH + 2 * CARD_MARGIN) + 2 * GRID_PAD); - const xOffset = Math.max(0, (width - gridWidth) / 2); - const cardWidth = (gridWidth - 2 * GRID_PAD) / columns - 2 * CARD_MARGIN; const styles = useMemo(() => createStyles(C), [C]); const [cardHeights, setCardHeights] = useState>({}); const animRef = useRef>({}); - const prevPos = useRef>({}); - - const { positions: targets, totalHeight } = useMemo( - () => computePositions(cards, cardHeights, columns, cardWidth, xOffset), - [cards, cardHeights, columns, cardWidth, xOffset], + const prevPos = useRef>({}); + + const { + cardWidth, + positions: targets, + totalHeight, + } = useMemo( + () => computeReferenceCardLayout({ + cards, + measuredHeights: cardHeights, + viewport: { width, height: winHeight }, + cardSize, + }), + [cards, cardHeights, width, winHeight, cardSize], ); for (const [id, pos] of Object.entries(targets)) { diff --git a/src/components/EntityCard.tsx b/src/components/EntityCard.tsx index 810e377..883dc1e 100644 --- a/src/components/EntityCard.tsx +++ b/src/components/EntityCard.tsx @@ -1,18 +1,11 @@ import React, { useEffect, useMemo, useRef } from 'react'; import { Animated, Image, Platform, StyleSheet, Text, TouchableOpacity, View } from 'react-native'; -import { Ionicon } from './Ionicon'; -import { CardState } from '../context/session'; +import type { CardState } from '../context/session-types'; import { CARD_SIZE_CONFIGS, useColors, useUISettings } from '../context/ui-settings'; +import { deriveEntityCardPresentation } from '../entity-card-presentation'; import { Colors, F, typeAccent } from '../theme'; - -function parseBullets(summary: string): string[] { - return summary - .split(/(?<=[.!])\s+/) - .map((s) => s.replace(/[.!]$/, '').trim()) - .filter((s) => s.length > 0) - .slice(0, 5); -} +import { Ionicon } from './Ionicon'; interface Props { card: CardState; @@ -23,9 +16,8 @@ interface Props { } export function EntityCard({ card, width, onPin, onUnpin, onDismiss }: Props) { - const { entity, pinned } = card; const C = useColors(); - const color = typeAccent(entity.type, C); + const entityAccentColor = typeAccent(card.entity.type, C); const fadeIn = useRef(new Animated.Value(0)).current; const slideUp = useRef(new Animated.Value(8)).current; @@ -33,6 +25,21 @@ export function EntityCard({ card, width, onPin, onUnpin, onDismiss }: Props) { const { fontScale } = CARD_SIZE_CONFIGS[cardSize]; const styles = useMemo(() => createStyles(C), [C]); + const presentation = useMemo( + () => deriveEntityCardPresentation({ card, accentColor: entityAccentColor }), + [card, entityAccentColor], + ); + const { + actions: { dismiss: dismissAction, pinToggle: pinToggleAction }, + accentColor, + bulletMarker, + imageUri, + name, + pinned, + summaryBullets, + typeLabel, + } = presentation; + const onTogglePin = pinToggleAction.kind === 'unpin' ? onUnpin : onPin; useEffect(() => { const anim = Animated.parallel([ @@ -43,12 +50,10 @@ export function EntityCard({ card, width, onPin, onUnpin, onDismiss }: Props) { return () => anim.stop(); }, []); - const bullets = useMemo(() => parseBullets(entity.summary), [entity.summary]); - const webStyles = Platform.OS === 'web' ? { boxShadow: pinned - ? `0 0 0 1px ${color}48, 0 6px 28px ${color}20, 0 2px 8px #00000040` + ? `0 0 0 1px ${accentColor}48, 0 6px 28px ${accentColor}20, 0 2px 8px #00000040` : '0 2px 12px #00000030', } : {}; @@ -59,50 +64,50 @@ export function EntityCard({ card, width, onPin, onUnpin, onDismiss }: Props) { styles.card, { width, opacity: fadeIn, transform: [{ translateY: slideUp }] }, pinned && styles.cardPinned, - pinned && { borderColor: color + '40', borderLeftColor: color, borderLeftWidth: 3 }, + pinned && { borderColor: accentColor + '40', borderLeftColor: accentColor, borderLeftWidth: 3 }, webStyles as object, ]} > - + - + - {entity.name} + {name} - - {entity.type.toUpperCase()} + + {typeLabel} - + - {entity.image && ( + {imageUri && ( )} @@ -110,13 +115,13 @@ export function EntityCard({ card, width, onPin, onUnpin, onDismiss }: Props) { - + - {bullets.map((bullet, i) => ( + {summaryBullets.map((bullet, i) => ( - - {'>'} + + {bulletMarker} {bullet} diff --git a/src/context/card-stack.ts b/src/context/card-stack.ts new file mode 100644 index 0000000..7ee5518 --- /dev/null +++ b/src/context/card-stack.ts @@ -0,0 +1,70 @@ +import type { Entity } from '../entities'; +import type { CardState } from './session-types'; + +export const MAX_CARDS = 6; + +export function extractCard(cards: CardState[], instanceId: string): [CardState, CardState[]] | null { + const card = cards.find((candidate) => candidate.instanceId === instanceId); + if (!card) return null; + + const remainingCards = cards.filter((candidate) => candidate.instanceId !== instanceId); + return [card, remainingCards]; +} + +export function buildCardIdSet(cards: CardState[]): Set { + return new Set(cards.map((card) => card.entity.id)); +} + +export function insertAfterPinned(cards: CardState[], card: CardState): CardState[] { + const lastPinnedIndex = cards.reduce((lastIndex, candidate, index) => { + if (!candidate.pinned) return lastIndex; + return index; + }, -1); + const nextCards = [...cards]; + nextCards.splice(lastPinnedIndex + 1, 0, card); + return nextCards; +} + +function findRightmostUnpinnedIndex(cards: CardState[]): number { + for (let index = cards.length - 1; index >= 0; index--) { + if (!cards[index].pinned) { + return index; + } + } + + return -1; +} + +export function addCard(cards: CardState[], entity: Entity): CardState[] { + const existingIds = buildCardIdSet(cards); + if (existingIds.has(entity.id)) return cards; + + const newCard: CardState = { instanceId: `${entity.id}-${Date.now()}`, entity, pinned: false }; + const nextCards = insertAfterPinned(cards, newCard); + + if (nextCards.length > MAX_CARDS) { + const evictionIndex = findRightmostUnpinnedIndex(nextCards); + if (evictionIndex === -1) return cards; + nextCards.splice(evictionIndex, 1); + } + + return nextCards; +} + +export function pinCard(cards: CardState[], instanceId: string): CardState[] { + const extracted = extractCard(cards, instanceId); + if (!extracted) return cards; + const [card, rest] = extracted; + return [{ ...card, pinned: true }, ...rest]; +} + +export function unpinCard(cards: CardState[], instanceId: string): CardState[] { + const extracted = extractCard(cards, instanceId); + if (!extracted) return cards; + const [card, rest] = extracted; + return insertAfterPinned(rest, { ...card, pinned: false }); +} + +export function dismissCard(cards: CardState[], instanceId: string): CardState[] { + return cards.filter((card) => card.instanceId !== instanceId); +} diff --git a/src/context/data-sources.tsx b/src/context/data-sources.tsx index 45ae699..0f25a38 100644 --- a/src/context/data-sources.tsx +++ b/src/context/data-sources.tsx @@ -1,39 +1,19 @@ -import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; +import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; import { - allowAppDataCacheWrites, - createAppDataWriteToken, - getAppDataItem, - isAppDataWriteTokenCurrent, - setAppDataItem, + createDefaultDataSourceSettings, + loadDataSourceSettings, + mergeDataSourceSettings, + saveDataSourceSettings, + type DataSourcesSettings, } from '../storage/app-data'; -import { DATA_SOURCES_KEY } from '../storage/keys'; export { DATA_SOURCES_KEY } from '../storage/keys'; - -export interface DataSourcesSettings { - srdEnabled: boolean; - srdSources: string[]; - kankaToken: string; - kankaCampaignId: string; - homebreweryUrl: string; - notionToken: string; - notionPageIds: string; - googleDocsUrl: string; - aiApiKey: string; -} - -export const DEFAULT_DATA_SOURCES_SETTINGS: DataSourcesSettings = { - srdEnabled: true, - srdSources: ['wotc-srd'], - kankaToken: '', - kankaCampaignId: '', - homebreweryUrl: '', - notionToken: '', - notionPageIds: '', - googleDocsUrl: '', - aiApiKey: '', -}; +export { + DEFAULT_DATA_SOURCES_SETTINGS, + createDefaultDataSourceSettings, + type DataSourcesSettings, +} from '../storage/app-data'; interface DataSourcesContextType { settings: DataSourcesSettings; @@ -46,47 +26,42 @@ interface DataSourcesContextType { const DataSourcesContext = createContext(null); export function DataSourcesProvider({ children }: { children: React.ReactNode }) { - const [settings, setSettings] = useState(DEFAULT_DATA_SOURCES_SETTINGS); + const [settings, setSettings] = useState(() => createDefaultDataSourceSettings()); + const latestSettings = useRef(settings); const [uploadsVersion, setUploadsVersion] = useState(0); - useEffect(() => { - const token = createAppDataWriteToken(); - getAppDataItem(DATA_SOURCES_KEY, token) - .then((raw) => { - if (raw) { - try { - setSettings({ ...DEFAULT_DATA_SOURCES_SETTINGS, ...(JSON.parse(raw) as Partial) }); - } catch (parseErr) { - console.warn('[dnd-ref] Failed to parse data source settings:', parseErr); - } - } - }) - .catch((e) => console.warn('[dnd-ref] Failed to load data source settings:', e)); + const replaceSettings = useCallback((nextSettings: DataSourcesSettings) => { + latestSettings.current = nextSettings; + setSettings(nextSettings); }, []); - async function update(patch: Partial) { - const token = createAppDataWriteToken(); - if (!isAppDataWriteTokenCurrent(token)) return; - let next!: DataSourcesSettings; - setSettings((prev) => { - next = { ...prev, ...patch }; - return next; - }); - const saved = await setAppDataItem(DATA_SOURCES_KEY, JSON.stringify(next), { token }).catch((e) => { - console.warn('[dnd-ref] Failed to save data source settings:', e); - return false; + useEffect(() => { + let mounted = true; + + loadDataSourceSettings().then((loadedSettings) => { + if (!mounted || !loadedSettings) return; + replaceSettings(loadedSettings); }); - if (saved) allowAppDataCacheWrites(); - } + + return () => { + mounted = false; + }; + }, [replaceSettings]); + + const update = useCallback(async (patch: Partial) => { + const nextSettings = mergeDataSourceSettings({ ...latestSettings.current, ...patch }); + replaceSettings(nextSettings); + await saveDataSourceSettings(nextSettings); + }, [replaceSettings]); const bumpUploads = useCallback(() => { setUploadsVersion((v) => v + 1); }, []); const reset = useCallback(() => { - setSettings(DEFAULT_DATA_SOURCES_SETTINGS); + replaceSettings(createDefaultDataSourceSettings()); setUploadsVersion((v) => v + 1); - }, []); + }, [replaceSettings]); return ( diff --git a/src/context/session-helpers.ts b/src/context/session-helpers.ts index 9cacd16..cc92fcb 100644 --- a/src/context/session-helpers.ts +++ b/src/context/session-helpers.ts @@ -1,61 +1,14 @@ -import { Entity } from '../entities'; -import { CardState } from './session-types'; - -export const MAX_CARDS = 6; export const DETECT_INTERVAL_MS = 2000; -export function extractCard(cards: CardState[], instanceId: string): [CardState, CardState[]] | null { - const card = cards.find((c) => c.instanceId === instanceId); - if (!card) return null; - return [card, cards.filter((c) => c.instanceId !== instanceId)]; -} - -export function buildCardIdSet(cards: CardState[]): Set { - return new Set(cards.map((c) => c.entity.id)); -} - -export function insertAfterPinned(cards: CardState[], card: CardState): CardState[] { - const lastPinnedIdx = cards.reduce((acc, c, i) => (c.pinned ? i : acc), -1); - const result = [...cards]; - result.splice(lastPinnedIdx + 1, 0, card); - return result; -} - -export function addCard(cards: CardState[], entity: Entity): CardState[] { - const existingIds = buildCardIdSet(cards); - if (existingIds.has(entity.id)) return cards; - - const newCard: CardState = { instanceId: `${entity.id}-${Date.now()}`, entity, pinned: false }; - const next = insertAfterPinned(cards, newCard); - - if (next.length > MAX_CARDS) { - let evictIdx = -1; - for (let i = next.length - 1; i >= 0; i--) { - if (!next[i].pinned) { evictIdx = i; break; } - } - if (evictIdx === -1) return cards; - next.splice(evictIdx, 1); - } - - return next; -} - -export function pinCard(cards: CardState[], instanceId: string): CardState[] { - const result = extractCard(cards, instanceId); - if (!result) return cards; - const [card, rest] = result; - return [{ ...card, pinned: true }, ...rest]; -} - -export function unpinCard(cards: CardState[], instanceId: string): CardState[] { - const result = extractCard(cards, instanceId); - if (!result) return cards; - const [card, rest] = result; - return insertAfterPinned(rest, { ...card, pinned: false }); -} - -export function dismissCard(cards: CardState[], instanceId: string): CardState[] { - return cards.filter((c) => c.instanceId !== instanceId); -} +export { + MAX_CARDS, + extractCard, + buildCardIdSet, + insertAfterPinned, + addCard, + pinCard, + unpinCard, + dismissCard, +} from './card-stack'; export { loadSettings, buildProvider } from '../stt/build-provider'; diff --git a/src/context/session-runtime.test.ts b/src/context/session-runtime.test.ts new file mode 100644 index 0000000..b039fb3 --- /dev/null +++ b/src/context/session-runtime.test.ts @@ -0,0 +1,295 @@ +import { describe, expect, it, vi } from 'vitest'; + +import type { Entity } from '../entities'; +import type { STTProvider, STTSettings } from '../stt'; +import { SessionRuntime, type SessionRuntimeDetector } from './session-runtime'; + +const TEST_STT_SETTINGS: STTSettings = { provider: 'web-speech', deepgramApiKey: '' }; + +function makeEntity(id: string, name: string): Entity { + return { + id, + name, + type: 'NPC', + aliases: [], + summary: `${name} summary`, + }; +} + +class FakeDetector implements SessionRuntimeDetector { + inputs: string[] = []; + + constructor(private readonly respond: (input: string) => Entity[]) {} + + detect(input: string): Entity[] { + this.inputs.push(input); + return this.respond(input); + } +} + +class FakeSTTProvider implements STTProvider { + readonly name = 'Fake STT'; + pauseCalls = 0; + resumeCalls = 0; + startCalls = 0; + stopCalls = 0; + resumeError: unknown = null; + startError: unknown = null; + + constructor( + private readonly onTranscript: (text: string) => void, + private readonly onError: (error: string) => void, + ) {} + + async start(): Promise { + this.startCalls += 1; + if (this.startError) throw this.startError; + } + + async pause(): Promise { + this.pauseCalls += 1; + } + + async resume(): Promise { + this.resumeCalls += 1; + if (this.resumeError) throw this.resumeError; + } + + async stop(): Promise { + this.stopCalls += 1; + } + + emitTranscript(text: string): void { + this.onTranscript(text); + } + + emitError(error: string): void { + this.onError(error); + } +} + +function makeRuntimeWithFakeStt( + configure?: (provider: FakeSTTProvider) => void, +): { providers: FakeSTTProvider[]; runtime: SessionRuntime } { + const providers: FakeSTTProvider[] = []; + const runtime = new SessionRuntime({ + loadSttSettings: async () => TEST_STT_SETTINGS, + buildSttProvider: (_settings, onTranscript, onError) => { + const provider = new FakeSTTProvider(onTranscript, onError); + configure?.(provider); + providers.push(provider); + return provider; + }, + detectIntervalMs: 0, + }); + return { providers, runtime }; +} + +function normalizeSpaces(value: string): string { + return value.replace(/\s+/g, ' ').trim(); +} + +describe('SessionRuntime', () => { + it('carries active transcript context so split entity names are detected', () => { + const redOakKeep = makeEntity('red-oak-keep', 'Red Oak Keep'); + const detector = new FakeDetector((input) => ( + normalizeSpaces(input).includes('Red Oak Keep') ? [redOakKeep] : [] + )); + const runtime = new SessionRuntime(); + + runtime.setDetector(detector); + runtime.activate(); + runtime.appendTranscript('The party reached Red'); + runtime.processTranscript(); + runtime.appendTranscript('Oak Keep before sunset'); + runtime.processTranscript(); + + const lastInput = detector.inputs[detector.inputs.length - 1]; + expect(normalizeSpaces(lastInput)).toContain('Red Oak Keep before sunset'); + expect(runtime.getSnapshot().cards.map((card) => card.entity.name)).toEqual(['Red Oak Keep']); + expect(runtime.getSnapshot().recentDetections).toEqual([redOakKeep]); + }); + + it('suppresses duplicate detections without replacing recent detections or cards', () => { + const valdrath = makeEntity('valdrath', 'Valdrath the Undying'); + const detector = new FakeDetector((input) => ( + input.includes('Valdrath') ? [valdrath] : [] + )); + const runtime = new SessionRuntime(); + + runtime.setDetector(detector); + runtime.activate(); + runtime.appendTranscript('Valdrath spoke first'); + runtime.processTranscript(); + const firstCards = runtime.getSnapshot().cards; + const firstRecentDetections = runtime.getSnapshot().recentDetections; + + runtime.appendTranscript('then Valdrath spoke again'); + runtime.processTranscript(); + + expect(runtime.getSnapshot().cards).toBe(firstCards); + expect(runtime.getSnapshot().cards).toHaveLength(1); + expect(runtime.getSnapshot().recentDetections).toBe(firstRecentDetections); + }); + + it('adds detected entities to the card stack and recent detections', () => { + const valdrath = makeEntity('valdrath', 'Valdrath the Undying'); + const malachar = makeEntity('malachar', 'Malachar the Grey'); + const detector = new FakeDetector(() => [valdrath, malachar]); + const runtime = new SessionRuntime(); + + runtime.setDetector(detector); + runtime.activate(); + runtime.appendTranscript('Valdrath summoned Malachar to the fortress'); + runtime.processTranscript(); + + expect(runtime.getSnapshot().cards.map((card) => card.entity.id).sort()).toEqual(['malachar', 'valdrath']); + expect(runtime.getSnapshot().cards).toHaveLength(2); + expect(runtime.getSnapshot().cards.every((card) => !card.pinned)).toBe(true); + expect(runtime.getSnapshot().recentDetections).toEqual([valdrath, malachar]); + }); + + it('runs detection from its own active interval and clears the interval when paused', () => { + vi.useFakeTimers(); + let runtime: SessionRuntime | null = null; + try { + const valdrath = makeEntity('valdrath', 'Valdrath the Undying'); + const seraphine = makeEntity('seraphine', 'Lady Seraphine Voss'); + const detector = new FakeDetector((input) => [ + ...(input.includes('Valdrath') ? [valdrath] : []), + ...(input.includes('Seraphine') ? [seraphine] : []), + ]); + runtime = new SessionRuntime({ detectIntervalMs: 100 }); + runtime.setDetector(detector); + + runtime.activate(); + runtime.appendTranscript('Valdrath watches from the throne'); + vi.advanceTimersByTime(100); + expect(runtime.getSnapshot().cards.map((card) => card.entity.id)).toEqual(['valdrath']); + + runtime.pause(); + runtime.appendTranscript('Seraphine entered while paused'); + vi.advanceTimersByTime(300); + expect(runtime.getSnapshot().cards.map((card) => card.entity.id)).toEqual(['valdrath']); + } finally { + runtime?.dispose(); + vi.useRealTimers(); + } + }); + + it('starts, pauses, resumes, gates speech, and stops through the runtime', async () => { + const valdrath = makeEntity('valdrath', 'Valdrath the Undying'); + const detector = new FakeDetector((input) => (input.includes('Valdrath') ? [valdrath] : [])); + const { providers, runtime } = makeRuntimeWithFakeStt(); + runtime.setDetector(detector); + + await runtime.start(); + const provider = providers[0]; + expect(runtime.getSnapshot()).toMatchObject({ + status: 'active', + sttStatus: 'active', + sttError: null, + sttProviderName: 'Fake STT', + }); + + provider.emitTranscript('Valdrath spoke first'); + runtime.processTranscript(); + expect(runtime.getSnapshot().cards.map((card) => card.entity.id)).toEqual(['valdrath']); + + runtime.pause(); + expect(provider.pauseCalls).toBe(1); + provider.emitTranscript('Malachar was only heard while paused'); + expect(runtime.getSnapshot().transcript).toBe('Valdrath spoke first'); + + await runtime.resume(); + expect(providers).toHaveLength(1); + expect(provider.resumeCalls).toBe(1); + provider.emitTranscript('Seraphine spoke after resume'); + expect(runtime.getSnapshot().transcript).toBe('Valdrath spoke first Seraphine spoke after resume'); + + runtime.stop(); + expect(provider.stopCalls).toBe(1); + expect(runtime.getSnapshot()).toMatchObject({ + status: 'idle', + sttStatus: 'idle', + sttError: null, + sttProviderName: '', + cards: [], + recentDetections: [], + transcript: '', + }); + provider.emitTranscript('late speech'); + provider.emitError('late error'); + expect(runtime.getSnapshot()).toMatchObject({ sttError: null, transcript: '' }); + }); + + it('reports start failures, clears provider state, and can retry', async () => { + let failNextStart = true; + const { providers, runtime } = makeRuntimeWithFakeStt((provider) => { + if (failNextStart) { + provider.startError = new Error('permission denied'); + failNextStart = false; + } + }); + + await runtime.start(); + expect(providers[0].startCalls).toBe(1); + expect(providers[0].stopCalls).toBe(1); + expect(runtime.getSnapshot()).toMatchObject({ + status: 'idle', + sttStatus: 'error', + sttError: 'Failed to start mic: permission denied', + sttProviderName: '', + }); + + await runtime.start(); + expect(providers).toHaveLength(2); + expect(providers[1].startCalls).toBe(1); + expect(runtime.getSnapshot()).toMatchObject({ status: 'active', sttStatus: 'active', sttError: null }); + }); + + it('reports resume failures, ignores stale speech, and creates a fresh provider on retry', async () => { + const { providers, runtime } = makeRuntimeWithFakeStt(); + await runtime.start(); + const provider = providers[0]; + runtime.pause(); + provider.resumeError = new Error('device lost'); + + await runtime.resume(); + expect(provider.resumeCalls).toBe(1); + expect(provider.stopCalls).toBe(1); + expect(runtime.getSnapshot()).toMatchObject({ + status: 'paused', + sttStatus: 'error', + sttError: 'Failed to resume mic: device lost', + }); + + provider.emitTranscript('stale speech after resume failure'); + expect(runtime.getSnapshot().transcript).toBe(''); + + await runtime.start(); + expect(providers).toHaveLength(2); + expect(runtime.getSnapshot()).toMatchObject({ status: 'active', sttStatus: 'active', sttError: null }); + }); + + it('handles provider error callbacks by pausing and allowing a fresh start', async () => { + const { providers, runtime } = makeRuntimeWithFakeStt(); + await runtime.start(); + const provider = providers[0]; + + provider.emitError('Mic error: audio-capture'); + + expect(provider.stopCalls).toBe(1); + expect(runtime.getSnapshot()).toMatchObject({ + status: 'paused', + sttStatus: 'error', + sttError: 'Mic error: audio-capture', + }); + provider.emitTranscript('speech after fatal error'); + expect(runtime.getSnapshot().transcript).toBe(''); + + await runtime.start(); + expect(providers).toHaveLength(2); + expect(runtime.getSnapshot()).toMatchObject({ status: 'active', sttStatus: 'active', sttError: null }); + }); +}); diff --git a/src/context/session-runtime.ts b/src/context/session-runtime.ts new file mode 100644 index 0000000..bee9524 --- /dev/null +++ b/src/context/session-runtime.ts @@ -0,0 +1,296 @@ +import type { Entity } from '../entities'; +import type { STTProvider, STTSettings } from '../stt'; +import { addCard, dismissCard, pinCard, unpinCard } from './card-stack'; +import { buildDetectionInput, nextDetectionContext } from './detection-window'; +import type { CardState, SessionStatus, SttStatus } from './session-types'; + +export interface SessionRuntimeDetector { + detect(transcript: string): Entity[]; +} + +export interface SessionRuntimeSnapshot { + status: SessionStatus; + sttStatus: SttStatus; + sttError: string | null; + sttProviderName: string; + cards: CardState[]; + transcript: string; + recentDetections: Entity[]; +} + +type SttSettingsLoader = () => Promise; +type SttProviderBuilder = ( + settings: STTSettings, + onTranscript: (text: string) => void, + onError: (error: string) => void, +) => STTProvider; + +export interface SessionRuntimeOptions { + loadSttSettings?: SttSettingsLoader; + buildSttProvider?: SttProviderBuilder; + detectIntervalMs?: number; +} + +type SessionRuntimeListener = (snapshot: SessionRuntimeSnapshot) => void; +type DetectionInterval = ReturnType; +type SnapshotPatch = Partial; + +export class SessionRuntime { + private acceptingTranscript = false; + private detector: SessionRuntimeDetector | null = null; + private detectionInterval: DetectionInterval | null = null; + private readonly detectIntervalMs: number; + private readonly buildSttProvider?: SessionRuntimeOptions['buildSttProvider']; + private readonly loadSttSettings?: SessionRuntimeOptions['loadSttSettings']; + private lastDetectionKey = ''; + private listeners = new Set(); + private previousDetectionContext = ''; + private processedTranscriptLength = 0; + private startInFlight: Promise | null = null; + private sttGeneration = 0; + private sttProvider: STTProvider | null = null; + private snapshot: SessionRuntimeSnapshot = { + status: 'idle', + sttStatus: 'idle', + sttError: null, + sttProviderName: '', + cards: [], + transcript: '', + recentDetections: [], + }; + + constructor(options: SessionRuntimeOptions = {}) { + this.loadSttSettings = options.loadSttSettings; + this.buildSttProvider = options.buildSttProvider; + this.detectIntervalMs = options.detectIntervalMs ?? 0; + } + + getSnapshot(): SessionRuntimeSnapshot { return this.snapshot; } + + subscribe(listener: SessionRuntimeListener): () => void { + this.listeners.add(listener); + return () => { this.listeners.delete(listener); }; + } + + setDetector(detector: SessionRuntimeDetector | null): void { this.detector = detector; } + + start(): Promise { + if (this.startInFlight) return this.startInFlight; + + const command = this.sttProvider + ? this.resumeExistingProvider(this.sttProvider, this.sttGeneration) + : this.startNewProvider(); + + this.startInFlight = command; + command.then( + () => this.clearStartInFlight(command), + () => this.clearStartInFlight(command), + ); + return command; + } + + resume(): Promise { return this.start(); } + activate(): void { this.setSessionActive(); } + + pause(): void { + this.acceptingTranscript = false; + const provider = this.sttProvider; + if (provider) void Promise.resolve(provider.pause()).catch(() => {}); + this.setSessionPaused({ sttStatus: 'idle' }); + } + + stop(): void { + const provider = this.invalidateStt(); + if (provider) void this.stopProvider(provider); + this.resetSession({ sttStatus: 'idle', sttError: null, sttProviderName: '' }); + } + + dispose(): void { + const provider = this.invalidateStt(); + this.clearDetectionInterval(); + if (provider) void this.stopProvider(provider); + this.listeners.clear(); + } + + appendTranscript(text: string): void { + const transcript = this.snapshot.transcript ? `${this.snapshot.transcript} ${text}` : text; + this.updateSnapshot({ transcript }); + } + + processTranscript(): void { + if (this.snapshot.status !== 'active') return; + if (!this.detector) return; + const newText = this.snapshot.transcript.slice(this.processedTranscriptLength); + if (!newText.trim()) return; + + const detectionInput = buildDetectionInput(this.previousDetectionContext, newText); + const detectedEntities = this.detector.detect(detectionInput); + this.processedTranscriptLength = this.snapshot.transcript.length; + this.previousDetectionContext = nextDetectionContext(this.snapshot.transcript); + if (detectedEntities.length === 0) return; + + let nextCards = this.snapshot.cards; + for (const entity of detectedEntities) nextCards = addCard(nextCards, entity); + const detectionKey = detectedEntities.map((entity) => entity.id).join(','); + const recentDetectionsChanged = detectionKey !== this.lastDetectionKey; + if (recentDetectionsChanged) this.lastDetectionKey = detectionKey; + + const cardsChanged = nextCards !== this.snapshot.cards; + if (!cardsChanged && !recentDetectionsChanged) return; + const snapshotPatch: SnapshotPatch = {}; + if (cardsChanged) snapshotPatch.cards = nextCards; + if (recentDetectionsChanged) snapshotPatch.recentDetections = detectedEntities; + this.updateSnapshot(snapshotPatch); + } + + pin(instanceId: string): void { + const cards = pinCard(this.snapshot.cards, instanceId); + if (cards !== this.snapshot.cards) this.updateSnapshot({ cards }); + } + + unpin(instanceId: string): void { + const cards = unpinCard(this.snapshot.cards, instanceId); + if (cards !== this.snapshot.cards) this.updateSnapshot({ cards }); + } + + dismiss(instanceId: string): void { + const cards = dismissCard(this.snapshot.cards, instanceId); + if (cards !== this.snapshot.cards) this.updateSnapshot({ cards }); + } + + private async startNewProvider(): Promise { + const generation = this.sttGeneration + 1; + this.sttGeneration = generation; + this.acceptingTranscript = false; + this.updateSnapshot({ sttStatus: 'connecting', sttError: null }); + + try { + if (!this.loadSttSettings || !this.buildSttProvider) throw new Error('STT provider factory not configured.'); + const settings = await this.loadSttSettings(); + if (this.sttGeneration !== generation) return; + const provider = this.buildSttProvider( + settings, + (text) => this.acceptProviderTranscript(text, generation), + (error) => this.handleProviderError(error, generation), + ); + this.sttProvider = provider; + this.updateSnapshot({ sttProviderName: provider.name }); + + await provider.start(); + if (this.sttGeneration !== generation || this.sttProvider !== provider) { + await this.stopProvider(provider); + return; + } + this.acceptingTranscript = true; + this.setSessionActive({ sttStatus: 'active', sttError: null }); + } catch (e) { + if (this.sttGeneration !== generation) return; + const provider = this.sttProvider; + if (provider) void this.stopProvider(provider); + this.sttProvider = null; + this.acceptingTranscript = false; + this.updateSnapshot({ + sttProviderName: '', + sttError: `Failed to start mic: ${this.formatError(e)}`, + sttStatus: 'error', + }); + } + } + + private async resumeExistingProvider(provider: STTProvider, generation: number): Promise { + try { + await Promise.resolve(provider.resume()); + if (this.sttGeneration !== generation || this.sttProvider !== provider) return; + this.acceptingTranscript = true; + this.setSessionActive({ sttStatus: 'active', sttError: null }); + } catch (e) { + if (this.sttGeneration !== generation || this.sttProvider !== provider) return; + this.acceptingTranscript = false; + await this.stopProvider(provider); + if (this.sttProvider === provider) this.sttProvider = null; + this.setSessionPaused({ + sttError: `Failed to resume mic: ${this.formatError(e)}`, + sttStatus: 'error', + }); + } + } + + private acceptProviderTranscript(text: string, generation: number): void { + if (this.sttGeneration === generation && this.acceptingTranscript) this.appendTranscript(text); + } + + private handleProviderError(error: string, generation: number): void { + if (this.sttGeneration !== generation) return; + this.acceptingTranscript = false; + const provider = this.sttProvider; + if (provider) void this.stopProvider(provider); + this.sttProvider = null; + const patch: SnapshotPatch = { sttError: error, sttStatus: 'error' }; + if (this.snapshot.status === 'active') this.setSessionPaused(patch); + else this.updateSnapshot(patch); + } + + private setSessionActive(patch: SnapshotPatch = {}): void { + this.processedTranscriptLength = this.snapshot.transcript.length; + this.previousDetectionContext = ''; + this.startDetectionInterval(); + this.updateSnapshot({ status: 'active', ...patch }); + } + + private setSessionPaused(patch: SnapshotPatch = {}): void { + this.previousDetectionContext = ''; + this.clearDetectionInterval(); + this.updateSnapshot({ status: 'paused', ...patch }); + } + + private resetSession(patch: SnapshotPatch = {}): void { + this.processedTranscriptLength = 0; + this.previousDetectionContext = ''; + this.lastDetectionKey = ''; + this.clearDetectionInterval(); + this.updateSnapshot({ status: 'idle', cards: [], transcript: '', recentDetections: [], ...patch }); + } + + private startDetectionInterval(): void { + this.clearDetectionInterval(); + if (this.detectIntervalMs <= 0) return; + this.detectionInterval = setInterval(() => this.processTranscript(), this.detectIntervalMs); + (this.detectionInterval as { unref?: () => void }).unref?.(); + } + + private clearDetectionInterval(): void { + if (!this.detectionInterval) return; + clearInterval(this.detectionInterval); + this.detectionInterval = null; + } + + private clearStartInFlight(command: Promise): void { + if (this.startInFlight === command) this.startInFlight = null; + } + + private invalidateStt(): STTProvider | null { + this.sttGeneration += 1; + this.startInFlight = null; + this.acceptingTranscript = false; + const provider = this.sttProvider; + this.sttProvider = null; + return provider; + } + + private async stopProvider(provider: STTProvider): Promise { + try { await Promise.resolve(provider.stop()); } catch {} + } + + private formatError(error: unknown): string { return error instanceof Error ? error.message : String(error); } + + private updateSnapshot(patch: SnapshotPatch): void { + if (Object.keys(patch).length === 0) return; + this.snapshot = { ...this.snapshot, ...patch }; + this.notifyListeners(); + } + + private notifyListeners(): void { + const snapshot = this.snapshot; + for (const listener of this.listeners) listener(snapshot); + } +} diff --git a/src/context/session.tsx b/src/context/session.tsx index a0493d8..524da9b 100644 --- a/src/context/session.tsx +++ b/src/context/session.tsx @@ -1,19 +1,6 @@ -import React, { createContext, useCallback, useContext, useEffect, useRef, useState } from 'react'; +import React, { createContext, useCallback, useContext, useEffect, useState } from 'react'; -import { Entity, EntityIndex, WorldDataProvider } from '../entities'; -import { useDataSources } from './data-sources'; -import { buildDetectionInput, nextDetectionContext } from './detection-window'; -import { - MAX_CARDS, - DETECT_INTERVAL_MS, - addCard, - pinCard, - unpinCard, - dismissCard, - loadSettings, - buildProvider, -} from './session-helpers'; -import { SessionStatus, SttStatus, EntityStatus, CardState, SessionContextType } from './session-types'; +import type { EntityIndex, WorldDataProvider } from '../entities'; import { EntityDetector } from '../entities/detector'; import { FileUploadProvider } from '../entities/providers/file-upload'; import { GoogleDocsProvider } from '../entities/providers/google-docs'; @@ -23,31 +10,35 @@ import { MarkdownProvider } from '../entities/providers/markdown'; import { NotionProvider, extractNotionId } from '../entities/providers/notion'; import { SRDProvider } from '../entities/providers/srd'; import { SAMPLE_WORLD } from '../sample-world'; -import { STTProvider } from '../stt/index'; +import { useDataSources } from './data-sources'; +import { DETECT_INTERVAL_MS, buildProvider, loadSettings } from './session-helpers'; +import { SessionRuntime } from './session-runtime'; +import type { EntityStatus, SessionContextType } from './session-types'; export { SessionStatus, SttStatus, EntityStatus, CardState } from './session-types'; const SessionContext = createContext(null); export function SessionProvider({ children }: { children: React.ReactNode }) { const { settings: ds, uploadsVersion } = useDataSources(); - const [status, setStatus] = useState('idle'); - const [sttStatus, setSttStatus] = useState('idle'); - const [sttError, setSttError] = useState(null); - const [sttProviderName, setSttProviderName] = useState(''); const [entityStatus, setEntityStatus] = useState('loading'); - const [cards, setCards] = useState([]); const [entities, setEntities] = useState([]); - const [transcript, setTranscript] = useState(''); - const [recentDetections, setRecentDetections] = useState([]); - const detectorRef = useRef(null); - const transcriptRef = useRef(''); - const processedUpToRef = useRef(0); - const detectionContextRef = useRef(''); - const prevDetectionKeyRef = useRef(''); - const sttRef = useRef(null); - const sttGenerationRef = useRef(0); - const startInFlightRef = useRef | null>(null); - const acceptingTranscriptRef = useRef(false); + const [runtime] = useState(() => new SessionRuntime({ + loadSttSettings: loadSettings, + buildSttProvider: buildProvider, + detectIntervalMs: DETECT_INTERVAL_MS, + })); + const [runtimeSnapshot, setRuntimeSnapshot] = useState(() => runtime.getSnapshot()); + const { status, sttStatus, sttError, sttProviderName, cards, transcript, recentDetections } = runtimeSnapshot; + + useEffect(() => { + return runtime.subscribe(setRuntimeSnapshot); + }, [runtime]); + + useEffect(() => { + return () => { + runtime.dispose(); + }; + }, [runtime]); useEffect(() => { let cancelled = false; @@ -78,197 +69,40 @@ export function SessionProvider({ children }: { children: React.ReactNode }) { }); const combined = results.flatMap((r) => r.status === 'fulfilled' ? r.value : []); setEntities(combined); - detectorRef.current = new EntityDetector(combined); + runtime.setDetector(new EntityDetector(combined)); setEntityStatus(combined.length > 0 ? 'ready' : 'error'); }); return () => { cancelled = true; }; - }, [ds.srdEnabled, ds.srdSources.join(','), ds.kankaToken, ds.kankaCampaignId, ds.homebreweryUrl, ds.notionToken, ds.notionPageIds, ds.googleDocsUrl, uploadsVersion]); - - useEffect(() => { - if (status !== 'active') return; - - const interval = setInterval(() => { - const detector = detectorRef.current; - if (!detector) return; - - const newText = transcriptRef.current.slice(processedUpToRef.current); - if (!newText.trim()) return; - - const found = detector.detect(buildDetectionInput(detectionContextRef.current, newText)); - processedUpToRef.current = transcriptRef.current.length; - detectionContextRef.current = nextDetectionContext(transcriptRef.current); - - if (found.length === 0) return; - - const detectionKey = found.map((e) => e.id).join(','); - if (detectionKey !== prevDetectionKeyRef.current) { - prevDetectionKeyRef.current = detectionKey; - setRecentDetections(found); - } - - setCards((prev) => { - let next = prev; - found.forEach((entity) => { next = addCard(next, entity); }); - return next; - }); - }, DETECT_INTERVAL_MS); - - return () => clearInterval(interval); - }, [status]); + }, [ds.srdEnabled, ds.srdSources.join(','), ds.kankaToken, ds.kankaCampaignId, ds.homebreweryUrl, ds.notionToken, ds.notionPageIds, ds.googleDocsUrl, uploadsVersion, runtime]); - const appendTranscript = useCallback((text: string) => { - setTranscript((prev) => { - const next = prev ? `${prev} ${text}` : text; - transcriptRef.current = next; - return next; - }); - }, []); - - const start = useCallback(async () => { - if (startInFlightRef.current) return; - - if (sttRef.current) { - const generation = sttGenerationRef.current; - const provider = sttRef.current; - const resumePromise = Promise.resolve() - .then(() => provider.resume()) - .then(() => { - if (sttGenerationRef.current !== generation || sttRef.current !== provider) return; - acceptingTranscriptRef.current = true; - setStatus('active'); - setSttStatus('active'); - processedUpToRef.current = transcriptRef.current.length; - detectionContextRef.current = ''; - }) - .catch((e) => { - if (sttGenerationRef.current !== generation || sttRef.current !== provider) return; - acceptingTranscriptRef.current = false; - Promise.resolve(provider.stop()).catch(() => {}); - sttRef.current = null; - setSttError(`Failed to resume mic: ${e instanceof Error ? e.message : String(e)}`); - setSttStatus('error'); - setStatus('paused'); - }) - .finally(() => { - if (startInFlightRef.current === resumePromise) startInFlightRef.current = null; - }); - startInFlightRef.current = resumePromise; - return; - } - - const generation = sttGenerationRef.current + 1; - sttGenerationRef.current = generation; - acceptingTranscriptRef.current = false; - detectionContextRef.current = ''; - setSttStatus('connecting'); - setSttError(null); - - const startPromise = (async () => { - const settings = await loadSettings(); - if (sttGenerationRef.current !== generation) return; - - const provider = buildProvider( - settings, - (text) => { - if (sttGenerationRef.current === generation && acceptingTranscriptRef.current) { - appendTranscript(text); - } - }, - (err) => { - if (sttGenerationRef.current !== generation) return; - acceptingTranscriptRef.current = false; - const current = sttRef.current; - if (current) Promise.resolve(current.stop()).catch(() => {}); - sttRef.current = null; - setSttError(err); - setSttStatus('error'); - setStatus((prev) => prev === 'active' ? 'paused' : prev); - }, - ); - - sttRef.current = provider; - setSttProviderName(provider.name); - - await provider.start(); - if (sttGenerationRef.current !== generation || sttRef.current !== provider) { - await provider.stop(); - return; - } - - acceptingTranscriptRef.current = true; - setSttStatus('active'); - setStatus('active'); - processedUpToRef.current = transcriptRef.current.length; - detectionContextRef.current = ''; - })() - .catch((e) => { - if (sttGenerationRef.current !== generation) return; - const provider = sttRef.current; - if (provider) Promise.resolve(provider.stop()).catch(() => {}); - sttRef.current = null; - acceptingTranscriptRef.current = false; - setSttProviderName(''); - setSttError(`Failed to start mic: ${e instanceof Error ? e.message : String(e)}`); - setSttStatus('error'); - }) - .finally(() => { - if (startInFlightRef.current === startPromise) startInFlightRef.current = null; - }); - - startInFlightRef.current = startPromise; - }, [appendTranscript]); + const start = useCallback(() => { + void runtime.start(); + }, [runtime]); const pause = useCallback(() => { - acceptingTranscriptRef.current = false; - detectionContextRef.current = ''; - const provider = sttRef.current; - if (provider) Promise.resolve(provider.pause()).catch(() => {}); - setSttStatus('idle'); - setStatus('paused'); - }, []); + runtime.pause(); + }, [runtime]); const stop = useCallback(() => { - sttGenerationRef.current += 1; - startInFlightRef.current = null; - acceptingTranscriptRef.current = false; - const provider = sttRef.current; - if (provider) Promise.resolve(provider.stop()).catch(() => {}); - sttRef.current = null; - setSttStatus('idle'); - setSttError(null); - setSttProviderName(''); - setStatus('idle'); - setCards([]); - setTranscript(''); - transcriptRef.current = ''; - setRecentDetections([]); - prevDetectionKeyRef.current = ''; - processedUpToRef.current = 0; - detectionContextRef.current = ''; - }, []); + runtime.stop(); + }, [runtime]); - useEffect(() => { - return () => { - sttGenerationRef.current += 1; - acceptingTranscriptRef.current = false; - const provider = sttRef.current; - if (provider) Promise.resolve(provider.stop()).catch(() => {}); - sttRef.current = null; - }; - }, []); + const appendTranscript = useCallback((text: string) => { + runtime.appendTranscript(text); + }, [runtime]); const pin = useCallback((instanceId: string) => { - setCards((prev) => pinCard(prev, instanceId)); - }, []); + runtime.pin(instanceId); + }, [runtime]); const unpin = useCallback((instanceId: string) => { - setCards((prev) => unpinCard(prev, instanceId)); - }, []); + runtime.unpin(instanceId); + }, [runtime]); const dismiss = useCallback((instanceId: string) => { - setCards((prev) => dismissCard(prev, instanceId)); - }, []); + runtime.dismiss(instanceId); + }, [runtime]); return ( = { - S: { landscapeCols: 4, portraitCols: 3, fontScale: 0.85 }, - M: { landscapeCols: 3, portraitCols: 2, fontScale: 1.0 }, - L: { landscapeCols: 2, portraitCols: 2, fontScale: 1.15 }, - XL: { landscapeCols: 2, portraitCols: 1, fontScale: 1.35 }, + S: { ...CARD_SIZE_LAYOUT_CONFIGS.S, fontScale: 0.85 }, + M: { ...CARD_SIZE_LAYOUT_CONFIGS.M, fontScale: 1.0 }, + L: { ...CARD_SIZE_LAYOUT_CONFIGS.L, fontScale: 1.15 }, + XL: { ...CARD_SIZE_LAYOUT_CONFIGS.XL, fontScale: 1.35 }, }; export { CARD_SIZE_KEY, COLOR_SCHEME_KEY } from '../storage/keys'; export const DEFAULT_CARD_SIZE: CardSize = 'M'; export const DEFAULT_COLOR_SCHEME: ColorScheme = 'dark'; +function isColorScheme(value: unknown): value is ColorScheme { + return typeof value === 'string' && (COLOR_SCHEMES as readonly string[]).includes(value); +} + // Read synchronously from localStorage on web so the first render matches // the stored preference -- avoids SSR/client hydration mismatch. function readStoredColorScheme(): ColorScheme { if (Platform.OS === 'web' && typeof window !== 'undefined') { try { - const v = window.localStorage.getItem(COLOR_SCHEME_KEY); - if (v === 'dark' || v === 'light' || v === 'system') return v; + const value = window.localStorage.getItem(COLOR_SCHEME_KEY); + if (isColorScheme(value)) return value; } catch {} } return DEFAULT_COLOR_SCHEME; @@ -45,8 +56,8 @@ function readStoredColorScheme(): ColorScheme { function readStoredCardSize(): CardSize { if (Platform.OS === 'web' && typeof window !== 'undefined') { try { - const v = window.localStorage.getItem(CARD_SIZE_KEY); - if (v && v in CARD_SIZE_CONFIGS) return v as CardSize; + const value = window.localStorage.getItem(CARD_SIZE_KEY); + if (isCardSize(value)) return value; } catch {} } return DEFAULT_CARD_SIZE; @@ -75,14 +86,12 @@ export function UISettingsProvider({ children }: { children: React.ReactNode }) getAppDataItem(COLOR_SCHEME_KEY, token), ]).then(([rawSize, rawScheme]) => { try { - if (rawSize && rawSize in CARD_SIZE_CONFIGS) setCardSizeState(rawSize as CardSize); + if (isCardSize(rawSize)) setCardSizeState(rawSize); } catch (e) { console.warn('[dnd-ref] Failed to parse card size preference:', e); } try { - if (rawScheme === 'dark' || rawScheme === 'light' || rawScheme === 'system') { - setColorSchemeState(rawScheme); - } + if (isColorScheme(rawScheme)) setColorSchemeState(rawScheme); } catch (e) { console.warn('[dnd-ref] Failed to parse color scheme preference:', e); } diff --git a/src/entities/ingestion.test.ts b/src/entities/ingestion.test.ts new file mode 100644 index 0000000..f313281 --- /dev/null +++ b/src/entities/ingestion.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from 'vitest'; + +import { + ingestJsonContent, + ingestMarkdownContent, + normalizeIngestedEntity, +} from './ingestion'; + +describe('world data ingestion', () => { + it('normalizes entity type, ids, aliases, and summaries for shared ingested records', () => { + expect(normalizeIngestedEntity({ + name: ' The Argent Key! ', + type: 'artifact weapon', + aliases: [' key ', '', 7, 'silver key'], + description: ' Opens the Moon Door. ', + }, { idPrefix: 'upload', idNamespace: 42, index: 3 })).toEqual({ + id: 'upload-the-argent-key-42-3', + name: 'The Argent Key!', + type: 'Item', + aliases: ['key', 'silver key'], + summary: 'Opens the Moon Door.', + }); + }); + + it('keeps markdown/text parsing behavior while using shared normalization', () => { + const entities = ingestMarkdownContent(` +## Moonlit Bazaar +Type: place +Aliases: Night Market; Bazaar | moon market + +Open only under the new moon. + +### Captain Aria +**Type:** character +**Aliases:** Aria, the captain + +Commands the east watch. +`); + + expect(entities).toEqual([ + { + id: 'moonlit-bazaar', + name: 'Moonlit Bazaar', + type: 'Location', + aliases: ['Night Market', 'Bazaar', 'moon market'], + summary: 'Open only under the new moon.', + }, + { + id: 'captain-aria', + name: 'Captain Aria', + type: 'NPC', + aliases: ['Aria', 'the captain'], + summary: 'Commands the east watch.', + }, + ]); + }); + + it('ingests JSON arrays with upload-style ids and description fallback summaries', () => { + const entities = ingestJsonContent(JSON.stringify([ + { + name: 'Lady Seraphine Voss', + type: 'person', + aliases: ['Seraphine', ' Lady Voss '], + description: 'Spymaster of the Dawnwarden Order.', + }, + { name: '', type: 'npc' }, + { nope: 'missing name' }, + ]), { idPrefix: 'upload', idNamespace: 1234 }); + + expect(entities).toEqual([ + { + id: 'upload-lady-seraphine-voss-1234-0', + name: 'Lady Seraphine Voss', + type: 'NPC', + aliases: ['Seraphine', 'Lady Voss'], + summary: 'Spymaster of the Dawnwarden Order.', + }, + ]); + }); +}); diff --git a/src/entities/ingestion.ts b/src/entities/ingestion.ts new file mode 100644 index 0000000..0bb4b91 --- /dev/null +++ b/src/entities/ingestion.ts @@ -0,0 +1,228 @@ +import { + Entity, + EntityIndex, + EntityType, + normalizeEntityType, + slugify, +} from './index'; + +const JSON_UPLOAD_EXTENSION = '.json'; +const UPLOAD_ENTITY_ID_PREFIX = 'upload'; + +interface MarkdownBlock { + name: string; + body: string; +} + +export interface IngestedEntityRecord { + name?: unknown; + type?: unknown; + aliases?: unknown; + summary?: unknown; + description?: unknown; + image?: unknown; +} + +export interface NormalizeIngestedEntityOptions { + idPrefix?: string; + idNamespace?: string | number; + index?: number; +} + +export interface UploadedWorldData { + name: string; + content: string; +} + +export interface UploadedWorldDataIngestionOptions { + idNamespace?: string | number; + onJsonParseError?: (error: unknown) => void; +} + +export function normalizeIngestedEntity( + record: IngestedEntityRecord, + options: NormalizeIngestedEntityOptions = {}, +): Entity | null { + const name = normalizeNonEmptyString(record.name); + if (!name) return null; + + const entity: Entity = { + id: buildEntityId(name, options), + name, + type: normalizeIngestedEntityType(record.type), + aliases: normalizeAliases(record.aliases), + summary: normalizeSummary(record.summary, record.description), + }; + + const image = normalizeNonEmptyString(record.image); + if (image) entity.image = image; + + return entity; +} + +export function ingestMarkdownContent(content: string): EntityIndex { + const blocks = getHeadingBlocks(content); + const sourceBlocks = blocks.length > 0 ? blocks : getFallbackBlock(content); + + return sourceBlocks + .map((block) => normalizeMarkdownBlock(block)) + .filter((entity): entity is Entity => entity !== null); +} + +export function ingestJsonContent( + content: string, + options: Omit = {}, +): EntityIndex { + const data = JSON.parse(content) as unknown; + const items = Array.isArray(data) ? data : []; + const entities: EntityIndex = []; + + for (const item of items) { + if (!isIngestedEntityRecord(item)) continue; + + const entity = normalizeIngestedEntity(item, { + ...options, + index: entities.length, + }); + if (entity) entities.push(entity); + } + + return entities; +} + +export function ingestUploadedFile( + upload: UploadedWorldData, + options: UploadedWorldDataIngestionOptions = {}, +): EntityIndex { + if (isJsonUploadName(upload.name)) { + try { + return ingestJsonContent(upload.content, { + idPrefix: UPLOAD_ENTITY_ID_PREFIX, + idNamespace: options.idNamespace ?? Date.now(), + }); + } catch (error) { + options.onJsonParseError?.(error); + } + } + + return ingestMarkdownContent(upload.content); +} + +export function isJsonUploadName(name: string): boolean { + return name.toLowerCase().endsWith(JSON_UPLOAD_EXTENSION); +} + +function normalizeMarkdownBlock(block: MarkdownBlock): Entity | null { + const name = cleanHeading(block.name); + let rawType = ''; + let rawAliases = ''; + const summaryLines: string[] = []; + + for (const line of block.body.split('\n')) { + const trimmed = line.trim(); + if (trimmed === '---') continue; + + const field = parseField(trimmed); + if (field?.key === 'type') { + rawType = field.value; + continue; + } + if (field?.key === 'aliases') { + rawAliases = field.value; + continue; + } + + if (!trimmed && summaryLines.length === 0) continue; + summaryLines.push(line); + } + + return normalizeIngestedEntity({ + name, + type: rawType, + aliases: rawAliases, + summary: summaryLines.join('\n'), + }); +} + +function getHeadingBlocks(content: string): MarkdownBlock[] { + const headingRe = /^(#{1,3})\s+(.+?)\s*#*\s*$/gm; + const matches = Array.from(content.matchAll(headingRe)); + + return matches.map((match, i) => { + const start = (match.index ?? 0) + match[0].length; + const end = matches[i + 1]?.index ?? content.length; + return { name: match[2], body: content.slice(start, end) }; + }); +} + +function getFallbackBlock(content: string): MarkdownBlock[] { + const lines = content.trim().split('\n'); + const name = lines.shift()?.trim() ?? ''; + return name ? [{ name, body: lines.join('\n') }] : []; +} + +function cleanHeading(name: string): string { + return name.replace(/\*\*/g, '').trim(); +} + +function parseField(line: string): { key: string; value: string } | null { + const match = line.match(/^(?:[-*]\s*)?(?:\*\*)?([^:*]+):(?:\*\*)?\s*(.+)$/); + if (!match) return null; + + return { + key: match[1].replace(/\*/g, '').trim().toLowerCase(), + value: match[2].trim(), + }; +} + +function isIngestedEntityRecord(value: unknown): value is IngestedEntityRecord { + return value !== null && typeof value === 'object'; +} + +function normalizeNonEmptyString(value: unknown): string | null { + if (typeof value !== 'string') return null; + + const normalized = value.trim(); + return normalized || null; +} + +function normalizeIngestedEntityType(value: unknown): EntityType { + return normalizeEntityType(typeof value === 'string' ? value : ''); +} + +function normalizeAliases(value: unknown): string[] { + if (typeof value === 'string') { + return splitAliasString(value); + } + + if (!Array.isArray(value)) return []; + + return value + .filter((alias): alias is string => typeof alias === 'string') + .map((alias) => alias.trim()) + .filter(Boolean); +} + +function splitAliasString(value: string): string[] { + return value + .split(/[,;|]/) + .map((alias) => alias.trim()) + .filter(Boolean); +} + +function normalizeSummary(summary: unknown, description: unknown): string { + const value = typeof summary === 'string' ? summary : description; + return typeof value === 'string' ? value.trim() : ''; +} + +function buildEntityId(name: string, options: NormalizeIngestedEntityOptions): string { + const parts: Array = []; + if (options.idPrefix) parts.push(options.idPrefix); + parts.push(slugify(name)); + if (options.idNamespace !== undefined && options.idNamespace !== '') { + parts.push(options.idNamespace); + } + if (options.index !== undefined) parts.push(options.index); + + return parts.join('-'); +} diff --git a/src/entities/providers/file-upload.test.ts b/src/entities/providers/file-upload.test.ts index cce0bb9..3c0bbe1 100644 --- a/src/entities/providers/file-upload.test.ts +++ b/src/entities/providers/file-upload.test.ts @@ -1,4 +1,4 @@ -import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const storage = vi.hoisted(() => new Map()); const storageControls = vi.hoisted(() => ({ @@ -21,7 +21,7 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ }, })); -import { addUpload, getUploads, removeUpload, waitForUploadMutations } from './file-upload'; +import { addUpload, FileUploadProvider, getUploads, removeUpload } from './file-upload'; import { resetAppDataControlsForTests, resetStoredAppData } from '../../storage/app-data'; describe('file upload storage', () => { @@ -31,6 +31,10 @@ describe('file upload storage', () => { resetAppDataControlsForTests(); }); + afterEach(() => { + vi.restoreAllMocks(); + }); + it('preserves every file when uploads are added concurrently', async () => { await Promise.all([ addUpload('one.md', '# One'), @@ -78,7 +82,7 @@ describe('file upload storage', () => { const upload = addUpload('late.md', '# Late'); await Promise.resolve(); - const reset = resetStoredAppData({ beforeClear: waitForUploadMutations }); + const reset = resetStoredAppData(); releaseGetItem(); await Promise.all([upload, reset]); @@ -86,4 +90,74 @@ describe('file upload storage', () => { expect(await getUploads()).toEqual([]); }); + + it('loads markdown, text, and JSON uploads through world data ingestion', async () => { + await addUpload('bazaar.md', ` +## Moonlit Bazaar +Type: place +Aliases: Night Market; Bazaar + +Open only under the new moon. +`); + await addUpload('captain.txt', `Captain Aria +Type: character +Aliases: Aria | the captain + +Commands the east watch.`); + await addUpload('items.json', JSON.stringify([ + { + name: 'The Sundering Blade', + type: 'artifact', + aliases: ['Sundering Blade', 'the blade'], + description: 'Can destroy a lich phylactery.', + }, + ])); + + const entities = await new FileUploadProvider().load(); + + expect(entities.map(({ name, type, aliases, summary }) => ({ name, type, aliases, summary }))).toEqual([ + { + name: 'Moonlit Bazaar', + type: 'Location', + aliases: ['Night Market', 'Bazaar'], + summary: 'Open only under the new moon.', + }, + { + name: 'Captain Aria', + type: 'NPC', + aliases: ['Aria', 'the captain'], + summary: 'Commands the east watch.', + }, + { + name: 'The Sundering Blade', + type: 'Item', + aliases: ['Sundering Blade', 'the blade'], + summary: 'Can destroy a lich phylactery.', + }, + ]); + expect(entities[2].id).toMatch(/^upload-the-sundering-blade-\d+-0$/); + }); + + it('falls back to markdown ingestion for invalid JSON uploads', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined); + await addUpload('fallback.json', ` +# Lord Ember +Type: npc +Aliases: Ember + +Rules the cinder court. +`); + + const entities = await new FileUploadProvider().load(); + + expect(warn).toHaveBeenCalledWith('[dnd-ref] Failed to parse JSON upload: fallback.json'); + expect(entities.map(({ name, type, aliases, summary }) => ({ name, type, aliases, summary }))).toEqual([ + { + name: 'Lord Ember', + type: 'NPC', + aliases: ['Ember'], + summary: 'Rules the cinder court.', + }, + ]); + }); }); diff --git a/src/entities/providers/file-upload.ts b/src/entities/providers/file-upload.ts index e5b29e7..310d508 100644 --- a/src/entities/providers/file-upload.ts +++ b/src/entities/providers/file-upload.ts @@ -1,58 +1,30 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; - -import { EntityIndex, WorldDataProvider, normalizeEntityType, slugify } from '../index'; -import { MarkdownProvider } from './markdown'; -import { canPersistAppData, createAppDataWriteToken } from '../../storage/app-data'; -import { UPLOADS_KEY } from '../../storage/keys'; +import { + addUploadedFile, + getUploadedFiles, + removeUploadedFile, + waitForUploadedFileMutations, + type UploadedFile, +} from '../../storage/app-data'; +import { EntityIndex, WorldDataProvider } from '../index'; +import { ingestUploadedFile } from '../ingestion'; export { UPLOADS_KEY } from '../../storage/keys'; -let uploadMutationQueue: Promise = Promise.resolve(); - -export interface UploadedFile { - id: string; - name: string; - content: string; -} +export type { UploadedFile } from '../../storage/app-data'; export async function getUploads(): Promise { - try { - const raw = await AsyncStorage.getItem(UPLOADS_KEY); - return raw ? (JSON.parse(raw) as UploadedFile[]) : []; - } catch (e) { - console.warn('[dnd-ref] Failed to read uploads from storage:', e); - return []; - } + return getUploadedFiles(); } export async function addUpload(name: string, content: string): Promise { - const token = createAppDataWriteToken(); - await mutateUploads(token, (uploads) => { - const id = `upload-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`; - return [...uploads, { id, name, content }]; - }); + await addUploadedFile(name, content); } export async function removeUpload(id: string): Promise { - const token = createAppDataWriteToken(); - await mutateUploads(token, (uploads) => uploads.filter((u) => u.id !== id)); + await removeUploadedFile(id); } export async function waitForUploadMutations(): Promise { - await uploadMutationQueue.catch(() => undefined); -} - -function mutateUploads(token: number, mutator: (uploads: UploadedFile[]) => UploadedFile[]): Promise { - const operation = uploadMutationQueue - .catch(() => undefined) - .then(async () => { - if (!canPersistAppData(token)) return; - const uploads = await getUploads(); - if (!canPersistAppData(token)) return; - await AsyncStorage.setItem(UPLOADS_KEY, JSON.stringify(mutator(uploads))); - }); - - uploadMutationQueue = operation.catch(() => undefined); - return operation; + await waitForUploadedFileMutations(); } export class FileUploadProvider implements WorldDataProvider { @@ -60,34 +32,16 @@ export class FileUploadProvider implements WorldDataProvider { async load(): Promise { const uploads = await getUploads(); - const results = await Promise.all(uploads.map(parseUpload)); - return results.flat(); + return uploads.flatMap(parseUpload); } getName(): string { return this.name; } } -async function parseUpload(upload: UploadedFile): Promise { - if (upload.name.endsWith('.json')) { - try { - return parseJSON(upload.content); - } catch { +function parseUpload(upload: UploadedFile): EntityIndex { + return ingestUploadedFile(upload, { + onJsonParseError: () => { console.warn(`[dnd-ref] Failed to parse JSON upload: ${upload.name}`); - } - } - return new MarkdownProvider(upload.content, upload.name).load(); -} - -function parseJSON(content: string): EntityIndex { - const data = JSON.parse(content) as unknown; - const items = Array.isArray(data) ? data : []; - return items - .filter((item): item is Record => !!item && typeof (item as any).name === 'string') - .map((item, i) => ({ - id: `upload-${slugify(item.name as string)}-${Date.now()}-${i}`, - name: item.name as string, - type: normalizeEntityType((item.type as string) ?? ''), - aliases: Array.isArray(item.aliases) ? (item.aliases as string[]) : [], - summary: ((item.summary ?? item.description ?? '') as string), - })); + }, + }); } diff --git a/src/entities/providers/google-docs.test.ts b/src/entities/providers/google-docs.test.ts new file mode 100644 index 0000000..b7914df --- /dev/null +++ b/src/entities/providers/google-docs.test.ts @@ -0,0 +1,96 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +const fetchMock = vi.hoisted(() => vi.fn()); +const ingestionMocks = vi.hoisted(() => ({ + ingestMarkdownContent: vi.fn(), +})); + +vi.mock('react-native', () => ({ Platform: { OS: 'web' } })); +vi.mock('../ingestion', () => ingestionMocks); + +import { ingestMarkdownContent } from '../ingestion'; +import { + buildGoogleDocsExportUrl, + fetchGoogleDocText, + GoogleDocsProvider, +} from './google-docs'; + +const mockedIngestMarkdownContent = vi.mocked(ingestMarkdownContent); + +describe('GoogleDocsProvider', () => { + beforeEach(() => { + fetchMock.mockReset(); + mockedIngestMarkdownContent.mockReset(); + vi.stubGlobal('fetch', fetchMock); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('fetches the exported document text through the browser CORS proxy', async () => { + fetchMock.mockResolvedValue(textResponse('# Moonlit Bazaar')); + + await expect(fetchGoogleDocText('https://docs.google.com/document/d/doc_123/edit')) + .resolves.toBe('# Moonlit Bazaar'); + + expect(fetchMock).toHaveBeenCalledWith( + 'https://proxy.dndref.com/google-docs/document/d/doc_123/export?format=txt', + ); + }); + + it('builds direct Google Docs export URLs for native callers', () => { + const documentUrl = 'https://docs.google.com/document/d/doc_123/edit#heading=h.1'; + + expect(buildGoogleDocsExportUrl(documentUrl, null)) + .toBe('https://docs.google.com/document/d/doc_123/export?format=txt'); + expect(buildGoogleDocsExportUrl('doc_123', null)) + .toBe('https://docs.google.com/document/d/doc_123/export?format=txt'); + }); + + it('loads fetched document text through shared ingestion', async () => { + const documentText = ` +## Moonlit Bazaar +Type: place +Aliases: Night Market + +Open only under the new moon. +`; + const ingestedEntities = [{ + id: 'moonlit-bazaar', + name: 'Moonlit Bazaar', + type: 'Location' as const, + aliases: ['Night Market'], + summary: 'Open only under the new moon.', + }]; + fetchMock.mockResolvedValue(textResponse(documentText)); + mockedIngestMarkdownContent.mockReturnValue(ingestedEntities); + + const entities = await new GoogleDocsProvider( + 'https://docs.google.com/document/d/doc_123/edit', + ).load(); + + expect(mockedIngestMarkdownContent).toHaveBeenCalledOnce(); + expect(mockedIngestMarkdownContent).toHaveBeenCalledWith(documentText); + expect(entities).toBe(ingestedEntities); + }); + + it('surfaces failed public document exports through the provider failure path', async () => { + fetchMock.mockResolvedValue(textResponse('', 403)); + + await expect(new GoogleDocsProvider('doc_123').load()) + .rejects.toThrow('Google Docs fetch failed: 403'); + expect(mockedIngestMarkdownContent).not.toHaveBeenCalled(); + }); + + it('keeps browser CORS errors on the existing user-friendly path', async () => { + fetchMock.mockRejectedValue(new TypeError('Failed to fetch')); + + await expect(fetchGoogleDocText('doc_123')) + .rejects.toThrow('Cannot reach Google Docs from the browser (CORS). Use the iOS app or paste content via file upload.'); + }); +}); + +function textResponse(body: string, status = 200): Response { + return new Response(body, { status }); +} diff --git a/src/entities/providers/google-docs.ts b/src/entities/providers/google-docs.ts index c2f8b00..f13f7b2 100644 --- a/src/entities/providers/google-docs.ts +++ b/src/entities/providers/google-docs.ts @@ -1,37 +1,53 @@ -import { EntityIndex, WorldDataProvider } from '../index'; -import { MarkdownProvider } from './markdown'; import { CORS_PROXY } from '../../proxy'; import { handleCorsError } from '../../utils/providers'; +import { EntityIndex, WorldDataProvider } from '../index'; +import { ingestMarkdownContent } from '../ingestion'; -const GDOCS_BASE = CORS_PROXY ? `${CORS_PROXY}/google-docs` : 'https://docs.google.com'; +const GOOGLE_DOCS_ORIGIN = 'https://docs.google.com'; +const GOOGLE_DOCS_PROXY_PATH = '/google-docs'; +const GOOGLE_DOCS_SOURCE_NAME = 'Google Docs'; +const GOOGLE_DOCS_SHARE_HINT = 'Make sure the doc is shared with "Anyone with the link".'; +const GOOGLE_DOCS_CORS_FALLBACK = 'Use the iOS app or paste content via file upload.'; export class GoogleDocsProvider implements WorldDataProvider { - readonly name = 'Google Docs'; - private url: string; + readonly name = GOOGLE_DOCS_SOURCE_NAME; + private readonly url: string; constructor(url: string) { this.url = url; } async load(): Promise { - const docId = extractDocId(this.url); - const exportUrl = `${GDOCS_BASE}/document/d/${docId}/export?format=txt`; - let text: string; - try { - const res = await fetch(exportUrl); - if (!res.ok) throw new Error(`Google Docs fetch failed: ${res.status}. Make sure the doc is shared with "Anyone with the link".`); - text = await res.text(); - } catch (e) { - throw handleCorsError(e, 'Google Docs', 'Use the iOS app or paste content via file upload.'); - } - return new MarkdownProvider(text, 'Google Docs').load(); + const text = await fetchGoogleDocText(this.url); + return ingestMarkdownContent(text); } getName(): string { return this.name; } } -function extractDocId(url: string): string { - const match = url.match(/\/document\/d\/([a-zA-Z0-9_-]+)/); +export async function fetchGoogleDocText(urlOrId: string): Promise { + const exportUrl = buildGoogleDocsExportUrl(urlOrId); + + try { + const response = await fetch(exportUrl); + if (!response.ok) { + throw new Error(`Google Docs fetch failed: ${response.status}. ${GOOGLE_DOCS_SHARE_HINT}`); + } + + return await response.text(); + } catch (error) { + throw handleCorsError(error, GOOGLE_DOCS_SOURCE_NAME, GOOGLE_DOCS_CORS_FALLBACK); + } +} + +export function buildGoogleDocsExportUrl(urlOrId: string, corsProxy: string | null = CORS_PROXY): string { + const docId = extractGoogleDocId(urlOrId); + const baseUrl = corsProxy ? `${corsProxy}${GOOGLE_DOCS_PROXY_PATH}` : GOOGLE_DOCS_ORIGIN; + return `${baseUrl}/document/d/${docId}/export?format=txt`; +} + +export function extractGoogleDocId(urlOrId: string): string { + const match = urlOrId.match(/\/document\/d\/([a-zA-Z0-9_-]+)/); if (match) return match[1]; - return url.trim(); + return urlOrId.trim(); } diff --git a/src/entities/providers/markdown.ts b/src/entities/providers/markdown.ts index e0e3dab..9bf9d24 100644 --- a/src/entities/providers/markdown.ts +++ b/src/entities/providers/markdown.ts @@ -1,75 +1,5 @@ -import { Entity, EntityIndex, WorldDataProvider, normalizeEntityType, slugify } from '../index'; - -interface MarkdownBlock { - name: string; - body: string; -} - -function parseMarkdown(content: string): EntityIndex { - const blocks = getHeadingBlocks(content); - const sourceBlocks = blocks.length > 0 ? blocks : getFallbackBlock(content); - - return sourceBlocks.map((block) => { - const name = cleanHeading(block.name); - if (!name) return null; - - const id = slugify(name); - let type = normalizeEntityType(''); - let aliases: string[] = []; - const summaryLines: string[] = []; - - for (const line of block.body.split('\n')) { - const trimmed = line.trim(); - if (trimmed === '---') continue; - - const field = parseField(trimmed); - if (field?.key === 'type') { - type = normalizeEntityType(field.value); - continue; - } - if (field?.key === 'aliases') { - aliases = field.value.split(/[,;|]/).map((a) => a.trim()).filter(Boolean); - continue; - } - - if (!trimmed && summaryLines.length === 0) continue; - summaryLines.push(line); - } - - return { id, name, type, aliases, summary: summaryLines.join('\n').trim() }; - }).filter((e): e is Entity => e !== null && e.name.length > 0); -} - -function getHeadingBlocks(content: string): MarkdownBlock[] { - const headingRe = /^(#{1,3})\s+(.+?)\s*#*\s*$/gm; - const matches = Array.from(content.matchAll(headingRe)); - - return matches.map((match, i) => { - const start = (match.index ?? 0) + match[0].length; - const end = matches[i + 1]?.index ?? content.length; - return { name: match[2], body: content.slice(start, end) }; - }); -} - -function getFallbackBlock(content: string): MarkdownBlock[] { - const lines = content.trim().split('\n'); - const name = lines.shift()?.trim() ?? ''; - return name ? [{ name, body: lines.join('\n') }] : []; -} - -function cleanHeading(name: string): string { - return name.replace(/\*\*/g, '').trim(); -} - -function parseField(line: string): { key: string; value: string } | null { - const match = line.match(/^(?:[-*]\s*)?(?:\*\*)?([^:*]+):(?:\*\*)?\s*(.+)$/); - if (!match) return null; - - return { - key: match[1].replace(/\*/g, '').trim().toLowerCase(), - value: match[2].trim(), - }; -} +import { EntityIndex, WorldDataProvider } from '../index'; +import { ingestMarkdownContent } from '../ingestion'; export class MarkdownProvider implements WorldDataProvider { private content: string; @@ -81,7 +11,7 @@ export class MarkdownProvider implements WorldDataProvider { } async load(): Promise { - return parseMarkdown(this.content); + return ingestMarkdownContent(this.content); } getName(): string { diff --git a/src/entities/providers/srd.ts b/src/entities/providers/srd.ts index cafcac8..663cbbd 100644 --- a/src/entities/providers/srd.ts +++ b/src/entities/providers/srd.ts @@ -1,6 +1,4 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; - -import { canPersistAppDataCache, createAppDataWriteToken, setAppDataItem } from '../../storage/app-data'; +import { createAppDataCacheSession, type AppDataCacheSession } from '../../storage/app-data'; import { SRD_CACHE_KEY_PREFIX } from '../../storage/keys'; import { fetchAll } from '../../utils/providers'; import { Entity, EntityIndex, WorldDataProvider, slugify, stripHtml } from '../index'; @@ -52,9 +50,9 @@ export class SRDProvider implements WorldDataProvider { async load(): Promise { if (this.sources.length === 0) return []; - const cacheWriteToken = createAppDataWriteToken(); + const cacheSession = createAppDataCacheSession(); const cacheKey = `${SRD_CACHE_KEY_PREFIX}${[...this.sources].sort().join(',')}`; - const cached = await loadCache(cacheKey); + const cached = await loadCache(cacheKey, cacheSession); if (cached) return cached; const sourceParam = this.sources.join(','); @@ -72,18 +70,18 @@ export class SRDProvider implements WorldDataProvider { ...monsters.map(monsterToEntity), ...items.map(itemToEntity), ]; - await saveCache(cacheKey, entities, cacheWriteToken); + await saveCache(cacheKey, entities, cacheSession); return entities; } getName(): string { return this.name; } } -async function loadCache(key: string): Promise { +async function loadCache(key: string, cacheSession: AppDataCacheSession): Promise { try { - const raw = await AsyncStorage.getItem(key); + const raw = await cacheSession.getItem(key); if (!raw) return null; - const cache = JSON.parse(raw) as { ts: number; entities: EntityIndex }; + const cache = JSON.parse(raw) as SRDCache; if (Date.now() - cache.ts > CACHE_TTL_MS) return null; return cache.entities; } catch { @@ -91,10 +89,11 @@ async function loadCache(key: string): Promise { } } -async function saveCache(key: string, entities: EntityIndex, token: number): Promise { - if (!canPersistAppDataCache(token)) return; +async function saveCache(key: string, entities: EntityIndex, cacheSession: AppDataCacheSession): Promise { + const cache: SRDCache = { ts: Date.now(), entities }; + try { - await setAppDataItem(key, JSON.stringify({ ts: Date.now(), entities }), { cache: true, token }); + await cacheSession.setItem(key, JSON.stringify(cache)); } catch { // Storage full -- skip caching } diff --git a/src/entity-card-presentation.test.ts b/src/entity-card-presentation.test.ts new file mode 100644 index 0000000..4f6eb2d --- /dev/null +++ b/src/entity-card-presentation.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, test } from 'vitest'; + +import type { CardState } from './context/session-types'; +import { deriveEntityCardPresentation, extractEntityCardSummaryBullets } from './entity-card-presentation'; + +function makeCard(overrides: Partial = {}): CardState { + return { + instanceId: 'card-1', + pinned: false, + entity: { + id: 'ironspire', + name: 'Ironspire Fortress', + type: 'Location', + aliases: [], + summary: 'Ancient dwarven stronghold. Seven levels deep.', + image: undefined, + }, + ...overrides, + }; +} + +describe('extractEntityCardSummaryBullets', () => { + test('splits sentence summaries into display bullets with terminal marks removed', () => { + expect(extractEntityCardSummaryBullets('The Lich King. Undead sorcerer! His phylactery remains hidden.')).toEqual([ + 'The Lich King', + 'Undead sorcerer', + 'His phylactery remains hidden', + ]); + }); + + test.each(['', ' \n '])('returns no bullets for empty summary %#', (summary) => { + expect(extractEntityCardSummaryBullets(summary)).toEqual([]); + }); + + test('limits long summaries to five bullets', () => { + const summary = 'One. Two. Three. Four. Five. Six. Seven.'; + + expect(extractEntityCardSummaryBullets(summary)).toEqual([ + 'One', + 'Two', + 'Three', + 'Four', + 'Five', + ]); + }); + + test('handles punctuation while preserving internal punctuation', () => { + const summary = 'Who guards the armory? Gorm knows: level 4. Wait--listen!'; + + expect(extractEntityCardSummaryBullets(summary)).toEqual([ + 'Who guards the armory', + 'Gorm knows: level 4', + 'Wait--listen', + ]); + }); +}); + +describe('deriveEntityCardPresentation', () => { + test('represents type, accent color, image, and actions for unpinned cards', () => { + const card = makeCard({ + entity: { + id: 'ironspire', + name: 'Ironspire Fortress', + type: 'Location', + aliases: [], + summary: 'Ancient dwarven stronghold. Seven levels deep.', + image: 'https://example.com/ironspire.png', + }, + }); + + expect(deriveEntityCardPresentation({ card, accentColor: '#2878b0' })).toEqual({ + instanceId: 'card-1', + name: 'Ironspire Fortress', + type: 'Location', + typeLabel: 'LOCATION', + accentColor: '#2878b0', + pinned: false, + imageUri: 'https://example.com/ironspire.png', + bulletMarker: '>', + summaryBullets: ['Ancient dwarven stronghold', 'Seven levels deep'], + actions: { + pinToggle: { + kind: 'pin', + accessibilityLabel: 'Pin', + iconName: 'bookmark-outline', + }, + dismiss: { + kind: 'dismiss', + accessibilityLabel: 'Dismiss', + iconName: 'close', + }, + }, + }); + }); + + test('represents pinned state with unpin action and absent image state', () => { + const card = makeCard({ + pinned: true, + entity: { + id: 'valdrath', + name: 'Valdrath the Undying', + type: 'NPC', + aliases: [], + summary: '', + image: '', + }, + }); + + expect(deriveEntityCardPresentation({ card, accentColor: '#45b882' })).toEqual({ + instanceId: 'card-1', + name: 'Valdrath the Undying', + type: 'NPC', + typeLabel: 'NPC', + accentColor: '#45b882', + pinned: true, + imageUri: null, + bulletMarker: '>', + summaryBullets: [], + actions: { + pinToggle: { + kind: 'unpin', + accessibilityLabel: 'Unpin', + iconName: 'bookmark', + }, + dismiss: { + kind: 'dismiss', + accessibilityLabel: 'Dismiss', + iconName: 'close', + }, + }, + }); + }); +}); diff --git a/src/entity-card-presentation.ts b/src/entity-card-presentation.ts new file mode 100644 index 0000000..44600e1 --- /dev/null +++ b/src/entity-card-presentation.ts @@ -0,0 +1,96 @@ +import type { CardState } from './context/session-types'; +import type { EntityType } from './entities'; + +export const ENTITY_CARD_MAX_SUMMARY_BULLETS = 5; +export const ENTITY_CARD_BULLET_MARKER = '>'; + +type EntityCardPinToggleKind = 'pin' | 'unpin'; +type EntityCardPinToggleIconName = 'bookmark' | 'bookmark-outline'; +type EntityCardPinToggleLabel = 'Pin' | 'Unpin'; + +export interface EntityCardPinTogglePresentation { + kind: EntityCardPinToggleKind; + accessibilityLabel: EntityCardPinToggleLabel; + iconName: EntityCardPinToggleIconName; +} + +export interface EntityCardDismissActionPresentation { + kind: 'dismiss'; + accessibilityLabel: 'Dismiss'; + iconName: 'close'; +} + +export interface EntityCardActionsPresentation { + pinToggle: EntityCardPinTogglePresentation; + dismiss: EntityCardDismissActionPresentation; +} + +export interface EntityCardPresentation { + instanceId: string; + name: string; + type: EntityType; + typeLabel: string; + accentColor: string; + pinned: boolean; + imageUri: string | null; + bulletMarker: typeof ENTITY_CARD_BULLET_MARKER; + summaryBullets: string[]; + actions: EntityCardActionsPresentation; +} + +export interface DeriveEntityCardPresentationInput { + card: CardState; + accentColor: string; +} + +export function extractEntityCardSummaryBullets(summary: string): string[] { + return summary + .split(/(?<=[.!?])\s+/) + .map((sentence) => sentence.replace(/[.!?]$/, '').trim()) + .filter((sentence) => sentence.length > 0) + .slice(0, ENTITY_CARD_MAX_SUMMARY_BULLETS); +} + +function derivePinTogglePresentation(pinned: boolean): EntityCardPinTogglePresentation { + if (pinned) { + return { + kind: 'unpin', + accessibilityLabel: 'Unpin', + iconName: 'bookmark', + }; + } + + return { + kind: 'pin', + accessibilityLabel: 'Pin', + iconName: 'bookmark-outline', + }; +} + +export function deriveEntityCardPresentation({ + card, + accentColor, +}: DeriveEntityCardPresentationInput): EntityCardPresentation { + const { entity, pinned } = card; + const imageUri = entity.image || null; + + return { + instanceId: card.instanceId, + name: entity.name, + type: entity.type, + typeLabel: entity.type.toUpperCase(), + accentColor, + pinned, + imageUri, + bulletMarker: ENTITY_CARD_BULLET_MARKER, + summaryBullets: extractEntityCardSummaryBullets(entity.summary), + actions: { + pinToggle: derivePinTogglePresentation(pinned), + dismiss: { + kind: 'dismiss', + accessibilityLabel: 'Dismiss', + iconName: 'close', + }, + }, + }; +} diff --git a/src/reference-card-layout.test.ts b/src/reference-card-layout.test.ts new file mode 100644 index 0000000..9d13e68 --- /dev/null +++ b/src/reference-card-layout.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from 'vitest'; + +import { + computeReferenceCardLayout, + type CardSize, +} from './reference-card-layout'; + +const cards = ['a', 'b', 'c', 'd', 'e'].map((instanceId) => ({ instanceId })); + +describe('computeReferenceCardLayout', () => { + test.each([ + ['S', 3, 242.66666666666666], + ['M', 2, 369], + ['L', 2, 369], + ['XL', 1, 380], + ] satisfies Array<[CardSize, number, number]>)('uses %s portrait columns within the readable width', (cardSize, columns, cardWidth) => { + const layout = computeReferenceCardLayout({ + cards, + measuredHeights: {}, + viewport: { width: 768, height: 1024 }, + cardSize, + }); + + expect(layout.columns).toBe(columns); + expect(layout.cardWidth).toBeCloseTo(cardWidth, 6); + }); + + test.each([ + ['S', 4, 347.5], + ['M', 3, 380], + ['L', 2, 380], + ['XL', 2, 380], + ] satisfies Array<[CardSize, number, number]>)('uses %s landscape columns within the readable width', (cardSize, columns, cardWidth) => { + const layout = computeReferenceCardLayout({ + cards, + measuredHeights: {}, + viewport: { width: 1440, height: 900 }, + cardSize, + }); + + expect(layout.columns).toBe(columns); + expect(layout.cardWidth).toBeCloseTo(cardWidth, 6); + }); + + test('centers constrained single-column XL portrait cards', () => { + const layout = computeReferenceCardLayout({ + cards: cards.slice(0, 1), + measuredHeights: {}, + viewport: { width: 768, height: 1024 }, + cardSize: 'XL', + }); + + expect(layout.columns).toBe(1); + expect(layout.gridWidth).toBe(400); + expect(layout.xOffset).toBe(184); + expect(layout.positions.a).toEqual({ x: 189, y: 5 }); + }); + + test('uses measured row heights for positions and content height', () => { + const layout = computeReferenceCardLayout({ + cards, + measuredHeights: { + a: 160, + b: 240, + c: 180, + d: 320, + e: 150, + }, + viewport: { width: 1440, height: 900 }, + cardSize: 'M', + }); + + expect(layout.columns).toBe(3); + expect(layout.positions).toEqual({ + a: { x: 135, y: 5 }, + b: { x: 525, y: 5 }, + c: { x: 915, y: 5 }, + d: { x: 135, y: 245 }, + e: { x: 525, y: 245 }, + }); + expect(layout.totalHeight).toBe(570); + }); + + test('falls back to the default card height until rows are measured', () => { + const layout = computeReferenceCardLayout({ + cards: cards.slice(0, 4), + measuredHeights: { a: 220 }, + viewport: { width: 1440, height: 900 }, + cardSize: 'M', + }); + + expect(layout.positions.d).toEqual({ x: 135, y: 225 }); + expect(layout.totalHeight).toBe(430); + }); +}); diff --git a/src/reference-card-layout.ts b/src/reference-card-layout.ts new file mode 100644 index 0000000..19382c0 --- /dev/null +++ b/src/reference-card-layout.ts @@ -0,0 +1,118 @@ +export const CARD_SIZES = ['S', 'M', 'L', 'XL'] as const; + +export type CardSize = (typeof CARD_SIZES)[number]; + +export function isCardSize(value: unknown): value is CardSize { + return typeof value === 'string' && (CARD_SIZES as readonly string[]).includes(value); +} + +export interface CardSizeLayoutConfig { + landscapeCols: number; + portraitCols: number; +} + +export const CARD_SIZE_LAYOUT_CONFIGS: Record = { + S: { landscapeCols: 4, portraitCols: 3 }, + M: { landscapeCols: 3, portraitCols: 2 }, + L: { landscapeCols: 2, portraitCols: 2 }, + XL: { landscapeCols: 2, portraitCols: 1 }, +}; + +export const REFERENCE_CARD_LAYOUT = { + gridPad: 5, + cardMargin: 5, + minCardWidth: 230, + maxCardWidth: 380, + defaultMeasuredHeight: 200, +} as const; + +export interface ReferenceCardLayoutItem { + instanceId: string; +} + +export interface ReferenceCardLayoutViewport { + width: number; + height: number; +} + +export interface ReferenceCardPosition { + x: number; + y: number; +} + +export interface ReferenceCardLayout { + columns: number; + cardWidth: number; + gridWidth: number; + xOffset: number; + positions: Record; + totalHeight: number; +} + +export interface ComputeReferenceCardLayoutInput { + cards: readonly ReferenceCardLayoutItem[]; + measuredHeights: Readonly>; + viewport: ReferenceCardLayoutViewport; + cardSize: CardSize; +} + +export function computeReferenceCardLayout({ + cards, + measuredHeights, + viewport, + cardSize, +}: ComputeReferenceCardLayoutInput): ReferenceCardLayout { + const { + gridPad, + cardMargin, + minCardWidth, + maxCardWidth, + defaultMeasuredHeight, + } = REFERENCE_CARD_LAYOUT; + const { width: viewportWidth, height: viewportHeight } = viewport; + const config = CARD_SIZE_LAYOUT_CONFIGS[cardSize]; + const preferredColumns = viewportWidth > viewportHeight ? config.landscapeCols : config.portraitCols; + const maxReadableColumns = Math.max( + 1, + Math.floor((viewportWidth - 2 * gridPad) / (minCardWidth + 2 * cardMargin)), + ); + const columns = Math.min(preferredColumns, maxReadableColumns); + const gridWidth = Math.min( + viewportWidth, + columns * (maxCardWidth + 2 * cardMargin) + 2 * gridPad, + ); + const xOffset = Math.max(0, (viewportWidth - gridWidth) / 2); + const cardWidth = (gridWidth - 2 * gridPad) / columns - 2 * cardMargin; + const columnWidth = cardWidth + 2 * cardMargin; + + const rowHeights: number[] = []; + for (let i = 0; i < cards.length; i++) { + const row = Math.floor(i / columns); + const cardHeight = measuredHeights[cards[i].instanceId] ?? defaultMeasuredHeight; + rowHeights[row] = Math.max(rowHeights[row] ?? 0, cardHeight); + } + + const rowTops: number[] = [gridPad]; + for (let r = 0; r < rowHeights.length; r++) { + rowTops[r + 1] = rowTops[r] + rowHeights[r]; + } + + const positions: Record = {}; + for (let i = 0; i < cards.length; i++) { + const col = i % columns; + const row = Math.floor(i / columns); + positions[cards[i].instanceId] = { + x: xOffset + gridPad + col * columnWidth, + y: rowTops[row], + }; + } + + return { + columns, + cardWidth, + gridWidth, + xOffset, + positions, + totalHeight: rowTops[rowHeights.length] + gridPad, + }; +} diff --git a/src/settings/constants.ts b/src/settings/constants.ts index 7828726..44c4624 100644 --- a/src/settings/constants.ts +++ b/src/settings/constants.ts @@ -1,5 +1,5 @@ import type { IoniconName } from '../components/Ionicon'; -import { CardSize, ColorScheme } from '../context/ui-settings'; +import type { CardSize, ColorScheme } from '../context/ui-settings'; export type Category = 'display' | 'voice' | 'data' | 'files' | 'ai'; diff --git a/src/settings/files-settings-category.test.ts b/src/settings/files-settings-category.test.ts new file mode 100644 index 0000000..6f32db5 --- /dev/null +++ b/src/settings/files-settings-category.test.ts @@ -0,0 +1,142 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('react-native', () => ({ + Alert: { alert: vi.fn() }, + Platform: { OS: 'web' }, +})); + +import { + createFilesSettingsCategoryController, + type FilesSettingsCategoryController, + type FilesSettingsCategoryControllerOptions, +} from './files-settings-category'; +import type { UploadedFile } from '../entities/providers/file-upload'; + +const controllers: FilesSettingsCategoryController[] = []; + +function makeUpload(id: string, name: string, content = `# ${name}`): UploadedFile { + return { id, name, content }; +} + +function createController(options: FilesSettingsCategoryControllerOptions) { + const controller = createFilesSettingsCategoryController(options); + controllers.push(controller); + return controller; +} + +describe('files settings category controller', () => { + afterEach(() => { + controllers.splice(0).forEach((controller) => controller.dispose()); + }); + + it('stores pasted content, refreshes uploads, and bumps the uploads version', async () => { + const events: string[] = []; + let uploads: UploadedFile[] = []; + const addUpload = vi.fn(async (name: string, content: string) => { + events.push(`add:${name}:${content}`); + uploads = [...uploads, makeUpload('pasted', name, content)]; + }); + const getUploads = vi.fn(async () => { + events.push('get'); + return uploads; + }); + const bumpUploads = vi.fn(() => events.push('bump')); + const controller = createController({ addUpload, getUploads, bumpUploads }); + + controller.setPasteFileName(' villains.md '); + controller.setPasteContent('# Lord Ember'); + await controller.addPastedContent(); + + expect(addUpload).toHaveBeenCalledWith('villains.md', '# Lord Ember'); + expect(events).toEqual(['add:villains.md:# Lord Ember', 'get', 'bump']); + expect(controller.getSnapshot()).toMatchObject({ + uploads: [makeUpload('pasted', 'villains.md', '# Lord Ember')], + pasteFileName: '', + pasteContent: '', + }); + }); + + it('chooses web files through the picker and stores every selected file', async () => { + let uploads: UploadedFile[] = []; + const addUpload = vi.fn(async (name: string, content: string) => { + uploads = [...uploads, makeUpload(name, name, content)]; + }); + const getUploads = vi.fn(async () => uploads); + const bumpUploads = vi.fn(); + const controller = createController({ + addUpload, + getUploads, + bumpUploads, + pickFiles: vi.fn(async () => [ + { name: 'npc.md', text: async () => '# NPC' }, + { name: 'items.json', text: async () => '[{"name":"Moonblade"}]' }, + ]), + }); + + await controller.pickFilesWeb(); + + expect(addUpload).toHaveBeenNthCalledWith(1, 'npc.md', '# NPC'); + expect(addUpload).toHaveBeenNthCalledWith(2, 'items.json', '[{"name":"Moonblade"}]'); + expect(controller.getSnapshot().uploads.map((upload) => upload.name)).toEqual(['npc.md', 'items.json']); + expect(bumpUploads).toHaveBeenCalledTimes(1); + }); + + it('removes an upload with pending row state and refreshes the session upload version', async () => { + let uploads = [makeUpload('keep', 'keep.md'), makeUpload('drop', 'drop.md')]; + const bumpUploads = vi.fn(); + const controller = createController({ + getUploads: vi.fn(async () => uploads), + removeUpload: vi.fn(async (id: string) => { + uploads = uploads.filter((upload) => upload.id !== id); + }), + bumpUploads, + }); + + await controller.load(); + bumpUploads.mockClear(); + const deleteUpload = controller.deleteUpload('drop'); + + expect(controller.getSnapshot().removingUploadId).toBe('drop'); + await deleteUpload; + expect(controller.getSnapshot().uploads.map((upload) => upload.id)).toEqual(['keep']); + expect(controller.getSnapshot().removingUploadId).toBeNull(); + expect(bumpUploads).toHaveBeenCalledTimes(1); + }); + + it('deletes all local app data through the files category reset path', async () => { + const events: string[] = []; + let finishReset!: () => void; + const controller = createController({ + confirmDeleteAllData: vi.fn(async () => { + events.push('confirm'); + return true; + }), + getUploads: vi.fn(async () => [makeUpload('old', 'old.md')]), + resetStoredAppData: vi.fn(() => new Promise((resolve) => { + events.push('reset-storage'); + finishReset = resolve; + })), + stopSession: vi.fn(() => events.push('stop-session')), + onDeleteAllDataReset: vi.fn(() => events.push('reset-settings')), + }); + await controller.load(); + controller.setPasteFileName('scratch.md'); + controller.setPasteContent('# Scratch'); + + const deletion = controller.deleteAllData(); + await Promise.resolve(); + + expect(controller.getSnapshot().deleteAllPending).toBe(true); + finishReset(); + await deletion; + + expect(events).toEqual(['confirm', 'stop-session', 'reset-storage', 'reset-settings']); + expect(controller.getSnapshot()).toMatchObject({ + uploads: [], + pasteFileName: '', + pasteContent: '', + deleteAllPending: false, + deleteAllStatus: 'All local app data was deleted.', + }); + }); +}); diff --git a/src/settings/files-settings-category.ts b/src/settings/files-settings-category.ts new file mode 100644 index 0000000..ef49241 --- /dev/null +++ b/src/settings/files-settings-category.ts @@ -0,0 +1,299 @@ +import { useCallback, useEffect, useRef, useState, type Dispatch, type SetStateAction } from 'react'; +import { Alert, Platform } from 'react-native'; + +import { + addUpload as addStoredUpload, + getUploads as getStoredUploads, + removeUpload as removeStoredUpload, + type UploadedFile, +} from '../entities/providers/file-upload'; +import { resetStoredAppData as resetStoredLocalAppData } from '../storage/app-data'; + +const DELETE_ALL_MESSAGE = 'This deletes uploads, pasted content, AI parsed files, saved settings, API keys, source URLs, cached SRD data, and the current session on this device.'; +const PASTED_CONTENT_FILE_NAME = 'Pasted Content.md'; + +type FilesSettingsListener = (snapshot: FilesSettingsCategorySnapshot) => void; +type MaybePromise = T | Promise; + +export interface PickedTextFile { + name: string; + text: () => Promise; +} + +export interface FilesSettingsCategorySnapshot { + uploads: UploadedFile[]; + removingUploadId: string | null; + pasteFileName: string; + pasteContent: string; + deleteAllPending: boolean; + deleteAllStatus: string; +} + +export interface FilesSettingsCategoryControllerOptions { + getUploads?: () => Promise; + addUpload?: (name: string, content: string) => MaybePromise; + removeUpload?: (id: string) => MaybePromise; + bumpUploads?: () => void; + pickFiles?: () => Promise; + confirmDeleteAllData?: () => Promise; + resetStoredAppData?: () => Promise; + stopSession?: () => void; + onDeleteAllDataReset?: () => void; +} + +export interface FilesSettingsCategoryController { + getSnapshot(): FilesSettingsCategorySnapshot; + subscribe(listener: FilesSettingsListener): () => void; + load(): Promise; + setPasteFileName(update: SetStateAction): void; + setPasteContent(update: SetStateAction): void; + saveUpload(name: string, content: string): Promise; + pickFilesWeb(): Promise; + addPastedContent(): Promise; + deleteUpload(id: string): Promise; + deleteAllData(): Promise; + reset(): void; + dispose(): void; +} + +class DefaultFilesSettingsCategoryController implements FilesSettingsCategoryController { + private readonly getUploads: () => Promise; + private readonly addUpload: (name: string, content: string) => MaybePromise; + private readonly removeUpload: (id: string) => MaybePromise; + private readonly bumpUploads: () => void; + private readonly pickFiles: () => Promise; + private readonly confirmDeleteAllData: () => Promise; + private readonly resetStoredAppData: () => Promise; + private readonly stopSession: () => void; + private readonly onDeleteAllDataReset: () => void; + private readonly listeners = new Set(); + private snapshot: FilesSettingsCategorySnapshot = createDefaultSnapshot(); + private refreshGeneration = 0; + private disposed = false; + + constructor(options: FilesSettingsCategoryControllerOptions = {}) { + this.getUploads = options.getUploads ?? getStoredUploads; + this.addUpload = options.addUpload ?? addStoredUpload; + this.removeUpload = options.removeUpload ?? removeStoredUpload; + this.bumpUploads = options.bumpUploads ?? noop; + this.pickFiles = options.pickFiles ?? pickFilesWithWebInput; + this.confirmDeleteAllData = options.confirmDeleteAllData ?? confirmDeleteAllData; + this.resetStoredAppData = options.resetStoredAppData ?? resetStoredLocalAppData; + this.stopSession = options.stopSession ?? noop; + this.onDeleteAllDataReset = options.onDeleteAllDataReset ?? noop; + } + + getSnapshot(): FilesSettingsCategorySnapshot { + return this.snapshot; + } + + subscribe(listener: FilesSettingsListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + async load(): Promise { + await this.refreshUploads(); + } + + setPasteFileName(update: SetStateAction): void { + this.updateSnapshot({ pasteFileName: resolveStringUpdate(update, this.snapshot.pasteFileName) }); + } + + setPasteContent(update: SetStateAction): void { + this.updateSnapshot({ pasteContent: resolveStringUpdate(update, this.snapshot.pasteContent) }); + } + + async saveUpload(name: string, content: string): Promise { + await this.addUpload(name, content); + await this.refreshUploads(); + } + + async pickFilesWeb(): Promise { + const files = await this.pickFiles(); + await Promise.all(files.map(async (file) => { + await this.addUpload(file.name, await file.text()); + })); + await this.refreshUploads(); + } + + async addPastedContent(): Promise { + const content = this.snapshot.pasteContent; + if (!content.trim()) return; + + const name = this.snapshot.pasteFileName.trim() || PASTED_CONTENT_FILE_NAME; + await this.addUpload(name, content); + this.updateSnapshot({ pasteFileName: '', pasteContent: '' }); + await this.refreshUploads(); + } + + async deleteUpload(id: string): Promise { + this.updateSnapshot({ removingUploadId: id }); + try { + await this.removeUpload(id); + await this.refreshUploads(); + } finally { + if (!this.disposed && this.snapshot.removingUploadId === id) { + this.updateSnapshot({ removingUploadId: null }); + } + } + } + + async deleteAllData(): Promise { + const confirmed = await this.confirmDeleteAllData(); + if (this.disposed || !confirmed) return; + + this.updateSnapshot({ deleteAllPending: true, deleteAllStatus: '' }); + try { + this.stopSession(); + await this.resetStoredAppData(); + if (this.disposed) return; + + this.onDeleteAllDataReset(); + this.refreshGeneration += 1; + this.updateSnapshot({ + uploads: [], + removingUploadId: null, + pasteFileName: '', + pasteContent: '', + deleteAllStatus: 'All local app data was deleted.', + }); + } catch (e: unknown) { + if (!this.disposed) { + this.updateSnapshot({ deleteAllStatus: `Delete failed: ${e instanceof Error ? e.message : String(e)}` }); + } + } finally { + if (!this.disposed) this.updateSnapshot({ deleteAllPending: false }); + } + } + + reset(): void { + this.refreshGeneration += 1; + this.replaceSnapshot(createDefaultSnapshot()); + } + + dispose(): void { + this.disposed = true; + this.refreshGeneration += 1; + this.listeners.clear(); + } + + private async refreshUploads(): Promise { + const generation = ++this.refreshGeneration; + const uploads = await this.getUploads(); + if (this.disposed || generation !== this.refreshGeneration) return; + + this.updateSnapshot({ uploads }); + this.bumpUploads(); + } + + private updateSnapshot(patch: Partial): void { + this.replaceSnapshot({ ...this.snapshot, ...patch }); + } + + private replaceSnapshot(snapshot: FilesSettingsCategorySnapshot): void { + this.snapshot = snapshot; + this.listeners.forEach((listener) => listener(this.snapshot)); + } +} + +function createDefaultSnapshot(): FilesSettingsCategorySnapshot { + return { + uploads: [], + removingUploadId: null, + pasteFileName: '', + pasteContent: '', + deleteAllPending: false, + deleteAllStatus: '', + }; +} + +function noop(): void {} + +function resolveStringUpdate(update: SetStateAction, current: string): string { + return typeof update === 'function' ? update(current) : update; +} + +function pickFilesWithWebInput(): Promise { + if (Platform.OS !== 'web' || typeof document === 'undefined') return Promise.resolve([]); + + return new Promise((resolve) => { + const input = document.createElement('input'); + input.type = 'file'; + input.multiple = true; + input.accept = '.md,.txt,.json'; + input.onchange = () => { + const files = Array.from(input.files ?? [], (file) => ({ + name: file.name, text: () => file.text(), + })); + input.onchange = null; + resolve(files); + }; + input.click(); + }); +} + +function confirmDeleteAllData(): Promise { + if (Platform.OS === 'web' && typeof window !== 'undefined') { + return Promise.resolve(window.confirm(`Delete all local app data?\n\n${DELETE_ALL_MESSAGE}`)); + } + + return new Promise((resolve) => { + Alert.alert( + 'Delete all local app data?', + DELETE_ALL_MESSAGE, + [ + { text: 'Cancel', style: 'cancel', onPress: () => resolve(false) }, + { text: 'Delete All Data', style: 'destructive', onPress: () => resolve(true) }, + ], + { cancelable: true, onDismiss: () => resolve(false) }, + ); + }); +} + +export function createFilesSettingsCategoryController( + options: FilesSettingsCategoryControllerOptions = {}, +): FilesSettingsCategoryController { + return new DefaultFilesSettingsCategoryController(options); +} + +export function useFilesSettingsCategory(options: FilesSettingsCategoryControllerOptions) { + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = createFilesSettingsCategoryController(options); + } + const controller = controllerRef.current; + const [snapshot, setSnapshot] = useState(() => controller.getSnapshot()); + + useEffect(() => controller.subscribe(setSnapshot), [controller]); + + useEffect(() => { + void controller.load(); + return () => controller.dispose(); + }, [controller]); + + const setPasteFileName = useCallback>>((update) => { + controller.setPasteFileName(update); + }, [controller]); + const setPasteContent = useCallback>>((update) => { + controller.setPasteContent(update); + }, [controller]); + + return { + uploads: snapshot.uploads, + removingUploadId: snapshot.removingUploadId, + pasteFileName: snapshot.pasteFileName, + setPasteFileName, + pasteContent: snapshot.pasteContent, + setPasteContent, + pickFilesWeb: useCallback(() => controller.pickFilesWeb(), [controller]), + handlePasteAdd: useCallback(() => controller.addPastedContent(), [controller]), + handleDeleteUpload: useCallback((id: string) => controller.deleteUpload(id), [controller]), + handleDeleteAllData: useCallback(() => controller.deleteAllData(), [controller]), + saveUpload: useCallback((name: string, content: string) => controller.saveUpload(name, content), [controller]), + deleteAllPending: snapshot.deleteAllPending, + deleteAllStatus: snapshot.deleteAllStatus, + }; +} diff --git a/src/settings/renderers/DisplaySection.tsx b/src/settings/renderers/DisplaySection.tsx index 2cdacdb..52dd3dd 100644 --- a/src/settings/renderers/DisplaySection.tsx +++ b/src/settings/renderers/DisplaySection.tsx @@ -1,19 +1,17 @@ import React from 'react'; import { Text, TouchableOpacity, View } from 'react-native'; -import { CardSize, ColorScheme } from '../../context/ui-settings'; +import { CARD_SIZES, COLOR_SCHEMES } from '../../context/ui-settings'; import { CARD_SIZE_DESCS, CARD_SIZE_LABELS, COLOR_SCHEME_LABELS } from '../constants'; import { DisplaySectionProps } from '../types'; export function DisplaySection({ cardSize, setCardSize, colorScheme, setColorScheme, styles }: DisplaySectionProps) { - const CARD_SIZE_CONFIGS = { S: {}, M: {}, L: {}, XL: {} }; - return ( CARD SIZE - {(Object.keys(CARD_SIZE_CONFIGS) as CardSize[]).map((size) => ( + {CARD_SIZES.map((size) => ( THEME - {(['system', 'dark', 'light'] as ColorScheme[]).map((scheme) => ( + {COLOR_SCHEMES.map((scheme) => ( ({ Platform: { OS: 'web' } })); + +import { DEFAULT_STT_SETTINGS, type STTSettings } from '../stt'; +import { + VOICE_SAVED_INDICATOR_MS, + createVoiceSettingsCategoryController, + type VoiceSettingsCategoryController, + type VoiceSettingsCategoryControllerOptions, +} from './voice-settings-category'; + +const controllers: VoiceSettingsCategoryController[] = []; + +function createController(options: VoiceSettingsCategoryControllerOptions) { + const controller = createVoiceSettingsCategoryController(options); + controllers.push(controller); + return controller; +} + +describe('voice settings category controller', () => { + afterEach(() => { + controllers.splice(0).forEach((controller) => controller.dispose()); + vi.useRealTimers(); + }); + + it('loads saved voice settings and saves the current category draft', async () => { + const savedSettings: STTSettings = { + provider: 'deepgram', + deepgramApiKey: 'saved-deepgram-key', + }; + const loadVoiceSettings = vi.fn(async () => savedSettings); + const saveVoiceSettings = vi.fn(async () => true); + const controller = createController({ loadVoiceSettings, saveVoiceSettings }); + + await controller.load(); + expect(controller.getSnapshot().sttSettings).toEqual(savedSettings); + + controller.setSttSettings((current) => ({ + ...current, + provider: 'web-speech', + })); + await controller.save(); + + expect(saveVoiceSettings).toHaveBeenCalledWith({ + provider: 'web-speech', + deepgramApiKey: 'saved-deepgram-key', + }); + expect(controller.getSnapshot().voiceSaved).toBe(true); + }); + + it('keeps newer draft changes when an earlier save finishes', async () => { + let finishSave: (saved: boolean) => void = () => {}; + const saveVoiceSettings = vi.fn(() => new Promise((resolve) => { + finishSave = resolve; + })); + const controller = createController({ + loadVoiceSettings: vi.fn(async () => DEFAULT_STT_SETTINGS), + saveVoiceSettings, + }); + + controller.setSttSettings({ + provider: 'deepgram', + deepgramApiKey: 'saved-key', + }); + const save = controller.save(); + controller.setSttSettings((current) => ({ + ...current, + deepgramApiKey: 'newer-draft-key', + })); + + finishSave(true); + await save; + + expect(saveVoiceSettings).toHaveBeenCalledWith({ + provider: 'deepgram', + deepgramApiKey: 'saved-key', + }); + expect(controller.getSnapshot().sttSettings).toEqual({ + provider: 'deepgram', + deepgramApiKey: 'newer-draft-key', + }); + expect(controller.getSnapshot().voiceSaved).toBe(true); + }); + + it('hides the saved indicator after the confirmation timeout', async () => { + vi.useFakeTimers(); + const controller = createController({ + loadVoiceSettings: vi.fn(async () => DEFAULT_STT_SETTINGS), + saveVoiceSettings: vi.fn(async () => true), + }); + + await controller.save(); + expect(controller.getSnapshot().voiceSaved).toBe(true); + + vi.advanceTimersByTime(VOICE_SAVED_INDICATOR_MS - 1); + expect(controller.getSnapshot().voiceSaved).toBe(true); + + vi.advanceTimersByTime(1); + expect(controller.getSnapshot().voiceSaved).toBe(false); + }); +}); diff --git a/src/settings/voice-settings-category.ts b/src/settings/voice-settings-category.ts new file mode 100644 index 0000000..bc7469e --- /dev/null +++ b/src/settings/voice-settings-category.ts @@ -0,0 +1,180 @@ +import { + useCallback, + useEffect, + useRef, + useState, + type Dispatch, + type SetStateAction, +} from 'react'; +import { Platform } from 'react-native'; + +import { + createDefaultVoiceSettings, + loadVoiceSettings as loadStoredVoiceSettings, + mergeVoiceSettings, + saveVoiceSettings as saveStoredVoiceSettings, +} from '../storage/app-data'; +import type { STTSettings } from '../stt'; + +export const VOICE_SAVED_INDICATOR_MS = 2000; + +type SavedTimer = ReturnType; +type VoiceSettingsListener = (snapshot: VoiceSettingsCategorySnapshot) => void; + +export interface VoiceSettingsCategorySnapshot { + sttSettings: STTSettings; + voiceSaved: boolean; +} + +export interface VoiceSettingsCategoryControllerOptions { + loadVoiceSettings?: () => Promise; + saveVoiceSettings?: (settings: STTSettings) => Promise; + setSavedTimer?: (callback: () => void, ms: number) => SavedTimer; + clearSavedTimer?: (timer: SavedTimer) => void; +} + +export interface VoiceSettingsCategoryController { + getSnapshot(): VoiceSettingsCategorySnapshot; + subscribe(listener: VoiceSettingsListener): () => void; + load(): Promise; + setSttSettings(update: SetStateAction): void; + save(): Promise; + reset(): void; + dispose(): void; +} + +class DefaultVoiceSettingsCategoryController implements VoiceSettingsCategoryController { + private readonly loadVoiceSettings: () => Promise; + private readonly saveVoiceSettings: (settings: STTSettings) => Promise; + private readonly setSavedTimer: (callback: () => void, ms: number) => SavedTimer; + private readonly clearSavedTimer: (timer: SavedTimer) => void; + private readonly listeners = new Set(); + private snapshot: VoiceSettingsCategorySnapshot = { + sttSettings: createDefaultVoiceSettings(), + voiceSaved: false, + }; + private savedTimer: SavedTimer | null = null; + private loadGeneration = 0; + private disposed = false; + + constructor(options: VoiceSettingsCategoryControllerOptions = {}) { + this.loadVoiceSettings = options.loadVoiceSettings ?? loadStoredVoiceSettings; + this.saveVoiceSettings = options.saveVoiceSettings ?? saveStoredVoiceSettings; + this.setSavedTimer = options.setSavedTimer ?? setTimeout; + this.clearSavedTimer = options.clearSavedTimer ?? clearTimeout; + } + + getSnapshot(): VoiceSettingsCategorySnapshot { + return this.snapshot; + } + + subscribe(listener: VoiceSettingsListener): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + async load(): Promise { + const generation = ++this.loadGeneration; + const loadedSettings = await this.loadVoiceSettings(); + if (this.disposed || generation !== this.loadGeneration || !loadedSettings) return; + + this.updateSnapshot({ sttSettings: mergeVoiceSettings(loadedSettings) }); + } + + setSttSettings(update: SetStateAction): void { + const nextSettings = typeof update === 'function' + ? update(this.snapshot.sttSettings) + : update; + + this.updateSnapshot({ sttSettings: mergeVoiceSettings(nextSettings) }); + } + + async save(): Promise { + const settingsToSave = mergeVoiceSettings(this.snapshot.sttSettings); + const saved = await this.saveVoiceSettings(settingsToSave); + if (this.disposed || !saved) return; + + this.updateSnapshot({ voiceSaved: true }); + this.restartSavedTimer(); + } + + reset(): void { + this.loadGeneration += 1; + this.clearSavedIndicatorTimer(); + this.replaceSnapshot({ + sttSettings: createDefaultVoiceSettings(), + voiceSaved: false, + }); + } + + dispose(): void { + this.disposed = true; + this.loadGeneration += 1; + this.clearSavedIndicatorTimer(); + this.listeners.clear(); + } + + private restartSavedTimer(): void { + this.clearSavedIndicatorTimer(); + this.savedTimer = this.setSavedTimer(() => { + this.savedTimer = null; + if (this.disposed) return; + this.updateSnapshot({ voiceSaved: false }); + }, VOICE_SAVED_INDICATOR_MS); + } + + private clearSavedIndicatorTimer(): void { + if (!this.savedTimer) return; + this.clearSavedTimer(this.savedTimer); + this.savedTimer = null; + } + + private updateSnapshot(patch: Partial): void { + this.replaceSnapshot({ ...this.snapshot, ...patch }); + } + + private replaceSnapshot(snapshot: VoiceSettingsCategorySnapshot): void { + this.snapshot = snapshot; + this.listeners.forEach((listener) => listener(this.snapshot)); + } +} + +export function createVoiceSettingsCategoryController( + options: VoiceSettingsCategoryControllerOptions = {}, +): VoiceSettingsCategoryController { + return new DefaultVoiceSettingsCategoryController(options); +} + +export function useVoiceSettingsCategory() { + const controllerRef = useRef(null); + if (!controllerRef.current) { + controllerRef.current = createVoiceSettingsCategoryController(); + } + const controller = controllerRef.current; + const [snapshot, setSnapshot] = useState(() => controller.getSnapshot()); + + useEffect(() => controller.subscribe(setSnapshot), [controller]); + + useEffect(() => { + void controller.load(); + return () => controller.dispose(); + }, [controller]); + + const setSttSettings = useCallback>>((update) => { + controller.setSttSettings(update); + }, [controller]); + + const saveVoice = useCallback(() => controller.save(), [controller]); + const resetVoiceSettings = useCallback(() => controller.reset(), [controller]); + + return { + sttSettings: snapshot.sttSettings, + setSttSettings, + saveVoice, + voiceSaved: snapshot.voiceSaved, + isWebSpeech: Platform.OS === 'web', + resetVoiceSettings, + }; +} diff --git a/src/storage/app-data-core.ts b/src/storage/app-data-core.ts new file mode 100644 index 0000000..f89f723 --- /dev/null +++ b/src/storage/app-data-core.ts @@ -0,0 +1,135 @@ +import AsyncStorage from '@react-native-async-storage/async-storage'; + +import { STT_SETTINGS_KEY } from '../stt'; +import { + CARD_SIZE_KEY, + COLOR_SCHEME_KEY, + DATA_SOURCES_KEY, + SRD_CACHE_KEY_PREFIX, + UPLOADS_KEY, +} from './keys'; + +const APP_STORAGE_PREFIXES = ['dndref:', '@dnd-ref/']; +const INVALID_APP_DATA_TOKEN = -1; + +let appDataResetGeneration = 0; +let appDataResetActive = false; +let cacheWritesBlockedForGeneration: number | null = null; +let appDataWriteQueue: Promise = Promise.resolve(); + +export const APP_STORAGE_KEYS = [ + DATA_SOURCES_KEY, + UPLOADS_KEY, + STT_SETTINGS_KEY, + CARD_SIZE_KEY, + COLOR_SCHEME_KEY, +]; + +export function isAppStorageKey(key: string): boolean { + return ( + APP_STORAGE_KEYS.includes(key) || + key.startsWith(SRD_CACHE_KEY_PREFIX) || + APP_STORAGE_PREFIXES.some((prefix) => key.startsWith(prefix)) + ); +} + +export function createAppDataWriteToken(): number { + return appDataResetActive ? INVALID_APP_DATA_TOKEN : appDataResetGeneration; +} + +export function isAppDataWriteTokenCurrent(token: number): boolean { + return token !== INVALID_APP_DATA_TOKEN && + token === appDataResetGeneration && + !appDataResetActive; +} + +export function canPersistAppData(token: number): boolean { + return isAppDataWriteTokenCurrent(token); +} + +export function canPersistAppDataCache(token: number): boolean { + return canPersistAppData(token) && cacheWritesBlockedForGeneration !== token; +} + +export function allowAppDataCacheWrites(): void { + cacheWritesBlockedForGeneration = null; +} + +export interface AppDataCacheSession { + getItem: (key: string) => Promise; + setItem: (key: string, value: string) => Promise; +} + +export function createAppDataCacheSession(): AppDataCacheSession { + const token = createAppDataWriteToken(); + return { + getItem: (key) => getAppDataItem(key, token), + setItem: (key, value) => setAppDataItem(key, value, { cache: true, token }), + }; +} + +export async function getAppDataItem( + key: string, + token = createAppDataWriteToken(), +): Promise { + const value = await AsyncStorage.getItem(key); + return isAppDataWriteTokenCurrent(token) ? value : null; +} + +export async function setAppDataItem( + key: string, + value: string, + options: { cache?: boolean; token?: number } = {}, +): Promise { + const { cache = false, token = createAppDataWriteToken() } = options; + const operation = appDataWriteQueue + .catch(() => undefined) + .then(async () => { + const canPersist = cache ? canPersistAppDataCache : canPersistAppData; + if (!canPersist(token)) return false; + + await AsyncStorage.setItem(key, value); + const saved = canPersist(token); + if (saved && !cache) allowAppDataCacheWrites(); + return saved; + }); + + appDataWriteQueue = operation.catch(() => undefined); + return operation; +} + +export async function waitForAppDataWrites(): Promise { + await appDataWriteQueue.catch(() => undefined); +} + +export function beginAppDataReset(): number { + appDataResetGeneration += 1; + appDataResetActive = true; + return appDataResetGeneration; +} + +export function finishAppDataReset(generation: number): void { + if (generation !== appDataResetGeneration) return; + appDataResetActive = false; + cacheWritesBlockedForGeneration = generation; +} + +export async function getStoredAppDataKeys(): Promise { + const keys = await AsyncStorage.getAllKeys(); + return Array.from(new Set(keys.filter(isAppStorageKey))); +} + +export async function clearStoredAppData(): Promise { + const keys = await getStoredAppDataKeys(); + if (keys.length > 0) { + await AsyncStorage.multiRemove(keys); + } + return keys; +} + +export function resetAppDataCoreControlsForTests(): void { + appDataResetGeneration = 0; + appDataResetActive = false; + cacheWritesBlockedForGeneration = null; + appDataWriteQueue = Promise.resolve(); +} diff --git a/src/storage/app-data-settings.ts b/src/storage/app-data-settings.ts new file mode 100644 index 0000000..cff723a --- /dev/null +++ b/src/storage/app-data-settings.ts @@ -0,0 +1,142 @@ +import { DEFAULT_STT_SETTINGS, STT_SETTINGS_KEY, type STTSettings } from '../stt'; +import { DATA_SOURCES_KEY } from './keys'; +import { getAppDataItem, setAppDataItem } from './app-data-core'; + +export interface DataSourcesSettings { + srdEnabled: boolean; + srdSources: string[]; + kankaToken: string; + kankaCampaignId: string; + homebreweryUrl: string; + notionToken: string; + notionPageIds: string; + googleDocsUrl: string; + aiApiKey: string; +} + +export const DEFAULT_DATA_SOURCES_SETTINGS: DataSourcesSettings = { + srdEnabled: true, + srdSources: ['wotc-srd'], + kankaToken: '', + kankaCampaignId: '', + homebreweryUrl: '', + notionToken: '', + notionPageIds: '', + googleDocsUrl: '', + aiApiKey: '', +}; + +export function createDefaultDataSourceSettings(): DataSourcesSettings { + return { + ...DEFAULT_DATA_SOURCES_SETTINGS, + srdSources: [...DEFAULT_DATA_SOURCES_SETTINGS.srdSources], + }; +} + +export function mergeDataSourceSettings( + settings?: Partial | null, +): DataSourcesSettings { + const patch = settings ?? {}; + const defaultSettings = createDefaultDataSourceSettings(); + const srdSources = Array.isArray(patch.srdSources) + ? [...patch.srdSources] + : defaultSettings.srdSources; + + return { + ...defaultSettings, + ...patch, + srdSources, + }; +} + +type VoiceSettingsPatch = Partial>; + +function isVoiceSettingsPatch(value: unknown): value is VoiceSettingsPatch { + return value !== null && typeof value === 'object'; +} + +function isVoiceProvider(value: unknown): value is STTSettings['provider'] { + return value === 'deepgram' || value === 'web-speech'; +} + +export function createDefaultVoiceSettings(): STTSettings { + return { ...DEFAULT_STT_SETTINGS }; +} + +function normalizeVoiceSettings(settings: unknown): STTSettings { + const patch = isVoiceSettingsPatch(settings) ? settings : {}; + const defaultSettings = createDefaultVoiceSettings(); + + const provider = isVoiceProvider(patch.provider) + ? patch.provider + : defaultSettings.provider; + const deepgramApiKey = typeof patch.deepgramApiKey === 'string' + ? patch.deepgramApiKey + : defaultSettings.deepgramApiKey; + + return { provider, deepgramApiKey }; +} + +export function mergeVoiceSettings(settings?: Partial | null): STTSettings { + return normalizeVoiceSettings(settings); +} + +export async function loadVoiceSettings(): Promise { + let raw: string | null; + try { + raw = await getAppDataItem(STT_SETTINGS_KEY); + } catch (e) { + console.warn('[dnd-ref] Failed to load voice settings:', e); + return null; + } + + if (!raw) return null; + + try { + return normalizeVoiceSettings(JSON.parse(raw)); + } catch (parseErr) { + console.warn('[dnd-ref] Failed to parse voice settings:', parseErr); + return null; + } +} + +export async function saveVoiceSettings(settings: STTSettings): Promise { + const serializedSettings = JSON.stringify(mergeVoiceSettings(settings)); + + try { + return await setAppDataItem(STT_SETTINGS_KEY, serializedSettings); + } catch (e) { + console.warn('[dnd-ref] Failed to save voice settings:', e); + return false; + } +} + +export async function loadDataSourceSettings(): Promise { + let raw: string | null; + try { + raw = await getAppDataItem(DATA_SOURCES_KEY); + } catch (e) { + console.warn('[dnd-ref] Failed to load data source settings:', e); + return null; + } + + if (!raw) return null; + + try { + return mergeDataSourceSettings(JSON.parse(raw) as Partial); + } catch (parseErr) { + console.warn('[dnd-ref] Failed to parse data source settings:', parseErr); + return null; + } +} + +export async function saveDataSourceSettings(settings: DataSourcesSettings): Promise { + const serializedSettings = JSON.stringify(settings); + + try { + return await setAppDataItem(DATA_SOURCES_KEY, serializedSettings); + } catch (e) { + console.warn('[dnd-ref] Failed to save data source settings:', e); + return false; + } +} diff --git a/src/storage/app-data-uploads.ts b/src/storage/app-data-uploads.ts new file mode 100644 index 0000000..ebc54af --- /dev/null +++ b/src/storage/app-data-uploads.ts @@ -0,0 +1,81 @@ +import { UPLOADS_KEY } from './keys'; +import { + canPersistAppData, + createAppDataWriteToken, + getAppDataItem, + setAppDataItem, +} from './app-data-core'; + +export interface UploadedFile { + id: string; + name: string; + content: string; +} + +let uploadMutationQueue: Promise = Promise.resolve(); + +export async function getUploadedFiles(): Promise { + return readUploadedFiles(createAppDataWriteToken()); +} + +export async function addUploadedFile(name: string, content: string): Promise { + return mutateUploadedFiles((uploads) => [...uploads, createUploadedFile(name, content)]); +} + +export async function removeUploadedFile(id: string): Promise { + return mutateUploadedFiles((uploads) => uploads.filter((u) => u.id !== id)); +} + +export async function waitForUploadedFileMutations(): Promise { + await uploadMutationQueue.catch(() => undefined); +} + +function createUploadedFile(name: string, content: string): UploadedFile { + return { + id: `upload-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + name, + content, + }; +} + +function isUploadedFile(value: unknown): value is UploadedFile { + if (!value || typeof value !== 'object') return false; + + const file = value as Record; + return typeof file.id === 'string' && + typeof file.name === 'string' && + typeof file.content === 'string'; +} + +async function readUploadedFiles(token: number): Promise { + try { + const raw = await getAppDataItem(UPLOADS_KEY, token); + if (!raw) return []; + const parsed = JSON.parse(raw) as unknown; + return Array.isArray(parsed) ? parsed.filter(isUploadedFile) : []; + } catch (e) { + console.warn('[dnd-ref] Failed to read uploads from storage:', e); + return []; + } +} + +function mutateUploadedFiles(mutator: (uploads: UploadedFile[]) => UploadedFile[]): Promise { + const token = createAppDataWriteToken(); + const operation = uploadMutationQueue + .catch(() => undefined) + .then(async () => { + if (!canPersistAppData(token)) return false; + const currentUploads = await readUploadedFiles(token); + if (!canPersistAppData(token)) return false; + + const nextUploads = mutator(currentUploads); + return setAppDataItem(UPLOADS_KEY, JSON.stringify(nextUploads), { token }); + }); + + uploadMutationQueue = operation.catch(() => undefined); + return operation; +} + +export function resetUploadedFileMutationQueueForTests(): void { + uploadMutationQueue = Promise.resolve(); +} diff --git a/src/storage/app-data.test.ts b/src/storage/app-data.test.ts index 26d8f45..2608e7e 100644 --- a/src/storage/app-data.test.ts +++ b/src/storage/app-data.test.ts @@ -25,15 +25,24 @@ vi.mock('@react-native-async-storage/async-storage', () => ({ import { APP_STORAGE_KEYS, + DEFAULT_DATA_SOURCES_SETTINGS, + addUploadedFile, allowAppDataCacheWrites, beginAppDataReset, canPersistAppDataCache, + createAppDataCacheSession, createAppDataWriteToken, finishAppDataReset, getAppDataItem, + getUploadedFiles, isAppStorageKey, + loadDataSourceSettings, + loadVoiceSettings, + removeUploadedFile, resetAppDataControlsForTests, resetStoredAppData, + saveDataSourceSettings, + saveVoiceSettings, setAppDataItem, } from './app-data'; import { @@ -43,7 +52,15 @@ import { SRD_CACHE_KEY_PREFIX, UPLOADS_KEY, } from './keys'; -import { STT_SETTINGS_KEY } from '../stt'; +import { DEFAULT_STT_SETTINGS, STT_SETTINGS_KEY } from '../stt'; + +function blockStorageOperation(operation: keyof typeof storageControls): () => void { + let releaseGate = () => {}; + storageControls[operation] = new Promise((resolve) => { + releaseGate = resolve; + }); + return releaseGate; +} describe('app data storage clearing', () => { beforeEach(() => { @@ -92,6 +109,32 @@ describe('app data storage clearing', () => { ].sort()); }); + it('adds, removes, and reads uploaded files through the local app data seam', async () => { + await expect(addUploadedFile('one.md', '# One')).resolves.toBe(true); + await expect(addUploadedFile('two.md', '# Two')).resolves.toBe(true); + + const [first] = await getUploadedFiles(); + await expect(removeUploadedFile(first.id)).resolves.toBe(true); + + expect((await getUploadedFiles()).map((u) => u.name)).toEqual(['two.md']); + }); + + it('waits for in-flight upload mutations before clearing storage', async () => { + const releaseGetItem = blockStorageOperation('getItemGate'); + + const upload = addUploadedFile('late.md', '# Late'); + await Promise.resolve(); + + const reset = resetStoredAppData(); + releaseGetItem(); + + await Promise.all([upload, reset]); + storageControls.getItemGate = null; + + expect(await getUploadedFiles()).toEqual([]); + expect(storage.get('unrelated:other-app')).toBe('keep'); + }); + it('invalidates stale async write tokens after a reset starts', () => { const token = createAppDataWriteToken(); const generation = beginAppDataReset(); @@ -113,11 +156,24 @@ describe('app data storage clearing', () => { expect(canPersistAppDataCache(token)).toBe(true); }); + it('routes cache writes through sessions and re-allows them after non-cache app data writes', async () => { + await resetStoredAppData(); + const blockedCache = createAppDataCacheSession(); + const blockedKey = `${SRD_CACHE_KEY_PREFIX}blocked`; + const allowedKey = `${SRD_CACHE_KEY_PREFIX}allowed`; + + await expect(blockedCache.setItem(blockedKey, '{}')).resolves.toBe(false); + expect(storage.has(blockedKey)).toBe(false); + + await expect(setAppDataItem(CARD_SIZE_KEY, 'L')).resolves.toBe(true); + + const allowedCache = createAppDataCacheSession(); + await expect(allowedCache.setItem(allowedKey, '{}')).resolves.toBe(true); + expect(storage.get(allowedKey)).toBe('{}'); + }); + it('waits for in-flight settings writes before clearing storage', async () => { - let releaseSetItem!: () => void; - storageControls.setItemGate = new Promise((resolve) => { - releaseSetItem = resolve; - }); + const releaseSetItem = blockStorageOperation('setItemGate'); const write = setAppDataItem(DATA_SOURCES_KEY, '{"aiApiKey":"secret"}'); await Promise.resolve(); @@ -132,10 +188,7 @@ describe('app data storage clearing', () => { }); it('ignores stale hydration reads that resolve after reset starts', async () => { - let releaseGetItem!: () => void; - storageControls.getItemGate = new Promise((resolve) => { - releaseGetItem = resolve; - }); + const releaseGetItem = blockStorageOperation('getItemGate'); const read = getAppDataItem(STT_SETTINGS_KEY); await Promise.resolve(); @@ -146,4 +199,86 @@ describe('app data storage clearing', () => { expect(await read).toBeNull(); }); + + it('loads and saves voice settings through the local app data seam', async () => { + const settings = { + provider: 'deepgram' as const, + deepgramApiKey: 'voice-secret', + }; + + await expect(saveVoiceSettings(settings)).resolves.toBe(true); + expect(JSON.parse(storage.get(STT_SETTINGS_KEY)!)).toEqual(settings); + await expect(loadVoiceSettings()).resolves.toEqual(settings); + }); + + it('validates loaded voice settings through the local app data seam', async () => { + storage.set(STT_SETTINGS_KEY, JSON.stringify({ + provider: 'bogus-provider', + deepgramApiKey: 42, + })); + + await expect(loadVoiceSettings()).resolves.toEqual(DEFAULT_STT_SETTINGS); + }); + + it('loads data source settings through the local app data seam', async () => { + storage.set(DATA_SOURCES_KEY, JSON.stringify({ + srdEnabled: false, + kankaToken: 'kanka-secret', + srdSources: ['kobold-press-tob'], + })); + + await expect(loadDataSourceSettings()).resolves.toEqual({ + ...DEFAULT_DATA_SOURCES_SETTINGS, + srdEnabled: false, + kankaToken: 'kanka-secret', + srdSources: ['kobold-press-tob'], + }); + }); + + it('saves data source settings through the local app data seam', async () => { + await resetStoredAppData(); + const cacheWriteToken = createAppDataWriteToken(); + const settings = { + ...DEFAULT_DATA_SOURCES_SETTINGS, + googleDocsUrl: 'https://docs.google.com/document/d/campaign', + }; + + expect(canPersistAppDataCache(cacheWriteToken)).toBe(false); + await expect(saveDataSourceSettings(settings)).resolves.toBe(true); + + expect(JSON.parse(storage.get(DATA_SOURCES_KEY)!)).toEqual(settings); + expect(canPersistAppDataCache(cacheWriteToken)).toBe(true); + }); + + it('drops stale data source settings hydration through the local app data seam', async () => { + storage.set(DATA_SOURCES_KEY, JSON.stringify({ aiApiKey: 'stale-secret' })); + const releaseGetItem = blockStorageOperation('getItemGate'); + + const read = loadDataSourceSettings(); + await Promise.resolve(); + + const generation = beginAppDataReset(); + finishAppDataReset(generation); + releaseGetItem(); + + await expect(read).resolves.toBeNull(); + }); + + it('drops stale data source settings writes through the local app data seam', async () => { + const releaseSetItem = blockStorageOperation('setItemGate'); + + const write = saveDataSourceSettings({ + ...DEFAULT_DATA_SOURCES_SETTINGS, + aiApiKey: 'secret', + }); + await Promise.resolve(); + + const reset = resetStoredAppData(); + releaseSetItem(); + + await expect(write).resolves.toBe(false); + await reset; + + expect(storage.get(DATA_SOURCES_KEY)).toBeUndefined(); + }); }); diff --git a/src/storage/app-data.ts b/src/storage/app-data.ts index ca3eead..f75a32a 100644 --- a/src/storage/app-data.ts +++ b/src/storage/app-data.ts @@ -1,105 +1,59 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; +export { + APP_STORAGE_KEYS, + allowAppDataCacheWrites, + beginAppDataReset, + canPersistAppData, + canPersistAppDataCache, + clearStoredAppData, + createAppDataCacheSession, + createAppDataWriteToken, + finishAppDataReset, + getAppDataItem, + getStoredAppDataKeys, + isAppDataWriteTokenCurrent, + isAppStorageKey, + setAppDataItem, + waitForAppDataWrites, + type AppDataCacheSession, +} from './app-data-core'; +export { + DEFAULT_DATA_SOURCES_SETTINGS, + createDefaultDataSourceSettings, + createDefaultVoiceSettings, + loadDataSourceSettings, + loadVoiceSettings, + mergeDataSourceSettings, + mergeVoiceSettings, + saveDataSourceSettings, + saveVoiceSettings, + type DataSourcesSettings, +} from './app-data-settings'; +export { + addUploadedFile, + getUploadedFiles, + removeUploadedFile, + waitForUploadedFileMutations, + type UploadedFile, +} from './app-data-uploads'; -import { STT_SETTINGS_KEY } from '../stt'; import { - CARD_SIZE_KEY, - COLOR_SCHEME_KEY, - DATA_SOURCES_KEY, - SRD_CACHE_KEY_PREFIX, - UPLOADS_KEY, -} from './keys'; - -const APP_STORAGE_PREFIXES = ['dndref:', '@dnd-ref/']; -const INVALID_APP_DATA_TOKEN = -1; - -let appDataResetGeneration = 0; -let appDataResetActive = false; -let cacheWritesBlockedForGeneration: number | null = null; -let appDataWriteQueue: Promise = Promise.resolve(); - -export const APP_STORAGE_KEYS = [ - DATA_SOURCES_KEY, - UPLOADS_KEY, - STT_SETTINGS_KEY, - CARD_SIZE_KEY, - COLOR_SCHEME_KEY, -]; - -export function isAppStorageKey(key: string): boolean { - return ( - APP_STORAGE_KEYS.includes(key) || - key.startsWith(SRD_CACHE_KEY_PREFIX) || - APP_STORAGE_PREFIXES.some((prefix) => key.startsWith(prefix)) - ); -} - -export function createAppDataWriteToken(): number { - return appDataResetActive ? INVALID_APP_DATA_TOKEN : appDataResetGeneration; -} - -export function isAppDataWriteTokenCurrent(token: number): boolean { - return token !== INVALID_APP_DATA_TOKEN && - token === appDataResetGeneration && - !appDataResetActive; -} - -export function canPersistAppData(token: number): boolean { - return isAppDataWriteTokenCurrent(token); -} - -export function canPersistAppDataCache(token: number): boolean { - return canPersistAppData(token) && cacheWritesBlockedForGeneration !== token; -} - -export function allowAppDataCacheWrites(): void { - cacheWritesBlockedForGeneration = null; -} - -export async function getAppDataItem(key: string, token = createAppDataWriteToken()): Promise { - const value = await AsyncStorage.getItem(key); - return isAppDataWriteTokenCurrent(token) ? value : null; -} - -export async function setAppDataItem( - key: string, - value: string, - options: { cache?: boolean; token?: number } = {}, -): Promise { - const token = options.token ?? createAppDataWriteToken(); - const operation = appDataWriteQueue - .catch(() => undefined) - .then(async () => { - const canPersist = options.cache ? canPersistAppDataCache : canPersistAppData; - if (!canPersist(token)) return false; - await AsyncStorage.setItem(key, value); - return canPersist(token); - }); - - appDataWriteQueue = operation.catch(() => undefined); - return operation; -} - -export async function waitForAppDataWrites(): Promise { - await appDataWriteQueue.catch(() => undefined); -} - -export function beginAppDataReset(): number { - appDataResetGeneration += 1; - appDataResetActive = true; - return appDataResetGeneration; -} - -export function finishAppDataReset(generation: number): void { - if (generation !== appDataResetGeneration) return; - appDataResetActive = false; - cacheWritesBlockedForGeneration = generation; -} + beginAppDataReset, + clearStoredAppData, + finishAppDataReset, + resetAppDataCoreControlsForTests, + waitForAppDataWrites, +} from './app-data-core'; +import { + resetUploadedFileMutationQueueForTests, + waitForUploadedFileMutations, +} from './app-data-uploads'; export async function resetStoredAppData(options: { beforeClear?: () => Promise; } = {}): Promise { const generation = beginAppDataReset(); try { + await waitForUploadedFileMutations(); await options.beforeClear?.(); await waitForAppDataWrites(); return await clearStoredAppData(); @@ -108,22 +62,7 @@ export async function resetStoredAppData(options: { } } -export async function getStoredAppDataKeys(): Promise { - const keys = await AsyncStorage.getAllKeys(); - return Array.from(new Set(keys.filter(isAppStorageKey))); -} - -export async function clearStoredAppData(): Promise { - const keys = await getStoredAppDataKeys(); - if (keys.length > 0) { - await AsyncStorage.multiRemove(keys); - } - return keys; -} - export function resetAppDataControlsForTests(): void { - appDataResetGeneration = 0; - appDataResetActive = false; - cacheWritesBlockedForGeneration = null; - appDataWriteQueue = Promise.resolve(); + resetAppDataCoreControlsForTests(); + resetUploadedFileMutationQueueForTests(); } diff --git a/src/stt/build-provider.test.ts b/src/stt/build-provider.test.ts new file mode 100644 index 0000000..3fcfc9a --- /dev/null +++ b/src/stt/build-provider.test.ts @@ -0,0 +1,131 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +const storage = vi.hoisted(() => new Map()); +const platform = vi.hoisted(() => ({ OS: 'web' })); + +type MockProviderInstance = { + emitError(error: string): void; + emitTranscript(text: string): void; +}; + +const sttMocks = vi.hoisted(() => ({ + deepgramInstances: [] as MockProviderInstance[], + deepgramStartError: null as Error | null, + webSpeechInstances: [] as MockProviderInstance[], + webSpeechStartError: null as Error | null, +})); + +vi.mock('@react-native-async-storage/async-storage', () => ({ + default: { + getItem: vi.fn(async (key: string) => storage.get(key) ?? null), + setItem: vi.fn(async (key: string, value: string) => { + storage.set(key, value); + }), + }, +})); +vi.mock('react-native', () => ({ Platform: platform })); +vi.mock('./deepgram', () => ({ + DeepgramProvider: class { + readonly name = 'Deepgram'; + + constructor( + readonly apiKey: string, + readonly onTranscript: (text: string) => void, + readonly onError: (error: string) => void, + ) { + sttMocks.deepgramInstances.push(this); + } + + async start(): Promise { + if (sttMocks.deepgramStartError) throw sttMocks.deepgramStartError; + } + + pause(): void {} + resume(): void {} + stop(): void {} + + emitTranscript(text: string): void { this.onTranscript(text); } + emitError(error: string): void { this.onError(error); } + }, +})); +vi.mock('./web-speech', () => ({ + WebSpeechProvider: class { + readonly name = 'Web Speech'; + + constructor( + readonly onTranscript: (text: string) => void, + readonly onError: (error: string) => void, + ) { + sttMocks.webSpeechInstances.push(this); + } + + async start(): Promise { + if (sttMocks.webSpeechStartError) throw sttMocks.webSpeechStartError; + } + + pause(): void {} + resume(): void {} + stop(): void {} + + emitTranscript(text: string): void { this.onTranscript(text); } + emitError(error: string): void { this.onError(error); } + }, +})); + +import { buildProvider, loadSettings } from './build-provider'; +import { resetAppDataControlsForTests, saveVoiceSettings } from '../storage/app-data'; + +describe('STT provider settings', () => { + beforeEach(() => { + storage.clear(); + platform.OS = 'web'; + sttMocks.deepgramInstances.length = 0; + sttMocks.deepgramStartError = null; + sttMocks.webSpeechInstances.length = 0; + sttMocks.webSpeechStartError = null; + resetAppDataControlsForTests(); + vi.clearAllMocks(); + }); + + it('uses voice settings saved through local app data for the next provider selection', async () => { + const savedSettings = { + provider: 'deepgram' as const, + deepgramApiKey: 'saved-session-key', + }; + + await saveVoiceSettings(savedSettings); + const loadedSettings = await loadSettings(); + const provider = buildProvider(loadedSettings, vi.fn(), vi.fn()); + + expect(loadedSettings).toEqual(savedSettings); + expect(provider.name).toBe('Deepgram'); + }); + + it('wraps Web Speech so stopped capture events do not reach session callbacks', async () => { + const onTranscript = vi.fn(); + const onError = vi.fn(); + const provider = buildProvider({ provider: 'web-speech', deepgramApiKey: '' }, onTranscript, onError); + + await provider.start(); + sttMocks.webSpeechInstances[0].emitTranscript('active speech'); + await provider.stop(); + sttMocks.webSpeechInstances[0].emitTranscript('late speech'); + sttMocks.webSpeechInstances[0].emitError('late error'); + + expect(onTranscript).toHaveBeenCalledTimes(1); + expect(onTranscript).toHaveBeenCalledWith('active speech'); + expect(onError).not.toHaveBeenCalled(); + }); + + it('still propagates Web Speech and Deepgram startup failures', async () => { + sttMocks.webSpeechStartError = new Error('browser mic denied'); + await expect( + buildProvider({ provider: 'web-speech', deepgramApiKey: '' }, vi.fn(), vi.fn()).start(), + ).rejects.toThrow('browser mic denied'); + + sttMocks.deepgramStartError = new Error('deepgram rejected key'); + await expect( + buildProvider({ provider: 'deepgram', deepgramApiKey: 'bad-key' }, vi.fn(), vi.fn()).start(), + ).rejects.toThrow('deepgram rejected key'); + }); +}); diff --git a/src/stt/build-provider.ts b/src/stt/build-provider.ts index fa43974..c9cfa64 100644 --- a/src/stt/build-provider.ts +++ b/src/stt/build-provider.ts @@ -1,24 +1,14 @@ -import AsyncStorage from '@react-native-async-storage/async-storage'; import { Platform } from 'react-native'; -import { DeepgramProvider } from '../stt/deepgram'; -import { DEFAULT_STT_SETTINGS, STT_SETTINGS_KEY, STTProvider, STTSettings } from '../stt/index'; -import { WebSpeechProvider } from '../stt/web-speech'; +import { DeepgramProvider } from './deepgram'; +import { createLateEventSafeSTTProvider, type STTProviderFactory } from './lifecycle-safe-provider'; +import { WebSpeechProvider } from './web-speech'; +import { createDefaultVoiceSettings, loadVoiceSettings } from '../storage/app-data'; + +import type { STTProvider, STTSettings } from './index'; export async function loadSettings(): Promise { - try { - const raw = await AsyncStorage.getItem(STT_SETTINGS_KEY); - if (raw) { - try { - return { ...DEFAULT_STT_SETTINGS, ...(JSON.parse(raw) as Partial) }; - } catch (parseErr) { - console.warn('[dnd-ref] Failed to parse STT settings, using defaults:', parseErr); - } - } - } catch (e) { - console.warn('[dnd-ref] Failed to load STT settings, using defaults:', e); - } - return DEFAULT_STT_SETTINGS; + return (await loadVoiceSettings()) ?? createDefaultVoiceSettings(); } export function buildProvider( @@ -26,11 +16,26 @@ export function buildProvider( onTranscript: (text: string) => void, onError: (error: string) => void, ): STTProvider { - if (Platform.OS !== 'web') { - return new DeepgramProvider(settings.deepgramApiKey, onTranscript, onError); - } - if (settings.provider === 'deepgram' && settings.deepgramApiKey) { - return new DeepgramProvider(settings.deepgramApiKey, onTranscript, onError); + return createLateEventSafeSTTProvider( + createProviderFactory(settings), + onTranscript, + onError, + ); +} + +function createProviderFactory(settings: STTSettings): STTProviderFactory { + if (shouldUseDeepgram(settings)) { + return (safeTranscript, safeError) => new DeepgramProvider( + settings.deepgramApiKey, + safeTranscript, + safeError, + ); } - return new WebSpeechProvider(onTranscript, onError); + + return (safeTranscript, safeError) => new WebSpeechProvider(safeTranscript, safeError); +} + +function shouldUseDeepgram(settings: STTSettings): boolean { + return Platform.OS !== 'web' + || (settings.provider === 'deepgram' && Boolean(settings.deepgramApiKey)); } diff --git a/src/stt/deepgram-browser.test.ts b/src/stt/deepgram-browser.test.ts new file mode 100644 index 0000000..d4a21ce --- /dev/null +++ b/src/stt/deepgram-browser.test.ts @@ -0,0 +1,200 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { DeepgramBrowserCaptureAdapter } from './deepgram-browser'; + +const originalNavigator = Object.getOwnPropertyDescriptor(globalThis, 'navigator'); +const originalMediaRecorder = Object.getOwnPropertyDescriptor(globalThis, 'MediaRecorder'); +const originalWebSocket = Object.getOwnPropertyDescriptor(globalThis, 'WebSocket'); + +type MockTrack = { stop: () => void }; +type MockStream = { getTracks: () => MockTrack[] }; + +type BrowserCaptureMocks = { + getUserMedia: ReturnType; + recorders: MockMediaRecorder[]; + sockets: MockWebSocket[]; + stream: MockStream; + trackStop: ReturnType; +}; + +class MockMediaRecorder { + static isTypeSupported(): boolean { return true; } + + ondataavailable: ((event: { data: { size: number } }) => void) | null = null; + onerror: ((event: unknown) => void) | null = null; + startMs: number | null = null; + state = 'inactive'; + stopCalls = 0; + + constructor( + readonly stream: MockStream, + readonly options?: unknown, + ) { + installedBrowserMocks?.recorders.push(this); + } + + start(ms: number): void { + this.state = 'recording'; + this.startMs = ms; + } + + pause(): void { this.state = 'paused'; } + resume(): void { this.state = 'recording'; } + + stop(): void { + this.stopCalls += 1; + this.state = 'inactive'; + } + + emitChunk(data: { size: number }): void { + this.ondataavailable?.({ data }); + } +} + +class MockWebSocket { + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + + closeCalls = 0; + onclose: ((event: CloseEvent) => void) | null = null; + onerror: (() => void) | null = null; + onmessage: ((event: { data: string }) => void) | null = null; + onopen: (() => void) | null = null; + readyState = 0; + sent: unknown[] = []; + + constructor( + readonly url: string, + readonly protocols: string[], + ) { + installedBrowserMocks?.sockets.push(this); + } + + close(): void { + this.closeCalls += 1; + this.readyState = MockWebSocket.CLOSED; + this.onclose?.({ code: 1000 } as CloseEvent); + } + + open(): void { + this.readyState = MockWebSocket.OPEN; + this.onopen?.(); + } + + send(data: unknown): void { + this.sent.push(data); + } + + receive(data: string): void { + this.onmessage?.({ data }); + } +} + +let installedBrowserMocks: BrowserCaptureMocks | null = null; + +function flush(): Promise { + return Promise.resolve().then(() => undefined); +} + +function restoreGlobalProperty(key: string, descriptor: PropertyDescriptor | undefined): void { + if (descriptor) Object.defineProperty(globalThis, key, descriptor); + else Reflect.deleteProperty(globalThis, key); +} + +function installBrowserCapture(getUserMedia?: ReturnType): BrowserCaptureMocks { + const recorders: MockMediaRecorder[] = []; + const sockets: MockWebSocket[] = []; + const trackStop = vi.fn(); + const stream: MockStream = { getTracks: () => [{ stop: trackStop }] }; + const mediaGetter = getUserMedia ?? vi.fn(async () => stream); + const mocks = { getUserMedia: mediaGetter, recorders, sockets, stream, trackStop }; + + installedBrowserMocks = mocks; + Object.defineProperty(globalThis, 'navigator', { + configurable: true, + value: { mediaDevices: { getUserMedia: mediaGetter } }, + }); + (globalThis as any).MediaRecorder = MockMediaRecorder; + (globalThis as any).WebSocket = MockWebSocket; + return mocks; +} + +afterEach(() => { + installedBrowserMocks = null; + restoreGlobalProperty('navigator', originalNavigator); + restoreGlobalProperty('MediaRecorder', originalMediaRecorder); + restoreGlobalProperty('WebSocket', originalWebSocket); + vi.clearAllMocks(); +}); + +describe('Deepgram browser capture adapter', () => { + it('streams browser microphone chunks over Deepgram websocket and cleans up resources', async () => { + const { recorders, sockets, trackStop } = installBrowserCapture(); + const onTranscript = vi.fn(); + const provider = new DeepgramBrowserCaptureAdapter('browser-key', onTranscript, vi.fn()); + + const startPromise = provider.start(); + await flush(); + expect(sockets).toHaveLength(1); + expect(sockets[0].url).toContain('wss://api.deepgram.com/v1/listen?model=nova-2'); + expect(sockets[0].protocols).toEqual(['token', 'browser-key']); + + sockets[0].open(); + await startPromise; + + expect(recorders).toHaveLength(1); + expect(recorders[0].startMs).toBe(250); + + const chunk = { size: 42 }; + recorders[0].emitChunk(chunk); + sockets[0].receive(JSON.stringify({ + type: 'Results', + is_final: true, + channel: { alternatives: [{ transcript: 'Strahd arrives' }] }, + })); + + expect(sockets[0].sent).toEqual([chunk]); + expect(onTranscript).toHaveBeenCalledWith('Strahd arrives'); + + await provider.stop(); + + expect(recorders[0].stopCalls).toBe(1); + expect(sockets[0].closeCalls).toBe(1); + expect(trackStop).toHaveBeenCalledTimes(1); + }); + + it('cleans up web mic stream when stopped before Deepgram websocket opens', async () => { + const { sockets, trackStop } = installBrowserCapture(); + const provider = new DeepgramBrowserCaptureAdapter('key', vi.fn(), vi.fn()); + + const startPromise = provider.start(); + await flush(); + + const rejectedStart = expect(startPromise).rejects.toThrow('Deepgram connection closed'); + provider.stop(); + await rejectedStart; + expect(sockets).toHaveLength(1); + expect(sockets[0].closeCalls).toBe(1); + expect(trackStop).toHaveBeenCalledTimes(1); + }); + + it('cleans up web mic stream when stopped while getUserMedia is pending', async () => { + const trackStop = vi.fn(); + const stream: MockStream = { getTracks: () => [{ stop: trackStop }] }; + let resolveStream!: (value: MockStream) => void; + const getUserMedia = vi.fn(() => new Promise((resolve) => { resolveStream = resolve; })); + const { sockets } = installBrowserCapture(getUserMedia); + const provider = new DeepgramBrowserCaptureAdapter('key', vi.fn(), vi.fn()); + + const startPromise = provider.start(); + await flush(); + + await provider.stop(); + resolveStream(stream); + + await expect(startPromise).rejects.toThrow('microphone access completed'); + expect(trackStop).toHaveBeenCalledTimes(1); + expect(sockets).toHaveLength(0); + }); +}); diff --git a/src/stt/deepgram-browser.ts b/src/stt/deepgram-browser.ts new file mode 100644 index 0000000..657910f --- /dev/null +++ b/src/stt/deepgram-browser.ts @@ -0,0 +1,197 @@ +import { + assertDeepgramApiKey, + DEEPGRAM_PARAMS, + DEEPGRAM_WS_URL, + extractDeepgramFinalTranscript, + getDeepgramCloseMessage, +} from './deepgram-shared'; + +import type { STTProvider } from './index'; + +const BROWSER_RECORDER_MIME_TYPES = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4']; +const DEEPGRAM_CONNECTION_TIMEOUT_MS = 10000; +const RECORDER_TIMESLICE_MS = 250; + +export class DeepgramBrowserCaptureAdapter implements STTProvider { + readonly name = 'Deepgram'; + private active = false; + private recorder: MediaRecorder | null = null; + private stream: MediaStream | null = null; + private ws: WebSocket | null = null; + + constructor( + private readonly apiKey: string, + private readonly onTranscript: (text: string) => void, + private readonly onError: (error: string) => void, + ) {} + + async start(): Promise { + assertDeepgramApiKey(this.apiKey); + this.active = true; + await this.startBrowserCapture(); + } + + pause(): void { + this.active = false; + if (this.recorder?.state === 'recording') this.recorder.pause(); + } + + async resume(): Promise { + this.active = true; + if (this.recorder?.state === 'paused' && this.ws?.readyState === WebSocket.OPEN) { + this.recorder.resume(); + return; + } + this.cleanup(); + await this.startBrowserCapture(); + } + + stop(): void { + this.active = false; + this.cleanup(); + } + + private getRecorderOptions(): MediaRecorderOptions | undefined { + if (typeof MediaRecorder === 'undefined') return undefined; + const mimeType = BROWSER_RECORDER_MIME_TYPES.find((type) => MediaRecorder.isTypeSupported(type)); + return mimeType ? { mimeType } : undefined; + } + + private cleanup(): void { + this.stopRecorder(); + this.closeSocket(); + if (this.stream) stopMediaStream(this.stream); + this.stream = null; + } + + private stopRecorder(): void { + const recorder = this.recorder; + this.recorder = null; + try { + if (recorder && recorder.state !== 'inactive') recorder.stop(); + } catch {} + } + + private closeSocket(): void { + const ws = this.ws; + this.ws = null; + try { + if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(); + } catch {} + } + + private async startBrowserCapture(): Promise { + this.stream = await this.openMicrophoneStream(); + await this.connectDeepgramSocket(); + } + + private async openMicrophoneStream(): Promise { + const mediaDevices = globalThis.navigator?.mediaDevices; + if (!mediaDevices?.getUserMedia) throw new Error('Microphone capture is not available in this browser.'); + if (typeof MediaRecorder === 'undefined') throw new Error('Browser audio recording is not available.'); + + let stream: MediaStream; + try { + stream = await mediaDevices.getUserMedia({ audio: true }); + } catch (e) { + const detail = e instanceof Error ? e.message : String(e); + throw new Error(`Microphone access denied. ${detail}`); + } + + if (!this.active) { + stopMediaStream(stream); + throw new Error('Recording was stopped before microphone access completed.'); + } + return stream; + } + + private connectDeepgramSocket(): Promise { + return new Promise((resolve, reject) => { + const ws = new WebSocket(`${DEEPGRAM_WS_URL}?${DEEPGRAM_PARAMS}`, ['token', this.apiKey]); + this.ws = ws; + let settled = false; + + const settle = (err?: Error) => { + if (settled) return; + settled = true; + clearTimeout(timeout); + if (err) { + this.active = false; + this.cleanup(); + reject(err); + } else { + resolve(); + } + }; + + const timeout = setTimeout(() => { + settle(new Error('Deepgram connection timed out. Check your API key and network.')); + }, DEEPGRAM_CONNECTION_TIMEOUT_MS); + + ws.onopen = () => { + if (!this.active || !this.stream) { + settle(new Error('Recording was stopped before Deepgram connected.')); + return; + } + try { + const recorder = new MediaRecorder(this.stream, this.getRecorderOptions()); + this.recorder = recorder; + recorder.ondataavailable = (e) => { + if (e.data.size > 0 && this.ws?.readyState === WebSocket.OPEN) this.ws.send(e.data); + }; + recorder.onerror = (event) => { + const err = event instanceof ErrorEvent ? event.message : 'Unknown recording error'; + if (!settled) { + settle(new Error(`Mic recording error: ${err}`)); + return; + } + if (this.active) this.onError(`Mic recording error: ${err}`); + }; + recorder.start(RECORDER_TIMESLICE_MS); + settle(); + } catch (e) { + settle(new Error(`Failed to start browser recorder: ${e instanceof Error ? e.message : String(e)}`)); + } + }; + + ws.onmessage = (event) => { + this.handleDeepgramMessage(event.data as string); + }; + + ws.onerror = () => { + const err = new Error('Deepgram connection error. Check your API key and network.'); + if (!settled) { + settle(err); + return; + } + if (this.active) this.onError(err.message); + }; + + ws.onclose = (event) => { + const message = getDeepgramCloseMessage(event); + if (!settled) { + settle(new Error(message)); + return; + } + if (this.active) { + this.onError(message); + this.active = false; + this.cleanup(); + } + }; + }); + } + + private handleDeepgramMessage(message: string): void { + try { + const text = extractDeepgramFinalTranscript(message).trim(); + if (text && this.active) this.onTranscript(text); + } catch (e) { + if (this.active) this.onError(`Unexpected Deepgram response: ${e instanceof Error ? e.message : String(e)}`); + } + } +} + +function stopMediaStream(stream: MediaStream): void { + stream.getTracks().forEach((track) => track.stop()); +} diff --git a/src/stt/deepgram-native.test.ts b/src/stt/deepgram-native.test.ts new file mode 100644 index 0000000..d673377 --- /dev/null +++ b/src/stt/deepgram-native.test.ts @@ -0,0 +1,153 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +type Deferred = { + promise: Promise; + resolve: () => void; +}; + +const nativeState = vi.hoisted(() => { + class MockRecording { + recordCalls = 0; + stopCalls = 0; + uri: string; + + constructor() { + this.uri = `file:///chunk-${state.recordings.length}.m4a`; + state.recordings.push(this); + } + + async prepareToRecordAsync(): Promise { + if (!state.nextPrepare) return; + await state.nextPrepare.promise; + } + + record(): void { this.recordCalls += 1; } + + async stop(): Promise { this.stopCalls += 1; } + + release(): void {} + } + + const state = { + MockRecording, + nextPrepare: null as Deferred | null, + recordings: [] as MockRecording[], + }; + return state; +}); + +const fileSystemMocks = vi.hoisted(() => ({ + deleteAsync: vi.fn(async () => undefined), + uploadAsync: vi.fn(async () => ({ + body: '{"results":{"channels":[{"alternatives":[{"transcript":""}]}]}}', + status: 200, + })), +})); + +vi.mock('expo-audio', () => ({ + RecordingPresets: { HIGH_QUALITY: {} }, + requestRecordingPermissionsAsync: vi.fn(async () => ({ granted: true })), + setAudioModeAsync: vi.fn(async () => undefined), +})); +vi.mock('expo-audio/build/AudioModule', () => ({ + default: { AudioRecorder: nativeState.MockRecording }, +})); +vi.mock('expo-audio/build/utils/options', () => ({ + createRecordingOptions: vi.fn((options) => options), +})); +vi.mock('expo-file-system/legacy', () => ({ + default: {}, + FileSystemUploadType: { BINARY_CONTENT: 'BINARY_CONTENT' }, + deleteAsync: fileSystemMocks.deleteAsync, + uploadAsync: fileSystemMocks.uploadAsync, +})); + +import { DeepgramNativeCaptureAdapter } from './deepgram-native'; + +function deferred(): Deferred { + let resolve!: () => void; + const promise = new Promise((res) => { resolve = res; }); + return { promise, resolve }; +} + +async function flush(): Promise { + await Promise.resolve(); + await Promise.resolve(); +} + +async function flushUntil(predicate: () => boolean): Promise { + for (let i = 0; i < 20 && !predicate(); i += 1) await flush(); +} + +describe('Deepgram native capture adapter', () => { + beforeEach(() => { + vi.useRealTimers(); + vi.clearAllMocks(); + nativeState.nextPrepare = null; + nativeState.recordings.length = 0; + fileSystemMocks.deleteAsync.mockResolvedValue(undefined); + fileSystemMocks.uploadAsync.mockResolvedValue({ + body: '{"results":{"channels":[{"alternatives":[{"transcript":""}]}]}}', + status: 200, + }); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('unloads native recording if stop happens during first chunk startup', async () => { + nativeState.nextPrepare = deferred(); + const onError = vi.fn(); + const provider = new DeepgramNativeCaptureAdapter('key', vi.fn(), onError); + + const startPromise = provider.start(); + for (let i = 0; i < 10 && nativeState.recordings.length === 0; i += 1) await flush(); + expect(nativeState.recordings).toHaveLength(1); + + const stopPromise = provider.stop(); + nativeState.nextPrepare.resolve(); + await Promise.all([startPromise, stopPromise]); + + expect(nativeState.recordings[0].stopCalls).toBe(1); + expect(onError).not.toHaveBeenCalled(); + }); + + it('uploads completed native chunks, emits transcripts, and deletes chunk files', async () => { + vi.useFakeTimers(); + fileSystemMocks.uploadAsync.mockResolvedValueOnce({ + body: '{"results":{"channels":[{"alternatives":[{"transcript":"Acererak awakens"}]}]}}', + status: 200, + }); + const onTranscript = vi.fn(); + const provider = new DeepgramNativeCaptureAdapter('native-key', onTranscript, vi.fn()); + + await provider.start(); + + try { + expect(nativeState.recordings).toHaveLength(1); + const firstChunk = nativeState.recordings[0]; + + await vi.advanceTimersByTimeAsync(5000); + await flushUntil(() => fileSystemMocks.deleteAsync.mock.calls.length > 0); + + expect(firstChunk.stopCalls).toBe(1); + expect(fileSystemMocks.uploadAsync).toHaveBeenCalledWith( + 'https://api.deepgram.com/v1/listen?model=nova-2&punctuate=true&smart_format=true&language=en-US', + firstChunk.uri, + expect.objectContaining({ + headers: expect.objectContaining({ + Authorization: 'Token native-key', + 'Content-Type': 'audio/mp4', + }), + httpMethod: 'POST', + }), + ); + expect(fileSystemMocks.deleteAsync).toHaveBeenCalledWith(firstChunk.uri, { idempotent: true }); + expect(onTranscript).toHaveBeenCalledWith('Acererak awakens'); + expect(nativeState.recordings.length).toBeGreaterThanOrEqual(2); + } finally { + await provider.stop(); + } + }); +}); diff --git a/src/stt/deepgram-native.ts b/src/stt/deepgram-native.ts new file mode 100644 index 0000000..8f064e6 --- /dev/null +++ b/src/stt/deepgram-native.ts @@ -0,0 +1,173 @@ +import * as FileSystem from 'expo-file-system/legacy'; + +import { + assertDeepgramApiKey, + DEEPGRAM_HTTP_URL, + DEEPGRAM_PARAMS, + extractDeepgramTranscript, +} from './deepgram-shared'; +import { + createNativeAudioRecorder, + releaseNativeAudioRecorder, + requestNativeRecordingAccess, + type NativeAudioRecorder, +} from './native-audio'; + +import type { STTProvider } from './index'; + +const NATIVE_AUDIO_CONTENT_TYPE = 'audio/mp4'; +const NATIVE_CHUNK_INTERVAL_MS = 5000; + +export class DeepgramNativeCaptureAdapter implements STTProvider { + readonly name = 'Deepgram'; + private active = false; + private chunkTimer: ReturnType | null = null; + private nativeOperation: Promise = Promise.resolve(); + private recording: NativeAudioRecorder | null = null; + + constructor( + private readonly apiKey: string, + private readonly onTranscript: (text: string) => void, + private readonly onError: (error: string) => void, + ) {} + + async start(): Promise { + assertDeepgramApiKey(this.apiKey); + this.active = true; + await requestNativeRecordingAccess(); + await this.startNativeCapture(); + } + + async pause(): Promise { + this.active = false; + await this.stopNativeCapture(); + } + + async resume(): Promise { + this.active = true; + await this.startNativeCapture(); + } + + async stop(): Promise { + this.active = false; + await this.stopNativeCapture(); + } + + private startNativeCapture(): Promise { + return this.enqueueNative(async () => { await this.startNativeChunks(); }); + } + + private stopNativeCapture(): Promise { + return this.enqueueNative(async () => { + this.clearNativeTimer(); + await this.stopCurrentRecording(); + }); + } + + private enqueueNative(op: () => Promise): Promise { + const next = this.nativeOperation.catch(() => undefined).then(op); + this.nativeOperation = next.catch(() => undefined); + return next; + } + + private clearNativeTimer(): void { + if (this.chunkTimer === null) return; + clearInterval(this.chunkTimer); + this.chunkTimer = null; + } + + private async stopCurrentRecording(): Promise { + const recording = this.recording; + this.recording = null; + if (!recording) return; + try { + await recording.stop(); + } catch (e) { + if (this.active) throw e; + } finally { + releaseNativeAudioRecorder(recording); + } + } + + private async startNativeChunks(): Promise { + if (!this.active || this.recording) return; + this.clearNativeTimer(); + await this.startChunk(); + if (this.active && this.recording) { + this.chunkTimer = setInterval(() => { void this.rotateChunk(); }, NATIVE_CHUNK_INTERVAL_MS); + } + } + + private async startChunk(): Promise { + const recording = createNativeAudioRecorder(); + try { + await recording.prepareToRecordAsync(); + recording.record(); + if (!this.active) { + await recording.stop().catch(() => {}); + releaseNativeAudioRecorder(recording); + return; + } + this.recording = recording; + } catch (e) { + await recording.stop().catch(() => {}); + releaseNativeAudioRecorder(recording); + if (this.active) throw e; + } + } + + private async rotateChunk(): Promise { + await this.enqueueNative(async () => { + if (!this.active || !this.recording) return; + const recording = this.recording; + this.recording = null; + try { + const uri = await this.stopRecordingForUpload(recording); + if (uri) this.transcribeCompletedChunk(uri); + if (this.active) await this.startChunk(); + } catch (e) { + if (this.active) { + this.onError(`Recording error: ${e instanceof Error ? e.message : String(e)}`); + this.active = false; + this.clearNativeTimer(); + } + } + }); + } + + private async stopRecordingForUpload(recording: NativeAudioRecorder): Promise { + try { + await recording.stop(); + return recording.uri; + } finally { + releaseNativeAudioRecorder(recording); + } + } + + private transcribeCompletedChunk(uri: string): void { + void this.transcribeChunk(uri).then((text) => { + if (text && this.active) this.onTranscript(text); + }).catch((e: unknown) => { + if (this.active) this.onError(`Transcription failed: ${e instanceof Error ? e.message : String(e)}`); + }); + } + + private async transcribeChunk(uri: string): Promise { + try { + const result = await FileSystem.uploadAsync(`${DEEPGRAM_HTTP_URL}?${DEEPGRAM_PARAMS}`, uri, { + headers: { + Authorization: `Token ${this.apiKey}`, + 'Content-Type': NATIVE_AUDIO_CONTENT_TYPE, + }, + httpMethod: 'POST', + uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT, + }); + if (result.status < 200 || result.status >= 300) { + throw new Error(`Deepgram HTTP ${result.status}: ${result.body}`); + } + return extractDeepgramTranscript(result.body); + } finally { + FileSystem.deleteAsync(uri, { idempotent: true }).catch(() => {}); + } + } +} diff --git a/src/stt/deepgram-shared.ts b/src/stt/deepgram-shared.ts new file mode 100644 index 0000000..7e6f724 --- /dev/null +++ b/src/stt/deepgram-shared.ts @@ -0,0 +1,41 @@ +export const DEEPGRAM_HTTP_URL = 'https://api.deepgram.com/v1/listen'; +export const DEEPGRAM_WS_URL = 'wss://api.deepgram.com/v1/listen'; +export const DEEPGRAM_PARAMS = 'model=nova-2&punctuate=true&smart_format=true&language=en-US'; + +type DeepgramTranscriptChannel = { + alternatives?: Array<{ transcript?: string }>; +}; + +type DeepgramHttpResponse = { + results?: { channels?: DeepgramTranscriptChannel[] }; +}; + +type DeepgramStreamingResponse = { + channel?: DeepgramTranscriptChannel; + is_final?: boolean; + type?: string; +}; + +export function assertDeepgramApiKey(apiKey: string): void { + if (!apiKey) throw new Error('Deepgram API key not set. Configure it in the Settings tab.'); +} + +export function getDeepgramCloseMessage(event: CloseEvent): string { + if (event.code === 1008) return 'Deepgram rejected the connection -- verify your API key.'; + return `Deepgram connection closed (${event.code}). Check your network.`; +} + +export function extractDeepgramTranscript(body: string): string { + const data = JSON.parse(body) as DeepgramHttpResponse | null; + return getFirstTranscript(data?.results?.channels?.[0]); +} + +export function extractDeepgramFinalTranscript(message: string): string { + const data = JSON.parse(message) as DeepgramStreamingResponse; + if (data.type !== 'Results' || !data.is_final) return ''; + return getFirstTranscript(data.channel); +} + +function getFirstTranscript(channel: DeepgramTranscriptChannel | undefined): string { + return channel?.alternatives?.[0]?.transcript ?? ''; +} diff --git a/src/stt/deepgram.test.ts b/src/stt/deepgram.test.ts index c12c054..861cfae 100644 --- a/src/stt/deepgram.test.ts +++ b/src/stt/deepgram.test.ts @@ -1,175 +1,110 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; -const platform = vi.hoisted(() => { - (globalThis as any).__DEV__ = false; - return { OS: 'ios' }; -}); - -type Deferred = { - promise: Promise; - resolve: () => void; -}; +const platform = vi.hoisted(() => ({ OS: 'web' })); -const nativeState = vi.hoisted(() => { - class MockRecording { +const adapterState = vi.hoisted(() => { + class MockBrowserAdapter { + pauseCalls = 0; + resumeCalls = 0; + startCalls = 0; stopCalls = 0; - uri = 'file:///chunk.m4a'; - constructor() { - state.recordings.push(this); + constructor( + readonly apiKey: string, + readonly onTranscript: (text: string) => void, + readonly onError: (error: string) => void, + ) { + state.browserInstances.push(this); } - async prepareToRecordAsync(): Promise { - if (!state.nextPrepare) return; - await state.nextPrepare.promise; - } + async start(): Promise { this.startCalls += 1; } + async pause(): Promise { this.pauseCalls += 1; } + async resume(): Promise { this.resumeCalls += 1; } + async stop(): Promise { this.stopCalls += 1; } + } - record(): void {} + class MockNativeAdapter { + pauseCalls = 0; + resumeCalls = 0; + startCalls = 0; + stopCalls = 0; - async stop(): Promise { - this.stopCalls += 1; + constructor( + readonly apiKey: string, + readonly onTranscript: (text: string) => void, + readonly onError: (error: string) => void, + ) { + state.nativeInstances.push(this); } - release(): void {} + async start(): Promise { this.startCalls += 1; } + async pause(): Promise { this.pauseCalls += 1; } + async resume(): Promise { this.resumeCalls += 1; } + async stop(): Promise { this.stopCalls += 1; } } const state = { - MockRecording, - recordings: [] as MockRecording[], - nextPrepare: null as Deferred | null, + MockBrowserAdapter, + MockNativeAdapter, + browserInstances: [] as MockBrowserAdapter[], + nativeInstances: [] as MockNativeAdapter[], }; return state; }); vi.mock('react-native', () => ({ Platform: platform })); -vi.mock('expo-audio', () => ({ - RecordingPresets: { HIGH_QUALITY: {} }, - requestRecordingPermissionsAsync: vi.fn(async () => ({ granted: true })), - setAudioModeAsync: vi.fn(async () => undefined), -})); -vi.mock('expo-audio/build/AudioModule', () => ({ - default: { AudioRecorder: nativeState.MockRecording }, +vi.mock('./deepgram-browser', () => ({ + DeepgramBrowserCaptureAdapter: adapterState.MockBrowserAdapter, })); -vi.mock('expo-audio/build/utils/options', () => ({ - createRecordingOptions: vi.fn((options) => options), -})); -vi.mock('expo-file-system/legacy', () => ({ - default: {}, - FileSystemUploadType: { BINARY_CONTENT: 'BINARY_CONTENT' }, - uploadAsync: vi.fn(async () => ({ status: 200, body: '{"results":{"channels":[{"alternatives":[{"transcript":""}]}]}}' })), - deleteAsync: vi.fn(async () => undefined), +vi.mock('./deepgram-native', () => ({ + DeepgramNativeCaptureAdapter: adapterState.MockNativeAdapter, })); import { DeepgramProvider } from './deepgram'; -function deferred(): Deferred { - let resolve!: () => void; - const promise = new Promise((res) => { resolve = res; }); - return { promise, resolve }; -} - -async function flush(): Promise { - await Promise.resolve(); - await Promise.resolve(); -} - -describe('DeepgramProvider lifecycle', () => { +describe('DeepgramProvider internal adapter selection', () => { beforeEach(() => { - platform.OS = 'ios'; - nativeState.recordings.length = 0; - nativeState.nextPrepare = null; - vi.clearAllMocks(); + platform.OS = 'web'; + adapterState.browserInstances.length = 0; + adapterState.nativeInstances.length = 0; }); - it('unloads native recording if stop happens during first chunk startup', async () => { - nativeState.nextPrepare = deferred(); - const onError = vi.fn(); - const provider = new DeepgramProvider('key', vi.fn(), onError); + it('keeps browser capture hidden behind the Deepgram provider seam on web', async () => { + const provider = new DeepgramProvider('browser-key', vi.fn(), vi.fn()); - const startPromise = provider.start(); - for (let i = 0; i < 10 && nativeState.recordings.length === 0; i++) { - await flush(); - } - expect(nativeState.recordings).toHaveLength(1); - - const stopPromise = provider.stop(); - nativeState.nextPrepare.resolve(); - await Promise.all([startPromise, stopPromise]); + await provider.start(); + await provider.pause(); + await provider.resume(); + await provider.stop(); - expect(nativeState.recordings[0].stopCalls).toBe(1); - expect(onError).not.toHaveBeenCalled(); + expect(provider.name).toBe('Deepgram'); + expect(adapterState.browserInstances).toHaveLength(1); + expect(adapterState.nativeInstances).toHaveLength(0); + expect(adapterState.browserInstances[0].apiKey).toBe('browser-key'); + expect(adapterState.browserInstances[0].startCalls).toBe(1); + expect(adapterState.browserInstances[0].pauseCalls).toBe(1); + expect(adapterState.browserInstances[0].resumeCalls).toBe(1); + expect(adapterState.browserInstances[0].stopCalls).toBe(1); }); - it('cleans up web mic stream when stopped before Deepgram websocket opens', async () => { - platform.OS = 'web'; - const trackStop = vi.fn(); - const stream = { getTracks: () => [{ stop: trackStop }] }; - const sockets: Array<{ close: () => void; onclose: ((event: CloseEvent) => void) | null }> = []; - - Object.defineProperty(globalThis, 'navigator', { - configurable: true, - value: { mediaDevices: { getUserMedia: vi.fn(async () => stream) } }, - }); - (globalThis as any).MediaRecorder = class { - static isTypeSupported() { return true; } - }; - (globalThis as any).WebSocket = class { - static OPEN = 1; - static CLOSING = 2; - static CLOSED = 3; - readyState = 0; - onclose: ((event: CloseEvent) => void) | null = null; - - constructor() { - sockets.push(this); - } - - close() { - this.readyState = 3; - this.onclose?.({ code: 1000 } as CloseEvent); - } - }; - - const provider = new DeepgramProvider('key', vi.fn(), vi.fn()); - const startPromise = provider.start(); - await flush(); - - await expect(provider.stop()).resolves.toBeUndefined(); - await expect(startPromise).rejects.toThrow('Deepgram connection closed'); - expect(sockets).toHaveLength(1); - expect(trackStop).toHaveBeenCalledTimes(1); - }); + it('keeps native capture hidden behind the Deepgram provider seam off web', async () => { + platform.OS = 'ios'; + const provider = new DeepgramProvider('native-key', vi.fn(), vi.fn()); - it('cleans up web mic stream when stopped while getUserMedia is pending', async () => { - platform.OS = 'web'; - const trackStop = vi.fn(); - const stream = { getTracks: () => [{ stop: trackStop }] }; - let resolveStream!: (value: typeof stream) => void; - const getUserMedia = vi.fn(() => new Promise((resolve) => { resolveStream = resolve; })); - - Object.defineProperty(globalThis, 'navigator', { - configurable: true, - value: { mediaDevices: { getUserMedia } }, - }); - (globalThis as any).MediaRecorder = class { - static isTypeSupported() { return true; } - }; - const sockets: unknown[] = []; - (globalThis as any).WebSocket = class { - constructor() { - sockets.push(this); - } - }; - - const provider = new DeepgramProvider('key', vi.fn(), vi.fn()); - const startPromise = provider.start(); - await flush(); + await provider.start(); - await provider.stop(); - resolveStream(stream); + expect(provider.name).toBe('Deepgram'); + expect(adapterState.browserInstances).toHaveLength(0); + expect(adapterState.nativeInstances).toHaveLength(1); + expect(adapterState.nativeInstances[0].apiKey).toBe('native-key'); + expect(adapterState.nativeInstances[0].startCalls).toBe(1); + }); + + it('preserves the missing API key startup error before starting an adapter', async () => { + const provider = new DeepgramProvider('', vi.fn(), vi.fn()); - await expect(startPromise).rejects.toThrow('microphone access completed'); - expect(trackStop).toHaveBeenCalledTimes(1); - expect(sockets).toHaveLength(0); + await expect(provider.start()).rejects.toThrow('Deepgram API key not set'); + expect(adapterState.browserInstances).toHaveLength(1); + expect(adapterState.browserInstances[0].startCalls).toBe(0); }); }); diff --git a/src/stt/deepgram.ts b/src/stt/deepgram.ts index 31bcfcf..c0b1294 100644 --- a/src/stt/deepgram.ts +++ b/src/stt/deepgram.ts @@ -1,298 +1,43 @@ -import * as FileSystem from 'expo-file-system/legacy'; import { Platform } from 'react-native'; -import { createNativeAudioRecorder, releaseNativeAudioRecorder, requestNativeRecordingAccess, type NativeAudioRecorder } from './native-audio'; +import { DeepgramBrowserCaptureAdapter } from './deepgram-browser'; +import { DeepgramNativeCaptureAdapter } from './deepgram-native'; +import { assertDeepgramApiKey } from './deepgram-shared'; -import { STTProvider } from './index'; +import type { STTProvider } from './index'; -const DG_BASE = 'https://api.deepgram.com/v1/listen'; -const DG_WS = 'wss://api.deepgram.com/v1/listen'; -const DG_PARAMS = 'model=nova-2&punctuate=true&smart_format=true&language=en-US'; +type TranscriptHandler = (text: string) => void; +type ErrorHandler = (error: string) => void; export class DeepgramProvider implements STTProvider { readonly name = 'Deepgram'; - private apiKey: string; - private onTranscript: (text: string) => void; - private onError: (error: string) => void; - private active = false; - private ws: WebSocket | null = null; - private stream: MediaStream | null = null; - private recorder: MediaRecorder | null = null; + private readonly adapter: STTProvider; - private chunkTimer: ReturnType | null = null; - private recording: NativeAudioRecorder | null = null; - private nativeOperation: Promise = Promise.resolve(); - - constructor(apiKey: string, onTranscript: (text: string) => void, onError: (error: string) => void) { - this.apiKey = apiKey; - this.onTranscript = onTranscript; - this.onError = onError; + constructor( + private readonly apiKey: string, + onTranscript: TranscriptHandler, + onError: ErrorHandler, + ) { + this.adapter = createDeepgramCaptureAdapter(apiKey, onTranscript, onError); } async start(): Promise { - if (!this.apiKey) { - throw new Error('Deepgram API key not set. Configure it in the Settings tab.'); - } - this.active = true; - if (Platform.OS === 'web') { - await this.startWeb(); - } else { - await this.startNative(); - } - } - - private getDeepgramCloseMessage(event: CloseEvent): string { - if (event.code === 1008) return 'Deepgram rejected the connection -- verify your API key.'; - return `Deepgram connection closed (${event.code}). Check your network.`; - } - - private getRecorderOptions(): MediaRecorderOptions | undefined { - if (typeof MediaRecorder === 'undefined') return undefined; - const supportedTypes = ['audio/webm;codecs=opus', 'audio/webm', 'audio/mp4']; - const mimeType = supportedTypes.find((type) => MediaRecorder.isTypeSupported(type)); - return mimeType ? { mimeType } : undefined; - } - - private cleanupWeb(): void { - const recorder = this.recorder; - this.recorder = null; - try { - if (recorder && recorder.state !== 'inactive') recorder.stop(); - } catch {} - const ws = this.ws; - this.ws = null; - try { - if (ws && ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) ws.close(); - } catch {} - this.stream?.getTracks().forEach((track) => track.stop()); - this.stream = null; - } - - private async startWeb(): Promise { - if (!navigator.mediaDevices?.getUserMedia) throw new Error('Microphone capture is not available in this browser.'); - if (typeof MediaRecorder === 'undefined') throw new Error('Browser audio recording is not available.'); - - let stream: MediaStream; - try { - stream = await navigator.mediaDevices.getUserMedia({ audio: true }); - } catch (e) { - const detail = e instanceof Error ? e.message : String(e); - throw new Error(`Microphone access denied. ${detail}`); - } - if (!this.active) { stream.getTracks().forEach((track) => track.stop()); throw new Error('Recording was stopped before microphone access completed.'); } - this.stream = stream; - - await new Promise((resolve, reject) => { - const ws = new WebSocket(`${DG_WS}?${DG_PARAMS}`, ['token', this.apiKey]); - this.ws = ws; - let settled = false; - - const settle = (err?: Error) => { - if (settled) return; - settled = true; - clearTimeout(timeout); - if (err) { - this.active = false; - this.cleanupWeb(); - reject(err); - } else { - resolve(); - } - }; - - const timeout = setTimeout(() => { - settle(new Error('Deepgram connection timed out. Check your API key and network.')); - }, 10000); - - ws.onopen = () => { - if (!this.active || !this.stream) { - settle(new Error('Recording was stopped before Deepgram connected.')); - return; - } - try { - const recorder = new MediaRecorder(this.stream, this.getRecorderOptions()); - this.recorder = recorder; - recorder.ondataavailable = (e) => { - if (e.data.size > 0 && this.ws?.readyState === WebSocket.OPEN) { - this.ws.send(e.data); - } - }; - recorder.onerror = (event) => { - const err = event instanceof ErrorEvent ? event.message : 'Unknown recording error'; - if (!settled) { settle(new Error(`Mic recording error: ${err}`)); return; } - if (this.active) this.onError(`Mic recording error: ${err}`); - }; - recorder.start(250); - settle(); - } catch (e) { - settle(new Error(`Failed to start browser recorder: ${e instanceof Error ? e.message : String(e)}`)); - } - }; - - ws.onmessage = (event) => { - try { - const data = JSON.parse(event.data as string); - if (data.type === 'Results' && data.is_final) { - const text: string = data.channel?.alternatives?.[0]?.transcript ?? ''; - if (text.trim() && this.active) this.onTranscript(text.trim()); - } - } catch (e) { - if (this.active) this.onError(`Unexpected Deepgram response: ${e instanceof Error ? e.message : String(e)}`); - } - }; - - ws.onerror = () => { - const err = new Error('Deepgram connection error. Check your API key and network.'); - if (!settled) { settle(err); return; } - if (this.active) this.onError(err.message); - }; - - ws.onclose = (event) => { - if (!settled) { settle(new Error(this.getDeepgramCloseMessage(event))); return; } - if (this.active) { - this.onError(this.getDeepgramCloseMessage(event)); - this.active = false; - this.cleanupWeb(); - } - }; - }); - } - - private async startNative(): Promise { - await requestNativeRecordingAccess(); - await this.enqueueNative(async () => { await this.startNativeChunks(); }); + assertDeepgramApiKey(this.apiKey); + await this.adapter.start(); } - private enqueueNative(op: () => Promise): Promise { - const next = this.nativeOperation.catch(() => undefined).then(op); - this.nativeOperation = next.catch(() => undefined); - return next; - } - - private clearNativeTimer(): void { - if (this.chunkTimer !== null) { clearInterval(this.chunkTimer); this.chunkTimer = null; } - } - - private async stopCurrentRecording(): Promise { - const rec = this.recording; - this.recording = null; - if (!rec) return; - try { - await rec.stop(); - } catch (e) { - if (this.active) throw e; - } finally { - releaseNativeAudioRecorder(rec); - } - } - - private async startNativeChunks(): Promise { - if (!this.active || this.recording) return; - this.clearNativeTimer(); - await this.startChunk(); - if (this.active && this.recording) this.chunkTimer = setInterval(() => { void this.rotateChunk(); }, 5000); - } - - private async startChunk(): Promise { - const rec = createNativeAudioRecorder(); - try { - await rec.prepareToRecordAsync(); - rec.record(); - if (!this.active) { - await rec.stop().catch(() => {}); - releaseNativeAudioRecorder(rec); - return; - } - this.recording = rec; - } catch (e) { - await rec.stop().catch(() => {}); - releaseNativeAudioRecorder(rec); - if (this.active) throw e; - } - } - - private async rotateChunk(): Promise { - await this.enqueueNative(async () => { - if (!this.active || !this.recording) return; - const rec = this.recording; - this.recording = null; - try { - let uri: string | null = null; - try { - await rec.stop(); - uri = rec.uri; - } finally { - releaseNativeAudioRecorder(rec); - } - if (uri) { - this.transcribeChunk(uri).then((text) => { - if (text && this.active) this.onTranscript(text); - }).catch((e: unknown) => { - if (this.active) this.onError(`Transcription failed: ${e instanceof Error ? e.message : String(e)}`); - }); - } - if (this.active) await this.startChunk(); - } catch (e) { - if (this.active) { - this.onError(`Recording error: ${e instanceof Error ? e.message : String(e)}`); - this.active = false; - this.clearNativeTimer(); - } - } - }); - } - - private async transcribeChunk(uri: string): Promise { - try { - const result = await FileSystem.uploadAsync(`${DG_BASE}?${DG_PARAMS}`, uri, { - httpMethod: 'POST', - uploadType: FileSystem.FileSystemUploadType.BINARY_CONTENT, - headers: { - Authorization: `Token ${this.apiKey}`, - 'Content-Type': 'audio/mp4', - }, - }); - if (result.status < 200 || result.status >= 300) { - throw new Error(`Deepgram HTTP ${result.status}: ${result.body}`); - } - const data = JSON.parse(result.body) as { - results?: { channels?: Array<{ alternatives?: Array<{ transcript?: string }> }> }; - }; - return data?.results?.channels?.[0]?.alternatives?.[0]?.transcript ?? ''; - } finally { - FileSystem.deleteAsync(uri, { idempotent: true }).catch(() => {}); - } - } - - async pause(): Promise { - this.active = false; - if (Platform.OS === 'web') { - if (this.recorder?.state === 'recording') this.recorder.pause(); - return; - } - await this.enqueueNative(async () => { this.clearNativeTimer(); await this.stopCurrentRecording(); }); - } - - async resume(): Promise { - this.active = true; - if (Platform.OS === 'web') { - if (this.recorder?.state === 'paused' && this.ws?.readyState === WebSocket.OPEN) { - this.recorder.resume(); - return; - } - this.cleanupWeb(); - await this.startWeb(); - return; - } - await this.enqueueNative(async () => { await this.startNativeChunks(); }); - } + pause(): void | Promise { return this.adapter.pause(); } + resume(): void | Promise { return this.adapter.resume(); } + stop(): void | Promise { return this.adapter.stop(); } +} - async stop(): Promise { - this.active = false; - if (Platform.OS === 'web') { - this.cleanupWeb(); - return; - } - await this.enqueueNative(async () => { this.clearNativeTimer(); await this.stopCurrentRecording(); }); +function createDeepgramCaptureAdapter( + apiKey: string, + onTranscript: TranscriptHandler, + onError: ErrorHandler, +): STTProvider { + if (Platform.OS === 'web') { + return new DeepgramBrowserCaptureAdapter(apiKey, onTranscript, onError); } + return new DeepgramNativeCaptureAdapter(apiKey, onTranscript, onError); } diff --git a/src/stt/lifecycle-safe-provider.test.ts b/src/stt/lifecycle-safe-provider.test.ts new file mode 100644 index 0000000..331016b --- /dev/null +++ b/src/stt/lifecycle-safe-provider.test.ts @@ -0,0 +1,133 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { createLateEventSafeSTTProvider } from './lifecycle-safe-provider'; + +import type { STTProvider } from './index'; + +type RejectablePromise = { + promise: Promise; + reject: (error: unknown) => void; +}; + +function rejectablePromise(): RejectablePromise { + let reject!: (error: unknown) => void; + const promise = new Promise((_resolve, rej) => { + reject = rej; + }); + return { promise, reject }; +} + +class FakeCaptureAdapter implements STTProvider { + readonly name = 'Fake Capture'; + startCalls = 0; + stopCalls = 0; + startResult: Promise | null = null; + startError: unknown = null; + + constructor( + private readonly onTranscript: (text: string) => void, + private readonly onError: (error: string) => void, + ) {} + + async start(): Promise { + this.startCalls += 1; + if (this.startError) throw this.startError; + if (this.startResult) await this.startResult; + } + + pause(): void {} + resume(): void {} + + async stop(): Promise { + this.stopCalls += 1; + } + + emitTranscript(text: string): void { + this.onTranscript(text); + } + + emitError(error: string): void { + this.onError(error); + } +} + +function makeProvider(configure?: (adapter: FakeCaptureAdapter) => void) { + const adapters: FakeCaptureAdapter[] = []; + const onTranscript = vi.fn(); + const onError = vi.fn(); + const provider = createLateEventSafeSTTProvider((safeTranscript, safeError) => { + const adapter = new FakeCaptureAdapter(safeTranscript, safeError); + configure?.(adapter); + adapters.push(adapter); + return adapter; + }, onTranscript, onError); + + return { adapters, onError, onTranscript, provider }; +} + +describe('late-event-safe STT provider', () => { + it('drops transcript and error events from a stopped capture generation', async () => { + const { adapters, onError, onTranscript, provider } = makeProvider(); + + await provider.start(); + adapters[0].emitTranscript('heard while active'); + await provider.stop(); + adapters[0].emitTranscript('late after stop'); + adapters[0].emitError('late error after stop'); + + expect(onTranscript).toHaveBeenCalledTimes(1); + expect(onTranscript).toHaveBeenCalledWith('heard while active'); + expect(onError).not.toHaveBeenCalled(); + }); + + it('cancels stop-during-start so startup completions cannot deliver stale events', async () => { + const startup = rejectablePromise(); + const { adapters, onError, onTranscript, provider } = makeProvider((adapter) => { + adapter.startResult = startup.promise; + }); + + const start = provider.start(); + adapters[0].emitTranscript('too early'); + const stop = provider.stop(); + startup.reject(new Error('mic failed after stop')); + adapters[0].emitError('late startup error'); + + await expect(start).resolves.toBeUndefined(); + await expect(stop).resolves.toBeUndefined(); + expect(adapters[0].stopCalls).toBe(1); + expect(onTranscript).not.toHaveBeenCalled(); + expect(onError).not.toHaveBeenCalled(); + }); + + it('suppresses paused events and resumes with only the current generation enabled', async () => { + const { adapters, onError, onTranscript, provider } = makeProvider(); + + await provider.start(); + const pausedAdapter = adapters[0]; + await provider.pause(); + pausedAdapter.emitTranscript('heard while paused'); + pausedAdapter.emitError('paused error'); + + await provider.resume(); + expect(adapters).toHaveLength(2); + pausedAdapter.emitTranscript('old generation after resume'); + pausedAdapter.emitError('old error after resume'); + adapters[1].emitTranscript('current generation'); + adapters[1].emitError('current error'); + + expect(pausedAdapter.stopCalls).toBe(1); + expect(onTranscript).toHaveBeenCalledTimes(1); + expect(onTranscript).toHaveBeenCalledWith('current generation'); + expect(onError).toHaveBeenCalledTimes(1); + expect(onError).toHaveBeenCalledWith('current error'); + }); + + it('propagates current startup failures so callers can show user-facing errors', async () => { + const { onError, provider } = makeProvider((adapter) => { + adapter.startError = new Error('permission denied'); + }); + + await expect(provider.start()).rejects.toThrow('permission denied'); + expect(onError).not.toHaveBeenCalled(); + }); +}); diff --git a/src/stt/lifecycle-safe-provider.ts b/src/stt/lifecycle-safe-provider.ts new file mode 100644 index 0000000..56076e4 --- /dev/null +++ b/src/stt/lifecycle-safe-provider.ts @@ -0,0 +1,126 @@ +import type { STTProvider } from './index'; + +export type STTProviderFactory = ( + onTranscript: (text: string) => void, + onError: (error: string) => void, +) => STTProvider; + +type CaptureInstance = { + readonly generation: number; + readonly provider: STTProvider; +}; + +export class LateEventSafeSTTProvider implements STTProvider { + readonly name: string; + private currentCapture: CaptureInstance | null; + private deliveryGeneration: number | null = null; + private nextGeneration = 0; + private startInFlight: Promise | null = null; + + constructor( + private readonly createProvider: STTProviderFactory, + private readonly onTranscript: (text: string) => void, + private readonly onError: (error: string) => void, + ) { + this.currentCapture = this.createCapture(); + this.name = this.currentCapture.provider.name; + } + + start(): Promise { + if (this.startInFlight) return this.startInFlight; + + const capture = this.currentCapture ?? this.createCapture(); + this.currentCapture = capture; + this.deliveryGeneration = null; + + const command = this.startCapture(capture); + this.startInFlight = command; + command.then( + () => this.clearStartInFlight(command), + () => this.clearStartInFlight(command), + ); + return command; + } + + pause(): Promise { return this.stopCurrentCapture(); } + resume(): Promise { return this.start(); } + stop(): Promise { return this.stopCurrentCapture(); } + + private createCapture(): CaptureInstance { + this.nextGeneration += 1; + const generation = this.nextGeneration; + const provider = this.createProvider( + (text) => this.emitTranscript(text, generation), + (error) => this.emitError(error, generation), + ); + return { generation, provider }; + } + + private startCapture(capture: CaptureInstance): Promise { + let startup: Promise; + try { + startup = Promise.resolve(capture.provider.start()); + } catch (error) { + startup = Promise.reject(error); + } + + return startup.then( + () => { + if (!this.isCurrentCapture(capture)) return; + this.deliveryGeneration = capture.generation; + }, + (error: unknown) => { + if (!this.isCurrentCapture(capture)) return; + this.currentCapture = null; + this.deliveryGeneration = null; + throw error; + }, + ); + } + + private emitTranscript(text: string, generation: number): void { + if (this.canDeliver(generation)) this.onTranscript(text); + } + + private emitError(error: string, generation: number): void { + if (!this.canDeliver(generation)) return; + this.deliveryGeneration = null; + this.onError(error); + } + + private canDeliver(generation: number): boolean { + return this.deliveryGeneration === generation && this.currentCapture?.generation === generation; + } + + private isCurrentCapture(capture: CaptureInstance): boolean { + const current = this.currentCapture; + return current !== null && current === capture && current.generation === capture.generation; + } + + private stopCurrentCapture(): Promise { + const capture = this.currentCapture; + this.currentCapture = null; + this.deliveryGeneration = null; + this.startInFlight = null; + if (!capture) return Promise.resolve(); + return this.stopProvider(capture.provider); + } + + private async stopProvider(provider: STTProvider): Promise { + try { + await provider.stop(); + } catch {} + } + + private clearStartInFlight(command: Promise): void { + if (this.startInFlight === command) this.startInFlight = null; + } +} + +export function createLateEventSafeSTTProvider( + createProvider: STTProviderFactory, + onTranscript: (text: string) => void, + onError: (error: string) => void, +): STTProvider { + return new LateEventSafeSTTProvider(createProvider, onTranscript, onError); +}