Two features:
- Granola-style live notes redesign: ManualNotes/SegmentNote are the IPC +
on-disk shape of manual_notes.json (freeform notes typed during
recording, plus per-moment annotations anchored by timestamp).
- Calendar bloat fix: Settings.pst_import_range_days persists how far back
a PST import should go (None = full history), applied to both a manual
import and the pst_auto_sync startup re-import.
Granola-style live notes redesign: manual_notes_file(id) mirrors the
existing meeting_dir()-based helpers for the new per-meeting raw
user-notes artifact.
Back/Forward/Reload/Inspect doesn't belong in a native-feeling desktop
app. Disabled app-wide for now, per explicit instruction -- a
WhispAssist-specific context menu is a deferred follow-up, not decided
against.
readpst.exe is a console-subsystem binary; WhispAssist has no console of
its own, so Windows created a brand new visible console window for it on
every PST import. CREATE_NO_WINDOW spawns it fully headless -- its
stdout/stderr are still captured normally via .output().
parse_vevents used to leave raw_uid: None for any VEVENT with no UID
line, which the storage layer's unique index exempts from dedup -- so
that event duplicated on every re-import forever. Falls back to
content_uid (subject|organizer|starts_at|ends_at) so re-parsing the same
source resolves to the same key. Also fixes ymd_digits, the per-occurrence
dedup-key suffix for expanded recurring events, to format in UTC instead
of the machine's local timezone -- it could otherwise compute a different
date (and therefore a different key) for the same occurrence across a DST
transition or timezone change between imports.
One-time cleanup for anyone already hit by the reimport-duplication bug:
groups existing rows by (source, raw_uid) -- backfilling a content-based
key first for rows that had none -- keeps the row a meeting is attached
to (or the first survivor otherwise), repoints any meeting pointing at a
row about to be removed, then deletes the rest. The unique index is
dropped and recreated around this since the backfill step can
momentarily produce rows that collide before they're deduped.
summary.json is sealed straight to disk, not through a store write, so
the FTS index needs an explicit nudge to pick up newly generated
summaries (FR-SEARCH-1).
Two bugs, one file:
- Search: reindex_fts now also indexes summary_text and tags_text, and
runs on every set_tags call (it previously only fired from
finalize_meeting/update_notes, so tag and summary changes never touched
the index at all). Promoted from a private helper to a Store trait
method so commands.rs can call it after summary.json is written
(summary lives only on disk, not through a store write).
- Calendar dedup: import_calendar_events used to insert whatever raw_uid
the caller passed, including None -- which the (source, raw_uid)
unique index explicitly exempts from dedup, so any event without a
captured UID duplicated on every re-import forever (the reported
1762 -> 11,764 row bug). Defaults to a deterministic content-derived
key (calendar::content_uid) when raw_uid is missing, and adds a
regression test that re-importing the same batch repeatedly doesn't
grow the table.
meeting_fts only ever indexed title/transcript/notes -- search silently
never covered summary or tags despite FR-SEARCH-1 requiring it (bug
report: "search not working" for those). meeting_fts is a derived index
(never a source of truth), so drop+recreate is safe: backfill_fts
repopulates every meeting from scratch on next startup.
Wires VoiceSample creation into start_recording (mic-enabled path) and
runs the voiceprint match after the final diarization pass in
stop_recording: on a confident match, persists "You" and "Speaker 2",
"Speaker 3"... via the existing rename_speaker store path, skipping any
label the user already renamed live.
mic_voice_sample carries the VoiceSample handle from start_recording
through to stop_recording so the final diarization pass can run the
mic-speaker voiceprint match.
Mic and system audio are already summed into one mono stream before
diarization runs, so clustering alone can't tell which cluster is the
user's own voice. match_mic_speaker compares a mic-only sample's speaker
embedding (same sherpa-onnx model diarization already uses) against each
diarized cluster's own audio and returns a label->name map: best match ->
"You", the rest -> "Speaker 2", "Speaker 3", etc. Returns empty (no
guessing) when the mic sample is too short or no cluster clears the
similarity threshold.
VoiceSample collects the first ~8s of raw 16kHz-mono mic audio during a
recording, then stops accepting once full. Feeds the mic-speaker
voiceprint match in diarization::voiceprint (bug: mic speaker mislabeled
S1/S2 instead of "You").
DropboxTarget: path-addressed like OneDrive/WebDAV; create_folder_v2 creates
the whole intermediate path in one call; upload-session chunking above
LARGE_FILE_THRESHOLD (start/append_v2/finish).
BoxTarget: Box addresses items by numeric ID, not path, so ensure_dir/exists/
put all walk (and lazily create) the folder chain from root ("0") by listing
each level's children. Always uploads via Box's session API regardless of
file size rather than its simple multipart endpoint, since the session API
takes plain PUT bodies (consistent with every other target here) and needs
no new reqwest feature; Box computes and returns each part's digest, so no
local hashing is needed either.
Both follow the same documented ceiling as OneDriveTarget's put_chunked
(M4.1): the upload-session id lives only for one put() call, not persisted
across a durable-queue retry after a restart.
Tests: in-process std::net mock servers for both providers (same pattern as
the existing MockNextcloud/MockGraph), each exercising a real multi-chunk
upload end-to-end.
resolve_language(Some("EN"), false) was passed through unchanged instead of
being normalized to lowercase "en" - the English-only-model guard only fired
when the requested language *differed* from English, not when it matched with
different casing. Also fixes cargo fmt drift in mod.rs/npu.rs left over from
the M4.2 worktree (stopped mid-verification per session instruction before
running fmt).
Recordings are now native-quality (~50-100 MB) and put() used to buffer
the whole file into memory with a single PUT/upload call; OneDrive/Graph
also caps a single PUT at 250 MB. This replaces that with disk-streamed,
chunked uploads on both sync backends (FR-SYNC-2/5):
- Both WebDavTarget::put and OneDriveTarget::put now stream the file off
disk in fixed-size buffers (tokio::fs::File + BufReader) instead of
tokio::fs::read()'ing it whole — memory use is O(chunk), not O(file
size), for every upload, large or small.
- Files at or below LARGE_FILE_THRESHOLD (8 MiB) still take a single
streamed PUT; only large artifacts (in practice, .wav recordings) take
the chunked path, so small transcript/notes/summary uploads are
unaffected.
- WebDAV (Nextcloud/ownCloud): implements the chunking-v2 protocol —
MKCOL an upload collection keyed deterministically off the remote
path's sha256 (stable across retries), PUT lexically-sortable parts,
MOVE the virtual `.file` to assemble server-side. A retry PROPFINDs
the collection first and skips any part already landed, so a
partially-uploaded large file resumes instead of restarting at byte 0
— genuine mid-file resume, not just chunk-level retry. Generic WebDAV
targets (Seafile, Synology, unbranded servers) have no equivalent
server-side assembly endpoint, so they fall back to one streamed PUT
above the threshold too — memory-safe, just not chunk-resumable
(documented ponytail simplification).
- OneDrive/Graph: implements the createUploadSession + Content-Range
byte-range PUT flow (chunks capped at 10 MiB, a multiple of Graph's
required 320 KiB granularity). A failed chunk is retried a few times
within the same put() call. ponytail-documented ceiling: the session
URL isn't persisted on the sync_jobs row, so a retry from a *later*
pump cycle (e.g. after an app restart) starts a fresh session and
re-uploads from byte 0 rather than resuming — true cross-attempt
resume would need a sync_jobs column, left as a follow-up.
- Progress is reported via the existing ProgressSink after each chunk,
not just once at the end, so the UI sees steady movement on a large
upload (FR-SYNC-11).
- OneDriveTarget's Graph URLs are now built from a `graph_base()` helper
(overridable via WA_GRAPH_BASE_URL, unset in production) so the
chunked session flow can be exercised against a local mock in tests.
Tests (all against small, in-process, dependency-free mock servers —
std::net only, no new crates, same pattern as oauth::LoopbackRedirect):
- nextcloud_chunked_upload_assembles_and_resumes: multi-chunk upload
assembles byte-for-byte, then a resumed upload with one chunk
pre-seeded skips exactly that chunk.
- small_file_skips_chunking_even_on_a_chunking_capable_target: a small
file never takes the chunked path.
- onedrive_chunked_upload_via_graph_session_mock: multi-chunk Graph
upload session assembles byte-for-byte with per-chunk progress.
- parse_chunk_sizes_extracts_href_and_length_ignoring_namespace_prefix
and chunk_name_is_zero_padded_and_lexically_sortable: pure unit tests
for the PROPFIND-response scraper and chunk-naming scheme.
Skip-if-unchanged (SHA-256, in storage::upsert_sync_job) and the
exponential-backoff retry queue (storage::claim_due_sync_jobs) are
untouched — this only changes how SyncTarget::put moves bytes.
Verified: cargo fmt clean; cargo clippy --features sync and --features
mcp both clean (-D warnings); cargo test --lib (default features:
audio, cpu-transcription, diarization, pst, sync, npu) 165 passed, 0
failed, 7 ignored (unrelated hardware-gated tests); cargo test --features
sync --lib: 18 passed, 0 failed, 1 ignored (webdav_round_trip, opt-in
live-server test per existing convention).
Needed for AsyncReadExt/AsyncSeekExt/BufReader used to stream .wav
recordings off disk in fixed-size chunks instead of buffering the
whole file in memory (T9.2 refinement, FR-SYNC-5).