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 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 diff --git a/docs/04-api-contracts.md b/docs/04-api-contracts.md index c280765..af90f18 100644 --- a/docs/04-api-contracts.md +++ b/docs/04-api-contracts.md @@ -63,9 +63,21 @@ 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 -export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf" | "docx" | "bundle" }): string +// 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. +// format "obsidian" writes one self-contained vault note (dest is a .md file path, no audio): +// YAML frontmatter (title/date/duration/participants/tags/source) + notes + summary + decisions +// + action items + timestamped transcript. For dropping a meeting into an Obsidian vault. +export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf" | "docx" | "bundle" | "obsidian" }): 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 +89,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 diff --git a/docs/i18n-tracking.md b/docs/i18n-tracking.md new file mode 100644 index 0000000..fe06f1a --- /dev/null +++ b/docs/i18n-tracking.md @@ -0,0 +1,142 @@ +# i18n migration tracking + +Working doc for the incremental UI-string translation effort. The i18n **mechanism** +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) +- **Selector:** Settings → Language (`section === "language"`) +- **Scope decision:** display language is separate from the transcription `whisper_language` + setting — don't conflate them. + +## What we translate (and what we never do) + +**Translate: UI chrome only** — fixed labels, buttons, headings, placeholders, +empty/loading/status states, tooltips, and app-generated default *labels*. + +**Never translate: user-authored content.** Meeting titles, tag names, notes, transcript +text, search snippets, dates — these are bound straight from data (`item.title`, tag +values, `s.text`, …) and stay exactly as the user wrote them. If a string comes from the +user or the recording, it does not get a key. + +So "convert view X" always means "key its fixed labels", never "touch its content". Most +views are mostly content with a thin shell of labels — MeetingsList, for example, is ~10 +fixed labels (search placeholder, empty states, filter/bulk-export labels) wrapped around +a list whose rows are pure user data. + +> Caveat — the `"Untitled meeting"` **default title is generated in the Rust backend**, not +> the frontend, so it never reaches `t()`. Localizing app-generated defaults is a separate +> backend decision, out of scope for this frontend effort. + +## How to convert a string (the pattern) + +1. Add a key to `en.json`. Naming: `..`, dotted, grouped by view — + e.g. `nav.recording`, `settings.transcription.title`, `meetings.empty`. +2. Replace the literal in markup with `{t("key")}` (import `t` from `../i18n/index.svelte`). +3. Dynamic bits use placeholders: `t("meetings.count", { n })` against + `"meetings.count": "{n} meetings"`. Handle plurals with a caller-side ternary for now + (`n === 1 ? t("...one") : t("...many")`) — the engine is intentionally simple. +4. Attributes translate the same way: `title={t("...")}`, `aria-label={t("...")}`, + `placeholder={t("...")}`. +5. `npm run check` must stay at 0 errors/warnings. + +> When adding a **new language** (not covered by this doc's batches): copy `en.json` to +> `.json`, translate it, and register it in `DICTS` + `LOCALES` in +> `index.svelte.ts`. That one file is the whole job. + +## Done + +- [x] i18n engine + `en.json` baseline + Settings language selector (branch + `feature_chore_bug_007`) +- [x] `Settings.svelte` — nav labels (`nav.*`), the Language section + (`settings.language.*`), and the Transcription Language section + (`settings.transcription.*`) + +- [x] `src/App.svelte` — app shell chrome (`app.*`): header (tagline, template + picker, record/stop/cancel/add-meeting, recording status, retention, backend, + device-notice), Settings button, consent + vault-unlock dialogs, pane toggles, + splitter labels, and JS strings (discard confirm, SR announcements, vault error). +- [x] `src/lib/views/MeetingsList.svelte` — labels only (`meetings.*`): search/filter/ + bulk-export controls, empty/loading/status states, status badges (via + `statusLabel`), resume + delete, delete-confirm, and the pluralized export result. + List rows (titles/tags/dates/snippets) left as user data. +- [x] `src/lib/views/TranscriptNotes.svelte` — transcript + notes chrome (`transcript.*`, + `notes.*`): pane headings/toggles, reprocess controls, notes toolbar (bold/heading/ + list/preview), export button tooltips + dialog filter names, empty states, segment + tooltips + note placeholders. Transcript/notes/speaker text left as user data. +- [x] `src/lib/views/SummaryPanel.svelte` — all panel chrome (`summary.*`): section + headings (Recording/Sync/Tags/Summary/Briefs/Action items/Calendar/Participants/ + Speakers), buttons, placeholders, empty states, provider labels (via `providerLabel`; + brand names kept literal), action-item + brief + speaker controls. Summary text, + tags, brief content, participant/speaker names left as user data. +- [x] `src/lib/views/Settings.svelte` — **all sections** done (`settings.*`): the modal + shell (dialog title, Close), plus recording, hardware (incl. models/diarization), + storage (+ export/import status), calendar (.pst import/cleanup/events), sync + (targets + WebDAV/OAuth forms), AI (provider + Ollama advanced params), MCP + (transport/scope/token/access log), privacy (egress self-check + vault), about. + 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. + +### ~~Batch A — app shell~~ ✅ done (branch `feature_chore_bug_007`) +Moved to the Done list above. + +### ~~Batch B — meetings list~~ ✅ done (branch `feature_chore_bug_007`) +Moved to the Done list above. + +### ~~Batch C — transcript & notes~~ ✅ done (branch `feature_chore_bug_007`) +Moved to the Done list above. + +### ~~Batch D — summary panel~~ ✅ done (branch `feature_chore_bug_007`) +Moved to the Done list above. + +### ~~Batch E — Settings, all sections~~ ✅ done (branch `feature_chore_bug_007`) +All nine sections + the modal shell converted, one commit per section. Moved to the Done +list above. + +### ~~Batch F — components~~ ✅ done (branch `feature_chore_bug_007`) +Moved to the Done list above. + +## Not translated (intentional) + +- **User-authored content** — meeting titles, tag names, notes, transcript text, search + snippets. Bound from data; stays as the user wrote it (see "What we translate" above). +- App-generated defaults created in the backend (e.g. `"Untitled meeting"`) — a separate + backend concern; the frontend `t()` never sees them. +- Backend / Rust error messages surfaced via `WaError` — out of scope for the frontend + `t()`; revisit only if we localize command errors. +- Provider/proper names (Ollama, Anthropic, OpenAI, Obsidian, WebDAV, WhispAssist), + model ids, ISO language codes. +- Console/`tracing` logs. + +## Batch log + +Record each landed batch here (date / branch / commit) so progress is auditable. + +| Date | Batch | Branch / commit | Notes | +|------|-------|-----------------|-------| +| 2026-07-12 | Infra + Settings nav/language/transcription | `feature_chore_bug_007` | engine + en.json + selector | +| 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 | diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 119ab03..8d60e2c 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 @@ -2320,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. @@ -2402,6 +2557,45 @@ 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()))?; + } + "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( @@ -2413,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 @@ -2710,6 +3037,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,10 +3051,28 @@ 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, ) -> 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) @@ -2741,6 +3087,7 @@ pub async fn confirm_action_items( _ => crate::reminders::cancel(id), } } + spawn_auto_sync(&app, state.store.clone(), meeting_id); Ok(()) } @@ -3672,6 +4019,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, @@ -4415,6 +4789,7 @@ mod tests { calendar_event_id: None, tags: Vec::new(), template_id: None, + action_items: Vec::new(), } } 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, 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, diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index 797817a..778dc96 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 @@ -337,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 @@ -892,6 +908,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 +935,7 @@ impl Store for SqliteStore { calendar_event_id: row.get("calendar_event_id"), tags, template_id: row.get("template_id"), + action_items, }) } @@ -1196,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, @@ -1254,6 +1295,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 { 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} -