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 @@
@@ -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.
- {activeModel ? `"${activeModel.label}" is English-only.` : "No model selected."} Switch to
- a multilingual model above to choose a language.
+ {activeModel
+ ? t("settings.transcription.model_english_only", { model: activeModel.label })
+ : t("settings.transcription.no_model")}
+ {t("settings.transcription.switch_multilingual")}
{/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.
-
- 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.
-
--
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")}
-
{: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)}
- {form.kind} is a third-party cloud — uploading sends your data off your device to {form.kind}.
+ {t("settings.sync.third_party_banner", { kind: form.kind })}
-
+
- Link {form.kind} account…
- 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")}