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:
parent
feefdb81b9
commit
c4626dd64d
34 changed files with 2825 additions and 771 deletions
28
.gitignore
vendored
28
.gitignore
vendored
|
|
@ -1 +1,29 @@
|
|||
target/
|
||||
*.tgz
|
||||
*.zip
|
||||
breadpadcli
|
||||
breadmancli
|
||||
svgs.txt
|
||||
|
||||
# Editor & IDE
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Debug & Logs
|
||||
*.log
|
||||
*.pid
|
||||
*.sock
|
||||
*.pdb
|
||||
|
||||
# Rust/Cargo
|
||||
Cargo.lock
|
||||
dist/
|
||||
|
|
|
|||
230
Cargo.lock
generated
230
Cargo.lock
generated
|
|
@ -259,12 +259,6 @@ version = "0.22.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
|
|
@ -316,6 +310,7 @@ dependencies = [
|
|||
"breadpad-shared",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"futures-channel",
|
||||
"gtk4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -335,6 +330,7 @@ dependencies = [
|
|||
"gtk4",
|
||||
"gtk4-layer-shell",
|
||||
"hyprland",
|
||||
"ort",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
|
|
@ -362,7 +358,7 @@ dependencies = [
|
|||
"tokio",
|
||||
"toml 0.8.23",
|
||||
"tracing",
|
||||
"ureq 2.12.1",
|
||||
"ureq",
|
||||
"uuid",
|
||||
"zbus",
|
||||
]
|
||||
|
|
@ -377,6 +373,8 @@ dependencies = [
|
|||
"clap",
|
||||
"colored",
|
||||
"comfy-table",
|
||||
"dirs 5.0.1",
|
||||
"ort",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
|
@ -612,16 +610,6 @@ dependencies = [
|
|||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
|
|
@ -748,16 +736,6 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b"
|
||||
dependencies = [
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
|
|
@ -1021,21 +999,6 @@ version = "0.1.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
|
|
@ -1521,16 +1484,10 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"ureq 2.12.1",
|
||||
"ureq",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac-sha256"
|
||||
version = "1.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
|
|
@ -1894,6 +1851,16 @@ version = "0.2.186"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libloading"
|
||||
version = "0.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.16"
|
||||
|
|
@ -1942,12 +1909,6 @@ version = "0.1.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lzma-rust2"
|
||||
version = "0.15.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619"
|
||||
|
||||
[[package]]
|
||||
name = "macro_rules_attribute"
|
||||
version = "0.2.2"
|
||||
|
|
@ -2047,23 +2008,6 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndarray"
|
||||
version = "0.16.1"
|
||||
|
|
@ -2171,49 +2115,6 @@ version = "1.70.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.80"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
|
|
@ -2236,11 +2137,11 @@ version = "2.0.0-rc.12"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
"ndarray 0.17.2",
|
||||
"ort-sys",
|
||||
"smallvec",
|
||||
"tracing",
|
||||
"ureq 3.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2248,11 +2149,6 @@ name = "ort-sys"
|
|||
version = "2.0.0-rc.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90"
|
||||
dependencies = [
|
||||
"hmac-sha256",
|
||||
"lzma-rust2",
|
||||
"ureq 3.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
|
|
@ -2328,15 +2224,6 @@ version = "0.1.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
|
|
@ -2853,44 +2740,12 @@ version = "1.0.23"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
|
|
@ -3593,36 +3448,6 @@ dependencies = [
|
|||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"der",
|
||||
"log",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"rustls-pki-types",
|
||||
"socks",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
"webpki-root-certs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ureq-proto"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
|
|
@ -3635,12 +3460,6 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
|
|
@ -3670,12 +3489,6 @@ version = "0.1.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
|
|
@ -3830,15 +3643,6 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ chrono = { version = "0.4", features = ["serde"] }
|
|||
rrule = "0.12"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
zbus = { version = "4", default-features = false, features = ["tokio"] }
|
||||
ort = { version = "2.0.0-rc.12", features = ["download-binaries"] }
|
||||
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "ndarray", "tracing", "api-24", "load-dynamic"] }
|
||||
ndarray = "0.16"
|
||||
tokenizers = { version = "0.21", default-features = false, features = ["http", "fancy-regex"] }
|
||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||
|
|
|
|||
21
LICENSE
Normal file
21
LICENSE
Normal 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.
|
||||
31
README.md
31
README.md
|
|
@ -67,7 +67,7 @@ User-defined tags can be added freely on top of the built-in types.
|
|||
- Inline editing — click any card to edit body, type, time, or recurrence
|
||||
- Mark todo/reminder as done; done items move to an archive accessible via a toggle
|
||||
- Search across all notes (full-text, instant)
|
||||
- Sort by: newest, oldest, due time
|
||||
- Sort: newest first (default)
|
||||
|
||||
### Theming
|
||||
|
||||
|
|
@ -83,8 +83,8 @@ User-defined tags can be added freely on top of the built-in types.
|
|||
Notes are stored as JSONL at `~/.local/share/breadpad/notes.jsonl` — one JSON object per line, human-readable, easy to back up or script against.
|
||||
|
||||
```jsonl
|
||||
{"id":"a1b2c3","body":"Pack calculator in bag","type":"reminder","time":"2026-05-25T19:00:00","rrule":null,"done":false,"workspace":"1","created":"2026-05-25T18:45:00","snoozed_until":null}
|
||||
{"id":"d4e5f6","body":"Look into relm4 reactive patterns","type":"idea","time":null,"rrule":null,"done":false,"workspace":"2","created":"2026-05-25T14:10:00","snoozed_until":null}
|
||||
{"id":"a1b2c3","body":"Pack calculator in bag","type":"reminder","time":"2026-05-25T19:00:00Z","rrule":null,"done":false,"workspace":"1","created":"2026-05-25T18:45:00Z","snoozed_until":null,"completed":null,"tags":[],"caldav_uid":null}
|
||||
{"id":"d4e5f6","body":"Look into relm4 reactive patterns","type":"idea","time":null,"rrule":null,"done":false,"workspace":"2","created":"2026-05-25T14:10:00Z","snoozed_until":null,"completed":null,"tags":[],"caldav_uid":null}
|
||||
```
|
||||
|
||||
Completed notes are never deleted — they gain `"done": true` and a `"completed"` timestamp. A separate `~/.local/share/breadpad/archive.jsonl` is written periodically for notes older than 30 days.
|
||||
|
|
@ -108,13 +108,7 @@ Returns a calibrated confidence. If ≥ 0.82, Tiers 2 and 3 are skipped.
|
|||
|
||||
Runs when Tier 1 confidence is below threshold. Responsible for **type classification only** — Tier 1's extracted time, recurrence rule, and cleaned body are always preserved.
|
||||
|
||||
Invoked via `ort` (ONNX Runtime Rust bindings). Execution provider order:
|
||||
|
||||
1. **QNN (Qualcomm/AMD XDNA NPU)** — requires `libQnnHtp.so` from the AMD Ryzen AI software stack
|
||||
2. **Vulkan** — iGPU via the ONNX Runtime Vulkan EP
|
||||
3. **CPU** — always available fallback
|
||||
|
||||
Active provider shown in `breadpad --status`.
|
||||
Invoked via `ort` (ONNX Runtime Rust bindings, `load-dynamic`) on the CPU. Requires an external `libonnxruntime.so`; set `model.ort_dylib_path` in `breadpad.toml` or let breadpad auto-discover it via `ORT_DYLIB_PATH`.
|
||||
|
||||
#### Tier 3 — Large local model via Ollama
|
||||
|
||||
|
|
@ -129,11 +123,10 @@ If Ollama is unreachable or returns an invalid response, breadpad logs a warning
|
|||
~/.local/share/breadpad/model/tokenizer.json
|
||||
```
|
||||
|
||||
breadpad ships without a bundled model. Run `breadpad download-model` to fetch a recommended quantised model, or drop your own ONNX model in the above path.
|
||||
breadpad ships without a bundled model. Drop a compatible ONNX classifier and `tokenizer.json` at those paths, then configure `model.ort_dylib_path` to point at your ONNX Runtime library.
|
||||
|
||||
```bash
|
||||
breadpad download-model # fetches default model (~150 MB)
|
||||
breadpad model-info # shows active EP, model path, last inference time
|
||||
breadpad model-info # shows active EP and model path
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -144,9 +137,8 @@ breadpad model-info # shows active EP, model path, last inference time
|
|||
- GTK4 (≥ 4.12) + `gtk4-layer-shell`
|
||||
- D-Bus session bus (for notifications)
|
||||
- systemd user session (for timer-backed reminders)
|
||||
- Rust 1.77+
|
||||
- ONNX Runtime (`ort` crate pulls this in automatically via the `download-binaries` feature)
|
||||
- **NPU path only:** AMD Ryzen AI software stack (`amdxdna` kernel driver ≥ 6.11, QNN EP shared libs)
|
||||
- Rust 1.80+
|
||||
- **Tier 2 (ONNX classifier):** An external `libonnxruntime.so`. Set `model.ort_dylib_path` in `breadpad.toml`, or set `ORT_DYLIB_PATH` in your environment. Without a library, Tier 2 is disabled; Tier 1 + 3 still work.
|
||||
- **Tier 3 only (optional):** [Ollama](https://ollama.com) running locally with your chosen model pulled (`ollama pull llama3.2:3b`). Tier 3 is silently skipped if Ollama is not running.
|
||||
|
||||
---
|
||||
|
|
@ -160,8 +152,9 @@ cargo build --release
|
|||
cp target/release/breadpad ~/.local/bin/
|
||||
cp target/release/breadman ~/.local/bin/
|
||||
|
||||
# Fetch the default classifier model
|
||||
breadpad download-model
|
||||
# Place your ONNX classifier and tokenizer in the model directory
|
||||
mkdir -p ~/.local/share/breadpad/model
|
||||
# Then set model.ort_dylib_path in breadpad.toml to your libonnxruntime.so
|
||||
```
|
||||
|
||||
On Arch Linux, install GTK4 dependencies first:
|
||||
|
|
@ -186,7 +179,7 @@ archive_after_days = 30
|
|||
[model]
|
||||
path = "~/.local/share/breadpad/model/classifier.onnx"
|
||||
tokenizer = "~/.local/share/breadpad/model/tokenizer.json"
|
||||
execution_provider = "auto" # auto | npu | vulkan | cpu
|
||||
ort_dylib_path = "" # optional: explicit path to libonnxruntime.so; auto-discovered when empty
|
||||
|
||||
[model.ollama]
|
||||
endpoint = "http://localhost:11434"
|
||||
|
|
|
|||
BIN
bread.zip
BIN
bread.zip
Binary file not shown.
|
|
@ -20,3 +20,4 @@ tokio.workspace = true
|
|||
chrono.workspace = true
|
||||
gtk4.workspace = true
|
||||
dirs.workspace = true
|
||||
futures-channel = "0.3"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
use breadpad_shared::{
|
||||
parser::parse_rule_based,
|
||||
scheduler::Scheduler,
|
||||
store::Store,
|
||||
types::{Note, NoteType, RecurrenceRule},
|
||||
};
|
||||
use chrono::{Local, TimeZone, Utc};
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{glib, prelude::*};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
|
@ -13,8 +14,9 @@ pub fn build_editor_popover(
|
|||
note: &Note,
|
||||
store: Arc<Store>,
|
||||
morning: String,
|
||||
on_save: impl Fn(Note) + 'static,
|
||||
on_delete: impl Fn() + 'static,
|
||||
on_save: Rc<dyn Fn(Note)>,
|
||||
on_delete: Rc<dyn Fn()>,
|
||||
on_error: Rc<dyn Fn(String)>,
|
||||
) -> gtk4::Popover {
|
||||
let popover = gtk4::Popover::new();
|
||||
popover.set_has_arrow(false);
|
||||
|
|
@ -86,7 +88,7 @@ pub fn build_editor_popover(
|
|||
btn_row.append(&save_btn);
|
||||
vbox.append(&btn_row);
|
||||
|
||||
// Delete: two-click confirm using a single handler and shared state
|
||||
// Delete: two-click confirm
|
||||
let confirming = Rc::new(RefCell::new(false));
|
||||
{
|
||||
let confirming = confirming.clone();
|
||||
|
|
@ -94,16 +96,32 @@ pub fn build_editor_popover(
|
|||
let note_id = note.id.clone();
|
||||
let store_del = store.clone();
|
||||
let popover_del = popover.clone();
|
||||
let on_delete = Rc::clone(&on_delete);
|
||||
let on_error = Rc::clone(&on_error);
|
||||
|
||||
delete_btn.connect_clicked(move |_| {
|
||||
let currently = *confirming.borrow();
|
||||
if currently {
|
||||
if let Err(e) = store_del.delete_note(¬e_id) {
|
||||
tracing::error!("failed to delete note: {}", e);
|
||||
} else {
|
||||
on_delete();
|
||||
if *confirming.borrow() {
|
||||
let store = store_del.clone();
|
||||
let id = note_id.clone();
|
||||
let on_delete = Rc::clone(&on_delete);
|
||||
let on_error = Rc::clone(&on_error);
|
||||
let popover = popover_del.clone();
|
||||
spawn_bg(
|
||||
move || -> anyhow::Result<()> {
|
||||
store.delete_note(&id)?;
|
||||
if let Err(e) = Scheduler::cancel(&id) {
|
||||
tracing::warn!("failed to cancel timer for {}: {}", id, e);
|
||||
}
|
||||
popover_del.popdown();
|
||||
Ok(())
|
||||
},
|
||||
move |result| {
|
||||
match result {
|
||||
Ok(()) => on_delete(),
|
||||
Err(e) => on_error(format!("delete failed: {}", e)),
|
||||
}
|
||||
popover.popdown();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
*confirming.borrow_mut() = true;
|
||||
delete_btn_label.set_label("Sure?");
|
||||
|
|
@ -112,27 +130,27 @@ pub fn build_editor_popover(
|
|||
}
|
||||
|
||||
// Save
|
||||
{
|
||||
let note_clone = note.clone();
|
||||
let popover_save = popover.clone();
|
||||
let on_error = Rc::clone(&on_error);
|
||||
|
||||
save_btn.connect_clicked(move |_| {
|
||||
// Read all field values on the main thread before handing off.
|
||||
let mut updated = note_clone.clone();
|
||||
updated.body = body_entry.text().to_string();
|
||||
|
||||
updated.note_type = NoteType::from_str(
|
||||
NoteType::all_builtin()
|
||||
.get(type_combo.selected() as usize)
|
||||
.copied()
|
||||
.unwrap_or("note"),
|
||||
);
|
||||
|
||||
let time_str = time_entry.text().to_string();
|
||||
updated.time = if time_str.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
parse_time_field(&time_str, &morning)
|
||||
};
|
||||
|
||||
let rrule_text = rrule_entry.text().to_string();
|
||||
updated.rrule = if rrule_text.trim().is_empty() {
|
||||
None
|
||||
|
|
@ -140,18 +158,49 @@ pub fn build_editor_popover(
|
|||
Some(RecurrenceRule::new(rrule_text))
|
||||
};
|
||||
|
||||
if let Err(e) = store.update_note(&updated) {
|
||||
tracing::error!("failed to update note: {}", e);
|
||||
} else {
|
||||
on_save(updated);
|
||||
}
|
||||
popover_save.popdown();
|
||||
|
||||
let store_bg = store.clone();
|
||||
let on_save = Rc::clone(&on_save);
|
||||
let on_error = Rc::clone(&on_error);
|
||||
spawn_bg(
|
||||
move || -> anyhow::Result<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
|
||||
}
|
||||
|
||||
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>> {
|
||||
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) {
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn refresh(state: &AppState) {
|
||||
|
|
@ -116,6 +140,16 @@ fn refresh(state: &AppState) {
|
|||
state.stack.set_visible_child_name(&active);
|
||||
}
|
||||
|
||||
/// Replace only the "all" stack page with a new list built from `notes`.
|
||||
/// All other pages are left untouched, preserving scroll position etc.
|
||||
fn rebuild_all_view(notes: &[Note], state: &AppState) {
|
||||
if let Some(child) = state.stack.child_by_name("all") {
|
||||
state.stack.remove(&child);
|
||||
}
|
||||
let scroll = build_note_list(notes, state.clone());
|
||||
state.stack.add_named(&scroll, Some("all"));
|
||||
}
|
||||
|
||||
fn rebuild_stack(state: &AppState) {
|
||||
while let Some(child) = state.stack.first_child() {
|
||||
state.stack.remove(&child);
|
||||
|
|
@ -208,9 +242,9 @@ fn cmd_upcoming_plain() -> Result<()> {
|
|||
&& n.effective_time().is_some()
|
||||
})
|
||||
.collect();
|
||||
notes.sort_by_key(|n| n.effective_time().unwrap());
|
||||
notes.sort_by_key(|n| n.effective_time().expect("filtered by is_some above"));
|
||||
for note in ¬es {
|
||||
let t = note.effective_time().unwrap();
|
||||
let t = note.effective_time().expect("filtered by is_some above");
|
||||
let local: chrono::DateTime<Local> = t.into();
|
||||
println!("[{}] {} — {}", note.id, local.format("%a %b %d %H:%M"), note.body);
|
||||
}
|
||||
|
|
@ -272,10 +306,10 @@ fn build_app_window(
|
|||
let new_note_btn = gtk4::Button::builder()
|
||||
.label("✚ New Note")
|
||||
.css_classes(["confirm-button"])
|
||||
.margin_start(10)
|
||||
.margin_end(10)
|
||||
.margin_top(12)
|
||||
.margin_bottom(6)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_top(16)
|
||||
.margin_bottom(12)
|
||||
.build();
|
||||
sidebar_vbox.append(&new_note_btn);
|
||||
|
||||
|
|
@ -349,10 +383,10 @@ fn build_app_window(
|
|||
let search_entry = gtk4::SearchEntry::builder()
|
||||
.placeholder_text("Search notes…")
|
||||
.css_classes(["search-entry"])
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.margin_top(8)
|
||||
.margin_bottom(4)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_top(12)
|
||||
.margin_bottom(8)
|
||||
.build();
|
||||
|
||||
let stack = gtk4::Stack::builder().hexpand(true).vexpand(true).build();
|
||||
|
|
@ -401,36 +435,8 @@ fn build_app_window(
|
|||
.filter(|n| n.body.to_lowercase().contains(&q))
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Replace the "all" page with the filtered list while preserving others
|
||||
while let Some(child) = state_c.stack.first_child() {
|
||||
state_c.stack.remove(&child);
|
||||
}
|
||||
let all_scroll = build_note_list(&filtered, state_c.clone());
|
||||
state_c.stack.add_named(&all_scroll, Some("all"));
|
||||
|
||||
let notes_snap = state_c.notes.borrow().clone();
|
||||
let cfg_snap = state_c.cfg.borrow().clone();
|
||||
let errors_snap = state_c.errors.borrow().clone();
|
||||
|
||||
let upcoming = views::upcoming::build(¬es_snap);
|
||||
state_c.stack.add_named(&upcoming, Some("upcoming"));
|
||||
for type_name in NoteType::all_builtin() {
|
||||
let nt = NoteType::from_str(type_name);
|
||||
let typed: Vec<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(¬es_snap, state_c.clone()), Some("archive"));
|
||||
let state_s = state_c.clone();
|
||||
state_c.stack.add_named(
|
||||
&views::settings::build(&cfg_snap, move |nc| { *state_s.cfg.borrow_mut() = nc; }),
|
||||
Some("settings"),
|
||||
);
|
||||
state_c.stack.add_named(&views::errors::build(&errors_snap), Some("errors"));
|
||||
// Only replace the "all" page — other views keep their scroll position.
|
||||
rebuild_all_view(&filtered, &state_c);
|
||||
state_c.stack.set_visible_child_name("all");
|
||||
});
|
||||
}
|
||||
|
|
@ -475,9 +481,11 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow {
|
|||
|
||||
let list = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.spacing(8)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.build();
|
||||
|
||||
let mut sorted: Vec<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 {
|
||||
let card = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.margin_top(4)
|
||||
.margin_bottom(4)
|
||||
.spacing(8)
|
||||
.margin_start(0)
|
||||
.margin_end(0)
|
||||
.margin_top(0)
|
||||
.margin_bottom(0)
|
||||
.css_classes(["note-card"])
|
||||
.build();
|
||||
card.add_css_class(&format!("note-card-{}", note.note_type.as_str()));
|
||||
|
|
@ -590,14 +598,30 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
|
|||
let card_c = card.clone();
|
||||
let state_c = state.clone();
|
||||
done_btn.connect_clicked(move |_| {
|
||||
if let Ok(Some(mut n)) = state_c.store.get_by_id(¬e_id) {
|
||||
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();
|
||||
if let Err(e) = state_c.store.update_note(&n) {
|
||||
state_c.log_error(format!("mark done failed: {}", e));
|
||||
store.update_note(&n)?;
|
||||
}
|
||||
store.load_all()
|
||||
},
|
||||
move |result| {
|
||||
match result {
|
||||
Ok(fresh) => {
|
||||
*state.notes.borrow_mut() = fresh;
|
||||
rebuild_stack(&state);
|
||||
let active = state.active_view.borrow().clone();
|
||||
state.stack.set_visible_child_name(&active);
|
||||
}
|
||||
card_c.set_visible(false);
|
||||
state_c.reload_notes();
|
||||
Err(e) => state.log_error(format!("mark done failed: {}", e)),
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
bottom_row.append(&done_btn);
|
||||
|
|
@ -622,19 +646,29 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
|
|||
let body_label_save = body_label_c.clone();
|
||||
let state_del = state_c.clone();
|
||||
let card_del = card_c.clone();
|
||||
let state_err = state_c.clone();
|
||||
|
||||
let popover = editor::build_editor_popover(
|
||||
¬e_c,
|
||||
store,
|
||||
morning,
|
||||
move |updated: Note| {
|
||||
Rc::new(move |updated: Note| {
|
||||
body_label_save.set_label(&updated.body);
|
||||
state_save.reload_notes();
|
||||
},
|
||||
move || {
|
||||
rebuild_stack(&state_save);
|
||||
let active = state_save.active_view.borrow().clone();
|
||||
state_save.stack.set_visible_child_name(&active);
|
||||
}),
|
||||
Rc::new(move || {
|
||||
card_del.set_visible(false);
|
||||
state_del.reload_notes();
|
||||
},
|
||||
rebuild_stack(&state_del);
|
||||
let active = state_del.active_view.borrow().clone();
|
||||
state_del.stack.set_visible_child_name(&active);
|
||||
}),
|
||||
Rc::new(move |e: String| {
|
||||
state_err.log_error(e);
|
||||
}),
|
||||
);
|
||||
popover.set_parent(btn);
|
||||
popover.popup();
|
||||
|
|
@ -659,12 +693,30 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
|
|||
|
||||
delete_btn.connect_clicked(move |_| {
|
||||
if *confirming.borrow() {
|
||||
card_c.set_visible(false); // optimistic hide
|
||||
let store = state_c.write_store();
|
||||
if let Err(e) = store.delete_note(¬e_id) {
|
||||
state_c.log_error(format!("delete failed: {}", e));
|
||||
let id = note_id.clone();
|
||||
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);
|
||||
state_c.reload_notes();
|
||||
store.load_all()
|
||||
},
|
||||
move |result| {
|
||||
match result {
|
||||
Ok(fresh) => {
|
||||
*state.notes.borrow_mut() = fresh;
|
||||
rebuild_stack(&state);
|
||||
let active = state.active_view.borrow().clone();
|
||||
state.stack.set_visible_child_name(&active);
|
||||
}
|
||||
Err(e) => state.log_error(format!("delete failed: {}", e)),
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
*confirming.borrow_mut() = true;
|
||||
btn_c.set_label("Sure?");
|
||||
|
|
@ -779,108 +831,88 @@ fn show_add_note_window(parent: >k4::ApplicationWindow, state: AppState) {
|
|||
cancel_btn.connect_clicked(move |_| win_c.close());
|
||||
}
|
||||
|
||||
// Add Note
|
||||
{
|
||||
let win_c = win.clone();
|
||||
let state_c = state.clone();
|
||||
let body_c = body_entry.clone();
|
||||
let time_c = time_entry.clone();
|
||||
let rrule_c = rrule_entry.clone();
|
||||
let sel_c = selected_type.clone();
|
||||
let status_c = status_label.clone();
|
||||
// Shared add-note logic — called by both the button and the Enter key.
|
||||
let do_add: Rc<dyn Fn()> = Rc::new({
|
||||
let win = win.clone();
|
||||
let state = state.clone();
|
||||
let body_entry = body_entry.clone();
|
||||
let time_entry = time_entry.clone();
|
||||
let rrule_entry = rrule_entry.clone();
|
||||
let selected_type = selected_type.clone();
|
||||
let status_label = status_label.clone();
|
||||
|
||||
let do_add = move || {
|
||||
let body_text = body_c.text().to_string();
|
||||
move || {
|
||||
let body_text = body_entry.text().to_string();
|
||||
if body_text.trim().is_empty() {
|
||||
status_c.set_label("Body is required.");
|
||||
status_label.set_label("Body is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
let morning = state_c.cfg.borrow().reminders.default_morning.clone();
|
||||
|
||||
// Tier 1 classification on body
|
||||
let morning = state.cfg.borrow().reminders.default_morning.clone();
|
||||
let parsed = parse_rule_based(&body_text, &morning);
|
||||
|
||||
let user_type = sel_c.borrow().clone();
|
||||
let default_type = NoteType::from_str(&state_c.cfg.borrow().settings.default_type);
|
||||
let user_type = selected_type.borrow().clone();
|
||||
let default_type = NoteType::from_str(&state.cfg.borrow().settings.default_type);
|
||||
|
||||
let mut note = Note::new(parsed.body.clone(), user_type.clone(), None);
|
||||
// Use parsed type if user left it at the default
|
||||
if user_type == default_type {
|
||||
note.note_type = parsed.note_type;
|
||||
}
|
||||
note.time = parsed.time;
|
||||
note.rrule = parsed.rrule;
|
||||
|
||||
// Time field overrides
|
||||
let time_str = time_c.text().to_string();
|
||||
let time_str = time_entry.text().to_string();
|
||||
if !time_str.trim().is_empty() {
|
||||
let tp = parse_rule_based(&time_str, &morning);
|
||||
if tp.time.is_some() { note.time = tp.time; }
|
||||
if tp.rrule.is_some() { note.rrule = tp.rrule; }
|
||||
}
|
||||
|
||||
// RRULE field overrides
|
||||
let rrule_str = rrule_c.text().to_string();
|
||||
let rrule_str = rrule_entry.text().to_string();
|
||||
if !rrule_str.trim().is_empty() {
|
||||
note.rrule = Some(RecurrenceRule::new(rrule_str));
|
||||
}
|
||||
|
||||
let store = state_c.write_store();
|
||||
if let Err(e) = store.save_note(¬e) {
|
||||
state_c.log_error(format!("save failed: {}", e));
|
||||
return;
|
||||
}
|
||||
if note.time.is_some() {
|
||||
if let Err(e) = Scheduler::schedule(¬e) {
|
||||
state_c.log_error(format!("schedule failed: {}", e));
|
||||
}
|
||||
}
|
||||
let store = state.write_store();
|
||||
win.close();
|
||||
|
||||
win_c.close();
|
||||
// Defer refresh so the window close event is processed first
|
||||
let state_refresh = state_c.clone();
|
||||
glib::idle_add_local_once(move || refresh(&state_refresh));
|
||||
};
|
||||
let state_bg = state.clone();
|
||||
spawn_bg(
|
||||
move || -> anyhow::Result<Vec<Note>> {
|
||||
store.save_note(¬e)?;
|
||||
if note.time.is_some() || note.rrule.is_some() {
|
||||
if let Err(e) = Scheduler::cancel(¬e.id) {
|
||||
tracing::warn!("cancel before schedule: {}", e);
|
||||
}
|
||||
Scheduler::schedule(¬e)?;
|
||||
}
|
||||
store.load_all()
|
||||
},
|
||||
move |result| {
|
||||
match result {
|
||||
Ok(fresh) => {
|
||||
*state_bg.notes.borrow_mut() = fresh;
|
||||
rebuild_stack(&state_bg);
|
||||
let active = state_bg.active_view.borrow().clone();
|
||||
state_bg.stack.set_visible_child_name(&active);
|
||||
}
|
||||
Err(e) => state_bg.log_error(format!("save failed: {}", e)),
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
let do_add = Rc::clone(&do_add);
|
||||
add_btn.connect_clicked(move |_| do_add());
|
||||
}
|
||||
|
||||
// Also trigger add on Enter in body field
|
||||
{
|
||||
let win_c2 = win.clone();
|
||||
let state_c2 = state.clone();
|
||||
let body_c2 = body_entry.clone();
|
||||
let time_c2 = time_entry.clone();
|
||||
let rrule_c2 = rrule_entry.clone();
|
||||
let sel_c2 = selected_type.clone();
|
||||
|
||||
let do_add = Rc::clone(&do_add);
|
||||
let time_entry = time_entry.clone();
|
||||
let rrule_entry = rrule_entry.clone();
|
||||
body_entry.connect_activate(move |_| {
|
||||
// If time/rrule fields are empty, submit immediately
|
||||
if time_c2.text().is_empty() && rrule_c2.text().is_empty() {
|
||||
let body_text = body_c2.text().to_string();
|
||||
if body_text.trim().is_empty() { return; }
|
||||
let morning = state_c2.cfg.borrow().reminders.default_morning.clone();
|
||||
let parsed = parse_rule_based(&body_text, &morning);
|
||||
let user_type = sel_c2.borrow().clone();
|
||||
let default_type = NoteType::from_str(&state_c2.cfg.borrow().settings.default_type);
|
||||
let mut note = Note::new(parsed.body.clone(), user_type.clone(), None);
|
||||
if user_type == default_type { note.note_type = parsed.note_type; }
|
||||
note.time = parsed.time;
|
||||
note.rrule = parsed.rrule;
|
||||
let store = state_c2.write_store();
|
||||
if let Err(e) = store.save_note(¬e) {
|
||||
state_c2.log_error(format!("save failed: {}", e));
|
||||
return;
|
||||
}
|
||||
if note.time.is_some() {
|
||||
if let Err(e) = Scheduler::schedule(¬e) {
|
||||
state_c2.log_error(format!("schedule failed: {}", e));
|
||||
}
|
||||
}
|
||||
win_c2.close();
|
||||
let sr = state_c2.clone();
|
||||
glib::idle_add_local_once(move || refresh(&sr));
|
||||
if time_entry.text().is_empty() && rrule_entry.text().is_empty() {
|
||||
do_add();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -898,8 +930,12 @@ fn apply_css(_cfg: &Config) {
|
|||
|
||||
let provider = gtk4::CssProvider::new();
|
||||
provider.load_from_string(&css);
|
||||
let Some(display) = gtk4::gdk::Display::default() else {
|
||||
tracing::warn!("no default display; skipping CSS provider");
|
||||
return;
|
||||
};
|
||||
gtk4::style_context_add_provider_for_display(
|
||||
>k4::gdk::Display::default().unwrap(),
|
||||
&display,
|
||||
&provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -79,14 +79,11 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
|
|||
.build();
|
||||
attach_row(&model_grid, 1, "Tokenizer path", &tokenizer_entry);
|
||||
|
||||
let ep_options = ["auto", "npu", "vulkan", "cpu"];
|
||||
let ep_combo = gtk4::DropDown::from_strings(&ep_options);
|
||||
let ep_idx = ep_options
|
||||
.iter()
|
||||
.position(|&s| s == cfg.model.execution_provider.as_str())
|
||||
.unwrap_or(0) as u32;
|
||||
ep_combo.set_selected(ep_idx);
|
||||
attach_row(&model_grid, 2, "Execution provider", &ep_combo);
|
||||
let ort_dylib_entry = gtk4::Entry::builder()
|
||||
.text(&cfg.model.ort_dylib_path)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
attach_row(&model_grid, 2, "ORT dylib path", &ort_dylib_entry);
|
||||
|
||||
outer.append(&model_frame);
|
||||
|
||||
|
|
@ -168,7 +165,7 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
|
|||
let grs = grace_spin.clone();
|
||||
let mpe = model_path_entry.clone();
|
||||
let tke = tokenizer_entry.clone();
|
||||
let epc = ep_combo.clone();
|
||||
let ode = ort_dylib_entry.clone();
|
||||
let oec = ollama_enabled.clone();
|
||||
let oee = ollama_endpoint.clone();
|
||||
let ome = ollama_model.clone();
|
||||
|
|
@ -203,11 +200,7 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
|
|||
model: ModelConfig {
|
||||
path: mpe.text().to_string(),
|
||||
tokenizer: tke.text().to_string(),
|
||||
execution_provider: ep_options
|
||||
.get(epc.selected() as usize)
|
||||
.copied()
|
||||
.unwrap_or("auto")
|
||||
.to_string(),
|
||||
ort_dylib_path: ode.text().to_string(),
|
||||
ollama: OllamaConfig {
|
||||
enabled: oec.is_active(),
|
||||
endpoint: oee.text().to_string(),
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ edition.workspace = true
|
|||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
|
|
|
|||
|
|
@ -70,10 +70,9 @@ impl OllamaClient {
|
|||
.into_json()
|
||||
.map_err(|e| anyhow::anyhow!("deserialize Ollama envelope: {}", e))?;
|
||||
|
||||
let classification: OllamaClassification = serde_json::from_str(&ollama_resp.response)
|
||||
.map_err(|e| anyhow::anyhow!(
|
||||
"parse Ollama classification JSON: {} — raw: {:?}",
|
||||
e,
|
||||
let classification: OllamaClassification = extract_json(&ollama_resp.response)
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"no JSON object found in response — raw: {:?}",
|
||||
&ollama_resp.response
|
||||
))?;
|
||||
|
||||
|
|
@ -116,3 +115,12 @@ impl OllamaClient {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Some backends (e.g. FastFlowLM) ignore `"format": "json"` and may wrap the
|
||||
// JSON in prose. Find the first `{...}` span and parse that.
|
||||
fn extract_json<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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,14 @@ pub struct CalDavEventInfo {
|
|||
|
||||
impl CalDavClient {
|
||||
pub fn new(config: CalendarConfig) -> Self {
|
||||
// `reqwest::Client::builder().build()` can only fail if the TLS backend can't be
|
||||
// initialised; fall back to `Client::new()` semantics rather than panicking.
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.expect("failed to build HTTP client");
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("falling back to default HTTP client: {}", e);
|
||||
reqwest::Client::new()
|
||||
});
|
||||
CalDavClient { config, client }
|
||||
}
|
||||
|
||||
|
|
@ -139,28 +144,56 @@ fn event_url(base: &str, uid: &str) -> String {
|
|||
fn build_ical(note: &Note, uid: &str) -> String {
|
||||
let dt = note.time.unwrap_or(note.created);
|
||||
let dtstart = dt.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let summary = escape_ical(¬e.body);
|
||||
let description = escape_ical(&format!("type={}", note.note_type.as_str()));
|
||||
let dtstamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
|
||||
|
||||
let mut ical = format!(
|
||||
"BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
PRODID:-//breadpad//EN\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
UID:{uid}\r\n\
|
||||
SUMMARY:{summary}\r\n\
|
||||
DTSTART:{dtstart}\r\n\
|
||||
DTEND:{dtstart}\r\n\
|
||||
DESCRIPTION:{description}\r\n"
|
||||
);
|
||||
let mut lines: Vec<String> = vec![
|
||||
"BEGIN:VCALENDAR".into(),
|
||||
"VERSION:2.0".into(),
|
||||
"PRODID:-//breadpad//EN".into(),
|
||||
"BEGIN:VEVENT".into(),
|
||||
format!("UID:{}", uid),
|
||||
fold_line(&format!("SUMMARY:{}", escape_ical(¬e.body))),
|
||||
format!("DTSTART:{}", dtstart),
|
||||
format!("DTEND:{}", dtstart),
|
||||
format!("DTSTAMP:{}", dtstamp),
|
||||
fold_line(&format!("DESCRIPTION:{}", escape_ical(&format!("type={}", note.note_type.as_str())))),
|
||||
];
|
||||
|
||||
if let Some(rrule) = ¬e.rrule {
|
||||
ical.push_str(rrule.as_str());
|
||||
ical.push_str("\r\n");
|
||||
lines.push(rrule.as_str().to_string());
|
||||
}
|
||||
|
||||
ical.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n");
|
||||
ical
|
||||
lines.push("END:VEVENT".into());
|
||||
lines.push("END:VCALENDAR".into());
|
||||
|
||||
lines.join("\r\n") + "\r\n"
|
||||
}
|
||||
|
||||
/// Fold an iCal property line per RFC 5545 §3.1: lines longer than 75 octets
|
||||
/// are split with CRLF + a single space continuation character.
|
||||
fn fold_line(line: &str) -> String {
|
||||
let bytes = line.as_bytes();
|
||||
if bytes.len() <= 75 {
|
||||
return line.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(line.len() + line.len() / 75 * 3);
|
||||
let mut pos = 0;
|
||||
let mut first = true;
|
||||
while pos < bytes.len() {
|
||||
if !first {
|
||||
out.push_str("\r\n ");
|
||||
}
|
||||
let limit = if first { 75 } else { 74 }; // continuation lines lose 1 octet to the space
|
||||
let mut end = (pos + limit).min(bytes.len());
|
||||
// Step back if we landed in the middle of a multi-byte UTF-8 sequence.
|
||||
while end > pos && end < bytes.len() && (bytes[end] & 0xC0) == 0x80 {
|
||||
end -= 1;
|
||||
}
|
||||
out.push_str(std::str::from_utf8(&bytes[pos..end]).unwrap_or(""));
|
||||
pos = end;
|
||||
first = false;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn escape_ical(s: &str) -> String {
|
||||
|
|
@ -219,3 +252,241 @@ fn parse_ical_block(data: &str) -> Vec<CalDavEventInfo> {
|
|||
|
||||
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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,16 +9,14 @@ const TIER1_SKIP_THRESHOLD: f32 = 0.82;
|
|||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ExecutionProvider {
|
||||
Qnn,
|
||||
Vulkan,
|
||||
Gpu,
|
||||
Cpu,
|
||||
}
|
||||
|
||||
impl ExecutionProvider {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
ExecutionProvider::Qnn => "QNN (NPU)",
|
||||
ExecutionProvider::Vulkan => "Vulkan",
|
||||
ExecutionProvider::Gpu => "ROCm (iGPU)",
|
||||
ExecutionProvider::Cpu => "CPU",
|
||||
}
|
||||
}
|
||||
|
|
@ -43,20 +41,27 @@ fn model_dir() -> PathBuf {
|
|||
impl Classifier {
|
||||
/// Load with Tier 1 + optional Tier 2 (ONNX). Tier 3 disabled unless
|
||||
/// `.with_ollama()` is called on the returned value.
|
||||
pub fn load(ep_pref: &str, default_morning: &str) -> Self {
|
||||
pub fn load(default_morning: &str) -> Self {
|
||||
let dir = model_dir();
|
||||
let onnx_path = dir.join("classifier.onnx");
|
||||
let tok_path = dir.join("tokenizer.json");
|
||||
Self::load_with_paths(default_morning, onnx_path, tok_path)
|
||||
}
|
||||
|
||||
let (session, active_provider) = if onnx_path.exists() {
|
||||
try_load_session(&onnx_path, ep_pref)
|
||||
pub fn load_with_paths(
|
||||
default_morning: &str,
|
||||
model_path: PathBuf,
|
||||
tokenizer_path: PathBuf,
|
||||
) -> Self {
|
||||
let (session, active_provider) = if model_path.exists() {
|
||||
try_load_session(&model_path)
|
||||
} else {
|
||||
tracing::warn!("model not found at {:?}; Tier 2 disabled", onnx_path);
|
||||
tracing::warn!("model not found at {:?}; Tier 2 disabled", model_path);
|
||||
(None, ExecutionProvider::Cpu)
|
||||
};
|
||||
|
||||
let tokenizer = if tok_path.exists() && session.is_some() {
|
||||
match tokenizers::Tokenizer::from_file(&tok_path) {
|
||||
let tokenizer = if tokenizer_path.exists() && session.is_some() {
|
||||
match tokenizers::Tokenizer::from_file(&tokenizer_path) {
|
||||
Ok(tok) => Some(tok),
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to load tokenizer: {}", e);
|
||||
|
|
@ -71,7 +76,7 @@ impl Classifier {
|
|||
session,
|
||||
tokenizer,
|
||||
active_provider,
|
||||
model_path: onnx_path,
|
||||
model_path,
|
||||
default_morning: default_morning.to_string(),
|
||||
ollama: None,
|
||||
}
|
||||
|
|
@ -144,6 +149,13 @@ impl Classifier {
|
|||
pub fn model_available(&self) -> bool {
|
||||
self.session.is_some()
|
||||
}
|
||||
|
||||
/// Run only the ONNX model (Tier 2) with no Tier 1 pre-processing or fallback.
|
||||
/// Returns `None` if no model is loaded.
|
||||
pub fn classify_tier2_only(&mut self, text: &str) -> Option<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
|
||||
|
|
@ -204,7 +216,7 @@ fn run_onnx(
|
|||
let best_idx = entailment_scores
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Less))
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(3);
|
||||
|
||||
|
|
@ -233,52 +245,34 @@ fn softmax_single(logits: &[f32], idx: usize) -> f32 {
|
|||
|
||||
fn try_load_session(
|
||||
path: &std::path::Path,
|
||||
ep_pref: &str,
|
||||
) -> (Option<ort::session::Session>, ExecutionProvider) {
|
||||
let providers: &[(&str, ExecutionProvider)] = &[
|
||||
("qnn", ExecutionProvider::Qnn),
|
||||
("vulkan", ExecutionProvider::Vulkan),
|
||||
("cpu", ExecutionProvider::Cpu),
|
||||
];
|
||||
|
||||
let to_try: Vec<&(&str, ExecutionProvider)> = match ep_pref {
|
||||
"npu" => providers[..1].iter().collect(),
|
||||
"vulkan" => providers[1..2].iter().collect(),
|
||||
"cpu" => providers[2..].iter().collect(),
|
||||
_ => providers.iter().collect(),
|
||||
};
|
||||
|
||||
for (ep_name, ep) in to_try {
|
||||
match build_session(path, ep_name) {
|
||||
Ok(session) => {
|
||||
tracing::info!("ONNX session loaded with {} EP", ep.as_str());
|
||||
return (Some(session), ep.clone());
|
||||
// Try ROCm (iGPU) first, fall back to CPU.
|
||||
match build_onnx_session(path, ort::ep::ROCm::default().build()) {
|
||||
Ok(s) => {
|
||||
tracing::info!("ONNX session loaded (ROCm iGPU)");
|
||||
return (Some(s), ExecutionProvider::Gpu);
|
||||
}
|
||||
Err(e) => tracing::debug!("ROCm EP unavailable: {}; trying CPU", e),
|
||||
}
|
||||
match build_onnx_session(path, ort::ep::CPU::default().build()) {
|
||||
Ok(s) => {
|
||||
tracing::info!("ONNX session loaded (CPU)");
|
||||
(Some(s), ExecutionProvider::Cpu)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("{} EP unavailable: {}", ep_name, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tracing::warn!("failed to load ONNX session: {}; Tier 2 disabled", e);
|
||||
(None, ExecutionProvider::Cpu)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn build_session(
|
||||
fn build_onnx_session(
|
||||
path: &std::path::Path,
|
||||
ep_name: &str,
|
||||
ep: ort::ep::ExecutionProviderDispatch,
|
||||
) -> anyhow::Result<ort::session::Session> {
|
||||
match ep_name {
|
||||
"cpu" => {
|
||||
let builder = ort::session::Session::builder()
|
||||
.map_err(|e| anyhow::anyhow!("builder: {}", e))?;
|
||||
let mut builder = builder
|
||||
.with_execution_providers([ort::ep::CPU::default().build()])
|
||||
let mut builder = ort::session::Session::builder()
|
||||
.map_err(|e| anyhow::anyhow!("builder: {}", e))?
|
||||
.with_execution_providers([ep])
|
||||
.map_err(|e| anyhow::anyhow!("ep: {}", e))?;
|
||||
let session = builder
|
||||
.commit_from_file(path)
|
||||
.map_err(|e| anyhow::anyhow!("load: {}", e))?;
|
||||
Ok(session)
|
||||
}
|
||||
_ => Err(anyhow::anyhow!("EP '{}' not available in this build", ep_name)),
|
||||
}
|
||||
builder.commit_from_file(path).map_err(|e| anyhow::anyhow!("load: {}", e))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,15 +11,29 @@ fn default_snooze_options() -> Vec<String> {
|
|||
fn default_archive_after_days() -> i64 { 30 }
|
||||
fn default_model_path() -> String { "~/.local/share/breadpad/model/classifier.onnx".into() }
|
||||
fn default_tokenizer_path() -> String { "~/.local/share/breadpad/model/tokenizer.json".into() }
|
||||
fn default_execution_provider() -> String { "auto".into() }
|
||||
fn default_ort_dylib_path() -> String { "".into() }
|
||||
fn default_morning_time() -> String { "08:00".into() }
|
||||
fn default_missed_grace_minutes() -> i64 { 60 }
|
||||
fn default_ollama_endpoint() -> String { "http://localhost:11434".into() }
|
||||
fn default_ollama_model() -> String { "llama3.2:3b".into() }
|
||||
fn default_ollama_model() -> String { "fastflowlm".into() }
|
||||
fn default_ollama_confidence_threshold() -> f32 { 0.6 }
|
||||
fn default_ollama_enabled() -> bool { true }
|
||||
fn default_calendar_enabled() -> bool { false }
|
||||
|
||||
pub fn expand_path(path: &str) -> PathBuf {
|
||||
if path == "~" {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home;
|
||||
}
|
||||
}
|
||||
if let Some(stripped) = path.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(stripped);
|
||||
}
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
#[serde(default = "default_type_str")]
|
||||
|
|
@ -72,8 +86,9 @@ pub struct ModelConfig {
|
|||
pub path: String,
|
||||
#[serde(default = "default_tokenizer_path")]
|
||||
pub tokenizer: String,
|
||||
#[serde(default = "default_execution_provider")]
|
||||
pub execution_provider: String,
|
||||
/// Path to `libonnxruntime.so`. Auto-discovered when empty.
|
||||
#[serde(default = "default_ort_dylib_path")]
|
||||
pub ort_dylib_path: String,
|
||||
#[serde(default)]
|
||||
pub ollama: OllamaConfig,
|
||||
}
|
||||
|
|
@ -83,12 +98,26 @@ impl Default for ModelConfig {
|
|||
ModelConfig {
|
||||
path: default_model_path(),
|
||||
tokenizer: default_tokenizer_path(),
|
||||
execution_provider: default_execution_provider(),
|
||||
ort_dylib_path: default_ort_dylib_path(),
|
||||
ollama: OllamaConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelConfig {
|
||||
pub fn resolved_paths(&self) -> (PathBuf, PathBuf) {
|
||||
(expand_path(&self.path), expand_path(&self.tokenizer))
|
||||
}
|
||||
|
||||
pub fn resolved_ort_dylib_path(&self) -> Option<PathBuf> {
|
||||
let raw = self.ort_dylib_path.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(expand_path(raw))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemindersConfig {
|
||||
#[serde(default = "default_morning_time")]
|
||||
|
|
@ -114,6 +143,9 @@ pub struct CalendarConfig {
|
|||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
/// WARNING: stored as plaintext in breadpad.toml. Restrict the file's permissions
|
||||
/// (`chmod 600 ~/.config/breadpad/breadpad.toml`) and keep it out of version control.
|
||||
/// A future release may support reading the password from the OS secret service instead.
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,3 +7,4 @@ pub mod scheduler;
|
|||
pub mod store;
|
||||
pub mod theme;
|
||||
pub mod types;
|
||||
pub mod util;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use crate::types::{ClassificationResult, NoteType, RecurrenceRule};
|
||||
use crate::util::local_naive_to_utc;
|
||||
use chrono::{DateTime, Datelike, Duration, Local, NaiveTime, Timelike, Utc, Weekday};
|
||||
use regex::Regex;
|
||||
use std::sync::OnceLock;
|
||||
|
|
@ -22,7 +23,7 @@ static PATTERNS: OnceLock<Patterns> = OnceLock::new();
|
|||
fn patterns() -> &'static Patterns {
|
||||
PATTERNS.get_or_init(|| Patterns {
|
||||
at_time: Regex::new(r"(?i)\bat\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?").unwrap(),
|
||||
in_duration: Regex::new(r"(?i)\bin\s+(\d+)\s+(minute|hour|day)s?").unwrap(),
|
||||
in_duration: Regex::new(r"(?i)\bin\s+(\d+)\s+(second|minute|hour|day|week)s?").unwrap(),
|
||||
// Word-form durations: "in an hour", "in a couple of hours", "in half an hour"
|
||||
in_duration_word: Regex::new(
|
||||
r"(?i)\bin\s+(?:an?\s+hour|a\s+couple\s+of\s+hours?|a\s+few\s+hours?|half\s+an?\s+hour|an?\s+minutes?|a\s+couple\s+of\s+minutes?)"
|
||||
|
|
@ -100,7 +101,7 @@ fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime<Utc> {
|
|||
};
|
||||
let target_date = local.date_naive() + Duration::days(days_ahead);
|
||||
let naive = target_date.and_time(time);
|
||||
naive.and_local_timezone(Local).unwrap().with_timezone(&Utc)
|
||||
local_naive_to_utc(naive)
|
||||
}
|
||||
|
||||
pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResult {
|
||||
|
|
@ -209,7 +210,7 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
|
|||
} else {
|
||||
(local.date_naive() + Duration::days(1)).and_time(t)
|
||||
};
|
||||
extracted_time = Some(naive.and_local_timezone(Local).unwrap().with_timezone(&Utc));
|
||||
extracted_time = Some(local_naive_to_utc(naive));
|
||||
let full_match = caps.get(0).unwrap().as_str();
|
||||
cleaned = cleaned.replacen(full_match, "", 1).trim().to_string();
|
||||
}
|
||||
|
|
@ -218,9 +219,11 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
|
|||
let n: i64 = caps.get(1).unwrap().as_str().parse().unwrap_or(1);
|
||||
let unit = caps.get(2).unwrap().as_str().to_lowercase();
|
||||
let delta = match unit.as_str() {
|
||||
"second" => Duration::seconds(n),
|
||||
"minute" => Duration::minutes(n),
|
||||
"hour" => Duration::hours(n),
|
||||
"day" => Duration::days(n),
|
||||
"week" => Duration::weeks(n),
|
||||
_ => Duration::minutes(n),
|
||||
};
|
||||
extracted_time = Some(Utc::now() + delta);
|
||||
|
|
@ -254,7 +257,7 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
|
|||
};
|
||||
let local = Local::now();
|
||||
let target = (local.date_naive() + Duration::days(1)).and_time(t);
|
||||
extracted_time = Some(target.and_local_timezone(Local).unwrap().with_timezone(&Utc));
|
||||
extracted_time = Some(local_naive_to_utc(target));
|
||||
cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
|
||||
}
|
||||
// One-off: next <weekday>
|
||||
|
|
@ -273,7 +276,7 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
|
|||
} else {
|
||||
(local.date_naive() + Duration::days(1)).and_time(anchor)
|
||||
};
|
||||
extracted_time = Some(target.and_local_timezone(Local).unwrap().with_timezone(&Utc));
|
||||
extracted_time = Some(local_naive_to_utc(target));
|
||||
cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
|
||||
}
|
||||
}
|
||||
|
|
@ -860,6 +863,23 @@ fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType {
|
|||
|| lower.starts_with("finish ")
|
||||
|| lower.starts_with("write ")
|
||||
|| lower.starts_with("update ")
|
||||
|| lower.starts_with("prepare ")
|
||||
|| lower.starts_with("schedule ")
|
||||
|| lower.starts_with("organize ")
|
||||
|| lower.starts_with("deploy ")
|
||||
|| lower.starts_with("install ")
|
||||
|| lower.starts_with("send ")
|
||||
|| lower.starts_with("submit ")
|
||||
|| lower.starts_with("create ")
|
||||
|| lower.starts_with("setup ")
|
||||
|| lower.starts_with("restore ")
|
||||
|| lower.starts_with("archive ")
|
||||
|| lower.starts_with("export ")
|
||||
|| lower.starts_with("import ")
|
||||
|| lower.starts_with("approve ")
|
||||
|| lower.starts_with("configure ")
|
||||
|| lower.starts_with("refactor ")
|
||||
|| lower.starts_with("review ")
|
||||
{
|
||||
return NoteType::Todo;
|
||||
}
|
||||
|
|
@ -867,13 +887,21 @@ fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType {
|
|||
|| lower.starts_with("idea:")
|
||||
|| lower.contains("could ")
|
||||
|| lower.contains("maybe ")
|
||||
|| lower.contains("should we ")
|
||||
|| lower.starts_with("should we ")
|
||||
{
|
||||
return NoteType::Idea;
|
||||
}
|
||||
if lower.starts_with("why ")
|
||||
|| lower.starts_with("how ")
|
||||
|| lower.starts_with("what ")
|
||||
|| (lower.starts_with("what ") && !lower.starts_with("what if "))
|
||||
|| lower.starts_with("when ")
|
||||
|| lower.starts_with("where ")
|
||||
|| lower.starts_with("who ")
|
||||
|| lower.starts_with("will ")
|
||||
|| lower.starts_with("is ")
|
||||
|| lower.starts_with("are ")
|
||||
|| lower.starts_with("did ")
|
||||
|| lower.starts_with("does ")
|
||||
|| lower.ends_with('?')
|
||||
{
|
||||
return NoteType::Question;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use crate::types::Note;
|
||||
use crate::util::local_naive_to_utc;
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Duration, Local, NaiveTime, Utc};
|
||||
use std::process::Command;
|
||||
|
|
@ -59,27 +60,63 @@ fn create_timer(id: &str, fire_time: DateTime<Utc>) -> Result<()> {
|
|||
|
||||
let timer_name = timer_unit_name(id);
|
||||
|
||||
// Find the breadpad binary. Order of preference:
|
||||
// 1. $BREADPAD_BIN override,
|
||||
// 2. a `breadpad` next to the currently running executable,
|
||||
// 3. standard install locations.
|
||||
let breadpad_exe = std::env::var_os("BREADPAD_BIN")
|
||||
.map(std::path::PathBuf::from)
|
||||
.filter(|p| p.exists())
|
||||
.or_else(|| {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|p| p.join("breadpad")))
|
||||
.filter(|p| p.exists())
|
||||
})
|
||||
.or_else(|| {
|
||||
let home_bin = dirs::home_dir().map(|h| h.join(".local/bin/breadpad"));
|
||||
["/usr/local/bin/breadpad", "/usr/bin/breadpad"]
|
||||
.iter()
|
||||
.map(std::path::PathBuf::from)
|
||||
.chain(home_bin)
|
||||
.find(|p| p.exists())
|
||||
})
|
||||
.context("breadpad binary not found in $BREADPAD_BIN, alongside this executable, or in standard locations")?;
|
||||
|
||||
// Use systemd-run to create both service + timer as a transient unit
|
||||
let status = Command::new("systemd-run")
|
||||
.arg("--user")
|
||||
// Pass necessary environment variables for notifications to work
|
||||
let mut cmd = Command::new("systemd-run");
|
||||
cmd.arg("--user")
|
||||
.arg("--unit")
|
||||
.arg(&timer_name.strip_suffix(".timer").unwrap_or(&timer_name))
|
||||
.arg(timer_name.strip_suffix(".timer").unwrap_or(&timer_name))
|
||||
.arg("--timer-property")
|
||||
.arg(format!("OnCalendar={}", on_calendar))
|
||||
.arg("--timer-property")
|
||||
.arg("Persistent=true")
|
||||
.arg("--")
|
||||
.arg("breadpad")
|
||||
.arg("Persistent=true");
|
||||
|
||||
// Pass DBUS and display environment variables so notify-send works
|
||||
if let Ok(dbus) = std::env::var("DBUS_SESSION_BUS_ADDRESS") {
|
||||
cmd.arg("--setenv").arg(format!("DBUS_SESSION_BUS_ADDRESS={}", dbus));
|
||||
}
|
||||
if let Ok(display) = std::env::var("DISPLAY") {
|
||||
cmd.arg("--setenv").arg(format!("DISPLAY={}", display));
|
||||
}
|
||||
if let Ok(wayland) = std::env::var("WAYLAND_DISPLAY") {
|
||||
cmd.arg("--setenv").arg(format!("WAYLAND_DISPLAY={}", wayland));
|
||||
}
|
||||
|
||||
cmd.arg("--")
|
||||
.arg(&breadpad_exe)
|
||||
.arg("fire")
|
||||
.arg(id)
|
||||
.status()
|
||||
.context("failed to run systemd-run")?;
|
||||
.arg(id);
|
||||
|
||||
let status = cmd.status().context("failed to run systemd-run")?;
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!("systemd-run failed for reminder {}", id);
|
||||
}
|
||||
|
||||
tracing::info!("scheduled reminder {} at {}", id, on_calendar);
|
||||
tracing::info!("scheduled reminder {} at {} using {}", id, on_calendar, breadpad_exe.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -131,17 +168,15 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O
|
|||
let now = Local::now();
|
||||
let fire_time = NaiveTime::from_hms_opt(hour, minute, 0)?;
|
||||
|
||||
let next = match freq {
|
||||
match freq {
|
||||
"DAILY" => {
|
||||
let today = now.date_naive().and_time(fire_time);
|
||||
if now.naive_local() < today {
|
||||
today.and_local_timezone(Local).unwrap()
|
||||
let naive = if now.naive_local() < today {
|
||||
today
|
||||
} else {
|
||||
(now.date_naive() + chrono::Duration::days(1))
|
||||
.and_time(fire_time)
|
||||
.and_local_timezone(Local)
|
||||
.unwrap()
|
||||
}
|
||||
(now.date_naive() + chrono::Duration::days(1)).and_time(fire_time)
|
||||
};
|
||||
return Some(local_naive_to_utc(naive));
|
||||
}
|
||||
"WEEKLY" => {
|
||||
use chrono::Datelike;
|
||||
|
|
@ -169,12 +204,10 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O
|
|||
};
|
||||
let target_date =
|
||||
(now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time);
|
||||
target_date.and_local_timezone(Local).unwrap()
|
||||
return Some(local_naive_to_utc(target_date));
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(next.with_timezone(&Utc))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -331,6 +364,21 @@ mod tests {
|
|||
assert_eq!(local.weekday(), chrono::Weekday::Sat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_tuesday_is_tuesday() {
|
||||
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=TU;BYHOUR=10;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
|
||||
let local: chrono::DateTime<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]
|
||||
fn weekly_sunday_is_sunday() {
|
||||
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=SU;BYHOUR=19;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
|
||||
|
|
@ -338,6 +386,22 @@ mod tests {
|
|||
assert_eq!(local.weekday(), chrono::Weekday::Sun);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_unknown_byday_falls_back_to_sunday() {
|
||||
// The match arm `_ => Weekday::Sun` handles unrecognised BYDAY values
|
||||
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=XX;BYHOUR=9;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
|
||||
let local: chrono::DateTime<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]
|
||||
fn unknown_freq_returns_none() {
|
||||
assert!(parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none());
|
||||
|
|
|
|||
|
|
@ -36,6 +36,14 @@ impl Store {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn with_calendar_if_enabled(self, cfg: &crate::config::Config) -> Self {
|
||||
if cfg.calendar.enabled {
|
||||
self.with_calendar(cfg.calendar.clone())
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_all(&self) -> Result<Vec<Note>> {
|
||||
self.load_from(&self.notes_path)
|
||||
}
|
||||
|
|
@ -84,12 +92,14 @@ impl Store {
|
|||
|
||||
pub fn update_note(&self, updated: &Note) -> Result<()> {
|
||||
self.rewrite_notes(|note| {
|
||||
if note.id == updated.id {
|
||||
updated.clone()
|
||||
} else {
|
||||
note
|
||||
if note.id == updated.id { updated.clone() } else { note }
|
||||
})?;
|
||||
if let Some(cal_cfg) = &self.calendar {
|
||||
if cal_cfg.enabled && (updated.time.is_some() || updated.rrule.is_some()) {
|
||||
spawn_caldav_push(updated.clone(), cal_cfg.clone());
|
||||
}
|
||||
})
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_note(&self, id: &str) -> Result<()> {
|
||||
|
|
|
|||
|
|
@ -91,19 +91,23 @@ pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String {
|
|||
@define-color teal {c6};
|
||||
@define-color overlay {c0};
|
||||
|
||||
* {{
|
||||
font-family: 'Varela Round', sans-serif;
|
||||
}}
|
||||
|
||||
window {{
|
||||
background-color: @bg;
|
||||
color: @fg;
|
||||
border-radius: 12px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
.popup-entry {{
|
||||
background: @bg;
|
||||
color: @fg;
|
||||
border: 2px solid @blue;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
caret-color: @fg;
|
||||
}}
|
||||
|
||||
|
|
@ -116,9 +120,9 @@ window {{
|
|||
background: @overlay;
|
||||
color: @fg;
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
margin: 2px;
|
||||
margin: 4px;
|
||||
}}
|
||||
|
||||
.type-chip.active {{
|
||||
|
|
@ -127,7 +131,7 @@ window {{
|
|||
}}
|
||||
|
||||
.confirm-button {{
|
||||
background: @green;
|
||||
background: @blue;
|
||||
color: @bg;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
|
|
@ -139,7 +143,7 @@ window {{
|
|||
background: shade(@bg, 1.1);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin: 4px 8px;
|
||||
margin: 8px;
|
||||
border-left: 3px solid @blue;
|
||||
}}
|
||||
|
||||
|
|
@ -151,9 +155,13 @@ window {{
|
|||
background: shade(@bg, 1.1);
|
||||
color: @fg;
|
||||
border: 1px solid @overlay;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin: 8px;
|
||||
}}
|
||||
|
||||
.search-entry:focus {{
|
||||
border-color: @blue;
|
||||
outline: none;
|
||||
}}
|
||||
"#,
|
||||
bg = palette.background,
|
||||
|
|
@ -178,25 +186,27 @@ window {{
|
|||
}
|
||||
|
||||
.sidebar-row {
|
||||
padding: 6px 12px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
transition: background 100ms ease;
|
||||
}
|
||||
|
||||
.sidebar-row:hover:not(:selected) {
|
||||
background: shade(@bg, 1.08);
|
||||
background: shade(@bg, 1.1);
|
||||
}
|
||||
|
||||
.sidebar-row:selected {
|
||||
background: @blue;
|
||||
color: @bg;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-section-label {
|
||||
color: alpha(@fg, 0.4);
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
padding: 10px 14px 2px 14px;
|
||||
letter-spacing: 1px;
|
||||
color: alpha(@fg, 0.5);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 12px 12px 8px 12px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
|
|
@ -228,6 +238,62 @@ window {{
|
|||
.note-card-question { border-left-color: @teal; }
|
||||
.note-card-note { border-left-color: @blue; }
|
||||
|
||||
.reminder-window {
|
||||
background: @bg;
|
||||
border: 1px solid @overlay;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.reminder-emoji { font-size: 20px; }
|
||||
|
||||
.reminder-title {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: alpha(@fg, 0.6);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.reminder-time {
|
||||
font-size: 12px;
|
||||
color: alpha(@fg, 0.5);
|
||||
}
|
||||
|
||||
.reminder-body {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: @fg;
|
||||
}
|
||||
|
||||
.reminder-dismiss {
|
||||
background: transparent;
|
||||
border: 1px solid @overlay;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
color: alpha(@fg, 0.6);
|
||||
}
|
||||
|
||||
.reminder-dismiss:hover { background: shade(@bg, 1.1); }
|
||||
|
||||
.reminder-snooze {
|
||||
background: transparent;
|
||||
border: 1px solid @overlay;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
color: @fg;
|
||||
}
|
||||
|
||||
.reminder-snooze:hover { background: shade(@bg, 1.1); }
|
||||
|
||||
.snooze-option {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
color: @fg;
|
||||
}
|
||||
|
||||
.snooze-option:hover { background: shade(@bg, 1.2); }
|
||||
|
||||
entry {
|
||||
background: shade(@bg, 1.1);
|
||||
color: @fg;
|
||||
|
|
|
|||
|
|
@ -72,7 +72,9 @@ pub struct Note {
|
|||
pub done: bool,
|
||||
pub workspace: Option<String>,
|
||||
pub created: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
pub snoozed_until: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub completed: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
|
|
@ -83,10 +85,14 @@ pub struct Note {
|
|||
impl Note {
|
||||
pub fn new(body: String, note_type: NoteType, workspace: Option<String>) -> Self {
|
||||
Note {
|
||||
// 12 hex chars (~48 bits) keeps IDs short and human-typable while making
|
||||
// collisions vanishingly unlikely — important because update/delete/get_by_id
|
||||
// all match notes purely by this id.
|
||||
id: uuid::Uuid::new_v4()
|
||||
.simple()
|
||||
.to_string()
|
||||
.chars()
|
||||
.take(6)
|
||||
.take(12)
|
||||
.collect(),
|
||||
body,
|
||||
note_type,
|
||||
|
|
@ -250,10 +256,15 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn note_id_is_six_chars() {
|
||||
fn note_id_is_twelve_chars() {
|
||||
for _ in 0..50 {
|
||||
let note = Note::new("x".into(), NoteType::Note, None);
|
||||
assert_eq!(note.id.len(), 6, "id '{}' is not 6 chars", note.id);
|
||||
assert_eq!(note.id.len(), 12, "id '{}' is not 12 chars", note.id);
|
||||
assert!(
|
||||
note.id.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"id '{}' is not all hex",
|
||||
note.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
63
breadpad-shared/src/util.rs
Normal file
63
breadpad-shared/src/util.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,27 @@
|
|||
use breadpad_shared::classifier::Classifier;
|
||||
use breadpad_shared::classifier::{Classifier, ExecutionProvider};
|
||||
use breadpad_shared::types::NoteType;
|
||||
use chrono::Timelike;
|
||||
|
||||
fn cl() -> Classifier {
|
||||
Classifier::load("auto", "08:00")
|
||||
Classifier::load("08:00")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_provider_is_cpu() {
|
||||
// QNN and Vulkan EPs are not compiled in; CPU is always the fallback.
|
||||
fn active_provider_is_valid() {
|
||||
// The active provider depends on the host: a machine with the ONNX model present and
|
||||
// a working ROCm iGPU loads `Gpu`, otherwise `Cpu`. Either is valid — but when no
|
||||
// model is available we must be on CPU (no session => no GPU EP in use).
|
||||
let c = cl();
|
||||
assert_eq!(c.active_provider, breadpad_shared::classifier::ExecutionProvider::Cpu);
|
||||
assert!(matches!(
|
||||
c.active_provider,
|
||||
ExecutionProvider::Cpu | ExecutionProvider::Gpu
|
||||
));
|
||||
if !c.model_available() {
|
||||
assert!(
|
||||
matches!(c.active_provider, ExecutionProvider::Cpu),
|
||||
"no model loaded but provider was not CPU"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -63,7 +74,7 @@ fn classify_recurrence_via_fallback() {
|
|||
|
||||
#[test]
|
||||
fn classify_custom_morning_time() {
|
||||
let mut c = Classifier::load("auto", "07:15");
|
||||
let mut c = Classifier::load("07:15");
|
||||
let r = c.classify("sync tomorrow morning");
|
||||
let t = r.time.expect("should have a time for tomorrow morning");
|
||||
let local: chrono::DateTime<chrono::Local> = t.into();
|
||||
|
|
@ -71,6 +82,41 @@ fn classify_custom_morning_time() {
|
|||
assert_eq!(local.minute(), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_empty_string_does_not_panic() {
|
||||
let mut c = cl();
|
||||
let _ = c.classify("");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_whitespace_only_does_not_panic() {
|
||||
let mut c = cl();
|
||||
let _ = c.classify(" ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_in_duration_sets_time() {
|
||||
let mut c = cl();
|
||||
let r = c.classify("take a break in 30 minutes");
|
||||
assert!(r.time.is_some(), "should have a time for 'in 30 minutes'");
|
||||
assert_eq!(r.note_type, NoteType::Reminder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_tomorrow_sets_time() {
|
||||
let mut c = cl();
|
||||
let r = c.classify("submit the invoice tomorrow");
|
||||
assert!(r.time.is_some(), "tomorrow should produce a scheduled time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_returns_cleaned_body() {
|
||||
let mut c = cl();
|
||||
let r = c.classify("call mum at 6pm");
|
||||
assert!(r.body.contains("call mum"), "body: {}", r.body);
|
||||
assert!(!r.body.contains("6pm"), "time phrase should be stripped from body: {}", r.body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_path_points_to_expected_location() {
|
||||
let c = cl();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use breadpad_shared::config::{Config, ModelConfig, RemindersConfig, Settings};
|
||||
use breadpad_shared::config::{expand_path, CalendarConfig, Config, ModelConfig, RemindersConfig, Settings};
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ---- Default values ----
|
||||
|
|
@ -22,9 +22,9 @@ fn default_snooze_options_contains_all_three() {
|
|||
#[test]
|
||||
fn default_model_config() {
|
||||
let m = ModelConfig::default();
|
||||
assert_eq!(m.execution_provider, "auto");
|
||||
assert!(m.path.contains("classifier.onnx"));
|
||||
assert!(m.tokenizer.contains("tokenizer.json"));
|
||||
assert_eq!(m.ort_dylib_path, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -38,7 +38,6 @@ fn default_reminders_config() {
|
|||
fn default_config_composes_defaults() {
|
||||
let cfg = Config::default();
|
||||
assert_eq!(cfg.settings.default_type, "note");
|
||||
assert_eq!(cfg.model.execution_provider, "auto");
|
||||
assert_eq!(cfg.reminders.default_morning, "08:00");
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +55,7 @@ archive_after_days = 7
|
|||
[model]
|
||||
path = "/tmp/classifier.onnx"
|
||||
tokenizer = "/tmp/tokenizer.json"
|
||||
execution_provider = "cpu"
|
||||
ort_dylib_path = "/tmp/libonnxruntime.so"
|
||||
|
||||
[reminders]
|
||||
default_morning = "07:30"
|
||||
|
|
@ -67,8 +66,8 @@ missed_grace_minutes = 30
|
|||
assert!(!cfg.settings.workspace_tag);
|
||||
assert_eq!(cfg.settings.snooze_options, vec!["15m", "2h"]);
|
||||
assert_eq!(cfg.settings.archive_after_days, 7);
|
||||
assert_eq!(cfg.model.execution_provider, "cpu");
|
||||
assert_eq!(cfg.model.path, "/tmp/classifier.onnx");
|
||||
assert_eq!(cfg.model.ort_dylib_path, "/tmp/libonnxruntime.so");
|
||||
assert_eq!(cfg.reminders.default_morning, "07:30");
|
||||
assert_eq!(cfg.reminders.missed_grace_minutes, 30);
|
||||
}
|
||||
|
|
@ -78,7 +77,6 @@ fn empty_toml_uses_all_defaults() {
|
|||
let cfg: Config = toml::from_str("").unwrap();
|
||||
assert_eq!(cfg.settings.default_type, "note");
|
||||
assert!(cfg.settings.workspace_tag);
|
||||
assert_eq!(cfg.model.execution_provider, "auto");
|
||||
assert_eq!(cfg.reminders.default_morning, "08:00");
|
||||
}
|
||||
|
||||
|
|
@ -90,31 +88,9 @@ default_type = "reminder"
|
|||
"#;
|
||||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(cfg.settings.default_type, "reminder");
|
||||
// Other sections should still have defaults
|
||||
assert_eq!(cfg.model.execution_provider, "auto");
|
||||
assert_eq!(cfg.reminders.default_morning, "08:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_toml_only_model_section() {
|
||||
let toml = r#"
|
||||
[model]
|
||||
execution_provider = "npu"
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(cfg.model.execution_provider, "npu");
|
||||
assert_eq!(cfg.settings.default_type, "note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_provider_variants_accepted() {
|
||||
for ep in &["auto", "npu", "vulkan", "cpu"] {
|
||||
let toml = format!("[model]\nexecution_provider = \"{}\"", ep);
|
||||
let cfg: Config = toml::from_str(&toml).unwrap();
|
||||
assert_eq!(cfg.model.execution_provider, *ep);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- TOML serialization round-trip ----
|
||||
|
||||
#[test]
|
||||
|
|
@ -124,7 +100,6 @@ fn default_config_serializes_to_valid_toml() {
|
|||
let reparsed: Config = toml::from_str(&serialized).unwrap();
|
||||
assert_eq!(reparsed.settings.default_type, cfg.settings.default_type);
|
||||
assert_eq!(reparsed.settings.workspace_tag, cfg.settings.workspace_tag);
|
||||
assert_eq!(reparsed.model.execution_provider, cfg.model.execution_provider);
|
||||
assert_eq!(reparsed.reminders.default_morning, cfg.reminders.default_morning);
|
||||
}
|
||||
|
||||
|
|
@ -133,7 +108,6 @@ fn custom_config_round_trips() {
|
|||
let mut cfg = Config::default();
|
||||
cfg.settings.default_type = "idea".into();
|
||||
cfg.settings.archive_after_days = 14;
|
||||
cfg.model.execution_provider = "vulkan".into();
|
||||
cfg.reminders.default_morning = "06:45".into();
|
||||
cfg.reminders.missed_grace_minutes = 120;
|
||||
|
||||
|
|
@ -141,7 +115,6 @@ fn custom_config_round_trips() {
|
|||
let rt: Config = toml::from_str(&toml).unwrap();
|
||||
assert_eq!(rt.settings.default_type, "idea");
|
||||
assert_eq!(rt.settings.archive_after_days, 14);
|
||||
assert_eq!(rt.model.execution_provider, "vulkan");
|
||||
assert_eq!(rt.reminders.default_morning, "06:45");
|
||||
assert_eq!(rt.reminders.missed_grace_minutes, 120);
|
||||
}
|
||||
|
|
@ -155,24 +128,20 @@ fn save_and_load_round_trip() {
|
|||
|
||||
let mut cfg = Config::default();
|
||||
cfg.settings.default_type = "question".into();
|
||||
cfg.model.execution_provider = "cpu".into();
|
||||
cfg.reminders.missed_grace_minutes = 45;
|
||||
|
||||
// Manually save to a known path (Config::save uses the fixed XDG path,
|
||||
// so we use toml serialization + write here to test the round-trip logic)
|
||||
let toml = toml::to_string_pretty(&cfg).unwrap();
|
||||
std::fs::write(&config_path, &toml).unwrap();
|
||||
|
||||
let loaded: Config = toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
|
||||
assert_eq!(loaded.settings.default_type, "question");
|
||||
assert_eq!(loaded.model.execution_provider, "cpu");
|
||||
assert_eq!(loaded.reminders.missed_grace_minutes, 45);
|
||||
}
|
||||
|
||||
// ---- The example from the README ----
|
||||
|
||||
#[test]
|
||||
fn readme_example_toml_parses() {
|
||||
fn example_toml_parses() {
|
||||
let toml = r#"
|
||||
[settings]
|
||||
default_type = "note"
|
||||
|
|
@ -183,7 +152,7 @@ archive_after_days = 30
|
|||
[model]
|
||||
path = "~/.local/share/breadpad/model/classifier.onnx"
|
||||
tokenizer = "~/.local/share/breadpad/model/tokenizer.json"
|
||||
execution_provider = "auto"
|
||||
ort_dylib_path = ""
|
||||
|
||||
[reminders]
|
||||
default_morning = "08:00"
|
||||
|
|
@ -192,7 +161,146 @@ missed_grace_minutes = 60
|
|||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(cfg.settings.default_type, "note");
|
||||
assert!(cfg.settings.workspace_tag);
|
||||
assert_eq!(cfg.model.execution_provider, "auto");
|
||||
assert_eq!(cfg.reminders.default_morning, "08:00");
|
||||
assert_eq!(cfg.reminders.missed_grace_minutes, 60);
|
||||
}
|
||||
|
||||
// ---- CalendarConfig ----
|
||||
|
||||
#[test]
|
||||
fn default_calendar_config_is_disabled() {
|
||||
let c = CalendarConfig::default();
|
||||
assert!(!c.enabled);
|
||||
assert!(c.url.is_empty());
|
||||
assert!(c.username.is_empty());
|
||||
assert!(c.password.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_config_from_toml() {
|
||||
let toml = r#"
|
||||
[calendar]
|
||||
enabled = true
|
||||
url = "https://cloud.example.com/remote.php/dav/calendars/user/personal/"
|
||||
username = "user"
|
||||
password = "secret"
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert!(cfg.calendar.enabled);
|
||||
assert!(cfg.calendar.url.contains("dav/calendars"));
|
||||
assert_eq!(cfg.calendar.username, "user");
|
||||
assert_eq!(cfg.calendar.password, "secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_config_round_trips() {
|
||||
let mut cfg = Config::default();
|
||||
cfg.calendar.enabled = true;
|
||||
cfg.calendar.url = "https://example.com/cal".into();
|
||||
cfg.calendar.username = "alice".into();
|
||||
cfg.calendar.password = "hunter2".into();
|
||||
|
||||
let toml = toml::to_string_pretty(&cfg).unwrap();
|
||||
let rt: Config = toml::from_str(&toml).unwrap();
|
||||
assert!(rt.calendar.enabled);
|
||||
assert_eq!(rt.calendar.url, "https://example.com/cal");
|
||||
assert_eq!(rt.calendar.username, "alice");
|
||||
assert_eq!(rt.calendar.password, "hunter2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_config_calendar_disabled() {
|
||||
let cfg = Config::default();
|
||||
assert!(!cfg.calendar.enabled);
|
||||
}
|
||||
|
||||
// ---- OllamaConfig ----
|
||||
|
||||
#[test]
|
||||
fn default_ollama_config_enabled() {
|
||||
let m = ModelConfig::default();
|
||||
assert!(m.ollama.enabled);
|
||||
assert_eq!(m.ollama.endpoint, "http://localhost:11434");
|
||||
assert!(!m.ollama.model.is_empty());
|
||||
assert!(m.ollama.confidence_threshold > 0.0 && m.ollama.confidence_threshold <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_config_from_toml() {
|
||||
let toml = r#"
|
||||
[model.ollama]
|
||||
enabled = false
|
||||
endpoint = "http://localhost:9999"
|
||||
model = "llama3"
|
||||
confidence_threshold = 0.8
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert!(!cfg.model.ollama.enabled);
|
||||
assert_eq!(cfg.model.ollama.endpoint, "http://localhost:9999");
|
||||
assert_eq!(cfg.model.ollama.model, "llama3");
|
||||
assert!((cfg.model.ollama.confidence_threshold - 0.8).abs() < 1e-5);
|
||||
}
|
||||
|
||||
// ---- expand_path ----
|
||||
|
||||
#[test]
|
||||
fn expand_path_tilde_prefix_replaced_with_home() {
|
||||
let home = dirs::home_dir().unwrap();
|
||||
let expanded = expand_path("~/some/path");
|
||||
assert!(expanded.starts_with(&home));
|
||||
assert!(expanded.ends_with("some/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_path_bare_tilde_is_home() {
|
||||
let home = dirs::home_dir().unwrap();
|
||||
assert_eq!(expand_path("~"), home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_path_absolute_path_unchanged() {
|
||||
let p = expand_path("/usr/local/bin/breadpad");
|
||||
assert_eq!(p.to_str().unwrap(), "/usr/local/bin/breadpad");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_path_relative_path_unchanged() {
|
||||
let p = expand_path("relative/path");
|
||||
assert_eq!(p.to_str().unwrap(), "relative/path");
|
||||
}
|
||||
|
||||
// ---- ModelConfig::resolved_ort_dylib_path ----
|
||||
|
||||
#[test]
|
||||
fn resolved_ort_dylib_empty_returns_none() {
|
||||
let m = ModelConfig::default();
|
||||
assert!(m.resolved_ort_dylib_path().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_ort_dylib_whitespace_only_returns_none() {
|
||||
let mut m = ModelConfig::default();
|
||||
m.ort_dylib_path = " ".into();
|
||||
assert!(m.resolved_ort_dylib_path().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_ort_dylib_set_returns_some() {
|
||||
let mut m = ModelConfig::default();
|
||||
m.ort_dylib_path = "/usr/lib/libonnxruntime.so".into();
|
||||
assert_eq!(
|
||||
m.resolved_ort_dylib_path().unwrap().to_str().unwrap(),
|
||||
"/usr/lib/libonnxruntime.so"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- ModelConfig::resolved_paths ----
|
||||
|
||||
#[test]
|
||||
fn resolved_paths_expands_tildes() {
|
||||
let m = ModelConfig::default();
|
||||
let (model, tokenizer) = m.resolved_paths();
|
||||
let home = dirs::home_dir().unwrap();
|
||||
assert!(model.starts_with(&home), "model path should be under home: {:?}", model);
|
||||
assert!(tokenizer.starts_with(&home), "tokenizer path should be under home: {:?}", tokenizer);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use tempfile::TempDir;
|
|||
// Mirrors commit_note() in breadpad/src/main.rs.
|
||||
// `user_type` is the type the user selected in the chip row (default = NoteType::Note).
|
||||
fn capture(store: &Store, text: &str, user_type: NoteType) -> Note {
|
||||
let mut classifier = Classifier::load("auto", "08:00");
|
||||
let mut classifier = Classifier::load("08:00");
|
||||
let result = classifier.classify(text);
|
||||
|
||||
let mut note = Note::new(text.into(), user_type.clone(), None);
|
||||
|
|
|
|||
|
|
@ -301,6 +301,51 @@ fn rotate_archive_zero_when_nothing_qualifies() {
|
|||
assert_eq!(store.load_all().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_note_just_inside_boundary_stays() {
|
||||
let (_dir, store) = mk();
|
||||
// 29 days ago — threshold is 30 — should NOT be archived
|
||||
let mut n = note("fresh enough", NoteType::Todo);
|
||||
n.done = true;
|
||||
n.completed = Some(Utc::now() - Duration::days(29));
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
assert_eq!(store.rotate_archive(30).unwrap(), 0);
|
||||
assert_eq!(store.load_all().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_note_just_past_boundary_is_archived() {
|
||||
let (_dir, store) = mk();
|
||||
// 31 days ago — threshold is 30 — should be archived
|
||||
let mut n = note("old enough", NoteType::Todo);
|
||||
n.done = true;
|
||||
n.completed = Some(Utc::now() - Duration::days(31));
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
assert_eq!(store.rotate_archive(30).unwrap(), 1);
|
||||
assert!(store.load_all().unwrap().is_empty());
|
||||
assert_eq!(store.load_archive().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_zero_day_threshold_archives_completed_notes() {
|
||||
let (_dir, store) = mk();
|
||||
let mut done = note("done a second ago", NoteType::Todo);
|
||||
done.done = true;
|
||||
done.completed = Some(Utc::now() - Duration::seconds(1));
|
||||
store.save_note(&done).unwrap();
|
||||
|
||||
let undone = note("still active", NoteType::Todo);
|
||||
store.save_note(&undone).unwrap();
|
||||
|
||||
assert_eq!(store.rotate_archive(0).unwrap(), 1);
|
||||
let remaining = store.load_all().unwrap();
|
||||
assert_eq!(remaining.len(), 1);
|
||||
assert_eq!(remaining[0].body, "still active");
|
||||
assert_eq!(store.load_archive().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_ignores_undone_notes_no_matter_how_old() {
|
||||
let (_dir, store) = mk();
|
||||
|
|
|
|||
|
|
@ -18,3 +18,5 @@ chrono = { workspace = true }
|
|||
clap = { version = "4", features = ["derive"] }
|
||||
colored = "2"
|
||||
comfy-table = "7"
|
||||
ort = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -67,6 +67,9 @@ enum TierArg {
|
|||
Three,
|
||||
#[value(name = "all")]
|
||||
All,
|
||||
/// Production path: Tier 1 → Tier 2 (no Ollama)
|
||||
#[value(name = "pipeline")]
|
||||
Pipeline,
|
||||
}
|
||||
|
||||
impl TierArg {
|
||||
|
|
@ -76,6 +79,7 @@ impl TierArg {
|
|||
TierArg::Two => "2",
|
||||
TierArg::Three => "3",
|
||||
TierArg::All => "all",
|
||||
TierArg::Pipeline => "pipeline",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -142,7 +146,14 @@ fn classify_with_tier(text: &str, tier: &TierArg) -> ClassificationResult {
|
|||
match tier {
|
||||
TierArg::One => parse_rule_based(text, DEFAULT_MORNING),
|
||||
TierArg::Two => {
|
||||
let mut clf = Classifier::load("auto", DEFAULT_MORNING);
|
||||
let mut clf = Classifier::load(DEFAULT_MORNING);
|
||||
clf.classify_tier2_only(text).unwrap_or_else(|| {
|
||||
eprintln!("warning: ONNX model not loaded; Tier 2 unavailable");
|
||||
parse_rule_based(text, DEFAULT_MORNING)
|
||||
})
|
||||
}
|
||||
TierArg::Pipeline => {
|
||||
let mut clf = Classifier::load(DEFAULT_MORNING);
|
||||
clf.classify(text)
|
||||
}
|
||||
TierArg::Three | TierArg::All => {
|
||||
|
|
@ -152,7 +163,7 @@ fn classify_with_tier(text: &str, tier: &TierArg) -> ClassificationResult {
|
|||
confidence_threshold: 0.6,
|
||||
enabled: true,
|
||||
};
|
||||
let mut clf = Classifier::load("auto", DEFAULT_MORNING).with_ollama(ollama);
|
||||
let mut clf = Classifier::load(DEFAULT_MORNING).with_ollama(ollama);
|
||||
clf.classify(text)
|
||||
}
|
||||
}
|
||||
|
|
@ -444,8 +455,35 @@ fn cmd_edit(index: usize, corpus_path: &Path) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn init_ort() {
|
||||
use std::path::PathBuf;
|
||||
// Prefer the system CPU-only library for testing — no Ryzen AI startup overhead.
|
||||
// Fall back to the Ryzen AI SDK library if the system one isn't installed.
|
||||
let candidates: Vec<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<()> {
|
||||
let cli = Cli::parse();
|
||||
init_ort();
|
||||
|
||||
match cli.command {
|
||||
Commands::Run { corpus, tier, format } => {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,25 @@ archive_after_days = 30
|
|||
[model]
|
||||
path = "~/.local/share/breadpad/model/classifier.onnx"
|
||||
tokenizer = "~/.local/share/breadpad/model/tokenizer.json"
|
||||
execution_provider = "auto" # auto | npu | vulkan | cpu
|
||||
# ort_dylib_path: path to libonnxruntime.so. Leave empty to auto-discover from
|
||||
# standard system paths or $ORT_DYLIB_PATH. Tier 2 is disabled if no library is found.
|
||||
ort_dylib_path = ""
|
||||
|
||||
[model.ollama]
|
||||
endpoint = "http://localhost:11434"
|
||||
model = "fastflowlm"
|
||||
confidence_threshold = 0.6
|
||||
enabled = true
|
||||
|
||||
[reminders]
|
||||
default_morning = "08:00"
|
||||
missed_grace_minutes = 60
|
||||
|
||||
[calendar]
|
||||
enabled = false
|
||||
url = "" # e.g. https://cloud.example.com/remote.php/dav/calendars/user/personal/
|
||||
username = ""
|
||||
# WARNING: password is stored in plaintext. Restrict file permissions:
|
||||
# chmod 600 ~/.config/breadpad/breadpad.toml
|
||||
# and keep this file out of version control.
|
||||
password = ""
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ path = "src/main.rs"
|
|||
[dependencies]
|
||||
breadpad-shared = { path = "../breadpad-shared" }
|
||||
anyhow.workspace = true
|
||||
ort.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
serde.workspace = true
|
||||
|
|
|
|||
|
|
@ -12,7 +12,24 @@ use gtk4::{glib, prelude::*};
|
|||
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Once};
|
||||
|
||||
static ORT_INIT: Once = Once::new();
|
||||
|
||||
fn init_ort_once(cfg: &Config) {
|
||||
ORT_INIT.call_once(|| {
|
||||
let Some(path) = cfg.model.resolved_ort_dylib_path() else { return; };
|
||||
if !path.exists() {
|
||||
tracing::warn!("ORT dylib not found at {:?}; Tier 2 disabled", path);
|
||||
return;
|
||||
}
|
||||
tracing::info!("loading ONNX Runtime from {:?}", path);
|
||||
match ort::init_from(&path) {
|
||||
Ok(builder) => { builder.commit(); }
|
||||
Err(e) => tracing::warn!("ORT init failed: {}; Tier 2 disabled", e),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
mod args {
|
||||
#[derive(Debug)]
|
||||
|
|
@ -89,7 +106,7 @@ fn main() -> Result<()> {
|
|||
return cmd_status(&cfg);
|
||||
}
|
||||
if args.download_model {
|
||||
return cmd_download_model();
|
||||
return cmd_download_model(&cfg);
|
||||
}
|
||||
if args.model_info {
|
||||
return cmd_model_info(&cfg);
|
||||
|
|
@ -108,9 +125,15 @@ fn main() -> Result<()> {
|
|||
}
|
||||
|
||||
fn cmd_status(cfg: &Config) -> Result<()> {
|
||||
init_ort_once(cfg);
|
||||
let store = Store::new()?;
|
||||
let notes = store.load_all()?;
|
||||
let classifier = Classifier::load(&cfg.model.execution_provider, &cfg.reminders.default_morning);
|
||||
let (model_path, tokenizer_path) = cfg.model.resolved_paths();
|
||||
let classifier = Classifier::load_with_paths(
|
||||
&cfg.reminders.default_morning,
|
||||
model_path,
|
||||
tokenizer_path,
|
||||
);
|
||||
println!("breadpad status");
|
||||
println!(" notes: {}", notes.len());
|
||||
println!(
|
||||
|
|
@ -126,7 +149,13 @@ fn cmd_status(cfg: &Config) -> Result<()> {
|
|||
}
|
||||
|
||||
fn cmd_model_info(cfg: &Config) -> Result<()> {
|
||||
let classifier = Classifier::load(&cfg.model.execution_provider, &cfg.reminders.default_morning);
|
||||
init_ort_once(cfg);
|
||||
let (model_path, tokenizer_path) = cfg.model.resolved_paths();
|
||||
let classifier = Classifier::load_with_paths(
|
||||
&cfg.reminders.default_morning,
|
||||
model_path,
|
||||
tokenizer_path,
|
||||
);
|
||||
println!("model path: {:?}", classifier.model_path);
|
||||
println!("execution provider: {}", classifier.active_provider.as_str());
|
||||
println!(
|
||||
|
|
@ -136,16 +165,19 @@ fn cmd_model_info(cfg: &Config) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_download_model() -> Result<()> {
|
||||
fn cmd_download_model(cfg: &Config) -> Result<()> {
|
||||
// Placeholder — a real implementation would download a quantised ONNX model.
|
||||
// The exact model URL is left for the user to configure.
|
||||
let dir = dirs::data_local_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("~/.local/share"))
|
||||
.join("breadpad")
|
||||
.join("model");
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
println!("Model directory: {}", dir.display());
|
||||
println!("Place classifier.onnx and tokenizer.json in that directory.");
|
||||
let (model_path, tokenizer_path) = cfg.model.resolved_paths();
|
||||
if let Some(dir) = model_path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
if let Some(dir) = tokenizer_path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
println!("Model path: {}", model_path.display());
|
||||
println!("Tokenizer path: {}", tokenizer_path.display());
|
||||
println!("Place the classifier ONNX and tokenizer JSON at those paths.");
|
||||
println!("(Automatic download not yet configured — set a model URL in breadpad.toml)");
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -221,7 +253,7 @@ fn cmd_calendar_list_uid(note_id: &str, cfg: &Config) -> Result<()> {
|
|||
}
|
||||
|
||||
fn cmd_fire(id: &str, cfg: &Config) -> Result<()> {
|
||||
let store = Store::new()?;
|
||||
let store = Store::new()?.with_calendar_if_enabled(cfg);
|
||||
let note = match store.get_by_id(id)? {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
|
|
@ -234,37 +266,7 @@ fn cmd_fire(id: &str, cfg: &Config) -> Result<()> {
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
// Send notification via notify-send
|
||||
let title = format!("[{}] breadpad reminder", note.note_type);
|
||||
|
||||
let mut cmd = std::process::Command::new("notify-send");
|
||||
cmd.arg("--urgency=normal")
|
||||
.arg(format!("--app-name=breadpad"))
|
||||
.arg(&title)
|
||||
.arg(¬e.body);
|
||||
for opt in &cfg.settings.snooze_options {
|
||||
cmd.arg(format!("--action=snooze_{}={}", opt, humanize_snooze(opt)));
|
||||
}
|
||||
let output = cmd.output()?;
|
||||
|
||||
// If the user clicked a snooze action, notify-send prints the action key
|
||||
if let Ok(action) = String::from_utf8(output.stdout) {
|
||||
let action = action.trim();
|
||||
if action.starts_with("snooze_") {
|
||||
let key = action.trim_start_matches("snooze_");
|
||||
if let Some(until) = resolve_snooze(key, cfg) {
|
||||
let mut updated = note.clone();
|
||||
store.update_note({
|
||||
updated.snoozed_until = Some(until);
|
||||
&updated
|
||||
})?;
|
||||
Scheduler::schedule(&updated)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle recurrence
|
||||
// Schedule next recurrence before showing UI
|
||||
if note.rrule.is_some() {
|
||||
if let Some(next) = Scheduler::next_recurrence(¬e, &cfg.reminders.default_morning) {
|
||||
let mut updated = note.clone();
|
||||
|
|
@ -275,6 +277,22 @@ fn cmd_fire(id: &str, cfg: &Config) -> Result<()> {
|
|||
}
|
||||
}
|
||||
|
||||
run_reminder_window(note, cfg)
|
||||
}
|
||||
|
||||
fn run_reminder_window(note: breadpad_shared::types::Note, cfg: &Config) -> Result<()> {
|
||||
let app = gtk4::Application::builder()
|
||||
.application_id("com.breadway.breadpad.reminder")
|
||||
.build();
|
||||
|
||||
let note = Arc::new(note);
|
||||
let cfg = Arc::new(cfg.clone());
|
||||
|
||||
app.connect_activate(move |app| {
|
||||
build_reminder_window(app, note.clone(), cfg.clone());
|
||||
});
|
||||
|
||||
app.run_with_args::<String>(&[]);
|
||||
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 tomorrow = local.date_naive() + chrono::Duration::days(1);
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_reminder_window(
|
||||
app: >k4::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(
|
||||
>k4::Label::builder()
|
||||
.label(type_emoji)
|
||||
.css_classes(["reminder-emoji"])
|
||||
.build(),
|
||||
);
|
||||
header.append(
|
||||
>k4::Label::builder()
|
||||
.label("Reminder")
|
||||
.css_classes(["reminder-title"])
|
||||
.hexpand(true)
|
||||
.xalign(0.0)
|
||||
.build(),
|
||||
);
|
||||
|
||||
// Optional time label
|
||||
if let Some(t) = note.effective_time() {
|
||||
let local: chrono::DateTime<chrono::Local> = t.into();
|
||||
header.append(
|
||||
>k4::Label::builder()
|
||||
.label(&local.format("%H:%M").to_string())
|
||||
.css_classes(["reminder-time"])
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
|
||||
outer.append(&header);
|
||||
|
||||
// Body
|
||||
let body_label = gtk4::Label::builder()
|
||||
.label(¬e.body)
|
||||
.css_classes(["reminder-body"])
|
||||
.wrap(true)
|
||||
.xalign(0.0)
|
||||
.margin_start(20)
|
||||
.margin_end(20)
|
||||
.margin_bottom(16)
|
||||
.build();
|
||||
outer.append(&body_label);
|
||||
|
||||
// Separator
|
||||
outer.append(>k4::Separator::builder()
|
||||
.orientation(gtk4::Orientation::Horizontal)
|
||||
.build());
|
||||
|
||||
// Button row
|
||||
let btn_row = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(16)
|
||||
.margin_end(16)
|
||||
.build();
|
||||
|
||||
let dismiss_btn = gtk4::Button::builder()
|
||||
.label("Dismiss")
|
||||
.css_classes(["reminder-dismiss"])
|
||||
.build();
|
||||
|
||||
// Snooze popover
|
||||
let snooze_popover = gtk4::Popover::new();
|
||||
let snooze_vbox = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.build();
|
||||
|
||||
for opt in &cfg.settings.snooze_options {
|
||||
let label = humanize_snooze(opt).to_string();
|
||||
let btn = gtk4::Button::builder()
|
||||
.label(&label)
|
||||
.css_classes(["snooze-option"])
|
||||
.build();
|
||||
let key = opt.clone();
|
||||
let note_c = note.clone();
|
||||
let cfg_c = cfg.clone();
|
||||
let win_c = window.clone();
|
||||
let popover_c = snooze_popover.clone();
|
||||
btn.connect_clicked(move |_| {
|
||||
if let Some(until) = resolve_snooze(&key, &cfg_c) {
|
||||
if let Ok(store) = Store::new().map(|s| s.with_calendar_if_enabled(&cfg_c)) {
|
||||
let mut updated = note_c.as_ref().clone();
|
||||
updated.snoozed_until = Some(until);
|
||||
let _ = store.update_note(&updated);
|
||||
let _ = Scheduler::schedule(&updated);
|
||||
}
|
||||
}
|
||||
popover_c.popdown();
|
||||
win_c.close();
|
||||
});
|
||||
snooze_vbox.append(&btn);
|
||||
}
|
||||
snooze_popover.set_child(Some(&snooze_vbox));
|
||||
|
||||
let snooze_btn = gtk4::MenuButton::builder()
|
||||
.label("Snooze")
|
||||
.css_classes(["reminder-snooze"])
|
||||
.popover(&snooze_popover)
|
||||
.build();
|
||||
|
||||
let done_btn = gtk4::Button::builder()
|
||||
.label("Done ✓")
|
||||
.css_classes(["confirm-button", "reminder-done"])
|
||||
.hexpand(true)
|
||||
.build();
|
||||
|
||||
{
|
||||
let note_c = note.clone();
|
||||
let cfg_c = cfg.clone();
|
||||
let win_c = window.clone();
|
||||
done_btn.connect_clicked(move |_| {
|
||||
if let Ok(store) = Store::new().map(|s| s.with_calendar_if_enabled(&cfg_c)) {
|
||||
let mut updated = note_c.as_ref().clone();
|
||||
updated.mark_done();
|
||||
let _ = store.update_note(&updated);
|
||||
}
|
||||
win_c.close();
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let win_c = window.clone();
|
||||
dismiss_btn.connect_clicked(move |_| { win_c.close(); });
|
||||
}
|
||||
|
||||
btn_row.append(&dismiss_btn);
|
||||
btn_row.append(&snooze_btn);
|
||||
btn_row.append(&done_btn);
|
||||
outer.append(&btn_row);
|
||||
|
||||
window.set_child(Some(&outer));
|
||||
window.present();
|
||||
}
|
||||
|
||||
fn run_popup(preset_type: Option<String>, no_classify: bool, cfg: Config) -> Result<()> {
|
||||
// Try to get current Hyprland workspace
|
||||
let workspace = get_active_workspace();
|
||||
|
|
@ -321,10 +522,11 @@ fn run_popup(preset_type: Option<String>, no_classify: bool, cfg: Config) -> Res
|
|||
let cfg = Arc::new(cfg);
|
||||
|
||||
app.connect_activate(move |app| {
|
||||
let cfg = cfg.clone();
|
||||
let workspace = workspace.clone();
|
||||
let preset_type = preset_type.clone();
|
||||
build_window(app, cfg, workspace, preset_type, no_classify);
|
||||
if let Some(win) = app.windows().first().cloned() {
|
||||
win.close();
|
||||
return;
|
||||
}
|
||||
build_window(app, cfg.clone(), workspace.clone(), preset_type.clone(), no_classify);
|
||||
});
|
||||
|
||||
let code = app.run_with_args::<String>(&[]);
|
||||
|
|
@ -475,12 +677,13 @@ fn build_window(
|
|||
return;
|
||||
}
|
||||
let note_type = selected_type.borrow().clone();
|
||||
|
||||
// Classify and save synchronously. Tier 1 + 2 finish in <100ms.
|
||||
// Tier 3 (Ollama) only fires for ambiguous inputs; the brief pause
|
||||
// is acceptable since the user has already committed the note.
|
||||
save_note_classified(&text, note_type, no_classify, cfg.clone(), workspace.clone());
|
||||
let cfg_c = cfg.clone();
|
||||
let ws_c = workspace.clone();
|
||||
// Close first so the popup disappears immediately, then save.
|
||||
win.close();
|
||||
glib::idle_add_local_once(move || {
|
||||
save_note_classified(&text, note_type, no_classify, cfg_c, ws_c);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -523,9 +726,12 @@ fn save_note_classified(
|
|||
let mut note = Note::new(text.into(), user_type.clone(), workspace);
|
||||
|
||||
if !no_classify {
|
||||
let mut classifier = Classifier::load(
|
||||
&cfg.model.execution_provider,
|
||||
init_ort_once(&cfg);
|
||||
let (model_path, tokenizer_path) = cfg.model.resolved_paths();
|
||||
let mut classifier = Classifier::load_with_paths(
|
||||
&cfg.reminders.default_morning,
|
||||
model_path,
|
||||
tokenizer_path,
|
||||
)
|
||||
.with_ollama(cfg.model.ollama.clone());
|
||||
let result = classifier.classify(text);
|
||||
|
|
@ -565,8 +771,12 @@ fn apply_css(_cfg: &Config) {
|
|||
|
||||
let provider = gtk4::CssProvider::new();
|
||||
provider.load_from_string(&css);
|
||||
let Some(display) = gtk4::gdk::Display::default() else {
|
||||
tracing::warn!("no default display; skipping CSS provider");
|
||||
return;
|
||||
};
|
||||
gtk4::style_context_add_provider_for_display(
|
||||
>k4::gdk::Display::default().unwrap(),
|
||||
&display,
|
||||
&provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
./target/release/breadpad
|
||||
102
svgs.txt
102
svgs.txt
|
|
@ -1,102 +0,0 @@
|
|||
# SVG Icons for breadman
|
||||
# Replace the placeholder emojis in breadman/src/main.rs and breadman/src/editor.rs
|
||||
# with SVG-backed gtk4::Image widgets once you have the files.
|
||||
# All icons should be single-color/symbolic so GTK can recolor them with CSS.
|
||||
# Recommended source: Lucide (https://lucide.dev), Phosphor, or Material Symbols.
|
||||
|
||||
## Sidebar — navigation items
|
||||
|
||||
all-notes.svg
|
||||
Placeholder: 📋
|
||||
Use: "All" view — a stack of pages or a grid of squares
|
||||
Lucide suggestion: layout-grid, files, or layers
|
||||
|
||||
calendar-clock.svg
|
||||
Placeholder: 📅
|
||||
Use: "Upcoming" view — calendar with a clock overlay
|
||||
Lucide suggestion: calendar-clock
|
||||
|
||||
checkbox.svg
|
||||
Placeholder: ✅
|
||||
Use: "Todo" type — empty or checked checkbox
|
||||
Lucide suggestion: square-check or check-square
|
||||
|
||||
bell.svg
|
||||
Placeholder: 🔔
|
||||
Use: "Reminder" type — bell icon
|
||||
Lucide suggestion: bell
|
||||
|
||||
lightbulb.svg
|
||||
Placeholder: 💡
|
||||
Use: "Idea" type — lightbulb
|
||||
Lucide suggestion: lightbulb
|
||||
|
||||
pencil-line.svg
|
||||
Placeholder: 📝
|
||||
Use: "Note" type — pencil writing on a line
|
||||
Lucide suggestion: pencil-line or file-text
|
||||
|
||||
circle-help.svg
|
||||
Placeholder: ❓
|
||||
Use: "Question" type — question mark in a circle
|
||||
Lucide suggestion: circle-help or help-circle
|
||||
|
||||
archive-box.svg
|
||||
Placeholder: 📦
|
||||
Use: "Archive" view — box with down-arrow or archive tray
|
||||
Lucide suggestion: archive or archive-restore
|
||||
|
||||
settings-gear.svg
|
||||
Placeholder: ⚙
|
||||
Use: "Settings" view — gear/cog
|
||||
Lucide suggestion: settings or settings-2
|
||||
|
||||
triangle-alert.svg
|
||||
Placeholder: ⚠
|
||||
Use: "Errors" view — triangle with exclamation mark
|
||||
Lucide suggestion: triangle-alert or alert-triangle
|
||||
|
||||
## Note card action buttons
|
||||
|
||||
check.svg
|
||||
Placeholder: ✓
|
||||
Use: "Mark done" action button on note cards
|
||||
Lucide suggestion: check or circle-check
|
||||
|
||||
pencil.svg
|
||||
Placeholder: ✎
|
||||
Use: "Edit" action button on note cards
|
||||
Lucide suggestion: pencil or pen
|
||||
|
||||
trash.svg
|
||||
Placeholder: 🗑
|
||||
Use: "Delete" action button on note cards and archive
|
||||
Lucide suggestion: trash-2
|
||||
|
||||
## Note card metadata badges
|
||||
|
||||
clock.svg
|
||||
Placeholder: ⏰ (used inline in label text)
|
||||
Use: Scheduled time indicator on note cards
|
||||
Lucide suggestion: clock or alarm-clock
|
||||
|
||||
repeat.svg
|
||||
Placeholder: ↻ (used as type-chip label)
|
||||
Use: Recurrence indicator on note cards
|
||||
Lucide suggestion: repeat or refresh-cw
|
||||
|
||||
## New Note button
|
||||
|
||||
plus.svg
|
||||
Placeholder: ✚ (used in "✚ New Note" button label)
|
||||
Use: New note creation button in sidebar
|
||||
Lucide suggestion: plus or plus-circle
|
||||
|
||||
## Notes on integration
|
||||
# When switching from emoji/text to SVG icons:
|
||||
# 1. Use gtk4::Image::from_file() or load via gtk4::IconTheme for theme-aware icons.
|
||||
# 2. For action buttons, replace the label with a gtk4::Image child:
|
||||
# let btn = gtk4::Button::new();
|
||||
# btn.set_child(Some(>k4::Image::from_file("path/to/icon.svg")));
|
||||
# 3. Symbolic icons (named with "-symbolic" suffix in icon theme) follow the
|
||||
# CSS color property automatically.
|
||||
Loading…
Add table
Add a link
Reference in a new issue