Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
246fb60731 | ||
|
|
233c6fa8cd | ||
|
|
2eff32662b | ||
|
|
f0480244bf | ||
|
|
2a0379ce43 | ||
|
|
91677476cc | ||
|
|
76a4f8ce08 | ||
|
|
2603e12b36 | ||
|
|
90ba14cc2d | ||
|
|
7106dc1584 | ||
|
|
84b9087de7 | ||
|
|
1634820523 | ||
|
|
57127cfa07 | ||
|
|
49893d5874 | ||
|
|
a14ca871e1 | ||
|
|
2fda4041ff | ||
|
|
0ecba7f055 | ||
|
|
ea79fb656d | ||
|
|
a7eba7fd82 | ||
|
|
d08d03077c | ||
|
|
3b0f27f5d9 | ||
|
|
f2db826e8c | ||
|
|
a5d81bd9e4 | ||
|
|
010941e620 | ||
|
|
90580da0af | ||
|
|
e715c09ce0 | ||
|
|
da25ccaec6 | ||
|
|
e7f628ad7a | ||
|
|
25d0232256 | ||
|
|
34b4f4ae09 | ||
|
|
96b30ed99a | ||
|
|
a37f766817 | ||
|
|
4cdeb8f475 | ||
|
|
5828023651 | ||
|
|
3202f884af | ||
|
|
996c7edc99 | ||
|
|
bb688418bf | ||
|
|
97b5f67049 | ||
|
|
55ddaad544 | ||
|
|
75f7d19ade | ||
|
|
2fb5fd6f30 | ||
|
|
b8dc5fc37f | ||
|
|
38b9cb2910 | ||
|
|
137605934c | ||
|
|
5b281531fb | ||
|
|
2759a0e49a | ||
|
|
8e3e22c0e5 | ||
|
|
bb1d1730f8 | ||
|
|
16fcb154f3 | ||
|
|
cf4232cbae | ||
|
|
4a30674008 | ||
|
|
e16d9e5ade | ||
|
|
1a31cac65b | ||
|
|
ba1b577734 | ||
|
|
fb406d62da | ||
|
|
68c32ffa06 | ||
|
|
2efe29558a | ||
|
|
3cd267f737 | ||
|
|
f9e75b7d17 | ||
|
|
ae9e8398c5 | ||
|
|
4f80c14f72 | ||
|
|
e68e888c07 | ||
|
|
7e646b3e0f | ||
|
|
00de9d3f68 | ||
|
|
e6a2109c9c | ||
|
|
67ac4c3813 | ||
|
|
9d757be415 | ||
|
|
871b11d75e | ||
|
|
75ec24ddf6 | ||
|
|
4637ce8aac | ||
|
|
6f07f28cef | ||
|
|
fa8a7f2552 | ||
|
|
8f98332cdd | ||
|
|
2ca4151444 | ||
|
|
66aae232c5 | ||
|
|
43497ca8cd | ||
|
|
6a4b832628 | ||
|
|
33c0397de8 | ||
|
|
9c670f9b6b | ||
|
|
8f6e8041d6 | ||
|
|
fb790091fd | ||
|
|
496a3372c4 | ||
|
|
45b2acbbc6 | ||
|
|
149f2c995d |
@@ -41,3 +41,8 @@ Thumbs.db
|
||||
# NPU runtime bundle: a large binary artifact hosted as a Gitea package, not
|
||||
# committed. The folder's README is tracked; the zip is produced locally.
|
||||
packaging/npu-runtime/*.zip
|
||||
|
||||
# Vulkan loader staged next to the exe / into src-tauri by build.rs for the
|
||||
# --features vulkan build (bundled into the installer); a redistributable blob,
|
||||
# not committed.
|
||||
src-tauri/vulkan-1.dll
|
||||
|
||||
@@ -7,11 +7,13 @@ on-device acceleration (**NPU → GPU → CPU**), labels speakers, structures th
|
||||
Markdown notes, and optionally augments them with a locally hosted LLM (Ollama). Audio and
|
||||
transcripts **never leave the machine** unless you explicitly configure a destination.
|
||||
|
||||
> **Status: working application (v0.1.5).** Capture, transcription (CPU / Intel NPU / Vulkan
|
||||
> GPU), speaker diarization, storage + crash recovery, local-LLM summaries, opt-in recording,
|
||||
> at-rest encryption, and self-hosted sync are implemented and ship as signed **MSI + NSIS**
|
||||
> installers. Outlook `.pst`/calendar context and the coding-agent (MCP) handoff are in
|
||||
> progress. Build order and remaining tasks are in [`docs/05-roadmap.md`](docs/05-roadmap.md).
|
||||
> **Status: working application (v0.2.0).** Capture (system audio **+ your microphone**),
|
||||
> transcription (CPU / Intel NPU / Vulkan GPU), speaker diarization, storage + crash recovery,
|
||||
> local-LLM summaries, AI tags, opt-in recording with **in-app playback**, at-rest encryption,
|
||||
> and self-hosted sync are implemented and ship as a single signed **MSI + NSIS** universal
|
||||
> installer. Outlook `.pst`/calendar context (recurring-event import + filtering) is landing;
|
||||
> the coding-agent (MCP) handoff is in progress. Build order and remaining tasks are in
|
||||
> [`docs/05-roadmap.md`](docs/05-roadmap.md).
|
||||
|
||||
## Why WhispAssist — Granola vs Meetily vs WhispAssist
|
||||
|
||||
@@ -44,10 +46,12 @@ accelerated, zero-egress-by-default** option: it exploits the NPU/GPU in modern
|
||||
everything on the device unless you opt in, and adds Windows-specific context (Outlook) and a
|
||||
coding-agent handoff.
|
||||
|
||||
## What's built (v0.1.5)
|
||||
## What's built (v0.2.0)
|
||||
|
||||
- **Bot-free capture** — WASAPI loopback records the system mix (all participants) with no
|
||||
meeting bot and no per-app plumbing.
|
||||
- **Bot-free capture — now both sides** — WASAPI loopback records the system mix (all
|
||||
participants), and an optional **microphone** path captures your own voice, mixed into both the
|
||||
live transcript and the saved recording. Pick a specific output/input or turn the mic off in
|
||||
**Settings ▸ Hardware**. No meeting bot, no per-app plumbing.
|
||||
- **Local transcription with a hardware ladder** — whisper.cpp via `whisper-rs` on CPU; the
|
||||
**Intel NPU** via ONNX Runtime + OpenVINO; **GPU via Vulkan** (a single binary that runs on
|
||||
NVIDIA, AMD, and Intel). WA detects the hardware, picks the best backend
|
||||
@@ -61,17 +65,24 @@ coding-agent handoff.
|
||||
- **Storage & crash recovery** — SQLite + on-disk audio/transcripts under
|
||||
`%LOCALAPPDATA%\WhispAssist`. Audio is the source of truth; notes and transcripts regenerate
|
||||
after a crash.
|
||||
- **Opt-in recording** — off by default; `.wav` retained only when you turn it on, after a
|
||||
one-time consent notice.
|
||||
- **Opt-in recording + in-app playback** — off by default; `.wav` retained only when you turn it
|
||||
on, after a one-time consent notice. Play a saved recording back in the app — encrypted
|
||||
recordings are decrypted **in memory on the fly** (nothing plaintext is written to disk).
|
||||
Recordings are 16-bit for roughly half the size, and an accidental recording can be **cancelled**
|
||||
(audio + transcript deleted).
|
||||
- **Notes, summaries & AI tags** — Markdown notes with an **Editor/Preview** toggle; local-LLM
|
||||
summaries and one-click **tag generation** with a chip-based tag editor and tag filtering.
|
||||
- **At-rest encryption vault** — Argon2id key derivation + XChaCha20-Poly1305; transcripts,
|
||||
notes, summaries, and recordings sealed on disk; startup unlock gate; keys zeroized on lock.
|
||||
- **Self-hosted sync (optional, off by default)** — WebDAV (Nextcloud, ownCloud, Cloudreve,
|
||||
Seafile, Synology) plus OneDrive/Dropbox/Box (OAuth 2.0 PKCE); durable retry queue with
|
||||
backoff; **client-side encryption before upload** so the destination holds only ciphertext.
|
||||
Credentials live only in the OS credential store.
|
||||
backoff and **live per-item upload progress**; **client-side encryption before upload** so the
|
||||
destination holds only ciphertext. Credentials live only in the OS credential store.
|
||||
- **Optional hosted AI** — Anthropic and OpenAI-compatible providers behind the same
|
||||
`LlmProvider` interface, off by default (third-party egress, keys in the OS credential store).
|
||||
- **Installers** — signed MSI and NSIS `-setup.exe`.
|
||||
- **One universal installer** — a single signed MSI and NSIS `-setup.exe` that covers every
|
||||
machine: Vulkan for all GPUs, the Intel NPU path, and CPU fallback. The Vulkan loader is bundled
|
||||
so it launches even on machines without a GPU driver.
|
||||
|
||||
**In progress:** Outlook `.pst` + calendar context, the local **MCP server** that hands meeting
|
||||
context to your coding agents (Claude, Codex, Copilot, OpenCode), and MS Graph calendar.
|
||||
@@ -142,18 +153,28 @@ npm install
|
||||
npm run tauri dev # CPU/NPU build
|
||||
```
|
||||
|
||||
**GPU (Vulkan) build.** whisper.cpp's GPU backends are compiled in (not downloaded at runtime),
|
||||
so a GPU build needs a one-time toolchain setup — the **Vulkan SDK**, a **Ninja** generator, and
|
||||
a short target dir (to dodge Windows' 260-char path limit in the shader build):
|
||||
**Release build (single universal installer).** whisper.cpp's GPU backends are compiled in (not
|
||||
downloaded at runtime), so the release build needs a one-time toolchain setup — the **Vulkan SDK**,
|
||||
a **Ninja** generator, and a short target dir (to dodge Windows' 260-char path limit in the shader
|
||||
build):
|
||||
|
||||
```bash
|
||||
# after: Vulkan SDK installed, ninja.exe on PATH, vcvars64 loaded
|
||||
set VULKAN_SDK=C:\VulkanSDK\1.4.350.0
|
||||
set CMAKE_GENERATOR=Ninja
|
||||
set CARGO_TARGET_DIR=C:\wt
|
||||
npm run tauri build -- --features vulkan
|
||||
npm run tauri build -- --features vulkan --config src-tauri/tauri.vulkan.conf.json
|
||||
```
|
||||
|
||||
This one build covers **every** machine: Vulkan accelerates all GPUs (NVIDIA/AMD/Intel), the Intel
|
||||
NPU path works via the runtime OpenVINO download, and CPU is the fallback. The `--features vulkan`
|
||||
binary links `vulkan-1.dll`, so `build.rs` stages the redistributable Vulkan **loader** (from
|
||||
`VULKAN_SDK\Bin`, or System32) next to the exe and `tauri.vulkan.conf.json` bundles it into the
|
||||
installer — the app then launches even on a machine with no GPU driver (it reports zero Vulkan
|
||||
devices and decodes on the CPU). DirectML is intentionally not offered here because Vulkan already
|
||||
covers those GPUs; it's the GPU path only in the plain `npm run tauri build` (no Vulkan) variant,
|
||||
kept as an internal fallback.
|
||||
|
||||
CUDA (NVIDIA-only, faster) is planned as an optional variant. The full, gotcha-annotated build
|
||||
recipe lives in the project notes.
|
||||
|
||||
|
||||
@@ -16,6 +16,9 @@ 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, mixing it into **both** the live transcript and the saved recording (default ON, local-only/no egress, selectable device + "off"). |
|
||||
| FR-CAP-8 | S | 1 | Write the retained recording as **16-bit PCM** at the device's native rate/channels — roughly half the size of the 32-bit-float mix, with no material quality loss for speech. |
|
||||
| FR-CAP-9 | S | 1 | **Cancel** an in-progress recording: stop capture, delete working files, and remove the meeting from the DB entirely (for one started by mistake — no finalize/transcript/sync). |
|
||||
|
||||
### Recording retention & consent (REC) — see ADR-0009
|
||||
|
||||
@@ -25,6 +28,7 @@ Each requirement has a stable ID used across the roadmap, tests, and commits. Pr
|
||||
| FR-REC-2 | M | 1 | Before retaining a recording for the first time (and shown near the toggle thereafter), display a consent notice: recording without participants' consent may be illegal in some regions; advise checking local laws. Require a one-time acknowledgment; store it. This is a caution, not legal advice. |
|
||||
| FR-REC-3 | M | 1 | When retention is on, the UI indicates the meeting is being **saved** (in addition to the "recording active" indicator, FR-CAP-4). |
|
||||
| FR-REC-4 | M | 2 | Deleting working audio on finalize happens only **after** the transcript is successfully finalized; never race with crash recovery (audio stays source of truth until then). |
|
||||
| FR-REC-5 | S | 2 | Play a meeting's retained `.wav` back in the app (decrypting a vault-sealed recording on demand for playback). |
|
||||
|
||||
### Hardware acceleration (HW)
|
||||
|
||||
@@ -152,6 +156,7 @@ the local LLM endpoint, and it is **off by default**. Primary targets are self-h
|
||||
| FR-SYNC-8 | M | 9 | Surface sync state in the UI and label targets: self-hosted/primary as "your server"; third-party clouds carry a clear "data leaves your device to a third party" banner. |
|
||||
| FR-SYNC-9 | S | 9 | **Secondary** targets via provider APIs + OAuth 2.0 (PKCE, loopback redirect): **OneDrive** (MS Graph), **Dropbox**, **Box**. |
|
||||
| FR-SYNC-10 | C | 9 | Optional client-side encryption of artifacts before upload (ties to FR-SEC-3): destination holds only ciphertext. |
|
||||
| FR-SYNC-11 | S | 9 | Show **live per-item upload progress** while syncing (stream the upload body and report bytes sent per artifact). |
|
||||
|
||||
### UX, accessibility, recovery (UX)
|
||||
|
||||
|
||||
@@ -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) |
|
||||
|
||||
@@ -125,7 +125,7 @@ privacy_self_check(): {
|
||||
## 2. Tauri events (Rust → frontend)
|
||||
|
||||
```ts
|
||||
"recording://state" { meetingId, state: "recording"|"paused"|"stopped", elapsedMs }
|
||||
"recording://state" { meetingId, state: "recording"|"paused"|"stopped"|"cancelled", elapsedMs }
|
||||
"recording://level" { meetingId, rms: number, peak: number } // waveform (FR-CAP-5)
|
||||
"recording://device" { meetingId, recovered: boolean, message: string } // capture device change (FR-CAP-6)
|
||||
"transcript://segment" { meetingId, segment: TranscriptSegment } // live segments (FR-TRX-2)
|
||||
@@ -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
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "whispassist",
|
||||
"private": true,
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.0",
|
||||
"type": "module",
|
||||
"description": "Privacy-first, fully local Windows meeting assistant.",
|
||||
"license": "MIT OR Apache-2.0",
|
||||
|
||||
Generated
+4
-1
@@ -2209,7 +2209,9 @@ version = "3.6.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c"
|
||||
dependencies = [
|
||||
"byteorder",
|
||||
"log",
|
||||
"windows-sys 0.60.2",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -5973,11 +5975,12 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "whispassist"
|
||||
version = "0.1.5"
|
||||
version = "0.2.0"
|
||||
dependencies = [
|
||||
"argon2",
|
||||
"async-trait",
|
||||
"chacha20poly1305",
|
||||
"chrono",
|
||||
"docx-rs",
|
||||
"futures-util",
|
||||
"getrandom 0.2.17",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "whispassist"
|
||||
version = "0.1.5"
|
||||
version = "0.2.0"
|
||||
description = "Privacy-first, fully local Windows meeting assistant"
|
||||
authors = ["WhispAssist contributors"]
|
||||
license = "MIT OR Apache-2.0"
|
||||
@@ -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"] }
|
||||
@@ -43,7 +46,7 @@ argon2 = "0.5"
|
||||
chacha20poly1305 = "0.10"
|
||||
getrandom = "0.2"
|
||||
zeroize = "1" # wipe key material from memory on lock
|
||||
keyring = { version = "3", optional = true } # OS credential store (sync + AI creds)
|
||||
keyring = { version = "3", optional = true, features = ["windows-native"] } # OS credential store (sync + AI creds); windows-native = real Credential Manager (else keyring 3.x uses a no-op mock store)
|
||||
rmcp = { version = "0.16", optional = true, features = ["server"] } # MCP server (ADR-0011)
|
||||
|
||||
# audio / transcription / diarization / calendar are integrated per-phase and are
|
||||
|
||||
@@ -14,5 +14,51 @@ fn main() {
|
||||
println!("cargo:rustc-env=WA_GIT_HASH={hash}");
|
||||
println!("cargo:rerun-if-changed=../.git/logs/HEAD");
|
||||
|
||||
// The `vulkan` build links `vulkan-1.dll` at load time, so the exe won't
|
||||
// launch on a machine that lacks the Vulkan loader (no GPU driver / bare VM).
|
||||
// Bundling the redistributable loader (Apache-2.0) next to the exe makes the
|
||||
// single universal installer start everywhere — with no GPU it simply reports
|
||||
// zero devices and we fall back to CPU. Copied both next to the built exe (so
|
||||
// `tauri dev`/`cargo run` work) and into the crate dir where the bundler picks
|
||||
// it up as a resource (see tauri.vulkan.conf.json).
|
||||
if std::env::var_os("CARGO_FEATURE_VULKAN").is_some() {
|
||||
stage_vulkan_loader();
|
||||
}
|
||||
|
||||
tauri_build::build();
|
||||
}
|
||||
|
||||
/// Locate `vulkan-1.dll` (Vulkan SDK first, then System32) and copy it beside
|
||||
/// the compiled exe and into the crate dir for bundling. Warns rather than fails
|
||||
/// so a dev build on a machine with the loader already on PATH still succeeds.
|
||||
fn stage_vulkan_loader() {
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
let source = std::env::var_os("VULKAN_SDK")
|
||||
.map(|sdk| Path::new(&sdk).join("Bin").join("vulkan-1.dll"))
|
||||
.filter(|p| p.exists())
|
||||
.or_else(|| {
|
||||
let sys = PathBuf::from(r"C:\Windows\System32\vulkan-1.dll");
|
||||
sys.exists().then_some(sys)
|
||||
});
|
||||
let Some(source) = source else {
|
||||
println!(
|
||||
"cargo:warning=vulkan feature is on but vulkan-1.dll wasn't found \
|
||||
(set VULKAN_SDK); the installer won't bundle the Vulkan loader"
|
||||
);
|
||||
return;
|
||||
};
|
||||
|
||||
// Beside the exe: OUT_DIR is target/<profile>/build/<pkg>-<hash>/out, so three
|
||||
// parents up is target/<profile> (correct even under CARGO_TARGET_DIR=C:\wt).
|
||||
if let Some(out_dir) = std::env::var_os("OUT_DIR") {
|
||||
if let Some(exe_dir) = Path::new(&out_dir).ancestors().nth(3) {
|
||||
let _ = std::fs::copy(&source, exe_dir.join("vulkan-1.dll"));
|
||||
}
|
||||
}
|
||||
// Into the crate dir for the bundler resource.
|
||||
let manifest_dir = std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR");
|
||||
if let Err(e) = std::fs::copy(&source, Path::new(&manifest_dir).join("vulkan-1.dll")) {
|
||||
println!("cargo:warning=failed to stage vulkan-1.dll for bundling: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
+782
-73
File diff suppressed because it is too large
Load Diff
+423
-16
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+432
-22
@@ -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,49 @@ 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 {
|
||||
// The mixer sums both streams for the live transcript; the bridge carries
|
||||
// the mic into the loopback thread so the recorded WAV holds both sides
|
||||
// at native quality (FR-CAP-7).
|
||||
let bridge = crate::audio::MicBridge::shared();
|
||||
let (loop_sink, mic_sink) = crate::audio::spawn_mixer(frame_tx);
|
||||
let capture = WasapiCapture
|
||||
.start_loopback_recording(
|
||||
&wav_path,
|
||||
settings.audio_output_device.as_deref(),
|
||||
loop_sink,
|
||||
event_tx.clone(),
|
||||
bridge.clone(),
|
||||
)
|
||||
.map_err(|e| WaError::new("audio", e.to_string()))?;
|
||||
let mic = WasapiCapture
|
||||
.start_microphone_recording(
|
||||
settings.audio_input_device.as_deref(),
|
||||
mic_sink,
|
||||
event_tx,
|
||||
bridge,
|
||||
)
|
||||
.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 +486,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 +535,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();
|
||||
|
||||
@@ -599,6 +650,58 @@ pub async fn stop_recording(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Abandon the in-progress recording: stop capture, drop the transcript, and
|
||||
/// delete the meeting row + its working files entirely — for a recording that
|
||||
/// was started by mistake. Unlike `stop_recording`, nothing is finalized,
|
||||
/// transcribed further, diarized, retained, or synced.
|
||||
#[tauri::command]
|
||||
pub async fn cancel_recording(
|
||||
app: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
meeting_id: MeetingId,
|
||||
) -> WaResult<()> {
|
||||
let mut guard = state.session.lock().await;
|
||||
let session = match guard.take() {
|
||||
Some(s) if s.meeting_id == meeting_id => s,
|
||||
Some(s) => {
|
||||
*guard = Some(s);
|
||||
return Err(WaError::new(
|
||||
"recording",
|
||||
"meeting_id does not match the active recording",
|
||||
));
|
||||
}
|
||||
None => {
|
||||
return Err(WaError::new(
|
||||
"recording",
|
||||
"no meeting is currently recording",
|
||||
))
|
||||
}
|
||||
};
|
||||
drop(guard);
|
||||
|
||||
// Stop both captures so their frame sinks drop and the transcription worker
|
||||
// exits; join it so nothing is still touching the files we're about to delete.
|
||||
let _ = WasapiCapture.stop(session.capture);
|
||||
if let Some(mic) = session.mic_capture {
|
||||
let _ = WasapiCapture.stop(mic);
|
||||
}
|
||||
let _ = session.transcription_worker.join();
|
||||
|
||||
// Remove the DB row + the whole meeting folder (working audio.wav included).
|
||||
state
|
||||
.store
|
||||
.delete_meeting(&meeting_id)
|
||||
.await
|
||||
.map_err(|e| WaError::new("storage", e.to_string()))?;
|
||||
|
||||
crate::update_tray_tooltip(&app, "WhispAssist — idle");
|
||||
let _ = app.emit(
|
||||
"recording://state",
|
||||
serde_json::json!({ "meetingId": meeting_id, "state": "cancelled", "elapsedMs": 0 }),
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn pause_recording(
|
||||
app: AppHandle,
|
||||
@@ -613,6 +716,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 +743,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 +1016,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"
|
||||
@@ -1513,6 +1648,141 @@ pub async fn delete_meeting(state: State<'_, AppState>, meeting_id: MeetingId) -
|
||||
.map_err(|e| WaError::new("storage", e.to_string()))
|
||||
}
|
||||
|
||||
/// Return the `waaudio://` URL the in-app player loads for a meeting's recording
|
||||
/// (FR-REC-5). The bytes are decrypted in memory on demand by `serve_recording`
|
||||
/// — nothing plaintext is ever written to disk. Prechecks that a recording
|
||||
/// exists and (if sealed) the vault is unlocked, so the UI can show a clear
|
||||
/// error before playback; also clears any stale plaintext `audio.play.wav` left
|
||||
/// by the previous file-based player.
|
||||
#[tauri::command]
|
||||
pub async fn recording_playback_path(meeting_id: MeetingId) -> WaResult<String> {
|
||||
let id = meeting_id.clone();
|
||||
tauri::async_runtime::spawn_blocking(move || {
|
||||
let dir = meeting_dir(&id);
|
||||
let wav = dir.join("audio.wav");
|
||||
if !wav.exists() {
|
||||
return Err(WaError::new(
|
||||
"recording",
|
||||
"no saved recording for this meeting",
|
||||
));
|
||||
}
|
||||
// Cheap sealed check — read only the magic prefix, not the whole file.
|
||||
let mut head = [0u8; 8];
|
||||
let sealed = std::fs::File::open(&wav)
|
||||
.and_then(|mut f| {
|
||||
use std::io::Read;
|
||||
let n = f.read(&mut head)?;
|
||||
Ok(n)
|
||||
})
|
||||
.map(|n| crate::vault::is_sealed(&head[..n]))
|
||||
.unwrap_or(false);
|
||||
if sealed && !crate::vault::is_unlocked() {
|
||||
return Err(WaError::new(
|
||||
"recording",
|
||||
"unlock the vault to play this recording",
|
||||
));
|
||||
}
|
||||
// Drop any plaintext temp the old file-based player left behind.
|
||||
let _ = std::fs::remove_file(dir.join("audio.play.wav"));
|
||||
Ok(())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| WaError::new("recording", e.to_string()))??;
|
||||
Ok(format!("http://waaudio.localhost/{meeting_id}"))
|
||||
}
|
||||
|
||||
/// Custom-scheme handler backing the `waaudio://` URL: reads the meeting's
|
||||
/// `audio.wav`, decrypts it in memory if sealed (T8.8), and streams the PCM to
|
||||
/// the `<audio>` element with byte-range support for seeking. The plaintext
|
||||
/// never touches the filesystem, so playback can't undermine encryption at rest.
|
||||
pub(crate) fn serve_recording(
|
||||
request: &tauri::http::Request<Vec<u8>>,
|
||||
) -> tauri::http::Response<Vec<u8>> {
|
||||
use tauri::http::{header, Response, StatusCode};
|
||||
let fail = |code: StatusCode| {
|
||||
Response::builder()
|
||||
.status(code)
|
||||
.body(Vec::new())
|
||||
.expect("static error response")
|
||||
};
|
||||
|
||||
// Guard the id against path traversal before joining it into a path.
|
||||
let id = request.uri().path().trim_start_matches('/').to_string();
|
||||
if id.is_empty() || !id.chars().all(|c| c.is_ascii_alphanumeric() || c == '-') {
|
||||
return fail(StatusCode::BAD_REQUEST);
|
||||
}
|
||||
let raw = match std::fs::read(meeting_dir(&id).join("audio.wav")) {
|
||||
Ok(b) => b,
|
||||
Err(_) => return fail(StatusCode::NOT_FOUND),
|
||||
};
|
||||
let plain = match crate::vault::open(&raw) {
|
||||
Ok(p) => p,
|
||||
Err(_) => return fail(StatusCode::FORBIDDEN), // sealed + vault locked
|
||||
};
|
||||
let total = plain.len();
|
||||
|
||||
let base = || {
|
||||
Response::builder()
|
||||
.header(header::CONTENT_TYPE, "audio/wav")
|
||||
.header(header::ACCEPT_RANGES, "bytes")
|
||||
};
|
||||
// Honour a single `Range` request so the player can seek.
|
||||
if let Some(range) = request
|
||||
.headers()
|
||||
.get(header::RANGE)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
{
|
||||
if let Some((start, end)) = parse_byte_range(range, total) {
|
||||
return base()
|
||||
.status(StatusCode::PARTIAL_CONTENT)
|
||||
.header(header::CONTENT_RANGE, format!("bytes {start}-{end}/{total}"))
|
||||
.header(header::CONTENT_LENGTH, (end - start + 1).to_string())
|
||||
.body(plain[start..=end].to_vec())
|
||||
.unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR));
|
||||
}
|
||||
}
|
||||
base()
|
||||
.status(StatusCode::OK)
|
||||
.header(header::CONTENT_LENGTH, total.to_string())
|
||||
.body(plain)
|
||||
.unwrap_or_else(|_| fail(StatusCode::INTERNAL_SERVER_ERROR))
|
||||
}
|
||||
|
||||
/// Parse a single `bytes=start-end` range against `total`, returning inclusive
|
||||
/// clamped indices. Supports open-ended (`start-`) and suffix (`-n`) forms; only
|
||||
/// the common single-range case is handled (enough for an `<audio>` element).
|
||||
fn parse_byte_range(header: &str, total: usize) -> Option<(usize, usize)> {
|
||||
if total == 0 {
|
||||
return None;
|
||||
}
|
||||
let (s, e) = header.strip_prefix("bytes=")?.split_once('-')?;
|
||||
if s.is_empty() {
|
||||
let n: usize = e.parse().ok()?;
|
||||
return Some((total.saturating_sub(n), total - 1));
|
||||
}
|
||||
let start: usize = s.parse().ok()?;
|
||||
let end = if e.is_empty() {
|
||||
total - 1
|
||||
} else {
|
||||
e.parse::<usize>().ok()?.min(total - 1)
|
||||
};
|
||||
(start <= end && start < total).then_some((start, end))
|
||||
}
|
||||
|
||||
/// Remove any leftover plaintext `audio.play.wav` files from the previous
|
||||
/// file-based player, so no decrypted audio lingers on disk (T8.8).
|
||||
pub(crate) fn cleanup_playback_temp() {
|
||||
let Ok(entries) = std::fs::read_dir(crate::paths::meetings_dir()) else {
|
||||
return;
|
||||
};
|
||||
for entry in entries.flatten() {
|
||||
let play = entry.path().join("audio.play.wav");
|
||||
if play.exists() {
|
||||
let _ = std::fs::remove_file(play);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
pub async fn update_notes(
|
||||
state: State<'_, AppState>,
|
||||
@@ -1939,6 +2209,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 +2257,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 +2279,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 +2291,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 +2344,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`
|
||||
@@ -2349,15 +2689,54 @@ pub async fn test_sync_target(
|
||||
let hints = provider_setup_hints(config.provider_hint.as_deref());
|
||||
let (target, temp_ref): (Box<dyn SyncTarget>, Option<String>) =
|
||||
if let Some(id) = config.id.clone() {
|
||||
// Existing target (any kind) — dispatch to its concrete impl.
|
||||
let row = state
|
||||
.store
|
||||
.get_sync_target(&id)
|
||||
.await
|
||||
.map_err(|e| WaError::new("sync", e.to_string()))?;
|
||||
let target = crate::sync::build_sync_target(&row)
|
||||
.map_err(|e| WaError::new("sync", e.to_string()))?;
|
||||
(target, None)
|
||||
if row.kind == "webdav" {
|
||||
// Editing an existing webdav target: test the values on screen but
|
||||
// reuse the STORED password (via its credential_ref) unless the user
|
||||
// typed a new one — so "Test" works after a URL fix without having to
|
||||
// re-enter the password.
|
||||
let (credential_ref, temp_ref) =
|
||||
if let Some(secret) = config.secret.as_deref().filter(|s| !s.is_empty()) {
|
||||
let temp_ref = format!("wa-sync-test-{}", uuid::Uuid::new_v4());
|
||||
crate::sync::credentials::set(&temp_ref, secret)
|
||||
.map_err(|e| WaError::new("sync", e.to_string()))?;
|
||||
(temp_ref.clone(), Some(temp_ref))
|
||||
} else {
|
||||
(row.credential_ref.clone(), None)
|
||||
};
|
||||
let target = crate::sync::WebDavTarget {
|
||||
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(),
|
||||
credential_ref,
|
||||
third_party: false,
|
||||
allow_plaintext_lan: config
|
||||
.allow_plaintext_lan
|
||||
.unwrap_or(row.allow_plaintext_lan),
|
||||
};
|
||||
(Box::new(target), temp_ref)
|
||||
} else {
|
||||
// Non-webdav (OAuth) target — dispatch to its concrete impl as-is.
|
||||
let target = crate::sync::build_sync_target(&row)
|
||||
.map_err(|e| WaError::new("sync", e.to_string()))?;
|
||||
(target, None)
|
||||
}
|
||||
} else {
|
||||
// Unsaved WebDAV target: stash the secret under a temp credential_ref so
|
||||
// the same resolve-at-use path works, then clean it up after the test.
|
||||
@@ -2372,6 +2751,7 @@ pub async fn test_sync_target(
|
||||
}
|
||||
let target = crate::sync::WebDavTarget {
|
||||
base_url,
|
||||
provider_hint: config.provider_hint.clone(),
|
||||
remote_base_path: config
|
||||
.remote_base_path
|
||||
.clone()
|
||||
@@ -2579,6 +2959,7 @@ async fn upload_job(
|
||||
target: &dyn crate::sync::SyncTarget,
|
||||
row: &crate::storage::SyncJobRow,
|
||||
encrypt: bool,
|
||||
mut on_progress: impl FnMut(u64, u64) + Send + 'static,
|
||||
) -> Result<u64, crate::sync::SyncError> {
|
||||
use crate::sync::SyncError;
|
||||
let remote_dir = row
|
||||
@@ -2588,6 +2969,22 @@ async fn upload_job(
|
||||
.unwrap_or("");
|
||||
target.ensure_dir(remote_dir).await?;
|
||||
let (tx, rx) = std::sync::mpsc::channel();
|
||||
// Forward live upload progress off the async task (the sink is a blocking
|
||||
// std channel): throttled so a large recording emits a steady stream of
|
||||
// `sync://job` updates without flooding the UI. Ends when `put` drops `tx`.
|
||||
let fallback_total = row.bytes_total.unwrap_or(0) as u64;
|
||||
let drain = std::thread::spawn(move || {
|
||||
let mut last = 0u64;
|
||||
let mut last_emit = std::time::Instant::now() - std::time::Duration::from_millis(500);
|
||||
while let Ok((sent, total)) = rx.recv() {
|
||||
last = sent;
|
||||
if last_emit.elapsed() >= std::time::Duration::from_millis(250) {
|
||||
on_progress(sent, total);
|
||||
last_emit = std::time::Instant::now();
|
||||
}
|
||||
}
|
||||
last
|
||||
});
|
||||
if encrypt {
|
||||
// Client-side encryption before upload (T9.12, FR-SYNC-10): seal to a
|
||||
// temp file (idempotent if already sealed at rest) so the destination
|
||||
@@ -2609,12 +3006,10 @@ async fn upload_job(
|
||||
.put(Path::new(&row.local_path), &row.remote_path, tx)
|
||||
.await?;
|
||||
}
|
||||
let sent = rx
|
||||
.try_iter()
|
||||
.last()
|
||||
.map(|(s, _)| s)
|
||||
.unwrap_or(row.bytes_total.unwrap_or(0) as u64);
|
||||
Ok(sent)
|
||||
// `tx` is dropped now that `put` returned, so the drain thread finishes and
|
||||
// hands back the final byte count it observed.
|
||||
let sent = drain.join().unwrap_or(fallback_total);
|
||||
Ok(if sent > 0 { sent } else { fallback_total })
|
||||
}
|
||||
|
||||
/// Drive all due jobs once: upload each, emit `sync://job` on every transition,
|
||||
@@ -2639,8 +3034,16 @@ pub(crate) async fn pump_sync(app: &AppHandle, store: &dyn crate::storage::Store
|
||||
let _ = store.update_sync_job(job.clone()).await;
|
||||
emit_sync_job(app, &job);
|
||||
|
||||
// Emit a live `sync://job` (status still "uploading") as bytes go out, so
|
||||
// the UI shows a moving per-item progress bar (FR-SYNC-11).
|
||||
let app_prog = app.clone();
|
||||
let mut prog_row = job.clone();
|
||||
let on_progress = move |sent: u64, _total: u64| {
|
||||
prog_row.bytes_sent = sent as i64;
|
||||
emit_sync_job(&app_prog, &prog_row);
|
||||
};
|
||||
let outcome = match crate::sync::build_sync_target(&target) {
|
||||
Ok(t) => upload_job(t.as_ref(), &job, target.encrypt_before_upload).await,
|
||||
Ok(t) => upload_job(t.as_ref(), &job, target.encrypt_before_upload, on_progress).await,
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
match outcome {
|
||||
@@ -3028,6 +3431,13 @@ mod tests {
|
||||
enabled,
|
||||
third_party,
|
||||
host: Some(host.to_string()),
|
||||
upload_transcript: true,
|
||||
upload_notes: true,
|
||||
upload_summary: true,
|
||||
upload_recording: false,
|
||||
trigger_on_finalize: true,
|
||||
allow_plaintext_lan: false,
|
||||
encrypt_before_upload: false,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
@@ -84,6 +88,11 @@ pub fn run() {
|
||||
|
||||
tauri::Builder::default()
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
// In-memory streaming of recordings for the player (FR-REC-5): decrypts
|
||||
// on the fly so no plaintext audio is ever written to disk.
|
||||
.register_uri_scheme_protocol("waaudio", |_ctx, request| {
|
||||
commands::serve_recording(&request)
|
||||
})
|
||||
.manage(AppState {
|
||||
store,
|
||||
session: Mutex::new(None),
|
||||
@@ -123,6 +132,10 @@ pub fn run() {
|
||||
// Startup recovery + retention + reminder-reconcile pass (FR-REL-1,
|
||||
// FR-STORE-2, FR-CAL-5). Spawned so it never blocks the window from
|
||||
// showing (NFR-PERF-4); nothing here repeats on a timer (NFR-RES-1).
|
||||
// Sweep away any plaintext playback temp files left by the previous
|
||||
// file-based player (T8.8) — playback is now in-memory only.
|
||||
commands::cleanup_playback_temp();
|
||||
|
||||
let store = store_for_setup;
|
||||
let startup_app = app.handle().clone();
|
||||
tauri::async_runtime::spawn(async move {
|
||||
@@ -161,12 +174,30 @@ 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(())
|
||||
})
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
commands::start_recording,
|
||||
commands::stop_recording,
|
||||
commands::cancel_recording,
|
||||
commands::recording_playback_path,
|
||||
commands::pause_recording,
|
||||
commands::resume_recording,
|
||||
commands::set_recording_retention,
|
||||
@@ -175,6 +206,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 +233,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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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) ----
|
||||
@@ -233,6 +258,15 @@ pub struct SyncTargetInfo {
|
||||
pub enabled: bool,
|
||||
pub third_party: bool,
|
||||
pub host: Option<String>,
|
||||
// Upload selection + options, so the UI can pre-fill an edit form (the secret
|
||||
// is never included — FR-SYNC-6).
|
||||
pub upload_transcript: bool,
|
||||
pub upload_notes: bool,
|
||||
pub upload_summary: bool,
|
||||
pub upload_recording: bool,
|
||||
pub trigger_on_finalize: bool,
|
||||
pub allow_plaintext_lan: bool,
|
||||
pub encrypt_before_upload: bool,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
|
||||
@@ -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();
|
||||
|
||||
+97
-13
@@ -75,7 +75,11 @@ pub trait SyncManager: Send + Sync {
|
||||
/// WebDAV provider — primary targets + Synology (feature `sync`).
|
||||
#[cfg(feature = "sync")]
|
||||
pub struct WebDavTarget {
|
||||
pub base_url: String, // https://host/remote.php/dav/files/<user>/ , /seafdav , /dav , …
|
||||
/// For Nextcloud/ownCloud this is just the server URL (e.g. `https://host`);
|
||||
/// the canonical `/remote.php/dav/files/<user>/` path is derived. For other
|
||||
/// providers it's the full DAV URL entered by the user (`…/seafdav`, `…/dav`).
|
||||
pub base_url: String,
|
||||
pub provider_hint: Option<String>,
|
||||
pub remote_base_path: String,
|
||||
pub username: String,
|
||||
pub credential_ref: String, // key into the OS credential store — resolved at use, not stored here
|
||||
@@ -90,6 +94,7 @@ impl WebDavTarget {
|
||||
pub fn from_row(row: &crate::storage::SyncTargetRow) -> Self {
|
||||
Self {
|
||||
base_url: row.base_url.clone().unwrap_or_default(),
|
||||
provider_hint: row.provider_hint.clone(),
|
||||
remote_base_path: row.remote_base_path.clone(),
|
||||
username: row.username.clone().unwrap_or_default(),
|
||||
credential_ref: row.credential_ref.clone(),
|
||||
@@ -98,11 +103,38 @@ impl WebDavTarget {
|
||||
}
|
||||
}
|
||||
|
||||
/// The effective WebDAV root the paths hang off. For Nextcloud/ownCloud the
|
||||
/// user supplies only their server URL and we build the canonical
|
||||
/// `…/remote.php/dav/files/<user>/` path (deriving the origin, so a pasted
|
||||
/// full DAV URL is normalized too). Other providers use the URL verbatim.
|
||||
fn dav_root(&self) -> String {
|
||||
match self.provider_hint.as_deref() {
|
||||
Some("nextcloud") | Some("owncloud") => {
|
||||
let origin = reqwest::Url::parse(&self.base_url)
|
||||
.ok()
|
||||
.map(|u| u.origin().ascii_serialization())
|
||||
.unwrap_or_else(|| self.base_url.trim_end_matches('/').to_string());
|
||||
// ponytail: raw username in the path — fine for typical Nextcloud
|
||||
// usernames; add percent-encoding if one ever contains `/` or spaces.
|
||||
format!(
|
||||
"{origin}/remote.php/dav/files/{}",
|
||||
self.username.trim_matches('/')
|
||||
)
|
||||
}
|
||||
_ => self.base_url.trim_end_matches('/').to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Full URL for a path under this target, resolving `//` at the join.
|
||||
fn url_for(&self, remote_path: &str) -> String {
|
||||
let base = self.base_url.trim_end_matches('/');
|
||||
let base = self.dav_root();
|
||||
let base = base.trim_end_matches('/');
|
||||
let path = remote_path.trim_start_matches('/');
|
||||
format!("{base}/{path}")
|
||||
if path.is_empty() {
|
||||
base.to_string()
|
||||
} else {
|
||||
format!("{base}/{path}")
|
||||
}
|
||||
}
|
||||
|
||||
/// Resolves the secret and returns an authenticated request builder, after
|
||||
@@ -209,21 +241,42 @@ impl SyncTarget for WebDavTarget {
|
||||
let url = self.url_for(remote_path);
|
||||
let resp = self
|
||||
.request(reqwest::Method::PUT, &url)?
|
||||
.body(bytes)
|
||||
.header(reqwest::header::CONTENT_LENGTH, total)
|
||||
.body(progress_body(bytes, prog))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::Upload(e.to_string()))?;
|
||||
match resp.status().as_u16() {
|
||||
code if (200..300).contains(&code) => {
|
||||
let _ = prog.send((total, total));
|
||||
Ok(())
|
||||
}
|
||||
code if (200..300).contains(&code) => Ok(()),
|
||||
401 | 403 => Err(SyncError::Auth),
|
||||
other => Err(SyncError::Upload(format!("PUT {other}"))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap in-memory bytes as a reqwest streaming body that reports cumulative
|
||||
/// bytes-sent to `prog` as the request drains it — the basis for live per-item
|
||||
/// upload progress (FR-SYNC-11). Chunked so the UI sees steady movement on a
|
||||
/// large recording instead of a single 0→100% jump at the end.
|
||||
#[cfg(feature = "sync")]
|
||||
fn progress_body(bytes: Vec<u8>, prog: ProgressSink) -> reqwest::Body {
|
||||
const CHUNK: usize = 64 * 1024;
|
||||
let total = bytes.len() as u64;
|
||||
let stream = futures_util::stream::unfold((bytes, 0usize), move |(bytes, pos)| {
|
||||
let prog = prog.clone();
|
||||
async move {
|
||||
if pos >= bytes.len() {
|
||||
return None;
|
||||
}
|
||||
let end = (pos + CHUNK).min(bytes.len());
|
||||
let chunk = bytes[pos..end].to_vec();
|
||||
let _ = prog.send((end as u64, total));
|
||||
Some((Ok::<Vec<u8>, std::io::Error>(chunk), (bytes, end)))
|
||||
}
|
||||
});
|
||||
reqwest::Body::wrap_stream(stream)
|
||||
}
|
||||
|
||||
/// Shared HTTP client (rustls TLS via reqwest's `rustls-tls` feature).
|
||||
#[cfg(feature = "sync")]
|
||||
fn http_client() -> Result<reqwest::Client, SyncError> {
|
||||
@@ -281,6 +334,13 @@ pub fn row_to_info(row: &crate::storage::SyncTargetRow) -> SyncTargetInfo {
|
||||
enabled: row.enabled,
|
||||
third_party: row.kind != "webdav",
|
||||
host,
|
||||
upload_transcript: row.upload_transcript,
|
||||
upload_notes: row.upload_notes,
|
||||
upload_summary: row.upload_summary,
|
||||
upload_recording: row.upload_recording,
|
||||
trigger_on_finalize: row.trigger_on_finalize,
|
||||
allow_plaintext_lan: row.allow_plaintext_lan,
|
||||
encrypt_before_upload: row.encrypt_before_upload,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -373,6 +433,7 @@ mod tests {
|
||||
fn url_join_normalizes_slashes() {
|
||||
let t = WebDavTarget {
|
||||
base_url: "https://host/dav/".to_string(),
|
||||
provider_hint: None,
|
||||
remote_base_path: "/WhispAssist".to_string(),
|
||||
username: "u".to_string(),
|
||||
credential_ref: "r".to_string(),
|
||||
@@ -385,6 +446,30 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nextcloud_builds_dav_path_from_server_url() {
|
||||
let mk = |base: &str| WebDavTarget {
|
||||
base_url: base.to_string(),
|
||||
provider_hint: Some("nextcloud".to_string()),
|
||||
remote_base_path: "/WhispAssist".to_string(),
|
||||
username: "dwdoubet".to_string(),
|
||||
credential_ref: "r".to_string(),
|
||||
third_party: false,
|
||||
allow_plaintext_lan: false,
|
||||
};
|
||||
// Bare server URL → canonical DAV path built from username.
|
||||
assert_eq!(
|
||||
mk("https://box.dou.bet").url_for("/WhispAssist"),
|
||||
"https://box.dou.bet/remote.php/dav/files/dwdoubet/WhispAssist"
|
||||
);
|
||||
// A pasted full DAV URL normalizes to the same thing (origin only).
|
||||
assert_eq!(
|
||||
mk("https://box.dou.bet/remote.php/dav/files/dwdoubet/WhispAssist")
|
||||
.url_for("/WhispAssist"),
|
||||
"https://box.dou.bet/remote.php/dav/files/dwdoubet/WhispAssist"
|
||||
);
|
||||
}
|
||||
|
||||
/// Full WebDAV round-trip against a real server, opt-in (needs one running):
|
||||
/// WA_WEBDAV_URL, WA_WEBDAV_USER, WA_WEBDAV_PASS
|
||||
/// e.g. `wsgidav --host=127.0.0.1 --port=8899 --root=<dir> --auth=anonymous`
|
||||
@@ -407,6 +492,7 @@ mod tests {
|
||||
|
||||
let target = WebDavTarget {
|
||||
base_url,
|
||||
provider_hint: None,
|
||||
remote_base_path: "/WhispAssist-test".to_string(),
|
||||
username: user,
|
||||
credential_ref: cred_ref.to_string(),
|
||||
@@ -602,15 +688,13 @@ impl SyncTarget for OneDriveTarget {
|
||||
let resp = http_client()?
|
||||
.put(Self::item_url(remote_path, ":/content"))
|
||||
.bearer_auth(token)
|
||||
.body(bytes)
|
||||
.header(reqwest::header::CONTENT_LENGTH, total)
|
||||
.body(progress_body(bytes, prog))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| SyncError::Upload(e.to_string()))?;
|
||||
match resp.status().as_u16() {
|
||||
code if (200..300).contains(&code) => {
|
||||
let _ = prog.send((total, total));
|
||||
Ok(())
|
||||
}
|
||||
code if (200..300).contains(&code) => Ok(()),
|
||||
401 | 403 => Err(SyncError::Auth),
|
||||
other => Err(SyncError::Upload(format!("PUT {other}"))),
|
||||
}
|
||||
|
||||
@@ -216,6 +216,12 @@ pub fn seal(plaintext: &[u8]) -> Result<Vec<u8>, VaultError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// True if `data` begins with the vault's sealed-file magic (i.e. it's
|
||||
/// ciphertext at rest, not a plaintext/pre-vault file).
|
||||
pub fn is_sealed(data: &[u8]) -> bool {
|
||||
data.len() >= MAGIC.len() && &data[..MAGIC.len()] == MAGIC
|
||||
}
|
||||
|
||||
/// Inverse of `seal`. Plaintext (no magic) passes through unchanged; sealed data
|
||||
/// requires the vault to be unlocked.
|
||||
pub fn open(data: &[u8]) -> Result<Vec<u8>, VaultError> {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "WhispAssist",
|
||||
"version": "0.1.5",
|
||||
"version": "0.2.0",
|
||||
"identifier": "bet.dou.whispassist",
|
||||
"build": {
|
||||
"frontendDist": "../dist",
|
||||
@@ -21,7 +21,7 @@
|
||||
}
|
||||
],
|
||||
"security": {
|
||||
"csp": "default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:*; img-src 'self' data:; style-src 'self' 'unsafe-inline'"
|
||||
"csp": "default-src 'self'; connect-src 'self' http://localhost:* http://127.0.0.1:*; img-src 'self' data:; media-src 'self' http://waaudio.localhost; style-src 'self' 'unsafe-inline'"
|
||||
},
|
||||
"trayIcon": {
|
||||
"iconPath": "icons/tray.png",
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"$schema": "gen/schemas/desktop-schema.json",
|
||||
"bundle": {
|
||||
"resources": ["vulkan-1.dll"]
|
||||
}
|
||||
}
|
||||
+34
-1
@@ -14,7 +14,7 @@
|
||||
import { api, type NoteTemplate } from "./lib/api";
|
||||
import { onMount } from "svelte";
|
||||
import ThemeToggle from "./lib/components/ThemeToggle.svelte";
|
||||
import { Circle, Square, Settings as SettingsIcon, AlertTriangle } from "@lucide/svelte";
|
||||
import { Circle, Square, Trash2, Settings as SettingsIcon, AlertTriangle } from "@lucide/svelte";
|
||||
|
||||
let showSettings = $state(false);
|
||||
let showConsent = $state(false);
|
||||
@@ -99,6 +99,12 @@
|
||||
else recording.stop();
|
||||
}
|
||||
|
||||
async function cancelRecording() {
|
||||
if (!confirm("Discard this recording? Its audio and transcript will be deleted.")) return;
|
||||
await recording.cancel();
|
||||
meetings.deselect();
|
||||
}
|
||||
|
||||
// Global shortcuts (T7.4, FR-UX-3): record start/stop, view toggles.
|
||||
function isEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
@@ -199,6 +205,14 @@
|
||||
<Square size={11} fill="currentColor" aria-hidden="true" />
|
||||
Stop
|
||||
</button>
|
||||
<button
|
||||
class="cancel-btn"
|
||||
onclick={cancelRecording}
|
||||
title="Discard this recording and delete it"
|
||||
>
|
||||
<Trash2 size={12} aria-hidden="true" />
|
||||
Cancel
|
||||
</button>
|
||||
<span class="rec">
|
||||
<span class="rec-dot" aria-hidden="true"></span>
|
||||
Recording…
|
||||
@@ -461,6 +475,25 @@
|
||||
.stop-btn:hover {
|
||||
background: var(--border);
|
||||
}
|
||||
.cancel-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: var(--radius-full);
|
||||
background: transparent;
|
||||
color: var(--muted);
|
||||
border: 1px solid var(--border);
|
||||
font-size: 0.85rem;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
color 150ms ease-out,
|
||||
border-color 150ms ease-out;
|
||||
}
|
||||
.cancel-btn:hover {
|
||||
color: var(--danger);
|
||||
border-color: var(--danger);
|
||||
}
|
||||
.rec {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -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;
|
||||
@@ -202,6 +210,13 @@ export interface SyncTargetInfo {
|
||||
enabled: boolean;
|
||||
third_party: boolean;
|
||||
host: string | null;
|
||||
upload_transcript: boolean;
|
||||
upload_notes: boolean;
|
||||
upload_summary: boolean;
|
||||
upload_recording: boolean;
|
||||
trigger_on_finalize: boolean;
|
||||
allow_plaintext_lan: boolean;
|
||||
encrypt_before_upload: boolean;
|
||||
}
|
||||
|
||||
// privacy_self_check() response (FR-SEC-2) — proves local-only handling.
|
||||
@@ -276,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).
|
||||
@@ -319,6 +339,11 @@ export const api = {
|
||||
}),
|
||||
listNoteTemplates: () => invoke<NoteTemplate[]>("list_note_templates"),
|
||||
stopRecording: (meetingId: MeetingId) => invoke<void>("stop_recording", { meetingId }),
|
||||
// Abandon an accidental recording: stop + delete files + drop the DB row.
|
||||
cancelRecording: (meetingId: MeetingId) => invoke<void>("cancel_recording", { meetingId }),
|
||||
// Absolute path to a playable audio.wav (decrypted if sealed), for convertFileSrc.
|
||||
recordingPlaybackPath: (meetingId: MeetingId) =>
|
||||
invoke<string>("recording_playback_path", { meetingId }),
|
||||
pauseRecording: (meetingId: MeetingId) => invoke<void>("pause_recording", { meetingId }),
|
||||
resumeRecording: (meetingId: MeetingId) => invoke<void>("resume_recording", { meetingId }),
|
||||
setRecordingRetention: (meetingId: MeetingId, record: boolean) =>
|
||||
@@ -328,6 +353,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"),
|
||||
@@ -387,6 +414,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) =>
|
||||
@@ -395,6 +423,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) =>
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,10 +18,14 @@ class RecordingStore {
|
||||
|
||||
async init() {
|
||||
await events.onRecordingState((p) => {
|
||||
const e = p as { state: "recording" | "paused" | "stopped"; elapsedMs: number };
|
||||
this.state = e.state === "stopped" ? "idle" : e.state;
|
||||
const e = p as {
|
||||
state: "recording" | "paused" | "stopped" | "cancelled";
|
||||
elapsedMs: number;
|
||||
};
|
||||
const ended = e.state === "stopped" || e.state === "cancelled";
|
||||
this.state = ended ? "idle" : (e.state as "recording" | "paused");
|
||||
this.elapsedMs = e.elapsedMs ?? this.elapsedMs;
|
||||
if (e.state === "stopped") {
|
||||
if (ended) {
|
||||
this.levelRms = 0;
|
||||
this.levelPeak = 0;
|
||||
}
|
||||
@@ -60,6 +64,17 @@ class RecordingStore {
|
||||
this.deviceNotice = null;
|
||||
}
|
||||
|
||||
/** Abandon an accidental recording: stop capture and delete it entirely. */
|
||||
async cancel() {
|
||||
if (this.meetingId) await api.cancelRecording(this.meetingId);
|
||||
this.meetingId = null;
|
||||
this.segments = [];
|
||||
this.state = "idle";
|
||||
this.levelRms = 0;
|
||||
this.levelPeak = 0;
|
||||
this.deviceNotice = null;
|
||||
}
|
||||
|
||||
/** Toggle audio retention mid-meeting (FR-REC-1); caller must have gated consent already. */
|
||||
async setRetention(record: boolean) {
|
||||
if (!this.meetingId) return;
|
||||
|
||||
@@ -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();
|
||||
@@ -248,6 +284,19 @@ class SettingsStore {
|
||||
await this.loadPrivacy();
|
||||
}
|
||||
|
||||
async updateTarget(config: SyncTargetConfig & { id: string }) {
|
||||
try {
|
||||
const updated = await api.updateSyncTarget(config);
|
||||
this.targets = this.targets.map((t) => (t.id === config.id ? updated : t));
|
||||
} catch {
|
||||
this.backendStub = true;
|
||||
this.targets = this.targets.map((t) =>
|
||||
t.id === config.id ? { ...t, ...stubTarget(config), id: config.id } : t,
|
||||
);
|
||||
}
|
||||
await this.loadPrivacy();
|
||||
}
|
||||
|
||||
async removeTarget(id: string) {
|
||||
this.targets = this.targets.filter((t) => t.id !== id);
|
||||
try {
|
||||
@@ -297,6 +346,13 @@ function stubTarget(c: SyncTargetConfig): SyncTargetInfo {
|
||||
enabled: c.enabled ?? false,
|
||||
third_party: c.kind !== "webdav",
|
||||
host,
|
||||
upload_transcript: c.upload_transcript ?? true,
|
||||
upload_notes: c.upload_notes ?? true,
|
||||
upload_summary: c.upload_summary ?? true,
|
||||
upload_recording: c.upload_recording ?? false,
|
||||
trigger_on_finalize: c.trigger_on_finalize ?? true,
|
||||
allow_plaintext_lan: c.allow_plaintext_lan ?? false,
|
||||
encrypt_before_upload: c.encrypt_before_upload ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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("");
|
||||
|
||||
|
||||
+182
-20
@@ -8,7 +8,7 @@
|
||||
import ConsentNotice from "../components/ConsentNotice.svelte";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { api, errorMessage, events } from "../api";
|
||||
import type { BackendId, SyncKind, SyncTargetConfig } from "../api";
|
||||
import type { BackendId, SyncKind, SyncTargetConfig, SyncTargetInfo } from "../api";
|
||||
import { trapFocus } from "../actions/trapFocus";
|
||||
import {
|
||||
X,
|
||||
@@ -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 "";
|
||||
@@ -343,6 +375,12 @@
|
||||
// ---- Add-target form ----
|
||||
const PROVIDERS = ["nextcloud", "owncloud", "cloudreve", "seafile", "synology", "generic"];
|
||||
let form = $state<SyncTargetConfig>(blankForm("webdav"));
|
||||
// When set, the form is editing an existing target rather than adding one.
|
||||
let editingId = $state<string | null>(null);
|
||||
// Nextcloud/ownCloud only need the server URL — the app builds the DAV path.
|
||||
let davAutoPath = $derived(
|
||||
form.provider_hint === "nextcloud" || form.provider_hint === "owncloud",
|
||||
);
|
||||
|
||||
function blankForm(kind: SyncKind): SyncTargetConfig {
|
||||
return {
|
||||
@@ -368,14 +406,51 @@
|
||||
testResult = null;
|
||||
}
|
||||
async function testConnection() {
|
||||
const r = await settings.test(form);
|
||||
// In edit mode, pass the id so the backend reuses the stored password
|
||||
// (unless a new one was typed) while still testing the edited URL/fields.
|
||||
const r = await settings.test(editingId ? { ...form, id: editingId } : form);
|
||||
testResult = { ok: r.ok, message: r.message };
|
||||
}
|
||||
async function addTarget() {
|
||||
await settings.addTarget(form);
|
||||
form = blankForm(form.kind);
|
||||
// Load an existing target into the form for editing. The secret is never
|
||||
// returned to the UI (FR-SYNC-6), so it stays blank — left blank on save it
|
||||
// keeps the stored password; typed, it rotates. No `id` on the form itself, so
|
||||
// "Test connection" checks the typed values (needs the password re-entered).
|
||||
function startEdit(t: SyncTargetInfo) {
|
||||
editingId = t.id;
|
||||
form = {
|
||||
name: t.name,
|
||||
kind: t.kind,
|
||||
provider_hint: t.provider_hint ?? undefined,
|
||||
base_url: t.base_url ?? "",
|
||||
remote_base_path: t.remote_base_path,
|
||||
username: t.username ?? "",
|
||||
secret: "",
|
||||
enabled: t.enabled,
|
||||
upload_transcript: t.upload_transcript,
|
||||
upload_notes: t.upload_notes,
|
||||
upload_summary: t.upload_summary,
|
||||
upload_recording: t.upload_recording,
|
||||
trigger_on_finalize: t.trigger_on_finalize,
|
||||
allow_plaintext_lan: t.allow_plaintext_lan,
|
||||
encrypt_before_upload: t.encrypt_before_upload,
|
||||
};
|
||||
testResult = null;
|
||||
}
|
||||
function cancelEdit() {
|
||||
editingId = null;
|
||||
form = blankForm("webdav");
|
||||
testResult = null;
|
||||
}
|
||||
async function saveTarget() {
|
||||
if (editingId) {
|
||||
await settings.updateTarget({ ...form, id: editingId });
|
||||
cancelEdit();
|
||||
} else {
|
||||
await settings.addTarget(form);
|
||||
form = blankForm(form.kind);
|
||||
testResult = null;
|
||||
}
|
||||
}
|
||||
function linkOAuth() {
|
||||
// begin_oauth_link → loopback PKCE flow; the target is created in the
|
||||
// background on success (sync://linked), which the store handles.
|
||||
@@ -488,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}
|
||||
@@ -654,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
|
||||
@@ -665,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>
|
||||
@@ -711,6 +855,9 @@
|
||||
>{t.third_party ? "third-party" : "your server"}</span
|
||||
>
|
||||
<span class="host">{t.host ?? t.kind}</span>
|
||||
{#if !t.third_party}
|
||||
<button class="link" onclick={() => startEdit(t)}>Edit</button>
|
||||
{/if}
|
||||
<button class="link danger" onclick={() => settings.removeTarget(t.id)}
|
||||
>Remove</button
|
||||
>
|
||||
@@ -719,14 +866,16 @@
|
||||
</ul>
|
||||
{/if}
|
||||
|
||||
<h4>Add a target</h4>
|
||||
<div class="kinds">
|
||||
{#each ["webdav", "onedrive", "dropbox", "box"] as k (k)}
|
||||
<button class:active={form.kind === k} onclick={() => setKind(k as SyncKind)}
|
||||
>{k}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
<h4>{editingId ? "Edit target" : "Add a target"}</h4>
|
||||
{#if !editingId}
|
||||
<div class="kinds">
|
||||
{#each ["webdav", "onedrive", "dropbox", "box"] as k (k)}
|
||||
<button class:active={form.kind === k} onclick={() => setKind(k as SyncKind)}
|
||||
>{k}</button
|
||||
>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if form.kind === "webdav"}
|
||||
<p class="muted">
|
||||
@@ -744,16 +893,26 @@
|
||||
<label class="wide"
|
||||
>Server URL<input
|
||||
bind:value={form.base_url}
|
||||
placeholder="https://cloud.example.com/remote.php/dav/files/me/"
|
||||
/></label
|
||||
>
|
||||
placeholder={davAutoPath
|
||||
? "https://cloud.example.com"
|
||||
: "https://host/remote.php/dav/… (or /seafdav, /dav)"}
|
||||
/>
|
||||
{#if davAutoPath}
|
||||
<small class="muted"
|
||||
>Just your server URL — WhispAssist adds
|
||||
<code>/remote.php/dav/files/<username>/</code> automatically.</small
|
||||
>
|
||||
{/if}
|
||||
</label>
|
||||
<label>Remote folder<input bind:value={form.remote_base_path} /></label>
|
||||
<label>Username<input bind:value={form.username} /></label>
|
||||
<label
|
||||
>App password<input
|
||||
type="password"
|
||||
bind:value={form.secret}
|
||||
placeholder="stored in OS credential store"
|
||||
placeholder={editingId
|
||||
? "leave blank to keep current password"
|
||||
: "stored in OS credential store"}
|
||||
/></label
|
||||
>
|
||||
</div>
|
||||
@@ -782,9 +941,12 @@
|
||||
</label>
|
||||
<div class="actions">
|
||||
<button onclick={testConnection}>Test connection</button>
|
||||
<button class="primary" onclick={addTarget} disabled={!form.name || !form.base_url}
|
||||
>Add target</button
|
||||
<button class="primary" onclick={saveTarget} disabled={!form.name || !form.base_url}
|
||||
>{editingId ? "Save changes" : "Add target"}</button
|
||||
>
|
||||
{#if editingId}
|
||||
<button class="link" onclick={cancelEdit}>Cancel</button>
|
||||
{/if}
|
||||
{#if testResult}
|
||||
<span class="test" class:ok={testResult.ok} class:fail={!testResult.ok}>
|
||||
{#if testResult.ok}<Check size={14} aria-hidden="true" />{:else}<X
|
||||
|
||||
@@ -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,
|
||||
@@ -19,6 +26,26 @@
|
||||
|
||||
onMount(() => calendar.load());
|
||||
|
||||
// ---- Recording playback (FR-REC-5) ----
|
||||
let audioSrc = $state<string | null>(null);
|
||||
let audioError = $state<string | null>(null);
|
||||
$effect(() => {
|
||||
const m = meetings.selected;
|
||||
audioSrc = null;
|
||||
audioError = null;
|
||||
if (m?.recorded) {
|
||||
const id = m.id;
|
||||
api
|
||||
.recordingPlaybackPath(id)
|
||||
.then((url) => {
|
||||
if (meetings.selected?.id === id) audioSrc = url;
|
||||
})
|
||||
.catch((e) => {
|
||||
if (meetings.selected?.id === id) audioError = errorMessage(e);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// ---- Summary + action items (T5.4/T5.5/T5.6, FR-LLM-2/3/4) ----
|
||||
let llmStatus = $state<LlmStatus | null>(null);
|
||||
onMount(async () => {
|
||||
@@ -94,6 +121,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,27 +177,102 @@
|
||||
}
|
||||
|
||||
// ---- 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">
|
||||
{#if meetings.selected?.recorded}
|
||||
<h3><Mic2 size={14} aria-hidden="true" /> Recording</h3>
|
||||
{#if audioSrc}
|
||||
<!-- svelte-ignore a11y_media_has_caption -- a meeting recording has no caption track -->
|
||||
<!-- controlsList/contextmenu: no download affordance — the decrypted audio
|
||||
must not be savable to disk (would undermine encryption at rest). -->
|
||||
<audio
|
||||
class="player"
|
||||
controls
|
||||
controlsList="nodownload noplaybackrate"
|
||||
oncontextmenu={(e) => e.preventDefault()}
|
||||
src={audioSrc}
|
||||
></audio>
|
||||
{:else if audioError}
|
||||
<p class="muted">{audioError}</p>
|
||||
{:else}
|
||||
<p class="muted">Loading recording…</p>
|
||||
{/if}
|
||||
{/if}
|
||||
|
||||
{#if meetings.selected && settings.settings.sync_enabled}
|
||||
<h3><UploadCloud size={14} aria-hidden="true" /> Sync</h3>
|
||||
<button
|
||||
@@ -161,6 +292,7 @@
|
||||
<span class="artifact">{job.artifact}</span>
|
||||
<span class="job-status {job.status}">{job.status}</span>
|
||||
{#if job.status === "uploading" && job.bytesTotal}
|
||||
<progress class="job-bar" max={job.bytesTotal} value={job.bytesSent}></progress>
|
||||
<span class="muted">{Math.round((100 * job.bytesSent) / job.bytesTotal)}%</span>
|
||||
{/if}
|
||||
{#if job.status === "failed"}
|
||||
@@ -185,21 +317,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 +428,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 +447,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 +559,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;
|
||||
}
|
||||
@@ -541,6 +724,27 @@
|
||||
.artifact {
|
||||
min-width: 5rem;
|
||||
}
|
||||
.job-bar {
|
||||
flex: 1;
|
||||
height: 0.4rem;
|
||||
min-width: 3rem;
|
||||
border: none;
|
||||
border-radius: var(--radius-full);
|
||||
overflow: hidden;
|
||||
accent-color: var(--accent, #2563eb);
|
||||
}
|
||||
.job-bar::-webkit-progress-bar {
|
||||
background: var(--bg-hover);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.job-bar::-webkit-progress-value {
|
||||
background: var(--accent, #2563eb);
|
||||
border-radius: var(--radius-full);
|
||||
}
|
||||
.player {
|
||||
width: 100%;
|
||||
margin: 0.2rem 0 0.6rem;
|
||||
}
|
||||
.job-status {
|
||||
text-transform: capitalize;
|
||||
color: var(--muted);
|
||||
|
||||
@@ -21,6 +21,8 @@
|
||||
RefreshCw,
|
||||
MessageSquareText,
|
||||
NotebookPen,
|
||||
Eye,
|
||||
Pencil,
|
||||
} from "@lucide/svelte";
|
||||
|
||||
function speakerName(label: string, speakers: SpeakerInfo[] = []): string {
|
||||
@@ -28,6 +30,9 @@
|
||||
}
|
||||
|
||||
let notesText = $state("");
|
||||
// Notes is a single pane: raw markdown ("Editor") or the rendered result
|
||||
// ("Preview"), toggled by one button whose label flips to the other mode.
|
||||
let notesPreview = $state(false);
|
||||
let editorEl: HTMLTextAreaElement | undefined = $state();
|
||||
let saveTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let loadedForId: string | null = null;
|
||||
@@ -43,6 +48,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 +146,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>
|
||||
@@ -182,6 +206,20 @@
|
||||
>
|
||||
<ListChecks size={14} aria-hidden="true" />
|
||||
</button>
|
||||
<button
|
||||
class="toggle"
|
||||
onclick={() => (notesPreview = !notesPreview)}
|
||||
title={notesPreview ? "Edit the raw markdown" : "Render the markdown"}
|
||||
aria-pressed={notesPreview}
|
||||
>
|
||||
{#if notesPreview}
|
||||
<Pencil size={14} aria-hidden="true" />
|
||||
Editor
|
||||
{:else}
|
||||
<Eye size={14} aria-hidden="true" />
|
||||
Preview
|
||||
{/if}
|
||||
</button>
|
||||
<span class="spacer"></span>
|
||||
<button onclick={exportMd} title="Export notes as .md">
|
||||
<FileText size={13} aria-hidden="true" />
|
||||
@@ -201,14 +239,17 @@
|
||||
</button>
|
||||
</div>
|
||||
<div class="editor-preview">
|
||||
<textarea
|
||||
bind:this={editorEl}
|
||||
bind:value={notesText}
|
||||
oninput={scheduleSave}
|
||||
placeholder="Notes…"
|
||||
></textarea>
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
|
||||
<div class="preview">{@html renderMarkdown(notesText)}</div>
|
||||
{#if notesPreview}
|
||||
<!-- eslint-disable-next-line svelte/no-at-html-tags -- sanitized via renderMarkdown() -->
|
||||
<div class="preview">{@html renderMarkdown(notesText)}</div>
|
||||
{:else}
|
||||
<textarea
|
||||
bind:this={editorEl}
|
||||
bind:value={notesText}
|
||||
oninput={scheduleSave}
|
||||
placeholder="Notes…"
|
||||
></textarea>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -229,6 +270,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 +306,8 @@
|
||||
.split {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
height: 100%;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
.pane {
|
||||
overflow: auto;
|
||||
@@ -330,11 +388,14 @@
|
||||
}
|
||||
|
||||
.editor-preview {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 0.75rem;
|
||||
height: calc(100% - 2.5rem);
|
||||
}
|
||||
.toolbar .toggle {
|
||||
font-weight: 600;
|
||||
}
|
||||
.toolbar .toggle[aria-pressed="true"] {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
textarea {
|
||||
resize: none;
|
||||
width: 100%;
|
||||
@@ -352,8 +413,12 @@
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.preview {
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
overflow: auto;
|
||||
padding: 0.25rem 0.5rem;
|
||||
padding: 0.6rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user