Hermes 008 #8
@@ -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
|
||||
);
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+251
-1
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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
@@ -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, ¬es_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, ¬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,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, ¬es_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, ¬es_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
@@ -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,
|
||||
|
||||
@@ -130,6 +130,7 @@ fn parse_summary(text: &str) -> Summary {
|
||||
owner: None,
|
||||
due_at: None,
|
||||
confirmed: false,
|
||||
reminder_set: false,
|
||||
})
|
||||
.collect(),
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<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}"))
|
||||
}
|
||||
@@ -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('&', "&")
|
||||
.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!("<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
@@ -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(¬es_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
@@ -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
@@ -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).
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user