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:
+227
-25
@@ -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<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, ¬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<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(),
|
||||
|
||||
Reference in New Issue
Block a user