Feature chore bug 002 #16

Merged
iamdoubz merged 43 commits from feature_chore_bug_002 into main 2026-07-06 17:05:58 -05:00
21 changed files with 1840 additions and 87 deletions
+1
View File
@@ -16,6 +16,7 @@ Each requirement has a stable ID used across the roadmap, tests, and commits. Pr
| FR-CAP-4 | M | 1 | Show an unambiguous "recording active" indicator (in-app banner + tray icon). |
| FR-CAP-5 | S | 7 | Render a live input waveform / level meter while recording. |
| FR-CAP-6 | S | 7 | Handle audio device changes mid-recording without losing the session. |
| FR-CAP-7 | S | 1 | Optionally capture the user's **microphone** alongside loopback and mix it into the live transcript (default ON, local-only/no egress, selectable device + "off"). |
### Recording retention & consent (REC) — see ADR-0009
+1 -1
View File
@@ -55,7 +55,7 @@ touches files, DB, or network directly — only Tauri commands/events (`04-api-c
| Service | Responsibility | Primary crate(s) |
|---|---|---|
| `audio` | WASAPI loopback capture; PCM ring buffer; write WAV to disk; pause/resume | `wasapi`, `hound` |
| `audio` | WASAPI loopback capture (+ optional microphone, mixed into the transcript stream, FR-CAP-7); PCM ring buffer; write WAV to disk; pause/resume | `wasapi`, `hound` |
| `hardware` | Enumerate NPU/GPU/CPU; rank backends; report capabilities | `ort`, DXGI via `windows` |
| `transcription` | Load model on a backend; stream segments (whisper.cpp) or NPU (ONNX) | `whisper-rs`, `ort` |
| `diarization` | Post-process audio → speaker spans; align to segments; merge | `sherpa-onnx` (FFI) |
+5
View File
@@ -154,10 +154,15 @@ indicative (async where I/O-bound).
pub trait AudioCapture: Send + Sync {
/// Begin WASAPI loopback capture, writing PCM to `wav_path`; frames also pushed to `sink`.
fn start(&self, wav_path: &Path, sink: FrameSink) -> Result<CaptureHandle, AudioError>;
/// Capture the user's microphone (FR-CAP-7); frames pushed to `sink`, no WAV.
fn start_microphone(&self, device_id: Option<&str>, sink: FrameSink) -> Result<CaptureHandle, AudioError>;
fn pause(&self, h: &CaptureHandle) -> Result<(), AudioError>;
fn resume(&self, h: &CaptureHandle) -> Result<(), AudioError>;
fn stop(&self, h: CaptureHandle) -> Result<CaptureSummary, AudioError>;
}
// When the mic is enabled, `spawn_mixer` sums the loopback + mic 16kHz-mono
// frames into the single transcription stream (`list_input_devices` enumerates
// mic devices, mirroring `list_audio_devices` for render devices).
// hardware/mod.rs
pub trait HardwareDetector: Send + Sync {
+1
View File
@@ -5980,6 +5980,7 @@ dependencies = [
"argon2",
"async-trait",
"chacha20poly1305",
"chrono",
"docx-rs",
"futures-util",
"getrandom 0.2.17",
+3
View File
@@ -27,6 +27,9 @@ tokio = { version = "1", features = ["rt-multi-thread", "macros", "sync", "time"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
uuid = { version = "1", features = ["v4"] }
# Local<->UTC, DST-aware — needed for calendar recurrence (T6.2); already in
# the dependency tree transitively (sqlx), this just promotes it to direct.
chrono = { version = "0.4", default-features = false, features = ["clock"] }
# storage
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite", "migrate"] }
+387 -24
View File
@@ -81,9 +81,24 @@ pub struct CaptureSummary {
}
pub trait AudioCapture: Send + Sync {
/// `device_id` is a `Device::get_id()` string (Settings.audio_output_device);
/// `None` uses the system default render device, same as before this was
/// selectable at all.
fn start(
&self,
wav_path: &Path,
device_id: Option<&str>,
frame_sink: FrameSink,
event_sink: EventSink,
) -> Result<CaptureHandle, AudioError>;
/// Capture the user's **microphone** (an input device — normal capture, no
/// loopback) into `frame_sink` for live transcription (FR-CAP-7). No WAV is
/// written here: the byte-accurate recording stays the loopback WAV; the mic
/// is mixed into the transcript stream (see `spawn_mixer`). `device_id` is a
/// `Device::get_id()`; `None` uses the system default capture device.
fn start_microphone(
&self,
device_id: Option<&str>,
frame_sink: FrameSink,
event_sink: EventSink,
) -> Result<CaptureHandle, AudioError>;
@@ -92,15 +107,67 @@ pub trait AudioCapture: Send + Sync {
fn stop(&self, h: CaptureHandle) -> Result<CaptureSummary, AudioError>;
}
/// One enumerated render (playback) device — the id is what gets persisted in
/// Settings and passed back into `AudioCapture::start`; the name is display-only.
#[derive(Debug, Clone, serde::Serialize)]
pub struct AudioDeviceInfo {
pub id: String,
pub name: String,
}
/// Enumerates active devices in one direction (Render = playback for loopback,
/// Capture = microphones) for the Settings device pickers.
#[cfg(feature = "audio")]
fn list_devices(direction: &Direction) -> Result<Vec<AudioDeviceInfo>, AudioError> {
wasapi::initialize_mta()
.ok()
.map_err(|e| AudioError::Device(format!("COM init failed: {e}")))?;
let collection = wasapi::DeviceCollection::new(direction)
.map_err(|e| AudioError::Device(format!("enumerate devices failed: {e}")))?;
let mut devices = Vec::new();
for device in &collection {
let device =
device.map_err(|e| AudioError::Device(format!("device enumeration error: {e}")))?;
let id = device
.get_id()
.map_err(|e| AudioError::Device(format!("device id: {e}")))?;
let name = device.get_friendlyname().unwrap_or_else(|_| id.clone());
devices.push(AudioDeviceInfo { id, name });
}
Ok(devices)
}
/// Lists active render (playback) devices for the loopback "Audio Devices"
/// picker (Settings -> Hardware); WhispAssist records whatever the chosen output
/// plays (FR-CAP-1).
#[cfg(feature = "audio")]
pub fn list_render_devices() -> Result<Vec<AudioDeviceInfo>, AudioError> {
list_devices(&Direction::Render)
}
/// Lists active capture (microphone) devices for the "Microphone" picker
/// (FR-CAP-7) — the user's own voice, mixed into the live transcript.
#[cfg(feature = "audio")]
pub fn list_capture_devices() -> Result<Vec<AudioDeviceInfo>, AudioError> {
list_devices(&Direction::Capture)
}
/// Default Windows WASAPI implementation (Phase 1, feature `audio`).
#[cfg(feature = "audio")]
pub struct WasapiCapture;
#[cfg(feature = "audio")]
impl AudioCapture for WasapiCapture {
fn start(
impl WasapiCapture {
/// Shared spawn path for both loopback and microphone capture. `wav_path` is
/// `Some` only for the loopback stream (the byte-accurate recording); the mic
/// stream produces frames for transcription but no WAV. `direction` selects
/// loopback (Render) vs. plain microphone capture (Capture).
fn start_capture(
&self,
wav_path: &Path,
thread_name: &str,
wav_path: Option<&Path>,
direction: Direction,
device_id: Option<&str>,
frame_sink: FrameSink,
event_sink: EventSink,
) -> Result<CaptureHandle, AudioError> {
@@ -108,12 +175,21 @@ impl AudioCapture for WasapiCapture {
let paused = Arc::new(AtomicBool::new(false));
let running_th = running.clone();
let paused_th = paused.clone();
let wav_path = wav_path.to_path_buf();
let wav_path = wav_path.map(Path::to_path_buf);
let device_id = device_id.map(str::to_string);
let thread = thread::Builder::new()
.name("wa-audio-capture".into())
.name(thread_name.into())
.spawn(move || {
capture_loop(&wav_path, &frame_sink, &event_sink, &running_th, &paused_th)
capture_loop(
wav_path.as_deref(),
direction,
device_id.as_deref(),
&frame_sink,
&event_sink,
&running_th,
&paused_th,
)
})
.map_err(|e| AudioError::Capture(format!("spawn failed: {e}")))?;
@@ -123,6 +199,42 @@ impl AudioCapture for WasapiCapture {
thread,
})
}
}
#[cfg(feature = "audio")]
impl AudioCapture for WasapiCapture {
fn start(
&self,
wav_path: &Path,
device_id: Option<&str>,
frame_sink: FrameSink,
event_sink: EventSink,
) -> Result<CaptureHandle, AudioError> {
self.start_capture(
"wa-audio-capture",
Some(wav_path),
Direction::Render,
device_id,
frame_sink,
event_sink,
)
}
fn start_microphone(
&self,
device_id: Option<&str>,
frame_sink: FrameSink,
event_sink: EventSink,
) -> Result<CaptureHandle, AudioError> {
self.start_capture(
"wa-mic-capture",
None,
Direction::Capture,
device_id,
frame_sink,
event_sink,
)
}
fn pause(&self, h: &CaptureHandle) -> Result<(), AudioError> {
h.paused.store(true, Ordering::SeqCst);
@@ -153,14 +265,40 @@ struct CaptureSession {
format: WaveFormat,
}
/// Opens the current default render device for loopback capture. Called both
/// for the initial open and to reconnect after the device disappears
/// mid-recording (FR-CAP-6) — each call re-resolves "the default device",
/// so it naturally picks up whatever the OS switched to.
/// Resolves the configured device (in the given direction) by id, falling back
/// to the system default if it's no longer present (unplugged/renamed since it
/// was picked) — same "degrade gracefully rather than fail the recording" spirit
/// as the device-recovery reconnect below.
#[cfg(feature = "audio")]
fn open_capture_session() -> Result<CaptureSession, AudioError> {
let device = wasapi::get_default_device(&Direction::Render)
.map_err(|e| AudioError::Device(format!("no default render device: {e}")))?;
fn find_device(direction: &Direction, device_id: Option<&str>) -> Result<wasapi::Device, AudioError> {
let Some(id) = device_id else {
return wasapi::get_default_device(direction)
.map_err(|e| AudioError::Device(format!("no default audio device: {e}")));
};
let collection = wasapi::DeviceCollection::new(direction)
.map_err(|e| AudioError::Device(format!("enumerate devices failed: {e}")))?;
for device in &collection {
let device =
device.map_err(|e| AudioError::Device(format!("device enumeration error: {e}")))?;
if device.get_id().map(|d| d == id).unwrap_or(false) {
return Ok(device);
}
}
wasapi::get_default_device(direction)
.map_err(|e| AudioError::Device(format!("configured device not found and no default: {e}")))
}
/// Opens the configured render device (or the current default, if none is
/// configured) for loopback capture. Called both for the initial open and to
/// reconnect after the device disappears mid-recording (FR-CAP-6) — each call
/// re-resolves the same selection, so a transient loss reconnects to the same
/// device while a truly-gone device falls back to whatever is now default.
#[cfg(feature = "audio")]
fn open_capture_session(
direction: &Direction,
device_id: Option<&str>,
) -> Result<CaptureSession, AudioError> {
let device = find_device(direction, device_id)?;
let mut audio_client = device
.get_iaudioclient()
.map_err(|e| AudioError::Device(format!("activate IAudioClient failed: {e}")))?;
@@ -239,7 +377,9 @@ const LEVEL_EMIT_INTERVAL: Duration = Duration::from_millis(50);
/// is cleared.
#[cfg(feature = "audio")]
fn capture_loop(
wav_path: &Path,
wav_path: Option<&Path>,
direction: Direction,
device_id: Option<&str>,
frame_sink: &FrameSink,
event_sink: &EventSink,
running: &AtomicBool,
@@ -249,11 +389,17 @@ fn capture_loop(
.ok()
.map_err(|e| AudioError::Device(format!("COM init failed: {e}")))?;
let mut session = open_capture_session()?;
// Only the loopback stream emits level updates (FR-CAP-5) and writes a WAV;
// the mic stream just contributes frames to the transcript mixer (FR-CAP-7).
let emit_level = wav_path.is_some();
let mut session = open_capture_session(&direction, device_id)?;
let spec = wav_spec_for(&session.format)?;
let mut writer = WavWriter::create(wav_path, spec).map_err(|e| {
AudioError::Capture(format!("could not create {}: {e}", wav_path.display()))
})?;
let mut writer = match wav_path {
Some(path) => Some(WavWriter::create(path, spec).map_err(|e| {
AudioError::Capture(format!("could not create {}: {e}", path.display()))
})?),
None => None,
};
session
.audio_client
@@ -279,7 +425,7 @@ fn capture_loop(
message: e.to_string(),
});
session.audio_client.stop_stream().ok();
let new_session = open_capture_session().map_err(|re| {
let new_session = open_capture_session(&direction, device_id).map_err(|re| {
AudioError::Capture(format!("device lost, reconnect failed: {re}"))
})?;
if !format_compatible(&session.format, &new_session.format) {
@@ -310,10 +456,12 @@ fn capture_loop(
continue;
}
frames_written += write_wav_bytes(&mut writer, &bytes, &session.format)?;
if let Some(w) = writer.as_mut() {
frames_written += write_wav_bytes(w, &bytes, &session.format)?;
}
let mono = decode_mono_f32(&bytes, &session.format)?;
if last_level_emit.elapsed() >= LEVEL_EMIT_INTERVAL {
if emit_level && last_level_emit.elapsed() >= LEVEL_EMIT_INTERVAL {
let _ = event_sink.try_send(CaptureEvent::Level(audio_level(&mono)));
last_level_emit = Instant::now();
}
@@ -325,9 +473,10 @@ fn capture_loop(
}
session.audio_client.stop_stream().ok();
writer
.finalize()
.map_err(|e| AudioError::Capture(format!("wav finalize failed: {e}")))?;
if let Some(w) = writer {
w.finalize()
.map_err(|e| AudioError::Capture(format!("wav finalize failed: {e}")))?;
}
let sample_rate = session.format.get_samplespersec();
Ok(CaptureSummary {
@@ -546,10 +695,145 @@ pub fn read_wav_mono_16k(path: &Path) -> Result<Vec<f32>, AudioError> {
Ok(resampler.process(&mono))
}
/// Ceiling on how far the still-flowing stream may run ahead of a stalled
/// partner before the mixer forwards it alone — so a muted/dead microphone (or a
/// silent system output) can't hold up the live transcript. 0.5s @ 16kHz.
const MIXER_MAX_LAG: usize = 8_000;
/// Sums two already-16kHz-mono streams (loopback + microphone, FR-CAP-7) sample
/// for sample into the single transcript stream. Pure struct so the alignment
/// logic is unit-testable without threads or real devices.
struct Mixer {
loopback: std::collections::VecDeque<f32>,
mic: std::collections::VecDeque<f32>,
}
impl Mixer {
fn new() -> Self {
Self {
loopback: std::collections::VecDeque::new(),
mic: std::collections::VecDeque::new(),
}
}
/// Emit what's ready: the overlapping head of both buffers summed, plus — if
/// a source has ended (`*_open == false`) or stalled while the other backs up
/// past `MIXER_MAX_LAG` — the leftover of the still-flowing source on its own.
fn drain_ready(&mut self, loop_open: bool, mic_open: bool) -> Vec<f32> {
let n = self.loopback.len().min(self.mic.len());
let mut out = Vec::with_capacity(n);
for _ in 0..n {
let s = self.loopback.pop_front().unwrap() + self.mic.pop_front().unwrap();
// ponytail: hard-clip on sum; only distorts when both sources peak at
// once, and STT tolerates it far better than a wrapped sample.
out.push(s.clamp(-1.0, 1.0));
}
// Microphone gone/stalled -> forward the loopback tail rather than wait.
if !mic_open || (self.mic.is_empty() && self.loopback.len() >= MIXER_MAX_LAG) {
out.extend(self.loopback.drain(..));
}
// Symmetric: loopback gone/stalled -> forward the mic tail.
if !loop_open || (self.loopback.is_empty() && self.mic.len() >= MIXER_MAX_LAG) {
out.extend(self.mic.drain(..));
}
out
}
}
/// Spawns the mixer thread and returns the two frame sinks to hand to the
/// loopback and microphone captures respectively; their summed output flows into
/// `out` (the transcription worker). Only used when the mic is enabled — with it
/// off, loopback frames go straight to `out` as before (no mixer overhead).
pub fn spawn_mixer(out: FrameSink) -> (FrameSink, FrameSink) {
let (loop_tx, loop_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(8);
let (mic_tx, mic_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(8);
let _ = thread::Builder::new()
.name("wa-audio-mixer".into())
.spawn(move || mixer_loop(loop_rx, mic_rx, &out));
(loop_tx, mic_tx)
}
fn mixer_loop(
loop_rx: std::sync::mpsc::Receiver<Vec<f32>>,
mic_rx: std::sync::mpsc::Receiver<Vec<f32>>,
out: &FrameSink,
) {
use std::sync::mpsc::{RecvTimeoutError, TryRecvError};
let mut mixer = Mixer::new();
let mut loop_open = true;
let mut mic_open = true;
while loop_open || mic_open || !mixer.loopback.is_empty() || !mixer.mic.is_empty() {
// Loopback is the guaranteed primary source and drives the cadence: block
// on it briefly so we wake on its ~10ms chunks instead of busy-spinning.
if loop_open {
match loop_rx.recv_timeout(Duration::from_millis(20)) {
Ok(chunk) => mixer.loopback.extend(chunk),
Err(RecvTimeoutError::Timeout) => {}
Err(RecvTimeoutError::Disconnected) => loop_open = false,
}
} else {
thread::sleep(Duration::from_millis(20));
}
// Drain whatever the mic has queued without blocking.
loop {
match mic_rx.try_recv() {
Ok(chunk) => mixer.mic.extend(chunk),
Err(TryRecvError::Empty) => break,
Err(TryRecvError::Disconnected) => {
mic_open = false;
break;
}
}
}
let ready = mixer.drain_ready(loop_open, mic_open);
// Blocking send applies backpressure (which then makes the capture threads
// drop frames, as they already do); a send error means the transcription
// consumer is gone, so there's nothing left to mix.
if !ready.is_empty() && out.send(ready).is_err() {
break;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn mixer_sums_aligned_streams_and_keeps_the_unmatched_tail() {
let mut m = Mixer::new();
m.loopback.extend([0.1, 0.2, 0.3, 0.4]);
m.mic.extend([0.5, 0.5]);
// Both still open: only the 2-sample overlap is emitted; the loopback tail
// is held back to align with future mic samples (tail < MIXER_MAX_LAG).
let out = m.drain_ready(true, true);
assert_eq!(out, vec![0.6, 0.7]);
assert_eq!(m.loopback.len(), 2);
assert!(m.mic.is_empty());
}
#[test]
fn mixer_clamps_a_summed_overload() {
let mut m = Mixer::new();
m.loopback.extend([0.8, -0.8]);
m.mic.extend([0.8, -0.8]);
let out = m.drain_ready(true, true);
assert_eq!(out, vec![1.0, -1.0]); // 1.6 / -1.6 clamped to the valid range
}
#[test]
fn mixer_flushes_the_survivor_when_the_mic_ends() {
let mut m = Mixer::new();
m.loopback.extend([0.1, 0.2, 0.3]);
m.mic.extend([0.5]);
// Mic closed: sum the 1-sample overlap, then forward the loopback tail
// alone rather than stall the transcript forever.
let out = m.drain_ready(true, false);
assert_eq!(out, vec![0.6, 0.2, 0.3]);
assert!(m.loopback.is_empty());
}
#[test]
fn wav_writer_roundtrip_produces_valid_header_and_duration() {
let dir = std::env::temp_dir().join(format!("wa-test-{}", uuid::Uuid::new_v4()));
@@ -690,6 +974,85 @@ mod tests {
assert_eq!(r.process(&input), input);
}
#[test]
fn list_render_devices_succeeds_and_ids_are_unique() {
// Real COM/WASAPI enumeration against whatever hardware this machine
// actually has — device count/names aren't asserted (machine-
// dependent), just that enumeration works and ids are stable/unique
// (the property the Settings picker and find_render_device rely on).
let devices = list_render_devices().unwrap();
let mut ids: Vec<&str> = devices.iter().map(|d| d.id.as_str()).collect();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), devices.len());
}
#[test]
#[ignore = "opens the real default microphone; run with --ignored on a machine that has one"]
fn microphone_capture_opens_and_stops_cleanly() {
// FR-CAP-7 against real hardware: the default mic opens for capture and
// stops without error. Frame *delivery* isn't asserted here — WASAPI
// doesn't pump default-device audio into a `cargo test` process on some
// machines (same quirk that makes get_default_device(Render) fail under
// test), so that end-to-end check belongs in the running app. The logged
// frame count is informational.
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(64);
let (event_tx, _event_rx) = std::sync::mpsc::sync_channel::<CaptureEvent>(8);
let handle = WasapiCapture
.start_microphone(None, frame_tx, event_tx)
.expect("open default microphone");
std::thread::sleep(Duration::from_millis(600));
let summary = WasapiCapture.stop(handle).expect("stop mic");
let samples: usize = frame_rx.try_iter().map(|c| c.len()).sum();
eprintln!(
"[mic] device opened at {}Hz; delivered {samples} frames @16kHz over ~0.6s",
summary.sample_rate
);
assert!(summary.sample_rate > 0, "mic session did not open a device");
}
#[test]
fn list_capture_devices_succeeds_and_ids_are_unique() {
// Same contract as the render picker, for the microphone picker
// (FR-CAP-7): enumeration works and ids are stable/unique so
// find_device can resolve a persisted selection.
let devices = list_capture_devices().unwrap();
let mut ids: Vec<&str> = devices.iter().map(|d| d.id.as_str()).collect();
ids.sort_unstable();
ids.dedup();
assert_eq!(ids.len(), devices.len());
}
#[test]
fn find_render_device_resolves_a_real_enumerated_id() {
let devices = list_render_devices().unwrap();
let Some(first) = devices.first() else {
return; // no render device on this machine/CI runner
};
assert!(find_device(&Direction::Render, Some(&first.id)).is_ok());
}
#[test]
fn find_render_device_none_matches_get_default_device() {
// Documents the actual environment rather than assuming one: if this
// machine/CI runner has no default render device at all,
// wasapi::get_default_device itself errors — same as it always has,
// unrelated to device selection — and find_device(None) must
// fail identically rather than pretending to succeed.
let default_ok = wasapi::get_default_device(&Direction::Render).is_ok();
assert_eq!(find_device(&Direction::Render, None).is_ok(), default_ok);
}
#[test]
fn find_render_device_unknown_id_falls_back_exactly_like_none() {
// An id that can never be real should resolve identically to "no
// selection" — same success/failure outcome as whatever this
// environment's default-device lookup actually does.
let none_ok = find_device(&Direction::Render, None).is_ok();
let unknown_ok = find_device(&Direction::Render, Some("not-a-real-device-id")).is_ok();
assert_eq!(none_ok, unknown_ok);
}
#[test]
fn audio_level_of_empty_chunk_is_silence() {
let level = audio_level(&[]);
+423 -16
View File
@@ -9,6 +9,7 @@
//! writes to it.
use crate::models::{AttendeeInfo, CalendarEvent, ImportedEvent};
use chrono::{Datelike, Duration, Local, NaiveDate, TimeZone, Timelike, Utc};
use std::path::Path;
use std::process::Command;
@@ -79,9 +80,15 @@ fn run_readpst(pst_path: &str, out_dir: &Path) -> Result<(), CalError> {
_ => CalError::Open(e.to_string()),
})?;
if !output.status.success() {
return Err(CalError::Parse(
String::from_utf8_lossy(&output.stderr).trim().to_string(),
));
// readpst writes some errors to stdout rather than stderr; show
// whichever stream actually has text, stderr first.
let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string();
let message = if stderr.is_empty() {
String::from_utf8_lossy(&output.stdout).trim().to_string()
} else {
stderr
};
return Err(CalError::Parse(message));
}
Ok(())
}
@@ -202,6 +209,7 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
let mut description = None;
let mut starts_at = None;
let mut ends_at = None;
let mut rrule: Option<String> = None;
let mut attendees: Vec<AttendeeInfo> = Vec::new();
for line in &lines {
@@ -217,22 +225,49 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
description = None;
starts_at = None;
ends_at = None;
rrule = None;
attendees = Vec::new();
}
"END" if value.eq_ignore_ascii_case("VEVENT") && in_event => {
events.push(ImportedEvent {
event: CalendarEvent {
id: uuid::Uuid::new_v4().to_string(),
source: source.to_string(),
subject: summary.take(),
organizer: organizer.take(),
starts_at,
ends_at,
description: description.take(),
raw_uid: uid.take(),
},
attendees: std::mem::take(&mut attendees),
});
let attendees = std::mem::take(&mut attendees);
match (rrule.take(), &uid, starts_at) {
// A recurring event needs a UID to key its occurrences
// for dedup (source, raw_uid) — without one, fall back
// to importing just the single stored occurrence below.
(Some(rule), Some(base_uid), Some(dtstart)) => {
let duration = ends_at.map(|e| e - dtstart).unwrap_or(0);
for occ_start in expand_rrule(&rule, dtstart) {
events.push(ImportedEvent {
event: CalendarEvent {
id: uuid::Uuid::new_v4().to_string(),
source: source.to_string(),
subject: summary.clone(),
organizer: organizer.clone(),
starts_at: Some(occ_start),
ends_at: Some(occ_start + duration),
description: description.clone(),
raw_uid: Some(format!("{base_uid}@{}", ymd_digits(occ_start))),
},
attendees: attendees.clone(),
});
}
}
_ => {
events.push(ImportedEvent {
event: CalendarEvent {
id: uuid::Uuid::new_v4().to_string(),
source: source.to_string(),
subject: summary.take(),
organizer: organizer.take(),
starts_at,
ends_at,
description: description.take(),
raw_uid: uid.take(),
},
attendees,
});
}
}
in_event = false;
}
"UID" if in_event => uid = Some(value.to_string()),
@@ -240,6 +275,7 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
"DESCRIPTION" if in_event => description = Some(unescape_text(value)),
"DTSTART" if in_event => starts_at = parse_ics_datetime(value),
"DTEND" if in_event => ends_at = parse_ics_datetime(value),
"RRULE" if in_event => rrule = Some(value.to_string()),
"ORGANIZER" if in_event => {
let (name, email) = cal_address(params, value);
organizer = name.or(email);
@@ -261,6 +297,266 @@ fn parse_vevents(ics_text: &str, source: &str) -> Vec<ImportedEvent> {
events
}
// ---- RRULE (RFC 5545 recurrence) expansion — pure, no I/O ----
// ponytail: caps for RRULEs with no COUNT/UNTIL (only YEARLY holidays do this
// in practice) and a hard ceiling regardless — extend both if a real series
// needs more instances than this.
const RECURRENCE_HORIZON_YEARS: i64 = 10;
const RECURRENCE_MAX_OCCURRENCES: usize = 500;
enum Freq {
Daily,
Weekly,
Monthly,
Yearly,
}
struct Rrule {
freq: Freq,
interval: i64,
count: Option<usize>,
until: Option<i64>,
byday: Vec<u32>, // weekday indices, 0=SU..6=SA
bymonthday: Vec<u32>, // 1..31
bymonth: Vec<u32>, // 1..12
}
fn weekday_code_to_index(code: &str) -> Option<u32> {
// Strips a leading ordinal like "2MO" ("2nd Monday") — not seen in this
// codebase's real-world data (only plain weekday codes are), but the
// weekday is all this parser uses either way.
let letters: String = code.chars().filter(|c| c.is_ascii_alphabetic()).collect();
match letters.to_ascii_uppercase().as_str() {
"SU" => Some(0),
"MO" => Some(1),
"TU" => Some(2),
"WE" => Some(3),
"TH" => Some(4),
"FR" => Some(5),
"SA" => Some(6),
_ => None,
}
}
/// Parses `FREQ=WEEKLY;COUNT=26;BYDAY=MO`-style RRULE values. Supports
/// DAILY/WEEKLY/MONTHLY/YEARLY with INTERVAL/COUNT/UNTIL/BYDAY/BYMONTHDAY/
/// BYMONTH — every combination confirmed present in a real 7.2GB mailbox
/// (ADR-0008). No BYSETPOS, no per-occurrence exceptions (RECURRENCE-ID).
fn parse_rrule(rule: &str) -> Option<Rrule> {
let mut freq = None;
let mut interval = 1i64;
let mut count = None;
let mut until = None;
let mut byday = Vec::new();
let mut bymonthday = Vec::new();
let mut bymonth = Vec::new();
let mut current_list_key = String::new();
for part in rule.split(';') {
// readpst joins a multi-value BYDAY with `;` instead of RFC 5545's
// `,` (e.g. `BYDAY=MO;TU;WE;TH;FR`), so a continuation token has no
// `=` at all — attribute it to whichever list key came before it.
let (key, v) = match part.split_once('=') {
Some((k, v)) => {
current_list_key = k.to_ascii_uppercase();
(current_list_key.as_str(), v)
}
None => (current_list_key.as_str(), part),
};
match key {
"FREQ" => {
freq = match v.to_ascii_uppercase().as_str() {
"DAILY" => Some(Freq::Daily),
"WEEKLY" => Some(Freq::Weekly),
"MONTHLY" => Some(Freq::Monthly),
"YEARLY" => Some(Freq::Yearly),
_ => None,
}
}
"INTERVAL" => interval = v.parse().unwrap_or(1).max(1),
"COUNT" => count = v.parse().ok(),
"UNTIL" => until = parse_ics_datetime(v),
"BYDAY" => byday.extend(v.split(',').filter_map(weekday_code_to_index)),
"BYMONTHDAY" => bymonthday.extend(v.split(',').filter_map(|s| s.parse::<u32>().ok())),
"BYMONTH" => bymonth.extend(v.split(',').filter_map(|s| s.parse::<u32>().ok())),
_ => {}
}
}
Some(Rrule {
freq: freq?,
interval,
count,
until,
byday,
bymonthday,
bymonth,
})
}
/// Resolves a local wall-clock datetime to a UTC unix timestamp, applying
/// whatever DST rule the OS has for that specific calendar date — this is
/// what keeps a recurring meeting at the same local time across a DST
/// transition instead of drifting by an hour. A skipped (spring-forward gap)
/// or ambiguous (fall-back overlap) local time resolves to the OS's earliest
/// matching instant rather than failing outright.
fn local_to_utc_secs(dt: chrono::NaiveDateTime) -> i64 {
match Local.from_local_datetime(&dt) {
chrono::LocalResult::Single(ldt) | chrono::LocalResult::Ambiguous(ldt, _) => {
ldt.with_timezone(&Utc).timestamp()
}
chrono::LocalResult::None => dt.and_utc().timestamp(),
}
}
fn ymd_digits(unix_secs: i64) -> String {
match Utc.timestamp_opt(unix_secs, 0).single() {
Some(dt) => {
let d = dt.with_timezone(&Local).date_naive();
format!("{:04}{:02}{:02}", d.year(), d.month(), d.day())
}
None => String::new(),
}
}
/// Expands an RRULE into occurrence start timestamps (unix seconds). Each
/// occurrence keeps `dtstart`'s *local* wall-clock time-of-day (assuming the
/// meeting's timezone matches this machine's — reasonable for a single-user
/// tool reading its own Outlook data), re-resolving the UTC offset per
/// occurrence so a series spanning a DST transition doesn't drift by an hour.
fn expand_rrule(rule: &str, dtstart: i64) -> Vec<i64> {
let Some(r) = parse_rrule(rule) else {
return vec![dtstart];
};
let Some(dtstart_utc) = Utc.timestamp_opt(dtstart, 0).single() else {
return vec![dtstart];
};
let local_start = dtstart_utc.with_timezone(&Local).naive_local();
let (hour, min, sec) = (
local_start.hour(),
local_start.minute(),
local_start.second(),
);
let start_date = local_start.date();
let indefinite = r.count.is_none() && r.until.is_none();
let effective_until = if indefinite {
dtstart + RECURRENCE_HORIZON_YEARS * 365 * 86_400
} else {
r.until.unwrap_or(i64::MAX)
};
let count_cap = r
.count
.unwrap_or(RECURRENCE_MAX_OCCURRENCES)
.min(RECURRENCE_MAX_OCCURRENCES);
let at = |date: NaiveDate| date.and_hms_opt(hour, min, sec).map(local_to_utc_secs);
let mut occurrences = Vec::new();
match r.freq {
// Outlook emits "every weekday" as either FREQ, always with BYDAY —
// both iterate calendar weeks and keep the requested weekdays.
Freq::Weekly | Freq::Daily if !r.byday.is_empty() => {
let step_weeks = if matches!(r.freq, Freq::Weekly) {
r.interval
} else {
1
};
let mut week_start =
start_date - Duration::days(start_date.weekday().num_days_from_sunday() as i64);
'weeks: loop {
for &wd in &r.byday {
let date = week_start + Duration::days(wd as i64);
if date < start_date {
continue;
}
let Some(ts) = at(date) else { continue };
if ts > effective_until || occurrences.len() >= count_cap {
break 'weeks;
}
occurrences.push(ts);
}
week_start += Duration::weeks(step_weeks);
}
}
Freq::Daily => {
let mut date = start_date;
while let Some(ts) = at(date) {
if ts > effective_until || occurrences.len() >= count_cap {
break;
}
occurrences.push(ts);
date += Duration::days(r.interval);
}
}
Freq::Weekly => {
let mut date = start_date;
while let Some(ts) = at(date) {
if ts > effective_until || occurrences.len() >= count_cap {
break;
}
occurrences.push(ts);
date += Duration::weeks(r.interval);
}
}
Freq::Monthly => {
let day_of_month = r.bymonthday.first().copied().unwrap_or(start_date.day());
let mut idx: i64 = 0;
loop {
let total = start_date.month0() as i64 + idx * r.interval;
let year = start_date.year() + total.div_euclid(12) as i32;
let month = (total.rem_euclid(12) + 1) as u32;
if let Some(date) = NaiveDate::from_ymd_opt(year, month, day_of_month) {
if date >= start_date {
if let Some(ts) = at(date) {
if ts > effective_until || occurrences.len() >= count_cap {
break;
}
occurrences.push(ts);
}
}
}
idx += 1;
if idx as usize > RECURRENCE_MAX_OCCURRENCES * 2 {
break; // safety valve against a pathological rule
}
}
}
Freq::Yearly => {
let months = if r.bymonth.is_empty() {
vec![start_date.month()]
} else {
r.bymonth.clone()
};
let day_of_month = r.bymonthday.first().copied().unwrap_or(start_date.day());
let mut year = start_date.year();
loop {
for &month in &months {
if let Some(date) = NaiveDate::from_ymd_opt(year, month, day_of_month) {
if date >= start_date {
if let Some(ts) = at(date) {
if ts <= effective_until && occurrences.len() < count_cap {
occurrences.push(ts);
}
}
}
}
}
year += r.interval as i32;
if occurrences.len() >= count_cap
|| year > start_date.year() + (RECURRENCE_HORIZON_YEARS * 2) as i32
{
break;
}
}
}
}
if occurrences.is_empty() {
vec![dtstart]
} else {
occurrences
}
}
/// Parses an iCalendar DATE-TIME (`20260701T090000Z` / `20260701T090000`) or
/// DATE (`20260701`) value to a unix epoch. Both `Z`-suffixed and floating
/// (no `Z`, no `TZID`) values are treated as UTC — full IANA timezone
@@ -402,4 +698,115 @@ END:VCALENDAR\r\n";
});
assert!(matches!(result, Err(CalError::Open(_))));
}
// These assert on *local* wall-clock time rather than raw UTC offsets —
// that's the entire point of the DST fix (a fixed UTC time-of-day is
// exactly the bug: a recurring meeting drifts an hour across a DST
// transition). Local-time assertions depend on this machine's configured
// timezone, same as the production code they're testing.
fn local_hms(ts: i64) -> (u32, u32, u32) {
let dt = Utc.timestamp_opt(ts, 0).unwrap().with_timezone(&Local);
(dt.hour(), dt.minute(), dt.second())
}
fn local_date(ts: i64) -> NaiveDate {
Utc.timestamp_opt(ts, 0)
.unwrap()
.with_timezone(&Local)
.date_naive()
}
#[test]
fn expand_rrule_weekly_single_byday_matches_the_real_1on1_pattern() {
// The exact rule readpst produced for a real "Weekly 1:1" on Mondays.
let dtstart = ymd_hms_to_unix(2026, 1, 12, 16, 30, 0); // a Monday
let occurrences = expand_rrule("FREQ=WEEKLY;COUNT=26;BYDAY=MO", dtstart);
assert_eq!(occurrences.len(), 26);
assert_eq!(occurrences[0], dtstart);
let expected_hms = local_hms(dtstart);
for pair in occurrences.windows(2) {
assert_eq!((local_date(pair[1]) - local_date(pair[0])).num_days(), 7);
}
for occ in &occurrences {
assert_eq!(
local_hms(*occ),
expected_hms,
"local wall-clock time must not drift across a DST transition"
);
assert_eq!(local_date(*occ).weekday(), chrono::Weekday::Mon);
}
}
#[test]
fn expand_rrule_weekly_multi_byday_covers_every_weekday_in_order() {
let dtstart = ymd_hms_to_unix(2026, 1, 12, 9, 0, 0); // Monday
let occurrences = expand_rrule("FREQ=WEEKLY;COUNT=10;BYDAY=MO;TU;WE;TH;FR", dtstart);
assert_eq!(occurrences.len(), 10);
// Mon..Fri week 1, then Mon..Fri week 2 — a flat +1 day step except
// the weekend gap between index 4 (Fri) and 5 (next Mon).
for i in 0..4 {
assert_eq!(
(local_date(occurrences[i + 1]) - local_date(occurrences[i])).num_days(),
1
);
}
assert_eq!(
(local_date(occurrences[5]) - local_date(occurrences[4])).num_days(),
3
);
let expected_hms = local_hms(dtstart);
for occ in &occurrences {
assert_eq!(local_hms(*occ), expected_hms);
}
}
#[test]
fn expand_rrule_monthly_bymonthday_steps_calendar_months() {
let dtstart = ymd_hms_to_unix(2026, 1, 1, 9, 0, 0);
let occurrences = expand_rrule("FREQ=MONTHLY;COUNT=7;BYMONTHDAY=1", dtstart);
assert_eq!(occurrences.len(), 7);
let last = local_date(occurrences[6]);
assert_eq!((last.year(), last.month(), last.day()), (2026, 7, 1));
let expected_hms = local_hms(dtstart);
for occ in &occurrences {
assert_eq!(local_hms(*occ), expected_hms);
}
}
#[test]
fn expand_rrule_yearly_with_no_count_or_until_is_capped_by_the_horizon() {
let dtstart = ymd_hms_to_unix(2020, 11, 11, 17, 0, 0); // afternoon UTC, safe midnight margin
let occurrences = expand_rrule("FREQ=YEARLY;BYMONTHDAY=11;BYMONTH=11", dtstart);
assert!(
!occurrences.is_empty() && occurrences.len() <= RECURRENCE_HORIZON_YEARS as usize + 2
);
for occ in &occurrences {
let d = local_date(*occ);
assert_eq!((d.month(), d.day()), (11, 11));
}
}
#[test]
fn parse_vevents_expands_a_recurring_event_into_distinct_occurrences() {
let ics = "BEGIN:VEVENT\r\n\
UID:series-1\r\n\
SUMMARY:Weekly 1:1\r\n\
DTSTART:20260112T163000Z\r\n\
DTEND:20260112T170000Z\r\n\
RRULE:FREQ=WEEKLY;COUNT=3;BYDAY=MO\r\n\
END:VEVENT\r\n";
let events = parse_vevents(ics, "pst");
assert_eq!(events.len(), 3);
let raw_uids: Vec<_> = events.iter().map(|e| e.event.raw_uid.clone()).collect();
assert_eq!(
raw_uids.len(),
raw_uids
.iter()
.collect::<std::collections::HashSet<_>>()
.len()
);
for e in &events {
assert_eq!(e.event.subject.as_deref(), Some("Weekly 1:1"));
assert_eq!(e.event.ends_at.unwrap() - e.event.starts_at.unwrap(), 1800);
}
}
}
+164 -13
View File
@@ -60,6 +60,11 @@ fn default_settings() -> Settings {
sync_enabled: false,
retention_max_age_days: None,
retention_max_size_gb: None,
pst_last_path: None,
pst_auto_sync: false,
audio_output_device: None,
microphone_enabled: true,
audio_input_device: None,
}
}
@@ -283,9 +288,39 @@ pub async fn start_recording(
let (frame_tx, frame_rx) = std::sync::mpsc::sync_channel::<Vec<f32>>(8);
// Level updates + device-change notices (FR-CAP-5/6); low-volume, forwarded to events below.
let (event_tx, event_rx) = std::sync::mpsc::sync_channel::<crate::audio::CaptureEvent>(8);
let capture = WasapiCapture
.start(&wav_path, frame_tx, event_tx)
.map_err(|e| WaError::new("audio", e.to_string()))?;
// When the mic is enabled (FR-CAP-7), capture it alongside loopback and let
// the mixer sum both into `frame_tx`; with it off, loopback feeds the
// transcription worker directly, exactly as before (no mixer overhead). A
// microphone that fails to open must not sink the meeting: we log and fall
// back to loopback-only (the mixer forwards loopback alone once its sink
// drops).
let (capture, mic_capture) = if settings.microphone_enabled {
let (loop_sink, mic_sink) = crate::audio::spawn_mixer(frame_tx);
let capture = WasapiCapture
.start(
&wav_path,
settings.audio_output_device.as_deref(),
loop_sink,
event_tx.clone(),
)
.map_err(|e| WaError::new("audio", e.to_string()))?;
let mic = WasapiCapture
.start_microphone(settings.audio_input_device.as_deref(), mic_sink, event_tx)
.map_err(|e| tracing::warn!("microphone capture unavailable: {e}"))
.ok();
(capture, mic)
} else {
let capture = WasapiCapture
.start(
&wav_path,
settings.audio_output_device.as_deref(),
frame_tx,
event_tx,
)
.map_err(|e| WaError::new("audio", e.to_string()))?;
(capture, None)
};
// Fire-and-forget: exits on its own once `event_tx` drops at capture stop;
// nothing downstream needs to join it.
@@ -441,6 +476,7 @@ pub async fn start_recording(
*guard = Some(RecordingSession {
meeting_id: meeting_id.clone(),
capture,
mic_capture,
retention: args.record,
wav_path,
started_at: std::time::Instant::now(),
@@ -489,8 +525,13 @@ pub async fn stop_recording(
let summary = WasapiCapture
.stop(session.capture)
.map_err(|e| WaError::new("audio", e.to_string()))?;
// The capture thread has dropped its `FrameSink`; the transcription worker's
// `recv()` now returns `Err` and the worker exits on its own — join to
// Stop the mic too (FR-CAP-7) so the mixer sees both sinks drop and closes
// `frame_tx`; ignore its summary/errors — the loopback WAV is the recording.
if let Some(mic) = session.mic_capture {
let _ = WasapiCapture.stop(mic);
}
// Both capture threads have now dropped their `FrameSink`s; the transcription
// worker's `recv()` returns `Err` and the worker exits on its own — join to
// guarantee it has fully drained the audio before we act on retention.
let _ = session.transcription_worker.join();
@@ -613,6 +654,11 @@ pub async fn pause_recording(
WasapiCapture
.pause(&session.capture)
.map_err(|e| WaError::new("audio", e.to_string()))?;
if let Some(mic) = session.mic_capture.as_ref() {
WasapiCapture
.pause(mic)
.map_err(|e| WaError::new("audio", e.to_string()))?;
}
drop(guard);
let _ = app.emit(
"recording://state",
@@ -635,6 +681,11 @@ pub async fn resume_recording(
WasapiCapture
.resume(&session.capture)
.map_err(|e| WaError::new("audio", e.to_string()))?;
if let Some(mic) = session.mic_capture.as_ref() {
WasapiCapture
.resume(mic)
.map_err(|e| WaError::new("audio", e.to_string()))?;
}
drop(guard);
let _ = app.emit(
"recording://state",
@@ -903,6 +954,28 @@ pub async fn hardware_status() -> WaResult<serde_json::Value> {
}))
}
/// Lists active render (playback) devices for the "Audio Devices" picker
/// (Settings ▸ Hardware) — loopback-only, matching the app's only capture
/// path (FR-CAP-1). Runs off the async runtime thread since device
/// enumeration is a blocking COM call.
#[tauri::command]
pub async fn list_audio_devices() -> WaResult<Vec<crate::audio::AudioDeviceInfo>> {
tauri::async_runtime::spawn_blocking(crate::audio::list_render_devices)
.await
.map_err(|e| WaError::new("audio", e.to_string()))?
.map_err(|e| WaError::new("audio", e.to_string()))
}
/// Enumerate capture (microphone) devices for the Settings "Microphone" picker
/// (FR-CAP-7). Blocking COM enumeration, so it runs off the async runtime thread.
#[tauri::command]
pub async fn list_input_devices() -> WaResult<Vec<crate::audio::AudioDeviceInfo>> {
tauri::async_runtime::spawn_blocking(crate::audio::list_capture_devices)
.await
.map_err(|e| WaError::new("audio", e.to_string()))?
.map_err(|e| WaError::new("audio", e.to_string()))
}
#[derive(Deserialize)]
pub struct SetPreferredBackendArgs {
pub backend: String, // "auto"|"npu"|"nvidia"|"amd"|"intel"|"cpu"
@@ -1939,6 +2012,46 @@ pub async fn confirm_action_items(
Ok(())
}
/// Suggests 1-8 short topical tags from the transcript (T8.3, FR-SEARCH-2) —
/// same LLM path/prompt assembly as generate_summary, just a much shorter
/// non-streamed reply. Suggestions are NOT saved automatically; the caller
/// reviews/merges them and still calls set_tags to persist, same as a
/// manually typed tag.
#[tauri::command]
pub async fn generate_tags(
state: State<'_, AppState>,
meeting_id: MeetingId,
) -> WaResult<Vec<String>> {
let guard = state.session.lock().await;
if guard.as_ref().is_some_and(|s| s.meeting_id == meeting_id) {
return Err(WaError::new(
"llm",
"cannot generate tags while this meeting is still recording — wait until it's stopped",
));
}
drop(guard);
let settings = load_settings();
let provider = llm_provider_from_settings(&settings).ok_or_else(|| {
WaError::new(
"llm",
"no LLM provider is configured — enable one in Settings first",
)
})?;
let meeting = state
.store
.get_meeting(&meeting_id)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
let prompt = build_prompt(&meeting, None);
let text = format!("{}\n\n{}", prompt.metadata, prompt.transcript);
provider
.suggest_tags(&text)
.await
.map_err(|e| WaError::new("llm", e.to_string()))
}
// ---- Calendar / .pst (Phase 6) ----
/// Import events + attendees from a `.pst` (T6.1/T6.2, FR-CAL-1). The
@@ -1947,10 +2060,12 @@ pub async fn confirm_action_items(
/// progress, so `pst://progress` is a start/done signal rather than
/// per-item — real per-item progress would mean reimplementing readpst's
/// internals, not worth it for a one-shot import.
#[tauri::command]
pub async fn import_pst(
app: AppHandle,
state: State<'_, AppState>,
///
/// Split from the `#[tauri::command]` wrapper so the startup auto-sync pass
/// (T6.2, `pst_auto_sync`) can call the same logic without a `State` extractor.
pub(crate) async fn import_pst_core(
app: &AppHandle,
store: &dyn crate::storage::Store,
path: String,
password: Option<String>,
) -> WaResult<u32> {
@@ -1967,8 +2082,7 @@ pub async fn import_pst(
serde_json::json!({ "processed": 0, "total": total }),
);
let imported = state
.store
let imported = store
.import_calendar_events(events)
.await
.map_err(|e| WaError::new("storage", e.to_string()))?;
@@ -1980,6 +2094,16 @@ pub async fn import_pst(
Ok(imported)
}
#[tauri::command]
pub async fn import_pst(
app: AppHandle,
state: State<'_, AppState>,
path: String,
password: Option<String>,
) -> WaResult<u32> {
import_pst_core(&app, state.store.as_ref(), path, password).await
}
/// Browse imported calendar events (T6.3, FR-CAL-2).
#[tauri::command]
pub async fn list_calendar_events(
@@ -2023,6 +2147,25 @@ pub async fn attach_meeting_to_event(
.map_err(|e| WaError::new("storage", e.to_string()))
}
/// Manually rename a meeting — recordings default to "Untitled meeting" with
/// no prior way to change that from the UI.
#[tauri::command]
pub async fn rename_meeting(
state: State<'_, AppState>,
meeting_id: MeetingId,
title: String,
) -> WaResult<()> {
let title = title.trim();
if title.is_empty() {
return Err(WaError::new("storage", "title cannot be empty"));
}
state
.store
.rename_meeting(&meeting_id, title)
.await
.map_err(|e| WaError::new("storage", e.to_string()))
}
// ---- Sync / upload (Phase 9, ADR-0010) ----
/// Config payload for add/update (snake_case, matching the TS `SyncTargetConfig`
@@ -2369,13 +2512,21 @@ pub async fn test_sync_target(
(row.credential_ref.clone(), None)
};
let target = crate::sync::WebDavTarget {
base_url: config.base_url.clone().or(row.base_url.clone()).unwrap_or_default(),
base_url: config
.base_url
.clone()
.or(row.base_url.clone())
.unwrap_or_default(),
provider_hint: config.provider_hint.clone().or(row.provider_hint.clone()),
remote_base_path: config
.remote_base_path
.clone()
.unwrap_or(row.remote_base_path.clone()),
username: config.username.clone().or(row.username.clone()).unwrap_or_default(),
username: config
.username
.clone()
.or(row.username.clone())
.unwrap_or_default(),
credential_ref,
third_party: false,
allow_plaintext_lan: config
+24
View File
@@ -44,6 +44,10 @@ pub struct AppState {
pub struct RecordingSession {
pub meeting_id: models::MeetingId,
pub capture: audio::CaptureHandle,
/// The user's microphone capture (FR-CAP-7), mixed into the transcript
/// stream. `None` when the mic is disabled in Settings or failed to open —
/// the meeting proceeds on loopback alone either way.
pub mic_capture: Option<audio::CaptureHandle>,
/// Audio retention for this meeting (ADR-0009); toggle-able mid-meeting.
pub retention: bool,
pub wav_path: PathBuf,
@@ -161,6 +165,22 @@ pub fn run() {
if commands::load_settings().sync_enabled {
commands::pump_sync(&startup_app, store.as_ref()).await;
}
// Re-import the last .pst path if the user opted into auto-sync
// (T6.2). One-shot on startup, same as the sync-job resume above
// — no idle timer (NFR-RES-1). Re-import is dedup'd by
// (source, raw_uid), so this just catches up on new/changed events.
let pst_settings = commands::load_settings();
if pst_settings.pst_auto_sync {
if let Some(path) = pst_settings.pst_last_path {
if let Err(e) =
commands::import_pst_core(&startup_app, store.as_ref(), path, None)
.await
{
tracing::warn!("startup PST auto-sync failed: {e:?}");
}
}
}
});
Ok(())
})
@@ -175,6 +195,8 @@ pub fn run() {
commands::app_info,
commands::open_url,
commands::hardware_status,
commands::list_audio_devices,
commands::list_input_devices,
commands::set_preferred_backend,
commands::list_models,
commands::download_npu_package,
@@ -200,12 +222,14 @@ pub fn run() {
commands::set_llm_provider,
commands::generate_summary,
commands::confirm_action_items,
commands::generate_tags,
commands::llm_setup_suggestions,
commands::pull_ollama_model,
commands::import_pst,
commands::list_calendar_events,
commands::get_calendar_event,
commands::attach_meeting_to_event,
commands::rename_meeting,
commands::list_sync_targets,
commands::add_sync_target,
commands::update_sync_target,
+181
View File
@@ -42,6 +42,10 @@ pub type TokenSink = std::sync::mpsc::Sender<String>;
pub trait LlmProvider: Send + Sync {
async fn status(&self) -> LlmStatus;
async fn summarize(&self, prompt: Prompt, out: TokenSink) -> Result<Summary, LlmError>;
/// Suggests 1-8 short topical tags for a transcript (T8.3, FR-SEARCH-2).
/// Non-streaming — the reply is short enough that a single round trip is
/// simpler than wiring up another token-stream event for it.
async fn suggest_tags(&self, transcript: &str) -> Result<Vec<String>, LlmError>;
/// True if the endpoint resolves to loopback/local (FR-LLM-6, FR-SEC-1).
fn is_local(&self) -> bool;
}
@@ -164,6 +168,69 @@ fn bullet_text(line: &str) -> Option<String> {
}
}
// ---- Tag suggestion (T8.3, FR-SEARCH-2) ----
const TAG_INSTRUCTIONS: &str = "You generate short topical tags for a meeting transcript. Respond \
with ONLY a comma-separated list of 1 to 8 short tags, nothing else — no numbering, no \
explanation, no quotes. Each tag: lowercase, 1-3 words, hyphenated instead of spaces (e.g. \
\"budget-review\" not \"budget review\").";
fn build_tag_messages(transcript: &str) -> Vec<serde_json::Value> {
vec![
serde_json::json!({ "role": "system", "content": TAG_INSTRUCTIONS }),
serde_json::json!({ "role": "user", "content": format!("Transcript:\n{transcript}") }),
]
}
/// Strips a leading "1. "/"1) " ordinal a model sometimes adds despite being
/// asked for a plain comma list — only when digits are immediately followed
/// by `.`/`)`, so a legitimate tag like "3d-printing" is left alone.
fn strip_ordinal_prefix(s: &str) -> &str {
let digits_end = s.find(|c: char| !c.is_ascii_digit()).unwrap_or(s.len());
if digits_end > 0 && matches!(s[digits_end..].chars().next(), Some('.') | Some(')')) {
s[digits_end..].trim_start_matches(['.', ')']).trim_start()
} else {
s
}
}
/// Lowercases, collapses whitespace/underscores to hyphens, and drops any
/// other punctuation — regardless of how well the model followed
/// `TAG_INSTRUCTIONS`, every tag that reaches the UI is chip-safe.
fn sanitize_tag(raw: &str) -> Option<String> {
let cleaned: String = raw
.trim()
.to_ascii_lowercase()
.chars()
.filter_map(|c| match c {
c if c.is_ascii_alphanumeric() || c == '-' => Some(c),
' ' | '_' => Some('-'),
_ => None,
})
.collect();
let cleaned = cleaned.trim_matches('-').to_string();
(!cleaned.is_empty()).then_some(cleaned)
}
/// Parses a model's tag-list reply into at most 8 deduped, sanitized tags.
/// Splits on comma AND newline since a model doesn't always follow the
/// requested comma-separated format.
fn parse_tags(text: &str) -> Vec<String> {
let mut tags = Vec::new();
for raw in text.split([',', '\n']) {
let Some(tag) = sanitize_tag(strip_ordinal_prefix(raw.trim())) else {
continue;
};
if !tags.contains(&tag) {
tags.push(tag);
}
if tags.len() >= 8 {
break;
}
}
tags
}
/// Whether a host is on the user's own machine or private LAN — i.e. not the
/// public internet or a third party. Loopback and RFC-1918 / link-local ranges
/// (e.g. `192.168.0.0/24`, `10.0.0.0/8`) plus `*.local` count as local, so a
@@ -347,6 +414,30 @@ impl LlmProvider for OllamaProvider {
Ok(parse_summary(&full_text))
}
async fn suggest_tags(&self, transcript: &str) -> Result<Vec<String>, LlmError> {
let body = serde_json::json!({
"model": self.model,
"messages": build_tag_messages(transcript),
"stream": false,
});
let resp = reqwest::Client::new()
.post(format!("{}/api/chat", self.base()))
.json(&body)
.send()
.await
.map_err(|e| LlmError::Unreachable(e.to_string()))?;
if !resp.status().is_success() {
return Err(LlmError::Request(format!("HTTP {}", resp.status())));
}
let chunk: OllamaChatChunk = resp
.json()
.await
.map_err(|e| LlmError::Request(e.to_string()))?;
Ok(parse_tags(
&chunk.message.map(|m| m.content).unwrap_or_default(),
))
}
fn is_local(&self) -> bool {
is_local_endpoint(&self.endpoint)
}
@@ -490,6 +581,52 @@ impl LlmProvider for OpenAiCompatProvider {
Ok(parse_summary(&full_text))
}
async fn suggest_tags(&self, transcript: &str) -> Result<Vec<String>, LlmError> {
// Reuses the same SSE streaming path as summarize() (this API has no
// simpler non-streaming reply shape worth a second response struct
// for) — just accumulates instead of forwarding to a TokenSink.
let body = serde_json::json!({
"model": self.model,
"messages": build_tag_messages(transcript),
"stream": true,
});
let req = self.auth(
reqwest::Client::new()
.post(format!("{}/v1/chat/completions", self.base()))
.json(&body),
);
let resp = req
.send()
.await
.map_err(|e| LlmError::Unreachable(e.to_string()))?;
if !resp.status().is_success() {
return Err(LlmError::Request(format!("HTTP {}", resp.status())));
}
let mut full_text = String::new();
stream_lines(resp, |line| {
let Some(payload) = line.strip_prefix("data:") else {
return true;
};
let payload = payload.trim();
if payload == "[DONE]" {
return false;
}
let Ok(chunk) = serde_json::from_str::<OpenAiChatChunk>(payload) else {
return true;
};
for choice in chunk.choices {
if let Some(content) = choice.delta.content {
full_text.push_str(&content);
}
}
true
})
.await?;
Ok(parse_tags(&full_text))
}
fn is_local(&self) -> bool {
self.credential_ref.is_none() && is_local_endpoint(&self.endpoint)
}
@@ -613,6 +750,11 @@ impl LlmProvider for AnthropicProvider {
"Anthropic provider isn't built yet (Phase 10a)".to_string(),
))
}
async fn suggest_tags(&self, _transcript: &str) -> Result<Vec<String>, LlmError> {
Err(LlmError::Request(
"Anthropic provider isn't built yet (Phase 10a)".to_string(),
))
}
fn is_local(&self) -> bool {
false
}
@@ -648,6 +790,45 @@ mod tests {
assert!(summary.action_items.is_empty());
}
#[test]
fn parse_tags_splits_a_well_formed_comma_list() {
let tags = parse_tags("golang, webrtc, dtls, wasm");
assert_eq!(tags, vec!["golang", "webrtc", "dtls", "wasm"]);
}
#[test]
fn parse_tags_handles_newline_separated_and_numbered_replies() {
// Models don't always follow "comma-separated, nothing else" exactly.
let tags = parse_tags("1. Budget Review\n2) Q3 Planning\n3. hiring");
assert_eq!(tags, vec!["budget-review", "q3-planning", "hiring"]);
}
#[test]
fn parse_tags_dedupes_and_caps_at_eight() {
let text = (1..=10)
.map(|i| format!("tag{i}"))
.collect::<Vec<_>>()
.join(", ")
+ ", tag1";
let tags = parse_tags(&text);
assert_eq!(tags.len(), 8);
assert_eq!(tags[0], "tag1");
}
#[test]
fn parse_tags_does_not_mangle_a_tag_that_starts_with_a_digit() {
// strip_ordinal_prefix must only fire for "<digits>." / "<digits>)",
// not any tag that happens to start with a number.
let tags = parse_tags("3d-printing, web3");
assert_eq!(tags, vec!["3d-printing", "web3"]);
}
#[test]
fn parse_tags_ignores_blank_entries_and_quotes() {
let tags = parse_tags("\"golang\", , \"web-dev\" ,,");
assert_eq!(tags, vec!["golang", "web-dev"]);
}
#[test]
fn is_local_endpoint_accepts_loopback_and_lan_but_rejects_public_hosts() {
// Loopback.
+25
View File
@@ -207,6 +207,31 @@ pub struct Settings {
// Storage retention policy (FR-STORE-2). None = no cap on that dimension.
pub retention_max_age_days: Option<u32>,
pub retention_max_size_gb: Option<u32>,
// Calendar / .pst (T6.2) — remembered so the user doesn't re-browse every
// launch. `pst_auto_sync` re-imports this path once at startup if set.
#[serde(default)]
pub pst_last_path: Option<String>,
#[serde(default)]
pub pst_auto_sync: bool,
// Audio capture device override (FR-CAP-1). `Device::get_id()` string;
// None = system default render device (loopback / system audio).
#[serde(default)]
pub audio_output_device: Option<String>,
// Microphone capture (FR-CAP-7): mix the user's own voice into the live
// transcript. Local-only, no egress; default ON. Turn off to transcribe just
// the system/loopback audio, as WA did before.
#[serde(default = "default_true")]
pub microphone_enabled: bool,
// Microphone device override — `Device::get_id()` string; None = system
// default capture device.
#[serde(default)]
pub audio_input_device: Option<String>,
}
/// serde default for a `bool` field that should be `true` when absent from an
/// older `settings.json` (so upgrading users get the microphone, FR-CAP-7).
fn default_true() -> bool {
true
}
// ---- Sync (ADR-0010) ----
+153 -8
View File
@@ -234,12 +234,18 @@ pub trait Store: Send + Sync {
/// pre-meeting context panel and the speaker-naming attendee dropdown.
async fn get_calendar_event(&self, id: &str) -> Result<CalendarEventDetail, StoreError>;
/// Link a meeting (current or historical) to a calendar event (T6.3/T6.6,
/// FR-CAL-2/4). Errs if either id doesn't exist.
/// FR-CAL-2/4). Errs if either id doesn't exist. Also mirrors the event's
/// subject onto the meeting's title when it has one — linking is meant to
/// say "this recording is that meeting," so a recording still sitting at
/// its default "Untitled meeting" name should follow it.
async fn attach_meeting_to_event(
&self,
meeting_id: &MeetingId,
event_id: &str,
) -> Result<(), StoreError>;
/// Manually rename a meeting — recordings otherwise default to "Untitled
/// meeting" with no other way to change that.
async fn rename_meeting(&self, meeting_id: &MeetingId, title: &str) -> Result<(), StoreError>;
/// Names a speaker AND links them to a known `Participant` (T6.5/T6.6,
/// FR-SPK-4): the display name comes from the participant record, and
/// the shared `participant_id` is what gives naming "continuity" across
@@ -931,21 +937,49 @@ impl Store for SqliteStore {
meeting_id: &MeetingId,
event_id: &str,
) -> Result<(), StoreError> {
let exists: Option<String> =
sqlx::query_scalar("SELECT id FROM calendar_events WHERE id = ?")
let row: Option<(String, Option<String>)> =
sqlx::query_as("SELECT id, subject FROM calendar_events WHERE id = ?")
.bind(event_id)
.fetch_optional(&self.pool)
.await?;
if exists.is_none() {
let Some((_, subject)) = row else {
return Err(StoreError::NotFound(format!("calendar event {event_id}")));
}
let result =
sqlx::query("UPDATE meetings SET calendar_event_id = ?, updated_at = ? WHERE id = ?")
};
let result = match subject.filter(|s| !s.is_empty()) {
Some(title) => sqlx::query(
"UPDATE meetings SET calendar_event_id = ?, title = ?, updated_at = ? WHERE id = ?",
)
.bind(event_id)
.bind(title)
.bind(now_unix())
.bind(meeting_id)
.execute(&self.pool)
.await?,
None => {
sqlx::query(
"UPDATE meetings SET calendar_event_id = ?, updated_at = ? WHERE id = ?",
)
.bind(event_id)
.bind(now_unix())
.bind(meeting_id)
.execute(&self.pool)
.await?;
.await?
}
};
if result.rows_affected() == 0 {
return Err(StoreError::NotFound(meeting_id.clone()));
}
Ok(())
}
async fn rename_meeting(&self, meeting_id: &MeetingId, title: &str) -> Result<(), StoreError> {
let result = sqlx::query("UPDATE meetings SET title = ?, updated_at = ? WHERE id = ?")
.bind(title)
.bind(now_unix())
.bind(meeting_id)
.execute(&self.pool)
.await?;
if result.rows_affected() == 0 {
return Err(StoreError::NotFound(meeting_id.clone()));
}
@@ -1525,6 +1559,117 @@ mod tests {
}
}
#[tokio::test]
async fn rename_meeting_updates_the_title() {
let store = SqliteStore::connect_in_memory().await.unwrap();
let id = store
.create_meeting(NewMeeting {
title: "Untitled meeting".to_string(),
calendar_event_id: None,
template_id: None,
})
.await
.unwrap();
store.rename_meeting(&id, "Sprint planning").await.unwrap();
assert_eq!(
store.get_meeting(&id).await.unwrap().title,
"Sprint planning"
);
}
#[tokio::test]
async fn rename_meeting_errs_for_an_unknown_id() {
let store = SqliteStore::connect_in_memory().await.unwrap();
let result = store.rename_meeting(&"no-such-id".to_string(), "x").await;
assert!(matches!(result, Err(StoreError::NotFound(_))));
}
#[tokio::test]
async fn attach_meeting_to_event_mirrors_the_events_subject_onto_the_title() {
let store = SqliteStore::connect_in_memory().await.unwrap();
let meeting_id = store
.create_meeting(NewMeeting {
title: "Untitled meeting".to_string(),
calendar_event_id: None,
template_id: None,
})
.await
.unwrap();
store
.import_calendar_events(vec![ImportedEvent {
event: CalendarEvent {
id: "ev1".to_string(),
source: "pst".to_string(),
subject: Some("Jerry / Daniel - Weekly 1:1".to_string()),
organizer: None,
starts_at: None,
ends_at: None,
description: None,
raw_uid: Some("uid-1".to_string()),
},
attendees: vec![],
}])
.await
.unwrap();
let event_id = store.list_calendar_events(None, None).await.unwrap()[0]
.id
.clone();
store
.attach_meeting_to_event(&meeting_id, &event_id)
.await
.unwrap();
let meeting = store.get_meeting(&meeting_id).await.unwrap();
assert_eq!(meeting.title, "Jerry / Daniel - Weekly 1:1");
assert_eq!(
meeting.calendar_event_id.as_deref(),
Some(event_id.as_str())
);
}
#[tokio::test]
async fn attach_meeting_to_event_leaves_the_title_alone_when_the_event_has_no_subject() {
let store = SqliteStore::connect_in_memory().await.unwrap();
let meeting_id = store
.create_meeting(NewMeeting {
title: "Untitled meeting".to_string(),
calendar_event_id: None,
template_id: None,
})
.await
.unwrap();
store
.import_calendar_events(vec![ImportedEvent {
event: CalendarEvent {
id: "ev1".to_string(),
source: "pst".to_string(),
subject: None,
organizer: None,
starts_at: None,
ends_at: None,
description: None,
raw_uid: Some("uid-2".to_string()),
},
attendees: vec![],
}])
.await
.unwrap();
let event_id = store.list_calendar_events(None, None).await.unwrap()[0]
.id
.clone();
store
.attach_meeting_to_event(&meeting_id, &event_id)
.await
.unwrap();
assert_eq!(
store.get_meeting(&meeting_id).await.unwrap().title,
"Untitled meeting"
);
}
#[tokio::test]
async fn sync_target_crud_round_trips() {
let store = SqliteStore::connect_in_memory().await.unwrap();
+18
View File
@@ -48,6 +48,14 @@ export interface HardwareStatus {
directml?: { applicable: boolean; runtimeReady: boolean; modelInstalled: boolean };
}
// One enumerated audio device — a render (playback) device for the loopback
// picker (FR-CAP-1) or a capture (microphone) device for the mic picker
// (FR-CAP-7). `id` is the persisted `Device::get_id()`; `name` is display-only.
export interface AudioDeviceInfo {
id: string;
name: string;
}
export interface LlmStatus {
provider: string; // ollama|custom|off (Phase 10a adds anthropic|openai)
reachable: boolean;
@@ -283,6 +291,11 @@ export interface AppSettings {
mcp_enabled: boolean;
retention_max_age_days: number | null;
retention_max_size_gb: number | null;
pst_last_path: string | null;
pst_auto_sync: boolean;
audio_output_device: string | null;
microphone_enabled: boolean;
audio_input_device: string | null;
}
// Feature brief — agent-ready spec distilled from a meeting (ADR-0011).
@@ -335,6 +348,8 @@ export const api = {
appInfo: () => invoke<AppInfo>("app_info"),
openUrl: (url: string) => invoke<void>("open_url", { url }),
hardwareStatus: () => invoke<HardwareStatus>("hardware_status"),
listAudioDevices: () => invoke<AudioDeviceInfo[]>("list_audio_devices"),
listInputDevices: () => invoke<AudioDeviceInfo[]>("list_input_devices"),
setPreferredBackend: (backend: BackendId | "auto") =>
invoke<void>("set_preferred_backend", { args: { backend } }),
downloadNpuPackage: () => invoke<void>("download_npu_package"),
@@ -394,6 +409,7 @@ export const api = {
invoke<void>("generate_summary", { meetingId, templateId }),
confirmActionItems: (meetingId: MeetingId, items: ActionItem[]) =>
invoke<void>("confirm_action_items", { meetingId, items }),
generateTags: (meetingId: MeetingId) => invoke<string[]>("generate_tags", { meetingId }),
importPst: (path: string, password?: string) => invoke<number>("import_pst", { path, password }),
listCalendarEvents: (from?: number, to?: number) =>
@@ -402,6 +418,8 @@ export const api = {
invoke<CalendarEventDetail>("get_calendar_event", { eventId }),
attachMeetingToEvent: (meetingId: MeetingId, eventId: string) =>
invoke<void>("attach_meeting_to_event", { meetingId, eventId }),
renameMeeting: (meetingId: MeetingId, title: string) =>
invoke<void>("rename_meeting", { meetingId, title }),
renameSpeaker: (meetingId: MeetingId, label: string, name: string) =>
invoke<void>("rename_speaker", { meetingId, label, name }),
mapSpeakerToParticipant: (meetingId: MeetingId, label: string, participantId: string) =>
+92
View File
@@ -0,0 +1,92 @@
<script lang="ts">
// GitHub-topic-style pill (T8.3, FR-SEARCH-2). Clicking the label filters
// the meeting list to this tag; the optional 'x' removes it from whatever
// list it's rendered in (not a global delete — the caller decides what
// "remove" means).
import { X } from "@lucide/svelte";
interface Props {
tag: string;
removable?: boolean;
onRemove?: () => void;
onClick?: () => void;
}
let { tag, removable = false, onRemove, onClick }: Props = $props();
</script>
<span class="chip" class:clickable={!!onClick}>
<button
type="button"
class="label"
onclick={onClick}
disabled={!onClick}
title={onClick ? `Filter meetings tagged "${tag}"` : undefined}
>
{tag}
</button>
{#if removable}
<button type="button" class="remove" onclick={onRemove} aria-label={`Remove tag ${tag}`}>
<X size={10} aria-hidden="true" />
</button>
{/if}
</span>
<style>
.chip {
display: inline-flex;
align-items: center;
background: var(--accent-soft);
color: var(--accent);
border-radius: var(--radius-full);
font-size: 0.78rem;
font-weight: 500;
}
.label {
background: none;
border: none;
color: inherit;
font: inherit;
padding: 0.15rem 0.65rem;
border-radius: var(--radius-full);
cursor: default;
}
.label:disabled {
opacity: 1; /* a non-clickable chip should still read as fully legible */
}
.chip.clickable .label {
cursor: pointer;
}
.chip.clickable .label:hover {
background: var(--accent);
color: var(--accent-fg);
}
.label:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}
.remove {
display: grid;
place-items: center;
width: 1.05rem;
height: 1.05rem;
margin: 0 0.3rem 0 -0.25rem;
padding: 0;
border: none;
border-radius: 50%;
background: transparent;
color: inherit;
opacity: 0.65;
cursor: pointer;
transition:
background-color 150ms ease-out,
opacity 150ms ease-out;
}
.remove:hover {
opacity: 1;
background: rgba(0, 0, 0, 0.18);
}
.remove:focus-visible {
outline: 2px solid var(--focus-ring);
outline-offset: 1px;
}
</style>
+2 -2
View File
@@ -1,7 +1,7 @@
// Imported calendar events (Phase 6, FR-CAL-*). Svelte 5 runes store, same
// shape as settings.svelte.ts/meetings.svelte.ts.
import { api, events, type CalendarEvent } from "../api";
import { api, errorMessage, events, type CalendarEvent } from "../api";
class CalendarStore {
events = $state<CalendarEvent[]>([]);
@@ -30,7 +30,7 @@ class CalendarStore {
await api.importPst(path, password);
await this.load();
} catch (e) {
this.importError = e instanceof Error ? e.message : String(e);
this.importError = errorMessage(e);
} finally {
this.importing = false;
this.importProgress = null;
+19
View File
@@ -169,6 +169,15 @@ class MeetingsStore {
await this.load();
}
/** Click-to-filter from a tag chip anywhere (T8.3, FR-SEARCH-2) — same
* mechanism as the sidebar's tag dropdown, just triggered elsewhere.
* Keeps any existing date filter, drops search mode (a tag filter and a
* text search are two different views over the same list). */
async filterByTag(tag: string) {
this.searchResults = null;
await this.load({ ...this.filter, tag });
}
/** `null` clears search mode and reverts the list view to `load()`'s results. */
async search(query: string | null) {
if (!query || !query.trim()) {
@@ -224,7 +233,17 @@ class MeetingsStore {
/** Link a recording to a calendar event (T6.3/T6.6, FR-CAL-2/4). */
async attachEvent(id: MeetingId, eventId: string) {
await api.attachMeetingToEvent(id, eventId);
// Attaching mirrors the event's subject onto the title server-side
// (FR-CAL-2) — refresh the list too, not just the detail view.
if (this.selectedId === id) await this.select(id);
await this.load();
}
/** Manual rename (T2.2) — recordings otherwise default to "Untitled meeting". */
async renameMeeting(id: MeetingId, title: string) {
await api.renameMeeting(id, title);
if (this.selectedId === id) await this.select(id);
await this.load();
}
/** Free-text speaker rename (T4.4, FR-SPK-2) — the "add new name" escape
+36
View File
@@ -8,6 +8,7 @@ import {
errorMessage,
events,
type AppSettings,
type AudioDeviceInfo,
type SyncTargetInfo,
type SyncTargetConfig,
type HardwareStatus,
@@ -31,6 +32,11 @@ const DEFAULT_SETTINGS: AppSettings = {
mcp_enabled: false, // MCP server OFF by default (ADR-0011)
retention_max_age_days: null, // no cap by default (FR-STORE-2)
retention_max_size_gb: null,
pst_last_path: null,
pst_auto_sync: false,
audio_output_device: null, // system default render device (FR-CAP-1)
microphone_enabled: true, // capture the user's mic into the transcript (FR-CAP-7)
audio_input_device: null, // system default capture device
};
class SettingsStore {
@@ -45,6 +51,8 @@ class SettingsStore {
// Hardware + model management (Phase 3, T3.6/T3.7).
hardware = $state<HardwareStatus | null>(null);
models = $state<ModelInfo[]>([]);
audioDevices = $state<AudioDeviceInfo[]>([]);
inputDevices = $state<AudioDeviceInfo[]>([]);
downloadProgress = $state<Record<string, { received: number; total: number | null }>>({});
// Privacy self-check (T7.6, FR-SEC-2).
@@ -69,6 +77,8 @@ class SettingsStore {
this.backendStub = true;
}
await this.loadHardware();
await this.loadAudioDevices();
await this.loadInputDevices();
await this.loadModels();
await this.loadPrivacy();
await this.loadLlmStatus();
@@ -123,6 +133,32 @@ class SettingsStore {
}
}
async loadAudioDevices() {
try {
this.audioDevices = await api.listAudioDevices();
} catch {
this.audioDevices = [];
}
}
async setAudioOutputDevice(deviceId: string | null) {
await this.patch({ audio_output_device: deviceId });
}
async loadInputDevices() {
try {
this.inputDevices = await api.listInputDevices();
} catch {
this.inputDevices = [];
}
}
/** Set the microphone selection in one patch (FR-CAP-7): `enabled=false`
* disables mic capture entirely; `deviceId=null` uses the system default. */
async setMicrophone(enabled: boolean, deviceId: string | null) {
await this.patch({ microphone_enabled: enabled, audio_input_device: deviceId });
}
async loadModels() {
try {
this.models = await api.listModels();
+4 -1
View File
@@ -17,7 +17,10 @@
// 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("");
// Writable derived (not $state+$effect) so this dropdown also reflects a
// tag filter triggered elsewhere (e.g. clicking a chip in the Tags panel),
// while still being directly editable via bind:value below.
let tagFilter = $derived(meetings.filter.tag ?? "");
let fromFilter = $state("");
let toFilter = $state("");
+102 -1
View File
@@ -173,6 +173,34 @@
// ---- Calendar / .pst import (T6.1/T6.2/T6.3, FR-CAL-1/2) ----
let pstPath = $state("");
let pstPassword = $state("");
let eventTitleFilter = $state("");
let eventDateFilter = $state("");
function eventLocalYmd(unixSecs: number | null): string {
if (!unixSecs) return "";
const d = new Date(unixSecs * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
let filteredEvents = $derived(
calendar.events.filter((ev) => {
if (eventDateFilter && eventLocalYmd(ev.starts_at) !== eventDateFilter) return false;
if (
eventTitleFilter &&
!(ev.subject ?? "").toLowerCase().includes(eventTitleFilter.toLowerCase())
)
return false;
return true;
}),
);
// Prefills the remembered path once settings load, without clobbering
// whatever the user is actively typing/browsing to.
$effect(() => {
if (!pstPath && settings.settings.pst_last_path) {
pstPath = settings.settings.pst_last_path;
}
});
async function pickPstFile() {
const path = await open({ filters: [{ name: "Outlook data file", extensions: ["pst"] }] });
@@ -182,6 +210,10 @@
if (!pstPath) return;
await calendar.importPst(pstPath, pstPassword || undefined);
pstPassword = "";
if (!calendar.importError) await settings.patch({ pst_last_path: pstPath });
}
function onToggleAutoSync(e: Event) {
settings.patch({ pst_auto_sync: (e.target as HTMLInputElement).checked });
}
function formatEventDate(unixSecs: number | null): string {
if (!unixSecs) return "";
@@ -531,6 +563,50 @@
to load.
</p>
<h4>Audio Devices</h4>
<label
>Recording device
<select
value={settings.settings.audio_output_device ?? ""}
onchange={(e) =>
settings.setAudioOutputDevice((e.target as HTMLSelectElement).value || null)}
>
<option value="">Default system audio</option>
{#each settings.audioDevices as d (d.id)}
<option value={d.id}>{d.name}</option>
{/each}
</select>
</label>
<p class="muted">
WhispAssist records whatever this device plays (loopback) — the other side of the call.
Pick a specific output if you don't want it following Windows' system default.
</p>
<label
>Microphone
<select
value={!settings.settings.microphone_enabled
? "off"
: (settings.settings.audio_input_device ?? "")}
onchange={(e) => {
const v = (e.target as HTMLSelectElement).value;
if (v === "off") settings.setMicrophone(false, null);
else settings.setMicrophone(true, v || null);
}}
>
<option value="off">Off — don't capture my microphone</option>
<option value="">Default microphone</option>
{#each settings.inputDevices as d (d.id)}
<option value={d.id}>{d.name}</option>
{/each}
</select>
</label>
<p class="muted">
Adds your own voice to the live transcript so both sides of the meeting are captured.
Stays on your device — nothing is uploaded. Choose “Off” to transcribe only the system
audio above.
</p>
{#if settings.hardware.npu?.present}
{@const npu = settings.hardware.npu}
{@const ready = npu.runtimeReady && npu.modelInstalled}
@@ -697,6 +773,18 @@
>
{/if}
</div>
<label class="row">
<input
type="checkbox"
checked={settings.settings.pst_auto_sync}
onchange={onToggleAutoSync}
/>
<span>Re-import this file automatically on launch</span>
</label>
<p class="muted small">
Runs once at startup, not on a timer — re-import is safe to repeat (existing events are
matched and updated, not duplicated).
</p>
{#if calendar.importError}
<p class="error">
Import failed: {calendar.importError} — the file itself is untouched; check the path and try
@@ -708,8 +796,21 @@
{#if calendar.events.length === 0}
<p class="muted">No events imported yet.</p>
{:else}
<div class="grid">
<label class="wide"
>Search title
<input type="text" bind:value={eventTitleFilter} placeholder="Meeting name…" />
</label>
<label
>Date
<input type="date" bind:value={eventDateFilter} />
</label>
</div>
<p class="muted">
{filteredEvents.length} of {calendar.events.length} events
</p>
<ul class="events">
{#each calendar.events as ev (ev.id)}
{#each filteredEvents as ev (ev.id)}
<li>
<span class="name">{ev.subject ?? "(untitled)"}</span>
<span class="muted">{formatEventDate(ev.starts_at)}</span>
+162 -20
View File
@@ -4,8 +4,15 @@
import { meetings } from "../stores/meetings.svelte";
import { calendar } from "../stores/calendar.svelte";
import { settings } from "../stores/settings.svelte";
import { api, type ActionItem, type CalendarEventDetail, type LlmStatus } from "../api";
import {
api,
errorMessage,
type ActionItem,
type CalendarEventDetail,
type LlmStatus,
} from "../api";
import { renderMarkdown } from "../markdown";
import TagChip from "../components/TagChip.svelte";
import {
Tags,
Sparkles,
@@ -94,6 +101,35 @@
await meetings.attachEvent(m.id, eventId);
}
// ---- Search/date filter for the event picker (a real mailbox import can
// be thousands of events) — defaults to the recording's own date since
// that's almost always the event being linked. ----
let eventLinkSearch = $state("");
let eventLinkDateFilter = $state("");
$effect(() => {
const m = meetings.selected;
eventLinkDateFilter = m ? eventLocalYmd(m.started_at) : "";
eventLinkSearch = "";
});
function eventLocalYmd(unixSecs: number | null): string {
if (!unixSecs) return "";
const d = new Date(unixSecs * 1000);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`;
}
let filteredLinkEvents = $derived(
calendar.events.filter((ev) => {
if (eventLinkDateFilter && eventLocalYmd(ev.starts_at) !== eventLinkDateFilter) return false;
if (
eventLinkSearch &&
!(ev.subject ?? "").toLowerCase().includes(eventLinkSearch.toLowerCase())
)
return false;
return true;
}),
);
// ---- Attendee-aware speaker naming (T6.5, FR-SPK-4) ----
const NEW_NAME = "__new__";
let addingNameFor = $state<string | null>(null);
@@ -121,24 +157,79 @@
}
// ---- 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(", "));
// pendingTags is the editable working copy — resynced from
// meetings.selected.tags whenever a *different* meeting is selected, same
// guard pattern as notesText in TranscriptNotes.svelte, so it isn't
// clobbered by other reactivity while the user is mid-edit.
let pendingTags = $state<string[]>([]);
let tagDraft = $state("");
let loadedTagsForId: string | null = null;
$effect(() => {
const m = meetings.selected;
if (m && m.id !== loadedTagsForId) {
pendingTags = [...m.tags];
tagDraft = "";
loadedTagsForId = m.id;
} else if (!m) {
loadedTagsForId = null;
}
});
function addTag(raw: string) {
const t = raw.trim().toLowerCase();
if (t && !pendingTags.includes(t)) pendingTags.push(t);
}
// GitHub-topics-style input: a comma commits everything before it as its
// own chip immediately, leaving whatever's after as the live draft.
function onTagInput() {
if (!tagDraft.includes(",")) return;
const parts = tagDraft.split(",");
tagDraft = parts.pop() ?? "";
parts.forEach(addTag);
}
function onTagInputKeydown(e: KeyboardEvent) {
if (e.key === "Enter") {
e.preventDefault();
commitDraft();
}
}
function commitDraft() {
if (tagDraft.trim()) addTag(tagDraft);
tagDraft = "";
}
function removeTag(t: string) {
pendingTags = pendingTags.filter((x) => x !== t);
}
let savingTags = $state(false);
async function saveTags() {
const m = meetings.selected;
if (!m) return;
commitDraft();
savingTags = true;
try {
const tags = tagsInput
.split(",")
.map((t) => t.trim())
.filter(Boolean);
await meetings.setTags(m.id, tags);
await meetings.setTags(m.id, pendingTags);
} finally {
savingTags = false;
}
}
let generatingTags = $state(false);
let tagGenError = $state<string | null>(null);
async function generateTagsNow() {
const m = meetings.selected;
if (!m) return;
generatingTags = true;
tagGenError = null;
try {
const suggested = await api.generateTags(m.id);
suggested.forEach(addTag);
} catch (e) {
tagGenError = errorMessage(e);
} finally {
generatingTags = false;
}
}
</script>
<div class="wrap">
@@ -185,21 +276,42 @@
{#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()}
/>
<div class="tag-editor">
{#each pendingTags as t (t)}
<TagChip
tag={t}
removable
onRemove={() => removeTag(t)}
onClick={() => meetings.filterByTag(t)}
/>
{/each}
<input
class="tag-input"
list="known-tags"
placeholder={pendingTags.length ? "Add tag…" : "project, client, topic…"}
bind:value={tagDraft}
oninput={onTagInput}
onkeydown={onTagInputKeydown}
onblur={commitDraft}
/>
</div>
<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>
<div class="actions">
<button class="primary" onclick={generateTagsNow} disabled={generatingTags}>
<Sparkles size={14} aria-hidden="true" />
{generatingTags ? "Generating…" : "Generate tags"}
</button>
<button class="link" onclick={saveTags} disabled={savingTags}>
{savingTags ? "Saving…" : "Save tags"}
</button>
</div>
{#if tagGenError}
<p class="error">{tagGenError}</p>
{/if}
{/if}
<h3><Sparkles size={14} aria-hidden="true" /> Summary</h3>
@@ -275,11 +387,15 @@
{#if !meetings.selected}
<p class="muted">Select a meeting to link it to a calendar event.</p>
{:else}
<div class="row">
<input type="text" bind:value={eventLinkSearch} placeholder="Search event title…" />
<input type="date" bind:value={eventLinkDateFilter} />
</div>
<label class="pick"
>Linked event
<select value={meetings.selected.calendar_event_id ?? ""} onchange={onPickEvent}>
<option value="" disabled>{eventDetail ? "Change event…" : "Link an event…"}</option>
{#each calendar.events as ev (ev.id)}
{#each filteredLinkEvents as ev (ev.id)}
<option value={ev.id}
>{ev.subject ?? "(untitled)"}{formatEventTime(ev.starts_at)}</option
>
@@ -290,6 +406,8 @@
<p class="muted small">
No events imported yet — import a <code>.pst</code> from Settings → Calendar.
</p>
{:else if filteredLinkEvents.length === 0}
<p class="muted small">No events match this search/date — try clearing one.</p>
{/if}
{#if eventDetail}
@@ -400,6 +518,30 @@
font-size: 0.85rem;
margin: 0;
}
.tag-editor {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.35rem;
padding: 0.3rem 0;
}
.tag-input {
flex: 1;
min-width: 6rem;
border: none;
background: none;
padding: 0.2rem 0;
font-size: 0.8rem;
}
.tag-input:focus {
outline: none;
}
.actions {
display: flex;
align-items: center;
gap: 0.7rem;
margin-top: 0.4rem;
}
.muted.small {
font-size: 0.78rem;
}
+37 -1
View File
@@ -43,6 +43,19 @@
}
});
// Recordings default to "Untitled meeting" (T2.2) — this is the only
// rename affordance, since nothing else in the UI shows the title at all.
async function onTitleChange(e: Event) {
const m = meetings.selected;
const value = (e.target as HTMLInputElement).value.trim();
if (!m) return;
if (!value) {
(e.target as HTMLInputElement).value = m.title; // revert an empty edit
return;
}
if (value !== m.title) await meetings.renameMeeting(m.id, value);
}
function scheduleSave() {
const id = meetings.selected?.id;
if (!id) return;
@@ -128,6 +141,12 @@
<div class="wrap">
{#if meetings.selected}
{@const m = meetings.selected}
<input
class="meeting-title"
value={m.title}
onchange={onTitleChange}
aria-label="Meeting title"
/>
<div class="split">
<div class="pane transcript">
<h4><MessageSquareText size={14} aria-hidden="true" /> Transcript</h4>
@@ -229,6 +248,22 @@
<style>
.wrap {
height: 100%;
display: flex;
flex-direction: column;
}
.meeting-title {
flex: none;
border: none;
background: transparent;
font-size: 1.05rem;
font-weight: 600;
padding: 0.75rem 1rem 0.25rem;
color: inherit;
}
.meeting-title:hover,
.meeting-title:focus {
background: var(--border);
outline: none;
}
.pad {
padding: 1rem;
@@ -249,7 +284,8 @@
.split {
display: grid;
grid-template-columns: 1fr 1fr;
height: 100%;
flex: 1;
min-height: 0;
}
.pane {
overflow: auto;