diff --git a/src-tauri/src/commands.rs b/src-tauri/src/commands.rs index 600211d..6d375b2 100644 --- a/src-tauri/src/commands.rs +++ b/src-tauri/src/commands.rs @@ -15,8 +15,8 @@ use crate::hardware::{HardwareDetector, WinHardwareDetector}; use crate::models::*; use crate::notes::NotesRenderer; use crate::paths::{ - diarization_embedding_model_file, diarization_segmentation_model_file, meeting_dir, - settings_path, wa_root, whisper_model_file, + diarization_embedding_model_file, diarization_segmentation_model_file, manual_notes_file, + meeting_dir, settings_path, wa_root, whisper_model_file, }; use crate::storage::{FinalizeMeeting, Meeting, NewMeeting, SummaryFile, SyncTargetRow}; use crate::transcription::{ @@ -75,6 +75,7 @@ fn default_settings() -> Settings { retention_max_size_gb: None, pst_last_path: None, pst_auto_sync: false, + pst_import_range_days: None, graph_calendar_enabled: false, graph_calendar_credential_ref: None, audio_output_device: None, @@ -565,6 +566,7 @@ pub async fn start_recording( diarizer, speaker_names, mic_voice_sample, + manual_notes: Arc::new(StdMutex::new(ManualNotes::default())), }); drop(guard); @@ -716,8 +718,21 @@ pub async fn stop_recording( 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()); + // Granola-style redesign: fold in whatever was captured live (freeform + // notes typed during the meeting + per-moment annotations) instead of + // generating notes.md from the transcript alone. + let manual_notes = session + .manual_notes + .lock() + .map(|g| g.clone()) + .unwrap_or_default(); + let notes_md = crate::notes::MarkdownNotes.merge( + &segments, + &speakers, + &manual_notes, + None, + template.as_ref(), + ); let _ = state.store.update_notes(&meeting_id, ¬es_md).await; let _ = app.emit( @@ -905,13 +920,127 @@ pub async fn acknowledge_recording_consent() -> WaResult<()> { save_settings(&settings) } +// ---- Live notes: Granola-style redesign (freeform + per-moment, during recording) ---- + +/// Best-effort write-through of `manual_notes.json` — crash safety for what +/// the user typed live, same spirit as T2.8's recover-scan. Never fails the +/// calling command on a disk error; the in-memory copy (what `stop_recording` +/// reads) is already updated by the time this runs. +fn persist_manual_notes(meeting_id: &MeetingId, manual: &ManualNotes) { + match serde_json::to_vec_pretty(manual) { + Ok(bytes) => { + if let Err(e) = std::fs::write(manual_notes_file(meeting_id), bytes) { + tracing::warn!("failed to persist manual notes for {meeting_id}: {e}"); + } + } + Err(e) => tracing::warn!("failed to serialize manual notes for {meeting_id}: {e}"), + } +} + +/// Reads `manual_notes.json` for a finalize path with no live +/// `RecordingSession` to read it from in-memory — crash recovery +/// (`resume_transcription`, T2.8) and post-finalize batch re-transcription +/// (`reprocess_transcript`, T3.8) both re-render `notes.md` from scratch, and +/// must not silently drop whatever manual notes were captured during the +/// original recording. Defaults to empty if the file is missing (a meeting +/// with the mic-notes feature never used, or nothing typed) or unreadable. +fn load_manual_notes(meeting_id: &MeetingId) -> ManualNotes { + std::fs::read(manual_notes_file(meeting_id)) + .ok() + .and_then(|bytes| serde_json::from_slice(&bytes).ok()) + .unwrap_or_default() +} + +/// Update the freeform notes typed live during an in-progress recording — +/// the "Notes pane, open and typable while recording" feature. Live-session +/// only: once a meeting is finalized, `notes.md` is the single editable +/// document and `update_notes` is the command for it. +#[tauri::command] +pub async fn update_live_notes( + state: State<'_, AppState>, + meeting_id: MeetingId, + markdown: String, +) -> WaResult<()> { + let guard = state.session.lock().await; + let session = guard + .as_ref() + .filter(|s| s.meeting_id == meeting_id) + .ok_or_else(|| WaError::new("recording", "no matching active recording"))?; + let manual = { + let mut manual = session + .manual_notes + .lock() + .unwrap_or_else(|e| e.into_inner()); + manual.freeform_md = markdown; + manual.clone() + }; + drop(guard); + persist_manual_notes(&meeting_id, &manual); + Ok(()) +} + +/// Attach (or clear, with `text: ""`) a note to a specific moment in an +/// in-progress recording — the "click a transcript line, add a note to it" +/// feature. Anchored by timestamp rather than segment id: a later batch +/// re-transcription (T3.8) can renumber segments, but never moves the moment +/// in time the note pointed at. Live-session only, same reasoning as +/// `update_live_notes`. +#[tauri::command] +pub async fn set_segment_note( + state: State<'_, AppState>, + meeting_id: MeetingId, + anchor_ms: u64, + text: String, +) -> WaResult<()> { + let guard = state.session.lock().await; + let session = guard + .as_ref() + .filter(|s| s.meeting_id == meeting_id) + .ok_or_else(|| WaError::new("recording", "no matching active recording"))?; + let manual = { + let mut manual = session + .manual_notes + .lock() + .unwrap_or_else(|e| e.into_inner()); + upsert_segment_note(&mut manual.segment_notes, anchor_ms, text, now_unix()); + manual.clone() + }; + drop(guard); + persist_manual_notes(&meeting_id, &manual); + Ok(()) +} + +/// Update the note at `anchor_ms` in place if one already exists (matches +/// a re-click on an already-annotated segment), else append a new one. +fn upsert_segment_note(notes: &mut Vec, anchor_ms: u64, text: String, now: i64) { + match notes.iter_mut().find(|n| n.anchor_ms == anchor_ms) { + Some(existing) => { + existing.text = text; + existing.updated_at = now; + } + None => notes.push(SegmentNote { + anchor_ms, + text, + created_at: now, + updated_at: now, + }), + } +} + // ---- Speakers (Phase 4) ---- -/// Re-renders and persists `notes.md` from a finalized meeting's current -/// (post-rename/post-merge) segments+speakers, and tells the frontend what -/// changed (T4.5/4.6, FR-SPK-3/5). This is what keeps `export_meeting` — which -/// just copies the already-rendered `notes.md` — in sync with naming changes -/// made after the meeting ends; `transcript.json` itself is untouched. +/// Tells the frontend a finalized meeting's speaker names/mapping changed +/// (T4.5/4.6, FR-SPK-3/5), e.g. after a rename or merge. +/// +/// Bug fix: this used to also re-render and overwrite `notes.md` from the +/// current segments+speakers on every call — which, once the notes redesign +/// made `notes.md` the user's actual freely-edited document (manual notes + +/// transcript merged at finalize, see `MarkdownNotes::merge`), would have +/// silently destroyed whatever the user had written. `notes.md` is only ever +/// generated once, at finalize; a later rename updates the `speakers` table +/// and live UI display, and simply doesn't retroactively rewrite text +/// already baked into notes.md — same as any other manual edit isn't +/// retroactively touched either. async fn refresh_notes_and_notify( app: &AppHandle, state: &State<'_, AppState>, @@ -922,17 +1051,6 @@ async fn refresh_notes_and_notify( .get_meeting(meeting_id) .await .map_err(|e| WaError::new("storage", e.to_string()))?; - 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 }), @@ -1588,9 +1706,13 @@ pub async fn reprocess_transcript( .await .map_err(|e| WaError::new("storage", e.to_string()))?; - let notes_md = crate::notes::MarkdownNotes.to_markdown( + // Bug fix: re-transcribing must not silently drop manual notes the user + // typed live during the original recording (see `load_manual_notes`). + let manual_notes = load_manual_notes(&meeting_id); + let notes_md = crate::notes::MarkdownNotes.merge( &segments, &meeting.speakers, + &manual_notes, None, template.as_ref(), ); @@ -1694,8 +1816,16 @@ pub async fn resume_transcription( 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()); + // Crash recovery: no RecordingSession survives a crash, so read whatever + // manual notes were write-through persisted to disk before it happened. + let manual_notes = load_manual_notes(&meeting_id); + let notes_md = crate::notes::MarkdownNotes.merge( + &segments, + &speakers, + &manual_notes, + None, + template.as_ref(), + ); let _ = state.store.update_notes(&meeting_id, ¬es_md).await; let _ = app.emit( @@ -2488,12 +2618,14 @@ pub(crate) async fn import_pst_core( store: &dyn crate::storage::Store, path: String, password: Option, + range_days: Option, ) -> WaResult { + let from = range_days.map(|d| now_unix() - (d as i64) * 86_400); let events = tauri::async_runtime::spawn_blocking(move || { PstSource.import(CalImport { path, password, - from: None, + from, to: None, }) }) @@ -2525,8 +2657,9 @@ pub async fn import_pst( state: State<'_, AppState>, path: String, password: Option, + range_days: Option, ) -> WaResult { - import_pst_core(&app, state.store.as_ref(), path, password).await + import_pst_core(&app, state.store.as_ref(), path, password, range_days).await } /// Browse imported calendar events (T6.3, FR-CAL-2). @@ -2557,6 +2690,25 @@ pub async fn get_calendar_event( .map_err(|e| WaError::new("storage", e.to_string())) } +/// Prune imported calendar events (bug fix: unbounded PST history could +/// grow to tens of thousands of rows). `older_than_days: None` deletes +/// every unlinked event ("Delete all"); `Some(n)` only those starting more +/// than `n` days ago. An event attached to a recorded meeting is always +/// kept regardless of the choice. +#[tauri::command] +pub async fn cleanup_calendar_events( + state: State<'_, AppState>, + older_than_days: Option, +) -> WaResult { + let cutoff = older_than_days.map(|d| now_unix() - (d as i64) * 86_400); + let (deleted, protected) = state + .store + .cleanup_calendar_events(cutoff) + .await + .map_err(|e| WaError::new("storage", e.to_string()))?; + Ok(crate::storage::CalendarCleanupResult { deleted, protected }) +} + /// Link a meeting (current or historical) to a calendar event (T6.3/T6.6, /// FR-CAL-2/4). #[tauri::command] @@ -4001,6 +4153,56 @@ mod tests { assert!(diarizer_from_installed_models().is_none()); } + // ---- Live notes redesign: upsert_segment_note / load_manual_notes ---- + + #[test] + fn upsert_segment_note_appends_a_new_anchor() { + let mut notes = Vec::new(); + upsert_segment_note(&mut notes, 1_000, "first".to_string(), 100); + assert_eq!(notes.len(), 1); + assert_eq!(notes[0].anchor_ms, 1_000); + assert_eq!(notes[0].text, "first"); + assert_eq!(notes[0].created_at, 100); + assert_eq!(notes[0].updated_at, 100); + } + + #[test] + fn upsert_segment_note_edits_the_existing_anchor_in_place_without_resetting_created_at() { + let mut notes = vec![SegmentNote { + anchor_ms: 1_000, + text: "first".to_string(), + created_at: 100, + updated_at: 100, + }]; + upsert_segment_note(&mut notes, 1_000, "edited".to_string(), 200); + assert_eq!( + notes.len(), + 1, + "re-clicking the same segment must not duplicate it" + ); + assert_eq!(notes[0].text, "edited"); + assert_eq!(notes[0].created_at, 100); + assert_eq!(notes[0].updated_at, 200); + } + + #[test] + fn upsert_segment_note_with_empty_text_clears_rather_than_removes() { + // Kept (not deleted) so `updated_at` still reflects the clear, and + // `transcript_with_notes` already skips blank-text notes when + // rendering (see notes/mod.rs). + let mut notes = Vec::new(); + upsert_segment_note(&mut notes, 1_000, String::new(), 100); + assert_eq!(notes.len(), 1); + assert_eq!(notes[0].text, ""); + } + + #[test] + fn load_manual_notes_defaults_when_the_file_does_not_exist() { + let manual = load_manual_notes(&"no-such-meeting-id".to_string()); + assert_eq!(manual.freeform_md, ""); + assert!(manual.segment_notes.is_empty()); + } + fn meeting_fixture() -> Meeting { Meeting { id: "m1".to_string(),