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