feat(commands): live notes commands + calendar range/cleanup commands

Live notes (Granola-style redesign):
- update_live_notes/set_segment_note: live-session-only, mutate
  RecordingSession.manual_notes and write-through to manual_notes.json.
- stop_recording, reprocess_transcript, and resume_transcription now call
  MarkdownNotes::merge (loading manual_notes.json from disk for the
  latter two, which have no live RecordingSession) instead of the old
  transcript-only to_markdown, so re-transcription/crash-recovery never
  silently drops what the user typed.
- refresh_notes_and_notify no longer re-renders/overwrites notes.md on a
  post-finalize speaker rename/merge -- that used to clobber manual
  edits; it still updates the speakers table and emits the live-refresh
  event.

Calendar range/cleanup (14,686-row bloat fix):
- import_pst_core/import_pst take an optional range_days, converted to a
  from timestamp for PstSource.
- New cleanup_calendar_events command wrapping the new store method.
This commit is contained in:
iamdoubz
2026-07-11 00:11:45 -05:00
parent bb226dcb5e
commit 45ddc2bd3e
+227 -25
View File
@@ -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, &notes_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<SegmentNote>, 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, &notes_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, &notes_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<String>,
range_days: Option<u32>,
) -> WaResult<u32> {
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<String>,
range_days: Option<u32>,
) -> WaResult<u32> {
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<u32>,
) -> WaResult<crate::storage::CalendarCleanupResult> {
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(),