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)
This commit is contained in:
Breadway 2026-06-06 12:25:40 +08:00
parent feefdb81b9
commit c4626dd64d
34 changed files with 2825 additions and 771 deletions

28
.gitignore vendored
View file

@ -1 +1,29 @@
target/ 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/

230
Cargo.lock generated
View file

@ -259,12 +259,6 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@ -316,6 +310,7 @@ dependencies = [
"breadpad-shared", "breadpad-shared",
"chrono", "chrono",
"dirs 5.0.1", "dirs 5.0.1",
"futures-channel",
"gtk4", "gtk4",
"serde", "serde",
"serde_json", "serde_json",
@ -335,6 +330,7 @@ dependencies = [
"gtk4", "gtk4",
"gtk4-layer-shell", "gtk4-layer-shell",
"hyprland", "hyprland",
"ort",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
@ -362,7 +358,7 @@ dependencies = [
"tokio", "tokio",
"toml 0.8.23", "toml 0.8.23",
"tracing", "tracing",
"ureq 2.12.1", "ureq",
"uuid", "uuid",
"zbus", "zbus",
] ]
@ -377,6 +373,8 @@ dependencies = [
"clap", "clap",
"colored", "colored",
"comfy-table", "comfy-table",
"dirs 5.0.1",
"ort",
"serde", "serde",
"serde_json", "serde_json",
] ]
@ -612,16 +610,6 @@ dependencies = [
"unicode-segmentation", "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]] [[package]]
name = "core-foundation-sys" name = "core-foundation-sys"
version = "0.8.7" version = "0.8.7"
@ -748,16 +736,6 @@ dependencies = [
"serde", "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]] [[package]]
name = "derive_builder" name = "derive_builder"
version = "0.20.2" version = "0.20.2"
@ -1021,21 +999,6 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" 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]] [[package]]
name = "form_urlencoded" name = "form_urlencoded"
version = "1.2.2" version = "1.2.2"
@ -1521,16 +1484,10 @@ dependencies = [
"serde", "serde",
"serde_json", "serde_json",
"thiserror 2.0.18", "thiserror 2.0.18",
"ureq 2.12.1", "ureq",
"windows-sys 0.60.2", "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]] [[package]]
name = "http" name = "http"
version = "1.4.0" version = "1.4.0"
@ -1894,6 +1851,16 @@ version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" 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]] [[package]]
name = "libredox" name = "libredox"
version = "0.1.16" version = "0.1.16"
@ -1942,12 +1909,6 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "lzma-rust2"
version = "0.15.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619"
[[package]] [[package]]
name = "macro_rules_attribute" name = "macro_rules_attribute"
version = "0.2.2" version = "0.2.2"
@ -2047,23 +2008,6 @@ dependencies = [
"syn", "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]] [[package]]
name = "ndarray" name = "ndarray"
version = "0.16.1" version = "0.16.1"
@ -2171,49 +2115,6 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" 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]] [[package]]
name = "option-ext" name = "option-ext"
version = "0.2.0" version = "0.2.0"
@ -2236,11 +2137,11 @@ version = "2.0.0-rc.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133" checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133"
dependencies = [ dependencies = [
"libloading",
"ndarray 0.17.2", "ndarray 0.17.2",
"ort-sys", "ort-sys",
"smallvec", "smallvec",
"tracing", "tracing",
"ureq 3.3.0",
] ]
[[package]] [[package]]
@ -2248,11 +2149,6 @@ name = "ort-sys"
version = "2.0.0-rc.12" version = "2.0.0-rc.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90" checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90"
dependencies = [
"hmac-sha256",
"lzma-rust2",
"ureq 3.3.0",
]
[[package]] [[package]]
name = "pango" name = "pango"
@ -2328,15 +2224,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" 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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
@ -2853,44 +2740,12 @@ version = "1.0.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" 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]] [[package]]
name = "scopeguard" name = "scopeguard"
version = "1.2.0" version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" 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]] [[package]]
name = "semver" name = "semver"
version = "1.0.28" version = "1.0.28"
@ -3593,36 +3448,6 @@ dependencies = [
"webpki-roots 0.26.11", "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]] [[package]]
name = "url" name = "url"
version = "2.5.8" version = "2.5.8"
@ -3635,12 +3460,6 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "utf8-zero"
version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]] [[package]]
name = "utf8_iter" name = "utf8_iter"
version = "1.0.4" version = "1.0.4"
@ -3670,12 +3489,6 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]] [[package]]
name = "version-compare" name = "version-compare"
version = "0.2.1" version = "0.2.1"
@ -3830,15 +3643,6 @@ dependencies = [
"wasm-bindgen", "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]] [[package]]
name = "webpki-roots" name = "webpki-roots"
version = "0.26.11" version = "0.26.11"

View file

@ -24,7 +24,7 @@ chrono = { version = "0.4", features = ["serde"] }
rrule = "0.12" rrule = "0.12"
tokio = { version = "1", features = ["full"] } tokio = { version = "1", features = ["full"] }
zbus = { version = "4", default-features = false, features = ["tokio"] } 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" ndarray = "0.16"
tokenizers = { version = "0.21", default-features = false, features = ["http", "fancy-regex"] } tokenizers = { version = "0.21", default-features = false, features = ["http", "fancy-regex"] }
gtk4 = { version = "0.11", features = ["v4_12"] } gtk4 = { version = "0.11", features = ["v4_12"] }

21
LICENSE Normal file
View file

@ -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.

View file

@ -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 - 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 - Mark todo/reminder as done; done items move to an archive accessible via a toggle
- Search across all notes (full-text, instant) - Search across all notes (full-text, instant)
- Sort by: newest, oldest, due time - Sort: newest first (default)
### Theming ### 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. 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 ```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":"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: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: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. 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. 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: 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`.
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`.
#### Tier 3 — Large local model via Ollama #### 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 ~/.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 ```bash
breadpad download-model # fetches default model (~150 MB) breadpad model-info # shows active EP and model path
breadpad model-info # shows active EP, model path, last inference time
``` ```
--- ---
@ -144,9 +137,8 @@ breadpad model-info # shows active EP, model path, last inference time
- GTK4 (≥ 4.12) + `gtk4-layer-shell` - GTK4 (≥ 4.12) + `gtk4-layer-shell`
- D-Bus session bus (for notifications) - D-Bus session bus (for notifications)
- systemd user session (for timer-backed reminders) - systemd user session (for timer-backed reminders)
- Rust 1.77+ - Rust 1.80+
- ONNX Runtime (`ort` crate pulls this in automatically via the `download-binaries` feature) - **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.
- **NPU path only:** AMD Ryzen AI software stack (`amdxdna` kernel driver ≥ 6.11, QNN EP shared libs)
- **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. - **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/breadpad ~/.local/bin/
cp target/release/breadman ~/.local/bin/ cp target/release/breadman ~/.local/bin/
# Fetch the default classifier model # Place your ONNX classifier and tokenizer in the model directory
breadpad download-model 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: On Arch Linux, install GTK4 dependencies first:
@ -186,7 +179,7 @@ archive_after_days = 30
[model] [model]
path = "~/.local/share/breadpad/model/classifier.onnx" path = "~/.local/share/breadpad/model/classifier.onnx"
tokenizer = "~/.local/share/breadpad/model/tokenizer.json" 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] [model.ollama]
endpoint = "http://localhost:11434" endpoint = "http://localhost:11434"

BIN
bread.zip

Binary file not shown.

View file

@ -20,3 +20,4 @@ tokio.workspace = true
chrono.workspace = true chrono.workspace = true
gtk4.workspace = true gtk4.workspace = true
dirs.workspace = true dirs.workspace = true
futures-channel = "0.3"

View file

@ -1,10 +1,11 @@
use breadpad_shared::{ use breadpad_shared::{
parser::parse_rule_based, parser::parse_rule_based,
scheduler::Scheduler,
store::Store, store::Store,
types::{Note, NoteType, RecurrenceRule}, types::{Note, NoteType, RecurrenceRule},
}; };
use chrono::{Local, TimeZone, Utc}; use chrono::{Local, TimeZone, Utc};
use gtk4::prelude::*; use gtk4::{glib, prelude::*};
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; use std::rc::Rc;
use std::sync::Arc; use std::sync::Arc;
@ -13,8 +14,9 @@ pub fn build_editor_popover(
note: &Note, note: &Note,
store: Arc<Store>, store: Arc<Store>,
morning: String, morning: String,
on_save: impl Fn(Note) + 'static, on_save: Rc<dyn Fn(Note)>,
on_delete: impl Fn() + 'static, on_delete: Rc<dyn Fn()>,
on_error: Rc<dyn Fn(String)>,
) -> gtk4::Popover { ) -> gtk4::Popover {
let popover = gtk4::Popover::new(); let popover = gtk4::Popover::new();
popover.set_has_arrow(false); popover.set_has_arrow(false);
@ -86,7 +88,7 @@ pub fn build_editor_popover(
btn_row.append(&save_btn); btn_row.append(&save_btn);
vbox.append(&btn_row); 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 = Rc::new(RefCell::new(false));
{ {
let confirming = confirming.clone(); let confirming = confirming.clone();
@ -94,16 +96,32 @@ pub fn build_editor_popover(
let note_id = note.id.clone(); let note_id = note.id.clone();
let store_del = store.clone(); let store_del = store.clone();
let popover_del = popover.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 |_| { delete_btn.connect_clicked(move |_| {
let currently = *confirming.borrow(); if *confirming.borrow() {
if currently { let store = store_del.clone();
if let Err(e) = store_del.delete_note(&note_id) { let id = note_id.clone();
tracing::error!("failed to delete note: {}", e); let on_delete = Rc::clone(&on_delete);
} else { let on_error = Rc::clone(&on_error);
on_delete(); 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);
} }
popover_del.popdown(); Ok(())
},
move |result| {
match result {
Ok(()) => on_delete(),
Err(e) => on_error(format!("delete failed: {}", e)),
}
popover.popdown();
},
);
} else { } else {
*confirming.borrow_mut() = true; *confirming.borrow_mut() = true;
delete_btn_label.set_label("Sure?"); delete_btn_label.set_label("Sure?");
@ -112,27 +130,27 @@ pub fn build_editor_popover(
} }
// Save // Save
{
let note_clone = note.clone(); let note_clone = note.clone();
let popover_save = popover.clone(); let popover_save = popover.clone();
let on_error = Rc::clone(&on_error);
save_btn.connect_clicked(move |_| { save_btn.connect_clicked(move |_| {
// Read all field values on the main thread before handing off.
let mut updated = note_clone.clone(); let mut updated = note_clone.clone();
updated.body = body_entry.text().to_string(); updated.body = body_entry.text().to_string();
updated.note_type = NoteType::from_str( updated.note_type = NoteType::from_str(
NoteType::all_builtin() NoteType::all_builtin()
.get(type_combo.selected() as usize) .get(type_combo.selected() as usize)
.copied() .copied()
.unwrap_or("note"), .unwrap_or("note"),
); );
let time_str = time_entry.text().to_string(); let time_str = time_entry.text().to_string();
updated.time = if time_str.trim().is_empty() { updated.time = if time_str.trim().is_empty() {
None None
} else { } else {
parse_time_field(&time_str, &morning) parse_time_field(&time_str, &morning)
}; };
let rrule_text = rrule_entry.text().to_string(); let rrule_text = rrule_entry.text().to_string();
updated.rrule = if rrule_text.trim().is_empty() { updated.rrule = if rrule_text.trim().is_empty() {
None None
@ -140,18 +158,49 @@ pub fn build_editor_popover(
Some(RecurrenceRule::new(rrule_text)) 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(); 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<Note> {
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.set_child(Some(&vbox));
popover popover
} }
fn spawn_bg<F, T, C>(work: F, then: C)
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
C: FnOnce(T) + 'static,
{
let (tx, rx) = futures_channel::oneshot::channel::<T>();
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<chrono::DateTime<Utc>> { fn parse_time_field(s: &str, morning: &str) -> Option<chrono::DateTime<Utc>> {
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s.trim(), "%Y-%m-%d %H:%M") { 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) { if let chrono::LocalResult::Single(local) = Local.from_local_datetime(&naive) {

View file

@ -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<RefCell<...>>`.
///
/// 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<F, T, C>(work: F, then: C)
where
F: FnOnce() -> T + Send + 'static,
T: Send + 'static,
C: FnOnce(T) + 'static,
{
let (tx, rx) = futures_channel::oneshot::channel::<T>();
std::thread::spawn(move || { let _ = tx.send(work()); });
glib::MainContext::default().spawn_local(async move {
if let Ok(result) = rx.await {
then(result);
}
});
}
// ── Refresh ─────────────────────────────────────────────────────────────────── // ── Refresh ───────────────────────────────────────────────────────────────────
fn refresh(state: &AppState) { fn refresh(state: &AppState) {
@ -116,6 +140,16 @@ fn refresh(state: &AppState) {
state.stack.set_visible_child_name(&active); 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) { fn rebuild_stack(state: &AppState) {
while let Some(child) = state.stack.first_child() { while let Some(child) = state.stack.first_child() {
state.stack.remove(&child); state.stack.remove(&child);
@ -208,9 +242,9 @@ fn cmd_upcoming_plain() -> Result<()> {
&& n.effective_time().is_some() && n.effective_time().is_some()
}) })
.collect(); .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 &notes { for note in &notes {
let t = note.effective_time().unwrap(); let t = note.effective_time().expect("filtered by is_some above");
let local: chrono::DateTime<Local> = t.into(); let local: chrono::DateTime<Local> = t.into();
println!("[{}] {}{}", note.id, local.format("%a %b %d %H:%M"), note.body); 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() let new_note_btn = gtk4::Button::builder()
.label("✚ New Note") .label("✚ New Note")
.css_classes(["confirm-button"]) .css_classes(["confirm-button"])
.margin_start(10) .margin_start(12)
.margin_end(10) .margin_end(12)
.margin_top(12) .margin_top(16)
.margin_bottom(6) .margin_bottom(12)
.build(); .build();
sidebar_vbox.append(&new_note_btn); sidebar_vbox.append(&new_note_btn);
@ -349,10 +383,10 @@ fn build_app_window(
let search_entry = gtk4::SearchEntry::builder() let search_entry = gtk4::SearchEntry::builder()
.placeholder_text("Search notes…") .placeholder_text("Search notes…")
.css_classes(["search-entry"]) .css_classes(["search-entry"])
.margin_start(8) .margin_start(12)
.margin_end(8) .margin_end(12)
.margin_top(8) .margin_top(12)
.margin_bottom(4) .margin_bottom(8)
.build(); .build();
let stack = gtk4::Stack::builder().hexpand(true).vexpand(true).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)) .filter(|n| n.body.to_lowercase().contains(&q))
.collect() .collect()
}; };
// Only replace the "all" page — other views keep their scroll position.
// Replace the "all" page with the filtered list while preserving others rebuild_all_view(&filtered, &state_c);
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(&notes_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<Note> = 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(&notes_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"));
state_c.stack.set_visible_child_name("all"); 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() let list = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical) .orientation(gtk4::Orientation::Vertical)
.spacing(4) .spacing(8)
.margin_top(8) .margin_top(12)
.margin_bottom(8) .margin_bottom(12)
.margin_start(12)
.margin_end(12)
.build(); .build();
let mut sorted: Vec<Note> = notes.iter().filter(|n| !n.done).cloned().collect(); let mut sorted: Vec<Note> = 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 { fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
let card = gtk4::Box::builder() let card = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical) .orientation(gtk4::Orientation::Vertical)
.spacing(4) .spacing(8)
.margin_start(8) .margin_start(0)
.margin_end(8) .margin_end(0)
.margin_top(4) .margin_top(0)
.margin_bottom(4) .margin_bottom(0)
.css_classes(["note-card"]) .css_classes(["note-card"])
.build(); .build();
card.add_css_class(&format!("note-card-{}", note.note_type.as_str())); 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 card_c = card.clone();
let state_c = state.clone(); let state_c = state.clone();
done_btn.connect_clicked(move |_| { done_btn.connect_clicked(move |_| {
if let Ok(Some(mut n)) = state_c.store.get_by_id(&note_id) { 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<Vec<Note>> {
if let Some(mut n) = store.get_by_id(&id)? {
n.mark_done(); n.mark_done();
if let Err(e) = state_c.store.update_note(&n) { store.update_note(&n)?;
state_c.log_error(format!("mark done failed: {}", 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);
} }
card_c.set_visible(false); Err(e) => state.log_error(format!("mark done failed: {}", e)),
state_c.reload_notes(); }
},
);
}); });
} }
bottom_row.append(&done_btn); 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 body_label_save = body_label_c.clone();
let state_del = state_c.clone(); let state_del = state_c.clone();
let card_del = card_c.clone(); let card_del = card_c.clone();
let state_err = state_c.clone();
let popover = editor::build_editor_popover( let popover = editor::build_editor_popover(
&note_c, &note_c,
store, store,
morning, morning,
move |updated: Note| { Rc::new(move |updated: Note| {
body_label_save.set_label(&updated.body); body_label_save.set_label(&updated.body);
state_save.reload_notes(); state_save.reload_notes();
}, rebuild_stack(&state_save);
move || { let active = state_save.active_view.borrow().clone();
state_save.stack.set_visible_child_name(&active);
}),
Rc::new(move || {
card_del.set_visible(false); card_del.set_visible(false);
state_del.reload_notes(); 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.set_parent(btn);
popover.popup(); popover.popup();
@ -659,12 +693,30 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
delete_btn.connect_clicked(move |_| { delete_btn.connect_clicked(move |_| {
if *confirming.borrow() { if *confirming.borrow() {
card_c.set_visible(false); // optimistic hide
let store = state_c.write_store(); let store = state_c.write_store();
if let Err(e) = store.delete_note(&note_id) { let id = note_id.clone();
state_c.log_error(format!("delete failed: {}", e)); let state = state_c.clone();
spawn_bg(
move || -> anyhow::Result<Vec<Note>> {
store.delete_note(&id)?;
if let Err(e) = Scheduler::cancel(&id) {
tracing::warn!("failed to cancel timer for {}: {}", id, e);
} }
card_c.set_visible(false); store.load_all()
state_c.reload_notes(); },
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 { } else {
*confirming.borrow_mut() = true; *confirming.borrow_mut() = true;
btn_c.set_label("Sure?"); btn_c.set_label("Sure?");
@ -779,108 +831,88 @@ fn show_add_note_window(parent: &gtk4::ApplicationWindow, state: AppState) {
cancel_btn.connect_clicked(move |_| win_c.close()); cancel_btn.connect_clicked(move |_| win_c.close());
} }
// Add Note // Shared add-note logic — called by both the button and the Enter key.
{ let do_add: Rc<dyn Fn()> = Rc::new({
let win_c = win.clone(); let win = win.clone();
let state_c = state.clone(); let state = state.clone();
let body_c = body_entry.clone(); let body_entry = body_entry.clone();
let time_c = time_entry.clone(); let time_entry = time_entry.clone();
let rrule_c = rrule_entry.clone(); let rrule_entry = rrule_entry.clone();
let sel_c = selected_type.clone(); let selected_type = selected_type.clone();
let status_c = status_label.clone(); let status_label = status_label.clone();
let do_add = move || { move || {
let body_text = body_c.text().to_string(); let body_text = body_entry.text().to_string();
if body_text.trim().is_empty() { if body_text.trim().is_empty() {
status_c.set_label("Body is required."); status_label.set_label("Body is required.");
return; return;
} }
let morning = state_c.cfg.borrow().reminders.default_morning.clone(); let morning = state.cfg.borrow().reminders.default_morning.clone();
// Tier 1 classification on body
let parsed = parse_rule_based(&body_text, &morning); let parsed = parse_rule_based(&body_text, &morning);
let user_type = selected_type.borrow().clone();
let user_type = sel_c.borrow().clone(); let default_type = NoteType::from_str(&state.cfg.borrow().settings.default_type);
let default_type = NoteType::from_str(&state_c.cfg.borrow().settings.default_type);
let mut note = Note::new(parsed.body.clone(), user_type.clone(), None); 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 { if user_type == default_type {
note.note_type = parsed.note_type; note.note_type = parsed.note_type;
} }
note.time = parsed.time; note.time = parsed.time;
note.rrule = parsed.rrule; note.rrule = parsed.rrule;
// Time field overrides let time_str = time_entry.text().to_string();
let time_str = time_c.text().to_string();
if !time_str.trim().is_empty() { if !time_str.trim().is_empty() {
let tp = parse_rule_based(&time_str, &morning); let tp = parse_rule_based(&time_str, &morning);
if tp.time.is_some() { note.time = tp.time; } if tp.time.is_some() { note.time = tp.time; }
if tp.rrule.is_some() { note.rrule = tp.rrule; } if tp.rrule.is_some() { note.rrule = tp.rrule; }
} }
// RRULE field overrides let rrule_str = rrule_entry.text().to_string();
let rrule_str = rrule_c.text().to_string();
if !rrule_str.trim().is_empty() { if !rrule_str.trim().is_empty() {
note.rrule = Some(RecurrenceRule::new(rrule_str)); note.rrule = Some(RecurrenceRule::new(rrule_str));
} }
let store = state_c.write_store(); let store = state.write_store();
if let Err(e) = store.save_note(&note) { win.close();
state_c.log_error(format!("save failed: {}", e));
return;
}
if note.time.is_some() {
if let Err(e) = Scheduler::schedule(&note) {
state_c.log_error(format!("schedule failed: {}", e));
}
}
win_c.close(); let state_bg = state.clone();
// Defer refresh so the window close event is processed first spawn_bg(
let state_refresh = state_c.clone(); move || -> anyhow::Result<Vec<Note>> {
glib::idle_add_local_once(move || refresh(&state_refresh)); store.save_note(&note)?;
}; if note.time.is_some() || note.rrule.is_some() {
if let Err(e) = Scheduler::cancel(&note.id) {
tracing::warn!("cancel before schedule: {}", e);
}
Scheduler::schedule(&note)?;
}
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()); add_btn.connect_clicked(move |_| do_add());
} }
// Also trigger add on Enter in body field
{ {
let win_c2 = win.clone(); let do_add = Rc::clone(&do_add);
let state_c2 = state.clone(); let time_entry = time_entry.clone();
let body_c2 = body_entry.clone(); let rrule_entry = rrule_entry.clone();
let time_c2 = time_entry.clone();
let rrule_c2 = rrule_entry.clone();
let sel_c2 = selected_type.clone();
body_entry.connect_activate(move |_| { body_entry.connect_activate(move |_| {
// If time/rrule fields are empty, submit immediately if time_entry.text().is_empty() && rrule_entry.text().is_empty() {
if time_c2.text().is_empty() && rrule_c2.text().is_empty() { do_add();
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(&note) {
state_c2.log_error(format!("save failed: {}", e));
return;
}
if note.time.is_some() {
if let Err(e) = Scheduler::schedule(&note) {
state_c2.log_error(format!("schedule failed: {}", e));
}
}
win_c2.close();
let sr = state_c2.clone();
glib::idle_add_local_once(move || refresh(&sr));
} }
}); });
} }
@ -898,8 +930,12 @@ fn apply_css(_cfg: &Config) {
let provider = gtk4::CssProvider::new(); let provider = gtk4::CssProvider::new();
provider.load_from_string(&css); 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( gtk4::style_context_add_provider_for_display(
&gtk4::gdk::Display::default().unwrap(), &display,
&provider, &provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
); );

View file

@ -79,14 +79,11 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
.build(); .build();
attach_row(&model_grid, 1, "Tokenizer path", &tokenizer_entry); attach_row(&model_grid, 1, "Tokenizer path", &tokenizer_entry);
let ep_options = ["auto", "npu", "vulkan", "cpu"]; let ort_dylib_entry = gtk4::Entry::builder()
let ep_combo = gtk4::DropDown::from_strings(&ep_options); .text(&cfg.model.ort_dylib_path)
let ep_idx = ep_options .hexpand(true)
.iter() .build();
.position(|&s| s == cfg.model.execution_provider.as_str()) attach_row(&model_grid, 2, "ORT dylib path", &ort_dylib_entry);
.unwrap_or(0) as u32;
ep_combo.set_selected(ep_idx);
attach_row(&model_grid, 2, "Execution provider", &ep_combo);
outer.append(&model_frame); 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 grs = grace_spin.clone();
let mpe = model_path_entry.clone(); let mpe = model_path_entry.clone();
let tke = tokenizer_entry.clone(); let tke = tokenizer_entry.clone();
let epc = ep_combo.clone(); let ode = ort_dylib_entry.clone();
let oec = ollama_enabled.clone(); let oec = ollama_enabled.clone();
let oee = ollama_endpoint.clone(); let oee = ollama_endpoint.clone();
let ome = ollama_model.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 { model: ModelConfig {
path: mpe.text().to_string(), path: mpe.text().to_string(),
tokenizer: tke.text().to_string(), tokenizer: tke.text().to_string(),
execution_provider: ep_options ort_dylib_path: ode.text().to_string(),
.get(epc.selected() as usize)
.copied()
.unwrap_or("auto")
.to_string(),
ollama: OllamaConfig { ollama: OllamaConfig {
enabled: oec.is_active(), enabled: oec.is_active(),
endpoint: oee.text().to_string(), endpoint: oee.text().to_string(),

View file

@ -5,6 +5,7 @@ edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
[dependencies] [dependencies]
anyhow.workspace = true anyhow.workspace = true
tracing.workspace = true tracing.workspace = true

View file

@ -70,10 +70,9 @@ impl OllamaClient {
.into_json() .into_json()
.map_err(|e| anyhow::anyhow!("deserialize Ollama envelope: {}", e))?; .map_err(|e| anyhow::anyhow!("deserialize Ollama envelope: {}", e))?;
let classification: OllamaClassification = serde_json::from_str(&ollama_resp.response) let classification: OllamaClassification = extract_json(&ollama_resp.response)
.map_err(|e| anyhow::anyhow!( .ok_or_else(|| anyhow::anyhow!(
"parse Ollama classification JSON: {} — raw: {:?}", "no JSON object found in response — raw: {:?}",
e,
&ollama_resp.response &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<T: serde::de::DeserializeOwned>(s: &str) -> Option<T> {
let start = s.find('{')?;
let end = s.rfind('}')?;
if end < start { return None; }
serde_json::from_str(&s[start..=end]).ok()
}

View file

@ -16,9 +16,14 @@ pub struct CalDavEventInfo {
impl CalDavClient { impl CalDavClient {
pub fn new(config: CalendarConfig) -> Self { 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() let client = reqwest::Client::builder()
.build() .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 } CalDavClient { config, client }
} }
@ -139,28 +144,56 @@ fn event_url(base: &str, uid: &str) -> String {
fn build_ical(note: &Note, uid: &str) -> String { fn build_ical(note: &Note, uid: &str) -> String {
let dt = note.time.unwrap_or(note.created); let dt = note.time.unwrap_or(note.created);
let dtstart = dt.format("%Y%m%dT%H%M%SZ").to_string(); let dtstart = dt.format("%Y%m%dT%H%M%SZ").to_string();
let summary = escape_ical(&note.body); let dtstamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
let description = escape_ical(&format!("type={}", note.note_type.as_str()));
let mut ical = format!( let mut lines: Vec<String> = vec![
"BEGIN:VCALENDAR\r\n\ "BEGIN:VCALENDAR".into(),
VERSION:2.0\r\n\ "VERSION:2.0".into(),
PRODID:-//breadpad//EN\r\n\ "PRODID:-//breadpad//EN".into(),
BEGIN:VEVENT\r\n\ "BEGIN:VEVENT".into(),
UID:{uid}\r\n\ format!("UID:{}", uid),
SUMMARY:{summary}\r\n\ fold_line(&format!("SUMMARY:{}", escape_ical(&note.body))),
DTSTART:{dtstart}\r\n\ format!("DTSTART:{}", dtstart),
DTEND:{dtstart}\r\n\ format!("DTEND:{}", dtstart),
DESCRIPTION:{description}\r\n" format!("DTSTAMP:{}", dtstamp),
); fold_line(&format!("DESCRIPTION:{}", escape_ical(&format!("type={}", note.note_type.as_str())))),
];
if let Some(rrule) = &note.rrule { if let Some(rrule) = &note.rrule {
ical.push_str(rrule.as_str()); lines.push(rrule.as_str().to_string());
ical.push_str("\r\n");
} }
ical.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n"); lines.push("END:VEVENT".into());
ical 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 { fn escape_ical(s: &str) -> String {
@ -219,3 +252,241 @@ fn parse_ical_block(data: &str) -> Vec<CalDavEventInfo> {
out 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 = "<multistatus><response><status>HTTP/1.1 200 OK</status></response></multistatus>";
let events = parse_report_response(xml).unwrap();
assert!(events.is_empty());
}
}

View file

@ -9,16 +9,14 @@ const TIER1_SKIP_THRESHOLD: f32 = 0.82;
#[derive(Debug, Clone, PartialEq)] #[derive(Debug, Clone, PartialEq)]
pub enum ExecutionProvider { pub enum ExecutionProvider {
Qnn, Gpu,
Vulkan,
Cpu, Cpu,
} }
impl ExecutionProvider { impl ExecutionProvider {
pub fn as_str(&self) -> &str { pub fn as_str(&self) -> &str {
match self { match self {
ExecutionProvider::Qnn => "QNN (NPU)", ExecutionProvider::Gpu => "ROCm (iGPU)",
ExecutionProvider::Vulkan => "Vulkan",
ExecutionProvider::Cpu => "CPU", ExecutionProvider::Cpu => "CPU",
} }
} }
@ -43,20 +41,27 @@ fn model_dir() -> PathBuf {
impl Classifier { impl Classifier {
/// Load with Tier 1 + optional Tier 2 (ONNX). Tier 3 disabled unless /// Load with Tier 1 + optional Tier 2 (ONNX). Tier 3 disabled unless
/// `.with_ollama()` is called on the returned value. /// `.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 dir = model_dir();
let onnx_path = dir.join("classifier.onnx"); let onnx_path = dir.join("classifier.onnx");
let tok_path = dir.join("tokenizer.json"); 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() { pub fn load_with_paths(
try_load_session(&onnx_path, ep_pref) default_morning: &str,
model_path: PathBuf,
tokenizer_path: PathBuf,
) -> Self {
let (session, active_provider) = if model_path.exists() {
try_load_session(&model_path)
} else { } 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) (None, ExecutionProvider::Cpu)
}; };
let tokenizer = if tok_path.exists() && session.is_some() { let tokenizer = if tokenizer_path.exists() && session.is_some() {
match tokenizers::Tokenizer::from_file(&tok_path) { match tokenizers::Tokenizer::from_file(&tokenizer_path) {
Ok(tok) => Some(tok), Ok(tok) => Some(tok),
Err(e) => { Err(e) => {
tracing::warn!("failed to load tokenizer: {}", e); tracing::warn!("failed to load tokenizer: {}", e);
@ -71,7 +76,7 @@ impl Classifier {
session, session,
tokenizer, tokenizer,
active_provider, active_provider,
model_path: onnx_path, model_path,
default_morning: default_morning.to_string(), default_morning: default_morning.to_string(),
ollama: None, ollama: None,
} }
@ -144,6 +149,13 @@ impl Classifier {
pub fn model_available(&self) -> bool { pub fn model_available(&self) -> bool {
self.session.is_some() 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<ClassificationResult> {
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 // NLI hypotheses paired with their note types. The model scores each as
@ -204,7 +216,7 @@ fn run_onnx(
let best_idx = entailment_scores let best_idx = entailment_scores
.iter() .iter()
.enumerate() .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) .map(|(i, _)| i)
.unwrap_or(3); .unwrap_or(3);
@ -233,52 +245,34 @@ fn softmax_single(logits: &[f32], idx: usize) -> f32 {
fn try_load_session( fn try_load_session(
path: &std::path::Path, path: &std::path::Path,
ep_pref: &str,
) -> (Option<ort::session::Session>, ExecutionProvider) { ) -> (Option<ort::session::Session>, ExecutionProvider) {
let providers: &[(&str, ExecutionProvider)] = &[ // Try ROCm (iGPU) first, fall back to CPU.
("qnn", ExecutionProvider::Qnn), match build_onnx_session(path, ort::ep::ROCm::default().build()) {
("vulkan", ExecutionProvider::Vulkan), Ok(s) => {
("cpu", ExecutionProvider::Cpu), tracing::info!("ONNX session loaded (ROCm iGPU)");
]; return (Some(s), ExecutionProvider::Gpu);
}
let to_try: Vec<&(&str, ExecutionProvider)> = match ep_pref { Err(e) => tracing::debug!("ROCm EP unavailable: {}; trying CPU", e),
"npu" => providers[..1].iter().collect(), }
"vulkan" => providers[1..2].iter().collect(), match build_onnx_session(path, ort::ep::CPU::default().build()) {
"cpu" => providers[2..].iter().collect(), Ok(s) => {
_ => providers.iter().collect(), tracing::info!("ONNX session loaded (CPU)");
}; (Some(s), ExecutionProvider::Cpu)
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) => { Err(e) => {
tracing::debug!("{} EP unavailable: {}", ep_name, 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, path: &std::path::Path,
ep_name: &str, ep: ort::ep::ExecutionProviderDispatch,
) -> anyhow::Result<ort::session::Session> { ) -> anyhow::Result<ort::session::Session> {
match ep_name { let mut builder = ort::session::Session::builder()
"cpu" => { .map_err(|e| anyhow::anyhow!("builder: {}", e))?
let builder = ort::session::Session::builder() .with_execution_providers([ep])
.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))?; .map_err(|e| anyhow::anyhow!("ep: {}", e))?;
let session = builder builder.commit_from_file(path).map_err(|e| anyhow::anyhow!("load: {}", e))
.commit_from_file(path)
.map_err(|e| anyhow::anyhow!("load: {}", e))?;
Ok(session)
}
_ => Err(anyhow::anyhow!("EP '{}' not available in this build", ep_name)),
}
} }

View file

@ -11,15 +11,29 @@ fn default_snooze_options() -> Vec<String> {
fn default_archive_after_days() -> i64 { 30 } fn default_archive_after_days() -> i64 { 30 }
fn default_model_path() -> String { "~/.local/share/breadpad/model/classifier.onnx".into() } 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_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_morning_time() -> String { "08:00".into() }
fn default_missed_grace_minutes() -> i64 { 60 } fn default_missed_grace_minutes() -> i64 { 60 }
fn default_ollama_endpoint() -> String { "http://localhost:11434".into() } 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_confidence_threshold() -> f32 { 0.6 }
fn default_ollama_enabled() -> bool { true } fn default_ollama_enabled() -> bool { true }
fn default_calendar_enabled() -> bool { false } 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings { pub struct Settings {
#[serde(default = "default_type_str")] #[serde(default = "default_type_str")]
@ -72,8 +86,9 @@ pub struct ModelConfig {
pub path: String, pub path: String,
#[serde(default = "default_tokenizer_path")] #[serde(default = "default_tokenizer_path")]
pub tokenizer: String, pub tokenizer: String,
#[serde(default = "default_execution_provider")] /// Path to `libonnxruntime.so`. Auto-discovered when empty.
pub execution_provider: String, #[serde(default = "default_ort_dylib_path")]
pub ort_dylib_path: String,
#[serde(default)] #[serde(default)]
pub ollama: OllamaConfig, pub ollama: OllamaConfig,
} }
@ -83,12 +98,26 @@ impl Default for ModelConfig {
ModelConfig { ModelConfig {
path: default_model_path(), path: default_model_path(),
tokenizer: default_tokenizer_path(), tokenizer: default_tokenizer_path(),
execution_provider: default_execution_provider(), ort_dylib_path: default_ort_dylib_path(),
ollama: OllamaConfig::default(), 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<PathBuf> {
let raw = self.ort_dylib_path.trim();
if raw.is_empty() {
return None;
}
Some(expand_path(raw))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemindersConfig { pub struct RemindersConfig {
#[serde(default = "default_morning_time")] #[serde(default = "default_morning_time")]
@ -114,6 +143,9 @@ pub struct CalendarConfig {
pub url: String, pub url: String,
#[serde(default)] #[serde(default)]
pub username: String, 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)] #[serde(default)]
pub password: String, pub password: String,
} }

View file

@ -7,3 +7,4 @@ pub mod scheduler;
pub mod store; pub mod store;
pub mod theme; pub mod theme;
pub mod types; pub mod types;
pub mod util;

View file

@ -1,4 +1,5 @@
use crate::types::{ClassificationResult, NoteType, RecurrenceRule}; use crate::types::{ClassificationResult, NoteType, RecurrenceRule};
use crate::util::local_naive_to_utc;
use chrono::{DateTime, Datelike, Duration, Local, NaiveTime, Timelike, Utc, Weekday}; use chrono::{DateTime, Datelike, Duration, Local, NaiveTime, Timelike, Utc, Weekday};
use regex::Regex; use regex::Regex;
use std::sync::OnceLock; use std::sync::OnceLock;
@ -22,7 +23,7 @@ static PATTERNS: OnceLock<Patterns> = OnceLock::new();
fn patterns() -> &'static Patterns { fn patterns() -> &'static Patterns {
PATTERNS.get_or_init(|| Patterns { PATTERNS.get_or_init(|| Patterns {
at_time: Regex::new(r"(?i)\bat\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?").unwrap(), 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" // Word-form durations: "in an hour", "in a couple of hours", "in half an hour"
in_duration_word: Regex::new( 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?)" 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<Utc> {
}; };
let target_date = local.date_naive() + Duration::days(days_ahead); let target_date = local.date_naive() + Duration::days(days_ahead);
let naive = target_date.and_time(time); 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 { 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 { } else {
(local.date_naive() + Duration::days(1)).and_time(t) (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(); let full_match = caps.get(0).unwrap().as_str();
cleaned = cleaned.replacen(full_match, "", 1).trim().to_string(); 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 n: i64 = caps.get(1).unwrap().as_str().parse().unwrap_or(1);
let unit = caps.get(2).unwrap().as_str().to_lowercase(); let unit = caps.get(2).unwrap().as_str().to_lowercase();
let delta = match unit.as_str() { let delta = match unit.as_str() {
"second" => Duration::seconds(n),
"minute" => Duration::minutes(n), "minute" => Duration::minutes(n),
"hour" => Duration::hours(n), "hour" => Duration::hours(n),
"day" => Duration::days(n), "day" => Duration::days(n),
"week" => Duration::weeks(n),
_ => Duration::minutes(n), _ => Duration::minutes(n),
}; };
extracted_time = Some(Utc::now() + delta); 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 local = Local::now();
let target = (local.date_naive() + Duration::days(1)).and_time(t); 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(); cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
} }
// One-off: next <weekday> // One-off: next <weekday>
@ -273,7 +276,7 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
} else { } else {
(local.date_naive() + Duration::days(1)).and_time(anchor) (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(); 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("finish ")
|| lower.starts_with("write ") || lower.starts_with("write ")
|| lower.starts_with("update ") || 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; return NoteType::Todo;
} }
@ -867,13 +887,21 @@ fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType {
|| lower.starts_with("idea:") || lower.starts_with("idea:")
|| lower.contains("could ") || lower.contains("could ")
|| lower.contains("maybe ") || lower.contains("maybe ")
|| lower.contains("should we ") || lower.starts_with("should we ")
{ {
return NoteType::Idea; return NoteType::Idea;
} }
if lower.starts_with("why ") if lower.starts_with("why ")
|| lower.starts_with("how ") || 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('?') || lower.ends_with('?')
{ {
return NoteType::Question; return NoteType::Question;

View file

@ -1,4 +1,5 @@
use crate::types::Note; use crate::types::Note;
use crate::util::local_naive_to_utc;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use chrono::{DateTime, Duration, Local, NaiveTime, Utc}; use chrono::{DateTime, Duration, Local, NaiveTime, Utc};
use std::process::Command; use std::process::Command;
@ -59,27 +60,63 @@ fn create_timer(id: &str, fire_time: DateTime<Utc>) -> Result<()> {
let timer_name = timer_unit_name(id); 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 // Use systemd-run to create both service + timer as a transient unit
let status = Command::new("systemd-run") // Pass necessary environment variables for notifications to work
.arg("--user") let mut cmd = Command::new("systemd-run");
cmd.arg("--user")
.arg("--unit") .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("--timer-property")
.arg(format!("OnCalendar={}", on_calendar)) .arg(format!("OnCalendar={}", on_calendar))
.arg("--timer-property") .arg("--timer-property")
.arg("Persistent=true") .arg("Persistent=true");
.arg("--")
.arg("breadpad") // 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("fire")
.arg(id) .arg(id);
.status()
.context("failed to run systemd-run")?; let status = cmd.status().context("failed to run systemd-run")?;
if !status.success() { if !status.success() {
anyhow::bail!("systemd-run failed for reminder {}", id); 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(()) Ok(())
} }
@ -131,17 +168,15 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O
let now = Local::now(); let now = Local::now();
let fire_time = NaiveTime::from_hms_opt(hour, minute, 0)?; let fire_time = NaiveTime::from_hms_opt(hour, minute, 0)?;
let next = match freq { match freq {
"DAILY" => { "DAILY" => {
let today = now.date_naive().and_time(fire_time); let today = now.date_naive().and_time(fire_time);
if now.naive_local() < today { let naive = if now.naive_local() < today {
today.and_local_timezone(Local).unwrap() today
} else { } else {
(now.date_naive() + chrono::Duration::days(1)) (now.date_naive() + chrono::Duration::days(1)).and_time(fire_time)
.and_time(fire_time) };
.and_local_timezone(Local) return Some(local_naive_to_utc(naive));
.unwrap()
}
} }
"WEEKLY" => { "WEEKLY" => {
use chrono::Datelike; use chrono::Datelike;
@ -169,12 +204,10 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O
}; };
let target_date = let target_date =
(now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time); (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));
}
_ => None,
} }
_ => return None,
};
Some(next.with_timezone(&Utc))
} }
#[cfg(test)] #[cfg(test)]
@ -331,6 +364,21 @@ mod tests {
assert_eq!(local.weekday(), chrono::Weekday::Sat); 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<Local> = 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<Local> = t.into();
assert_eq!(local.weekday(), chrono::Weekday::Thu);
assert_eq!(local.minute(), 30);
}
#[test] #[test]
fn weekly_sunday_is_sunday() { 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(); 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); 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<Local> = 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<Local> = t.into();
assert_eq!(local.hour(), 6);
assert_eq!(local.minute(), 45);
}
#[test] #[test]
fn unknown_freq_returns_none() { fn unknown_freq_returns_none() {
assert!(parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none()); assert!(parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none());

View file

@ -36,6 +36,14 @@ impl Store {
self 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<Vec<Note>> { pub fn load_all(&self) -> Result<Vec<Note>> {
self.load_from(&self.notes_path) self.load_from(&self.notes_path)
} }
@ -84,12 +92,14 @@ impl Store {
pub fn update_note(&self, updated: &Note) -> Result<()> { pub fn update_note(&self, updated: &Note) -> Result<()> {
self.rewrite_notes(|note| { self.rewrite_notes(|note| {
if note.id == updated.id { if note.id == updated.id { updated.clone() } else { note }
updated.clone() })?;
} else { if let Some(cal_cfg) = &self.calendar {
note 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<()> { pub fn delete_note(&self, id: &str) -> Result<()> {

View file

@ -91,19 +91,23 @@ pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String {
@define-color teal {c6}; @define-color teal {c6};
@define-color overlay {c0}; @define-color overlay {c0};
* {{
font-family: 'Varela Round', sans-serif;
}}
window {{ window {{
background-color: @bg; background-color: @bg;
color: @fg; color: @fg;
border-radius: 12px; border-radius: 8px;
}} }}
.popup-entry {{ .popup-entry {{
background: @bg; background: @bg;
color: @fg; color: @fg;
border: 2px solid @blue; border: 2px solid @blue;
border-radius: 8px; border-radius: 6px;
padding: 12px 16px; padding: 12px 16px;
font-size: 16px; font-size: 14px;
caret-color: @fg; caret-color: @fg;
}} }}
@ -116,9 +120,9 @@ window {{
background: @overlay; background: @overlay;
color: @fg; color: @fg;
border-radius: 999px; border-radius: 999px;
padding: 2px 10px; padding: 4px 12px;
font-size: 12px; font-size: 12px;
margin: 2px; margin: 4px;
}} }}
.type-chip.active {{ .type-chip.active {{
@ -127,7 +131,7 @@ window {{
}} }}
.confirm-button {{ .confirm-button {{
background: @green; background: @blue;
color: @bg; color: @bg;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
@ -139,7 +143,7 @@ window {{
background: shade(@bg, 1.1); background: shade(@bg, 1.1);
border-radius: 8px; border-radius: 8px;
padding: 12px; padding: 12px;
margin: 4px 8px; margin: 8px;
border-left: 3px solid @blue; border-left: 3px solid @blue;
}} }}
@ -151,9 +155,13 @@ window {{
background: shade(@bg, 1.1); background: shade(@bg, 1.1);
color: @fg; color: @fg;
border: 1px solid @overlay; border: 1px solid @overlay;
border-radius: 8px; border-radius: 6px;
padding: 8px 12px; padding: 8px 12px;
margin: 8px; }}
.search-entry:focus {{
border-color: @blue;
outline: none;
}} }}
"#, "#,
bg = palette.background, bg = palette.background,
@ -178,25 +186,27 @@ window {{
} }
.sidebar-row { .sidebar-row {
padding: 6px 12px; padding: 8px 12px;
font-size: 14px; font-size: 14px;
transition: background 100ms ease;
} }
.sidebar-row:hover:not(:selected) { .sidebar-row:hover:not(:selected) {
background: shade(@bg, 1.08); background: shade(@bg, 1.1);
} }
.sidebar-row:selected { .sidebar-row:selected {
background: @blue; background: @blue;
color: @bg; color: @bg;
font-weight: 500;
} }
.sidebar-section-label { .sidebar-section-label {
color: alpha(@fg, 0.4); color: alpha(@fg, 0.5);
font-size: 10px; font-size: 11px;
font-weight: bold; font-weight: 600;
padding: 10px 14px 2px 14px; padding: 12px 12px 8px 12px;
letter-spacing: 1px; letter-spacing: 0.5px;
} }
.action-btn { .action-btn {
@ -228,6 +238,62 @@ window {{
.note-card-question { border-left-color: @teal; } .note-card-question { border-left-color: @teal; }
.note-card-note { border-left-color: @blue; } .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 { entry {
background: shade(@bg, 1.1); background: shade(@bg, 1.1);
color: @fg; color: @fg;

View file

@ -72,7 +72,9 @@ pub struct Note {
pub done: bool, pub done: bool,
pub workspace: Option<String>, pub workspace: Option<String>,
pub created: DateTime<Utc>, pub created: DateTime<Utc>,
#[serde(default)]
pub snoozed_until: Option<DateTime<Utc>>, pub snoozed_until: Option<DateTime<Utc>>,
#[serde(default)]
pub completed: Option<DateTime<Utc>>, pub completed: Option<DateTime<Utc>>,
#[serde(default)] #[serde(default)]
pub tags: Vec<String>, pub tags: Vec<String>,
@ -83,10 +85,14 @@ pub struct Note {
impl Note { impl Note {
pub fn new(body: String, note_type: NoteType, workspace: Option<String>) -> Self { pub fn new(body: String, note_type: NoteType, workspace: Option<String>) -> Self {
Note { 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() id: uuid::Uuid::new_v4()
.simple()
.to_string() .to_string()
.chars() .chars()
.take(6) .take(12)
.collect(), .collect(),
body, body,
note_type, note_type,
@ -250,10 +256,15 @@ mod tests {
} }
#[test] #[test]
fn note_id_is_six_chars() { fn note_id_is_twelve_chars() {
for _ in 0..50 { for _ in 0..50 {
let note = Note::new("x".into(), NoteType::Note, None); 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
);
} }
} }

View file

@ -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<Utc> {
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<Local> = 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);
}
}
}

View file

@ -1,16 +1,27 @@
use breadpad_shared::classifier::Classifier; use breadpad_shared::classifier::{Classifier, ExecutionProvider};
use breadpad_shared::types::NoteType; use breadpad_shared::types::NoteType;
use chrono::Timelike; use chrono::Timelike;
fn cl() -> Classifier { fn cl() -> Classifier {
Classifier::load("auto", "08:00") Classifier::load("08:00")
} }
#[test] #[test]
fn active_provider_is_cpu() { fn active_provider_is_valid() {
// QNN and Vulkan EPs are not compiled in; CPU is always the fallback. // 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(); 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] #[test]
@ -63,7 +74,7 @@ fn classify_recurrence_via_fallback() {
#[test] #[test]
fn classify_custom_morning_time() { 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 r = c.classify("sync tomorrow morning");
let t = r.time.expect("should have a time for tomorrow morning"); let t = r.time.expect("should have a time for tomorrow morning");
let local: chrono::DateTime<chrono::Local> = t.into(); let local: chrono::DateTime<chrono::Local> = t.into();
@ -71,6 +82,41 @@ fn classify_custom_morning_time() {
assert_eq!(local.minute(), 15); 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] #[test]
fn model_path_points_to_expected_location() { fn model_path_points_to_expected_location() {
let c = cl(); let c = cl();

View file

@ -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; use tempfile::TempDir;
// ---- Default values ---- // ---- Default values ----
@ -22,9 +22,9 @@ fn default_snooze_options_contains_all_three() {
#[test] #[test]
fn default_model_config() { fn default_model_config() {
let m = ModelConfig::default(); let m = ModelConfig::default();
assert_eq!(m.execution_provider, "auto");
assert!(m.path.contains("classifier.onnx")); assert!(m.path.contains("classifier.onnx"));
assert!(m.tokenizer.contains("tokenizer.json")); assert!(m.tokenizer.contains("tokenizer.json"));
assert_eq!(m.ort_dylib_path, "");
} }
#[test] #[test]
@ -38,7 +38,6 @@ fn default_reminders_config() {
fn default_config_composes_defaults() { fn default_config_composes_defaults() {
let cfg = Config::default(); let cfg = Config::default();
assert_eq!(cfg.settings.default_type, "note"); assert_eq!(cfg.settings.default_type, "note");
assert_eq!(cfg.model.execution_provider, "auto");
assert_eq!(cfg.reminders.default_morning, "08:00"); assert_eq!(cfg.reminders.default_morning, "08:00");
} }
@ -56,7 +55,7 @@ archive_after_days = 7
[model] [model]
path = "/tmp/classifier.onnx" path = "/tmp/classifier.onnx"
tokenizer = "/tmp/tokenizer.json" tokenizer = "/tmp/tokenizer.json"
execution_provider = "cpu" ort_dylib_path = "/tmp/libonnxruntime.so"
[reminders] [reminders]
default_morning = "07:30" default_morning = "07:30"
@ -67,8 +66,8 @@ missed_grace_minutes = 30
assert!(!cfg.settings.workspace_tag); assert!(!cfg.settings.workspace_tag);
assert_eq!(cfg.settings.snooze_options, vec!["15m", "2h"]); assert_eq!(cfg.settings.snooze_options, vec!["15m", "2h"]);
assert_eq!(cfg.settings.archive_after_days, 7); 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.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.default_morning, "07:30");
assert_eq!(cfg.reminders.missed_grace_minutes, 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(); let cfg: Config = toml::from_str("").unwrap();
assert_eq!(cfg.settings.default_type, "note"); assert_eq!(cfg.settings.default_type, "note");
assert!(cfg.settings.workspace_tag); assert!(cfg.settings.workspace_tag);
assert_eq!(cfg.model.execution_provider, "auto");
assert_eq!(cfg.reminders.default_morning, "08:00"); assert_eq!(cfg.reminders.default_morning, "08:00");
} }
@ -90,31 +88,9 @@ default_type = "reminder"
"#; "#;
let cfg: Config = toml::from_str(toml).unwrap(); let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.settings.default_type, "reminder"); 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"); 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 ---- // ---- TOML serialization round-trip ----
#[test] #[test]
@ -124,7 +100,6 @@ fn default_config_serializes_to_valid_toml() {
let reparsed: Config = toml::from_str(&serialized).unwrap(); let reparsed: Config = toml::from_str(&serialized).unwrap();
assert_eq!(reparsed.settings.default_type, cfg.settings.default_type); assert_eq!(reparsed.settings.default_type, cfg.settings.default_type);
assert_eq!(reparsed.settings.workspace_tag, cfg.settings.workspace_tag); 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); assert_eq!(reparsed.reminders.default_morning, cfg.reminders.default_morning);
} }
@ -133,7 +108,6 @@ fn custom_config_round_trips() {
let mut cfg = Config::default(); let mut cfg = Config::default();
cfg.settings.default_type = "idea".into(); cfg.settings.default_type = "idea".into();
cfg.settings.archive_after_days = 14; cfg.settings.archive_after_days = 14;
cfg.model.execution_provider = "vulkan".into();
cfg.reminders.default_morning = "06:45".into(); cfg.reminders.default_morning = "06:45".into();
cfg.reminders.missed_grace_minutes = 120; cfg.reminders.missed_grace_minutes = 120;
@ -141,7 +115,6 @@ fn custom_config_round_trips() {
let rt: Config = toml::from_str(&toml).unwrap(); let rt: Config = toml::from_str(&toml).unwrap();
assert_eq!(rt.settings.default_type, "idea"); assert_eq!(rt.settings.default_type, "idea");
assert_eq!(rt.settings.archive_after_days, 14); 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.default_morning, "06:45");
assert_eq!(rt.reminders.missed_grace_minutes, 120); assert_eq!(rt.reminders.missed_grace_minutes, 120);
} }
@ -155,24 +128,20 @@ fn save_and_load_round_trip() {
let mut cfg = Config::default(); let mut cfg = Config::default();
cfg.settings.default_type = "question".into(); cfg.settings.default_type = "question".into();
cfg.model.execution_provider = "cpu".into();
cfg.reminders.missed_grace_minutes = 45; 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(); let toml = toml::to_string_pretty(&cfg).unwrap();
std::fs::write(&config_path, &toml).unwrap(); std::fs::write(&config_path, &toml).unwrap();
let loaded: Config = toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).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.settings.default_type, "question");
assert_eq!(loaded.model.execution_provider, "cpu");
assert_eq!(loaded.reminders.missed_grace_minutes, 45); assert_eq!(loaded.reminders.missed_grace_minutes, 45);
} }
// ---- The example from the README ---- // ---- The example from the README ----
#[test] #[test]
fn readme_example_toml_parses() { fn example_toml_parses() {
let toml = r#" let toml = r#"
[settings] [settings]
default_type = "note" default_type = "note"
@ -183,7 +152,7 @@ archive_after_days = 30
[model] [model]
path = "~/.local/share/breadpad/model/classifier.onnx" path = "~/.local/share/breadpad/model/classifier.onnx"
tokenizer = "~/.local/share/breadpad/model/tokenizer.json" tokenizer = "~/.local/share/breadpad/model/tokenizer.json"
execution_provider = "auto" ort_dylib_path = ""
[reminders] [reminders]
default_morning = "08:00" default_morning = "08:00"
@ -192,7 +161,146 @@ missed_grace_minutes = 60
let cfg: Config = toml::from_str(toml).unwrap(); let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.settings.default_type, "note"); assert_eq!(cfg.settings.default_type, "note");
assert!(cfg.settings.workspace_tag); assert!(cfg.settings.workspace_tag);
assert_eq!(cfg.model.execution_provider, "auto");
assert_eq!(cfg.reminders.default_morning, "08:00"); assert_eq!(cfg.reminders.default_morning, "08:00");
assert_eq!(cfg.reminders.missed_grace_minutes, 60); 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);
}

View file

@ -14,7 +14,7 @@ use tempfile::TempDir;
// Mirrors commit_note() in breadpad/src/main.rs. // Mirrors commit_note() in breadpad/src/main.rs.
// `user_type` is the type the user selected in the chip row (default = NoteType::Note). // `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 { 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 result = classifier.classify(text);
let mut note = Note::new(text.into(), user_type.clone(), None); let mut note = Note::new(text.into(), user_type.clone(), None);

View file

@ -301,6 +301,51 @@ fn rotate_archive_zero_when_nothing_qualifies() {
assert_eq!(store.load_all().unwrap().len(), 1); 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] #[test]
fn rotate_archive_ignores_undone_notes_no_matter_how_old() { fn rotate_archive_ignores_undone_notes_no_matter_how_old() {
let (_dir, store) = mk(); let (_dir, store) = mk();

View file

@ -18,3 +18,5 @@ chrono = { workspace = true }
clap = { version = "4", features = ["derive"] } clap = { version = "4", features = ["derive"] }
colored = "2" colored = "2"
comfy-table = "7" comfy-table = "7"
ort = { workspace = true }
dirs = { workspace = true }

File diff suppressed because it is too large Load diff

View file

@ -67,6 +67,9 @@ enum TierArg {
Three, Three,
#[value(name = "all")] #[value(name = "all")]
All, All,
/// Production path: Tier 1 → Tier 2 (no Ollama)
#[value(name = "pipeline")]
Pipeline,
} }
impl TierArg { impl TierArg {
@ -76,6 +79,7 @@ impl TierArg {
TierArg::Two => "2", TierArg::Two => "2",
TierArg::Three => "3", TierArg::Three => "3",
TierArg::All => "all", TierArg::All => "all",
TierArg::Pipeline => "pipeline",
} }
} }
} }
@ -142,7 +146,14 @@ fn classify_with_tier(text: &str, tier: &TierArg) -> ClassificationResult {
match tier { match tier {
TierArg::One => parse_rule_based(text, DEFAULT_MORNING), TierArg::One => parse_rule_based(text, DEFAULT_MORNING),
TierArg::Two => { 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) clf.classify(text)
} }
TierArg::Three | TierArg::All => { TierArg::Three | TierArg::All => {
@ -152,7 +163,7 @@ fn classify_with_tier(text: &str, tier: &TierArg) -> ClassificationResult {
confidence_threshold: 0.6, confidence_threshold: 0.6,
enabled: true, 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) clf.classify(text)
} }
} }
@ -444,8 +455,35 @@ fn cmd_edit(index: usize, corpus_path: &Path) -> Result<()> {
Ok(()) 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<PathBuf> = {
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<()> { fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
init_ort();
match cli.command { match cli.command {
Commands::Run { corpus, tier, format } => { Commands::Run { corpus, tier, format } => {

View file

@ -7,8 +7,25 @@ archive_after_days = 30
[model] [model]
path = "~/.local/share/breadpad/model/classifier.onnx" path = "~/.local/share/breadpad/model/classifier.onnx"
tokenizer = "~/.local/share/breadpad/model/tokenizer.json" 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] [reminders]
default_morning = "08:00" default_morning = "08:00"
missed_grace_minutes = 60 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 = ""

View file

@ -12,6 +12,7 @@ path = "src/main.rs"
[dependencies] [dependencies]
breadpad-shared = { path = "../breadpad-shared" } breadpad-shared = { path = "../breadpad-shared" }
anyhow.workspace = true anyhow.workspace = true
ort.workspace = true
tracing.workspace = true tracing.workspace = true
tracing-subscriber.workspace = true tracing-subscriber.workspace = true
serde.workspace = true serde.workspace = true

View file

@ -12,7 +12,24 @@ use gtk4::{glib, prelude::*};
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell}; use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
use std::cell::RefCell; use std::cell::RefCell;
use std::rc::Rc; 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 { mod args {
#[derive(Debug)] #[derive(Debug)]
@ -89,7 +106,7 @@ fn main() -> Result<()> {
return cmd_status(&cfg); return cmd_status(&cfg);
} }
if args.download_model { if args.download_model {
return cmd_download_model(); return cmd_download_model(&cfg);
} }
if args.model_info { if args.model_info {
return cmd_model_info(&cfg); return cmd_model_info(&cfg);
@ -108,9 +125,15 @@ fn main() -> Result<()> {
} }
fn cmd_status(cfg: &Config) -> Result<()> { fn cmd_status(cfg: &Config) -> Result<()> {
init_ort_once(cfg);
let store = Store::new()?; let store = Store::new()?;
let notes = store.load_all()?; 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!("breadpad status");
println!(" notes: {}", notes.len()); println!(" notes: {}", notes.len());
println!( println!(
@ -126,7 +149,13 @@ fn cmd_status(cfg: &Config) -> Result<()> {
} }
fn cmd_model_info(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!("model path: {:?}", classifier.model_path);
println!("execution provider: {}", classifier.active_provider.as_str()); println!("execution provider: {}", classifier.active_provider.as_str());
println!( println!(
@ -136,16 +165,19 @@ fn cmd_model_info(cfg: &Config) -> Result<()> {
Ok(()) Ok(())
} }
fn cmd_download_model() -> Result<()> { fn cmd_download_model(cfg: &Config) -> Result<()> {
// Placeholder — a real implementation would download a quantised ONNX model. // Placeholder — a real implementation would download a quantised ONNX model.
// The exact model URL is left for the user to configure. // The exact model URL is left for the user to configure.
let dir = dirs::data_local_dir() let (model_path, tokenizer_path) = cfg.model.resolved_paths();
.unwrap_or_else(|| std::path::PathBuf::from("~/.local/share")) if let Some(dir) = model_path.parent() {
.join("breadpad") std::fs::create_dir_all(dir)?;
.join("model"); }
std::fs::create_dir_all(&dir)?; if let Some(dir) = tokenizer_path.parent() {
println!("Model directory: {}", dir.display()); std::fs::create_dir_all(dir)?;
println!("Place classifier.onnx and tokenizer.json in that directory."); }
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)"); println!("(Automatic download not yet configured — set a model URL in breadpad.toml)");
Ok(()) Ok(())
} }
@ -221,7 +253,7 @@ fn cmd_calendar_list_uid(note_id: &str, cfg: &Config) -> Result<()> {
} }
fn cmd_fire(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)? { let note = match store.get_by_id(id)? {
Some(n) => n, Some(n) => n,
None => { None => {
@ -234,37 +266,7 @@ fn cmd_fire(id: &str, cfg: &Config) -> Result<()> {
return Ok(()); return Ok(());
} }
// Send notification via notify-send // Schedule next recurrence before showing UI
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(&note.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
if note.rrule.is_some() { if note.rrule.is_some() {
if let Some(next) = Scheduler::next_recurrence(&note, &cfg.reminders.default_morning) { if let Some(next) = Scheduler::next_recurrence(&note, &cfg.reminders.default_morning) {
let mut updated = note.clone(); 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::<String>(&[]);
Ok(()) Ok(())
} }
@ -304,12 +322,195 @@ fn resolve_snooze(key: &str, cfg: &Config) -> Option<chrono::DateTime<chrono::Ut
let m = parts.get(1).copied().unwrap_or(0); let m = parts.get(1).copied().unwrap_or(0);
let tomorrow = local.date_naive() + chrono::Duration::days(1); let tomorrow = local.date_naive() + chrono::Duration::days(1);
let naive = tomorrow.and_hms_opt(h, m, 0)?; let naive = tomorrow.and_hms_opt(h, m, 0)?;
Some(naive.and_local_timezone(chrono::Local).unwrap().with_timezone(&chrono::Utc)) Some(breadpad_shared::util::local_naive_to_utc(naive))
} }
_ => None, _ => None,
} }
} }
fn build_reminder_window(
app: &gtk4::Application,
note: Arc<breadpad_shared::types::Note>,
cfg: Arc<Config>,
) {
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(
&gtk4::Label::builder()
.label(type_emoji)
.css_classes(["reminder-emoji"])
.build(),
);
header.append(
&gtk4::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<chrono::Local> = t.into();
header.append(
&gtk4::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(&note.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(&gtk4::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<String>, no_classify: bool, cfg: Config) -> Result<()> { fn run_popup(preset_type: Option<String>, no_classify: bool, cfg: Config) -> Result<()> {
// Try to get current Hyprland workspace // Try to get current Hyprland workspace
let workspace = get_active_workspace(); let workspace = get_active_workspace();
@ -321,10 +522,11 @@ fn run_popup(preset_type: Option<String>, no_classify: bool, cfg: Config) -> Res
let cfg = Arc::new(cfg); let cfg = Arc::new(cfg);
app.connect_activate(move |app| { app.connect_activate(move |app| {
let cfg = cfg.clone(); if let Some(win) = app.windows().first().cloned() {
let workspace = workspace.clone(); win.close();
let preset_type = preset_type.clone(); return;
build_window(app, cfg, workspace, preset_type, no_classify); }
build_window(app, cfg.clone(), workspace.clone(), preset_type.clone(), no_classify);
}); });
let code = app.run_with_args::<String>(&[]); let code = app.run_with_args::<String>(&[]);
@ -475,12 +677,13 @@ fn build_window(
return; return;
} }
let note_type = selected_type.borrow().clone(); let note_type = selected_type.borrow().clone();
let cfg_c = cfg.clone();
// Classify and save synchronously. Tier 1 + 2 finish in <100ms. let ws_c = workspace.clone();
// Tier 3 (Ollama) only fires for ambiguous inputs; the brief pause // Close first so the popup disappears immediately, then save.
// is acceptable since the user has already committed the note.
save_note_classified(&text, note_type, no_classify, cfg.clone(), workspace.clone());
win.close(); 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); let mut note = Note::new(text.into(), user_type.clone(), workspace);
if !no_classify { if !no_classify {
let mut classifier = Classifier::load( init_ort_once(&cfg);
&cfg.model.execution_provider, let (model_path, tokenizer_path) = cfg.model.resolved_paths();
let mut classifier = Classifier::load_with_paths(
&cfg.reminders.default_morning, &cfg.reminders.default_morning,
model_path,
tokenizer_path,
) )
.with_ollama(cfg.model.ollama.clone()); .with_ollama(cfg.model.ollama.clone());
let result = classifier.classify(text); let result = classifier.classify(text);
@ -565,8 +771,12 @@ fn apply_css(_cfg: &Config) {
let provider = gtk4::CssProvider::new(); let provider = gtk4::CssProvider::new();
provider.load_from_string(&css); 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( gtk4::style_context_add_provider_for_display(
&gtk4::gdk::Display::default().unwrap(), &display,
&provider, &provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION, gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
); );

View file

@ -1 +0,0 @@
./target/release/breadpad

102
svgs.txt
View file

@ -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(&gtk4::Image::from_file("path/to/icon.svg")));
# 3. Symbolic icons (named with "-symbolic" suffix in icon theme) follow the
# CSS color property automatically.