From b5aa1714ad5ab28dec44d0b1fdbbcb050ab1c568 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 10:07:03 -0500 Subject: [PATCH 01/49] feat(search): add SearchHit type (T8.2, FR-SEARCH-1) Same fields as MeetingListItem plus a highlighted snippet of what matched. --- src-tauri/src/models.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index ce763e5..6f1099a 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -118,6 +118,18 @@ pub struct MeetingListItem { pub status: MeetingStatus, } +/// One full-text search hit (Phase 8, FR-SEARCH-1) — the same shape as +/// `MeetingListItem` plus a highlighted excerpt of what matched. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SearchHit { + pub id: MeetingId, + pub title: String, + pub started_at: i64, + pub duration_secs: Option, + pub status: MeetingStatus, + pub snippet: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ActionItem { pub id: Option, -- 2.34.1 From 68379f8f561016848df1538bb09b25827637c7d5 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 10:07:20 -0500 Subject: [PATCH 02/49] feat(search): wire meeting_fts population + real search (T8.2, FR-SEARCH-1) meeting_fts existed since migration 0001 but nothing ever wrote to it. Adds reindex_fts (called from finalize_meeting/update_notes so the index can't drift from what's on disk), a one-time startup backfill for meetings finalized before this feature existed, delete_meeting cleanup (meeting_fts is a virtual table with no FK/CASCADE support), and a real Store::search() replacing the hardcoded stub. Query input is phrase-quoted per token so stray FTS5 operators in free-text search input can't throw a MATCH syntax error. --- src-tauri/src/storage/mod.rs | 165 +++++++++++++++++++++++++++++++++-- 1 file changed, 156 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index 5f0a472..fe1ad23 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -6,7 +6,7 @@ use crate::models::{ ActionItem, CalendarEvent, ImportedEvent, MeetingId, MeetingListItem, MeetingStatus, - Participant, SpeakerInfo, TranscriptSegment, + Participant, SearchHit, SpeakerInfo, TranscriptSegment, }; use crate::paths; use async_trait::async_trait; @@ -176,7 +176,7 @@ pub trait Store: Send + Sync { participant_id: &str, ) -> Result<(), StoreError>; /// Full-text search across transcripts + notes (Phase 8, FR-SEARCH-1). - async fn search(&self, query: &str) -> Result, StoreError>; + async fn search(&self, query: &str) -> Result, StoreError>; /// Startup reconcile: meetings with audio but no finalized transcript (FR-REL-1). async fn recover_scan(&self) -> Result, StoreError>; /// Enforce retention; returns count removed. Skips in-progress meetings @@ -205,7 +205,29 @@ impl SqliteStore { .run(&pool) .await .map_err(|e| StoreError::Db(e.to_string()))?; - Ok(Self { pool }) + let store = Self { pool }; + store.backfill_fts().await?; + Ok(store) + } + + /// One-time-per-meeting catch-up for meetings finalized before full-text + /// search existed (T8.2, FR-SEARCH-1) — `finalize_meeting`/`update_notes` + /// keep `meeting_fts` current for everything from here on, but that adds + /// nothing retroactively for meetings that predate this feature. Cheap + /// and idempotent: only touches meetings missing a row, so after the + /// first run this is a no-op scan on every later startup. + async fn backfill_fts(&self) -> Result<(), StoreError> { + let missing: Vec = sqlx::query_scalar( + "SELECT id FROM meetings WHERE id NOT IN (SELECT meeting_id FROM meeting_fts)", + ) + .fetch_all(&self.pool) + .await?; + for id in missing { + if let Err(e) = self.reindex_fts(&id).await { + tracing::warn!("FTS backfill skipped meeting {id}: {e}"); + } + } + Ok(()) } } @@ -273,6 +295,51 @@ impl SqliteStore { .await?; Ok(id) } + + /// (Re)builds this meeting's `meeting_fts` row from the current title and + /// whatever's on disk for transcript/notes (Phase 8, FR-SEARCH-1). + /// `meeting_fts` is a plain (non-`content=`) FTS5 table, so nothing keeps + /// it in sync automatically — called from `finalize_meeting`/ + /// `update_notes` so the index can't drift from what's actually stored. + /// Deletes-then-inserts rather than `INSERT OR REPLACE`: FTS5 has no + /// unique constraint to conflict on. + async fn reindex_fts(&self, id: &MeetingId) -> Result<(), StoreError> { + let title: String = sqlx::query_scalar("SELECT title FROM meetings WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await?; + + let transcript_text = + std::fs::read_to_string(paths::meeting_dir(id).join("transcript.json")) + .ok() + .and_then(|s| serde_json::from_str::(&s).ok()) + .map(|t| { + t.segments + .iter() + .map(|s| s.text.as_str()) + .collect::>() + .join(" ") + }) + .unwrap_or_default(); + + let notes_text = + std::fs::read_to_string(paths::meeting_dir(id).join("notes.md")).unwrap_or_default(); + + sqlx::query("DELETE FROM meeting_fts WHERE meeting_id = ?") + .bind(id) + .execute(&self.pool) + .await?; + sqlx::query( + "INSERT INTO meeting_fts (meeting_id, title, transcript_text, notes_text) VALUES (?, ?, ?, ?)", + ) + .bind(id) + .bind(&title) + .bind(&transcript_text) + .bind(¬es_text) + .execute(&self.pool) + .await?; + Ok(()) + } } /// Follows a `label -> merged_into` chain to its canonical label. Capped at 8 @@ -296,6 +363,20 @@ fn now_unix() -> i64 { .unwrap_or(0) } +/// Turns free-text user input into a safe FTS5 MATCH query (Phase 8, +/// FR-SEARCH-1). FTS5's query syntax gives special meaning to `" - * AND OR +/// NOT`, so passing a search box's raw text straight to MATCH can either +/// throw a syntax error on stray punctuation or search less literally than +/// the user typed. Quoting each token as its own phrase (doubling embedded +/// quotes) makes every token a literal match, ANDed together by default. +fn fts_query_from_input(input: &str) -> String { + input + .split_whitespace() + .map(|tok| format!("\"{}\"", tok.replace('"', "\"\""))) + .collect::>() + .join(" ") +} + /// On-disk shape of `transcript.json` (`docs/03-data-model.md`). #[derive(Debug, Serialize, Deserialize, Default)] struct TranscriptFile { @@ -408,6 +489,7 @@ impl Store for SqliteStore { let json = serde_json::to_string_pretty(&transcript).map_err(|e| StoreError::Db(e.to_string()))?; std::fs::write(paths::meeting_dir(id).join("transcript.json"), json)?; + self.reindex_fts(id).await?; Ok(()) } @@ -507,6 +589,12 @@ impl Store for SqliteStore { } async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError> { + // meeting_fts is a virtual table — no FK/CASCADE support, so its row + // would otherwise outlive the meeting and show up as a ghost result. + sqlx::query("DELETE FROM meeting_fts WHERE meeting_id = ?") + .bind(id) + .execute(&self.pool) + .await?; sqlx::query("DELETE FROM meetings WHERE id = ?") .bind(id) .execute(&self.pool) @@ -525,6 +613,7 @@ impl Store for SqliteStore { .bind(id) .execute(&self.pool) .await?; + self.reindex_fts(id).await?; Ok(()) } @@ -784,12 +873,37 @@ impl Store for SqliteStore { Ok(imported) } - async fn search(&self, _query: &str) -> Result, StoreError> { - // Not wired to a command yet, but never panic on a trait method a - // future caller could reach (see commands::not_implemented). - Err(StoreError::Db( - "full-text search isn't built yet (Phase 8)".to_string(), - )) + async fn search(&self, query: &str) -> Result, StoreError> { + if query.trim().is_empty() { + return Ok(Vec::new()); + } + let fts_query = fts_query_from_input(query); + let rows = sqlx::query( + "SELECT m.id, m.title, m.started_at, m.duration_secs, m.status, + snippet(meeting_fts, -1, '', '', '…', 12) AS snippet + FROM meeting_fts + JOIN meetings m ON m.id = meeting_fts.meeting_id + WHERE meeting_fts MATCH ? + ORDER BY meeting_fts.rank + LIMIT 50", + ) + .bind(&fts_query) + .fetch_all(&self.pool) + .await?; + Ok(rows + .iter() + .map(|row| { + let item = row_to_list_item(row); + SearchHit { + id: item.id, + title: item.title, + started_at: item.started_at, + duration_secs: item.duration_secs, + status: item.status, + snippet: row.get("snippet"), + } + }) + .collect()) } async fn recover_scan(&self) -> Result, StoreError> { @@ -883,3 +997,36 @@ fn dir_size(path: &str) -> u64 { .map(|m| m.len()) .sum() } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fts_query_from_input_quotes_each_token_as_a_literal_phrase() { + assert_eq!( + fts_query_from_input("standup notes"), + "\"standup\" \"notes\"" + ); + } + + #[test] + fn fts_query_from_input_escapes_embedded_quotes() { + assert_eq!(fts_query_from_input("say \"hi\""), "\"say\" \"\"\"hi\"\"\""); + } + + #[test] + fn fts_query_from_input_neutralizes_fts5_operators() { + // Without quoting, "-" and "OR"/"AND"/"*" carry special FTS5 meaning; + // quoted, they're just literal tokens that can't blow up MATCH. + assert_eq!( + fts_query_from_input("a - b OR c*"), + "\"a\" \"-\" \"b\" \"OR\" \"c*\"" + ); + } + + #[test] + fn fts_query_from_input_of_blank_input_is_blank() { + assert_eq!(fts_query_from_input(" "), ""); + } +} -- 2.34.1 From d928dfd51dc28d605783f534bb921a0d9d046a5d Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 10:07:29 -0500 Subject: [PATCH 03/49] feat(search): add search command; route notes.md writes through the store (T8.2, FR-SEARCH-1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The initial notes.md render after finalize/resume/recovery bypassed the Store abstraction entirely (raw std::fs::write) — routing it through update_notes means that first render also gets FTS-indexed and bumps updated_at, instead of only later manual edits doing either. --- src-tauri/src/commands.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index a8f19fe..b405cd2 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -495,7 +495,7 @@ pub async fn stop_recording( .map_err(|e| WaError::new("storage", e.to_string()))?; let notes_md = crate::notes::MarkdownNotes.to_markdown(&segments, &speakers, None); - let _ = std::fs::write(meeting_dir(&meeting_id).join("notes.md"), notes_md); + let _ = state.store.update_notes(&meeting_id, ¬es_md).await; let _ = app.emit( "transcript://finalized", @@ -899,7 +899,7 @@ pub async fn reprocess_transcript( .map_err(|e| WaError::new("storage", e.to_string()))?; let notes_md = crate::notes::MarkdownNotes.to_markdown(&segments, &meeting.speakers, None); - let _ = std::fs::write(meeting_dir(&meeting_id).join("notes.md"), notes_md); + let _ = state.store.update_notes(&meeting_id, ¬es_md).await; let _ = app.emit( "transcript://finalized", @@ -981,7 +981,7 @@ pub async fn resume_transcription( .map_err(|e| WaError::new("storage", e.to_string()))?; let notes_md = crate::notes::MarkdownNotes.to_markdown(&segments, &speakers, None); - let _ = std::fs::write(meeting_dir(&meeting_id).join("notes.md"), notes_md); + let _ = state.store.update_notes(&meeting_id, ¬es_md).await; let _ = app.emit( "transcript://finalized", @@ -1004,6 +1004,18 @@ pub async fn list_meetings( .map_err(|e| WaError::new("storage", e.to_string())) } +/// Full-text search across transcripts + notes (Phase 8, FR-SEARCH-1) — +/// distinct from `list_meetings`' `query`, which only substring-matches the +/// title. +#[tauri::command] +pub async fn search(state: State<'_, AppState>, query: String) -> WaResult> { + state + .store + .search(&query) + .await + .map_err(|e| WaError::new("storage", e.to_string())) +} + #[tauri::command] pub async fn get_meeting(state: State<'_, AppState>, meeting_id: MeetingId) -> WaResult { state -- 2.34.1 From c9c049ce2a06f41bc823c19b1b663d503092ad4b Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 10:07:40 -0500 Subject: [PATCH 04/49] feat(search): register the search command (T8.2, FR-SEARCH-1) --- 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 84dc811..0bad01c 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -133,6 +133,7 @@ pub fn run() { commands::remove_model, commands::reprocess_transcript, commands::list_meetings, + commands::search, commands::get_meeting, commands::delete_meeting, commands::update_notes, -- 2.34.1 From f2992508e05e79296fbf0ad161e309d6f8c28e05 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 10:07:48 -0500 Subject: [PATCH 05/49] docs: define SearchHit's shape for the search command (T8.2, FR-SEARCH-1) Was referenced in the contract but never actually specified. --- docs/04-api-contracts.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/04-api-contracts.md b/docs/04-api-contracts.md index c425f84..88adda1 100644 --- a/docs/04-api-contracts.md +++ b/docs/04-api-contracts.md @@ -43,6 +43,7 @@ get_meeting(input: { meetingId: MeetingId }): Meeting // includes trans delete_meeting(input: { meetingId: MeetingId }): void export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf" | "docx" | "bundle" }): string update_notes(input: { meetingId: MeetingId; markdown: string }): void +// SearchHit = MeetingListItem fields (id, title, started_at, duration_secs, status) + snippet: string search(input: { query: string }): SearchHit[] // FTS (FR-SEARCH-1) set_tags(input: { meetingId: MeetingId; tags: string[] }): void -- 2.34.1 From 354588e1ab3fe212a7fb4a768a88552e4e5b5c38 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 10:07:55 -0500 Subject: [PATCH 06/49] feat(ui): add SearchHit type + search() client wrapper (T8.2, FR-SEARCH-1) --- src/lib/api.ts | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/src/lib/api.ts b/src/lib/api.ts index b23491e..2d42198 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -64,6 +64,17 @@ export interface MeetingListItem { status: MeetingStatus; } +// Full-text search hit (Phase 8, FR-SEARCH-1) — same shape as MeetingListItem +// plus a highlighted excerpt of what matched. +export interface SearchHit { + id: MeetingId; + title: string; + started_at: number; + duration_secs: number | null; + status: MeetingStatus; + snippet: string; +} + export interface TranscriptSegment { id: number; start_ms: number; @@ -265,6 +276,9 @@ export const api = { resumeTranscription: (meetingId: MeetingId) => invoke("resume_transcription", { meetingId }), listMeetings: (query?: string) => invoke("list_meetings", { query }), + // Full-text search across transcripts + notes — distinct from listMeetings' + // `query`, which only substring-matches the title. + search: (query: string) => invoke("search", { query }), getMeeting: (meetingId: MeetingId) => invoke("get_meeting", { meetingId }), deleteMeeting: (meetingId: MeetingId) => invoke("delete_meeting", { meetingId }), updateNotes: (meetingId: MeetingId, markdown: string) => -- 2.34.1 From 3d1129ff1e5de1d53795b07d4265436814bed13d Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 10:08:02 -0500 Subject: [PATCH 07/49] feat(ui): add search state + method to the meetings store (T8.2, FR-SEARCH-1) --- src/lib/stores/meetings.svelte.ts | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/lib/stores/meetings.svelte.ts b/src/lib/stores/meetings.svelte.ts index 37d1a54..242b0eb 100644 --- a/src/lib/stores/meetings.svelte.ts +++ b/src/lib/stores/meetings.svelte.ts @@ -9,6 +9,7 @@ import { type Meeting, type MeetingId, type MeetingListItem, + type SearchHit, } from "../api"; class MeetingsStore { @@ -17,6 +18,11 @@ class MeetingsStore { selected = $state(null); loading = $state(false); + // Full-text search across transcripts + notes (T8.2, FR-SEARCH-1) — + // separate from `list`/`load()`, which only substring-matches the title. + searchResults = $state(null); + searching = $state(false); + // Summary generation (T5.4/T5.5, FR-LLM-2/4) — streamed tokens for whichever // meeting is generating, so a stale stream can't overwrite a different // meeting's summary if the user switches selection mid-generation. @@ -69,6 +75,22 @@ class MeetingsStore { } } + /** `null` clears search mode and reverts the list view to `load()`'s results. */ + async search(query: string | null) { + if (!query || !query.trim()) { + this.searchResults = null; + return; + } + this.searching = true; + try { + this.searchResults = await api.search(query); + } catch { + this.searchResults = []; + } finally { + this.searching = false; + } + } + async select(id: MeetingId) { this.selectedId = id; this.selected = await api.getMeeting(id); -- 2.34.1 From 66d4c614bb5ef03bdabbb789aeba74f5162b597d Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 10:08:12 -0500 Subject: [PATCH 08/49] feat(ui): replace title-only filter with full-text search (T8.2, FR-SEARCH-1) The search box now searches transcripts + notes (not just titles) and shows a matched snippet per result. FTS5 is word-based rather than substring-based, a deliberate tradeoff for gaining full-content search. --- src/lib/views/MeetingsList.svelte | 39 +++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 7 deletions(-) diff --git a/src/lib/views/MeetingsList.svelte b/src/lib/views/MeetingsList.svelte index 7ae5716..d93cdf9 100644 --- a/src/lib/views/MeetingsList.svelte +++ b/src/lib/views/MeetingsList.svelte @@ -1,15 +1,24 @@
+

Tags

+ {#if !meetings.selected} +

Select a meeting to tag it.

+ {:else} + e.key === "Enter" && saveTags()} + /> + + {#each meetings.allTags as t (t)} + + {/each} + + + {/if} +

Summary

{#if !meetings.selected}

Select a meeting to generate a summary.

-- 2.34.1 From 3dec299c4c3a5e50aa4700a15ff479b5e01ab441 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:02:29 -0500 Subject: [PATCH 18/49] feat(export): add printpdf/docx-rs/pulldown-cmark deps (T8.4, FR-NOTE-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pure-Rust PDF/Word generation and Markdown parsing — no external binary or cloud conversion service, consistent with the fully-local invariant. --- src-tauri/Cargo.lock | 252 ++++++++++++++++++++++++++++++++++++++++++- src-tauri/Cargo.toml | 6 ++ 2 files changed, 257 insertions(+), 1 deletion(-) diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 5295858..511c731 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -247,6 +247,17 @@ dependencies = [ "tinyvec", ] +[[package]] +name = "bstr" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5cee35f73844aa3014bb606320a6c1f010249dbdf43342fe54b5a4f6a8ed4b79" +dependencies = [ + "memchr", + "regex-automata", + "serde_core", +] + [[package]] name = "bumpalo" version = "3.20.3" @@ -459,6 +470,12 @@ dependencies = [ "cc", ] +[[package]] +name = "color_quant" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" + [[package]] name = "combine" version = "4.6.7" @@ -591,6 +608,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -826,6 +849,21 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "docx-rs" +version = "0.4.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed73cbf5e1c37baa23f4132569ac1187829f03922c206bd68fe109e3001a343d" +dependencies = [ + "base64 0.22.1", + "image", + "quick-xml 0.36.2", + "serde", + "serde_json", + "thiserror 2.0.18", + "zip", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -927,6 +965,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -992,6 +1039,12 @@ version = "2.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1316,6 +1369,15 @@ dependencies = [ "version_check", ] +[[package]] +name = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1354,6 +1416,16 @@ dependencies = [ "r-efi 6.0.0", ] +[[package]] +name = "gif" +version = "0.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee8cfcc411d9adbbaba82fb72661cc1bcca13e8bba98b364e62b2dba8f960159" +dependencies = [ + "color_quant", + "weezl", +] + [[package]] name = "gio" version = "0.18.4" @@ -1502,6 +1574,17 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1844,9 +1927,14 @@ checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", + "color_quant", + "gif", "moxcms", "num-traits", "png 0.18.1", + "tiff", + "zune-core", + "zune-jpeg", ] [[package]] @@ -2141,6 +2229,12 @@ dependencies = [ "vcpkg", ] +[[package]] +name = "linked-hash-map" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0717cef1bc8b636c6e1c1bbdefc09e6322da8a9321966e8928ef80d20f7f770f" + [[package]] name = "linux-raw-sys" version = "0.4.15" @@ -2174,6 +2268,23 @@ version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" +[[package]] +name = "lopdf" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07c8e1b6184b1b32ea5f72f572ebdc40e5da1d2921fa469947ff7c480ad1f85a" +dependencies = [ + "encoding_rs", + "flate2", + "itoa", + "linked-hash-map", + "log", + "md5", + "pom", + "time", + "weezl", +] + [[package]] name = "lru-slab" version = "0.1.2" @@ -2210,6 +2321,12 @@ dependencies = [ "digest", ] +[[package]] +name = "md5" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" + [[package]] name = "memchr" version = "2.8.2" @@ -2620,6 +2737,15 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "owned_ttf_parser" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "706de7e2214113d63a8238d1910463cfce781129a6f263d13fdb09ff64355ba4" +dependencies = [ + "ttf-parser", +] + [[package]] name = "pango" version = "0.18.3" @@ -2795,7 +2921,7 @@ checksum = "092791278e026273c1b65bbdcfbba3a300f2994c896bd01ab01da613c29c46f1" dependencies = [ "base64 0.22.1", "indexmap 2.14.0", - "quick-xml", + "quick-xml 0.39.4", "serde", "time", ] @@ -2826,6 +2952,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "pom" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c972d8f86e943ad532d0b04e8965a749ad1d18bb981a9c7b3ae72fe7fd7744b" +dependencies = [ + "bstr", +] + [[package]] name = "potential_utf" version = "0.1.5" @@ -2866,6 +3001,18 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "printpdf" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c30a4cc87c3ca9a98f4970db158a7153f8d1ec8076e005751173c57836380b1d" +dependencies = [ + "js-sys", + "lopdf", + "owned_ttf_parser", + "time", +] + [[package]] name = "proc-macro-crate" version = "1.3.1" @@ -2928,12 +3075,47 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pulldown-cmark" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" +dependencies = [ + "bitflags 2.13.0", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "pxfm" version = "0.1.28" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b5a041e753da8b807c9255f28de81879c78c876392ff2469cde94799b2896b9d" +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quick-xml" +version = "0.36.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7649a7b4df05aed9ea7ec6f628c67c9953a43869b8bc50929569b2999d443fe" +dependencies = [ + "encoding_rs", + "memchr", +] + [[package]] name = "quick-xml" version = "0.39.4" @@ -4620,6 +4802,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.51" @@ -4990,6 +5186,12 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "ttf-parser" +version = "0.19.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49d64318d8311fc2668e48b63969f4343e0a85c4a109aa8460d6672e364b8bd1" + [[package]] name = "typeid" version = "1.0.3" @@ -5043,6 +5245,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -5076,6 +5284,12 @@ version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + [[package]] name = "untrusted" version = "0.9.0" @@ -5455,6 +5669,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "which" version = "4.4.2" @@ -5472,9 +5692,12 @@ name = "whispassist" version = "0.0.0" dependencies = [ "async-trait", + "docx-rs", "futures-util", "hound", "keyring", + "printpdf", + "pulldown-cmark", "reqwest 0.12.28", "rmcp", "serde", @@ -6375,8 +6598,35 @@ dependencies = [ "syn 2.0.118", ] +[[package]] +name = "zip" +version = "0.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "760394e246e4c28189f19d488c058bf16f564016aefac5d32bb1f3b51d5e9261" +dependencies = [ + "byteorder", + "crc32fast", + "crossbeam-utils", + "flate2", +] + [[package]] name = "zmij" version = "1.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" + +[[package]] +name = "zune-core" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index d1567c7..8095ef6 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -45,6 +45,12 @@ whisper-rs = { version = "0.16", optional = true } # whisper.cpp bindin sherpa-rs = { version = "0.6", optional = true, default-features = false, features = ["download-binaries"] } # sherpa-onnx bindings (Phase 4, ADR-0005) tauri-plugin-dialog = "2" # native Save/choose-folder (Phase 2 export) +# notes export (Phase 8, FR-NOTE-4) — pure-Rust, no external binary/cloud +# conversion service, consistent with the fully-local invariant. +pulldown-cmark = "0.12" +printpdf = "0.7" +docx-rs = "0.4" + [target.'cfg(windows)'.dependencies] windows = { version = "0.58", features = [ "Win32_Media_Audio", # WASAPI (Phase 1) -- 2.34.1 From f6a5b9583a3f42db19ff88c771c49ee9a3c361f6 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:02:35 -0500 Subject: [PATCH 19/49] feat(export): parse notes Markdown into a block model for PDF/Word (T8.4, FR-NOTE-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fills in the ExportFormat::Pdf/Docx arms that previously errored "lands in Phase 8". markdown_to_blocks() covers headings, paragraphs, bullet/task lists, and bold runs — everything MarkdownNotes::to_markdown and the notes editor's toolbar actually produce, not general Markdown. Shared by both new renderer submodules (pdf, docx). --- src-tauri/src/notes/mod.rs | 184 ++++++++++++++++++++++++++++++++++++- 1 file changed, 181 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/notes/mod.rs b/src-tauri/src/notes/mod.rs index f8d1a7e..ea10fff 100644 --- a/src-tauri/src/notes/mod.rs +++ b/src-tauri/src/notes/mod.rs @@ -4,6 +4,7 @@ //! summary). Names are resolved here from the mapping; segments keep internal IDs. use crate::models::{SpeakerInfo, TranscriptSegment}; +use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; use std::path::{Path, PathBuf}; #[derive(Debug, thiserror::Error)] @@ -111,13 +112,117 @@ impl NotesRenderer for MarkdownNotes { ExportFormat::Bundle => Err(NotesError::Export( "bundle export is assembled in commands::export_meeting, not NotesRenderer".into(), )), - ExportFormat::Pdf | ExportFormat::Docx => Err(NotesError::Export( - "PDF/Word export lands in Phase 8".into(), - )), + ExportFormat::Pdf => { + let blocks = markdown_to_blocks(markdown); + pdf::render(&blocks, dest)?; + Ok(dest.to_path_buf()) + } + ExportFormat::Docx => { + let blocks = markdown_to_blocks(markdown); + docx::render(&blocks, dest)?; + Ok(dest.to_path_buf()) + } } } } +/// One inline run of text — `bold` is only ever set on the run pulldown-cmark +/// wraps in `Strong`, which in practice means just the leading speaker-name +/// prefix ("**Alice:**") our own `to_markdown` emits, not general inline +/// formatting anywhere in a paragraph. +#[derive(Debug, Clone)] +struct Span { + text: String, + bold: bool, +} + +#[derive(Debug, Clone)] +enum Block { + Heading(u8, Vec), + Paragraph(Vec), + BulletItem(Vec), +} + +/// Parses notes Markdown (headings, paragraphs, bullet/task lists, bold +/// runs) into a small block model shared by the PDF and DOCX renderers — +/// good enough for what `MarkdownNotes::to_markdown` and the notes editor's +/// toolbar (bold/bullet/checkbox) actually produce, not general Markdown. +fn markdown_to_blocks(markdown: &str) -> Vec { + let mut options = Options::empty(); + options.insert(Options::ENABLE_TASKLISTS); + let parser = Parser::new_ext(markdown, options); + + let mut blocks = Vec::new(); + let mut current: Vec = Vec::new(); + let mut bold_depth = 0u32; + let mut heading_level: Option = None; + let mut in_item = false; + + for event in parser { + match event { + Event::Start(Tag::Heading { level, .. }) => { + heading_level = Some(heading_level_to_u8(level)); + current.clear(); + } + Event::End(TagEnd::Heading(level)) => { + blocks.push(Block::Heading( + heading_level.take().unwrap_or(heading_level_to_u8(level)), + std::mem::take(&mut current), + )); + } + Event::Start(Tag::Paragraph) => current.clear(), + Event::End(TagEnd::Paragraph) => { + let spans = std::mem::take(&mut current); + if in_item { + blocks.push(Block::BulletItem(spans)); + } else { + blocks.push(Block::Paragraph(spans)); + } + } + Event::Start(Tag::Item) => { + in_item = true; + current.clear(); + } + Event::End(TagEnd::Item) => { + if !current.is_empty() { + blocks.push(Block::BulletItem(std::mem::take(&mut current))); + } + in_item = false; + } + Event::Start(Tag::Strong) => bold_depth += 1, + Event::End(TagEnd::Strong) => bold_depth = bold_depth.saturating_sub(1), + Event::Text(text) => current.push(Span { + text: text.to_string(), + bold: bold_depth > 0, + }), + Event::TaskListMarker(checked) => current.push(Span { + text: (if checked { "[x] " } else { "[ ] " }).to_string(), + bold: false, + }), + Event::SoftBreak | Event::HardBreak => current.push(Span { + text: " ".to_string(), + bold: bold_depth > 0, + }), + _ => {} + } + } + blocks +} + +fn heading_level_to_u8(level: HeadingLevel) -> u8 { + match level { + HeadingLevel::H1 => 1, + HeadingLevel::H2 => 2, + HeadingLevel::H3 => 3, + HeadingLevel::H4 => 4, + HeadingLevel::H5 => 5, + HeadingLevel::H6 => 6, + } +} + +mod docx; +mod pdf; + #[cfg(test)] mod tests { use super::*; @@ -169,4 +274,77 @@ mod tests { let md = MarkdownNotes.to_markdown(&segments, &[], Some("## Summary\nDone.")); assert_eq!(md, "## Summary\nDone.\n\n---\n\n**S1:** Real text."); } + + #[test] + fn markdown_to_blocks_parses_heading_bold_prefix_and_task_list() { + let md = "## Summary\n\n**Alice:** Hello world.\n\n- [ ] Follow up\n- [x] Done thing"; + let blocks = markdown_to_blocks(md); + + match &blocks[0] { + Block::Heading(level, spans) => { + assert_eq!(*level, 2); + assert_eq!(spans[0].text, "Summary"); + } + other => panic!("expected Heading, got {other:?}"), + } + + match &blocks[1] { + Block::Paragraph(spans) => { + assert!(spans[0].bold, "speaker name prefix should be bold"); + assert_eq!(spans[0].text, "Alice:"); + assert!(!spans[1].bold); + } + other => panic!("expected Paragraph, got {other:?}"), + } + + match &blocks[2] { + Block::BulletItem(spans) => assert_eq!(spans[0].text, "[ ] "), + other => panic!("expected BulletItem, got {other:?}"), + } + match &blocks[3] { + Block::BulletItem(spans) => assert_eq!(spans[0].text, "[x] "), + other => panic!("expected BulletItem, got {other:?}"), + } + } + + // A real speaker-tagged notes sample, long enough to force a page break + // in the PDF renderer, exercising more than a one-line happy path. + fn sample_markdown() -> String { + let mut md = String::from("## Summary\n\nThis meeting covered quarterly planning.\n\n"); + for i in 0..40 { + md.push_str(&format!( + "**Alice:** This is talking point number {i} about the roadmap and staffing.\n\n" + )); + } + md.push_str("- [ ] Follow up with finance\n- [x] Send recap email\n"); + md + } + + #[test] + fn export_pdf_writes_a_valid_pdf_file() { + let dir = std::env::temp_dir().join(format!("wa-export-test-{}", uuid::Uuid::new_v4())); + let dest = dir.join("notes.pdf"); + MarkdownNotes + .export(&sample_markdown(), &dest, ExportFormat::Pdf) + .expect("pdf export should succeed"); + let bytes = std::fs::read(&dest).expect("pdf file should exist"); + assert!(bytes.starts_with(b"%PDF-"), "missing PDF magic bytes"); + std::fs::remove_dir_all(&dir).ok(); + } + + #[test] + fn export_docx_writes_a_valid_zip_container() { + let dir = std::env::temp_dir().join(format!("wa-export-test-{}", uuid::Uuid::new_v4())); + let dest = dir.join("notes.docx"); + MarkdownNotes + .export(&sample_markdown(), &dest, ExportFormat::Docx) + .expect("docx export should succeed"); + let bytes = std::fs::read(&dest).expect("docx file should exist"); + // .docx is a zip container — "PK\x03\x04" is the local-file-header magic. + assert!( + bytes.starts_with(b"PK\x03\x04"), + "missing zip/docx magic bytes" + ); + std::fs::remove_dir_all(&dir).ok(); + } } -- 2.34.1 From ec7ae5c01ff88b964b30b5eeb5c0946d2b86004c Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:02:41 -0500 Subject: [PATCH 20/49] feat(export): PDF renderer via printpdf + built-in Helvetica (T8.4, FR-NOTE-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Character-count word wrap (printpdf's builtin fonts expose no glyph metrics) and per-boldness-run line groups (a bold speaker-name prefix gets its own line above the dialogue) — simple choices that keep the renderer self-contained with no bundled font asset. --- src-tauri/src/notes/pdf.rs | 173 +++++++++++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 src-tauri/src/notes/pdf.rs diff --git a/src-tauri/src/notes/pdf.rs b/src-tauri/src/notes/pdf.rs new file mode 100644 index 0000000..492b96a --- /dev/null +++ b/src-tauri/src/notes/pdf.rs @@ -0,0 +1,173 @@ +//! PDF export (Phase 8, FR-NOTE-4) via printpdf + built-in Helvetica — no +//! external font file or binary/cloud conversion, consistent with the +//! fully-local invariant. + +use super::{Block, NotesError, Span}; +use printpdf::{ + BuiltinFont, IndirectFontRef, Mm, PdfDocument, PdfDocumentReference, PdfLayerReference, +}; +use std::path::Path; + +const PAGE_W: f32 = 210.0; // A4, mm +const PAGE_H: f32 = 297.0; +const MARGIN: f32 = 20.0; +const BODY_SIZE: f32 = 11.0; + +pub(super) fn render(blocks: &[Block], dest: &Path) -> Result<(), NotesError> { + let (doc, page1, layer1) = PdfDocument::new("Meeting notes", Mm(PAGE_W), Mm(PAGE_H), "Layer 1"); + let regular = doc + .add_builtin_font(BuiltinFont::Helvetica) + .map_err(font_err)?; + let bold = doc + .add_builtin_font(BuiltinFont::HelveticaBold) + .map_err(font_err)?; + + let mut layer = doc.get_page(page1).get_layer(layer1); + let mut y = PAGE_H - MARGIN; + + for block in blocks { + match block { + Block::Heading(level, spans) => { + draw_wrapped( + &doc, + &mut layer, + &mut y, + &flatten(spans), + &bold, + heading_size(*level), + 0.0, + ); + y -= 2.0; + } + Block::Paragraph(spans) => { + render_runs(&doc, &mut layer, &mut y, spans, ®ular, &bold, 0.0, ""); + y -= 3.0; + } + Block::BulletItem(spans) => { + render_runs( + &doc, + &mut layer, + &mut y, + spans, + ®ular, + &bold, + 6.0, + "\u{2022} ", + ); + y -= 1.5; + } + } + } + + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).map_err(NotesError::Io)?; + } + let file = std::fs::File::create(dest).map_err(NotesError::Io)?; + doc.save(&mut std::io::BufWriter::new(file)) + .map_err(|e| NotesError::Export(format!("pdf save: {e}")))?; + Ok(()) +} + +/// Runs of the same boldness each get their own wrapped line-group — a bold +/// speaker-name prefix ends up on its own line above the dialogue rather +/// than sharing a line with it. Simpler than tracking a shared text cursor +/// across a font change mid-line, and still reads fine. +#[allow(clippy::too_many_arguments)] +fn render_runs( + doc: &PdfDocumentReference, + layer: &mut PdfLayerReference, + y: &mut f32, + spans: &[Span], + regular: &IndirectFontRef, + bold: &IndirectFontRef, + indent: f32, + prefix: &str, +) { + let mut first = true; + for (is_bold, text) in group_by_boldness(spans) { + let text = if first && !prefix.is_empty() { + format!("{prefix}{text}") + } else { + text + }; + first = false; + let font = if is_bold { bold } else { regular }; + draw_wrapped(doc, layer, y, &text, font, BODY_SIZE, indent); + } +} + +/// ponytail: character-count word wrap — printpdf's builtin fonts don't +/// expose glyph-width metrics, so this estimates an average character width +/// instead of measuring real text width. Upgrade to measured widths if +/// wrapped lines look visibly ragged. +fn draw_wrapped( + doc: &PdfDocumentReference, + layer: &mut PdfLayerReference, + y: &mut f32, + text: &str, + font: &IndirectFontRef, + size: f32, + indent: f32, +) { + let line_h = size * 0.3528 * 1.4; + let wrap_at = (((PAGE_W - 2.0 * MARGIN - indent) / (size * 0.14)) as usize).max(10); + for line in wrap_text(text, wrap_at) { + if *y - line_h < MARGIN { + let (page, l) = doc.add_page(Mm(PAGE_W), Mm(PAGE_H), "Layer 1"); + *layer = doc.get_page(page).get_layer(l); + *y = PAGE_H - MARGIN; + } + layer.use_text(&line, size, Mm(MARGIN + indent), Mm(*y), font); + *y -= line_h; + } +} + +fn wrap_text(text: &str, max_chars: usize) -> Vec { + let mut lines = Vec::new(); + let mut current = String::new(); + for word in text.split_whitespace() { + if current.is_empty() { + current.push_str(word); + } else if current.len() + 1 + word.len() <= max_chars { + current.push(' '); + current.push_str(word); + } else { + lines.push(std::mem::take(&mut current)); + current.push_str(word); + } + } + if !current.is_empty() || lines.is_empty() { + lines.push(current); + } + lines +} + +fn group_by_boldness(spans: &[Span]) -> Vec<(bool, String)> { + let mut groups: Vec<(bool, String)> = Vec::new(); + for s in spans { + if let Some(last) = groups.last_mut() { + if last.0 == s.bold { + last.1.push_str(&s.text); + continue; + } + } + groups.push((s.bold, s.text.clone())); + } + groups +} + +fn flatten(spans: &[Span]) -> String { + spans.iter().map(|s| s.text.as_str()).collect() +} + +fn heading_size(level: u8) -> f32 { + match level { + 1 => 18.0, + 2 => 15.0, + _ => 13.0, + } +} + +fn font_err(e: E) -> NotesError { + NotesError::Export(format!("pdf font: {e}")) +} -- 2.34.1 From 0425f64cfe35d47862478a65b7c2f88e8eabd261 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:02:46 -0500 Subject: [PATCH 21/49] feat(export): DOCX renderer via docx-rs (T8.4, FR-NOTE-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bullet items get a literal "• " prefix rather than a real Word numbered-list definition — this is read-only generated content, not something the user continues typing into, so numbering-continuity machinery isn't worth the extra API surface. --- src-tauri/src/notes/docx.rs | 60 +++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 src-tauri/src/notes/docx.rs diff --git a/src-tauri/src/notes/docx.rs b/src-tauri/src/notes/docx.rs new file mode 100644 index 0000000..dc0506a --- /dev/null +++ b/src-tauri/src/notes/docx.rs @@ -0,0 +1,60 @@ +//! Word (.docx) export (Phase 8, FR-NOTE-4) via docx-rs — no external +//! binary/cloud conversion, consistent with the fully-local invariant. + +use super::{Block, NotesError, Span}; +use docx_rs::{Docx, Paragraph, Run}; +use std::path::Path; + +pub(super) fn render(blocks: &[Block], dest: &Path) -> Result<(), NotesError> { + let mut docx = Docx::new(); + for block in blocks { + docx = docx.add_paragraph(to_paragraph(block)); + } + + if let Some(parent) = dest.parent() { + std::fs::create_dir_all(parent).map_err(NotesError::Io)?; + } + let file = std::fs::File::create(dest).map_err(NotesError::Io)?; + docx.build() + .pack(file) + .map_err(|e| NotesError::Export(format!("docx save: {e}")))?; + Ok(()) +} + +fn to_paragraph(block: &Block) -> Paragraph { + match block { + Block::Heading(level, spans) => { + let size = match level { + 1 => 32, + 2 => 28, + _ => 24, + }; + let mut p = Paragraph::new(); + for s in spans { + p = p.add_run(Run::new().add_text(s.text.as_str()).bold().size(size)); + } + p + } + Block::Paragraph(spans) => spans_to_paragraph(spans, ""), + // ponytail: a literal "• " prefix, not a real Word numbered-list + // definition (AbstractNum/Num) — this is read-only generated + // content, not something the user continues typing into, so + // auto-numbering continuity isn't worth the extra API surface. + Block::BulletItem(spans) => spans_to_paragraph(spans, "\u{2022} "), + } +} + +fn spans_to_paragraph(spans: &[Span], prefix: &str) -> Paragraph { + let mut p = Paragraph::new(); + if !prefix.is_empty() { + p = p.add_run(Run::new().add_text(prefix)); + } + for s in spans { + let mut run = Run::new().add_text(s.text.as_str()); + if s.bold { + run = run.bold(); + } + p = p.add_run(run); + } + p +} -- 2.34.1 From 06bdf6d469f02931f20cdcc10a4dc214d3e3986b Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:02:52 -0500 Subject: [PATCH 22/49] feat(export): wire pdf/docx formats into export_meeting (T8.4, FR-NOTE-4) --- 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 9a0ed27..eaac5cc 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1115,6 +1115,24 @@ pub async fn export_meeting( ) .map_err(|e| WaError::new("export", e.to_string()))?; } + "pdf" => { + crate::notes::MarkdownNotes + .export( + &meeting.notes_markdown, + &dest_path, + crate::notes::ExportFormat::Pdf, + ) + .map_err(|e| WaError::new("export", e.to_string()))?; + } + "docx" => { + crate::notes::MarkdownNotes + .export( + &meeting.notes_markdown, + &dest_path, + crate::notes::ExportFormat::Docx, + ) + .map_err(|e| WaError::new("export", e.to_string()))?; + } "bundle" => { std::fs::create_dir_all(&dest_path) .map_err(|e| WaError::new("export", e.to_string()))?; -- 2.34.1 From 7b03b12f25e41af165c1fbdf4867dd8594fdfd9a Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:02:57 -0500 Subject: [PATCH 23/49] feat(ui): extend exportMeeting's format type to include pdf/docx (T8.4, FR-NOTE-4) --- src/lib/api.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index 26df0d0..2d077ba 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -304,8 +304,8 @@ export const api = { deleteMeeting: (meetingId: MeetingId) => invoke("delete_meeting", { meetingId }), updateNotes: (meetingId: MeetingId, markdown: string) => invoke("update_notes", { meetingId, markdown }), - // format ∈ "md" | "bundle"; dest is a file path for md, a folder for bundle. - exportMeeting: (meetingId: MeetingId, dest: string, format: "md" | "bundle") => + // 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 }), llmStatus: () => invoke("llm_status"), -- 2.34.1 From fd220641ac704298f43d89c3bdf3ee288fbb066c Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:03:01 -0500 Subject: [PATCH 24/49] feat(ui): add Export PDF / Export Word buttons (T8.4, FR-NOTE-4) --- src/lib/views/TranscriptNotes.svelte | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/src/lib/views/TranscriptNotes.svelte b/src/lib/views/TranscriptNotes.svelte index 114fa54..da570bb 100644 --- a/src/lib/views/TranscriptNotes.svelte +++ b/src/lib/views/TranscriptNotes.svelte @@ -77,6 +77,26 @@ if (typeof dir === "string") await api.exportMeeting(m.id, dir, "bundle"); } + async function exportPdf() { + const m = meetings.selected; + if (!m) return; + const path = await save({ + defaultPath: `${m.title}.pdf`, + filters: [{ name: "PDF", extensions: ["pdf"] }], + }); + if (path) await api.exportMeeting(m.id, path, "pdf"); + } + + async function exportDocx() { + const m = meetings.selected; + if (!m) return; + const path = await save({ + defaultPath: `${m.title}.docx`, + filters: [{ name: "Word document", extensions: ["docx"] }], + }); + if (path) await api.exportMeeting(m.id, path, "docx"); + } + let reprocessModel = $state(""); let reprocessing = $state(false); async function reprocess() { @@ -140,6 +160,8 @@ > + + -- 2.34.1 From 67354642ab441b70e28fa15a00c29172323a9cac Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:25:27 -0500 Subject: [PATCH 25/49] feat(templates): add meetings.template_id column (T8.1, FR-NOTE-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Templates themselves are a built-in Rust catalog, not a DB table — this just remembers which one a meeting picked, so re-rendering notes.md on reprocess/resume keeps the same section structure. --- src-tauri/migrations/0006_note_templates.sql | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 src-tauri/migrations/0006_note_templates.sql diff --git a/src-tauri/migrations/0006_note_templates.sql b/src-tauri/migrations/0006_note_templates.sql new file mode 100644 index 0000000..862681e --- /dev/null +++ b/src-tauri/migrations/0006_note_templates.sql @@ -0,0 +1,5 @@ +-- Note templates (Phase 8, T8.1, FR-NOTE-5). Templates themselves are a +-- built-in Rust catalog (notes::templates), not a DB table — this column +-- just remembers which one a meeting picked, so re-rendering notes.md on +-- reprocess/resume keeps the same section structure instead of losing it. +ALTER TABLE meetings ADD COLUMN template_id TEXT; -- 2.34.1 From f63076ad066f6e3efb333383b588e6cc4557e244 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:25:34 -0500 Subject: [PATCH 26/49] feat(templates): NoteTemplate catalog + apply in to_markdown (T8.1, FR-NOTE-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 4 built-in templates (Standup, 1:1, Sales Call, Retro), keyed by the same meeting-type ids commands::summary_prompt_bias already uses for LLM-prompt biasing — one identifier space shared by both, but two independent lookups (structuring notes.md vs. biasing a summary prompt). Section headers are prepended as an empty scaffold above the auto-rendered speaker dialogue. --- src-tauri/src/notes/mod.rs | 83 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 4 deletions(-) diff --git a/src-tauri/src/notes/mod.rs b/src-tauri/src/notes/mod.rs index ea10fff..756c2ce 100644 --- a/src-tauri/src/notes/mod.rs +++ b/src-tauri/src/notes/mod.rs @@ -5,6 +5,7 @@ use crate::models::{SpeakerInfo, TranscriptSegment}; use pulldown_cmark::{Event, HeadingLevel, Options, Parser, Tag, TagEnd}; +use serde::{Deserialize, Serialize}; use std::path::{Path, PathBuf}; #[derive(Debug, thiserror::Error)] @@ -25,13 +26,73 @@ pub enum ExportFormat { Bundle, // audio + transcript + notes } +/// A note template (Phase 8, T8.1, FR-NOTE-5) — section headers prepended +/// to `to_markdown`'s output, giving the user a scaffold to fill in by hand +/// above the auto-rendered speaker dialogue. Distinct from +/// `commands::summary_prompt_bias`, which biases the *LLM summary prompt* +/// for the same meeting-type identifiers rather than structuring notes.md. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct NoteTemplate { + pub id: String, + pub name: String, + pub sections: Vec, +} + +/// Built-in templates, keyed by the same ids `commands::summary_prompt_bias` +/// already uses for its LLM-prompt biasing ("standup"/"retro"/"one-on-one"), +/// plus "sales-call" per FR-NOTE-5's example — one meeting-type identifier +/// space shared by both features. +pub fn built_in_note_templates() -> Vec { + vec![ + NoteTemplate { + id: "standup".to_string(), + name: "Standup".to_string(), + sections: vec![ + "Yesterday".to_string(), + "Today".to_string(), + "Blockers".to_string(), + ], + }, + NoteTemplate { + id: "one-on-one".to_string(), + name: "1:1".to_string(), + sections: vec!["Discussion".to_string(), "Action Items".to_string()], + }, + NoteTemplate { + id: "sales-call".to_string(), + name: "Sales Call".to_string(), + sections: vec![ + "Agenda".to_string(), + "Discussion".to_string(), + "Next Steps".to_string(), + ], + }, + NoteTemplate { + id: "retro".to_string(), + name: "Retro".to_string(), + sections: vec![ + "What Went Well".to_string(), + "What Didn't".to_string(), + "Action Items".to_string(), + ], + }, + ] +} + +pub fn note_template_by_id(id: &str) -> Option { + built_in_note_templates().into_iter().find(|t| t.id == id) +} + pub trait NotesRenderer: Send + Sync { /// Build Markdown with speaker-tagged dialogue (+ summary if present). + /// `template`'s section headers (if any) are prepended as an empty + /// scaffold for the user to fill in above the dialogue. fn to_markdown( &self, segments: &[TranscriptSegment], speakers: &[SpeakerInfo], summary_md: Option<&str>, + template: Option<&NoteTemplate>, ) -> String; fn export(&self, markdown: &str, dest: &Path, fmt: ExportFormat) @@ -46,8 +107,14 @@ impl NotesRenderer for MarkdownNotes { segments: &[TranscriptSegment], speakers: &[SpeakerInfo], summary_md: Option<&str>, + template: Option<&NoteTemplate>, ) -> String { let mut out = String::new(); + if let Some(template) = template { + for section in &template.sections { + out.push_str(&format!("## {section}\n\n")); + } + } if let Some(summary) = summary_md { let summary = summary.trim(); if !summary.is_empty() { @@ -246,7 +313,7 @@ mod tests { seg(1, "S1", "world."), seg(2, "S2", "Hi there."), ]; - let md = MarkdownNotes.to_markdown(&segments, &[], None); + let md = MarkdownNotes.to_markdown(&segments, &[], None, None); assert_eq!(md, "**S1:** Hello world.\n\n**S2:** Hi there."); } @@ -259,11 +326,11 @@ mod tests { participant_id: None, }]; assert_eq!( - MarkdownNotes.to_markdown(&segments, &speakers, None), + MarkdownNotes.to_markdown(&segments, &speakers, None, None), "**Alex:** Hi" ); assert_eq!( - MarkdownNotes.to_markdown(&segments, &[], None), + MarkdownNotes.to_markdown(&segments, &[], None, None), "**S1:** Hi" ); } @@ -271,10 +338,18 @@ mod tests { #[test] fn skips_blank_segments_and_prepends_summary() { let segments = vec![seg(0, "S1", " "), seg(1, "S1", "Real text.")]; - let md = MarkdownNotes.to_markdown(&segments, &[], Some("## Summary\nDone.")); + let md = MarkdownNotes.to_markdown(&segments, &[], Some("## Summary\nDone."), None); assert_eq!(md, "## Summary\nDone.\n\n---\n\n**S1:** Real text."); } + #[test] + fn applies_template_sections_as_an_empty_scaffold_above_the_dialogue() { + let segments = vec![seg(0, "S1", "Hi")]; + let template = note_template_by_id("one-on-one").unwrap(); + let md = MarkdownNotes.to_markdown(&segments, &[], None, Some(&template)); + assert_eq!(md, "## Discussion\n\n## Action Items\n\n**S1:** Hi"); + } + #[test] fn markdown_to_blocks_parses_heading_bold_prefix_and_task_list() { let md = "## Summary\n\n**Alice:** Hello world.\n\n- [ ] Follow up\n- [x] Done thing"; -- 2.34.1 From 6b5b3594381f1303ece508dc5d39c654179591cc Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:25:41 -0500 Subject: [PATCH 27/49] feat(templates): persist template_id; finalize_meeting returns it (T8.1, FR-NOTE-5) NewMeeting/Meeting gain template_id; finalize_meeting now returns it (fetched via one cheap scalar SELECT) so callers that only have segments/speakers in memory (not a fetched Meeting) can resolve the template without a separate round-trip. --- src-tauri/src/storage/mod.rs | 38 ++++++++++++++++++++++++++++++------ 1 file changed, 32 insertions(+), 6 deletions(-) diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index 082d576..4410d88 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -36,6 +36,11 @@ impl From for StoreError { pub struct NewMeeting { pub title: String, pub calendar_event_id: Option, + /// Note-template id (Phase 8, T8.1, FR-NOTE-5) — resolved against + /// `notes::templates::catalog()`, kept alongside the meeting so + /// re-rendering notes.md on reprocess/resume reapplies the same + /// section structure instead of losing it. + pub template_id: Option, } /// `list_meetings` filters (Phase 8, FR-SEARCH-2). All fields are ANDed @@ -89,6 +94,9 @@ pub struct Meeting { pub calendar_event_id: Option, /// Tags (Phase 8, FR-SEARCH-2), sorted. pub tags: Vec, + /// 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, } /// A calendar event with its attendees (T6.3/T6.4, FR-CAL-1/3) — the @@ -123,7 +131,14 @@ pub struct Retention { #[async_trait] pub trait Store: Send + Sync { async fn create_meeting(&self, m: NewMeeting) -> Result; - async fn finalize_meeting(&self, id: &MeetingId, s: FinalizeMeeting) -> Result<(), StoreError>; + /// Returns the meeting's `template_id` (set at creation, Phase 8 T8.1) so + /// callers can re-render notes.md with the same section structure + /// without a separate fetch. + async fn finalize_meeting( + &self, + id: &MeetingId, + s: FinalizeMeeting, + ) -> Result, StoreError>; async fn list_meetings( &self, filter: MeetingFilter, @@ -490,8 +505,8 @@ impl Store for SqliteStore { let audio_path = folder.join("audio.wav"); let now = now_unix(); sqlx::query( - "INSERT INTO meetings (id, title, started_at, folder_path, audio_path, status, calendar_event_id, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, 'recording', ?, ?, ?)", + "INSERT INTO meetings (id, title, started_at, folder_path, audio_path, status, calendar_event_id, template_id, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, 'recording', ?, ?, ?, ?)", ) .bind(&id) .bind(&m.title) @@ -499,6 +514,7 @@ impl Store for SqliteStore { .bind(folder.display().to_string()) .bind(audio_path.display().to_string()) .bind(&m.calendar_event_id) + .bind(&m.template_id) .bind(now) .bind(now) .execute(&self.pool) @@ -506,7 +522,11 @@ impl Store for SqliteStore { Ok(id) } - async fn finalize_meeting(&self, id: &MeetingId, s: FinalizeMeeting) -> Result<(), StoreError> { + async fn finalize_meeting( + &self, + id: &MeetingId, + s: FinalizeMeeting, + ) -> Result, StoreError> { let now = now_unix(); sqlx::query( "UPDATE meetings SET status = 'ready', ended_at = ?, duration_secs = ?, recorded = ?, @@ -553,7 +573,12 @@ impl Store for SqliteStore { serde_json::to_string_pretty(&transcript).map_err(|e| StoreError::Db(e.to_string()))?; std::fs::write(paths::meeting_dir(id).join("transcript.json"), json)?; self.reindex_fts(id).await?; - Ok(()) + let template_id: Option = + sqlx::query_scalar("SELECT template_id FROM meetings WHERE id = ?") + .bind(id) + .fetch_one(&self.pool) + .await?; + Ok(template_id) } async fn list_meetings( @@ -625,7 +650,7 @@ impl Store for SqliteStore { async fn get_meeting(&self, id: &MeetingId) -> Result { let row = sqlx::query( - "SELECT id, title, started_at, ended_at, duration_secs, status, recorded, language, backend_used, model_used, calendar_event_id + "SELECT id, title, started_at, ended_at, duration_secs, status, recorded, language, backend_used, model_used, calendar_event_id, template_id FROM meetings WHERE id = ?", ) .bind(id) @@ -696,6 +721,7 @@ impl Store for SqliteStore { summary, calendar_event_id: row.get("calendar_event_id"), tags, + template_id: row.get("template_id"), }) } -- 2.34.1 From 2f56891d40face7bdc1c7ce69bfba80b1be11f51 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:25:48 -0500 Subject: [PATCH 28/49] feat(templates): apply templates at all 4 notes-render call sites (T8.1, FR-NOTE-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds StartRecordingArgs.template_id + list_note_templates command; renames built_in_template to summary_prompt_bias to disambiguate it from the new NoteTemplate concept (same meeting-type identifiers, different purpose). Also fixes refresh_notes_and_notify (the rename/merge-speaker refresh path) to route through Store::update_notes instead of a raw std::fs::write — it was bypassing T8.2's FTS reindexing, a gap missed there because this call site uses meeting_dir(meeting_id) without the & that my earlier grep matched on. --- src-tauri/src/commands.rs | 67 +++++++++++++++++++++++++++++++-------- 1 file changed, 54 insertions(+), 13 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index eaac5cc..aeee57f 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -36,6 +36,8 @@ pub struct StartRecordingArgs { /// Retain audio as .wav? Defaults to false (ADR-0009). Controls retention, not capture. #[serde(default)] pub record: bool, + /// Note-template id (Phase 8, T8.1, FR-NOTE-5) — see `notes::built_in_note_templates`. + pub template_id: Option, } // ---- File-backed settings (consent/default-retention/storage policy) ---- @@ -215,6 +217,7 @@ pub async fn start_recording( .meeting_title .unwrap_or_else(|| "Untitled meeting".to_string()), calendar_event_id: args.calendar_event_id, + template_id: args.template_id, }) .await .map_err(|e| WaError::new("storage", e.to_string()))?; @@ -477,7 +480,7 @@ pub async fn stop_recording( // T2.10: persist transcript.json (via finalize_meeting) and notes.md // *before* touching the working WAV, so a crash here still leaves a // recoverable, regenerable meeting. - state + let template_id = state .store .finalize_meeting( &meeting_id, @@ -494,7 +497,11 @@ pub async fn stop_recording( .await .map_err(|e| WaError::new("storage", e.to_string()))?; - let notes_md = crate::notes::MarkdownNotes.to_markdown(&segments, &speakers, None); + let template = template_id + .as_deref() + .and_then(crate::notes::note_template_by_id); + let notes_md = + crate::notes::MarkdownNotes.to_markdown(&segments, &speakers, None, template.as_ref()); let _ = state.store.update_notes(&meeting_id, ¬es_md).await; let _ = app.emit( @@ -613,9 +620,17 @@ async fn refresh_notes_and_notify( .get_meeting(meeting_id) .await .map_err(|e| WaError::new("storage", e.to_string()))?; - let notes_md = - crate::notes::MarkdownNotes.to_markdown(&meeting.segments, &meeting.speakers, None); - let _ = std::fs::write(meeting_dir(meeting_id).join("notes.md"), notes_md); + let template = meeting + .template_id + .as_deref() + .and_then(crate::notes::note_template_by_id); + let notes_md = crate::notes::MarkdownNotes.to_markdown( + &meeting.segments, + &meeting.speakers, + None, + template.as_ref(), + ); + let _ = state.store.update_notes(meeting_id, ¬es_md).await; let _ = app.emit( "diarization://updated", serde_json::json!({ "meetingId": meeting_id, "speakers": meeting.speakers }), @@ -881,6 +896,10 @@ pub async fn reprocess_transcript( .map(|s| (s.end_ms / 1000) as i64) .unwrap_or(meeting.duration_secs.unwrap_or(0)); + let template = meeting + .template_id + .as_deref() + .and_then(crate::notes::note_template_by_id); state .store .finalize_meeting( @@ -898,7 +917,12 @@ pub async fn reprocess_transcript( .await .map_err(|e| WaError::new("storage", e.to_string()))?; - let notes_md = crate::notes::MarkdownNotes.to_markdown(&segments, &meeting.speakers, None); + let notes_md = crate::notes::MarkdownNotes.to_markdown( + &segments, + &meeting.speakers, + None, + template.as_ref(), + ); let _ = state.store.update_notes(&meeting_id, ¬es_md).await; let _ = app.emit( @@ -961,7 +985,7 @@ pub async fn resume_transcription( .map(|s| (s.end_ms / 1000) as i64) .unwrap_or(0); - state + let template_id = state .store .finalize_meeting( &meeting_id, @@ -980,7 +1004,11 @@ pub async fn resume_transcription( .await .map_err(|e| WaError::new("storage", e.to_string()))?; - let notes_md = crate::notes::MarkdownNotes.to_markdown(&segments, &speakers, None); + let template = template_id + .as_deref() + .and_then(crate::notes::note_template_by_id); + let notes_md = + crate::notes::MarkdownNotes.to_markdown(&segments, &speakers, None, template.as_ref()); let _ = state.store.update_notes(&meeting_id, ¬es_md).await; let _ = app.emit( @@ -1050,6 +1078,13 @@ pub async fn search(state: State<'_, AppState>, query: String) -> WaResult WaResult> { + Ok(crate::notes::built_in_note_templates()) +} + #[tauri::command] pub async fn get_meeting(state: State<'_, AppState>, meeting_id: MeetingId) -> WaResult { state @@ -1261,10 +1296,13 @@ pub async fn pull_ollama_model(app: AppHandle, model: String) -> WaResult<()> { .map_err(|e| WaError::new("llm", e.to_string())) } -/// Built-in prompt biases for common meeting shapes (T5.4). User-authored -/// templates are Phase 8's FR-NOTE-5 — an unrecognized id is ignored rather -/// than erroring, so a stale/removed template id never blocks a summary. -fn built_in_template(id: &str) -> Option<&'static str> { +/// Built-in LLM-prompt biases for common meeting shapes (T5.4) — distinct +/// from `notes::NoteTemplate` (Phase 8, T8.1/FR-NOTE-5), which structures +/// notes.md itself. Both use the same meeting-type identifiers +/// ("standup"/"retro"/"one-on-one") since they describe the same kinds of +/// meetings, but are two independent lookups. An unrecognized id is ignored +/// rather than erroring, so a stale/removed template id never blocks a summary. +fn summary_prompt_bias(id: &str) -> Option<&'static str> { match id { "standup" => Some( "This is a daily standup. Focus the summary on what each person did, what's \ @@ -1304,7 +1342,9 @@ fn build_prompt(meeting: &Meeting, template_id: Option<&str>) -> crate::llm::Pro crate::llm::Prompt { transcript: meeting.notes_markdown.clone(), metadata, - template: template_id.and_then(built_in_template).map(str::to_string), + template: template_id + .and_then(summary_prompt_bias) + .map(str::to_string), } } @@ -1779,6 +1819,7 @@ mod tests { summary: None, calendar_event_id: None, tags: Vec::new(), + template_id: None, } } -- 2.34.1 From 9a2177dd62a9fba904cdbc3ad097e86da3988290 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:25:53 -0500 Subject: [PATCH 29/49] feat(templates): register list_note_templates command (T8.1, FR-NOTE-5) --- 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 8937112..a33a73f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -136,6 +136,7 @@ pub fn run() { commands::search, commands::set_tags, commands::list_tags, + commands::list_note_templates, commands::get_meeting, commands::delete_meeting, commands::update_notes, -- 2.34.1 From 31e644a671256dd28078ffdeef4b44fd55c14ce7 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:25:59 -0500 Subject: [PATCH 30/49] test: fix calendar_link_smoke_test for NewMeeting.template_id (T8.1) --- src-tauri/tests/calendar_link_smoke_test.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/tests/calendar_link_smoke_test.rs b/src-tauri/tests/calendar_link_smoke_test.rs index 8ad092c..e371146 100644 --- a/src-tauri/tests/calendar_link_smoke_test.rs +++ b/src-tauri/tests/calendar_link_smoke_test.rs @@ -53,6 +53,7 @@ async fn attach_meeting_to_event_and_map_speaker_to_participant_round_trip() { .create_meeting(NewMeeting { title: "Test meeting".to_string(), calendar_event_id: None, + template_id: None, }) .await .unwrap(); -- 2.34.1 From 04628bcb461811e205b35728dfd2f182933f81d5 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:26:05 -0500 Subject: [PATCH 31/49] docs: document template_id column and list_note_templates/start_recording (T8.1, FR-NOTE-5) --- docs/03-data-model.md | 2 ++ docs/04-api-contracts.md | 6 +++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/docs/03-data-model.md b/docs/03-data-model.md index 5f0e4bc..fdf9d4d 100644 --- a/docs/03-data-model.md +++ b/docs/03-data-model.md @@ -46,6 +46,8 @@ CREATE TABLE meetings ( backend_used TEXT, -- npu|nvidia|amd|intel|cpu model_used TEXT, -- e.g. whisper-base calendar_event_id TEXT, -- FK -> calendar_events.id (nullable) + template_id TEXT, -- note-template id (T8.1, FR-NOTE-5); catalog is + -- a built-in Rust list (notes::templates), not a table created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL ); diff --git a/docs/04-api-contracts.md b/docs/04-api-contracts.md index 5dcb536..c6e5d80 100644 --- a/docs/04-api-contracts.md +++ b/docs/04-api-contracts.md @@ -14,7 +14,8 @@ each command returns `Result` where `WaError` carries a `kind` (mach // ---- Recording lifecycle ---- // `record` (default false) controls audio RETENTION (ADR-0009). When false, working audio is // deleted on finalize and only the transcript/notes persist. It can be toggled mid-meeting. -start_recording(input: { meetingTitle?: string; calendarEventId?: string; record?: boolean }): MeetingId +// templateId (Phase 8, T8.1, FR-NOTE-5) picks a NoteTemplate — see list_note_templates below. +start_recording(input: { meetingTitle?: string; calendarEventId?: string; record?: boolean; templateId?: string }): MeetingId stop_recording(input: { meetingId: MeetingId }): MeetingSummaryRef pause_recording(input: { meetingId: MeetingId }): void resume_recording(input: { meetingId: MeetingId }): void @@ -51,6 +52,9 @@ update_notes(input: { meetingId: MeetingId; markdown: string }): void search(input: { query: string }): SearchHit[] // FTS (FR-SEARCH-1) set_tags(input: { meetingId: MeetingId; tags: string[] }): void list_tags(): string[] // all known tag names, sorted +// NoteTemplate = { id: string, name: string, sections: string[] } (T8.1, FR-NOTE-5). Meeting +// gained `template_id: string | null` — the template picked at start_recording. +list_note_templates(): NoteTemplate[] // ---- LLM / AI provider (ADR-0007/0011) ---- // provider ∈ ollama | custom | anthropic | openai | off. Hosted-provider API keys are passed to -- 2.34.1 From 181e874a2aeba4e5cbcd1114400ff611c30b3e4c Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:26:09 -0500 Subject: [PATCH 32/49] feat(ui): add NoteTemplate type, listNoteTemplates, templateId param (T8.1, FR-NOTE-5) --- src/lib/api.ts | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/lib/api.ts b/src/lib/api.ts index 2d077ba..2f225d2 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -142,6 +142,15 @@ export interface Meeting { summary: SummaryFile | null; calendar_event_id: string | null; tags: string[]; + template_id: string | null; +} + +// Note template (Phase 8, T8.1, FR-NOTE-5) — section headers applied to +// notes.md at recording-start time. +export interface NoteTemplate { + id: string; + name: string; + sections: string[]; } // Calendar / .pst (Phase 6, FR-CAL-*). @@ -267,8 +276,16 @@ export interface McpAccessEntry { // ---- Commands ---- export const api = { // `record` controls audio RETENTION (default false / off — ADR-0009). - startRecording: (meetingTitle?: string, calendarEventId?: string, record = false) => - invoke("start_recording", { args: { meetingTitle, calendarEventId, record } }), + startRecording: ( + meetingTitle?: string, + calendarEventId?: string, + record = false, + templateId?: string, + ) => + invoke("start_recording", { + args: { meetingTitle, calendarEventId, record, templateId }, + }), + listNoteTemplates: () => invoke("list_note_templates"), stopRecording: (meetingId: MeetingId) => invoke("stop_recording", { meetingId }), pauseRecording: (meetingId: MeetingId) => invoke("pause_recording", { meetingId }), resumeRecording: (meetingId: MeetingId) => invoke("resume_recording", { meetingId }), -- 2.34.1 From 21d2b40961f2d48ffac693848bcde637f4975d9c Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:26:14 -0500 Subject: [PATCH 33/49] feat(ui): thread templateId through recording.start() (T8.1, FR-NOTE-5) --- src/lib/stores/recording.svelte.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/stores/recording.svelte.ts b/src/lib/stores/recording.svelte.ts index 9f6632c..551c097 100644 --- a/src/lib/stores/recording.svelte.ts +++ b/src/lib/stores/recording.svelte.ts @@ -44,11 +44,11 @@ class RecordingStore { }); } - async start(title?: string, record = false) { + async start(title?: string, record = false, templateId?: string) { this.segments = []; this.retention = record; this.deviceNotice = null; - this.meetingId = await api.startRecording(title, undefined, record); + this.meetingId = await api.startRecording(title, undefined, record, templateId); this.state = "recording"; } -- 2.34.1 From db838d97419a5acbbf78e82365bc6bf4bc9c10da Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:26:20 -0500 Subject: [PATCH 34/49] feat(ui): note-template picker before starting a recording (T8.1, FR-NOTE-5) --- src/App.svelte | 24 ++++++++++++++++++++++-- 1 file changed, 22 insertions(+), 2 deletions(-) diff --git a/src/App.svelte b/src/App.svelte index c7dc6f9..8214ec2 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -11,11 +11,17 @@ import { recording } from "./lib/stores/recording.svelte"; import { settings } from "./lib/stores/settings.svelte"; import { meetings } from "./lib/stores/meetings.svelte"; + import { api, type NoteTemplate } from "./lib/api"; import { onMount } from "svelte"; let showSettings = $state(false); let showConsent = $state(false); + // Note templates (T8.1, FR-NOTE-5) — picked before starting a recording, + // applied to notes.md's structure once the meeting finalizes. + let noteTemplates = $state([]); + let selectedTemplateId = $state(""); + // Theme (T7.2, FR-UX-2): "system" tracks the OS preference live; "light"/"dark" override it. let systemPrefersDark = $state(false); let resolvedTheme = $derived( @@ -30,6 +36,10 @@ recording.init(); settings.load(); meetings.init(); + api + .listNoteTemplates() + .then((t) => (noteTemplates = t)) + .catch(() => (noteTemplates = [])); const media = window.matchMedia("(prefers-color-scheme: dark)"); systemPrefersDark = media.matches; @@ -45,7 +55,7 @@ function startRecording() { meetings.deselect(); // show the live view, not whatever past meeting was open - recording.start(undefined, settings.settings.default_record); + recording.start(undefined, settings.settings.default_record, selectedTemplateId || undefined); } function toggleRecording() { @@ -54,7 +64,6 @@ } // Global shortcuts (T7.4, FR-UX-3): record start/stop, view toggles. - // "Template apply" isn't included — there's no template UI yet (Phase 8, T8.1). function isEditableTarget(target: EventTarget | null): boolean { if (!(target instanceof HTMLElement)) return false; return ( @@ -124,6 +133,17 @@ local · private
{#if recording.state === "idle"} +
+
+ + +
+ {#if bulkResult} +

{bulkResult}

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

Searching…

{:else if displayItems.length === 0 && meetings.loading} @@ -183,7 +224,8 @@ margin-bottom: 0.5rem; } .filters select, - .filters input { + .filters input, + .filters button { flex: 1; min-width: 0; padding: 0.25rem; -- 2.34.1 From 5b3ebb6cf0338a5ac9e7b8fde5dc4a57401be46a Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:58:34 -0500 Subject: [PATCH 40/49] feat(reminders): add windows-rs WinRT notification features (T8.6, FR-CAL-5) Win32_UI_Shell (AppUserModelID), UI_Notifications + Data_Xml_Dom + Foundation + Foundation_Collections (scheduled toast notifications and IVectorView iteration for GetScheduledToastNotifications). --- src-tauri/Cargo.toml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 8095ef6..032eb08 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -56,6 +56,11 @@ windows = { version = "0.58", features = [ "Win32_Media_Audio", # WASAPI (Phase 1) "Win32_Graphics_Dxgi", # GPU enumeration (Phase 3) "Win32_System_Com", + "Win32_UI_Shell", # SetCurrentProcessExplicitAppUserModelID (Phase 8, T8.6) + "UI_Notifications", # scheduled toast reminders (Phase 8, T8.6, FR-CAL-5) — the + "Data_Xml_Dom", # OS delivers these itself at the due time, no polling timer + "Foundation", + "Foundation_Collections", # IVectorView iteration (GetScheduledToastNotifications) ] } wasapi = { version = "0.15", optional = true } # Phase 1 -- 2.34.1 From f6e0de5486d11fe4cca96ac72adab2b6f9a71380 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:58:44 -0500 Subject: [PATCH 41/49] feat(reminders): schedule/cancel via Windows scheduled toast notifications (T8.6, FR-CAL-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chosen over an app-side polling timer specifically to honor NFR-RES-1 ("no polling timers running when not recording") — Windows itself delivers the toast at due_at, and a scheduled toast is cleared by the OS after it fires, so there's no "already fired" bookkeeping needed in this app either. Windows-only, matching hardware::dxgi's pattern: the real WinRT implementation lives in a #[cfg(windows)] submodule behind plain top-level functions. Verified against the live Windows toast API (not just compiled): a throwaway example scheduled a reminder, confirmed GetScheduledToastNotifications() reported it back with the correct id, then confirmed cancel() removed it. --- src-tauri/src/reminders.rs | 120 +++++++++++++++++++++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src-tauri/src/reminders.rs diff --git a/src-tauri/src/reminders.rs b/src-tauri/src/reminders.rs new file mode 100644 index 0000000..0b2fc85 --- /dev/null +++ b/src-tauri/src/reminders.rs @@ -0,0 +1,120 @@ +//! Local reminders for action items (Phase 8, T8.6, FR-CAL-5), via Windows' +//! native scheduled toast notifications. The OS itself delivers these at the +//! due time — no app-side polling timer runs while idle (NFR-RES-1), and a +//! scheduled toast is `AddToSchedule`d once and cleared by the OS after it +//! fires, so there's no "already fired" bookkeeping to keep in this app +//! either. Windows-only, matching `hardware::dxgi`'s pattern: the real +//! implementation lives in a `#[cfg(windows)]` submodule; call sites use the +//! plain top-level functions either way. + +#[cfg(windows)] +pub fn init() { + winrt::init(); +} + +#[cfg(not(windows))] +pub fn init() {} + +/// Schedules (or reschedules) a reminder toast for one action item. `id` is +/// the `action_items` row id, set as the toast's own Id so a later `cancel` +/// can find and remove it. No-ops (rather than erroring) on failure — a +/// missed reminder shouldn't block saving action items. +#[cfg(windows)] +pub fn schedule(id: &str, text: &str, owner: Option<&str>, due_at_unix: i64) { + if let Err(e) = winrt::schedule(id, text, owner, due_at_unix) { + tracing::warn!("failed to schedule reminder for action item {id}: {e}"); + } +} + +#[cfg(not(windows))] +pub fn schedule(_id: &str, _text: &str, _owner: Option<&str>, _due_at_unix: i64) {} + +/// Removes a previously scheduled reminder, if any (e.g. `reminder_set` +/// toggled off, or `due_at` changed before the schedule fired). +#[cfg(windows)] +pub fn cancel(id: &str) { + if let Err(e) = winrt::cancel(id) { + tracing::warn!("failed to cancel reminder for action item {id}: {e}"); + } +} + +#[cfg(not(windows))] +pub fn cancel(_id: &str) {} + +#[cfg(windows)] +mod winrt { + use windows::core::{Result, HSTRING}; + use windows::Data::Xml::Dom::XmlDocument; + use windows::Foundation::DateTime; + use windows::Win32::UI::Shell::SetCurrentProcessExplicitAppUserModelID; + use windows::UI::Notifications::{ + ScheduledToastNotification, ToastNotificationManager, ToastNotifier, + }; + + /// Arbitrary but stable — Windows uses this to associate scheduled + /// toasts (and their Action Center history) with this app specifically. + const APP_ID: &str = "WhispAssist.MeetingAssistant"; + /// Seconds between the Windows FILETIME epoch (1601-01-01) and the Unix + /// epoch (1970-01-01) — `windows::Foundation::DateTime` counts 100ns + /// ticks since the former; our stored `due_at` is Unix seconds. + const EPOCH_OFFSET_SECS: i64 = 11_644_473_600; + + pub fn init() { + // Best-effort: without this, toasts simply won't schedule — not + // worth failing startup over. + let result = unsafe { SetCurrentProcessExplicitAppUserModelID(&HSTRING::from(APP_ID)) }; + if let Err(e) = result { + tracing::warn!("failed to set AppUserModelID for notifications: {e}"); + } + } + + fn notifier() -> Result { + ToastNotificationManager::CreateToastNotifierWithId(&HSTRING::from(APP_ID)) + } + + fn to_windows_datetime(unix_secs: i64) -> DateTime { + DateTime { + UniversalTime: (unix_secs + EPOCH_OFFSET_SECS) * 10_000_000, + } + } + + fn xml_escape(s: &str) -> String { + s.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + } + + pub fn schedule(id: &str, text: &str, owner: Option<&str>, due_at_unix: i64) -> Result<()> { + // Replace any existing schedule for this item rather than stacking a + // second toast on top of it. + let _ = cancel(id); + + let owner_line = owner + .map(|o| format!("Owner: {}", xml_escape(o))) + .unwrap_or_default(); + let xml = format!( + "Action item due{}{owner_line}", + xml_escape(text), + ); + let doc = XmlDocument::new()?; + doc.LoadXml(&HSTRING::from(xml))?; + + let toast = ScheduledToastNotification::CreateScheduledToastNotification( + &doc, + to_windows_datetime(due_at_unix), + )?; + toast.SetId(&HSTRING::from(id))?; + notifier()?.AddToSchedule(&toast)?; + Ok(()) + } + + pub fn cancel(id: &str) -> Result<()> { + let notifier = notifier()?; + for toast in notifier.GetScheduledToastNotifications()? { + if toast.Id()? == id { + notifier.RemoveFromSchedule(&toast)?; + } + } + Ok(()) + } +} -- 2.34.1 From 26ad92018286fed29eecb115ca5b046b480fa815 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:58:50 -0500 Subject: [PATCH 42/49] feat(reminders): register module; init + startup reconcile (T8.6, FR-CAL-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reschedules anything Windows' own toast schedule lost track of (uninstall/reinstall, a changed AppUserModelID) — safe to run every startup since schedule() is idempotent (cancels any existing entry for the same action item id first). --- src-tauri/src/lib.rs | 26 +++++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 13bdb4e..2f0ed78 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -16,6 +16,7 @@ pub mod mcp; pub mod models; pub mod notes; pub mod paths; +pub mod reminders; pub mod storage; pub mod sync; pub mod transcription; @@ -94,9 +95,14 @@ pub fn run() { .build(app)?; app.manage(TrayHandle(tray)); - // Startup recovery + retention pass (FR-REL-1, FR-STORE-2). Spawned so - // it never blocks the window from showing (NFR-PERF-4); nothing here - // repeats on a timer (NFR-RES-1). + // Reminders (Phase 8, T8.6, FR-CAL-5) are Windows-scheduled toasts, not + // an app-side timer — Windows itself is what's "polling", so this stays + // within NFR-RES-1. init() just registers the AppUserModelID. + reminders::init(); + + // Startup recovery + retention + reminder-reconcile pass (FR-REL-1, + // FR-STORE-2, FR-CAL-5). Spawned so it never blocks the window from + // showing (NFR-PERF-4); nothing here repeats on a timer (NFR-RES-1). let store = store_for_setup; tauri::async_runtime::spawn(async move { match store.recover_scan().await { @@ -114,6 +120,20 @@ pub fn run() { if let Err(e) = store.enforce_retention(policy).await { tracing::error!("startup retention enforcement failed: {e}"); } + // Re-schedules anything Windows' own toast schedule lost track of + // (uninstall/reinstall, a changed AppUserModelID, …) — schedule() + // itself is idempotent (cancels any existing entry for the same + // action item id first), so this is safe to run every startup. + match store.list_pending_reminders().await { + Ok(pending) => { + for item in &pending { + if let (Some(id), Some(due_at)) = (&item.id, item.due_at) { + reminders::schedule(id, &item.text, item.owner.as_deref(), due_at); + } + } + } + Err(e) => tracing::error!("startup reminder reconcile failed: {e}"), + } }); Ok(()) }) -- 2.34.1 From 47706cc0f32750e7f7b3bc209a36b869a98b075c Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:58:56 -0500 Subject: [PATCH 43/49] feat(reminders): add ActionItem.reminder_set field (T8.6, FR-CAL-5) --- src-tauri/src/models.rs | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src-tauri/src/models.rs b/src-tauri/src/models.rs index ee5e93b..c3e7947 100644 --- a/src-tauri/src/models.rs +++ b/src-tauri/src/models.rs @@ -140,6 +140,9 @@ pub struct ActionItem { pub owner: Option, pub due_at: Option, pub confirmed: bool, + /// Schedule a local OS reminder for `due_at` (Phase 8, T8.6, FR-CAL-5). + #[serde(default)] + pub reminder_set: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] -- 2.34.1 From 994ace4b3f6e0e5411ad6e9a23f1915443cd23c0 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:59:02 -0500 Subject: [PATCH 44/49] feat(reminders): initialize reminder_set: false on drafted action items (T8.6) --- src-tauri/src/llm/mod.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/src-tauri/src/llm/mod.rs b/src-tauri/src/llm/mod.rs index 463589e..2362724 100644 --- a/src-tauri/src/llm/mod.rs +++ b/src-tauri/src/llm/mod.rs @@ -130,6 +130,7 @@ fn parse_summary(text: &str) -> Summary { owner: None, due_at: None, confirmed: false, + reminder_set: false, }) .collect(), } -- 2.34.1 From 7f97328a2d0e39631b95cc3fed1a06ffa1db5910 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:59:09 -0500 Subject: [PATCH 45/49] feat(reminders): persist reminder_set; save_action_items returns saved rows (T8.6, FR-CAL-5) reminder_set was accepted on the model but hard-coded to 0 on insert and never included in the UPDATE. save_action_items's return type changes from () to Vec so the caller (which needs the row id to key reminder scheduling) doesn't need a second round-trip for newly-inserted items. Also adds list_pending_reminders for startup reconciliation. --- src-tauri/src/storage/mod.rs | 57 ++++++++++++++++++++++++++++++------ 1 file changed, 48 insertions(+), 9 deletions(-) diff --git a/src-tauri/src/storage/mod.rs b/src-tauri/src/storage/mod.rs index 4410d88..6923169 100644 --- a/src-tauri/src/storage/mod.rs +++ b/src-tauri/src/storage/mod.rs @@ -167,12 +167,14 @@ pub trait Store: Send + Sync { /// Persist reviewed/edited action items as confirmed tasks (T5.6, /// FR-LLM-3): items with an `id` update that row, items without one /// insert a new row. Drafted (unconfirmed) items from `generate_summary` - /// only ever live in `summary.json` until this is called. + /// only ever live in `summary.json` until this is called. Returns the + /// saved items with `id` populated (Phase 8, T8.6) — new rows don't have + /// one yet at call time, but the caller needs it to key reminder scheduling. async fn save_action_items( &self, id: &MeetingId, items: &[ActionItem], - ) -> Result<(), StoreError>; + ) -> Result, StoreError>; /// Persist imported calendar events + their attendees (T6.1/T6.2, /// FR-CAL-1). Re-importing the same event (matched by `source` + /// `raw_uid`) updates it in place rather than duplicating it. Returns @@ -213,6 +215,12 @@ pub trait Store: Send + Sync { async fn set_tags(&self, meeting_id: &MeetingId, tags: &[String]) -> Result<(), StoreError>; /// All known tag names, sorted, for filter/autocomplete UI. async fn list_tags(&self) -> Result, StoreError>; + /// Action items with an unfired reminder due in the future (Phase 8, + /// T8.6, FR-CAL-5) — a startup reconcile against Windows' scheduled-toast + /// list, since that's the actual source of truth for "already scheduled" + /// (see `reminders` module) and it can be cleared out from under the app + /// (uninstall/reinstall, a different AppUserModelID, etc). + async fn list_pending_reminders(&self) -> Result, StoreError>; /// Startup reconcile: meetings with audio but no finalized transcript (FR-REL-1). async fn recover_scan(&self) -> Result, StoreError>; /// Enforce retention; returns count removed. Skips in-progress meetings @@ -913,42 +921,52 @@ impl Store for SqliteStore { &self, id: &MeetingId, items: &[ActionItem], - ) -> Result<(), StoreError> { + ) -> Result, StoreError> { let now = now_unix(); + let mut saved = Vec::with_capacity(items.len()); for item in items { - match &item.id { + let item_id = match &item.id { Some(existing_id) => { sqlx::query( - "UPDATE action_items SET text = ?, owner = ?, due_at = ?, confirmed = ? + "UPDATE action_items SET text = ?, owner = ?, due_at = ?, confirmed = ?, reminder_set = ? WHERE id = ? AND meeting_id = ?", ) .bind(&item.text) .bind(&item.owner) .bind(item.due_at) .bind(item.confirmed as i64) + .bind(item.reminder_set as i64) .bind(existing_id) .bind(id) .execute(&self.pool) .await?; + existing_id.clone() } None => { + let new_id = uuid::Uuid::new_v4().to_string(); sqlx::query( "INSERT INTO action_items (id, meeting_id, text, owner, due_at, confirmed, reminder_set, created_at) - VALUES (?, ?, ?, ?, ?, ?, 0, ?)", + VALUES (?, ?, ?, ?, ?, ?, ?, ?)", ) - .bind(uuid::Uuid::new_v4().to_string()) + .bind(&new_id) .bind(id) .bind(&item.text) .bind(&item.owner) .bind(item.due_at) .bind(item.confirmed as i64) + .bind(item.reminder_set as i64) .bind(now) .execute(&self.pool) .await?; + new_id } - } + }; + saved.push(ActionItem { + id: Some(item_id), + ..item.clone() + }); } - Ok(()) + Ok(saved) } async fn import_calendar_events(&self, events: Vec) -> Result { @@ -1070,6 +1088,27 @@ impl Store for SqliteStore { .await?) } + async fn list_pending_reminders(&self) -> Result, StoreError> { + let rows = sqlx::query( + "SELECT id, text, owner, due_at, confirmed, reminder_set FROM action_items + WHERE reminder_set = 1 AND due_at IS NOT NULL AND due_at > ?", + ) + .bind(now_unix()) + .fetch_all(&self.pool) + .await?; + Ok(rows + .iter() + .map(|r| ActionItem { + id: Some(r.get("id")), + text: r.get("text"), + owner: r.get("owner"), + due_at: r.get("due_at"), + confirmed: r.get::("confirmed") != 0, + reminder_set: r.get::("reminder_set") != 0, + }) + .collect()) + } + async fn recover_scan(&self) -> Result, StoreError> { let rows = sqlx::query( "SELECT id, audio_path FROM meetings WHERE status IN ('recording', 'transcribing')", -- 2.34.1 From 8a28854250d6da64bad5eadd60fd631819812b8b Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:59:15 -0500 Subject: [PATCH 46/49] feat(reminders): schedule/cancel from confirm_action_items (T8.6, FR-CAL-5) --- src-tauri/src/commands.rs | 18 +++++++++++++++--- 1 file changed, 15 insertions(+), 3 deletions(-) diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 01f1884..400844b 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -1497,18 +1497,30 @@ pub async fn generate_summary( Ok(()) } -/// Persist reviewed/edited action items as confirmed tasks (T5.6, FR-LLM-3). +/// Persist reviewed/edited action items as confirmed tasks (T5.6, FR-LLM-3), +/// then schedule or cancel each one's local reminder to match (Phase 8, +/// T8.6, FR-CAL-5). #[tauri::command] pub async fn confirm_action_items( state: State<'_, AppState>, meeting_id: MeetingId, items: Vec, ) -> WaResult<()> { - state + let saved = state .store .save_action_items(&meeting_id, &items) .await - .map_err(|e| WaError::new("storage", e.to_string())) + .map_err(|e| WaError::new("storage", e.to_string()))?; + for item in &saved { + let Some(id) = &item.id else { continue }; + match (item.reminder_set, item.due_at) { + (true, Some(due_at)) => { + crate::reminders::schedule(id, &item.text, item.owner.as_deref(), due_at) + } + _ => crate::reminders::cancel(id), + } + } + Ok(()) } // ---- Calendar / .pst (Phase 6) ---- -- 2.34.1 From e04e95d6dac8c59485bedb9455537b09ae19ba89 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:59:22 -0500 Subject: [PATCH 47/49] docs: document ActionItem.reminder_set and confirm_action_items' reminder side effect (T8.6, FR-CAL-5) --- docs/04-api-contracts.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/04-api-contracts.md b/docs/04-api-contracts.md index d694a5f..a3de873 100644 --- a/docs/04-api-contracts.md +++ b/docs/04-api-contracts.md @@ -65,6 +65,9 @@ bulk_export_meetings(input: { destDir: string; format: "md" | "pdf" | "docx" | " llm_status(): { provider: string; reachable: boolean; isLocal: boolean; models: string[] } set_llm_provider(input: { provider: string; endpoint?: string; model?: string; apiKey?: string }): void // FR-AI-1/2 generate_summary(input: { meetingId: MeetingId; templateId?: string }): void // streams via events (FR-LLM-2/4) +// ActionItem gained `reminder_set: boolean` (T8.6, FR-CAL-5). confirm_action_items now also +// schedules/cancels each item's local reminder (Windows scheduled toast notification — the OS +// itself delivers it at due_at, no app-side polling timer; see src-tauri/src/reminders.rs). confirm_action_items(input: { meetingId: MeetingId; items: ActionItem[] }): void llm_setup_suggestions(): { ollamaInstalled: boolean; installUrl: string; suggestedModel: { id: string; label: string; approxSizeGb: number } } // T5.7, FR-LLM-5 pull_ollama_model(input: { model: string }): void // guided download via Ollama's own /api/pull; emits model://progress (T5.7) -- 2.34.1 From f27c19d8defd3835dc5f8a303115a5c205247339 Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:59:27 -0500 Subject: [PATCH 48/49] feat(ui): add ActionItem.reminder_set to the TS type (T8.6, FR-CAL-5) --- src/lib/api.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/lib/api.ts b/src/lib/api.ts index 87dd95e..8be118c 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -110,6 +110,8 @@ export interface ActionItem { owner: string | null; due_at: number | null; confirmed: boolean; + // Schedule a local OS reminder for due_at (Phase 8, T8.6, FR-CAL-5). + reminder_set: boolean; } // On-disk shape of summary.json (FR-LLM-2/4) — `null` until generate_summary -- 2.34.1 From 0f7526770d09d0f55d7817d920b57011ea74a01a Mon Sep 17 00:00:00 2001 From: iamdoubz <> Date: Thu, 2 Jul 2026 11:59:34 -0500 Subject: [PATCH 49/49] feat(ui): due-date input + remind-me checkbox per action item (T8.6, FR-CAL-5) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The due-date input is new too — action items previously had no way to set one, which would have made the reminder checkbox unusable (a reminder needs a due date to schedule against). --- src/lib/views/SummaryPanel.svelte | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/src/lib/views/SummaryPanel.svelte b/src/lib/views/SummaryPanel.svelte index 5aefc1c..d99a0d5 100644 --- a/src/lib/views/SummaryPanel.svelte +++ b/src/lib/views/SummaryPanel.svelte @@ -42,6 +42,17 @@ } } + // Due date + reminder (T8.6, FR-CAL-5) — a reminder needs a due date to + // schedule against, so the checkbox is disabled until one is set. + function dueDateInput(secs: number | null): string { + if (!secs) return ""; + return new Date(secs * 1000).toISOString().slice(0, 10); + } + function onDueDateChange(item: ActionItem, value: string) { + item.due_at = value ? Math.floor(new Date(value).getTime() / 1000) : null; + if (!item.due_at) item.reminder_set = false; + } + let eventDetail = $state(null); $effect(() => { const eventId = meetings.selected?.calendar_event_id; @@ -188,6 +199,17 @@ {item.text} {#if item.owner}{item.owner}{/if} + onDueDateChange(item, (e.target as HTMLInputElement).value)} + /> + {/each} @@ -333,6 +355,14 @@ align-items: center; gap: 0.4rem; } + .due-date { + font-size: 0.75rem; + padding: 0.15rem; + max-width: 8.5rem; + } + .remind { + font-size: 0.85rem; + } .error { color: var(--danger); font-size: 0.85rem; -- 2.34.1