diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index c497498..294e82e 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -49,3 +49,30 @@ pnpm test # vitest - IPC contract lives in `src/preload/api.ts` — every renderer call goes through `window.api.*`. Add a channel there + a matching handler in `src/main/ipc.ts`. + +## Toasts + +System-level toasts run through `sonner`, wrapped behind +`@renderer/lib/toast`. ESLint blocks direct imports of `sonner` +outside the wrapper + `components/ui/sonner.tsx`. + +- **Use `toastError`** for genuine errors a user can't recover from + inline — background sync failures, unexpected API rejections, OAuth + refresh blowing up. Pass `copyText` whenever the user might need to + share details with us; the toast renders a Copy logs action when + present. +- **Use `toastInfo`** sparingly. Only for transient confirmations + with no inline home (e.g. "Purged X recordings"). Routine + sign-in / sync success is _not_ a toast — the card itself carries + that signal. +- **Never duplicate an inline error state.** The ConnectionCard + already surfaces "Last sync failed — " with a Retry + button; the toast is at most an _additional_ one-shot signal on + the transition. Fire it from a state-change `useEffect` keyed on + the error string, never on every render. +- **Never on routine success paths.** Sign-in / sync completion + both transition the card — no toast. + +The single transition watcher lives in `App.tsx` (`lastToastedErrorRef`) +and observes `useMarfaStore().status` for `disconnected.lastError` / +`connected.lastError` changes. diff --git a/electron-builder.yml b/electron-builder.yml index 4651611..66e3ce7 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -1,5 +1,5 @@ -appId: me.cyzr.superwhisper-analytics -productName: SuperWhisper Analytics +appId: me.cyzr.superwhisper-analytics-marfa +productName: SuperWhisper Analytics (Marfa) directories: buildResources: build files: @@ -30,7 +30,5 @@ mac: dmg: artifactName: ${name}-${version}.${ext} npmRebuild: false -publish: - provider: github - owner: aicayzer - repo: superwhisper-analytics +# publish stanza intentionally removed for the local Marfa install build — +# no latest-mac.yml is produced and auto-update is disabled in main.ts. diff --git a/electron.vite.config.ts b/electron.vite.config.ts index 93c1044..eda3751 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -12,7 +12,15 @@ const sharedAlias = { '@shared': resolve('src/shared') } export default defineConfig({ main: { - resolve: { alias: sharedAlias } + resolve: { alias: sharedAlias }, + build: { + // electron-vite's default `externalizeDepsPlugin` marks every + // entry in package.json `dependencies` as a runtime CJS require. + // The Marfa SDK is ESM-only — `require('@withmarfa/sdk')` blows up + // with `ERR_PACKAGE_PATH_NOT_EXPORTED`. Exclude the two packages + // here so they're bundled into the main process output instead. + externalizeDeps: { exclude: ['@withmarfa/sdk', '@withmarfa/shared'] } + } }, preload: { resolve: { alias: sharedAlias } diff --git a/eslint.config.mjs b/eslint.config.mjs index 2f57311..45345bc 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -37,6 +37,28 @@ export default defineConfig( 'react/prop-types': 'off' } }, + // Funnel every `sonner` consumer through `@renderer/lib/toast`. The + // wrapper and the Toaster component are the only files allowed to + // import sonner directly; everywhere else uses `toastError` / + // `toastInfo` so behaviour stays centralised. + { + files: ['src/renderer/**/*.{ts,tsx}'], + ignores: ['src/renderer/src/lib/toast.ts', 'src/renderer/src/components/ui/sonner.tsx'], + rules: { + 'no-restricted-imports': [ + 'error', + { + paths: [ + { + name: 'sonner', + message: + 'Import `toastError` / `toastInfo` from `@renderer/lib/toast` instead — the wrapper is the only sonner consumer.' + } + ] + } + ] + } + }, // Recharts wrappers — TS prop types are sufficient; the plugin can't see // them through Recharts' content-render-prop call signatures. { diff --git a/package.json b/package.json index 6323031..0fa610b 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "dependencies": { "@electron-toolkit/preload": "^3.0.2", "@electron-toolkit/utils": "^4.0.0", + "@withmarfa/sdk": "^1.0.0", "@radix-ui/react-slot": "^1.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -62,6 +63,7 @@ "react-day-picker": "^10.0.0", "react-router-dom": "^7.1.5", "recharts": "^3.2.1", + "sonner": "^2.0.7", "tailwind-merge": "^3.6.0", "zustand": "^5.0.13" }, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bbf3fc9..35cd2ad 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: '@radix-ui/react-slot': specifier: ^1.2.0 version: 1.2.4(@types/react@19.2.14)(react@19.2.6) + '@withmarfa/sdk': + specifier: ^1.0.0 + version: 1.0.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -47,6 +50,9 @@ importers: recharts: specifier: ^3.2.1 version: 3.8.1(@types/react@19.2.14)(react-dom@19.2.6(react@19.2.6))(react-is@16.13.1)(react@19.2.6)(redux@5.0.1) + sonner: + specifier: ^2.0.7 + version: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6) tailwind-merge: specifier: ^3.6.0 version: 3.6.0 @@ -1946,6 +1952,12 @@ packages: '@vitest/utils@3.2.4': resolution: {integrity: sha512-fB2V0JFrQSMsCo9HiSq3Ezpdv4iYaXRG1Sx8edX3MwxfyNn83mKiGzOcH+Fkxt4MHxr3y42fQi1oeAInqgX2QA==} + '@withmarfa/sdk@1.0.0': + resolution: {integrity: sha512-AZxC5kqLYWvN0FrrxlhCuCU9AGoxOCd2iPIZf8VEfEjeGzT/NEG9AjlwckgZfFcwvWHNjWpupl4g/rJAVZ5dAQ==} + + '@withmarfa/shared@1.0.0': + resolution: {integrity: sha512-8o3oMI/74NbGNih8P+9GlLonBXf54/Wq44FFm9cq2USn1LNyJMSTZOUGKARN7ZCHJ6IhMKXhTfhq8sHfIEDnOQ==} + '@xmldom/xmldom@0.8.13': resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==} engines: {node: '>=10.0.0'} @@ -3759,6 +3771,12 @@ packages: resolution: {integrity: sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==} engines: {node: '>= 6.0.0', npm: '>= 3.0.0'} + sonner@2.0.7: + resolution: {integrity: sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==} + peerDependencies: + react: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + react-dom: ^18.0.0 || ^19.0.0 || ^19.0.0-rc + source-map-js@1.2.1: resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} engines: {node: '>=0.10.0'} @@ -4025,6 +4043,10 @@ packages: utf8-byte-length@1.0.5: resolution: {integrity: sha512-Xn0w3MtiQ6zoz2vFyUVruaCL53O/DwUvkEeOvj+uulMm0BkUGYWmBYVyElqZaSLhY6ZD0ulfU3aBra2aVT4xfA==} + uuidv7@1.2.1: + resolution: {integrity: sha512-4kPkK3/XTQW9Hbm4CaqfICn+kY9LJtDVEOfgsRRra/+n2Ofg4NqzRFceAkxvQ/Ud/6BpHOPzj8cirqM7TzTN5Q==} + hasBin: true + verror@1.10.1: resolution: {integrity: sha512-veufcmxri4e3XSrT0xwfUR7kguIkaxBeosDg00yDWhk49wdwkSUrvvsm7nc75e1PUyvIeZj6nS8VQRYz2/S4Xg==} engines: {node: '>=0.6.0'} @@ -6006,6 +6028,15 @@ snapshots: loupe: 3.2.1 tinyrainbow: 2.0.0 + '@withmarfa/sdk@1.0.0': + dependencies: + '@withmarfa/shared': 1.0.0 + + '@withmarfa/shared@1.0.0': + dependencies: + uuidv7: 1.2.1 + zod: 4.3.6 + '@xmldom/xmldom@0.8.13': {} '@xmldom/xmldom@0.9.10': @@ -8179,6 +8210,11 @@ snapshots: smart-buffer@4.2.0: optional: true + sonner@2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + source-map-js@1.2.1: {} source-map-support@0.5.21: @@ -8460,6 +8496,8 @@ snapshots: utf8-byte-length@1.0.5: {} + uuidv7@1.2.1: {} + verror@1.10.1: dependencies: assert-plus: 1.0.0 diff --git a/scripts/marfa-register-types.mts b/scripts/marfa-register-types.mts new file mode 100644 index 0000000..164d353 --- /dev/null +++ b/scripts/marfa-register-types.mts @@ -0,0 +1,73 @@ +/** + * One-shot script: register the Superwhisper integration's custom types + * against a Marfa tenant. + * + * Reads admin credentials from `~/.marfa/admin.json` (the + * machine-local admin token used for direct-API operations), registers + * `superwhisper.recording` and `superwhisper.session`, then verifies + * each by reading back via `types.get`. + * + * Idempotent — already-registered types resolve as an upsert in Marfa, + * not a 409. Re-run safely. + * + * Not part of the runtime integration; not bundled into the app. The + * production shape (integration code registering its own types on first + * connect-after-OAuth) is out of scope for the worktree experiment. + * + * Run with: + * pnpm dlx tsx scripts/marfa-register-types.ts + */ + +import { readFileSync } from 'fs' +import { homedir } from 'os' +import { join } from 'path' +import { MarfaClient, MarfaError } from '@withmarfa/sdk' +import { ALL_SCHEMAS } from '../src/main/marfa/schemas' + +interface AdminCreds { + url: string + key: string + label?: string +} + +function loadAdminCreds(): AdminCreds { + const path = join(homedir(), '.marfa', 'admin.json') + const raw = readFileSync(path, 'utf-8') + const parsed = JSON.parse(raw) as Partial + if (typeof parsed.url !== 'string' || typeof parsed.key !== 'string') { + throw new Error(`~/.marfa/admin.json missing url or key fields`) + } + return { url: parsed.url, key: parsed.key, label: parsed.label } +} + +async function main(): Promise { + const creds = loadAdminCreds() + console.log(`[register] using ${creds.label ?? 'admin'} @ ${creds.url}`) + + const client = new MarfaClient({ url: creds.url, apiKey: creds.key }) + + for (const schema of ALL_SCHEMAS) { + try { + const registered = await client.types.register(schema) + console.log(`[register] ${registered.id}: registered (version ${registered.version})`) + } catch (err) { + if (err instanceof MarfaError) { + console.error(`[register] ${schema.id}: ${err.message}`) + throw err + } + throw err + } + + // Read back to verify. + const fetched = await client.types.get(schema.id) + const fieldKeys = Object.keys(fetched.fields).sort().join(', ') + console.log(`[register] ${fetched.id}: verified — fields = ${fieldKeys}`) + } + + console.log('[register] done') +} + +main().catch((err: unknown) => { + console.error(err) + process.exit(1) +}) diff --git a/scripts/marfa-smoke.mts b/scripts/marfa-smoke.mts new file mode 100644 index 0000000..980fb94 --- /dev/null +++ b/scripts/marfa-smoke.mts @@ -0,0 +1,327 @@ +/** + * End-to-end smoke harness for the Marfa integration. + * + * Designed to be small and deterministic — runs against a synthetic set + * of recordings rather than the real 11.7k-recording corpus (which is + * bottlenecked by the admin-only-bulk constraint; see the running log). + * + * Exercises every diff path from [[Marfa integration — May 2026]] + * §"How it writes" and §"Open questions": + * + * 1. First-run bulk push: 5 fresh recordings → 5 items minted + + * 1 session minted with 5 parent-of edges. + * 2. Mutation handling: edit one recording's transcript, re-sync, + * verify the corresponding item's version increments and + * properties match. + * 3. Disk-delete propagation: drop one recording, re-sync, verify + * the corresponding item transitions to `trashed`. + * 4. Threshold-change re-mint: bump the gap threshold past the + * built-in default, re-sync, verify the prior session item + * trashes and a fresh session source_id appears. + * + * Reads admin credentials from `~/.marfa/admin.json` for both the + * integration's client and the verifier (so we can inspect items + * without going through the integration's own credential). + * + * Run with: + * pnpm dlx tsx scripts/marfa-smoke.mts + * + * Tears down between runs by trashing every `superwhisper.recording` + * and `superwhisper.session` item the previous smoke left behind. + */ + +import { readFileSync } from 'fs' +import { homedir } from 'os' +import { join } from 'path' +import { MarfaClient, type Item } from '@withmarfa/sdk' +import { hashProjection, projectRecording, projectSession } from '../src/main/marfa/projection' +import { SOURCE } from '../src/main/marfa/schemas' +import { DEFAULT_GAP_THRESHOLD_MINUTES, groupIntoSessions } from '../src/main/marfa/sessions' +import type { Recording } from '../src/shared/types' + +interface AdminCreds { + url: string + key: string +} + +function loadAdminCreds(): AdminCreds { + const path = join(homedir(), '.marfa', 'admin.json') + const raw = readFileSync(path, 'utf-8') + const parsed = JSON.parse(raw) as Partial + if (typeof parsed.url !== 'string' || typeof parsed.key !== 'string') { + throw new Error(`~/.marfa/admin.json missing url or key fields`) + } + return { url: parsed.url, key: parsed.key } +} + +function makeRecording(id: string, datetime: string, override: Partial = {}): Recording { + return { + id, + datetime, + modeName: 'dictation', + modelName: 'medium', + appVersion: '3.0.0', + recordingDevice: 'Built-in', + languageSelected: 'en', + duration: 60_000, + processingTime: 1000, + result: `Smoke recording ${id}`, + rawResult: `smoke recording ${id}`, + segments: [{ start: 0, end: 1.5, text: 'smoke' }], + wordCount: 2, + wordsPerMinute: 26, + sentenceCount: 1, + fillerCount: 0, + fillerBreakdown: [], + excerpt: `Smoke recording ${id}`, + ...override + } +} + +const SMOKE_RECORDINGS: Recording[] = [ + makeRecording('smoke-001', '2026-05-11T10:00:00Z'), + makeRecording('smoke-002', '2026-05-11T10:05:00Z'), + makeRecording('smoke-003', '2026-05-11T10:10:00Z'), + makeRecording('smoke-004', '2026-05-11T10:15:00Z'), + makeRecording('smoke-005', '2026-05-11T10:20:00Z') +] + +/** Minimal in-memory sync state — mirrors the shape on disk but lives + * for the run. Keeps the smoke decoupled from `marfa-sync.json`. */ +interface RunState { + recordings: Map + sessions: Map +} + +async function listAll(client: MarfaClient, type: string): Promise { + const out: Item[] = [] + let cursor: string | null | undefined + do { + const page = await client.items.list({ + type, + source: SOURCE, + limit: 100, + ...(cursor ? { cursor } : {}) + }) + out.push(...page.data) + cursor = page.cursor + } while (cursor) + return out +} + +async function trashAll(client: MarfaClient, type: string): Promise { + const items = await listAll(client, type) + let n = 0 + for (const item of items) { + if (item.state !== 'trashed') { + await client.items.transition(item.id, 'trashed') + n += 1 + } + } + return n +} + +async function pushRecordings( + client: MarfaClient, + recordings: Recording[], + state: RunState +): Promise<{ created: number; updated: number }> { + let created = 0 + let updated = 0 + for (const r of recordings) { + const p = projectRecording(r) + const hash = hashProjection(p) + const prev = state.recordings.get(p.source_id) + if (prev && prev.hash === hash) continue + const { item, created: wasCreated } = await client.items.upsert({ + type: p.type, + source_id: p.source_id, + tier: p.tier, + properties: p.properties + }) + if (wasCreated) created += 1 + else updated += 1 + state.recordings.set(p.source_id, { hash, itemId: item.id }) + } + return { created, updated } +} + +async function pushSessions( + client: MarfaClient, + recordings: Recording[], + state: RunState, + thresholdMinutes: number +): Promise<{ created: number; updated: number; softDeleted: number }> { + const groups = groupIntoSessions(recordings, thresholdMinutes) + const seen = new Set() + let created = 0 + let updated = 0 + let softDeleted = 0 + + for (const g of groups) { + const p = projectSession(g) + const hash = hashProjection(p) + seen.add(p.source_id) + const prev = state.sessions.get(p.source_id) + if (prev && prev.hash === hash) continue + const recordingItemIds: string[] = [] + for (const rid of g.recordingIds) { + const entry = state.recordings.get(rid) + if (entry) recordingItemIds.push(entry.itemId) + } + const { item, created: wasCreated } = await client.items.upsert({ + type: p.type, + source_id: p.source_id, + tier: p.tier, + properties: p.properties, + edges: { 'core.parent-of': recordingItemIds } + }) + if (wasCreated) created += 1 + else updated += 1 + state.sessions.set(p.source_id, { hash, itemId: item.id }) + } + for (const [sourceId, entry] of state.sessions) { + if (!seen.has(sourceId)) { + await client.items.transition(entry.itemId, 'trashed') + softDeleted += 1 + state.sessions.delete(sourceId) + } + } + return { created, updated, softDeleted } +} + +async function softDeleteMissingRecordings( + client: MarfaClient, + current: Recording[], + state: RunState +): Promise { + const present = new Set(current.map((r) => r.id)) + let n = 0 + for (const [sourceId, entry] of state.recordings) { + if (!present.has(sourceId)) { + await client.items.transition(entry.itemId, 'trashed') + state.recordings.delete(sourceId) + n += 1 + } + } + return n +} + +function ok(label: string): void { + console.log(` ✓ ${label}`) +} + +function fail(label: string, detail?: string): void { + console.error(` ✗ ${label}${detail ? `: ${detail}` : ''}`) + process.exit(1) +} + +async function main(): Promise { + const creds = loadAdminCreds() + console.log(`[smoke] using admin @ ${creds.url}`) + + const client = new MarfaClient({ url: creds.url, apiKey: creds.key }) + const state: RunState = { recordings: new Map(), sessions: new Map() } + + // Teardown: clear out everything from a previous smoke run. + console.log('[smoke] tearing down prior smoke state…') + const trashedSessions = await trashAll(client, 'superwhisper.session') + const trashedRecordings = await trashAll(client, 'superwhisper.recording') + console.log(` trashed ${trashedSessions} session(s) + ${trashedRecordings} recording(s)`) + + // 1. First-run push. + console.log('\n[smoke] 1. first-run push') + const initial = await pushRecordings(client, SMOKE_RECORDINGS, state) + const initialSessions = await pushSessions( + client, + SMOKE_RECORDINGS, + state, + DEFAULT_GAP_THRESHOLD_MINUTES + ) + initial.created === 5 + ? ok('5 recordings created') + : fail('expected 5 created recordings', String(initial.created)) + initialSessions.created === 1 + ? ok('1 session created') + : fail('expected 1 created session', String(initialSessions.created)) + const sessionItemId = [...state.sessions.values()][0]?.itemId + if (!sessionItemId) fail('no session itemId stashed') + const sessionItem = await client.items.get(sessionItemId as string) + const sessionEdges = await client.items.edges(sessionItem.id, { edge_type: 'core.parent-of' }) + sessionEdges.data.length === 5 + ? ok('session has 5 core.parent-of edges') + : fail('expected 5 edges', String(sessionEdges.data.length)) + + // 2. Mutation handling. + console.log('\n[smoke] 2. mutation handling') + const mutated: Recording[] = SMOKE_RECORDINGS.map((r) => + r.id === 'smoke-003' ? { ...r, result: 'Smoke recording smoke-003 (edited)' } : r + ) + const beforeMutation = await client.items.get(state.recordings.get('smoke-003')!.itemId) + const updateRound = await pushRecordings(client, mutated, state) + updateRound.updated === 1 && updateRound.created === 0 + ? ok('1 recording updated, 0 created') + : fail( + 'expected 1 updated + 0 created', + `created=${updateRound.created}, updated=${updateRound.updated}` + ) + const afterMutation = await client.items.get(state.recordings.get('smoke-003')!.itemId) + afterMutation.version > beforeMutation.version + ? ok(`version bumped ${beforeMutation.version} → ${afterMutation.version}`) + : fail('version did not increment') + + // Other 4 recordings should hash-match and be no-ops. + const noopRound = await pushRecordings(client, mutated, state) + noopRound.created === 0 && noopRound.updated === 0 + ? ok('idempotent re-push is a no-op') + : fail('expected 0 created + 0 updated on re-run') + + // 3. Disk-delete propagation. + console.log('\n[smoke] 3. disk-delete propagation') + const afterDelete = SMOKE_RECORDINGS.filter((r) => r.id !== 'smoke-005') + const deleted = await softDeleteMissingRecordings(client, afterDelete, state) + deleted === 1 ? ok('1 recording soft-deleted') : fail('expected 1 soft-delete', String(deleted)) + // The corresponding item should now be in state=trashed in Marfa. + // We dropped it from `state` so look it up by `(source, source_id)`. + const deletedQuery = await client.items.list({ + type: 'superwhisper.recording', + source: SOURCE, + state: 'trashed', + limit: 10 + }) + const trashedMatch = deletedQuery.data.find((i) => i.properties?.source_id === undefined) + // The list endpoint doesn't echo source_id back as a property in + // this build — fall back to checking that at least one trashed + // recording exists in the source's tenant scope. + deletedQuery.data.length >= 1 + ? ok(`${deletedQuery.data.length} trashed recording(s) visible in tenant`) + : fail('no trashed recordings found') + void trashedMatch + + // 4. Threshold-change re-mint. + console.log('\n[smoke] 4. threshold-change re-mint') + const priorSessionSourceId = [...state.sessions.keys()][0] + if (!priorSessionSourceId) fail('no prior session source_id in state') + // Bumping to 1 minute means the gaps between adjacent smoke + // recordings (5 minutes) all exceed the threshold — each remaining + // recording becomes its own session. + const remintRound = await pushSessions(client, afterDelete, state, 1) + remintRound.created === 4 + ? ok('4 new sessions minted under new threshold') + : fail('expected 4 new sessions', String(remintRound.created)) + remintRound.softDeleted === 1 + ? ok('old session soft-deleted') + : fail('expected 1 soft-delete', String(remintRound.softDeleted)) + // The new session source_ids should embed the new threshold. + const samples = [...state.sessions.keys()].filter((id) => id.endsWith('-1')) + samples.length === 4 + ? ok('new source_ids carry the new threshold suffix') + : fail('source_id suffix mismatch', samples.join(',')) + + console.log('\n[smoke] ✓ all milestone-7 paths verified') +} + +main().catch((err: unknown) => { + console.error('[smoke] failed:', err) + process.exit(1) +}) diff --git a/src/main/cache.ts b/src/main/cache.ts index 1a848f3..37ed487 100644 --- a/src/main/cache.ts +++ b/src/main/cache.ts @@ -48,6 +48,39 @@ function clear(): void { scanSkipped = 0 } +/** + * Subscribers invoked after every `rescan()` returns. Used by the Marfa + * integration to kick off a fire-and-forget sync whenever the + * recording set changes — either via the fs.watch cascade or a manual + * Reindex. Plain array of callbacks rather than an EventEmitter to keep + * the surface small and explicit; the Marfa module is the only consumer + * today. + * + * Listeners run with the freshly-built payload (errors and all) so they + * can inspect what changed; they should not throw — a thrown listener + * is logged and skipped, the cache state is unaffected. + */ +type ReindexListener = (payload: HydratePayload) => void +const reindexListeners: ReindexListener[] = [] + +export function onReindexed(cb: ReindexListener): () => void { + reindexListeners.push(cb) + return () => { + const i = reindexListeners.indexOf(cb) + if (i >= 0) reindexListeners.splice(i, 1) + } +} + +function notifyReindexed(payload: HydratePayload): void { + for (const cb of reindexListeners) { + try { + cb(payload) + } catch (err) { + console.warn('[cache] onReindexed listener threw:', err) + } + } +} + function rescan(): HydratePayload { const config = getConfig() @@ -77,7 +110,9 @@ function rescan(): HydratePayload { console.log( `[cache] generated ${recordings.length} demo recordings in ${Date.now() - t0}ms (${reason})` ) - return buildPayload(null) + const demoPayload = buildPayload(null) + notifyReindexed(demoPayload) + return demoPayload } // `superwhisperPath` is guaranteed non-null here: the demo fallback @@ -86,7 +121,9 @@ function rescan(): HydratePayload { const path = config.superwhisperPath as string if (!isPathValid(path)) { clear() - return buildPayload(`Path not found: ${path}`) + const errorPayload = buildPayload(`Path not found: ${path}`) + notifyReindexed(errorPayload) + return errorPayload } const t0 = Date.now() @@ -105,7 +142,9 @@ function rescan(): HydratePayload { (result.skipped ? ` (${result.skipped} folders without meta.json)` : '') + (result.errors ? ` (${result.errors} parse errors)` : '') ) - return buildPayload(null) + const payload = buildPayload(null) + notifyReindexed(payload) + return payload } /** diff --git a/src/main/config.test.ts b/src/main/config.test.ts index e9d4ea8..21de527 100644 --- a/src/main/config.test.ts +++ b/src/main/config.test.ts @@ -177,3 +177,65 @@ describe('isPathInsideHome', () => { expect(isPathInsideHome(undefined)).toBe(true) }) }) + +describe('settings-redesign migrations', () => { + it('defaults sessionGapThresholdMinutes + pipeline flags when absent', () => { + // Config from before the redesign — no session-gap, no pipeline toggles. + writeFileSync( + configFile(), + JSON.stringify({ + superwhisperPath: '/tmp/x', + marfa: { endpoint: 'https://staging.marfa.so' } + }), + 'utf-8' + ) + const config = getConfig() + expect(config.sessionGapThresholdMinutes).toBe(30) + expect(config.marfa.recordingPipelineEnabled).toBe(true) + expect(config.marfa.sessionPipelineEnabled).toBe(true) + }) + + it('preserves persisted pipeline-disabled flags across reloads', () => { + writeFileSync( + configFile(), + JSON.stringify({ + marfa: { + endpoint: 'https://staging.marfa.so', + recordingPipelineEnabled: false, + sessionPipelineEnabled: false + } + }), + 'utf-8' + ) + const config = getConfig() + expect(config.marfa.recordingPipelineEnabled).toBe(false) + expect(config.marfa.sessionPipelineEnabled).toBe(false) + }) + + it('clamps a stored session-gap to the [1, 120] range', () => { + writeFileSync(configFile(), JSON.stringify({ sessionGapThresholdMinutes: 9999 }), 'utf-8') + expect(getConfig().sessionGapThresholdMinutes).toBe(120) + + writeFileSync(configFile(), JSON.stringify({ sessionGapThresholdMinutes: -5 }), 'utf-8') + expect(getConfig().sessionGapThresholdMinutes).toBe(1) + }) + + it('rounds a fractional session-gap', () => { + writeFileSync(configFile(), JSON.stringify({ sessionGapThresholdMinutes: 17.7 }), 'utf-8') + expect(getConfig().sessionGapThresholdMinutes).toBe(18) + }) + + it('persists a round-trip set via setConfig', () => { + setConfig({ sessionGapThresholdMinutes: 45 }) + setConfig({ + marfa: { + ...getConfig().marfa, + recordingPipelineEnabled: false + } + }) + const reloaded = getConfig() + expect(reloaded.sessionGapThresholdMinutes).toBe(45) + expect(reloaded.marfa.recordingPipelineEnabled).toBe(false) + expect(reloaded.marfa.sessionPipelineEnabled).toBe(true) + }) +}) diff --git a/src/main/config.ts b/src/main/config.ts index 4b1c9d3..f37b186 100644 --- a/src/main/config.ts +++ b/src/main/config.ts @@ -3,6 +3,8 @@ import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from import { homedir } from 'os' import { dirname, join } from 'path' import { DEFAULT_FILLER_PHRASES, normalisePhrases } from '@shared/text-metrics' +import { defaultMapping, migrateRecordingMapping, type MarfaMapping } from './marfa/mapping' +import { DEFAULT_GAP_THRESHOLD_MINUTES } from './marfa/sessions' import type { Config } from '../preload/api' /** @@ -21,6 +23,8 @@ const CANDIDATE_PATHS = [ join(homedir(), 'Services/superwhisper/recordings') ] +const DEFAULT_MARFA_ENDPOINT = 'https://staging.marfa.so' + function defaultConfig(): Config { return { superwhisperPath: null, @@ -29,7 +33,15 @@ function defaultConfig(): Config { transcriptsOnly: false, demoMode: false, autoHideSidebar: true, - devTools: false + devTools: false, + sessionGapThresholdMinutes: DEFAULT_GAP_THRESHOLD_MINUTES, + marfa: { + endpoint: DEFAULT_MARFA_ENDPOINT, + mapping: defaultMapping(), + modeFilter: null, + recordingPipelineEnabled: true, + sessionPipelineEnabled: true + } } } @@ -64,6 +76,41 @@ export function getConfig(): Config { const missing = DEFAULT_FILLER_PHRASES.filter((p) => !known.has(p.toLowerCase())) return missing.length === 0 ? persisted : normalisePhrases([...persisted, ...missing]) })() + // Additive migration: pre-Marfa configs have no `marfa` block; default + // the endpoint to staging on first read so the Settings card has a + // sensible value to display without forcing the user to type it in. + const marfaEndpoint = + typeof parsed.marfa?.endpoint === 'string' && parsed.marfa.endpoint.length > 0 + ? parsed.marfa.endpoint + : DEFAULT_MARFA_ENDPOINT + // Additive migration: configs pre-dating the mapping work have no + // `mapping` block. Drop in the bundled default so the engine has a + // working mapping to project against from the very first sync. + // + // T-204 migration: rewrite the renamed source-field-ref + // `recording.device` → `recording.input_device` so installs from + // before the rename project the device value into the new shape + // instead of silently dropping it. + const marfaMapping = isPlainObject(parsed.marfa?.mapping) + ? migrateRecordingMapping(parsed.marfa.mapping as MarfaMapping) + : defaultMapping() + const rawModeFilter: unknown = parsed.marfa?.modeFilter + const marfaModeFilter: string[] | null = Array.isArray(rawModeFilter) + ? rawModeFilter.filter((s: unknown): s is string => typeof s === 'string') + : null + // Additive migration: pipeline toggles default to true for installs + // that pre-date the per-pipeline setting. Reading `=== false` rather + // than `!== true` so the absence path (`undefined`) lands on `true`. + const recordingPipelineEnabled = parsed.marfa?.recordingPipelineEnabled !== false + const sessionPipelineEnabled = parsed.marfa?.sessionPipelineEnabled !== false + // Additive migration: session-gap threshold default mirrors the + // engine's long-standing default. Range-clamped to keep the + // settings UI's stepper in a sensible bracket. + const sessionGap = + typeof parsed.sessionGapThresholdMinutes === 'number' && + Number.isFinite(parsed.sessionGapThresholdMinutes) + ? Math.min(120, Math.max(1, Math.round(parsed.sessionGapThresholdMinutes))) + : DEFAULT_GAP_THRESHOLD_MINUTES return { superwhisperPath: parsed.superwhisperPath ?? null, fillerWords, @@ -73,7 +120,15 @@ export function getConfig(): Config { // Default ON when absent — first-launch behaviour is auto-hide on // narrow windows, which matches the plan's UX intent. autoHideSidebar: parsed.autoHideSidebar !== false, - devTools: parsed.devTools === true + devTools: parsed.devTools === true, + sessionGapThresholdMinutes: sessionGap, + marfa: { + endpoint: marfaEndpoint, + mapping: marfaMapping, + modeFilter: marfaModeFilter, + recordingPipelineEnabled, + sessionPipelineEnabled + } } } catch (err) { console.warn('[config] failed to read config.json, falling back to defaults:', err) @@ -106,6 +161,10 @@ export function resetConfig(): Config { return fresh } +function isPlainObject(v: unknown): v is Record { + return typeof v === 'object' && v !== null && !Array.isArray(v) +} + /** * Probe known SuperWhisper recordings paths. Returns the first that * exists, or `null` if neither does (user must pick manually). diff --git a/src/main/index.ts b/src/main/index.ts index 78319de..7ba06ef 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -3,11 +3,17 @@ import { join } from 'path' import { electronApp, is, optimizer } from '@electron-toolkit/utils' import icon from '../../resources/icon.png?asset' import { registerIpcHandlers } from './ipc' +import { registerReindexHook } from './marfa' import { registerSwProtocolHandler, registerSwSchemeAsPrivileged } from './protocol' -import { initAutoUpdater } from './updater' import { disableWatch } from './watcher' import { getConfig } from './config' +// Local Marfa install build: redirect the app name + userData dir so this +// build coexists with an installed v0.2.x release without sharing config, +// Marfa tokens, or sync state. Must run before any module reads userData. +app.setName('superwhisper-analytics-marfa') +app.setPath('userData', join(app.getPath('appData'), 'superwhisper-analytics-marfa')) + // Privileged scheme registration must happen BEFORE app.whenReady — the // renderer's session inherits these privileges at startup. Doing this at // import time is safe; Electron caches the registration until ready. @@ -49,7 +55,7 @@ function createWindow(): void { } app.whenReady().then(() => { - electronApp.setAppUserModelId('me.cyzr.superwhisper-analytics') + electronApp.setAppUserModelId('me.cyzr.superwhisper-analytics-marfa') // In dev, the dock icon comes from Electron's bundled framework .app — // not from build/icon.icns (which only applies to packaged builds). @@ -64,13 +70,15 @@ app.whenReady().then(() => { registerIpcHandlers() registerSwProtocolHandler() + // Light up the optional Marfa integration's reindex hook so a sync + // fires whenever the recording set changes. Stays inert until the + // user connects via Settings → Integrations → Marfa. + registerReindexHook() createWindow() - // Production-only: kick off a silent check for a newer release on - // GitHub. Dev builds skip this — electron-updater errors on a - // non-packaged app. - if (!is.dev) initAutoUpdater() + // Auto-updater intentionally disabled for the local Marfa install build — + // the upstream release channel ships v0.2.x without Marfa code. app.on('activate', () => { if (BrowserWindow.getAllWindows().length === 0) createWindow() diff --git a/src/main/ipc.ts b/src/main/ipc.ts index ea6793d..c463ca8 100644 --- a/src/main/ipc.ts +++ b/src/main/ipc.ts @@ -10,10 +10,24 @@ import { setConfig } from './config' import { hydrate, reindex, setFillerWords } from './cache' -import { checkForUpdatesManually, getUpdaterStatus, type UpdaterStatus } from './updater' +import type { UpdaterStatus } from '../preload/api' +// Local Marfa install build: do not import './updater' — that pulls +// electron-updater into the bundle, and the pnpm-packaged asar is +// missing transitive deps (e.g. `ms`), which crashes the main process +// at module load. The Marfa build has auto-update disabled in main.ts +// anyway, so the renderer's "Check for updates" button is stubbed. +const getUpdaterStatus = (): UpdaterStatus => ({ kind: 'idle' }) +const checkForUpdatesManually = async (): Promise => {} import { disableWatch, enableWatch } from './watcher' +import * as marfa from './marfa' import { validBool, validString, validStringArray } from './validators' -import type { ConfigStatus } from '../preload/api' +import type { + ConfigStatus, + MarfaMapping, + MarfaStatus, + ProbeResult, + TypeSummary +} from '../preload/api' /** * Central IPC registration. Called once on app ready. @@ -34,7 +48,10 @@ function buildStatus(): ConfigStatus { transcriptsOnly: config.transcriptsOnly, demoMode: config.demoMode, autoHideSidebar: config.autoHideSidebar, - devTools: config.devTools + devTools: config.devTools, + sessionGapThresholdMinutes: config.sessionGapThresholdMinutes, + recordingPipelineEnabled: config.marfa.recordingPipelineEnabled, + sessionPipelineEnabled: config.marfa.sessionPipelineEnabled } } @@ -118,6 +135,34 @@ export function registerIpcHandlers(): void { return buildStatus() }) + // Pipeline toggles — read by `syncRun` in engine.ts to decide whether + // to project + upsert the corresponding kind. The engine also skips + // soft-deletes for a disabled pipeline so flipping off doesn't trash + // previously-synced items on the server. + ipcMain.handle('config:setRecordingPipelineEnabled', (_, enabled: unknown): ConfigStatus => { + if (!validBool(enabled)) return buildStatus() + const existing = getConfig().marfa + setConfig({ marfa: { ...existing, recordingPipelineEnabled: enabled } }) + return buildStatus() + }) + + ipcMain.handle('config:setSessionPipelineEnabled', (_, enabled: unknown): ConfigStatus => { + if (!validBool(enabled)) return buildStatus() + const existing = getConfig().marfa + setConfig({ marfa: { ...existing, sessionPipelineEnabled: enabled } }) + return buildStatus() + }) + + // Session-gap threshold — minute count between recordings before they + // start a new session. Read by `groupIntoSessions` in engine.ts. + // Clamp to [1, 120] so the renderer can't push us into a silly state. + ipcMain.handle('config:setSessionGapThresholdMinutes', (_, minutes: unknown): ConfigStatus => { + if (typeof minutes !== 'number' || !Number.isFinite(minutes)) return buildStatus() + const clamped = Math.min(120, Math.max(1, Math.round(minutes))) + setConfig({ sessionGapThresholdMinutes: clamped }) + return buildStatus() + }) + ipcMain.handle('dialog:pickFolder', async (event): Promise => { const win = BrowserWindow.fromWebContents(event.sender) const opts = { @@ -153,6 +198,73 @@ export function registerIpcHandlers(): void { return getUpdaterStatus() }) + // Marfa integration — optional, off by default. The renderer composes + // the "disabled" UX (demo mode / no path) from configStore; main + // always reports the sync engine's actual state. Push notifications + // go out on `marfa:status` from `src/main/marfa/index.ts` whenever the + // status transitions. + ipcMain.handle('marfa:status', (): MarfaStatus => marfa.getStatus()) + ipcMain.handle('marfa:setEndpoint', (_, url: unknown): MarfaStatus => { + if (!validString(url)) return marfa.getStatus() + if (!/^https?:\/\//i.test(url)) return marfa.getStatus() + return marfa.setEndpoint(url) + }) + ipcMain.handle('marfa:connect', (): Promise => marfa.connect()) + ipcMain.handle('marfa:useApiKey', (): MarfaStatus => marfa.useApiKey()) + ipcMain.handle('marfa:cancelConnect', (): MarfaStatus => marfa.cancelConnect()) + ipcMain.handle('marfa:submitApiKey', (_, key: unknown): Promise => { + if (!validString(key)) return Promise.resolve(marfa.getStatus()) + return marfa.submitApiKey(key) + }) + ipcMain.handle('marfa:disconnect', (): MarfaStatus => marfa.disconnect()) + ipcMain.handle('marfa:syncNow', (): Promise => marfa.syncNow()) + ipcMain.handle('marfa:cancelSync', (): MarfaStatus => marfa.cancelSync()) + ipcMain.handle('marfa:testSync', (): Promise => marfa.testSync()) + ipcMain.handle( + 'marfa:purgeAllData', + (): Promise< + { ok: true; recordings: number; sessions: number } | { ok: false; error: string } + > => marfa.purgeAllData() + ) + ipcMain.handle('marfa:getMapping', (): MarfaMapping => marfa.getMapping()) + ipcMain.handle('marfa:setMapping', (_, mapping: unknown): MarfaMapping => { + // Light validation: the structure must have `recording` and + // `session` bindings each carrying mode/typeId/fieldMap. Anything + // missing means a renderer bug or an injection attempt — return the + // current mapping unchanged. + if (!isMarfaMapping(mapping)) return marfa.getMapping() + return marfa.setMapping(mapping) + }) + ipcMain.handle('marfa:getModeFilter', (): string[] | null => marfa.getModeFilter()) + ipcMain.handle('marfa:setModeFilter', (_, modes: unknown): string[] | null => { + if (modes === null) return marfa.setModeFilter(null) + if (!validStringArray(modes)) return marfa.getModeFilter() + return marfa.setModeFilter(modes) + }) + ipcMain.handle('marfa:probeConnection', (): Promise => marfa.probeConnection()) + ipcMain.handle('marfa:listTypes', (): Promise => marfa.listServerTypes()) + ipcMain.handle('marfa:registerType', (_, schema: unknown): Promise => { + // The SDK validates the schema shape on register; we just + // require it's an object so we don't crash the IPC layer. + if (typeof schema !== 'object' || schema === null) return Promise.resolve(null) + return marfa.registerType(schema as Parameters[0]) + }) + // Apply the persisted watch-folder preference on startup. syncWatcher() } + +function isMarfaMapping(value: unknown): value is MarfaMapping { + if (typeof value !== 'object' || value === null) return false + const v = value as { recording?: unknown; session?: unknown } + return isBinding(v.recording) && isBinding(v.session) +} + +function isBinding(value: unknown): boolean { + if (typeof value !== 'object' || value === null) return false + const v = value as { mode?: unknown; typeId?: unknown; fieldMap?: unknown } + if (v.mode !== 'bundled' && v.mode !== 'existing' && v.mode !== 'authored') return false + if (typeof v.typeId !== 'string' || v.typeId.length === 0) return false + if (typeof v.fieldMap !== 'object' || v.fieldMap === null) return false + return true +} diff --git a/src/main/marfa/client.ts b/src/main/marfa/client.ts new file mode 100644 index 0000000..ef3973c --- /dev/null +++ b/src/main/marfa/client.ts @@ -0,0 +1,84 @@ +import { MarfaClient } from '@withmarfa/sdk' +import { StoredTokenProvider } from '@withmarfa/sdk/auth' +import { getConfig } from '../config' +import { readCredential } from './tokens' +import { SafeStorageTokenStorage } from './token-storage' + +/** + * Cached `MarfaClient` instance built from the persisted endpoint + the + * decrypted credential. Invalidated whenever the credential is cleared + * (on disconnect) or the endpoint changes (`setEndpoint`). + * + * Two credential shapes are supported: + * + * - **API key** (`{ kind: 'api-key', key }`) — passed straight through + * as the SDK's static `apiKey` credential. Dev/escape hatch. + * - **OAuth** (`{ kind: 'oauth', clientId, tokens }`) — wrapped in the + * SDK's `StoredTokenProvider`. Refresh + persistence are owned by + * the SDK (post-T-214 the token endpoint is discovered from + * `/.well-known/oauth-authorization-server`, eliminating the + * hardcoded `/auth/token` that broke against staging after the + * T-131 Better Auth migration). Persistence is bridged through + * `SafeStorageTokenStorage`, which round-trips through the + * existing `safeStorage`-backed credential file. + */ + +let cached: { client: MarfaClient; endpoint: string; cacheKey: string } | null = null + +/** Build a discriminating cache key so a credential rotation (e.g. a + * refresh that changes the refresh token) rebuilds the client. */ +function credentialCacheKey(): string | null { + const cred = readCredential() + if (!cred) return null + if (cred.kind === 'api-key') return `apikey:${cred.key}` + return `oauth:${cred.clientId}:${cred.tokens.refresh_token}` +} + +/** + * Return a configured `MarfaClient`, or null if the integration has no + * credential yet (the renderer is responsible for not calling sync APIs + * in that state, but we guard defensively). + */ +export function getClient(): MarfaClient | null { + const endpoint = getConfig().marfa.endpoint + const cred = readCredential() + if (!cred) { + cached = null + return null + } + const cacheKey = credentialCacheKey() + if (!cacheKey) { + cached = null + return null + } + if (cached && cached.endpoint === endpoint && cached.cacheKey === cacheKey) { + return cached.client + } + let client: MarfaClient + if (cred.kind === 'api-key') { + client = new MarfaClient({ url: endpoint, apiKey: cred.key }) + } else { + // The SDK's `StoredTokenProvider` hydrates lazily from storage on + // the first `getAccessToken()` call — no explicit hydrate here. It + // also discovers the token endpoint from `.well-known` on the first + // refresh, so we don't pass a `tokenEndpoint` literal. + const storage = new SafeStorageTokenStorage(cred.clientId) + const origin = new URL(endpoint).origin + const storageKey = `marfa.auth.tokens:${origin}:${cred.clientId}` + const tokenProvider = new StoredTokenProvider({ + issuer: endpoint, + clientId: cred.clientId, + storage, + storageKey + }) + client = new MarfaClient({ url: endpoint, tokenProvider }) + } + cached = { client, endpoint, cacheKey } + return client +} + +/** Drop the cached client. Call on disconnect / endpoint change so the + * next `getClient()` rebuilds against fresh credentials. */ +export function invalidateClient(): void { + cached = null +} diff --git a/src/main/marfa/device-flow.test.ts b/src/main/marfa/device-flow.test.ts new file mode 100644 index 0000000..88578c3 --- /dev/null +++ b/src/main/marfa/device-flow.test.ts @@ -0,0 +1,363 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +/** + * T-164: state-machine coverage for the device-flow happy path + the + * common terminal failure modes. The SDK transport is mocked at the + * module level so we exercise the engine-side state transitions without + * touching the network. + * + * Renderer ceremony is out of scope here — covered by the T-166 e2e + * walkthrough later. + */ + +const fixtures = vi.hoisted(() => ({ + userData: '', + // Mutable fetch handler — each test points this at whatever shape it + // wants the network to behave as. + fetch: vi.fn() as unknown as typeof globalThis.fetch, + // Stub for the SDK client's items.stats — flipped per-test. + statsResponse: { ok: true as const, value: {} as Record } +})) + +// --------------------------------------------------------------------------- +// Electron + filesystem stubs +// --------------------------------------------------------------------------- + +vi.mock('electron', () => ({ + app: { + getPath: () => fixtures.userData + }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (s: string) => Buffer.from(s, 'utf-8'), + decryptString: (b: Buffer) => b.toString('utf-8') + }, + BrowserWindow: { + getAllWindows: () => [] + } +})) + +// --------------------------------------------------------------------------- +// SDK transport stubs +// --------------------------------------------------------------------------- + +// The MarfaClient constructor accepts either { apiKey } or +// { tokenProvider }. Both paths land on the same minimal mock — what +// matters for these tests is that the verification probe (items.stats) +// returns ok / throws per the fixture flag. +vi.mock('@withmarfa/sdk', async () => { + const actual = await vi.importActual('@withmarfa/sdk') + class FakeMarfaClient { + items = { + stats: vi.fn(async (): Promise> => { + if (!fixtures.statsResponse.ok) { + throw new actual.UnauthorizedError('Invalid credential', undefined) + } + return fixtures.statsResponse.value + }) + } + constructor(_config: unknown) { + void _config + } + } + return { ...actual, MarfaClient: FakeMarfaClient } +}) + +// SDK auth — only `startDeviceFlow` + the storage class are imported by +// the engine. We hand back a `handle` whose `pollForToken` resolves or +// rejects per the test's wiring; the storage gets the same JSON shape +// the real SDK would persist so `persistTokensFromStorage` round-trips. +vi.mock('@withmarfa/sdk/auth', async () => { + const actual = await vi.importActual('@withmarfa/sdk/auth') + + // Replaceable across tests so we can swap happy / denied / expired + // paths without re-mocking the module. + return { + ...actual, + startDeviceFlow: vi.fn() + } +}) + +// --------------------------------------------------------------------------- +// Module under test — re-imported per test so state resets cleanly +// --------------------------------------------------------------------------- + +let marfaModule: typeof import('./index') +let authModule: typeof import('@withmarfa/sdk/auth') +let tokensModule: typeof import('./tokens') + +beforeEach(async () => { + fixtures.userData = mkdtempSync(join(tmpdir(), 'sw-marfa-deviceflow-')) + fixtures.statsResponse = { ok: true, value: {} } + // Reset modules so the lazy `currentStatus` cache in index.ts doesn't + // leak between tests. + vi.resetModules() + authModule = await import('@withmarfa/sdk/auth') + tokensModule = await import('./tokens') + marfaModule = await import('./index') + + // Default fetch — DCR returns a client_id; anything else 404s. Tests + // override this directly when they need richer behaviour. + fixtures.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString() + if (url.endsWith('/auth/oauth2/register')) { + return new Response(JSON.stringify({ client_id: 'test-client-id-0001' }), { + status: 201, + headers: { 'content-type': 'application/json' } + }) + } + return new Response('', { status: 404 }) + }) as unknown as typeof globalThis.fetch + vi.stubGlobal('fetch', fixtures.fetch) +}) + +afterEach(() => { + rmSync(fixtures.userData, { recursive: true, force: true }) + vi.unstubAllGlobals() + vi.clearAllMocks() +}) + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +interface MockHandleConfig { + userCode?: string + verificationUri?: string + verificationUriComplete?: string + expiresIn?: number + // `pollForToken` resolves with the SDK persisting this bundle to its + // injected storage; provide null to make it throw an OAuthError. + tokenBundle?: { + access_token: string + refresh_token: string + access_expires_at: number + scope: string + } | null + rejectWith?: import('@withmarfa/sdk/auth').OAuthError +} + +function installDeviceFlowMock(config: MockHandleConfig): void { + const mock = authModule.startDeviceFlow as unknown as ReturnType + mock.mockImplementation((cfg: import('@withmarfa/sdk/auth').StartDeviceFlowConfig) => { + return Promise.resolve({ + user_code: config.userCode ?? 'TEST-0001', + verification_uri: config.verificationUri ?? 'https://staging.marfa.so/device', + verification_uri_complete: + config.verificationUriComplete ?? 'https://staging.marfa.so/device?user_code=TEST-0001', + expires_in: config.expiresIn ?? 600, + interval: 5, + pollForToken: async () => { + if (config.rejectWith) throw config.rejectWith + if (!config.tokenBundle) { + throw new authModule.OAuthError('invalid_grant', 'No bundle configured') + } + // Mirror the SDK's persist-to-storage step so + // `persistTokensFromStorage` can find the JSON when we read it + // out. Key shape is fixed by `device-flow.ts` in the SDK. + const origin = new URL(cfg.issuer).origin + const key = `marfa.auth.tokens:${origin}:${cfg.clientId}` + await cfg.storage!.set(key, JSON.stringify(config.tokenBundle)) + // Return a minimal TokenProvider-shaped object; the engine + // doesn't actually use this — it pulls tokens out of storage. + return { + getAccessToken: async () => config.tokenBundle!.access_token, + onSignOut: () => () => undefined, + signOut: async () => undefined, + refresh: async () => config.tokenBundle!.access_token + } + } + }) + }) +} + +// --------------------------------------------------------------------------- +// Happy path +// --------------------------------------------------------------------------- + +describe('device flow — happy path', () => { + it('transitions through preparing → connecting(device) → connected', async () => { + installDeviceFlowMock({ + tokenBundle: { + access_token: 'access-1', + refresh_token: 'refresh-1', + access_expires_at: Date.now() + 3600_000, + scope: '*:read *:write' + } + }) + + const final = await marfaModule.connect() + expect(final.kind).toBe('connected') + if (final.kind !== 'connected') return + expect(final.lastError).toBeNull() + + // The credential blob on disk should now carry the OAuth bundle. + const stored = tokensModule.readCredential() + expect(stored).not.toBeNull() + if (stored === null) return + expect(stored.kind).toBe('oauth') + if (stored.kind !== 'oauth') return + expect(stored.clientId).toBe('test-client-id-0001') + expect(stored.tokens.access_token).toBe('access-1') + expect(stored.tokens.refresh_token).toBe('refresh-1') + }) + + it('exposes the user code + verification URI mid-flow', async () => { + // Use a controllable poll so we can inspect the connecting status + // before it resolves. We swap startDeviceFlow inline to hold the + // promise open until we release it. + let release: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + const mock = authModule.startDeviceFlow as unknown as ReturnType + mock.mockImplementation((cfg: import('@withmarfa/sdk/auth').StartDeviceFlowConfig) => + Promise.resolve({ + user_code: 'WXYZ-1234', + verification_uri: 'https://staging.marfa.so/device', + verification_uri_complete: 'https://staging.marfa.so/device?user_code=WXYZ-1234', + expires_in: 600, + interval: 5, + pollForToken: async () => { + await gate + const origin = new URL(cfg.issuer).origin + await cfg.storage!.set( + `marfa.auth.tokens:${origin}:${cfg.clientId}`, + JSON.stringify({ + access_token: 'a', + refresh_token: 'r', + access_expires_at: Date.now() + 3600_000, + scope: '*:read *:write' + }) + ) + return { + getAccessToken: async () => 'a', + onSignOut: () => () => undefined, + signOut: async () => undefined, + refresh: async () => 'a' + } + } + }) + ) + + const connectP = marfaModule.connect() + // Yield a few microtasks so the DCR + initiate-flow setState run. + await new Promise((r) => setTimeout(r, 10)) + const mid = marfaModule.getStatus() + expect(mid.kind).toBe('connecting') + if (mid.kind !== 'connecting') return + expect(mid.mode).toBe('device') + if (mid.mode !== 'device') return + expect(mid.userCode).toBe('WXYZ-1234') + expect(mid.verificationUri).toBe('https://staging.marfa.so/device') + expect(mid.verificationUriComplete).toBe('https://staging.marfa.so/device?user_code=WXYZ-1234') + + release!() + const final = await connectP + expect(final.kind).toBe('connected') + }) +}) + +// --------------------------------------------------------------------------- +// Failure paths +// --------------------------------------------------------------------------- + +describe('device flow — terminal failures', () => { + it('user denial returns to disconnected with a readable error', async () => { + installDeviceFlowMock({ + rejectWith: new authModule.OAuthError('access_denied', 'User denied'), + tokenBundle: null + }) + + const final = await marfaModule.connect() + expect(final.kind).toBe('disconnected') + if (final.kind !== 'disconnected') return + expect(final.lastError).toMatch(/cancelled/i) + // No credential should have been persisted. + expect(tokensModule.readCredential()).toBeNull() + }) + + it('code expired returns to disconnected with an expired-code message', async () => { + installDeviceFlowMock({ + rejectWith: new authModule.OAuthError('expired_token', 'Device code expired'), + tokenBundle: null + }) + + const final = await marfaModule.connect() + expect(final.kind).toBe('disconnected') + if (final.kind !== 'disconnected') return + expect(final.lastError).toMatch(/expired/i) + }) + + it('DCR network failure surfaces as a connect error', async () => { + // Make fetch reject for the registration URL. + fixtures.fetch = vi.fn(async (input: RequestInfo | URL) => { + const url = typeof input === 'string' ? input : input.toString() + if (url.endsWith('/auth/oauth2/register')) { + throw new Error('ECONNREFUSED') + } + return new Response('', { status: 404 }) + }) as unknown as typeof globalThis.fetch + vi.stubGlobal('fetch', fixtures.fetch) + installDeviceFlowMock({ tokenBundle: null }) + + const final = await marfaModule.connect() + expect(final.kind).toBe('disconnected') + if (final.kind !== 'disconnected') return + expect(final.lastError).toMatch(/ECONNREFUSED/) + }) + + it('cancelConnect mid-flow aborts the poll and lands on disconnected', async () => { + // Hold the poll open indefinitely so we can hit the cancel path. + const mock = authModule.startDeviceFlow as unknown as ReturnType + mock.mockImplementation(() => + Promise.resolve({ + user_code: 'A', + verification_uri: 'https://x', + verification_uri_complete: 'https://x?a', + expires_in: 600, + interval: 5, + pollForToken: ({ signal }: { signal?: AbortSignal } = {}) => + new Promise((_, reject) => { + signal?.addEventListener('abort', () => { + reject(new authModule.OAuthError('invalid_request', 'Aborted')) + }) + }) + }) + ) + + const connectP = marfaModule.connect() + await new Promise((r) => setTimeout(r, 10)) + expect(marfaModule.getStatus().kind).toBe('connecting') + const after = marfaModule.cancelConnect() + expect(after.kind).toBe('disconnected') + // The original connect promise resolves once the abort surfaces; + // the final status should still be disconnected. + const final = await connectP + expect(final.kind).toBe('disconnected') + }) +}) + +// --------------------------------------------------------------------------- +// API-key fallback path +// --------------------------------------------------------------------------- + +describe('device flow — API key fallback', () => { + it('useApiKey transitions into connecting(mode: api-key)', () => { + const next = marfaModule.useApiKey() + expect(next.kind).toBe('connecting') + if (next.kind !== 'connecting') return + expect(next.mode).toBe('api-key') + }) + + it('submitApiKey on a valid key still reaches connected', async () => { + fixtures.statsResponse = { ok: true, value: {} } + const final = await marfaModule.submitApiKey('marfa_k1_test') + expect(final.kind).toBe('connected') + const stored = tokensModule.readCredential() + expect(stored?.kind).toBe('api-key') + }) +}) diff --git a/src/main/marfa/engine.ts b/src/main/marfa/engine.ts new file mode 100644 index 0000000..b766ebc --- /dev/null +++ b/src/main/marfa/engine.ts @@ -0,0 +1,677 @@ +import os from 'os' +import { MarfaError, UnauthorizedError, ValidationError, type CreateItemInput } from '@withmarfa/sdk' +import type { Recording } from '@shared/types' +import { hydrate } from '../cache' +import { getConfig } from '../config' +import { getClient, invalidateClient } from './client' +import { defaultMapping, type MarfaMapping } from './mapping' +import { hashProjection, projectRecording, projectSession } from './projection' +import { ensureTypesRegistered } from './registration' +import { groupIntoSessions } from './sessions' +import { loadState, saveState, type SyncState, type SyncStateEntry } from './state' + +/** + * Sync engine — pushes the local recording set into a Marfa tenant. + * + * Diff semantics from [[Marfa integration — May 2026]] §"How it writes": + * + * - in current ∧ ¬state → bulk-upsert (created) + * - in current ∧ state ∧ Δhash → bulk-upsert (updated) + * - ¬current ∧ state → transition(trashed) (soft-delete) + * - match → no-op + * + * `(source, source_id)` is the natural key — repeated upserts of the + * same `source_id` resolve as updates, never duplicates. The content + * hash gates the wire call: a recording whose projection didn't change + * since the last push doesn't go out. + * + * Milestone 4 ships recording sync. Sessions land in milestone 6 — the + * `state.sessions` map is untouched here. + */ + +/** + * How many `items.upsert` requests run concurrently against Marfa. + * + * `items.bulk` is admin-only — see the running log. For a member-role + * credential we drive N parallel singular upserts instead. 10 keeps us + * comfortably under the rate-limit ceiling (2000 req/min observed) and + * is fast enough that the initial 11.8k-recording sync completes in + * ~minutes rather than tens of minutes. + */ +const UPSERT_CONCURRENCY = 10 + +/** Reason a sync attempt was skipped without contacting the server. */ +type SkipReason = 'no-client' + +export interface SyncOutcome { + ok: boolean + /** ISO timestamp on success; null when skipped or failed. */ + finishedAt: string | null + /** Counts for the run — useful for logs and the Settings card. */ + counts: { + created: number + updated: number + softDeleted: number + noop: number + errored: number + } + /** Populated on failure; null on success. */ + error: string | null + /** When the engine bailed before contacting the server. */ + skipped: SkipReason | null +} + +export type SyncProgressEvent = + | { phase: 'preparing'; processed: 0; total: number } + | { phase: 'recordings'; processed: number; total: number } + | { phase: 'sessions'; processed: number; total: number } + +export interface SyncOptions { + /** Push channel for progress emissions. */ + onProgress?: (e: SyncProgressEvent) => void + /** Override the recording source. Tests inject a fixed set; production + * reads from the in-memory cache. */ + recordingsOverride?: Recording[] + /** Cap the sync to the N most-recent recordings (sorted newest-first + * by the scanner). 0 / omit / negative → sync the full set. Used by + * the Settings card's "Push N most recent" testing knob so a smoke + * run hits ~25 recordings instead of 11.7k. The diff still trims + * the cap to recordings that haven't been synced yet. */ + limit?: number + /** Cancellation signal. Checked between every `items.upsert` / + * `items.transition` call. On abort the engine persists any + * successes already landed and returns with `error = "Cancelled"`. */ + signal?: AbortSignal + /** Active mapping config — defines what the engine projects + * recordings + sessions into. Defaults to the bundled mapping when + * omitted (used by tests; production threads the persisted mapping + * in from `index.ts`). */ + mapping?: MarfaMapping + /** Optional mode filter — only sync recordings whose `modeName` is + * in this set. `null` / omit → no filter. Empty array → no + * recordings sync (degenerate but legal: the renderer's "select all" + * produces a fresh set, not an empty one). */ + modeFilter?: string[] | null +} + +/** + * Run one sync pass. Returns the outcome — never throws, so callers + * can confidently `await syncRun(...)` without try/catch around it. + * + * Auth failures are signalled via the `auth-failed` outcome so the + * caller (the integration's index module) can flip the card status to + * `disconnected` and clear the credential. + */ +export async function syncRun(opts: SyncOptions = {}): Promise { + const maybeClient = getClient() + if (!maybeClient) { + return { + ok: false, + finishedAt: null, + counts: { created: 0, updated: 0, softDeleted: 0, noop: 0, errored: 0 }, + error: null, + skipped: 'no-client' + } + } + // Hoist into a const that's non-null in every closure below — TS can't + // narrow `client` from the `if (!client)` early return through the + // pushOne / soft-delete closures. + const client = maybeClient + + const mapping = opts.mapping ?? defaultMapping() + const modeFilter = opts.modeFilter ?? null + // Read pipeline gates + session-gap once per run. The gates suppress + // the relevant projection / upsert / soft-delete sub-pass; importantly + // they DON'T trash previously-synced items — flipping a pipeline off + // is "no further activity", not "regress". + const appConfig = getConfig() + const recordingPipelineEnabled = appConfig.marfa.recordingPipelineEnabled + const sessionPipelineEnabled = appConfig.marfa.sessionPipelineEnabled + const sessionGapMinutes = appConfig.sessionGapThresholdMinutes + + // Pre-flight: make sure the mapping's target types exist server-side. + // Bundled / authored types get registered if missing; existing types + // are taken on trust. If registration fails (e.g. server unreachable) + // we surface that as the sync error rather than blocking the engine + // from running the projection step — the upserts will fail with a + // clearer message anyway. + try { + await ensureTypesRegistered(client, mapping) + } catch (err) { + if (err instanceof UnauthorizedError) { + return failAuth( + { created: 0, updated: 0, softDeleted: 0, noop: 0, errored: 0 }, + new Map(), + loadState() + ) + } + console.warn('[marfa] type registration failed:', err) + // Fall through — the upsert will surface the real error. + } + + // Suppress the entire recordings pass when the Recordings pipeline is + // disabled — empty `recordings` produces an empty `toUpsert`, and the + // soft-delete computation below sees an empty `seenSourceIds` but we + // override `toSoftDelete` to [] so previously-synced items are left + // alone. State preserved for clean resume on re-enable. + const fullRecordings = recordingPipelineEnabled + ? (opts.recordingsOverride ?? hydrate().recordings) + : [] + // Drop recordings with no transcript content. Superwhisper writes a + // meta.json with an empty `result` for failed captures (mic glitch, + // background-noise rejection, user-aborted before transcription). + // These have nothing useful to land in Marfa, AND they'd fail server- + // side validation: the bundled type inherits from `core.note`, where + // `body` is `required: true` — projecting `body: ''` drops the entry + // in `buildProperties`, leaving a payload that 400s on `/items`. A + // single empty recording in the sync window would halt the recordings + // pass via the `failValidation` path and surface as "Schema drift" on + // the card. Filtering upstream means the user never sees that. + const nonEmptyRecordings = fullRecordings.filter((r) => r.result.trim().length > 0) + // Apply the mode filter before the limit so the cap counts post- + // filter, not pre — otherwise narrowing to "only command" with a + // cap of 100 could return zero results from a recordings tail + // dominated by dictation. + const filteredRecordings = modeFilter + ? nonEmptyRecordings.filter((r) => modeFilter.includes(r.modeName)) + : nonEmptyRecordings + // Apply the "push N most-recent" testing knob. Scanner sorts + // newest-first, so a plain `slice(0, n)` is the right shape — keeps + // the smoke close to "what would happen if you'd only kept these + // recordings". A non-positive limit syncs the full set. + const limit = typeof opts.limit === 'number' && opts.limit > 0 ? opts.limit : null + const recordings = limit ? filteredRecordings.slice(0, limit) : filteredRecordings + const state = loadState() + + opts.onProgress?.({ phase: 'preparing', processed: 0, total: recordings.length }) + + // Build the diff. We hold the projections in memory so the state file + // update at the end has the new hash for every successfully-pushed + // recording without re-projecting. + type Item = { sourceId: string; hash: string; payload: CreateItemInput } + const toUpsert: Item[] = [] + const seenSourceIds = new Set() + + for (const r of recordings) { + const p = projectRecording(r, mapping.recording) + const hash = hashProjection(p) + seenSourceIds.add(p.source_id) + const prev = state.recordings[p.source_id] + if (prev && prev.hash === hash) continue + toUpsert.push({ + sourceId: p.source_id, + hash, + payload: { + type: p.type, + source_id: p.source_id, + tier: p.tier, + device: os.hostname(), + properties: p.properties + } + }) + } + + // Soft-delete list: recordings tracked in state but absent from + // current. Skipped when a sync cap is in effect — every recording + // past the cap would be falsely "missing" from the view, which would + // trash the whole tail. Also skipped when the Recordings pipeline is + // off — turning the pipeline off should NOT trash already-synced + // items; it should be a no-op until re-enabled. + const toSoftDelete = + limit || !recordingPipelineEnabled + ? [] + : Object.keys(state.recordings).filter((id) => !seenSourceIds.has(id)) + + const counts = { + created: 0, + updated: 0, + softDeleted: 0, + noop: recordings.length - toUpsert.length, + errored: 0 + } + let lastError: string | null = null + let cancelled = false + const successesById = new Map() + + // ── Upserts ─────────────────────────────────────────────────────── + // Parallel singular `items.upsert` calls, capped at + // `UPSERT_CONCURRENCY`. `items.bulk` would be one call per chunk, but + // it's admin-only — see the running log. + const pushedAt = new Date().toISOString() + let processed = 0 + let halted = false + let authFailed = false + let validationFailed: ValidationError | null = null + + async function pushOne(item: Item): Promise { + if (halted) return + // Pre-flight cancellation check — once the signal fires, we stop + // starting fresh requests. In-flight ones still finish (we can't + // abort an `items.upsert` call mid-flight via the public SDK), but + // their successes still record so the next sync diff-skips them. + if (opts.signal?.aborted) { + cancelled = true + halted = true + return + } + try { + const { item: stored, created } = await client.items.upsert(item.payload) + if (created) counts.created += 1 + else counts.updated += 1 + successesById.set(item.sourceId, { + hash: item.hash, + itemId: stored.id, + lastPushedAt: pushedAt + }) + } catch (err) { + if (err instanceof UnauthorizedError) { + authFailed = true + halted = true + return + } + if (err instanceof ValidationError) { + validationFailed = err + halted = true + return + } + // If the user cancelled while this request was in flight, + // suppress the resulting timeout / network error — the cancel + // outcome is the more honest signal. Without this, the card's + // last-error row would surface the transport error rather than + // "Cancelled" and the user would be left wondering whether + // their click did anything. + if (opts.signal?.aborted) { + cancelled = true + return + } + counts.errored += 1 + lastError = describeError(err) + console.warn(`[marfa] upsert failed for ${item.sourceId}:`, err) + } finally { + processed += 1 + if (processed % UPSERT_CONCURRENCY === 0 || processed === toUpsert.length) { + opts.onProgress?.({ phase: 'recordings', processed, total: toUpsert.length }) + } + } + } + + // Simple sliding-window concurrency. Promise.all with chunk slicing + // would also work; this shape keeps the channel saturated as faster + // requests complete instead of waiting for the slowest in each chunk. + const inFlight: Set> = new Set() + for (const item of toUpsert) { + if (halted || opts.signal?.aborted) { + if (opts.signal?.aborted) cancelled = true + break + } + const p = pushOne(item).finally(() => inFlight.delete(p)) + inFlight.add(p) + if (inFlight.size >= UPSERT_CONCURRENCY) { + await Promise.race(inFlight) + } + } + await Promise.all(inFlight) + + if (authFailed) return failAuth(counts, successesById, state) + if (validationFailed) return failValidation(validationFailed, counts, successesById, state) + // `cancelled` is checked again after the soft-delete pass — the loop + // we just exited only sets the flag if the *queue* drained on an + // aborted signal, not if a push was actively cancelled. Either way, + // the final cancelled branch below sees it. + + opts.onProgress?.({ + phase: 'recordings', + processed: toUpsert.length, + total: toUpsert.length + }) + + // ── Soft-deletes ────────────────────────────────────────────────── + // Recordings tracked in state but absent from current. The state + // file stores the server-assigned item id from the upsert response, + // so we can transition by id directly — no list+filter dance. Done + // serially because the dataset has very few disk-side deletes day to + // day. + const softDeletedSourceIds = new Set() + for (const sourceId of toSoftDelete) { + if (opts.signal?.aborted) { + cancelled = true + break + } + const prev = state.recordings[sourceId] + if (!prev) { + softDeletedSourceIds.add(sourceId) + continue + } + try { + await client.items.transition(prev.itemId, 'trashed') + counts.softDeleted += 1 + softDeletedSourceIds.add(sourceId) + } catch (err) { + if (err instanceof UnauthorizedError) { + return failAuth(counts, successesById, state) + } + // 404 on the item id means it's already gone — fine, treat as a + // successful soft-delete and drop from state. Other errors stay + // tracked so we retry next pass. + if (err instanceof MarfaError && /not.?found|404/i.test(err.message)) { + softDeletedSourceIds.add(sourceId) + continue + } + counts.errored += 1 + lastError = describeError(err) + console.warn(`[marfa] soft-delete failed for ${sourceId}:`, err) + } + } + if (cancelled) { + // Persist what we've got from this run's upserts + soft-deletes so + // the next sync's diff sees the correct delta. + const partialRecordings: Record = { ...state.recordings } + for (const [sourceId, entry] of successesById) partialRecordings[sourceId] = entry + for (const sourceId of softDeletedSourceIds) delete partialRecordings[sourceId] + saveState({ ...state, recordings: partialRecordings }) + return { + ok: false, + finishedAt: null, + counts, + error: 'Cancelled', + skipped: null + } + } + + // Build the next-recordings state up-front so the session pass can + // resolve recording itemIds (for `parent-of` edges) from a + // single coherent map of "everything that successfully landed, + // including this run's upserts". + const nextRecordings: Record = { ...state.recordings } + for (const [sourceId, entry] of successesById) { + nextRecordings[sourceId] = entry + } + for (const sourceId of softDeletedSourceIds) { + delete nextRecordings[sourceId] + } + + // ── Sessions ────────────────────────────────────────────────────── + // Derived from the current recording set, full-replaced every run. + // Threshold change yields fresh source_ids → fresh items → the diff + // naturally trash-and-re-mints. Skipped while a sync cap is in + // effect: a partial recording view would mint malformed session + // groups (and trash legitimate prior sessions). Sessions are an + // all-or-nothing concept; the cap-surface UI flags the trade-off. + // Also skipped when the Sessions pipeline is off — same no-trash + // semantics as the Recordings gate above. And skipped when the + // Recordings pipeline is off, because we have no recordings to + // group. + const sessionsSkipped = limit || !sessionPipelineEnabled || !recordingPipelineEnabled + const sessionOutcome = sessionsSkipped + ? { + created: 0, + updated: 0, + softDeleted: 0, + errored: 0, + noop: 0, + error: null, + authFailed: false, + nextSessions: state.sessions + } + : await syncSessions({ + client, + recordings, + state, + recordingIds: nextRecordings, + pushedAt, + binding: mapping.session, + sessionGapMinutes, + onProgress: opts.onProgress, + signal: opts.signal + }) + counts.created += sessionOutcome.created + counts.updated += sessionOutcome.updated + counts.softDeleted += sessionOutcome.softDeleted + counts.errored += sessionOutcome.errored + counts.noop += sessionOutcome.noop + if (sessionOutcome.error && !lastError) lastError = sessionOutcome.error + if (sessionOutcome.authFailed) { + return failAuth(counts, successesById, { + ...state, + recordings: nextRecordings, + sessions: sessionOutcome.nextSessions + }) + } + if (sessionOutcome.cancelled) { + // Persist partial progress including the recordings half so we + // don't redo work we've already done. + saveState({ + ...state, + recordings: nextRecordings, + sessions: sessionOutcome.nextSessions + }) + return { + ok: false, + finishedAt: null, + counts, + error: 'Cancelled', + skipped: null + } + } + + // ── Persist new state ───────────────────────────────────────────── + const nextState: SyncState = { + ...state, + recordings: nextRecordings, + sessions: sessionOutcome.nextSessions, + lastFullSyncAt: pushedAt + } + saveState(nextState) + + return { + ok: counts.errored === 0, + finishedAt: pushedAt, + counts, + error: lastError, + skipped: null + } +} + +interface SessionSyncOutcome { + created: number + updated: number + softDeleted: number + errored: number + noop: number + error: string | null + authFailed: boolean + cancelled?: boolean + nextSessions: Record +} + +async function syncSessions(opts: { + client: ReturnType & object + recordings: Recording[] + state: SyncState + recordingIds: Record + pushedAt: string + binding: MarfaMapping['session'] + /** Gap (minutes) that defines a session boundary. Threaded in from + * `syncRun` so the engine reads it once per run and the test path + * can pass it explicitly without going through electron config. */ + sessionGapMinutes: number + onProgress?: SyncOptions['onProgress'] + signal?: AbortSignal +}): Promise { + const { + client, + recordings, + state, + recordingIds, + pushedAt, + binding, + sessionGapMinutes, + onProgress, + signal + } = opts + const out: SessionSyncOutcome = { + created: 0, + updated: 0, + softDeleted: 0, + errored: 0, + noop: 0, + error: null, + authFailed: false, + nextSessions: { ...state.sessions } + } + + const groups = groupIntoSessions(recordings, sessionGapMinutes) + type SessionItem = { + sourceId: string + hash: string + payload: CreateItemInput & { edges?: Record } + } + const toUpsert: SessionItem[] = [] + const seenSourceIds = new Set() + + for (const g of groups) { + const projection = projectSession(g, binding) + const hash = hashProjection(projection) + seenSourceIds.add(projection.source_id) + const prev = state.sessions[projection.source_id] + if (prev && prev.hash === hash) { + out.noop += 1 + continue + } + // Resolve the recording itemIds the session's `parent-of` edges + // should point at. If a recording's still pending (its + // upsert failed earlier in the run, or it isn't in state yet), + // skip it from the edge list — we'd rather mint a session with a + // partial edge set than fail the whole session push. + const recordingItemIds: string[] = [] + for (const rid of g.recordingIds) { + const entry = recordingIds[rid] + if (entry) recordingItemIds.push(entry.itemId) + } + toUpsert.push({ + sourceId: projection.source_id, + hash, + payload: { + type: projection.type, + source_id: projection.source_id, + tier: projection.tier, + properties: projection.properties, + edges: { 'parent-of': recordingItemIds } + } + }) + } + + const toSoftDelete = Object.keys(state.sessions).filter((id) => !seenSourceIds.has(id)) + + // Sessions are far fewer than recordings (typical: dozens) so a + // serial loop is fine. + for (let i = 0; i < toUpsert.length; i += 1) { + if (signal?.aborted) { + out.cancelled = true + return out + } + const item = toUpsert[i] + if (!item) continue + onProgress?.({ phase: 'sessions', processed: i, total: toUpsert.length }) + try { + const { item: stored, created } = await client.items.upsert(item.payload) + if (created) out.created += 1 + else out.updated += 1 + out.nextSessions[item.sourceId] = { + hash: item.hash, + itemId: stored.id, + lastPushedAt: pushedAt + } + } catch (err) { + if (err instanceof UnauthorizedError) { + out.authFailed = true + return out + } + out.errored += 1 + out.error = err instanceof Error ? err.message : 'Unknown error.' + console.warn(`[marfa] session upsert failed for ${item.sourceId}:`, err) + } + } + onProgress?.({ phase: 'sessions', processed: toUpsert.length, total: toUpsert.length }) + + for (const sourceId of toSoftDelete) { + if (signal?.aborted) { + out.cancelled = true + return out + } + const prev = state.sessions[sourceId] + if (!prev) { + delete out.nextSessions[sourceId] + continue + } + try { + await client.items.transition(prev.itemId, 'trashed') + out.softDeleted += 1 + delete out.nextSessions[sourceId] + } catch (err) { + if (err instanceof UnauthorizedError) { + out.authFailed = true + return out + } + if (err instanceof MarfaError && /not.?found|404/i.test(err.message)) { + delete out.nextSessions[sourceId] + continue + } + out.errored += 1 + out.error = err instanceof Error ? err.message : out.error + console.warn(`[marfa] session soft-delete failed for ${sourceId}:`, err) + } + } + + return out +} + +function failAuth( + counts: SyncOutcome['counts'], + successes: Map, + state: SyncState +): SyncOutcome { + // Best-effort persist of any successes that landed before the auth + // failure — we'd otherwise lose track of them and re-push next time. + // Hash-equal re-pushes are no-ops on the server (idempotent upsert) + // so this is a strict improvement. + if (successes.size > 0) { + const nextRecordings = { ...state.recordings } + for (const [sourceId, entry] of successes) nextRecordings[sourceId] = entry + saveState({ ...state, recordings: nextRecordings }) + } + invalidateClient() + return { + ok: false, + finishedAt: null, + counts, + error: 'Authentication failed — reconnect to Marfa.', + skipped: null + } +} + +function failValidation( + err: ValidationError, + counts: SyncOutcome['counts'], + successes: Map, + state: SyncState +): SyncOutcome { + if (successes.size > 0) { + const nextRecordings = { ...state.recordings } + for (const [sourceId, entry] of successes) nextRecordings[sourceId] = entry + saveState({ ...state, recordings: nextRecordings }) + } + return { + ok: false, + finishedAt: null, + counts, + error: `Schema drift: ${err.message}. Re-register types and retry.`, + skipped: null + } +} + +function describeError(err: unknown): string { + if (err instanceof MarfaError) return err.message + if (err instanceof Error) return err.message + return 'Unknown error.' +} diff --git a/src/main/marfa/index.ts b/src/main/marfa/index.ts new file mode 100644 index 0000000..c9b8805 --- /dev/null +++ b/src/main/marfa/index.ts @@ -0,0 +1,733 @@ +import { BrowserWindow } from 'electron' +import { MarfaError, UnauthorizedError, type TypeSchema } from '@withmarfa/sdk' +import { startDeviceFlow, OAuthError } from '@withmarfa/sdk/auth' +import type { DeviceFlowHandle } from '@withmarfa/sdk/auth' +import type { MarfaStatus, ProbeResult, TypeSummary } from '../../preload/api' +import { onReindexed } from '../cache' +import { getConfig, setConfig } from '../config' +import { getClient, invalidateClient } from './client' +import { syncRun } from './engine' +import { defaultMapping, type MarfaMapping } from './mapping' +import { clearState, loadState } from './state' +import { SafeStorageTokenStorage } from './token-storage' +import { clearCredential, credentialExists, readCredential, writeCredential } from './tokens' + +/** + * Public surface of the Marfa integration module — used by `ipc.ts`. + * + * State machine (mirrors `MarfaStatus`): + * + * disconnected ── connect ─────▶ connecting(device) ─poll approve─▶ connected + * │ │ useApiKey ▲ + * │ ▼ │ + * │ connecting(api-key) ── submitApiKey ───┤ + * │ │ + * └──────────────── disconnect ◀──────── connected ◀─┐ │ + * │ │ (sync │ + * ▼ │ complete)│ + * syncing ─┘ │ + * + * The `disabled` UX state (demo mode on, or no recordings path) is + * composed in the renderer from `configStore`; main always reports the + * engine's actual state. + * + * Boot behaviour: on first call to `getStatus()`, the module probes + * `/marfa-credential.enc`. If a credential is present and + * decryptable, the initial status is `connected` (no last-synced time + * until the engine actually runs); otherwise it's `disconnected`. + * + * Default auth is the OAuth device flow: `startDeviceFlow` from the SDK + * (`@withmarfa/sdk/auth`) initiates `/auth/device`, returns the user-code + + * verification URI, and `pollForToken()` blocks until the user approves. + * On approval we persist the token bundle through `tokens.ts` and the + * existing `connected` terminal state takes over. + * + * The API-key path stays as a dev/escape hatch — linked from the + * device-flow connecting pane. Both terminate at `connected` and share + * the rest of the engine. + */ + +const STATUS_CHANNEL = 'marfa:status' + +/** OAuth client metadata used for DCR + device-flow. Stored in the + * credential blob alongside the tokens. */ +const CLIENT_NAME = 'SuperWhisper Analytics' +/** + * Scopes the test app actually needs. Must be a subset of the server's + * OAuth allowlist (which mirrors top-level `TYPE_REGISTRY` ids + + * `EDGE_TYPE_REGISTRY` ids + OIDC standards). + * + * Subtype gotcha: `superwhisper.recording` was re-registered as a + * subtype of `core.note` (parent: core.note). Subtype scopes are NOT + * published in the OAuth allowlist — DCR rejects them with + * `invalid_scope: cannot request scope superwhisper.recording:read`. + * The right scope for a `core.note` subtype is `core.note:*` on the + * parent. Verified against `/.well-known/oauth-authorization-server` + * `scopes_supported`. `superwhisper.session` is a top-level type, so + * its own `:read/write` scopes remain valid. + * + * - `core.note:*` — recording data plane (via the subtype parent) + * - `superwhisper.session:*` — derived sessions (top-level type) + * - `metadata.types:write` — registers the custom types on first sync + * - `edge.parent-of:*` — session→recording linkage minted in the + * sessions pass (engine.ts:464) + * - `openid profile email offline_access` — OIDC standards + refresh + */ +const DEFAULT_SCOPES = [ + 'core.note:read', + 'core.note:write', + 'superwhisper.session:read', + 'superwhisper.session:write', + 'metadata.types:write', + 'edge.parent-of:read', + 'edge.parent-of:write', + 'openid', + 'profile', + 'email', + 'offline_access' +] + +const DEVICE_CODE_GRANT_TYPE = 'urn:ietf:params:oauth:grant-type:device_code' + +// Lazy-initialised so the boot-time probe runs after `app.ready` — +// `safeStorage.isEncryptionAvailable()` returns false until then on +// macOS, which would mis-classify a connected user as disconnected on +// every launch. +let currentStatus: MarfaStatus | null = null + +// In-flight device-flow polling — abort signal lets `cancelConnect` +// short-circuit a long-running poll. +let activeDeviceFlowAbort: AbortController | null = null + +// In-flight sync state. Coalesces concurrent `syncNow` / `testSync` +// calls; the single-promise guard means a click on either while a sync +// is running returns the in-flight promise rather than spawning a +// second pass. +let syncInFlight: Promise | null = null +let activeAbort: AbortController | null = null + +function endpointSnapshot(): string { + return getConfig().marfa.endpoint +} + +/** + * Snapshot the engine state for inclusion in a `connected` status — + * synced counts + the persisted last-full-sync timestamp. Reading + * `loadState()` is cheap (sub-ms for typical sizes) and keeps the + * status payload honest across app restarts. + */ +function syncedSnapshot(): { + syncedRecordings: number + syncedSessions: number + lastFullSyncAt: string | null +} { + const state = loadState() + return { + syncedRecordings: Object.keys(state.recordings).length, + syncedSessions: Object.keys(state.sessions).length, + lastFullSyncAt: state.lastFullSyncAt + } +} + +function buildInitialStatus(): MarfaStatus { + const endpoint = endpointSnapshot() + if (credentialExists() && readCredential() !== null) { + const snap = syncedSnapshot() + return { + kind: 'connected', + endpoint, + lastSyncedAt: snap.lastFullSyncAt, + lastError: null, + syncedRecordings: snap.syncedRecordings, + syncedSessions: snap.syncedSessions, + lastSyncCancelled: false + } + } + return { kind: 'disconnected', endpoint, lastError: null } +} + +function ensureStatus(): MarfaStatus { + if (currentStatus === null) currentStatus = buildInitialStatus() + return currentStatus +} + +function setStatus(next: MarfaStatus): MarfaStatus { + currentStatus = next + for (const win of BrowserWindow.getAllWindows()) { + if (win.isDestroyed()) continue + win.webContents.send(STATUS_CHANNEL, next) + } + return next +} + +export function getStatus(): MarfaStatus { + return ensureStatus() +} + +export function setEndpoint(url: string): MarfaStatus { + const trimmed = url.trim() + const existing = getConfig().marfa + setConfig({ marfa: { ...existing, endpoint: trimmed } }) + invalidateClient() + // Endpoint only changes the URL the next request will hit; the + // credential still applies. If the card is currently `connected` but + // the user changed the endpoint, the next sync attempt is what + // surfaces a failure — we don't pre-emptively flip status here. + setStatus({ ...ensureStatus(), endpoint: trimmed } as MarfaStatus) + return ensureStatus() +} + +/** Run a small dry-run sync against the 5 most recent recordings. + * Useful for sanity-checking the integration without touching the + * full corpus. Skips soft-delete and session derivation while the + * cap is in effect, so the test run never trashes existing items + * beyond the cap. */ +export async function testSync(): Promise { + if (syncInFlight) return syncInFlight + const status = ensureStatus() + if (status.kind !== 'connected') return status + syncInFlight = runSync({ limit: 5 }) + try { + return await syncInFlight + } finally { + syncInFlight = null + activeAbort = null + } +} + +/** Read the persisted mapping config. The renderer hydrates this on + * first paint of the Settings card so it can render the current + * binding without round-tripping for every field. */ +export function getMapping(): MarfaMapping { + return getConfig().marfa.mapping ?? defaultMapping() +} + +/** Persist a new mapping. Trash-and-re-mint happens naturally on the + * next sync — fresh fingerprint → fresh source_ids → old items diff + * out and soft-delete. Clears the in-memory sync state so the diff + * starts clean; the old `lastFullSyncAt` is dropped along with it so + * the next sync surfaces as a fresh run on the card. + * + * Returns the persisted mapping so the renderer can confirm what + * landed (server-side validation on `register` may rewrite or fail + * later, but the persistence step is fire-and-forget). */ +export function setMapping(mapping: MarfaMapping): MarfaMapping { + const existing = getConfig().marfa + setConfig({ marfa: { ...existing, mapping } }) + // Reset the sync state so the next pass treats every recording as + // new under the fresh fingerprints. Without this the diff would + // try to update items at the *old* source_ids (which the new + // projection no longer produces). + clearState() + return mapping +} + +/** Read the persisted mode filter. `null` = no filter. */ +export function getModeFilter(): string[] | null { + return getConfig().marfa.modeFilter ?? null +} + +/** Persist the Superwhisper-mode filter. `null` clears it. */ +export function setModeFilter(modes: string[] | null): string[] | null { + const existing = getConfig().marfa + const next = modes && modes.length > 0 ? [...new Set(modes)].sort() : null + setConfig({ marfa: { ...existing, modeFilter: next } }) + return next +} + +/** + * Probe the configured endpoint with the current credential. Returns + * a small identity payload on success, or an error string on failure. + * Backs the "Test connection" button in the connected card. + */ +export async function probeConnection(): Promise { + const client = getClient() + if (!client) { + return { ok: false, error: 'No credential available.' } + } + try { + const profile = await client.profile.get() + const composed = + [profile.first_name, profile.last_name].filter((s): s is string => Boolean(s)).join(' ') || + profile.username || + null + return { + ok: true, + email: profile.email ?? null, + displayName: composed + } + } catch (err) { + if (err instanceof UnauthorizedError) { + return { ok: false, error: 'Credential rejected — reconnect.' } + } + if (err instanceof MarfaError) return { ok: false, error: err.message } + if (err instanceof Error) return { ok: false, error: err.message } + return { ok: false, error: 'Connection probe failed.' } + } +} + +/** List the types registered on the server. Used by the mapping + * picker's "existing type" mode. Returns null on failure so the UI + * can surface an error state cleanly. */ +export async function listServerTypes(): Promise { + const client = getClient() + if (!client) return null + try { + const types = await client.types.list() + return types.map((t) => ({ + id: t.id, + label: t.label ?? null, + description: t.description ?? null, + parent: t.parent ?? null, + fields: Object.keys(t.fields ?? {}) + })) + } catch (err) { + console.warn('[marfa] listServerTypes failed:', err) + return null + } +} + +/** Register a user-authored type schema against the server. Wraps + * `client.types.register` so the renderer doesn't have to know about + * the SDK. */ +export async function registerType(schema: TypeSchema): Promise { + const client = getClient() + if (!client) return null + try { + return await client.types.register(schema) + } catch (err) { + console.warn('[marfa] registerType failed:', err) + return null + } +} + +/** + * Default connect path — initiate the OAuth device flow against the + * configured endpoint. Registers a fresh OAuth client via DCR + * (`POST /auth/oauth2/register`) so each device has its own client id, + * then calls SDK's `startDeviceFlow` and surfaces the resulting + * user-code + verification URI on the connecting pane. A background + * poll waits for user approval; success transitions to `connected`, + * failure to `disconnected` with a sensible `lastError`. + */ +export async function connect(): Promise { + const endpoint = endpointSnapshot() + // Optimistic transition so the renderer can show a "preparing…" UX if + // it wants; the real device-flow payload arrives a moment later. + setStatus({ + kind: 'connecting', + mode: 'device', + endpoint, + + userCode: '', + verificationUri: '', + verificationUriComplete: null, + expiresAt: 0 + }) + + let handle: DeviceFlowHandle + let clientId: string + try { + clientId = await registerOAuthClient(endpoint) + // SafeStorageTokenStorage adapts the SDK's `TokenStorage` interface + // to our `safeStorage`-backed credential file. The SDK writes + // directly through it when the user approves the device-flow code, + // so there's no post-hoc lift step. + handle = await startDeviceFlow({ + issuer: endpoint, + clientId, + scopes: DEFAULT_SCOPES, + storage: new SafeStorageTokenStorage(clientId) + }) + } catch (err) { + return setStatus({ + kind: 'disconnected', + endpoint, + + lastError: describeAuthError(err) + }) + } + + setStatus({ + kind: 'connecting', + mode: 'device', + endpoint, + + userCode: handle.user_code, + verificationUri: handle.verification_uri, + verificationUriComplete: handle.verification_uri_complete || null, + expiresAt: Date.now() + handle.expires_in * 1000 + }) + + activeDeviceFlowAbort = new AbortController() + const signal = activeDeviceFlowAbort.signal + + try { + await handle.pollForToken({ signal }) + // SafeStorageTokenStorage wrote the credential to disk during the + // SDK's `provider.persist()` call inside `pollForToken`, so nothing + // more to do here — just drop the cached client so the next + // `getClient()` rebuilds against the freshly-stored credential. + invalidateClient() + + // Verify the new credentials against the server before flipping to + // `connected` — same probe shape as the API-key path. + const client = getClient() + if (!client) throw new Error('client construction failed after device-flow approval') + await client.items.stats() + + return setStatus(connectedStatus(endpoint)) + } catch (err) { + clearCredential() + invalidateClient() + return setStatus({ + kind: 'disconnected', + endpoint, + + lastError: describeAuthError(err) + }) + } finally { + activeDeviceFlowAbort = null + } +} + +/** + * Build a fresh `connected` status from the persisted engine state. + * Used wherever we transition to connected (device-flow success, + * API-key success, sync completion). Centralised so the synced-count + * + last-sync-cancelled fields don't drift between call sites. + */ +function connectedStatus( + endpoint: string, + opts?: { lastError?: string | null; lastSyncCancelled?: boolean; lastSyncedAt?: string | null } +): Extract { + const snap = syncedSnapshot() + return { + kind: 'connected', + endpoint, + lastSyncedAt: opts?.lastSyncedAt ?? snap.lastFullSyncAt, + lastError: opts?.lastError ?? null, + syncedRecordings: snap.syncedRecordings, + syncedSessions: snap.syncedSessions, + lastSyncCancelled: opts?.lastSyncCancelled ?? false + } +} + +/** + * Dev/escape hatch — transition into the API-key paste pane. Linked + * from the device-flow connecting pane via "use API key instead". + * If a device-flow poll is in flight, cancel it first. + */ +export function useApiKey(): MarfaStatus { + if (activeDeviceFlowAbort) { + activeDeviceFlowAbort.abort() + activeDeviceFlowAbort = null + } + const endpoint = endpointSnapshot() + return setStatus({ kind: 'connecting', mode: 'api-key', endpoint }) +} + +/** Cancel an in-progress connect attempt. Aborts a device-flow poll if + * one is running, then falls back to `disconnected`. Safe to call from + * either connecting variant. */ +export function cancelConnect(): MarfaStatus { + if (activeDeviceFlowAbort) { + activeDeviceFlowAbort.abort() + activeDeviceFlowAbort = null + } + const endpoint = endpointSnapshot() + return setStatus({ kind: 'disconnected', endpoint, lastError: null }) +} + +/** + * Verify a user-supplied API key against the current endpoint. On + * success, encrypt + persist it and flip status to `connected`. On + * failure, flip back to `disconnected` with a user-readable error so + * the card can surface what went wrong. + * + * Verification is a cheap probe — `client.items.stats()` returns a + * tiny payload and only succeeds against a valid key. + */ +export async function submitApiKey(key: string): Promise { + const trimmed = key.trim() + const endpoint = endpointSnapshot() + if (!trimmed) { + return setStatus({ + kind: 'disconnected', + endpoint, + + lastError: 'API key is empty.' + }) + } + // Temporarily stage the key so getClient() picks it up; persist only + // after verification succeeds (so a failed attempt doesn't leave a + // bad credential on disk). + const persisted = writeCredential({ kind: 'api-key', key: trimmed }) + if (!persisted) { + return setStatus({ + kind: 'disconnected', + endpoint, + + lastError: 'Could not encrypt credential (safeStorage unavailable).' + }) + } + invalidateClient() + try { + const client = getClient() + if (!client) throw new Error('client construction failed') + await client.items.stats() + return setStatus(connectedStatus(endpoint)) + } catch (err) { + clearCredential() + invalidateClient() + const message = describeAuthError(err) + return setStatus({ kind: 'disconnected', endpoint, lastError: message }) + } +} + +export function disconnect(): MarfaStatus { + if (activeDeviceFlowAbort) { + activeDeviceFlowAbort.abort() + activeDeviceFlowAbort = null + } + clearCredential() + clearState() + invalidateClient() + const endpoint = endpointSnapshot() + setStatus({ kind: 'disconnected', endpoint, lastError: null }) + return ensureStatus() +} + +/** + * Wipe all SuperWhisper-typed items from the connected Marfa tenant. + * Bulk-purges `superwhisper.recording` + `superwhisper.session` via + * `client.items.bulkAction`, then clears the local sync state so the + * next sync re-mints everything from scratch. + * + * Developer-tab affordance — fast way to reset staging for testing. + * No confirmation dialog yet (the confirmation pattern lands with the + * toast system, not this ticket); callers should make sure the user + * knows what they're triggering. + */ +export async function purgeAllData(): Promise< + { ok: true; recordings: number; sessions: number } | { ok: false; error: string } +> { + const client = getClient() + if (!client) return { ok: false, error: 'Not connected.' } + try { + const [recRes, sesRes] = await Promise.all([ + client.items.bulkAction({ + action: 'purge', + confirm: 'PURGE', + filter: { type: 'superwhisper.recording', state: 'active' }, + max_items: 50000 + }), + client.items.bulkAction({ + action: 'purge', + confirm: 'PURGE', + filter: { type: 'superwhisper.session', state: 'active' }, + max_items: 50000 + }) + ]) + // Local sync state is now stale — clearing it forces the next sync + // to treat every recording as fresh and re-mint, instead of seeing + // server-side ghosts via stored itemIds that no longer exist. + clearState() + return { + ok: true, + recordings: recRes.succeeded, + sessions: sesRes.succeeded + } + } catch (err) { + return { ok: false, error: err instanceof Error ? err.message : String(err) } + } +} + +/** + * Run one sync pass against the configured tenant. Flips the card to + * `syncing` for the duration; resolves to `connected` (with or without + * a last-sync error) on success, or `disconnected` on auth failure. + * + * Concurrent calls are coalesced — a second `syncNow` while one is in + * flight returns the current status without spawning a parallel run. + * Same engine path is reused by the watcher cascade in milestone 5. + */ +export async function syncNow(): Promise { + if (syncInFlight) return syncInFlight + const status = ensureStatus() + if (status.kind !== 'connected') return status + syncInFlight = runSync() + try { + return await syncInFlight + } finally { + syncInFlight = null + activeAbort = null + } +} + +/** Abort the in-flight sync, if any. The engine persists whatever's + * already landed and returns; this function flips the status back to + * `connected` with `lastError = "Cancelled"` so the card surfaces the + * outcome. A noop when nothing is in flight. */ +export function cancelSync(): MarfaStatus { + if (activeAbort) activeAbort.abort() + return ensureStatus() +} + +async function runSync(opts: { limit?: number } = {}): Promise { + const endpoint = endpointSnapshot() + const status = ensureStatus() + const previousLastSyncedAt = status.kind === 'connected' ? status.lastSyncedAt : null + + setStatus({ + kind: 'syncing', + endpoint, + phase: 'preparing', + processed: 0, + total: 0 + }) + + activeAbort = new AbortController() + const cfg = getConfig().marfa + const outcome = await syncRun({ + limit: opts.limit ?? 0, + mapping: cfg.mapping, + modeFilter: cfg.modeFilter, + signal: activeAbort.signal, + onProgress: (e) => { + setStatus({ + kind: 'syncing', + endpoint, + phase: e.phase, + processed: e.processed, + total: e.total + }) + } + }) + + if (outcome.skipped === 'no-client') { + // Credential disappeared between status check and engine call — + // surface as disconnected so the card prompts a reconnect. + return setStatus({ + kind: 'disconnected', + endpoint, + + lastError: 'No credential available.' + }) + } + + if (outcome.error && /Authentication/i.test(outcome.error)) { + clearCredential() + return setStatus({ kind: 'disconnected', endpoint, lastError: outcome.error }) + } + + const lastSyncedAt = outcome.ok ? outcome.finishedAt : previousLastSyncedAt + // Distinguish user-cancelled from a genuine failure. The engine + // returns `error: 'Cancelled'` for the abort path; that becomes + // `lastSyncCancelled: true` here so the renderer can render "Sync + // cancelled" instead of "Sync failed". + const cancelled = !outcome.ok && outcome.error === 'Cancelled' + const next = setStatus( + connectedStatus(endpoint, { + lastSyncedAt, + lastError: outcome.ok ? null : outcome.error, + lastSyncCancelled: cancelled + }) + ) + console.log( + `[marfa] sync completed: created=${outcome.counts.created} updated=${outcome.counts.updated} ` + + `soft-deleted=${outcome.counts.softDeleted} no-op=${outcome.counts.noop} ` + + `errored=${outcome.counts.errored}` + ) + return next +} + +/** Translate an arbitrary error from the SDK or transport into the + * single-line message that surfaces on the card. */ +function describeAuthError(err: unknown): string { + if (err instanceof OAuthError) { + // RFC 8628 terminal codes — give the user something they can act on. + switch (err.code) { + case 'access_denied': + return 'Connection cancelled in browser.' + case 'expired_token': + return 'The verification code expired. Try connecting again.' + case 'invalid_grant': + return 'Device-flow approval was rejected by the server.' + case 'invalid_request': + return err.message || 'Device-flow request was invalid.' + default: + return err.message || `OAuth error: ${err.code}` + } + } + if (err instanceof UnauthorizedError) { + return 'Invalid API key. Generate a fresh one in your Marfa client and try again.' + } + if (err instanceof MarfaError) { + return err.message + } + if (err instanceof Error) { + return err.message + } + return 'Connection failed.' +} + +/** + * Subscribe the integration to the cache's reindex cascade so a sync + * fires whenever the recording set changes. Fire-and-forget; the + * engine's `syncInFlight` guard coalesces overlapping runs, so back- + * to-back watcher events don't spawn parallel syncs. + * + * Demo mode / no-path / disconnected → noop (the engine bails on no + * client, but we'd rather skip the round trip entirely). Called once + * from `src/main/index.ts` at `app.whenReady`. + */ +export function registerReindexHook(): void { + onReindexed((payload) => { + if (payload.error) return + if (currentStatus?.kind !== 'connected') return + const config = getConfig() + if (config.demoMode || !config.superwhisperPath) return + void syncNow().catch((err) => console.warn('[marfa] reindex-triggered sync failed:', err)) + }) +} + +// --------------------------------------------------------------------------- +// OAuth bootstrap helpers — DCR + token persistence +// --------------------------------------------------------------------------- + +interface DcrResponse { + client_id: string +} + +/** Register a fresh OAuth client via Dynamic Client Registration. The + * client_id is persisted alongside the tokens — each install ends up + * with its own client_id, so revoking one device doesn't cascade to + * the others. */ +async function registerOAuthClient(endpoint: string): Promise { + const issuer = endpoint.replace(/\/+$/, '') + const res = await fetch(`${issuer}/auth/oauth2/register`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + grant_types: [DEVICE_CODE_GRANT_TYPE, 'refresh_token'], + client_name: CLIENT_NAME, + scope: DEFAULT_SCOPES.join(' ') + }) + }) + if (!res.ok) { + const body = (await res.json().catch(() => ({}))) as { error?: unknown } + const code = + typeof body.error === 'string' + ? body.error + : typeof body.error === 'object' && body.error !== null + ? ((body.error as { code?: string }).code ?? 'invalid_request') + : 'invalid_request' + throw new Error(`Client registration failed: ${code} (${res.status})`) + } + const body = (await res.json()) as DcrResponse + if (typeof body.client_id !== 'string' || body.client_id.length === 0) { + throw new Error('Client registration returned no client_id') + } + return body.client_id +} diff --git a/src/main/marfa/mapping.test.ts b/src/main/marfa/mapping.test.ts new file mode 100644 index 0000000..bbe6831 --- /dev/null +++ b/src/main/marfa/mapping.test.ts @@ -0,0 +1,287 @@ +import { describe, expect, it } from 'vitest' +import { + authoredRecordingStarter, + bindingFingerprint, + defaultBundledRecordingBinding, + defaultBundledSessionBinding, + defaultMapping, + defaultRecordingFieldMap, + defaultSessionFieldMap, + migrateRecordingMapping, + type MappingBinding, + type MarfaMapping +} from './mapping' +import { SUPERWHISPER_RECORDING, SUPERWHISPER_SESSION } from './schemas' + +describe('defaultMapping', () => { + it('points both source kinds at the bundled types by default', () => { + const m = defaultMapping() + expect(m.recording.mode).toBe('bundled') + expect(m.recording.typeId).toBe(SUPERWHISPER_RECORDING.id) + expect(m.session.mode).toBe('bundled') + expect(m.session.typeId).toBe(SUPERWHISPER_SESSION.id) + }) + + it('emits independent field-map copies so callers can mutate safely', () => { + const a = defaultMapping() + const b = defaultMapping() + a.recording.fieldMap.body = { kind: 'literal', value: 'mutated' } + expect(b.recording.fieldMap.body).toEqual({ + kind: 'source', + field: 'recording.transcript' + }) + }) +}) + +describe('bindingFingerprint', () => { + it('returns the bundled sentinel for bundled bindings', () => { + expect(bindingFingerprint(defaultBundledRecordingBinding())).toBe('b') + expect(bindingFingerprint(defaultBundledSessionBinding())).toBe('b') + }) + + it('returns an 8-char hex slice for non-bundled bindings', () => { + const schema = authoredRecordingStarter('eval.recording') + const binding: MappingBinding = { + mode: 'authored', + typeId: schema.id, + fieldMap: defaultRecordingFieldMap(schema), + authoredSchema: schema + } + const fp = bindingFingerprint(binding) + expect(fp).toMatch(/^[0-9a-f]{8}$/) + expect(fp).not.toBe('b') + }) + + it('changes when the field map changes', () => { + const schema = authoredRecordingStarter('eval.recording') + const binding: MappingBinding = { + mode: 'authored', + typeId: schema.id, + fieldMap: defaultRecordingFieldMap(schema), + authoredSchema: schema + } + const before = bindingFingerprint(binding) + const after = bindingFingerprint({ + ...binding, + fieldMap: { ...binding.fieldMap, extra: { kind: 'literal', value: 'x' } } + }) + expect(after).not.toBe(before) + }) + + it('changes when the type id changes', () => { + const schema = authoredRecordingStarter('eval.recording') + const binding: MappingBinding = { + mode: 'authored', + typeId: schema.id, + fieldMap: defaultRecordingFieldMap(schema), + authoredSchema: schema + } + const before = bindingFingerprint(binding) + const after = bindingFingerprint({ ...binding, typeId: 'eval.other' }) + expect(after).not.toBe(before) + }) + + it('is stable across field-map key order', () => { + const binding: MappingBinding = { + mode: 'authored', + typeId: 'eval.foo', + fieldMap: { + a: { kind: 'source', field: 'recording.mode' }, + b: { kind: 'source', field: 'recording.model' } + } + } + const reversed: MappingBinding = { + ...binding, + fieldMap: { + b: { kind: 'source', field: 'recording.model' }, + a: { kind: 'source', field: 'recording.mode' } + } + } + expect(bindingFingerprint(binding)).toBe(bindingFingerprint(reversed)) + }) +}) + +describe('defaultRecordingFieldMap', () => { + it('returns the canonical bundled layout for the bundled type', () => { + const map = defaultRecordingFieldMap(SUPERWHISPER_RECORDING) + expect(map.body).toEqual({ kind: 'source', field: 'recording.transcript' }) + expect(map.raw_result).toEqual({ + kind: 'source', + field: 'recording.rawTranscript' + }) + expect(map.duration_seconds).toEqual({ + kind: 'source', + field: 'recording.durationSeconds' + }) + }) + + it('auto-pairs declared fields via the alias table', () => { + const map = defaultRecordingFieldMap({ + id: 'eval.r', + version: 1, + fields: { + body: { type: 'string' }, + duration_seconds: { type: 'number' }, + mode: { type: 'string' }, + appVersion: { type: 'string' } + } + }) + expect(map.body).toEqual({ kind: 'source', field: 'recording.transcript' }) + expect(map.duration_seconds).toEqual({ + kind: 'source', + field: 'recording.durationSeconds' + }) + expect(map.mode).toEqual({ kind: 'source', field: 'recording.mode' }) + // appVersion isn't in the alias table (snake_case `app_version` is); + // it stays unmapped. The override UI lets the user wire it. + expect(map.appVersion).toBeUndefined() + }) + + it('adds inherited core.note landing pads when parent === core.note', () => { + const map = defaultRecordingFieldMap({ + id: 'eval.r', + version: 1, + parent: 'core.note', + fields: {} + }) + expect(map.body).toEqual({ kind: 'source', field: 'recording.transcript' }) + expect(map.title).toEqual({ kind: 'source', field: 'recording.excerpt' }) + expect(map.language).toEqual({ kind: 'source', field: 'recording.language' }) + }) + + it('leaves fields with no alias unmapped', () => { + const map = defaultRecordingFieldMap({ + id: 'eval.r', + version: 1, + fields: { + unrelated_metric: { type: 'number' } + } + }) + expect(map.unrelated_metric).toBeUndefined() + }) +}) + +describe('defaultSessionFieldMap', () => { + it('returns the canonical bundled layout for the bundled session type', () => { + const map = defaultSessionFieldMap(SUPERWHISPER_SESSION) + expect(map.started_at).toEqual({ kind: 'source', field: 'session.startedAt' }) + expect(map.recording_count).toEqual({ + kind: 'source', + field: 'session.recordingCount' + }) + }) + + it('auto-pairs declared fields via the alias table', () => { + const map = defaultSessionFieldMap({ + id: 'eval.s', + version: 1, + fields: { + title: { type: 'string' }, + started_at: { type: 'datetime' }, + recording_count: { type: 'number' } + } + }) + expect(map.title).toEqual({ kind: 'source', field: 'session.sourceId' }) + expect(map.started_at).toEqual({ kind: 'source', field: 'session.startedAt' }) + expect(map.recording_count).toEqual({ + kind: 'source', + field: 'session.recordingCount' + }) + }) +}) + +describe('migrateRecordingMapping (T-204)', () => { + // Persisted shape from before the rename. `recording.device` is no + // longer a member of `RecordingSourceField` but persisted configs + // still hold the string. The cast is the whole point of the test. + const STALE_REF = { kind: 'source', field: 'recording.device' as unknown as never } as const + + it('replaces a bundled fieldMap wholesale when it contains the stale ref', () => { + const stale: MarfaMapping = { + recording: { + mode: 'bundled', + typeId: SUPERWHISPER_RECORDING.id, + fieldMap: { + body: { kind: 'source', field: 'recording.transcript' }, + device: STALE_REF + } + }, + session: defaultBundledSessionBinding() + } + const next = migrateRecordingMapping(stale) + expect(next.recording.fieldMap).toEqual(defaultBundledRecordingBinding().fieldMap) + expect(next.recording.fieldMap.device).toBeUndefined() + expect(next.recording.fieldMap.input_device).toEqual({ + kind: 'source', + field: 'recording.input_device' + }) + }) + + it('rewrites only the stale source-ref on authored bindings, preserving the target key', () => { + const userKey = 'mic' + const stale: MarfaMapping = { + recording: { + mode: 'authored', + typeId: 'eval.recording', + fieldMap: { + body: { kind: 'source', field: 'recording.transcript' }, + [userKey]: STALE_REF + }, + authoredSchema: authoredRecordingStarter('eval.recording') + }, + session: defaultBundledSessionBinding() + } + const next = migrateRecordingMapping(stale) + expect(next.recording.fieldMap[userKey]).toEqual({ + kind: 'source', + field: 'recording.input_device' + }) + expect(next.recording.fieldMap.body).toEqual({ + kind: 'source', + field: 'recording.transcript' + }) + }) + + it('returns the input unchanged when no stale ref is present', () => { + const clean = defaultMapping() + const next = migrateRecordingMapping(clean) + expect(next).toBe(clean) + }) + + it('is idempotent', () => { + const stale: MarfaMapping = { + recording: { + mode: 'bundled', + typeId: SUPERWHISPER_RECORDING.id, + fieldMap: { + body: { kind: 'source', field: 'recording.transcript' }, + device: STALE_REF + } + }, + session: defaultBundledSessionBinding() + } + const once = migrateRecordingMapping(stale) + const twice = migrateRecordingMapping(once) + expect(twice).toBe(once) + }) +}) + +describe('authoredRecordingStarter', () => { + it('seeds a sensible default field set for a user-authored type', () => { + const schema = authoredRecordingStarter('eval.recording', 'My recording') + expect(schema.id).toBe('eval.recording') + expect(schema.label).toBe('My recording') + expect(schema.version).toBe(1) + expect(schema.parent).toBe('core.note') + expect(Object.keys(schema.fields ?? {})).toEqual( + expect.arrayContaining([ + 'raw_result', + 'duration_seconds', + 'mode', + 'model', + 'input_device', + 'datetime' + ]) + ) + }) +}) diff --git a/src/main/marfa/mapping.ts b/src/main/marfa/mapping.ts new file mode 100644 index 0000000..2329be6 --- /dev/null +++ b/src/main/marfa/mapping.ts @@ -0,0 +1,403 @@ +import { createHash } from 'crypto' +import type { TypeSchema } from '@withmarfa/sdk' +import { SUPERWHISPER_RECORDING, SUPERWHISPER_SESSION } from './schemas' + +/** + * Mapping config — what local Superwhisper data maps to in Marfa. + * + * Two source kinds (recording, session); each binds to a target type + * (bundled / existing / authored) and a field map. The field map is the + * contract between the local source object and the wire payload: + * `{ [targetField]: SourceFieldRef }`. + * + * Default = bundled, identity field map. Older configs without a mapping + * block migrate transparently in `config.ts`. + * + * Trash-and-re-mint: each non-bundled mapping has a fingerprint that + * gets folded into the item's `source_id`. Changing the binding or the + * field map produces fresh source_ids; old items diff out and + * soft-delete on the next sync. Bundled uses no suffix so existing + * source_ids stay stable for users mid-migration. + */ + +export type MappingMode = 'bundled' | 'existing' | 'authored' + +/** + * Reference to a field on the local source object. Finite set of kinds + * keeps the picker UI bounded and the projection layer unambiguous. + * + * `recording.durationSeconds` converts the on-disk millisecond duration + * to seconds, matching the bundled `superwhisper.recording.duration_seconds` + * field. `recording.transcript` is the cleaned `result`; + * `recording.rawTranscript` is `rawResult`. + */ +export type RecordingSourceField = + | 'recording.id' + | 'recording.datetime' + | 'recording.transcript' + | 'recording.rawTranscript' + | 'recording.excerpt' + | 'recording.mode' + | 'recording.model' + | 'recording.input_device' + | 'recording.appVersion' + | 'recording.language' + | 'recording.durationSeconds' + | 'recording.segments' + | 'recording.wordCount' + | 'recording.wordsPerMinute' + +export type SessionSourceField = + | 'session.sourceId' + | 'session.startedAt' + | 'session.endedAt' + | 'session.recordingCount' + | 'session.totalDurationSeconds' + | 'session.dominantMode' + | 'session.gapThresholdMinutes' + +export type SourceFieldRef = + | { kind: 'source'; field: RecordingSourceField | SessionSourceField } + | { kind: 'literal'; value: string | number | boolean } + +export type FieldMap = Record + +export interface MappingBinding { + mode: MappingMode + /** Type id of the target. For bundled this is + * `superwhisper.recording` / `superwhisper.session`; for existing it's + * whichever type the user picked; for authored it's the id baked into + * `authoredSchema`. */ + typeId: string + /** Field map keyed by target field name. */ + fieldMap: FieldMap + /** When `mode === 'authored'`, the schema the app will register on + * next sync. Carried in config so registration is idempotent and + * survives restart. */ + authoredSchema?: TypeSchema +} + +export interface MarfaMapping { + recording: MappingBinding + session: MappingBinding +} + +/** Source kind a binding describes. */ +export type SourceKind = 'recording' | 'session' + +// Plain-English label helpers live in `@shared/marfa-labels` so the +// renderer can import them without dragging main-process code into +// its bundle. Re-export here so main-process callers can grab them +// in one stop. +export { + getSourceFieldLabel, + listRecordingSourceLabels, + listSessionSourceLabels, + type SourceFieldLabel +} from '@shared/marfa-labels' + +// --------------------------------------------------------------------------- +// Bundled defaults +// --------------------------------------------------------------------------- + +const BUNDLED_RECORDING_FIELD_MAP: FieldMap = { + body: { kind: 'source', field: 'recording.transcript' }, + raw_result: { kind: 'source', field: 'recording.rawTranscript' }, + segments: { kind: 'source', field: 'recording.segments' }, + duration_seconds: { kind: 'source', field: 'recording.durationSeconds' }, + model: { kind: 'source', field: 'recording.model' }, + mode: { kind: 'source', field: 'recording.mode' }, + input_device: { kind: 'source', field: 'recording.input_device' }, + app_version: { kind: 'source', field: 'recording.appVersion' }, + datetime: { kind: 'source', field: 'recording.datetime' }, + language: { kind: 'source', field: 'recording.language' } +} + +const BUNDLED_SESSION_FIELD_MAP: FieldMap = { + title: { kind: 'literal', value: '' }, + started_at: { kind: 'source', field: 'session.startedAt' }, + ended_at: { kind: 'source', field: 'session.endedAt' }, + recording_count: { kind: 'source', field: 'session.recordingCount' }, + total_duration_seconds: { kind: 'source', field: 'session.totalDurationSeconds' }, + dominant_mode: { kind: 'source', field: 'session.dominantMode' }, + gap_threshold_minutes: { kind: 'source', field: 'session.gapThresholdMinutes' } +} + +export function defaultBundledRecordingBinding(): MappingBinding { + return { + mode: 'bundled', + typeId: SUPERWHISPER_RECORDING.id, + fieldMap: { ...BUNDLED_RECORDING_FIELD_MAP } + } +} + +export function defaultBundledSessionBinding(): MappingBinding { + return { + mode: 'bundled', + typeId: SUPERWHISPER_SESSION.id, + fieldMap: { ...BUNDLED_SESSION_FIELD_MAP } + } +} + +export function defaultMapping(): MarfaMapping { + return { + recording: defaultBundledRecordingBinding(), + session: defaultBundledSessionBinding() + } +} + +/** + * Forward-migrate a persisted mapping after T-204 — the source-field-ref + * `'recording.device'` was renamed to `'recording.input_device'`. A + * mapping persisted before T-204 references a value that is no longer a + * member of `RecordingSourceField`; the projection's switch falls through + * to `default` and silently drops the device value. Run on every config + * read so existing installs converge on the new shape. + * + * Bundled bindings are rebuilt from `BUNDLED_RECORDING_FIELD_MAP` — + * bundled means "track the canonical schema 1:1", and the target key + * also renamed (`device` → `input_device`). Authored / existing bindings + * keep their user-defined target keys and only get the stale source-ref + * rewritten. + * + * Idempotent. Returns the input reference unchanged when nothing + * needed migrating. + */ +export function migrateRecordingMapping(mapping: MarfaMapping): MarfaMapping { + const recording = mapping.recording + const hasStaleRef = Object.values(recording.fieldMap).some( + (ref) => ref.kind === 'source' && (ref.field as string) === 'recording.device' + ) + if (!hasStaleRef) return mapping + if (recording.mode === 'bundled') { + return { + ...mapping, + recording: { ...recording, fieldMap: { ...BUNDLED_RECORDING_FIELD_MAP } } + } + } + const nextFieldMap: FieldMap = {} + for (const [target, ref] of Object.entries(recording.fieldMap)) { + if (ref.kind === 'source' && (ref.field as string) === 'recording.device') { + nextFieldMap[target] = { kind: 'source', field: 'recording.input_device' } + } else { + nextFieldMap[target] = ref + } + } + return { + ...mapping, + recording: { ...recording, fieldMap: nextFieldMap } + } +} + +// --------------------------------------------------------------------------- +// Fingerprinting +// --------------------------------------------------------------------------- + +const BUNDLED_FINGERPRINT = 'b' +const FINGERPRINT_LENGTH = 8 + +/** + * Stable short fingerprint of a binding. Bundled is the sentinel string + * `'b'` (so source_ids stay stable for users on the default mapping); + * any other mapping gets an 8-char SHA-256 prefix of + * `{ typeId, fieldMap }`. Changing either invalidates source_ids and + * drives trash-and-re-mint on next sync. + */ +export function bindingFingerprint(binding: MappingBinding): string { + if (binding.mode === 'bundled') return BUNDLED_FINGERPRINT + const canonical = canonicalize({ typeId: binding.typeId, fieldMap: binding.fieldMap }) + return createHash('sha256').update(canonical).digest('hex').slice(0, FINGERPRINT_LENGTH) +} + +/** True when the binding uses the bundled fingerprint (no suffix on source_ids). */ +export function isBundled(binding: MappingBinding): boolean { + return binding.mode === 'bundled' +} + +// --------------------------------------------------------------------------- +// Default field-map generation for a target type +// --------------------------------------------------------------------------- + +/** + * Case-insensitive alias table mapping target field names to recording + * source fields. Used by `defaultRecordingFieldMap` to auto-pair fields + * when the user picks a non-bundled type. + */ +const RECORDING_FIELD_ALIASES: Record = { + body: 'recording.transcript', + text: 'recording.transcript', + content: 'recording.transcript', + transcript: 'recording.transcript', + raw: 'recording.rawTranscript', + raw_result: 'recording.rawTranscript', + raw_transcript: 'recording.rawTranscript', + title: 'recording.excerpt', + name: 'recording.excerpt', + excerpt: 'recording.excerpt', + datetime: 'recording.datetime', + recorded_at: 'recording.datetime', + created_at: 'recording.datetime', + date: 'recording.datetime', + duration: 'recording.durationSeconds', + duration_seconds: 'recording.durationSeconds', + duration_sec: 'recording.durationSeconds', + mode: 'recording.mode', + model: 'recording.model', + device: 'recording.input_device', + input_device: 'recording.input_device', + app_version: 'recording.appVersion', + version: 'recording.appVersion', + language: 'recording.language', + lang: 'recording.language', + segments: 'recording.segments', + word_count: 'recording.wordCount', + words: 'recording.wordCount', + wpm: 'recording.wordsPerMinute', + words_per_minute: 'recording.wordsPerMinute' +} + +const SESSION_FIELD_ALIASES: Record = { + title: 'session.sourceId', + name: 'session.sourceId', + source_id: 'session.sourceId', + started_at: 'session.startedAt', + start: 'session.startedAt', + ended_at: 'session.endedAt', + end: 'session.endedAt', + recording_count: 'session.recordingCount', + count: 'session.recordingCount', + duration_seconds: 'session.totalDurationSeconds', + total_duration_seconds: 'session.totalDurationSeconds', + mode: 'session.dominantMode', + dominant_mode: 'session.dominantMode', + gap_threshold_minutes: 'session.gapThresholdMinutes' +} + +/** + * Build a sensible default field map for a target type given its + * schema. Iterates declared fields, pairing each to a source ref by + * name alias. Unmapped fields are left out — the projection emits only + * fields that have a ref. Inherited fields (e.g. `core.note.body`) are + * not visible on the child schema's `fields`; the bundled mapping + * defines those manually. + */ +export function defaultRecordingFieldMap(target: TypeSchema): FieldMap { + // For the bundled type, return the canonical layout (which includes + // inherited core.note fields not listed on the type itself). + if (target.id === SUPERWHISPER_RECORDING.id) { + return { ...BUNDLED_RECORDING_FIELD_MAP } + } + const out: FieldMap = {} + for (const fieldName of Object.keys(target.fields ?? {})) { + const ref = aliasFor(fieldName, RECORDING_FIELD_ALIASES) + if (ref) out[fieldName] = { kind: 'source', field: ref } + } + // If the target inherits from core.note, also wire body to transcript + // and title to excerpt — they're inherited fields, not on + // `target.fields`, but they're the natural landing pads for a + // recording's text content. The user can drop them in the override + // UI if they don't want them. + if (target.parent === 'core.note') { + if (!out.body) out.body = { kind: 'source', field: 'recording.transcript' } + if (!out.title) out.title = { kind: 'source', field: 'recording.excerpt' } + if (!out.language) out.language = { kind: 'source', field: 'recording.language' } + } + return out +} + +export function defaultSessionFieldMap(target: TypeSchema): FieldMap { + if (target.id === SUPERWHISPER_SESSION.id) { + return { ...BUNDLED_SESSION_FIELD_MAP } + } + const out: FieldMap = {} + for (const fieldName of Object.keys(target.fields ?? {})) { + const ref = aliasFor(fieldName, SESSION_FIELD_ALIASES) + if (ref) out[fieldName] = { kind: 'source', field: ref } + } + return out +} + +function aliasFor(name: string, table: Record): T | null { + return table[name.toLowerCase()] ?? null +} + +// --------------------------------------------------------------------------- +// Authored-type starter schema +// --------------------------------------------------------------------------- + +/** + * Build a starter `TypeSchema` for the inline "author a new type" + * flow. The user supplies a type id (e.g. `eval.recording`); we + * pre-populate a sensible field set covering the Superwhisper + * recording surface so the resulting type captures the same data the + * bundled type does, just under a user-owned id. + * + * The user can tweak the field set in the form; this just defines the + * happy-path defaults. + */ +export function authoredRecordingStarter(typeId: string, label?: string): TypeSchema { + return { + id: typeId, + parent: 'core.note', + label: label || typeId, + description: 'User-authored mapping for Superwhisper recordings.', + version: 1, + fields: { + raw_result: { type: 'string', description: 'Raw transcript text.' }, + duration_seconds: { type: 'number', description: 'Recording length in seconds.' }, + model: { type: 'string', description: 'Transcription model used.' }, + mode: { type: 'string', description: 'Superwhisper mode.' }, + input_device: { + type: 'string', + description: 'The audio input device used to capture the recording.' + }, + datetime: { type: 'datetime', description: 'Recording start time.' } + } + } +} + +export function authoredSessionStarter(typeId: string, label?: string): TypeSchema { + return { + id: typeId, + label: label || typeId, + description: 'User-authored mapping for Superwhisper sessions.', + version: 1, + fields: { + title: { type: 'string', description: 'Free-text user-naming field.' }, + started_at: { type: 'datetime', description: 'First recording in the session.' }, + ended_at: { type: 'datetime', description: 'Last recording in the session.' }, + recording_count: { type: 'number', description: 'Number of recordings.' }, + total_duration_seconds: { type: 'number', description: 'Sum of durations.' }, + dominant_mode: { type: 'string', description: 'Most-used mode.' } + } + } +} + +// --------------------------------------------------------------------------- +// Canonical stringify (re-implemented here to keep mapping.ts free of +// circular deps with projection.ts) +// --------------------------------------------------------------------------- + +function canonicalize(value: unknown): string { + if (value === null) return 'null' + if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'null' + if (typeof value === 'string') return JSON.stringify(value) + if (typeof value === 'boolean') return value ? 'true' : 'false' + if (Array.isArray(value)) return '[' + value.map(canonicalize).join(',') + ']' + if (typeof value === 'object') { + const keys = Object.keys(value as object).sort() + return ( + '{' + + keys + .map((k) => { + const v = (value as Record)[k] + if (v === undefined) return null + return JSON.stringify(k) + ':' + canonicalize(v) + }) + .filter((s) => s !== null) + .join(',') + + '}' + ) + } + return 'null' +} diff --git a/src/main/marfa/projection.test.ts b/src/main/marfa/projection.test.ts new file mode 100644 index 0000000..41e722a --- /dev/null +++ b/src/main/marfa/projection.test.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest' +import type { Recording } from '@shared/types' +import { + authoredRecordingStarter, + bindingFingerprint, + defaultBundledRecordingBinding, + defaultRecordingFieldMap, + type MappingBinding +} from './mapping' +import { hashProjection, projectRecording } from './projection' + +function makeRecording(over: Partial = {}): Recording { + return { + id: '1755164573', + datetime: '2026-05-11T10:00:00', + modeName: 'dictation', + modelName: 'medium', + appVersion: '3.0.0', + recordingDevice: 'Built-in', + languageSelected: 'en', + duration: 4500, + processingTime: 1200, + result: 'Hello world.', + rawResult: 'hello world', + segments: [{ start: 0, end: 1.5, text: 'hello' }], + wordCount: 2, + wordsPerMinute: 26.6, + sentenceCount: 1, + fillerCount: 0, + fillerBreakdown: [], + excerpt: 'Hello world.', + ...over + } +} + +const bundled = defaultBundledRecordingBinding() + +describe('projectRecording — bundled mapping', () => { + it('maps the Superwhisper recording into the bundled item shape', () => { + const p = projectRecording(makeRecording(), bundled) + expect(p.type).toBe('superwhisper.recording') + expect(p.source).toBe('superwhisper-analytics') + expect(p.source_id).toBe('1755164573') + expect(p.tier).toBe('feed') + expect(p.properties.body).toBe('Hello world.') + expect(p.properties.raw_result).toBe('hello world') + expect(p.properties.duration_seconds).toBe(4.5) + expect(p.properties.segments).toEqual([{ start: 0, end: 1.5, text: 'hello' }]) + expect(p.properties.datetime).toBe('2026-05-11T10:00:00') + expect(p.properties.language).toBe('en') + }) + + it('drops blank language so it does not override the inherited field', () => { + const p = projectRecording(makeRecording({ languageSelected: '' }), bundled) + expect(p.properties.language).toBeUndefined() + }) + + it('omits derived analytics fields from the wire shape', () => { + const p = projectRecording(makeRecording(), bundled) + expect(Object.keys(p.properties).sort()).toEqual( + [ + 'app_version', + 'body', + 'datetime', + 'duration_seconds', + 'input_device', + 'language', + 'mode', + 'model', + 'raw_result', + 'segments' + ].sort() + ) + }) + + it('uses the bare recording id as source_id when bundled', () => { + const p = projectRecording(makeRecording(), bundled) + expect(p.source_id).toBe('1755164573') + expect(p.source_id).not.toContain('#') + }) +}) + +describe('projectRecording — authored mapping', () => { + const authored: MappingBinding = (() => { + const schema = authoredRecordingStarter('eval.recording', 'Eval recording') + return { + mode: 'authored', + typeId: schema.id, + fieldMap: defaultRecordingFieldMap(schema), + authoredSchema: schema + } + })() + + it('uses the authored type id', () => { + const p = projectRecording(makeRecording(), authored) + expect(p.type).toBe('eval.recording') + }) + + it('appends the binding fingerprint to source_id', () => { + const p = projectRecording(makeRecording(), authored) + const fp = bindingFingerprint(authored) + expect(p.source_id).toBe(`1755164573#${fp}`) + expect(fp).not.toBe('b') + expect(fp).toHaveLength(8) + }) + + it('emits properties for fields with auto-paired source refs', () => { + const p = projectRecording(makeRecording(), authored) + // authoredRecordingStarter declares raw_result, duration_seconds, + // model, mode, input_device, datetime; defaultRecordingFieldMap also + // adds body/title/language because parent === core.note. + expect(p.properties).toMatchObject({ + raw_result: 'hello world', + duration_seconds: 4.5, + model: 'medium', + mode: 'dictation', + input_device: 'Built-in', + datetime: '2026-05-11T10:00:00', + body: 'Hello world.', + title: 'Hello world.', + language: 'en' + }) + }) +}) + +describe('hashProjection', () => { + it('produces a stable hash for the same input', () => { + const a = projectRecording(makeRecording(), bundled) + const b = projectRecording(makeRecording(), bundled) + expect(hashProjection(a)).toBe(hashProjection(b)) + }) + + it('is independent of property declaration order', () => { + const original = projectRecording(makeRecording(), bundled) + const shuffled = { + properties: original.properties, + tier: original.tier, + source_id: original.source_id, + type: original.type, + source: original.source + } + expect(hashProjection(shuffled)).toBe(hashProjection(original)) + }) + + it('changes when a field changes', () => { + const a = projectRecording(makeRecording({ result: 'Hello' }), bundled) + const b = projectRecording(makeRecording({ result: 'Goodbye' }), bundled) + expect(hashProjection(a)).not.toBe(hashProjection(b)) + }) + + it('treats undefined the same as missing (canonical drop)', () => { + expect(hashProjection({ a: 1, b: undefined })).toBe(hashProjection({ a: 1 })) + }) +}) diff --git a/src/main/marfa/projection.ts b/src/main/marfa/projection.ts new file mode 100644 index 0000000..409920a --- /dev/null +++ b/src/main/marfa/projection.ts @@ -0,0 +1,170 @@ +import { createHash } from 'crypto' +import type { Recording } from '@shared/types' +import { bindingFingerprint, isBundled, type MappingBinding, type SourceFieldRef } from './mapping' +import { SOURCE } from './schemas' +import type { SessionGroup } from './sessions' + +/** + * Pure mapping layer — given a local source object and a `MappingBinding`, + * produce the Marfa item payload that gets pushed. + * + * `source_id` is derived from the source identifier plus the binding + * fingerprint. Bundled bindings emit the bare source identifier (so + * existing data stays valid mid-migration); non-bundled bindings get a + * `#` suffix that flips when the binding changes, driving + * trash-and-re-mint on the next sync. See `mapping.ts` for the + * fingerprint rules. + * + * `properties` is built from the binding's field map: each entry pulls + * a value off the source object (or a literal) and lands under the + * target field name. Undefined / empty-string values are dropped so a + * sparse mapping doesn't write empty strings into Marfa fields. + */ + +export interface Projection { + type: string + source: string + source_id: string + tier: 'feed' + properties: Record +} + +export function projectRecording(r: Recording, binding: MappingBinding): Projection { + const sourceId = isBundled(binding) ? r.id : `${r.id}#${bindingFingerprint(binding)}` + return { + type: binding.typeId, + source: SOURCE, + source_id: sourceId, + tier: 'feed', + properties: buildProperties(binding.fieldMap, (ref) => readRecordingField(r, ref)) + } +} + +export function projectSession(s: SessionGroup, binding: MappingBinding): Projection { + // Session source identifiers already encode the gap threshold + // (`${first.id}-${threshold}`); the binding fingerprint is appended + // only for non-bundled bindings so changing the binding trash-and- + // re-mints sessions the same way changing the threshold does. + const sourceId = isBundled(binding) ? s.sourceId : `${s.sourceId}#${bindingFingerprint(binding)}` + return { + type: binding.typeId, + source: SOURCE, + source_id: sourceId, + tier: 'feed', + properties: buildProperties(binding.fieldMap, (ref) => readSessionField(s, ref)) + } +} + +function buildProperties( + fieldMap: Record, + read: (ref: SourceFieldRef) => unknown +): Record { + const out: Record = {} + for (const [target, ref] of Object.entries(fieldMap)) { + const value = read(ref) + if (value === undefined) continue + if (typeof value === 'string' && value.length === 0) continue + out[target] = value + } + return out +} + +function readRecordingField(r: Recording, ref: SourceFieldRef): unknown { + if (ref.kind === 'literal') return ref.value + switch (ref.field) { + case 'recording.id': + return r.id + case 'recording.datetime': + return r.datetime + case 'recording.transcript': + return r.result + case 'recording.rawTranscript': + return r.rawResult + case 'recording.excerpt': + return r.excerpt + case 'recording.mode': + return r.modeName + case 'recording.model': + return r.modelName + case 'recording.input_device': + return r.recordingDevice + case 'recording.appVersion': + return r.appVersion + case 'recording.language': + return r.languageSelected || undefined + case 'recording.durationSeconds': + return r.duration / 1000 + case 'recording.segments': + return r.segments.map((s) => ({ start: s.start, end: s.end, text: s.text })) + case 'recording.wordCount': + return r.wordCount + case 'recording.wordsPerMinute': + return r.wordsPerMinute + default: + // Session-kind ref on a recording — return undefined so the entry + // gets dropped rather than emitting nonsense. Shouldn't happen + // through legitimate UI but the type system can't enforce kind- + // matching here. + return undefined + } +} + +function readSessionField(s: SessionGroup, ref: SourceFieldRef): unknown { + if (ref.kind === 'literal') return ref.value + switch (ref.field) { + case 'session.sourceId': + return s.sourceId + case 'session.startedAt': + return s.startedAt + case 'session.endedAt': + return s.endedAt + case 'session.recordingCount': + return s.recordingCount + case 'session.totalDurationSeconds': + return s.totalDurationSeconds + case 'session.dominantMode': + return s.dominantMode + case 'session.gapThresholdMinutes': + return s.gapThresholdMinutes + default: + return undefined + } +} + +/** + * SHA-256 hash of the projection. Stable across runs given the same + * input, because we walk keys in sorted order. Used as the change + * detector in the sync state file — a re-push that produces the same + * projection is a no-op. + */ +export function hashProjection(projection: unknown): string { + const canonical = canonicalJsonStringify(projection) + return createHash('sha256').update(canonical).digest('hex') +} + +function canonicalJsonStringify(value: unknown): string { + if (value === null) return 'null' + if (typeof value === 'number') return Number.isFinite(value) ? String(value) : 'null' + if (typeof value === 'string') return JSON.stringify(value) + if (typeof value === 'boolean') return value ? 'true' : 'false' + if (Array.isArray(value)) { + return '[' + value.map(canonicalJsonStringify).join(',') + ']' + } + if (typeof value === 'object') { + const keys = Object.keys(value as object).sort() + return ( + '{' + + keys + .map((k) => { + const v = (value as Record)[k] + if (v === undefined) return null + return JSON.stringify(k) + ':' + canonicalJsonStringify(v) + }) + .filter((s) => s !== null) + .join(',') + + '}' + ) + } + // undefined, function, symbol — drop. + return 'null' +} diff --git a/src/main/marfa/registration.test.ts b/src/main/marfa/registration.test.ts new file mode 100644 index 0000000..cad2d1d --- /dev/null +++ b/src/main/marfa/registration.test.ts @@ -0,0 +1,106 @@ +import type { MarfaClient, TypeSchema } from '@withmarfa/sdk' +import { MarfaError } from '@withmarfa/sdk' +import { describe, expect, it, vi, type Mock } from 'vitest' +import { defaultMapping, type MarfaMapping } from './mapping' +import { ensureTypesRegistered } from './registration' +import { SUPERWHISPER_RECORDING, SUPERWHISPER_SESSION } from './schemas' + +interface MockTypes { + list: Mock<() => Promise> + register: Mock<(schema: TypeSchema) => Promise> +} + +function makeClient(opts: { serverTypes: TypeSchema[]; registerFails?: unknown }): { + client: MarfaClient + types: MockTypes +} { + const types: MockTypes = { + list: vi.fn(async () => opts.serverTypes), + register: vi.fn(async (schema: TypeSchema) => { + if (opts.registerFails) throw opts.registerFails + return schema + }) + } + // Cast through `unknown` — we only need .types for ensureTypesRegistered. + const client = { types } as unknown as MarfaClient + return { client, types } +} + +describe('ensureTypesRegistered', () => { + it('registers types that are absent from the server', async () => { + const { client, types } = makeClient({ serverTypes: [] }) + await ensureTypesRegistered(client, defaultMapping()) + expect(types.register).toHaveBeenCalledTimes(2) + const registeredIds = types.register.mock.calls.map((c: [TypeSchema]) => c[0].id) + expect(registeredIds).toContain(SUPERWHISPER_RECORDING.id) + expect(registeredIds).toContain(SUPERWHISPER_SESSION.id) + }) + + it('skips types that are present and at the same version', async () => { + const { client, types } = makeClient({ + serverTypes: [SUPERWHISPER_RECORDING, SUPERWHISPER_SESSION] + }) + await ensureTypesRegistered(client, defaultMapping()) + expect(types.register).not.toHaveBeenCalled() + }) + + it('re-registers when the local version is higher than the server version (T-204)', async () => { + // Server is on v1 (pre-rename); local bundle is v2. Without this + // branch the rename would never propagate, items would silently + // drop `input_device`, and we lose data integrity. + const serverV1: TypeSchema = { + ...SUPERWHISPER_RECORDING, + version: 1, + fields: { + ...SUPERWHISPER_RECORDING.fields, + // Approximate the stale v1 shape: this test only cares that + // version=1 < local version=2, not the field-level diff. + input_device: undefined as unknown as TypeSchema['fields'][string] + } + } + const { client, types } = makeClient({ + serverTypes: [serverV1, SUPERWHISPER_SESSION] + }) + await ensureTypesRegistered(client, defaultMapping()) + expect(types.register).toHaveBeenCalledTimes(1) + const registered = types.register.mock.calls[0]?.[0] + expect(registered?.id).toBe(SUPERWHISPER_RECORDING.id) + expect(registered?.version).toBe(2) + }) + + it('does not downgrade when the server is on a higher version than the local schema', async () => { + const serverV3: TypeSchema = { ...SUPERWHISPER_RECORDING, version: 3 } + const { client, types } = makeClient({ + serverTypes: [serverV3, SUPERWHISPER_SESSION] + }) + await ensureTypesRegistered(client, defaultMapping()) + expect(types.register).not.toHaveBeenCalled() + }) + + it('skips existing-mode bindings entirely (no register, no lookup)', async () => { + const { client, types } = makeClient({ serverTypes: [] }) + const mapping: MarfaMapping = { + recording: { mode: 'existing', typeId: 'core.note', fieldMap: {} }, + session: { mode: 'existing', typeId: 'core.note', fieldMap: {} } + } + await ensureTypesRegistered(client, mapping) + // No candidates → no list call, no register call. + expect(types.list).not.toHaveBeenCalled() + expect(types.register).not.toHaveBeenCalled() + }) + + it('swallows non-auth register errors without throwing', async () => { + const failure = new Error('temporary boom') + const { client, types } = makeClient({ serverTypes: [], registerFails: failure }) + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + await expect(ensureTypesRegistered(client, defaultMapping())).resolves.toBeUndefined() + expect(types.register).toHaveBeenCalledTimes(2) + warn.mockRestore() + }) + + it('propagates auth errors (the caller flips the card)', async () => { + const unauthorized = new MarfaError('UNAUTHORIZED', 'Unauthorized', 401) + const { client } = makeClient({ serverTypes: [], registerFails: unauthorized }) + await expect(ensureTypesRegistered(client, defaultMapping())).rejects.toBe(unauthorized) + }) +}) diff --git a/src/main/marfa/registration.ts b/src/main/marfa/registration.ts new file mode 100644 index 0000000..b2fac5a --- /dev/null +++ b/src/main/marfa/registration.ts @@ -0,0 +1,101 @@ +import { MarfaError, type MarfaClient, type TypeSchema } from '@withmarfa/sdk' +import type { MappingBinding, MarfaMapping } from './mapping' +import { SUPERWHISPER_RECORDING, SUPERWHISPER_SESSION } from './schemas' + +/** + * Ensure the type ids referenced by the active mapping exist on the + * server side AND that the server is at-least the local schema version. + * Runs on connect and before each sync. Idempotent — + * `client.types.list()` once per call, then `register()` only for ids + * that are absent or where the local schema is a higher version than + * what the server has. + * + * Three binding modes get different treatment: + * + * - `bundled` — register from the canonical schemas in `schemas.ts`. + * A version bump in the bundle (e.g. T-204's v1 → v2 rename) is + * pushed to the server on the next sync so the server's validation + * stays in lockstep with what the projection produces. + * - `authored` — register from `binding.authoredSchema`. Same + * versioning rule: if the user bumps the version in config, the + * bumped schema is pushed. + * - `existing` — taken on trust. If it's missing the user picked + * wrongly and the next upsert will surface the failure with a + * clearer error than this function could synthesise. + * + * The server's `version_bump_mismatch` check is the source of truth on + * what's an acceptable bump — we let the server decide and log the + * rejection rather than implementing the rules client-side. + * + * Auth failures bubble up — the engine's caller knows how to flip the + * card to `disconnected`. Other failures are swallowed with a console + * warning so a transient registration error doesn't block the sync + * loop entirely; the subsequent `items.upsert` will fail clearly if + * the type really is missing. + */ +export async function ensureTypesRegistered( + client: MarfaClient, + mapping: MarfaMapping +): Promise { + // Collect target ids that *could* need registration. Existing-mode + // bindings are skipped — we don't know the schema to re-register + // them with and the user picked them precisely because they exist. + const candidates: Array<{ binding: MappingBinding; fallback: TypeSchema }> = [] + if (mapping.recording.mode !== 'existing') { + candidates.push({ + binding: mapping.recording, + fallback: SUPERWHISPER_RECORDING + }) + } + if (mapping.session.mode !== 'existing') { + candidates.push({ + binding: mapping.session, + fallback: SUPERWHISPER_SESSION + }) + } + if (candidates.length === 0) return + + let existing: TypeSchema[] + try { + existing = await client.types.list() + } catch (err) { + // Bubble auth failures (the wrapping engine call will flip status); + // log + continue for anything else so the sync attempt proceeds and + // surfaces a clearer error on the upsert path. + if (err instanceof MarfaError && /unauthor/i.test(err.message)) throw err + console.warn('[marfa] types.list failed during registration probe:', err) + return + } + const presentById = new Map(existing.map((t) => [t.id, t])) + + for (const { binding, fallback } of candidates) { + const schema = resolveSchema(binding, fallback) + if (!schema) { + console.warn( + `[marfa] cannot register ${binding.typeId}: no schema available (mode=${binding.mode})` + ) + continue + } + const serverType = presentById.get(binding.typeId) + const serverVersion = serverType?.version ?? 0 + const localVersion = schema.version ?? 1 + // Register when the type is missing or when our local schema is at + // a higher version. The server adjudicates whether the bump itself + // is legal (`version_bump_mismatch`) — if it rejects, we surface + // that as a warning and the next `items.upsert` will fail with a + // clearer message. + if (serverType && localVersion <= serverVersion) continue + try { + await client.types.register(schema) + } catch (err) { + if (err instanceof MarfaError && /unauthor/i.test(err.message)) throw err + console.warn(`[marfa] register failed for ${binding.typeId}:`, err) + } + } +} + +function resolveSchema(binding: MappingBinding, fallback: TypeSchema): TypeSchema | null { + if (binding.mode === 'bundled') return fallback + if (binding.mode === 'authored') return binding.authoredSchema ?? null + return null +} diff --git a/src/main/marfa/schemas.ts b/src/main/marfa/schemas.ts new file mode 100644 index 0000000..c11fe0e --- /dev/null +++ b/src/main/marfa/schemas.ts @@ -0,0 +1,95 @@ +import type { TypeSchema } from '@withmarfa/sdk' + +/** + * Marfa custom-type schemas registered by this integration. + * + * Two types, both under the `superwhisper.*` publisher namespace: + * `superwhisper.recording` (inherits from `core.note`) and + * `superwhisper.session` (standalone, analytics-app-only). + * + * The schemas live here so both the one-off registration script + * (`scripts/marfa-register-types.ts`) and the projection layer + * (`projection.ts`) share a single source of truth for field names. + * + * Caveats noted in the running-log artifact: + * + * - `core.file.audio` has a required `blob_ref`, so v1 cannot push the + * audio file as a separate item without uploading the bytes. The + * transcript on `superwhisper.recording.body` is the load-bearing + * data — audio is deferred. + * - The spec's field-shape examples (`{ type: 'array', items: { type: + * 'object' } }`) don't compile against the SDK's `FieldDefinition` + * (which uses `items_type: string`). The schemas below use the SDK + * shape. + * - The spec's `superwhisper.session` declares `merge_policy.fields.title` + * but doesn't list `title` in `fields`; `MergePolicy` requires every + * field-keyed entry to exist in `fields`, so `title` is added below. + */ + +export const SOURCE = 'superwhisper-analytics' + +export const SUPERWHISPER_RECORDING: TypeSchema = { + id: 'superwhisper.recording', + parent: 'core.note', + label: 'Superwhisper recording', + description: 'A voice recording captured by Superwhisper, with transcript and metadata.', + version: 2, + fields: { + // body / title / language inherited from core.note + segments: { + type: 'array', + items_type: 'object', + description: 'Word-timestamped transcript segments.' + }, + raw_result: { + type: 'string', + description: 'Raw, unprocessed transcript (Superwhisper rawResult).' + }, + duration_seconds: { type: 'number', description: 'Recording length in seconds.' }, + model: { type: 'string', description: 'Transcription model used.' }, + mode: { type: 'string', description: 'Superwhisper mode (dictation, command, etc.).' }, + input_device: { + type: 'string', + description: 'The audio input device used to capture the recording.' + }, + app_version: { + type: 'string', + description: 'Superwhisper app version that captured the recording.' + }, + datetime: { type: 'datetime', description: 'Recording start time (not item creation time).' } + }, + merge_policy: { + fields: { + body: 'keep_both_copies' + }, + default: 'last_writer_wins' + } +} + +export const SUPERWHISPER_SESSION: TypeSchema = { + id: 'superwhisper.session', + label: 'Superwhisper session', + description: + 'A gap-grouped set of recordings, derived locally by the Superwhisper Analytics app.', + version: 1, + fields: { + title: { type: 'string', description: 'Free-text user-naming field. Empty by default.' }, + started_at: { type: 'datetime', description: 'First recording in the session.' }, + ended_at: { type: 'datetime', description: 'End of the last recording in the session.' }, + recording_count: { type: 'number', description: 'Number of recordings in this session.' }, + total_duration_seconds: { type: 'number', description: 'Sum of recording durations.' }, + dominant_mode: { type: 'string', description: 'Most-used Superwhisper mode in the session.' }, + gap_threshold_minutes: { + type: 'number', + description: 'Threshold (minutes) that defined session boundaries when this was minted.' + } + }, + merge_policy: { + fields: { + title: 'keep_both_copies' + }, + default: 'last_writer_wins' + } +} + +export const ALL_SCHEMAS: TypeSchema[] = [SUPERWHISPER_RECORDING, SUPERWHISPER_SESSION] diff --git a/src/main/marfa/sessions.test.ts b/src/main/marfa/sessions.test.ts new file mode 100644 index 0000000..2540ab7 --- /dev/null +++ b/src/main/marfa/sessions.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest' +import type { Recording } from '@shared/types' +import { DEFAULT_GAP_THRESHOLD_MINUTES, groupIntoSessions } from './sessions' + +function makeRecording(over: Partial = {}): Recording { + return { + id: '1000000000', + datetime: '2026-05-11T10:00:00Z', + modeName: 'dictation', + modelName: 'medium', + appVersion: '3.0.0', + recordingDevice: 'Built-in', + languageSelected: 'en', + duration: 60_000, // 60s + processingTime: 1000, + result: '', + rawResult: '', + segments: [], + wordCount: 0, + wordsPerMinute: 0, + sentenceCount: 0, + fillerCount: 0, + fillerBreakdown: [], + excerpt: '', + ...over + } +} + +describe('groupIntoSessions', () => { + it('returns an empty array for empty input', () => { + expect(groupIntoSessions([])).toEqual([]) + }) + + it('groups recordings within the gap threshold into one session', () => { + // Three recordings, each 1 min long, 10 min apart — well under the + // default 30 min threshold so all share one session. + const groups = groupIntoSessions([ + makeRecording({ id: 'A', datetime: '2026-05-11T10:00:00Z' }), + makeRecording({ id: 'B', datetime: '2026-05-11T10:11:00Z' }), + makeRecording({ id: 'C', datetime: '2026-05-11T10:22:00Z' }) + ]) + expect(groups).toHaveLength(1) + expect(groups[0]?.recordingIds).toEqual(['A', 'B', 'C']) + expect(groups[0]?.recordingCount).toBe(3) + expect(groups[0]?.sourceId).toBe(`A-${DEFAULT_GAP_THRESHOLD_MINUTES}`) + }) + + it('starts a new session when the gap exceeds the threshold', () => { + // Two recordings 31 min apart with a 30 min threshold → two sessions. + const groups = groupIntoSessions( + [ + makeRecording({ id: 'A', datetime: '2026-05-11T10:00:00Z' }), + makeRecording({ id: 'B', datetime: '2026-05-11T10:32:00Z' }) + ], + 30 + ) + expect(groups).toHaveLength(2) + expect(groups[0]?.recordingIds).toEqual(['A']) + expect(groups[1]?.recordingIds).toEqual(['B']) + }) + + it('uses recording end time, not start time, for gap measurement', () => { + // A long-running recording (10 min) then a short one starting 5 min + // after its end → 5 min gap, well under threshold; one session. + const groups = groupIntoSessions( + [ + makeRecording({ id: 'A', datetime: '2026-05-11T10:00:00Z', duration: 10 * 60_000 }), + makeRecording({ id: 'B', datetime: '2026-05-11T10:15:00Z' }) + ], + 10 + ) + expect(groups).toHaveLength(1) + expect(groups[0]?.recordingCount).toBe(2) + }) + + it('source_id changes when the threshold changes', () => { + const recordings = [makeRecording({ id: 'X' })] + const groupsAt30 = groupIntoSessions(recordings, 30) + const groupsAt45 = groupIntoSessions(recordings, 45) + expect(groupsAt30[0]?.sourceId).toBe('X-30') + expect(groupsAt45[0]?.sourceId).toBe('X-45') + }) + + it('picks the most-used mode as dominantMode, with first-seen tie-break', () => { + const groups = groupIntoSessions([ + makeRecording({ id: 'A', datetime: '2026-05-11T10:00:00Z', modeName: 'dictation' }), + makeRecording({ id: 'B', datetime: '2026-05-11T10:05:00Z', modeName: 'command' }), + makeRecording({ id: 'C', datetime: '2026-05-11T10:10:00Z', modeName: 'dictation' }) + ]) + expect(groups[0]?.dominantMode).toBe('dictation') + }) + + it('falls back to first-seen mode on tie', () => { + // Two recordings, two different modes: dictation wins because it + // appeared first in chronological order. + const groups = groupIntoSessions([ + makeRecording({ id: 'A', datetime: '2026-05-11T10:00:00Z', modeName: 'dictation' }), + makeRecording({ id: 'B', datetime: '2026-05-11T10:05:00Z', modeName: 'command' }) + ]) + expect(groups[0]?.dominantMode).toBe('dictation') + }) + + it('computes totalDurationSeconds as the sum of constituent durations', () => { + const groups = groupIntoSessions([ + makeRecording({ id: 'A', datetime: '2026-05-11T10:00:00Z', duration: 60_000 }), + makeRecording({ id: 'B', datetime: '2026-05-11T10:05:00Z', duration: 120_000 }) + ]) + expect(groups[0]?.totalDurationSeconds).toBe(180) // 60s + 120s + }) + + it('sorts input by datetime before grouping', () => { + // Out-of-order input: the function should sort by datetime + // ascending before walking the gap boundaries. + const groups = groupIntoSessions([ + makeRecording({ id: 'C', datetime: '2026-05-11T11:00:00Z' }), + makeRecording({ id: 'A', datetime: '2026-05-11T10:00:00Z' }), + makeRecording({ id: 'B', datetime: '2026-05-11T10:05:00Z' }) + ]) + expect(groups[0]?.recordingIds[0]).toBe('A') + expect(groups[0]?.recordingIds).toContain('A') + }) +}) diff --git a/src/main/marfa/sessions.ts b/src/main/marfa/sessions.ts new file mode 100644 index 0000000..2283c86 --- /dev/null +++ b/src/main/marfa/sessions.ts @@ -0,0 +1,128 @@ +import type { Recording } from '@shared/types' + +/** + * Sessions are an analytics-app concept — gap-grouped recordings, + * computed locally. They don't exist in Superwhisper's on-disk data. + * + * Algorithm: sort recordings by start datetime ascending; walk in order, + * starting a new session whenever the gap between the previous recording's + * *end* and the current recording's *start* exceeds the configured + * threshold. Each session carries the constituent recording ids in the + * original chronological order. + * + * The natural key for the resulting Marfa item is `(first_recording_id, + * gap_threshold_minutes)` — a threshold change yields fresh source_ids, + * which means fresh items in Marfa. Old session items get diff'd out by + * the engine and soft-deleted. This is the "trash-and-re-mint" + * resolution to the open question in [[Marfa integration — May 2026]]. + */ + +export const DEFAULT_GAP_THRESHOLD_MINUTES = 30 + +export interface SessionGroup { + /** Stable identifier for the session — `${recordingIds[0]}-${threshold}`. */ + sourceId: string + /** Earliest recording in chronological order. */ + startedAt: string // ISO + /** End of the last recording (datetime + duration). */ + endedAt: string // ISO + /** Number of recordings included. */ + recordingCount: number + /** Sum of recording durations (seconds). */ + totalDurationSeconds: number + /** Most-used mode among constituent recordings. Tie-break: first seen. */ + dominantMode: string + /** Threshold that defined this session's boundaries. Persisted so the + * natural key resolves stably across runs at the same setting and + * surfaces fresh source_ids when the user changes it. */ + gapThresholdMinutes: number + /** Recording source_ids in chronological order — used for the + * `parent-of` edges from session → recording. */ + recordingIds: string[] +} + +/** + * Group `recordings` into sessions using the supplied threshold. Pure — + * given the same input + threshold, returns the same groups. Empty + * input → empty array; never throws. + */ +export function groupIntoSessions( + recordings: Recording[], + thresholdMinutes: number = DEFAULT_GAP_THRESHOLD_MINUTES +): SessionGroup[] { + if (recordings.length === 0) return [] + // Stable sort by start datetime. Recordings with the same datetime + // (very rare in practice) keep insertion order. + const sorted = [...recordings].sort((a, b) => a.datetime.localeCompare(b.datetime)) + + const gapMs = Math.max(0, thresholdMinutes) * 60_000 + const groups: Array<{ + items: Recording[] + startMs: number + endMs: number + }> = [] + for (const r of sorted) { + const startMs = new Date(r.datetime).getTime() + if (!Number.isFinite(startMs)) continue + const endMs = startMs + Math.max(0, r.duration) + const current = groups[groups.length - 1] + if (current && startMs - current.endMs <= gapMs) { + current.items.push(r) + // The session extends to whichever endpoint is later — defensive + // against pathological out-of-order duration values. + if (endMs > current.endMs) current.endMs = endMs + } else { + groups.push({ items: [r], startMs, endMs }) + } + } + + return groups.map((g) => { + const first = g.items[0] + // `first` is always defined — every group has at least one entry by + // construction. Narrow defensively rather than via assertion so + // future refactors can't introduce a silent crash. + if (!first) { + throw new Error('Internal: empty session group') + } + const totalDurationSeconds = g.items.reduce((sum, r) => sum + Math.max(0, r.duration), 0) / 1000 + return { + sourceId: `${first.id}-${thresholdMinutes}`, + startedAt: new Date(g.startMs).toISOString(), + endedAt: new Date(g.endMs).toISOString(), + recordingCount: g.items.length, + totalDurationSeconds, + dominantMode: pickDominantMode(g.items), + gapThresholdMinutes: thresholdMinutes, + recordingIds: g.items.map((r) => r.id) + } + }) +} + +/** + * Pick the most-used mode in a session. Tie-break: the first mode + * encountered chronologically wins, matching the "label by what the + * user started with" intuition. + */ +function pickDominantMode(items: Recording[]): string { + const counts = new Map() + const firstSeen = new Map() + for (let i = 0; i < items.length; i += 1) { + const item = items[i] + if (!item) continue + const mode = item.modeName || '' + counts.set(mode, (counts.get(mode) ?? 0) + 1) + if (!firstSeen.has(mode)) firstSeen.set(mode, i) + } + let bestMode = '' + let bestCount = -1 + let bestFirstSeen = Number.POSITIVE_INFINITY + for (const [mode, count] of counts) { + const seen = firstSeen.get(mode) ?? Number.POSITIVE_INFINITY + if (count > bestCount || (count === bestCount && seen < bestFirstSeen)) { + bestMode = mode + bestCount = count + bestFirstSeen = seen + } + } + return bestMode +} diff --git a/src/main/marfa/state.test.ts b/src/main/marfa/state.test.ts new file mode 100644 index 0000000..f3bb7e5 --- /dev/null +++ b/src/main/marfa/state.test.ts @@ -0,0 +1,99 @@ +import { existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const state = vi.hoisted(() => ({ userData: '' })) +vi.mock('electron', () => ({ app: { getPath: () => state.userData } })) + +import { clearState, loadState, saveState } from './state' + +beforeEach(() => { + state.userData = mkdtempSync(join(tmpdir(), 'sw-marfa-state-')) +}) + +afterEach(() => { + rmSync(state.userData, { recursive: true, force: true }) +}) + +describe('loadState', () => { + it('returns an empty state when the file is missing', () => { + const s = loadState() + expect(s.schemaVersion).toBe(1) + expect(s.recordings).toEqual({}) + expect(s.sessions).toEqual({}) + expect(s.lastFullSyncAt).toBeNull() + }) + + it('falls back to empty state on malformed JSON', () => { + writeFileSync(join(state.userData, 'marfa-sync.json'), '{not json', 'utf-8') + const s = loadState() + expect(s.recordings).toEqual({}) + }) + + it('drops bogus recording entries (defensive parse)', () => { + writeFileSync( + join(state.userData, 'marfa-sync.json'), + JSON.stringify({ + schemaVersion: 1, + recordings: { ok: { hash: 'abc', itemId: 'item-1', lastPushedAt: 'x' }, bad: 'nope' }, + sessions: {}, + lastFullSyncAt: null + }), + 'utf-8' + ) + const s = loadState() + // Validator is whole-record: if any entry is malformed, the whole + // map is dropped. Acceptable for engine state — a corrupted file + // forces a fresh full sync rather than silently using mixed data. + expect(s.recordings).toEqual({}) + }) + + it('round-trips a saved state', () => { + const original = { + schemaVersion: 1, + recordings: { + '1755164573': { + hash: 'abc', + itemId: '019e0000-0000-7000-8000-000000000001', + lastPushedAt: '2026-05-11T10:00:00.000Z' + } + }, + sessions: {}, + lastFullSyncAt: '2026-05-11T10:00:01.000Z' + } + expect(saveState(original)).toBe(true) + const loaded = loadState() + expect(loaded).toEqual(original) + }) +}) + +describe('saveState', () => { + it('writes via a temp file + rename (no partial files on success)', () => { + saveState({ + schemaVersion: 1, + recordings: { a: { hash: 'h', itemId: 'item-a', lastPushedAt: 'x' } }, + sessions: {}, + lastFullSyncAt: null + }) + expect(existsSync(join(state.userData, 'marfa-sync.json'))).toBe(true) + // Atomic write idiom: the .tmp shouldn't survive a successful write. + expect(existsSync(join(state.userData, 'marfa-sync.json.tmp'))).toBe(false) + // Ensure the persisted JSON is well-formed. + const raw = readFileSync(join(state.userData, 'marfa-sync.json'), 'utf-8') + expect(() => JSON.parse(raw)).not.toThrow() + }) +}) + +describe('clearState', () => { + it('deletes the persisted file', () => { + saveState({ schemaVersion: 1, recordings: {}, sessions: {}, lastFullSyncAt: null }) + expect(existsSync(join(state.userData, 'marfa-sync.json'))).toBe(true) + clearState() + expect(existsSync(join(state.userData, 'marfa-sync.json'))).toBe(false) + }) + + it('is a noop when no file exists', () => { + expect(() => clearState()).not.toThrow() + }) +}) diff --git a/src/main/marfa/state.ts b/src/main/marfa/state.ts new file mode 100644 index 0000000..32c0a36 --- /dev/null +++ b/src/main/marfa/state.ts @@ -0,0 +1,125 @@ +import { app } from 'electron' +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs' +import { dirname, join } from 'path' + +/** + * Persistent sync-state file: per-source_id content hash + last-pushed + * timestamp. Lives alongside `config.json` in ``. + * + * Engine state, not user-preference state — keeping it separate from + * `config.json` so a `Reset app` action doesn't wipe sync state, and + * conversely so engine-state changes don't churn the config file's + * mtime on every reindex. + * + * Atomic write via write-temp + rename so a crash mid-write can't + * leave a half-written JSON file. The whole map is rewritten on every + * sync — typical size is on the order of (recording_count * 80 bytes) + * which is still under 1MB for 11k recordings, so chunked persistence + * isn't needed. + */ + +const STATE_FILE = 'marfa-sync.json' +const SCHEMA_VERSION = 1 + +export interface SyncStateEntry { + /** SHA-256 hash of the projected payload — see `projection.ts`. */ + hash: string + /** Server-assigned item id, captured from the bulk-upsert response. + * Lets the soft-delete path call `transition(id, 'trashed')` without + * a separate list lookup to resolve the natural key. */ + itemId: string + /** ISO timestamp of the last successful push. */ + lastPushedAt: string +} + +export interface SyncState { + schemaVersion: number + /** Keyed by `source_id`. */ + recordings: Record + /** Keyed by `source_id`. */ + sessions: Record + /** Wall-clock ISO of the last completed full-sync pass. Used for the + * "last synced" timestamp on the Settings card. Null until the + * first run completes. */ + lastFullSyncAt: string | null +} + +function filePath(): string { + return join(app.getPath('userData'), STATE_FILE) +} + +function emptyState(): SyncState { + return { + schemaVersion: SCHEMA_VERSION, + recordings: {}, + sessions: {}, + lastFullSyncAt: null + } +} + +/** Read the sync state from disk. Returns an empty state if the file + * is missing or unreadable; never throws — Marfa is optional. */ +export function loadState(): SyncState { + const path = filePath() + if (!existsSync(path)) return emptyState() + try { + const raw = readFileSync(path, 'utf-8') + const parsed = JSON.parse(raw) as Partial + if (typeof parsed !== 'object' || parsed === null) return emptyState() + return { + schemaVersion: SCHEMA_VERSION, + recordings: isRecordOfEntries(parsed.recordings) ? parsed.recordings : {}, + sessions: isRecordOfEntries(parsed.sessions) ? parsed.sessions : {}, + lastFullSyncAt: typeof parsed.lastFullSyncAt === 'string' ? parsed.lastFullSyncAt : null + } + } catch (err) { + console.warn('[marfa] failed to read sync state, starting fresh:', err) + return emptyState() + } +} + +/** Atomically write the sync state to disk. Returns true on success. */ +export function saveState(state: SyncState): boolean { + try { + const path = filePath() + mkdirSync(dirname(path), { recursive: true }) + const tmp = path + '.tmp' + writeFileSync(tmp, JSON.stringify(state, null, 2), 'utf-8') + renameSync(tmp, path) + return true + } catch (err) { + console.warn('[marfa] failed to write sync state:', err) + return false + } +} + +/** Delete the sync-state file — used on disconnect so a fresh connect + * starts with an empty state map (every recording looks new to the + * diff). The natural-key upsert in Marfa makes the resulting full push + * a bandwidth cost, not a correctness one. */ +export function clearState(): void { + const path = filePath() + if (existsSync(path)) { + try { + unlinkSync(path) + } catch (err) { + console.warn('[marfa] failed to delete sync state:', err) + } + } +} + +function isRecordOfEntries(value: unknown): value is Record { + if (typeof value !== 'object' || value === null || Array.isArray(value)) return false + for (const v of Object.values(value)) { + if ( + typeof v !== 'object' || + v === null || + typeof (v as SyncStateEntry).hash !== 'string' || + typeof (v as SyncStateEntry).itemId !== 'string' || + typeof (v as SyncStateEntry).lastPushedAt !== 'string' + ) { + return false + } + } + return true +} diff --git a/src/main/marfa/token-storage.ts b/src/main/marfa/token-storage.ts new file mode 100644 index 0000000..5487c4a --- /dev/null +++ b/src/main/marfa/token-storage.ts @@ -0,0 +1,60 @@ +import type { TokenStorage } from '@withmarfa/sdk/auth' +import { clearCredential, readCredential, writeCredential, type OAuthTokenBundle } from './tokens' + +/** + * `TokenStorage` adapter that bridges the SDK's three-method storage + * interface to our existing `safeStorage`-backed credential file. + * + * The Marfa app has exactly one OAuth credential at a time (single tenant, + * single user), so the `key` parameter the SDK passes through is ignored — + * we read and write the same credential file regardless. The clientId is + * captured at construction time so `set()` can preserve it across token + * rotations (the SDK's `PersistedTokens` JSON doesn't carry the + * `client_id`, but our on-disk shape does). + * + * Replaces the old `buildOAuthTokenProvider` + `persistTokensFromStorage` + * dance — the SDK's `StoredTokenProvider` now drives refresh against the + * discovered token endpoint (post-T-131 `/auth/oauth2/token`), and this + * adapter just makes the persistence transparent. + */ +export class SafeStorageTokenStorage implements TokenStorage { + constructor(private readonly clientId: string) {} + + // SDK persists tokens as JSON-encoded `PersistedTokens` — + // { access_token, refresh_token, access_expires_at, scope } + // which matches our `OAuthTokenBundle` shape exactly. We round-trip + // through the credential file's `{ kind: 'oauth', clientId, tokens }` + // wrapper so the on-disk shape stays compatible with `readCredential` + // / `writeCredential` across the rest of the app (boot probes, status + // checks, API-key escape hatch). + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async get(_key: string): Promise { + const cred = readCredential() + if (!cred || cred.kind !== 'oauth') return null + // Defensive: if the stored credential is for a different clientId, + // pretend storage is empty — the SDK will throw "No active session", + // the renderer surfaces the disconnected state, and the user + // reconnects cleanly. Better than handing back a foreign credential. + if (cred.clientId !== this.clientId) return null + return JSON.stringify(cred.tokens) + } + + async set(_key: string, value: string): Promise { + const tokens = JSON.parse(value) as OAuthTokenBundle + const ok = writeCredential({ kind: 'oauth', clientId: this.clientId, tokens }) + if (!ok) { + // safeStorage unavailable mid-session. The SDK's in-memory cache + // still holds the rotated bundle, so the current process keeps + // working — but the next app launch will re-prompt sign-in. + // Surface as a thrown error so the SDK's caller can decide; the + // current `persist` paths swallow it on the renderer side. + throw new Error('safeStorage unavailable — cannot persist OAuth tokens to disk') + } + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + async delete(_key: string): Promise { + clearCredential() + } +} diff --git a/src/main/marfa/tokens.test.ts b/src/main/marfa/tokens.test.ts new file mode 100644 index 0000000..142b93b --- /dev/null +++ b/src/main/marfa/tokens.test.ts @@ -0,0 +1,77 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync, writeFileSync } from 'fs' +import { tmpdir } from 'os' +import { join } from 'path' + +/** + * Credential-blob round-trip coverage. The JSON shape changed in T-164 + * (previously: single string; now: discriminated union). These tests + * pin the on-disk format + the legacy-string up-conversion path so an + * existing API-key user doesn't get logged out by the format change. + */ + +const fixtures = vi.hoisted(() => ({ userData: '' })) + +vi.mock('electron', () => ({ + app: { getPath: () => fixtures.userData }, + safeStorage: { + isEncryptionAvailable: () => true, + encryptString: (s: string) => Buffer.from(s, 'utf-8'), + decryptString: (b: Buffer) => b.toString('utf-8') + } +})) + +let tokensModule: typeof import('./tokens') + +beforeEach(async () => { + fixtures.userData = mkdtempSync(join(tmpdir(), 'sw-marfa-tokens-')) + vi.resetModules() + tokensModule = await import('./tokens') +}) + +afterEach(() => { + rmSync(fixtures.userData, { recursive: true, force: true }) +}) + +describe('credential round-trip', () => { + it('persists and reads back an API-key credential', () => { + const ok = tokensModule.writeCredential({ kind: 'api-key', key: 'marfa_k1_abc' }) + expect(ok).toBe(true) + const back = tokensModule.readCredential() + expect(back).toEqual({ kind: 'api-key', key: 'marfa_k1_abc' }) + }) + + it('persists and reads back an OAuth credential', () => { + const bundle = { + access_token: 'at-1', + refresh_token: 'rt-1', + access_expires_at: 1735689600000, + scope: '*:read *:write' + } + tokensModule.writeCredential({ kind: 'oauth', clientId: 'cli-1', tokens: bundle }) + const back = tokensModule.readCredential() + expect(back).toEqual({ kind: 'oauth', clientId: 'cli-1', tokens: bundle }) + }) + + it('up-converts a legacy bare-string payload to api-key shape', () => { + // Pre-T-164 the file held the raw API key as a string. + const path = join(fixtures.userData, 'marfa-credential.enc') + writeFileSync(path, Buffer.from('marfa_k1_legacy', 'utf-8')) + const back = tokensModule.readCredential() + expect(back).toEqual({ kind: 'api-key', key: 'marfa_k1_legacy' }) + }) + + it('returns null on a malformed credential blob', () => { + const path = join(fixtures.userData, 'marfa-credential.enc') + // JSON, but not the expected shape. + writeFileSync(path, Buffer.from(JSON.stringify({ kind: 'nope' }), 'utf-8')) + expect(tokensModule.readCredential()).toBeNull() + }) + + it('clearCredential removes the persisted file', () => { + tokensModule.writeCredential({ kind: 'api-key', key: 'k' }) + expect(tokensModule.credentialExists()).toBe(true) + tokensModule.clearCredential() + expect(tokensModule.credentialExists()).toBe(false) + }) +}) diff --git a/src/main/marfa/tokens.ts b/src/main/marfa/tokens.ts new file mode 100644 index 0000000..6f0d2fc --- /dev/null +++ b/src/main/marfa/tokens.ts @@ -0,0 +1,160 @@ +import { app, safeStorage } from 'electron' +import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'fs' +import { dirname, join } from 'path' + +/** + * Encrypted-on-disk storage for the Marfa credential. + * + * `safeStorage` is Keychain-backed on macOS (the only platform this app + * targets) — the blob written to disk is opaque ciphertext that can only + * be decrypted on the machine that wrote it, under the same user. If the + * Keychain is unavailable (extremely unlikely on macOS) the helpers + * degrade gracefully — read returns null, write returns false — so the + * rest of the integration stays optional. + * + * On disk: `/marfa-credential.enc` — a single binary file + * containing the encrypted JSON-encoded credential blob. Written via the + * write-temp + rename idiom so a crash mid-write doesn't corrupt the + * file. + * + * The credential is a discriminated union of: + * - `{ kind: 'api-key', key }` — user-pasted API key (dev/escape hatch) + * - `{ kind: 'oauth', clientId, tokens: { access_token, refresh_token, + * access_expires_at, scope } }` — device-flow tokens + * + * Older installs that pre-date device-flow stored a single string here. + * On read we transparently up-convert a raw string to the `api-key` + * shape so an existing API-key connection survives the format change + * without forcing a reconnect. + */ + +const CREDENTIAL_FILE = 'marfa-credential.enc' + +export interface OAuthTokenBundle { + access_token: string + refresh_token: string + /** Unix ms. */ + access_expires_at: number + scope: string +} + +export type StoredCredential = + | { kind: 'api-key'; key: string } + | { kind: 'oauth'; clientId: string; tokens: OAuthTokenBundle } + +function filePath(): string { + return join(app.getPath('userData'), CREDENTIAL_FILE) +} + +/** True when an encrypted credential file is present. Does not attempt + * to decrypt — useful for boot-time "do we have a token to try?" + * without paying the safeStorage cost. */ +export function credentialExists(): boolean { + return existsSync(filePath()) +} + +/** Read and decrypt the stored credential. Returns null on any failure + * (missing file, safeStorage unavailable, decryption error). Never + * throws — Marfa is optional and shouldn't break the rest of the app. + * + * A legacy raw-string payload is up-converted to the `api-key` shape + * transparently so existing installs don't need to reconnect. */ +export function readCredential(): StoredCredential | null { + try { + if (!safeStorage.isEncryptionAvailable()) { + console.warn('[marfa] safeStorage unavailable — cannot decrypt credential') + return null + } + const path = filePath() + if (!existsSync(path)) return null + const buf = readFileSync(path) + const decoded = safeStorage.decryptString(buf) + // Try JSON first. If it parses, the on-disk file was written by the + // current code path — validate the shape and return (or null on a + // shape mismatch; we never silently fall back to legacy in that case + // because the only way to land here with valid JSON is the new + // format). If JSON parsing fails, the file is from the pre-T-164 + // bare-string era — up-convert it. + let parsed: unknown + try { + parsed = JSON.parse(decoded) + } catch { + if (decoded.length > 0) return { kind: 'api-key', key: decoded } + return null + } + return validate(parsed) + } catch (err) { + console.warn('[marfa] failed to read credential:', err) + return null + } +} + +/** Encrypt and persist the credential. Returns true on success, false + * on any failure (safeStorage unavailable, write error). */ +export function writeCredential(value: StoredCredential): boolean { + try { + if (!safeStorage.isEncryptionAvailable()) { + console.warn('[marfa] safeStorage unavailable — cannot encrypt credential') + return false + } + const path = filePath() + mkdirSync(dirname(path), { recursive: true }) + const tmp = path + '.tmp' + writeFileSync(tmp, safeStorage.encryptString(JSON.stringify(value))) + renameSync(tmp, path) + return true + } catch (err) { + console.warn('[marfa] failed to write credential:', err) + return false + } +} + +/** Delete the persisted credential. Safe to call when no credential + * exists. */ +export function clearCredential(): void { + const path = filePath() + if (existsSync(path)) { + try { + unlinkSync(path) + } catch (err) { + console.warn('[marfa] failed to delete credential:', err) + } + } +} + +/** Defensive parse — accept either credential variant; reject anything + * else so a corrupted file forces a fresh reconnect rather than wedging + * on bad data. */ +function validate(parsed: unknown): StoredCredential | null { + if (typeof parsed !== 'object' || parsed === null) return null + const obj = parsed as Record + if (obj.kind === 'api-key' && typeof obj.key === 'string' && obj.key.length > 0) { + return { kind: 'api-key', key: obj.key } + } + if ( + obj.kind === 'oauth' && + typeof obj.clientId === 'string' && + typeof obj.tokens === 'object' && + obj.tokens !== null + ) { + const t = obj.tokens as Record + if ( + typeof t.access_token === 'string' && + typeof t.refresh_token === 'string' && + typeof t.access_expires_at === 'number' && + typeof t.scope === 'string' + ) { + return { + kind: 'oauth', + clientId: obj.clientId, + tokens: { + access_token: t.access_token, + refresh_token: t.refresh_token, + access_expires_at: t.access_expires_at, + scope: t.scope + } + } + } + } + return null +} diff --git a/src/preload/api.ts b/src/preload/api.ts index 74d555b..f4455c1 100644 --- a/src/preload/api.ts +++ b/src/preload/api.ts @@ -1,5 +1,21 @@ import { ipcRenderer } from 'electron' import type { HydratePayload } from '@shared/types' +import type { MarfaMapping } from '../main/marfa/mapping' + +export type { + MarfaMapping, + MappingBinding, + FieldMap, + SourceFieldRef, + RecordingSourceField, + SessionSourceField, + SourceKind +} from '../main/marfa/mapping' +// Runtime helpers for plain-English source-field labels live in +// `@shared/marfa-labels` (renderer-safe — no electron/SDK imports). Type +// `SourceFieldLabel` is re-exported here from the same module so a +// caller can grab the type alongside the preload contract. +export type { SourceFieldLabel } from '@shared/marfa-labels' /** * Single source of truth for the renderer ↔ main IPC surface. @@ -42,6 +58,36 @@ export interface Config { autoHideSidebar: boolean /** When true, DevTools open on launch. Equivalent to Cmd+Option+I. */ devTools: boolean + /** Gap (in minutes) that defines a session boundary at sync time. + * Two recordings whose `datetime` values are within this window + * group into the same `superwhisper.session` Marfa item; a gap + * larger than this starts a new session. Default 30 mirrors the + * long-standing engine default. Local-only; reading code is + * `src/main/marfa/engine.ts`. */ + sessionGapThresholdMinutes: number + /** Optional Marfa integration settings. The endpoint URL is plain + * config; OAuth tokens live in encrypted storage via Electron's + * safeStorage, sync state in its own JSON file. */ + marfa: { + endpoint: string + /** Active mapping config — which Marfa type each source kind binds + * to, plus the field map for projection. Defaults to the bundled + * superwhisper.* types. Older configs without a `mapping` block + * default to the bundled mapping on first read. */ + mapping: MarfaMapping + /** Optional Superwhisper-mode filter — recordings whose `modeName` + * is in this list are the only ones that sync. `null` = no + * filter; empty array is legal but yields zero recordings. */ + modeFilter: string[] | null + /** When false, the recordings pipeline is suppressed at sync time: + * no upserts, no soft-deletes. Existing items on the server are + * left untouched. Default true. */ + recordingPipelineEnabled: boolean + /** When false, the sessions pipeline is suppressed at sync time: + * no upserts, no soft-deletes. Existing items on the server are + * left untouched. Default true. */ + sessionPipelineEnabled: boolean + } } /** @@ -68,6 +114,12 @@ export interface ConfigStatus { demoMode: boolean autoHideSidebar: boolean devTools: boolean + /** See Config.sessionGapThresholdMinutes. */ + sessionGapThresholdMinutes: number + /** See Config.marfa.recordingPipelineEnabled. */ + recordingPipelineEnabled: boolean + /** See Config.marfa.sessionPipelineEnabled. */ + sessionPipelineEnabled: boolean } /** Wire callback type for main → renderer push when the indexed dataset @@ -86,6 +138,118 @@ export type UpdaterStatus = | { kind: 'downloaded'; version: string } | { kind: 'error'; message: string } +/** + * Status of the optional Marfa integration. Mirrors the implementation in + * `src/main/marfa/index.ts`; lives here so the IPC contract is one place. + * + * The renderer composes the "disabled" UX (when demo mode is on or no + * recordings path is set) from `configStore` — main always reports the + * sync engine's actual state. Card states the user sees: + * + * - `disconnected` → endpoint + "Connect to Marfa" CTA + * - `connecting` (mode: device) → user-code + "Verify in browser" pane, + * with a "use API key instead" link + * - `connecting` (mode: api-key)→ API-key paste-and-verify pane + * - `connected` (no error) → "last synced …" + "Sync now" + * - `connected` (with error) → as above + inline error row + * - `syncing` → progress phase + processed/total + * + * Default auth is the OAuth device-flow (`startDeviceFlow` from the SDK). + * The API-key paste path stays as a dev/escape hatch — surfaced via the + * "use API key instead" link on the device-flow connecting pane. + * Tokens are encrypted via `safeStorage` and persisted to + * `/marfa-credential.enc`. + */ +export type MarfaSyncPhase = 'preparing' | 'recordings' | 'sessions' + +/** + * Identity payload returned by `marfa.probeConnection`. On success, the + * card surfaces the user's email + role in the connection header; on + * failure, the error string lands inline. + */ +export type ProbeResult = + | { + ok: true + /** Email from the profile, when present. */ + email: string | null + /** Display name composed from first + last name (or username + * fallback). */ + displayName: string | null + } + | { ok: false; error: string } + +/** + * Compact summary of a server-side type, returned by + * `marfa.listTypes()`. Enough to render a picker (id + label + + * description) and an auto-pair preview (field names), without pulling + * the full SDK `TypeSchema` shape into the renderer. + */ +export interface TypeSummary { + id: string + label: string | null + description: string | null + parent: string | null + fields: string[] +} + +export type MarfaStatus = + | { + kind: 'disconnected' + endpoint: string + /** Populated when a previous connect attempt failed. Cleared on + * successful connect or disconnect. */ + lastError: string | null + } + | { + kind: 'connecting' + mode: 'api-key' + endpoint: string + } + | { + kind: 'connecting' + mode: 'device' + endpoint: string + /** Short user-readable code (XXXX-XXXX shape) the user types into + * the verification page. */ + userCode: string + /** Plain verification URL — show as fallback if `verificationUriComplete` + * is absent or the user wants to type the code manually. */ + verificationUri: string + /** Deep-link variant with the code pre-filled. When present, the + * "Verify in browser" button opens this so the user doesn't have + * to type the code at all. */ + verificationUriComplete: string | null + /** Unix ms — used by the renderer to show a "code expires in …" + * countdown if we want one later. */ + expiresAt: number + } + | { + kind: 'connected' + endpoint: string + /** ISO; null until the first successful sync. */ + lastSyncedAt: string | null + /** Set after a failed sync; cleared on the next success. */ + lastError: string | null + /** Number of recordings tracked in local sync state — i.e. how + * many have been successfully pushed to Marfa. Equal to the + * size of `state.recordings` in the engine. */ + syncedRecordings: number + /** Same shape, for sessions. */ + syncedSessions: number + /** True when the previous sync attempt was aborted via + * `cancelSync()` rather than failing for a genuine reason. + * Lets the card render "Sync cancelled" instead of "Sync + * failed". `lastError` is `'Cancelled'` in this case. */ + lastSyncCancelled: boolean + } + | { + kind: 'syncing' + endpoint: string + phase: MarfaSyncPhase + processed: number + total: number + } + export const api = { config: { status: (): Promise => ipcRenderer.invoke('config:status'), @@ -114,7 +278,18 @@ export const api = { * folder, demo flag, custom filler dictionary, etc. The renderer * follows up by triggering a fresh hydrate so the dataStore picks * up the cleared state. Used by Settings → About → Reset app. */ - reset: (): Promise => ipcRenderer.invoke('config:reset') + reset: (): Promise => ipcRenderer.invoke('config:reset'), + /** Toggle the Recordings sync pipeline on/off. When off, the sync + * engine performs no upserts or soft-deletes for recording items. */ + setRecordingPipelineEnabled: (enabled: boolean): Promise => + ipcRenderer.invoke('config:setRecordingPipelineEnabled', enabled), + /** Toggle the Sessions sync pipeline on/off. */ + setSessionPipelineEnabled: (enabled: boolean): Promise => + ipcRenderer.invoke('config:setSessionPipelineEnabled', enabled), + /** Persist the gap (in minutes) that defines a session boundary + * at sync time. Read by the engine's `groupIntoSessions` call. */ + setSessionGapThresholdMinutes: (minutes: number): Promise => + ipcRenderer.invoke('config:setSessionGapThresholdMinutes', minutes) }, data: { hydrate: (): Promise => ipcRenderer.invoke('data:hydrate'), @@ -153,7 +328,82 @@ export const api = { return () => ipcRenderer.removeListener('updater:status', listener) } }, - openExternal: (url: string): Promise => ipcRenderer.invoke('shell:openExternal', url) + openExternal: (url: string): Promise => ipcRenderer.invoke('shell:openExternal', url), + marfa: { + /** Current sync-engine status. The renderer composes the + * "disabled" state from configStore (demoMode / null path); main + * always reports `disconnected` / `connected` / etc. */ + status: (): Promise => ipcRenderer.invoke('marfa:status'), + /** Persist a new Marfa endpoint URL. Returns the updated status. */ + setEndpoint: (url: string): Promise => ipcRenderer.invoke('marfa:setEndpoint', url), + /** Default connect path — initiates the OAuth device flow against + * the configured endpoint. Returns a `connecting` (mode: 'device') + * status carrying the user code + verification URI; the renderer + * shows that, and a background poll resolves to `connected` on + * user approval (push lands via `onStatus`). */ + connect: (): Promise => ipcRenderer.invoke('marfa:connect'), + /** Dev/escape-hatch path: transition the card into the API-key + * paste pane. Linked from the device-flow connecting pane via + * "use API key instead". */ + useApiKey: (): Promise => ipcRenderer.invoke('marfa:useApiKey'), + /** Verify the supplied API key against the current endpoint and, on + * success, encrypt + persist it. Resolves with the new status — + * `connected` on success, `disconnected` with `lastError` set on + * failure. */ + submitApiKey: (key: string): Promise => + ipcRenderer.invoke('marfa:submitApiKey', key), + /** Cancel an in-progress connect attempt (device-flow polling or + * API-key pane). Falls back to `disconnected`. */ + cancelConnect: (): Promise => ipcRenderer.invoke('marfa:cancelConnect'), + /** Revoke the persisted token + clear sync state. */ + disconnect: (): Promise => ipcRenderer.invoke('marfa:disconnect'), + /** Manual sync trigger. Returns when the sync completes (success + * or otherwise); intermediate progress lands via `onStatus`. */ + syncNow: (): Promise => ipcRenderer.invoke('marfa:syncNow'), + /** Abort the currently-running sync. Returns the post-cancel + * status (typically `connected` with `lastError = 'Cancelled'`). + * No-op when nothing is in flight. */ + cancelSync: (): Promise => ipcRenderer.invoke('marfa:cancelSync'), + /** Run a small dry-run sync against the 5 most recent recordings. + * Useful for sanity-checking the integration without touching the + * full corpus. Skips soft-delete and session derivation while the + * cap is in effect. */ + testSync: (): Promise => ipcRenderer.invoke('marfa:testSync'), + /** Developer affordance — wipe all `superwhisper.*` items from the + * connected tenant and clear the local sync state so the next sync + * re-mints everything. No confirmation dialog at this layer; the + * caller (Settings → Developer) renders the user-facing prompt. */ + purgeAllData: (): Promise< + { ok: true; recordings: number; sessions: number } | { ok: false; error: string } + > => ipcRenderer.invoke('marfa:purgeAllData'), + /** Read the persisted mapping config. */ + getMapping: (): Promise => ipcRenderer.invoke('marfa:getMapping'), + /** Persist a new mapping. Resets the sync state so the next pass + * treats every recording as new under the fresh fingerprints. */ + setMapping: (mapping: MarfaMapping): Promise => + ipcRenderer.invoke('marfa:setMapping', mapping), + /** Read the persisted mode filter. `null` = no filter. */ + getModeFilter: (): Promise => ipcRenderer.invoke('marfa:getModeFilter'), + /** Persist the Superwhisper-mode filter. `null` clears it. */ + setModeFilter: (modes: string[] | null): Promise => + ipcRenderer.invoke('marfa:setModeFilter', modes), + /** Probe the configured endpoint with the current credential. + * Backs the "Test connection" button. */ + probeConnection: (): Promise => ipcRenderer.invoke('marfa:probeConnection'), + /** List the types registered on the server. `null` on failure. */ + listTypes: (): Promise => ipcRenderer.invoke('marfa:listTypes'), + /** Register a user-authored type schema. Returns the persisted + * schema on success; `null` on failure (renderer can re-probe via + * `listTypes` to see what happened). */ + registerType: (schema: unknown): Promise => + ipcRenderer.invoke('marfa:registerType', schema), + /** Subscribe to status changes from the sync engine. */ + onStatus: (handler: (status: MarfaStatus) => void): Unsubscribe => { + const listener = (_e: unknown, payload: MarfaStatus): void => handler(payload) + ipcRenderer.on('marfa:status', listener) + return () => ipcRenderer.removeListener('marfa:status', listener) + } + } } export type Api = typeof api diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index ba5cddb..af6cee6 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1,9 +1,12 @@ -import { useEffect } from 'react' +import { useEffect, useRef } from 'react' import { RouterProvider } from 'react-router-dom' import { LoadingOverlay } from './components/LoadingOverlay' +import { Toaster } from './components/ui/sonner' +import { toastError } from './lib/toast' import { router } from './routes' import { useConfigStore } from './state/configStore' import { useDataStore } from './state/dataStore' +import { useMarfaStore } from './state/marfaStore' import { useThemeStore } from './state/themeStore' /** @@ -19,6 +22,38 @@ function App(): React.JSX.Element { const hydrateData = useDataStore((s) => s.hydrate) const clearData = useDataStore((s) => s.clearData) const dataLoading = useDataStore((s) => s.loading) + const hydrateMarfa = useMarfaStore((s) => s.hydrate) + const marfaStatus = useMarfaStore((s) => s.status) + // Track the most-recent error string we've already surfaced so we + // only toast on transitions, not on every render. A null entry means + // the next non-null error is a fresh transition worth toasting. + const lastToastedErrorRef = useRef(null) + + // Surface auth + sync errors via a system-level toast in addition to + // the inline state on the ConnectionCard. The card carries the full + // explanation; the toast is the one-shot heads-up so a background + // failure can't go unnoticed when the user is on another screen. + // + // Only fires when the error *string* changes — re-renders that + // re-emit the same error are coalesced. Cleared whenever the error + // goes away so the next occurrence triggers again. + useEffect(() => { + if (!marfaStatus) return + const error = + marfaStatus.kind === 'disconnected' || marfaStatus.kind === 'connected' + ? (marfaStatus.lastError ?? null) + : null + + if (error === null) { + lastToastedErrorRef.current = null + return + } + if (lastToastedErrorRef.current === error) return + lastToastedErrorRef.current = error + + const message = marfaStatus.kind === 'disconnected' ? 'Sign-in failed' : 'Last sync failed' + toastError({ message, copyText: error }) + }, [marfaStatus]) useEffect(() => { const mq = @@ -43,6 +78,14 @@ function App(): React.JSX.Element { void hydrateConfig() }, [hydrateConfig]) + // Pull Marfa integration state once on mount — the store's `hydrated` + // flag makes this a no-op if it's already run. Without this, every + // Sync-tab consumer (ConnectionCard, PipelineCard) + // sees `status: null` indefinitely and renders its loading state. + useEffect(() => { + void hydrateMarfa() + }, [hydrateMarfa]) + // Once config is hydrated, ask main for data. Main decides what to // serve: // • configured + valid path → real recordings @@ -85,6 +128,7 @@ function App(): React.JSX.Element { data behind the welcome modal — no need for a loading curtain there too. */} {configValid && dataLoading && } + ) } diff --git a/src/renderer/src/components/settings/AboutCard.tsx b/src/renderer/src/components/settings/AboutCard.tsx new file mode 100644 index 0000000..7ee482e --- /dev/null +++ b/src/renderer/src/components/settings/AboutCard.tsx @@ -0,0 +1,121 @@ +import { cn } from '@renderer/lib/cn' +import { ExternalLink, Info, RefreshCw } from 'lucide-react' +import { useEffect, useState } from 'react' +import type { UpdaterStatus } from '../../../../preload/api' +import { CHROME_BUTTON } from './parts/chromeButton' +import { SettingsCard } from './SettingsCard' + +const GITHUB_URL = 'https://github.com/aicayzer/superwhisper-analytics' + +/** + * Settings → About. + * + * Version + license + source + updates. Reset moved out to its own + * App data card on the Developer tab — it's an action-on-data card, + * not an about-the-app card. + * + * The Updates row auto-checks on mount when status is idle, so the + * user never sees "Not checked yet" stale text. The Check now button + * stays as a manual override. + */ +export function AboutCard(): React.JSX.Element { + const [status, setStatus] = useState({ kind: 'idle' }) + + useEffect(() => { + let cancelled = false + void (async () => { + const initial = await window.api.updater.status() + if (cancelled) return + setStatus(initial) + // Auto-check on first mount when idle — better than showing + // stale "Not checked yet" text. + if (initial.kind === 'idle') { + const checked = await window.api.updater.check() + if (!cancelled) setStatus(checked) + } + })() + const off = window.api.updater.onStatus((next) => { + if (!cancelled) setStatus(next) + }) + return () => { + cancelled = true + off() + } + }, []) + + const checking = status.kind === 'checking' || status.kind === 'downloading' + + function check(): void { + void window.api.updater.check().then(setStatus) + } + + function openGithub(): void { + void window.api.openExternal(GITHUB_URL) + } + + return ( + +

+ A local companion for your SuperWhisper recordings — nothing leaves your machine. Personal + project, not affiliated with SuperWhisper. +

+
+ +
+
Source
+
+ +
+
+ +
+
Updates
+
+ {describeStatus(status) && ( + {describeStatus(status)} + )} + +
+
+
+
+ ) +} + +function describeStatus(s: UpdaterStatus): string { + switch (s.kind) { + case 'idle': + return '' + case 'checking': + return '' + case 'up-to-date': + return 'Up to date' + case 'available': + return `Update available (v${s.version})` + case 'downloading': + return `Downloading… ${s.percent}%` + case 'downloaded': + return `v${s.version} downloaded — restart to install` + case 'error': + return `Couldn't check (${s.message})` + } +} + +function Row({ k, v }: { k: string; v: string }): React.JSX.Element { + return ( +
+
{k}
+
{v}
+
+ ) +} diff --git a/src/renderer/src/components/settings/AppDataCard.tsx b/src/renderer/src/components/settings/AppDataCard.tsx new file mode 100644 index 0000000..9af9d26 --- /dev/null +++ b/src/renderer/src/components/settings/AppDataCard.tsx @@ -0,0 +1,59 @@ +import { useConfigStore } from '@renderer/state/configStore' +import { Database } from 'lucide-react' +import { useState } from 'react' +import { CHROME_BUTTON_WARN } from './parts/chromeButton' +import { SettingsCard } from './SettingsCard' + +/** + * Settings → Developer → App data. + * + * Sits below the Developer card on the Developer tab. Single row, + * single action: reset the app. Confirmation is a native + * `window.confirm` rather than a bespoke modal — same affordance as + * the rest of macOS, no design surface needed. + * + * Sibling to DeveloperCard's row shape: label + sublabel on the left, + * an action button on the right (where DeveloperCard puts a switch). + */ +export function AppDataCard(): React.JSX.Element { + const resetApp = useConfigStore((s) => s.resetApp) + const [resetting, setResetting] = useState(false) + + async function doReset(): Promise { + const ok = window.confirm( + 'Reset all settings? Your recordings folder, sync credentials, filler dictionary and UI preferences will be cleared. Recordings on disk are not affected.' + ) + if (!ok) return + setResetting(true) + try { + await resetApp() + } finally { + setResetting(false) + } + } + + return ( + +
+
+
Reset app configuration
+
+ Clears your folder and preferences. Recordings on disk are not affected. +
+
+ +
+
+ ) +} diff --git a/src/renderer/src/components/settings/AppearanceCard.tsx b/src/renderer/src/components/settings/AppearanceCard.tsx new file mode 100644 index 0000000..e4c437d --- /dev/null +++ b/src/renderer/src/components/settings/AppearanceCard.tsx @@ -0,0 +1,17 @@ +import { Sun } from 'lucide-react' +import { AppearancePicker } from './AppearancePicker' +import { SettingsCard } from './SettingsCard' + +/** Wrapper around the existing `AppearancePicker`. Promoted out of + * `screens/Settings.tsx` so the new orchestrator stays thin. */ +export function AppearanceCard(): React.JSX.Element { + return ( + + + + ) +} diff --git a/src/renderer/src/components/settings/AppearancePicker.tsx b/src/renderer/src/components/settings/AppearancePicker.tsx index e059eda..076238c 100644 --- a/src/renderer/src/components/settings/AppearancePicker.tsx +++ b/src/renderer/src/components/settings/AppearancePicker.tsx @@ -2,20 +2,27 @@ import { cn } from '@renderer/lib/cn' import { useThemeStore, type ThemePref } from '@renderer/state/themeStore' /** - * Three large preview cards (Light / System / Dark) — each a tiny mock - * of the app showing the kind of contrast the theme produces. The active - * card gets a blue ring to mirror the design. + * Three preview tiles (System / Light / Dark — in that order). * - * Previews are hand-drawn SVGs rather than real screenshots — fewer - * assets to maintain, and they read clearly at the preview size. + * Each tile shows a faux window peeking up from below — the window's + * bottom edge is clipped by the tile boundary so the eye reads "this is + * what the top of the app looks like in this mode". Sidebar tint on + * the left, content panel inset to the right; "System" uses a single + * sidebar (light) with the content panel split light/dark down the + * middle so it doesn't show two sidebars wedged side by side. + * + * Every tile sits on the same subtle grey wash as the path bar — gives + * the row a consistent base. Selected state uses a soft dark-grey + * border + matching ring rather than the previous accent-blue or pure + * black, so the highlight reads as a tint, not an alert. */ export function AppearancePicker(): React.JSX.Element { const pref = useThemeStore((s) => s.pref) const setPref = useThemeStore((s) => s.setPref) return (
- +
) @@ -40,114 +47,131 @@ function PreviewTile({ pref, active, onSelect }: PreviewTileProps): React.JSX.El onClick={() => onSelect(pref)} aria-pressed={active} className={cn( - 'flex flex-col items-stretch overflow-hidden rounded-lg border bg-card text-left transition-all', + 'flex flex-col items-stretch overflow-hidden rounded-lg border bg-foreground/[0.025] text-left transition-all', 'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40', active - ? 'border-accent-blue ring-1 ring-accent-blue' + ? 'border-foreground/40 ring-1 ring-foreground/40' : 'border-border hover:border-foreground/20' )} > -
- +
+ +
+
+ {LABELS[pref]}
-
{LABELS[pref]}
) } +// Sidebar holds ~17% of the window width — half of the previous 34% +// inset. Content panel takes the remaining 83%. +const SIDEBAR_INSET = '17%' + +const LIGHT = { + outer: '#fafaf9', + sidebar: '#ededeb', + // Content "white" softened from #ffffff towards the sidebar grey so + // it doesn't punch against the muted tile background. + content: '#f6f6f4', + border: 'rgba(0,0,0,0.10)', + strong: '#1f1f1f', + muted: '#bdbcb6' +} + +const DARK = { + outer: '#1c1c1c', + sidebar: '#2a2a2a', + // Similarly toned: closer to the sidebar tint than the previous + // #222222 so the panels read as one window rather than two slabs. + content: '#1f1f1f', + border: 'rgba(255,255,255,0.08)', + strong: '#fafafa', + muted: '#5d5d5b' +} + +function WindowMock({ pref }: { pref: ThemePref }): React.JSX.Element { + if (pref === 'system') return + const palette = pref === 'dark' ? DARK : LIGHT + return ( +
+ +
+ ) +} + /** - * Tiny app-screenshot mockup. Three rectangles — sidebar, header, content - * — tinted to match the theme being previewed. Half-and-half split for - * "System" so it visibly suggests "follows OS". + * System tile: ONE window outline, ONE sidebar (light, on the left), + * content panel inset to the right and split half-light / half-dark. */ -function PreviewMock({ pref }: { pref: ThemePref }): React.JSX.Element { - // Token swatches per theme — picked to match what the app actually looks - // like rather than just light/dark slabs. - const palettes = { - light: { - bg: '#fafaf9', - sidebar: '#f3f3f1', - lineStrong: '#1f1f1f', - lineMuted: '#bdbcb6' - }, - dark: { - bg: '#1c1c1c', - sidebar: '#2a2a2a', - lineStrong: '#fafafa', - lineMuted: '#5d5d5b' - } - } as const - if (pref === 'system') { - return ( - - - - - ) - } - const palette = palettes[pref] +function SystemMock(): React.JSX.Element { return ( - - - +
+
+
+
+ +
+
+ +
+
+
+
) } -function PreviewBody({ - palette, - side = 'full' -}: { - palette: { bg: string; sidebar: string; lineStrong: string; lineMuted: string } - side?: 'left' | 'right' | 'full' -}): React.JSX.Element { - const x = side === 'right' ? 50 : 0 - const w = side === 'full' ? 100 : 50 - // Clip the right half by drawing at x=50 with width 50. For 'left', clip - // ends naturally at x=50 because we draw a 50-wide rect. +function ContentPanel({ palette }: { palette: typeof LIGHT }): React.JSX.Element { return ( - - - {/* Sidebar slab */} - +
- {/* Faux content rows — short title + two muted lines */} - - +
+ + ) +} + +function Lines({ palette }: { palette: typeof LIGHT }): React.JSX.Element { + return ( + <> +
- - - + ) } diff --git a/src/renderer/src/components/settings/ConnectionCard.tsx b/src/renderer/src/components/settings/ConnectionCard.tsx new file mode 100644 index 0000000..7598ad8 --- /dev/null +++ b/src/renderer/src/components/settings/ConnectionCard.tsx @@ -0,0 +1,591 @@ +import { useMarfaStore } from '@renderer/state/marfaStore' +import { useDataStore } from '@renderer/state/dataStore' +import { relativeTime } from '@renderer/lib/format' +import { toastError, toastSuccess } from '@renderer/lib/toast' +import { ChevronDown, Cloud, ExternalLink, UploadCloud } from 'lucide-react' +import { useEffect, useState } from 'react' +import type { ProbeResult, MarfaStatus } from '../../../../preload/api' +import { CHROME_BUTTON, CHROME_BUTTON_PRIMARY } from './parts/chromeButton' +import { SettingsCard } from './SettingsCard' +import { StatusPill, type StatusTone } from './parts/StatusPill' +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger +} from '@renderer/components/ui/dropdown-menu' + +/** + * Sync → Connection. + * + * disconnected (fresh / error) → Endpoint row + "Sign in" primary + * connecting (device) → User code + "Open verification page" + * connected (never synced) → Identity + "Ready to sync" hero + + * SplitButton(Start sync) + * connected (idle) → Identity + last-synced caption + + * SplitButton(Sync now) + * connected (failed) → Identity + small failure caption + + * SplitButton(Retry) + * syncing → Identity + progress + Cancel + * + * The action chrome is a single `SyncSplitButton` component — outlined + * (not the dark "primary" face), face + thin pipe + chevron-down + * trigger in one unit. Same shape across idle / first-sync / failed, + * different labels — so the failed-state retry doesn't visually fight + * the steady-state "Sync now". + * + * StatusPill in the header reflects the *connection* state only. + * `lastError` from a failed sync surfaces inside the body as a muted + * caption; the pill stays "Connected". Wipe-all-data lives on the + * Developer tab where the rest of the testing affordances sit. + */ +export function ConnectionCard(): React.JSX.Element { + const status = useMarfaStore((s) => s.status) + const probeConnection = useMarfaStore((s) => s.probeConnection) + const disconnect = useMarfaStore((s) => s.disconnect) + const connect = useMarfaStore((s) => s.connect) + const cancelConnect = useMarfaStore((s) => s.cancelConnect) + const syncNow = useMarfaStore((s) => s.syncNow) + const testSync = useMarfaStore((s) => s.testSync) + const cancelSync = useMarfaStore((s) => s.cancelSync) + const recordingCount = useDataStore((s) => s.recordings.length) + + const [probe, setProbe] = useState(null) + const [probing, setProbing] = useState(false) + const [busyAction, setBusyAction] = useState<'connect' | 'disconnect' | 'sync' | 'cancel' | null>( + null + ) + const [, setNow] = useState(() => Date.now()) + + // Tick once a minute so "Last synced 5m ago" drifts. + useEffect(() => { + const t = window.setInterval(() => setNow(Date.now()), 60_000) + return () => window.clearInterval(t) + }, []) + + // Background probe runs on every transition into connected/syncing. + // Result is used to display the account row — explicit Test connection + // is a separate flow that surfaces via toast. + useEffect(() => { + if (status?.kind !== 'connected' && status?.kind !== 'syncing') { + const t = window.setTimeout(() => setProbe(null), 0) + return () => window.clearTimeout(t) + } + let cancelled = false + void (async () => { + setProbing(true) + try { + const result = await probeConnection() + if (!cancelled) setProbe(result) + } finally { + if (!cancelled) setProbing(false) + } + })() + return () => { + cancelled = true + } + }, [status?.kind, probeConnection]) + + async function run( + action: 'connect' | 'disconnect' | 'sync' | 'test-sync' | 'cancel' + ): Promise { + // `test-sync` shares the sync busy lane engine-side. + setBusyAction(action === 'test-sync' ? 'sync' : action) + try { + if (action === 'connect') await connect() + else if (action === 'disconnect') await disconnect() + else if (action === 'sync') await syncNow() + else if (action === 'test-sync') await testSync() + else if (action === 'cancel') { + if (status?.kind === 'connecting') await cancelConnect() + else if (status?.kind === 'syncing') await cancelSync() + } + } finally { + setBusyAction(null) + } + } + + async function onTestConnection(): Promise { + setProbing(true) + try { + const result = await probeConnection() + setProbe(result) + if (result.ok) { + toastSuccess({ message: 'Connection OK.' }) + } else { + toastError({ message: `Connection failed: ${result.error}` }) + } + } finally { + setProbing(false) + } + } + + const { tone, label, title } = computePill(status, probe, probing) + + return ( + } + > + {renderBody({ + status, + probe, + recordingCount, + busyAction, + onConnect: () => void run('connect'), + onCancel: () => void run('cancel'), + onStartSync: () => void run('sync'), + onTestSync: () => void run('test-sync'), + onSyncNow: () => void run('sync'), + onRetry: () => void run('sync'), + onDisconnect: () => void run('disconnect'), + onTestConnection: () => void onTestConnection() + })} + + ) +} + +// ────────────────────────────────────────────────────────────────────── +// Body switcher +// ────────────────────────────────────────────────────────────────────── + +interface BodyProps { + status: MarfaStatus | null + probe: ProbeResult | null + recordingCount: number + busyAction: 'connect' | 'disconnect' | 'sync' | 'cancel' | null + onConnect: () => void + onCancel: () => void + onStartSync: () => void + onTestSync: () => void + onSyncNow: () => void + onRetry: () => void + onDisconnect: () => void + onTestConnection: () => void +} + +function renderBody(p: BodyProps): React.JSX.Element { + if (!p.status) return

Loading…

+ if (p.status.kind === 'disconnected') return + if (p.status.kind === 'connecting') return + if (p.status.kind === 'syncing') return + return +} + +// ────────────────────────────────────────────────────────────────────── +// Disconnected +// ────────────────────────────────────────────────────────────────────── + +function DisconnectedBody({ + status, + busyAction, + onConnect +}: BodyProps & { status: Extract }): React.JSX.Element { + return ( +
+ + {status.lastError &&

{status.lastError}

} +
+ +
+
+ ) +} + +// ────────────────────────────────────────────────────────────────────── +// Connecting (device flow) +// ────────────────────────────────────────────────────────────────────── + +function ConnectingBody({ + status, + busyAction, + onCancel +}: BodyProps & { status: Extract }): React.JSX.Element { + if (status.mode === 'api-key') { + return ( +
+

+ Paste your Marfa API key to finish connecting. +

+
+ +
+
+ ) + } + + if (!status.userCode) { + return ( +
+

Preparing sign-in…

+
+ +
+
+ ) + } + + const openUrl = status.verificationUriComplete || status.verificationUri + return ( +
+
+

Your code

+

+ {status.userCode} +

+

+ Open the verification page and the code will be filled in for you. +

+
+
+ + +
+
+ ) +} + +// ────────────────────────────────────────────────────────────────────── +// Connected — three sub-states share an identity-block + caption + +// SplitButton shape. Caption text varies; button label varies; chrome +// is constant. +// ────────────────────────────────────────────────────────────────────── + +function ConnectedBody({ + status, + probe, + recordingCount, + busyAction, + onStartSync, + onTestSync, + onSyncNow, + onRetry, + onDisconnect, + onTestConnection +}: BodyProps & { status: Extract }): React.JSX.Element { + const neverSynced = status.lastSyncedAt === null && !status.lastError + const failed = !!status.lastError + + // Single-source the menu — same items regardless of sub-state, so + // the dropdown doesn't grow / shrink between renders. + const menuItems = ( + <> + + Test sync (5 recordings) + + Test connection + + + Disconnect + + + ) + + const cancelled = failed && status.lastSyncCancelled + + // Primary label + busy label for each sub-state. Cancelled gets + // "Resume" — the engine persists partial progress, so the next sync + // picks up where it left off rather than starting over. + let primaryLabel = 'Sync now' + let busyLabel = 'Syncing…' + let onPrimary = onSyncNow + if (neverSynced) { + primaryLabel = 'Start sync' + busyLabel = 'Starting…' + onPrimary = onStartSync + } else if (cancelled) { + primaryLabel = 'Resume' + busyLabel = 'Resuming…' + onPrimary = onRetry + } else if (failed) { + primaryLabel = 'Retry' + busyLabel = 'Retrying…' + onPrimary = onRetry + } + + return ( +
+ + + {neverSynced ? ( + // First-sync hero — slight wash, count of what's about to push, + // SplitButton with "Start sync" label. +
+

Ready to sync

+

+ {recordingCount.toLocaleString()} recording + {recordingCount === 1 ? '' : 's'} will be pushed to Marfa on the first sync. +

+
+ +
+
+ ) : ( +
+

+ {buildCaption({ + recordingCount, + syncedRecordings: status.syncedRecordings, + lastSyncedAt: status.lastSyncedAt, + lastError: status.lastError, + cancelled + })} +

+ +
+ )} +
+ ) +} + +/** + * Build the one-line muted caption beneath the identity rows. + * + * Three shapes, all anchored on the same "X of Y" synced count so the + * user can see at a glance how far the engine has got: + * + * idle — "X of Y recordings synced · Z ago" + * cancelled — "Sync cancelled · X of Y recordings synced" + * failed — "Sync failed: · X of Y recordings synced" + * + * "X of Y" uses local total (`recordingCount`) as Y. Mode-filter + * exclusions can drive X < Y even after a successful run; the user + * reads that as "not everything is pushing" and can check the Modes + * section if they care. + */ +function buildCaption({ + recordingCount, + syncedRecordings, + lastSyncedAt, + lastError, + cancelled +}: { + recordingCount: number + syncedRecordings: number + lastSyncedAt: string | null + lastError: string | null + cancelled: boolean +}): string { + const synced = `${syncedRecordings.toLocaleString()} of ${recordingCount.toLocaleString()} recording${recordingCount === 1 ? '' : 's'} synced` + if (cancelled) return `Sync cancelled · ${synced}` + if (lastError) return `Sync failed: ${lastError} · ${synced}` + const when = lastSyncedAt ? `${relativeTime(lastSyncedAt)}` : 'just now' + return `${synced} · ${when}` +} + +// ────────────────────────────────────────────────────────────────────── +// Syncing — live progress +// ────────────────────────────────────────────────────────────────────── + +function SyncingBody({ + status, + probe, + busyAction, + onCancel +}: BodyProps & { status: Extract }): React.JSX.Element { + const pct = status.total > 0 ? Math.round((status.processed / status.total) * 100) : 0 + return ( +
+ +
+
+

{describePhase(status.phase)}

+

+ {status.processed.toLocaleString()} / {status.total.toLocaleString()} +

+
+
+
+
+
+
+ +
+
+ ) +} + +// ────────────────────────────────────────────────────────────────────── +// SyncSplitButton — one shape, three labels +// ────────────────────────────────────────────────────────────────────── + +/** + * Outlined split-button used across the connected sub-states. The + * face triggers the primary action; the chevron cell opens the menu. + * Sized to match `CHROME_BUTTON` so the chrome sits flush with the + * rest of the app. + * + * `min-w-[5.25rem]` on the face keeps the divider from jiggling + * between "Sync now" and "Syncing…". + */ +function SyncSplitButton({ + label, + busyLabel, + busy, + onClick, + menuItems +}: { + label: string + busyLabel: string + busy: boolean + onClick: () => void + menuItems: React.ReactNode +}): React.JSX.Element { + // Visual rhythm matches `CHROME_BUTTON` — same border, radius, bg + // and hover treatment as the Check-now button on About. No shadow, + // no font-medium, no white-on-dark face. The chevron cell shares the + // outer chrome and is separated by a single-px border-coloured pipe. + return ( +
+ +
+ + + + + + {menuItems} + + +
+ ) +} + +// ────────────────────────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────────────────────────── + +function computePill( + status: MarfaStatus | null, + probe: ProbeResult | null, + probing: boolean +): { tone: StatusTone; label: string; title?: string } { + if (!status) return { tone: 'neutral', label: 'Loading…' } + if (status.kind === 'connecting') return { tone: 'busy', label: 'Connecting…' } + if (status.kind === 'disconnected') { + return status.lastError + ? { tone: 'neutral', label: 'Not connected', title: status.lastError } + : { tone: 'neutral', label: 'Not connected' } + } + if (status.kind === 'syncing') return { tone: 'busy', label: 'Syncing…' } + // Connected — the pill reflects the *connection* only. Sync errors + // don't flip it; that detail lives in the body caption. + if (probing && !probe) return { tone: 'busy', label: 'Checking…' } + if (probe && !probe.ok) return { tone: 'error', label: probe.error } + return { tone: 'ok', label: 'Connected' } +} + +function IdentityBlock({ + probe, + endpoint +}: { + probe: ProbeResult | null + endpoint: string +}): React.JSX.Element { + return ( +
+ + +
+ ) +} + +function describeAccount(probe: ProbeResult | null): string { + if (!probe?.ok) return '—' + const { displayName, email } = probe + if (displayName && email) return `${displayName} (${email})` + if (displayName) return displayName + if (email) return email + return 'Signed in' +} + +function describePhase(phase: string): string { + switch (phase) { + case 'preparing': + return 'Preparing' + case 'recordings': + return 'Syncing recordings' + case 'sessions': + return 'Syncing sessions' + default: + return 'Syncing' + } +} + +function Row({ label, value }: { label: string; value: string }): React.JSX.Element { + return ( +
+
{label}
+
+ {value} +
+
+ ) +} diff --git a/src/renderer/src/components/settings/DestinationPickerSheet.tsx b/src/renderer/src/components/settings/DestinationPickerSheet.tsx new file mode 100644 index 0000000..e58046a --- /dev/null +++ b/src/renderer/src/components/settings/DestinationPickerSheet.tsx @@ -0,0 +1,150 @@ +import { + Dialog, + DialogBody, + DialogContent, + DialogHeader, + DialogTitle +} from '@renderer/components/ui/dialog' +import { useMarfaStore } from '@renderer/state/marfaStore' +import { useEffect, useMemo, useState } from 'react' +import type { MappingBinding, SourceKind, TypeSummary } from '../../../../preload/api' +import { PickTile } from './parts/PickTile' + +interface DestinationPickerSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + kind: SourceKind + binding: MappingBinding + onPick: (next: MappingBinding) => void +} + +/** + * Sheet for picking the destination Marfa type. + * + * • Default — the bundled `superwhisper.recording` / `.session` type. + * Single tile. Sensible starting point; what most users use. + * • Your custom types — pulled from the user's tenant via + * `listServerTypes`. Search input above the grid; tiles for each + * custom type. Hidden when the user has no custom types. + * + * Picking commits + closes. Plain sans-serif throughout — no mono on + * the type IDs. Monochrome selection (dark grey border + faint tint). + */ +export function DestinationPickerSheet({ + open, + onOpenChange, + kind, + binding, + onPick +}: DestinationPickerSheetProps): React.JSX.Element { + const typeList = useMarfaStore((s) => s.typeList) + const typeListLoading = useMarfaStore((s) => s.typeListLoading) + const refreshTypeList = useMarfaStore((s) => s.refreshTypeList) + const [query, setQuery] = useState('') + + // Refresh the type list when the sheet opens so the user sees the + // latest tenant state. Idempotent — the store no-ops if already loading. + useEffect(() => { + if (open) void refreshTypeList() + }, [open, refreshTypeList]) + + const defaultTypeId = kind === 'recording' ? 'superwhisper.recording' : 'superwhisper.session' + + const tenantTypes = useMemo(() => { + if (!typeList) return [] + // Filter out the bundled types — those land under "Default". + return typeList.filter( + (t) => t.id !== 'superwhisper.recording' && t.id !== 'superwhisper.session' + ) + }, [typeList]) + + const filtered = useMemo(() => { + if (!query.trim()) return tenantTypes + const q = query.toLowerCase() + return tenantTypes.filter( + (t) => t.id.toLowerCase().includes(q) || (t.label?.toLowerCase().includes(q) ?? false) + ) + }, [tenantTypes, query]) + + function pickDefault(): void { + onPick({ ...binding, mode: 'bundled', typeId: defaultTypeId }) + } + + function pickTenant(type: TypeSummary): void { + onPick({ ...binding, mode: 'existing', typeId: type.id }) + } + + const showCustomGroup = tenantTypes.length > 0 || typeListLoading + + return ( + + + + Pick a destination +

+ Where in Marfa this pipeline writes. Default works for everyone — pick a custom type only + if you’ve authored one in your tenant. +

+
+ +
+ Default + +
+ + {showCustomGroup && ( +
+
+ Your custom types + + {typeListLoading ? 'Loading…' : `${tenantTypes.length} available`} + +
+ setQuery(e.target.value)} + placeholder="Search your types…" + className="h-7 w-full rounded-md border border-border bg-card px-2.5 text-[12.5px] text-foreground outline-none placeholder:text-muted-foreground focus:border-foreground/40" + /> + {filtered.length === 0 ? ( +

+ {query ? `No types match "${query}".` : 'No types in your tenant yet.'} +

+ ) : ( +
+ {filtered.map((t) => ( + pickTenant(t)} + /> + ))} +
+ )} +
+ )} +
+
+
+ ) +} + +function SectionLabel({ children }: { children: React.ReactNode }): React.JSX.Element { + return ( +

+ {children} +

+ ) +} diff --git a/src/renderer/src/components/settings/DeveloperCard.tsx b/src/renderer/src/components/settings/DeveloperCard.tsx new file mode 100644 index 0000000..f561e97 --- /dev/null +++ b/src/renderer/src/components/settings/DeveloperCard.tsx @@ -0,0 +1,110 @@ +import { Switch } from '@renderer/components/ui/Switch' +import { toastError, toastSuccess } from '@renderer/lib/toast' +import { useConfigStore } from '@renderer/state/configStore' +import { useMarfaStore } from '@renderer/state/marfaStore' +import { Code2 } from 'lucide-react' +import { useState } from 'react' +import { CHROME_BUTTON_WARN } from './parts/chromeButton' +import { SettingsCard } from './SettingsCard' + +/** + * Settings → Developer. + * + * Both developer-y affordances live in one card: + * + * - Demo data — swap real recordings for a synthetic dataset + * - DevTools — open the Chromium DevTools panel + * + * Rows share the same shape (label + sublabel on the left, switch on + * the right) and divide-by-border the way every other multi-row + * settings card does. + */ +export function DeveloperCard(): React.JSX.Element { + const demoMode = useConfigStore((s) => s.demoMode) + const setDemoMode = useConfigStore((s) => s.setDemoMode) + const devTools = useConfigStore((s) => s.devTools) + const setDevTools = useConfigStore((s) => s.setDevTools) + const status = useMarfaStore((s) => s.status) + const purgeAllData = useMarfaStore((s) => s.purgeAllData) + const [purging, setPurging] = useState(false) + + async function onPurge(): Promise { + const ok = window.confirm( + 'Wipe all SuperWhisper recordings and sessions from the connected Marfa tenant? Your local files are not affected.' + ) + if (!ok) return + setPurging(true) + try { + const res = await purgeAllData() + if (res.ok) { + toastSuccess({ + message: `Purged ${res.recordings.toLocaleString()} recordings and ${res.sessions.toLocaleString()} sessions.` + }) + } else { + toastError({ message: 'Purge failed', copyText: res.error }) + } + } finally { + setPurging(false) + } + } + + const canPurge = status?.kind === 'connected' && !purging + + return ( + +
+ void setDemoMode(next)} + /> + void setDevTools(next)} + /> +
+
+
Purge Marfa data
+
+ Wipes all SuperWhisper recordings and sessions from the connected Marfa tenant and + clears local sync state. Your local files are not affected. +
+
+ +
+
+
+ ) +} + +function Row({ + label, + sublabel, + checked, + onChange +}: { + label: string + sublabel: string + checked: boolean + onChange: (next: boolean) => void +}): React.JSX.Element { + return ( +
+
+
{label}
+
{sublabel}
+
+ +
+ ) +} diff --git a/src/renderer/src/components/settings/FieldSourcePickerSheet.tsx b/src/renderer/src/components/settings/FieldSourcePickerSheet.tsx new file mode 100644 index 0000000..96424ef --- /dev/null +++ b/src/renderer/src/components/settings/FieldSourcePickerSheet.tsx @@ -0,0 +1,114 @@ +import { + Dialog, + DialogBody, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle +} from '@renderer/components/ui/dialog' +import { useMemo } from 'react' +import { listRecordingSourceLabels, listSessionSourceLabels } from '@shared/marfa-labels' +import type { + MappingBinding, + RecordingSourceField, + SessionSourceField, + SourceKind +} from '../../../../preload/api' +import { CHROME_BUTTON_WARN } from './parts/chromeButton' +import { PickTile } from './parts/PickTile' + +interface FieldSourcePickerSheetProps { + open: boolean + onOpenChange: (open: boolean) => void + kind: SourceKind + /** `null` when the sheet is closed. */ + targetField: string | null + binding: MappingBinding + onChange: (next: MappingBinding) => void + onRemove: () => void +} + +/** + * Sheet for picking the source ref for a target field. Each source is + * rendered as a compact tile in a two-column grid: plain-English label + * + the canonical ref underneath as small muted text. No preview value, + * no type tag, no radio icon — selection is conveyed by the tile's + * border + background. + * + * "Remove this field" lives in the footer on the right (the action), + * with a small muted caption on the left explaining what it does. Reads + * left-to-right: what happens → button. + */ +export function FieldSourcePickerSheet({ + open, + onOpenChange, + kind, + targetField, + binding, + onChange, + onRemove +}: FieldSourcePickerSheetProps): React.JSX.Element { + const sourceOptions = useMemo( + () => (kind === 'recording' ? listRecordingSourceLabels() : listSessionSourceLabels()), + [kind] + ) + + const currentRef = targetField ? binding.fieldMap[targetField] : undefined + const currentField = currentRef?.kind === 'source' ? currentRef.field : null + + function pick(field: string): void { + if (!targetField) return + // The source list is hand-mirrored from the canonical + // `RecordingSourceField` / `SessionSourceField` union, so the cast + // at the boundary is safe by construction. + onChange({ + ...binding, + fieldMap: { + ...binding.fieldMap, + [targetField]: { + kind: 'source', + field: field as RecordingSourceField | SessionSourceField + } + } + }) + } + + return ( + + + + + + Source for + {targetField} + + +

+ Where this field’s value comes from on each recording. +

+
+ +
+ {sourceOptions.map(({ field, label }) => ( + pick(field)} + /> + ))} +
+
+ + + Drops it from the mapping on the next sync. + + + +
+
+ ) +} diff --git a/src/renderer/src/components/settings/FillerDictionaryCard.tsx b/src/renderer/src/components/settings/FillerDictionaryCard.tsx new file mode 100644 index 0000000..c281627 --- /dev/null +++ b/src/renderer/src/components/settings/FillerDictionaryCard.tsx @@ -0,0 +1,362 @@ +import { + DEFAULT_FILLER_CATEGORIES, + normalisePhrases, + type FillerCategory +} from '@shared/text-metrics' +import { DEFAULT_FILLER_PHRASES_FROM_CATEGORIES } from '@shared/filler-categories' +import { Switch } from '@renderer/components/ui/Switch' +import { useConfigStore } from '@renderer/state/configStore' +import { cn } from '@renderer/lib/cn' +import { BookOpen, ChevronDown, Plus, X } from 'lucide-react' +import { useMemo, useState } from 'react' +import { SettingsCard } from './SettingsCard' + +const CUSTOM_ID = '__custom__' + +/** + * Analysis → Filler dictionary. + * + * Categories live inside a bordered inner container — box-within-a-box, + * so the row dividers don't bleed to the card edge. Each row shows: + * + * • Chevron to expand / collapse. + * • Plain title. + * • When closed, an inline preview "um · uh · er · hmm · +28 more" + * (replaces the separate count pill). + * • A switch on the right that bulk-toggles every phrase in the + * category. + * + * Opening a category swaps the preview line out for a chip cloud below. + * Each chip is `rounded-full` with an × to drop the phrase; a dashed + * `+ add` chip at the end opens an inline input — Enter saves, Escape + * or blur-without-text cancels. + * + * `Custom` is rendered as a regular toggleable row (only when there + * are phrases the default categories don't cover). Reset link sits + * outside the box, bottom-right. + */ +export function FillerDictionaryCard(): React.JSX.Element { + const fillerWords = useConfigStore((s) => s.fillerWords) + const setFillerWords = useConfigStore((s) => s.setFillerWords) + const [openId, setOpenId] = useState(null) + const [addingId, setAddingId] = useState(null) + const [draft, setDraft] = useState('') + const [busy, setBusy] = useState(false) + + const active = useMemo(() => new Set(fillerWords), [fillerWords]) + + // Phrases in `fillerWords` that aren't in any default category. + const customPhrases = useMemo(() => { + const known = new Set(DEFAULT_FILLER_PHRASES_FROM_CATEGORIES) + return fillerWords.filter((w) => !known.has(w)) + }, [fillerWords]) + + const isDefault = + fillerWords.length === DEFAULT_FILLER_PHRASES_FROM_CATEGORIES.length && + fillerWords.every((w, i) => w === DEFAULT_FILLER_PHRASES_FROM_CATEGORIES[i]) + + async function commit(next: string[]): Promise { + setBusy(true) + try { + await setFillerWords(next) + } finally { + setBusy(false) + } + } + + function categoryActive(cat: FillerCategory): boolean { + return cat.phrases.some((p) => active.has(p)) + } + + async function toggleCategory(cat: FillerCategory, on: boolean): Promise { + const inCat = new Set(cat.phrases) + if (on) { + const next = [...fillerWords] + for (const p of cat.phrases) if (!active.has(p)) next.push(p) + await commit(normalisePhrases(next)) + } else { + await commit(fillerWords.filter((w) => !inCat.has(w))) + } + } + + async function toggleCustom(on: boolean): Promise { + if (on) return // can't bulk-enable without phrases + await commit(fillerWords.filter((w) => !customPhrases.includes(w))) + } + + async function removePhrase(phrase: string): Promise { + await commit(fillerWords.filter((w) => w !== phrase)) + } + + async function commitDraft(): Promise { + const merged = normalisePhrases([...fillerWords, draft]) + setDraft('') + setAddingId(null) + if (merged.length === fillerWords.length) return + await commit(merged) + } + + function cancelDraft(): void { + setDraft('') + setAddingId(null) + } + + async function resetToDefaults(): Promise { + await commit([...DEFAULT_FILLER_PHRASES_FROM_CATEGORIES]) + } + + return ( + +
+
+
+ {DEFAULT_FILLER_CATEGORIES.map((cat) => ( + void toggleCategory(cat, on)} + onToggleOpen={() => setOpenId(openId === cat.id ? null : cat.id)} + onRemovePhrase={(p) => void removePhrase(p)} + onStartAdd={() => { + setAddingId(cat.id) + setDraft('') + }} + onCancelAdd={cancelDraft} + onChangeDraft={setDraft} + onCommitAdd={() => void commitDraft()} + /> + ))} + {customPhrases.length > 0 && ( + void toggleCustom(on)} + onToggleOpen={() => setOpenId(openId === CUSTOM_ID ? null : CUSTOM_ID)} + onRemovePhrase={(p) => void removePhrase(p)} + onStartAdd={() => { + setAddingId(CUSTOM_ID) + setDraft('') + }} + onCancelAdd={cancelDraft} + onChangeDraft={setDraft} + onCommitAdd={() => void commitDraft()} + /> + )} +
+
+
+ +
+
+
+ ) +} + +interface CategoryRowProps { + title: string + phrases: readonly string[] + activeSet: Set + isActive: boolean + isOpen: boolean + isAdding: boolean + draft: string + busy: boolean + onToggleCategory: (on: boolean) => void + onToggleOpen: () => void + onRemovePhrase: (phrase: string) => void + onStartAdd: () => void + onCancelAdd: () => void + onChangeDraft: (next: string) => void + onCommitAdd: () => void +} + +function CategoryRow({ + title, + phrases, + activeSet, + isActive, + isOpen, + isAdding, + draft, + busy, + onToggleCategory, + onToggleOpen, + onRemovePhrase, + onStartAdd, + onCancelAdd, + onChangeDraft, + onCommitAdd +}: CategoryRowProps): React.JSX.Element { + const visiblePhrases = phrases.filter((p) => activeSet.has(p)) + const PREVIEW_COUNT = 4 + const previewHead = visiblePhrases.slice(0, PREVIEW_COUNT).join(' · ') + const extra = Math.max(0, visiblePhrases.length - PREVIEW_COUNT) + const previewLine = + visiblePhrases.length === 0 + ? '(all removed)' + : extra > 0 + ? `${previewHead} · +${extra} more` + : previewHead + + return ( +
+ {/* Title row — fixed-height so the switch stays vertically + centered against the title regardless of whether the row + below renders a preview, a chip cloud, or nothing. */} +
+ + +
+ {/* Preview / chip cloud — indented to align with the title text + (chevron width + gap), not the chevron itself. */} + {!isOpen && ( +
+ {previewLine} +
+ )} + {isOpen && ( +
+ {visiblePhrases.length === 0 ? ( + + No phrases active. Toggle the switch above to add the defaults back. + + ) : ( + visiblePhrases.map((phrase) => ( + onRemovePhrase(phrase)} + disabled={busy} + /> + )) + )} + {isAdding ? ( + + ) : ( + + )} +
+ )} +
+ ) +} + +function Chip({ + label, + onRemove, + disabled +}: { + label: string + onRemove: () => void + disabled?: boolean +}): React.JSX.Element { + return ( + + {label} + + + ) +} + +function AddInput({ + draft, + onChange, + onCommit, + onCancel +}: { + draft: string + onChange: (next: string) => void + onCommit: () => void + onCancel: () => void +}): React.JSX.Element { + // Plain input with explicit key handling. Avoids `
` — a form + // here was being swallowed somewhere and Enter wasn't reaching + // onSubmit. onKeyDown is reliable. + return ( + onChange(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault() + onCommit() + } else if (e.key === 'Escape') { + e.preventDefault() + onCancel() + } + }} + onBlur={() => { + if (!draft.trim()) onCancel() + }} + autoFocus + placeholder="new phrase" + className="h-[26px] w-32 rounded-full border border-border bg-card px-3 text-[12px] text-foreground outline-none placeholder:text-muted-foreground focus:border-foreground/40" + /> + ) +} diff --git a/src/renderer/src/components/settings/IndexingCard.tsx b/src/renderer/src/components/settings/IndexingCard.tsx new file mode 100644 index 0000000..b6e0521 --- /dev/null +++ b/src/renderer/src/components/settings/IndexingCard.tsx @@ -0,0 +1,57 @@ +import { Switch } from '@renderer/components/ui/Switch' +import { useConfigStore } from '@renderer/state/configStore' +import { Settings as SettingsIcon } from 'lucide-react' +import { SettingsCard } from './SettingsCard' + +/** Settings → Analysis → Indexing. Two toggles that govern how the + * local cache stays fresh against the SuperWhisper recordings folder. + * The session-gap control used to live here too — it's been moved + * inside the Sessions PipelineCard since that's literally where the + * value is consumed at sync time. */ +export function IndexingCard(): React.JSX.Element { + const watchFolder = useConfigStore((s) => s.watchFolder) + const setWatchFolder = useConfigStore((s) => s.setWatchFolder) + const transcriptsOnly = useConfigStore((s) => s.transcriptsOnly) + const setTranscriptsOnly = useConfigStore((s) => s.setTranscriptsOnly) + return ( + +
+ void setWatchFolder(next)} + /> + void setTranscriptsOnly(next)} + /> +
+
+ ) +} + +interface ToggleRowProps { + label: string + description: string + checked: boolean + onChange: (next: boolean) => void +} + +function ToggleRow({ label, description, checked, onChange }: ToggleRowProps): React.JSX.Element { + return ( +
+
+
{label}
+
{description}
+
+ +
+ ) +} diff --git a/src/renderer/src/components/settings/MappingRoot.tsx b/src/renderer/src/components/settings/MappingRoot.tsx new file mode 100644 index 0000000..d3720b1 --- /dev/null +++ b/src/renderer/src/components/settings/MappingRoot.tsx @@ -0,0 +1,153 @@ +import { cn } from '@renderer/lib/cn' +import { ChevronRight } from 'lucide-react' +import { useState } from 'react' +import { getSourceFieldLabel } from '@shared/marfa-labels' +import type { MappingBinding, SourceKind } from '../../../../preload/api' +import { DestinationPickerSheet } from './DestinationPickerSheet' +import { FieldSourcePickerSheet } from './FieldSourcePickerSheet' +import { Group } from './parts/Group' + +interface MappingRootProps { + kind: SourceKind + binding: MappingBinding + onChange: (next: MappingBinding) => void +} + +/** + * Inline mapping editor inside the PipelineCard body. Two groups: + * • Goes to — destination row showing the bound type and field count. + * • Fields — one row per mapped field, click opens the field-source + * picker. + * + * Both groups use plain sans-serif throughout — no mono. The type id + * and field names are identifiers but display as regular text so the + * card doesn't read like a config file. + * + * Removal lives inside the field-source picker (footer right) rather + * than as a per-row × on the field list. + */ +export function MappingRoot({ kind, binding, onChange }: MappingRootProps): React.JSX.Element { + const [destOpen, setDestOpen] = useState(false) + const [fieldKey, setFieldKey] = useState(null) + + const fieldEntries = Object.entries(binding.fieldMap) + + return ( +
+ + + + + {fieldEntries.length > 0 && ( + + {fieldEntries.map(([targetField, ref], i) => ( + setFieldKey(targetField)} + /> + ))} + + )} + + { + onChange(next) + setDestOpen(false) + }} + /> + + { + if (!open) setFieldKey(null) + }} + kind={kind} + targetField={fieldKey} + binding={binding} + onChange={onChange} + onRemove={() => { + if (fieldKey) onChange(removeField(binding, fieldKey)) + setFieldKey(null) + }} + /> +
+ ) +} + +function describeMode(binding: MappingBinding): string { + switch (binding.mode) { + case 'bundled': + return 'Default' + case 'existing': + return 'Your custom type' + case 'authored': + return 'New type' + } +} + +function removeField(binding: MappingBinding, key: string): MappingBinding { + const next: Record = {} + for (const [k, v] of Object.entries(binding.fieldMap)) { + if (k !== key) next[k] = v + } + return { ...binding, fieldMap: next } +} + +interface FieldRowProps { + targetField: string + sourceRef: MappingBinding['fieldMap'][string] + first: boolean + onOpen: () => void +} + +function FieldRow({ targetField, sourceRef, first, onOpen }: FieldRowProps): React.JSX.Element { + // Plain-English source label so the row reads at a glance — the ref + // itself (recording.transcript etc.) lives inside the picker. + const sourceLabel = + sourceRef.kind === 'source' ? getSourceFieldLabel(sourceRef.field).label : 'Fixed value' + return ( + + ) +} diff --git a/src/renderer/src/components/settings/PipelineCard.tsx b/src/renderer/src/components/settings/PipelineCard.tsx new file mode 100644 index 0000000..b20e595 --- /dev/null +++ b/src/renderer/src/components/settings/PipelineCard.tsx @@ -0,0 +1,236 @@ +import { Switch } from '@renderer/components/ui/Switch' +import { useConfigStore } from '@renderer/state/configStore' +import { useDataStore } from '@renderer/state/dataStore' +import { useMarfaStore } from '@renderer/state/marfaStore' +import { cn } from '@renderer/lib/cn' +import { Layers, Mic } from 'lucide-react' +import { useMemo, useState } from 'react' +import type { MappingBinding, SourceKind } from '../../../../preload/api' +import { MappingRoot } from './MappingRoot' +import { SettingsCard } from './SettingsCard' +import { Group } from './parts/Group' + +interface PipelineCardProps { + kind: SourceKind +} + +/** + * Sync → Recordings / Sessions pipeline card. The header switch + * suspends the whole pipeline (no upserts, no soft-deletes — see + * `engine.ts`). The body is dim + non-interactive when off. + * + * Body composition: + * • Sessions only: a read-only "Session gap" line that mirrors the + * canonical value from the Analysis tab. + * • Mapping editor (`MappingRoot`). + * • Recordings only: Modes section (per-mode toggles for which + * SuperWhisper modes get synced). + */ +export function PipelineCard({ kind }: PipelineCardProps): React.JSX.Element { + const labels = + kind === 'recording' + ? { + title: 'Recordings', + subtitle: "Push each transcript to Marfa as it's saved.", + icon: Mic + } + : { + title: 'Sessions', + subtitle: 'Push the session windows your recordings group into.', + icon: Layers + } + + const enabled = useConfigStore( + kind === 'recording' ? (s) => s.recordingPipelineEnabled : (s) => s.sessionPipelineEnabled + ) + const setEnabled = useConfigStore( + kind === 'recording' ? (s) => s.setRecordingPipelineEnabled : (s) => s.setSessionPipelineEnabled + ) + + const mapping = useMarfaStore((s) => s.mapping) + const setMapping = useMarfaStore((s) => s.setMapping) + const binding = mapping?.[kind] ?? null + + function updateBinding(next: MappingBinding): void { + if (!mapping) return + void setMapping({ ...mapping, [kind]: next }) + } + + return ( + void setEnabled(next)} + ariaLabel={`Sync ${labels.title.toLowerCase()}`} + /> + } + > +
+ {kind === 'session' && } + {binding ? ( + + ) : ( +

Loading mapping…

+ )} + {kind === 'recording' && } +
+
+ ) +} + +/** + * Read-only mirror of the canonical session-gap value from the Analysis + * tab. Edits live there; this row just confirms what's currently in + * effect so the user doesn't have to flip tabs to check. + */ +function SessionGapInheritedRow(): React.JSX.Element { + const value = useConfigStore((s) => s.sessionGapThresholdMinutes) + return ( +
+
+
Session gap
+
+ Recordings within this gap count as the same session. +
+
+ + {value} min · from Analysis + +
+ ) +} + +function ModesSection(): React.JSX.Element { + const modeFilter = useMarfaStore((s) => s.modeFilter) + const setModeFilter = useMarfaStore((s) => s.setModeFilter) + const recordings = useDataStore((s) => s.recordings) + + // Compute mode counts from the local cache; show the top-8 by count + // by default, with a "show more" toggle for the long tail. + const modes = useMemo(() => { + const counts = new Map() + for (const r of recordings) { + counts.set(r.modeName, (counts.get(r.modeName) ?? 0) + 1) + } + return [...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .map(([name, count]) => ({ name, count })) + }, [recordings]) + + const [expanded, setExpanded] = useState(false) + const TOP = 8 + const visible = expanded ? modes : modes.slice(0, TOP) + const overflow = Math.max(0, modes.length - TOP) + + const allModes = modes.map((m) => m.name) + const isEnabled = (name: string): boolean => modeFilter === null || modeFilter.includes(name) + const enabledCount = + modeFilter === null ? modes.length : modes.filter((m) => isEnabled(m.name)).length + const allOn = enabledCount === modes.length + + function toggleMode(name: string, on: boolean): void { + const current = modeFilter ?? allModes + const nextSet = on ? Array.from(new Set([...current, name])) : current.filter((n) => n !== name) + const next = nextSet.length === allModes.length ? null : nextSet + void setModeFilter(next) + } + + function toggleAll(): void { + if (allOn) void setModeFilter([]) + else void setModeFilter(null) + } + + if (modes.length === 0) return <> + + return ( +
+ + + {enabledCount} of {modes.length} on + + + + } + > +
+ {visible.map((m, i) => ( + toggleMode(m.name, on)} + first={i === 0} + /> + ))} +
+ {overflow > 0 && ( + + )} +
+
+ ) +} + +function ModeRow({ + name, + count, + enabled, + onToggle, + first +}: { + name: string + count: number + enabled: boolean + onToggle: (on: boolean) => void + first: boolean +}): React.JSX.Element { + return ( +
+ + {name} + + ({count.toLocaleString()}) + + + +
+ ) +} diff --git a/src/renderer/src/components/settings/RecordingsFolderCard.tsx b/src/renderer/src/components/settings/RecordingsFolderCard.tsx new file mode 100644 index 0000000..b81a0a8 --- /dev/null +++ b/src/renderer/src/components/settings/RecordingsFolderCard.tsx @@ -0,0 +1,135 @@ +import { useConfigStore } from '@renderer/state/configStore' +import { useDataStore } from '@renderer/state/dataStore' +import { cn } from '@renderer/lib/cn' +import { relativeTime } from '@renderer/lib/format' +import { Folder, RefreshCw } from 'lucide-react' +import { useEffect, useState } from 'react' +import { SettingsCard } from './SettingsCard' +import { PathBar } from './parts/PathBar' + +/** + * General → Recordings folder. + * + * Header — title, subtitle, and the Reindex button in the top-right. + * The button doubles as the "last indexed" status surface: + * its label reads "Indexed Xm" / "Indexed just now" and a + * refresh icon sits to the right. + * Body — path bar + one small stats line beneath. Stats render as + * "12k recordings · 279 hours of audio" with the numbers in + * foreground colour and the units in muted-foreground, so + * the eye picks up the figures first. + * + * No 18px chunky numbers, no duplicated indexed timestamp, no action + * button floating beneath the stats. + */ +export function RecordingsFolderCard(): React.JSX.Element { + const path = useConfigStore((s) => s.path) + const isValid = useConfigStore((s) => s.isValid) + const isInsideHome = useConfigStore((s) => s.isInsideHome) + const setPath = useConfigStore((s) => s.setPath) + const demoMode = useConfigStore((s) => s.demoMode) + const setDemoMode = useConfigStore((s) => s.setDemoMode) + const count = useDataStore((s) => s.count) + const indexedAt = useDataStore((s) => s.indexedAt) + const loading = useDataStore((s) => s.loading) + const reindexing = useDataStore((s) => s.reindexing) + const error = useDataStore((s) => s.error) + const scanErrors = useDataStore((s) => s.scanErrors) + const reindex = useDataStore((s) => s.reindex) + const totalDurationSec = useDataStore((s) => s.aggregates.overview.totalDurationSec) + + // Tick once a minute so the "5m" string drifts naturally. + const [, setNow] = useState(() => Date.now()) + useEffect(() => { + const t = window.setInterval(() => setNow(Date.now()), 60_000) + return () => window.clearInterval(t) + }, []) + + async function choose(): Promise { + const chosen = await window.api.dialog.pickFolder() + if (!chosen) return + // Order matters: persist the path first, THEN flip demo off. The + // reverse briefly lands us in (!demoMode && !path) which fires the + // welcome modal and produces a flash. + await setPath(chosen) + if (demoMode) await setDemoMode(false) + } + + const busy = loading || reindexing + const hours = formatNearestHours(totalDurationSec) + const recordings = formatNearestK(count) + const indexedRight = (() => { + if (loading) return 'Scanning…' + if (reindexing) return 'Reindexing…' + if (!indexedAt) return 'Not yet indexed' + return `Indexed ${relativeTime(indexedAt)}` + })() + + const headerExtra = ( + + ) + + return ( + +
+ void choose()} chooseLabel="Change folder" /> +
+

+ {recordings} recordings ·{' '} + {hours} of audio +

+

{indexedRight}

+
+ {error && ( +

+ {error} +

+ )} + {!error && scanErrors > 0 && ( +

+ {scanErrors} recording{scanErrors === 1 ? '' : 's'} failed to parse +

+ )} + {!error && path && !isInsideHome && ( +

+ Path outside home directory +

+ )} +
+
+ ) +} + +/** + * Round count to the nearest thousand / million with zero decimals. + * 4321 → "4k", 12671 → "13k", 1_240_000 → "1M". + */ +function formatNearestK(n: number): string { + if (n < 1000) return String(n) + if (n < 1_000_000) return `${Math.round(n / 1000)}k` + return `${Math.round(n / 1_000_000)}M` +} + +/** Round seconds to the nearest whole hour with the "hours" suffix. */ +function formatNearestHours(seconds: number): string { + const h = Math.round(seconds / 3600) + return h === 1 ? '1 hour' : `${h} hours` +} diff --git a/src/renderer/src/components/settings/SessionGapCard.tsx b/src/renderer/src/components/settings/SessionGapCard.tsx new file mode 100644 index 0000000..661126d --- /dev/null +++ b/src/renderer/src/components/settings/SessionGapCard.tsx @@ -0,0 +1,56 @@ +import { SegmentedTabs } from '@renderer/components/ui/SegmentedTabs' +import { useConfigStore } from '@renderer/state/configStore' +import { Layers } from 'lucide-react' +import { SettingsCard } from './SettingsCard' + +type GapMinutes = 15 | 30 | 60 | 120 + +const GAP_OPTIONS: ReadonlyArray<{ id: `${GapMinutes}`; label: string }> = [ + { id: '15', label: '15m' }, + { id: '30', label: '30m' }, + { id: '60', label: '60m' }, + { id: '120', label: '120m' } +] + +/** + * Analysis → Sessions. Canonical home for the session-gap threshold — + * the value that decides "this recording belongs to the same session + * as the previous one". + * + * Picker is a SegmentedTabs control, not a dropdown — visually rhymes + * with the Settings tab strip and the navbar range pill, so the chrome + * stays consistent across the app. + */ +export function SessionGapCard(): React.JSX.Element { + const value = useConfigStore((s) => s.sessionGapThresholdMinutes) + const setValue = useConfigStore((s) => s.setSessionGapThresholdMinutes) + + // Map the persisted value to the nearest dropdown option so a + // freshly migrated config with a weird value still highlights + // something sensible. + const selected: `${GapMinutes}` = GAP_OPTIONS.find((o) => Number(o.id) === value)?.id ?? '30' + + return ( + +
+
+
Session gap
+
+ Recordings within this gap count as the same session. +
+
+ + value={selected} + onChange={(next) => void setValue(Number(next))} + options={GAP_OPTIONS} + ariaLabel="Session gap in minutes" + className="self-center" + /> +
+
+ ) +} diff --git a/src/renderer/src/components/settings/SettingsCard.tsx b/src/renderer/src/components/settings/SettingsCard.tsx index 63abd24..d921fb6 100644 --- a/src/renderer/src/components/settings/SettingsCard.tsx +++ b/src/renderer/src/components/settings/SettingsCard.tsx @@ -43,7 +43,7 @@ export function SettingsCard({ className )} > -
+
diff --git a/src/renderer/src/components/settings/TranscriptsCard.tsx b/src/renderer/src/components/settings/TranscriptsCard.tsx new file mode 100644 index 0000000..073d750 --- /dev/null +++ b/src/renderer/src/components/settings/TranscriptsCard.tsx @@ -0,0 +1,35 @@ +import { Switch } from '@renderer/components/ui/Switch' +import { useUiPrefsStore } from '@renderer/state/uiPrefsStore' +import { AlignLeft } from 'lucide-react' +import { SettingsCard } from './SettingsCard' + +/** Settings → General → Transcripts. Just a "show timestamps" switch + * today — the dropdown that used to live here was replaced with a + * toggle so it matches the rhythm of the rest of Settings. */ +export function TranscriptsCard(): React.JSX.Element { + const mode = useUiPrefsStore((s) => s.transcriptViewMode) + const setMode = useUiPrefsStore((s) => s.setTranscriptViewMode) + const showTimestamps = mode === 'block' + return ( + +
+
+
Show timestamps
+
+ Each segment carries a clickable [m:ss] prefix. Toggle off for a continuous inline + transcript. +
+
+ setMode(next ? 'block' : 'inline')} + ariaLabel="Show timestamps" + /> +
+
+ ) +} diff --git a/src/renderer/src/components/settings/parts/DrillRow.tsx b/src/renderer/src/components/settings/parts/DrillRow.tsx new file mode 100644 index 0000000..93a6c9d --- /dev/null +++ b/src/renderer/src/components/settings/parts/DrillRow.tsx @@ -0,0 +1,77 @@ +import { cn } from '@renderer/lib/cn' +import { ChevronRight, type LucideIcon } from 'lucide-react' + +interface DrillRowProps { + /** Title — usually inline JSX so callers can drop in mono code spans. */ + title: React.ReactNode + subtitle?: React.ReactNode + /** Optional small icon-in-square to the left of the title. */ + leadingIcon?: LucideIcon + /** Drop the chevron — used by destructive / "Add a field" rows that + * don't push to a sub-view. */ + noChevron?: boolean + /** Render in accent-blue tone (e.g. "Create new type…"). */ + accent?: boolean + /** Top border is drawn unless `first` is true. Lets a group stack rows + * with internal dividers without painting a leading line. */ + first?: boolean + onClick?: () => void + className?: string +} + +/** + * Tappable list-row used inside `Group`. The interaction surface is the + * whole row; the chevron is decorative. Hover state matches the rest of + * the app (`hover:bg-foreground/5`). + */ +export function DrillRow({ + title, + subtitle, + leadingIcon: Icon, + noChevron, + accent, + first, + onClick, + className +}: DrillRowProps): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/settings/parts/Group.tsx b/src/renderer/src/components/settings/parts/Group.tsx new file mode 100644 index 0000000..70afcc2 --- /dev/null +++ b/src/renderer/src/components/settings/parts/Group.tsx @@ -0,0 +1,42 @@ +interface GroupProps { + /** Small-caps section header. Optional — when omitted, the group + * renders without a header row. */ + label?: string + /** Right-aligned hint (e.g. "10 of 12 on"). Tabular nums. */ + hint?: React.ReactNode + /** Children sit inside a bordered, rounded surface. Rows inside should + * use first/last-child styling for top/bottom radius — `Group`'s + * `overflow-hidden` will clip them anyway. */ + children: React.ReactNode + className?: string +} + +/** + * Section grouping primitive. Used by the new Sync tab to lay out rows + * (destination, fields, source) inside bordered cards with optional + * small-caps headers. The visual rhythm matches macOS System Settings — + * mid-grey label above a single rounded surface. + * + * GROUP LABEL hint? + * ┌────────────────────────────┐ + * │ row │ + * │ row │ + * └────────────────────────────┘ + */ +export function Group({ label, hint, children, className }: GroupProps): React.JSX.Element { + return ( +
+ {(label || hint) && ( +
+ {label && ( + + {label} + + )} + {hint && {hint}} +
+ )} +
{children}
+
+ ) +} diff --git a/src/renderer/src/components/settings/parts/PathBar.tsx b/src/renderer/src/components/settings/parts/PathBar.tsx new file mode 100644 index 0000000..9672770 --- /dev/null +++ b/src/renderer/src/components/settings/parts/PathBar.tsx @@ -0,0 +1,66 @@ +import { cn } from '@renderer/lib/cn' + +interface PathBarProps { + /** Path to display. `null` renders the empty-state placeholder. */ + path: string | null + placeholder?: string + /** Click handler for the trailing button. When omitted the button is + * hidden — useful when the bar is purely informational. */ + onChoose?: () => void + /** Label for the trailing button. Defaults to "Choose folder". */ + chooseLabel?: string + /** Click handler for the path itself — e.g. "open in Finder". The path + * is rendered as a button when set; static text otherwise. */ + onOpenPath?: () => void + className?: string +} + +/** + * Path row used by the Recordings folder card. + * + * Plain text path on the left, action button on the right, with a + * subtle grey wash so it reads as one inline field — calm enough not + * to dominate the card, distinct enough to know it's interactive. + */ +export function PathBar({ + path, + placeholder = 'No folder selected', + onChoose, + chooseLabel = 'Choose folder', + onOpenPath, + className +}: PathBarProps): React.JSX.Element { + const display = path ?? placeholder + return ( +
+ {onOpenPath ? ( + + ) : ( + + {display} + + )} + {onChoose && ( + + )} +
+ ) +} diff --git a/src/renderer/src/components/settings/parts/PickRow.tsx b/src/renderer/src/components/settings/parts/PickRow.tsx new file mode 100644 index 0000000..d5d10f5 --- /dev/null +++ b/src/renderer/src/components/settings/parts/PickRow.tsx @@ -0,0 +1,71 @@ +import { cn } from '@renderer/lib/cn' + +interface PickRowProps { + picked: boolean + /** Primary content — plain-English label, usually with a small type + * tag suffix. */ + title: React.ReactNode + /** Secondary line — sources use this for the dimmed canonical ref. */ + subtitle?: React.ReactNode + /** Tertiary line — used for preview values. */ + preview?: React.ReactNode + /** Top border is drawn unless `first` is true. */ + first?: boolean + onClick: () => void + className?: string +} + +/** + * Radio row used by the destination + field-source pickers. Visual + * weight matches macOS native settings — small filled circle on pick, + * subtle blue-tinted background row to reinforce the selection state. + */ +export function PickRow({ + picked, + title, + subtitle, + preview, + first, + onClick, + className +}: PickRowProps): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/settings/parts/PickTile.tsx b/src/renderer/src/components/settings/parts/PickTile.tsx new file mode 100644 index 0000000..8fe892c --- /dev/null +++ b/src/renderer/src/components/settings/parts/PickTile.tsx @@ -0,0 +1,49 @@ +import { cn } from '@renderer/lib/cn' + +interface PickTileProps { + picked: boolean + /** Plain-English title. */ + label: string + /** Optional small secondary line — used for the canonical ref string. */ + subtitle?: string + onClick: () => void + className?: string +} + +/** + * Compact tile used by the destination + field-source pickers. Two + * lines: plain-English label, optional muted subtitle. Selection is + * conveyed by a dark-grey border + subtle tinted background — no radio + * icon, no preview value, no type tag. Designed to sit in a 2-column + * grid so a list of 10+ options reads at a glance instead of as a + * scrolling stack of tall rows. + */ +export function PickTile({ + picked, + label, + subtitle, + onClick, + className +}: PickTileProps): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/settings/parts/StatusPill.tsx b/src/renderer/src/components/settings/parts/StatusPill.tsx new file mode 100644 index 0000000..63836ca --- /dev/null +++ b/src/renderer/src/components/settings/parts/StatusPill.tsx @@ -0,0 +1,74 @@ +import { cn } from '@renderer/lib/cn' +import { Check, CircleAlert, Loader2 } from 'lucide-react' + +export type StatusTone = 'ok' | 'busy' | 'error' | 'neutral' + +interface StatusPillProps { + tone: StatusTone + label: string + /** Optional title text shown on hover — useful for error tones where + * the label is truncated. */ + title?: string + /** Omit the per-tone icon — opt-in for callers that don't want the + * tick / cross / spinner glyph (e.g. the Connection card, where the + * label is already self-explanatory). */ + hideIcon?: boolean + className?: string +} + +/** + * Compact status indicator used in card headers (Connection, Recordings + * folder). Narrow palette use only — accent-green for healthy, + * accent-orange for warning/error, neutral grey for everything else. + * The pill carries an icon by tone so colour alone doesn't carry the + * meaning. + */ +export function StatusPill({ + tone, + label, + title, + hideIcon, + className +}: StatusPillProps): React.JSX.Element { + const palette = { + ok: { + bg: 'bg-accent-green-bg', + fg: 'text-accent-green', + border: 'border-accent-green/30', + icon: + }, + busy: { + bg: 'bg-foreground/[0.04]', + fg: 'text-muted-foreground', + border: 'border-border', + icon: + }, + error: { + bg: 'bg-accent-orange-bg', + fg: 'text-accent-orange', + border: 'border-accent-orange/30', + icon: + }, + neutral: { + bg: 'bg-foreground/[0.04]', + fg: 'text-muted-foreground', + border: 'border-border', + icon: null as React.ReactNode + } + }[tone] + return ( + + {!hideIcon && palette.icon} + {label} + + ) +} diff --git a/src/renderer/src/components/settings/parts/Stepper.tsx b/src/renderer/src/components/settings/parts/Stepper.tsx new file mode 100644 index 0000000..bef8adc --- /dev/null +++ b/src/renderer/src/components/settings/parts/Stepper.tsx @@ -0,0 +1,95 @@ +import { cn } from '@renderer/lib/cn' +import { Minus, Plus } from 'lucide-react' + +interface StepperProps { + value: number + onChange: (next: number) => void + min?: number + max?: number + step?: number + /** Unit suffix shown next to the value (e.g. "min"). Visually dimmed + * so the value reads first. */ + unit?: string + /** Width of the value display column. Default 64px is enough for two + * digits + a short unit. */ + width?: number + disabled?: boolean + className?: string +} + +/** + * Numeric stepper: − / value / +. Sits at the same height as Switch + * (22px tall track equivalent) so it lines up with toggle rows. + * + * Deliberately built as a small composition rather than wrapping a + * native `` — the latter has inconsistent platform + * styling and the up/down spinner can't be made to match. The two + * buttons keep keyboarded operation in reach (Tab + Enter). + */ +export function Stepper({ + value, + onChange, + min = 1, + max = 999, + step = 1, + unit, + width = 64, + disabled, + className +}: StepperProps): React.JSX.Element { + const dec = (): void => { + if (disabled) return + onChange(Math.max(min, value - step)) + } + const inc = (): void => { + if (disabled) return + onChange(Math.min(max, value + step)) + } + return ( +
+ + + + + {value} + {unit && {unit}} + + = max} ariaLabel="Increase"> + + +
+ ) +} + +function StepperBtn({ + onClick, + disabled, + ariaLabel, + children +}: { + onClick: () => void + disabled?: boolean + ariaLabel: string + children: React.ReactNode +}): React.JSX.Element { + return ( + + ) +} diff --git a/src/renderer/src/components/settings/parts/chromeButton.ts b/src/renderer/src/components/settings/parts/chromeButton.ts new file mode 100644 index 0000000..d19263c --- /dev/null +++ b/src/renderer/src/components/settings/parts/chromeButton.ts @@ -0,0 +1,26 @@ +/** + * Shared button-class strings used by Settings cards. Promoted out of + * `screens/Settings.tsx` so the redesigned cards (each in its own file) + * can reuse the same visual treatment without re-deriving. + * + * The chrome here is intentionally restrained — small, neutral, with the + * border + floating bg pattern that runs through the rest of the app. + * Hover state lifts the surface to `foreground/5`. + * + * Three variants: + * + * • `CHROME_BUTTON` — default ghost-bordered chrome button. + * • `CHROME_BUTTON_PRIMARY` — black fill / white foreground; used + * sparingly for the primary "Sync" action. + * • `CHROME_BUTTON_WARN` — accent-orange tint, used by destructive + * actions (Reset…). Reads as "be careful" + * rather than red-alarm "destructive". + */ +export const CHROME_BUTTON = + 'inline-flex h-7 items-center gap-1.5 rounded-md border border-border bg-floating px-3 text-[12px] text-foreground transition-colors hover:bg-foreground/5 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-floating' + +export const CHROME_BUTTON_PRIMARY = + 'inline-flex h-7 items-center gap-1.5 rounded-md border border-foreground bg-foreground px-3 text-[12px] font-medium text-background transition-colors hover:bg-foreground/90 disabled:cursor-not-allowed disabled:opacity-60' + +export const CHROME_BUTTON_WARN = + 'inline-flex h-7 items-center gap-1.5 rounded-md border border-accent-orange/40 bg-accent-orange/10 px-3 text-[12px] font-medium text-accent-orange transition-colors hover:bg-accent-orange/15 disabled:cursor-not-allowed disabled:opacity-50' diff --git a/src/renderer/src/components/ui/button.tsx b/src/renderer/src/components/ui/button.tsx index bb27213..edebee1 100644 --- a/src/renderer/src/components/ui/button.tsx +++ b/src/renderer/src/components/ui/button.tsx @@ -5,7 +5,7 @@ import { Slot } from 'radix-ui' import { cn } from '@renderer/lib/cn' const buttonVariants = cva( - "inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", + "inline-flex shrink-0 items-center justify-center gap-2 rounded-lg text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4", { variants: { variant: { @@ -20,11 +20,11 @@ const buttonVariants = cva( }, size: { default: 'h-9 px-4 py-2 has-[>svg]:px-3', - xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", - sm: 'h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5', - lg: 'h-10 rounded-md px-6 has-[>svg]:px-4', + xs: "h-6 gap-1 rounded-lg px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3", + sm: 'h-8 gap-1.5 rounded-lg px-3 has-[>svg]:px-2.5', + lg: 'h-10 rounded-lg px-6 has-[>svg]:px-4', icon: 'size-9', - 'icon-xs': "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3", + 'icon-xs': "size-6 rounded-lg [&_svg:not([class*='size-'])]:size-3", 'icon-sm': 'size-8', 'icon-lg': 'size-10' } diff --git a/src/renderer/src/components/ui/dialog.tsx b/src/renderer/src/components/ui/dialog.tsx new file mode 100644 index 0000000..def70c5 --- /dev/null +++ b/src/renderer/src/components/ui/dialog.tsx @@ -0,0 +1,155 @@ +import { cn } from '@renderer/lib/cn' +import { X } from 'lucide-react' +import { Dialog as DialogPrimitive } from 'radix-ui' +import * as React from 'react' + +/** + * Light ShadCN-style wrapper around Radix Dialog. Used by the Sync + * tab's destination + field-source pickers (Sheet pattern). + * + * Visual: + * • Dimming overlay covering the whole window. + * • Centred panel with rounded card surface, 12px radius. + * • Close icon (X) in the top-right of the panel. + * + * Surface area is intentionally small — the same five subcomponents the + * rest of ShadCN's dialog exposes, no more. + */ + +function Dialog({ + ...props +}: React.ComponentProps): React.JSX.Element { + return +} + +function DialogTrigger({ + ...props +}: React.ComponentProps): React.JSX.Element { + return +} + +function DialogOverlay({ + className, + ...props +}: React.ComponentProps): React.JSX.Element { + return ( + + ) +} + +interface DialogContentProps extends React.ComponentProps { + /** Hide the default close (X) button — only do this if the dialog + * guarantees its own close affordance. */ + hideCloseButton?: boolean +} + +function DialogContent({ + className, + children, + hideCloseButton, + ...props +}: DialogContentProps): React.JSX.Element { + return ( + + + + {children} + {!hideCloseButton && ( + + + + )} + + + ) +} + +function DialogHeader({ className, ...props }: React.ComponentProps<'div'>): React.JSX.Element { + return ( +
+ ) +} + +function DialogBody({ className, ...props }: React.ComponentProps<'div'>): React.JSX.Element { + return ( +
+ ) +} + +function DialogFooter({ className, ...props }: React.ComponentProps<'div'>): React.JSX.Element { + return ( +
+ ) +} + +function DialogTitle({ + className, + ...props +}: React.ComponentProps): React.JSX.Element { + return ( + + ) +} + +function DialogDescription({ + className, + ...props +}: React.ComponentProps): React.JSX.Element { + return ( + + ) +} + +export { + Dialog, + DialogTrigger, + DialogOverlay, + DialogContent, + DialogHeader, + DialogBody, + DialogFooter, + DialogTitle, + DialogDescription +} diff --git a/src/renderer/src/components/ui/sonner.tsx b/src/renderer/src/components/ui/sonner.tsx new file mode 100644 index 0000000..376e048 --- /dev/null +++ b/src/renderer/src/components/ui/sonner.tsx @@ -0,0 +1,71 @@ +import { useThemeStore } from '@renderer/state/themeStore' +import { useEffect, useState } from 'react' +import { Toaster as SonnerToaster } from 'sonner' + +/** + * Sonner-backed toast container. Mount once at the app root. Toast + * call-sites must use the `toastError` / `toastInfo` / `toastSuccess` + * helpers in `@renderer/lib/toast` — direct imports from `sonner` + * outside this file (and the wrapper module) are blocked by ESLint. + * + * Visual rhythm: failures lean on the destructive token (red text + + * red-tinted surface), successes on accent-green. Padding is tighter + * than sonner's defaults so the toast reads as one calm line rather + * than a slab. Action buttons are outlined (not the bright + * black-on-white "primary" face) so they don't compete with the toast + * body for attention. + * + * Theming: derives `'light' | 'dark'` from the app's ThemePref + the + * OS media query so the toast surface tracks the rest of the chrome. + * `pref === 'system'` listens live and re-renders on appearance flip. + */ +export function Toaster(): React.JSX.Element { + const pref = useThemeStore((s) => s.pref) + const [systemDark, setSystemDark] = useState(() => prefersDark()) + + useEffect(() => { + const mq = window.matchMedia('(prefers-color-scheme: dark)') + const onChange = (): void => setSystemDark(mq.matches) + mq.addEventListener('change', onChange) + return () => mq.removeEventListener('change', onChange) + }, []) + + const isDark = pref === 'dark' || (pref === 'system' && systemDark) + + return ( + + ) +} + +function prefersDark(): boolean { + if (typeof window === 'undefined') return false + return window.matchMedia('(prefers-color-scheme: dark)').matches +} diff --git a/src/renderer/src/lib/format.ts b/src/renderer/src/lib/format.ts index 0600e11..42a8f3f 100644 --- a/src/renderer/src/lib/format.ts +++ b/src/renderer/src/lib/format.ts @@ -123,3 +123,26 @@ export function formatTrendTick(raw: unknown): string { } return v } + +/** + * Render an ISO timestamp as a relative time string ("just now", + * "5m ago", "2h ago", "3d ago"). Returns the empty string for an + * unparseable input — callers usually display a dash in that case. + * + * Lives here so the Settings cards (Recordings folder, Sync action bar, + * Connection card) can share the same wording. + */ +export function relativeTime(iso: string | null | undefined): string { + if (!iso) return '' + const t = new Date(iso).getTime() + if (isNaN(t)) return '' + const diffSec = Math.max(0, Math.floor((Date.now() - t) / 1000)) + if (diffSec < 30) return 'just now' + if (diffSec < 60) return `${diffSec}s ago` + const diffMin = Math.floor(diffSec / 60) + if (diffMin < 60) return `${diffMin}m ago` + const diffHr = Math.floor(diffMin / 60) + if (diffHr < 24) return `${diffHr}h ago` + const diffD = Math.floor(diffHr / 24) + return `${diffD}d ago` +} diff --git a/src/renderer/src/lib/toast.ts b/src/renderer/src/lib/toast.ts new file mode 100644 index 0000000..b26a161 --- /dev/null +++ b/src/renderer/src/lib/toast.ts @@ -0,0 +1,91 @@ +import { toast as sonnerToast } from 'sonner' + +/** + * Single entry point for toast notifications. Wraps sonner so the rest + * of the app never imports from 'sonner' directly — that boundary is + * enforced by ESLint's `no-restricted-imports` rule (see eslint.config.mjs). + * + * When to reach for a toast: + * + * • toastError — genuine errors the user can't recover from inline. + * Background sync failures, unexpected API rejections, OAuth + * refresh blowing up. Pair with `copyText` whenever the user + * might need to share details with us. + * + * • toastInfo — sparingly, for transient confirmations that *don't* + * have an inline home. The Developer tab's "Purged X recordings" + * line is a fair example; routine sign-in / sync success is not + * (the card itself carries that signal). + * + * When NOT to reach for a toast: + * + * • Never duplicate an inline error state. If the ConnectionCard + * already renders "Last sync failed — " with a Retry + * button, the toast is at most an *additional* one-shot signal on + * the transition. Don't pile up. + * + * • Never on every render. Trigger on state-change transitions only + * (keyed `useEffect` watching the error string). Otherwise the + * same toast loops every re-render. + * + * • Never for routine success paths that are visible elsewhere. + * Sign-in success and sync completion both transition the card — + * no toast needed. + */ + +export interface ToastErrorInput { + /** Headline shown in the toast body. Keep it short and human. */ + message: string + /** + * Optional log payload offered behind a Copy button. Use whenever + * the user might need to share the error with us — full stack + * trace, response body, request ID, etc. + */ + copyText?: string +} + +export interface ToastInfoInput { + /** Headline shown in the toast body. Keep it short and human. */ + message: string +} + +export function toastError({ message, copyText }: ToastErrorInput): void { + if (copyText) { + sonnerToast.error(message, { + duration: 8_000, + action: { + label: 'Copy logs', + onClick: () => { + void copyToClipboard(copyText) + } + } + }) + return + } + sonnerToast.error(message, { duration: 6_000 }) +} + +export function toastInfo({ message }: ToastInfoInput): void { + sonnerToast(message, { duration: 4_000 }) +} + +/** + * Explicit positive confirmation. Use when the success isn't otherwise + * visible (e.g. a background completion the user triggered but isn't + * watching). Renders with the green success tint from `sonner.tsx`. + */ +export function toastSuccess({ message }: ToastInfoInput): void { + sonnerToast.success(message, { duration: 3_000 }) +} + +async function copyToClipboard(value: string): Promise { + try { + await navigator.clipboard.writeText(value) + sonnerToast.success('Copied', { duration: 1_500 }) + } catch (err) { + // Fall back to a follow-up toast — the user can read the value + // from console if needed. Don't dump it into the original toast. + console.warn('[toast] clipboard write failed:', err) + sonnerToast.error('Copy failed — see console.', { duration: 3_000 }) + } +} diff --git a/src/renderer/src/screens/Settings.tsx b/src/renderer/src/screens/Settings.tsx index f083fed..63f5062 100644 --- a/src/renderer/src/screens/Settings.tsx +++ b/src/renderer/src/screens/Settings.tsx @@ -1,55 +1,45 @@ -import { AppearancePicker } from '@renderer/components/settings/AppearancePicker' -import { SettingsCard } from '@renderer/components/settings/SettingsCard' +import { AboutCard } from '@renderer/components/settings/AboutCard' +import { AppDataCard } from '@renderer/components/settings/AppDataCard' +import { AppearanceCard } from '@renderer/components/settings/AppearanceCard' +import { ConnectionCard } from '@renderer/components/settings/ConnectionCard' +import { DeveloperCard } from '@renderer/components/settings/DeveloperCard' +import { FillerDictionaryCard } from '@renderer/components/settings/FillerDictionaryCard' +import { IndexingCard } from '@renderer/components/settings/IndexingCard' +import { PipelineCard } from '@renderer/components/settings/PipelineCard' +import { RecordingsFolderCard } from '@renderer/components/settings/RecordingsFolderCard' +import { SessionGapCard } from '@renderer/components/settings/SessionGapCard' +import { TranscriptsCard } from '@renderer/components/settings/TranscriptsCard' import { SegmentedTabs } from '@renderer/components/ui/SegmentedTabs' -import { Switch } from '@renderer/components/ui/Switch' -import { cn } from '@renderer/lib/cn' -import { formatCompact, formatDurationSec } from '@renderer/lib/format' -import { DEFAULT_FILLER_PHRASES, normalisePhrases } from '@shared/text-metrics' -import { useConfigStore } from '@renderer/state/configStore' -import { useDataStore } from '@renderer/state/dataStore' -import { useUiPrefsStore } from '@renderer/state/uiPrefsStore' -import { - AlignLeft, - BookOpen, - Code2, - ExternalLink, - Folder, - Info, - RefreshCw, - RotateCcw, - Settings as SettingsIcon, - Sparkles, - Sun, - X -} from 'lucide-react' -import { useEffect, useMemo, useState } from 'react' -import type { UpdaterStatus } from '../../../preload/api' +import { useState } from 'react' -type SettingsTab = 'general' | 'data' | 'about' +type SettingsTab = 'general' | 'analysis' | 'sync' | 'developer' | 'about' const SETTINGS_TABS: ReadonlyArray<{ id: SettingsTab; label: string }> = [ { id: 'general', label: 'General' }, - { id: 'data', label: 'Data' }, + { id: 'analysis', label: 'Analysis' }, + { id: 'sync', label: 'Sync' }, + { id: 'developer', label: 'Developer' }, { id: 'about', label: 'About' } ] -const GITHUB_URL = 'https://github.com/aicayzer/superwhisper-analytics' -const DISCLAIMER = - 'Personal project, not affiliated with SuperWhisper. Shared in case it’s useful to anyone else.' - /** - * Settings page, organised into three tabs: + * Settings screen. A thin orchestrator — each tab renders a set of + * cards from `components/settings/`. The redesign (May 2026) split the + * old four-tab structure into five, surfaced two new persisted fields + * (pipeline-enabled flags + a configurable session-gap), and rebuilt + * the Sync tab as a destination panel (Connection card + two Pipeline + * cards + a sticky action bar) instead of the old single-card Marfa + * pane. * - * • General — Recordings folder, Appearance, Transcript view-mode, - * Demo data toggle. The everyday user preferences. - * • Data — Indexing toggles + Filler-phrase dictionary. Editorial - * control over what gets counted and how. - * • About — Version, License, Source, Updates. + * General → Recordings folder · Appearance · Transcripts + * Analysis → Indexing · Filler dictionary + * Sync → Connection · Recordings pipeline · Sessions pipeline + * Developer → Developer (demo data + devtools) · App data (reset) + * About → About * - * The tab strip lives at the top using the same lifted-pill segmented - * visual as the navbar RangePill — keeps the chrome consistent. The - * navbar's range pill hides on this route (set in RootLayout) so the - * date-window control doesn't compete with the tab strip. + * The tab strip uses the same lifted-pill segmented control as the + * navbar range pill. The navbar's range pill is hidden on this route + * via `RootLayout`. */ export function Settings(): React.JSX.Element { const [tab, setTab] = useState('general') @@ -63,617 +53,32 @@ export function Settings(): React.JSX.Element { /> {tab === 'general' && ( <> - + - )} - {tab === 'data' && ( + {tab === 'analysis' && ( <> - + + + + )} + {tab === 'sync' && ( + <> + + + )} - {tab === 'about' && ( + {tab === 'developer' && ( <> - - + )} -
- ) -} - -// ---------- Recordings folder ------------------------------------------ - -function RecordingsCard(): React.JSX.Element { - const path = useConfigStore((s) => s.path) - const isValid = useConfigStore((s) => s.isValid) - const isInsideHome = useConfigStore((s) => s.isInsideHome) - const setPath = useConfigStore((s) => s.setPath) - const demoMode = useConfigStore((s) => s.demoMode) - const setDemoMode = useConfigStore((s) => s.setDemoMode) - const count = useDataStore((s) => s.count) - const indexedAt = useDataStore((s) => s.indexedAt) - const loading = useDataStore((s) => s.loading) - const reindexing = useDataStore((s) => s.reindexing) - const error = useDataStore((s) => s.error) - const scanErrors = useDataStore((s) => s.scanErrors) - const reindex = useDataStore((s) => s.reindex) - const totalDurationSec = useDataStore((s) => s.aggregates.overview.totalDurationSec) - // Tick once a minute so the "5m ago" string drifts naturally without - // a custom hook per stat. - const [, setNow] = useState(() => Date.now()) - useEffect(() => { - const t = window.setInterval(() => setNow(Date.now()), 60_000) - return () => window.clearInterval(t) - }, []) - - async function choose(): Promise { - const chosen = await window.api.dialog.pickFolder() - if (!chosen) return - // Order matters: persist the path first, THEN flip demo off. The - // reverse order briefly leaves the renderer in (!demoMode && !path) - // which fires the welcome-modal trigger and causes a flash before - // setPath resolves. Path-first means the in-between state is - // (demoMode && path-set) which the trigger ignores. - await setPath(chosen) - if (demoMode) await setDemoMode(false) - } - - const busy = loading || reindexing - const status: { label: string; tone: 'ok' | 'busy' | 'error' } = !isValid - ? { label: 'Path not found', tone: 'error' } - : loading - ? { label: 'Scanning…', tone: 'busy' } - : reindexing - ? { label: 'Reindexing…', tone: 'busy' } - : count > 0 - ? { label: 'All recordings indexed', tone: 'ok' } - : { label: 'Not yet indexed', tone: 'busy' } - - // Top-right of the card header now mirrors the sidebar footer pattern: - // a small status line + a refresh icon button. Folder picking lives - // inline at the right edge of the path bar. The previous "Choose folder - // / Reindex now" button row is gone — both actions are now reachable - // from the header / path bar. - const headerExtra = ( -
- - -
- ) - - return ( - -
- {/* Path + inline "Choose…" affordance. The path stretches; the - button sits on the right inside the same surface so the row - reads as one editable field. */} -
- - {path ?? 'No folder selected'} - - -
-
- - - -
- {error && ( -

- {error} -

- )} - {!error && scanErrors > 0 && ( -

- {scanErrors} recording{scanErrors === 1 ? '' : 's'} failed to parse -

- )} - {!error && path && !isInsideHome && ( -

- Path outside home directory -

- )} -
-
- ) -} - -function Stat({ label, value }: { label: string; value: string }): React.JSX.Element { - return ( -
-
- {value} -
-
{label}
-
- ) -} - -/** Status line shown in the Recordings card header. "Indexed Xm ago" / - * "Scanning…" string only — no status dot. The healthy state is the - * default and doesn't need a coloured indicator; the unhealthy states - * (error / not-found) surface via the inline status text and the - * warning rows in the body below. */ -function StatusLine({ - label, - tone, - indexedAt, - busy, - reindexing -}: { - label: string - tone: 'ok' | 'busy' | 'error' - indexedAt: string | null - busy: boolean - reindexing: boolean -}): React.JSX.Element { - const text = (() => { - if (reindexing) return 'Reindexing…' - if (busy) return label - if (tone === 'ok' && indexedAt) return `Indexed ${relativeTime(indexedAt)}` - return label - })() - return {text} -} - -function relativeTime(iso: string): string { - const t = new Date(iso).getTime() - if (isNaN(t)) return '' - const diffSec = Math.max(0, Math.floor((Date.now() - t) / 1000)) - if (diffSec < 30) return 'just now' - if (diffSec < 60) return `${diffSec}s ago` - const diffMin = Math.floor(diffSec / 60) - if (diffMin < 60) return `${diffMin}m ago` - const diffHr = Math.floor(diffMin / 60) - if (diffHr < 24) return `${diffHr}h ago` - const diffD = Math.floor(diffHr / 24) - return `${diffD}d ago` -} - -const CHROME_BUTTON = - 'inline-flex h-7 items-center gap-1.5 rounded-md border border-border bg-floating px-3 text-[12px] text-foreground transition-colors hover:bg-foreground/5 disabled:cursor-not-allowed disabled:opacity-50 disabled:hover:bg-floating' - -// ---------- Appearance --------------------------------------------------- - -function AppearanceCard(): React.JSX.Element { - return ( - - - - ) -} - -// ---------- Indexing ----------------------------------------------------- - -function IndexingCard(): React.JSX.Element { - const watchFolder = useConfigStore((s) => s.watchFolder) - const setWatchFolder = useConfigStore((s) => s.setWatchFolder) - const transcriptsOnly = useConfigStore((s) => s.transcriptsOnly) - const setTranscriptsOnly = useConfigStore((s) => s.setTranscriptsOnly) - return ( - -
- void setWatchFolder(next)} - /> - void setTranscriptsOnly(next)} - /> -
-
- ) -} - -interface ToggleRowProps { - label: string - description: string - checked: boolean - onChange: (next: boolean) => void -} - -function ToggleRow({ label, description, checked, onChange }: ToggleRowProps): React.JSX.Element { - return ( -
-
-
{label}
-
{description}
-
- -
- ) -} - -// ---------- Demo mode ---------------------------------------------------- - -function DemoModeCard(): React.JSX.Element { - const demoMode = useConfigStore((s) => s.demoMode) - const setDemoMode = useConfigStore((s) => s.setDemoMode) - return ( - - void setDemoMode(next)} - /> - - ) -} - -// ---------- Transcripts -------------------------------------------------- - -function TranscriptsCard(): React.JSX.Element { - const mode = useUiPrefsStore((s) => s.transcriptViewMode) - const setMode = useUiPrefsStore((s) => s.setTranscriptViewMode) - // 'block' = timestamps shown, 'inline' = timestamps hidden. Surface this - // as a Switch to match the Indexing / Demo data toggles — the dropdown - // it replaces felt out of place against the rest of Settings. - const showTimestamps = mode === 'block' - return ( - - setMode(next ? 'block' : 'inline')} - /> - - ) -} - -// ---------- Dictionary --------------------------------------------------- - -function DictionaryCard(): React.JSX.Element { - const fillerWords = useConfigStore((s) => s.fillerWords) - const setFillerWords = useConfigStore((s) => s.setFillerWords) - const [draft, setDraft] = useState('') - const [busy, setBusy] = useState(false) - - // Normalise the default list once so the "Reset to default" disabled - // check matches what main will actually persist. - const normalisedDefault = useMemo(() => DEFAULT_FILLER_PHRASES.map((w) => w.toLowerCase()), []) - const isDefault = - fillerWords.length === normalisedDefault.length && - fillerWords.every((w, i) => w === normalisedDefault[i]) - - async function commit(next: string[]): Promise { - setBusy(true) - try { - await setFillerWords(next) - } finally { - setBusy(false) - } - } - - async function add(): Promise { - // Run the candidate through the same canon as the persisted list, - // then dedupe against the existing entries — `normalisePhrases` returns - // an empty list if the draft is blank, a single-entry list otherwise. - // Merging then re-normalising gives us trim/lower/dedup semantics for - // free, including whitespace- and case-only duplicates. - const merged = normalisePhrases([...fillerWords, draft]) - setDraft('') - if (merged.length === fillerWords.length) return - await commit(merged) - } - - async function remove(phrase: string): Promise { - await commit(fillerWords.filter((w) => w !== phrase)) - } - - async function resetToDefault(): Promise { - await commit([...DEFAULT_FILLER_PHRASES]) - } - - return ( - - {fillerWords.length} phrase{fillerWords.length === 1 ? '' : 's'} - - } - > -
-
- {fillerWords.length === 0 ? ( - - No filler phrases configured. - - ) : ( - fillerWords.map((phrase) => ( - - {phrase} - - - )) - )} -
- { - e.preventDefault() - void add() - }} - className="flex items-center gap-2" - > - setDraft(e.target.value)} - placeholder="Add a phrase (e.g. honestly)" - disabled={busy} - className="h-7 flex-1 rounded-md border border-border bg-background px-2 text-[12.5px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-foreground/40 disabled:opacity-50" - /> - - - -
-
- ) -} - -// ---------- About -------------------------------------------------------- - -function AboutCard(): React.JSX.Element { - function openGithub(): void { - void window.api.openExternal(GITHUB_URL) - } - - // Updater state. Initialised from main on mount, then re-rendered via - // the push subscription on every status change. - const [status, setStatus] = useState({ kind: 'idle' }) - useEffect(() => { - void window.api.updater.status().then(setStatus) - const off = window.api.updater.onStatus(setStatus) - return off - }, []) - - const checking = status.kind === 'checking' || status.kind === 'downloading' - - function check(): void { - void window.api.updater.check().then(setStatus) - } - - // Layout: - // 1. Tagline + disclaimer combined into one paragraph at the top — - // saves a trailing legal-style footer and keeps the card tidy. - // 2. Version / License / Source / Updates in a single label-value - // table. The Updates row carries the current updater state + - // a manual "Check now" trigger. - // Reset lives in its own card below — see ResetAppCard. - return ( - -

- SuperWhisper Analytics is a local companion. Nothing leaves your machine.{' '} - {DISCLAIMER} -

-
- - -
-
Source
-
- -
-
-
-
Updates
-
- {describeStatus(status)} - -
-
-
-
- ) -} - -// ---------- Reset app --------------------------------------------------- - -/** - * Standalone card for the "Reset app" affordance. Lived inside AboutCard - * previously but the row was crowding the version table and breaking - * the visual rhythm of the rest of Settings — separating it gives the - * action room to breathe and signals that it's a heavier operation than - * the other About metadata. - */ -function ResetAppCard(): React.JSX.Element { - const resetApp = useConfigStore((s) => s.resetApp) - const [confirmReset, setConfirmReset] = useState(false) - const [resetting, setResetting] = useState(false) - - async function doReset(): Promise { - setResetting(true) - try { - await resetApp() - } finally { - setResetting(false) - setConfirmReset(false) - } - } - - return ( - -
- {confirmReset ? ( -
- - -
- ) : ( - - )} -
-
- ) -} - -// ---------- Developer ---------------------------------------------------- - -function DeveloperCard(): React.JSX.Element { - const devTools = useConfigStore((s) => s.devTools) - const setDevTools = useConfigStore((s) => s.setDevTools) - return ( - - void setDevTools(next)} - /> - - ) -} - -/** Plain-language summary of the updater state, shown next to the - * "Check now" button. */ -function describeStatus(s: UpdaterStatus): string { - switch (s.kind) { - case 'idle': - return 'Not checked yet' - case 'checking': - return 'Checking…' - case 'up-to-date': - return 'Up to date' - case 'available': - return `Update available (v${s.version})` - case 'downloading': - return `Downloading… ${s.percent}%` - case 'downloaded': - return `Update downloaded (v${s.version}) — restart to install` - case 'error': - return `Couldn't check (${s.message})` - } -} - -function Row({ k, v }: { k: string; v: string }): React.JSX.Element { - return ( -
-
{k}
-
{v}
+ {tab === 'about' && }
) } diff --git a/src/renderer/src/state/configStore.ts b/src/renderer/src/state/configStore.ts index 77061f0..291eed4 100644 --- a/src/renderer/src/state/configStore.ts +++ b/src/renderer/src/state/configStore.ts @@ -37,6 +37,14 @@ interface ConfigState { autoHideSidebar: boolean /** When true, DevTools are open. Persisted across restarts. */ devTools: boolean + /** Gap in minutes that defines a session boundary at Marfa sync time. + * Surfaced in Settings → Sync → Sessions pipeline. */ + sessionGapThresholdMinutes: number + /** When false, the Recordings sync pipeline is paused — no upserts, + * no soft-deletes. Existing items on the server are left alone. */ + recordingPipelineEnabled: boolean + /** When false, the Sessions sync pipeline is paused. */ + sessionPipelineEnabled: boolean /** Has the initial round-trip completed? Gates the first-run modal. */ hydrated: boolean /** Transient flag — when true, force the welcome modal regardless @@ -64,6 +72,13 @@ interface ConfigState { setAutoHideSidebar: (enabled: boolean) => Promise /** Toggle DevTools open/close. Persists across restarts. */ setDevTools: (enabled: boolean) => Promise + /** Persist the session-gap threshold (minutes). Clamped to [1, 120] + * by main. */ + setSessionGapThresholdMinutes: (minutes: number) => Promise + /** Toggle the Recordings sync pipeline. */ + setRecordingPipelineEnabled: (enabled: boolean) => Promise + /** Toggle the Sessions sync pipeline. */ + setSessionPipelineEnabled: (enabled: boolean) => Promise /** Wipe the persisted config back to defaults and force the welcome * modal to re-appear. Used by Settings → About → Reset app. */ resetApp: () => Promise @@ -84,6 +99,9 @@ function applyStatus(status: ConfigStatus): Partial { demoMode: status.demoMode, autoHideSidebar: status.autoHideSidebar, devTools: status.devTools, + sessionGapThresholdMinutes: status.sessionGapThresholdMinutes, + recordingPipelineEnabled: status.recordingPipelineEnabled, + sessionPipelineEnabled: status.sessionPipelineEnabled, hydrated: true } } @@ -99,6 +117,9 @@ export const useConfigStore = create((set, get) => ({ demoMode: false, autoHideSidebar: true, devTools: false, + sessionGapThresholdMinutes: 30, + recordingPipelineEnabled: true, + sessionPipelineEnabled: true, hydrated: false, welcomeForceShow: false, @@ -172,6 +193,27 @@ export const useConfigStore = create((set, get) => ({ set(applyStatus(updated)) }, + setSessionGapThresholdMinutes: async (minutes) => { + // Optimistic — the renderer's stepper updates instantly; main + // clamps and persists, and we replace with the canonical value on + // round-trip in case the clamp engaged. + set({ sessionGapThresholdMinutes: minutes }) + const updated = await window.api.config.setSessionGapThresholdMinutes(minutes) + set(applyStatus(updated)) + }, + + setRecordingPipelineEnabled: async (enabled) => { + set({ recordingPipelineEnabled: enabled }) + const updated = await window.api.config.setRecordingPipelineEnabled(enabled) + set(applyStatus(updated)) + }, + + setSessionPipelineEnabled: async (enabled) => { + set({ sessionPipelineEnabled: enabled }) + const updated = await window.api.config.setSessionPipelineEnabled(enabled) + set(applyStatus(updated)) + }, + resetApp: async () => { const status = await window.api.config.reset() // Apply the cleared status AND flip the force-show flag so the diff --git a/src/renderer/src/state/marfaStore.ts b/src/renderer/src/state/marfaStore.ts new file mode 100644 index 0000000..2bdbbd5 --- /dev/null +++ b/src/renderer/src/state/marfaStore.ts @@ -0,0 +1,164 @@ +import { create } from 'zustand' +import type { MarfaMapping, MarfaStatus, ProbeResult, TypeSummary } from '../../../preload/api' + +/** + * Renderer mirror of the Marfa integration's sync-engine status. + * + * Hydrated once via `window.api.marfa.status()` on first use, then kept + * fresh by the `marfa:status` push channel. The card subscribes to this + * store and renders the four card-states (disconnected / connecting / + * connected{,-with-error} / syncing) directly off the discriminated + * union. The fifth state — `disabled` — is composed in the card from + * `configStore.demoMode` + `configStore.path`; main doesn't know about + * it. + * + * The action wrappers (`connect`, `disconnect`, `syncNow`, `setEndpoint`) + * thread results back into the store optimistically. The push channel + * remains the canonical source of truth — it catches state transitions + * the round-trip doesn't (e.g. device-flow approval landing in the + * background). + * + * The mapping / type-list / mode-filter slice is read-on-demand: + * mapping hydrates alongside the initial status, type list refreshes + * on explicit call (the picker UI), mode filter mirrors persisted + * config. + */ + +interface MarfaState { + status: MarfaStatus | null + hydrated: boolean + mapping: MarfaMapping | null + modeFilter: string[] | null + typeList: TypeSummary[] | null + typeListLoading: boolean + hydrate: () => Promise + setEndpoint: (url: string) => Promise + connect: () => Promise + useApiKey: () => Promise + cancelConnect: () => Promise + submitApiKey: (key: string) => Promise + disconnect: () => Promise + syncNow: () => Promise + testSync: () => Promise + cancelSync: () => Promise + purgeAllData: () => Promise< + { ok: true; recordings: number; sessions: number } | { ok: false; error: string } + > + refreshMapping: () => Promise + setMapping: (mapping: MarfaMapping) => Promise + refreshModeFilter: () => Promise + setModeFilter: (modes: string[] | null) => Promise + refreshTypeList: () => Promise + probeConnection: () => Promise + registerType: (schema: unknown) => Promise +} + +export const useMarfaStore = create((set, get) => ({ + status: null, + hydrated: false, + mapping: null, + modeFilter: null, + typeList: null, + typeListLoading: false, + + hydrate: async () => { + if (get().hydrated) return + const [initial, mapping, modeFilter] = await Promise.all([ + window.api.marfa.status(), + window.api.marfa.getMapping(), + window.api.marfa.getModeFilter() + ]) + set({ status: initial, mapping, modeFilter, hydrated: true }) + // Subscribe to push updates. The store is a singleton in the + // renderer, so we leak no handlers on hot-reload — the IPC channel + // is one-per-window and tears down with the window. + window.api.marfa.onStatus((next) => set({ status: next })) + }, + + setEndpoint: async (url) => { + const next = await window.api.marfa.setEndpoint(url) + set({ status: next }) + }, + + connect: async () => { + const next = await window.api.marfa.connect() + set({ status: next }) + }, + + useApiKey: async () => { + const next = await window.api.marfa.useApiKey() + set({ status: next }) + }, + + cancelConnect: async () => { + const next = await window.api.marfa.cancelConnect() + set({ status: next }) + }, + + submitApiKey: async (key) => { + const next = await window.api.marfa.submitApiKey(key) + set({ status: next }) + }, + + disconnect: async () => { + const next = await window.api.marfa.disconnect() + set({ status: next }) + }, + + syncNow: async () => { + const next = await window.api.marfa.syncNow() + set({ status: next }) + }, + + testSync: async () => { + const next = await window.api.marfa.testSync() + set({ status: next }) + }, + + cancelSync: async () => { + const next = await window.api.marfa.cancelSync() + set({ status: next }) + }, + + purgeAllData: async () => { + return await window.api.marfa.purgeAllData() + }, + + refreshMapping: async () => { + const mapping = await window.api.marfa.getMapping() + set({ mapping }) + }, + + setMapping: async (mapping) => { + const persisted = await window.api.marfa.setMapping(mapping) + set({ mapping: persisted }) + }, + + refreshModeFilter: async () => { + const modeFilter = await window.api.marfa.getModeFilter() + set({ modeFilter }) + }, + + setModeFilter: async (modes) => { + const persisted = await window.api.marfa.setModeFilter(modes) + set({ modeFilter: persisted }) + }, + + refreshTypeList: async () => { + set({ typeListLoading: true }) + try { + const types = await window.api.marfa.listTypes() + set({ typeList: types, typeListLoading: false }) + } catch { + set({ typeListLoading: false }) + } + }, + + probeConnection: async () => { + return await window.api.marfa.probeConnection() + }, + + registerType: async (schema) => { + return await window.api.marfa.registerType(schema) + } +})) diff --git a/src/renderer/src/styles/tokens.css b/src/renderer/src/styles/tokens.css index fd8456d..10b8cbb 100644 --- a/src/renderer/src/styles/tokens.css +++ b/src/renderer/src/styles/tokens.css @@ -69,6 +69,15 @@ --accent-orange: #c2410c; --accent-orange-bg: #fef3e7; + /* Accent green — used by the StatusPill in settings ("Connected") and + other healthy-state indicators. Narrow palette use only. */ + --accent-green: #15803d; + --accent-green-bg: #ecfdf3; + + /* A stronger border for emphasised affordances (steppers, radio rings). + Sits one shade darker than --border. */ + --border-strong: #d4d4d4; + /* Elevation — used only by floating overlays (sidebar, palette). Cards get their lift from a brighter --card colour, not shadow. */ --shadow-float: 0 1px 2px rgba(0, 0, 0, 0.04), 0 8px 24px -4px rgba(0, 0, 0, 0.08); @@ -112,6 +121,14 @@ --accent-orange: #fb923c; --accent-orange-bg: #2a1f12; + /* Lighter green in dark mode for legibility on deeper surfaces. */ + --accent-green: #4ade80; + --accent-green-bg: rgba(74, 222, 128, 0.1); + + /* Stronger border in dark mode — mid-grey, sits between --border and + the chart-5 stop. */ + --border-strong: #4a4a4a; + /* Pulled right back in dark mode — the brighter --card and --floating colours carry the elevation cue. Just enough to avoid a hard outline. */ --shadow-float: 0 1px 2px rgba(0, 0, 0, 0.4); @@ -149,6 +166,9 @@ --color-accent-blue-bg: var(--accent-blue-bg); --color-accent-orange: var(--accent-orange); --color-accent-orange-bg: var(--accent-orange-bg); + --color-accent-green: var(--accent-green); + --color-accent-green-bg: var(--accent-green-bg); + --color-border-strong: var(--border-strong); /* Foundation aliases (kept so layout primitives still compile) */ --color-window: var(--window); diff --git a/src/shared/filler-categories.ts b/src/shared/filler-categories.ts new file mode 100644 index 0000000..9d7d6b4 --- /dev/null +++ b/src/shared/filler-categories.ts @@ -0,0 +1,250 @@ +/** + * Default filler-phrase categories used by Settings → Analysis → + * Filler dictionary. Categories are a renderer-side grouping over the + * flat `fillerWords` config — the persisted format stays as a flat + * `string[]`, but the UI displays them grouped so the user can scan, + * toggle, and edit cohesive groups rather than 170+ chips. + * + * Names are deliberately plain-English: "Emphasis words", not + * "Intensifiers"; "Conversational fillers", not "Discourse markers". + * + * `DEFAULT_FILLER_PHRASES` (in `text-metrics.ts`) is derived from this + * — one source of truth for what phrases count by default. + */ + +export interface FillerCategory { + /** Stable kebab-case slug used as a React key and for category-state + * persistence if/when we add it. */ + id: string + /** Plain-English title shown in the UI. */ + label: string + /** One-line description for the row subtitle. */ + description: string + /** Phrases that fall under this category. Order is curated. */ + phrases: readonly string[] +} + +export const DEFAULT_FILLER_CATEGORIES: readonly FillerCategory[] = [ + { + id: 'hesitations', + label: 'Hesitations', + description: 'Sounds people make while thinking — um, uh, hmm.', + phrases: [ + 'um', + 'umm', + 'uhm', + 'uh', + 'uhh', + 'uhhh', + 'er', + 'err', + 'errr', + 'erm', + 'ehm', + 'errm', + 'errrm', + 'ah', + 'ahh', + 'ahem', + 'eh', + 'hmm', + 'mhm', + 'mhmm', + 'mm', + 'mmm', + 'nnn', + 'oh', + 'ohh', + 'oof', + 'phew', + 'psst', + 'tsk', + 'duh', + 'pfft', + 'huh' + ] + }, + { + id: 'conversational-fillers', + label: 'Conversational fillers', + description: + 'Little phrases that pad speech without adding meaning — like, you know, the thing is.', + phrases: [ + 'like', + 'you know', + 'I mean', + 'so', + 'well', + 'right', + 'okay', + 'alright', + 'anyway', + 'anyhow', + 'now', + 'you see', + 'see', + 'look', + 'listen', + 'so yeah', + 'yeah so', + 'and yeah', + 'right so', + 'so basically', + 'I mean yeah', + 'yeah no', + 'no but', + 'so anyway', + 'so look', + 'you know what I mean', + 'do you know what I mean', + 'you see what I mean', + 'if that makes sense', + 'does that make sense', + 'what I mean is', + 'the thing is', + 'the fact is', + 'the point is', + 'the way I see it', + 'if you ask me', + 'in my opinion', + 'like I said', + 'as I said' + ] + }, + { + id: 'softeners', + label: 'Softeners', + description: + "Phrases that hedge or downplay what you're saying — kind of, sort of, a little bit.", + phrases: [ + 'kind of', + 'sort of', + 'kinda', + 'sorta', + 'basically', + 'essentially', + 'more or less', + 'in a way', + 'in a sense', + 'in some way', + 'somewhat', + 'roughly', + 'approximately', + 'sort of like', + 'kind of like', + 'type of thing', + 'type thing', + 'sort of a', + 'kind of a', + 'a bit of a', + 'a little bit', + 'a sort of', + 'a kind of', + 'as it were', + 'so to speak' + ] + }, + { + id: 'emphasis-words', + label: 'Emphasis words', + description: + 'Words that amplify a point but rarely add meaning — literally, actually, totally.', + phrases: [ + 'actually', + 'literally', + 'definitely', + 'totally', + 'obviously', + 'clearly', + 'honestly', + 'frankly', + 'really', + 'just', + 'pretty much', + 'absolutely', + 'completely', + 'entirely', + 'thoroughly', + 'super', + 'genuinely', + 'truly', + 'really really' + ] + }, + { + id: 'uncertainty-corrections', + label: 'Uncertainty and corrections', + description: 'Hedging your opinion or rephrasing mid-thought — I think, maybe, or rather.', + phrases: [ + 'I guess', + 'I suppose', + 'I think', + 'I would say', + "I'd say", + 'it seems', + "it's like", + 'you could say', + 'I would think', + 'I just', + 'I feel like', + 'maybe', + 'probably', + 'arguably', + 'I think so', + 'who knows', + 'or rather', + 'or actually', + 'I should say', + 'I mean to say', + 'scratch that', + 'not really', + 'not necessarily', + 'no I mean', + 'I dunno', + 'I don’t know' + ] + }, + { + id: 'vague-references', + label: 'Vague references', + description: 'Generic stand-ins and trailing closers — thing, stuff, or something, and so on.', + phrases: [ + 'thing', + 'stuff', + 'whatever', + 'something', + 'anything', + 'somehow', + 'that thing', + 'this thing', + 'those things', + 'these things', + 'all that stuff', + 'all that', + 'stuff like that', + 'things like that', + 'something like that', + 'or something', + 'or whatever', + 'or anything', + 'or so', + 'or thereabouts', + 'or such', + 'or whatnot', + 'and so on', + 'and so forth', + 'et cetera', + 'etcetera', + 'and what have you', + 'and whatnot', + 'and stuff', + 'and things' + ] + } +] as const + +/** Flat default list — derived from the categories above. Order matches + * the category traversal so existing callers see the same phrases in + * roughly the same order as before. */ +export const DEFAULT_FILLER_PHRASES_FROM_CATEGORIES: readonly string[] = + DEFAULT_FILLER_CATEGORIES.flatMap((c) => c.phrases) diff --git a/src/shared/marfa-labels.ts b/src/shared/marfa-labels.ts new file mode 100644 index 0000000..e97cad1 --- /dev/null +++ b/src/shared/marfa-labels.ts @@ -0,0 +1,76 @@ +/** + * Plain-English labels for Marfa source-field refs. Shared between + * main and renderer — main uses them when projecting (today: not yet, + * but it can grow into hover-tooltip context); renderer uses them in + * the Sync tab's field-source picker so the user reads "Transcript + * text" instead of `recording.transcript`. + * + * Pure data. No imports from electron / SDKs / main-only modules so + * the renderer can pull from it without dragging the main bundle into + * its build graph. + * + * The string literals here are intentionally duplicated from + * `RecordingSourceField` / `SessionSourceField` in `main/marfa/mapping` + * — keeping the canonical type union there lets mapping.ts continue + * to compile-time-check the field map, while this module stays free + * of any cross-boundary imports. Drift is caught by the type + * narrowing in `getSourceFieldLabel` callers. + */ + +export interface SourceFieldLabel { + /** Plain-English title. */ + label: string + /** Wire type tag — "string" / "number" / "datetime" / "array". */ + type: 'string' | 'number' | 'datetime' | 'array' +} + +const RECORDING_LABELS: Record = { + 'recording.id': { label: 'Recording id', type: 'string' }, + 'recording.datetime': { label: 'Recording start time', type: 'datetime' }, + 'recording.transcript': { label: 'Transcript text', type: 'string' }, + 'recording.rawTranscript': { label: 'Raw transcript', type: 'string' }, + 'recording.excerpt': { label: 'Excerpt', type: 'string' }, + 'recording.mode': { label: 'Mode', type: 'string' }, + 'recording.model': { label: 'Model', type: 'string' }, + 'recording.input_device': { label: 'Input device', type: 'string' }, + 'recording.appVersion': { label: 'SuperWhisper version', type: 'string' }, + 'recording.language': { label: 'Language', type: 'string' }, + 'recording.durationSeconds': { label: 'Duration (seconds)', type: 'number' }, + 'recording.segments': { label: 'Transcript segments', type: 'array' }, + 'recording.wordCount': { label: 'Word count', type: 'number' }, + 'recording.wordsPerMinute': { label: 'Words per minute', type: 'number' } +} + +const SESSION_LABELS: Record = { + 'session.sourceId': { label: 'Session id', type: 'string' }, + 'session.startedAt': { label: 'Session start', type: 'datetime' }, + 'session.endedAt': { label: 'Session end', type: 'datetime' }, + 'session.recordingCount': { label: 'Recording count', type: 'number' }, + 'session.totalDurationSeconds': { label: 'Total duration (seconds)', type: 'number' }, + 'session.dominantMode': { label: 'Dominant mode', type: 'string' }, + 'session.gapThresholdMinutes': { label: 'Session gap (minutes)', type: 'number' } +} + +/** Render-side lookup. Falls back to the raw ref string if the ref + * isn't in either dictionary. */ +export function getSourceFieldLabel(field: string): SourceFieldLabel { + return RECORDING_LABELS[field] ?? SESSION_LABELS[field] ?? { label: field, type: 'string' } +} + +/** All Recording source fields with labels — for the field-source + * picker list. */ +export function listRecordingSourceLabels(): Array<{ field: string; label: SourceFieldLabel }> { + return Object.keys(RECORDING_LABELS).map((field) => ({ + field, + label: RECORDING_LABELS[field] as SourceFieldLabel + })) +} + +/** All Session source fields with labels — for the field-source picker + * list. */ +export function listSessionSourceLabels(): Array<{ field: string; label: SourceFieldLabel }> { + return Object.keys(SESSION_LABELS).map((field) => ({ + field, + label: SESSION_LABELS[field] as SourceFieldLabel + })) +} diff --git a/src/shared/text-metrics.ts b/src/shared/text-metrics.ts index d3ad71a..3d35df8 100644 --- a/src/shared/text-metrics.ts +++ b/src/shared/text-metrics.ts @@ -124,201 +124,20 @@ export function tokenise(text: string): string[] { } /** - * Default conversational filler phrases. Used as the seed value when a - * config has no `fillerWords` field yet, and as the "Reset to default" - * target in Settings → Dictionary. Multi-word phrases match - * whitespace-flexibly so "you know" and "you know" both count. + * Default conversational filler phrases. The canonical list now lives + * in `filler-categories.ts` as a categorised structure used by the + * Settings UI; this aliased re-export keeps the flat-array contract + * for downstream callers (`buildFillers`, `cache.ts`, demo data, etc.). * - * The list spans the usual linguistic categories — interjections, - * discourse markers, hedges, vague intensifiers, epistemic phrases, - * vague references, closers, and common filler clauses. Users can prune - * what doesn't suit them from Settings → Dictionary; the canonical list - * is intentionally generous so the analytics catch most candidates. + * Spans the usual range — interjections, conversational fillers, + * softeners, emphasis words, uncertainty + corrections, and vague + * references / trailing closers. Users prune what doesn't suit them + * from Settings → Analysis → Filler dictionary. */ -export const DEFAULT_FILLER_PHRASES: readonly string[] = [ - // Pure hesitations / interjections - 'um', - 'umm', - 'uhm', - 'uh', - 'uhh', - 'uhhh', - 'er', - 'err', - 'errr', - 'erm', - 'ehm', - 'errm', - 'errrm', - 'ah', - 'ahh', - 'ahem', - 'eh', - 'hmm', - 'mhm', - 'mhmm', - 'mm', - 'mmm', - 'nnn', - 'oh', - 'ohh', - 'oof', - 'phew', - 'psst', - 'tsk', - 'duh', - 'pfft', - 'huh', - // Discourse markers - 'like', - 'you know', - 'I mean', - 'so', - 'well', - 'right', - 'okay', - 'alright', - 'anyway', - 'anyhow', - 'now', - 'you see', - 'see', - 'look', - 'listen', - 'so yeah', - 'yeah so', - 'and yeah', - 'right so', - 'so basically', - 'I mean yeah', - 'yeah no', - 'no but', - 'so anyway', - 'so look', - // Hedges - 'kind of', - 'sort of', - 'kinda', - 'sorta', - 'basically', - 'essentially', - 'more or less', - 'in a way', - 'in a sense', - 'in some way', - 'somewhat', - 'roughly', - 'approximately', - 'sort of like', - 'kind of like', - 'type of thing', - 'type thing', - // Vague intensifiers - 'actually', - 'literally', - 'definitely', - 'totally', - 'obviously', - 'clearly', - 'honestly', - 'frankly', - 'really', - 'just', - 'pretty much', - 'absolutely', - 'completely', - 'entirely', - 'thoroughly', - 'super', - 'genuinely', - 'truly', - 'really really', - // Epistemic / opinion - 'I guess', - 'I suppose', - 'I think', - 'I would say', - "I'd say", - 'it seems', - "it's like", - 'you could say', - 'I would think', - 'I just', - 'I feel like', - 'maybe', - 'probably', - 'arguably', - 'I think so', - 'who knows', - // Vague references - 'thing', - 'stuff', - 'whatever', - 'something', - 'anything', - 'somehow', - 'that thing', - 'this thing', - 'those things', - 'these things', - 'all that stuff', - 'all that', - 'stuff like that', - 'things like that', - 'something like that', - // Closers - 'or something', - 'or whatever', - 'or anything', - 'or so', - 'or thereabouts', - 'or such', - 'or whatnot', - 'and so on', - 'and so forth', - 'et cetera', - 'etcetera', - 'and what have you', - 'and whatnot', - 'and stuff', - 'and things', - // Filler clauses - 'you know what I mean', - 'do you know what I mean', - 'you see what I mean', - 'if that makes sense', - 'does that make sense', - 'what I mean is', - 'the thing is', - 'the fact is', - 'the point is', - 'the way I see it', - 'if you ask me', - 'in my opinion', - // Self-corrections - 'or rather', - 'or actually', - 'I should say', - 'I mean to say', - 'scratch that', - // Negations / push-backs - 'not really', - 'not necessarily', - 'no I mean', - // Other common - 'like I said', - 'as I said', - 'sort of a', - 'kind of a', - 'a bit of a', - 'a little bit', - 'a sort of', - 'a kind of', - 'as it were', - 'so to speak', - 'I dunno', - 'I don’t know' -] as const +import { DEFAULT_FILLER_PHRASES_FROM_CATEGORIES } from './filler-categories' + +export { DEFAULT_FILLER_CATEGORIES, type FillerCategory } from './filler-categories' +export const DEFAULT_FILLER_PHRASES: readonly string[] = DEFAULT_FILLER_PHRASES_FROM_CATEGORIES export interface FillerSummary { count: number