From c4626dd64da4761597e875eb8cf14f6ad64cda9e Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 12:25:40 +0800 Subject: [PATCH 01/52] Prepare repo for GitHub publication - Add MIT LICENSE file - Expand .gitignore with standard Rust/Linux entries - Remove dangling symlinks (breadmancli, breadpadcli) and dev scratchpad (svgs.txt) from git tracking - Replace unsafe unwrap() calls with expect() in breadman CLI (guarded by prior filter) --- .gitignore | 28 + Cargo.lock | 230 +---- Cargo.toml | 2 +- LICENSE | 21 + README.md | 31 +- bread.zip | Bin 53251 -> 0 bytes breadman/Cargo.toml | 1 + breadman/src/editor.rs | 137 ++- breadman/src/main.rs | 312 ++++--- breadman/src/views/settings.rs | 21 +- breadpad-shared/Cargo.toml | 1 + breadpad-shared/src/ai.rs | 16 +- breadpad-shared/src/calendar.rs | 307 ++++++- breadpad-shared/src/classifier.rs | 100 ++- breadpad-shared/src/config.rs | 42 +- breadpad-shared/src/lib.rs | 1 + breadpad-shared/src/parser.rs | 42 +- breadpad-shared/src/scheduler.rs | 110 ++- breadpad-shared/src/store.rs | 20 +- breadpad-shared/src/theme.rs | 98 ++- breadpad-shared/src/types.rs | 17 +- breadpad-shared/src/util.rs | 63 ++ breadpad-shared/tests/classifier.rs | 58 +- breadpad-shared/tests/config.rs | 184 +++- breadpad-shared/tests/pipeline.rs | 2 +- breadpad-shared/tests/store.rs | 45 + breadpad-test/Cargo.toml | 2 + breadpad-test/corpus.json | 1216 +++++++++++++++++++++++++++ breadpad-test/src/main.rs | 42 +- breadpad.example.toml | 19 +- breadpad/Cargo.toml | 1 + breadpad/src/main.rs | 324 +++++-- breadpadcli | 1 - svgs.txt | 102 --- 34 files changed, 2825 insertions(+), 771 deletions(-) create mode 100644 LICENSE delete mode 100644 bread.zip create mode 100644 breadpad-shared/src/util.rs delete mode 120000 breadpadcli delete mode 100644 svgs.txt 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 9ce30677fa9e6a0c8d42347c4ea7bd03efbe1239..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53251 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 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. From 347508828f2e25b6e3190b419bbb4227c951dfce Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 12:25:40 +0800 Subject: [PATCH 02/52] Prepare repo for GitHub publication - Add MIT LICENSE file - Expand .gitignore with standard Rust/Linux entries - Remove dangling symlinks (breadmancli, breadpadcli) and dev scratchpad (svgs.txt) from git tracking - Replace unsafe unwrap() calls with expect() in breadman CLI (guarded by prior filter) Co-Authored-By: Claude Haiku 4.5 --- .gitignore | 28 + Cargo.lock | 230 +---- Cargo.toml | 2 +- LICENSE | 21 + README.md | 31 +- bread.zip | Bin 53251 -> 0 bytes breadman/Cargo.toml | 1 + breadman/src/editor.rs | 137 ++- breadman/src/main.rs | 312 ++++--- breadman/src/views/settings.rs | 21 +- breadpad-shared/Cargo.toml | 1 + breadpad-shared/src/ai.rs | 16 +- breadpad-shared/src/calendar.rs | 307 ++++++- breadpad-shared/src/classifier.rs | 100 ++- breadpad-shared/src/config.rs | 42 +- breadpad-shared/src/lib.rs | 1 + breadpad-shared/src/parser.rs | 42 +- breadpad-shared/src/scheduler.rs | 110 ++- breadpad-shared/src/store.rs | 20 +- breadpad-shared/src/theme.rs | 98 ++- breadpad-shared/src/types.rs | 17 +- breadpad-shared/src/util.rs | 63 ++ breadpad-shared/tests/classifier.rs | 58 +- breadpad-shared/tests/config.rs | 184 +++- breadpad-shared/tests/pipeline.rs | 2 +- breadpad-shared/tests/store.rs | 45 + breadpad-test/Cargo.toml | 2 + breadpad-test/corpus.json | 1216 +++++++++++++++++++++++++++ breadpad-test/src/main.rs | 42 +- breadpad.example.toml | 19 +- breadpad/Cargo.toml | 1 + breadpad/src/main.rs | 324 +++++-- breadpadcli | 1 - svgs.txt | 102 --- 34 files changed, 2825 insertions(+), 771 deletions(-) create mode 100644 LICENSE delete mode 100644 bread.zip create mode 100644 breadpad-shared/src/util.rs delete mode 120000 breadpadcli delete mode 100644 svgs.txt 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 9ce30677fa9e6a0c8d42347c4ea7bd03efbe1239..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 53251 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 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. From 9537a12537173477d57d2ae31f55d3f5981f7e5a Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 22:31:38 +0800 Subject: [PATCH 03/52] Refactor theme onto bread-theme; add bakery.toml and release workflow - breadpad-shared/Cargo.toml: depend on bread-theme (no gtk feature needed in the shared crate) - breadpad-shared/src/theme.rs: re-export Palette and load_palette from bread-theme; retain all breadpad-specific CSS in build_css() - bakery.toml: describes breadpad for bakery install - release.yml: builds on hestia self-hosted runner, publishes binaries to dl.breadway.dev and GitHub Releases on v* tags --- .github/workflows/release.yml | 61 ++++++++++ Cargo.lock | 10 ++ bakery.toml | 14 +++ breadpad-shared/Cargo.toml | 3 + breadpad-shared/src/theme.rs | 218 +--------------------------------- 5 files changed, 92 insertions(+), 214 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 bakery.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4d3ffa7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,61 @@ +name: release + +on: + push: + tags: ["v*"] + +env: + DL_DIR: /srv/breadway-dl + ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - uses: actions/checkout@v4 + + - name: install system deps + run: sudo pacman -S --noconfirm gtk4 gtk4-layer-shell 2>/dev/null || true + + - name: build + run: cargo build --release --locked + + - name: prepare artifacts + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/breadpad/${VERSION}" + mkdir -p "${PKG_DIR}" + for bin in breadpad breadman; do + cp "target/release/${bin}" "${PKG_DIR}/${bin}-x86_64" + strip "${PKG_DIR}/${bin}-x86_64" + sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/${bin}-x86_64.sha256" + done + cp breadpad.example.toml "${PKG_DIR}/" + cp bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${PKG_DIR}" "${DL_DIR}/breadpad/latest" + + - name: ensure bread-ecosystem + run: | + if [[ -d "${ECOSYSTEM_DIR}/.git" ]]; then + git -C "${ECOSYSTEM_DIR}" pull --ff-only + else + mkdir -p "$(dirname "${ECOSYSTEM_DIR}")" + git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}" + fi + + - name: regenerate index.json + run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh" + + - name: upload to GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/breadpad/${VERSION}" + gh release upload "${GITHUB_REF_NAME}" \ + "${PKG_DIR}/breadpad-x86_64" \ + "${PKG_DIR}/breadman-x86_64" \ + "${PKG_DIR}/breadpad-x86_64.sha256" \ + "${PKG_DIR}/breadman-x86_64.sha256" \ + --clobber diff --git a/Cargo.lock b/Cargo.lock index 16ce198..0367649 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -302,6 +302,15 @@ dependencies = [ "piper", ] +[[package]] +name = "bread-theme" +version = "0.1.0" +dependencies = [ + "dirs 5.0.1", + "serde", + "serde_json", +] + [[package]] name = "breadman" version = "0.1.0" @@ -343,6 +352,7 @@ name = "breadpad-shared" version = "0.1.0" dependencies = [ "anyhow", + "bread-theme", "chrono", "dirs 5.0.1", "ical", diff --git a/bakery.toml b/bakery.toml new file mode 100644 index 0000000..e1c028d --- /dev/null +++ b/bakery.toml @@ -0,0 +1,14 @@ +name = "breadpad" +description = "Quick-capture scratchpad and note viewer with AI classification" +binaries = ["breadpad", "breadman"] +system_deps = ["gtk4", "gtk4-layer-shell"] +bread_deps = [] + +[config] +dir = "~/.config/breadpad" +example = "breadpad.example.toml" + +[install] +post_install = [ + "mkdir -p ~/.local/share/breadpad/model", +] diff --git a/breadpad-shared/Cargo.toml b/breadpad-shared/Cargo.toml index a4b3ddb..5d6d26a 100644 --- a/breadpad-shared/Cargo.toml +++ b/breadpad-shared/Cargo.toml @@ -7,6 +7,9 @@ authors.workspace = true [dependencies] +# Path dep for local dev; replace with git dep on first tag: +# bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "theme-v0.1.0" } +bread-theme = { path = "../../bread-ecosystem/bread-theme" } anyhow.workspace = true tracing.workspace = true serde.workspace = true diff --git a/breadpad-shared/src/theme.rs b/breadpad-shared/src/theme.rs index 64192d9..adc76dc 100644 --- a/breadpad-shared/src/theme.rs +++ b/breadpad-shared/src/theme.rs @@ -1,83 +1,7 @@ -use serde::Deserialize; -use std::collections::HashMap; -use std::path::PathBuf; - -#[derive(Debug, Clone)] -pub struct Palette { - pub background: String, - pub foreground: String, - pub color0: String, - pub color1: String, - pub color2: String, - pub color3: String, - pub color4: String, - pub color5: String, - pub color6: String, - pub color7: String, -} - -// Catppuccin Mocha fallback -impl Default for Palette { - fn default() -> Self { - Palette { - background: "#1e1e2e".into(), - foreground: "#cdd6f4".into(), - color0: "#45475a".into(), - color1: "#f38ba8".into(), - color2: "#a6e3a1".into(), - color3: "#f9e2af".into(), - color4: "#89b4fa".into(), - color5: "#f5c2e7".into(), - color6: "#94e2d5".into(), - color7: "#bac2de".into(), - } - } -} - -#[derive(Debug, Deserialize)] -struct WalColors { - #[serde(default)] - colors: HashMap, - special: Option, -} - -#[derive(Debug, Deserialize)] -struct WalSpecial { - background: Option, - foreground: Option, -} - -pub(crate) fn palette_from_wal_json(json: &str) -> Option { - let wal: WalColors = serde_json::from_str(json).ok()?; - Some(Palette { - background: wal.special.as_ref().and_then(|s| s.background.clone()).unwrap_or_else(|| "#1e1e2e".into()), - foreground: wal.special.as_ref().and_then(|s| s.foreground.clone()).unwrap_or_else(|| "#cdd6f4".into()), - color0: wal.colors.get("color0").cloned().unwrap_or_else(|| "#45475a".into()), - color1: wal.colors.get("color1").cloned().unwrap_or_else(|| "#f38ba8".into()), - color2: wal.colors.get("color2").cloned().unwrap_or_else(|| "#a6e3a1".into()), - color3: wal.colors.get("color3").cloned().unwrap_or_else(|| "#f9e2af".into()), - color4: wal.colors.get("color4").cloned().unwrap_or_else(|| "#89b4fa".into()), - color5: wal.colors.get("color5").cloned().unwrap_or_else(|| "#f5c2e7".into()), - color6: wal.colors.get("color6").cloned().unwrap_or_else(|| "#94e2d5".into()), - color7: wal.colors.get("color7").cloned().unwrap_or_else(|| "#bac2de".into()), - }) -} - -pub fn load_palette() -> Palette { - let wal_path = wal_colors_path(); - if !wal_path.exists() { - return Palette::default(); - } - match std::fs::read_to_string(&wal_path) - .ok() - .and_then(|s| palette_from_wal_json(&s)) - { - Some(wal) => wal, - None => Palette::default(), - } -} - +pub use bread_theme::{load_palette, Palette}; +/// Generate the full breadpad CSS string. The base colour variables come from +/// `bread-theme`; the widget rules below are breadpad-specific. pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String { let mut css = format!( r#" @@ -317,125 +241,21 @@ entry:focus { css } -fn wal_colors_path() -> PathBuf { - dirs::cache_dir() - .unwrap_or_else(|| PathBuf::from("~/.cache")) - .join("wal") - .join("colors.json") -} - #[cfg(test)] mod tests { use super::*; - const TOKYO_NIGHT_WAL: &str = r##"{ - "special": { - "background": "#1a1b26", - "foreground": "#c0caf5" - }, - "colors": { - "color0": "#15161e", - "color1": "#f7768e", - "color2": "#9ece6a", - "color3": "#e0af68", - "color4": "#7aa2f7", - "color5": "#bb9af7", - "color6": "#7dcfff", - "color7": "#a9b1d6" - } - }"##; - - // ---- Default palette (Catppuccin Mocha) ---- - - #[test] - fn default_background_is_catppuccin_mocha() { - assert_eq!(Palette::default().background, "#1e1e2e"); - } - - #[test] - fn default_foreground_is_catppuccin_mocha() { - assert_eq!(Palette::default().foreground, "#cdd6f4"); - } - - #[test] - fn default_red_is_catppuccin_mocha() { - assert_eq!(Palette::default().color1, "#f38ba8"); - } - - #[test] - fn default_blue_is_catppuccin_mocha() { - assert_eq!(Palette::default().color4, "#89b4fa"); - } - - #[test] - fn default_teal_is_catppuccin_mocha() { - assert_eq!(Palette::default().color6, "#94e2d5"); - } - - // ---- palette_from_wal_json ---- - - #[test] - fn wal_json_parses_special_background() { - let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap(); - assert_eq!(p.background, "#1a1b26"); - } - - #[test] - fn wal_json_parses_special_foreground() { - let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap(); - assert_eq!(p.foreground, "#c0caf5"); - } - - #[test] - fn wal_json_parses_numbered_colors() { - let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap(); - assert_eq!(p.color0, "#15161e"); - assert_eq!(p.color1, "#f7768e"); - assert_eq!(p.color4, "#7aa2f7"); - assert_eq!(p.color7, "#a9b1d6"); - } - - #[test] - fn wal_json_missing_special_falls_back_to_defaults() { - let json = r##"{"colors":{"color0":"#000000"}}"##; - let p = palette_from_wal_json(json).unwrap(); - assert_eq!(p.background, "#1e1e2e"); - assert_eq!(p.foreground, "#cdd6f4"); - } - - #[test] - fn wal_json_missing_color_falls_back_to_default() { - let json = r##"{"special":{"background":"#ff0000","foreground":"#ffffff"},"colors":{}}"##; - let p = palette_from_wal_json(json).unwrap(); - assert_eq!(p.color4, "#89b4fa"); // default blue - } - - #[test] - fn invalid_wal_json_returns_none() { - assert!(palette_from_wal_json("not json").is_none()); - assert!(palette_from_wal_json("").is_none()); - assert!(palette_from_wal_json("{}").is_some()); // empty but valid → all defaults - } - - // ---- build_css ---- - #[test] fn css_defines_bg_color() { let css = build_css(&Palette::default(), None); assert!(css.contains("@define-color bg #1e1e2e"), "css missing bg: {}", &css[..300]); } - #[test] - fn css_defines_fg_color() { - let css = build_css(&Palette::default(), None); - assert!(css.contains("@define-color fg #cdd6f4")); - } - #[test] fn css_defines_all_named_colors() { let css = build_css(&Palette::default(), None); for name in &["red", "green", "yellow", "blue", "pink", "teal", "overlay"] { - assert!(css.contains(&format!("@define-color {} ", name)), "missing @define-color {}", name); + assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}"); } } @@ -458,18 +278,6 @@ mod tests { assert!(css.contains(".note-card {")); } - #[test] - fn css_contains_type_chip_class() { - let css = build_css(&Palette::default(), None); - assert!(css.contains(".type-chip {")); - } - - #[test] - fn css_contains_sidebar_row_class() { - let css = build_css(&Palette::default(), None); - assert!(css.contains(".sidebar-row {")); - } - #[test] fn css_appends_user_css() { let user = ".my-override { color: hotpink; }"; @@ -492,22 +300,4 @@ mod tests { assert!(css.contains("@define-color bg #deadbe"), "css: {}", &css[..300]); assert!(css.contains("@define-color blue #cafe00"), "css: {}", &css[..300]); } - - #[test] - fn css_from_wal_palette_uses_wal_colors() { - let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap(); - let css = build_css(&p, None); - assert!(css.contains("@define-color bg #1a1b26"), "css: {}", &css[..300]); - assert!(css.contains("@define-color fg #c0caf5")); - } - - #[test] - fn load_palette_returns_valid_palette() { - // No wal file in CI/test env; should return non-empty strings starting with # - let palette = load_palette(); - assert!(!palette.background.is_empty()); - assert!(palette.background.starts_with('#'), "bg: {}", palette.background); - assert!(!palette.foreground.is_empty()); - assert!(palette.color4.starts_with('#')); - } } From a2281607bb87bc7fa7d7b1e75b1762a6a5aaa69c Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 22:31:38 +0800 Subject: [PATCH 04/52] Refactor theme onto bread-theme; add bakery.toml and release workflow - breadpad-shared/Cargo.toml: depend on bread-theme (no gtk feature needed in the shared crate) - breadpad-shared/src/theme.rs: re-export Palette and load_palette from bread-theme; retain all breadpad-specific CSS in build_css() - bakery.toml: describes breadpad for bakery install - release.yml: builds on hestia self-hosted runner, publishes binaries to dl.breadway.dev and GitHub Releases on v* tags Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 61 ++++++++++ Cargo.lock | 10 ++ bakery.toml | 14 +++ breadpad-shared/Cargo.toml | 3 + breadpad-shared/src/theme.rs | 218 +--------------------------------- 5 files changed, 92 insertions(+), 214 deletions(-) create mode 100644 .github/workflows/release.yml create mode 100644 bakery.toml diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..4d3ffa7 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,61 @@ +name: release + +on: + push: + tags: ["v*"] + +env: + DL_DIR: /srv/breadway-dl + ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem + +jobs: + build: + runs-on: [self-hosted, hestia] + steps: + - uses: actions/checkout@v4 + + - name: install system deps + run: sudo pacman -S --noconfirm gtk4 gtk4-layer-shell 2>/dev/null || true + + - name: build + run: cargo build --release --locked + + - name: prepare artifacts + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/breadpad/${VERSION}" + mkdir -p "${PKG_DIR}" + for bin in breadpad breadman; do + cp "target/release/${bin}" "${PKG_DIR}/${bin}-x86_64" + strip "${PKG_DIR}/${bin}-x86_64" + sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \ + > "${PKG_DIR}/${bin}-x86_64.sha256" + done + cp breadpad.example.toml "${PKG_DIR}/" + cp bakery.toml "${PKG_DIR}/bakery.toml" + ln -sfn "${PKG_DIR}" "${DL_DIR}/breadpad/latest" + + - name: ensure bread-ecosystem + run: | + if [[ -d "${ECOSYSTEM_DIR}/.git" ]]; then + git -C "${ECOSYSTEM_DIR}" pull --ff-only + else + mkdir -p "$(dirname "${ECOSYSTEM_DIR}")" + git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}" + fi + + - name: regenerate index.json + run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh" + + - name: upload to GitHub Release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: | + VERSION="${GITHUB_REF_NAME#v}" + PKG_DIR="${DL_DIR}/breadpad/${VERSION}" + gh release upload "${GITHUB_REF_NAME}" \ + "${PKG_DIR}/breadpad-x86_64" \ + "${PKG_DIR}/breadman-x86_64" \ + "${PKG_DIR}/breadpad-x86_64.sha256" \ + "${PKG_DIR}/breadman-x86_64.sha256" \ + --clobber diff --git a/Cargo.lock b/Cargo.lock index 16ce198..0367649 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -302,6 +302,15 @@ dependencies = [ "piper", ] +[[package]] +name = "bread-theme" +version = "0.1.0" +dependencies = [ + "dirs 5.0.1", + "serde", + "serde_json", +] + [[package]] name = "breadman" version = "0.1.0" @@ -343,6 +352,7 @@ name = "breadpad-shared" version = "0.1.0" dependencies = [ "anyhow", + "bread-theme", "chrono", "dirs 5.0.1", "ical", diff --git a/bakery.toml b/bakery.toml new file mode 100644 index 0000000..e1c028d --- /dev/null +++ b/bakery.toml @@ -0,0 +1,14 @@ +name = "breadpad" +description = "Quick-capture scratchpad and note viewer with AI classification" +binaries = ["breadpad", "breadman"] +system_deps = ["gtk4", "gtk4-layer-shell"] +bread_deps = [] + +[config] +dir = "~/.config/breadpad" +example = "breadpad.example.toml" + +[install] +post_install = [ + "mkdir -p ~/.local/share/breadpad/model", +] diff --git a/breadpad-shared/Cargo.toml b/breadpad-shared/Cargo.toml index a4b3ddb..5d6d26a 100644 --- a/breadpad-shared/Cargo.toml +++ b/breadpad-shared/Cargo.toml @@ -7,6 +7,9 @@ authors.workspace = true [dependencies] +# Path dep for local dev; replace with git dep on first tag: +# bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "theme-v0.1.0" } +bread-theme = { path = "../../bread-ecosystem/bread-theme" } anyhow.workspace = true tracing.workspace = true serde.workspace = true diff --git a/breadpad-shared/src/theme.rs b/breadpad-shared/src/theme.rs index 64192d9..adc76dc 100644 --- a/breadpad-shared/src/theme.rs +++ b/breadpad-shared/src/theme.rs @@ -1,83 +1,7 @@ -use serde::Deserialize; -use std::collections::HashMap; -use std::path::PathBuf; - -#[derive(Debug, Clone)] -pub struct Palette { - pub background: String, - pub foreground: String, - pub color0: String, - pub color1: String, - pub color2: String, - pub color3: String, - pub color4: String, - pub color5: String, - pub color6: String, - pub color7: String, -} - -// Catppuccin Mocha fallback -impl Default for Palette { - fn default() -> Self { - Palette { - background: "#1e1e2e".into(), - foreground: "#cdd6f4".into(), - color0: "#45475a".into(), - color1: "#f38ba8".into(), - color2: "#a6e3a1".into(), - color3: "#f9e2af".into(), - color4: "#89b4fa".into(), - color5: "#f5c2e7".into(), - color6: "#94e2d5".into(), - color7: "#bac2de".into(), - } - } -} - -#[derive(Debug, Deserialize)] -struct WalColors { - #[serde(default)] - colors: HashMap, - special: Option, -} - -#[derive(Debug, Deserialize)] -struct WalSpecial { - background: Option, - foreground: Option, -} - -pub(crate) fn palette_from_wal_json(json: &str) -> Option { - let wal: WalColors = serde_json::from_str(json).ok()?; - Some(Palette { - background: wal.special.as_ref().and_then(|s| s.background.clone()).unwrap_or_else(|| "#1e1e2e".into()), - foreground: wal.special.as_ref().and_then(|s| s.foreground.clone()).unwrap_or_else(|| "#cdd6f4".into()), - color0: wal.colors.get("color0").cloned().unwrap_or_else(|| "#45475a".into()), - color1: wal.colors.get("color1").cloned().unwrap_or_else(|| "#f38ba8".into()), - color2: wal.colors.get("color2").cloned().unwrap_or_else(|| "#a6e3a1".into()), - color3: wal.colors.get("color3").cloned().unwrap_or_else(|| "#f9e2af".into()), - color4: wal.colors.get("color4").cloned().unwrap_or_else(|| "#89b4fa".into()), - color5: wal.colors.get("color5").cloned().unwrap_or_else(|| "#f5c2e7".into()), - color6: wal.colors.get("color6").cloned().unwrap_or_else(|| "#94e2d5".into()), - color7: wal.colors.get("color7").cloned().unwrap_or_else(|| "#bac2de".into()), - }) -} - -pub fn load_palette() -> Palette { - let wal_path = wal_colors_path(); - if !wal_path.exists() { - return Palette::default(); - } - match std::fs::read_to_string(&wal_path) - .ok() - .and_then(|s| palette_from_wal_json(&s)) - { - Some(wal) => wal, - None => Palette::default(), - } -} - +pub use bread_theme::{load_palette, Palette}; +/// Generate the full breadpad CSS string. The base colour variables come from +/// `bread-theme`; the widget rules below are breadpad-specific. pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String { let mut css = format!( r#" @@ -317,125 +241,21 @@ entry:focus { css } -fn wal_colors_path() -> PathBuf { - dirs::cache_dir() - .unwrap_or_else(|| PathBuf::from("~/.cache")) - .join("wal") - .join("colors.json") -} - #[cfg(test)] mod tests { use super::*; - const TOKYO_NIGHT_WAL: &str = r##"{ - "special": { - "background": "#1a1b26", - "foreground": "#c0caf5" - }, - "colors": { - "color0": "#15161e", - "color1": "#f7768e", - "color2": "#9ece6a", - "color3": "#e0af68", - "color4": "#7aa2f7", - "color5": "#bb9af7", - "color6": "#7dcfff", - "color7": "#a9b1d6" - } - }"##; - - // ---- Default palette (Catppuccin Mocha) ---- - - #[test] - fn default_background_is_catppuccin_mocha() { - assert_eq!(Palette::default().background, "#1e1e2e"); - } - - #[test] - fn default_foreground_is_catppuccin_mocha() { - assert_eq!(Palette::default().foreground, "#cdd6f4"); - } - - #[test] - fn default_red_is_catppuccin_mocha() { - assert_eq!(Palette::default().color1, "#f38ba8"); - } - - #[test] - fn default_blue_is_catppuccin_mocha() { - assert_eq!(Palette::default().color4, "#89b4fa"); - } - - #[test] - fn default_teal_is_catppuccin_mocha() { - assert_eq!(Palette::default().color6, "#94e2d5"); - } - - // ---- palette_from_wal_json ---- - - #[test] - fn wal_json_parses_special_background() { - let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap(); - assert_eq!(p.background, "#1a1b26"); - } - - #[test] - fn wal_json_parses_special_foreground() { - let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap(); - assert_eq!(p.foreground, "#c0caf5"); - } - - #[test] - fn wal_json_parses_numbered_colors() { - let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap(); - assert_eq!(p.color0, "#15161e"); - assert_eq!(p.color1, "#f7768e"); - assert_eq!(p.color4, "#7aa2f7"); - assert_eq!(p.color7, "#a9b1d6"); - } - - #[test] - fn wal_json_missing_special_falls_back_to_defaults() { - let json = r##"{"colors":{"color0":"#000000"}}"##; - let p = palette_from_wal_json(json).unwrap(); - assert_eq!(p.background, "#1e1e2e"); - assert_eq!(p.foreground, "#cdd6f4"); - } - - #[test] - fn wal_json_missing_color_falls_back_to_default() { - let json = r##"{"special":{"background":"#ff0000","foreground":"#ffffff"},"colors":{}}"##; - let p = palette_from_wal_json(json).unwrap(); - assert_eq!(p.color4, "#89b4fa"); // default blue - } - - #[test] - fn invalid_wal_json_returns_none() { - assert!(palette_from_wal_json("not json").is_none()); - assert!(palette_from_wal_json("").is_none()); - assert!(palette_from_wal_json("{}").is_some()); // empty but valid → all defaults - } - - // ---- build_css ---- - #[test] fn css_defines_bg_color() { let css = build_css(&Palette::default(), None); assert!(css.contains("@define-color bg #1e1e2e"), "css missing bg: {}", &css[..300]); } - #[test] - fn css_defines_fg_color() { - let css = build_css(&Palette::default(), None); - assert!(css.contains("@define-color fg #cdd6f4")); - } - #[test] fn css_defines_all_named_colors() { let css = build_css(&Palette::default(), None); for name in &["red", "green", "yellow", "blue", "pink", "teal", "overlay"] { - assert!(css.contains(&format!("@define-color {} ", name)), "missing @define-color {}", name); + assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}"); } } @@ -458,18 +278,6 @@ mod tests { assert!(css.contains(".note-card {")); } - #[test] - fn css_contains_type_chip_class() { - let css = build_css(&Palette::default(), None); - assert!(css.contains(".type-chip {")); - } - - #[test] - fn css_contains_sidebar_row_class() { - let css = build_css(&Palette::default(), None); - assert!(css.contains(".sidebar-row {")); - } - #[test] fn css_appends_user_css() { let user = ".my-override { color: hotpink; }"; @@ -492,22 +300,4 @@ mod tests { assert!(css.contains("@define-color bg #deadbe"), "css: {}", &css[..300]); assert!(css.contains("@define-color blue #cafe00"), "css: {}", &css[..300]); } - - #[test] - fn css_from_wal_palette_uses_wal_colors() { - let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap(); - let css = build_css(&p, None); - assert!(css.contains("@define-color bg #1a1b26"), "css: {}", &css[..300]); - assert!(css.contains("@define-color fg #c0caf5")); - } - - #[test] - fn load_palette_returns_valid_palette() { - // No wal file in CI/test env; should return non-empty strings starting with # - let palette = load_palette(); - assert!(!palette.background.is_empty()); - assert!(palette.background.starts_with('#'), "bg: {}", palette.background); - assert!(!palette.foreground.is_empty()); - assert!(palette.color4.starts_with('#')); - } } From 66a8dc9a14dde4a2b8c52d61b693d0d8f04d65bb Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 22:48:03 +0800 Subject: [PATCH 05/52] fix: use apt-get on hestia runner (Ubuntu, not Arch) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d3ffa7..7ae87bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v4 - name: install system deps - run: sudo pacman -S --noconfirm gtk4 gtk4-layer-shell 2>/dev/null || true + run: sudo apt-get install -y libgtk-4-dev 2>/dev/null || true - name: build run: cargo build --release --locked From 6a4a455ad9770de2f0db294ea318d9604737e68a Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 22:48:03 +0800 Subject: [PATCH 06/52] fix: use apt-get on hestia runner (Ubuntu, not Arch) --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4d3ffa7..7ae87bc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -15,7 +15,7 @@ jobs: - uses: actions/checkout@v4 - name: install system deps - run: sudo pacman -S --noconfirm gtk4 gtk4-layer-shell 2>/dev/null || true + run: sudo apt-get install -y libgtk-4-dev 2>/dev/null || true - name: build run: cargo build --release --locked From 59be59f7d541c5f34230ea8bcf7bb573ff376aec Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:20:07 +0800 Subject: [PATCH 07/52] fix: add missing build deps for hestia (Ubuntu) runner --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ae87bc..ea7a875 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,8 +14,8 @@ jobs: steps: - uses: actions/checkout@v4 - - name: install system deps - run: sudo apt-get install -y libgtk-4-dev 2>/dev/null || true + - name: install build deps + run: sudo apt-get install -y libgtk-4-dev libdbus-1-dev pkg-config 2>/dev/null || true - name: build run: cargo build --release --locked From 1240d97892eb74c675479239b5b04f65c6dfbd4c Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:20:07 +0800 Subject: [PATCH 08/52] fix: add missing build deps for hestia (Ubuntu) runner --- .github/workflows/release.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7ae87bc..ea7a875 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,8 +14,8 @@ jobs: steps: - uses: actions/checkout@v4 - - name: install system deps - run: sudo apt-get install -y libgtk-4-dev 2>/dev/null || true + - name: install build deps + run: sudo apt-get install -y libgtk-4-dev libdbus-1-dev pkg-config 2>/dev/null || true - name: build run: cargo build --release --locked From 2987e0373ef3d2f62e1677fff335598476b25d96 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:26:54 +0800 Subject: [PATCH 09/52] fix: switch bread-theme to git dep (v0.1.0) for CI --- Cargo.lock | 91 +++++++++++++++++++------------------- breadpad-shared/Cargo.toml | 4 +- 2 files changed, 47 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0367649..d08b439 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -276,9 +276,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "block-buffer" @@ -305,6 +305,7 @@ dependencies = [ [[package]] name = "bread-theme" version = "0.1.0" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.1.0#6b5f4f475f66a645b08cb865e6dda8228d23679b" dependencies = [ "dirs 5.0.1", "serde", @@ -441,9 +442,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "shlex", @@ -451,9 +452,9 @@ dependencies = [ [[package]] name = "cfg-expr" -version = "0.20.7" +version = "0.20.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" dependencies = [ "smallvec", "target-lexicon", @@ -473,9 +474,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -576,9 +577,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -854,9 +855,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -1500,9 +1501,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -1539,9 +1540,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -1873,9 +1874,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "libc", ] @@ -1909,9 +1910,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lru-slab" @@ -1956,9 +1957,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memoffset" @@ -1987,9 +1988,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -2364,7 +2365,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -2868,9 +2869,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -2908,9 +2909,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3009,9 +3010,9 @@ dependencies = [ [[package]] name = "target-lexicon" -version = "0.13.3" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "tempfile" @@ -3234,9 +3235,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", @@ -3379,9 +3380,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uds_windows" @@ -3411,9 +3412,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -3484,9 +3485,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -4119,9 +4120,9 @@ checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4199,18 +4200,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", diff --git a/breadpad-shared/Cargo.toml b/breadpad-shared/Cargo.toml index 5d6d26a..610c040 100644 --- a/breadpad-shared/Cargo.toml +++ b/breadpad-shared/Cargo.toml @@ -7,9 +7,7 @@ authors.workspace = true [dependencies] -# Path dep for local dev; replace with git dep on first tag: -# bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "theme-v0.1.0" } -bread-theme = { path = "../../bread-ecosystem/bread-theme" } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.1.0" } anyhow.workspace = true tracing.workspace = true serde.workspace = true From c147962da64b3f7c2477fa05c9554c3f3331d7c1 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:26:54 +0800 Subject: [PATCH 10/52] fix: switch bread-theme to git dep (v0.1.0) for CI --- Cargo.lock | 91 +++++++++++++++++++------------------- breadpad-shared/Cargo.toml | 4 +- 2 files changed, 47 insertions(+), 48 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 0367649..d08b439 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -276,9 +276,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "block-buffer" @@ -305,6 +305,7 @@ dependencies = [ [[package]] name = "bread-theme" version = "0.1.0" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.1.0#6b5f4f475f66a645b08cb865e6dda8228d23679b" dependencies = [ "dirs 5.0.1", "serde", @@ -441,9 +442,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "shlex", @@ -451,9 +452,9 @@ dependencies = [ [[package]] name = "cfg-expr" -version = "0.20.7" +version = "0.20.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" +checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c" dependencies = [ "smallvec", "target-lexicon", @@ -473,9 +474,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -576,9 +577,9 @@ dependencies = [ [[package]] name = "compact_str" -version = "0.9.0" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" +checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab" dependencies = [ "castaway", "cfg-if", @@ -854,9 +855,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -1500,9 +1501,9 @@ dependencies = [ [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -1539,9 +1540,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -1873,9 +1874,9 @@ dependencies = [ [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "libc", ] @@ -1909,9 +1910,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "lru-slab" @@ -1956,9 +1957,9 @@ dependencies = [ [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memoffset" @@ -1987,9 +1988,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "wasi", @@ -2364,7 +2365,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -2868,9 +2869,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook-registry" @@ -2908,9 +2909,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3009,9 +3010,9 @@ dependencies = [ [[package]] name = "target-lexicon" -version = "0.13.3" +version = "0.13.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" +checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca" [[package]] name = "tempfile" @@ -3234,9 +3235,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap", "toml_datetime 1.1.1+spec-1.1.0", @@ -3379,9 +3380,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uds_windows" @@ -3411,9 +3412,9 @@ dependencies = [ [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-width" @@ -3484,9 +3485,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -4119,9 +4120,9 @@ checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f" [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -4199,18 +4200,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", diff --git a/breadpad-shared/Cargo.toml b/breadpad-shared/Cargo.toml index 5d6d26a..610c040 100644 --- a/breadpad-shared/Cargo.toml +++ b/breadpad-shared/Cargo.toml @@ -7,9 +7,7 @@ authors.workspace = true [dependencies] -# Path dep for local dev; replace with git dep on first tag: -# bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "theme-v0.1.0" } -bread-theme = { path = "../../bread-ecosystem/bread-theme" } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.1.0" } anyhow.workspace = true tracing.workspace = true serde.workspace = true From fc3af84e5ea9538870b928b11651a0e3ca9cdd53 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:52:54 +0800 Subject: [PATCH 11/52] fix: create GitHub Release before uploading artifacts --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea7a875..92f2dec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,6 +53,8 @@ jobs: run: | VERSION="${GITHUB_REF_NAME#v}" PKG_DIR="${DL_DIR}/breadpad/${VERSION}" + gh release create "${GITHUB_REF_NAME}" \ + --title "breadpad v${VERSION}" --generate-notes 2>/dev/null || true gh release upload "${GITHUB_REF_NAME}" \ "${PKG_DIR}/breadpad-x86_64" \ "${PKG_DIR}/breadman-x86_64" \ From 66674fa5494edfd2a2aa024d7d1604da5abc457d Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 6 Jun 2026 23:52:54 +0800 Subject: [PATCH 12/52] fix: create GitHub Release before uploading artifacts --- .github/workflows/release.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ea7a875..92f2dec 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -53,6 +53,8 @@ jobs: run: | VERSION="${GITHUB_REF_NAME#v}" PKG_DIR="${DL_DIR}/breadpad/${VERSION}" + gh release create "${GITHUB_REF_NAME}" \ + --title "breadpad v${VERSION}" --generate-notes 2>/dev/null || true gh release upload "${GITHUB_REF_NAME}" \ "${PKG_DIR}/breadpad-x86_64" \ "${PKG_DIR}/breadman-x86_64" \ From f04a87e4768ca0a24d98d709c746428ea45396b3 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 00:00:52 +0800 Subject: [PATCH 13/52] fix: add contents: write permission for GitHub Release creation --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 92f2dec..e8c3b99 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,9 @@ on: push: tags: ["v*"] +permissions: + contents: write + env: DL_DIR: /srv/breadway-dl ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem From 160d4b52b99f05000a9c2b6ad6c0638778a87892 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 00:00:52 +0800 Subject: [PATCH 14/52] fix: add contents: write permission for GitHub Release creation Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 92f2dec..e8c3b99 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -4,6 +4,9 @@ on: push: tags: ["v*"] +permissions: + contents: write + env: DL_DIR: /srv/breadway-dl ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem From 708eb8f3b42f7cc175570c165528cbe495bfb36d Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 09:02:38 +0800 Subject: [PATCH 15/52] fix: use relative symlink for latest to work inside Docker containers --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8c3b99..8df4368 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: done cp breadpad.example.toml "${PKG_DIR}/" cp bakery.toml "${PKG_DIR}/bakery.toml" - ln -sfn "${PKG_DIR}" "${DL_DIR}/breadpad/latest" + ln -sfn "${VERSION}" "${DL_DIR}/breadpad/latest" - name: ensure bread-ecosystem run: | From efb3af70de6392073e3e97b416231a761c1adb6d Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 09:02:38 +0800 Subject: [PATCH 16/52] fix: use relative symlink for latest to work inside Docker containers Co-Authored-By: Claude Sonnet 4.6 --- .github/workflows/release.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index e8c3b99..8df4368 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,7 +36,7 @@ jobs: done cp breadpad.example.toml "${PKG_DIR}/" cp bakery.toml "${PKG_DIR}/bakery.toml" - ln -sfn "${PKG_DIR}" "${DL_DIR}/breadpad/latest" + ln -sfn "${VERSION}" "${DL_DIR}/breadpad/latest" - name: ensure bread-ecosystem run: | From 478d06a5d50daf2a0d4af5116a6527b5f6bdd0a1 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 15:53:06 +0800 Subject: [PATCH 17/52] fix: skip ROCm EP registration when not available in ORT build Eliminates the spurious ERROR log from ORT when ROCm isn't compiled in. Checks is_available() before attempting registration so the session correctly falls back to CPU without noise. --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- breadpad-shared/src/classifier.rs | 16 +++++++++++----- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d08b439..59a39e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,7 +314,7 @@ dependencies = [ [[package]] name = "breadman" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "breadpad-shared", @@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "breadpad" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "breadpad-shared", @@ -350,7 +350,7 @@ dependencies = [ [[package]] name = "breadpad-shared" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "bread-theme", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "breadpad-test" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "breadpad-shared", diff --git a/Cargo.toml b/Cargo.toml index 5ec24f9..9c26f19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.1.0" +version = "0.2.0" edition = "2021" license = "MIT" authors = ["Breadway"] diff --git a/breadpad-shared/src/classifier.rs b/breadpad-shared/src/classifier.rs index fef87e0..e752a31 100644 --- a/breadpad-shared/src/classifier.rs +++ b/breadpad-shared/src/classifier.rs @@ -247,12 +247,18 @@ fn try_load_session( path: &std::path::Path, ) -> (Option, ExecutionProvider) { // 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); + let rocm_available = { + use ort::execution_providers::ExecutionProvider as _; + ort::ep::ROCm::default().is_available().unwrap_or(false) + }; + if rocm_available { + 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), } - Err(e) => tracing::debug!("ROCm EP unavailable: {}; trying CPU", e), } match build_onnx_session(path, ort::ep::CPU::default().build()) { Ok(s) => { From 3b7609f354298d29b162f81be6e18ca3a0b2ca2c Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 15:53:06 +0800 Subject: [PATCH 18/52] fix: skip ROCm EP registration when not available in ORT build Eliminates the spurious ERROR log from ORT when ROCm isn't compiled in. Checks is_available() before attempting registration so the session correctly falls back to CPU without noise. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 8 ++++---- Cargo.toml | 2 +- breadpad-shared/src/classifier.rs | 16 +++++++++++----- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index d08b439..59a39e6 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,7 +314,7 @@ dependencies = [ [[package]] name = "breadman" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "breadpad-shared", @@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "breadpad" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "breadpad-shared", @@ -350,7 +350,7 @@ dependencies = [ [[package]] name = "breadpad-shared" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "bread-theme", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "breadpad-test" -version = "0.1.0" +version = "0.2.0" dependencies = [ "anyhow", "breadpad-shared", diff --git a/Cargo.toml b/Cargo.toml index 5ec24f9..9c26f19 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.1.0" +version = "0.2.0" edition = "2021" license = "MIT" authors = ["Breadway"] diff --git a/breadpad-shared/src/classifier.rs b/breadpad-shared/src/classifier.rs index fef87e0..e752a31 100644 --- a/breadpad-shared/src/classifier.rs +++ b/breadpad-shared/src/classifier.rs @@ -247,12 +247,18 @@ fn try_load_session( path: &std::path::Path, ) -> (Option, ExecutionProvider) { // 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); + let rocm_available = { + use ort::execution_providers::ExecutionProvider as _; + ort::ep::ROCm::default().is_available().unwrap_or(false) + }; + if rocm_available { + 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), } - Err(e) => tracing::debug!("ROCm EP unavailable: {}; trying CPU", e), } match build_onnx_session(path, ort::ep::CPU::default().build()) { Ok(s) => { From 8dbeacb46d2e97f2661591e7507882d7d778097b Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 15:59:02 +0800 Subject: [PATCH 19/52] feat: enable ROCm EP for GPU inference, add rocm-runtime system dep Switches ort from load-dynamic to rocm feature so the ROCm execution provider is compiled in. Adds rocm-runtime to bakery system_deps so bakery doctor/install can verify it's present. --- Cargo.lock | 19 ++++--------------- Cargo.toml | 4 ++-- bakery.toml | 2 +- 3 files changed, 7 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 59a39e6..be9fc6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,7 +314,7 @@ dependencies = [ [[package]] name = "breadman" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "breadpad-shared", @@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "breadpad" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "breadpad-shared", @@ -350,7 +350,7 @@ dependencies = [ [[package]] name = "breadpad-shared" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "bread-theme", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "breadpad-test" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "breadpad-shared", @@ -1862,16 +1862,6 @@ 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.17" @@ -2148,7 +2138,6 @@ 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", diff --git a/Cargo.toml b/Cargo.toml index 9c26f19..78ae4e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.2.0" +version = "0.3.0" edition = "2021" license = "MIT" authors = ["Breadway"] @@ -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", default-features = false, features = ["std", "ndarray", "tracing", "api-24", "load-dynamic"] } +ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "ndarray", "tracing", "api-24", "rocm"] } ndarray = "0.16" tokenizers = { version = "0.21", default-features = false, features = ["http", "fancy-regex"] } gtk4 = { version = "0.11", features = ["v4_12"] } diff --git a/bakery.toml b/bakery.toml index e1c028d..2fbc31d 100644 --- a/bakery.toml +++ b/bakery.toml @@ -1,7 +1,7 @@ name = "breadpad" description = "Quick-capture scratchpad and note viewer with AI classification" binaries = ["breadpad", "breadman"] -system_deps = ["gtk4", "gtk4-layer-shell"] +system_deps = ["gtk4", "gtk4-layer-shell", "rocm-runtime"] bread_deps = [] [config] From e23a7a97cdd0f00c2621c3eaf8a3c5bb69e5396c Mon Sep 17 00:00:00 2001 From: Breadway Date: Sun, 7 Jun 2026 15:59:02 +0800 Subject: [PATCH 20/52] feat: enable ROCm EP for GPU inference, add rocm-runtime system dep Switches ort from load-dynamic to rocm feature so the ROCm execution provider is compiled in. Adds rocm-runtime to bakery system_deps so bakery doctor/install can verify it's present. Co-Authored-By: Claude Sonnet 4.6 --- Cargo.lock | 19 ++++--------------- Cargo.toml | 4 ++-- bakery.toml | 2 +- 3 files changed, 7 insertions(+), 18 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 59a39e6..be9fc6a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,7 +314,7 @@ dependencies = [ [[package]] name = "breadman" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "breadpad-shared", @@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "breadpad" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "breadpad-shared", @@ -350,7 +350,7 @@ dependencies = [ [[package]] name = "breadpad-shared" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "bread-theme", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "breadpad-test" -version = "0.2.0" +version = "0.3.0" dependencies = [ "anyhow", "breadpad-shared", @@ -1862,16 +1862,6 @@ 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.17" @@ -2148,7 +2138,6 @@ 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", diff --git a/Cargo.toml b/Cargo.toml index 9c26f19..78ae4e0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.2.0" +version = "0.3.0" edition = "2021" license = "MIT" authors = ["Breadway"] @@ -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", default-features = false, features = ["std", "ndarray", "tracing", "api-24", "load-dynamic"] } +ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "ndarray", "tracing", "api-24", "rocm"] } ndarray = "0.16" tokenizers = { version = "0.21", default-features = false, features = ["http", "fancy-regex"] } gtk4 = { version = "0.11", features = ["v4_12"] } diff --git a/bakery.toml b/bakery.toml index e1c028d..2fbc31d 100644 --- a/bakery.toml +++ b/bakery.toml @@ -1,7 +1,7 @@ name = "breadpad" description = "Quick-capture scratchpad and note viewer with AI classification" binaries = ["breadpad", "breadman"] -system_deps = ["gtk4", "gtk4-layer-shell"] +system_deps = ["gtk4", "gtk4-layer-shell", "rocm-runtime"] bread_deps = [] [config] From d7d88284779a0008f9e798fe2d92b07aec010414 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 13:38:35 +0800 Subject: [PATCH 21/52] fix: remove non-existent rocm-runtime dep, add optional_system_deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rocm-runtime is not a real Arch package name. ORT links ONNX Runtime statically and falls back to CPU — ROCm should not block install. Required: gtk4, gtk4-layer-shell only. Optional: rocm-hip-runtime (GPU inference), ollama (AI fallback), hyprland. --- bakery.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bakery.toml b/bakery.toml index 2fbc31d..25e9033 100644 --- a/bakery.toml +++ b/bakery.toml @@ -1,7 +1,8 @@ name = "breadpad" description = "Quick-capture scratchpad and note viewer with AI classification" binaries = ["breadpad", "breadman"] -system_deps = ["gtk4", "gtk4-layer-shell", "rocm-runtime"] +system_deps = ["gtk4", "gtk4-layer-shell"] +optional_system_deps = ["rocm-hip-runtime", "ollama", "hyprland"] bread_deps = [] [config] From 0acf7fce48b736b4238888a3109c94cac20367b1 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 13:38:35 +0800 Subject: [PATCH 22/52] fix: remove non-existent rocm-runtime dep, add optional_system_deps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit rocm-runtime is not a real Arch package name. ORT links ONNX Runtime statically and falls back to CPU — ROCm should not block install. Required: gtk4, gtk4-layer-shell only. Optional: rocm-hip-runtime (GPU inference), ollama (AI fallback), hyprland. Co-Authored-By: Claude Sonnet 4.6 --- bakery.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/bakery.toml b/bakery.toml index 2fbc31d..25e9033 100644 --- a/bakery.toml +++ b/bakery.toml @@ -1,7 +1,8 @@ name = "breadpad" description = "Quick-capture scratchpad and note viewer with AI classification" binaries = ["breadpad", "breadman"] -system_deps = ["gtk4", "gtk4-layer-shell", "rocm-runtime"] +system_deps = ["gtk4", "gtk4-layer-shell"] +optional_system_deps = ["rocm-hip-runtime", "ollama", "hyprland"] bread_deps = [] [config] From bf4586e60898310f2fb5effe7b24c6bc8e1fb41e Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 14:21:50 +0800 Subject: [PATCH 23/52] chore: bump version to 0.3.1 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 78ae4e0..9fdda15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.3.0" +version = "0.3.1" edition = "2021" license = "MIT" authors = ["Breadway"] From d7b0813b0a49b88180c3ee56e4c722c02491ceb4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 14:21:50 +0800 Subject: [PATCH 24/52] chore: bump version to 0.3.1 --- Cargo.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cargo.toml b/Cargo.toml index 78ae4e0..9fdda15 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.3.0" +version = "0.3.1" edition = "2021" license = "MIT" authors = ["Breadway"] From e21c5c4ad37ee6f69d081d75f200bd926df05fe2 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 14:28:11 +0800 Subject: [PATCH 25/52] chore: update Cargo.lock for v0.3.1 --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index be9fc6a..d1d32f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,7 +314,7 @@ dependencies = [ [[package]] name = "breadman" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "breadpad-shared", @@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "breadpad" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "breadpad-shared", @@ -350,7 +350,7 @@ dependencies = [ [[package]] name = "breadpad-shared" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "bread-theme", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "breadpad-test" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "breadpad-shared", From 5406fcdf61fcbdbb82dd5ef3b09bbd8f8bb25793 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 14:28:11 +0800 Subject: [PATCH 26/52] chore: update Cargo.lock for v0.3.1 --- Cargo.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index be9fc6a..d1d32f2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -314,7 +314,7 @@ dependencies = [ [[package]] name = "breadman" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "breadpad-shared", @@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "breadpad" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "breadpad-shared", @@ -350,7 +350,7 @@ dependencies = [ [[package]] name = "breadpad-shared" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "bread-theme", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "breadpad-test" -version = "0.3.0" +version = "0.3.1" dependencies = [ "anyhow", "breadpad-shared", From 596ae90455ff95d6c980d5fe95b31cddd165e01c Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 14:53:23 +0800 Subject: [PATCH 27/52] fix: enable load-dynamic ORT feature for breadpad-test ort::init_from requires the load-dynamic feature; breadpad-test calls it to load libonnxruntime.so from a runtime path. --- Cargo.lock | 11 +++++++++++ Cargo.toml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index d1d32f2..8769946 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1862,6 +1862,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.17" @@ -2138,6 +2148,7 @@ 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", diff --git a/Cargo.toml b/Cargo.toml index 9fdda15..af2b588 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", default-features = false, features = ["std", "ndarray", "tracing", "api-24", "rocm"] } +ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "ndarray", "tracing", "api-24", "rocm", "load-dynamic"] } ndarray = "0.16" tokenizers = { version = "0.21", default-features = false, features = ["http", "fancy-regex"] } gtk4 = { version = "0.11", features = ["v4_12"] } From 8c2b2e419e6835fac8314c57964bd5271206c8c4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Thu, 11 Jun 2026 14:53:23 +0800 Subject: [PATCH 28/52] fix: enable load-dynamic ORT feature for breadpad-test ort::init_from requires the load-dynamic feature; breadpad-test calls it to load libonnxruntime.so from a runtime path. Co-Authored-By: Claude Fable 5 --- Cargo.lock | 11 +++++++++++ Cargo.toml | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index d1d32f2..8769946 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1862,6 +1862,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.17" @@ -2138,6 +2148,7 @@ 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", diff --git a/Cargo.toml b/Cargo.toml index 9fdda15..af2b588 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", default-features = false, features = ["std", "ndarray", "tracing", "api-24", "rocm"] } +ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "ndarray", "tracing", "api-24", "rocm", "load-dynamic"] } ndarray = "0.16" tokenizers = { version = "0.21", default-features = false, features = ["http", "fancy-regex"] } gtk4 = { version = "0.11", features = ["v4_12"] } From 659e3da5edc64bc6dd4ac293d3ed2c62865ef6f2 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 12:12:43 +0800 Subject: [PATCH 29/52] Add packaging/arch PKGBUILD and Forgejo Actions workflows - packaging/arch/PKGBUILD: builds and publishes breadpad to [breadway] repo - .forgejo/workflows/mirror.yml: mirrors every push/tag to GitHub - .forgejo/workflows/package.yml: builds on tag, publishes to Forgejo registry Requires FORGEJO_TOKEN and GITHUB_MIRROR_TOKEN secrets in Forgejo. --- .forgejo/workflows/mirror.yml | 20 +++++++++++++++ .forgejo/workflows/package.yml | 46 ++++++++++++++++++++++++++++++++++ packaging/arch/PKGBUILD | 34 +++++++++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 .forgejo/workflows/mirror.yml create mode 100644 .forgejo/workflows/package.yml create mode 100644 packaging/arch/PKGBUILD diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml new file mode 100644 index 0000000..dbc9c1b --- /dev/null +++ b/.forgejo/workflows/mirror.yml @@ -0,0 +1,20 @@ +name: Mirror to GitHub + +on: + push: + branches: ['**'] + tags: ['**'] + +jobs: + mirror: + runs-on: [self-hosted, hestia] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Push to GitHub + run: | + git remote add github \ + "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/breadpad.git" + git push github --mirror diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml new file mode 100644 index 0000000..624e210 --- /dev/null +++ b/.forgejo/workflows/package.yml @@ -0,0 +1,46 @@ +name: Build and publish package + +on: + push: + tags: ['v*'] + +jobs: + package: + runs-on: [self-hosted, hestia] + container: + image: archlinux:latest + options: --privileged + + steps: + - uses: actions/checkout@v4 + + - name: Set version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV + + - name: Install build dependencies + run: pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell + + - name: Create builder user + run: useradd -m builder + + - name: Prepare source + run: | + git archive --format=tar.gz \ + --prefix=breadpad-${VERSION}/ \ + HEAD > packaging/arch/breadpad-${VERSION}.tar.gz + SHA=$(sha256sum packaging/arch/breadpad-${VERSION}.tar.gz | awk '{print $1}') + sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD + sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD + cp -r . /home/builder/src + chown -R builder:builder /home/builder/src + + - name: Build package + run: su builder -c "cd /home/builder/src/packaging/arch && makepkg -sf --noconfirm" + + - name: Publish to Forgejo registry + run: | + PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) + curl -fsS -X PUT \ + -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ + --upload-file "${PKG}" \ + "https://git.breadway.dev/api/packages/breadway/arch/push?distrib=breadway" diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 0000000..f3d29fd --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,34 @@ +# Maintainer: Breadway + +pkgname=breadpad +pkgver=0.3.1 +pkgrel=1 +pkgdesc="Quick-capture scratchpad and note viewer with AI classification" +arch=('x86_64') +url="https://github.com/Breadway/breadpad" +license=('MIT') +depends=('gtk4' 'gtk4-layer-shell') +optdepends=( + 'ollama: local AI note classification' + 'hyprland: scratchpad window integration' +) +makedepends=('rust' 'cargo') +source=("${pkgname}-${pkgver}.tar.gz") +sha256sums=('SKIP') + +build() { + cd "${srcdir}/${pkgname}-${pkgver}" + cargo build --release --locked +} + +check() { + cd "${srcdir}/${pkgname}-${pkgver}" + cargo test --release --locked --workspace +} + +package() { + cd "${srcdir}/${pkgname}-${pkgver}" + install -Dm755 target/release/breadpad "${pkgdir}/usr/bin/breadpad" + install -Dm755 target/release/breadman "${pkgdir}/usr/bin/breadman" + install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" +} From 33ca7871d90c83c2a946d2c5234ed6f051d9c7e0 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 12:12:43 +0800 Subject: [PATCH 30/52] Add packaging/arch PKGBUILD and Forgejo Actions workflows - packaging/arch/PKGBUILD: builds and publishes breadpad to [breadway] repo - .forgejo/workflows/mirror.yml: mirrors every push/tag to GitHub - .forgejo/workflows/package.yml: builds on tag, publishes to Forgejo registry Requires FORGEJO_TOKEN and GITHUB_MIRROR_TOKEN secrets in Forgejo. Co-Authored-By: Claude Sonnet 4.6 --- .forgejo/workflows/mirror.yml | 20 +++++++++++++++ .forgejo/workflows/package.yml | 46 ++++++++++++++++++++++++++++++++++ packaging/arch/PKGBUILD | 34 +++++++++++++++++++++++++ 3 files changed, 100 insertions(+) create mode 100644 .forgejo/workflows/mirror.yml create mode 100644 .forgejo/workflows/package.yml create mode 100644 packaging/arch/PKGBUILD diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml new file mode 100644 index 0000000..dbc9c1b --- /dev/null +++ b/.forgejo/workflows/mirror.yml @@ -0,0 +1,20 @@ +name: Mirror to GitHub + +on: + push: + branches: ['**'] + tags: ['**'] + +jobs: + mirror: + runs-on: [self-hosted, hestia] + steps: + - uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Push to GitHub + run: | + git remote add github \ + "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/breadpad.git" + git push github --mirror diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml new file mode 100644 index 0000000..624e210 --- /dev/null +++ b/.forgejo/workflows/package.yml @@ -0,0 +1,46 @@ +name: Build and publish package + +on: + push: + tags: ['v*'] + +jobs: + package: + runs-on: [self-hosted, hestia] + container: + image: archlinux:latest + options: --privileged + + steps: + - uses: actions/checkout@v4 + + - name: Set version + run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV + + - name: Install build dependencies + run: pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell + + - name: Create builder user + run: useradd -m builder + + - name: Prepare source + run: | + git archive --format=tar.gz \ + --prefix=breadpad-${VERSION}/ \ + HEAD > packaging/arch/breadpad-${VERSION}.tar.gz + SHA=$(sha256sum packaging/arch/breadpad-${VERSION}.tar.gz | awk '{print $1}') + sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD + sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD + cp -r . /home/builder/src + chown -R builder:builder /home/builder/src + + - name: Build package + run: su builder -c "cd /home/builder/src/packaging/arch && makepkg -sf --noconfirm" + + - name: Publish to Forgejo registry + run: | + PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) + curl -fsS -X PUT \ + -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ + --upload-file "${PKG}" \ + "https://git.breadway.dev/api/packages/breadway/arch/push?distrib=breadway" diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD new file mode 100644 index 0000000..f3d29fd --- /dev/null +++ b/packaging/arch/PKGBUILD @@ -0,0 +1,34 @@ +# Maintainer: Breadway + +pkgname=breadpad +pkgver=0.3.1 +pkgrel=1 +pkgdesc="Quick-capture scratchpad and note viewer with AI classification" +arch=('x86_64') +url="https://github.com/Breadway/breadpad" +license=('MIT') +depends=('gtk4' 'gtk4-layer-shell') +optdepends=( + 'ollama: local AI note classification' + 'hyprland: scratchpad window integration' +) +makedepends=('rust' 'cargo') +source=("${pkgname}-${pkgver}.tar.gz") +sha256sums=('SKIP') + +build() { + cd "${srcdir}/${pkgname}-${pkgver}" + cargo build --release --locked +} + +check() { + cd "${srcdir}/${pkgname}-${pkgver}" + cargo test --release --locked --workspace +} + +package() { + cd "${srcdir}/${pkgname}-${pkgver}" + install -Dm755 target/release/breadpad "${pkgdir}/usr/bin/breadpad" + install -Dm755 target/release/breadman "${pkgdir}/usr/bin/breadman" + install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE" +} From ce0b7740d6d969279c30392007e33a7b230865d4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:02:30 +0800 Subject: [PATCH 31/52] Fix Forgejo workflows for the actual server capabilities - package.yml: correct Arch registry upload (octet-stream + binary body), drop --privileged, manual shell clone (archlinux image has no Node), built-in Actions token, --nocheck - mirror.yml: clone --mirror + explicit refs push with --prune --- .forgejo/workflows/mirror.yml | 17 ++++++------ .forgejo/workflows/package.yml | 48 +++++++++++++++------------------- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index dbc9c1b..2b2a5a5 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -9,12 +9,13 @@ jobs: mirror: runs-on: [self-hosted, hestia] steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Push to GitHub + - name: Mirror to GitHub run: | - git remote add github \ - "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/breadpad.git" - git push github --mirror + set -euo pipefail + git clone --mirror "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" repo.git + cd repo.git + # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); + # --prune deletes GitHub refs that no longer exist on Forgejo. + git push --prune \ + "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/breadpad.git" \ + '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 624e210..ef1413d 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -9,38 +9,32 @@ jobs: runs-on: [self-hosted, hestia] container: image: archlinux:latest - options: --privileged - steps: - - uses: actions/checkout@v4 - - - name: Set version - run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV - - - name: Install build dependencies - run: pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell - - - name: Create builder user - run: useradd -m builder - - - name: Prepare source + # Note: no actions/checkout — the archlinux image has no Node, which JS + # actions require. Everything runs as shell steps and clones manually. + - name: Build and publish + env: + PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - git archive --format=tar.gz \ - --prefix=breadpad-${VERSION}/ \ - HEAD > packaging/arch/breadpad-${VERSION}.tar.gz + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell + useradd -m builder + git config --global --add safe.directory '*' + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" /home/builder/src + cd /home/builder/src + git archive --format=tar.gz --prefix="breadpad-${VERSION}/" HEAD \ + > packaging/arch/breadpad-${VERSION}.tar.gz SHA=$(sha256sum packaging/arch/breadpad-${VERSION}.tar.gz | awk '{print $1}') sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD - cp -r . /home/builder/src chown -R builder:builder /home/builder/src - - - name: Build package - run: su builder -c "cd /home/builder/src/packaging/arch && makepkg -sf --noconfirm" - - - name: Publish to Forgejo registry - run: | + # --nocheck: packaging builds the artifact; tests belong in a CI job. + su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck" PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) curl -fsS -X PUT \ - -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ - --upload-file "${PKG}" \ - "https://git.breadway.dev/api/packages/breadway/arch/push?distrib=breadway" + -H "Authorization: token ${PUBLISH_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${PKG}" \ + "https://git.breadway.dev/api/packages/Breadway/arch/os" From 36249553a38a1f6fba2dbd92e6c56238239821a7 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:02:30 +0800 Subject: [PATCH 32/52] Fix Forgejo workflows for the actual server capabilities - package.yml: correct Arch registry upload (octet-stream + binary body), drop --privileged, manual shell clone (archlinux image has no Node), built-in Actions token, --nocheck - mirror.yml: clone --mirror + explicit refs push with --prune Co-Authored-By: Claude Opus 4.8 --- .forgejo/workflows/mirror.yml | 17 ++++++------ .forgejo/workflows/package.yml | 48 +++++++++++++++------------------- 2 files changed, 30 insertions(+), 35 deletions(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index dbc9c1b..2b2a5a5 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -9,12 +9,13 @@ jobs: mirror: runs-on: [self-hosted, hestia] steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - - - name: Push to GitHub + - name: Mirror to GitHub run: | - git remote add github \ - "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/breadpad.git" - git push github --mirror + set -euo pipefail + git clone --mirror "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" repo.git + cd repo.git + # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); + # --prune deletes GitHub refs that no longer exist on Forgejo. + git push --prune \ + "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/breadpad.git" \ + '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 624e210..ef1413d 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -9,38 +9,32 @@ jobs: runs-on: [self-hosted, hestia] container: image: archlinux:latest - options: --privileged - steps: - - uses: actions/checkout@v4 - - - name: Set version - run: echo "VERSION=${GITHUB_REF_NAME#v}" >> $GITHUB_ENV - - - name: Install build dependencies - run: pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell - - - name: Create builder user - run: useradd -m builder - - - name: Prepare source + # Note: no actions/checkout — the archlinux image has no Node, which JS + # actions require. Everything runs as shell steps and clones manually. + - name: Build and publish + env: + PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - git archive --format=tar.gz \ - --prefix=breadpad-${VERSION}/ \ - HEAD > packaging/arch/breadpad-${VERSION}.tar.gz + set -euo pipefail + VERSION="${GITHUB_REF_NAME#v}" + pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell + useradd -m builder + git config --global --add safe.directory '*' + git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ + "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" /home/builder/src + cd /home/builder/src + git archive --format=tar.gz --prefix="breadpad-${VERSION}/" HEAD \ + > packaging/arch/breadpad-${VERSION}.tar.gz SHA=$(sha256sum packaging/arch/breadpad-${VERSION}.tar.gz | awk '{print $1}') sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD - cp -r . /home/builder/src chown -R builder:builder /home/builder/src - - - name: Build package - run: su builder -c "cd /home/builder/src/packaging/arch && makepkg -sf --noconfirm" - - - name: Publish to Forgejo registry - run: | + # --nocheck: packaging builds the artifact; tests belong in a CI job. + su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck" PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1) curl -fsS -X PUT \ - -H "Authorization: token ${{ secrets.FORGEJO_TOKEN }}" \ - --upload-file "${PKG}" \ - "https://git.breadway.dev/api/packages/breadway/arch/push?distrib=breadway" + -H "Authorization: token ${PUBLISH_TOKEN}" \ + -H "Content-Type: application/octet-stream" \ + --data-binary "@${PKG}" \ + "https://git.breadway.dev/api/packages/Breadway/arch/os" From ca95ac06938cb5a0fe42017c1512370e7ff54962 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:10:51 +0800 Subject: [PATCH 33/52] Rename mirror secret to MIRROR_TOKEN (GITHUB_ prefix is reserved) Forgejo/gitea rejects user secret names starting with GITHUB_. --- .forgejo/workflows/mirror.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index 2b2a5a5..cda41f9 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -17,5 +17,5 @@ jobs: # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); # --prune deletes GitHub refs that no longer exist on Forgejo. git push --prune \ - "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/breadpad.git" \ + "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadpad.git" \ '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' From b71b8bea5e1c5dc69554b25e13c88cc23677f5af Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:10:51 +0800 Subject: [PATCH 34/52] Rename mirror secret to MIRROR_TOKEN (GITHUB_ prefix is reserved) Forgejo/gitea rejects user secret names starting with GITHUB_. Co-Authored-By: Claude Opus 4.8 --- .forgejo/workflows/mirror.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index 2b2a5a5..cda41f9 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -17,5 +17,5 @@ jobs: # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); # --prune deletes GitHub refs that no longer exist on Forgejo. git push --prune \ - "https://x-access-token:${{ secrets.GITHUB_MIRROR_TOKEN }}@github.com/Breadway/breadpad.git" \ + "https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadpad.git" \ '+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*' From d1aef21998fc2c9a8fd164cb42797d65e322cd01 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:14:15 +0800 Subject: [PATCH 35/52] Clone from public URL, not GITHUB_SERVER_URL (resolves to localhost in runner) The Forgejo runner injects GITHUB_SERVER_URL as http://localhost:3002, which is unreachable from inside the job container. Use the public URL instead. --- .forgejo/workflows/mirror.yml | 2 +- .forgejo/workflows/package.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index cda41f9..e91c97e 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -12,7 +12,7 @@ jobs: - name: Mirror to GitHub run: | set -euo pipefail - git clone --mirror "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" repo.git + git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git cd repo.git # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); # --prune deletes GitHub refs that no longer exist on Forgejo. diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index ef1413d..7e36a5a 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -22,7 +22,7 @@ jobs: useradd -m builder git config --global --add safe.directory '*' git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" /home/builder/src + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src cd /home/builder/src git archive --format=tar.gz --prefix="breadpad-${VERSION}/" HEAD \ > packaging/arch/breadpad-${VERSION}.tar.gz From 956bacb3e012cae52715a9099f1039c39de2aa07 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 16:14:15 +0800 Subject: [PATCH 36/52] Clone from public URL, not GITHUB_SERVER_URL (resolves to localhost in runner) The Forgejo runner injects GITHUB_SERVER_URL as http://localhost:3002, which is unreachable from inside the job container. Use the public URL instead. Co-Authored-By: Claude Opus 4.8 --- .forgejo/workflows/mirror.yml | 2 +- .forgejo/workflows/package.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.forgejo/workflows/mirror.yml b/.forgejo/workflows/mirror.yml index cda41f9..e91c97e 100644 --- a/.forgejo/workflows/mirror.yml +++ b/.forgejo/workflows/mirror.yml @@ -12,7 +12,7 @@ jobs: - name: Mirror to GitHub run: | set -euo pipefail - git clone --mirror "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" repo.git + git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git cd repo.git # Mirror only branches and tags (not refs/pull/*, which GitHub rejects); # --prune deletes GitHub refs that no longer exist on Forgejo. diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index ef1413d..7e36a5a 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -22,7 +22,7 @@ jobs: useradd -m builder git config --global --add safe.directory '*' git clone --branch "${GITHUB_REF_NAME}" --depth 1 \ - "${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}.git" /home/builder/src + "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src cd /home/builder/src git archive --format=tar.gz --prefix="breadpad-${VERSION}/" HEAD \ > packaging/arch/breadpad-${VERSION}.tar.gz From d4ae2dfed45867795e02b05935704569ee225bd5 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 17:06:56 +0800 Subject: [PATCH 37/52] Disable LTO in PKGBUILD (vendored ring/mlua static libs vs makepkg -flto) --- packaging/arch/PKGBUILD | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index f3d29fd..b815520 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -7,6 +7,10 @@ pkgdesc="Quick-capture scratchpad and note viewer with AI classification" arch=('x86_64') url="https://github.com/Breadway/breadpad" license=('MIT') +# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's +# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, +# causing undefined-symbol errors. Disable LTO. +options=(!lto) depends=('gtk4' 'gtk4-layer-shell') optdepends=( 'ollama: local AI note classification' From a7484495c893943d903ff3fa6419d90ec318f311 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 17:06:56 +0800 Subject: [PATCH 38/52] Disable LTO in PKGBUILD (vendored ring/mlua static libs vs makepkg -flto) Co-Authored-By: Claude Opus 4.8 --- packaging/arch/PKGBUILD | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index f3d29fd..b815520 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -7,6 +7,10 @@ pkgdesc="Quick-capture scratchpad and note viewer with AI classification" arch=('x86_64') url="https://github.com/Breadway/breadpad" license=('MIT') +# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's +# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, +# causing undefined-symbol errors. Disable LTO. +options=(!lto) depends=('gtk4' 'gtk4-layer-shell') optdepends=( 'ollama: local AI note classification' From 9ae815caa2147f663b731c6912c05926e9e38e27 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 22:55:43 +0800 Subject: [PATCH 39/52] Use REGISTRY_TOKEN (scoped write:package) for registry publish --- .forgejo/workflows/package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 7e36a5a..fdfdef9 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -14,7 +14,7 @@ jobs: # actions require. Everything runs as shell steps and clones manually. - name: Build and publish env: - PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" From d29ff9f8c92e9dc4a9435c41ca135e435d530c87 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 22:55:43 +0800 Subject: [PATCH 40/52] Use REGISTRY_TOKEN (scoped write:package) for registry publish Co-Authored-By: Claude Opus 4.8 --- .forgejo/workflows/package.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.forgejo/workflows/package.yml b/.forgejo/workflows/package.yml index 7e36a5a..fdfdef9 100644 --- a/.forgejo/workflows/package.yml +++ b/.forgejo/workflows/package.yml @@ -14,7 +14,7 @@ jobs: # actions require. Everything runs as shell steps and clones manually. - name: Build and publish env: - PUBLISH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }} run: | set -euo pipefail VERSION="${GITHUB_REF_NAME#v}" From eab3775de1c082f64e5489dc81694dbcd0b8e04a Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 23:00:52 +0800 Subject: [PATCH 41/52] Disable debug package so the main package publishes correctly makepkg's debug split produced a -debug pkg; the upload's head -1 could grab it instead of the main package. !debug yields a single package. --- packaging/arch/PKGBUILD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index b815520..22511b6 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -10,7 +10,7 @@ license=('MIT') # Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's # default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, # causing undefined-symbol errors. Disable LTO. -options=(!lto) +options=(!lto !debug) depends=('gtk4' 'gtk4-layer-shell') optdepends=( 'ollama: local AI note classification' From f30e215eabac75667ee6fa54f8a38f42e66866c3 Mon Sep 17 00:00:00 2001 From: Breadway Date: Sat, 13 Jun 2026 23:00:52 +0800 Subject: [PATCH 42/52] Disable debug package so the main package publishes correctly makepkg's debug split produced a -debug pkg; the upload's head -1 could grab it instead of the main package. !debug yields a single package. Co-Authored-By: Claude Opus 4.8 --- packaging/arch/PKGBUILD | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packaging/arch/PKGBUILD b/packaging/arch/PKGBUILD index b815520..22511b6 100644 --- a/packaging/arch/PKGBUILD +++ b/packaging/arch/PKGBUILD @@ -10,7 +10,7 @@ license=('MIT') # Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's # default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read, # causing undefined-symbol errors. Disable LTO. -options=(!lto) +options=(!lto !debug) depends=('gtk4' 'gtk4-layer-shell') optdepends=( 'ollama: local AI note classification' From 49f6966d9cbecd25aceb9f2510e59d9831dd863c Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 16 Jun 2026 16:57:16 +0800 Subject: [PATCH 43/52] theme: build on the shared bread-theme stylesheet build_css() now starts from bread_theme::stylesheet(palette) and appends only breadpad/breadman-specific components. This unifies fonts, palette, and generic widgets with the rest of the ecosystem and fixes the colour mapping (overlay is now color7, matching every other app, not color0). Bump bread-theme to v0.2.6. --- breadpad-shared/Cargo.toml | 2 +- breadpad-shared/src/theme.rs | 105 ++++++++++------------------------- 2 files changed, 31 insertions(+), 76 deletions(-) diff --git a/breadpad-shared/Cargo.toml b/breadpad-shared/Cargo.toml index 610c040..cea30b3 100644 --- a/breadpad-shared/Cargo.toml +++ b/breadpad-shared/Cargo.toml @@ -7,7 +7,7 @@ authors.workspace = true [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.1.0" } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.6" } anyhow.workspace = true tracing.workspace = true serde.workspace = true diff --git a/breadpad-shared/src/theme.rs b/breadpad-shared/src/theme.rs index adc76dc..857dcfe 100644 --- a/breadpad-shared/src/theme.rs +++ b/breadpad-shared/src/theme.rs @@ -1,31 +1,21 @@ pub use bread_theme::{load_palette, Palette}; -/// Generate the full breadpad CSS string. The base colour variables come from -/// `bread-theme`; the widget rules below are breadpad-specific. +/// Generate the full breadpad/breadman CSS string. The base — `@define-color` +/// palette, fonts, and generic widget styling — comes from the shared +/// `bread_theme::stylesheet`, so breadpad and breadman look identical to the +/// rest of the ecosystem. Only breadpad-specific component rules are appended. pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String { - let mut css = format!( + // Shared ecosystem base (define-colors incl. accent, font, buttons, entries, + // switches, lists, cards, scrollbars). `overlay` here is color7 — consistent + // with every other bread app (breadpad previously mapped it to color0). + let mut css = bread_theme::stylesheet(palette); + + css.push_str( r#" -@define-color bg {bg}; -@define-color fg {fg}; -@define-color red {c1}; -@define-color green {c2}; -@define-color yellow {c3}; -@define-color blue {c4}; -@define-color pink {c5}; -@define-color teal {c6}; -@define-color overlay {c0}; +/* breadpad/breadman-specific components */ +window { border-radius: 8px; } -* {{ - font-family: 'Varela Round', sans-serif; -}} - -window {{ - background-color: @bg; - color: @fg; - border-radius: 8px; -}} - -.popup-entry {{ +.popup-entry { background: @bg; color: @fg; border: 2px solid @blue; @@ -33,80 +23,59 @@ window {{ padding: 12px 16px; font-size: 14px; caret-color: @fg; -}} +} -.popup-entry:focus {{ +.popup-entry:focus { outline: none; border-color: @teal; -}} +} -.type-chip {{ +.type-chip { background: @overlay; color: @fg; border-radius: 999px; padding: 4px 12px; font-size: 12px; margin: 4px; -}} +} -.type-chip.active {{ +.type-chip.active { background: @blue; color: @bg; -}} +} -.confirm-button {{ +.confirm-button { background: @blue; color: @bg; border: none; border-radius: 8px; padding: 8px 16px; font-weight: bold; -}} +} -.note-card {{ +.note-card { background: shade(@bg, 1.1); border-radius: 8px; padding: 12px; margin: 8px; border-left: 3px solid @blue; -}} +} -.note-card:hover {{ +.note-card:hover { background: shade(@bg, 1.2); -}} +} -.search-entry {{ +.search-entry { background: shade(@bg, 1.1); color: @fg; border: 1px solid @overlay; border-radius: 6px; padding: 8px 12px; -}} - -.search-entry:focus {{ - border-color: @blue; - outline: none; -}} -"#, - bg = palette.background, - fg = palette.foreground, - c0 = palette.color0, - c1 = palette.color1, - c2 = palette.color2, - c3 = palette.color3, - c4 = palette.color4, - c5 = palette.color5, - c6 = palette.color6, - ); - - css.push_str(r#" -.dim-label { - color: alpha(@fg, 0.5); - font-size: 12px; } -.sidebar { - background: shade(@bg, 0.93); +.search-entry:focus { + border-color: @blue; + outline: none; } .sidebar-row { @@ -217,20 +186,6 @@ window {{ } .snooze-option:hover { background: shade(@bg, 1.2); } - -entry { - background: shade(@bg, 1.1); - color: @fg; - border: 1px solid @overlay; - border-radius: 6px; - caret-color: @fg; - padding: 5px 10px; -} - -entry:focus { - border-color: @blue; - outline: none; -} "#); if let Some(extra) = user_css { From b8993630e36703d88193f019f34beed877271f41 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 16 Jun 2026 16:57:16 +0800 Subject: [PATCH 44/52] theme: build on the shared bread-theme stylesheet build_css() now starts from bread_theme::stylesheet(palette) and appends only breadpad/breadman-specific components. This unifies fonts, palette, and generic widgets with the rest of the ecosystem and fixes the colour mapping (overlay is now color7, matching every other app, not color0). Bump bread-theme to v0.2.6. Co-Authored-By: Claude Opus 4.8 --- breadpad-shared/Cargo.toml | 2 +- breadpad-shared/src/theme.rs | 105 ++++++++++------------------------- 2 files changed, 31 insertions(+), 76 deletions(-) diff --git a/breadpad-shared/Cargo.toml b/breadpad-shared/Cargo.toml index 610c040..cea30b3 100644 --- a/breadpad-shared/Cargo.toml +++ b/breadpad-shared/Cargo.toml @@ -7,7 +7,7 @@ authors.workspace = true [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.1.0" } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.6" } anyhow.workspace = true tracing.workspace = true serde.workspace = true diff --git a/breadpad-shared/src/theme.rs b/breadpad-shared/src/theme.rs index adc76dc..857dcfe 100644 --- a/breadpad-shared/src/theme.rs +++ b/breadpad-shared/src/theme.rs @@ -1,31 +1,21 @@ pub use bread_theme::{load_palette, Palette}; -/// Generate the full breadpad CSS string. The base colour variables come from -/// `bread-theme`; the widget rules below are breadpad-specific. +/// Generate the full breadpad/breadman CSS string. The base — `@define-color` +/// palette, fonts, and generic widget styling — comes from the shared +/// `bread_theme::stylesheet`, so breadpad and breadman look identical to the +/// rest of the ecosystem. Only breadpad-specific component rules are appended. pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String { - let mut css = format!( + // Shared ecosystem base (define-colors incl. accent, font, buttons, entries, + // switches, lists, cards, scrollbars). `overlay` here is color7 — consistent + // with every other bread app (breadpad previously mapped it to color0). + let mut css = bread_theme::stylesheet(palette); + + css.push_str( r#" -@define-color bg {bg}; -@define-color fg {fg}; -@define-color red {c1}; -@define-color green {c2}; -@define-color yellow {c3}; -@define-color blue {c4}; -@define-color pink {c5}; -@define-color teal {c6}; -@define-color overlay {c0}; +/* breadpad/breadman-specific components */ +window { border-radius: 8px; } -* {{ - font-family: 'Varela Round', sans-serif; -}} - -window {{ - background-color: @bg; - color: @fg; - border-radius: 8px; -}} - -.popup-entry {{ +.popup-entry { background: @bg; color: @fg; border: 2px solid @blue; @@ -33,80 +23,59 @@ window {{ padding: 12px 16px; font-size: 14px; caret-color: @fg; -}} +} -.popup-entry:focus {{ +.popup-entry:focus { outline: none; border-color: @teal; -}} +} -.type-chip {{ +.type-chip { background: @overlay; color: @fg; border-radius: 999px; padding: 4px 12px; font-size: 12px; margin: 4px; -}} +} -.type-chip.active {{ +.type-chip.active { background: @blue; color: @bg; -}} +} -.confirm-button {{ +.confirm-button { background: @blue; color: @bg; border: none; border-radius: 8px; padding: 8px 16px; font-weight: bold; -}} +} -.note-card {{ +.note-card { background: shade(@bg, 1.1); border-radius: 8px; padding: 12px; margin: 8px; border-left: 3px solid @blue; -}} +} -.note-card:hover {{ +.note-card:hover { background: shade(@bg, 1.2); -}} +} -.search-entry {{ +.search-entry { background: shade(@bg, 1.1); color: @fg; border: 1px solid @overlay; border-radius: 6px; padding: 8px 12px; -}} - -.search-entry:focus {{ - border-color: @blue; - outline: none; -}} -"#, - bg = palette.background, - fg = palette.foreground, - c0 = palette.color0, - c1 = palette.color1, - c2 = palette.color2, - c3 = palette.color3, - c4 = palette.color4, - c5 = palette.color5, - c6 = palette.color6, - ); - - css.push_str(r#" -.dim-label { - color: alpha(@fg, 0.5); - font-size: 12px; } -.sidebar { - background: shade(@bg, 0.93); +.search-entry:focus { + border-color: @blue; + outline: none; } .sidebar-row { @@ -217,20 +186,6 @@ window {{ } .snooze-option:hover { background: shade(@bg, 1.2); } - -entry { - background: shade(@bg, 1.1); - color: @fg; - border: 1px solid @overlay; - border-radius: 6px; - caret-color: @fg; - padding: 5px 10px; -} - -entry:focus { - border-color: @blue; - outline: none; -} "#); if let Some(extra) = user_css { From 08d8956eac73d96e2eb396fb2b58422a98ad89b4 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 16 Jun 2026 17:07:06 +0800 Subject: [PATCH 45/52] docs: add CalDAV calendar-sync walkthrough The [calendar] config keys existed without explanation. Document enabling CalDAV sync end to end: finding the collection URL, creating an app password, the config block, and the best-effort sync behaviour. --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index 02ec8a2..b39ea3d 100644 --- a/README.md +++ b/README.md @@ -190,8 +190,41 @@ enabled = true # set false to never call Ollama [reminders] default_morning = "08:00" # what "tomorrow morning" resolves to missed_grace_minutes = 60 # how long after boot to still fire a missed reminder + +[calendar] +enabled = false # turn on CalDAV sync (see below) +url = "" # CalDAV calendar collection URL +username = "" +password = "" # app password / token recommended ``` +### Calendar sync (CalDAV) + +When `[calendar].enabled = true`, reminders and dated notes are pushed to a +CalDAV calendar as events (tracked by `caldav_uid` on each note), so they show +up alongside the rest of your calendar. + +1. Find your calendar's **collection URL**. It's the per-calendar CalDAV path, + not the server root — e.g. Nextcloud: + `https://host/remote.php/dav/calendars///`. +2. Create an **app password** for breadpad (don't use your main password): + Nextcloud → Settings → Security → *Devices & sessions* → "Create new app + password". Most CalDAV servers have an equivalent. +3. Fill in `breadpad.toml` (or BOS Settings → breadpad → Calendar): + + ```toml + [calendar] + enabled = true + url = "https://host/remote.php/dav/calendars/me/breadpad/" + username = "me" + password = "xxxx-xxxx-xxxx-xxxx" + ``` +4. Restart breadpad. New dated/reminder notes sync up; the `caldav_uid` field + links each note to its event so updates and deletes stay in step. + +If the server is unreachable, breadpad logs a warning and keeps the note +locally — sync is best-effort and never blocks capture. + --- ## Usage From 03a390a1ff010e34be83f6e66f948c47bdd1f468 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 16 Jun 2026 17:07:06 +0800 Subject: [PATCH 46/52] docs: add CalDAV calendar-sync walkthrough The [calendar] config keys existed without explanation. Document enabling CalDAV sync end to end: finding the collection URL, creating an app password, the config block, and the best-effort sync behaviour. Co-Authored-By: Claude Opus 4.8 --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index 02ec8a2..b39ea3d 100644 --- a/README.md +++ b/README.md @@ -190,8 +190,41 @@ enabled = true # set false to never call Ollama [reminders] default_morning = "08:00" # what "tomorrow morning" resolves to missed_grace_minutes = 60 # how long after boot to still fire a missed reminder + +[calendar] +enabled = false # turn on CalDAV sync (see below) +url = "" # CalDAV calendar collection URL +username = "" +password = "" # app password / token recommended ``` +### Calendar sync (CalDAV) + +When `[calendar].enabled = true`, reminders and dated notes are pushed to a +CalDAV calendar as events (tracked by `caldav_uid` on each note), so they show +up alongside the rest of your calendar. + +1. Find your calendar's **collection URL**. It's the per-calendar CalDAV path, + not the server root — e.g. Nextcloud: + `https://host/remote.php/dav/calendars///`. +2. Create an **app password** for breadpad (don't use your main password): + Nextcloud → Settings → Security → *Devices & sessions* → "Create new app + password". Most CalDAV servers have an equivalent. +3. Fill in `breadpad.toml` (or BOS Settings → breadpad → Calendar): + + ```toml + [calendar] + enabled = true + url = "https://host/remote.php/dav/calendars/me/breadpad/" + username = "me" + password = "xxxx-xxxx-xxxx-xxxx" + ``` +4. Restart breadpad. New dated/reminder notes sync up; the `caldav_uid` field + links each note to its event so updates and deletes stay in step. + +If the server is unreachable, breadpad logs a warning and keeps the note +locally — sync is best-effort and never blocks capture. + --- ## Usage From dfe19708ba334e9da4c109a0afde39c4cfdc1993 Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 16 Jun 2026 18:35:33 +0800 Subject: [PATCH 47/52] Release 0.3.4: shared bread-theme stylesheet (overlay=color7) --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8769946..46fa8d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -304,8 +304,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.1.0" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.1.0#6b5f4f475f66a645b08cb865e6dda8228d23679b" +version = "0.2.3" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.6#0c8c5c00e435fedff4f81e36d603424c153519a9" dependencies = [ "dirs 5.0.1", "serde", @@ -314,7 +314,7 @@ dependencies = [ [[package]] name = "breadman" -version = "0.3.1" +version = "0.3.4" dependencies = [ "anyhow", "breadpad-shared", @@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "breadpad" -version = "0.3.1" +version = "0.3.4" dependencies = [ "anyhow", "breadpad-shared", @@ -350,7 +350,7 @@ dependencies = [ [[package]] name = "breadpad-shared" -version = "0.3.1" +version = "0.3.4" dependencies = [ "anyhow", "bread-theme", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "breadpad-test" -version = "0.3.1" +version = "0.3.4" dependencies = [ "anyhow", "breadpad-shared", diff --git a/Cargo.toml b/Cargo.toml index af2b588..4092bed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.3.1" +version = "0.3.4" edition = "2021" license = "MIT" authors = ["Breadway"] From 35e0481314cf77f7eaf5068ab7d0de22a340545c Mon Sep 17 00:00:00 2001 From: Breadway Date: Tue, 16 Jun 2026 18:35:33 +0800 Subject: [PATCH 48/52] Release 0.3.4: shared bread-theme stylesheet (overlay=color7) Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 12 ++++++------ Cargo.toml | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 8769946..46fa8d0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -304,8 +304,8 @@ dependencies = [ [[package]] name = "bread-theme" -version = "0.1.0" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.1.0#6b5f4f475f66a645b08cb865e6dda8228d23679b" +version = "0.2.3" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.6#0c8c5c00e435fedff4f81e36d603424c153519a9" dependencies = [ "dirs 5.0.1", "serde", @@ -314,7 +314,7 @@ dependencies = [ [[package]] name = "breadman" -version = "0.3.1" +version = "0.3.4" dependencies = [ "anyhow", "breadpad-shared", @@ -331,7 +331,7 @@ dependencies = [ [[package]] name = "breadpad" -version = "0.3.1" +version = "0.3.4" dependencies = [ "anyhow", "breadpad-shared", @@ -350,7 +350,7 @@ dependencies = [ [[package]] name = "breadpad-shared" -version = "0.3.1" +version = "0.3.4" dependencies = [ "anyhow", "bread-theme", @@ -376,7 +376,7 @@ dependencies = [ [[package]] name = "breadpad-test" -version = "0.3.1" +version = "0.3.4" dependencies = [ "anyhow", "breadpad-shared", diff --git a/Cargo.toml b/Cargo.toml index af2b588..4092bed 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -8,7 +8,7 @@ members = [ resolver = "2" [workspace.package] -version = "0.3.1" +version = "0.3.4" edition = "2021" license = "MIT" authors = ["Breadway"] From c30aa2497e49451f3915707fb79be2858408eb87 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 17 Jun 2026 12:42:12 +0800 Subject: [PATCH 49/52] Fix illegible text on light pywal palettes + hot-reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use bread-theme 0.2.7's luminance-picked ink (@on-*): type chips on @overlay and selected sidebar rows / confirm buttons on @blue kept @fg or @bg, which vanished when those slots came out light/dark. They now use @on-overlay / @on-accent. Add breadpad_shared::theme::apply_live (wraps bread_theme::gtk::apply_app_css) so breadpad and breadman recolour live on `bread-theme reload` and re-read the user's style.css — replacing the build-once provider. bread-theme bumped to v0.2.7 (gtk feature). --- Cargo.lock | 3 ++- breadman/src/main.rs | 19 +++---------------- breadpad-shared/Cargo.toml | 2 +- breadpad-shared/src/theme.rs | 21 +++++++++++++++++---- breadpad/src/main.rs | 21 ++++----------------- 5 files changed, 27 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 46fa8d0..11b33d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -305,9 +305,10 @@ dependencies = [ [[package]] name = "bread-theme" version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.6#0c8c5c00e435fedff4f81e36d603424c153519a9" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.7#ea87083c0615fc9141b0ae4c99f833748a0189d1" dependencies = [ "dirs 5.0.1", + "gtk4", "serde", "serde_json", ] diff --git a/breadman/src/main.rs b/breadman/src/main.rs index 0ee89bc..d46557a 100644 --- a/breadman/src/main.rs +++ b/breadman/src/main.rs @@ -4,7 +4,6 @@ use breadpad_shared::{ parser::parse_rule_based, scheduler::Scheduler, store::Store, - theme::{build_css, load_palette}, types::{Note, NoteType, RecurrenceRule}, }; use chrono::Local; @@ -924,19 +923,7 @@ fn show_add_note_window(parent: >k4::ApplicationWindow, state: AppState) { // ── CSS ─────────────────────────────────────────────────────────────────────── fn apply_css(_cfg: &Config) { - let palette = load_palette(); - let user_css = std::fs::read_to_string(breadpad_shared::config::style_css_path()).ok(); - let css = build_css(&palette, user_css.as_deref()); - - 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( - &display, - &provider, - gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); + // Hot-reloads on `bread-theme reload` (recolours to the new pywal palette + // and re-reads the user's style.css). See breadpad_shared::theme::apply_live. + breadpad_shared::theme::apply_live(); } diff --git a/breadpad-shared/Cargo.toml b/breadpad-shared/Cargo.toml index cea30b3..52a5b21 100644 --- a/breadpad-shared/Cargo.toml +++ b/breadpad-shared/Cargo.toml @@ -7,7 +7,7 @@ authors.workspace = true [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.6" } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.7", features = ["gtk"] } anyhow.workspace = true tracing.workspace = true serde.workspace = true diff --git a/breadpad-shared/src/theme.rs b/breadpad-shared/src/theme.rs index 857dcfe..cb1139a 100644 --- a/breadpad-shared/src/theme.rs +++ b/breadpad-shared/src/theme.rs @@ -1,5 +1,18 @@ pub use bread_theme::{load_palette, Palette}; +/// Apply breadpad/breadman's stylesheet and keep it live across palette changes. +/// [`build_css`] bundles the shared component sheet with the app's own rules from +/// the current pywal palette; `bread_theme::gtk::apply_app_css` re-runs this +/// whenever `bread-theme reload` rewrites the shared theme file, so the UI +/// recolours in place (and re-reads the user's `style.css` override too). +pub fn apply_live() { + bread_theme::gtk::apply_app_css(|| { + let palette = load_palette(); + let user_css = std::fs::read_to_string(crate::config::style_css_path()).ok(); + build_css(&palette, user_css.as_deref()) + }); +} + /// Generate the full breadpad/breadman CSS string. The base — `@define-color` /// palette, fonts, and generic widget styling — comes from the shared /// `bread_theme::stylesheet`, so breadpad and breadman look identical to the @@ -32,7 +45,7 @@ window { border-radius: 8px; } .type-chip { background: @overlay; - color: @fg; + color: @on-overlay; border-radius: 999px; padding: 4px 12px; font-size: 12px; @@ -41,12 +54,12 @@ window { border-radius: 8px; } .type-chip.active { background: @blue; - color: @bg; + color: @on-accent; } .confirm-button { background: @blue; - color: @bg; + color: @on-accent; border: none; border-radius: 8px; padding: 8px 16px; @@ -90,7 +103,7 @@ window { border-radius: 8px; } .sidebar-row:selected { background: @blue; - color: @bg; + color: @on-accent; font-weight: 500; } diff --git a/breadpad/src/main.rs b/breadpad/src/main.rs index c840557..aa924d0 100644 --- a/breadpad/src/main.rs +++ b/breadpad/src/main.rs @@ -2,10 +2,9 @@ use anyhow::Result; use breadpad_shared::{ calendar::CalDavClient, classifier::Classifier, - config::{style_css_path, Config}, + config::Config, scheduler::Scheduler, store::Store, - theme::{build_css, load_palette}, types::{Note, NoteType}, }; use gtk4::{glib, prelude::*}; @@ -765,19 +764,7 @@ fn save_note_classified( } fn apply_css(_cfg: &Config) { - let palette = load_palette(); - let user_css = std::fs::read_to_string(style_css_path()).ok(); - let css = build_css(&palette, user_css.as_deref()); - - 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( - &display, - &provider, - gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); + // Hot-reloads on `bread-theme reload` (recolours to the new pywal palette + // and re-reads the user's style.css). See breadpad_shared::theme::apply_live. + breadpad_shared::theme::apply_live(); } From 1671439f907a840135f7caa13fce12b5321dfe75 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 17 Jun 2026 12:42:12 +0800 Subject: [PATCH 50/52] Fix illegible text on light pywal palettes + hot-reload MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use bread-theme 0.2.7's luminance-picked ink (@on-*): type chips on @overlay and selected sidebar rows / confirm buttons on @blue kept @fg or @bg, which vanished when those slots came out light/dark. They now use @on-overlay / @on-accent. Add breadpad_shared::theme::apply_live (wraps bread_theme::gtk::apply_app_css) so breadpad and breadman recolour live on `bread-theme reload` and re-read the user's style.css — replacing the build-once provider. bread-theme bumped to v0.2.7 (gtk feature). Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 3 ++- breadman/src/main.rs | 19 +++---------------- breadpad-shared/Cargo.toml | 2 +- breadpad-shared/src/theme.rs | 21 +++++++++++++++++---- breadpad/src/main.rs | 21 ++++----------------- 5 files changed, 27 insertions(+), 39 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 46fa8d0..11b33d9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -305,9 +305,10 @@ dependencies = [ [[package]] name = "bread-theme" version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.6#0c8c5c00e435fedff4f81e36d603424c153519a9" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.7#ea87083c0615fc9141b0ae4c99f833748a0189d1" dependencies = [ "dirs 5.0.1", + "gtk4", "serde", "serde_json", ] diff --git a/breadman/src/main.rs b/breadman/src/main.rs index 0ee89bc..d46557a 100644 --- a/breadman/src/main.rs +++ b/breadman/src/main.rs @@ -4,7 +4,6 @@ use breadpad_shared::{ parser::parse_rule_based, scheduler::Scheduler, store::Store, - theme::{build_css, load_palette}, types::{Note, NoteType, RecurrenceRule}, }; use chrono::Local; @@ -924,19 +923,7 @@ fn show_add_note_window(parent: >k4::ApplicationWindow, state: AppState) { // ── CSS ─────────────────────────────────────────────────────────────────────── fn apply_css(_cfg: &Config) { - let palette = load_palette(); - let user_css = std::fs::read_to_string(breadpad_shared::config::style_css_path()).ok(); - let css = build_css(&palette, user_css.as_deref()); - - 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( - &display, - &provider, - gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); + // Hot-reloads on `bread-theme reload` (recolours to the new pywal palette + // and re-reads the user's style.css). See breadpad_shared::theme::apply_live. + breadpad_shared::theme::apply_live(); } diff --git a/breadpad-shared/Cargo.toml b/breadpad-shared/Cargo.toml index cea30b3..52a5b21 100644 --- a/breadpad-shared/Cargo.toml +++ b/breadpad-shared/Cargo.toml @@ -7,7 +7,7 @@ authors.workspace = true [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.6" } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.7", features = ["gtk"] } anyhow.workspace = true tracing.workspace = true serde.workspace = true diff --git a/breadpad-shared/src/theme.rs b/breadpad-shared/src/theme.rs index 857dcfe..cb1139a 100644 --- a/breadpad-shared/src/theme.rs +++ b/breadpad-shared/src/theme.rs @@ -1,5 +1,18 @@ pub use bread_theme::{load_palette, Palette}; +/// Apply breadpad/breadman's stylesheet and keep it live across palette changes. +/// [`build_css`] bundles the shared component sheet with the app's own rules from +/// the current pywal palette; `bread_theme::gtk::apply_app_css` re-runs this +/// whenever `bread-theme reload` rewrites the shared theme file, so the UI +/// recolours in place (and re-reads the user's `style.css` override too). +pub fn apply_live() { + bread_theme::gtk::apply_app_css(|| { + let palette = load_palette(); + let user_css = std::fs::read_to_string(crate::config::style_css_path()).ok(); + build_css(&palette, user_css.as_deref()) + }); +} + /// Generate the full breadpad/breadman CSS string. The base — `@define-color` /// palette, fonts, and generic widget styling — comes from the shared /// `bread_theme::stylesheet`, so breadpad and breadman look identical to the @@ -32,7 +45,7 @@ window { border-radius: 8px; } .type-chip { background: @overlay; - color: @fg; + color: @on-overlay; border-radius: 999px; padding: 4px 12px; font-size: 12px; @@ -41,12 +54,12 @@ window { border-radius: 8px; } .type-chip.active { background: @blue; - color: @bg; + color: @on-accent; } .confirm-button { background: @blue; - color: @bg; + color: @on-accent; border: none; border-radius: 8px; padding: 8px 16px; @@ -90,7 +103,7 @@ window { border-radius: 8px; } .sidebar-row:selected { background: @blue; - color: @bg; + color: @on-accent; font-weight: 500; } diff --git a/breadpad/src/main.rs b/breadpad/src/main.rs index c840557..aa924d0 100644 --- a/breadpad/src/main.rs +++ b/breadpad/src/main.rs @@ -2,10 +2,9 @@ use anyhow::Result; use breadpad_shared::{ calendar::CalDavClient, classifier::Classifier, - config::{style_css_path, Config}, + config::Config, scheduler::Scheduler, store::Store, - theme::{build_css, load_palette}, types::{Note, NoteType}, }; use gtk4::{glib, prelude::*}; @@ -765,19 +764,7 @@ fn save_note_classified( } fn apply_css(_cfg: &Config) { - let palette = load_palette(); - let user_css = std::fs::read_to_string(style_css_path()).ok(); - let css = build_css(&palette, user_css.as_deref()); - - 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( - &display, - &provider, - gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, - ); + // Hot-reloads on `bread-theme reload` (recolours to the new pywal palette + // and re-reads the user's style.css). See breadpad_shared::theme::apply_live. + breadpad_shared::theme::apply_live(); } From e0b55e1713661f8fc7f28cff8711ecd2271a6856 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 17 Jun 2026 12:55:57 +0800 Subject: [PATCH 51/52] Bump bread-theme to v0.2.8 (live-reload fix) --- Cargo.lock | 2 +- breadpad-shared/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 11b33d9..756f0c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -305,7 +305,7 @@ dependencies = [ [[package]] name = "bread-theme" version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.7#ea87083c0615fc9141b0ae4c99f833748a0189d1" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.8#77417d552130281ff787e07d52541eb25e9d533b" dependencies = [ "dirs 5.0.1", "gtk4", diff --git a/breadpad-shared/Cargo.toml b/breadpad-shared/Cargo.toml index 52a5b21..01470f9 100644 --- a/breadpad-shared/Cargo.toml +++ b/breadpad-shared/Cargo.toml @@ -7,7 +7,7 @@ authors.workspace = true [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.7", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] } anyhow.workspace = true tracing.workspace = true serde.workspace = true From b5c8f8d43adda0553b0f053cd8f6dc1cf588f186 Mon Sep 17 00:00:00 2001 From: Breadway Date: Wed, 17 Jun 2026 12:55:57 +0800 Subject: [PATCH 52/52] Bump bread-theme to v0.2.8 (live-reload fix) Co-Authored-By: Claude Opus 4.8 --- Cargo.lock | 2 +- breadpad-shared/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 11b33d9..756f0c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -305,7 +305,7 @@ dependencies = [ [[package]] name = "bread-theme" version = "0.2.3" -source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.7#ea87083c0615fc9141b0ae4c99f833748a0189d1" +source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.8#77417d552130281ff787e07d52541eb25e9d533b" dependencies = [ "dirs 5.0.1", "gtk4", diff --git a/breadpad-shared/Cargo.toml b/breadpad-shared/Cargo.toml index 52a5b21..01470f9 100644 --- a/breadpad-shared/Cargo.toml +++ b/breadpad-shared/Cargo.toml @@ -7,7 +7,7 @@ authors.workspace = true [dependencies] -bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.7", features = ["gtk"] } +bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] } anyhow.workspace = true tracing.workspace = true serde.workspace = true