Compare commits

...

26 commits

Author SHA1 Message Date
Breadway
e0b55e1713 Bump bread-theme to v0.2.8 (live-reload fix)
Some checks failed
Mirror to GitHub / mirror (push) Successful in 2s
Build and publish package / package (push) Failing after 4m47s
2026-06-17 12:55:57 +08:00
Breadway
c30aa2497e Fix illegible text on light pywal palettes + hot-reload
Use bread-theme 0.2.7's luminance-picked ink (@on-*): type chips on @overlay and
selected sidebar rows / confirm buttons on @blue kept @fg or @bg, which vanished
when those slots came out light/dark. They now use @on-overlay / @on-accent.

Add breadpad_shared::theme::apply_live (wraps bread_theme::gtk::apply_app_css) so
breadpad and breadman recolour live on `bread-theme reload` and re-read the user's
style.css — replacing the build-once provider. bread-theme bumped to v0.2.7
(gtk feature).
2026-06-17 12:42:12 +08:00
Breadway
dfe19708ba Release 0.3.4: shared bread-theme stylesheet (overlay=color7)
Some checks failed
Mirror to GitHub / mirror (push) Successful in 2s
Build and publish package / package (push) Failing after 4m33s
2026-06-16 18:35:33 +08:00
Breadway
08d8956eac docs: add CalDAV calendar-sync walkthrough
The [calendar] config keys existed without explanation. Document enabling
CalDAV sync end to end: finding the collection URL, creating an app password,
the config block, and the best-effort sync behaviour.
2026-06-16 17:07:06 +08:00
Breadway
49f6966d9c theme: build on the shared bread-theme stylesheet
build_css() now starts from bread_theme::stylesheet(palette) and appends only
breadpad/breadman-specific components. This unifies fonts, palette, and generic
widgets with the rest of the ecosystem and fixes the colour mapping (overlay is
now color7, matching every other app, not color0). Bump bread-theme to v0.2.6.
2026-06-16 16:57:16 +08:00
Breadway
eab3775de1 Disable debug package so the main package publishes correctly
Some checks failed
Mirror to GitHub / mirror (push) Successful in 2s
Build and publish package / package (push) Failing after 3m53s
makepkg's debug split produced a -debug pkg; the upload's head -1 could
grab it instead of the main package. !debug yields a single package.
2026-06-13 23:00:52 +08:00
Breadway
9ae815caa2 Use REGISTRY_TOKEN (scoped write:package) for registry publish
Some checks failed
Mirror to GitHub / mirror (push) Successful in 2s
Build and publish package / package (push) Failing after 6m16s
2026-06-13 22:55:43 +08:00
Breadway
d4ae2dfed4 Disable LTO in PKGBUILD (vendored ring/mlua static libs vs makepkg -flto) 2026-06-13 17:06:56 +08:00
Breadway
d1aef21998 Clone from public URL, not GITHUB_SERVER_URL (resolves to localhost in runner)
The Forgejo runner injects GITHUB_SERVER_URL as http://localhost:3002, which
is unreachable from inside the job container. Use the public URL instead.
2026-06-13 16:14:15 +08:00
Breadway
ca95ac0693 Rename mirror secret to MIRROR_TOKEN (GITHUB_ prefix is reserved)
Forgejo/gitea rejects user secret names starting with GITHUB_.
2026-06-13 16:10:51 +08:00
Breadway
ce0b7740d6 Fix Forgejo workflows for the actual server capabilities
- package.yml: correct Arch registry upload (octet-stream + binary body),
  drop --privileged, manual shell clone (archlinux image has no Node),
  built-in Actions token, --nocheck
- mirror.yml: clone --mirror + explicit refs push with --prune
2026-06-13 16:02:30 +08:00
Breadway
659e3da5ed Add packaging/arch PKGBUILD and Forgejo Actions workflows
- packaging/arch/PKGBUILD: builds and publishes breadpad to [breadway] repo
- .forgejo/workflows/mirror.yml: mirrors every push/tag to GitHub
- .forgejo/workflows/package.yml: builds on tag, publishes to Forgejo registry

Requires FORGEJO_TOKEN and GITHUB_MIRROR_TOKEN secrets in Forgejo.
2026-06-13 12:12:43 +08:00
Breadway
596ae90455 fix: enable load-dynamic ORT feature for breadpad-test
Some checks failed
release / build (push) Failing after 2s
ort::init_from requires the load-dynamic feature; breadpad-test calls
it to load libonnxruntime.so from a runtime path.
2026-06-11 14:53:23 +08:00
Breadway
e21c5c4ad3 chore: update Cargo.lock for v0.3.1 2026-06-11 14:28:11 +08:00
Breadway
bf4586e608 chore: bump version to 0.3.1 2026-06-11 14:21:50 +08:00
Breadway
d7d8828477 fix: remove non-existent rocm-runtime dep, add optional_system_deps
rocm-runtime is not a real Arch package name. ORT links ONNX Runtime
statically and falls back to CPU — ROCm should not block install.
Required: gtk4, gtk4-layer-shell only.
Optional: rocm-hip-runtime (GPU inference), ollama (AI fallback), hyprland.
2026-06-11 13:38:35 +08:00
Breadway
8dbeacb46d feat: enable ROCm EP for GPU inference, add rocm-runtime system dep
Some checks failed
release / build (push) Failing after 5s
Switches ort from load-dynamic to rocm feature so the ROCm execution
provider is compiled in. Adds rocm-runtime to bakery system_deps so
bakery doctor/install can verify it's present.
2026-06-07 15:59:02 +08:00
Breadway
478d06a5d5 fix: skip ROCm EP registration when not available in ORT build
Some checks failed
release / build (push) Failing after 3s
Eliminates the spurious ERROR log from ORT when ROCm isn't compiled in.
Checks is_available() before attempting registration so the session
correctly falls back to CPU without noise.
2026-06-07 15:53:06 +08:00
Breadway
708eb8f3b4 fix: use relative symlink for latest to work inside Docker containers 2026-06-07 09:02:38 +08:00
Breadway
f04a87e476 fix: add contents: write permission for GitHub Release creation
Some checks failed
release / build (push) Failing after 5s
2026-06-07 00:00:52 +08:00
Breadway
fc3af84e5e fix: create GitHub Release before uploading artifacts 2026-06-06 23:52:54 +08:00
Breadway
2987e0373e fix: switch bread-theme to git dep (v0.1.0) for CI 2026-06-06 23:26:54 +08:00
Breadway
59be59f7d5 fix: add missing build deps for hestia (Ubuntu) runner 2026-06-06 23:20:07 +08:00
Breadway
66a8dc9a14 fix: use apt-get on hestia runner (Ubuntu, not Arch) 2026-06-06 22:48:03 +08:00
Breadway
9537a12537 Refactor theme onto bread-theme; add bakery.toml and release workflow
- breadpad-shared/Cargo.toml: depend on bread-theme (no gtk feature needed
  in the shared crate)
- breadpad-shared/src/theme.rs: re-export Palette and load_palette from
  bread-theme; retain all breadpad-specific CSS in build_css()
- bakery.toml: describes breadpad for bakery install
- release.yml: builds on hestia self-hosted runner, publishes binaries to
  dl.breadway.dev and GitHub Releases on v* tags
2026-06-06 22:31:38 +08:00
Breadway
c4626dd64d 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)
2026-06-06 12:25:40 +08:00
39 changed files with 3135 additions and 1117 deletions

View file

@ -0,0 +1,21 @@
name: Mirror to GitHub
on:
push:
branches: ['**']
tags: ['**']
jobs:
mirror:
runs-on: [self-hosted, hestia]
steps:
- name: Mirror to GitHub
run: |
set -euo pipefail
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
cd repo.git
# Mirror only branches and tags (not refs/pull/*, which GitHub rejects);
# --prune deletes GitHub refs that no longer exist on Forgejo.
git push --prune \
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadpad.git" \
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'

View file

@ -0,0 +1,40 @@
name: Build and publish package
on:
push:
tags: ['v*']
jobs:
package:
runs-on: [self-hosted, hestia]
container:
image: archlinux:latest
steps:
# Note: no actions/checkout — the archlinux image has no Node, which JS
# actions require. Everything runs as shell steps and clones manually.
- name: Build and publish
env:
PUBLISH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
set -euo pipefail
VERSION="${GITHUB_REF_NAME#v}"
pacman -Syu --noconfirm base-devel git rust cargo gtk4 gtk4-layer-shell
useradd -m builder
git config --global --add safe.directory '*'
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" /home/builder/src
cd /home/builder/src
git archive --format=tar.gz --prefix="breadpad-${VERSION}/" HEAD \
> packaging/arch/breadpad-${VERSION}.tar.gz
SHA=$(sha256sum packaging/arch/breadpad-${VERSION}.tar.gz | awk '{print $1}')
sed -i "s/^pkgver=.*/pkgver=${VERSION}/" packaging/arch/PKGBUILD
sed -i "s/^sha256sums=.*/sha256sums=('${SHA}')/" packaging/arch/PKGBUILD
chown -R builder:builder /home/builder/src
# --nocheck: packaging builds the artifact; tests belong in a CI job.
su builder -c "cd /home/builder/src/packaging/arch && makepkg -f --noconfirm --nocheck"
PKG=$(find /home/builder/src/packaging/arch -name '*.pkg.tar.zst' | head -1)
curl -fsS -X PUT \
-H "Authorization: token ${PUBLISH_TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${PKG}" \
"https://git.breadway.dev/api/packages/Breadway/arch/os"

66
.github/workflows/release.yml vendored Normal file
View file

@ -0,0 +1,66 @@
name: release
on:
push:
tags: ["v*"]
permissions:
contents: write
env:
DL_DIR: /srv/breadway-dl
ECOSYSTEM_DIR: /home/breadway/Projects/bread-ecosystem
jobs:
build:
runs-on: [self-hosted, hestia]
steps:
- uses: actions/checkout@v4
- name: install build deps
run: sudo apt-get install -y libgtk-4-dev libdbus-1-dev pkg-config 2>/dev/null || true
- name: build
run: cargo build --release --locked
- name: prepare artifacts
run: |
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="${DL_DIR}/breadpad/${VERSION}"
mkdir -p "${PKG_DIR}"
for bin in breadpad breadman; do
cp "target/release/${bin}" "${PKG_DIR}/${bin}-x86_64"
strip "${PKG_DIR}/${bin}-x86_64"
sha256sum "${PKG_DIR}/${bin}-x86_64" | awk '{print $1}' \
> "${PKG_DIR}/${bin}-x86_64.sha256"
done
cp breadpad.example.toml "${PKG_DIR}/"
cp bakery.toml "${PKG_DIR}/bakery.toml"
ln -sfn "${VERSION}" "${DL_DIR}/breadpad/latest"
- name: ensure bread-ecosystem
run: |
if [[ -d "${ECOSYSTEM_DIR}/.git" ]]; then
git -C "${ECOSYSTEM_DIR}" pull --ff-only
else
mkdir -p "$(dirname "${ECOSYSTEM_DIR}")"
git clone https://github.com/Breadway/bread-ecosystem.git "${ECOSYSTEM_DIR}"
fi
- name: regenerate index.json
run: bash "${ECOSYSTEM_DIR}/scripts/gen-index.sh"
- name: upload to GitHub Release
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
VERSION="${GITHUB_REF_NAME#v}"
PKG_DIR="${DL_DIR}/breadpad/${VERSION}"
gh release create "${GITHUB_REF_NAME}" \
--title "breadpad v${VERSION}" --generate-notes 2>/dev/null || true
gh release upload "${GITHUB_REF_NAME}" \
"${PKG_DIR}/breadpad-x86_64" \
"${PKG_DIR}/breadman-x86_64" \
"${PKG_DIR}/breadpad-x86_64.sha256" \
"${PKG_DIR}/breadman-x86_64.sha256" \
--clobber

28
.gitignore vendored
View file

@ -1 +1,29 @@
target/ target/
*.tgz
*.zip
breadpadcli
breadmancli
svgs.txt
# Editor & IDE
*.swp
*.swo
.DS_Store
.vscode/
.idea/
*.iml
# Environment
.env
.env.*
!.env.example
# Debug & Logs
*.log
*.pid
*.sock
*.pdb
# Rust/Cargo
Cargo.lock
dist/

342
Cargo.lock generated
View file

@ -259,12 +259,6 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bit-set" name = "bit-set"
version = "0.8.0" version = "0.8.0"
@ -282,9 +276,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.1" version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
@ -308,14 +302,26 @@ dependencies = [
"piper", "piper",
] ]
[[package]]
name = "bread-theme"
version = "0.2.3"
source = "git+https://github.com/Breadway/bread-ecosystem?tag=v0.2.8#77417d552130281ff787e07d52541eb25e9d533b"
dependencies = [
"dirs 5.0.1",
"gtk4",
"serde",
"serde_json",
]
[[package]] [[package]]
name = "breadman" name = "breadman"
version = "0.1.0" version = "0.3.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"breadpad-shared", "breadpad-shared",
"chrono", "chrono",
"dirs 5.0.1", "dirs 5.0.1",
"futures-channel",
"gtk4", "gtk4",
"serde", "serde",
"serde_json", "serde_json",
@ -326,7 +332,7 @@ dependencies = [
[[package]] [[package]]
name = "breadpad" name = "breadpad"
version = "0.1.0" version = "0.3.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"breadpad-shared", "breadpad-shared",
@ -335,6 +341,7 @@ dependencies = [
"gtk4", "gtk4",
"gtk4-layer-shell", "gtk4-layer-shell",
"hyprland", "hyprland",
"ort",
"serde", "serde",
"serde_json", "serde_json",
"tokio", "tokio",
@ -344,9 +351,10 @@ dependencies = [
[[package]] [[package]]
name = "breadpad-shared" name = "breadpad-shared"
version = "0.1.0" version = "0.3.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"bread-theme",
"chrono", "chrono",
"dirs 5.0.1", "dirs 5.0.1",
"ical", "ical",
@ -362,14 +370,14 @@ dependencies = [
"tokio", "tokio",
"toml 0.8.23", "toml 0.8.23",
"tracing", "tracing",
"ureq 2.12.1", "ureq",
"uuid", "uuid",
"zbus", "zbus",
] ]
[[package]] [[package]]
name = "breadpad-test" name = "breadpad-test"
version = "0.1.0" version = "0.3.4"
dependencies = [ dependencies = [
"anyhow", "anyhow",
"breadpad-shared", "breadpad-shared",
@ -377,6 +385,8 @@ dependencies = [
"clap", "clap",
"colored", "colored",
"comfy-table", "comfy-table",
"dirs 5.0.1",
"ort",
"serde", "serde",
"serde_json", "serde_json",
] ]
@ -433,9 +443,9 @@ dependencies = [
[[package]] [[package]]
name = "cc" name = "cc"
version = "1.2.62" version = "1.2.63"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
dependencies = [ dependencies = [
"find-msvc-tools", "find-msvc-tools",
"shlex", "shlex",
@ -443,9 +453,9 @@ dependencies = [
[[package]] [[package]]
name = "cfg-expr" name = "cfg-expr"
version = "0.20.7" version = "0.20.8"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368" checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c"
dependencies = [ dependencies = [
"smallvec", "smallvec",
"target-lexicon", "target-lexicon",
@ -465,9 +475,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]] [[package]]
name = "chrono" name = "chrono"
version = "0.4.44" version = "0.4.45"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
dependencies = [ dependencies = [
"iana-time-zone", "iana-time-zone",
"js-sys", "js-sys",
@ -568,9 +578,9 @@ dependencies = [
[[package]] [[package]]
name = "compact_str" name = "compact_str"
version = "0.9.0" version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a" checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
dependencies = [ dependencies = [
"castaway", "castaway",
"cfg-if", "cfg-if",
@ -612,16 +622,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 +748,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"
@ -866,9 +856,9 @@ dependencies = [
[[package]] [[package]]
name = "displaydoc" name = "displaydoc"
version = "0.2.5" version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
@ -1021,21 +1011,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,21 +1496,15 @@ 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.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
dependencies = [ dependencies = [
"bytes", "bytes",
"itoa", "itoa",
@ -1572,9 +1541,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]] [[package]]
name = "hyper" name = "hyper"
version = "1.9.0" version = "1.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
dependencies = [ dependencies = [
"atomic-waker", "atomic-waker",
"bytes", "bytes",
@ -1895,10 +1864,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
[[package]] [[package]]
name = "libredox" name = "libloading"
version = "0.1.16" version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
dependencies = [
"cfg-if",
"windows-link",
]
[[package]]
name = "libredox"
version = "0.1.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3"
dependencies = [ dependencies = [
"libc", "libc",
] ]
@ -1932,9 +1911,9 @@ dependencies = [
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.29" version = "0.4.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
[[package]] [[package]]
name = "lru-slab" name = "lru-slab"
@ -1942,12 +1921,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"
@ -1985,9 +1958,9 @@ dependencies = [
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.0" version = "2.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
[[package]] [[package]]
name = "memoffset" name = "memoffset"
@ -2016,9 +1989,9 @@ dependencies = [
[[package]] [[package]]
name = "mio" name = "mio"
version = "1.2.0" version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
dependencies = [ dependencies = [
"libc", "libc",
"wasi", "wasi",
@ -2047,23 +2020,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 +2127,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 +2149,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 +2161,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 +2236,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"
@ -2467,7 +2366,7 @@ version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [ dependencies = [
"toml_edit 0.25.11+spec-1.1.0", "toml_edit 0.25.12+spec-1.1.0",
] ]
[[package]] [[package]]
@ -2853,44 +2752,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"
@ -3003,9 +2870,9 @@ dependencies = [
[[package]] [[package]]
name = "shlex" name = "shlex"
version = "1.3.0" version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
[[package]] [[package]]
name = "signal-hook-registry" name = "signal-hook-registry"
@ -3043,9 +2910,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
[[package]] [[package]]
name = "socket2" name = "socket2"
version = "0.6.3" version = "0.6.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
dependencies = [ dependencies = [
"libc", "libc",
"windows-sys 0.61.2", "windows-sys 0.61.2",
@ -3144,9 +3011,9 @@ dependencies = [
[[package]] [[package]]
name = "target-lexicon" name = "target-lexicon"
version = "0.13.3" version = "0.13.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c" checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
[[package]] [[package]]
name = "tempfile" name = "tempfile"
@ -3369,9 +3236,9 @@ dependencies = [
[[package]] [[package]]
name = "toml_edit" name = "toml_edit"
version = "0.25.11+spec-1.1.0" version = "0.25.12+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
dependencies = [ dependencies = [
"indexmap", "indexmap",
"toml_datetime 1.1.1+spec-1.1.0", "toml_datetime 1.1.1+spec-1.1.0",
@ -3514,9 +3381,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
[[package]] [[package]]
name = "typenum" name = "typenum"
version = "1.20.0" version = "1.20.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
[[package]] [[package]]
name = "uds_windows" name = "uds_windows"
@ -3546,9 +3413,9 @@ dependencies = [
[[package]] [[package]]
name = "unicode-segmentation" name = "unicode-segmentation"
version = "1.13.2" version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
[[package]] [[package]]
name = "unicode-width" name = "unicode-width"
@ -3593,36 +3460,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 +3472,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"
@ -3655,9 +3486,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]] [[package]]
name = "uuid" name = "uuid"
version = "1.23.1" version = "1.23.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
dependencies = [ dependencies = [
"getrandom 0.4.2", "getrandom 0.4.2",
"js-sys", "js-sys",
@ -3670,12 +3501,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 +3655,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"
@ -4305,9 +4121,9 @@ checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
[[package]] [[package]]
name = "yoke" name = "yoke"
version = "0.8.2" version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
dependencies = [ dependencies = [
"stable_deref_trait", "stable_deref_trait",
"yoke-derive", "yoke-derive",
@ -4385,18 +4201,18 @@ dependencies = [
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.48" version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.48" version = "0.8.50"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",

View file

@ -8,7 +8,7 @@ members = [
resolver = "2" resolver = "2"
[workspace.package] [workspace.package]
version = "0.1.0" version = "0.3.4"
edition = "2021" edition = "2021"
license = "MIT" license = "MIT"
authors = ["Breadway"] authors = ["Breadway"]
@ -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", "rocm", "load-dynamic"] }
ndarray = "0.16" ndarray = "0.16"
tokenizers = { version = "0.21", default-features = false, features = ["http", "fancy-regex"] } tokenizers = { version = "0.21", default-features = false, features = ["http", "fancy-regex"] }
gtk4 = { version = "0.11", features = ["v4_12"] } gtk4 = { version = "0.11", features = ["v4_12"] }

21
LICENSE Normal file
View file

@ -0,0 +1,21 @@
MIT License
Copyright (c) 2024 Breadway
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View file

@ -67,7 +67,7 @@ User-defined tags can be added freely on top of the built-in types.
- Inline editing — click any card to edit body, type, time, or recurrence - Inline editing — click any card to edit body, type, time, or recurrence
- Mark todo/reminder as done; done items move to an archive accessible via a toggle - Mark todo/reminder as done; done items move to an archive accessible via a toggle
- Search across all notes (full-text, instant) - Search across all notes (full-text, instant)
- Sort by: newest, oldest, due time - Sort: newest first (default)
### Theming ### Theming
@ -83,8 +83,8 @@ User-defined tags can be added freely on top of the built-in types.
Notes are stored as JSONL at `~/.local/share/breadpad/notes.jsonl` — one JSON object per line, human-readable, easy to back up or script against. Notes are stored as JSONL at `~/.local/share/breadpad/notes.jsonl` — one JSON object per line, human-readable, easy to back up or script against.
```jsonl ```jsonl
{"id":"a1b2c3","body":"Pack calculator in bag","type":"reminder","time":"2026-05-25T19:00:00","rrule":null,"done":false,"workspace":"1","created":"2026-05-25T18:45:00","snoozed_until":null} {"id":"a1b2c3","body":"Pack calculator in bag","type":"reminder","time":"2026-05-25T19:00:00Z","rrule":null,"done":false,"workspace":"1","created":"2026-05-25T18:45:00Z","snoozed_until":null,"completed":null,"tags":[],"caldav_uid":null}
{"id":"d4e5f6","body":"Look into relm4 reactive patterns","type":"idea","time":null,"rrule":null,"done":false,"workspace":"2","created":"2026-05-25T14:10:00","snoozed_until":null} {"id":"d4e5f6","body":"Look into relm4 reactive patterns","type":"idea","time":null,"rrule":null,"done":false,"workspace":"2","created":"2026-05-25T14:10:00Z","snoozed_until":null,"completed":null,"tags":[],"caldav_uid":null}
``` ```
Completed notes are never deleted — they gain `"done": true` and a `"completed"` timestamp. A separate `~/.local/share/breadpad/archive.jsonl` is written periodically for notes older than 30 days. Completed notes are never deleted — they gain `"done": true` and a `"completed"` timestamp. A separate `~/.local/share/breadpad/archive.jsonl` is written periodically for notes older than 30 days.
@ -108,13 +108,7 @@ Returns a calibrated confidence. If ≥ 0.82, Tiers 2 and 3 are skipped.
Runs when Tier 1 confidence is below threshold. Responsible for **type classification only** — Tier 1's extracted time, recurrence rule, and cleaned body are always preserved. Runs when Tier 1 confidence is below threshold. Responsible for **type classification only** — Tier 1's extracted time, recurrence rule, and cleaned body are always preserved.
Invoked via `ort` (ONNX Runtime Rust bindings). Execution provider order: Invoked via `ort` (ONNX Runtime Rust bindings, `load-dynamic`) on the CPU. Requires an external `libonnxruntime.so`; set `model.ort_dylib_path` in `breadpad.toml` or let breadpad auto-discover it via `ORT_DYLIB_PATH`.
1. **QNN (Qualcomm/AMD XDNA NPU)** — requires `libQnnHtp.so` from the AMD Ryzen AI software stack
2. **Vulkan** — iGPU via the ONNX Runtime Vulkan EP
3. **CPU** — always available fallback
Active provider shown in `breadpad --status`.
#### Tier 3 — Large local model via Ollama #### Tier 3 — Large local model via Ollama
@ -129,11 +123,10 @@ If Ollama is unreachable or returns an invalid response, breadpad logs a warning
~/.local/share/breadpad/model/tokenizer.json ~/.local/share/breadpad/model/tokenizer.json
``` ```
breadpad ships without a bundled model. Run `breadpad download-model` to fetch a recommended quantised model, or drop your own ONNX model in the above path. breadpad ships without a bundled model. Drop a compatible ONNX classifier and `tokenizer.json` at those paths, then configure `model.ort_dylib_path` to point at your ONNX Runtime library.
```bash ```bash
breadpad download-model # fetches default model (~150 MB) breadpad model-info # shows active EP and model path
breadpad model-info # shows active EP, model path, last inference time
``` ```
--- ---
@ -144,9 +137,8 @@ breadpad model-info # shows active EP, model path, last inference time
- GTK4 (≥ 4.12) + `gtk4-layer-shell` - GTK4 (≥ 4.12) + `gtk4-layer-shell`
- D-Bus session bus (for notifications) - D-Bus session bus (for notifications)
- systemd user session (for timer-backed reminders) - systemd user session (for timer-backed reminders)
- Rust 1.77+ - Rust 1.80+
- ONNX Runtime (`ort` crate pulls this in automatically via the `download-binaries` feature) - **Tier 2 (ONNX classifier):** An external `libonnxruntime.so`. Set `model.ort_dylib_path` in `breadpad.toml`, or set `ORT_DYLIB_PATH` in your environment. Without a library, Tier 2 is disabled; Tier 1 + 3 still work.
- **NPU path only:** AMD Ryzen AI software stack (`amdxdna` kernel driver ≥ 6.11, QNN EP shared libs)
- **Tier 3 only (optional):** [Ollama](https://ollama.com) running locally with your chosen model pulled (`ollama pull llama3.2:3b`). Tier 3 is silently skipped if Ollama is not running. - **Tier 3 only (optional):** [Ollama](https://ollama.com) running locally with your chosen model pulled (`ollama pull llama3.2:3b`). Tier 3 is silently skipped if Ollama is not running.
--- ---
@ -160,8 +152,9 @@ cargo build --release
cp target/release/breadpad ~/.local/bin/ cp target/release/breadpad ~/.local/bin/
cp target/release/breadman ~/.local/bin/ cp target/release/breadman ~/.local/bin/
# Fetch the default classifier model # Place your ONNX classifier and tokenizer in the model directory
breadpad download-model mkdir -p ~/.local/share/breadpad/model
# Then set model.ort_dylib_path in breadpad.toml to your libonnxruntime.so
``` ```
On Arch Linux, install GTK4 dependencies first: On Arch Linux, install GTK4 dependencies first:
@ -186,7 +179,7 @@ archive_after_days = 30
[model] [model]
path = "~/.local/share/breadpad/model/classifier.onnx" path = "~/.local/share/breadpad/model/classifier.onnx"
tokenizer = "~/.local/share/breadpad/model/tokenizer.json" tokenizer = "~/.local/share/breadpad/model/tokenizer.json"
execution_provider = "auto" # auto | npu | vulkan | cpu ort_dylib_path = "" # optional: explicit path to libonnxruntime.so; auto-discovered when empty
[model.ollama] [model.ollama]
endpoint = "http://localhost:11434" endpoint = "http://localhost:11434"
@ -197,8 +190,41 @@ enabled = true # set false to never call Ollama
[reminders] [reminders]
default_morning = "08:00" # what "tomorrow morning" resolves to default_morning = "08:00" # what "tomorrow morning" resolves to
missed_grace_minutes = 60 # how long after boot to still fire a missed reminder missed_grace_minutes = 60 # how long after boot to still fire a missed reminder
[calendar]
enabled = false # turn on CalDAV sync (see below)
url = "" # CalDAV calendar collection URL
username = ""
password = "" # app password / token recommended
``` ```
### Calendar sync (CalDAV)
When `[calendar].enabled = true`, reminders and dated notes are pushed to a
CalDAV calendar as events (tracked by `caldav_uid` on each note), so they show
up alongside the rest of your calendar.
1. Find your calendar's **collection URL**. It's the per-calendar CalDAV path,
not the server root — e.g. Nextcloud:
`https://host/remote.php/dav/calendars/<user>/<calendar-id>/`.
2. Create an **app password** for breadpad (don't use your main password):
Nextcloud → Settings → Security → *Devices & sessions* → "Create new app
password". Most CalDAV servers have an equivalent.
3. Fill in `breadpad.toml` (or BOS Settings → breadpad → Calendar):
```toml
[calendar]
enabled = true
url = "https://host/remote.php/dav/calendars/me/breadpad/"
username = "me"
password = "xxxx-xxxx-xxxx-xxxx"
```
4. Restart breadpad. New dated/reminder notes sync up; the `caldav_uid` field
links each note to its event so updates and deletes stay in step.
If the server is unreachable, breadpad logs a warning and keeps the note
locally — sync is best-effort and never blocks capture.
--- ---
## Usage ## Usage

15
bakery.toml Normal file
View file

@ -0,0 +1,15 @@
name = "breadpad"
description = "Quick-capture scratchpad and note viewer with AI classification"
binaries = ["breadpad", "breadman"]
system_deps = ["gtk4", "gtk4-layer-shell"]
optional_system_deps = ["rocm-hip-runtime", "ollama", "hyprland"]
bread_deps = []
[config]
dir = "~/.config/breadpad"
example = "breadpad.example.toml"
[install]
post_install = [
"mkdir -p ~/.local/share/breadpad/model",
]

BIN
bread.zip

Binary file not shown.

View file

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

View file

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

View file

@ -4,7 +4,6 @@ use breadpad_shared::{
parser::parse_rule_based, parser::parse_rule_based,
scheduler::Scheduler, scheduler::Scheduler,
store::Store, store::Store,
theme::{build_css, load_palette},
types::{Note, NoteType, RecurrenceRule}, types::{Note, NoteType, RecurrenceRule},
}; };
use chrono::Local; use chrono::Local;
@ -107,6 +106,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 +139,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 +241,9 @@ fn cmd_upcoming_plain() -> Result<()> {
&& n.effective_time().is_some() && n.effective_time().is_some()
}) })
.collect(); .collect();
notes.sort_by_key(|n| n.effective_time().unwrap()); notes.sort_by_key(|n| n.effective_time().expect("filtered by is_some above"));
for note in &notes { for note in &notes {
let t = note.effective_time().unwrap(); let t = note.effective_time().expect("filtered by is_some above");
let local: chrono::DateTime<Local> = t.into(); let local: chrono::DateTime<Local> = t.into();
println!("[{}] {}{}", note.id, local.format("%a %b %d %H:%M"), note.body); println!("[{}] {}{}", note.id, local.format("%a %b %d %H:%M"), note.body);
} }
@ -272,10 +305,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 +382,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 +434,8 @@ fn build_app_window(
.filter(|n| n.body.to_lowercase().contains(&q)) .filter(|n| n.body.to_lowercase().contains(&q))
.collect() .collect()
}; };
// Only replace the "all" page — other views keep their scroll position.
// Replace the "all" page with the filtered list while preserving others rebuild_all_view(&filtered, &state_c);
while let Some(child) = state_c.stack.first_child() {
state_c.stack.remove(&child);
}
let all_scroll = build_note_list(&filtered, state_c.clone());
state_c.stack.add_named(&all_scroll, Some("all"));
let notes_snap = state_c.notes.borrow().clone();
let cfg_snap = state_c.cfg.borrow().clone();
let errors_snap = state_c.errors.borrow().clone();
let upcoming = views::upcoming::build(&notes_snap);
state_c.stack.add_named(&upcoming, Some("upcoming"));
for type_name in NoteType::all_builtin() {
let nt = NoteType::from_str(type_name);
let typed: Vec<Note> = notes_snap
.iter()
.filter(|n| n.note_type == nt && !n.done)
.cloned()
.collect();
state_c.stack.add_named(&build_note_list(&typed, state_c.clone()), Some(type_name));
}
state_c.stack.add_named(&views::archive::build(&notes_snap, state_c.clone()), Some("archive"));
let state_s = state_c.clone();
state_c.stack.add_named(
&views::settings::build(&cfg_snap, move |nc| { *state_s.cfg.borrow_mut() = nc; }),
Some("settings"),
);
state_c.stack.add_named(&views::errors::build(&errors_snap), Some("errors"));
state_c.stack.set_visible_child_name("all"); state_c.stack.set_visible_child_name("all");
}); });
} }
@ -475,9 +480,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 +510,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 +597,30 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
let card_c = card.clone(); let card_c = card.clone();
let state_c = state.clone(); let state_c = state.clone();
done_btn.connect_clicked(move |_| { done_btn.connect_clicked(move |_| {
if let Ok(Some(mut n)) = state_c.store.get_by_id(&note_id) { card_c.set_visible(false); // optimistic hide
let store = state_c.write_store();
let id = note_id.clone();
let state = state_c.clone();
spawn_bg(
move || -> anyhow::Result<Vec<Note>> {
if let Some(mut n) = store.get_by_id(&id)? {
n.mark_done(); n.mark_done();
if let Err(e) = state_c.store.update_note(&n) { store.update_note(&n)?;
state_c.log_error(format!("mark done failed: {}", e));
} }
store.load_all()
},
move |result| {
match result {
Ok(fresh) => {
*state.notes.borrow_mut() = fresh;
rebuild_stack(&state);
let active = state.active_view.borrow().clone();
state.stack.set_visible_child_name(&active);
} }
card_c.set_visible(false); Err(e) => state.log_error(format!("mark done failed: {}", e)),
state_c.reload_notes(); }
},
);
}); });
} }
bottom_row.append(&done_btn); bottom_row.append(&done_btn);
@ -622,19 +645,29 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
let body_label_save = body_label_c.clone(); let body_label_save = body_label_c.clone();
let state_del = state_c.clone(); let state_del = state_c.clone();
let card_del = card_c.clone(); let card_del = card_c.clone();
let state_err = state_c.clone();
let popover = editor::build_editor_popover( let popover = editor::build_editor_popover(
&note_c, &note_c,
store, store,
morning, morning,
move |updated: Note| { Rc::new(move |updated: Note| {
body_label_save.set_label(&updated.body); body_label_save.set_label(&updated.body);
state_save.reload_notes(); state_save.reload_notes();
}, rebuild_stack(&state_save);
move || { let active = state_save.active_view.borrow().clone();
state_save.stack.set_visible_child_name(&active);
}),
Rc::new(move || {
card_del.set_visible(false); card_del.set_visible(false);
state_del.reload_notes(); state_del.reload_notes();
}, rebuild_stack(&state_del);
let active = state_del.active_view.borrow().clone();
state_del.stack.set_visible_child_name(&active);
}),
Rc::new(move |e: String| {
state_err.log_error(e);
}),
); );
popover.set_parent(btn); popover.set_parent(btn);
popover.popup(); popover.popup();
@ -659,12 +692,30 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
delete_btn.connect_clicked(move |_| { delete_btn.connect_clicked(move |_| {
if *confirming.borrow() { if *confirming.borrow() {
card_c.set_visible(false); // optimistic hide
let store = state_c.write_store(); let store = state_c.write_store();
if let Err(e) = store.delete_note(&note_id) { let id = note_id.clone();
state_c.log_error(format!("delete failed: {}", e)); let state = state_c.clone();
spawn_bg(
move || -> anyhow::Result<Vec<Note>> {
store.delete_note(&id)?;
if let Err(e) = Scheduler::cancel(&id) {
tracing::warn!("failed to cancel timer for {}: {}", id, e);
} }
card_c.set_visible(false); store.load_all()
state_c.reload_notes(); },
move |result| {
match result {
Ok(fresh) => {
*state.notes.borrow_mut() = fresh;
rebuild_stack(&state);
let active = state.active_view.borrow().clone();
state.stack.set_visible_child_name(&active);
}
Err(e) => state.log_error(format!("delete failed: {}", e)),
}
},
);
} else { } else {
*confirming.borrow_mut() = true; *confirming.borrow_mut() = true;
btn_c.set_label("Sure?"); btn_c.set_label("Sure?");
@ -779,108 +830,88 @@ fn show_add_note_window(parent: &gtk4::ApplicationWindow, state: AppState) {
cancel_btn.connect_clicked(move |_| win_c.close()); cancel_btn.connect_clicked(move |_| win_c.close());
} }
// Add Note // Shared add-note logic — called by both the button and the Enter key.
{ let do_add: Rc<dyn Fn()> = Rc::new({
let win_c = win.clone(); let win = win.clone();
let state_c = state.clone(); let state = state.clone();
let body_c = body_entry.clone(); let body_entry = body_entry.clone();
let time_c = time_entry.clone(); let time_entry = time_entry.clone();
let rrule_c = rrule_entry.clone(); let rrule_entry = rrule_entry.clone();
let sel_c = selected_type.clone(); let selected_type = selected_type.clone();
let status_c = status_label.clone(); let status_label = status_label.clone();
let do_add = move || { move || {
let body_text = body_c.text().to_string(); let body_text = body_entry.text().to_string();
if body_text.trim().is_empty() { if body_text.trim().is_empty() {
status_c.set_label("Body is required."); status_label.set_label("Body is required.");
return; return;
} }
let morning = state_c.cfg.borrow().reminders.default_morning.clone(); let morning = state.cfg.borrow().reminders.default_morning.clone();
// Tier 1 classification on body
let parsed = parse_rule_based(&body_text, &morning); let parsed = parse_rule_based(&body_text, &morning);
let user_type = selected_type.borrow().clone();
let user_type = sel_c.borrow().clone(); let default_type = NoteType::from_str(&state.cfg.borrow().settings.default_type);
let default_type = NoteType::from_str(&state_c.cfg.borrow().settings.default_type);
let mut note = Note::new(parsed.body.clone(), user_type.clone(), None); let mut note = Note::new(parsed.body.clone(), user_type.clone(), None);
// Use parsed type if user left it at the default
if user_type == default_type { if user_type == default_type {
note.note_type = parsed.note_type; note.note_type = parsed.note_type;
} }
note.time = parsed.time; note.time = parsed.time;
note.rrule = parsed.rrule; note.rrule = parsed.rrule;
// Time field overrides let time_str = time_entry.text().to_string();
let time_str = time_c.text().to_string();
if !time_str.trim().is_empty() { if !time_str.trim().is_empty() {
let tp = parse_rule_based(&time_str, &morning); let tp = parse_rule_based(&time_str, &morning);
if tp.time.is_some() { note.time = tp.time; } if tp.time.is_some() { note.time = tp.time; }
if tp.rrule.is_some() { note.rrule = tp.rrule; } if tp.rrule.is_some() { note.rrule = tp.rrule; }
} }
// RRULE field overrides let rrule_str = rrule_entry.text().to_string();
let rrule_str = rrule_c.text().to_string();
if !rrule_str.trim().is_empty() { if !rrule_str.trim().is_empty() {
note.rrule = Some(RecurrenceRule::new(rrule_str)); note.rrule = Some(RecurrenceRule::new(rrule_str));
} }
let store = state_c.write_store(); let store = state.write_store();
if let Err(e) = store.save_note(&note) { win.close();
state_c.log_error(format!("save failed: {}", e));
return;
}
if note.time.is_some() {
if let Err(e) = Scheduler::schedule(&note) {
state_c.log_error(format!("schedule failed: {}", e));
}
}
win_c.close(); let state_bg = state.clone();
// Defer refresh so the window close event is processed first spawn_bg(
let state_refresh = state_c.clone(); move || -> anyhow::Result<Vec<Note>> {
glib::idle_add_local_once(move || refresh(&state_refresh)); store.save_note(&note)?;
}; if note.time.is_some() || note.rrule.is_some() {
if let Err(e) = Scheduler::cancel(&note.id) {
tracing::warn!("cancel before schedule: {}", e);
}
Scheduler::schedule(&note)?;
}
store.load_all()
},
move |result| {
match result {
Ok(fresh) => {
*state_bg.notes.borrow_mut() = fresh;
rebuild_stack(&state_bg);
let active = state_bg.active_view.borrow().clone();
state_bg.stack.set_visible_child_name(&active);
}
Err(e) => state_bg.log_error(format!("save failed: {}", e)),
}
},
);
}
});
{
let do_add = Rc::clone(&do_add);
add_btn.connect_clicked(move |_| do_add()); add_btn.connect_clicked(move |_| do_add());
} }
// Also trigger add on Enter in body field
{ {
let win_c2 = win.clone(); let do_add = Rc::clone(&do_add);
let state_c2 = state.clone(); let time_entry = time_entry.clone();
let body_c2 = body_entry.clone(); let rrule_entry = rrule_entry.clone();
let time_c2 = time_entry.clone();
let rrule_c2 = rrule_entry.clone();
let sel_c2 = selected_type.clone();
body_entry.connect_activate(move |_| { body_entry.connect_activate(move |_| {
// If time/rrule fields are empty, submit immediately if time_entry.text().is_empty() && rrule_entry.text().is_empty() {
if time_c2.text().is_empty() && rrule_c2.text().is_empty() { do_add();
let body_text = body_c2.text().to_string();
if body_text.trim().is_empty() { return; }
let morning = state_c2.cfg.borrow().reminders.default_morning.clone();
let parsed = parse_rule_based(&body_text, &morning);
let user_type = sel_c2.borrow().clone();
let default_type = NoteType::from_str(&state_c2.cfg.borrow().settings.default_type);
let mut note = Note::new(parsed.body.clone(), user_type.clone(), None);
if user_type == default_type { note.note_type = parsed.note_type; }
note.time = parsed.time;
note.rrule = parsed.rrule;
let store = state_c2.write_store();
if let Err(e) = store.save_note(&note) {
state_c2.log_error(format!("save failed: {}", e));
return;
}
if note.time.is_some() {
if let Err(e) = Scheduler::schedule(&note) {
state_c2.log_error(format!("schedule failed: {}", e));
}
}
win_c2.close();
let sr = state_c2.clone();
glib::idle_add_local_once(move || refresh(&sr));
} }
}); });
} }
@ -892,15 +923,7 @@ fn show_add_note_window(parent: &gtk4::ApplicationWindow, state: AppState) {
// ── CSS ─────────────────────────────────────────────────────────────────────── // ── CSS ───────────────────────────────────────────────────────────────────────
fn apply_css(_cfg: &Config) { fn apply_css(_cfg: &Config) {
let palette = load_palette(); // Hot-reloads on `bread-theme reload` (recolours to the new pywal palette
let user_css = std::fs::read_to_string(breadpad_shared::config::style_css_path()).ok(); // and re-reads the user's style.css). See breadpad_shared::theme::apply_live.
let css = build_css(&palette, user_css.as_deref()); breadpad_shared::theme::apply_live();
let provider = gtk4::CssProvider::new();
provider.load_from_string(&css);
gtk4::style_context_add_provider_for_display(
&gtk4::gdk::Display::default().unwrap(),
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
} }

View file

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

View file

@ -5,7 +5,9 @@ edition.workspace = true
license.workspace = true license.workspace = true
authors.workspace = true authors.workspace = true
[dependencies] [dependencies]
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] }
anyhow.workspace = true anyhow.workspace = true
tracing.workspace = true tracing.workspace = true
serde.workspace = true serde.workspace = true

View file

@ -70,10 +70,9 @@ impl OllamaClient {
.into_json() .into_json()
.map_err(|e| anyhow::anyhow!("deserialize Ollama envelope: {}", e))?; .map_err(|e| anyhow::anyhow!("deserialize Ollama envelope: {}", e))?;
let classification: OllamaClassification = serde_json::from_str(&ollama_resp.response) let classification: OllamaClassification = extract_json(&ollama_resp.response)
.map_err(|e| anyhow::anyhow!( .ok_or_else(|| anyhow::anyhow!(
"parse Ollama classification JSON: {} — raw: {:?}", "no JSON object found in response — raw: {:?}",
e,
&ollama_resp.response &ollama_resp.response
))?; ))?;
@ -116,3 +115,12 @@ impl OllamaClient {
}) })
} }
} }
// Some backends (e.g. FastFlowLM) ignore `"format": "json"` and may wrap the
// JSON in prose. Find the first `{...}` span and parse that.
fn extract_json<T: serde::de::DeserializeOwned>(s: &str) -> Option<T> {
let start = s.find('{')?;
let end = s.rfind('}')?;
if end < start { return None; }
serde_json::from_str(&s[start..=end]).ok()
}

View file

@ -16,9 +16,14 @@ pub struct CalDavEventInfo {
impl CalDavClient { impl CalDavClient {
pub fn new(config: CalendarConfig) -> Self { pub fn new(config: CalendarConfig) -> Self {
// `reqwest::Client::builder().build()` can only fail if the TLS backend can't be
// initialised; fall back to `Client::new()` semantics rather than panicking.
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.build() .build()
.expect("failed to build HTTP client"); .unwrap_or_else(|e| {
tracing::warn!("falling back to default HTTP client: {}", e);
reqwest::Client::new()
});
CalDavClient { config, client } CalDavClient { config, client }
} }
@ -139,28 +144,56 @@ fn event_url(base: &str, uid: &str) -> String {
fn build_ical(note: &Note, uid: &str) -> String { fn build_ical(note: &Note, uid: &str) -> String {
let dt = note.time.unwrap_or(note.created); let dt = note.time.unwrap_or(note.created);
let dtstart = dt.format("%Y%m%dT%H%M%SZ").to_string(); let dtstart = dt.format("%Y%m%dT%H%M%SZ").to_string();
let summary = escape_ical(&note.body); let dtstamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
let description = escape_ical(&format!("type={}", note.note_type.as_str()));
let mut ical = format!( let mut lines: Vec<String> = vec![
"BEGIN:VCALENDAR\r\n\ "BEGIN:VCALENDAR".into(),
VERSION:2.0\r\n\ "VERSION:2.0".into(),
PRODID:-//breadpad//EN\r\n\ "PRODID:-//breadpad//EN".into(),
BEGIN:VEVENT\r\n\ "BEGIN:VEVENT".into(),
UID:{uid}\r\n\ format!("UID:{}", uid),
SUMMARY:{summary}\r\n\ fold_line(&format!("SUMMARY:{}", escape_ical(&note.body))),
DTSTART:{dtstart}\r\n\ format!("DTSTART:{}", dtstart),
DTEND:{dtstart}\r\n\ format!("DTEND:{}", dtstart),
DESCRIPTION:{description}\r\n" format!("DTSTAMP:{}", dtstamp),
); fold_line(&format!("DESCRIPTION:{}", escape_ical(&format!("type={}", note.note_type.as_str())))),
];
if let Some(rrule) = &note.rrule { if let Some(rrule) = &note.rrule {
ical.push_str(rrule.as_str()); lines.push(rrule.as_str().to_string());
ical.push_str("\r\n");
} }
ical.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n"); lines.push("END:VEVENT".into());
ical lines.push("END:VCALENDAR".into());
lines.join("\r\n") + "\r\n"
}
/// Fold an iCal property line per RFC 5545 §3.1: lines longer than 75 octets
/// are split with CRLF + a single space continuation character.
fn fold_line(line: &str) -> String {
let bytes = line.as_bytes();
if bytes.len() <= 75 {
return line.to_string();
}
let mut out = String::with_capacity(line.len() + line.len() / 75 * 3);
let mut pos = 0;
let mut first = true;
while pos < bytes.len() {
if !first {
out.push_str("\r\n ");
}
let limit = if first { 75 } else { 74 }; // continuation lines lose 1 octet to the space
let mut end = (pos + limit).min(bytes.len());
// Step back if we landed in the middle of a multi-byte UTF-8 sequence.
while end > pos && end < bytes.len() && (bytes[end] & 0xC0) == 0x80 {
end -= 1;
}
out.push_str(std::str::from_utf8(&bytes[pos..end]).unwrap_or(""));
pos = end;
first = false;
}
out
} }
fn escape_ical(s: &str) -> String { fn escape_ical(s: &str) -> String {
@ -219,3 +252,241 @@ fn parse_ical_block(data: &str) -> Vec<CalDavEventInfo> {
out out
} }
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Note, NoteType, RecurrenceRule};
use chrono::{TimeZone, Utc};
fn reminder(body: &str) -> Note {
let mut n = Note::new(body.into(), NoteType::Reminder, None);
n.time = Some(Utc::now());
n
}
// ---- escape_ical ----
#[test]
fn escape_ical_clean_string_unchanged() {
assert_eq!(escape_ical("hello world"), "hello world");
}
#[test]
fn escape_ical_empty_string() {
assert_eq!(escape_ical(""), "");
}
#[test]
fn escape_ical_escapes_backslash() {
assert_eq!(escape_ical("back\\slash"), "back\\\\slash");
}
#[test]
fn escape_ical_escapes_semicolon() {
assert_eq!(escape_ical("a;b"), "a\\;b");
}
#[test]
fn escape_ical_escapes_comma() {
assert_eq!(escape_ical("apples,oranges"), "apples\\,oranges");
}
#[test]
fn escape_ical_escapes_newline() {
assert_eq!(escape_ical("line1\nline2"), "line1\\nline2");
}
#[test]
fn escape_ical_multiple_special_chars() {
assert_eq!(escape_ical("a;b,c\nd"), "a\\;b\\,c\\nd");
}
// ---- caldav_uid ----
#[test]
fn caldav_uid_uses_existing_field() {
let mut n = Note::new("test".into(), NoteType::Reminder, None);
n.caldav_uid = Some("my-custom-uid".into());
assert_eq!(caldav_uid(&n), "my-custom-uid");
}
#[test]
fn caldav_uid_falls_back_to_id_at_breadpad() {
let n = Note::new("test".into(), NoteType::Reminder, None);
assert_eq!(caldav_uid(&n), format!("{}@breadpad", n.id));
}
// ---- event_url ----
#[test]
fn event_url_with_trailing_slash() {
let url = event_url("https://cloud.example.com/cal/", "abc@breadpad");
assert_eq!(url, "https://cloud.example.com/cal/abc@breadpad.ics");
}
#[test]
fn event_url_without_trailing_slash() {
let url = event_url("https://cloud.example.com/cal", "abc@breadpad");
assert_eq!(url, "https://cloud.example.com/cal/abc@breadpad.ics");
}
// ---- build_ical ----
#[test]
fn build_ical_contains_vcalendar_markers() {
let n = reminder("team sync");
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("BEGIN:VCALENDAR"), "missing BEGIN:VCALENDAR");
assert!(ical.contains("END:VCALENDAR"), "missing END:VCALENDAR");
assert!(ical.contains("BEGIN:VEVENT"), "missing BEGIN:VEVENT");
assert!(ical.contains("END:VEVENT"), "missing END:VEVENT");
}
#[test]
fn build_ical_contains_uid() {
let n = reminder("team sync");
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains(&format!("UID:{}", uid)));
}
#[test]
fn build_ical_contains_summary() {
let n = reminder("team sync");
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("SUMMARY:team sync"));
}
#[test]
fn build_ical_description_contains_type() {
let n = reminder("team sync");
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("DESCRIPTION:type=reminder"));
}
#[test]
fn build_ical_uses_note_time_for_dtstart() {
let mut n = Note::new("dentist".into(), NoteType::Reminder, None);
n.time = Some(Utc.with_ymd_and_hms(2026, 6, 15, 14, 30, 0).unwrap());
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("DTSTART:20260615T143000Z"), "ical: {}", &ical[..400]);
}
#[test]
fn build_ical_falls_back_to_created_when_no_time() {
let n = Note::new("no time set".into(), NoteType::Reminder, None);
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("DTSTART:"), "DTSTART should be present");
}
#[test]
fn build_ical_includes_rrule_when_set() {
let mut n = reminder("standup");
n.rrule = Some(RecurrenceRule::new("RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0"));
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=MO"));
}
#[test]
fn build_ical_no_rrule_when_not_set() {
let n = reminder("one-off");
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(!ical.contains("RRULE:"));
}
#[test]
fn build_ical_escapes_special_chars_in_summary() {
let n = Note::new("dentist; bring card, and ID".into(), NoteType::Reminder, None);
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("SUMMARY:dentist\\; bring card\\, and ID"), "ical: {}", &ical[..400]);
}
#[test]
fn build_ical_contains_dtstamp() {
let n = reminder("team sync");
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
assert!(ical.contains("DTSTAMP:"), "missing DTSTAMP in:\n{}", ical);
}
#[test]
fn fold_line_short_unchanged() {
let line = "SUMMARY:short";
assert_eq!(fold_line(line), line);
}
#[test]
fn fold_line_exactly_75_unchanged() {
let line = "A".repeat(75);
assert_eq!(fold_line(&line), line);
}
#[test]
fn fold_line_76_chars_splits() {
let line = "X".repeat(76);
let folded = fold_line(&line);
assert!(folded.contains("\r\n "), "expected fold in: {:?}", folded);
// Reassembled content should equal the original.
let rejoined: String = folded.split("\r\n ").collect();
assert_eq!(rejoined, line);
}
#[test]
fn build_ical_long_summary_is_folded() {
let long_body = "a".repeat(200);
let n = Note::new(long_body.clone(), NoteType::Reminder, None);
let uid = caldav_uid(&n);
let ical = build_ical(&n, &uid);
// Every line (split on CRLF) must be at most 75 octets.
for line in ical.split("\r\n") {
assert!(
line.len() <= 75,
"line too long ({} octets): {:?}",
line.len(),
line
);
}
}
// ---- parse_report_response ----
#[test]
fn parse_report_response_empty_xml_returns_empty() {
let events = parse_report_response("").unwrap();
assert!(events.is_empty());
}
#[test]
fn parse_report_response_single_event() {
let xml = "\
BEGIN:VCALENDAR\r\n\
VERSION:2.0\r\n\
BEGIN:VEVENT\r\n\
UID:abc123@breadpad\r\n\
SUMMARY:team sync\r\n\
DTSTART:20260615T140000Z\r\n\
DTEND:20260615T140000Z\r\n\
END:VEVENT\r\n\
END:VCALENDAR\r\n";
let events = parse_report_response(xml).unwrap();
assert_eq!(events.len(), 1);
assert_eq!(events[0].uid, "abc123@breadpad");
assert_eq!(events[0].summary, "team sync");
}
#[test]
fn parse_report_response_no_vcalendar_block_returns_empty() {
let xml = "<multistatus><response><status>HTTP/1.1 200 OK</status></response></multistatus>";
let events = parse_report_response(xml).unwrap();
assert!(events.is_empty());
}
}

View file

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

View file

@ -11,15 +11,29 @@ fn default_snooze_options() -> Vec<String> {
fn default_archive_after_days() -> i64 { 30 } fn default_archive_after_days() -> i64 { 30 }
fn default_model_path() -> String { "~/.local/share/breadpad/model/classifier.onnx".into() } fn default_model_path() -> String { "~/.local/share/breadpad/model/classifier.onnx".into() }
fn default_tokenizer_path() -> String { "~/.local/share/breadpad/model/tokenizer.json".into() } fn default_tokenizer_path() -> String { "~/.local/share/breadpad/model/tokenizer.json".into() }
fn default_execution_provider() -> String { "auto".into() } fn default_ort_dylib_path() -> String { "".into() }
fn default_morning_time() -> String { "08:00".into() } fn default_morning_time() -> String { "08:00".into() }
fn default_missed_grace_minutes() -> i64 { 60 } fn default_missed_grace_minutes() -> i64 { 60 }
fn default_ollama_endpoint() -> String { "http://localhost:11434".into() } fn default_ollama_endpoint() -> String { "http://localhost:11434".into() }
fn default_ollama_model() -> String { "llama3.2:3b".into() } fn default_ollama_model() -> String { "fastflowlm".into() }
fn default_ollama_confidence_threshold() -> f32 { 0.6 } fn default_ollama_confidence_threshold() -> f32 { 0.6 }
fn default_ollama_enabled() -> bool { true } fn default_ollama_enabled() -> bool { true }
fn default_calendar_enabled() -> bool { false } fn default_calendar_enabled() -> bool { false }
pub fn expand_path(path: &str) -> PathBuf {
if path == "~" {
if let Some(home) = dirs::home_dir() {
return home;
}
}
if let Some(stripped) = path.strip_prefix("~/") {
if let Some(home) = dirs::home_dir() {
return home.join(stripped);
}
}
PathBuf::from(path)
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings { pub struct Settings {
#[serde(default = "default_type_str")] #[serde(default = "default_type_str")]
@ -72,8 +86,9 @@ pub struct ModelConfig {
pub path: String, pub path: String,
#[serde(default = "default_tokenizer_path")] #[serde(default = "default_tokenizer_path")]
pub tokenizer: String, pub tokenizer: String,
#[serde(default = "default_execution_provider")] /// Path to `libonnxruntime.so`. Auto-discovered when empty.
pub execution_provider: String, #[serde(default = "default_ort_dylib_path")]
pub ort_dylib_path: String,
#[serde(default)] #[serde(default)]
pub ollama: OllamaConfig, pub ollama: OllamaConfig,
} }
@ -83,12 +98,26 @@ impl Default for ModelConfig {
ModelConfig { ModelConfig {
path: default_model_path(), path: default_model_path(),
tokenizer: default_tokenizer_path(), tokenizer: default_tokenizer_path(),
execution_provider: default_execution_provider(), ort_dylib_path: default_ort_dylib_path(),
ollama: OllamaConfig::default(), ollama: OllamaConfig::default(),
} }
} }
} }
impl ModelConfig {
pub fn resolved_paths(&self) -> (PathBuf, PathBuf) {
(expand_path(&self.path), expand_path(&self.tokenizer))
}
pub fn resolved_ort_dylib_path(&self) -> Option<PathBuf> {
let raw = self.ort_dylib_path.trim();
if raw.is_empty() {
return None;
}
Some(expand_path(raw))
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemindersConfig { pub struct RemindersConfig {
#[serde(default = "default_morning_time")] #[serde(default = "default_morning_time")]
@ -114,6 +143,9 @@ pub struct CalendarConfig {
pub url: String, pub url: String,
#[serde(default)] #[serde(default)]
pub username: String, pub username: String,
/// WARNING: stored as plaintext in breadpad.toml. Restrict the file's permissions
/// (`chmod 600 ~/.config/breadpad/breadpad.toml`) and keep it out of version control.
/// A future release may support reading the password from the OS secret service instead.
#[serde(default)] #[serde(default)]
pub password: String, pub password: String,
} }

View file

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

View file

@ -1,4 +1,5 @@
use crate::types::{ClassificationResult, NoteType, RecurrenceRule}; use crate::types::{ClassificationResult, NoteType, RecurrenceRule};
use crate::util::local_naive_to_utc;
use chrono::{DateTime, Datelike, Duration, Local, NaiveTime, Timelike, Utc, Weekday}; use chrono::{DateTime, Datelike, Duration, Local, NaiveTime, Timelike, Utc, Weekday};
use regex::Regex; use regex::Regex;
use std::sync::OnceLock; use std::sync::OnceLock;
@ -22,7 +23,7 @@ static PATTERNS: OnceLock<Patterns> = OnceLock::new();
fn patterns() -> &'static Patterns { fn patterns() -> &'static Patterns {
PATTERNS.get_or_init(|| Patterns { PATTERNS.get_or_init(|| Patterns {
at_time: Regex::new(r"(?i)\bat\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?").unwrap(), at_time: Regex::new(r"(?i)\bat\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?").unwrap(),
in_duration: Regex::new(r"(?i)\bin\s+(\d+)\s+(minute|hour|day)s?").unwrap(), in_duration: Regex::new(r"(?i)\bin\s+(\d+)\s+(second|minute|hour|day|week)s?").unwrap(),
// Word-form durations: "in an hour", "in a couple of hours", "in half an hour" // Word-form durations: "in an hour", "in a couple of hours", "in half an hour"
in_duration_word: Regex::new( in_duration_word: Regex::new(
r"(?i)\bin\s+(?:an?\s+hour|a\s+couple\s+of\s+hours?|a\s+few\s+hours?|half\s+an?\s+hour|an?\s+minutes?|a\s+couple\s+of\s+minutes?)" r"(?i)\bin\s+(?:an?\s+hour|a\s+couple\s+of\s+hours?|a\s+few\s+hours?|half\s+an?\s+hour|an?\s+minutes?|a\s+couple\s+of\s+minutes?)"
@ -100,7 +101,7 @@ fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime<Utc> {
}; };
let target_date = local.date_naive() + Duration::days(days_ahead); let target_date = local.date_naive() + Duration::days(days_ahead);
let naive = target_date.and_time(time); let naive = target_date.and_time(time);
naive.and_local_timezone(Local).unwrap().with_timezone(&Utc) local_naive_to_utc(naive)
} }
pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResult { pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResult {
@ -209,7 +210,7 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
} else { } else {
(local.date_naive() + Duration::days(1)).and_time(t) (local.date_naive() + Duration::days(1)).and_time(t)
}; };
extracted_time = Some(naive.and_local_timezone(Local).unwrap().with_timezone(&Utc)); extracted_time = Some(local_naive_to_utc(naive));
let full_match = caps.get(0).unwrap().as_str(); let full_match = caps.get(0).unwrap().as_str();
cleaned = cleaned.replacen(full_match, "", 1).trim().to_string(); cleaned = cleaned.replacen(full_match, "", 1).trim().to_string();
} }
@ -218,9 +219,11 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
let n: i64 = caps.get(1).unwrap().as_str().parse().unwrap_or(1); let n: i64 = caps.get(1).unwrap().as_str().parse().unwrap_or(1);
let unit = caps.get(2).unwrap().as_str().to_lowercase(); let unit = caps.get(2).unwrap().as_str().to_lowercase();
let delta = match unit.as_str() { let delta = match unit.as_str() {
"second" => Duration::seconds(n),
"minute" => Duration::minutes(n), "minute" => Duration::minutes(n),
"hour" => Duration::hours(n), "hour" => Duration::hours(n),
"day" => Duration::days(n), "day" => Duration::days(n),
"week" => Duration::weeks(n),
_ => Duration::minutes(n), _ => Duration::minutes(n),
}; };
extracted_time = Some(Utc::now() + delta); extracted_time = Some(Utc::now() + delta);
@ -254,7 +257,7 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
}; };
let local = Local::now(); let local = Local::now();
let target = (local.date_naive() + Duration::days(1)).and_time(t); let target = (local.date_naive() + Duration::days(1)).and_time(t);
extracted_time = Some(target.and_local_timezone(Local).unwrap().with_timezone(&Utc)); extracted_time = Some(local_naive_to_utc(target));
cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string(); cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
} }
// One-off: next <weekday> // One-off: next <weekday>
@ -273,7 +276,7 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
} else { } else {
(local.date_naive() + Duration::days(1)).and_time(anchor) (local.date_naive() + Duration::days(1)).and_time(anchor)
}; };
extracted_time = Some(target.and_local_timezone(Local).unwrap().with_timezone(&Utc)); extracted_time = Some(local_naive_to_utc(target));
cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string(); cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
} }
} }
@ -860,6 +863,23 @@ fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType {
|| lower.starts_with("finish ") || lower.starts_with("finish ")
|| lower.starts_with("write ") || lower.starts_with("write ")
|| lower.starts_with("update ") || lower.starts_with("update ")
|| lower.starts_with("prepare ")
|| lower.starts_with("schedule ")
|| lower.starts_with("organize ")
|| lower.starts_with("deploy ")
|| lower.starts_with("install ")
|| lower.starts_with("send ")
|| lower.starts_with("submit ")
|| lower.starts_with("create ")
|| lower.starts_with("setup ")
|| lower.starts_with("restore ")
|| lower.starts_with("archive ")
|| lower.starts_with("export ")
|| lower.starts_with("import ")
|| lower.starts_with("approve ")
|| lower.starts_with("configure ")
|| lower.starts_with("refactor ")
|| lower.starts_with("review ")
{ {
return NoteType::Todo; return NoteType::Todo;
} }
@ -867,13 +887,21 @@ fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType {
|| lower.starts_with("idea:") || lower.starts_with("idea:")
|| lower.contains("could ") || lower.contains("could ")
|| lower.contains("maybe ") || lower.contains("maybe ")
|| lower.contains("should we ") || lower.starts_with("should we ")
{ {
return NoteType::Idea; return NoteType::Idea;
} }
if lower.starts_with("why ") if lower.starts_with("why ")
|| lower.starts_with("how ") || lower.starts_with("how ")
|| lower.starts_with("what ") || (lower.starts_with("what ") && !lower.starts_with("what if "))
|| lower.starts_with("when ")
|| lower.starts_with("where ")
|| lower.starts_with("who ")
|| lower.starts_with("will ")
|| lower.starts_with("is ")
|| lower.starts_with("are ")
|| lower.starts_with("did ")
|| lower.starts_with("does ")
|| lower.ends_with('?') || lower.ends_with('?')
{ {
return NoteType::Question; return NoteType::Question;

View file

@ -1,4 +1,5 @@
use crate::types::Note; use crate::types::Note;
use crate::util::local_naive_to_utc;
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use chrono::{DateTime, Duration, Local, NaiveTime, Utc}; use chrono::{DateTime, Duration, Local, NaiveTime, Utc};
use std::process::Command; use std::process::Command;
@ -59,27 +60,63 @@ fn create_timer(id: &str, fire_time: DateTime<Utc>) -> Result<()> {
let timer_name = timer_unit_name(id); let timer_name = timer_unit_name(id);
// Find the breadpad binary. Order of preference:
// 1. $BREADPAD_BIN override,
// 2. a `breadpad` next to the currently running executable,
// 3. standard install locations.
let breadpad_exe = std::env::var_os("BREADPAD_BIN")
.map(std::path::PathBuf::from)
.filter(|p| p.exists())
.or_else(|| {
std::env::current_exe()
.ok()
.and_then(|exe| exe.parent().map(|p| p.join("breadpad")))
.filter(|p| p.exists())
})
.or_else(|| {
let home_bin = dirs::home_dir().map(|h| h.join(".local/bin/breadpad"));
["/usr/local/bin/breadpad", "/usr/bin/breadpad"]
.iter()
.map(std::path::PathBuf::from)
.chain(home_bin)
.find(|p| p.exists())
})
.context("breadpad binary not found in $BREADPAD_BIN, alongside this executable, or in standard locations")?;
// Use systemd-run to create both service + timer as a transient unit // Use systemd-run to create both service + timer as a transient unit
let status = Command::new("systemd-run") // Pass necessary environment variables for notifications to work
.arg("--user") let mut cmd = Command::new("systemd-run");
cmd.arg("--user")
.arg("--unit") .arg("--unit")
.arg(&timer_name.strip_suffix(".timer").unwrap_or(&timer_name)) .arg(timer_name.strip_suffix(".timer").unwrap_or(&timer_name))
.arg("--timer-property") .arg("--timer-property")
.arg(format!("OnCalendar={}", on_calendar)) .arg(format!("OnCalendar={}", on_calendar))
.arg("--timer-property") .arg("--timer-property")
.arg("Persistent=true") .arg("Persistent=true");
.arg("--")
.arg("breadpad") // Pass DBUS and display environment variables so notify-send works
if let Ok(dbus) = std::env::var("DBUS_SESSION_BUS_ADDRESS") {
cmd.arg("--setenv").arg(format!("DBUS_SESSION_BUS_ADDRESS={}", dbus));
}
if let Ok(display) = std::env::var("DISPLAY") {
cmd.arg("--setenv").arg(format!("DISPLAY={}", display));
}
if let Ok(wayland) = std::env::var("WAYLAND_DISPLAY") {
cmd.arg("--setenv").arg(format!("WAYLAND_DISPLAY={}", wayland));
}
cmd.arg("--")
.arg(&breadpad_exe)
.arg("fire") .arg("fire")
.arg(id) .arg(id);
.status()
.context("failed to run systemd-run")?; let status = cmd.status().context("failed to run systemd-run")?;
if !status.success() { if !status.success() {
anyhow::bail!("systemd-run failed for reminder {}", id); anyhow::bail!("systemd-run failed for reminder {}", id);
} }
tracing::info!("scheduled reminder {} at {}", id, on_calendar); tracing::info!("scheduled reminder {} at {} using {}", id, on_calendar, breadpad_exe.display());
Ok(()) Ok(())
} }
@ -131,17 +168,15 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O
let now = Local::now(); let now = Local::now();
let fire_time = NaiveTime::from_hms_opt(hour, minute, 0)?; let fire_time = NaiveTime::from_hms_opt(hour, minute, 0)?;
let next = match freq { match freq {
"DAILY" => { "DAILY" => {
let today = now.date_naive().and_time(fire_time); let today = now.date_naive().and_time(fire_time);
if now.naive_local() < today { let naive = if now.naive_local() < today {
today.and_local_timezone(Local).unwrap() today
} else { } else {
(now.date_naive() + chrono::Duration::days(1)) (now.date_naive() + chrono::Duration::days(1)).and_time(fire_time)
.and_time(fire_time) };
.and_local_timezone(Local) return Some(local_naive_to_utc(naive));
.unwrap()
}
} }
"WEEKLY" => { "WEEKLY" => {
use chrono::Datelike; use chrono::Datelike;
@ -169,12 +204,10 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O
}; };
let target_date = let target_date =
(now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time); (now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time);
target_date.and_local_timezone(Local).unwrap() return Some(local_naive_to_utc(target_date));
}
_ => None,
} }
_ => return None,
};
Some(next.with_timezone(&Utc))
} }
#[cfg(test)] #[cfg(test)]
@ -331,6 +364,21 @@ mod tests {
assert_eq!(local.weekday(), chrono::Weekday::Sat); assert_eq!(local.weekday(), chrono::Weekday::Sat);
} }
#[test]
fn weekly_tuesday_is_tuesday() {
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=TU;BYHOUR=10;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
let local: chrono::DateTime<Local> = t.into();
assert_eq!(local.weekday(), chrono::Weekday::Tue);
}
#[test]
fn weekly_thursday_is_thursday() {
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=TH;BYHOUR=11;BYMINUTE=30;BYSECOND=0", "08:00").unwrap();
let local: chrono::DateTime<Local> = t.into();
assert_eq!(local.weekday(), chrono::Weekday::Thu);
assert_eq!(local.minute(), 30);
}
#[test] #[test]
fn weekly_sunday_is_sunday() { fn weekly_sunday_is_sunday() {
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=SU;BYHOUR=19;BYMINUTE=0;BYSECOND=0", "08:00").unwrap(); let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=SU;BYHOUR=19;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
@ -338,6 +386,22 @@ mod tests {
assert_eq!(local.weekday(), chrono::Weekday::Sun); assert_eq!(local.weekday(), chrono::Weekday::Sun);
} }
#[test]
fn weekly_unknown_byday_falls_back_to_sunday() {
// The match arm `_ => Weekday::Sun` handles unrecognised BYDAY values
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=XX;BYHOUR=9;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
let local: chrono::DateTime<Local> = t.into();
assert_eq!(local.weekday(), chrono::Weekday::Sun);
}
#[test]
fn daily_without_byhour_uses_default_morning() {
let t = parse_next_from_rrule("RRULE:FREQ=DAILY", "06:45").unwrap();
let local: chrono::DateTime<Local> = t.into();
assert_eq!(local.hour(), 6);
assert_eq!(local.minute(), 45);
}
#[test] #[test]
fn unknown_freq_returns_none() { fn unknown_freq_returns_none() {
assert!(parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none()); assert!(parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none());

View file

@ -36,6 +36,14 @@ impl Store {
self self
} }
pub fn with_calendar_if_enabled(self, cfg: &crate::config::Config) -> Self {
if cfg.calendar.enabled {
self.with_calendar(cfg.calendar.clone())
} else {
self
}
}
pub fn load_all(&self) -> Result<Vec<Note>> { pub fn load_all(&self) -> Result<Vec<Note>> {
self.load_from(&self.notes_path) self.load_from(&self.notes_path)
} }
@ -84,12 +92,14 @@ impl Store {
pub fn update_note(&self, updated: &Note) -> Result<()> { pub fn update_note(&self, updated: &Note) -> Result<()> {
self.rewrite_notes(|note| { self.rewrite_notes(|note| {
if note.id == updated.id { if note.id == updated.id { updated.clone() } else { note }
updated.clone() })?;
} else { if let Some(cal_cfg) = &self.calendar {
note if cal_cfg.enabled && (updated.time.is_some() || updated.rrule.is_some()) {
spawn_caldav_push(updated.clone(), cal_cfg.clone());
} }
}) }
Ok(())
} }
pub fn delete_note(&self, id: &str) -> Result<()> { pub fn delete_note(&self, id: &str) -> Result<()> {

View file

@ -1,202 +1,118 @@
use serde::Deserialize; pub use bread_theme::{load_palette, Palette};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone)] /// Apply breadpad/breadman's stylesheet and keep it live across palette changes.
pub struct Palette { /// [`build_css`] bundles the shared component sheet with the app's own rules from
pub background: String, /// the current pywal palette; `bread_theme::gtk::apply_app_css` re-runs this
pub foreground: String, /// whenever `bread-theme reload` rewrites the shared theme file, so the UI
pub color0: String, /// recolours in place (and re-reads the user's `style.css` override too).
pub color1: String, pub fn apply_live() {
pub color2: String, bread_theme::gtk::apply_app_css(|| {
pub color3: String, let palette = load_palette();
pub color4: String, let user_css = std::fs::read_to_string(crate::config::style_css_path()).ok();
pub color5: String, build_css(&palette, user_css.as_deref())
pub color6: String, });
pub color7: String,
} }
// Catppuccin Mocha fallback /// Generate the full breadpad/breadman CSS string. The base — `@define-color`
impl Default for Palette { /// palette, fonts, and generic widget styling — comes from the shared
fn default() -> Self { /// `bread_theme::stylesheet`, so breadpad and breadman look identical to the
Palette { /// rest of the ecosystem. Only breadpad-specific component rules are appended.
background: "#1e1e2e".into(),
foreground: "#cdd6f4".into(),
color0: "#45475a".into(),
color1: "#f38ba8".into(),
color2: "#a6e3a1".into(),
color3: "#f9e2af".into(),
color4: "#89b4fa".into(),
color5: "#f5c2e7".into(),
color6: "#94e2d5".into(),
color7: "#bac2de".into(),
}
}
}
#[derive(Debug, Deserialize)]
struct WalColors {
#[serde(default)]
colors: HashMap<String, String>,
special: Option<WalSpecial>,
}
#[derive(Debug, Deserialize)]
struct WalSpecial {
background: Option<String>,
foreground: Option<String>,
}
pub(crate) fn palette_from_wal_json(json: &str) -> Option<Palette> {
let wal: WalColors = serde_json::from_str(json).ok()?;
Some(Palette {
background: wal.special.as_ref().and_then(|s| s.background.clone()).unwrap_or_else(|| "#1e1e2e".into()),
foreground: wal.special.as_ref().and_then(|s| s.foreground.clone()).unwrap_or_else(|| "#cdd6f4".into()),
color0: wal.colors.get("color0").cloned().unwrap_or_else(|| "#45475a".into()),
color1: wal.colors.get("color1").cloned().unwrap_or_else(|| "#f38ba8".into()),
color2: wal.colors.get("color2").cloned().unwrap_or_else(|| "#a6e3a1".into()),
color3: wal.colors.get("color3").cloned().unwrap_or_else(|| "#f9e2af".into()),
color4: wal.colors.get("color4").cloned().unwrap_or_else(|| "#89b4fa".into()),
color5: wal.colors.get("color5").cloned().unwrap_or_else(|| "#f5c2e7".into()),
color6: wal.colors.get("color6").cloned().unwrap_or_else(|| "#94e2d5".into()),
color7: wal.colors.get("color7").cloned().unwrap_or_else(|| "#bac2de".into()),
})
}
pub fn load_palette() -> Palette {
let wal_path = wal_colors_path();
if !wal_path.exists() {
return Palette::default();
}
match std::fs::read_to_string(&wal_path)
.ok()
.and_then(|s| palette_from_wal_json(&s))
{
Some(wal) => wal,
None => Palette::default(),
}
}
pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String { pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String {
let mut css = format!( // Shared ecosystem base (define-colors incl. accent, font, buttons, entries,
// switches, lists, cards, scrollbars). `overlay` here is color7 — consistent
// with every other bread app (breadpad previously mapped it to color0).
let mut css = bread_theme::stylesheet(palette);
css.push_str(
r#" r#"
@define-color bg {bg}; /* breadpad/breadman-specific components */
@define-color fg {fg}; window { border-radius: 8px; }
@define-color red {c1};
@define-color green {c2};
@define-color yellow {c3};
@define-color blue {c4};
@define-color pink {c5};
@define-color teal {c6};
@define-color overlay {c0};
window {{ .popup-entry {
background-color: @bg;
color: @fg;
border-radius: 12px;
}}
.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;
}} }
.popup-entry:focus {{ .popup-entry:focus {
outline: none; outline: none;
border-color: @teal; border-color: @teal;
}} }
.type-chip {{ .type-chip {
background: @overlay; background: @overlay;
color: @fg; color: @on-overlay;
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 {
background: @blue; background: @blue;
color: @bg; color: @on-accent;
}} }
.confirm-button {{ .confirm-button {
background: @green; background: @blue;
color: @bg; color: @on-accent;
border: none; border: none;
border-radius: 8px; border-radius: 8px;
padding: 8px 16px; padding: 8px 16px;
font-weight: bold; font-weight: bold;
}} }
.note-card {{ .note-card {
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;
}} }
.note-card:hover {{ .note-card:hover {
background: shade(@bg, 1.2); background: shade(@bg, 1.2);
}} }
.search-entry {{ .search-entry {
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;
}}
"#,
bg = palette.background,
fg = palette.foreground,
c0 = palette.color0,
c1 = palette.color1,
c2 = palette.color2,
c3 = palette.color3,
c4 = palette.color4,
c5 = palette.color5,
c6 = palette.color6,
);
css.push_str(r#"
.dim-label {
color: alpha(@fg, 0.5);
font-size: 12px;
} }
.sidebar { .search-entry:focus {
background: shade(@bg, 0.93); border-color: @blue;
outline: none;
} }
.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: @on-accent;
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,19 +144,61 @@ 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; }
entry { .reminder-window {
background: shade(@bg, 1.1); background: @bg;
color: @fg;
border: 1px solid @overlay; border: 1px solid @overlay;
border-radius: 6px; border-radius: 8px;
caret-color: @fg;
padding: 5px 10px;
} }
entry:focus { .reminder-emoji { font-size: 20px; }
border-color: @blue;
outline: none; .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); }
"#); "#);
if let Some(extra) = user_css { if let Some(extra) = user_css {
@ -251,125 +209,21 @@ entry:focus {
css css
} }
fn wal_colors_path() -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("~/.cache"))
.join("wal")
.join("colors.json")
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
const TOKYO_NIGHT_WAL: &str = r##"{
"special": {
"background": "#1a1b26",
"foreground": "#c0caf5"
},
"colors": {
"color0": "#15161e",
"color1": "#f7768e",
"color2": "#9ece6a",
"color3": "#e0af68",
"color4": "#7aa2f7",
"color5": "#bb9af7",
"color6": "#7dcfff",
"color7": "#a9b1d6"
}
}"##;
// ---- Default palette (Catppuccin Mocha) ----
#[test]
fn default_background_is_catppuccin_mocha() {
assert_eq!(Palette::default().background, "#1e1e2e");
}
#[test]
fn default_foreground_is_catppuccin_mocha() {
assert_eq!(Palette::default().foreground, "#cdd6f4");
}
#[test]
fn default_red_is_catppuccin_mocha() {
assert_eq!(Palette::default().color1, "#f38ba8");
}
#[test]
fn default_blue_is_catppuccin_mocha() {
assert_eq!(Palette::default().color4, "#89b4fa");
}
#[test]
fn default_teal_is_catppuccin_mocha() {
assert_eq!(Palette::default().color6, "#94e2d5");
}
// ---- palette_from_wal_json ----
#[test]
fn wal_json_parses_special_background() {
let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap();
assert_eq!(p.background, "#1a1b26");
}
#[test]
fn wal_json_parses_special_foreground() {
let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap();
assert_eq!(p.foreground, "#c0caf5");
}
#[test]
fn wal_json_parses_numbered_colors() {
let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap();
assert_eq!(p.color0, "#15161e");
assert_eq!(p.color1, "#f7768e");
assert_eq!(p.color4, "#7aa2f7");
assert_eq!(p.color7, "#a9b1d6");
}
#[test]
fn wal_json_missing_special_falls_back_to_defaults() {
let json = r##"{"colors":{"color0":"#000000"}}"##;
let p = palette_from_wal_json(json).unwrap();
assert_eq!(p.background, "#1e1e2e");
assert_eq!(p.foreground, "#cdd6f4");
}
#[test]
fn wal_json_missing_color_falls_back_to_default() {
let json = r##"{"special":{"background":"#ff0000","foreground":"#ffffff"},"colors":{}}"##;
let p = palette_from_wal_json(json).unwrap();
assert_eq!(p.color4, "#89b4fa"); // default blue
}
#[test]
fn invalid_wal_json_returns_none() {
assert!(palette_from_wal_json("not json").is_none());
assert!(palette_from_wal_json("").is_none());
assert!(palette_from_wal_json("{}").is_some()); // empty but valid → all defaults
}
// ---- build_css ----
#[test] #[test]
fn css_defines_bg_color() { fn css_defines_bg_color() {
let css = build_css(&Palette::default(), None); let css = build_css(&Palette::default(), None);
assert!(css.contains("@define-color bg #1e1e2e"), "css missing bg: {}", &css[..300]); assert!(css.contains("@define-color bg #1e1e2e"), "css missing bg: {}", &css[..300]);
} }
#[test]
fn css_defines_fg_color() {
let css = build_css(&Palette::default(), None);
assert!(css.contains("@define-color fg #cdd6f4"));
}
#[test] #[test]
fn css_defines_all_named_colors() { fn css_defines_all_named_colors() {
let css = build_css(&Palette::default(), None); let css = build_css(&Palette::default(), None);
for name in &["red", "green", "yellow", "blue", "pink", "teal", "overlay"] { for name in &["red", "green", "yellow", "blue", "pink", "teal", "overlay"] {
assert!(css.contains(&format!("@define-color {} ", name)), "missing @define-color {}", name); assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
} }
} }
@ -392,18 +246,6 @@ mod tests {
assert!(css.contains(".note-card {")); assert!(css.contains(".note-card {"));
} }
#[test]
fn css_contains_type_chip_class() {
let css = build_css(&Palette::default(), None);
assert!(css.contains(".type-chip {"));
}
#[test]
fn css_contains_sidebar_row_class() {
let css = build_css(&Palette::default(), None);
assert!(css.contains(".sidebar-row {"));
}
#[test] #[test]
fn css_appends_user_css() { fn css_appends_user_css() {
let user = ".my-override { color: hotpink; }"; let user = ".my-override { color: hotpink; }";
@ -426,22 +268,4 @@ mod tests {
assert!(css.contains("@define-color bg #deadbe"), "css: {}", &css[..300]); assert!(css.contains("@define-color bg #deadbe"), "css: {}", &css[..300]);
assert!(css.contains("@define-color blue #cafe00"), "css: {}", &css[..300]); assert!(css.contains("@define-color blue #cafe00"), "css: {}", &css[..300]);
} }
#[test]
fn css_from_wal_palette_uses_wal_colors() {
let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap();
let css = build_css(&p, None);
assert!(css.contains("@define-color bg #1a1b26"), "css: {}", &css[..300]);
assert!(css.contains("@define-color fg #c0caf5"));
}
#[test]
fn load_palette_returns_valid_palette() {
// No wal file in CI/test env; should return non-empty strings starting with #
let palette = load_palette();
assert!(!palette.background.is_empty());
assert!(palette.background.starts_with('#'), "bg: {}", palette.background);
assert!(!palette.foreground.is_empty());
assert!(palette.color4.starts_with('#'));
}
} }

View file

@ -72,7 +72,9 @@ pub struct Note {
pub done: bool, pub done: bool,
pub workspace: Option<String>, pub workspace: Option<String>,
pub created: DateTime<Utc>, pub created: DateTime<Utc>,
#[serde(default)]
pub snoozed_until: Option<DateTime<Utc>>, pub snoozed_until: Option<DateTime<Utc>>,
#[serde(default)]
pub completed: Option<DateTime<Utc>>, pub completed: Option<DateTime<Utc>>,
#[serde(default)] #[serde(default)]
pub tags: Vec<String>, pub tags: Vec<String>,
@ -83,10 +85,14 @@ pub struct Note {
impl Note { impl Note {
pub fn new(body: String, note_type: NoteType, workspace: Option<String>) -> Self { pub fn new(body: String, note_type: NoteType, workspace: Option<String>) -> Self {
Note { Note {
// 12 hex chars (~48 bits) keeps IDs short and human-typable while making
// collisions vanishingly unlikely — important because update/delete/get_by_id
// all match notes purely by this id.
id: uuid::Uuid::new_v4() id: uuid::Uuid::new_v4()
.simple()
.to_string() .to_string()
.chars() .chars()
.take(6) .take(12)
.collect(), .collect(),
body, body,
note_type, note_type,
@ -250,10 +256,15 @@ mod tests {
} }
#[test] #[test]
fn note_id_is_six_chars() { fn note_id_is_twelve_chars() {
for _ in 0..50 { for _ in 0..50 {
let note = Note::new("x".into(), NoteType::Note, None); let note = Note::new("x".into(), NoteType::Note, None);
assert_eq!(note.id.len(), 6, "id '{}' is not 6 chars", note.id); assert_eq!(note.id.len(), 12, "id '{}' is not 12 chars", note.id);
assert!(
note.id.chars().all(|c| c.is_ascii_hexdigit()),
"id '{}' is not all hex",
note.id
);
} }
} }

View file

@ -0,0 +1,63 @@
use chrono::{DateTime, Duration, Local, LocalResult, NaiveDateTime, TimeZone, Utc};
/// Resolve a naive *local* datetime to UTC without panicking on DST transitions.
///
/// `NaiveDateTime::and_local_timezone` (and `Local.from_local_datetime`) returns a
/// `LocalResult`, which is not always `Single`:
/// - `Single` — the normal case.
/// - `Ambiguous` (a fall-back hour that occurs twice) — pick the earliest instant.
/// - `None` (a spring-forward gap where the wall-clock time never happens) — advance
/// an hour at a time until a valid instant is found, then fall back to treating the
/// naive value as UTC.
///
/// Calling `.unwrap()` on the `None`/`Ambiguous` cases panics, which is what this helper
/// exists to avoid (it bit us on the ~2 DST transition days per year).
pub fn local_naive_to_utc(naive: NaiveDateTime) -> DateTime<Utc> {
match Local.from_local_datetime(&naive) {
LocalResult::Single(dt) => dt.with_timezone(&Utc),
LocalResult::Ambiguous(earliest, _latest) => earliest.with_timezone(&Utc),
LocalResult::None => {
let mut shifted = naive;
for _ in 0..3 {
shifted += Duration::hours(1);
if let LocalResult::Single(dt) = Local.from_local_datetime(&shifted) {
return dt.with_timezone(&Utc);
}
}
// Last resort: interpret the wall-clock value as UTC so we still return a time.
Utc.from_utc_datetime(&naive)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::NaiveDate;
#[test]
fn ordinary_time_round_trips() {
let naive = NaiveDate::from_ymd_opt(2026, 6, 15)
.unwrap()
.and_hms_opt(9, 30, 0)
.unwrap();
let utc = local_naive_to_utc(naive);
// Converting back to local should yield the same wall-clock time.
let local: DateTime<Local> = utc.with_timezone(&Local);
assert_eq!(local.naive_local(), naive);
}
#[test]
fn never_panics_across_a_full_year_of_hours() {
// Walk every hour of a year through the helper; it must never panic regardless
// of the host timezone's DST rules.
let mut dt = NaiveDate::from_ymd_opt(2026, 1, 1)
.unwrap()
.and_hms_opt(0, 0, 0)
.unwrap();
for _ in 0..(366 * 24) {
let _ = local_naive_to_utc(dt);
dt += Duration::hours(1);
}
}
}

View file

@ -1,16 +1,27 @@
use breadpad_shared::classifier::Classifier; use breadpad_shared::classifier::{Classifier, ExecutionProvider};
use breadpad_shared::types::NoteType; use breadpad_shared::types::NoteType;
use chrono::Timelike; use chrono::Timelike;
fn cl() -> Classifier { fn cl() -> Classifier {
Classifier::load("auto", "08:00") Classifier::load("08:00")
} }
#[test] #[test]
fn active_provider_is_cpu() { fn active_provider_is_valid() {
// QNN and Vulkan EPs are not compiled in; CPU is always the fallback. // The active provider depends on the host: a machine with the ONNX model present and
// a working ROCm iGPU loads `Gpu`, otherwise `Cpu`. Either is valid — but when no
// model is available we must be on CPU (no session => no GPU EP in use).
let c = cl(); let c = cl();
assert_eq!(c.active_provider, breadpad_shared::classifier::ExecutionProvider::Cpu); assert!(matches!(
c.active_provider,
ExecutionProvider::Cpu | ExecutionProvider::Gpu
));
if !c.model_available() {
assert!(
matches!(c.active_provider, ExecutionProvider::Cpu),
"no model loaded but provider was not CPU"
);
}
} }
#[test] #[test]
@ -63,7 +74,7 @@ fn classify_recurrence_via_fallback() {
#[test] #[test]
fn classify_custom_morning_time() { fn classify_custom_morning_time() {
let mut c = Classifier::load("auto", "07:15"); let mut c = Classifier::load("07:15");
let r = c.classify("sync tomorrow morning"); let r = c.classify("sync tomorrow morning");
let t = r.time.expect("should have a time for tomorrow morning"); let t = r.time.expect("should have a time for tomorrow morning");
let local: chrono::DateTime<chrono::Local> = t.into(); let local: chrono::DateTime<chrono::Local> = t.into();
@ -71,6 +82,41 @@ fn classify_custom_morning_time() {
assert_eq!(local.minute(), 15); assert_eq!(local.minute(), 15);
} }
#[test]
fn classify_empty_string_does_not_panic() {
let mut c = cl();
let _ = c.classify("");
}
#[test]
fn classify_whitespace_only_does_not_panic() {
let mut c = cl();
let _ = c.classify(" ");
}
#[test]
fn classify_in_duration_sets_time() {
let mut c = cl();
let r = c.classify("take a break in 30 minutes");
assert!(r.time.is_some(), "should have a time for 'in 30 minutes'");
assert_eq!(r.note_type, NoteType::Reminder);
}
#[test]
fn classify_tomorrow_sets_time() {
let mut c = cl();
let r = c.classify("submit the invoice tomorrow");
assert!(r.time.is_some(), "tomorrow should produce a scheduled time");
}
#[test]
fn classify_returns_cleaned_body() {
let mut c = cl();
let r = c.classify("call mum at 6pm");
assert!(r.body.contains("call mum"), "body: {}", r.body);
assert!(!r.body.contains("6pm"), "time phrase should be stripped from body: {}", r.body);
}
#[test] #[test]
fn model_path_points_to_expected_location() { fn model_path_points_to_expected_location() {
let c = cl(); let c = cl();

View file

@ -1,4 +1,4 @@
use breadpad_shared::config::{Config, ModelConfig, RemindersConfig, Settings}; use breadpad_shared::config::{expand_path, CalendarConfig, Config, ModelConfig, RemindersConfig, Settings};
use tempfile::TempDir; use tempfile::TempDir;
// ---- Default values ---- // ---- Default values ----
@ -22,9 +22,9 @@ fn default_snooze_options_contains_all_three() {
#[test] #[test]
fn default_model_config() { fn default_model_config() {
let m = ModelConfig::default(); let m = ModelConfig::default();
assert_eq!(m.execution_provider, "auto");
assert!(m.path.contains("classifier.onnx")); assert!(m.path.contains("classifier.onnx"));
assert!(m.tokenizer.contains("tokenizer.json")); assert!(m.tokenizer.contains("tokenizer.json"));
assert_eq!(m.ort_dylib_path, "");
} }
#[test] #[test]
@ -38,7 +38,6 @@ fn default_reminders_config() {
fn default_config_composes_defaults() { fn default_config_composes_defaults() {
let cfg = Config::default(); let cfg = Config::default();
assert_eq!(cfg.settings.default_type, "note"); assert_eq!(cfg.settings.default_type, "note");
assert_eq!(cfg.model.execution_provider, "auto");
assert_eq!(cfg.reminders.default_morning, "08:00"); assert_eq!(cfg.reminders.default_morning, "08:00");
} }
@ -56,7 +55,7 @@ archive_after_days = 7
[model] [model]
path = "/tmp/classifier.onnx" path = "/tmp/classifier.onnx"
tokenizer = "/tmp/tokenizer.json" tokenizer = "/tmp/tokenizer.json"
execution_provider = "cpu" ort_dylib_path = "/tmp/libonnxruntime.so"
[reminders] [reminders]
default_morning = "07:30" default_morning = "07:30"
@ -67,8 +66,8 @@ missed_grace_minutes = 30
assert!(!cfg.settings.workspace_tag); assert!(!cfg.settings.workspace_tag);
assert_eq!(cfg.settings.snooze_options, vec!["15m", "2h"]); assert_eq!(cfg.settings.snooze_options, vec!["15m", "2h"]);
assert_eq!(cfg.settings.archive_after_days, 7); assert_eq!(cfg.settings.archive_after_days, 7);
assert_eq!(cfg.model.execution_provider, "cpu");
assert_eq!(cfg.model.path, "/tmp/classifier.onnx"); assert_eq!(cfg.model.path, "/tmp/classifier.onnx");
assert_eq!(cfg.model.ort_dylib_path, "/tmp/libonnxruntime.so");
assert_eq!(cfg.reminders.default_morning, "07:30"); assert_eq!(cfg.reminders.default_morning, "07:30");
assert_eq!(cfg.reminders.missed_grace_minutes, 30); assert_eq!(cfg.reminders.missed_grace_minutes, 30);
} }
@ -78,7 +77,6 @@ fn empty_toml_uses_all_defaults() {
let cfg: Config = toml::from_str("").unwrap(); let cfg: Config = toml::from_str("").unwrap();
assert_eq!(cfg.settings.default_type, "note"); assert_eq!(cfg.settings.default_type, "note");
assert!(cfg.settings.workspace_tag); assert!(cfg.settings.workspace_tag);
assert_eq!(cfg.model.execution_provider, "auto");
assert_eq!(cfg.reminders.default_morning, "08:00"); assert_eq!(cfg.reminders.default_morning, "08:00");
} }
@ -90,31 +88,9 @@ default_type = "reminder"
"#; "#;
let cfg: Config = toml::from_str(toml).unwrap(); let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.settings.default_type, "reminder"); assert_eq!(cfg.settings.default_type, "reminder");
// Other sections should still have defaults
assert_eq!(cfg.model.execution_provider, "auto");
assert_eq!(cfg.reminders.default_morning, "08:00"); assert_eq!(cfg.reminders.default_morning, "08:00");
} }
#[test]
fn partial_toml_only_model_section() {
let toml = r#"
[model]
execution_provider = "npu"
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.model.execution_provider, "npu");
assert_eq!(cfg.settings.default_type, "note");
}
#[test]
fn execution_provider_variants_accepted() {
for ep in &["auto", "npu", "vulkan", "cpu"] {
let toml = format!("[model]\nexecution_provider = \"{}\"", ep);
let cfg: Config = toml::from_str(&toml).unwrap();
assert_eq!(cfg.model.execution_provider, *ep);
}
}
// ---- TOML serialization round-trip ---- // ---- TOML serialization round-trip ----
#[test] #[test]
@ -124,7 +100,6 @@ fn default_config_serializes_to_valid_toml() {
let reparsed: Config = toml::from_str(&serialized).unwrap(); let reparsed: Config = toml::from_str(&serialized).unwrap();
assert_eq!(reparsed.settings.default_type, cfg.settings.default_type); assert_eq!(reparsed.settings.default_type, cfg.settings.default_type);
assert_eq!(reparsed.settings.workspace_tag, cfg.settings.workspace_tag); assert_eq!(reparsed.settings.workspace_tag, cfg.settings.workspace_tag);
assert_eq!(reparsed.model.execution_provider, cfg.model.execution_provider);
assert_eq!(reparsed.reminders.default_morning, cfg.reminders.default_morning); assert_eq!(reparsed.reminders.default_morning, cfg.reminders.default_morning);
} }
@ -133,7 +108,6 @@ fn custom_config_round_trips() {
let mut cfg = Config::default(); let mut cfg = Config::default();
cfg.settings.default_type = "idea".into(); cfg.settings.default_type = "idea".into();
cfg.settings.archive_after_days = 14; cfg.settings.archive_after_days = 14;
cfg.model.execution_provider = "vulkan".into();
cfg.reminders.default_morning = "06:45".into(); cfg.reminders.default_morning = "06:45".into();
cfg.reminders.missed_grace_minutes = 120; cfg.reminders.missed_grace_minutes = 120;
@ -141,7 +115,6 @@ fn custom_config_round_trips() {
let rt: Config = toml::from_str(&toml).unwrap(); let rt: Config = toml::from_str(&toml).unwrap();
assert_eq!(rt.settings.default_type, "idea"); assert_eq!(rt.settings.default_type, "idea");
assert_eq!(rt.settings.archive_after_days, 14); assert_eq!(rt.settings.archive_after_days, 14);
assert_eq!(rt.model.execution_provider, "vulkan");
assert_eq!(rt.reminders.default_morning, "06:45"); assert_eq!(rt.reminders.default_morning, "06:45");
assert_eq!(rt.reminders.missed_grace_minutes, 120); assert_eq!(rt.reminders.missed_grace_minutes, 120);
} }
@ -155,24 +128,20 @@ fn save_and_load_round_trip() {
let mut cfg = Config::default(); let mut cfg = Config::default();
cfg.settings.default_type = "question".into(); cfg.settings.default_type = "question".into();
cfg.model.execution_provider = "cpu".into();
cfg.reminders.missed_grace_minutes = 45; cfg.reminders.missed_grace_minutes = 45;
// Manually save to a known path (Config::save uses the fixed XDG path,
// so we use toml serialization + write here to test the round-trip logic)
let toml = toml::to_string_pretty(&cfg).unwrap(); let toml = toml::to_string_pretty(&cfg).unwrap();
std::fs::write(&config_path, &toml).unwrap(); std::fs::write(&config_path, &toml).unwrap();
let loaded: Config = toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap(); let loaded: Config = toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
assert_eq!(loaded.settings.default_type, "question"); assert_eq!(loaded.settings.default_type, "question");
assert_eq!(loaded.model.execution_provider, "cpu");
assert_eq!(loaded.reminders.missed_grace_minutes, 45); assert_eq!(loaded.reminders.missed_grace_minutes, 45);
} }
// ---- The example from the README ---- // ---- The example from the README ----
#[test] #[test]
fn readme_example_toml_parses() { fn example_toml_parses() {
let toml = r#" let toml = r#"
[settings] [settings]
default_type = "note" default_type = "note"
@ -183,7 +152,7 @@ archive_after_days = 30
[model] [model]
path = "~/.local/share/breadpad/model/classifier.onnx" path = "~/.local/share/breadpad/model/classifier.onnx"
tokenizer = "~/.local/share/breadpad/model/tokenizer.json" tokenizer = "~/.local/share/breadpad/model/tokenizer.json"
execution_provider = "auto" ort_dylib_path = ""
[reminders] [reminders]
default_morning = "08:00" default_morning = "08:00"
@ -192,7 +161,146 @@ missed_grace_minutes = 60
let cfg: Config = toml::from_str(toml).unwrap(); let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.settings.default_type, "note"); assert_eq!(cfg.settings.default_type, "note");
assert!(cfg.settings.workspace_tag); assert!(cfg.settings.workspace_tag);
assert_eq!(cfg.model.execution_provider, "auto");
assert_eq!(cfg.reminders.default_morning, "08:00"); assert_eq!(cfg.reminders.default_morning, "08:00");
assert_eq!(cfg.reminders.missed_grace_minutes, 60); assert_eq!(cfg.reminders.missed_grace_minutes, 60);
} }
// ---- CalendarConfig ----
#[test]
fn default_calendar_config_is_disabled() {
let c = CalendarConfig::default();
assert!(!c.enabled);
assert!(c.url.is_empty());
assert!(c.username.is_empty());
assert!(c.password.is_empty());
}
#[test]
fn calendar_config_from_toml() {
let toml = r#"
[calendar]
enabled = true
url = "https://cloud.example.com/remote.php/dav/calendars/user/personal/"
username = "user"
password = "secret"
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert!(cfg.calendar.enabled);
assert!(cfg.calendar.url.contains("dav/calendars"));
assert_eq!(cfg.calendar.username, "user");
assert_eq!(cfg.calendar.password, "secret");
}
#[test]
fn calendar_config_round_trips() {
let mut cfg = Config::default();
cfg.calendar.enabled = true;
cfg.calendar.url = "https://example.com/cal".into();
cfg.calendar.username = "alice".into();
cfg.calendar.password = "hunter2".into();
let toml = toml::to_string_pretty(&cfg).unwrap();
let rt: Config = toml::from_str(&toml).unwrap();
assert!(rt.calendar.enabled);
assert_eq!(rt.calendar.url, "https://example.com/cal");
assert_eq!(rt.calendar.username, "alice");
assert_eq!(rt.calendar.password, "hunter2");
}
#[test]
fn default_config_calendar_disabled() {
let cfg = Config::default();
assert!(!cfg.calendar.enabled);
}
// ---- OllamaConfig ----
#[test]
fn default_ollama_config_enabled() {
let m = ModelConfig::default();
assert!(m.ollama.enabled);
assert_eq!(m.ollama.endpoint, "http://localhost:11434");
assert!(!m.ollama.model.is_empty());
assert!(m.ollama.confidence_threshold > 0.0 && m.ollama.confidence_threshold <= 1.0);
}
#[test]
fn ollama_config_from_toml() {
let toml = r#"
[model.ollama]
enabled = false
endpoint = "http://localhost:9999"
model = "llama3"
confidence_threshold = 0.8
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert!(!cfg.model.ollama.enabled);
assert_eq!(cfg.model.ollama.endpoint, "http://localhost:9999");
assert_eq!(cfg.model.ollama.model, "llama3");
assert!((cfg.model.ollama.confidence_threshold - 0.8).abs() < 1e-5);
}
// ---- expand_path ----
#[test]
fn expand_path_tilde_prefix_replaced_with_home() {
let home = dirs::home_dir().unwrap();
let expanded = expand_path("~/some/path");
assert!(expanded.starts_with(&home));
assert!(expanded.ends_with("some/path"));
}
#[test]
fn expand_path_bare_tilde_is_home() {
let home = dirs::home_dir().unwrap();
assert_eq!(expand_path("~"), home);
}
#[test]
fn expand_path_absolute_path_unchanged() {
let p = expand_path("/usr/local/bin/breadpad");
assert_eq!(p.to_str().unwrap(), "/usr/local/bin/breadpad");
}
#[test]
fn expand_path_relative_path_unchanged() {
let p = expand_path("relative/path");
assert_eq!(p.to_str().unwrap(), "relative/path");
}
// ---- ModelConfig::resolved_ort_dylib_path ----
#[test]
fn resolved_ort_dylib_empty_returns_none() {
let m = ModelConfig::default();
assert!(m.resolved_ort_dylib_path().is_none());
}
#[test]
fn resolved_ort_dylib_whitespace_only_returns_none() {
let mut m = ModelConfig::default();
m.ort_dylib_path = " ".into();
assert!(m.resolved_ort_dylib_path().is_none());
}
#[test]
fn resolved_ort_dylib_set_returns_some() {
let mut m = ModelConfig::default();
m.ort_dylib_path = "/usr/lib/libonnxruntime.so".into();
assert_eq!(
m.resolved_ort_dylib_path().unwrap().to_str().unwrap(),
"/usr/lib/libonnxruntime.so"
);
}
// ---- ModelConfig::resolved_paths ----
#[test]
fn resolved_paths_expands_tildes() {
let m = ModelConfig::default();
let (model, tokenizer) = m.resolved_paths();
let home = dirs::home_dir().unwrap();
assert!(model.starts_with(&home), "model path should be under home: {:?}", model);
assert!(tokenizer.starts_with(&home), "tokenizer path should be under home: {:?}", tokenizer);
}

View file

@ -14,7 +14,7 @@ use tempfile::TempDir;
// Mirrors commit_note() in breadpad/src/main.rs. // Mirrors commit_note() in breadpad/src/main.rs.
// `user_type` is the type the user selected in the chip row (default = NoteType::Note). // `user_type` is the type the user selected in the chip row (default = NoteType::Note).
fn capture(store: &Store, text: &str, user_type: NoteType) -> Note { fn capture(store: &Store, text: &str, user_type: NoteType) -> Note {
let mut classifier = Classifier::load("auto", "08:00"); let mut classifier = Classifier::load("08:00");
let result = classifier.classify(text); let result = classifier.classify(text);
let mut note = Note::new(text.into(), user_type.clone(), None); let mut note = Note::new(text.into(), user_type.clone(), None);

View file

@ -301,6 +301,51 @@ fn rotate_archive_zero_when_nothing_qualifies() {
assert_eq!(store.load_all().unwrap().len(), 1); assert_eq!(store.load_all().unwrap().len(), 1);
} }
#[test]
fn rotate_archive_note_just_inside_boundary_stays() {
let (_dir, store) = mk();
// 29 days ago — threshold is 30 — should NOT be archived
let mut n = note("fresh enough", NoteType::Todo);
n.done = true;
n.completed = Some(Utc::now() - Duration::days(29));
store.save_note(&n).unwrap();
assert_eq!(store.rotate_archive(30).unwrap(), 0);
assert_eq!(store.load_all().unwrap().len(), 1);
}
#[test]
fn rotate_archive_note_just_past_boundary_is_archived() {
let (_dir, store) = mk();
// 31 days ago — threshold is 30 — should be archived
let mut n = note("old enough", NoteType::Todo);
n.done = true;
n.completed = Some(Utc::now() - Duration::days(31));
store.save_note(&n).unwrap();
assert_eq!(store.rotate_archive(30).unwrap(), 1);
assert!(store.load_all().unwrap().is_empty());
assert_eq!(store.load_archive().unwrap().len(), 1);
}
#[test]
fn rotate_archive_zero_day_threshold_archives_completed_notes() {
let (_dir, store) = mk();
let mut done = note("done a second ago", NoteType::Todo);
done.done = true;
done.completed = Some(Utc::now() - Duration::seconds(1));
store.save_note(&done).unwrap();
let undone = note("still active", NoteType::Todo);
store.save_note(&undone).unwrap();
assert_eq!(store.rotate_archive(0).unwrap(), 1);
let remaining = store.load_all().unwrap();
assert_eq!(remaining.len(), 1);
assert_eq!(remaining[0].body, "still active");
assert_eq!(store.load_archive().unwrap().len(), 1);
}
#[test] #[test]
fn rotate_archive_ignores_undone_notes_no_matter_how_old() { fn rotate_archive_ignores_undone_notes_no_matter_how_old() {
let (_dir, store) = mk(); let (_dir, store) = mk();

View file

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

File diff suppressed because it is too large Load diff

View file

@ -67,6 +67,9 @@ enum TierArg {
Three, Three,
#[value(name = "all")] #[value(name = "all")]
All, All,
/// Production path: Tier 1 → Tier 2 (no Ollama)
#[value(name = "pipeline")]
Pipeline,
} }
impl TierArg { impl TierArg {
@ -76,6 +79,7 @@ impl TierArg {
TierArg::Two => "2", TierArg::Two => "2",
TierArg::Three => "3", TierArg::Three => "3",
TierArg::All => "all", TierArg::All => "all",
TierArg::Pipeline => "pipeline",
} }
} }
} }
@ -142,7 +146,14 @@ fn classify_with_tier(text: &str, tier: &TierArg) -> ClassificationResult {
match tier { match tier {
TierArg::One => parse_rule_based(text, DEFAULT_MORNING), TierArg::One => parse_rule_based(text, DEFAULT_MORNING),
TierArg::Two => { TierArg::Two => {
let mut clf = Classifier::load("auto", DEFAULT_MORNING); let mut clf = Classifier::load(DEFAULT_MORNING);
clf.classify_tier2_only(text).unwrap_or_else(|| {
eprintln!("warning: ONNX model not loaded; Tier 2 unavailable");
parse_rule_based(text, DEFAULT_MORNING)
})
}
TierArg::Pipeline => {
let mut clf = Classifier::load(DEFAULT_MORNING);
clf.classify(text) clf.classify(text)
} }
TierArg::Three | TierArg::All => { TierArg::Three | TierArg::All => {
@ -152,7 +163,7 @@ fn classify_with_tier(text: &str, tier: &TierArg) -> ClassificationResult {
confidence_threshold: 0.6, confidence_threshold: 0.6,
enabled: true, enabled: true,
}; };
let mut clf = Classifier::load("auto", DEFAULT_MORNING).with_ollama(ollama); let mut clf = Classifier::load(DEFAULT_MORNING).with_ollama(ollama);
clf.classify(text) clf.classify(text)
} }
} }
@ -444,8 +455,35 @@ fn cmd_edit(index: usize, corpus_path: &Path) -> Result<()> {
Ok(()) Ok(())
} }
fn init_ort() {
use std::path::PathBuf;
// Prefer the system CPU-only library for testing — no Ryzen AI startup overhead.
// Fall back to the Ryzen AI SDK library if the system one isn't installed.
let candidates: Vec<PathBuf> = {
let mut v = vec![
PathBuf::from("/usr/lib/libonnxruntime.so"),
PathBuf::from("/usr/local/lib/libonnxruntime.so"),
];
if let Some(home) = dirs::home_dir() {
v.push(home.join(".local/share/ryzen-ai-1.7.1/lib/libonnxruntime.so"));
v.push(home.join(".local/share/ryzen-ai/lib/libonnxruntime.so"));
}
if let Ok(root) = std::env::var("RYZEN_AI_INSTALLATION_PATH") {
v.push(PathBuf::from(root).join("lib/libonnxruntime.so"));
}
v.push(PathBuf::from("/opt/ryzen-ai/lib/libonnxruntime.so"));
v
};
if let Some(path) = candidates.into_iter().find(|p| p.is_file()) {
if let Err(e) = ort::init_from(&path).map(|b| b.commit()) {
eprintln!("warning: failed to load ORT from {:?}: {}", path, e);
}
}
}
fn main() -> Result<()> { fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
init_ort();
match cli.command { match cli.command {
Commands::Run { corpus, tier, format } => { Commands::Run { corpus, tier, format } => {

View file

@ -7,8 +7,25 @@ archive_after_days = 30
[model] [model]
path = "~/.local/share/breadpad/model/classifier.onnx" path = "~/.local/share/breadpad/model/classifier.onnx"
tokenizer = "~/.local/share/breadpad/model/tokenizer.json" tokenizer = "~/.local/share/breadpad/model/tokenizer.json"
execution_provider = "auto" # auto | npu | vulkan | cpu # ort_dylib_path: path to libonnxruntime.so. Leave empty to auto-discover from
# standard system paths or $ORT_DYLIB_PATH. Tier 2 is disabled if no library is found.
ort_dylib_path = ""
[model.ollama]
endpoint = "http://localhost:11434"
model = "fastflowlm"
confidence_threshold = 0.6
enabled = true
[reminders] [reminders]
default_morning = "08:00" default_morning = "08:00"
missed_grace_minutes = 60 missed_grace_minutes = 60
[calendar]
enabled = false
url = "" # e.g. https://cloud.example.com/remote.php/dav/calendars/user/personal/
username = ""
# WARNING: password is stored in plaintext. Restrict file permissions:
# chmod 600 ~/.config/breadpad/breadpad.toml
# and keep this file out of version control.
password = ""

View file

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

View file

@ -2,17 +2,33 @@ use anyhow::Result;
use breadpad_shared::{ use breadpad_shared::{
calendar::CalDavClient, calendar::CalDavClient,
classifier::Classifier, classifier::Classifier,
config::{style_css_path, Config}, config::Config,
scheduler::Scheduler, scheduler::Scheduler,
store::Store, store::Store,
theme::{build_css, load_palette},
types::{Note, NoteType}, types::{Note, NoteType},
}; };
use gtk4::{glib, prelude::*}; 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 +105,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 +124,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 +148,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 +164,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 +252,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 +265,7 @@ fn cmd_fire(id: &str, cfg: &Config) -> Result<()> {
return Ok(()); return Ok(());
} }
// Send notification via notify-send // Schedule next recurrence before showing UI
let title = format!("[{}] breadpad reminder", note.note_type);
let mut cmd = std::process::Command::new("notify-send");
cmd.arg("--urgency=normal")
.arg(format!("--app-name=breadpad"))
.arg(&title)
.arg(&note.body);
for opt in &cfg.settings.snooze_options {
cmd.arg(format!("--action=snooze_{}={}", opt, humanize_snooze(opt)));
}
let output = cmd.output()?;
// If the user clicked a snooze action, notify-send prints the action key
if let Ok(action) = String::from_utf8(output.stdout) {
let action = action.trim();
if action.starts_with("snooze_") {
let key = action.trim_start_matches("snooze_");
if let Some(until) = resolve_snooze(key, cfg) {
let mut updated = note.clone();
store.update_note({
updated.snoozed_until = Some(until);
&updated
})?;
Scheduler::schedule(&updated)?;
return Ok(());
}
}
}
// Handle recurrence
if note.rrule.is_some() { if note.rrule.is_some() {
if let Some(next) = Scheduler::next_recurrence(&note, &cfg.reminders.default_morning) { if let Some(next) = Scheduler::next_recurrence(&note, &cfg.reminders.default_morning) {
let mut updated = note.clone(); let mut updated = note.clone();
@ -275,6 +276,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 +321,195 @@ fn resolve_snooze(key: &str, cfg: &Config) -> Option<chrono::DateTime<chrono::Ut
let m = parts.get(1).copied().unwrap_or(0); let m = parts.get(1).copied().unwrap_or(0);
let tomorrow = local.date_naive() + chrono::Duration::days(1); let tomorrow = local.date_naive() + chrono::Duration::days(1);
let naive = tomorrow.and_hms_opt(h, m, 0)?; let naive = tomorrow.and_hms_opt(h, m, 0)?;
Some(naive.and_local_timezone(chrono::Local).unwrap().with_timezone(&chrono::Utc)) Some(breadpad_shared::util::local_naive_to_utc(naive))
} }
_ => None, _ => None,
} }
} }
fn build_reminder_window(
app: &gtk4::Application,
note: Arc<breadpad_shared::types::Note>,
cfg: Arc<Config>,
) {
let window = gtk4::ApplicationWindow::builder()
.application(app)
.title("breadpad reminder")
.default_width(420)
.default_height(1)
.decorated(false)
.resizable(false)
.build();
window.init_layer_shell();
window.set_layer(Layer::Overlay);
window.set_keyboard_mode(KeyboardMode::Exclusive);
window.auto_exclusive_zone_enable();
apply_css(&cfg);
let type_emoji = match note.note_type.as_str() {
"reminder" => "🔔",
"todo" => "",
"idea" => "💡",
"question" => "",
_ => "📝",
};
let outer = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(0)
.css_classes(["reminder-window"])
.build();
// Header strip
let header = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.margin_top(16)
.margin_bottom(8)
.margin_start(20)
.margin_end(20)
.build();
header.append(
&gtk4::Label::builder()
.label(type_emoji)
.css_classes(["reminder-emoji"])
.build(),
);
header.append(
&gtk4::Label::builder()
.label("Reminder")
.css_classes(["reminder-title"])
.hexpand(true)
.xalign(0.0)
.build(),
);
// Optional time label
if let Some(t) = note.effective_time() {
let local: chrono::DateTime<chrono::Local> = t.into();
header.append(
&gtk4::Label::builder()
.label(&local.format("%H:%M").to_string())
.css_classes(["reminder-time"])
.build(),
);
}
outer.append(&header);
// Body
let body_label = gtk4::Label::builder()
.label(&note.body)
.css_classes(["reminder-body"])
.wrap(true)
.xalign(0.0)
.margin_start(20)
.margin_end(20)
.margin_bottom(16)
.build();
outer.append(&body_label);
// Separator
outer.append(&gtk4::Separator::builder()
.orientation(gtk4::Orientation::Horizontal)
.build());
// Button row
let btn_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.margin_top(12)
.margin_bottom(12)
.margin_start(16)
.margin_end(16)
.build();
let dismiss_btn = gtk4::Button::builder()
.label("Dismiss")
.css_classes(["reminder-dismiss"])
.build();
// Snooze popover
let snooze_popover = gtk4::Popover::new();
let snooze_vbox = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(4)
.margin_top(8)
.margin_bottom(8)
.margin_start(8)
.margin_end(8)
.build();
for opt in &cfg.settings.snooze_options {
let label = humanize_snooze(opt).to_string();
let btn = gtk4::Button::builder()
.label(&label)
.css_classes(["snooze-option"])
.build();
let key = opt.clone();
let note_c = note.clone();
let cfg_c = cfg.clone();
let win_c = window.clone();
let popover_c = snooze_popover.clone();
btn.connect_clicked(move |_| {
if let Some(until) = resolve_snooze(&key, &cfg_c) {
if let Ok(store) = Store::new().map(|s| s.with_calendar_if_enabled(&cfg_c)) {
let mut updated = note_c.as_ref().clone();
updated.snoozed_until = Some(until);
let _ = store.update_note(&updated);
let _ = Scheduler::schedule(&updated);
}
}
popover_c.popdown();
win_c.close();
});
snooze_vbox.append(&btn);
}
snooze_popover.set_child(Some(&snooze_vbox));
let snooze_btn = gtk4::MenuButton::builder()
.label("Snooze")
.css_classes(["reminder-snooze"])
.popover(&snooze_popover)
.build();
let done_btn = gtk4::Button::builder()
.label("Done ✓")
.css_classes(["confirm-button", "reminder-done"])
.hexpand(true)
.build();
{
let note_c = note.clone();
let cfg_c = cfg.clone();
let win_c = window.clone();
done_btn.connect_clicked(move |_| {
if let Ok(store) = Store::new().map(|s| s.with_calendar_if_enabled(&cfg_c)) {
let mut updated = note_c.as_ref().clone();
updated.mark_done();
let _ = store.update_note(&updated);
}
win_c.close();
});
}
{
let win_c = window.clone();
dismiss_btn.connect_clicked(move |_| { win_c.close(); });
}
btn_row.append(&dismiss_btn);
btn_row.append(&snooze_btn);
btn_row.append(&done_btn);
outer.append(&btn_row);
window.set_child(Some(&outer));
window.present();
}
fn run_popup(preset_type: Option<String>, no_classify: bool, cfg: Config) -> Result<()> { fn run_popup(preset_type: Option<String>, no_classify: bool, cfg: Config) -> Result<()> {
// Try to get current Hyprland workspace // Try to get current Hyprland workspace
let workspace = get_active_workspace(); let workspace = get_active_workspace();
@ -321,10 +521,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 +676,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 +725,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);
@ -559,15 +764,7 @@ fn save_note_classified(
} }
fn apply_css(_cfg: &Config) { fn apply_css(_cfg: &Config) {
let palette = load_palette(); // Hot-reloads on `bread-theme reload` (recolours to the new pywal palette
let user_css = std::fs::read_to_string(style_css_path()).ok(); // and re-reads the user's style.css). See breadpad_shared::theme::apply_live.
let css = build_css(&palette, user_css.as_deref()); breadpad_shared::theme::apply_live();
let provider = gtk4::CssProvider::new();
provider.load_from_string(&css);
gtk4::style_context_add_provider_for_display(
&gtk4::gdk::Display::default().unwrap(),
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
} }

View file

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

38
packaging/arch/PKGBUILD Normal file
View file

@ -0,0 +1,38 @@
# Maintainer: Breadway <rileyhorsham@gmail.com>
pkgname=breadpad
pkgver=0.3.1
pkgrel=1
pkgdesc="Quick-capture scratchpad and note viewer with AI classification"
arch=('x86_64')
url="https://github.com/Breadway/breadpad"
license=('MIT')
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,
# causing undefined-symbol errors. Disable LTO.
options=(!lto !debug)
depends=('gtk4' 'gtk4-layer-shell')
optdepends=(
'ollama: local AI note classification'
'hyprland: scratchpad window integration'
)
makedepends=('rust' 'cargo')
source=("${pkgname}-${pkgver}.tar.gz")
sha256sums=('SKIP')
build() {
cd "${srcdir}/${pkgname}-${pkgver}"
cargo build --release --locked
}
check() {
cd "${srcdir}/${pkgname}-${pkgver}"
cargo test --release --locked --workspace
}
package() {
cd "${srcdir}/${pkgname}-${pkgver}"
install -Dm755 target/release/breadpad "${pkgdir}/usr/bin/breadpad"
install -Dm755 target/release/breadman "${pkgdir}/usr/bin/breadman"
install -Dm644 LICENSE "${pkgdir}/usr/share/licenses/${pkgname}/LICENSE"
}

102
svgs.txt
View file

@ -1,102 +0,0 @@
# SVG Icons for breadman
# Replace the placeholder emojis in breadman/src/main.rs and breadman/src/editor.rs
# with SVG-backed gtk4::Image widgets once you have the files.
# All icons should be single-color/symbolic so GTK can recolor them with CSS.
# Recommended source: Lucide (https://lucide.dev), Phosphor, or Material Symbols.
## Sidebar — navigation items
all-notes.svg
Placeholder: 📋
Use: "All" view — a stack of pages or a grid of squares
Lucide suggestion: layout-grid, files, or layers
calendar-clock.svg
Placeholder: 📅
Use: "Upcoming" view — calendar with a clock overlay
Lucide suggestion: calendar-clock
checkbox.svg
Placeholder: ✅
Use: "Todo" type — empty or checked checkbox
Lucide suggestion: square-check or check-square
bell.svg
Placeholder: 🔔
Use: "Reminder" type — bell icon
Lucide suggestion: bell
lightbulb.svg
Placeholder: 💡
Use: "Idea" type — lightbulb
Lucide suggestion: lightbulb
pencil-line.svg
Placeholder: 📝
Use: "Note" type — pencil writing on a line
Lucide suggestion: pencil-line or file-text
circle-help.svg
Placeholder: ❓
Use: "Question" type — question mark in a circle
Lucide suggestion: circle-help or help-circle
archive-box.svg
Placeholder: 📦
Use: "Archive" view — box with down-arrow or archive tray
Lucide suggestion: archive or archive-restore
settings-gear.svg
Placeholder: ⚙
Use: "Settings" view — gear/cog
Lucide suggestion: settings or settings-2
triangle-alert.svg
Placeholder: ⚠
Use: "Errors" view — triangle with exclamation mark
Lucide suggestion: triangle-alert or alert-triangle
## Note card action buttons
check.svg
Placeholder: ✓
Use: "Mark done" action button on note cards
Lucide suggestion: check or circle-check
pencil.svg
Placeholder: ✎
Use: "Edit" action button on note cards
Lucide suggestion: pencil or pen
trash.svg
Placeholder: 🗑
Use: "Delete" action button on note cards and archive
Lucide suggestion: trash-2
## Note card metadata badges
clock.svg
Placeholder: ⏰ (used inline in label text)
Use: Scheduled time indicator on note cards
Lucide suggestion: clock or alarm-clock
repeat.svg
Placeholder: ↻ (used as type-chip label)
Use: Recurrence indicator on note cards
Lucide suggestion: repeat or refresh-cw
## New Note button
plus.svg
Placeholder: ✚ (used in "✚ New Note" button label)
Use: New note creation button in sidebar
Lucide suggestion: plus or plus-circle
## Notes on integration
# When switching from emoji/text to SVG icons:
# 1. Use gtk4::Image::from_file() or load via gtk4::IconTheme for theme-aware icons.
# 2. For action buttons, replace the label with a gtk4::Image child:
# let btn = gtk4::Button::new();
# btn.set_child(Some(&gtk4::Image::from_file("path/to/icon.svg")));
# 3. Symbolic icons (named with "-symbolic" suffix in icon theme) follow the
# CSS color property automatically.