diff --git a/.gitignore b/.gitignore index 2f7896d..9168541 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,29 @@ target/ +*.tgz +*.zip +breadpadcli +breadmancli +svgs.txt + +# Editor & IDE +*.swp +*.swo +.DS_Store +.vscode/ +.idea/ +*.iml + +# Environment +.env +.env.* +!.env.example + +# Debug & Logs +*.log +*.pid +*.sock +*.pdb + +# Rust/Cargo +Cargo.lock +dist/ diff --git a/Cargo.lock b/Cargo.lock index eaf5cc9..16ce198 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -259,12 +259,6 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" -[[package]] -name = "base64ct" -version = "1.8.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" - [[package]] name = "bit-set" version = "0.8.0" @@ -316,6 +310,7 @@ dependencies = [ "breadpad-shared", "chrono", "dirs 5.0.1", + "futures-channel", "gtk4", "serde", "serde_json", @@ -335,6 +330,7 @@ dependencies = [ "gtk4", "gtk4-layer-shell", "hyprland", + "ort", "serde", "serde_json", "tokio", @@ -362,7 +358,7 @@ dependencies = [ "tokio", "toml 0.8.23", "tracing", - "ureq 2.12.1", + "ureq", "uuid", "zbus", ] @@ -377,6 +373,8 @@ dependencies = [ "clap", "colored", "comfy-table", + "dirs 5.0.1", + "ort", "serde", "serde_json", ] @@ -612,16 +610,6 @@ dependencies = [ "unicode-segmentation", ] -[[package]] -name = "core-foundation" -version = "0.10.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "core-foundation-sys" version = "0.8.7" @@ -748,16 +736,6 @@ dependencies = [ "serde", ] -[[package]] -name = "der" -version = "0.8.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b" -dependencies = [ - "pem-rfc7468", - "zeroize", -] - [[package]] name = "derive_builder" version = "0.20.2" @@ -1021,21 +999,6 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" -[[package]] -name = "foreign-types" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" -dependencies = [ - "foreign-types-shared", -] - -[[package]] -name = "foreign-types-shared" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" - [[package]] name = "form_urlencoded" version = "1.2.2" @@ -1521,16 +1484,10 @@ dependencies = [ "serde", "serde_json", "thiserror 2.0.18", - "ureq 2.12.1", + "ureq", "windows-sys 0.60.2", ] -[[package]] -name = "hmac-sha256" -version = "1.1.14" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f" - [[package]] name = "http" version = "1.4.0" @@ -1894,6 +1851,16 @@ version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +[[package]] +name = "libloading" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60" +dependencies = [ + "cfg-if", + "windows-link", +] + [[package]] name = "libredox" version = "0.1.16" @@ -1942,12 +1909,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" -[[package]] -name = "lzma-rust2" -version = "0.15.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619" - [[package]] name = "macro_rules_attribute" version = "0.2.2" @@ -2047,23 +2008,6 @@ dependencies = [ "syn", ] -[[package]] -name = "native-tls" -version = "0.2.18" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" -dependencies = [ - "libc", - "log", - "openssl", - "openssl-probe", - "openssl-sys", - "schannel", - "security-framework", - "security-framework-sys", - "tempfile", -] - [[package]] name = "ndarray" version = "0.16.1" @@ -2171,49 +2115,6 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" -[[package]] -name = "openssl" -version = "0.10.80" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" -dependencies = [ - "bitflags", - "cfg-if", - "foreign-types", - "libc", - "openssl-macros", - "openssl-sys", -] - -[[package]] -name = "openssl-macros" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" -dependencies = [ - "proc-macro2", - "quote", - "syn", -] - -[[package]] -name = "openssl-probe" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" - -[[package]] -name = "openssl-sys" -version = "0.9.116" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" -dependencies = [ - "cc", - "libc", - "pkg-config", - "vcpkg", -] - [[package]] name = "option-ext" version = "0.2.0" @@ -2236,11 +2137,11 @@ version = "2.0.0-rc.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" dependencies = [ + "libloading", "ndarray 0.17.2", "ort-sys", "smallvec", "tracing", - "ureq 3.3.0", ] [[package]] @@ -2248,11 +2149,6 @@ name = "ort-sys" version = "2.0.0-rc.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" -dependencies = [ - "hmac-sha256", - "lzma-rust2", - "ureq 3.3.0", -] [[package]] name = "pango" @@ -2328,15 +2224,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" -[[package]] -name = "pem-rfc7468" -version = "1.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9" -dependencies = [ - "base64ct", -] - [[package]] name = "percent-encoding" version = "2.3.2" @@ -2853,44 +2740,12 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" -[[package]] -name = "schannel" -version = "0.1.29" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" -dependencies = [ - "windows-sys 0.61.2", -] - [[package]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" -[[package]] -name = "security-framework" -version = "3.7.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" -dependencies = [ - "bitflags", - "core-foundation", - "core-foundation-sys", - "libc", - "security-framework-sys", -] - -[[package]] -name = "security-framework-sys" -version = "2.17.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" -dependencies = [ - "core-foundation-sys", - "libc", -] - [[package]] name = "semver" version = "1.0.28" @@ -3593,36 +3448,6 @@ dependencies = [ "webpki-roots 0.26.11", ] -[[package]] -name = "ureq" -version = "3.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" -dependencies = [ - "base64 0.22.1", - "der", - "log", - "native-tls", - "percent-encoding", - "rustls-pki-types", - "socks", - "ureq-proto", - "utf8-zero", - "webpki-root-certs", -] - -[[package]] -name = "ureq-proto" -version = "0.6.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" -dependencies = [ - "base64 0.22.1", - "http", - "httparse", - "log", -] - [[package]] name = "url" version = "2.5.8" @@ -3635,12 +3460,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf8-zero" -version = "0.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e" - [[package]] name = "utf8_iter" version = "1.0.4" @@ -3670,12 +3489,6 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" -[[package]] -name = "vcpkg" -version = "0.2.15" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" - [[package]] name = "version-compare" version = "0.2.1" @@ -3830,15 +3643,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "webpki-root-certs" -version = "1.0.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c" -dependencies = [ - "rustls-pki-types", -] - [[package]] name = "webpki-roots" version = "0.26.11" diff --git a/Cargo.toml b/Cargo.toml index 90a0cfd..5ec24f9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,7 @@ chrono = { version = "0.4", features = ["serde"] } rrule = "0.12" tokio = { version = "1", features = ["full"] } zbus = { version = "4", default-features = false, features = ["tokio"] } -ort = { version = "2.0.0-rc.12", features = ["download-binaries"] } +ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "ndarray", "tracing", "api-24", "load-dynamic"] } ndarray = "0.16" tokenizers = { version = "0.21", default-features = false, features = ["http", "fancy-regex"] } gtk4 = { version = "0.11", features = ["v4_12"] } diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5db346b --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Breadway + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index b7feca3..02ec8a2 100644 --- a/README.md +++ b/README.md @@ -67,7 +67,7 @@ User-defined tags can be added freely on top of the built-in types. - Inline editing — click any card to edit body, type, time, or recurrence - Mark todo/reminder as done; done items move to an archive accessible via a toggle - Search across all notes (full-text, instant) -- Sort by: newest, oldest, due time +- Sort: newest first (default) ### Theming @@ -83,8 +83,8 @@ User-defined tags can be added freely on top of the built-in types. Notes are stored as JSONL at `~/.local/share/breadpad/notes.jsonl` — one JSON object per line, human-readable, easy to back up or script against. ```jsonl -{"id":"a1b2c3","body":"Pack calculator in bag","type":"reminder","time":"2026-05-25T19:00:00","rrule":null,"done":false,"workspace":"1","created":"2026-05-25T18:45:00","snoozed_until":null} -{"id":"d4e5f6","body":"Look into relm4 reactive patterns","type":"idea","time":null,"rrule":null,"done":false,"workspace":"2","created":"2026-05-25T14:10:00","snoozed_until":null} +{"id":"a1b2c3","body":"Pack calculator in bag","type":"reminder","time":"2026-05-25T19:00:00Z","rrule":null,"done":false,"workspace":"1","created":"2026-05-25T18:45:00Z","snoozed_until":null,"completed":null,"tags":[],"caldav_uid":null} +{"id":"d4e5f6","body":"Look into relm4 reactive patterns","type":"idea","time":null,"rrule":null,"done":false,"workspace":"2","created":"2026-05-25T14:10:00Z","snoozed_until":null,"completed":null,"tags":[],"caldav_uid":null} ``` Completed notes are never deleted — they gain `"done": true` and a `"completed"` timestamp. A separate `~/.local/share/breadpad/archive.jsonl` is written periodically for notes older than 30 days. @@ -108,13 +108,7 @@ Returns a calibrated confidence. If ≥ 0.82, Tiers 2 and 3 are skipped. Runs when Tier 1 confidence is below threshold. Responsible for **type classification only** — Tier 1's extracted time, recurrence rule, and cleaned body are always preserved. -Invoked via `ort` (ONNX Runtime Rust bindings). Execution provider order: - -1. **QNN (Qualcomm/AMD XDNA NPU)** — requires `libQnnHtp.so` from the AMD Ryzen AI software stack -2. **Vulkan** — iGPU via the ONNX Runtime Vulkan EP -3. **CPU** — always available fallback - -Active provider shown in `breadpad --status`. +Invoked via `ort` (ONNX Runtime Rust bindings, `load-dynamic`) on the CPU. Requires an external `libonnxruntime.so`; set `model.ort_dylib_path` in `breadpad.toml` or let breadpad auto-discover it via `ORT_DYLIB_PATH`. #### Tier 3 — Large local model via Ollama @@ -129,11 +123,10 @@ If Ollama is unreachable or returns an invalid response, breadpad logs a warning ~/.local/share/breadpad/model/tokenizer.json ``` -breadpad ships without a bundled model. Run `breadpad download-model` to fetch a recommended quantised model, or drop your own ONNX model in the above path. +breadpad ships without a bundled model. Drop a compatible ONNX classifier and `tokenizer.json` at those paths, then configure `model.ort_dylib_path` to point at your ONNX Runtime library. ```bash -breadpad download-model # fetches default model (~150 MB) -breadpad model-info # shows active EP, model path, last inference time +breadpad model-info # shows active EP and model path ``` --- @@ -144,9 +137,8 @@ breadpad model-info # shows active EP, model path, last inference time - GTK4 (≥ 4.12) + `gtk4-layer-shell` - D-Bus session bus (for notifications) - systemd user session (for timer-backed reminders) -- Rust 1.77+ -- ONNX Runtime (`ort` crate pulls this in automatically via the `download-binaries` feature) -- **NPU path only:** AMD Ryzen AI software stack (`amdxdna` kernel driver ≥ 6.11, QNN EP shared libs) +- Rust 1.80+ +- **Tier 2 (ONNX classifier):** An external `libonnxruntime.so`. Set `model.ort_dylib_path` in `breadpad.toml`, or set `ORT_DYLIB_PATH` in your environment. Without a library, Tier 2 is disabled; Tier 1 + 3 still work. - **Tier 3 only (optional):** [Ollama](https://ollama.com) running locally with your chosen model pulled (`ollama pull llama3.2:3b`). Tier 3 is silently skipped if Ollama is not running. --- @@ -160,8 +152,9 @@ cargo build --release cp target/release/breadpad ~/.local/bin/ cp target/release/breadman ~/.local/bin/ -# Fetch the default classifier model -breadpad download-model +# Place your ONNX classifier and tokenizer in the model directory +mkdir -p ~/.local/share/breadpad/model +# Then set model.ort_dylib_path in breadpad.toml to your libonnxruntime.so ``` On Arch Linux, install GTK4 dependencies first: @@ -186,7 +179,7 @@ archive_after_days = 30 [model] path = "~/.local/share/breadpad/model/classifier.onnx" tokenizer = "~/.local/share/breadpad/model/tokenizer.json" -execution_provider = "auto" # auto | npu | vulkan | cpu +ort_dylib_path = "" # optional: explicit path to libonnxruntime.so; auto-discovered when empty [model.ollama] endpoint = "http://localhost:11434" diff --git a/bread.zip b/bread.zip deleted file mode 100644 index 9ce3067..0000000 Binary files a/bread.zip and /dev/null differ diff --git a/breadman/Cargo.toml b/breadman/Cargo.toml index 34c0a80..577f684 100644 --- a/breadman/Cargo.toml +++ b/breadman/Cargo.toml @@ -20,3 +20,4 @@ tokio.workspace = true chrono.workspace = true gtk4.workspace = true dirs.workspace = true +futures-channel = "0.3" diff --git a/breadman/src/editor.rs b/breadman/src/editor.rs index 60277b4..a01dfa5 100644 --- a/breadman/src/editor.rs +++ b/breadman/src/editor.rs @@ -1,10 +1,11 @@ use breadpad_shared::{ parser::parse_rule_based, + scheduler::Scheduler, store::Store, types::{Note, NoteType, RecurrenceRule}, }; use chrono::{Local, TimeZone, Utc}; -use gtk4::prelude::*; +use gtk4::{glib, prelude::*}; use std::cell::RefCell; use std::rc::Rc; use std::sync::Arc; @@ -13,8 +14,9 @@ pub fn build_editor_popover( note: &Note, store: Arc, morning: String, - on_save: impl Fn(Note) + 'static, - on_delete: impl Fn() + 'static, + on_save: Rc, + on_delete: Rc, + on_error: Rc, ) -> gtk4::Popover { let popover = gtk4::Popover::new(); popover.set_has_arrow(false); @@ -86,7 +88,7 @@ pub fn build_editor_popover( btn_row.append(&save_btn); vbox.append(&btn_row); - // Delete: two-click confirm using a single handler and shared state + // Delete: two-click confirm let confirming = Rc::new(RefCell::new(false)); { let confirming = confirming.clone(); @@ -94,16 +96,32 @@ pub fn build_editor_popover( let note_id = note.id.clone(); let store_del = store.clone(); let popover_del = popover.clone(); + let on_delete = Rc::clone(&on_delete); + let on_error = Rc::clone(&on_error); delete_btn.connect_clicked(move |_| { - let currently = *confirming.borrow(); - if currently { - if let Err(e) = store_del.delete_note(¬e_id) { - tracing::error!("failed to delete note: {}", e); - } else { - on_delete(); - } - popover_del.popdown(); + if *confirming.borrow() { + let store = store_del.clone(); + let id = note_id.clone(); + let on_delete = Rc::clone(&on_delete); + let on_error = Rc::clone(&on_error); + let popover = popover_del.clone(); + spawn_bg( + move || -> anyhow::Result<()> { + store.delete_note(&id)?; + if let Err(e) = Scheduler::cancel(&id) { + tracing::warn!("failed to cancel timer for {}: {}", id, e); + } + Ok(()) + }, + move |result| { + match result { + Ok(()) => on_delete(), + Err(e) => on_error(format!("delete failed: {}", e)), + } + popover.popdown(); + }, + ); } else { *confirming.borrow_mut() = true; delete_btn_label.set_label("Sure?"); @@ -112,46 +130,77 @@ pub fn build_editor_popover( } // Save - let note_clone = note.clone(); - let popover_save = popover.clone(); + { + let note_clone = note.clone(); + let popover_save = popover.clone(); + let on_error = Rc::clone(&on_error); - save_btn.connect_clicked(move |_| { - let mut updated = note_clone.clone(); - updated.body = body_entry.text().to_string(); + save_btn.connect_clicked(move |_| { + // Read all field values on the main thread before handing off. + let mut updated = note_clone.clone(); + updated.body = body_entry.text().to_string(); + updated.note_type = NoteType::from_str( + NoteType::all_builtin() + .get(type_combo.selected() as usize) + .copied() + .unwrap_or("note"), + ); + let time_str = time_entry.text().to_string(); + updated.time = if time_str.trim().is_empty() { + None + } else { + parse_time_field(&time_str, &morning) + }; + let rrule_text = rrule_entry.text().to_string(); + updated.rrule = if rrule_text.trim().is_empty() { + None + } else { + Some(RecurrenceRule::new(rrule_text)) + }; - updated.note_type = NoteType::from_str( - NoteType::all_builtin() - .get(type_combo.selected() as usize) - .copied() - .unwrap_or("note"), - ); + popover_save.popdown(); - let time_str = time_entry.text().to_string(); - updated.time = if time_str.trim().is_empty() { - None - } else { - parse_time_field(&time_str, &morning) - }; - - let rrule_text = rrule_entry.text().to_string(); - updated.rrule = if rrule_text.trim().is_empty() { - None - } else { - Some(RecurrenceRule::new(rrule_text)) - }; - - if let Err(e) = store.update_note(&updated) { - tracing::error!("failed to update note: {}", e); - } else { - on_save(updated); - } - popover_save.popdown(); - }); + let store_bg = store.clone(); + let on_save = Rc::clone(&on_save); + let on_error = Rc::clone(&on_error); + spawn_bg( + move || -> anyhow::Result { + store_bg.update_note(&updated)?; + if let Err(e) = Scheduler::cancel(&updated.id) { + tracing::warn!("cancel before reschedule: {}", e); + } + if updated.time.is_some() || updated.rrule.is_some() { + Scheduler::schedule(&updated)?; + } + Ok(updated) + }, + move |result| match result { + Ok(note) => on_save(note), + Err(e) => on_error(format!("update failed: {}", e)), + }, + ); + }); + } popover.set_child(Some(&vbox)); popover } +fn spawn_bg(work: F, then: C) +where + F: FnOnce() -> T + Send + 'static, + T: Send + 'static, + C: FnOnce(T) + 'static, +{ + let (tx, rx) = futures_channel::oneshot::channel::(); + std::thread::spawn(move || { let _ = tx.send(work()); }); + glib::MainContext::default().spawn_local(async move { + if let Ok(result) = rx.await { + then(result); + } + }); +} + fn parse_time_field(s: &str, morning: &str) -> Option> { if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s.trim(), "%Y-%m-%d %H:%M") { if let chrono::LocalResult::Single(local) = Local.from_local_datetime(&naive) { diff --git a/breadman/src/main.rs b/breadman/src/main.rs index 386df78..0ee89bc 100644 --- a/breadman/src/main.rs +++ b/breadman/src/main.rs @@ -107,6 +107,30 @@ impl AppState { } } +// ── Background I/O helper ───────────────────────────────────────────────────── + +/// Run `work` on a background thread, then call `then` on the GTK main thread. +/// +/// `work` must be `Send + 'static` (moves into the thread). +/// `then` only needs `'static` — it can capture GTK widgets and `Rc>`. +/// +/// Uses `glib::MainContext::spawn_local` (called from the main thread) with a +/// `futures_channel::oneshot` to bridge the blocking result back to the async future. +fn spawn_bg(work: F, then: C) +where + F: FnOnce() -> T + Send + 'static, + T: Send + 'static, + C: FnOnce(T) + 'static, +{ + let (tx, rx) = futures_channel::oneshot::channel::(); + std::thread::spawn(move || { let _ = tx.send(work()); }); + glib::MainContext::default().spawn_local(async move { + if let Ok(result) = rx.await { + then(result); + } + }); +} + // ── Refresh ─────────────────────────────────────────────────────────────────── fn refresh(state: &AppState) { @@ -116,6 +140,16 @@ fn refresh(state: &AppState) { state.stack.set_visible_child_name(&active); } +/// Replace only the "all" stack page with a new list built from `notes`. +/// All other pages are left untouched, preserving scroll position etc. +fn rebuild_all_view(notes: &[Note], state: &AppState) { + if let Some(child) = state.stack.child_by_name("all") { + state.stack.remove(&child); + } + let scroll = build_note_list(notes, state.clone()); + state.stack.add_named(&scroll, Some("all")); +} + fn rebuild_stack(state: &AppState) { while let Some(child) = state.stack.first_child() { state.stack.remove(&child); @@ -208,9 +242,9 @@ fn cmd_upcoming_plain() -> Result<()> { && n.effective_time().is_some() }) .collect(); - notes.sort_by_key(|n| n.effective_time().unwrap()); + notes.sort_by_key(|n| n.effective_time().expect("filtered by is_some above")); for note in ¬es { - let t = note.effective_time().unwrap(); + let t = note.effective_time().expect("filtered by is_some above"); let local: chrono::DateTime = t.into(); println!("[{}] {} — {}", note.id, local.format("%a %b %d %H:%M"), note.body); } @@ -272,10 +306,10 @@ fn build_app_window( let new_note_btn = gtk4::Button::builder() .label("✚ New Note") .css_classes(["confirm-button"]) - .margin_start(10) - .margin_end(10) - .margin_top(12) - .margin_bottom(6) + .margin_start(12) + .margin_end(12) + .margin_top(16) + .margin_bottom(12) .build(); sidebar_vbox.append(&new_note_btn); @@ -349,10 +383,10 @@ fn build_app_window( let search_entry = gtk4::SearchEntry::builder() .placeholder_text("Search notes…") .css_classes(["search-entry"]) - .margin_start(8) - .margin_end(8) - .margin_top(8) - .margin_bottom(4) + .margin_start(12) + .margin_end(12) + .margin_top(12) + .margin_bottom(8) .build(); let stack = gtk4::Stack::builder().hexpand(true).vexpand(true).build(); @@ -401,36 +435,8 @@ fn build_app_window( .filter(|n| n.body.to_lowercase().contains(&q)) .collect() }; - - // Replace the "all" page with the filtered list while preserving others - while let Some(child) = state_c.stack.first_child() { - state_c.stack.remove(&child); - } - let all_scroll = build_note_list(&filtered, state_c.clone()); - state_c.stack.add_named(&all_scroll, Some("all")); - - let notes_snap = state_c.notes.borrow().clone(); - let cfg_snap = state_c.cfg.borrow().clone(); - let errors_snap = state_c.errors.borrow().clone(); - - let upcoming = views::upcoming::build(¬es_snap); - state_c.stack.add_named(&upcoming, Some("upcoming")); - for type_name in NoteType::all_builtin() { - let nt = NoteType::from_str(type_name); - let typed: Vec = notes_snap - .iter() - .filter(|n| n.note_type == nt && !n.done) - .cloned() - .collect(); - state_c.stack.add_named(&build_note_list(&typed, state_c.clone()), Some(type_name)); - } - state_c.stack.add_named(&views::archive::build(¬es_snap, state_c.clone()), Some("archive")); - let state_s = state_c.clone(); - state_c.stack.add_named( - &views::settings::build(&cfg_snap, move |nc| { *state_s.cfg.borrow_mut() = nc; }), - Some("settings"), - ); - state_c.stack.add_named(&views::errors::build(&errors_snap), Some("errors")); + // Only replace the "all" page — other views keep their scroll position. + rebuild_all_view(&filtered, &state_c); state_c.stack.set_visible_child_name("all"); }); } @@ -475,9 +481,11 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow { let list = gtk4::Box::builder() .orientation(gtk4::Orientation::Vertical) - .spacing(4) - .margin_top(8) - .margin_bottom(8) + .spacing(8) + .margin_top(12) + .margin_bottom(12) + .margin_start(12) + .margin_end(12) .build(); let mut sorted: Vec = notes.iter().filter(|n| !n.done).cloned().collect(); @@ -503,11 +511,11 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow { fn build_note_card(note: &Note, state: AppState) -> gtk4::Box { let card = gtk4::Box::builder() .orientation(gtk4::Orientation::Vertical) - .spacing(4) - .margin_start(8) - .margin_end(8) - .margin_top(4) - .margin_bottom(4) + .spacing(8) + .margin_start(0) + .margin_end(0) + .margin_top(0) + .margin_bottom(0) .css_classes(["note-card"]) .build(); card.add_css_class(&format!("note-card-{}", note.note_type.as_str())); @@ -590,14 +598,30 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box { let card_c = card.clone(); let state_c = state.clone(); done_btn.connect_clicked(move |_| { - if let Ok(Some(mut n)) = state_c.store.get_by_id(¬e_id) { - n.mark_done(); - if let Err(e) = state_c.store.update_note(&n) { - state_c.log_error(format!("mark done failed: {}", e)); - } - } - card_c.set_visible(false); - state_c.reload_notes(); + card_c.set_visible(false); // optimistic hide + let store = state_c.write_store(); + let id = note_id.clone(); + let state = state_c.clone(); + spawn_bg( + move || -> anyhow::Result> { + if let Some(mut n) = store.get_by_id(&id)? { + n.mark_done(); + store.update_note(&n)?; + } + store.load_all() + }, + move |result| { + match result { + Ok(fresh) => { + *state.notes.borrow_mut() = fresh; + rebuild_stack(&state); + let active = state.active_view.borrow().clone(); + state.stack.set_visible_child_name(&active); + } + Err(e) => state.log_error(format!("mark done failed: {}", e)), + } + }, + ); }); } bottom_row.append(&done_btn); @@ -622,19 +646,29 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box { let body_label_save = body_label_c.clone(); let state_del = state_c.clone(); let card_del = card_c.clone(); + let state_err = state_c.clone(); let popover = editor::build_editor_popover( ¬e_c, store, morning, - move |updated: Note| { + Rc::new(move |updated: Note| { body_label_save.set_label(&updated.body); state_save.reload_notes(); - }, - move || { + rebuild_stack(&state_save); + let active = state_save.active_view.borrow().clone(); + state_save.stack.set_visible_child_name(&active); + }), + Rc::new(move || { card_del.set_visible(false); state_del.reload_notes(); - }, + rebuild_stack(&state_del); + let active = state_del.active_view.borrow().clone(); + state_del.stack.set_visible_child_name(&active); + }), + Rc::new(move |e: String| { + state_err.log_error(e); + }), ); popover.set_parent(btn); popover.popup(); @@ -659,12 +693,30 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box { delete_btn.connect_clicked(move |_| { if *confirming.borrow() { + card_c.set_visible(false); // optimistic hide let store = state_c.write_store(); - if let Err(e) = store.delete_note(¬e_id) { - state_c.log_error(format!("delete failed: {}", e)); - } - card_c.set_visible(false); - state_c.reload_notes(); + let id = note_id.clone(); + let state = state_c.clone(); + spawn_bg( + move || -> anyhow::Result> { + store.delete_note(&id)?; + if let Err(e) = Scheduler::cancel(&id) { + tracing::warn!("failed to cancel timer for {}: {}", id, e); + } + store.load_all() + }, + move |result| { + match result { + Ok(fresh) => { + *state.notes.borrow_mut() = fresh; + rebuild_stack(&state); + let active = state.active_view.borrow().clone(); + state.stack.set_visible_child_name(&active); + } + Err(e) => state.log_error(format!("delete failed: {}", e)), + } + }, + ); } else { *confirming.borrow_mut() = true; btn_c.set_label("Sure?"); @@ -779,108 +831,88 @@ fn show_add_note_window(parent: >k4::ApplicationWindow, state: AppState) { cancel_btn.connect_clicked(move |_| win_c.close()); } - // Add Note - { - let win_c = win.clone(); - let state_c = state.clone(); - let body_c = body_entry.clone(); - let time_c = time_entry.clone(); - let rrule_c = rrule_entry.clone(); - let sel_c = selected_type.clone(); - let status_c = status_label.clone(); + // Shared add-note logic — called by both the button and the Enter key. + let do_add: Rc = Rc::new({ + let win = win.clone(); + let state = state.clone(); + let body_entry = body_entry.clone(); + let time_entry = time_entry.clone(); + let rrule_entry = rrule_entry.clone(); + let selected_type = selected_type.clone(); + let status_label = status_label.clone(); - let do_add = move || { - let body_text = body_c.text().to_string(); + move || { + let body_text = body_entry.text().to_string(); if body_text.trim().is_empty() { - status_c.set_label("Body is required."); + status_label.set_label("Body is required."); return; } - let morning = state_c.cfg.borrow().reminders.default_morning.clone(); - - // Tier 1 classification on body + let morning = state.cfg.borrow().reminders.default_morning.clone(); let parsed = parse_rule_based(&body_text, &morning); - - let user_type = sel_c.borrow().clone(); - let default_type = NoteType::from_str(&state_c.cfg.borrow().settings.default_type); + let user_type = selected_type.borrow().clone(); + let default_type = NoteType::from_str(&state.cfg.borrow().settings.default_type); let mut note = Note::new(parsed.body.clone(), user_type.clone(), None); - // Use parsed type if user left it at the default if user_type == default_type { note.note_type = parsed.note_type; } note.time = parsed.time; note.rrule = parsed.rrule; - // Time field overrides - let time_str = time_c.text().to_string(); + let time_str = time_entry.text().to_string(); if !time_str.trim().is_empty() { let tp = parse_rule_based(&time_str, &morning); if tp.time.is_some() { note.time = tp.time; } if tp.rrule.is_some() { note.rrule = tp.rrule; } } - // RRULE field overrides - let rrule_str = rrule_c.text().to_string(); + let rrule_str = rrule_entry.text().to_string(); if !rrule_str.trim().is_empty() { note.rrule = Some(RecurrenceRule::new(rrule_str)); } - let store = state_c.write_store(); - if let Err(e) = store.save_note(¬e) { - state_c.log_error(format!("save failed: {}", e)); - return; - } - if note.time.is_some() { - if let Err(e) = Scheduler::schedule(¬e) { - state_c.log_error(format!("schedule failed: {}", e)); - } - } + let store = state.write_store(); + win.close(); - win_c.close(); - // Defer refresh so the window close event is processed first - let state_refresh = state_c.clone(); - glib::idle_add_local_once(move || refresh(&state_refresh)); - }; + let state_bg = state.clone(); + spawn_bg( + move || -> anyhow::Result> { + store.save_note(¬e)?; + if note.time.is_some() || note.rrule.is_some() { + if let Err(e) = Scheduler::cancel(¬e.id) { + tracing::warn!("cancel before schedule: {}", e); + } + Scheduler::schedule(¬e)?; + } + store.load_all() + }, + move |result| { + match result { + Ok(fresh) => { + *state_bg.notes.borrow_mut() = fresh; + rebuild_stack(&state_bg); + let active = state_bg.active_view.borrow().clone(); + state_bg.stack.set_visible_child_name(&active); + } + Err(e) => state_bg.log_error(format!("save failed: {}", e)), + } + }, + ); + } + }); + { + let do_add = Rc::clone(&do_add); add_btn.connect_clicked(move |_| do_add()); } - - // Also trigger add on Enter in body field { - let win_c2 = win.clone(); - let state_c2 = state.clone(); - let body_c2 = body_entry.clone(); - let time_c2 = time_entry.clone(); - let rrule_c2 = rrule_entry.clone(); - let sel_c2 = selected_type.clone(); - + let do_add = Rc::clone(&do_add); + let time_entry = time_entry.clone(); + let rrule_entry = rrule_entry.clone(); body_entry.connect_activate(move |_| { - // If time/rrule fields are empty, submit immediately - if time_c2.text().is_empty() && rrule_c2.text().is_empty() { - let body_text = body_c2.text().to_string(); - if body_text.trim().is_empty() { return; } - let morning = state_c2.cfg.borrow().reminders.default_morning.clone(); - let parsed = parse_rule_based(&body_text, &morning); - let user_type = sel_c2.borrow().clone(); - let default_type = NoteType::from_str(&state_c2.cfg.borrow().settings.default_type); - let mut note = Note::new(parsed.body.clone(), user_type.clone(), None); - if user_type == default_type { note.note_type = parsed.note_type; } - note.time = parsed.time; - note.rrule = parsed.rrule; - let store = state_c2.write_store(); - if let Err(e) = store.save_note(¬e) { - state_c2.log_error(format!("save failed: {}", e)); - return; - } - if note.time.is_some() { - if let Err(e) = Scheduler::schedule(¬e) { - state_c2.log_error(format!("schedule failed: {}", e)); - } - } - win_c2.close(); - let sr = state_c2.clone(); - glib::idle_add_local_once(move || refresh(&sr)); + if time_entry.text().is_empty() && rrule_entry.text().is_empty() { + do_add(); } }); } @@ -898,8 +930,12 @@ fn apply_css(_cfg: &Config) { let provider = gtk4::CssProvider::new(); provider.load_from_string(&css); + let Some(display) = gtk4::gdk::Display::default() else { + tracing::warn!("no default display; skipping CSS provider"); + return; + }; gtk4::style_context_add_provider_for_display( - >k4::gdk::Display::default().unwrap(), + &display, &provider, gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, ); diff --git a/breadman/src/views/settings.rs b/breadman/src/views/settings.rs index d110dd4..8cf49c8 100644 --- a/breadman/src/views/settings.rs +++ b/breadman/src/views/settings.rs @@ -79,14 +79,11 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled .build(); attach_row(&model_grid, 1, "Tokenizer path", &tokenizer_entry); - let ep_options = ["auto", "npu", "vulkan", "cpu"]; - let ep_combo = gtk4::DropDown::from_strings(&ep_options); - let ep_idx = ep_options - .iter() - .position(|&s| s == cfg.model.execution_provider.as_str()) - .unwrap_or(0) as u32; - ep_combo.set_selected(ep_idx); - attach_row(&model_grid, 2, "Execution provider", &ep_combo); + let ort_dylib_entry = gtk4::Entry::builder() + .text(&cfg.model.ort_dylib_path) + .hexpand(true) + .build(); + attach_row(&model_grid, 2, "ORT dylib path", &ort_dylib_entry); outer.append(&model_frame); @@ -168,7 +165,7 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled let grs = grace_spin.clone(); let mpe = model_path_entry.clone(); let tke = tokenizer_entry.clone(); - let epc = ep_combo.clone(); + let ode = ort_dylib_entry.clone(); let oec = ollama_enabled.clone(); let oee = ollama_endpoint.clone(); let ome = ollama_model.clone(); @@ -203,11 +200,7 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled model: ModelConfig { path: mpe.text().to_string(), tokenizer: tke.text().to_string(), - execution_provider: ep_options - .get(epc.selected() as usize) - .copied() - .unwrap_or("auto") - .to_string(), + ort_dylib_path: ode.text().to_string(), ollama: OllamaConfig { enabled: oec.is_active(), endpoint: oee.text().to_string(), diff --git a/breadpad-shared/Cargo.toml b/breadpad-shared/Cargo.toml index d9acf83..a4b3ddb 100644 --- a/breadpad-shared/Cargo.toml +++ b/breadpad-shared/Cargo.toml @@ -5,6 +5,7 @@ edition.workspace = true license.workspace = true authors.workspace = true + [dependencies] anyhow.workspace = true tracing.workspace = true diff --git a/breadpad-shared/src/ai.rs b/breadpad-shared/src/ai.rs index 39d16de..7bb69ad 100644 --- a/breadpad-shared/src/ai.rs +++ b/breadpad-shared/src/ai.rs @@ -70,10 +70,9 @@ impl OllamaClient { .into_json() .map_err(|e| anyhow::anyhow!("deserialize Ollama envelope: {}", e))?; - let classification: OllamaClassification = serde_json::from_str(&ollama_resp.response) - .map_err(|e| anyhow::anyhow!( - "parse Ollama classification JSON: {} — raw: {:?}", - e, + let classification: OllamaClassification = extract_json(&ollama_resp.response) + .ok_or_else(|| anyhow::anyhow!( + "no JSON object found in response — raw: {:?}", &ollama_resp.response ))?; @@ -116,3 +115,12 @@ impl OllamaClient { }) } } + +// Some backends (e.g. FastFlowLM) ignore `"format": "json"` and may wrap the +// JSON in prose. Find the first `{...}` span and parse that. +fn extract_json(s: &str) -> Option { + let start = s.find('{')?; + let end = s.rfind('}')?; + if end < start { return None; } + serde_json::from_str(&s[start..=end]).ok() +} diff --git a/breadpad-shared/src/calendar.rs b/breadpad-shared/src/calendar.rs index 56f9cd1..479ad05 100644 --- a/breadpad-shared/src/calendar.rs +++ b/breadpad-shared/src/calendar.rs @@ -16,9 +16,14 @@ pub struct CalDavEventInfo { impl CalDavClient { pub fn new(config: CalendarConfig) -> Self { + // `reqwest::Client::builder().build()` can only fail if the TLS backend can't be + // initialised; fall back to `Client::new()` semantics rather than panicking. let client = reqwest::Client::builder() .build() - .expect("failed to build HTTP client"); + .unwrap_or_else(|e| { + tracing::warn!("falling back to default HTTP client: {}", e); + reqwest::Client::new() + }); CalDavClient { config, client } } @@ -139,28 +144,56 @@ fn event_url(base: &str, uid: &str) -> String { fn build_ical(note: &Note, uid: &str) -> String { let dt = note.time.unwrap_or(note.created); let dtstart = dt.format("%Y%m%dT%H%M%SZ").to_string(); - let summary = escape_ical(¬e.body); - let description = escape_ical(&format!("type={}", note.note_type.as_str())); + let dtstamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string(); - let mut ical = format!( - "BEGIN:VCALENDAR\r\n\ - VERSION:2.0\r\n\ - PRODID:-//breadpad//EN\r\n\ - BEGIN:VEVENT\r\n\ - UID:{uid}\r\n\ - SUMMARY:{summary}\r\n\ - DTSTART:{dtstart}\r\n\ - DTEND:{dtstart}\r\n\ - DESCRIPTION:{description}\r\n" - ); + let mut lines: Vec = vec![ + "BEGIN:VCALENDAR".into(), + "VERSION:2.0".into(), + "PRODID:-//breadpad//EN".into(), + "BEGIN:VEVENT".into(), + format!("UID:{}", uid), + fold_line(&format!("SUMMARY:{}", escape_ical(¬e.body))), + format!("DTSTART:{}", dtstart), + format!("DTEND:{}", dtstart), + format!("DTSTAMP:{}", dtstamp), + fold_line(&format!("DESCRIPTION:{}", escape_ical(&format!("type={}", note.note_type.as_str())))), + ]; if let Some(rrule) = ¬e.rrule { - ical.push_str(rrule.as_str()); - ical.push_str("\r\n"); + lines.push(rrule.as_str().to_string()); } - ical.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n"); - ical + lines.push("END:VEVENT".into()); + lines.push("END:VCALENDAR".into()); + + lines.join("\r\n") + "\r\n" +} + +/// Fold an iCal property line per RFC 5545 §3.1: lines longer than 75 octets +/// are split with CRLF + a single space continuation character. +fn fold_line(line: &str) -> String { + let bytes = line.as_bytes(); + if bytes.len() <= 75 { + return line.to_string(); + } + let mut out = String::with_capacity(line.len() + line.len() / 75 * 3); + let mut pos = 0; + let mut first = true; + while pos < bytes.len() { + if !first { + out.push_str("\r\n "); + } + let limit = if first { 75 } else { 74 }; // continuation lines lose 1 octet to the space + let mut end = (pos + limit).min(bytes.len()); + // Step back if we landed in the middle of a multi-byte UTF-8 sequence. + while end > pos && end < bytes.len() && (bytes[end] & 0xC0) == 0x80 { + end -= 1; + } + out.push_str(std::str::from_utf8(&bytes[pos..end]).unwrap_or("")); + pos = end; + first = false; + } + out } fn escape_ical(s: &str) -> String { @@ -219,3 +252,241 @@ fn parse_ical_block(data: &str) -> Vec { out } + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{Note, NoteType, RecurrenceRule}; + use chrono::{TimeZone, Utc}; + + fn reminder(body: &str) -> Note { + let mut n = Note::new(body.into(), NoteType::Reminder, None); + n.time = Some(Utc::now()); + n + } + + // ---- escape_ical ---- + + #[test] + fn escape_ical_clean_string_unchanged() { + assert_eq!(escape_ical("hello world"), "hello world"); + } + + #[test] + fn escape_ical_empty_string() { + assert_eq!(escape_ical(""), ""); + } + + #[test] + fn escape_ical_escapes_backslash() { + assert_eq!(escape_ical("back\\slash"), "back\\\\slash"); + } + + #[test] + fn escape_ical_escapes_semicolon() { + assert_eq!(escape_ical("a;b"), "a\\;b"); + } + + #[test] + fn escape_ical_escapes_comma() { + assert_eq!(escape_ical("apples,oranges"), "apples\\,oranges"); + } + + #[test] + fn escape_ical_escapes_newline() { + assert_eq!(escape_ical("line1\nline2"), "line1\\nline2"); + } + + #[test] + fn escape_ical_multiple_special_chars() { + assert_eq!(escape_ical("a;b,c\nd"), "a\\;b\\,c\\nd"); + } + + // ---- caldav_uid ---- + + #[test] + fn caldav_uid_uses_existing_field() { + let mut n = Note::new("test".into(), NoteType::Reminder, None); + n.caldav_uid = Some("my-custom-uid".into()); + assert_eq!(caldav_uid(&n), "my-custom-uid"); + } + + #[test] + fn caldav_uid_falls_back_to_id_at_breadpad() { + let n = Note::new("test".into(), NoteType::Reminder, None); + assert_eq!(caldav_uid(&n), format!("{}@breadpad", n.id)); + } + + // ---- event_url ---- + + #[test] + fn event_url_with_trailing_slash() { + let url = event_url("https://cloud.example.com/cal/", "abc@breadpad"); + assert_eq!(url, "https://cloud.example.com/cal/abc@breadpad.ics"); + } + + #[test] + fn event_url_without_trailing_slash() { + let url = event_url("https://cloud.example.com/cal", "abc@breadpad"); + assert_eq!(url, "https://cloud.example.com/cal/abc@breadpad.ics"); + } + + // ---- build_ical ---- + + #[test] + fn build_ical_contains_vcalendar_markers() { + let n = reminder("team sync"); + let uid = caldav_uid(&n); + let ical = build_ical(&n, &uid); + assert!(ical.contains("BEGIN:VCALENDAR"), "missing BEGIN:VCALENDAR"); + assert!(ical.contains("END:VCALENDAR"), "missing END:VCALENDAR"); + assert!(ical.contains("BEGIN:VEVENT"), "missing BEGIN:VEVENT"); + assert!(ical.contains("END:VEVENT"), "missing END:VEVENT"); + } + + #[test] + fn build_ical_contains_uid() { + let n = reminder("team sync"); + let uid = caldav_uid(&n); + let ical = build_ical(&n, &uid); + assert!(ical.contains(&format!("UID:{}", uid))); + } + + #[test] + fn build_ical_contains_summary() { + let n = reminder("team sync"); + let uid = caldav_uid(&n); + let ical = build_ical(&n, &uid); + assert!(ical.contains("SUMMARY:team sync")); + } + + #[test] + fn build_ical_description_contains_type() { + let n = reminder("team sync"); + let uid = caldav_uid(&n); + let ical = build_ical(&n, &uid); + assert!(ical.contains("DESCRIPTION:type=reminder")); + } + + #[test] + fn build_ical_uses_note_time_for_dtstart() { + let mut n = Note::new("dentist".into(), NoteType::Reminder, None); + n.time = Some(Utc.with_ymd_and_hms(2026, 6, 15, 14, 30, 0).unwrap()); + let uid = caldav_uid(&n); + let ical = build_ical(&n, &uid); + assert!(ical.contains("DTSTART:20260615T143000Z"), "ical: {}", &ical[..400]); + } + + #[test] + fn build_ical_falls_back_to_created_when_no_time() { + let n = Note::new("no time set".into(), NoteType::Reminder, None); + let uid = caldav_uid(&n); + let ical = build_ical(&n, &uid); + assert!(ical.contains("DTSTART:"), "DTSTART should be present"); + } + + #[test] + fn build_ical_includes_rrule_when_set() { + let mut n = reminder("standup"); + n.rrule = Some(RecurrenceRule::new("RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0")); + let uid = caldav_uid(&n); + let ical = build_ical(&n, &uid); + assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=MO")); + } + + #[test] + fn build_ical_no_rrule_when_not_set() { + let n = reminder("one-off"); + let uid = caldav_uid(&n); + let ical = build_ical(&n, &uid); + assert!(!ical.contains("RRULE:")); + } + + #[test] + fn build_ical_escapes_special_chars_in_summary() { + let n = Note::new("dentist; bring card, and ID".into(), NoteType::Reminder, None); + let uid = caldav_uid(&n); + let ical = build_ical(&n, &uid); + assert!(ical.contains("SUMMARY:dentist\\; bring card\\, and ID"), "ical: {}", &ical[..400]); + } + + #[test] + fn build_ical_contains_dtstamp() { + let n = reminder("team sync"); + let uid = caldav_uid(&n); + let ical = build_ical(&n, &uid); + assert!(ical.contains("DTSTAMP:"), "missing DTSTAMP in:\n{}", ical); + } + + #[test] + fn fold_line_short_unchanged() { + let line = "SUMMARY:short"; + assert_eq!(fold_line(line), line); + } + + #[test] + fn fold_line_exactly_75_unchanged() { + let line = "A".repeat(75); + assert_eq!(fold_line(&line), line); + } + + #[test] + fn fold_line_76_chars_splits() { + let line = "X".repeat(76); + let folded = fold_line(&line); + assert!(folded.contains("\r\n "), "expected fold in: {:?}", folded); + // Reassembled content should equal the original. + let rejoined: String = folded.split("\r\n ").collect(); + assert_eq!(rejoined, line); + } + + #[test] + fn build_ical_long_summary_is_folded() { + let long_body = "a".repeat(200); + let n = Note::new(long_body.clone(), NoteType::Reminder, None); + let uid = caldav_uid(&n); + let ical = build_ical(&n, &uid); + // Every line (split on CRLF) must be at most 75 octets. + for line in ical.split("\r\n") { + assert!( + line.len() <= 75, + "line too long ({} octets): {:?}", + line.len(), + line + ); + } + } + + // ---- parse_report_response ---- + + #[test] + fn parse_report_response_empty_xml_returns_empty() { + let events = parse_report_response("").unwrap(); + assert!(events.is_empty()); + } + + #[test] + fn parse_report_response_single_event() { + let xml = "\ +BEGIN:VCALENDAR\r\n\ +VERSION:2.0\r\n\ +BEGIN:VEVENT\r\n\ +UID:abc123@breadpad\r\n\ +SUMMARY:team sync\r\n\ +DTSTART:20260615T140000Z\r\n\ +DTEND:20260615T140000Z\r\n\ +END:VEVENT\r\n\ +END:VCALENDAR\r\n"; + let events = parse_report_response(xml).unwrap(); + assert_eq!(events.len(), 1); + assert_eq!(events[0].uid, "abc123@breadpad"); + assert_eq!(events[0].summary, "team sync"); + } + + #[test] + fn parse_report_response_no_vcalendar_block_returns_empty() { + let xml = "HTTP/1.1 200 OK"; + let events = parse_report_response(xml).unwrap(); + assert!(events.is_empty()); + } +} diff --git a/breadpad-shared/src/classifier.rs b/breadpad-shared/src/classifier.rs index adf1369..fef87e0 100644 --- a/breadpad-shared/src/classifier.rs +++ b/breadpad-shared/src/classifier.rs @@ -9,16 +9,14 @@ const TIER1_SKIP_THRESHOLD: f32 = 0.82; #[derive(Debug, Clone, PartialEq)] pub enum ExecutionProvider { - Qnn, - Vulkan, + Gpu, Cpu, } impl ExecutionProvider { pub fn as_str(&self) -> &str { match self { - ExecutionProvider::Qnn => "QNN (NPU)", - ExecutionProvider::Vulkan => "Vulkan", + ExecutionProvider::Gpu => "ROCm (iGPU)", ExecutionProvider::Cpu => "CPU", } } @@ -43,20 +41,27 @@ fn model_dir() -> PathBuf { impl Classifier { /// Load with Tier 1 + optional Tier 2 (ONNX). Tier 3 disabled unless /// `.with_ollama()` is called on the returned value. - pub fn load(ep_pref: &str, default_morning: &str) -> Self { + pub fn load(default_morning: &str) -> Self { let dir = model_dir(); let onnx_path = dir.join("classifier.onnx"); let tok_path = dir.join("tokenizer.json"); + Self::load_with_paths(default_morning, onnx_path, tok_path) + } - let (session, active_provider) = if onnx_path.exists() { - try_load_session(&onnx_path, ep_pref) + pub fn load_with_paths( + default_morning: &str, + model_path: PathBuf, + tokenizer_path: PathBuf, + ) -> Self { + let (session, active_provider) = if model_path.exists() { + try_load_session(&model_path) } else { - tracing::warn!("model not found at {:?}; Tier 2 disabled", onnx_path); + tracing::warn!("model not found at {:?}; Tier 2 disabled", model_path); (None, ExecutionProvider::Cpu) }; - let tokenizer = if tok_path.exists() && session.is_some() { - match tokenizers::Tokenizer::from_file(&tok_path) { + let tokenizer = if tokenizer_path.exists() && session.is_some() { + match tokenizers::Tokenizer::from_file(&tokenizer_path) { Ok(tok) => Some(tok), Err(e) => { tracing::warn!("failed to load tokenizer: {}", e); @@ -71,7 +76,7 @@ impl Classifier { session, tokenizer, active_provider, - model_path: onnx_path, + model_path, default_morning: default_morning.to_string(), ollama: None, } @@ -144,6 +149,13 @@ impl Classifier { pub fn model_available(&self) -> bool { self.session.is_some() } + + /// Run only the ONNX model (Tier 2) with no Tier 1 pre-processing or fallback. + /// Returns `None` if no model is loaded. + pub fn classify_tier2_only(&mut self, text: &str) -> Option { + let (session, tokenizer) = (self.session.as_mut()?, self.tokenizer.as_ref()?); + run_onnx(session, tokenizer, text).ok() + } } // NLI hypotheses paired with their note types. The model scores each as @@ -204,7 +216,7 @@ fn run_onnx( let best_idx = entailment_scores .iter() .enumerate() - .max_by(|a, b| a.1.partial_cmp(b.1).unwrap()) + .max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Less)) .map(|(i, _)| i) .unwrap_or(3); @@ -233,52 +245,34 @@ fn softmax_single(logits: &[f32], idx: usize) -> f32 { fn try_load_session( path: &std::path::Path, - ep_pref: &str, ) -> (Option, ExecutionProvider) { - let providers: &[(&str, ExecutionProvider)] = &[ - ("qnn", ExecutionProvider::Qnn), - ("vulkan", ExecutionProvider::Vulkan), - ("cpu", ExecutionProvider::Cpu), - ]; - - let to_try: Vec<&(&str, ExecutionProvider)> = match ep_pref { - "npu" => providers[..1].iter().collect(), - "vulkan" => providers[1..2].iter().collect(), - "cpu" => providers[2..].iter().collect(), - _ => providers.iter().collect(), - }; - - for (ep_name, ep) in to_try { - match build_session(path, ep_name) { - Ok(session) => { - tracing::info!("ONNX session loaded with {} EP", ep.as_str()); - return (Some(session), ep.clone()); - } - Err(e) => { - tracing::debug!("{} EP unavailable: {}", ep_name, e); - } + // Try ROCm (iGPU) first, fall back to CPU. + match build_onnx_session(path, ort::ep::ROCm::default().build()) { + Ok(s) => { + tracing::info!("ONNX session loaded (ROCm iGPU)"); + return (Some(s), ExecutionProvider::Gpu); + } + Err(e) => tracing::debug!("ROCm EP unavailable: {}; trying CPU", e), + } + match build_onnx_session(path, ort::ep::CPU::default().build()) { + Ok(s) => { + tracing::info!("ONNX session loaded (CPU)"); + (Some(s), ExecutionProvider::Cpu) + } + Err(e) => { + tracing::warn!("failed to load ONNX session: {}; Tier 2 disabled", e); + (None, ExecutionProvider::Cpu) } } - - (None, ExecutionProvider::Cpu) } -fn build_session( +fn build_onnx_session( path: &std::path::Path, - ep_name: &str, + ep: ort::ep::ExecutionProviderDispatch, ) -> anyhow::Result { - match ep_name { - "cpu" => { - let builder = ort::session::Session::builder() - .map_err(|e| anyhow::anyhow!("builder: {}", e))?; - let mut builder = builder - .with_execution_providers([ort::ep::CPU::default().build()]) - .map_err(|e| anyhow::anyhow!("ep: {}", e))?; - let session = builder - .commit_from_file(path) - .map_err(|e| anyhow::anyhow!("load: {}", e))?; - Ok(session) - } - _ => Err(anyhow::anyhow!("EP '{}' not available in this build", ep_name)), - } + let mut builder = ort::session::Session::builder() + .map_err(|e| anyhow::anyhow!("builder: {}", e))? + .with_execution_providers([ep]) + .map_err(|e| anyhow::anyhow!("ep: {}", e))?; + builder.commit_from_file(path).map_err(|e| anyhow::anyhow!("load: {}", e)) } diff --git a/breadpad-shared/src/config.rs b/breadpad-shared/src/config.rs index f65b392..9d81e5e 100644 --- a/breadpad-shared/src/config.rs +++ b/breadpad-shared/src/config.rs @@ -11,15 +11,29 @@ fn default_snooze_options() -> Vec { fn default_archive_after_days() -> i64 { 30 } fn default_model_path() -> String { "~/.local/share/breadpad/model/classifier.onnx".into() } fn default_tokenizer_path() -> String { "~/.local/share/breadpad/model/tokenizer.json".into() } -fn default_execution_provider() -> String { "auto".into() } +fn default_ort_dylib_path() -> String { "".into() } fn default_morning_time() -> String { "08:00".into() } fn default_missed_grace_minutes() -> i64 { 60 } fn default_ollama_endpoint() -> String { "http://localhost:11434".into() } -fn default_ollama_model() -> String { "llama3.2:3b".into() } +fn default_ollama_model() -> String { "fastflowlm".into() } fn default_ollama_confidence_threshold() -> f32 { 0.6 } fn default_ollama_enabled() -> bool { true } fn default_calendar_enabled() -> bool { false } +pub fn expand_path(path: &str) -> PathBuf { + if path == "~" { + if let Some(home) = dirs::home_dir() { + return home; + } + } + if let Some(stripped) = path.strip_prefix("~/") { + if let Some(home) = dirs::home_dir() { + return home.join(stripped); + } + } + PathBuf::from(path) +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Settings { #[serde(default = "default_type_str")] @@ -72,8 +86,9 @@ pub struct ModelConfig { pub path: String, #[serde(default = "default_tokenizer_path")] pub tokenizer: String, - #[serde(default = "default_execution_provider")] - pub execution_provider: String, + /// Path to `libonnxruntime.so`. Auto-discovered when empty. + #[serde(default = "default_ort_dylib_path")] + pub ort_dylib_path: String, #[serde(default)] pub ollama: OllamaConfig, } @@ -83,12 +98,26 @@ impl Default for ModelConfig { ModelConfig { path: default_model_path(), tokenizer: default_tokenizer_path(), - execution_provider: default_execution_provider(), + ort_dylib_path: default_ort_dylib_path(), ollama: OllamaConfig::default(), } } } +impl ModelConfig { + pub fn resolved_paths(&self) -> (PathBuf, PathBuf) { + (expand_path(&self.path), expand_path(&self.tokenizer)) + } + + pub fn resolved_ort_dylib_path(&self) -> Option { + let raw = self.ort_dylib_path.trim(); + if raw.is_empty() { + return None; + } + Some(expand_path(raw)) + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RemindersConfig { #[serde(default = "default_morning_time")] @@ -114,6 +143,9 @@ pub struct CalendarConfig { pub url: String, #[serde(default)] pub username: String, + /// WARNING: stored as plaintext in breadpad.toml. Restrict the file's permissions + /// (`chmod 600 ~/.config/breadpad/breadpad.toml`) and keep it out of version control. + /// A future release may support reading the password from the OS secret service instead. #[serde(default)] pub password: String, } diff --git a/breadpad-shared/src/lib.rs b/breadpad-shared/src/lib.rs index 1fa87d5..8902223 100644 --- a/breadpad-shared/src/lib.rs +++ b/breadpad-shared/src/lib.rs @@ -7,3 +7,4 @@ pub mod scheduler; pub mod store; pub mod theme; pub mod types; +pub mod util; diff --git a/breadpad-shared/src/parser.rs b/breadpad-shared/src/parser.rs index c298429..cc03b5e 100644 --- a/breadpad-shared/src/parser.rs +++ b/breadpad-shared/src/parser.rs @@ -1,4 +1,5 @@ use crate::types::{ClassificationResult, NoteType, RecurrenceRule}; +use crate::util::local_naive_to_utc; use chrono::{DateTime, Datelike, Duration, Local, NaiveTime, Timelike, Utc, Weekday}; use regex::Regex; use std::sync::OnceLock; @@ -22,7 +23,7 @@ static PATTERNS: OnceLock = OnceLock::new(); fn patterns() -> &'static Patterns { PATTERNS.get_or_init(|| Patterns { at_time: Regex::new(r"(?i)\bat\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?").unwrap(), - in_duration: Regex::new(r"(?i)\bin\s+(\d+)\s+(minute|hour|day)s?").unwrap(), + in_duration: Regex::new(r"(?i)\bin\s+(\d+)\s+(second|minute|hour|day|week)s?").unwrap(), // Word-form durations: "in an hour", "in a couple of hours", "in half an hour" in_duration_word: Regex::new( r"(?i)\bin\s+(?:an?\s+hour|a\s+couple\s+of\s+hours?|a\s+few\s+hours?|half\s+an?\s+hour|an?\s+minutes?|a\s+couple\s+of\s+minutes?)" @@ -100,7 +101,7 @@ fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime { }; let target_date = local.date_naive() + Duration::days(days_ahead); let naive = target_date.and_time(time); - naive.and_local_timezone(Local).unwrap().with_timezone(&Utc) + local_naive_to_utc(naive) } pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResult { @@ -209,7 +210,7 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu } else { (local.date_naive() + Duration::days(1)).and_time(t) }; - extracted_time = Some(naive.and_local_timezone(Local).unwrap().with_timezone(&Utc)); + extracted_time = Some(local_naive_to_utc(naive)); let full_match = caps.get(0).unwrap().as_str(); cleaned = cleaned.replacen(full_match, "", 1).trim().to_string(); } @@ -218,9 +219,11 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu let n: i64 = caps.get(1).unwrap().as_str().parse().unwrap_or(1); let unit = caps.get(2).unwrap().as_str().to_lowercase(); let delta = match unit.as_str() { + "second" => Duration::seconds(n), "minute" => Duration::minutes(n), "hour" => Duration::hours(n), "day" => Duration::days(n), + "week" => Duration::weeks(n), _ => Duration::minutes(n), }; extracted_time = Some(Utc::now() + delta); @@ -254,7 +257,7 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu }; let local = Local::now(); let target = (local.date_naive() + Duration::days(1)).and_time(t); - extracted_time = Some(target.and_local_timezone(Local).unwrap().with_timezone(&Utc)); + extracted_time = Some(local_naive_to_utc(target)); cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string(); } // One-off: next @@ -273,7 +276,7 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu } else { (local.date_naive() + Duration::days(1)).and_time(anchor) }; - extracted_time = Some(target.and_local_timezone(Local).unwrap().with_timezone(&Utc)); + extracted_time = Some(local_naive_to_utc(target)); cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string(); } } @@ -860,6 +863,23 @@ fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType { || lower.starts_with("finish ") || lower.starts_with("write ") || lower.starts_with("update ") + || lower.starts_with("prepare ") + || lower.starts_with("schedule ") + || lower.starts_with("organize ") + || lower.starts_with("deploy ") + || lower.starts_with("install ") + || lower.starts_with("send ") + || lower.starts_with("submit ") + || lower.starts_with("create ") + || lower.starts_with("setup ") + || lower.starts_with("restore ") + || lower.starts_with("archive ") + || lower.starts_with("export ") + || lower.starts_with("import ") + || lower.starts_with("approve ") + || lower.starts_with("configure ") + || lower.starts_with("refactor ") + || lower.starts_with("review ") { return NoteType::Todo; } @@ -867,13 +887,21 @@ fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType { || lower.starts_with("idea:") || lower.contains("could ") || lower.contains("maybe ") - || lower.contains("should we ") + || lower.starts_with("should we ") { return NoteType::Idea; } if lower.starts_with("why ") || lower.starts_with("how ") - || lower.starts_with("what ") + || (lower.starts_with("what ") && !lower.starts_with("what if ")) + || lower.starts_with("when ") + || lower.starts_with("where ") + || lower.starts_with("who ") + || lower.starts_with("will ") + || lower.starts_with("is ") + || lower.starts_with("are ") + || lower.starts_with("did ") + || lower.starts_with("does ") || lower.ends_with('?') { return NoteType::Question; diff --git a/breadpad-shared/src/scheduler.rs b/breadpad-shared/src/scheduler.rs index e5da12a..ff5ff26 100644 --- a/breadpad-shared/src/scheduler.rs +++ b/breadpad-shared/src/scheduler.rs @@ -1,4 +1,5 @@ use crate::types::Note; +use crate::util::local_naive_to_utc; use anyhow::{Context, Result}; use chrono::{DateTime, Duration, Local, NaiveTime, Utc}; use std::process::Command; @@ -59,27 +60,63 @@ fn create_timer(id: &str, fire_time: DateTime) -> Result<()> { let timer_name = timer_unit_name(id); + // Find the breadpad binary. Order of preference: + // 1. $BREADPAD_BIN override, + // 2. a `breadpad` next to the currently running executable, + // 3. standard install locations. + let breadpad_exe = std::env::var_os("BREADPAD_BIN") + .map(std::path::PathBuf::from) + .filter(|p| p.exists()) + .or_else(|| { + std::env::current_exe() + .ok() + .and_then(|exe| exe.parent().map(|p| p.join("breadpad"))) + .filter(|p| p.exists()) + }) + .or_else(|| { + let home_bin = dirs::home_dir().map(|h| h.join(".local/bin/breadpad")); + ["/usr/local/bin/breadpad", "/usr/bin/breadpad"] + .iter() + .map(std::path::PathBuf::from) + .chain(home_bin) + .find(|p| p.exists()) + }) + .context("breadpad binary not found in $BREADPAD_BIN, alongside this executable, or in standard locations")?; + // Use systemd-run to create both service + timer as a transient unit - let status = Command::new("systemd-run") - .arg("--user") + // Pass necessary environment variables for notifications to work + let mut cmd = Command::new("systemd-run"); + cmd.arg("--user") .arg("--unit") - .arg(&timer_name.strip_suffix(".timer").unwrap_or(&timer_name)) + .arg(timer_name.strip_suffix(".timer").unwrap_or(&timer_name)) .arg("--timer-property") .arg(format!("OnCalendar={}", on_calendar)) .arg("--timer-property") - .arg("Persistent=true") - .arg("--") - .arg("breadpad") + .arg("Persistent=true"); + + // Pass DBUS and display environment variables so notify-send works + if let Ok(dbus) = std::env::var("DBUS_SESSION_BUS_ADDRESS") { + cmd.arg("--setenv").arg(format!("DBUS_SESSION_BUS_ADDRESS={}", dbus)); + } + if let Ok(display) = std::env::var("DISPLAY") { + cmd.arg("--setenv").arg(format!("DISPLAY={}", display)); + } + if let Ok(wayland) = std::env::var("WAYLAND_DISPLAY") { + cmd.arg("--setenv").arg(format!("WAYLAND_DISPLAY={}", wayland)); + } + + cmd.arg("--") + .arg(&breadpad_exe) .arg("fire") - .arg(id) - .status() - .context("failed to run systemd-run")?; + .arg(id); + + let status = cmd.status().context("failed to run systemd-run")?; if !status.success() { anyhow::bail!("systemd-run failed for reminder {}", id); } - tracing::info!("scheduled reminder {} at {}", id, on_calendar); + tracing::info!("scheduled reminder {} at {} using {}", id, on_calendar, breadpad_exe.display()); Ok(()) } @@ -131,17 +168,15 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O let now = Local::now(); let fire_time = NaiveTime::from_hms_opt(hour, minute, 0)?; - let next = match freq { + match freq { "DAILY" => { let today = now.date_naive().and_time(fire_time); - if now.naive_local() < today { - today.and_local_timezone(Local).unwrap() + let naive = if now.naive_local() < today { + today } else { - (now.date_naive() + chrono::Duration::days(1)) - .and_time(fire_time) - .and_local_timezone(Local) - .unwrap() - } + (now.date_naive() + chrono::Duration::days(1)).and_time(fire_time) + }; + return Some(local_naive_to_utc(naive)); } "WEEKLY" => { use chrono::Datelike; @@ -169,12 +204,10 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O }; let target_date = (now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time); - target_date.and_local_timezone(Local).unwrap() + return Some(local_naive_to_utc(target_date)); } - _ => return None, - }; - - Some(next.with_timezone(&Utc)) + _ => None, + } } #[cfg(test)] @@ -331,6 +364,21 @@ mod tests { assert_eq!(local.weekday(), chrono::Weekday::Sat); } + #[test] + fn weekly_tuesday_is_tuesday() { + let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=TU;BYHOUR=10;BYMINUTE=0;BYSECOND=0", "08:00").unwrap(); + let local: chrono::DateTime = t.into(); + assert_eq!(local.weekday(), chrono::Weekday::Tue); + } + + #[test] + fn weekly_thursday_is_thursday() { + let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=TH;BYHOUR=11;BYMINUTE=30;BYSECOND=0", "08:00").unwrap(); + let local: chrono::DateTime = t.into(); + assert_eq!(local.weekday(), chrono::Weekday::Thu); + assert_eq!(local.minute(), 30); + } + #[test] fn weekly_sunday_is_sunday() { let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=SU;BYHOUR=19;BYMINUTE=0;BYSECOND=0", "08:00").unwrap(); @@ -338,6 +386,22 @@ mod tests { assert_eq!(local.weekday(), chrono::Weekday::Sun); } + #[test] + fn weekly_unknown_byday_falls_back_to_sunday() { + // The match arm `_ => Weekday::Sun` handles unrecognised BYDAY values + let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=XX;BYHOUR=9;BYMINUTE=0;BYSECOND=0", "08:00").unwrap(); + let local: chrono::DateTime = t.into(); + assert_eq!(local.weekday(), chrono::Weekday::Sun); + } + + #[test] + fn daily_without_byhour_uses_default_morning() { + let t = parse_next_from_rrule("RRULE:FREQ=DAILY", "06:45").unwrap(); + let local: chrono::DateTime = t.into(); + assert_eq!(local.hour(), 6); + assert_eq!(local.minute(), 45); + } + #[test] fn unknown_freq_returns_none() { assert!(parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none()); diff --git a/breadpad-shared/src/store.rs b/breadpad-shared/src/store.rs index 1d2566f..bc8b86a 100644 --- a/breadpad-shared/src/store.rs +++ b/breadpad-shared/src/store.rs @@ -36,6 +36,14 @@ impl Store { self } + pub fn with_calendar_if_enabled(self, cfg: &crate::config::Config) -> Self { + if cfg.calendar.enabled { + self.with_calendar(cfg.calendar.clone()) + } else { + self + } + } + pub fn load_all(&self) -> Result> { self.load_from(&self.notes_path) } @@ -84,12 +92,14 @@ impl Store { pub fn update_note(&self, updated: &Note) -> Result<()> { self.rewrite_notes(|note| { - if note.id == updated.id { - updated.clone() - } else { - note + if note.id == updated.id { updated.clone() } else { note } + })?; + if let Some(cal_cfg) = &self.calendar { + if cal_cfg.enabled && (updated.time.is_some() || updated.rrule.is_some()) { + spawn_caldav_push(updated.clone(), cal_cfg.clone()); } - }) + } + Ok(()) } pub fn delete_note(&self, id: &str) -> Result<()> { diff --git a/breadpad-shared/src/theme.rs b/breadpad-shared/src/theme.rs index 7d93231..64192d9 100644 --- a/breadpad-shared/src/theme.rs +++ b/breadpad-shared/src/theme.rs @@ -91,19 +91,23 @@ pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String { @define-color teal {c6}; @define-color overlay {c0}; +* {{ + font-family: 'Varela Round', sans-serif; +}} + window {{ background-color: @bg; color: @fg; - border-radius: 12px; + border-radius: 8px; }} .popup-entry {{ background: @bg; color: @fg; border: 2px solid @blue; - border-radius: 8px; + border-radius: 6px; padding: 12px 16px; - font-size: 16px; + font-size: 14px; caret-color: @fg; }} @@ -116,9 +120,9 @@ window {{ background: @overlay; color: @fg; border-radius: 999px; - padding: 2px 10px; + padding: 4px 12px; font-size: 12px; - margin: 2px; + margin: 4px; }} .type-chip.active {{ @@ -127,7 +131,7 @@ window {{ }} .confirm-button {{ - background: @green; + background: @blue; color: @bg; border: none; border-radius: 8px; @@ -139,7 +143,7 @@ window {{ background: shade(@bg, 1.1); border-radius: 8px; padding: 12px; - margin: 4px 8px; + margin: 8px; border-left: 3px solid @blue; }} @@ -151,9 +155,13 @@ window {{ background: shade(@bg, 1.1); color: @fg; border: 1px solid @overlay; - border-radius: 8px; + border-radius: 6px; padding: 8px 12px; - margin: 8px; +}} + +.search-entry:focus {{ + border-color: @blue; + outline: none; }} "#, bg = palette.background, @@ -178,25 +186,27 @@ window {{ } .sidebar-row { - padding: 6px 12px; + padding: 8px 12px; font-size: 14px; + transition: background 100ms ease; } .sidebar-row:hover:not(:selected) { - background: shade(@bg, 1.08); + background: shade(@bg, 1.1); } .sidebar-row:selected { background: @blue; color: @bg; + font-weight: 500; } .sidebar-section-label { - color: alpha(@fg, 0.4); - font-size: 10px; - font-weight: bold; - padding: 10px 14px 2px 14px; - letter-spacing: 1px; + color: alpha(@fg, 0.5); + font-size: 11px; + font-weight: 600; + padding: 12px 12px 8px 12px; + letter-spacing: 0.5px; } .action-btn { @@ -228,6 +238,62 @@ window {{ .note-card-question { border-left-color: @teal; } .note-card-note { border-left-color: @blue; } +.reminder-window { + background: @bg; + border: 1px solid @overlay; + border-radius: 8px; +} + +.reminder-emoji { font-size: 20px; } + +.reminder-title { + font-size: 12px; + font-weight: bold; + color: alpha(@fg, 0.6); + letter-spacing: 0.5px; +} + +.reminder-time { + font-size: 12px; + color: alpha(@fg, 0.5); +} + +.reminder-body { + font-size: 18px; + font-weight: bold; + color: @fg; +} + +.reminder-dismiss { + background: transparent; + border: 1px solid @overlay; + border-radius: 8px; + padding: 8px 16px; + color: alpha(@fg, 0.6); +} + +.reminder-dismiss:hover { background: shade(@bg, 1.1); } + +.reminder-snooze { + background: transparent; + border: 1px solid @overlay; + border-radius: 8px; + padding: 8px 16px; + color: @fg; +} + +.reminder-snooze:hover { background: shade(@bg, 1.1); } + +.snooze-option { + background: transparent; + border: none; + border-radius: 6px; + padding: 8px 12px; + color: @fg; +} + +.snooze-option:hover { background: shade(@bg, 1.2); } + entry { background: shade(@bg, 1.1); color: @fg; diff --git a/breadpad-shared/src/types.rs b/breadpad-shared/src/types.rs index 3522aa3..5be0b54 100644 --- a/breadpad-shared/src/types.rs +++ b/breadpad-shared/src/types.rs @@ -72,7 +72,9 @@ pub struct Note { pub done: bool, pub workspace: Option, pub created: DateTime, + #[serde(default)] pub snoozed_until: Option>, + #[serde(default)] pub completed: Option>, #[serde(default)] pub tags: Vec, @@ -83,10 +85,14 @@ pub struct Note { impl Note { pub fn new(body: String, note_type: NoteType, workspace: Option) -> Self { Note { + // 12 hex chars (~48 bits) keeps IDs short and human-typable while making + // collisions vanishingly unlikely — important because update/delete/get_by_id + // all match notes purely by this id. id: uuid::Uuid::new_v4() + .simple() .to_string() .chars() - .take(6) + .take(12) .collect(), body, note_type, @@ -250,10 +256,15 @@ mod tests { } #[test] - fn note_id_is_six_chars() { + fn note_id_is_twelve_chars() { for _ in 0..50 { let note = Note::new("x".into(), NoteType::Note, None); - assert_eq!(note.id.len(), 6, "id '{}' is not 6 chars", note.id); + assert_eq!(note.id.len(), 12, "id '{}' is not 12 chars", note.id); + assert!( + note.id.chars().all(|c| c.is_ascii_hexdigit()), + "id '{}' is not all hex", + note.id + ); } } diff --git a/breadpad-shared/src/util.rs b/breadpad-shared/src/util.rs new file mode 100644 index 0000000..241a8d8 --- /dev/null +++ b/breadpad-shared/src/util.rs @@ -0,0 +1,63 @@ +use chrono::{DateTime, Duration, Local, LocalResult, NaiveDateTime, TimeZone, Utc}; + +/// Resolve a naive *local* datetime to UTC without panicking on DST transitions. +/// +/// `NaiveDateTime::and_local_timezone` (and `Local.from_local_datetime`) returns a +/// `LocalResult`, which is not always `Single`: +/// - `Single` — the normal case. +/// - `Ambiguous` (a fall-back hour that occurs twice) — pick the earliest instant. +/// - `None` (a spring-forward gap where the wall-clock time never happens) — advance +/// an hour at a time until a valid instant is found, then fall back to treating the +/// naive value as UTC. +/// +/// Calling `.unwrap()` on the `None`/`Ambiguous` cases panics, which is what this helper +/// exists to avoid (it bit us on the ~2 DST transition days per year). +pub fn local_naive_to_utc(naive: NaiveDateTime) -> DateTime { + match Local.from_local_datetime(&naive) { + LocalResult::Single(dt) => dt.with_timezone(&Utc), + LocalResult::Ambiguous(earliest, _latest) => earliest.with_timezone(&Utc), + LocalResult::None => { + let mut shifted = naive; + for _ in 0..3 { + shifted += Duration::hours(1); + if let LocalResult::Single(dt) = Local.from_local_datetime(&shifted) { + return dt.with_timezone(&Utc); + } + } + // Last resort: interpret the wall-clock value as UTC so we still return a time. + Utc.from_utc_datetime(&naive) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use chrono::NaiveDate; + + #[test] + fn ordinary_time_round_trips() { + let naive = NaiveDate::from_ymd_opt(2026, 6, 15) + .unwrap() + .and_hms_opt(9, 30, 0) + .unwrap(); + let utc = local_naive_to_utc(naive); + // Converting back to local should yield the same wall-clock time. + let local: DateTime = utc.with_timezone(&Local); + assert_eq!(local.naive_local(), naive); + } + + #[test] + fn never_panics_across_a_full_year_of_hours() { + // Walk every hour of a year through the helper; it must never panic regardless + // of the host timezone's DST rules. + let mut dt = NaiveDate::from_ymd_opt(2026, 1, 1) + .unwrap() + .and_hms_opt(0, 0, 0) + .unwrap(); + for _ in 0..(366 * 24) { + let _ = local_naive_to_utc(dt); + dt += Duration::hours(1); + } + } +} diff --git a/breadpad-shared/tests/classifier.rs b/breadpad-shared/tests/classifier.rs index f13cd47..3b5527c 100644 --- a/breadpad-shared/tests/classifier.rs +++ b/breadpad-shared/tests/classifier.rs @@ -1,16 +1,27 @@ -use breadpad_shared::classifier::Classifier; +use breadpad_shared::classifier::{Classifier, ExecutionProvider}; use breadpad_shared::types::NoteType; use chrono::Timelike; fn cl() -> Classifier { - Classifier::load("auto", "08:00") + Classifier::load("08:00") } #[test] -fn active_provider_is_cpu() { - // QNN and Vulkan EPs are not compiled in; CPU is always the fallback. +fn active_provider_is_valid() { + // The active provider depends on the host: a machine with the ONNX model present and + // a working ROCm iGPU loads `Gpu`, otherwise `Cpu`. Either is valid — but when no + // model is available we must be on CPU (no session => no GPU EP in use). let c = cl(); - assert_eq!(c.active_provider, breadpad_shared::classifier::ExecutionProvider::Cpu); + assert!(matches!( + c.active_provider, + ExecutionProvider::Cpu | ExecutionProvider::Gpu + )); + if !c.model_available() { + assert!( + matches!(c.active_provider, ExecutionProvider::Cpu), + "no model loaded but provider was not CPU" + ); + } } #[test] @@ -63,7 +74,7 @@ fn classify_recurrence_via_fallback() { #[test] fn classify_custom_morning_time() { - let mut c = Classifier::load("auto", "07:15"); + let mut c = Classifier::load("07:15"); let r = c.classify("sync tomorrow morning"); let t = r.time.expect("should have a time for tomorrow morning"); let local: chrono::DateTime = t.into(); @@ -71,6 +82,41 @@ fn classify_custom_morning_time() { assert_eq!(local.minute(), 15); } +#[test] +fn classify_empty_string_does_not_panic() { + let mut c = cl(); + let _ = c.classify(""); +} + +#[test] +fn classify_whitespace_only_does_not_panic() { + let mut c = cl(); + let _ = c.classify(" "); +} + +#[test] +fn classify_in_duration_sets_time() { + let mut c = cl(); + let r = c.classify("take a break in 30 minutes"); + assert!(r.time.is_some(), "should have a time for 'in 30 minutes'"); + assert_eq!(r.note_type, NoteType::Reminder); +} + +#[test] +fn classify_tomorrow_sets_time() { + let mut c = cl(); + let r = c.classify("submit the invoice tomorrow"); + assert!(r.time.is_some(), "tomorrow should produce a scheduled time"); +} + +#[test] +fn classify_returns_cleaned_body() { + let mut c = cl(); + let r = c.classify("call mum at 6pm"); + assert!(r.body.contains("call mum"), "body: {}", r.body); + assert!(!r.body.contains("6pm"), "time phrase should be stripped from body: {}", r.body); +} + #[test] fn model_path_points_to_expected_location() { let c = cl(); diff --git a/breadpad-shared/tests/config.rs b/breadpad-shared/tests/config.rs index 200b026..6e057e5 100644 --- a/breadpad-shared/tests/config.rs +++ b/breadpad-shared/tests/config.rs @@ -1,4 +1,4 @@ -use breadpad_shared::config::{Config, ModelConfig, RemindersConfig, Settings}; +use breadpad_shared::config::{expand_path, CalendarConfig, Config, ModelConfig, RemindersConfig, Settings}; use tempfile::TempDir; // ---- Default values ---- @@ -22,9 +22,9 @@ fn default_snooze_options_contains_all_three() { #[test] fn default_model_config() { let m = ModelConfig::default(); - assert_eq!(m.execution_provider, "auto"); assert!(m.path.contains("classifier.onnx")); assert!(m.tokenizer.contains("tokenizer.json")); + assert_eq!(m.ort_dylib_path, ""); } #[test] @@ -38,7 +38,6 @@ fn default_reminders_config() { fn default_config_composes_defaults() { let cfg = Config::default(); assert_eq!(cfg.settings.default_type, "note"); - assert_eq!(cfg.model.execution_provider, "auto"); assert_eq!(cfg.reminders.default_morning, "08:00"); } @@ -56,7 +55,7 @@ archive_after_days = 7 [model] path = "/tmp/classifier.onnx" tokenizer = "/tmp/tokenizer.json" -execution_provider = "cpu" +ort_dylib_path = "/tmp/libonnxruntime.so" [reminders] default_morning = "07:30" @@ -67,8 +66,8 @@ missed_grace_minutes = 30 assert!(!cfg.settings.workspace_tag); assert_eq!(cfg.settings.snooze_options, vec!["15m", "2h"]); assert_eq!(cfg.settings.archive_after_days, 7); - assert_eq!(cfg.model.execution_provider, "cpu"); assert_eq!(cfg.model.path, "/tmp/classifier.onnx"); + assert_eq!(cfg.model.ort_dylib_path, "/tmp/libonnxruntime.so"); assert_eq!(cfg.reminders.default_morning, "07:30"); assert_eq!(cfg.reminders.missed_grace_minutes, 30); } @@ -78,7 +77,6 @@ fn empty_toml_uses_all_defaults() { let cfg: Config = toml::from_str("").unwrap(); assert_eq!(cfg.settings.default_type, "note"); assert!(cfg.settings.workspace_tag); - assert_eq!(cfg.model.execution_provider, "auto"); assert_eq!(cfg.reminders.default_morning, "08:00"); } @@ -90,31 +88,9 @@ default_type = "reminder" "#; let cfg: Config = toml::from_str(toml).unwrap(); assert_eq!(cfg.settings.default_type, "reminder"); - // Other sections should still have defaults - assert_eq!(cfg.model.execution_provider, "auto"); assert_eq!(cfg.reminders.default_morning, "08:00"); } -#[test] -fn partial_toml_only_model_section() { - let toml = r#" -[model] -execution_provider = "npu" -"#; - let cfg: Config = toml::from_str(toml).unwrap(); - assert_eq!(cfg.model.execution_provider, "npu"); - assert_eq!(cfg.settings.default_type, "note"); -} - -#[test] -fn execution_provider_variants_accepted() { - for ep in &["auto", "npu", "vulkan", "cpu"] { - let toml = format!("[model]\nexecution_provider = \"{}\"", ep); - let cfg: Config = toml::from_str(&toml).unwrap(); - assert_eq!(cfg.model.execution_provider, *ep); - } -} - // ---- TOML serialization round-trip ---- #[test] @@ -124,7 +100,6 @@ fn default_config_serializes_to_valid_toml() { let reparsed: Config = toml::from_str(&serialized).unwrap(); assert_eq!(reparsed.settings.default_type, cfg.settings.default_type); assert_eq!(reparsed.settings.workspace_tag, cfg.settings.workspace_tag); - assert_eq!(reparsed.model.execution_provider, cfg.model.execution_provider); assert_eq!(reparsed.reminders.default_morning, cfg.reminders.default_morning); } @@ -133,7 +108,6 @@ fn custom_config_round_trips() { let mut cfg = Config::default(); cfg.settings.default_type = "idea".into(); cfg.settings.archive_after_days = 14; - cfg.model.execution_provider = "vulkan".into(); cfg.reminders.default_morning = "06:45".into(); cfg.reminders.missed_grace_minutes = 120; @@ -141,7 +115,6 @@ fn custom_config_round_trips() { let rt: Config = toml::from_str(&toml).unwrap(); assert_eq!(rt.settings.default_type, "idea"); assert_eq!(rt.settings.archive_after_days, 14); - assert_eq!(rt.model.execution_provider, "vulkan"); assert_eq!(rt.reminders.default_morning, "06:45"); assert_eq!(rt.reminders.missed_grace_minutes, 120); } @@ -155,24 +128,20 @@ fn save_and_load_round_trip() { let mut cfg = Config::default(); cfg.settings.default_type = "question".into(); - cfg.model.execution_provider = "cpu".into(); cfg.reminders.missed_grace_minutes = 45; - // Manually save to a known path (Config::save uses the fixed XDG path, - // so we use toml serialization + write here to test the round-trip logic) let toml = toml::to_string_pretty(&cfg).unwrap(); std::fs::write(&config_path, &toml).unwrap(); let loaded: Config = toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap(); assert_eq!(loaded.settings.default_type, "question"); - assert_eq!(loaded.model.execution_provider, "cpu"); assert_eq!(loaded.reminders.missed_grace_minutes, 45); } // ---- The example from the README ---- #[test] -fn readme_example_toml_parses() { +fn example_toml_parses() { let toml = r#" [settings] default_type = "note" @@ -183,7 +152,7 @@ archive_after_days = 30 [model] path = "~/.local/share/breadpad/model/classifier.onnx" tokenizer = "~/.local/share/breadpad/model/tokenizer.json" -execution_provider = "auto" +ort_dylib_path = "" [reminders] default_morning = "08:00" @@ -192,7 +161,146 @@ missed_grace_minutes = 60 let cfg: Config = toml::from_str(toml).unwrap(); assert_eq!(cfg.settings.default_type, "note"); assert!(cfg.settings.workspace_tag); - assert_eq!(cfg.model.execution_provider, "auto"); assert_eq!(cfg.reminders.default_morning, "08:00"); assert_eq!(cfg.reminders.missed_grace_minutes, 60); } + +// ---- CalendarConfig ---- + +#[test] +fn default_calendar_config_is_disabled() { + let c = CalendarConfig::default(); + assert!(!c.enabled); + assert!(c.url.is_empty()); + assert!(c.username.is_empty()); + assert!(c.password.is_empty()); +} + +#[test] +fn calendar_config_from_toml() { + let toml = r#" +[calendar] +enabled = true +url = "https://cloud.example.com/remote.php/dav/calendars/user/personal/" +username = "user" +password = "secret" +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert!(cfg.calendar.enabled); + assert!(cfg.calendar.url.contains("dav/calendars")); + assert_eq!(cfg.calendar.username, "user"); + assert_eq!(cfg.calendar.password, "secret"); +} + +#[test] +fn calendar_config_round_trips() { + let mut cfg = Config::default(); + cfg.calendar.enabled = true; + cfg.calendar.url = "https://example.com/cal".into(); + cfg.calendar.username = "alice".into(); + cfg.calendar.password = "hunter2".into(); + + let toml = toml::to_string_pretty(&cfg).unwrap(); + let rt: Config = toml::from_str(&toml).unwrap(); + assert!(rt.calendar.enabled); + assert_eq!(rt.calendar.url, "https://example.com/cal"); + assert_eq!(rt.calendar.username, "alice"); + assert_eq!(rt.calendar.password, "hunter2"); +} + +#[test] +fn default_config_calendar_disabled() { + let cfg = Config::default(); + assert!(!cfg.calendar.enabled); +} + +// ---- OllamaConfig ---- + +#[test] +fn default_ollama_config_enabled() { + let m = ModelConfig::default(); + assert!(m.ollama.enabled); + assert_eq!(m.ollama.endpoint, "http://localhost:11434"); + assert!(!m.ollama.model.is_empty()); + assert!(m.ollama.confidence_threshold > 0.0 && m.ollama.confidence_threshold <= 1.0); +} + +#[test] +fn ollama_config_from_toml() { + let toml = r#" +[model.ollama] +enabled = false +endpoint = "http://localhost:9999" +model = "llama3" +confidence_threshold = 0.8 +"#; + let cfg: Config = toml::from_str(toml).unwrap(); + assert!(!cfg.model.ollama.enabled); + assert_eq!(cfg.model.ollama.endpoint, "http://localhost:9999"); + assert_eq!(cfg.model.ollama.model, "llama3"); + assert!((cfg.model.ollama.confidence_threshold - 0.8).abs() < 1e-5); +} + +// ---- expand_path ---- + +#[test] +fn expand_path_tilde_prefix_replaced_with_home() { + let home = dirs::home_dir().unwrap(); + let expanded = expand_path("~/some/path"); + assert!(expanded.starts_with(&home)); + assert!(expanded.ends_with("some/path")); +} + +#[test] +fn expand_path_bare_tilde_is_home() { + let home = dirs::home_dir().unwrap(); + assert_eq!(expand_path("~"), home); +} + +#[test] +fn expand_path_absolute_path_unchanged() { + let p = expand_path("/usr/local/bin/breadpad"); + assert_eq!(p.to_str().unwrap(), "/usr/local/bin/breadpad"); +} + +#[test] +fn expand_path_relative_path_unchanged() { + let p = expand_path("relative/path"); + assert_eq!(p.to_str().unwrap(), "relative/path"); +} + +// ---- ModelConfig::resolved_ort_dylib_path ---- + +#[test] +fn resolved_ort_dylib_empty_returns_none() { + let m = ModelConfig::default(); + assert!(m.resolved_ort_dylib_path().is_none()); +} + +#[test] +fn resolved_ort_dylib_whitespace_only_returns_none() { + let mut m = ModelConfig::default(); + m.ort_dylib_path = " ".into(); + assert!(m.resolved_ort_dylib_path().is_none()); +} + +#[test] +fn resolved_ort_dylib_set_returns_some() { + let mut m = ModelConfig::default(); + m.ort_dylib_path = "/usr/lib/libonnxruntime.so".into(); + assert_eq!( + m.resolved_ort_dylib_path().unwrap().to_str().unwrap(), + "/usr/lib/libonnxruntime.so" + ); +} + +// ---- ModelConfig::resolved_paths ---- + +#[test] +fn resolved_paths_expands_tildes() { + let m = ModelConfig::default(); + let (model, tokenizer) = m.resolved_paths(); + let home = dirs::home_dir().unwrap(); + assert!(model.starts_with(&home), "model path should be under home: {:?}", model); + assert!(tokenizer.starts_with(&home), "tokenizer path should be under home: {:?}", tokenizer); +} diff --git a/breadpad-shared/tests/pipeline.rs b/breadpad-shared/tests/pipeline.rs index db284c7..84cc850 100644 --- a/breadpad-shared/tests/pipeline.rs +++ b/breadpad-shared/tests/pipeline.rs @@ -14,7 +14,7 @@ use tempfile::TempDir; // Mirrors commit_note() in breadpad/src/main.rs. // `user_type` is the type the user selected in the chip row (default = NoteType::Note). fn capture(store: &Store, text: &str, user_type: NoteType) -> Note { - let mut classifier = Classifier::load("auto", "08:00"); + let mut classifier = Classifier::load("08:00"); let result = classifier.classify(text); let mut note = Note::new(text.into(), user_type.clone(), None); diff --git a/breadpad-shared/tests/store.rs b/breadpad-shared/tests/store.rs index 3117e0a..4223842 100644 --- a/breadpad-shared/tests/store.rs +++ b/breadpad-shared/tests/store.rs @@ -301,6 +301,51 @@ fn rotate_archive_zero_when_nothing_qualifies() { assert_eq!(store.load_all().unwrap().len(), 1); } +#[test] +fn rotate_archive_note_just_inside_boundary_stays() { + let (_dir, store) = mk(); + // 29 days ago — threshold is 30 — should NOT be archived + let mut n = note("fresh enough", NoteType::Todo); + n.done = true; + n.completed = Some(Utc::now() - Duration::days(29)); + store.save_note(&n).unwrap(); + + assert_eq!(store.rotate_archive(30).unwrap(), 0); + assert_eq!(store.load_all().unwrap().len(), 1); +} + +#[test] +fn rotate_archive_note_just_past_boundary_is_archived() { + let (_dir, store) = mk(); + // 31 days ago — threshold is 30 — should be archived + let mut n = note("old enough", NoteType::Todo); + n.done = true; + n.completed = Some(Utc::now() - Duration::days(31)); + store.save_note(&n).unwrap(); + + assert_eq!(store.rotate_archive(30).unwrap(), 1); + assert!(store.load_all().unwrap().is_empty()); + assert_eq!(store.load_archive().unwrap().len(), 1); +} + +#[test] +fn rotate_archive_zero_day_threshold_archives_completed_notes() { + let (_dir, store) = mk(); + let mut done = note("done a second ago", NoteType::Todo); + done.done = true; + done.completed = Some(Utc::now() - Duration::seconds(1)); + store.save_note(&done).unwrap(); + + let undone = note("still active", NoteType::Todo); + store.save_note(&undone).unwrap(); + + assert_eq!(store.rotate_archive(0).unwrap(), 1); + let remaining = store.load_all().unwrap(); + assert_eq!(remaining.len(), 1); + assert_eq!(remaining[0].body, "still active"); + assert_eq!(store.load_archive().unwrap().len(), 1); +} + #[test] fn rotate_archive_ignores_undone_notes_no_matter_how_old() { let (_dir, store) = mk(); diff --git a/breadpad-test/Cargo.toml b/breadpad-test/Cargo.toml index 30e5714..d23d3ae 100644 --- a/breadpad-test/Cargo.toml +++ b/breadpad-test/Cargo.toml @@ -18,3 +18,5 @@ chrono = { workspace = true } clap = { version = "4", features = ["derive"] } colored = "2" comfy-table = "7" +ort = { workspace = true } +dirs = { workspace = true } diff --git a/breadpad-test/corpus.json b/breadpad-test/corpus.json index 19ba12a..ec1b3d6 100644 --- a/breadpad-test/corpus.json +++ b/breadpad-test/corpus.json @@ -502,5 +502,1221 @@ "expected_body": null, "expected_rrule": null, "notes": "'at 3pm' matches rule-based despite informal phrasing around it" + }, + { + "input": "prepare the presentation for tomorrow", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "prepare the presentation for", + "expected_rrule": null, + "notes": "'tomorrow' triggers time extraction → Reminder; body has 'tomorrow' stripped" + }, + { + "input": "schedule a meeting with the team", + "expected_type": "todo", + "expected_time": null, + "expected_body": "schedule a meeting with the team", + "expected_rrule": null, + "notes": "starts with 'schedule'" + }, + { + "input": "organize the files in the cabinet", + "expected_type": "todo", + "expected_time": null, + "expected_body": "organize the files in the cabinet", + "expected_rrule": null, + "notes": "starts with 'organize'" + }, + { + "input": "create a backup of the database", + "expected_type": "todo", + "expected_time": null, + "expected_body": "create a backup of the database", + "expected_rrule": null, + "notes": "starts with 'create'" + }, + { + "input": "setup the new development environment", + "expected_type": "todo", + "expected_time": null, + "expected_body": "setup the new development environment", + "expected_rrule": null, + "notes": "starts with 'setup'" + }, + { + "input": "deploy the latest version to production", + "expected_type": "todo", + "expected_time": null, + "expected_body": "deploy the latest version to production", + "expected_rrule": null, + "notes": "starts with 'deploy'" + }, + { + "input": "test the new feature on staging", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "'test' not a recognized prefix (test results / test suite false-positive risk); Tier 1 → note" + }, + { + "input": "review the submitted pull requests", + "expected_type": "todo", + "expected_time": null, + "expected_body": "review the submitted pull requests", + "expected_rrule": null, + "notes": "starts with 'review'" + }, + { + "input": "approve the design mockups from UX", + "expected_type": "todo", + "expected_time": null, + "expected_body": "approve the design mockups from UX", + "expected_rrule": null, + "notes": "starts with 'approve'" + }, + { + "input": "merge the feature branch into main", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "'merge' not a recognized prefix (merge conflict / merge request false-positive risk); Tier 1 → note" + }, + { + "input": "configure the CI/CD pipeline settings", + "expected_type": "todo", + "expected_time": null, + "expected_body": "configure the CI/CD pipeline settings", + "expected_rrule": null, + "notes": "starts with 'configure'" + }, + { + "input": "optimize the database queries", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "'optimize' not a recognized prefix; Tier 1 → note" + }, + { + "input": "refactor the authentication module", + "expected_type": "todo", + "expected_time": null, + "expected_body": "refactor the authentication module", + "expected_rrule": null, + "notes": "starts with 'refactor'" + }, + { + "input": "document the API endpoints in the README", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "'document' not a recognized prefix (document is ready / document outlines… false-positive risk); Tier 1 → note" + }, + { + "input": "install the new version of Node.js", + "expected_type": "todo", + "expected_time": null, + "expected_body": "install the new version of Node.js", + "expected_rrule": null, + "notes": "starts with 'install'" + }, + { + "input": "verify the data migration was successful", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "'verify' not a recognized prefix; Tier 1 → note" + }, + { + "input": "validate the user input on the form", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "'validate' not a recognized prefix; Tier 1 → note" + }, + { + "input": "export the data to a CSV file", + "expected_type": "todo", + "expected_time": null, + "expected_body": "export the data to a CSV file", + "expected_rrule": null, + "notes": "starts with 'export'" + }, + { + "input": "import the user data from the old system", + "expected_type": "todo", + "expected_time": null, + "expected_body": "import the user data from the old system", + "expected_rrule": null, + "notes": "starts with 'import'" + }, + { + "input": "restore the database from last backup", + "expected_type": "todo", + "expected_time": null, + "expected_body": "restore the database from last backup", + "expected_rrule": null, + "notes": "starts with 'restore'" + }, + { + "input": "archive the old project files", + "expected_type": "todo", + "expected_time": null, + "expected_body": "archive the old project files", + "expected_rrule": null, + "notes": "starts with 'archive'" + }, + { + "input": "setup meeting at 9am", + "expected_type": "reminder", + "expected_time": "09:00", + "expected_body": "setup meeting", + "expected_rrule": null, + "notes": "time stripped for setup + time combo" + }, + { + "input": "review code at 2pm", + "expected_type": "reminder", + "expected_time": "14:00", + "expected_body": "review code", + "expected_rrule": null, + "notes": "time stripped for review + time combo" + }, + { + "input": "deploy release at 4pm", + "expected_type": "reminder", + "expected_time": "16:00", + "expected_body": "deploy release", + "expected_rrule": null, + "notes": "time stripped for deploy + time combo" + }, + { + "input": "call at 1pm", + "expected_type": "reminder", + "expected_time": "13:00", + "expected_body": "call", + "expected_rrule": null, + "notes": "minimal reminder with time" + }, + { + "input": "at 5pm check status", + "expected_type": "reminder", + "expected_time": "17:00", + "expected_body": "check status", + "expected_rrule": null, + "notes": "time at start of sentence" + }, + { + "input": "standup at 10:00", + "expected_type": "reminder", + "expected_time": "10:00", + "expected_body": "standup", + "expected_rrule": null, + "notes": "24h time in HH:MM format" + }, + { + "input": "presentation at 11:45", + "expected_type": "reminder", + "expected_time": "11:45", + "expected_body": "presentation", + "expected_rrule": null, + "notes": "24h time with minutes" + }, + { + "input": "therapy at 4pm tomorrow", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "therapy", + "expected_rrule": null, + "notes": "time on specific day (not assertable)" + }, + { + "input": "breakfast at 7:30 in the morning", + "expected_type": "reminder", + "expected_time": "07:30", + "expected_body": "breakfast", + "expected_rrule": null, + "notes": "explicit time with morning modifier" + }, + { + "input": "dinner at 6pm in the evening", + "expected_type": "reminder", + "expected_time": "18:00", + "expected_body": "dinner", + "expected_rrule": null, + "notes": "explicit time with evening modifier" + }, + { + "input": "in 15 minutes finish the report", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "finish the report", + "expected_rrule": null, + "notes": "relative time at start" + }, + { + "input": "in 5 minutes go to the meeting", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "go to the meeting", + "expected_rrule": null, + "notes": "relative 5 minute timer" + }, + { + "input": "in 30 seconds alert the team", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "alert the team", + "expected_rrule": null, + "notes": "relative seconds" + }, + { + "input": "in 1 day start the project", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "start the project", + "expected_rrule": null, + "notes": "relative 1 day" + }, + { + "input": "in 2 weeks review progress", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "review progress", + "expected_rrule": null, + "notes": "relative weeks" + }, + { + "input": "monday morning call the plumber", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "bare weekday without 'next'/'every' prefix not parsed by Tier 1; Tier 1 → note" + }, + { + "input": "wednesday at 2pm budget review", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "budget review", + "expected_rrule": null, + "notes": "specific day with explicit time" + }, + { + "input": "thursday evening pack for trip", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "bare weekday without 'next'/'every' prefix not parsed by Tier 1; Tier 1 → note" + }, + { + "input": "every monday morning team standup", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "team standup", + "expected_rrule": "BYDAY=MO", + "notes": "day + morning in recurrence" + }, + { + "input": "every wednesday at 10am planning session", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "planning session", + "expected_rrule": "BYDAY=WE", + "notes": "day + explicit time in recurrence" + }, + { + "input": "every thursday evening review tickets", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "review tickets", + "expected_rrule": "BYDAY=TH", + "notes": "day + evening in recurrence" + }, + { + "input": "every saturday at 2pm grocery shopping", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "grocery shopping", + "expected_rrule": "BYDAY=SA", + "notes": "weekend recurrence with time" + }, + { + "input": "every sunday at 6pm family dinner", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "family dinner", + "expected_rrule": "BYDAY=SU", + "notes": "weekly family event" + }, + { + "input": "every month on the 15th pay rent", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "monthly recurrence not supported by Tier 1 parser; Tier 1 → note" + }, + { + "input": "every 2 weeks sync with manager", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "biweekly 'every N weeks' not supported by Tier 1 parser; Tier 1 → note" + }, + { + "input": "twice a week go to gym", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "frequency phrase 'twice a week' not supported by Tier 1 parser; Tier 1 → note" + }, + { + "input": "three times a day take pills", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "frequency phrase 'three times a day' not supported by Tier 1 parser; Tier 1 → note" + }, + { + "input": "what if we used Rust for the backend", + "expected_type": "idea", + "expected_time": null, + "expected_body": "what if we used Rust for the backend", + "expected_rrule": null, + "notes": "what if idea about tech choice" + }, + { + "input": "what if notifications were real-time", + "expected_type": "idea", + "expected_time": null, + "expected_body": "what if notifications were real-time", + "expected_rrule": null, + "notes": "what if idea about features" + }, + { + "input": "what if users could customize the theme", + "expected_type": "idea", + "expected_time": null, + "expected_body": "what if users could customize the theme", + "expected_rrule": null, + "notes": "what if idea about customization" + }, + { + "input": "maybe add a search feature", + "expected_type": "idea", + "expected_time": null, + "expected_body": "maybe add a search feature", + "expected_rrule": null, + "notes": "maybe idea" + }, + { + "input": "could we improve performance", + "expected_type": "idea", + "expected_time": null, + "expected_body": "could we improve performance", + "expected_rrule": null, + "notes": "could idea" + }, + { + "input": "should we migrate to PostgreSQL", + "expected_type": "idea", + "expected_time": null, + "expected_body": "should we migrate to PostgreSQL", + "expected_rrule": null, + "notes": "should idea" + }, + { + "input": "idea: add file upload support", + "expected_type": "idea", + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "idea: prefix" + }, + { + "input": "idea: implement caching layer", + "expected_type": "idea", + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "idea: prefix 2" + }, + { + "input": "idea: mobile app version", + "expected_type": "idea", + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "idea: prefix 3" + }, + { + "input": "why is the tests failing", + "expected_type": "question", + "expected_time": null, + "expected_body": "why is the tests failing", + "expected_rrule": null, + "notes": "why question" + }, + { + "input": "why does it timeout on large datasets", + "expected_type": "question", + "expected_time": null, + "expected_body": "why does it timeout on large datasets", + "expected_rrule": null, + "notes": "why question 2" + }, + { + "input": "how can we reduce memory usage", + "expected_type": "question", + "expected_time": null, + "expected_body": "how can we reduce memory usage", + "expected_rrule": null, + "notes": "how question" + }, + { + "input": "how do I run the dev server", + "expected_type": "question", + "expected_time": null, + "expected_body": "how do I run the dev server", + "expected_rrule": null, + "notes": "how question 2" + }, + { + "input": "what time is the meeting tomorrow", + "expected_type": "reminder", + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "'tomorrow' triggers time extraction → Reminder overrides the 'what' question signal; time not asserted (date-relative)" + }, + { + "input": "what should we do next", + "expected_type": "question", + "expected_time": null, + "expected_body": "what should we do next", + "expected_rrule": null, + "notes": "what question" + }, + { + "input": "can we ship this today", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "'can' not a recognized question prefix (can opener false-positive risk); Tier 1 → note" + }, + { + "input": "will this break backward compatibility", + "expected_type": "question", + "expected_time": null, + "expected_body": "will this break backward compatibility", + "expected_rrule": null, + "notes": "will question" + }, + { + "input": "should I merge this PR", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "'should I' not recognized ('should we' is Idea, but 'should I' is not a known pattern); Tier 1 → note" + }, + { + "input": "could this cause issues", + "expected_type": "idea", + "expected_time": null, + "expected_body": "could this cause issues", + "expected_rrule": null, + "notes": "contains 'could ' → Idea (Tier 1 cannot distinguish idea-could from question-could without ?)" + }, + { + "input": "have we tested on production data", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "'have' not a recognized question prefix ('have some coffee' false-positive risk); Tier 1 → note" + }, + { + "input": "are the tests passing", + "expected_type": "question", + "expected_time": null, + "expected_body": "are the tests passing", + "expected_rrule": null, + "notes": "are question" + }, + { + "input": "did we document the API changes", + "expected_type": "question", + "expected_time": null, + "expected_body": "did we document the API changes", + "expected_rrule": null, + "notes": "did question" + }, + { + "input": "the bug is only on Firefox", + "expected_type": "note", + "expected_time": null, + "expected_body": "the bug is only on Firefox", + "expected_rrule": null, + "notes": "observation note" + }, + { + "input": "build time increased after adding new dependency", + "expected_type": "note", + "expected_time": null, + "expected_body": "build time increased after adding new dependency", + "expected_rrule": null, + "notes": "observation about build" + }, + { + "input": "need to research caching strategies", + "expected_type": "note", + "expected_time": null, + "expected_body": "need to research caching strategies", + "expected_rrule": null, + "notes": "research note" + }, + { + "input": "the PR has merge conflicts", + "expected_type": "note", + "expected_time": null, + "expected_body": "the PR has merge conflicts", + "expected_rrule": null, + "notes": "status note" + }, + { + "input": "performance improved by 40% after optimization", + "expected_type": "note", + "expected_time": null, + "expected_body": "performance improved by 40% after optimization", + "expected_rrule": null, + "notes": "achievement note" + }, + { + "input": "saw the new UI design mockups", + "expected_type": "note", + "expected_time": null, + "expected_body": "saw the new UI design mockups", + "expected_rrule": null, + "notes": "discovery note" + }, + { + "input": "the server went down during the demo", + "expected_type": "note", + "expected_time": null, + "expected_body": "the server went down during the demo", + "expected_rrule": null, + "notes": "incident note" + }, + { + "input": "learned about the new feature from the docs", + "expected_type": "note", + "expected_time": null, + "expected_body": "learned about the new feature from the docs", + "expected_rrule": null, + "notes": "learning note" + }, + { + "input": "client prefers to use Postgres instead of MySQL", + "expected_type": "note", + "expected_time": null, + "expected_body": "client prefers to use Postgres instead of MySQL", + "expected_rrule": null, + "notes": "preference note" + }, + { + "input": "noticed the memory leak in the dev tools", + "expected_type": "note", + "expected_time": null, + "expected_body": "noticed the memory leak in the dev tools", + "expected_rrule": null, + "notes": "problem observation" + }, + { + "input": "buy groceries", + "expected_type": "todo", + "expected_time": null, + "expected_body": "buy groceries", + "expected_rrule": null, + "notes": "simple buy action" + }, + { + "input": "send the invoice to the client", + "expected_type": "todo", + "expected_time": null, + "expected_body": "send the invoice to the client", + "expected_rrule": null, + "notes": "starts with send" + }, + { + "input": "read the documentation", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "'read' not a recognized prefix (read-only, read-write ambiguity); Tier 1 → note" + }, + { + "input": "submit the report by EOD", + "expected_type": "todo", + "expected_time": null, + "expected_body": "submit the report by EOD", + "expected_rrule": null, + "notes": "submit action" + }, + { + "input": "start the project kick-off meeting", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "'start' not a recognized prefix ('start time is 9am' false-positive risk); Tier 1 → note" + }, + { + "input": "edit the markdown file", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "'edit' not a recognized prefix ('edit mode' false-positive risk); Tier 1 → note" + }, + { + "input": "at 11am review the slides", + "expected_type": "reminder", + "expected_time": "11:00", + "expected_body": "review the slides", + "expected_rrule": null, + "notes": "time at start with todo action" + }, + { + "input": "go to the store at 5pm", + "expected_type": "reminder", + "expected_time": "17:00", + "expected_body": "go to the store", + "expected_rrule": null, + "notes": "go action with time" + }, + { + "input": "walk the dog in 10 minutes", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "walk the dog", + "expected_rrule": null, + "notes": "relative time with action" + }, + { + "input": "every day in the morning drink water", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "drink water", + "expected_rrule": "FREQ=DAILY", + "notes": "daily with morning time" + }, + { + "input": "every other day exercise", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "every other day not parsed" + }, + { + "input": "twice a month attend training", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "twice a month not parsed" + }, + { + "input": "at 6am go running", + "expected_type": "reminder", + "expected_time": "06:00", + "expected_body": "go running", + "expected_rrule": null, + "notes": "early morning reminder" + }, + { + "input": "at 1am check the server", + "expected_type": "reminder", + "expected_time": "01:00", + "expected_body": "check the server", + "expected_rrule": null, + "notes": "late night reminder" + }, + { + "input": "at 00:30 backup files", + "expected_type": "reminder", + "expected_time": "00:30", + "expected_body": "backup files", + "expected_rrule": null, + "notes": "midnight time" + }, + { + "input": "at 12:01 meeting starts", + "expected_type": "reminder", + "expected_time": "12:01", + "expected_body": "meeting starts", + "expected_rrule": null, + "notes": "edge case minute value" + }, + { + "input": "check the deploy at 15:59", + "expected_type": "reminder", + "expected_time": "15:59", + "expected_body": "check the deploy", + "expected_rrule": null, + "notes": "end of business hour" + }, + { + "input": "every monday at 9:30 daily standup", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "daily standup", + "expected_rrule": "BYDAY=MO", + "notes": "weekday with specific minutes" + }, + { + "input": "every tuesday at 15:15 sync meeting", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "sync meeting", + "expected_rrule": "BYDAY=TU", + "notes": "another day with minutes" + }, + { + "input": "remind me to call john at 8am", + "expected_type": "reminder", + "expected_time": "08:00", + "expected_body": "call john", + "expected_rrule": null, + "notes": "remind me prefix stripped" + }, + { + "input": "make sure to backup at 11pm", + "expected_type": "reminder", + "expected_time": "23:00", + "expected_body": "backup", + "expected_rrule": null, + "notes": "make sure to prefix" + }, + { + "input": "don't forget the meeting at 2pm", + "expected_type": "reminder", + "expected_time": "14:00", + "expected_body": null, + "expected_rrule": null, + "notes": "don't forget phrase" + }, + { + "input": "next week start the new project", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "'next week' not a recognized time pattern (only 'next ' is); Tier 1 → note" + }, + { + "input": "next month renew the subscription", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "next month not parsed by Tier 1" + }, + { + "input": "this week finish the report", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "this week not parsed by Tier 1" + }, + { + "input": "tonight at 9pm movie night", + "expected_type": "reminder", + "expected_time": "21:00", + "expected_body": "movie", + "expected_rrule": null, + "notes": "at 9pm fires first; morning_evening cleanup strips both 'tonight' and standalone 'night' → body becomes just 'movie'" + }, + { + "input": "idea: add dark theme", + "expected_type": "idea", + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "idea: prefix minimal" + }, + { + "input": "idea: fix the layout bug", + "expected_type": "idea", + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "idea: prefix with fix" + }, + { + "input": "could we add a download button", + "expected_type": "idea", + "expected_time": null, + "expected_body": "could we add a download button", + "expected_rrule": null, + "notes": "could we phrase" + }, + { + "input": "should we use WebSockets instead", + "expected_type": "idea", + "expected_time": null, + "expected_body": "should we use WebSockets instead", + "expected_rrule": null, + "notes": "should we idea" + }, + { + "input": "what if we added analytics", + "expected_type": "idea", + "expected_time": null, + "expected_body": "what if we added analytics", + "expected_rrule": null, + "notes": "what if idea with verb" + }, + { + "input": "maybe implement logging", + "expected_type": "idea", + "expected_time": null, + "expected_body": "maybe implement logging", + "expected_rrule": null, + "notes": "maybe idea" + }, + { + "input": "why can't we use this library", + "expected_type": "question", + "expected_time": null, + "expected_body": "why can't we use this library", + "expected_rrule": null, + "notes": "why with contraction" + }, + { + "input": "how would we scale this", + "expected_type": "question", + "expected_time": null, + "expected_body": "how would we scale this", + "expected_rrule": null, + "notes": "how question variant" + }, + { + "input": "when is the deadline", + "expected_type": "question", + "expected_time": null, + "expected_body": "when is the deadline", + "expected_rrule": null, + "notes": "when question" + }, + { + "input": "where should we deploy this", + "expected_type": "question", + "expected_time": null, + "expected_body": "where should we deploy this", + "expected_rrule": null, + "notes": "where question" + }, + { + "input": "who is the project owner", + "expected_type": "question", + "expected_time": null, + "expected_body": "who is the project owner", + "expected_rrule": null, + "notes": "who question" + }, + { + "input": "is it backward compatible", + "expected_type": "question", + "expected_time": null, + "expected_body": "is it backward compatible", + "expected_rrule": null, + "notes": "is question ending with ?" + }, + { + "input": "does the site perform well on mobile", + "expected_type": "question", + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "does question ending with ?" + }, + { + "input": "have you tried restarting the server", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "'have' not a recognized question prefix ('have some coffee' false-positive risk); Tier 1 → note" + }, + { + "input": "going to be late to the meeting", + "expected_type": "note", + "expected_time": null, + "expected_body": "going to be late to the meeting", + "expected_rrule": null, + "notes": "casual note observation" + }, + { + "input": "the API response time is slow today", + "expected_type": "note", + "expected_time": null, + "expected_body": "the API response time is slow today", + "expected_rrule": null, + "notes": "performance observation" + }, + { + "input": "someone broke the tests yesterday", + "expected_type": "note", + "expected_time": null, + "expected_body": "someone broke the tests yesterday", + "expected_rrule": null, + "notes": "incident note" + }, + { + "input": "the client liked the new design", + "expected_type": "note", + "expected_time": null, + "expected_body": "the client liked the new design", + "expected_rrule": null, + "notes": "feedback note" + }, + { + "input": "found a typo in the documentation", + "expected_type": "note", + "expected_time": null, + "expected_body": "found a typo in the documentation", + "expected_rrule": null, + "notes": "issue discovery note" + }, + { + "input": "waiting for the deploy to finish", + "expected_type": "note", + "expected_time": null, + "expected_body": "waiting for the deploy to finish", + "expected_rrule": null, + "notes": "status note" + }, + { + "input": "coffee maker is broken again", + "expected_type": "note", + "expected_time": null, + "expected_body": "coffee maker is broken again", + "expected_rrule": null, + "notes": "office note" + }, + { + "input": "remember to backup the database", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "remember not a recognized prefix; Tier 1 returns note" + }, + { + "input": "dont forget to update the changelog", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "don't forget not recognized; Tier 1 returns note" + }, + { + "input": "need to review the design specs", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "need to not recognized; Tier 1 returns note" + }, + { + "input": "have to attend the conference", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "have to not recognized; Tier 1 returns note" + }, + { + "input": "must apply the security patch", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "must not recognized; Tier 1 returns note" + }, + { + "input": "should sign the contract", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "should alone not recognized (not should we); Tier 1 returns note" + }, + { + "input": "at 7:00 am wake up early", + "expected_type": "reminder", + "expected_time": "07:00", + "expected_body": "wake up early", + "expected_rrule": null, + "notes": "time with am" + }, + { + "input": "at 8:00 pm watch the show", + "expected_type": "reminder", + "expected_time": "20:00", + "expected_body": "watch the show", + "expected_rrule": null, + "notes": "time with pm" + }, + { + "input": "call the doctor in 1 hour", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "call the doctor", + "expected_rrule": null, + "notes": "in X hour variant" + }, + { + "input": "finish work in 30 minutes please", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "finish work", + "expected_rrule": null, + "notes": "in X minutes with please" + }, + { + "input": "every friday morning team huddle", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "team huddle", + "expected_rrule": "BYDAY=FR", + "notes": "friday + morning" + }, + { + "input": "every monday evening standdown", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "standdown", + "expected_rrule": "BYDAY=MO", + "notes": "monday + evening (should work)" + }, + { + "input": "every sunday at 10am brunch", + "expected_type": "reminder", + "expected_time": null, + "expected_body": "brunch", + "expected_rrule": "BYDAY=SU", + "notes": "sunday + time" + }, + { + "input": "what could possibly go wrong with this approach?", + "expected_type": "idea", + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "what with could in sentence triggers idea pattern" + }, + { + "input": "isn't this implementation simpler?", + "expected_type": "question", + "expected_time": null, + "expected_body": "isn't this implementation simpler?", + "expected_rrule": null, + "notes": "isn't contraction question" + }, + { + "input": "won't this break the API?", + "expected_type": "question", + "expected_time": null, + "expected_body": "won't this break the API?", + "expected_rrule": null, + "notes": "won't contraction question" + }, + { + "input": "shouldn't we test this first?", + "expected_type": "question", + "expected_time": null, + "expected_body": "shouldn't we test this first?", + "expected_rrule": null, + "notes": "shouldn't contraction question" + }, + { + "input": "maybe the caching will help performance", + "expected_type": "idea", + "expected_time": null, + "expected_body": "maybe the caching will help performance", + "expected_rrule": null, + "notes": "maybe with will" + }, + { + "input": "what if we added a webhook system", + "expected_type": "idea", + "expected_time": null, + "expected_body": "what if we added a webhook system", + "expected_rrule": null, + "notes": "what if with system" + }, + { + "input": "could we make this configurable", + "expected_type": "idea", + "expected_time": null, + "expected_body": "could we make this configurable", + "expected_rrule": null, + "notes": "could we variant" + }, + { + "input": "should consider adding rate limiting", + "expected_type": null, + "expected_time": null, + "expected_body": null, + "expected_rrule": null, + "notes": "should consider not recognized; Tier 1 returns note" + }, + { + "input": "maybe add a retry mechanism", + "expected_type": "idea", + "expected_time": null, + "expected_body": "maybe add a retry mechanism", + "expected_rrule": null, + "notes": "maybe add phrase" + }, + { + "input": "could try using gRPC instead", + "expected_type": "idea", + "expected_time": null, + "expected_body": "could try using gRPC instead", + "expected_rrule": null, + "notes": "could try phrase" + }, + { + "input": "why not use message queues", + "expected_type": "question", + "expected_time": null, + "expected_body": "why not use message queues", + "expected_rrule": null, + "notes": "why not question" + }, + { + "input": "how about we use environment variables", + "expected_type": "question", + "expected_time": null, + "expected_body": "how about we use environment variables", + "expected_rrule": null, + "notes": "how about question" } ] diff --git a/breadpad-test/src/main.rs b/breadpad-test/src/main.rs index a270d2a..54a7d03 100644 --- a/breadpad-test/src/main.rs +++ b/breadpad-test/src/main.rs @@ -67,6 +67,9 @@ enum TierArg { Three, #[value(name = "all")] All, + /// Production path: Tier 1 → Tier 2 (no Ollama) + #[value(name = "pipeline")] + Pipeline, } impl TierArg { @@ -76,6 +79,7 @@ impl TierArg { TierArg::Two => "2", TierArg::Three => "3", TierArg::All => "all", + TierArg::Pipeline => "pipeline", } } } @@ -142,7 +146,14 @@ fn classify_with_tier(text: &str, tier: &TierArg) -> ClassificationResult { match tier { TierArg::One => parse_rule_based(text, DEFAULT_MORNING), TierArg::Two => { - let mut clf = Classifier::load("auto", DEFAULT_MORNING); + let mut clf = Classifier::load(DEFAULT_MORNING); + clf.classify_tier2_only(text).unwrap_or_else(|| { + eprintln!("warning: ONNX model not loaded; Tier 2 unavailable"); + parse_rule_based(text, DEFAULT_MORNING) + }) + } + TierArg::Pipeline => { + let mut clf = Classifier::load(DEFAULT_MORNING); clf.classify(text) } TierArg::Three | TierArg::All => { @@ -152,7 +163,7 @@ fn classify_with_tier(text: &str, tier: &TierArg) -> ClassificationResult { confidence_threshold: 0.6, enabled: true, }; - let mut clf = Classifier::load("auto", DEFAULT_MORNING).with_ollama(ollama); + let mut clf = Classifier::load(DEFAULT_MORNING).with_ollama(ollama); clf.classify(text) } } @@ -444,8 +455,35 @@ fn cmd_edit(index: usize, corpus_path: &Path) -> Result<()> { Ok(()) } +fn init_ort() { + use std::path::PathBuf; + // Prefer the system CPU-only library for testing — no Ryzen AI startup overhead. + // Fall back to the Ryzen AI SDK library if the system one isn't installed. + let candidates: Vec = { + let mut v = vec![ + PathBuf::from("/usr/lib/libonnxruntime.so"), + PathBuf::from("/usr/local/lib/libonnxruntime.so"), + ]; + if let Some(home) = dirs::home_dir() { + v.push(home.join(".local/share/ryzen-ai-1.7.1/lib/libonnxruntime.so")); + v.push(home.join(".local/share/ryzen-ai/lib/libonnxruntime.so")); + } + if let Ok(root) = std::env::var("RYZEN_AI_INSTALLATION_PATH") { + v.push(PathBuf::from(root).join("lib/libonnxruntime.so")); + } + v.push(PathBuf::from("/opt/ryzen-ai/lib/libonnxruntime.so")); + v + }; + if let Some(path) = candidates.into_iter().find(|p| p.is_file()) { + if let Err(e) = ort::init_from(&path).map(|b| b.commit()) { + eprintln!("warning: failed to load ORT from {:?}: {}", path, e); + } + } +} + fn main() -> Result<()> { let cli = Cli::parse(); + init_ort(); match cli.command { Commands::Run { corpus, tier, format } => { diff --git a/breadpad.example.toml b/breadpad.example.toml index cb7e18a..9b60023 100644 --- a/breadpad.example.toml +++ b/breadpad.example.toml @@ -7,8 +7,25 @@ archive_after_days = 30 [model] path = "~/.local/share/breadpad/model/classifier.onnx" tokenizer = "~/.local/share/breadpad/model/tokenizer.json" -execution_provider = "auto" # auto | npu | vulkan | cpu +# ort_dylib_path: path to libonnxruntime.so. Leave empty to auto-discover from +# standard system paths or $ORT_DYLIB_PATH. Tier 2 is disabled if no library is found. +ort_dylib_path = "" + +[model.ollama] +endpoint = "http://localhost:11434" +model = "fastflowlm" +confidence_threshold = 0.6 +enabled = true [reminders] default_morning = "08:00" missed_grace_minutes = 60 + +[calendar] +enabled = false +url = "" # e.g. https://cloud.example.com/remote.php/dav/calendars/user/personal/ +username = "" +# WARNING: password is stored in plaintext. Restrict file permissions: +# chmod 600 ~/.config/breadpad/breadpad.toml +# and keep this file out of version control. +password = "" diff --git a/breadpad/Cargo.toml b/breadpad/Cargo.toml index bd15153..c835bb9 100644 --- a/breadpad/Cargo.toml +++ b/breadpad/Cargo.toml @@ -12,6 +12,7 @@ path = "src/main.rs" [dependencies] breadpad-shared = { path = "../breadpad-shared" } anyhow.workspace = true +ort.workspace = true tracing.workspace = true tracing-subscriber.workspace = true serde.workspace = true diff --git a/breadpad/src/main.rs b/breadpad/src/main.rs index 3cd1d9a..c840557 100644 --- a/breadpad/src/main.rs +++ b/breadpad/src/main.rs @@ -12,7 +12,24 @@ use gtk4::{glib, prelude::*}; use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use std::cell::RefCell; use std::rc::Rc; -use std::sync::Arc; +use std::sync::{Arc, Once}; + +static ORT_INIT: Once = Once::new(); + +fn init_ort_once(cfg: &Config) { + ORT_INIT.call_once(|| { + let Some(path) = cfg.model.resolved_ort_dylib_path() else { return; }; + if !path.exists() { + tracing::warn!("ORT dylib not found at {:?}; Tier 2 disabled", path); + return; + } + tracing::info!("loading ONNX Runtime from {:?}", path); + match ort::init_from(&path) { + Ok(builder) => { builder.commit(); } + Err(e) => tracing::warn!("ORT init failed: {}; Tier 2 disabled", e), + } + }); +} mod args { #[derive(Debug)] @@ -89,7 +106,7 @@ fn main() -> Result<()> { return cmd_status(&cfg); } if args.download_model { - return cmd_download_model(); + return cmd_download_model(&cfg); } if args.model_info { return cmd_model_info(&cfg); @@ -108,9 +125,15 @@ fn main() -> Result<()> { } fn cmd_status(cfg: &Config) -> Result<()> { + init_ort_once(cfg); let store = Store::new()?; let notes = store.load_all()?; - let classifier = Classifier::load(&cfg.model.execution_provider, &cfg.reminders.default_morning); + let (model_path, tokenizer_path) = cfg.model.resolved_paths(); + let classifier = Classifier::load_with_paths( + &cfg.reminders.default_morning, + model_path, + tokenizer_path, + ); println!("breadpad status"); println!(" notes: {}", notes.len()); println!( @@ -126,7 +149,13 @@ fn cmd_status(cfg: &Config) -> Result<()> { } fn cmd_model_info(cfg: &Config) -> Result<()> { - let classifier = Classifier::load(&cfg.model.execution_provider, &cfg.reminders.default_morning); + init_ort_once(cfg); + let (model_path, tokenizer_path) = cfg.model.resolved_paths(); + let classifier = Classifier::load_with_paths( + &cfg.reminders.default_morning, + model_path, + tokenizer_path, + ); println!("model path: {:?}", classifier.model_path); println!("execution provider: {}", classifier.active_provider.as_str()); println!( @@ -136,16 +165,19 @@ fn cmd_model_info(cfg: &Config) -> Result<()> { Ok(()) } -fn cmd_download_model() -> Result<()> { +fn cmd_download_model(cfg: &Config) -> Result<()> { // Placeholder — a real implementation would download a quantised ONNX model. // The exact model URL is left for the user to configure. - let dir = dirs::data_local_dir() - .unwrap_or_else(|| std::path::PathBuf::from("~/.local/share")) - .join("breadpad") - .join("model"); - std::fs::create_dir_all(&dir)?; - println!("Model directory: {}", dir.display()); - println!("Place classifier.onnx and tokenizer.json in that directory."); + let (model_path, tokenizer_path) = cfg.model.resolved_paths(); + if let Some(dir) = model_path.parent() { + std::fs::create_dir_all(dir)?; + } + if let Some(dir) = tokenizer_path.parent() { + std::fs::create_dir_all(dir)?; + } + println!("Model path: {}", model_path.display()); + println!("Tokenizer path: {}", tokenizer_path.display()); + println!("Place the classifier ONNX and tokenizer JSON at those paths."); println!("(Automatic download not yet configured — set a model URL in breadpad.toml)"); Ok(()) } @@ -221,7 +253,7 @@ fn cmd_calendar_list_uid(note_id: &str, cfg: &Config) -> Result<()> { } fn cmd_fire(id: &str, cfg: &Config) -> Result<()> { - let store = Store::new()?; + let store = Store::new()?.with_calendar_if_enabled(cfg); let note = match store.get_by_id(id)? { Some(n) => n, None => { @@ -234,37 +266,7 @@ fn cmd_fire(id: &str, cfg: &Config) -> Result<()> { return Ok(()); } - // Send notification via notify-send - let title = format!("[{}] breadpad reminder", note.note_type); - - let mut cmd = std::process::Command::new("notify-send"); - cmd.arg("--urgency=normal") - .arg(format!("--app-name=breadpad")) - .arg(&title) - .arg(¬e.body); - for opt in &cfg.settings.snooze_options { - cmd.arg(format!("--action=snooze_{}={}", opt, humanize_snooze(opt))); - } - let output = cmd.output()?; - - // If the user clicked a snooze action, notify-send prints the action key - if let Ok(action) = String::from_utf8(output.stdout) { - let action = action.trim(); - if action.starts_with("snooze_") { - let key = action.trim_start_matches("snooze_"); - if let Some(until) = resolve_snooze(key, cfg) { - let mut updated = note.clone(); - store.update_note({ - updated.snoozed_until = Some(until); - &updated - })?; - Scheduler::schedule(&updated)?; - return Ok(()); - } - } - } - - // Handle recurrence + // Schedule next recurrence before showing UI if note.rrule.is_some() { if let Some(next) = Scheduler::next_recurrence(¬e, &cfg.reminders.default_morning) { let mut updated = note.clone(); @@ -275,6 +277,22 @@ fn cmd_fire(id: &str, cfg: &Config) -> Result<()> { } } + run_reminder_window(note, cfg) +} + +fn run_reminder_window(note: breadpad_shared::types::Note, cfg: &Config) -> Result<()> { + let app = gtk4::Application::builder() + .application_id("com.breadway.breadpad.reminder") + .build(); + + let note = Arc::new(note); + let cfg = Arc::new(cfg.clone()); + + app.connect_activate(move |app| { + build_reminder_window(app, note.clone(), cfg.clone()); + }); + + app.run_with_args::(&[]); Ok(()) } @@ -304,12 +322,195 @@ fn resolve_snooze(key: &str, cfg: &Config) -> Option None, } } +fn build_reminder_window( + app: >k4::Application, + note: Arc, + cfg: Arc, +) { + let window = gtk4::ApplicationWindow::builder() + .application(app) + .title("breadpad reminder") + .default_width(420) + .default_height(1) + .decorated(false) + .resizable(false) + .build(); + + window.init_layer_shell(); + window.set_layer(Layer::Overlay); + window.set_keyboard_mode(KeyboardMode::Exclusive); + window.auto_exclusive_zone_enable(); + + apply_css(&cfg); + + let type_emoji = match note.note_type.as_str() { + "reminder" => "🔔", + "todo" => "✅", + "idea" => "💡", + "question" => "❓", + _ => "📝", + }; + + let outer = gtk4::Box::builder() + .orientation(gtk4::Orientation::Vertical) + .spacing(0) + .css_classes(["reminder-window"]) + .build(); + + // Header strip + let header = gtk4::Box::builder() + .orientation(gtk4::Orientation::Horizontal) + .spacing(8) + .margin_top(16) + .margin_bottom(8) + .margin_start(20) + .margin_end(20) + .build(); + + header.append( + >k4::Label::builder() + .label(type_emoji) + .css_classes(["reminder-emoji"]) + .build(), + ); + header.append( + >k4::Label::builder() + .label("Reminder") + .css_classes(["reminder-title"]) + .hexpand(true) + .xalign(0.0) + .build(), + ); + + // Optional time label + if let Some(t) = note.effective_time() { + let local: chrono::DateTime = t.into(); + header.append( + >k4::Label::builder() + .label(&local.format("%H:%M").to_string()) + .css_classes(["reminder-time"]) + .build(), + ); + } + + outer.append(&header); + + // Body + let body_label = gtk4::Label::builder() + .label(¬e.body) + .css_classes(["reminder-body"]) + .wrap(true) + .xalign(0.0) + .margin_start(20) + .margin_end(20) + .margin_bottom(16) + .build(); + outer.append(&body_label); + + // Separator + outer.append(>k4::Separator::builder() + .orientation(gtk4::Orientation::Horizontal) + .build()); + + // Button row + let btn_row = gtk4::Box::builder() + .orientation(gtk4::Orientation::Horizontal) + .spacing(8) + .margin_top(12) + .margin_bottom(12) + .margin_start(16) + .margin_end(16) + .build(); + + let dismiss_btn = gtk4::Button::builder() + .label("Dismiss") + .css_classes(["reminder-dismiss"]) + .build(); + + // Snooze popover + let snooze_popover = gtk4::Popover::new(); + let snooze_vbox = gtk4::Box::builder() + .orientation(gtk4::Orientation::Vertical) + .spacing(4) + .margin_top(8) + .margin_bottom(8) + .margin_start(8) + .margin_end(8) + .build(); + + for opt in &cfg.settings.snooze_options { + let label = humanize_snooze(opt).to_string(); + let btn = gtk4::Button::builder() + .label(&label) + .css_classes(["snooze-option"]) + .build(); + let key = opt.clone(); + let note_c = note.clone(); + let cfg_c = cfg.clone(); + let win_c = window.clone(); + let popover_c = snooze_popover.clone(); + btn.connect_clicked(move |_| { + if let Some(until) = resolve_snooze(&key, &cfg_c) { + if let Ok(store) = Store::new().map(|s| s.with_calendar_if_enabled(&cfg_c)) { + let mut updated = note_c.as_ref().clone(); + updated.snoozed_until = Some(until); + let _ = store.update_note(&updated); + let _ = Scheduler::schedule(&updated); + } + } + popover_c.popdown(); + win_c.close(); + }); + snooze_vbox.append(&btn); + } + snooze_popover.set_child(Some(&snooze_vbox)); + + let snooze_btn = gtk4::MenuButton::builder() + .label("Snooze") + .css_classes(["reminder-snooze"]) + .popover(&snooze_popover) + .build(); + + let done_btn = gtk4::Button::builder() + .label("Done ✓") + .css_classes(["confirm-button", "reminder-done"]) + .hexpand(true) + .build(); + + { + let note_c = note.clone(); + let cfg_c = cfg.clone(); + let win_c = window.clone(); + done_btn.connect_clicked(move |_| { + if let Ok(store) = Store::new().map(|s| s.with_calendar_if_enabled(&cfg_c)) { + let mut updated = note_c.as_ref().clone(); + updated.mark_done(); + let _ = store.update_note(&updated); + } + win_c.close(); + }); + } + + { + let win_c = window.clone(); + dismiss_btn.connect_clicked(move |_| { win_c.close(); }); + } + + btn_row.append(&dismiss_btn); + btn_row.append(&snooze_btn); + btn_row.append(&done_btn); + outer.append(&btn_row); + + window.set_child(Some(&outer)); + window.present(); +} + fn run_popup(preset_type: Option, no_classify: bool, cfg: Config) -> Result<()> { // Try to get current Hyprland workspace let workspace = get_active_workspace(); @@ -321,10 +522,11 @@ fn run_popup(preset_type: Option, no_classify: bool, cfg: Config) -> Res let cfg = Arc::new(cfg); app.connect_activate(move |app| { - let cfg = cfg.clone(); - let workspace = workspace.clone(); - let preset_type = preset_type.clone(); - build_window(app, cfg, workspace, preset_type, no_classify); + if let Some(win) = app.windows().first().cloned() { + win.close(); + return; + } + build_window(app, cfg.clone(), workspace.clone(), preset_type.clone(), no_classify); }); let code = app.run_with_args::(&[]); @@ -475,12 +677,13 @@ fn build_window( return; } let note_type = selected_type.borrow().clone(); - - // Classify and save synchronously. Tier 1 + 2 finish in <100ms. - // Tier 3 (Ollama) only fires for ambiguous inputs; the brief pause - // is acceptable since the user has already committed the note. - save_note_classified(&text, note_type, no_classify, cfg.clone(), workspace.clone()); + let cfg_c = cfg.clone(); + let ws_c = workspace.clone(); + // Close first so the popup disappears immediately, then save. win.close(); + glib::idle_add_local_once(move || { + save_note_classified(&text, note_type, no_classify, cfg_c, ws_c); + }); } }; @@ -523,9 +726,12 @@ fn save_note_classified( let mut note = Note::new(text.into(), user_type.clone(), workspace); if !no_classify { - let mut classifier = Classifier::load( - &cfg.model.execution_provider, + init_ort_once(&cfg); + let (model_path, tokenizer_path) = cfg.model.resolved_paths(); + let mut classifier = Classifier::load_with_paths( &cfg.reminders.default_morning, + model_path, + tokenizer_path, ) .with_ollama(cfg.model.ollama.clone()); let result = classifier.classify(text); @@ -565,8 +771,12 @@ fn apply_css(_cfg: &Config) { let provider = gtk4::CssProvider::new(); provider.load_from_string(&css); + let Some(display) = gtk4::gdk::Display::default() else { + tracing::warn!("no default display; skipping CSS provider"); + return; + }; gtk4::style_context_add_provider_for_display( - >k4::gdk::Display::default().unwrap(), + &display, &provider, gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, ); diff --git a/breadpadcli b/breadpadcli deleted file mode 120000 index f1eb87b..0000000 --- a/breadpadcli +++ /dev/null @@ -1 +0,0 @@ -./target/release/breadpad \ No newline at end of file diff --git a/svgs.txt b/svgs.txt deleted file mode 100644 index 99d3d8a..0000000 --- a/svgs.txt +++ /dev/null @@ -1,102 +0,0 @@ -# SVG Icons for breadman -# Replace the placeholder emojis in breadman/src/main.rs and breadman/src/editor.rs -# with SVG-backed gtk4::Image widgets once you have the files. -# All icons should be single-color/symbolic so GTK can recolor them with CSS. -# Recommended source: Lucide (https://lucide.dev), Phosphor, or Material Symbols. - -## Sidebar — navigation items - -all-notes.svg - Placeholder: 📋 - Use: "All" view — a stack of pages or a grid of squares - Lucide suggestion: layout-grid, files, or layers - -calendar-clock.svg - Placeholder: 📅 - Use: "Upcoming" view — calendar with a clock overlay - Lucide suggestion: calendar-clock - -checkbox.svg - Placeholder: ✅ - Use: "Todo" type — empty or checked checkbox - Lucide suggestion: square-check or check-square - -bell.svg - Placeholder: 🔔 - Use: "Reminder" type — bell icon - Lucide suggestion: bell - -lightbulb.svg - Placeholder: 💡 - Use: "Idea" type — lightbulb - Lucide suggestion: lightbulb - -pencil-line.svg - Placeholder: 📝 - Use: "Note" type — pencil writing on a line - Lucide suggestion: pencil-line or file-text - -circle-help.svg - Placeholder: ❓ - Use: "Question" type — question mark in a circle - Lucide suggestion: circle-help or help-circle - -archive-box.svg - Placeholder: 📦 - Use: "Archive" view — box with down-arrow or archive tray - Lucide suggestion: archive or archive-restore - -settings-gear.svg - Placeholder: ⚙ - Use: "Settings" view — gear/cog - Lucide suggestion: settings or settings-2 - -triangle-alert.svg - Placeholder: ⚠ - Use: "Errors" view — triangle with exclamation mark - Lucide suggestion: triangle-alert or alert-triangle - -## Note card action buttons - -check.svg - Placeholder: ✓ - Use: "Mark done" action button on note cards - Lucide suggestion: check or circle-check - -pencil.svg - Placeholder: ✎ - Use: "Edit" action button on note cards - Lucide suggestion: pencil or pen - -trash.svg - Placeholder: 🗑 - Use: "Delete" action button on note cards and archive - Lucide suggestion: trash-2 - -## Note card metadata badges - -clock.svg - Placeholder: ⏰ (used inline in label text) - Use: Scheduled time indicator on note cards - Lucide suggestion: clock or alarm-clock - -repeat.svg - Placeholder: ↻ (used as type-chip label) - Use: Recurrence indicator on note cards - Lucide suggestion: repeat or refresh-cw - -## New Note button - -plus.svg - Placeholder: ✚ (used in "✚ New Note" button label) - Use: New note creation button in sidebar - Lucide suggestion: plus or plus-circle - -## Notes on integration -# When switching from emoji/text to SVG icons: -# 1. Use gtk4::Image::from_file() or load via gtk4::IconTheme for theme-aware icons. -# 2. For action buttons, replace the label with a gtk4::Image child: -# let btn = gtk4::Button::new(); -# btn.set_child(Some(>k4::Image::from_file("path/to/icon.svg"))); -# 3. Symbolic icons (named with "-symbolic" suffix in icon theme) follow the -# CSS color property automatically.