From 9c74f84c5b64b5c1d77f01ead925734cdca353f3 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sat, 11 Jul 2026 22:29:43 -0500 Subject: [PATCH 01/68] feat(transcript): show per-segment timestamps in live and finalized views --- src/lib/views/TranscriptNotes.svelte | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/src/lib/views/TranscriptNotes.svelte b/src/lib/views/TranscriptNotes.svelte index 490a161..a3cc08a 100644 --- a/src/lib/views/TranscriptNotes.svelte +++ b/src/lib/views/TranscriptNotes.svelte @@ -35,6 +35,17 @@ return speakers.find((s) => s.label === label)?.display_name ?? label; } + // Transcript timestamps: segment start_ms → "m:ss" (or "h:mm:ss" past an + // hour). Shown as a quiet monospace prefix so a line reads "0:42 Alice: …". + function fmtTs(ms: number): string { + const total = Math.floor(ms / 1000); + const h = Math.floor(total / 3600); + const m = Math.floor((total % 3600) / 60); + const s = total % 60; + const mm = h ? String(m).padStart(2, "0") : String(m); + return `${h ? `${h}:` : ""}${mm}:${String(s).padStart(2, "0")}`; + } + // T8.7/FR-TRX-4: the meeting view shows the language actually used, not // just the raw ISO code — falls back to the code itself if it's not in // the (curated) catalog, and to "auto-detecting…" before any is known. @@ -266,7 +277,11 @@

No transcript for this meeting.

{:else} {#each m.segments as s (s.id)} -

{speakerName(s.speaker, m.speakers)}: {s.text}

+

+ {fmtTs(s.start_ms)} + {speakerName(s.speaker, m.speakers)}: + {s.text} +

{/each} {/if} {/if} @@ -428,6 +443,7 @@ class:active={open} onclick={() => (selectedSegmentMs = open ? null : s.start_ms)} > + {fmtTs(s.start_ms)} {speakerName(s.speaker)}: {s.text} {#if hasNote} @@ -561,6 +577,14 @@ .note-badge { margin-left: 0.3rem; } + /* Transcript timestamp: a quiet monospace prefix, not competing with the + speaker name or text for attention. */ + .ts { + font-family: var(--font-mono, ui-monospace, monospace); + font-size: 0.78em; + color: var(--muted); + margin-right: 0.15rem; + } .segment-note-input { display: block; width: 100%; -- 2.34.1 From a0faaa94b428faf14f066e9a68c19a4c66f2c389 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sat, 11 Jul 2026 22:32:14 -0500 Subject: [PATCH 02/68] feat(action-items): table-backed action_items on Meeting; reconcile deletes (FR-LLM-3) --- src-tauri/src/storage/mod.rs | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index 797817a..9c7084c 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -104,6 +104,12 @@ pub struct Meeting { /// Note-template id (Phase 8, T8.1, FR-NOTE-5), if one was picked at /// creation — resolved against `notes::templates::catalog()`. pub template_id: Option, + /// Confirmed/edited action items from the `action_items` table — the + /// source of truth the user manages directly (add/edit/delete via + /// `confirm_action_items`, FR-LLM-3). Falls back to the drafts parsed + /// into `summary.json` when the table has none yet, so a freshly + /// generated summary still shows its suggestions. + pub action_items: Vec, } /// A calendar event with its attendees (T6.3/T6.4, FR-CAL-1/3) — the @@ -892,6 +898,14 @@ impl Store for SqliteStore { let summary = read_artifact(&folder.join("summary.json")) .and_then(|s| serde_json::from_str::(&s).ok()); let tags = self.tags_for_meeting(id).await?; + // Table is the source of truth for confirmed/edited items; fall back + // to the summary's parsed drafts only when nothing's been saved yet. + let mut action_items = self.list_action_items(id).await?; + if action_items.is_empty() { + if let Some(s) = &summary { + action_items = s.action_items.clone(); + } + } Ok(Meeting { id: row.get("id"), @@ -911,6 +925,7 @@ impl Store for SqliteStore { calendar_event_id: row.get("calendar_event_id"), tags, template_id: row.get("template_id"), + action_items, }) } @@ -1254,6 +1269,26 @@ impl Store for SqliteStore { items: &[ActionItem], ) -> Result, StoreError> { let now = now_unix(); + // Reconcile deletes: any row previously saved for this meeting that the + // caller no longer includes was removed in the UI (FR-LLM-3). Action + // items per meeting number in the low tens, so a per-row delete loop is + // fine. ponytail: O(n) delete scan, batch it only if n ever gets large. + let keep: std::collections::HashSet<&str> = + items.iter().filter_map(|i| i.id.as_deref()).collect(); + let existing_ids: Vec = + sqlx::query_scalar("SELECT id FROM action_items WHERE meeting_id = ?") + .bind(id) + .fetch_all(&self.pool) + .await?; + for eid in &existing_ids { + if !keep.contains(eid.as_str()) { + sqlx::query("DELETE FROM action_items WHERE id = ? AND meeting_id = ?") + .bind(eid) + .bind(id) + .execute(&self.pool) + .await?; + } + } let mut saved = Vec::with_capacity(items.len()); for item in items { let item_id = match &item.id { -- 2.34.1 From 862ab868609ada0633532901d20d85641ca3aea7 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sat, 11 Jul 2026 22:32:15 -0500 Subject: [PATCH 03/68] feat(action-items): cancel reminders for deleted items on confirm --- src-tauri/src/commands.rs | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 119ab03..e65952f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2727,6 +2727,23 @@ pub async fn confirm_action_items( meeting_id: MeetingId, items: Vec, ) -> WaResult<()> { + // Cancel reminders for items the user deleted (present in the table but no + // longer in the incoming list) — save_action_items drops the rows, but the + // scheduled OS reminder is separate state (T8.6, FR-CAL-5). + let existing = state + .store + .list_action_items(&meeting_id) + .await + .map_err(|e| WaError::new("storage", e.to_string()))?; + let keep: std::collections::HashSet<&str> = + items.iter().filter_map(|i| i.id.as_deref()).collect(); + for old in &existing { + if let Some(id) = &old.id { + if !keep.contains(id.as_str()) { + crate::reminders::cancel(id); + } + } + } let saved = state .store .save_action_items(&meeting_id, &items) @@ -4415,6 +4432,7 @@ mod tests { calendar_event_id: None, tags: Vec::new(), template_id: None, + action_items: Vec::new(), } } -- 2.34.1 From 58215076cb528554254833dbd8a4c7c0d202c871 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sat, 11 Jul 2026 22:32:27 -0500 Subject: [PATCH 04/68] feat(action-items): add action_items to Meeting type --- src/lib/api.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/api.ts b/src/lib/api.ts index 3819b80..af96c1f 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -172,6 +172,10 @@ export interface Meeting { speakers: SpeakerInfo[]; notes_markdown: string; summary: SummaryFile | null; + // Confirmed/edited action items (table-backed source of truth) — falls back + // to summary.action_items drafts until anything is saved. Manage via + // confirmActionItems (add/edit/delete). + action_items: ActionItem[]; calendar_event_id: string | null; tags: string[]; template_id: string | null; -- 2.34.1 From 836547596b7bb52a5ab92b058ccfe22368106a82 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sat, 11 Jul 2026 22:34:12 -0500 Subject: [PATCH 05/68] feat(action-items): manual add/delete/edit action items in the summary panel --- src/lib/views/SummaryPanel.svelte | 171 +++++++++++++++++++++++------- 1 file changed, 133 insertions(+), 38 deletions(-) diff --git a/src/lib/views/SummaryPanel.svelte b/src/lib/views/SummaryPanel.svelte index 1309725..6cfdd00 100644 --- a/src/lib/views/SummaryPanel.svelte +++ b/src/lib/views/SummaryPanel.svelte @@ -30,6 +30,8 @@ Check, Cpu, Globe, + Plus, + X, } from "@lucide/svelte"; onMount(() => calendar.load()); @@ -143,20 +145,47 @@ } } - // Editable copy so toggling a checkbox doesn't persist until "Save" is - // pressed; recomputes whenever a different meeting (or a freshly generated - // summary) comes in. bind:checked mutates each item in place, which a plain - // $derived array tolerates fine — only the array identity is recomputed. - let editableItems = $derived( - (meetings.selected?.summary?.action_items ?? []).map((i) => ({ ...i }) as ActionItem), - ); + // Editable working copy so add/delete/edit don't persist until "Save" is + // pressed. Resynced from the meeting's table-backed action_items whenever a + // *different* meeting is selected (same guard pattern as pendingTags), so + // it isn't clobbered mid-edit. + let editableItems = $state([]); + let loadedItemsForId: string | null = null; + $effect(() => { + const m = meetings.selected; + if (m && m.id !== loadedItemsForId) { + editableItems = m.action_items.map((i) => ({ ...i })); + loadedItemsForId = m.id; + } else if (!m) { + loadedItemsForId = null; + } + }); + + function addActionItem() { + editableItems = [ + ...editableItems, + { id: null, text: "", owner: null, due_at: null, confirmed: false, reminder_set: false }, + ]; + } + function removeActionItem(i: number) { + editableItems = editableItems.filter((_, idx) => idx !== i); + } + let savingItems = $state(false); async function saveActionItems() { const m = meetings.selected; if (!m) return; savingItems = true; try { - await meetings.confirmActionItems(m.id, editableItems); + // Drop blank rows (an added-but-never-typed item) rather than persisting + // empty tasks; keep null owner rather than "". + const items = editableItems + .filter((i) => i.text.trim()) + .map((i) => ({ ...i, owner: i.owner?.trim() || null })); + await meetings.confirmActionItems(m.id, items); + // confirmActionItems reloaded the meeting — resync so freshly inserted + // rows carry their new ids (a second save would otherwise re-insert them). + editableItems = (meetings.selected?.action_items ?? []).map((i) => ({ ...i })); } finally { savingItems = false; } @@ -709,35 +738,66 @@

{#if !meetings.selected}

Select a meeting to see its action items.

- {:else if editableItems.length === 0} -

Parsed from the summary; edit and confirm before they become tasks.

{:else} -
    - {#each editableItems as item, i (i)} -
  • - - {#if item.owner}{item.owner}{/if} - onDueDateChange(item, (e.target as HTMLInputElement).value)} - /> - -
  • - {/each} -
- + {#if editableItems.length === 0} +

+ None yet — add one below, or generate a summary to extract them automatically. +

+ {:else} +
    + {#each editableItems as item, i (i)} +
  • + + + (item.owner = (e.target as HTMLInputElement).value || null)} + placeholder="Owner" + aria-label="Owner" + /> + onDueDateChange(item, (e.target as HTMLInputElement).value)} + /> + + +
  • + {/each} +
+ {/if} +
+ + +
{/if}

@@ -982,8 +1042,7 @@ ul.action-items li { display: flex; align-items: center; - justify-content: space-between; - gap: 0.4rem; + gap: 0.35rem; padding: 0.25rem 0; border-bottom: 1px solid var(--border); } @@ -992,6 +1051,42 @@ align-items: center; gap: 0.4rem; } + .ai-text { + flex: 1; + min-width: 0; + font-size: 0.82rem; + } + .ai-owner { + flex: none; + width: 5rem; + font-size: 0.75rem; + } + .ai-del { + display: flex; + align-items: center; + flex: none; + background: none; + border: none; + color: var(--muted); + cursor: pointer; + padding: 0.15rem; + border-radius: var(--radius-sm); + } + .ai-del:hover { + color: var(--danger); + background: var(--bg-hover); + } + .ai-actions { + display: flex; + align-items: center; + gap: 0.8rem; + margin-top: 0.5rem; + } + .ai-actions .link { + display: inline-flex; + align-items: center; + gap: 0.3rem; + } .due-date { font-size: 0.75rem; padding: 0.2rem; -- 2.34.1 From 70eb182eb4eea5236f335f2858def4b72958892e Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sat, 11 Jul 2026 22:37:51 -0500 Subject: [PATCH 06/68] feat(sync): auto-resync artifacts on edit (notes/summary/transcript/tags/action items) (FR-SYNC-5) --- src-tauri/src/commands.rs | 41 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 39 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index e65952f..effc5ea 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1732,6 +1732,7 @@ pub async fn reprocess_transcript( template.as_ref(), ); let _ = state.store.update_notes(&meeting_id, ¬es_md).await; + spawn_auto_sync(&app, state.store.clone(), meeting_id.clone()); let _ = app.emit( "transcript://finalized", @@ -2044,6 +2045,7 @@ pub async fn list_meetings( /// Replaces a meeting's complete tag set (Phase 8, FR-SEARCH-2). #[tauri::command] pub async fn set_tags( + app: AppHandle, state: State<'_, AppState>, meeting_id: MeetingId, tags: Vec, @@ -2052,7 +2054,9 @@ pub async fn set_tags( .store .set_tags(&meeting_id, &tags) .await - .map_err(|e| WaError::new("storage", e.to_string())) + .map_err(|e| WaError::new("storage", e.to_string()))?; + spawn_auto_sync(&app, state.store.clone(), meeting_id); + Ok(()) } /// All known tag names, sorted, for filter/autocomplete UI (Phase 8, FR-SEARCH-2). @@ -2250,6 +2254,7 @@ pub(crate) fn cleanup_playback_temp() { #[tauri::command] pub async fn update_notes( + app: AppHandle, state: State<'_, AppState>, meeting_id: MeetingId, markdown: String, @@ -2258,7 +2263,9 @@ pub async fn update_notes( .store .update_notes(&meeting_id, &markdown) .await - .map_err(|e| WaError::new("storage", e.to_string())) + .map_err(|e| WaError::new("storage", e.to_string()))?; + spawn_auto_sync(&app, state.store.clone(), meeting_id); + Ok(()) } /// `format` ∈ `md | pdf | docx | bundle`. `dest` is a file path for @@ -2710,6 +2717,7 @@ pub async fn generate_summary( if let Err(e) = state.store.reindex_fts(&meeting_id).await { tracing::warn!("failed to reindex search after summary generation: {e}"); } + spawn_auto_sync(&app, state.store.clone(), meeting_id.clone()); let _ = app.emit( "llm://done", @@ -2723,6 +2731,7 @@ pub async fn generate_summary( /// T8.6, FR-CAL-5). #[tauri::command] pub async fn confirm_action_items( + app: AppHandle, state: State<'_, AppState>, meeting_id: MeetingId, items: Vec, @@ -2758,6 +2767,7 @@ pub async fn confirm_action_items( _ => crate::reminders::cancel(id), } } + spawn_auto_sync(&app, state.store.clone(), meeting_id); Ok(()) } @@ -3689,6 +3699,33 @@ pub(crate) async fn enqueue_meeting_sync( Ok(queued) } +/// Re-sync a meeting's artifacts after an edit (notes / summary / transcript / +/// tags / action items), mirroring sync-on-finalize (T9.5, FR-SYNC-5). A no-op +/// unless sync is enabled; only finalize-trigger targets are touched and +/// unchanged files are deduped by SHA-256, so a save that didn't alter an +/// uploaded artifact queues (and uploads) nothing. Fire-and-forget so the edit +/// command returns immediately. +/// +/// ponytail: fires on every (debounced) save; if WebDAV PUT volume from live +/// note-typing ever matters, coarsen the debounce or trigger on blur instead. +pub(crate) fn spawn_auto_sync( + app: &AppHandle, + store: std::sync::Arc, + meeting_id: MeetingId, +) { + if !load_settings().sync_enabled { + return; + } + let app = app.clone(); + tauri::async_runtime::spawn(async move { + match enqueue_meeting_sync(store.as_ref(), &meeting_id, None, true).await { + Ok(0) => {} // nothing changed + Ok(_) => pump_sync(&app, store.as_ref()).await, // upload the changes + Err(e) => tracing::warn!("auto-sync enqueue failed: {e:?}"), + } + }); +} + /// Upload one job's file: ensure the remote dir, PUT, report bytes sent. async fn upload_job( target: &dyn crate::sync::SyncTarget, -- 2.34.1 From 2b2e120f1256dd7c823ebb995e11e7913f374867 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sat, 11 Jul 2026 22:39:38 -0500 Subject: [PATCH 07/68] feat(export): MeetingBundle manifest for portable meeting export/import --- src-tauri/src/models.rs | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index 656230d..e4be205 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -193,6 +193,29 @@ pub struct ActionItem { pub reminder_set: bool, } +/// Portable meeting export manifest — the `meeting.json` inside an export +/// bundle folder (FR-STORE-4). Carries everything needed to reconstruct a +/// meeting on another machine alongside the bundle's files (`audio.wav`, +/// `transcript.json`, `notes.md`, `summary.json`). Deliberately excludes the +/// meeting id (a fresh one is minted on import to avoid collisions) and the +/// calendar-event link (event ids are machine-local). +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct MeetingBundle { + pub schema: u32, + pub title: String, + pub started_at: i64, + pub ended_at: Option, + pub duration_secs: Option, + pub language: Option, + pub backend_used: Option, + pub model_used: Option, + pub recorded: bool, + pub template_id: Option, + pub tags: Vec, + pub speakers: Vec, + pub action_items: Vec, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct CalendarEvent { pub id: String, -- 2.34.1 From 25204c02356457eefd78a4d182297c3a5925ade4 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sat, 11 Jul 2026 22:40:00 -0500 Subject: [PATCH 08/68] feat(export): set_meeting_times store method to preserve dates on import --- src-tauri/src/storage/mod.rs | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index 9c7084c..778dc96 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -343,6 +343,16 @@ pub trait Store: Send + Sync { /// Manually rename a meeting — recordings otherwise default to "Untitled /// meeting" with no other way to change that. async fn rename_meeting(&self, meeting_id: &MeetingId, title: &str) -> Result<(), StoreError>; + /// Overwrite a meeting's start/end timestamps — used by bundle import + /// (FR-STORE-4) so a moved recording keeps its original date rather than + /// showing the import time (`create_meeting`/`finalize_meeting` both stamp + /// "now"). + async fn set_meeting_times( + &self, + meeting_id: &MeetingId, + started_at: i64, + ended_at: Option, + ) -> Result<(), StoreError>; /// Names a speaker AND links them to a known `Participant` (T6.5/T6.6, /// FR-SPK-4): the display name comes from the participant record, and /// the shared `participant_id` is what gives naming "continuity" across @@ -1211,6 +1221,22 @@ impl Store for SqliteStore { Ok(()) } + async fn set_meeting_times( + &self, + meeting_id: &MeetingId, + started_at: i64, + ended_at: Option, + ) -> Result<(), StoreError> { + sqlx::query("UPDATE meetings SET started_at = ?, ended_at = ?, updated_at = ? WHERE id = ?") + .bind(started_at) + .bind(ended_at) + .bind(now_unix()) + .bind(meeting_id) + .execute(&self.pool) + .await?; + Ok(()) + } + async fn map_speaker_to_participant( &self, meeting_id: &MeetingId, -- 2.34.1 From 4dff7a58b52e3b369909ffea51400448e15b1f78 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sat, 11 Jul 2026 22:42:07 -0500 Subject: [PATCH 09/68] feat(export): portable bundle (meeting.json + summary.json) + import_meeting_bundle (FR-STORE-4) --- src-tauri/src/commands.rs | 181 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 181 insertions(+) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index effc5ea..dc74d2a 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2327,6 +2327,154 @@ pub async fn bulk_export_meetings( Ok(count) } +/// Import meeting bundle(s) exported with `format: "bundle"` (FR-STORE-4) — the +/// other half of moving recordings between computers. `dir` is either a single +/// bundle folder (contains `meeting.json`) or a parent folder of bundles (from +/// a bulk export); every bundle found is reconstructed under a fresh meeting id +/// so re-importing never collides with existing meetings. Returns the count +/// imported. +#[tauri::command] +pub async fn import_meeting_bundle( + state: State<'_, AppState>, + dir: String, +) -> WaResult { + let root = PathBuf::from(&dir); + let mut bundles: Vec = Vec::new(); + if root.join("meeting.json").exists() { + bundles.push(root.clone()); + } else { + let entries = + std::fs::read_dir(&root).map_err(|e| WaError::new("import", e.to_string()))?; + for entry in entries.flatten() { + let p = entry.path(); + if p.is_dir() && p.join("meeting.json").exists() { + bundles.push(p); + } + } + } + if bundles.is_empty() { + return Err(WaError::new( + "import", + "no meeting.json found — pick a folder exported with \"Bundle\"", + )); + } + bundles.sort(); + let mut count = 0u32; + for b in &bundles { + match import_one_bundle(&state, b).await { + Ok(_) => count += 1, + Err(e) => tracing::warn!("bundle import skipped {}: {}", b.display(), e.message), + } + } + Ok(count) +} + +/// Reconstruct one exported bundle folder into a new meeting. Order matters: +/// create → finalize (writes transcript.json/speakers/status) → restore dates → +/// copy audio/summary → notes → tags → action items. +async fn import_one_bundle(state: &State<'_, AppState>, dir: &Path) -> WaResult { + let manifest = std::fs::read_to_string(dir.join("meeting.json")) + .map_err(|e| WaError::new("import", e.to_string()))?; + let bundle: crate::models::MeetingBundle = serde_json::from_str(&manifest) + .map_err(|e| WaError::new("import", format!("bad meeting.json: {e}")))?; + + // Segments live in transcript.json (plaintext in an export); pull just the + // array rather than depending on storage's private TranscriptFile shape. + let segments: Vec = + std::fs::read_to_string(dir.join("transcript.json")) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .and_then(|v| serde_json::from_value(v.get("segments")?.clone()).ok()) + .unwrap_or_default(); + + let id = state + .store + .create_meeting(NewMeeting { + title: bundle.title.clone(), + calendar_event_id: None, + template_id: bundle.template_id.clone(), + language: bundle.language.clone(), + }) + .await + .map_err(|e| WaError::new("storage", e.to_string()))?; + + state + .store + .finalize_meeting( + &id, + FinalizeMeeting { + segments, + speakers: bundle.speakers.clone(), + duration_secs: bundle.duration_secs.unwrap_or(0), + recorded: bundle.recorded, + language: bundle.language.clone(), + backend_used: bundle.backend_used.clone(), + model_used: bundle.model_used.clone(), + }, + ) + .await + .map_err(|e| WaError::new("storage", e.to_string()))?; + + state + .store + .set_meeting_times(&id, bundle.started_at, bundle.ended_at) + .await + .map_err(|e| WaError::new("storage", e.to_string()))?; + + let dest_dir = meeting_dir(&id); + // Audio: copy byte-for-byte, mirroring the export's copy-as-is. ponytail: + // if the source machine sealed audio at rest under a different vault key, + // it won't decrypt here — cross-machine vault key transfer is out of scope; + // upgrade path is decrypting audio into the bundle on export. + let audio_src = dir.join("audio.wav"); + if audio_src.exists() { + std::fs::copy(&audio_src, dest_dir.join("audio.wav")) + .map_err(|e| WaError::new("import", e.to_string()))?; + } + // notes.md + if let Ok(notes) = std::fs::read_to_string(dir.join("notes.md")) { + state + .store + .update_notes(&id, ¬es) + .await + .map_err(|e| WaError::new("storage", e.to_string()))?; + } + // summary.json — reseal at rest when the vault is unlocked (T8.8), same as + // generate_summary's write path. + let summary_src = dir.join("summary.json"); + if summary_src.exists() { + if let Ok(raw) = std::fs::read(&summary_src) { + if let Ok(sealed) = crate::vault::seal(&raw) { + let _ = std::fs::write(dest_dir.join("summary.json"), sealed); + } + } + } + if !bundle.tags.is_empty() { + state + .store + .set_tags(&id, &bundle.tags) + .await + .map_err(|e| WaError::new("storage", e.to_string()))?; + } + if !bundle.action_items.is_empty() { + // Drop source ids so each inserts fresh under the new meeting id. + let items: Vec = bundle + .action_items + .iter() + .map(|i| ActionItem { + id: None, + ..i.clone() + }) + .collect(); + state + .store + .save_action_items(&id, &items) + .await + .map_err(|e| WaError::new("storage", e.to_string()))?; + } + Ok(id) +} + /// A filesystem-safe, collision-resistant filename stem: sanitized title + /// an id prefix, since meeting titles are very often duplicates ("Untitled /// meeting") and would otherwise silently overwrite each other in a batch. @@ -2409,6 +2557,39 @@ async fn export_meeting_to( } std::fs::write(dest_path.join("notes.md"), &meeting.notes_markdown) .map_err(|e| WaError::new("export", e.to_string()))?; + // summary.json may be vault-sealed; export the decrypted content so + // the bundle is portable (T8.8), same as transcript.json above. + let summary_src = source_dir.join("summary.json"); + if summary_src.exists() { + let bytes = std::fs::read(&summary_src) + .map_err(|e| WaError::new("export", e.to_string()))?; + let plain = + crate::vault::open(&bytes).map_err(|e| WaError::new("vault", e.to_string()))?; + std::fs::write(dest_path.join("summary.json"), plain) + .map_err(|e| WaError::new("export", e.to_string()))?; + } + // meeting.json — the portable manifest that makes a bundle + // re-importable on another machine (FR-STORE-4). meeting.action_items + // is table-backed (Feature 3), so confirmed items travel too. + let bundle = crate::models::MeetingBundle { + schema: 1, + title: meeting.title.clone(), + started_at: meeting.started_at, + ended_at: meeting.ended_at, + duration_secs: meeting.duration_secs, + language: meeting.language.clone(), + backend_used: meeting.backend_used.clone(), + model_used: meeting.model_used.clone(), + recorded: meeting.recorded, + template_id: meeting.template_id.clone(), + tags: meeting.tags.clone(), + speakers: meeting.speakers.clone(), + action_items: meeting.action_items.clone(), + }; + let manifest = serde_json::to_string_pretty(&bundle) + .map_err(|e| WaError::new("export", e.to_string()))?; + std::fs::write(dest_path.join("meeting.json"), manifest) + .map_err(|e| WaError::new("export", e.to_string()))?; } other => { return Err(WaError::new( -- 2.34.1 From 2e57ccfab5730ee41842a1175c30ab7a79aec6a8 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sat, 11 Jul 2026 22:42:08 -0500 Subject: [PATCH 10/68] feat(export): register import_meeting_bundle command --- src-tauri/src/lib.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 3b03a81..adb1a92 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -257,6 +257,7 @@ pub fn run() { commands::update_notes, commands::export_meeting, commands::bulk_export_meetings, + commands::import_meeting_bundle, commands::rename_speaker, commands::merge_speakers, commands::map_speaker_to_participant, -- 2.34.1 From 6129866ac57f00b97fb48d934402391313f69bb3 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sat, 11 Jul 2026 22:42:21 -0500 Subject: [PATCH 11/68] feat(export): importMeetingBundle api binding --- src/lib/api.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/api.ts b/src/lib/api.ts index af96c1f..c3299e2 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -477,6 +477,10 @@ export const api = { from: filter?.from, to: filter?.to, }), + // Import bundle(s) exported with format "bundle" — dir is a single bundle + // folder or a parent folder of them. Reconstructs each under a fresh id and + // returns the count imported (FR-STORE-4). + importMeetingBundle: (dir: string) => invoke("import_meeting_bundle", { dir }), llmStatus: () => invoke("llm_status"), // provider ∈ ollama|custom|anthropic|openai|off; apiKey (hosted) → OS credential store (ADR-0011). -- 2.34.1 From 23cfca7ea2a3cf76219949597ceaaae0505a2e51 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sat, 11 Jul 2026 22:43:51 -0500 Subject: [PATCH 12/68] =?UTF-8?q?feat(export):=20Export/Import=20section?= =?UTF-8?q?=20in=20Settings=20=E2=86=92=20Storage=20(FR-STORE-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/views/Settings.svelte | 68 +++++++++++++++++++++++++++++++++++ 1 file changed, 68 insertions(+) diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index 35d81e2..1b0a963 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -5,6 +5,7 @@ import { onMount } from "svelte"; import { settings } from "../stores/settings.svelte"; import { calendar } from "../stores/calendar.svelte"; + import { meetings } from "../stores/meetings.svelte"; import ConsentNotice from "../components/ConsentNotice.svelte"; import HostedAiBanner from "../components/HostedAiBanner.svelte"; import { open } from "@tauri-apps/plugin-dialog"; @@ -34,6 +35,8 @@ Globe, Download, Trash2, + FolderInput, + FolderOutput, } from "@lucide/svelte"; import { OLLAMA_OPTIONS, @@ -503,6 +506,50 @@ settings.setRetentionPolicy(age, size); } + // ---- Export / Import (FR-STORE-4) — move recordings between computers as + // portable "bundle" folders (audio + transcript + notes + summary + + // meeting.json manifest). Drop a bundle on any synced/removable drive, or a + // sync target's local mount, to carry it across. ---- + let exportBusy = $state(false); + let importBusy = $state(false); + let ioStatus = $state(null); + let ioError = $state(false); + + async function exportAllMeetings() { + const dir = await open({ directory: true, title: "Export all meetings into…" }); + if (typeof dir !== "string") return; + exportBusy = true; + ioStatus = null; + ioError = false; + try { + const n = await api.bulkExportMeetings(dir, "bundle"); + ioStatus = `Exported ${n} meeting${n === 1 ? "" : "s"}.`; + } catch (e) { + ioStatus = errorMessage(e); + ioError = true; + } finally { + exportBusy = false; + } + } + + async function importMeetings() { + const dir = await open({ directory: true, title: "Import a bundle (or a folder of bundles)…" }); + if (typeof dir !== "string") return; + importBusy = true; + ioStatus = null; + ioError = false; + try { + const n = await api.importMeetingBundle(dir); + await meetings.load(); + ioStatus = `Imported ${n} meeting${n === 1 ? "" : "s"}.`; + } catch (e) { + ioStatus = errorMessage(e); + ioError = true; + } finally { + importBusy = false; + } + } + // ---- Add-target form ---- const PROVIDERS = ["nextcloud", "owncloud", "cloudreve", "seafile", "synology", "generic"]; let form = $state(blankForm("webdav")); @@ -1022,6 +1069,27 @@ /> + +

Export & import

+

+ Move recordings between computers as portable bundle folders (audio, transcript, notes, + summary, and a meeting.json manifest). Export to any folder — a synced drive, + a USB stick, or a sync target's local mount — then import it on the other machine. + Imported meetings get a fresh id, so re-importing never overwrites anything. +

+
+ + +
+ {#if ioStatus} +

{ioStatus}

+ {/if} {:else if section === "calendar"}
-- 2.34.1 From d25c29725718240d77afa807d0ae952c560633dd Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sat, 11 Jul 2026 22:48:02 -0500 Subject: [PATCH 13/68] docs(api): document action_items on Meeting, bundle export shape, import_meeting_bundle, auto-resync --- docs/04-api-contracts.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/docs/04-api-contracts.md b/docs/04-api-contracts.md index c280765..dfa504d 100644 --- a/docs/04-api-contracts.md +++ b/docs/04-api-contracts.md @@ -63,9 +63,18 @@ map_speaker_to_participant(input: { meetingId: MeetingId; label: string; partici // yet at local-desktop meeting counts) and gained from/to date filters, per // FR-SEARCH-2's "filter by date, tag, or participant". list_meetings(input: { query?: string; tag?: string; participantId?: string; from?: number; to?: number }): MeetingListItem[] +// Meeting also carries `action_items: ActionItem[]` — table-backed (the confirmed/edited source +// of truth), falling back to summary.json drafts until any are saved (FR-LLM-3). TranscriptSegment +// keeps start_ms/end_ms; the UI renders these as per-line timestamps. get_meeting(input: { meetingId: MeetingId }): Meeting // includes transcript + speakers + summary (null until generated) delete_meeting(input: { meetingId: MeetingId }): void +// format "bundle" writes a portable folder: audio.wav + transcript.json + notes.md + summary.json +// (all decrypted) + meeting.json (the MeetingBundle manifest), re-importable on another machine. export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf" | "docx" | "bundle" }): string +// Edit commands that change an uploaded artifact — update_notes, set_tags, generate_summary, +// reprocess_transcript, confirm_action_items — auto-resync when sync is enabled (FR-SYNC-5): +// they enqueue for finalize-trigger targets and pump in the background. SHA-256 dedup means an +// edit that didn't alter a file uploads nothing. update_notes(input: { meetingId: MeetingId; markdown: string }): void // SearchHit = MeetingListItem fields (id, title, started_at, duration_secs, status, tags) + snippet: string search(input: { query: string }): SearchHit[] // FTS (FR-SEARCH-1) @@ -77,6 +86,10 @@ list_note_templates(): NoteTemplate[] // Bulk export (T8.5, FR-STORE-4): every meeting matching tag/from/to, one file (or bundle // folder) per meeting under destDir. Returns the count actually exported. bulk_export_meetings(input: { destDir: string; format: "md" | "pdf" | "docx" | "bundle"; tag?: string; from?: number; to?: number }): number +// Import bundle(s) (FR-STORE-4): `dir` is a single bundle folder (has meeting.json) or a parent +// folder of them (from a bulk export). Each is reconstructed under a fresh meeting id (original +// title/date/duration/speakers/tags/action items preserved). Returns the count imported. +import_meeting_bundle(input: { dir: string }): number // ---- LLM / AI provider (ADR-0007/0011) ---- // provider ∈ ollama | custom | anthropic | openai | off. Hosted-provider API keys are passed to -- 2.34.1 From 5564c9c9a6145a642744a1b236ffcf1f05187d1b Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sat, 11 Jul 2026 22:48:03 -0500 Subject: [PATCH 14/68] docs(data-model): document portable bundle export/import format --- docs/03-data-model.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/03-data-model.md b/docs/03-data-model.md index 9f634f7..ff5f4b8 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -23,6 +23,13 @@ Default root: `%LOCALAPPDATA%\WhispAssist\` (user-configurable, FR-STORE-2). └── .json # agent-ready spec served via the MCP `get_feature_brief` tool ``` +A **bundle export** (`export_meeting` / `bulk_export_meetings` with `format: "bundle"`, FR-STORE-4) +copies a meeting's `audio.wav`, `transcript.json`, `notes.md`, and `summary.json` (all decrypted) +into a destination folder plus a `meeting.json` manifest (the `MeetingBundle`: title, timestamps, +duration, language/backend/model, tags, speakers, and confirmed action items). `import_meeting_bundle` +reconstructs each such folder under a fresh meeting id — the portable format for moving recordings +between computers. + Rule: while a meeting is in progress a working WAV is the source of truth for crash recovery. On finalize, it is **kept** as `audio.wav` if "Record this meeting" was on, or **deleted** if not (FR-REC-1/4) — deletion happens only after `transcript.json` is finalized. `transcript.json` and -- 2.34.1 From c3da6cf0f36a38cc244809d367903145edb6158f Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sat, 11 Jul 2026 22:48:55 -0500 Subject: [PATCH 15/68] style(settings): prettier formatting for export/import copy --- src/lib/views/Settings.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index 1b0a963..2e0dbe2 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -1073,9 +1073,9 @@

Export & import

Move recordings between computers as portable bundle folders (audio, transcript, notes, - summary, and a meeting.json manifest). Export to any folder — a synced drive, - a USB stick, or a sync target's local mount — then import it on the other machine. - Imported meetings get a fresh id, so re-importing never overwrites anything. + summary, and a meeting.json manifest). Export to any folder — a synced drive, a + USB stick, or a sync target's local mount — then import it on the other machine. Imported meetings + get a fresh id, so re-importing never overwrites anything.

{/each} {/if} {/if} @@ -379,6 +433,13 @@
{#if notesPreview} @@ -577,6 +638,30 @@ .note-badge { margin-left: 0.3rem; } + /* Finalized-transcript line: a click-to-seek button styled to read as plain + transcript text, highlighted while it's the segment currently playing. */ + .seg { + display: block; + width: 100%; + text-align: left; + background: transparent; + border: none; + border-left: 2px solid transparent; + color: inherit; + font: inherit; + line-height: 1.5; + padding: 0.15rem 0.4rem; + border-radius: var(--radius-sm); + cursor: pointer; + transition: background-color 120ms ease-out; + } + .seg:hover { + background: var(--bg-hover); + } + .seg.active { + background: var(--accent-soft); + border-left-color: var(--accent); + } /* Transcript timestamp: a quiet monospace prefix, not competing with the speaker name or text for attention. */ .ts { -- 2.34.1 From dbe845e923166207b419a4b68610e03d8865cfab Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 09:32:23 -0500 Subject: [PATCH 19/68] =?UTF-8?q?feat(export):=20'obsidian'=20format=20?= =?UTF-8?q?=E2=80=94=20one=20vault=20note=20(notes+summary+action=20items+?= =?UTF-8?q?transcript,=20no=20audio)=20(FR-STORE-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/commands.rs | 139 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 139 insertions(+) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index dc74d2a..8d60e2c 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -2591,6 +2591,12 @@ async fn export_meeting_to( std::fs::write(dest_path.join("meeting.json"), manifest) .map_err(|e| WaError::new("export", e.to_string()))?; } + "obsidian" => { + // One self-contained vault note — no audio (FR-STORE-4). Everything + // is already on `meeting`, so this is pure formatting. + std::fs::write(dest_path, build_obsidian_note(&meeting)) + .map_err(|e| WaError::new("export", e.to_string()))?; + } other => { return Err(WaError::new( "export", @@ -2601,6 +2607,139 @@ async fn export_meeting_to( Ok(()) } +/// Render a meeting as a single portable Obsidian note (FR-STORE-4): YAML +/// frontmatter + notes + summary + decisions + action items + a timestamped +/// transcript, deliberately without audio. All fields come off the +/// already-loaded `Meeting`, so this does no I/O of its own. +fn build_obsidian_note(meeting: &crate::storage::Meeting) -> String { + use std::fmt::Write as _; + // Resolve a segment/participant speaker label to its display name, mirroring + // the frontend's `speakerName`. + let name_of = |label: &str| -> String { + meeting + .speakers + .iter() + .find(|sp| sp.label == label) + .and_then(|sp| sp.display_name.clone()) + .unwrap_or_else(|| label.to_string()) + }; + + let mut md = String::new(); + md.push_str("---\n"); + let _ = writeln!(md, "title: {}", yaml_str(&meeting.title)); + let _ = writeln!(md, "date: {}", fmt_date(meeting.started_at)); + if let Some(secs) = meeting.duration_secs { + let _ = writeln!(md, "duration: {}", fmt_duration(secs)); + } + let participants: Vec = meeting.speakers.iter().map(|s| name_of(&s.label)).collect(); + if !participants.is_empty() { + let joined = participants + .iter() + .map(|p| yaml_str(p)) + .collect::>() + .join(", "); + let _ = writeln!(md, "participants: [{joined}]"); + } + if !meeting.tags.is_empty() { + let joined = meeting + .tags + .iter() + .map(|t| yaml_str(t)) + .collect::>() + .join(", "); + let _ = writeln!(md, "tags: [{joined}]"); + } + md.push_str("source: WhispAssist\n---\n\n"); + + if !meeting.notes_markdown.trim().is_empty() { + let _ = write!(md, "## Notes\n\n{}\n\n", meeting.notes_markdown.trim()); + } + if let Some(sum) = &meeting.summary { + if !sum.summary_md.trim().is_empty() { + let _ = write!(md, "## Summary\n\n{}\n\n", sum.summary_md.trim()); + } + if !sum.decisions.is_empty() { + md.push_str("## Decisions\n\n"); + for d in &sum.decisions { + let _ = writeln!(md, "- {d}"); + } + md.push('\n'); + } + } + if !meeting.action_items.is_empty() { + md.push_str("## Action items\n\n"); + for a in &meeting.action_items { + let check = if a.confirmed { "x" } else { " " }; + let owner = a + .owner + .as_deref() + .map(|o| format!(" — {o}")) + .unwrap_or_default(); + let due = a + .due_at + .map(|d| format!(" (due {})", fmt_date(d))) + .unwrap_or_default(); + let _ = writeln!(md, "- [{check}] {}{owner}{due}", a.text); + } + md.push('\n'); + } + if !meeting.segments.is_empty() { + md.push_str("## Transcript\n\n"); + for seg in &meeting.segments { + let _ = writeln!( + md, + "**{}** {}: {}", + fmt_ts(seg.start_ms), + name_of(&seg.speaker), + seg.text.trim() + ); + } + md.push('\n'); + } + md +} + +/// Quote a value for a YAML frontmatter scalar — always double-quoted and +/// escaped, so titles/tags containing `:`/`"`/`#` can't break the block. +fn yaml_str(s: &str) -> String { + format!("\"{}\"", s.replace('\\', "\\\\").replace('"', "\\\"")) +} + +/// Unix seconds → `YYYY-MM-DD` in local time (matches how the UI shows dates). +fn fmt_date(unix: i64) -> String { + use chrono::TimeZone as _; + chrono::Local + .timestamp_opt(unix, 0) + .single() + .map(|dt| dt.format("%Y-%m-%d").to_string()) + .unwrap_or_default() +} + +/// Seconds → a compact `1h 5m` / `32m` / `48s` duration. +fn fmt_duration(secs: i64) -> String { + let secs = secs.max(0); + let (h, m, s) = (secs / 3600, (secs % 3600) / 60, secs % 60); + if h > 0 { + format!("{h}h {m}m") + } else if m > 0 { + format!("{m}m") + } else { + format!("{s}s") + } +} + +/// Milliseconds → `m:ss` (or `h:mm:ss` past an hour), matching the transcript +/// timestamp prefix shown in the UI. +fn fmt_ts(ms: u64) -> String { + let total = ms / 1000; + let (h, m, s) = (total / 3600, (total % 3600) / 60, total % 60); + if h > 0 { + format!("{h}:{m:02}:{s:02}") + } else { + format!("{m}:{s:02}") + } +} + // ---- LLM (Phase 5) ---- /// Builds the configured `LlmProvider`, or `None` if LLM integration is off -- 2.34.1 From 8ca6cf4f8976ec20bf4109d10c071911f4ca45a2 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 09:32:49 -0500 Subject: [PATCH 20/68] feat(export): add 'obsidian' to exportMeeting format union (FR-STORE-4) --- src/lib/api.ts | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index c3299e2..adedec0 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -460,9 +460,13 @@ export const api = { deleteMeeting: (meetingId: MeetingId) => invoke("delete_meeting", { meetingId }), updateNotes: (meetingId: MeetingId, markdown: string) => invoke("update_notes", { meetingId, markdown }), - // dest is a file path for md/pdf/docx, a folder for bundle. - exportMeeting: (meetingId: MeetingId, dest: string, format: "md" | "pdf" | "docx" | "bundle") => - invoke("export_meeting", { meetingId, dest, format }), + // dest is a file path for md/pdf/docx/obsidian, a folder for bundle. + // "obsidian" writes one self-contained vault note (no audio) — FR-STORE-4. + exportMeeting: ( + meetingId: MeetingId, + dest: string, + format: "md" | "pdf" | "docx" | "bundle" | "obsidian", + ) => invoke("export_meeting", { meetingId, dest, format }), // Every meeting matching tag/date filters, one file (or bundle folder) per // meeting under destDir. Returns the count actually exported (T8.5, FR-STORE-4). bulkExportMeetings: ( -- 2.34.1 From b98c258c135c4b7e882c9d920e0a3ef12befeb42 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 09:33:59 -0500 Subject: [PATCH 21/68] feat(i18n): English translation baseline (en.json) --- src/lib/i18n/en.json | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 src/lib/i18n/en.json diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json new file mode 100644 index 0000000..14a5537 --- /dev/null +++ b/src/lib/i18n/en.json @@ -0,0 +1,21 @@ +{ + "nav.recording": "Recording", + "nav.hardware": "Hardware", + "nav.storage": "Storage", + "nav.calendar": "Calendar", + "nav.sync": "Sync", + "nav.ai": "AI", + "nav.mcp": "MCP server", + "nav.privacy": "Privacy", + "nav.about": "About", + "nav.language": "Language", + + "settings.language.title": "Language", + "settings.language.display": "Display language", + "settings.language.display_hint": "The language of the app interface. Adding a language is as simple as dropping in one translation file.", + + "settings.transcription.title": "Transcription Language", + "settings.transcription.label": "Language", + "settings.transcription.auto": "Auto-detect", + "settings.transcription.applies_hint": "Applies to the next recording. The language actually used is shown on each meeting afterward." +} -- 2.34.1 From a7af623753acc504b12888919f86ee79b47b2de0 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 09:34:14 -0500 Subject: [PATCH 22/68] feat(i18n): reactive t() store with localStorage locale, English fallback --- src/lib/i18n/index.svelte.ts | 56 ++++++++++++++++++++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 src/lib/i18n/index.svelte.ts diff --git a/src/lib/i18n/index.svelte.ts b/src/lib/i18n/index.svelte.ts new file mode 100644 index 0000000..cd5eab2 --- /dev/null +++ b/src/lib/i18n/index.svelte.ts @@ -0,0 +1,56 @@ +// Minimal hand-rolled i18n — no dependency. A reactive `locale` (persisted to +// localStorage, UI ephemera like the layout store) plus a `t(key)` lookup over +// per-language JSON dictionaries. English is the fallback for any missing key, +// so a partial translation degrades to English rather than showing raw keys. +// +// Adding a language: create `.json` next to en.json, import it, and add +// it to DICTS + LOCALES below. Translating that one file is the whole job. + +import en from "./en.json"; + +type Dict = Record; + +// Register languages here. en is the source-of-truth key set + fallback. +const DICTS: Record = { en }; +export const LOCALES: { code: string; label: string }[] = [{ code: "en", label: "English" }]; + +const KEY = "wa-locale-v1"; + +function load(): string { + try { + const saved = localStorage.getItem(KEY); + if (saved && saved in DICTS) return saved; + } catch { + /* localStorage unavailable — fall through to default */ + } + return "en"; +} + +class I18n { + locale = $state(load()); + + setLocale(code: string) { + if (!(code in DICTS)) return; + this.locale = code; + try { + localStorage.setItem(KEY, code); + } catch { + /* non-fatal: preference just won't persist */ + } + } + + /** Look up `key` in the active locale, falling back to English then the key + * itself. `vars` fills `{name}` placeholders. Reads `locale` so components + * that call `t()` in markup re-render when the language changes. */ + t = (key: string, vars?: Record): string => { + const dict = DICTS[this.locale] ?? en; + let s = dict[key] ?? (en as Dict)[key] ?? key; + if (vars) { + for (const [k, v] of Object.entries(vars)) s = s.replaceAll(`{${k}}`, String(v)); + } + return s; + }; +} + +export const i18n = new I18n(); +export const t = i18n.t; -- 2.34.1 From 030b06329a19fd790ebb61ae1f97add0eda4c10c Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 09:35:22 -0500 Subject: [PATCH 23/68] feat(i18n): display-language selector in Settings + t() on nav and transcription-language strings --- src/lib/views/Settings.svelte | 60 +++++++++++++++++++++++++---------- 1 file changed, 43 insertions(+), 17 deletions(-) diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index 2e0dbe2..1d15f44 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -6,6 +6,7 @@ import { settings } from "../stores/settings.svelte"; import { calendar } from "../stores/calendar.svelte"; import { meetings } from "../stores/meetings.svelte"; + import { t, i18n, LOCALES } from "../i18n/index.svelte"; import ConsentNotice from "../components/ConsentNotice.svelte"; import HostedAiBanner from "../components/HostedAiBanner.svelte"; import { open } from "@tauri-apps/plugin-dialog"; @@ -49,7 +50,16 @@ let { onClose }: { onClose: () => void } = $props(); let section = $state< - "recording" | "hardware" | "storage" | "calendar" | "sync" | "ai" | "mcp" | "privacy" | "about" + | "recording" + | "hardware" + | "storage" + | "calendar" + | "sync" + | "ai" + | "mcp" + | "privacy" + | "language" + | "about" >("recording"); // The currently active whisper model (T8.7, FR-TRX-4) — gates the @@ -650,31 +660,34 @@ Settings
+ {:else if section === "language"} +
+

{t("settings.language.title")}

+ +

{t("settings.language.display_hint")}

+
{:else if section === "about"}

About

-- 2.34.1 From 3f04ec616714b3bb4d082d5ae7916189e9ddd33f Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 09:36:14 -0500 Subject: [PATCH 24/68] fix(playback): silence a11y warning on transcript scroll-intent handlers --- src/lib/views/TranscriptNotes.svelte | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/lib/views/TranscriptNotes.svelte b/src/lib/views/TranscriptNotes.svelte index 25b0066..402bc46 100644 --- a/src/lib/views/TranscriptNotes.svelte +++ b/src/lib/views/TranscriptNotes.svelte @@ -266,6 +266,9 @@ style="grid-template-columns: {splitColumns()};" bind:clientWidth={splitWidth} > +
Date: Sun, 12 Jul 2026 09:37:45 -0500 Subject: [PATCH 25/68] style(settings): prettier formatting for i18n selector --- src/lib/views/Settings.svelte | 30 ++++++++++++++++++++---------- 1 file changed, 20 insertions(+), 10 deletions(-) diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index 1d15f44..317c8cc 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -660,34 +660,44 @@ Settings
{#if bulkResult}

{bulkResult}

{/if} {#if meetings.searching} -

Searching…

+

{t("meetings.searching")}

{:else if displayItems.length === 0 && meetings.loading} -

Loading meetings…

+

{t("meetings.loading")}

{:else if displayItems.length === 0 && meetings.searchResults !== null} -

No matches.

+

{t("meetings.no_matches")}

{:else if displayItems.length === 0} -

No meetings yet — click Record above to start.

+

{t("meetings.empty")}

{:else}
    {#each displayItems as m (m.id)} @@ -159,11 +182,11 @@ {m.title} {#if m.status === "recording" || m.status === "transcribing"} - {m.status} + {statusLabel(m.status)} {:else if m.status === "recovering"} - recovering + {statusLabel("recovering")} {:else if m.status === "error"} - error + {statusLabel("error")} {/if} @@ -175,8 +198,8 @@ {/if} {#if m.tags.length > 0} - {#each m.tags as t (t)} - {t} + {#each m.tags as tag (tag)} + {tag} {/each} {/if} @@ -185,14 +208,14 @@ {#if m.status === "recovering"} {/if} -- 2.34.1 From aac88bfbc5fd7b8ddaa7ba2e33a3f2e07ab3cc74 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 11:03:42 -0500 Subject: [PATCH 31/68] feat(i18n): convert App shell chrome to t() (Batch A) --- src/App.svelte | 97 +++++++++++++++++++++++++------------------------- 1 file changed, 49 insertions(+), 48 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index 21777fb..8baa9e3 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -18,6 +18,7 @@ import ThemeToggle from "./lib/components/ThemeToggle.svelte"; import Splitter from "./lib/components/Splitter.svelte"; import { layout, clamp } from "./lib/stores/layout.svelte"; + import { t } from "./lib/i18n/index.svelte"; import { Circle, Square, @@ -62,7 +63,7 @@ vaultLocked = false; meetings.load(); // refresh now that encrypted content is readable } catch { - vaultErr = "Incorrect password"; + vaultErr = t("app.vault_incorrect"); } } @@ -134,7 +135,7 @@ checkVault(); api .listNoteTemplates() - .then((t) => (noteTemplates = t)) + .then((tpls) => (noteTemplates = tpls)) .catch(() => (noteTemplates = [])); const media = window.matchMedia("(prefers-color-scheme: dark)"); @@ -160,7 +161,7 @@ } async function cancelRecording() { - if (!confirm("Discard this recording? Its audio and transcript will be deleted.")) return; + if (!confirm(t("app.discard_confirm"))) return; await recording.cancel(); meetings.deselect(); } @@ -186,9 +187,9 @@ $effect(() => { const current = recording.state; if (current !== previousRecordingState) { - if (current === "recording") recordingAnnouncement = "Recording started"; - else if (current === "paused") recordingAnnouncement = "Recording paused"; - else if (previousRecordingState !== "idle") recordingAnnouncement = "Recording stopped"; + if (current === "recording") recordingAnnouncement = t("app.announce_started"); + else if (current === "paused") recordingAnnouncement = t("app.announce_paused"); + else if (previousRecordingState !== "idle") recordingAnnouncement = t("app.announce_stopped"); previousRecordingState = current; } }); @@ -232,58 +233,54 @@
    {recordingAnnouncement}
    WhispAssist - local · private + {t("app.tagline")}
    {#if recording.state === "idle"} {:else} - - Recording… + {t("app.recording")} {#if settings.hardware} - {settings.hardware.active} + {settings.hardware.active} {/if} -
-- 2.34.1 From b295e1ee833498419d6a99a596f9f76f1963435c Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 12:29:24 -0500 Subject: [PATCH 42/68] feat(i18n): Settings storage-section keys (Batch E) --- src/lib/i18n/en.json | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 999c744..e657652 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -63,6 +63,28 @@ "settings.hardware.diarization": "Speaker diarization", "settings.hardware.diarization_hint": "Install both models to have finished recordings separated by speaker (Speaker 1, Speaker 2, …) instead of one running transcript. Runs fully offline, once per meeting after it stops. Without them, every line is attributed to a single speaker.", + "settings.storage.title": "Storage", + "settings.storage.location_label": "Location", + "settings.storage.location_hint": "Changing the storage location isn't supported yet — this is where meetings, models, and the database currently live.", + "settings.storage.retention_title": "Retention", + "settings.storage.retention_hint": "Automatically delete the oldest meetings past a limit. Checked once at startup; empty means no limit. Never touches a meeting that's currently recording.", + "settings.storage.max_age": "Max age (days)", + "settings.storage.max_size": "Max size (GB)", + "settings.storage.no_limit": "no limit", + "settings.storage.export_import_title": "Export & import", + "settings.storage.export_hint_1": "Move recordings between computers as portable bundle folders (audio, transcript, notes, summary, and a", + "settings.storage.export_hint_2": "manifest). Export to any folder — a synced drive, a USB stick, or a sync target's local mount — then import it on the other machine. Imported meetings get a fresh id, so re-importing never overwrites anything.", + "settings.storage.export_dialog_title": "Export all meetings into…", + "settings.storage.import_dialog_title": "Import a bundle (or a folder of bundles)…", + "settings.storage.exporting": "Exporting…", + "settings.storage.export_all": "Export all meetings…", + "settings.storage.importing": "Importing…", + "settings.storage.import_all": "Import meetings…", + "settings.storage.exported_one": "Exported {n} meeting.", + "settings.storage.exported_many": "Exported {n} meetings.", + "settings.storage.imported_one": "Imported {n} meeting.", + "settings.storage.imported_many": "Imported {n} meetings.", + "app.tagline": "local · private", "app.note_template": "Note template", "app.no_template": "No template", -- 2.34.1 From 12388b8bd936f7cba4f429155b360eea7fd3b730 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 12:29:25 -0500 Subject: [PATCH 43/68] feat(i18n): convert Settings storage section to t() (Batch E) --- src/lib/views/Settings.svelte | 53 ++++++++++++++++++----------------- 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index 7d03615..b34e4d4 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -526,14 +526,17 @@ let ioError = $state(false); async function exportAllMeetings() { - const dir = await open({ directory: true, title: "Export all meetings into…" }); + const dir = await open({ directory: true, title: t("settings.storage.export_dialog_title") }); if (typeof dir !== "string") return; exportBusy = true; ioStatus = null; ioError = false; try { const n = await api.bulkExportMeetings(dir, "bundle"); - ioStatus = `Exported ${n} meeting${n === 1 ? "" : "s"}.`; + ioStatus = + n === 1 + ? t("settings.storage.exported_one", { n }) + : t("settings.storage.exported_many", { n }); } catch (e) { ioStatus = errorMessage(e); ioError = true; @@ -543,7 +546,7 @@ } async function importMeetings() { - const dir = await open({ directory: true, title: "Import a bundle (or a folder of bundles)…" }); + const dir = await open({ directory: true, title: t("settings.storage.import_dialog_title") }); if (typeof dir !== "string") return; importBusy = true; ioStatus = null; @@ -551,7 +554,10 @@ try { const n = await api.importMeetingBundle(dir); await meetings.load(); - ioStatus = `Imported ${n} meeting${n === 1 ? "" : "s"}.`; + ioStatus = + n === 1 + ? t("settings.storage.imported_one", { n }) + : t("settings.storage.imported_many", { n }); } catch (e) { ioStatus = errorMessage(e); ioError = true; @@ -1069,53 +1075,48 @@ {:else if section === "storage"}
-

Storage

-
Location{settings.settings.storage_root}
-

- Changing the storage location isn't supported yet — this is where meetings, models, and - the database currently live. -

-

Retention

-

- Automatically delete the oldest meetings past a limit. Checked once at startup; empty - means no limit. Never touches a meeting that's currently recording. -

+

{t("settings.storage.title")}

+
+ {t("settings.storage.location_label")}{settings.settings.storage_root} +
+

{t("settings.storage.location_hint")}

+

{t("settings.storage.retention_title")}

+

{t("settings.storage.retention_hint")}

-

Export & import

+

{t("settings.storage.export_import_title")}

- Move recordings between computers as portable bundle folders (audio, transcript, notes, - summary, and a meeting.json manifest). Export to any folder — a synced drive, a - USB stick, or a sync target's local mount — then import it on the other machine. Imported meetings - get a fresh id, so re-importing never overwrites anything. + {t("settings.storage.export_hint_1")} + meeting.json + {t("settings.storage.export_hint_2")}

{#if ioStatus} -- 2.34.1 From f1683af01ccc93c2d93b2d26bb4247a3809bfba3 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 12:30:50 -0500 Subject: [PATCH 44/68] feat(i18n): Settings calendar-section keys (Batch E) --- src/lib/i18n/en.json | 42 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index e657652..e001e70 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -85,6 +85,48 @@ "settings.storage.imported_one": "Imported {n} meeting.", "settings.storage.imported_many": "Imported {n} meetings.", + "settings.calendar.title": "Calendar & Outlook .pst", + "settings.calendar.intro_1": "Import events and attendees from a local Outlook", + "settings.calendar.intro_2": "backup — read-only, nothing is written back to the file. Nothing leaves this device.", + "settings.calendar.pst_file": ".pst file", + "settings.calendar.no_file": "No file selected", + "settings.calendar.browse": "Browse…", + "settings.calendar.password": "Password", + "settings.calendar.password_note": "rarely needed", + "settings.calendar.optional": "optional", + "settings.calendar.import_range": "Import range", + "settings.calendar.range_30": "Last 30 days", + "settings.calendar.range_90": "Last 90 days", + "settings.calendar.range_180": "Last 6 months", + "settings.calendar.range_365": "Last 1 year", + "settings.calendar.range_all": "All time", + "settings.calendar.range_hint": "A long-lived mailbox can hold years of recurring/holiday entries — narrowing the range keeps the imported calendar to what's actually relevant. Applies to both this Import button and automatic re-import on launch.", + "settings.calendar.importing": "Importing…", + "settings.calendar.import": "Import", + "settings.calendar.import_progress": "{processed}/{total} events", + "settings.calendar.auto_reimport": "Re-import this file automatically on launch", + "settings.calendar.auto_reimport_hint": "Runs once at startup, not on a timer — re-import is safe to repeat (existing events are matched and updated, not duplicated).", + "settings.calendar.import_failed": "Import failed: {error} — the file itself is untouched; check the path and try again.", + "settings.calendar.cleanup_title": "Clean up", + "settings.calendar.cleanup_hint": "Events already attached to a recorded meeting are always kept, no matter which option below is picked.", + "settings.calendar.cleanup_30": "Older than 30 days", + "settings.calendar.cleanup_90": "Older than 90 days", + "settings.calendar.cleanup_180": "Older than 6 months", + "settings.calendar.cleanup_365": "Older than 1 year", + "settings.calendar.cleanup_all": "Delete all", + "settings.calendar.cleaning": "Cleaning up…", + "settings.calendar.cleanup_btn": "Clean up calendar", + "settings.calendar.cleanup_result": "Deleted {deleted}{extra}.", + "settings.calendar.cleanup_kept": ", kept {n} (linked to a meeting)", + "settings.calendar.cleanup_failed": "Clean up failed: {error}", + "settings.calendar.imported_events": "Imported events", + "settings.calendar.no_events": "No events imported yet.", + "settings.calendar.search_title": "Search title", + "settings.calendar.search_placeholder": "Meeting name…", + "settings.calendar.date_label": "Date", + "settings.calendar.events_count": "{shown} of {total} events", + "settings.calendar.untitled": "(untitled)", + "app.tagline": "local · private", "app.note_template": "Note template", "app.no_template": "No template", -- 2.34.1 From 802e30e9c67bbb9f2e4b85c811119b1564380e90 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 12:30:51 -0500 Subject: [PATCH 45/68] feat(i18n): convert Settings calendar section to t() (Batch E) --- src/lib/views/Settings.svelte | 111 +++++++++++++++++++--------------- 1 file changed, 61 insertions(+), 50 deletions(-) diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index b34e4d4..6763b2d 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -1125,46 +1125,54 @@
{:else if section === "calendar"}
-

Calendar & Outlook .pst

+

{t("settings.calendar.title")}

- Import events and attendees from a local Outlook .pst backup — read-only, nothing - is written back to the file. Nothing leaves this device. + {t("settings.calendar.intro_1")} .pst + {t("settings.calendar.intro_2")}

-

- A long-lived mailbox can hold years of recurring/holiday entries — narrowing the range - keeps the imported calendar to what's actually relevant. Applies to both this Import - button and automatic re-import on launch. -

+

{t("settings.calendar.range_hint")}

{#if calendar.importProgress} {calendar.importProgress.processed}/{calendar.importProgress.total} events{t("settings.calendar.import_progress", { + processed: calendar.importProgress.processed, + total: calendar.importProgress.total, + })} {/if}
@@ -1174,31 +1182,24 @@ checked={settings.settings.pst_auto_sync} onchange={onToggleAutoSync} /> - Re-import this file automatically on launch + {t("settings.calendar.auto_reimport")} -

- Runs once at startup, not on a timer — re-import is safe to repeat (existing events are - matched and updated, not duplicated). -

+

{t("settings.calendar.auto_reimport_hint")}

{#if calendar.importError}

- Import failed: {calendar.importError} — the file itself is untouched; check the path and try - again. + {t("settings.calendar.import_failed", { error: calendar.importError })}

{/if} -

Clean up

-

- Events already attached to a recorded meeting are always kept, no matter which option - below is picked. -

+

{t("settings.calendar.cleanup_title")}

+

{t("settings.calendar.cleanup_hint")}

{#if cleanupResult}

- Deleted {cleanupResult.deleted}{cleanupResult.protected - ? `, kept ${cleanupResult.protected} (linked to a meeting)` - : ""}. + {t("settings.calendar.cleanup_result", { + deleted: cleanupResult.deleted, + extra: cleanupResult.protected + ? t("settings.calendar.cleanup_kept", { n: cleanupResult.protected }) + : "", + })}

{/if} {#if cleanupError} -

Clean up failed: {cleanupError}

+

{t("settings.calendar.cleanup_failed", { error: cleanupError })}

{/if} -

Imported events

+

{t("settings.calendar.imported_events")}

{#if calendar.events.length === 0} -

No events imported yet.

+

{t("settings.calendar.no_events")}

{:else}

- {filteredEvents.length} of {calendar.events.length} events + {t("settings.calendar.events_count", { + shown: filteredEvents.length, + total: calendar.events.length, + })}

    {#each filteredEvents as ev (ev.id)}
  • - {ev.subject ?? "(untitled)"} + {ev.subject ?? t("settings.calendar.untitled")} {formatEventDate(ev.starts_at)} {#if ev.organizer}· {ev.organizer}{/if}
  • -- 2.34.1 From 698fb26b0aa5031daf4d4769bf71c83eea1d26a6 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 12:32:53 -0500 Subject: [PATCH 46/68] feat(i18n): Settings sync-section keys + shared Close button (Batch E) --- src/lib/i18n/en.json | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index e001e70..2d2a8db 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -127,6 +127,50 @@ "settings.calendar.events_count": "{shown} of {total} events", "settings.calendar.untitled": "(untitled)", + "settings.close": "Close", + "settings.close_title": "Close (Esc)", + + "settings.sync.stub": "Preview mode — the sync backend isn't implemented yet (Phase 9). Changes are kept in the UI only.", + "settings.sync.title": "Sync & upload", + "settings.sync.enable_label": "Enable uploading meeting artifacts to configured targets", + "settings.sync.enable_hint": "Off by default. Nothing is uploaded unless this is on and a target is enabled. Self-hosted targets keep data on your own server; third-party clouds are clearly labeled.", + "settings.sync.targets_title": "Targets", + "settings.sync.no_targets": "No targets yet. Add one below.", + "settings.sync.third_party": "third-party", + "settings.sync.your_server": "your server", + "settings.sync.edit": "Edit", + "settings.sync.remove": "Remove", + "settings.sync.edit_target": "Edit target", + "settings.sync.add_target": "Add a target", + "settings.sync.webdav_hint": "WebDAV covers Nextcloud, ownCloud, Cloudreve, Seafile, and Synology. (Seafile: enable SeafDAV server-side.)", + "settings.sync.name": "Name", + "settings.sync.name_placeholder": "Home Nextcloud", + "settings.sync.provider": "Provider", + "settings.sync.server_url": "Server URL", + "settings.sync.server_url_note_1": "Just your server URL — WhispAssist adds", + "settings.sync.server_url_note_2": "automatically.", + "settings.sync.remote_folder": "Remote folder", + "settings.sync.username": "Username", + "settings.sync.app_password": "App password", + "settings.sync.pw_keep": "leave blank to keep current password", + "settings.sync.pw_store": "stored in OS credential store", + "settings.sync.upload_legend": "Upload", + "settings.sync.artifact_transcript": "transcript", + "settings.sync.artifact_notes": "notes", + "settings.sync.artifact_summary": "summary", + "settings.sync.artifact_recording": "recording (.wav, if retained)", + "settings.sync.allow_plaintext": "Allow plaintext http for a LAN address (not recommended)", + "settings.sync.encrypt_before": "Encrypt before upload (destination stores only ciphertext)", + "settings.sync.requires_vault": "— requires an unlocked vault", + "settings.sync.test_connection": "Test connection", + "settings.sync.save_changes": "Save changes", + "settings.sync.add_target_btn": "Add target", + "settings.sync.cancel": "Cancel", + "settings.sync.third_party_banner": "{kind} is a third-party cloud — uploading sends your data off your device to {kind}.", + "settings.sync.link_account": "Link {kind} account…", + "settings.sync.oauth_hint": "Opens an OAuth sign-in (loopback redirect); the token is stored in your OS credential store.", + "settings.sync.footnote": "Credentials are never written to settings or the database — only the OS credential store. TLS is required for non-LAN targets.", + "app.tagline": "local · private", "app.note_template": "Note template", "app.no_template": "No template", -- 2.34.1 From 7796c5b62d52b225d3e54745f1ccfa5131bb0b94 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 12:32:54 -0500 Subject: [PATCH 47/68] feat(i18n): convert Settings sync section to t() (Batch E) --- src/lib/views/Settings.svelte | 145 +++++++++++++++++++--------------- 1 file changed, 82 insertions(+), 63 deletions(-) diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index 6763b2d..b20e0b5 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -706,16 +706,18 @@ {t("nav.about")} - {#if settings.backendStub && section === "sync"} -

    - Preview mode — the sync backend isn't implemented yet (Phase 9). Changes are kept in the UI - only. -

    +

    {t("settings.sync.stub")}

    {/if} {#if section === "recording"} @@ -1261,51 +1263,52 @@
{:else if section === "sync"}
-

Sync & upload

+

{t("settings.sync.title")}

-

- Off by default. Nothing is uploaded unless this is on and a target is enabled. Self-hosted - targets keep data on your own server; third-party clouds are clearly labeled. -

+

{t("settings.sync.enable_hint")}

-

Targets

+

{t("settings.sync.targets_title")}

{#if settings.targets.length === 0} -

No targets yet. Add one below.

+

{t("settings.sync.no_targets")}

{:else}
    - {#each settings.targets as t (t.id)} + {#each settings.targets as target (target.id)}
  • - {t.third_party ? "third-party" : "your server"}{target.third_party + ? t("settings.sync.third_party") + : t("settings.sync.your_server")} - {t.host ?? t.kind} - {#if !t.third_party} - + {target.host ?? target.kind} + {#if !target.third_party} + {/if} - settings.removeTarget(target.id)} + >{t("settings.sync.remove")}
  • {/each}
{/if} -

{editingId ? "Edit target" : "Add a target"}

+

{editingId ? t("settings.sync.edit_target") : t("settings.sync.add_target")}

{#if !editingId}
{#each ["webdav", "onedrive", "dropbox", "box"] as k (k)} @@ -1317,20 +1320,22 @@ {/if} {#if form.kind === "webdav"} -

- WebDAV covers Nextcloud, ownCloud, Cloudreve, Seafile, and Synology. (Seafile: enable - SeafDAV server-side.) -

+

{t("settings.sync.webdav_hint")}

- + - - + +
- Upload - - - + {t("settings.sync.upload_legend")} + {t("settings.sync.artifact_transcript")} + + +
+ {t("settings.sync.allow_plaintext")}
- + {editingId + ? t("settings.sync.save_changes") + : t("settings.sync.add_target_btn")} {#if editingId} - + {/if} {#if testResult} @@ -1399,27 +1417,28 @@ {:else}
- +
- - Opens an OAuth sign-in (loopback redirect); the token is stored in your OS credential - store.{t("settings.sync.link_account", { kind: form.kind })} + {t("settings.sync.oauth_hint")}
{#if settings.linkMessage}

{settings.linkMessage}

{/if} {/if} -

- Credentials are never written to settings or the database — only the OS credential store. - TLS is required for non-LAN targets. -

+

{t("settings.sync.footnote")}

{:else if section === "ai"}
-- 2.34.1 From 377a91d1b832de192d013f111e058c49bf0aadeb Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 12:35:41 -0500 Subject: [PATCH 48/68] feat(i18n): Settings AI-section keys (Batch E) --- src/lib/i18n/en.json | 45 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 2d2a8db..f6316b3 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -171,6 +171,51 @@ "settings.sync.oauth_hint": "Opens an OAuth sign-in (loopback redirect); the token is stored in your OS credential store.", "settings.sync.footnote": "Credentials are never written to settings or the database — only the OS credential store. TLS is required for non-LAN targets.", + "settings.ai.title": "AI summary provider", + "settings.ai.intro_1": "Summaries run on a local LLM by default. Point this at Ollama on this PC or another machine on your LAN (e.g.", + "settings.ai.intro_2": "— both count as local, so nothing leaves your network. Hosted providers (Anthropic) are optional, off by default, and send the transcript to a third party once you turn one on.", + "settings.ai.provider": "Provider", + "settings.ai.off": "Off", + "settings.ai.provider_ollama": "Ollama (local / LAN)", + "settings.ai.provider_custom": "Custom (OpenAI-compatible)", + "settings.ai.provider_anthropic": "Anthropic (Claude) — hosted, leaves this device", + "settings.ai.endpoint": "Endpoint", + "settings.ai.model": "Model", + "settings.ai.api_key": "API key", + "settings.ai.api_key_set_placeholder": "•••••••••••••••• (already set — leave blank to keep it)", + "settings.ai.show_api_key": "Show API key", + "settings.ai.hide_api_key": "Hide API key", + "settings.ai.anthropic_banner": "Anthropic is a hosted, third-party service — this meeting's transcript leaves your device when you generate a summary.", + "settings.ai.endpoint_banner": "This endpoint isn't on your machine or LAN — your transcript would leave your network.", + "settings.ai.saving": "Saving…", + "settings.ai.save": "Save", + "settings.ai.test_connection": "Test connection", + "settings.ai.this_hosted_provider": "this hosted provider", + "settings.ai.reachable": "Reachable", + "settings.ai.unreachable": "Unreachable", + "settings.ai.model_count_one": "· {n} model", + "settings.ai.model_count_many": "· {n} models", + "settings.ai.on_lan": "· on your machine / LAN", + "settings.ai.leaves_network": "· leaves your network", + "settings.ai.advanced_title": "Advanced Ollama configuration", + "settings.ai.changed": "{n} changed", + "settings.ai.reset_all": "Reset all to defaults", + "settings.ai.system_prompt": "System prompt", + "settings.ai.system_prompt_placeholder": "e.g. You are a concise meeting summarizer.", + "settings.ai.system_prompt_hint": "Added before WhispAssist's required output format, so your instructions can't break summary/action-item parsing.", + "settings.ai.think": "Think", + "settings.ai.think_help": "Reasoning effort (reasoning models only).", + "settings.ai.keep_alive": "Keep alive", + "settings.ai.keep_alive_help": "How long the model stays in RAM. e.g. 5m, 1h, 0 (unload), -1 (forever).", + "settings.ai.runtime_hardware": "Runtime & hardware", + "settings.ai.rarely_needed": "— rarely needed", + "settings.ai.save_advanced": "Save advanced", + "settings.ai.saved": "Saved", + "settings.ai.turn_off": "Turn off", + "settings.ai.default_value": "Default {value}.", + "settings.ai.reset_default": "Reset to default", + "settings.ai.reset_field": "Reset {name}", + "app.tagline": "local · private", "app.note_template": "Note template", "app.no_template": "No template", -- 2.34.1 From a0ad1564a84c4f02f6eeebc7beb05ec546c07df8 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 12:35:42 -0500 Subject: [PATCH 49/68] feat(i18n): convert Settings AI section to t() (Batch E) --- src/lib/views/Settings.svelte | 107 ++++++++++++++++++---------------- 1 file changed, 58 insertions(+), 49 deletions(-) diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index b20e0b5..8665e23 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -1442,52 +1442,59 @@
{:else if section === "ai"}
-

AI summary provider

+

{t("settings.ai.title")}

- Summaries run on a local LLM by default. Point this at Ollama on this PC or another - machine on your LAN (e.g. 192.168.0.x) — both count as local, so nothing - leaves your network. Hosted providers (Anthropic) are optional, off by default, and send - the transcript to a third party once you turn one on. + {t("settings.ai.intro_1")} 192.168.0.x) {t("settings.ai.intro_2")}

{#if llmProvider !== "off"}
{#if llmProvider !== "anthropic"} - + {:else} {t("settings.ai.model")}
@@ -1594,8 +1606,8 @@ {t("settings.ai.reset_all")} {/if} -

- Added before WhispAssist's required output format, so your instructions can't - break summary/action-item parsing. -

+

{t("settings.ai.system_prompt_hint")}

{#each OPTION_GROUPS as g (g)}
@@ -1637,21 +1647,19 @@ {#if g === "context"}
- Think - Reasoning effort (reasoning models only). + {t("settings.ai.think")} + {t("settings.ai.think_help")}
- Keep alive - How long the model stays in RAM. e.g. 5m, 1h, 0 (unload), -1 (forever). + {t("settings.ai.keep_alive")} + {t("settings.ai.keep_alive_help")}
@@ -1667,8 +1675,8 @@
{#each OLLAMA_OPTIONS.filter((o) => o.group === "hardware") as opt (opt.key)} @@ -1679,10 +1687,10 @@
{#if advSaved}
@@ -1690,7 +1698,8 @@ {/if} {:else}
- {t("settings.ai.turn_off")}
{/if} -- 2.34.1 From 5967fd699730524b3418e44403c308330d907cdf Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 12:37:21 -0500 Subject: [PATCH 50/68] feat(i18n): Settings MCP-section keys (Batch E) --- src/lib/i18n/en.json | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index f6316b3..04b84cf 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -216,6 +216,38 @@ "settings.ai.reset_default": "Reset to default", "settings.ai.reset_field": "Reset {name}", + "settings.mcp.title": "MCP server", + "settings.mcp.banner_1": "This lets your own coding agent (Claude Code, Codex, Copilot, OpenCode, …) pull meeting context on your local machine. Once connected,", + "settings.mcp.banner_agent": "that agent", + "settings.mcp.banner_2": "may forward what it reads to its own model provider's cloud — outside WhispAssist's control. WhispAssist itself never sends this data anywhere; the server only listens on this device (", + "settings.mcp.banner_3": ") and every read is logged below.", + "settings.mcp.enable": "Enable the MCP server", + "settings.mcp.enable_hint": "Off by default. Loopback-only, token-gated — nothing is reachable from the network.", + "settings.mcp.transport": "Transport", + "settings.mcp.transport_http": "Streamable HTTP", + "settings.mcp.transport_stdio": "stdio (agent spawns a process)", + "settings.mcp.port": "Port", + "settings.mcp.transport_change_hint": "To change transport/port, turn the server off first, then back on.", + "settings.mcp.scope_title": "Scope", + "settings.mcp.expose": "Expose", + "settings.mcp.scope_none": "None — nothing is shared", + "settings.mcp.scope_selected": "Selected — only feature briefs you've marked shared", + "settings.mcp.scope_all": "All — meetings, transcripts, action items, and shared briefs", + "settings.mcp.expose_recordings": "Also allow meetings with a saved recording (off by default — a recorded meeting's transcript is withheld even in \"All\" scope until this is on)", + "settings.mcp.token_title": "New auth token — shown once, copy it now", + "settings.mcp.token_hint": "This won't be shown again. It's stored in your OS credential store; if you lose it, turn the server off and back on to mint a new one.", + "settings.mcp.copied": "Copied", + "settings.mcp.copy": "Copy", + "settings.mcp.endpoint": "Endpoint", + "settings.mcp.endpoint_hint": "Point your agent's MCP client config at this {kind}, with the token above as a bearer credential.", + "settings.mcp.kind_command": "command", + "settings.mcp.kind_url": "URL", + "settings.mcp.access_log": "Access log", + "settings.mcp.access_log_hint": "Every tool read an agent makes, allowed or denied (FR-MCP-5).", + "settings.mcp.no_reads": "No agent has read anything yet.", + "settings.mcp.log_meeting": "meeting {id}", + "settings.mcp.refresh": "Refresh", + "app.tagline": "local · private", "app.note_template": "Note template", "app.no_template": "No template", -- 2.34.1 From d2f35c64b1dfd4e59b88a272b5c954ac445add21 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 12:37:22 -0500 Subject: [PATCH 51/68] feat(i18n): convert Settings MCP section to t() (Batch E) --- src/lib/views/Settings.svelte | 78 +++++++++++++++-------------------- 1 file changed, 34 insertions(+), 44 deletions(-) diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index 8665e23..facf8a0 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -1706,15 +1706,12 @@
{:else if section === "mcp"}
-

MCP server

+

{t("settings.mcp.title")}

@@ -1725,23 +1722,21 @@ disabled={settings.mcpSaving} onchange={(e) => toggleMcpEnabled((e.target as HTMLInputElement).checked)} /> - Enable the MCP server + {t("settings.mcp.enable")} -

- Off by default. Loopback-only, token-gated — nothing is reachable from the network. -

+

{t("settings.mcp.enable_hint")}

{#if mcpTransport === "http"} {#if settings.mcpLastToken}
-

- This won't be shown again. It's stored in your OS credential store; if you lose it, - turn the server off and back on to mint a new one. -

+

{t("settings.mcp.token_hint")}

{settings.mcpLastToken}
@@ -1808,40 +1793,45 @@ {#if settings.mcpStatus?.enabled}
- Endpoint{settings.mcpStatus.endpoint} + {t("settings.mcp.endpoint")}{settings.mcpStatus.endpoint}

- Point your agent's MCP client config at this - {settings.mcpStatus.transport === "stdio" ? "command" : "URL"}, with the token above as - a bearer credential. + {t("settings.mcp.endpoint_hint", { + kind: + settings.mcpStatus.transport === "stdio" + ? t("settings.mcp.kind_command") + : t("settings.mcp.kind_url"), + })}

{/if} -

Access log

-

Every tool read an agent makes, allowed or denied (FR-MCP-5).

+

{t("settings.mcp.access_log")}

+

{t("settings.mcp.access_log_hint")}

{#if settings.mcpAccessLog.length === 0} -

No agent has read anything yet.

+

{t("settings.mcp.no_reads")}

{:else}
    {#each settings.mcpAccessLog as entry, i (entry.at + "-" + i)}
  • {entry.tool} {#if entry.meeting_id}meeting {entry.meeting_id.slice(0, 8)}{t("settings.mcp.log_meeting", { id: entry.meeting_id.slice(0, 8) })}{/if} {#if entry.client}{entry.client}{/if} {relativeTime(entry.at)}
  • {/each}
- + {/if}
{:else if section === "privacy"} -- 2.34.1 From 0ecd6db12ee41fdeb9d42297b72c56c6d2665565 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 12:38:52 -0500 Subject: [PATCH 52/68] feat(i18n): Settings privacy-section keys (Batch E) --- src/lib/i18n/en.json | 38 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 04b84cf..8853364 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -248,6 +248,44 @@ "settings.mcp.log_meeting": "meeting {id}", "settings.mcp.refresh": "Refresh", + "settings.privacy.title": "Privacy", + "settings.privacy.intro": "What WhispAssist is actually allowed to send off this device right now. With everything off (the default), nothing leaves the device at all.", + "settings.privacy.llm_endpoint": "LLM endpoint", + "settings.privacy.off": "off", + "settings.privacy.local_only": "local-only", + "settings.privacy.leaves_device": "leaves this device", + "settings.privacy.sync_label": "Sync", + "settings.privacy.enabled": "enabled", + "settings.privacy.mcp_label": "MCP server", + "settings.privacy.mcp_on": "on · {scope}", + "settings.privacy.mcp_loopback_note": "Inbound on loopback only — it adds nothing to the egress list above. A connected agent may still forward what it reads to its own model provider; see the MCP server tab.", + "settings.privacy.egress_title": "Egress allowlist", + "settings.privacy.no_egress": "No hosts are allowlisted — WA makes no content egress.", + "settings.privacy.sync_targets_title": "Sync targets", + "settings.privacy.third_party": "third-party", + "settings.privacy.your_server": "your server", + "settings.privacy.tls": "TLS", + "settings.privacy.no_tls": "no TLS", + "settings.privacy.refresh": "Refresh", + "settings.privacy.unavailable": "Privacy self-check unavailable.", + "settings.privacy.vault_title": "Encryption vault", + "settings.privacy.vault_intro": "Encrypt notes, transcripts, and summaries at rest with a password. (Audio files are not encrypted yet.)", + "settings.privacy.vault_password": "Vault password", + "settings.privacy.enable_vault": "Enable vault", + "settings.privacy.vault_pw_hint": "Use at least 8 characters. If you forget it, encrypted content can't be recovered.", + "settings.privacy.vault_locked_1": "Vault is ", + "settings.privacy.locked_word": "locked", + "settings.privacy.vault_locked_2": ". Unlock to read encrypted meetings.", + "settings.privacy.password": "Password", + "settings.privacy.unlock": "Unlock", + "settings.privacy.vault_unlocked_1": "Vault is ", + "settings.privacy.unlocked_word": "unlocked", + "settings.privacy.vault_unlocked_2": ". New notes, transcripts, and summaries are encrypted at rest.", + "settings.privacy.lock_now": "Lock now", + "settings.privacy.change_password": "Change password", + "settings.privacy.current_password": "Current password", + "settings.privacy.new_password": "New password", + "app.tagline": "local · private", "app.note_template": "Note template", "app.no_template": "No template", -- 2.34.1 From 27a1f98051ad476873a95e54f5947bf191fe6baf Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 12:38:53 -0500 Subject: [PATCH 53/68] feat(i18n): convert Settings privacy section to t() (Batch E) --- src/lib/views/Settings.svelte | 118 +++++++++++++++++++++------------- 1 file changed, 74 insertions(+), 44 deletions(-) diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index facf8a0..4993b72 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -1836,41 +1836,51 @@ {:else if section === "privacy"}
-

Privacy

-

- What WhispAssist is actually allowed to send off this device right now. With everything - off (the default), nothing leaves the device at all. -

+

{t("settings.privacy.title")}

+

{t("settings.privacy.intro")}

{#if settings.privacy}
- LLM endpoint{settings.privacy.llmEndpoint || "off"} + {t("settings.privacy.llm_endpoint")}{settings.privacy.llmEndpoint || t("settings.privacy.off")} - {settings.privacy.llmIsLocal ? "local-only" : "leaves this device"} + {settings.privacy.llmIsLocal + ? t("settings.privacy.local_only") + : t("settings.privacy.leaves_device")}
- Sync - {settings.privacy.syncEnabled ? "enabled" : "off"} + {t("settings.privacy.sync_label")} + {settings.privacy.syncEnabled + ? t("settings.privacy.enabled") + : t("settings.privacy.off")}
- MCP server - {settings.mcpStatus?.enabled ? `on · ${settings.mcpStatus.exposeScope}` : "off"} + {t("settings.privacy.mcp_label")} + {settings.mcpStatus?.enabled + ? t("settings.privacy.mcp_on", { scope: settings.mcpStatus.exposeScope }) + : t("settings.privacy.off")}
{#if settings.mcpStatus?.enabled}

{/if} -

Egress allowlist

+

{t("settings.privacy.egress_title")}

{#if settings.privacy.allowlistedHosts.length === 0}

{:else}
    @@ -1881,70 +1891,90 @@ {/if} {#if settings.privacy.syncTargets.length > 0} -

    Sync targets

    +

    {t("settings.privacy.sync_targets_title")}

      - {#each settings.privacy.syncTargets as t (t.name)} + {#each settings.privacy.syncTargets as target (target.name)}
    • - {t.name} - {t.thirdParty ? "third-party" : "your server"}{target.name} + {target.thirdParty + ? t("settings.privacy.third_party") + : t("settings.privacy.your_server")} + {target.host} + {target.tls ? t("settings.privacy.tls") : t("settings.privacy.no_tls")} - {t.host} - {t.tls ? "TLS" : "no TLS"}
    • {/each}
    {/if} - + {:else} -

    Privacy self-check unavailable.

    +

    {t("settings.privacy.unavailable")}

    {/if} {#if vault} -

    Encryption vault

    +

    {t("settings.privacy.vault_title")}

    {#if !vault.enabled} -

    - Encrypt notes, transcripts, and summaries at rest with a password. (Audio files are - not encrypted yet.) -

    +

    {t("settings.privacy.vault_intro")}

    {t("settings.privacy.vault_password")}
    {t("settings.privacy.enable_vault")} -

    - Use at least 8 characters. If you forget it, encrypted content can't be recovered. -

    +

    {t("settings.privacy.vault_pw_hint")}

    {:else if !vault.unlocked}

    - Vault is locked. Unlock to read encrypted meetings. + {t("settings.privacy.vault_locked_1")}{t("settings.privacy.locked_word")}{t("settings.privacy.vault_locked_2")}

    - +
    - + {:else}

    - Vault is unlocked. New notes, transcripts, and summaries are - encrypted at rest. + {t("settings.privacy.vault_unlocked_1")}{t("settings.privacy.unlocked_word")}{t("settings.privacy.vault_unlocked_2")}

    - +
    - Change password + {t("settings.privacy.change_password")}
    {t("settings.privacy.current_password")} {t("settings.privacy.new_password")}
    {t("settings.privacy.change_password")}
    {/if} -- 2.34.1 From 0d8bcf3b17a358941130018df9d468ad83c183d8 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 12:39:32 -0500 Subject: [PATCH 54/68] feat(i18n): Settings about-section keys (Batch E) --- src/lib/i18n/en.json | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/lib/i18n/en.json b/src/lib/i18n/en.json index 8853364..1ad4725 100644 --- a/src/lib/i18n/en.json +++ b/src/lib/i18n/en.json @@ -286,6 +286,10 @@ "settings.privacy.current_password": "Current password", "settings.privacy.new_password": "New password", + "settings.about.title": "About", + "settings.about.tagline": "A fully local, open-source, Windows-native meeting assistant.", + "settings.about.build_commit": "Build commit", + "app.tagline": "local · private", "app.note_template": "Note template", "app.no_template": "No template", -- 2.34.1 From 1f25833e74ed25b6b062ea4be900763b94d8124d Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 12:39:33 -0500 Subject: [PATCH 55/68] feat(i18n): convert Settings about section to t() (Batch E) --- src/lib/views/Settings.svelte | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/lib/views/Settings.svelte b/src/lib/views/Settings.svelte index 4993b72..33d7bcd 100644 --- a/src/lib/views/Settings.svelte +++ b/src/lib/views/Settings.svelte @@ -1999,13 +1999,13 @@
{:else if section === "about"}
-

About

+

{t("settings.about.title")}

WhispAssist v{appInfo?.version ?? "…"}

-

A fully local, open-source, Windows-native meeting assistant.

+

{t("settings.about.tagline")}

- Build commit{appInfo?.commit ?? "…"} + {t("settings.about.build_commit")}{appInfo?.commit ?? "…"}

- + + -- 2.34.1 From 681933c8cff74ffb4849b870f6e61173bd1dbfd2 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 12:53:21 -0500 Subject: [PATCH 62/68] feat(i18n): convert HostedAiBanner to t() (Batch F) --- src/lib/components/HostedAiBanner.svelte | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/src/lib/components/HostedAiBanner.svelte b/src/lib/components/HostedAiBanner.svelte index 49faf28..83a0177 100644 --- a/src/lib/components/HostedAiBanner.svelte +++ b/src/lib/components/HostedAiBanner.svelte @@ -6,6 +6,7 @@ // (recording consent, ADR-0009): both gate a single Settings toggle AND a // second use-time trigger point on the same one-time flag. import { Globe } from "@lucide/svelte"; + import { t } from "../i18n/index.svelte"; let { providerLabel, onAccept, @@ -16,16 +17,15 @@

-- 2.34.1 From 8fc5d72576a91de86bceebbb83c069024ef0834b Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 12:53:22 -0500 Subject: [PATCH 63/68] feat(i18n): convert ThemeToggle to t() (Batch F) --- src/lib/components/ThemeToggle.svelte | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/src/lib/components/ThemeToggle.svelte b/src/lib/components/ThemeToggle.svelte index 3fca540..601524b 100644 --- a/src/lib/components/ThemeToggle.svelte +++ b/src/lib/components/ThemeToggle.svelte @@ -3,6 +3,7 @@ // plain -

-

{#if error} @@ -94,9 +119,9 @@
- +
-- 2.34.1 From 84ab88b565fb4a5247a2a55d5d2c6196c299e22f Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 12:53:25 -0500 Subject: [PATCH 65/68] feat(i18n): convert TagChip to t() (Batch F) --- src/lib/components/TagChip.svelte | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/lib/components/TagChip.svelte b/src/lib/components/TagChip.svelte index e404921..9e7c009 100644 --- a/src/lib/components/TagChip.svelte +++ b/src/lib/components/TagChip.svelte @@ -4,6 +4,7 @@ // list it's rendered in (not a global delete — the caller decides what // "remove" means). import { X } from "@lucide/svelte"; + import { t } from "../i18n/index.svelte"; interface Props { tag: string; @@ -20,12 +21,17 @@ class="label" onclick={onClick} disabled={!onClick} - title={onClick ? `Filter meetings tagged "${tag}"` : undefined} + title={onClick ? t("tagchip.filter", { tag }) : undefined} > {tag} {#if removable} - {/if} -- 2.34.1 From 4e87cab3adcebfab36351c6361a45e3d1b13e94c Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 12:53:26 -0500 Subject: [PATCH 66/68] feat(i18n): convert LevelMeter to t() (Batch F) --- src/lib/components/LevelMeter.svelte | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/lib/components/LevelMeter.svelte b/src/lib/components/LevelMeter.svelte index ed97904..f5e90c2 100644 --- a/src/lib/components/LevelMeter.svelte +++ b/src/lib/components/LevelMeter.svelte @@ -3,6 +3,7 @@ // system/loopback level (green) and — when the mic is enabled — the microphone // level overlaid in the accent colour, so both sides of the call are visible // at a glance. Each stream shows an rms fill + a peak marker. + import { t } from "../i18n/index.svelte"; let { rms, peak, @@ -29,7 +30,7 @@
Date: Sun, 12 Jul 2026 12:54:10 -0500 Subject: [PATCH 67/68] =?UTF-8?q?docs(i18n):=20mark=20Batch=20F=20done=20?= =?UTF-8?q?=E2=80=94=20migration=20complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/i18n-tracking.md | 23 ++++++++++++++++------- 1 file changed, 16 insertions(+), 7 deletions(-) diff --git a/docs/i18n-tracking.md b/docs/i18n-tracking.md index 98a5da6..fe06f1a 100644 --- a/docs/i18n-tracking.md +++ b/docs/i18n-tracking.md @@ -4,6 +4,12 @@ Working doc for the incremental UI-string translation effort. The i18n **mechani is done; this tracks moving the app's remaining hardcoded strings into the translation files, one batch at a time. Tick boxes as views are converted. +> **Status (2026-07-12): migration complete.** All views and components (Batches A–F) are +> converted; `en.json` holds ~506 keys. Every user-facing English string flows through +> `t()`. What remains is intentionally-untranslated data (see the "Not translated" section) +> — plus the actual work of adding a second language, which is now just translating +> `en.json` into a new `.json`. + - **Engine:** hand-rolled, zero-dependency. `src/lib/i18n/index.svelte.ts` - **Baseline dictionary:** `src/lib/i18n/en.json` (English is the source-of-truth key set **and** the fallback for any missing key) @@ -79,8 +85,15 @@ a list whose rows are pure user data. OLLAMA_OPTIONS param catalog labels/help left as data (config catalog, like model ids); example URLs/model-id placeholders left literal. +- [x] Components (Batch F): `ConsentNotice` + `HostedAiBanner` (legal/consent copy, + `consent.*` / `hosted.*`), `ThemeToggle` (`theme.*`), `ImportMeeting` (`import.*`), + `TagChip` (`tagchip.*`), `LevelMeter` (`levelmeter.*`). `Splitter`'s `label` is + caller-supplied and already translated by the parent; no strings of its own. + ## Outstanding — suggested batches +_None — all batches complete._ The section below is kept as a record of the plan. + Ordered roughly by user-visibility ÷ effort. Sizes are rough (line count / labeled attributes) to help portion the work, not exact string counts. A file isn't "done" until its visible text **and** its `title`/`aria-label`/`placeholder` attributes are keyed. @@ -101,13 +114,8 @@ Moved to the Done list above. All nine sections + the modal shell converted, one commit per section. Moved to the Done list above. -### Batch F — components -- [ ] `ConsentNotice.svelte` (~72 lines) — **legal/consent copy; translate carefully** -- [ ] `HostedAiBanner.svelte` (~93 lines) — third-party-egress disclosure copy -- [ ] `ImportMeeting.svelte` (~229 lines, ~5 attrs) — import dialog -- [ ] `ThemeToggle.svelte` (~72 lines) — one aria-label -- [ ] `TagChip.svelte`, `LevelMeter.svelte`, `Splitter.svelte` — check for any stray - user-facing strings (likely minimal) +### ~~Batch F — components~~ ✅ done (branch `feature_chore_bug_007`) +Moved to the Done list above. ## Not translated (intentional) @@ -131,3 +139,4 @@ Record each landed batch here (date / branch / commit) so progress is auditable. | 2026-07-12 | Batch A (App shell) + Batch B (MeetingsList) | `feature_chore_bug_007` | +~60 keys; `app.*`, `meetings.*` | | 2026-07-12 | Batch C (TranscriptNotes) + Batch D (SummaryPanel) | `feature_chore_bug_007` | +~120 keys; `transcript.*`, `notes.*`, `summary.*`; en.json now 198 keys | | 2026-07-12 | Batch E (Settings, all 9 sections + shell) | `feature_chore_bug_007` | +~270 keys; `settings.*`; en.json now 470 keys; one commit per section | +| 2026-07-12 | Batch F (components) | `feature_chore_bug_007` | +~36 keys; `consent.*`/`hosted.*`/`theme.*`/`import.*`/`tagchip.*`/`levelmeter.*`; en.json now 506 keys — migration complete | -- 2.34.1 From 52d3fcbf3be5aaa529c2bad58c64504ce9f2a96e Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Sun, 12 Jul 2026 12:56:17 -0500 Subject: [PATCH 68/68] Instruction for how to activate whispassist memory --- CLAUDE.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/CLAUDE.md b/CLAUDE.md index 08e5ddb..0f9a7d5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -21,6 +21,13 @@ Every memory operation in this session goes through MEMANTO. There is no excepti These are not suggestions. Follow each one on every turn. +0. **Activate the `whispassist` agent at the start of every session, before any memory op.** Run + `memanto agent activate whispassist` first thing. This machine hosts multiple projects and the + session-start sync may activate a *different* project's agent (e.g. `whispassist`), so the + auto-synced `MEMORY.md` can belong to the wrong project — do not trust it as LastERP context + until you've activated `whispassist` and re-synced. Confirm with `memanto agent list` (the + active one is marked). All `recall`/`remember`/`answer` calls read and write the *active* + agent's store, so getting this wrong silently pollutes or mis-reads another project's memory. 1. **Read `MEMORY.md` before doing anything.** It is auto-synced at session start and holds the user's preferences, facts, goals, instructions, decisions, and commitments from every prior session. You MUST honor what is written there. If you act against it, you are -- 2.34.1