Hermes 008 #8

Merged
iamdoubz merged 49 commits from hermes-008 into main 2026-07-02 12:05:56 -05:00
22 changed files with 1971 additions and 105 deletions
+2
View File
@@ -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
);
+18 -2
View File
@@ -14,7 +14,8 @@ each command returns `Result<T, WaError>` 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
@@ -38,13 +39,25 @@ merge_speakers(input: { meetingId: MeetingId; from: string[]; into: string }): v
map_speaker_to_participant(input: { meetingId: MeetingId; label: string; participantId: string }): void
// ---- Meetings / storage ----
list_meetings(input: { query?: string; tag?: string; participantId?: string; limit?: number; offset?: number }): MeetingListItem[]
// MeetingListItem gained a `tags: string[]` field (Phase 8, FR-SEARCH-2).
// list_meetings dropped limit/offset (never implemented — no pagination need
// yet at local-desktop meeting counts) and gained from/to date filters, per
// FR-SEARCH-2's "filter by date, tag, or participant".
list_meetings(input: { query?: string; tag?: string; participantId?: string; from?: number; to?: number }): MeetingListItem[]
get_meeting(input: { meetingId: MeetingId }): Meeting // includes transcript + speakers + summary (null until generated)
delete_meeting(input: { meetingId: MeetingId }): void
export_meeting(input: { meetingId: MeetingId; dest: string; format: "md" | "pdf" | "docx" | "bundle" }): string
update_notes(input: { meetingId: MeetingId; markdown: string }): void
// SearchHit = MeetingListItem fields (id, title, started_at, duration_secs, status, tags) + snippet: string
search(input: { query: string }): SearchHit[] // FTS (FR-SEARCH-1)
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[]
// Bulk export (T8.5, FR-STORE-4): every meeting matching tag/from/to, one file (or bundle
// folder) per meeting under destDir. Returns the count actually exported.
bulk_export_meetings(input: { destDir: string; format: "md" | "pdf" | "docx" | "bundle"; tag?: string; from?: number; to?: number }): number
// ---- LLM / AI provider (ADR-0007/0011) ----
// provider ∈ ollama | custom | anthropic | openai | off. Hosted-provider API keys are passed to
@@ -52,6 +65,9 @@ set_tags(input: { meetingId: MeetingId; tags: string[] }): void
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)
+251 -1
View File
@@ -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",
]
+11
View File
@@ -45,11 +45,22 @@ 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)
"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
@@ -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;
+253 -33
View File
@@ -25,7 +25,7 @@ use crate::transcription::{
use crate::{error::WaError, AppState, RecordingSession};
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex as StdMutex};
use tauri::{AppHandle, Emitter, Manager, State};
@@ -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<String>,
}
// ---- 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,8 +497,12 @@ 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 _ = std::fs::write(meeting_dir(&meeting_id).join("notes.md"), notes_md);
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, &notes_md).await;
let _ = app.emit(
"transcript://finalized",
@@ -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, &notes_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,8 +917,13 @@ 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 _ = std::fs::write(meeting_dir(&meeting_id).join("notes.md"), notes_md);
let notes_md = crate::notes::MarkdownNotes.to_markdown(
&segments,
&meeting.speakers,
None,
template.as_ref(),
);
let _ = state.store.update_notes(&meeting_id, &notes_md).await;
let _ = app.emit(
"transcript://finalized",
@@ -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,8 +1004,12 @@ 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 _ = std::fs::write(meeting_dir(&meeting_id).join("notes.md"), notes_md);
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, &notes_md).await;
let _ = app.emit(
"transcript://finalized",
@@ -996,14 +1024,67 @@ pub async fn resume_transcription(
pub async fn list_meetings(
state: State<'_, AppState>,
query: Option<String>,
tag: Option<String>,
participant_id: Option<String>,
from: Option<i64>,
to: Option<i64>,
) -> WaResult<Vec<MeetingListItem>> {
state
.store
.list_meetings(query)
.list_meetings(crate::storage::MeetingFilter {
query,
tag,
participant_id,
from,
to,
})
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// Replaces a meeting's complete tag set (Phase 8, FR-SEARCH-2).
#[tauri::command]
pub async fn set_tags(
state: State<'_, AppState>,
meeting_id: MeetingId,
tags: Vec<String>,
) -> WaResult<()> {
state
.store
.set_tags(&meeting_id, &tags)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// All known tag names, sorted, for filter/autocomplete UI (Phase 8, FR-SEARCH-2).
#[tauri::command]
pub async fn list_tags(state: State<'_, AppState>) -> WaResult<Vec<String>> {
state
.store
.list_tags()
.await
.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<Vec<SearchHit>> {
state
.store
.search(&query)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// Built-in note templates for a picker at recording-start time (Phase 8,
/// T8.1, FR-NOTE-5).
#[tauri::command]
pub async fn list_note_templates() -> WaResult<Vec<crate::notes::NoteTemplate>> {
Ok(crate::notes::built_in_note_templates())
}
#[tauri::command]
pub async fn get_meeting(state: State<'_, AppState>, meeting_id: MeetingId) -> WaResult<Meeting> {
state
@@ -1043,9 +1124,9 @@ pub async fn update_notes(
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// `format` ∈ `md | bundle` (PDF/Word stay Phase 8). `dest` is a file path for
/// `md`, a destination folder for `bundle` (the frontend gets it from a native
/// Save/choose-folder dialog).
/// `format` ∈ `md | pdf | docx | bundle`. `dest` is a file path for
/// `md`/`pdf`/`docx`, a destination folder for `bundle` (the frontend gets
/// it from a native Save/choose-folder dialog).
#[tauri::command]
pub async fn export_meeting(
state: State<'_, AppState>,
@@ -1053,26 +1134,118 @@ pub async fn export_meeting(
dest: String,
format: String,
) -> WaResult<String> {
let meeting = state
let dest_path = PathBuf::from(&dest);
export_meeting_to(&state, &meeting_id, &dest_path, &format).await?;
Ok(dest)
}
/// Bulk export (Phase 8, T8.5, FR-STORE-4) — every meeting matching the
/// tag/date filter, one file (or bundle folder) per meeting under `dest_dir`.
/// Reuses the same filter shape as `list_meetings`; a meeting that fails to
/// export (e.g. a "recovering" meeting with no notes yet) is skipped rather
/// than aborting the whole batch. Returns the count actually exported.
#[tauri::command]
pub async fn bulk_export_meetings(
state: State<'_, AppState>,
dest_dir: String,
format: String,
tag: Option<String>,
from: Option<i64>,
to: Option<i64>,
) -> WaResult<u32> {
let items = state
.store
.get_meeting(&meeting_id)
.list_meetings(crate::storage::MeetingFilter {
tag,
from,
to,
..Default::default()
})
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let dest_path = PathBuf::from(&dest);
match format.as_str() {
let dir = PathBuf::from(&dest_dir);
std::fs::create_dir_all(&dir).map_err(|e| WaError::new("export", e.to_string()))?;
let mut count = 0u32;
for item in items {
let stem = bulk_export_stem(&item);
let dest_path = if format == "bundle" {
dir.join(stem)
} else {
dir.join(format!("{stem}.{format}"))
};
match export_meeting_to(&state, &item.id, &dest_path, &format).await {
Ok(()) => count += 1,
Err(e) => tracing::warn!("bulk export skipped meeting {}: {}", item.id, e.message),
}
}
Ok(count)
}
/// A filesystem-safe, collision-resistant filename stem: sanitized title +
/// an id prefix, since meeting titles are very often duplicates ("Untitled
/// meeting") and would otherwise silently overwrite each other in a batch.
fn bulk_export_stem(item: &MeetingListItem) -> String {
let safe_title: String = item
.title
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' {
c
} else {
'_'
}
})
.collect();
let safe_title = safe_title.trim_matches('_');
let id_prefix = &item.id[..item.id.len().min(8)];
format!("{safe_title}_{id_prefix}")
}
async fn export_meeting_to(
state: &State<'_, AppState>,
meeting_id: &MeetingId,
dest_path: &Path,
format: &str,
) -> WaResult<()> {
let meeting = state
.store
.get_meeting(meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
match format {
"md" => {
crate::notes::MarkdownNotes
.export(
&meeting.notes_markdown,
&dest_path,
dest_path,
crate::notes::ExportFormat::Md,
)
.map_err(|e| WaError::new("export", e.to_string()))?;
}
"bundle" => {
std::fs::create_dir_all(&dest_path)
"pdf" => {
crate::notes::MarkdownNotes
.export(
&meeting.notes_markdown,
dest_path,
crate::notes::ExportFormat::Pdf,
)
.map_err(|e| WaError::new("export", e.to_string()))?;
let source_dir = meeting_dir(&meeting_id);
}
"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()))?;
let source_dir = meeting_dir(meeting_id);
for name in ["audio.wav", "transcript.json"] {
let src = source_dir.join(name);
if src.exists() {
@@ -1090,7 +1263,7 @@ pub async fn export_meeting(
))
}
}
Ok(dest)
Ok(())
}
// ---- LLM (Phase 5) ----
@@ -1197,10 +1370,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 \
@@ -1240,7 +1416,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),
}
}
@@ -1319,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<ActionItem>,
) -> 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) ----
@@ -1714,6 +1904,8 @@ mod tests {
notes_markdown: "**Alice:** Let's plan the sprint.".to_string(),
summary: None,
calendar_event_id: None,
tags: Vec::new(),
template_id: None,
}
}
@@ -1806,4 +1998,32 @@ mod tests {
assert_eq!(result["syncTargets"][0]["thirdParty"], false);
assert_eq!(result["syncTargets"][0]["tls"], true);
}
fn list_item(id: &str, title: &str) -> MeetingListItem {
MeetingListItem {
id: id.to_string(),
title: title.to_string(),
started_at: 0,
duration_secs: None,
status: MeetingStatus::Ready,
tags: Vec::new(),
}
}
#[test]
fn bulk_export_stem_sanitizes_unsafe_filename_characters() {
let item = list_item("abcdef12-0000-0000-0000-000000000000", "Q3: Sales / Ops?");
// ':',' ' -> "__"; ' ','/',' ' -> "___"; trailing '?' trimmed with the
// underscore it became, since trim_matches only strips leading/trailing.
assert_eq!(bulk_export_stem(&item), "Q3__Sales___Ops_abcdef12");
}
#[test]
fn bulk_export_stem_disambiguates_duplicate_titles_by_id() {
// Meeting titles are very often duplicates ("Untitled meeting") — the
// id prefix is what keeps a bulk export from overwriting files.
let a = list_item("11111111-aaaa", "Untitled meeting");
let b = list_item("22222222-bbbb", "Untitled meeting");
assert_ne!(bulk_export_stem(&a), bulk_export_stem(&b));
}
}
+28 -3
View File
@@ -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(())
})
@@ -133,10 +153,15 @@ pub fn run() {
commands::remove_model,
commands::reprocess_transcript,
commands::list_meetings,
commands::search,
commands::set_tags,
commands::list_tags,
commands::list_note_templates,
commands::get_meeting,
commands::delete_meeting,
commands::update_notes,
commands::export_meeting,
commands::bulk_export_meetings,
commands::rename_speaker,
commands::merge_speakers,
commands::map_speaker_to_participant,
+1
View File
@@ -130,6 +130,7 @@ fn parse_summary(text: &str) -> Summary {
owner: None,
due_at: None,
confirmed: false,
reminder_set: false,
})
.collect(),
}
+18
View File
@@ -116,6 +116,21 @@ pub struct MeetingListItem {
pub started_at: i64,
pub duration_secs: Option<i64>,
pub status: MeetingStatus,
/// Tags (Phase 8, FR-SEARCH-2), sorted.
pub tags: Vec<String>,
}
/// 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<i64>,
pub status: MeetingStatus,
pub tags: Vec<String>,
pub snippet: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -125,6 +140,9 @@ pub struct ActionItem {
pub owner: Option<String>,
pub due_at: Option<i64>,
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)]
+60
View File
@@ -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
}
+260 -7
View File
@@ -4,6 +4,8 @@
//! 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 serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, thiserror::Error)]
@@ -24,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<String>,
}
/// 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<NoteTemplate> {
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<NoteTemplate> {
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)
@@ -45,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() {
@@ -111,13 +179,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<Span>),
Paragraph(Vec<Span>),
BulletItem(Vec<Span>),
}
/// 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<Block> {
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<Span> = Vec::new();
let mut bold_depth = 0u32;
let mut heading_level: Option<u8> = 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::*;
@@ -141,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.");
}
@@ -154,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"
);
}
@@ -166,7 +338,88 @@ 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";
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();
}
}
+173
View File
@@ -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, &regular, &bold, 0.0, "");
y -= 3.0;
}
Block::BulletItem(spans) => {
render_runs(
&doc,
&mut layer,
&mut y,
spans,
&regular,
&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<String> {
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: std::fmt::Display>(e: E) -> NotesError {
NotesError::Export(format!("pdf font: {e}"))
}
+120
View File
@@ -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<ToastNotifier> {
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('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
}
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!("<text>Owner: {}</text>", xml_escape(o)))
.unwrap_or_default();
let xml = format!(
"<toast><visual><binding template=\"ToastGeneric\"><text>Action item due</text><text>{}</text>{owner_line}</binding></visual></toast>",
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(())
}
}
+391 -41
View File
@@ -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;
@@ -36,6 +36,25 @@ impl From<sqlx::Error> for StoreError {
pub struct NewMeeting {
pub title: String,
pub calendar_event_id: Option<String>,
/// 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<String>,
}
/// `list_meetings` filters (Phase 8, FR-SEARCH-2). All fields are ANDed
/// together; each is skipped when `None`.
#[derive(Debug, Default)]
pub struct MeetingFilter {
/// Title substring match — separate from full-text `search()`.
pub query: Option<String>,
pub tag: Option<String>,
/// Matches either a speaker mapped to this participant or an attendee of
/// the meeting's linked calendar event.
pub participant_id: Option<String>,
pub from: Option<i64>,
pub to: Option<i64>,
}
/// What actually happened during the session — richer than the doc's bare
@@ -73,6 +92,11 @@ pub struct Meeting {
/// The linked calendar event, if any (T6.3/T6.6, FR-CAL-2/4) — set either
/// at `start_recording` time or later via `attach_meeting_to_event`.
pub calendar_event_id: Option<String>,
/// Tags (Phase 8, FR-SEARCH-2), sorted.
pub tags: Vec<String>,
/// 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<String>,
}
/// A calendar event with its attendees (T6.3/T6.4, FR-CAL-1/3) — the
@@ -107,10 +131,17 @@ pub struct Retention {
#[async_trait]
pub trait Store: Send + Sync {
async fn create_meeting(&self, m: NewMeeting) -> Result<MeetingId, StoreError>;
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<Option<String>, StoreError>;
async fn list_meetings(
&self,
query: Option<String>,
filter: MeetingFilter,
) -> Result<Vec<MeetingListItem>, StoreError>;
async fn get_meeting(&self, id: &MeetingId) -> Result<Meeting, StoreError>;
async fn delete_meeting(&self, id: &MeetingId) -> Result<(), StoreError>;
@@ -136,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<Vec<ActionItem>, 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
@@ -176,7 +209,18 @@ 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<Vec<MeetingListItem>, StoreError>;
async fn search(&self, query: &str) -> Result<Vec<SearchHit>, StoreError>;
/// Replaces this meeting's complete tag set (Phase 8, FR-SEARCH-2) —
/// not incremental add/remove. Unknown tag names are created.
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<Vec<String>, 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<Vec<ActionItem>, StoreError>;
/// Startup reconcile: meetings with audio but no finalized transcript (FR-REL-1).
async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError>;
/// Enforce retention; returns count removed. Skips in-progress meetings
@@ -205,7 +249,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<String> = 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 +339,90 @@ impl SqliteStore {
.await?;
Ok(id)
}
/// Finds-or-creates a `tags` row by name (Phase 8, FR-SEARCH-2). `name` is
/// `UNIQUE`, so — unlike `upsert_participant`'s nullable `email` — a plain
/// `INSERT OR IGNORE` conflict target is unambiguous here.
async fn upsert_tag(&self, name: &str) -> Result<String, StoreError> {
if let Some(id) = sqlx::query_scalar("SELECT id FROM tags WHERE name = ?")
.bind(name)
.fetch_optional(&self.pool)
.await?
{
return Ok(id);
}
let id = uuid::Uuid::new_v4().to_string();
sqlx::query("INSERT OR IGNORE INTO tags (id, name) VALUES (?, ?)")
.bind(&id)
.bind(name)
.execute(&self.pool)
.await?;
// Someone else (a concurrent set_tags call) may have won the
// INSERT OR IGNORE race; re-look-up rather than assume our id landed.
sqlx::query_scalar("SELECT id FROM tags WHERE name = ?")
.bind(name)
.fetch_one(&self.pool)
.await
.map_err(Into::into)
}
/// Tag names for one meeting, sorted (Phase 8, FR-SEARCH-2).
async fn tags_for_meeting(&self, meeting_id: &MeetingId) -> Result<Vec<String>, StoreError> {
Ok(sqlx::query_scalar(
"SELECT t.name FROM tags t
JOIN meeting_tags mt ON mt.tag_id = t.id
WHERE mt.meeting_id = ?
ORDER BY t.name",
)
.bind(meeting_id)
.fetch_all(&self.pool)
.await?)
}
/// (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::<TranscriptFile>(&s).ok())
.map(|t| {
t.segments
.iter()
.map(|s| s.text.as_str())
.collect::<Vec<_>>()
.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(&notes_text)
.execute(&self.pool)
.await?;
Ok(())
}
}
/// Follows a `label -> merged_into` chain to its canonical label. Capped at 8
@@ -296,6 +446,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::<Vec<_>>()
.join(" ")
}
/// On-disk shape of `transcript.json` (`docs/03-data-model.md`).
#[derive(Debug, Serialize, Deserialize, Default)]
struct TranscriptFile {
@@ -314,6 +478,8 @@ struct TranscriptSpeaker {
display_name: Option<String>,
}
/// Builds everything but `tags` — the caller fills that in with an async
/// `tags_for_meeting` lookup, since sqlx rows can't be read across an await.
fn row_to_list_item(row: &sqlx::sqlite::SqliteRow) -> MeetingListItem {
MeetingListItem {
id: row.get("id"),
@@ -321,6 +487,7 @@ fn row_to_list_item(row: &sqlx::sqlite::SqliteRow) -> MeetingListItem {
started_at: row.get("started_at"),
duration_secs: row.get("duration_secs"),
status: MeetingStatus::parse(row.get::<String, _>("status").as_str()),
tags: Vec::new(),
}
}
@@ -346,8 +513,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)
@@ -355,6 +522,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)
@@ -362,7 +530,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<Option<String>, StoreError> {
let now = now_unix();
sqlx::query(
"UPDATE meetings SET status = 'ready', ended_at = ?, duration_secs = ?, recorded = ?,
@@ -408,33 +580,85 @@ 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)?;
Ok(())
self.reindex_fts(id).await?;
let template_id: Option<String> =
sqlx::query_scalar("SELECT template_id FROM meetings WHERE id = ?")
.bind(id)
.fetch_one(&self.pool)
.await?;
Ok(template_id)
}
async fn list_meetings(
&self,
query: Option<String>,
filter: MeetingFilter,
) -> Result<Vec<MeetingListItem>, StoreError> {
let rows = match query.filter(|q| !q.trim().is_empty()) {
Some(q) => {
let like = format!("%{}%", q.trim());
sqlx::query("SELECT id, title, started_at, duration_secs, status FROM meetings WHERE title LIKE ? ORDER BY started_at DESC")
.bind(like)
.fetch_all(&self.pool)
.await?
}
None => {
sqlx::query("SELECT id, title, started_at, duration_secs, status FROM meetings ORDER BY started_at DESC")
.fetch_all(&self.pool)
.await?
}
};
Ok(rows.iter().map(row_to_list_item).collect())
// Conditions are pushed in a fixed order (tag, query, participant,
// from, to) and bound in that exact same order below — there's no
// named-parameter binding in this sqlx query style, so the two lists
// must stay in lockstep.
let mut sql = String::from("SELECT DISTINCT m.id, m.title, m.started_at, m.duration_secs, m.status FROM meetings m");
if filter.tag.is_some() {
sql.push_str(
" JOIN meeting_tags mt ON mt.meeting_id = m.id JOIN tags t ON t.id = mt.tag_id",
);
}
let mut conditions = Vec::new();
if filter.tag.is_some() {
conditions.push("t.name = ?");
}
let query = filter.query.filter(|q| !q.trim().is_empty());
if query.is_some() {
conditions.push("m.title LIKE ?");
}
if filter.participant_id.is_some() {
conditions.push(
"(EXISTS (SELECT 1 FROM speakers s WHERE s.meeting_id = m.id AND s.participant_id = ?)
OR EXISTS (SELECT 1 FROM calendar_event_participants cep WHERE cep.calendar_event_id = m.calendar_event_id AND cep.participant_id = ?))",
);
}
if filter.from.is_some() {
conditions.push("m.started_at >= ?");
}
if filter.to.is_some() {
conditions.push("m.started_at <= ?");
}
if !conditions.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&conditions.join(" AND "));
}
sql.push_str(" ORDER BY m.started_at DESC");
let mut q = sqlx::query(&sql);
if let Some(tag) = &filter.tag {
q = q.bind(tag);
}
if let Some(query) = &query {
q = q.bind(format!("%{}%", query.trim()));
}
if let Some(pid) = &filter.participant_id {
q = q.bind(pid).bind(pid);
}
if let Some(from) = filter.from {
q = q.bind(from);
}
if let Some(to) = filter.to {
q = q.bind(to);
}
let rows = q.fetch_all(&self.pool).await?;
let mut items = Vec::with_capacity(rows.len());
for row in &rows {
let mut item = row_to_list_item(row);
item.tags = self.tags_for_meeting(&item.id).await?;
items.push(item);
}
Ok(items)
}
async fn get_meeting(&self, id: &MeetingId) -> Result<Meeting, StoreError> {
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)
@@ -486,6 +710,7 @@ impl Store for SqliteStore {
let summary = std::fs::read_to_string(folder.join("summary.json"))
.ok()
.and_then(|s| serde_json::from_str::<SummaryFile>(&s).ok());
let tags = self.tags_for_meeting(id).await?;
Ok(Meeting {
id: row.get("id"),
@@ -503,10 +728,18 @@ impl Store for SqliteStore {
notes_markdown,
summary,
calendar_event_id: row.get("calendar_event_id"),
tags,
template_id: row.get("template_id"),
})
}
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 +758,7 @@ impl Store for SqliteStore {
.bind(id)
.execute(&self.pool)
.await?;
self.reindex_fts(id).await?;
Ok(())
}
@@ -687,42 +921,52 @@ impl Store for SqliteStore {
&self,
id: &MeetingId,
items: &[ActionItem],
) -> Result<(), StoreError> {
) -> Result<Vec<ActionItem>, 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<ImportedEvent>) -> Result<u32, StoreError> {
@@ -784,12 +1028,85 @@ impl Store for SqliteStore {
Ok(imported)
}
async fn search(&self, _query: &str) -> Result<Vec<MeetingListItem>, 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<Vec<SearchHit>, 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?;
let mut hits = Vec::with_capacity(rows.len());
for row in &rows {
let item = row_to_list_item(row);
let tags = self.tags_for_meeting(&item.id).await?;
hits.push(SearchHit {
id: item.id,
title: item.title,
started_at: item.started_at,
duration_secs: item.duration_secs,
status: item.status,
tags,
snippet: row.get("snippet"),
});
}
Ok(hits)
}
async fn set_tags(&self, meeting_id: &MeetingId, tags: &[String]) -> Result<(), StoreError> {
sqlx::query("DELETE FROM meeting_tags WHERE meeting_id = ?")
.bind(meeting_id)
.execute(&self.pool)
.await?;
for name in tags {
let name = name.trim();
if name.is_empty() {
continue;
}
let tag_id = self.upsert_tag(name).await?;
sqlx::query("INSERT OR IGNORE INTO meeting_tags (meeting_id, tag_id) VALUES (?, ?)")
.bind(meeting_id)
.bind(&tag_id)
.execute(&self.pool)
.await?;
}
Ok(())
}
async fn list_tags(&self) -> Result<Vec<String>, StoreError> {
Ok(sqlx::query_scalar("SELECT name FROM tags ORDER BY name")
.fetch_all(&self.pool)
.await?)
}
async fn list_pending_reminders(&self) -> Result<Vec<ActionItem>, 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::<i64, _>("confirmed") != 0,
reminder_set: r.get::<i64, _>("reminder_set") != 0,
})
.collect())
}
async fn recover_scan(&self) -> Result<Vec<MeetingId>, StoreError> {
@@ -883,3 +1200,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(" "), "");
}
}
@@ -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();
+22 -2
View File
@@ -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<NoteTemplate[]>([]);
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 @@
<span class="muted">local · private</span>
<div class="spacer"></div>
{#if recording.state === "idle"}
<select
class="theme-select"
bind:value={selectedTemplateId}
aria-label="Note template"
title="Note template"
>
<option value="">No template</option>
{#each noteTemplates as t (t.id)}
<option value={t.id}>{t.name}</option>
{/each}
</select>
<button
onclick={startRecording}
title="Start recording (Ctrl+Shift+R)"
+73 -5
View File
@@ -62,6 +62,28 @@ export interface MeetingListItem {
started_at: number;
duration_secs: number | null;
status: MeetingStatus;
tags: string[];
}
// 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;
tags: string[];
snippet: string;
}
// list_meetings filters (Phase 8, FR-SEARCH-2). All fields optional/ANDed.
export interface MeetingFilter {
query?: string;
tag?: string;
participantId?: string;
from?: number;
to?: number;
}
export interface TranscriptSegment {
@@ -88,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
@@ -119,6 +143,16 @@ export interface Meeting {
notes_markdown: string;
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-*).
@@ -244,8 +278,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<MeetingId>("start_recording", { args: { meetingTitle, calendarEventId, record } }),
startRecording: (
meetingTitle?: string,
calendarEventId?: string,
record = false,
templateId?: string,
) =>
invoke<MeetingId>("start_recording", {
args: { meetingTitle, calendarEventId, record, templateId },
}),
listNoteTemplates: () => invoke<NoteTemplate[]>("list_note_templates"),
stopRecording: (meetingId: MeetingId) => invoke<void>("stop_recording", { meetingId }),
pauseRecording: (meetingId: MeetingId) => invoke<void>("pause_recording", { meetingId }),
resumeRecording: (meetingId: MeetingId) => invoke<void>("resume_recording", { meetingId }),
@@ -264,14 +306,40 @@ export const api = {
invoke<void>("reprocess_transcript", { meetingId, model }),
resumeTranscription: (meetingId: MeetingId) =>
invoke<void>("resume_transcription", { meetingId }),
listMeetings: (query?: string) => invoke<MeetingListItem[]>("list_meetings", { query }),
listMeetings: (filter?: MeetingFilter) =>
invoke<MeetingListItem[]>("list_meetings", {
query: filter?.query,
tag: filter?.tag,
participantId: filter?.participantId,
from: filter?.from,
to: filter?.to,
}),
// Full-text search across transcripts + notes — distinct from listMeetings'
// `query`, which only substring-matches the title.
search: (query: string) => invoke<SearchHit[]>("search", { query }),
setTags: (meetingId: MeetingId, tags: string[]) => invoke<void>("set_tags", { meetingId, tags }),
listTags: () => invoke<string[]>("list_tags"),
getMeeting: (meetingId: MeetingId) => invoke<Meeting>("get_meeting", { meetingId }),
deleteMeeting: (meetingId: MeetingId) => invoke<void>("delete_meeting", { meetingId }),
updateNotes: (meetingId: MeetingId, markdown: string) =>
invoke<void>("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<string>("export_meeting", { meetingId, dest, format }),
// Every meeting matching tag/date filters, one file (or bundle folder) per
// meeting under destDir. Returns the count actually exported (T8.5, FR-STORE-4).
bulkExportMeetings: (
destDir: string,
format: "md" | "pdf" | "docx" | "bundle",
filter?: Pick<MeetingFilter, "tag" | "from" | "to">,
) =>
invoke<number>("bulk_export_meetings", {
destDir,
format,
tag: filter?.tag,
from: filter?.from,
to: filter?.to,
}),
llmStatus: () => invoke<LlmStatus>("llm_status"),
// provider ∈ ollama|custom|anthropic|openai|off; apiKey (hosted) → OS credential store (ADR-0011).
+48 -2
View File
@@ -7,8 +7,10 @@ import {
events,
type ActionItem,
type Meeting,
type MeetingFilter,
type MeetingId,
type MeetingListItem,
type SearchHit,
} from "../api";
class MeetingsStore {
@@ -17,6 +19,17 @@ class MeetingsStore {
selected = $state<Meeting | null>(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<SearchHit[] | null>(null);
searching = $state(false);
// Tag/date/participant filtering (T8.3, FR-SEARCH-2). `filter` is the
// active filter, re-applied whenever `load()` is called without an
// explicit override (e.g. after a meeting finalizes).
filter = $state<MeetingFilter>({});
allTags = $state<string[]>([]);
// 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.
@@ -25,6 +38,7 @@ class MeetingsStore {
summaryError = $state<string | null>(null);
async init() {
await this.loadTags();
await this.load();
// A meeting finishing (live stop, or a recovered meeting resuming
// transcription) changes the list and, if it's the open one, its detail.
@@ -58,10 +72,11 @@ class MeetingsStore {
if (this.selectedId === id) await this.select(id);
}
async load(query?: string) {
async load(filter: MeetingFilter = this.filter) {
this.filter = filter;
this.loading = true;
try {
this.list = await api.listMeetings(query);
this.list = await api.listMeetings(filter);
} catch {
// list_meetings backend not reachable; leave the list as-is.
} finally {
@@ -69,6 +84,37 @@ class MeetingsStore {
}
}
async loadTags() {
try {
this.allTags = await api.listTags();
} catch {
// list_tags backend not reachable; leave as-is.
}
}
async setTags(id: MeetingId, tags: string[]) {
await api.setTags(id, tags);
await this.loadTags();
if (this.selectedId === id) await this.select(id);
await this.load();
}
/** `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 -2
View File
@@ -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";
}
+141 -7
View File
@@ -1,15 +1,69 @@
<script lang="ts">
// Left pane: meetings list + search/filter (Phase 2, FR-STORE-5; Phase 8 search).
// Left pane: meetings list + full-text search (Phase 2, FR-STORE-5; T8.2, FR-SEARCH-1).
import { meetings } from "../stores/meetings.svelte";
import { recording } from "../stores/recording.svelte";
import type { MeetingListItem } from "../api";
import { api, type MeetingListItem, type SearchHit } from "../api";
import { open } from "@tauri-apps/plugin-dialog";
let query = $state("");
let searchTimer: ReturnType<typeof setTimeout> | undefined;
function onSearchInput() {
clearTimeout(searchTimer);
searchTimer = setTimeout(() => meetings.load(query || undefined), 250);
searchTimer = setTimeout(() => meetings.search(query), 250);
}
// Tag/date filters (T8.3, FR-SEARCH-2) apply to the plain list, not
// full-text search — changing one drops out of search mode so the
// filtered list is immediately visible rather than hidden behind results.
let tagFilter = $state("");
let fromFilter = $state("");
let toFilter = $state("");
function toUnix(dateStr: string): number | undefined {
if (!dateStr) return undefined;
return Math.floor(new Date(dateStr).getTime() / 1000);
}
function onFilterChange() {
query = "";
meetings.search(null);
meetings.load({
tag: tagFilter || undefined,
from: toUnix(fromFilter),
to: toUnix(toFilter),
});
}
// Bulk export (T8.5, FR-STORE-4) — exports whatever the tag/date filters
// above currently select, so there's one filter mechanism, not two.
let bulkFormat = $state<"md" | "pdf" | "docx" | "bundle">("md");
let bulkExporting = $state(false);
let bulkResult = $state<string | null>(null);
async function bulkExport() {
const dir = await open({ directory: true });
if (typeof dir !== "string") return;
bulkExporting = true;
bulkResult = null;
try {
const count = await api.bulkExportMeetings(dir, bulkFormat, {
tag: tagFilter || undefined,
from: toUnix(fromFilter),
to: toUnix(toFilter),
});
bulkResult = `Exported ${count} meeting${count === 1 ? "" : "s"}.`;
} finally {
bulkExporting = false;
}
}
// Unified view: full-text results (with snippets) while searching, else the
// plain list. Same underlying fields either way.
let displayItems = $derived<(MeetingListItem | SearchHit)[]>(
meetings.searchResults ?? meetings.list,
);
function snippetOf(item: MeetingListItem | SearchHit): string | null {
return "snippet" in item ? item.snippet : null;
}
function formatDate(unixSecs: number): string {
@@ -28,7 +82,7 @@
return `${m}:${s.toString().padStart(2, "0")}`;
}
async function openMeeting(m: MeetingListItem) {
async function openMeeting(m: MeetingListItem | SearchHit) {
if (recording.state !== "idle" && recording.meetingId === m.id) return; // still live
await meetings.select(m.id);
}
@@ -52,13 +106,45 @@
bind:value={query}
oninput={onSearchInput}
/>
{#if meetings.list.length === 0 && meetings.loading}
<div class="filters">
<select aria-label="Filter by tag" bind:value={tagFilter} onchange={onFilterChange}>
<option value="">All tags</option>
{#each meetings.allTags as t (t)}
<option value={t}>{t}</option>
{/each}
</select>
<input type="date" aria-label="From date" bind:value={fromFilter} onchange={onFilterChange} />
<input type="date" aria-label="To date" bind:value={toFilter} onchange={onFilterChange} />
</div>
<div class="filters">
<select aria-label="Bulk export format" bind:value={bulkFormat}>
<option value="md">.md</option>
<option value="pdf">.pdf</option>
<option value="docx">.docx</option>
<option value="bundle">bundle</option>
</select>
<button
onclick={bulkExport}
disabled={bulkExporting}
title="Export every meeting matching the tag/date filters above"
>
{bulkExporting ? "Exporting…" : "Bulk export"}
</button>
</div>
{#if bulkResult}
<p class="muted small">{bulkResult}</p>
{/if}
{#if meetings.searching}
<p class="muted">Searching…</p>
{:else if displayItems.length === 0 && meetings.loading}
<p class="muted">Loading meetings…</p>
{:else if meetings.list.length === 0}
{:else if displayItems.length === 0 && meetings.searchResults !== null}
<p class="muted">No matches.</p>
{:else if displayItems.length === 0}
<p class="muted">No meetings yet. Click ● Record to start.</p>
{:else}
<ul>
{#each meetings.list as m (m.id)}
{#each displayItems as m (m.id)}
<li class:active={meetings.selectedId === m.id}>
<button class="item-btn" onclick={() => openMeeting(m)}>
<span class="row">
@@ -75,6 +161,16 @@
<span>{formatDate(m.started_at)}</span>
{#if m.duration_secs}<span>{formatDuration(m.duration_secs)}</span>{/if}
</span>
{#if snippetOf(m)}
<span class="snippet">{snippetOf(m)}</span>
{/if}
{#if m.tags.length > 0}
<span class="row tags">
{#each m.tags as t (t)}
<span class="chip">{t}</span>
{/each}
</span>
{/if}
</button>
<div class="row actions">
{#if m.status === "recovering"}
@@ -113,6 +209,44 @@
.small {
font-size: 0.75rem;
}
.snippet {
display: block;
color: var(--muted);
font-size: 0.78rem;
margin-top: 0.15rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.filters {
display: flex;
gap: 0.3rem;
margin-bottom: 0.5rem;
}
.filters select,
.filters input,
.filters button {
flex: 1;
min-width: 0;
padding: 0.25rem;
font-size: 0.78rem;
background: var(--bg);
color: var(--fg);
border: 1px solid var(--border);
border-radius: 5px;
}
.tags {
justify-content: flex-start;
flex-wrap: wrap;
margin-top: 0.2rem;
}
.chip {
font-size: 0.68rem;
padding: 0.05rem 0.35rem;
border-radius: 8px;
background: color-mix(in srgb, var(--muted) 15%, transparent);
color: var(--muted);
}
ul {
list-style: none;
padding: 0;
+71
View File
@@ -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<CalendarEventDetail | null>(null);
$effect(() => {
const eventId = meetings.selected?.calendar_event_id;
@@ -97,9 +108,50 @@
addingNameFor = null;
newNameDraft = "";
}
// ---- Tags (T8.3, FR-SEARCH-2) ----
// Writable derived: reflects meetings.selected.tags, but typing (bind:value)
// locally overrides it until the selection changes again.
let tagsInput = $derived((meetings.selected?.tags ?? []).join(", "));
let savingTags = $state(false);
async function saveTags() {
const m = meetings.selected;
if (!m) return;
savingTags = true;
try {
const tags = tagsInput
.split(",")
.map((t) => t.trim())
.filter(Boolean);
await meetings.setTags(m.id, tags);
} finally {
savingTags = false;
}
}
</script>
<div class="wrap">
<h3>Tags</h3>
{#if !meetings.selected}
<p class="muted">Select a meeting to tag it.</p>
{:else}
<input
class="grow"
list="known-tags"
placeholder="project, client, topic…"
bind:value={tagsInput}
onkeydown={(e) => e.key === "Enter" && saveTags()}
/>
<datalist id="known-tags">
{#each meetings.allTags as t (t)}
<option value={t}></option>
{/each}
</datalist>
<button class="link" onclick={saveTags} disabled={savingTags}>
{savingTags ? "Saving…" : "Save tags"}
</button>
{/if}
<h3>Summary</h3>
{#if !meetings.selected}
<p class="muted">Select a meeting to generate a summary.</p>
@@ -147,6 +199,17 @@
<span>{item.text}</span>
</label>
{#if item.owner}<span class="muted small">{item.owner}</span>{/if}
<input
type="date"
class="due-date"
aria-label="Due date"
value={dueDateInput(item.due_at)}
onchange={(e) => onDueDateChange(item, (e.target as HTMLInputElement).value)}
/>
<label class="remind" title="Schedule a local reminder for this due date">
<input type="checkbox" bind:checked={item.reminder_set} disabled={!item.due_at} />
🔔
</label>
</li>
{/each}
</ul>
@@ -292,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;
+22
View File
@@ -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 @@
>
<span class="spacer"></span>
<button onclick={exportMd} title="Export notes as .md">Export .md</button>
<button onclick={exportPdf} title="Export notes as .pdf">Export PDF</button>
<button onclick={exportDocx} title="Export notes as .docx">Export Word</button>
<button onclick={exportBundle} title="Export audio + transcript + notes to a folder">
Export bundle
</button>