Compare commits
26 commits
e0b55e1713
...
b5c8f8d43a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5c8f8d43a | ||
|
|
1671439f90 | ||
|
|
35e0481314 | ||
|
|
03a390a1ff | ||
|
|
b8993630e3 | ||
|
|
f30e215eab | ||
|
|
d29ff9f8c9 | ||
|
|
a7484495c8 | ||
|
|
956bacb3e0 | ||
|
|
b71b8bea5e | ||
|
|
36249553a3 | ||
|
|
33ca7871d9 | ||
|
|
8c2b2e419e | ||
|
|
5406fcdf61 | ||
|
|
d7b0813b0a | ||
|
|
0acf7fce48 | ||
|
|
e23a7a97cd | ||
|
|
3b7609f354 | ||
|
|
efb3af70de | ||
|
|
160d4b52b9 | ||
|
|
66674fa549 | ||
|
|
c147962da6 | ||
|
|
1240d97892 | ||
|
|
6a4a455ad9 | ||
|
|
a2281607bb | ||
|
|
347508828f |
39 changed files with 3135 additions and 1117 deletions
21
.forgejo/workflows/mirror.yml
Normal file
21
.forgejo/workflows/mirror.yml
Normal 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/*'
|
||||
40
.forgejo/workflows/package.yml
Normal file
40
.forgejo/workflows/package.yml
Normal 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
66
.github/workflows/release.yml
vendored
Normal 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
28
.gitignore
vendored
|
|
@ -1 +1,29 @@
|
|||
target/
|
||||
*.tgz
|
||||
*.zip
|
||||
breadpadcli
|
||||
breadmancli
|
||||
svgs.txt
|
||||
|
||||
# Editor & IDE
|
||||
*.swp
|
||||
*.swo
|
||||
.DS_Store
|
||||
.vscode/
|
||||
.idea/
|
||||
*.iml
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Debug & Logs
|
||||
*.log
|
||||
*.pid
|
||||
*.sock
|
||||
*.pdb
|
||||
|
||||
# Rust/Cargo
|
||||
Cargo.lock
|
||||
dist/
|
||||
|
|
|
|||
342
Cargo.lock
generated
342
Cargo.lock
generated
|
|
@ -259,12 +259,6 @@ version = "0.22.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bit-set"
|
||||
version = "0.8.0"
|
||||
|
|
@ -282,9 +276,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7"
|
|||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "2.11.1"
|
||||
version = "2.13.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3"
|
||||
checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
|
|
@ -308,14 +302,26 @@ dependencies = [
|
|||
"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]]
|
||||
name = "breadman"
|
||||
version = "0.1.0"
|
||||
version = "0.3.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"breadpad-shared",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"futures-channel",
|
||||
"gtk4",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
|
@ -326,7 +332,7 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "breadpad"
|
||||
version = "0.1.0"
|
||||
version = "0.3.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"breadpad-shared",
|
||||
|
|
@ -335,6 +341,7 @@ dependencies = [
|
|||
"gtk4",
|
||||
"gtk4-layer-shell",
|
||||
"hyprland",
|
||||
"ort",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tokio",
|
||||
|
|
@ -344,9 +351,10 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "breadpad-shared"
|
||||
version = "0.1.0"
|
||||
version = "0.3.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"bread-theme",
|
||||
"chrono",
|
||||
"dirs 5.0.1",
|
||||
"ical",
|
||||
|
|
@ -362,14 +370,14 @@ dependencies = [
|
|||
"tokio",
|
||||
"toml 0.8.23",
|
||||
"tracing",
|
||||
"ureq 2.12.1",
|
||||
"ureq",
|
||||
"uuid",
|
||||
"zbus",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "breadpad-test"
|
||||
version = "0.1.0"
|
||||
version = "0.3.4"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"breadpad-shared",
|
||||
|
|
@ -377,6 +385,8 @@ dependencies = [
|
|||
"clap",
|
||||
"colored",
|
||||
"comfy-table",
|
||||
"dirs 5.0.1",
|
||||
"ort",
|
||||
"serde",
|
||||
"serde_json",
|
||||
]
|
||||
|
|
@ -433,9 +443,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "cc"
|
||||
version = "1.2.62"
|
||||
version = "1.2.63"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98"
|
||||
checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f"
|
||||
dependencies = [
|
||||
"find-msvc-tools",
|
||||
"shlex",
|
||||
|
|
@ -443,9 +453,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "cfg-expr"
|
||||
version = "0.20.7"
|
||||
version = "0.20.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c6b04e07d8080154ed4ac03546d9a2b303cc2fe1901ba0b35b301516e289368"
|
||||
checksum = "fb693542bcafa528e198be0ebd9d3632ca5b7c93dbe7237460e199910835997c"
|
||||
dependencies = [
|
||||
"smallvec",
|
||||
"target-lexicon",
|
||||
|
|
@ -465,9 +475,9 @@ checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
|||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
|
|
@ -568,9 +578,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "compact_str"
|
||||
version = "0.9.0"
|
||||
version = "0.9.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3fdb1325a1cece981e8a296ab8f0f9b63ae357bd0784a9faaf548cc7b480707a"
|
||||
checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
|
||||
dependencies = [
|
||||
"castaway",
|
||||
"cfg-if",
|
||||
|
|
@ -612,16 +622,6 @@ dependencies = [
|
|||
"unicode-segmentation",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
|
|
@ -748,16 +748,6 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "der"
|
||||
version = "0.8.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "71fd89660b2dc699704064e59e9dba0147b903e85319429e131620d022be411b"
|
||||
dependencies = [
|
||||
"pem-rfc7468",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "derive_builder"
|
||||
version = "0.20.2"
|
||||
|
|
@ -866,9 +856,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "displaydoc"
|
||||
version = "0.2.5"
|
||||
version = "0.2.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0"
|
||||
checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
@ -1021,21 +1011,6 @@ version = "0.1.5"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types"
|
||||
version = "0.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||
dependencies = [
|
||||
"foreign-types-shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "foreign-types-shared"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||
|
||||
[[package]]
|
||||
name = "form_urlencoded"
|
||||
version = "1.2.2"
|
||||
|
|
@ -1521,21 +1496,15 @@ dependencies = [
|
|||
"serde",
|
||||
"serde_json",
|
||||
"thiserror 2.0.18",
|
||||
"ureq 2.12.1",
|
||||
"ureq",
|
||||
"windows-sys 0.60.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hmac-sha256"
|
||||
version = "1.1.14"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ec9d92d097f4749b64e8cc33d924d9f40a2d4eb91402b458014b781f5733d60f"
|
||||
|
||||
[[package]]
|
||||
name = "http"
|
||||
version = "1.4.0"
|
||||
version = "1.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a"
|
||||
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"itoa",
|
||||
|
|
@ -1572,9 +1541,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
|
|||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.9.0"
|
||||
version = "1.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca"
|
||||
checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498"
|
||||
dependencies = [
|
||||
"atomic-waker",
|
||||
"bytes",
|
||||
|
|
@ -1895,10 +1864,20 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||
checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66"
|
||||
|
||||
[[package]]
|
||||
name = "libredox"
|
||||
version = "0.1.16"
|
||||
name = "libloading"
|
||||
version = "0.9.0"
|
||||
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 = [
|
||||
"libc",
|
||||
]
|
||||
|
|
@ -1932,9 +1911,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "log"
|
||||
version = "0.4.29"
|
||||
version = "0.4.32"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"
|
||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||
|
||||
[[package]]
|
||||
name = "lru-slab"
|
||||
|
|
@ -1942,12 +1921,6 @@ version = "0.1.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||
|
||||
[[package]]
|
||||
name = "lzma-rust2"
|
||||
version = "0.15.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e20f57f9918e5bd7bc58c22cdd70a6afc7375d4dd9683af5f2b34bd3d2bba619"
|
||||
|
||||
[[package]]
|
||||
name = "macro_rules_attribute"
|
||||
version = "0.2.2"
|
||||
|
|
@ -1985,9 +1958,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "memchr"
|
||||
version = "2.8.0"
|
||||
version = "2.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79"
|
||||
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8"
|
||||
|
||||
[[package]]
|
||||
name = "memoffset"
|
||||
|
|
@ -2016,9 +1989,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.2.0"
|
||||
version = "1.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1"
|
||||
checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi",
|
||||
|
|
@ -2047,23 +2020,6 @@ dependencies = [
|
|||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "native-tls"
|
||||
version = "0.2.18"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"log",
|
||||
"openssl",
|
||||
"openssl-probe",
|
||||
"openssl-sys",
|
||||
"schannel",
|
||||
"security-framework",
|
||||
"security-framework-sys",
|
||||
"tempfile",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ndarray"
|
||||
version = "0.16.1"
|
||||
|
|
@ -2171,49 +2127,6 @@ version = "1.70.2"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl"
|
||||
version = "0.10.80"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"cfg-if",
|
||||
"foreign-types",
|
||||
"libc",
|
||||
"openssl-macros",
|
||||
"openssl-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-macros"
|
||||
version = "0.1.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openssl-probe"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe"
|
||||
|
||||
[[package]]
|
||||
name = "openssl-sys"
|
||||
version = "0.9.116"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4"
|
||||
dependencies = [
|
||||
"cc",
|
||||
"libc",
|
||||
"pkg-config",
|
||||
"vcpkg",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "option-ext"
|
||||
version = "0.2.0"
|
||||
|
|
@ -2236,11 +2149,11 @@ version = "2.0.0-rc.12"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7de3af33d24a745ffb8fab904b13478438d1cd52868e6f17735ef6e1f8bf133"
|
||||
dependencies = [
|
||||
"libloading",
|
||||
"ndarray 0.17.2",
|
||||
"ort-sys",
|
||||
"smallvec",
|
||||
"tracing",
|
||||
"ureq 3.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2248,11 +2161,6 @@ name = "ort-sys"
|
|||
version = "2.0.0-rc.12"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7b497d21a8b6fbb4b5a544f8fadb77e801a09ae0add9e411d31c6f89e3c1e90"
|
||||
dependencies = [
|
||||
"hmac-sha256",
|
||||
"lzma-rust2",
|
||||
"ureq 3.3.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pango"
|
||||
|
|
@ -2328,15 +2236,6 @@ version = "0.1.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
|
||||
|
||||
[[package]]
|
||||
name = "pem-rfc7468"
|
||||
version = "1.0.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6305423e0e7738146434843d1694d621cce767262b2a86910beab705e4493d9"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
|
|
@ -2467,7 +2366,7 @@ version = "3.5.0"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
|
||||
dependencies = [
|
||||
"toml_edit 0.25.11+spec-1.1.0",
|
||||
"toml_edit 0.25.12+spec-1.1.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
|
@ -2853,44 +2752,12 @@ version = "1.0.23"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||
|
||||
[[package]]
|
||||
name = "schannel"
|
||||
version = "0.1.29"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "scopeguard"
|
||||
version = "1.2.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||
|
||||
[[package]]
|
||||
name = "security-framework"
|
||||
version = "3.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d"
|
||||
dependencies = [
|
||||
"bitflags",
|
||||
"core-foundation",
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
"security-framework-sys",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "security-framework-sys"
|
||||
version = "2.17.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3"
|
||||
dependencies = [
|
||||
"core-foundation-sys",
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semver"
|
||||
version = "1.0.28"
|
||||
|
|
@ -3003,9 +2870,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "shlex"
|
||||
version = "1.3.0"
|
||||
version = "2.0.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64"
|
||||
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||
|
||||
[[package]]
|
||||
name = "signal-hook-registry"
|
||||
|
|
@ -3043,9 +2910,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
|||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.3"
|
||||
version = "0.6.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e"
|
||||
checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
|
|
@ -3144,9 +3011,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "target-lexicon"
|
||||
version = "0.13.3"
|
||||
version = "0.13.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df7f62577c25e07834649fc3b39fafdc597c0a3527dc1c60129201ccfcbaa50c"
|
||||
checksum = "adb6935a6f5c20170eeceb1a3835a49e12e19d792f6dd344ccc76a985ca5a6ca"
|
||||
|
||||
[[package]]
|
||||
name = "tempfile"
|
||||
|
|
@ -3369,9 +3236,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "toml_edit"
|
||||
version = "0.25.11+spec-1.1.0"
|
||||
version = "0.25.12+spec-1.1.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b"
|
||||
checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7"
|
||||
dependencies = [
|
||||
"indexmap",
|
||||
"toml_datetime 1.1.1+spec-1.1.0",
|
||||
|
|
@ -3514,9 +3381,9 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
|||
|
||||
[[package]]
|
||||
name = "typenum"
|
||||
version = "1.20.0"
|
||||
version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "uds_windows"
|
||||
|
|
@ -3546,9 +3413,9 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "unicode-segmentation"
|
||||
version = "1.13.2"
|
||||
version = "1.13.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c"
|
||||
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-width"
|
||||
|
|
@ -3593,36 +3460,6 @@ dependencies = [
|
|||
"webpki-roots 0.26.11",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ureq"
|
||||
version = "3.3.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"der",
|
||||
"log",
|
||||
"native-tls",
|
||||
"percent-encoding",
|
||||
"rustls-pki-types",
|
||||
"socks",
|
||||
"ureq-proto",
|
||||
"utf8-zero",
|
||||
"webpki-root-certs",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ureq-proto"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "url"
|
||||
version = "2.5.8"
|
||||
|
|
@ -3635,12 +3472,6 @@ dependencies = [
|
|||
"serde",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "utf8-zero"
|
||||
version = "0.8.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
|
||||
|
||||
[[package]]
|
||||
name = "utf8_iter"
|
||||
version = "1.0.4"
|
||||
|
|
@ -3655,9 +3486,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
|||
|
||||
[[package]]
|
||||
name = "uuid"
|
||||
version = "1.23.1"
|
||||
version = "1.23.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76"
|
||||
checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7"
|
||||
dependencies = [
|
||||
"getrandom 0.4.2",
|
||||
"js-sys",
|
||||
|
|
@ -3670,12 +3501,6 @@ version = "0.1.1"
|
|||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"
|
||||
|
||||
[[package]]
|
||||
name = "vcpkg"
|
||||
version = "0.2.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "version-compare"
|
||||
version = "0.2.1"
|
||||
|
|
@ -3830,15 +3655,6 @@ dependencies = [
|
|||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31141ce3fc3e300ae89b78c0dd67f9708061d1d2eda54b8209346fd6be9a92c"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "0.26.11"
|
||||
|
|
@ -4305,9 +4121,9 @@ checksum = "3ae8337f8a065cfc972643663ea4279e04e7256de865aa66fe25cec5fb912d3f"
|
|||
|
||||
[[package]]
|
||||
name = "yoke"
|
||||
version = "0.8.2"
|
||||
version = "0.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca"
|
||||
checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
|
||||
dependencies = [
|
||||
"stable_deref_trait",
|
||||
"yoke-derive",
|
||||
|
|
@ -4385,18 +4201,18 @@ dependencies = [
|
|||
|
||||
[[package]]
|
||||
name = "zerocopy"
|
||||
version = "0.8.48"
|
||||
version = "0.8.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9"
|
||||
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1"
|
||||
dependencies = [
|
||||
"zerocopy-derive",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "zerocopy-derive"
|
||||
version = "0.8.48"
|
||||
version = "0.8.50"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4"
|
||||
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ members = [
|
|||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
version = "0.3.4"
|
||||
edition = "2021"
|
||||
license = "MIT"
|
||||
authors = ["Breadway"]
|
||||
|
|
@ -24,7 +24,7 @@ chrono = { version = "0.4", features = ["serde"] }
|
|||
rrule = "0.12"
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
zbus = { version = "4", default-features = false, features = ["tokio"] }
|
||||
ort = { version = "2.0.0-rc.12", 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"
|
||||
tokenizers = { version = "0.21", default-features = false, features = ["http", "fancy-regex"] }
|
||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||
|
|
|
|||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2024 Breadway
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
64
README.md
64
README.md
|
|
@ -67,7 +67,7 @@ User-defined tags can be added freely on top of the built-in types.
|
|||
- Inline editing — click any card to edit body, type, time, or recurrence
|
||||
- Mark todo/reminder as done; done items move to an archive accessible via a toggle
|
||||
- Search across all notes (full-text, instant)
|
||||
- Sort by: newest, oldest, due time
|
||||
- Sort: newest first (default)
|
||||
|
||||
### Theming
|
||||
|
||||
|
|
@ -83,8 +83,8 @@ User-defined tags can be added freely on top of the built-in types.
|
|||
Notes are stored as JSONL at `~/.local/share/breadpad/notes.jsonl` — one JSON object per line, human-readable, easy to back up or script against.
|
||||
|
||||
```jsonl
|
||||
{"id":"a1b2c3","body":"Pack calculator in bag","type":"reminder","time":"2026-05-25T19:00:00","rrule":null,"done":false,"workspace":"1","created":"2026-05-25T18:45:00","snoozed_until":null}
|
||||
{"id":"d4e5f6","body":"Look into relm4 reactive patterns","type":"idea","time":null,"rrule":null,"done":false,"workspace":"2","created":"2026-05-25T14:10:00","snoozed_until":null}
|
||||
{"id":"a1b2c3","body":"Pack calculator in bag","type":"reminder","time":"2026-05-25T19:00:00Z","rrule":null,"done":false,"workspace":"1","created":"2026-05-25T18:45:00Z","snoozed_until":null,"completed":null,"tags":[],"caldav_uid":null}
|
||||
{"id":"d4e5f6","body":"Look into relm4 reactive patterns","type":"idea","time":null,"rrule":null,"done":false,"workspace":"2","created":"2026-05-25T14:10:00Z","snoozed_until":null,"completed":null,"tags":[],"caldav_uid":null}
|
||||
```
|
||||
|
||||
Completed notes are never deleted — they gain `"done": true` and a `"completed"` timestamp. A separate `~/.local/share/breadpad/archive.jsonl` is written periodically for notes older than 30 days.
|
||||
|
|
@ -108,13 +108,7 @@ Returns a calibrated confidence. If ≥ 0.82, Tiers 2 and 3 are skipped.
|
|||
|
||||
Runs when Tier 1 confidence is below threshold. Responsible for **type classification only** — Tier 1's extracted time, recurrence rule, and cleaned body are always preserved.
|
||||
|
||||
Invoked via `ort` (ONNX Runtime Rust bindings). Execution provider order:
|
||||
|
||||
1. **QNN (Qualcomm/AMD XDNA NPU)** — requires `libQnnHtp.so` from the AMD Ryzen AI software stack
|
||||
2. **Vulkan** — iGPU via the ONNX Runtime Vulkan EP
|
||||
3. **CPU** — always available fallback
|
||||
|
||||
Active provider shown in `breadpad --status`.
|
||||
Invoked via `ort` (ONNX Runtime Rust bindings, `load-dynamic`) on the CPU. Requires an external `libonnxruntime.so`; set `model.ort_dylib_path` in `breadpad.toml` or let breadpad auto-discover it via `ORT_DYLIB_PATH`.
|
||||
|
||||
#### Tier 3 — Large local model via Ollama
|
||||
|
||||
|
|
@ -129,11 +123,10 @@ If Ollama is unreachable or returns an invalid response, breadpad logs a warning
|
|||
~/.local/share/breadpad/model/tokenizer.json
|
||||
```
|
||||
|
||||
breadpad ships without a bundled model. Run `breadpad download-model` to fetch a recommended quantised model, or drop your own ONNX model in the above path.
|
||||
breadpad ships without a bundled model. Drop a compatible ONNX classifier and `tokenizer.json` at those paths, then configure `model.ort_dylib_path` to point at your ONNX Runtime library.
|
||||
|
||||
```bash
|
||||
breadpad download-model # fetches default model (~150 MB)
|
||||
breadpad model-info # shows active EP, model path, last inference time
|
||||
breadpad model-info # shows active EP and model path
|
||||
```
|
||||
|
||||
---
|
||||
|
|
@ -144,9 +137,8 @@ breadpad model-info # shows active EP, model path, last inference time
|
|||
- GTK4 (≥ 4.12) + `gtk4-layer-shell`
|
||||
- D-Bus session bus (for notifications)
|
||||
- systemd user session (for timer-backed reminders)
|
||||
- Rust 1.77+
|
||||
- ONNX Runtime (`ort` crate pulls this in automatically via the `download-binaries` feature)
|
||||
- **NPU path only:** AMD Ryzen AI software stack (`amdxdna` kernel driver ≥ 6.11, QNN EP shared libs)
|
||||
- Rust 1.80+
|
||||
- **Tier 2 (ONNX classifier):** An external `libonnxruntime.so`. Set `model.ort_dylib_path` in `breadpad.toml`, or set `ORT_DYLIB_PATH` in your environment. Without a library, Tier 2 is disabled; Tier 1 + 3 still work.
|
||||
- **Tier 3 only (optional):** [Ollama](https://ollama.com) running locally with your chosen model pulled (`ollama pull llama3.2:3b`). Tier 3 is silently skipped if Ollama is not running.
|
||||
|
||||
---
|
||||
|
|
@ -160,8 +152,9 @@ cargo build --release
|
|||
cp target/release/breadpad ~/.local/bin/
|
||||
cp target/release/breadman ~/.local/bin/
|
||||
|
||||
# Fetch the default classifier model
|
||||
breadpad download-model
|
||||
# Place your ONNX classifier and tokenizer in the model directory
|
||||
mkdir -p ~/.local/share/breadpad/model
|
||||
# Then set model.ort_dylib_path in breadpad.toml to your libonnxruntime.so
|
||||
```
|
||||
|
||||
On Arch Linux, install GTK4 dependencies first:
|
||||
|
|
@ -186,7 +179,7 @@ archive_after_days = 30
|
|||
[model]
|
||||
path = "~/.local/share/breadpad/model/classifier.onnx"
|
||||
tokenizer = "~/.local/share/breadpad/model/tokenizer.json"
|
||||
execution_provider = "auto" # auto | npu | vulkan | cpu
|
||||
ort_dylib_path = "" # optional: explicit path to libonnxruntime.so; auto-discovered when empty
|
||||
|
||||
[model.ollama]
|
||||
endpoint = "http://localhost:11434"
|
||||
|
|
@ -197,8 +190,41 @@ enabled = true # set false to never call Ollama
|
|||
[reminders]
|
||||
default_morning = "08:00" # what "tomorrow morning" resolves to
|
||||
missed_grace_minutes = 60 # how long after boot to still fire a missed reminder
|
||||
|
||||
[calendar]
|
||||
enabled = false # turn on CalDAV sync (see below)
|
||||
url = "" # CalDAV calendar collection URL
|
||||
username = ""
|
||||
password = "" # app password / token recommended
|
||||
```
|
||||
|
||||
### Calendar sync (CalDAV)
|
||||
|
||||
When `[calendar].enabled = true`, reminders and dated notes are pushed to a
|
||||
CalDAV calendar as events (tracked by `caldav_uid` on each note), so they show
|
||||
up alongside the rest of your calendar.
|
||||
|
||||
1. Find your calendar's **collection URL**. It's the per-calendar CalDAV path,
|
||||
not the server root — e.g. Nextcloud:
|
||||
`https://host/remote.php/dav/calendars/<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
|
||||
|
|
|
|||
15
bakery.toml
Normal file
15
bakery.toml
Normal 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
BIN
bread.zip
Binary file not shown.
|
|
@ -20,3 +20,4 @@ tokio.workspace = true
|
|||
chrono.workspace = true
|
||||
gtk4.workspace = true
|
||||
dirs.workspace = true
|
||||
futures-channel = "0.3"
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
use breadpad_shared::{
|
||||
parser::parse_rule_based,
|
||||
scheduler::Scheduler,
|
||||
store::Store,
|
||||
types::{Note, NoteType, RecurrenceRule},
|
||||
};
|
||||
use chrono::{Local, TimeZone, Utc};
|
||||
use gtk4::prelude::*;
|
||||
use gtk4::{glib, prelude::*};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
|
|
@ -13,8 +14,9 @@ pub fn build_editor_popover(
|
|||
note: &Note,
|
||||
store: Arc<Store>,
|
||||
morning: String,
|
||||
on_save: impl Fn(Note) + 'static,
|
||||
on_delete: impl Fn() + 'static,
|
||||
on_save: Rc<dyn Fn(Note)>,
|
||||
on_delete: Rc<dyn Fn()>,
|
||||
on_error: Rc<dyn Fn(String)>,
|
||||
) -> gtk4::Popover {
|
||||
let popover = gtk4::Popover::new();
|
||||
popover.set_has_arrow(false);
|
||||
|
|
@ -86,7 +88,7 @@ pub fn build_editor_popover(
|
|||
btn_row.append(&save_btn);
|
||||
vbox.append(&btn_row);
|
||||
|
||||
// Delete: two-click confirm using a single handler and shared state
|
||||
// Delete: two-click confirm
|
||||
let confirming = Rc::new(RefCell::new(false));
|
||||
{
|
||||
let confirming = confirming.clone();
|
||||
|
|
@ -94,16 +96,32 @@ pub fn build_editor_popover(
|
|||
let note_id = note.id.clone();
|
||||
let store_del = store.clone();
|
||||
let popover_del = popover.clone();
|
||||
let on_delete = Rc::clone(&on_delete);
|
||||
let on_error = Rc::clone(&on_error);
|
||||
|
||||
delete_btn.connect_clicked(move |_| {
|
||||
let currently = *confirming.borrow();
|
||||
if currently {
|
||||
if let Err(e) = store_del.delete_note(¬e_id) {
|
||||
tracing::error!("failed to delete note: {}", e);
|
||||
} else {
|
||||
on_delete();
|
||||
}
|
||||
popover_del.popdown();
|
||||
if *confirming.borrow() {
|
||||
let store = store_del.clone();
|
||||
let id = note_id.clone();
|
||||
let on_delete = Rc::clone(&on_delete);
|
||||
let on_error = Rc::clone(&on_error);
|
||||
let popover = popover_del.clone();
|
||||
spawn_bg(
|
||||
move || -> anyhow::Result<()> {
|
||||
store.delete_note(&id)?;
|
||||
if let Err(e) = Scheduler::cancel(&id) {
|
||||
tracing::warn!("failed to cancel timer for {}: {}", id, e);
|
||||
}
|
||||
Ok(())
|
||||
},
|
||||
move |result| {
|
||||
match result {
|
||||
Ok(()) => on_delete(),
|
||||
Err(e) => on_error(format!("delete failed: {}", e)),
|
||||
}
|
||||
popover.popdown();
|
||||
},
|
||||
);
|
||||
} else {
|
||||
*confirming.borrow_mut() = true;
|
||||
delete_btn_label.set_label("Sure?");
|
||||
|
|
@ -112,46 +130,77 @@ pub fn build_editor_popover(
|
|||
}
|
||||
|
||||
// Save
|
||||
let note_clone = note.clone();
|
||||
let popover_save = popover.clone();
|
||||
{
|
||||
let note_clone = note.clone();
|
||||
let popover_save = popover.clone();
|
||||
let on_error = Rc::clone(&on_error);
|
||||
|
||||
save_btn.connect_clicked(move |_| {
|
||||
let mut updated = note_clone.clone();
|
||||
updated.body = body_entry.text().to_string();
|
||||
save_btn.connect_clicked(move |_| {
|
||||
// Read all field values on the main thread before handing off.
|
||||
let mut updated = note_clone.clone();
|
||||
updated.body = body_entry.text().to_string();
|
||||
updated.note_type = NoteType::from_str(
|
||||
NoteType::all_builtin()
|
||||
.get(type_combo.selected() as usize)
|
||||
.copied()
|
||||
.unwrap_or("note"),
|
||||
);
|
||||
let time_str = time_entry.text().to_string();
|
||||
updated.time = if time_str.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
parse_time_field(&time_str, &morning)
|
||||
};
|
||||
let rrule_text = rrule_entry.text().to_string();
|
||||
updated.rrule = if rrule_text.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(RecurrenceRule::new(rrule_text))
|
||||
};
|
||||
|
||||
updated.note_type = NoteType::from_str(
|
||||
NoteType::all_builtin()
|
||||
.get(type_combo.selected() as usize)
|
||||
.copied()
|
||||
.unwrap_or("note"),
|
||||
);
|
||||
popover_save.popdown();
|
||||
|
||||
let time_str = time_entry.text().to_string();
|
||||
updated.time = if time_str.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
parse_time_field(&time_str, &morning)
|
||||
};
|
||||
|
||||
let rrule_text = rrule_entry.text().to_string();
|
||||
updated.rrule = if rrule_text.trim().is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(RecurrenceRule::new(rrule_text))
|
||||
};
|
||||
|
||||
if let Err(e) = store.update_note(&updated) {
|
||||
tracing::error!("failed to update note: {}", e);
|
||||
} else {
|
||||
on_save(updated);
|
||||
}
|
||||
popover_save.popdown();
|
||||
});
|
||||
let store_bg = store.clone();
|
||||
let on_save = Rc::clone(&on_save);
|
||||
let on_error = Rc::clone(&on_error);
|
||||
spawn_bg(
|
||||
move || -> anyhow::Result<Note> {
|
||||
store_bg.update_note(&updated)?;
|
||||
if let Err(e) = Scheduler::cancel(&updated.id) {
|
||||
tracing::warn!("cancel before reschedule: {}", e);
|
||||
}
|
||||
if updated.time.is_some() || updated.rrule.is_some() {
|
||||
Scheduler::schedule(&updated)?;
|
||||
}
|
||||
Ok(updated)
|
||||
},
|
||||
move |result| match result {
|
||||
Ok(note) => on_save(note),
|
||||
Err(e) => on_error(format!("update failed: {}", e)),
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
popover.set_child(Some(&vbox));
|
||||
popover
|
||||
}
|
||||
|
||||
fn spawn_bg<F, T, C>(work: F, then: C)
|
||||
where
|
||||
F: FnOnce() -> T + Send + 'static,
|
||||
T: Send + 'static,
|
||||
C: FnOnce(T) + 'static,
|
||||
{
|
||||
let (tx, rx) = futures_channel::oneshot::channel::<T>();
|
||||
std::thread::spawn(move || { let _ = tx.send(work()); });
|
||||
glib::MainContext::default().spawn_local(async move {
|
||||
if let Ok(result) = rx.await {
|
||||
then(result);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
fn parse_time_field(s: &str, morning: &str) -> Option<chrono::DateTime<Utc>> {
|
||||
if let Ok(naive) = chrono::NaiveDateTime::parse_from_str(s.trim(), "%Y-%m-%d %H:%M") {
|
||||
if let chrono::LocalResult::Single(local) = Local.from_local_datetime(&naive) {
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ use breadpad_shared::{
|
|||
parser::parse_rule_based,
|
||||
scheduler::Scheduler,
|
||||
store::Store,
|
||||
theme::{build_css, load_palette},
|
||||
types::{Note, NoteType, RecurrenceRule},
|
||||
};
|
||||
use chrono::Local;
|
||||
|
|
@ -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 ───────────────────────────────────────────────────────────────────
|
||||
|
||||
fn refresh(state: &AppState) {
|
||||
|
|
@ -116,6 +139,16 @@ fn refresh(state: &AppState) {
|
|||
state.stack.set_visible_child_name(&active);
|
||||
}
|
||||
|
||||
/// Replace only the "all" stack page with a new list built from `notes`.
|
||||
/// All other pages are left untouched, preserving scroll position etc.
|
||||
fn rebuild_all_view(notes: &[Note], state: &AppState) {
|
||||
if let Some(child) = state.stack.child_by_name("all") {
|
||||
state.stack.remove(&child);
|
||||
}
|
||||
let scroll = build_note_list(notes, state.clone());
|
||||
state.stack.add_named(&scroll, Some("all"));
|
||||
}
|
||||
|
||||
fn rebuild_stack(state: &AppState) {
|
||||
while let Some(child) = state.stack.first_child() {
|
||||
state.stack.remove(&child);
|
||||
|
|
@ -208,9 +241,9 @@ fn cmd_upcoming_plain() -> Result<()> {
|
|||
&& n.effective_time().is_some()
|
||||
})
|
||||
.collect();
|
||||
notes.sort_by_key(|n| n.effective_time().unwrap());
|
||||
notes.sort_by_key(|n| n.effective_time().expect("filtered by is_some above"));
|
||||
for note in ¬es {
|
||||
let t = note.effective_time().unwrap();
|
||||
let t = note.effective_time().expect("filtered by is_some above");
|
||||
let local: chrono::DateTime<Local> = t.into();
|
||||
println!("[{}] {} — {}", note.id, local.format("%a %b %d %H:%M"), note.body);
|
||||
}
|
||||
|
|
@ -272,10 +305,10 @@ fn build_app_window(
|
|||
let new_note_btn = gtk4::Button::builder()
|
||||
.label("✚ New Note")
|
||||
.css_classes(["confirm-button"])
|
||||
.margin_start(10)
|
||||
.margin_end(10)
|
||||
.margin_top(12)
|
||||
.margin_bottom(6)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_top(16)
|
||||
.margin_bottom(12)
|
||||
.build();
|
||||
sidebar_vbox.append(&new_note_btn);
|
||||
|
||||
|
|
@ -349,10 +382,10 @@ fn build_app_window(
|
|||
let search_entry = gtk4::SearchEntry::builder()
|
||||
.placeholder_text("Search notes…")
|
||||
.css_classes(["search-entry"])
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.margin_top(8)
|
||||
.margin_bottom(4)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.margin_top(12)
|
||||
.margin_bottom(8)
|
||||
.build();
|
||||
|
||||
let stack = gtk4::Stack::builder().hexpand(true).vexpand(true).build();
|
||||
|
|
@ -401,36 +434,8 @@ fn build_app_window(
|
|||
.filter(|n| n.body.to_lowercase().contains(&q))
|
||||
.collect()
|
||||
};
|
||||
|
||||
// Replace the "all" page with the filtered list while preserving others
|
||||
while let Some(child) = state_c.stack.first_child() {
|
||||
state_c.stack.remove(&child);
|
||||
}
|
||||
let all_scroll = build_note_list(&filtered, state_c.clone());
|
||||
state_c.stack.add_named(&all_scroll, Some("all"));
|
||||
|
||||
let notes_snap = state_c.notes.borrow().clone();
|
||||
let cfg_snap = state_c.cfg.borrow().clone();
|
||||
let errors_snap = state_c.errors.borrow().clone();
|
||||
|
||||
let upcoming = views::upcoming::build(¬es_snap);
|
||||
state_c.stack.add_named(&upcoming, Some("upcoming"));
|
||||
for type_name in NoteType::all_builtin() {
|
||||
let nt = NoteType::from_str(type_name);
|
||||
let typed: Vec<Note> = notes_snap
|
||||
.iter()
|
||||
.filter(|n| n.note_type == nt && !n.done)
|
||||
.cloned()
|
||||
.collect();
|
||||
state_c.stack.add_named(&build_note_list(&typed, state_c.clone()), Some(type_name));
|
||||
}
|
||||
state_c.stack.add_named(&views::archive::build(¬es_snap, state_c.clone()), Some("archive"));
|
||||
let state_s = state_c.clone();
|
||||
state_c.stack.add_named(
|
||||
&views::settings::build(&cfg_snap, move |nc| { *state_s.cfg.borrow_mut() = nc; }),
|
||||
Some("settings"),
|
||||
);
|
||||
state_c.stack.add_named(&views::errors::build(&errors_snap), Some("errors"));
|
||||
// Only replace the "all" page — other views keep their scroll position.
|
||||
rebuild_all_view(&filtered, &state_c);
|
||||
state_c.stack.set_visible_child_name("all");
|
||||
});
|
||||
}
|
||||
|
|
@ -475,9 +480,11 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow {
|
|||
|
||||
let list = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.spacing(8)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(12)
|
||||
.margin_end(12)
|
||||
.build();
|
||||
|
||||
let mut sorted: Vec<Note> = notes.iter().filter(|n| !n.done).cloned().collect();
|
||||
|
|
@ -503,11 +510,11 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow {
|
|||
fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
|
||||
let card = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.margin_top(4)
|
||||
.margin_bottom(4)
|
||||
.spacing(8)
|
||||
.margin_start(0)
|
||||
.margin_end(0)
|
||||
.margin_top(0)
|
||||
.margin_bottom(0)
|
||||
.css_classes(["note-card"])
|
||||
.build();
|
||||
card.add_css_class(&format!("note-card-{}", note.note_type.as_str()));
|
||||
|
|
@ -590,14 +597,30 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
|
|||
let card_c = card.clone();
|
||||
let state_c = state.clone();
|
||||
done_btn.connect_clicked(move |_| {
|
||||
if let Ok(Some(mut n)) = state_c.store.get_by_id(¬e_id) {
|
||||
n.mark_done();
|
||||
if let Err(e) = state_c.store.update_note(&n) {
|
||||
state_c.log_error(format!("mark done failed: {}", e));
|
||||
}
|
||||
}
|
||||
card_c.set_visible(false);
|
||||
state_c.reload_notes();
|
||||
card_c.set_visible(false); // optimistic hide
|
||||
let store = state_c.write_store();
|
||||
let id = note_id.clone();
|
||||
let state = state_c.clone();
|
||||
spawn_bg(
|
||||
move || -> anyhow::Result<Vec<Note>> {
|
||||
if let Some(mut n) = store.get_by_id(&id)? {
|
||||
n.mark_done();
|
||||
store.update_note(&n)?;
|
||||
}
|
||||
store.load_all()
|
||||
},
|
||||
move |result| {
|
||||
match result {
|
||||
Ok(fresh) => {
|
||||
*state.notes.borrow_mut() = fresh;
|
||||
rebuild_stack(&state);
|
||||
let active = state.active_view.borrow().clone();
|
||||
state.stack.set_visible_child_name(&active);
|
||||
}
|
||||
Err(e) => state.log_error(format!("mark done failed: {}", e)),
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
bottom_row.append(&done_btn);
|
||||
|
|
@ -622,19 +645,29 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
|
|||
let body_label_save = body_label_c.clone();
|
||||
let state_del = state_c.clone();
|
||||
let card_del = card_c.clone();
|
||||
let state_err = state_c.clone();
|
||||
|
||||
let popover = editor::build_editor_popover(
|
||||
¬e_c,
|
||||
store,
|
||||
morning,
|
||||
move |updated: Note| {
|
||||
Rc::new(move |updated: Note| {
|
||||
body_label_save.set_label(&updated.body);
|
||||
state_save.reload_notes();
|
||||
},
|
||||
move || {
|
||||
rebuild_stack(&state_save);
|
||||
let active = state_save.active_view.borrow().clone();
|
||||
state_save.stack.set_visible_child_name(&active);
|
||||
}),
|
||||
Rc::new(move || {
|
||||
card_del.set_visible(false);
|
||||
state_del.reload_notes();
|
||||
},
|
||||
rebuild_stack(&state_del);
|
||||
let active = state_del.active_view.borrow().clone();
|
||||
state_del.stack.set_visible_child_name(&active);
|
||||
}),
|
||||
Rc::new(move |e: String| {
|
||||
state_err.log_error(e);
|
||||
}),
|
||||
);
|
||||
popover.set_parent(btn);
|
||||
popover.popup();
|
||||
|
|
@ -659,12 +692,30 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
|
|||
|
||||
delete_btn.connect_clicked(move |_| {
|
||||
if *confirming.borrow() {
|
||||
card_c.set_visible(false); // optimistic hide
|
||||
let store = state_c.write_store();
|
||||
if let Err(e) = store.delete_note(¬e_id) {
|
||||
state_c.log_error(format!("delete failed: {}", e));
|
||||
}
|
||||
card_c.set_visible(false);
|
||||
state_c.reload_notes();
|
||||
let id = note_id.clone();
|
||||
let state = state_c.clone();
|
||||
spawn_bg(
|
||||
move || -> anyhow::Result<Vec<Note>> {
|
||||
store.delete_note(&id)?;
|
||||
if let Err(e) = Scheduler::cancel(&id) {
|
||||
tracing::warn!("failed to cancel timer for {}: {}", id, e);
|
||||
}
|
||||
store.load_all()
|
||||
},
|
||||
move |result| {
|
||||
match result {
|
||||
Ok(fresh) => {
|
||||
*state.notes.borrow_mut() = fresh;
|
||||
rebuild_stack(&state);
|
||||
let active = state.active_view.borrow().clone();
|
||||
state.stack.set_visible_child_name(&active);
|
||||
}
|
||||
Err(e) => state.log_error(format!("delete failed: {}", e)),
|
||||
}
|
||||
},
|
||||
);
|
||||
} else {
|
||||
*confirming.borrow_mut() = true;
|
||||
btn_c.set_label("Sure?");
|
||||
|
|
@ -779,108 +830,88 @@ fn show_add_note_window(parent: >k4::ApplicationWindow, state: AppState) {
|
|||
cancel_btn.connect_clicked(move |_| win_c.close());
|
||||
}
|
||||
|
||||
// Add Note
|
||||
{
|
||||
let win_c = win.clone();
|
||||
let state_c = state.clone();
|
||||
let body_c = body_entry.clone();
|
||||
let time_c = time_entry.clone();
|
||||
let rrule_c = rrule_entry.clone();
|
||||
let sel_c = selected_type.clone();
|
||||
let status_c = status_label.clone();
|
||||
// Shared add-note logic — called by both the button and the Enter key.
|
||||
let do_add: Rc<dyn Fn()> = Rc::new({
|
||||
let win = win.clone();
|
||||
let state = state.clone();
|
||||
let body_entry = body_entry.clone();
|
||||
let time_entry = time_entry.clone();
|
||||
let rrule_entry = rrule_entry.clone();
|
||||
let selected_type = selected_type.clone();
|
||||
let status_label = status_label.clone();
|
||||
|
||||
let do_add = move || {
|
||||
let body_text = body_c.text().to_string();
|
||||
move || {
|
||||
let body_text = body_entry.text().to_string();
|
||||
if body_text.trim().is_empty() {
|
||||
status_c.set_label("Body is required.");
|
||||
status_label.set_label("Body is required.");
|
||||
return;
|
||||
}
|
||||
|
||||
let morning = state_c.cfg.borrow().reminders.default_morning.clone();
|
||||
|
||||
// Tier 1 classification on body
|
||||
let morning = state.cfg.borrow().reminders.default_morning.clone();
|
||||
let parsed = parse_rule_based(&body_text, &morning);
|
||||
|
||||
let user_type = sel_c.borrow().clone();
|
||||
let default_type = NoteType::from_str(&state_c.cfg.borrow().settings.default_type);
|
||||
let user_type = selected_type.borrow().clone();
|
||||
let default_type = NoteType::from_str(&state.cfg.borrow().settings.default_type);
|
||||
|
||||
let mut note = Note::new(parsed.body.clone(), user_type.clone(), None);
|
||||
// Use parsed type if user left it at the default
|
||||
if user_type == default_type {
|
||||
note.note_type = parsed.note_type;
|
||||
}
|
||||
note.time = parsed.time;
|
||||
note.rrule = parsed.rrule;
|
||||
|
||||
// Time field overrides
|
||||
let time_str = time_c.text().to_string();
|
||||
let time_str = time_entry.text().to_string();
|
||||
if !time_str.trim().is_empty() {
|
||||
let tp = parse_rule_based(&time_str, &morning);
|
||||
if tp.time.is_some() { note.time = tp.time; }
|
||||
if tp.rrule.is_some() { note.rrule = tp.rrule; }
|
||||
}
|
||||
|
||||
// RRULE field overrides
|
||||
let rrule_str = rrule_c.text().to_string();
|
||||
let rrule_str = rrule_entry.text().to_string();
|
||||
if !rrule_str.trim().is_empty() {
|
||||
note.rrule = Some(RecurrenceRule::new(rrule_str));
|
||||
}
|
||||
|
||||
let store = state_c.write_store();
|
||||
if let Err(e) = store.save_note(¬e) {
|
||||
state_c.log_error(format!("save failed: {}", e));
|
||||
return;
|
||||
}
|
||||
if note.time.is_some() {
|
||||
if let Err(e) = Scheduler::schedule(¬e) {
|
||||
state_c.log_error(format!("schedule failed: {}", e));
|
||||
}
|
||||
}
|
||||
let store = state.write_store();
|
||||
win.close();
|
||||
|
||||
win_c.close();
|
||||
// Defer refresh so the window close event is processed first
|
||||
let state_refresh = state_c.clone();
|
||||
glib::idle_add_local_once(move || refresh(&state_refresh));
|
||||
};
|
||||
let state_bg = state.clone();
|
||||
spawn_bg(
|
||||
move || -> anyhow::Result<Vec<Note>> {
|
||||
store.save_note(¬e)?;
|
||||
if note.time.is_some() || note.rrule.is_some() {
|
||||
if let Err(e) = Scheduler::cancel(¬e.id) {
|
||||
tracing::warn!("cancel before schedule: {}", e);
|
||||
}
|
||||
Scheduler::schedule(¬e)?;
|
||||
}
|
||||
store.load_all()
|
||||
},
|
||||
move |result| {
|
||||
match result {
|
||||
Ok(fresh) => {
|
||||
*state_bg.notes.borrow_mut() = fresh;
|
||||
rebuild_stack(&state_bg);
|
||||
let active = state_bg.active_view.borrow().clone();
|
||||
state_bg.stack.set_visible_child_name(&active);
|
||||
}
|
||||
Err(e) => state_bg.log_error(format!("save failed: {}", e)),
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
{
|
||||
let do_add = Rc::clone(&do_add);
|
||||
add_btn.connect_clicked(move |_| do_add());
|
||||
}
|
||||
|
||||
// Also trigger add on Enter in body field
|
||||
{
|
||||
let win_c2 = win.clone();
|
||||
let state_c2 = state.clone();
|
||||
let body_c2 = body_entry.clone();
|
||||
let time_c2 = time_entry.clone();
|
||||
let rrule_c2 = rrule_entry.clone();
|
||||
let sel_c2 = selected_type.clone();
|
||||
|
||||
let do_add = Rc::clone(&do_add);
|
||||
let time_entry = time_entry.clone();
|
||||
let rrule_entry = rrule_entry.clone();
|
||||
body_entry.connect_activate(move |_| {
|
||||
// If time/rrule fields are empty, submit immediately
|
||||
if time_c2.text().is_empty() && rrule_c2.text().is_empty() {
|
||||
let body_text = body_c2.text().to_string();
|
||||
if body_text.trim().is_empty() { return; }
|
||||
let morning = state_c2.cfg.borrow().reminders.default_morning.clone();
|
||||
let parsed = parse_rule_based(&body_text, &morning);
|
||||
let user_type = sel_c2.borrow().clone();
|
||||
let default_type = NoteType::from_str(&state_c2.cfg.borrow().settings.default_type);
|
||||
let mut note = Note::new(parsed.body.clone(), user_type.clone(), None);
|
||||
if user_type == default_type { note.note_type = parsed.note_type; }
|
||||
note.time = parsed.time;
|
||||
note.rrule = parsed.rrule;
|
||||
let store = state_c2.write_store();
|
||||
if let Err(e) = store.save_note(¬e) {
|
||||
state_c2.log_error(format!("save failed: {}", e));
|
||||
return;
|
||||
}
|
||||
if note.time.is_some() {
|
||||
if let Err(e) = Scheduler::schedule(¬e) {
|
||||
state_c2.log_error(format!("schedule failed: {}", e));
|
||||
}
|
||||
}
|
||||
win_c2.close();
|
||||
let sr = state_c2.clone();
|
||||
glib::idle_add_local_once(move || refresh(&sr));
|
||||
if time_entry.text().is_empty() && rrule_entry.text().is_empty() {
|
||||
do_add();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
@ -892,15 +923,7 @@ fn show_add_note_window(parent: >k4::ApplicationWindow, state: AppState) {
|
|||
// ── CSS ───────────────────────────────────────────────────────────────────────
|
||||
|
||||
fn apply_css(_cfg: &Config) {
|
||||
let palette = load_palette();
|
||||
let user_css = std::fs::read_to_string(breadpad_shared::config::style_css_path()).ok();
|
||||
let css = build_css(&palette, user_css.as_deref());
|
||||
|
||||
let provider = gtk4::CssProvider::new();
|
||||
provider.load_from_string(&css);
|
||||
gtk4::style_context_add_provider_for_display(
|
||||
>k4::gdk::Display::default().unwrap(),
|
||||
&provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
// Hot-reloads on `bread-theme reload` (recolours to the new pywal palette
|
||||
// and re-reads the user's style.css). See breadpad_shared::theme::apply_live.
|
||||
breadpad_shared::theme::apply_live();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -79,14 +79,11 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
|
|||
.build();
|
||||
attach_row(&model_grid, 1, "Tokenizer path", &tokenizer_entry);
|
||||
|
||||
let ep_options = ["auto", "npu", "vulkan", "cpu"];
|
||||
let ep_combo = gtk4::DropDown::from_strings(&ep_options);
|
||||
let ep_idx = ep_options
|
||||
.iter()
|
||||
.position(|&s| s == cfg.model.execution_provider.as_str())
|
||||
.unwrap_or(0) as u32;
|
||||
ep_combo.set_selected(ep_idx);
|
||||
attach_row(&model_grid, 2, "Execution provider", &ep_combo);
|
||||
let ort_dylib_entry = gtk4::Entry::builder()
|
||||
.text(&cfg.model.ort_dylib_path)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
attach_row(&model_grid, 2, "ORT dylib path", &ort_dylib_entry);
|
||||
|
||||
outer.append(&model_frame);
|
||||
|
||||
|
|
@ -168,7 +165,7 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
|
|||
let grs = grace_spin.clone();
|
||||
let mpe = model_path_entry.clone();
|
||||
let tke = tokenizer_entry.clone();
|
||||
let epc = ep_combo.clone();
|
||||
let ode = ort_dylib_entry.clone();
|
||||
let oec = ollama_enabled.clone();
|
||||
let oee = ollama_endpoint.clone();
|
||||
let ome = ollama_model.clone();
|
||||
|
|
@ -203,11 +200,7 @@ pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::Scrolled
|
|||
model: ModelConfig {
|
||||
path: mpe.text().to_string(),
|
||||
tokenizer: tke.text().to_string(),
|
||||
execution_provider: ep_options
|
||||
.get(epc.selected() as usize)
|
||||
.copied()
|
||||
.unwrap_or("auto")
|
||||
.to_string(),
|
||||
ort_dylib_path: ode.text().to_string(),
|
||||
ollama: OllamaConfig {
|
||||
enabled: oec.is_active(),
|
||||
endpoint: oee.text().to_string(),
|
||||
|
|
|
|||
|
|
@ -5,7 +5,9 @@ edition.workspace = true
|
|||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
|
||||
[dependencies]
|
||||
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] }
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
serde.workspace = true
|
||||
|
|
|
|||
|
|
@ -70,10 +70,9 @@ impl OllamaClient {
|
|||
.into_json()
|
||||
.map_err(|e| anyhow::anyhow!("deserialize Ollama envelope: {}", e))?;
|
||||
|
||||
let classification: OllamaClassification = serde_json::from_str(&ollama_resp.response)
|
||||
.map_err(|e| anyhow::anyhow!(
|
||||
"parse Ollama classification JSON: {} — raw: {:?}",
|
||||
e,
|
||||
let classification: OllamaClassification = extract_json(&ollama_resp.response)
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"no JSON object found in response — raw: {:?}",
|
||||
&ollama_resp.response
|
||||
))?;
|
||||
|
||||
|
|
@ -116,3 +115,12 @@ impl OllamaClient {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Some backends (e.g. FastFlowLM) ignore `"format": "json"` and may wrap the
|
||||
// JSON in prose. Find the first `{...}` span and parse that.
|
||||
fn extract_json<T: serde::de::DeserializeOwned>(s: &str) -> Option<T> {
|
||||
let start = s.find('{')?;
|
||||
let end = s.rfind('}')?;
|
||||
if end < start { return None; }
|
||||
serde_json::from_str(&s[start..=end]).ok()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,14 @@ pub struct CalDavEventInfo {
|
|||
|
||||
impl CalDavClient {
|
||||
pub fn new(config: CalendarConfig) -> Self {
|
||||
// `reqwest::Client::builder().build()` can only fail if the TLS backend can't be
|
||||
// initialised; fall back to `Client::new()` semantics rather than panicking.
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.expect("failed to build HTTP client");
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("falling back to default HTTP client: {}", e);
|
||||
reqwest::Client::new()
|
||||
});
|
||||
CalDavClient { config, client }
|
||||
}
|
||||
|
||||
|
|
@ -139,28 +144,56 @@ fn event_url(base: &str, uid: &str) -> String {
|
|||
fn build_ical(note: &Note, uid: &str) -> String {
|
||||
let dt = note.time.unwrap_or(note.created);
|
||||
let dtstart = dt.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let summary = escape_ical(¬e.body);
|
||||
let description = escape_ical(&format!("type={}", note.note_type.as_str()));
|
||||
let dtstamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
|
||||
|
||||
let mut ical = format!(
|
||||
"BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
PRODID:-//breadpad//EN\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
UID:{uid}\r\n\
|
||||
SUMMARY:{summary}\r\n\
|
||||
DTSTART:{dtstart}\r\n\
|
||||
DTEND:{dtstart}\r\n\
|
||||
DESCRIPTION:{description}\r\n"
|
||||
);
|
||||
let mut lines: Vec<String> = vec![
|
||||
"BEGIN:VCALENDAR".into(),
|
||||
"VERSION:2.0".into(),
|
||||
"PRODID:-//breadpad//EN".into(),
|
||||
"BEGIN:VEVENT".into(),
|
||||
format!("UID:{}", uid),
|
||||
fold_line(&format!("SUMMARY:{}", escape_ical(¬e.body))),
|
||||
format!("DTSTART:{}", dtstart),
|
||||
format!("DTEND:{}", dtstart),
|
||||
format!("DTSTAMP:{}", dtstamp),
|
||||
fold_line(&format!("DESCRIPTION:{}", escape_ical(&format!("type={}", note.note_type.as_str())))),
|
||||
];
|
||||
|
||||
if let Some(rrule) = ¬e.rrule {
|
||||
ical.push_str(rrule.as_str());
|
||||
ical.push_str("\r\n");
|
||||
lines.push(rrule.as_str().to_string());
|
||||
}
|
||||
|
||||
ical.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n");
|
||||
ical
|
||||
lines.push("END:VEVENT".into());
|
||||
lines.push("END:VCALENDAR".into());
|
||||
|
||||
lines.join("\r\n") + "\r\n"
|
||||
}
|
||||
|
||||
/// Fold an iCal property line per RFC 5545 §3.1: lines longer than 75 octets
|
||||
/// are split with CRLF + a single space continuation character.
|
||||
fn fold_line(line: &str) -> String {
|
||||
let bytes = line.as_bytes();
|
||||
if bytes.len() <= 75 {
|
||||
return line.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(line.len() + line.len() / 75 * 3);
|
||||
let mut pos = 0;
|
||||
let mut first = true;
|
||||
while pos < bytes.len() {
|
||||
if !first {
|
||||
out.push_str("\r\n ");
|
||||
}
|
||||
let limit = if first { 75 } else { 74 }; // continuation lines lose 1 octet to the space
|
||||
let mut end = (pos + limit).min(bytes.len());
|
||||
// Step back if we landed in the middle of a multi-byte UTF-8 sequence.
|
||||
while end > pos && end < bytes.len() && (bytes[end] & 0xC0) == 0x80 {
|
||||
end -= 1;
|
||||
}
|
||||
out.push_str(std::str::from_utf8(&bytes[pos..end]).unwrap_or(""));
|
||||
pos = end;
|
||||
first = false;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn escape_ical(s: &str) -> String {
|
||||
|
|
@ -219,3 +252,241 @@ fn parse_ical_block(data: &str) -> Vec<CalDavEventInfo> {
|
|||
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{Note, NoteType, RecurrenceRule};
|
||||
use chrono::{TimeZone, Utc};
|
||||
|
||||
fn reminder(body: &str) -> Note {
|
||||
let mut n = Note::new(body.into(), NoteType::Reminder, None);
|
||||
n.time = Some(Utc::now());
|
||||
n
|
||||
}
|
||||
|
||||
// ---- escape_ical ----
|
||||
|
||||
#[test]
|
||||
fn escape_ical_clean_string_unchanged() {
|
||||
assert_eq!(escape_ical("hello world"), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_ical_empty_string() {
|
||||
assert_eq!(escape_ical(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_ical_escapes_backslash() {
|
||||
assert_eq!(escape_ical("back\\slash"), "back\\\\slash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_ical_escapes_semicolon() {
|
||||
assert_eq!(escape_ical("a;b"), "a\\;b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_ical_escapes_comma() {
|
||||
assert_eq!(escape_ical("apples,oranges"), "apples\\,oranges");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_ical_escapes_newline() {
|
||||
assert_eq!(escape_ical("line1\nline2"), "line1\\nline2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_ical_multiple_special_chars() {
|
||||
assert_eq!(escape_ical("a;b,c\nd"), "a\\;b\\,c\\nd");
|
||||
}
|
||||
|
||||
// ---- caldav_uid ----
|
||||
|
||||
#[test]
|
||||
fn caldav_uid_uses_existing_field() {
|
||||
let mut n = Note::new("test".into(), NoteType::Reminder, None);
|
||||
n.caldav_uid = Some("my-custom-uid".into());
|
||||
assert_eq!(caldav_uid(&n), "my-custom-uid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caldav_uid_falls_back_to_id_at_breadpad() {
|
||||
let n = Note::new("test".into(), NoteType::Reminder, None);
|
||||
assert_eq!(caldav_uid(&n), format!("{}@breadpad", n.id));
|
||||
}
|
||||
|
||||
// ---- event_url ----
|
||||
|
||||
#[test]
|
||||
fn event_url_with_trailing_slash() {
|
||||
let url = event_url("https://cloud.example.com/cal/", "abc@breadpad");
|
||||
assert_eq!(url, "https://cloud.example.com/cal/abc@breadpad.ics");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_url_without_trailing_slash() {
|
||||
let url = event_url("https://cloud.example.com/cal", "abc@breadpad");
|
||||
assert_eq!(url, "https://cloud.example.com/cal/abc@breadpad.ics");
|
||||
}
|
||||
|
||||
// ---- build_ical ----
|
||||
|
||||
#[test]
|
||||
fn build_ical_contains_vcalendar_markers() {
|
||||
let n = reminder("team sync");
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("BEGIN:VCALENDAR"), "missing BEGIN:VCALENDAR");
|
||||
assert!(ical.contains("END:VCALENDAR"), "missing END:VCALENDAR");
|
||||
assert!(ical.contains("BEGIN:VEVENT"), "missing BEGIN:VEVENT");
|
||||
assert!(ical.contains("END:VEVENT"), "missing END:VEVENT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_contains_uid() {
|
||||
let n = reminder("team sync");
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains(&format!("UID:{}", uid)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_contains_summary() {
|
||||
let n = reminder("team sync");
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("SUMMARY:team sync"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_description_contains_type() {
|
||||
let n = reminder("team sync");
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("DESCRIPTION:type=reminder"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_uses_note_time_for_dtstart() {
|
||||
let mut n = Note::new("dentist".into(), NoteType::Reminder, None);
|
||||
n.time = Some(Utc.with_ymd_and_hms(2026, 6, 15, 14, 30, 0).unwrap());
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("DTSTART:20260615T143000Z"), "ical: {}", &ical[..400]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_falls_back_to_created_when_no_time() {
|
||||
let n = Note::new("no time set".into(), NoteType::Reminder, None);
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("DTSTART:"), "DTSTART should be present");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_includes_rrule_when_set() {
|
||||
let mut n = reminder("standup");
|
||||
n.rrule = Some(RecurrenceRule::new("RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0"));
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=MO"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_no_rrule_when_not_set() {
|
||||
let n = reminder("one-off");
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(!ical.contains("RRULE:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_escapes_special_chars_in_summary() {
|
||||
let n = Note::new("dentist; bring card, and ID".into(), NoteType::Reminder, None);
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("SUMMARY:dentist\\; bring card\\, and ID"), "ical: {}", &ical[..400]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_contains_dtstamp() {
|
||||
let n = reminder("team sync");
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("DTSTAMP:"), "missing DTSTAMP in:\n{}", ical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_line_short_unchanged() {
|
||||
let line = "SUMMARY:short";
|
||||
assert_eq!(fold_line(line), line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_line_exactly_75_unchanged() {
|
||||
let line = "A".repeat(75);
|
||||
assert_eq!(fold_line(&line), line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_line_76_chars_splits() {
|
||||
let line = "X".repeat(76);
|
||||
let folded = fold_line(&line);
|
||||
assert!(folded.contains("\r\n "), "expected fold in: {:?}", folded);
|
||||
// Reassembled content should equal the original.
|
||||
let rejoined: String = folded.split("\r\n ").collect();
|
||||
assert_eq!(rejoined, line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_long_summary_is_folded() {
|
||||
let long_body = "a".repeat(200);
|
||||
let n = Note::new(long_body.clone(), NoteType::Reminder, None);
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
// Every line (split on CRLF) must be at most 75 octets.
|
||||
for line in ical.split("\r\n") {
|
||||
assert!(
|
||||
line.len() <= 75,
|
||||
"line too long ({} octets): {:?}",
|
||||
line.len(),
|
||||
line
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- parse_report_response ----
|
||||
|
||||
#[test]
|
||||
fn parse_report_response_empty_xml_returns_empty() {
|
||||
let events = parse_report_response("").unwrap();
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_report_response_single_event() {
|
||||
let xml = "\
|
||||
BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
UID:abc123@breadpad\r\n\
|
||||
SUMMARY:team sync\r\n\
|
||||
DTSTART:20260615T140000Z\r\n\
|
||||
DTEND:20260615T140000Z\r\n\
|
||||
END:VEVENT\r\n\
|
||||
END:VCALENDAR\r\n";
|
||||
let events = parse_report_response(xml).unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].uid, "abc123@breadpad");
|
||||
assert_eq!(events[0].summary, "team sync");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_report_response_no_vcalendar_block_returns_empty() {
|
||||
let xml = "<multistatus><response><status>HTTP/1.1 200 OK</status></response></multistatus>";
|
||||
let events = parse_report_response(xml).unwrap();
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,16 +9,14 @@ const TIER1_SKIP_THRESHOLD: f32 = 0.82;
|
|||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ExecutionProvider {
|
||||
Qnn,
|
||||
Vulkan,
|
||||
Gpu,
|
||||
Cpu,
|
||||
}
|
||||
|
||||
impl ExecutionProvider {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
ExecutionProvider::Qnn => "QNN (NPU)",
|
||||
ExecutionProvider::Vulkan => "Vulkan",
|
||||
ExecutionProvider::Gpu => "ROCm (iGPU)",
|
||||
ExecutionProvider::Cpu => "CPU",
|
||||
}
|
||||
}
|
||||
|
|
@ -43,20 +41,27 @@ fn model_dir() -> PathBuf {
|
|||
impl Classifier {
|
||||
/// Load with Tier 1 + optional Tier 2 (ONNX). Tier 3 disabled unless
|
||||
/// `.with_ollama()` is called on the returned value.
|
||||
pub fn load(ep_pref: &str, default_morning: &str) -> Self {
|
||||
pub fn load(default_morning: &str) -> Self {
|
||||
let dir = model_dir();
|
||||
let onnx_path = dir.join("classifier.onnx");
|
||||
let tok_path = dir.join("tokenizer.json");
|
||||
Self::load_with_paths(default_morning, onnx_path, tok_path)
|
||||
}
|
||||
|
||||
let (session, active_provider) = if onnx_path.exists() {
|
||||
try_load_session(&onnx_path, ep_pref)
|
||||
pub fn load_with_paths(
|
||||
default_morning: &str,
|
||||
model_path: PathBuf,
|
||||
tokenizer_path: PathBuf,
|
||||
) -> Self {
|
||||
let (session, active_provider) = if model_path.exists() {
|
||||
try_load_session(&model_path)
|
||||
} else {
|
||||
tracing::warn!("model not found at {:?}; Tier 2 disabled", onnx_path);
|
||||
tracing::warn!("model not found at {:?}; Tier 2 disabled", model_path);
|
||||
(None, ExecutionProvider::Cpu)
|
||||
};
|
||||
|
||||
let tokenizer = if tok_path.exists() && session.is_some() {
|
||||
match tokenizers::Tokenizer::from_file(&tok_path) {
|
||||
let tokenizer = if tokenizer_path.exists() && session.is_some() {
|
||||
match tokenizers::Tokenizer::from_file(&tokenizer_path) {
|
||||
Ok(tok) => Some(tok),
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to load tokenizer: {}", e);
|
||||
|
|
@ -71,7 +76,7 @@ impl Classifier {
|
|||
session,
|
||||
tokenizer,
|
||||
active_provider,
|
||||
model_path: onnx_path,
|
||||
model_path,
|
||||
default_morning: default_morning.to_string(),
|
||||
ollama: None,
|
||||
}
|
||||
|
|
@ -144,6 +149,13 @@ impl Classifier {
|
|||
pub fn model_available(&self) -> bool {
|
||||
self.session.is_some()
|
||||
}
|
||||
|
||||
/// Run only the ONNX model (Tier 2) with no Tier 1 pre-processing or fallback.
|
||||
/// Returns `None` if no model is loaded.
|
||||
pub fn classify_tier2_only(&mut self, text: &str) -> Option<ClassificationResult> {
|
||||
let (session, tokenizer) = (self.session.as_mut()?, self.tokenizer.as_ref()?);
|
||||
run_onnx(session, tokenizer, text).ok()
|
||||
}
|
||||
}
|
||||
|
||||
// NLI hypotheses paired with their note types. The model scores each as
|
||||
|
|
@ -204,7 +216,7 @@ fn run_onnx(
|
|||
let best_idx = entailment_scores
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Less))
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(3);
|
||||
|
||||
|
|
@ -233,52 +245,40 @@ fn softmax_single(logits: &[f32], idx: usize) -> f32 {
|
|||
|
||||
fn try_load_session(
|
||||
path: &std::path::Path,
|
||||
ep_pref: &str,
|
||||
) -> (Option<ort::session::Session>, ExecutionProvider) {
|
||||
let providers: &[(&str, ExecutionProvider)] = &[
|
||||
("qnn", ExecutionProvider::Qnn),
|
||||
("vulkan", ExecutionProvider::Vulkan),
|
||||
("cpu", ExecutionProvider::Cpu),
|
||||
];
|
||||
|
||||
let to_try: Vec<&(&str, ExecutionProvider)> = match ep_pref {
|
||||
"npu" => providers[..1].iter().collect(),
|
||||
"vulkan" => providers[1..2].iter().collect(),
|
||||
"cpu" => providers[2..].iter().collect(),
|
||||
_ => providers.iter().collect(),
|
||||
// Try ROCm (iGPU) first, fall back to CPU.
|
||||
let rocm_available = {
|
||||
use ort::execution_providers::ExecutionProvider as _;
|
||||
ort::ep::ROCm::default().is_available().unwrap_or(false)
|
||||
};
|
||||
|
||||
for (ep_name, ep) in to_try {
|
||||
match build_session(path, ep_name) {
|
||||
Ok(session) => {
|
||||
tracing::info!("ONNX session loaded with {} EP", ep.as_str());
|
||||
return (Some(session), ep.clone());
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::debug!("{} EP unavailable: {}", ep_name, e);
|
||||
if rocm_available {
|
||||
match build_onnx_session(path, ort::ep::ROCm::default().build()) {
|
||||
Ok(s) => {
|
||||
tracing::info!("ONNX session loaded (ROCm iGPU)");
|
||||
return (Some(s), ExecutionProvider::Gpu);
|
||||
}
|
||||
Err(e) => tracing::debug!("ROCm EP unavailable: {}; trying CPU", e),
|
||||
}
|
||||
}
|
||||
match build_onnx_session(path, ort::ep::CPU::default().build()) {
|
||||
Ok(s) => {
|
||||
tracing::info!("ONNX session loaded (CPU)");
|
||||
(Some(s), ExecutionProvider::Cpu)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to load ONNX session: {}; Tier 2 disabled", e);
|
||||
(None, ExecutionProvider::Cpu)
|
||||
}
|
||||
}
|
||||
|
||||
(None, ExecutionProvider::Cpu)
|
||||
}
|
||||
|
||||
fn build_session(
|
||||
fn build_onnx_session(
|
||||
path: &std::path::Path,
|
||||
ep_name: &str,
|
||||
ep: ort::ep::ExecutionProviderDispatch,
|
||||
) -> anyhow::Result<ort::session::Session> {
|
||||
match ep_name {
|
||||
"cpu" => {
|
||||
let builder = ort::session::Session::builder()
|
||||
.map_err(|e| anyhow::anyhow!("builder: {}", e))?;
|
||||
let mut builder = builder
|
||||
.with_execution_providers([ort::ep::CPU::default().build()])
|
||||
.map_err(|e| anyhow::anyhow!("ep: {}", e))?;
|
||||
let session = builder
|
||||
.commit_from_file(path)
|
||||
.map_err(|e| anyhow::anyhow!("load: {}", e))?;
|
||||
Ok(session)
|
||||
}
|
||||
_ => Err(anyhow::anyhow!("EP '{}' not available in this build", ep_name)),
|
||||
}
|
||||
let mut builder = ort::session::Session::builder()
|
||||
.map_err(|e| anyhow::anyhow!("builder: {}", e))?
|
||||
.with_execution_providers([ep])
|
||||
.map_err(|e| anyhow::anyhow!("ep: {}", e))?;
|
||||
builder.commit_from_file(path).map_err(|e| anyhow::anyhow!("load: {}", e))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,15 +11,29 @@ fn default_snooze_options() -> Vec<String> {
|
|||
fn default_archive_after_days() -> i64 { 30 }
|
||||
fn default_model_path() -> String { "~/.local/share/breadpad/model/classifier.onnx".into() }
|
||||
fn default_tokenizer_path() -> String { "~/.local/share/breadpad/model/tokenizer.json".into() }
|
||||
fn default_execution_provider() -> String { "auto".into() }
|
||||
fn default_ort_dylib_path() -> String { "".into() }
|
||||
fn default_morning_time() -> String { "08:00".into() }
|
||||
fn default_missed_grace_minutes() -> i64 { 60 }
|
||||
fn default_ollama_endpoint() -> String { "http://localhost:11434".into() }
|
||||
fn default_ollama_model() -> String { "llama3.2:3b".into() }
|
||||
fn default_ollama_model() -> String { "fastflowlm".into() }
|
||||
fn default_ollama_confidence_threshold() -> f32 { 0.6 }
|
||||
fn default_ollama_enabled() -> bool { true }
|
||||
fn default_calendar_enabled() -> bool { false }
|
||||
|
||||
pub fn expand_path(path: &str) -> PathBuf {
|
||||
if path == "~" {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home;
|
||||
}
|
||||
}
|
||||
if let Some(stripped) = path.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(stripped);
|
||||
}
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
#[serde(default = "default_type_str")]
|
||||
|
|
@ -72,8 +86,9 @@ pub struct ModelConfig {
|
|||
pub path: String,
|
||||
#[serde(default = "default_tokenizer_path")]
|
||||
pub tokenizer: String,
|
||||
#[serde(default = "default_execution_provider")]
|
||||
pub execution_provider: String,
|
||||
/// Path to `libonnxruntime.so`. Auto-discovered when empty.
|
||||
#[serde(default = "default_ort_dylib_path")]
|
||||
pub ort_dylib_path: String,
|
||||
#[serde(default)]
|
||||
pub ollama: OllamaConfig,
|
||||
}
|
||||
|
|
@ -83,12 +98,26 @@ impl Default for ModelConfig {
|
|||
ModelConfig {
|
||||
path: default_model_path(),
|
||||
tokenizer: default_tokenizer_path(),
|
||||
execution_provider: default_execution_provider(),
|
||||
ort_dylib_path: default_ort_dylib_path(),
|
||||
ollama: OllamaConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelConfig {
|
||||
pub fn resolved_paths(&self) -> (PathBuf, PathBuf) {
|
||||
(expand_path(&self.path), expand_path(&self.tokenizer))
|
||||
}
|
||||
|
||||
pub fn resolved_ort_dylib_path(&self) -> Option<PathBuf> {
|
||||
let raw = self.ort_dylib_path.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(expand_path(raw))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemindersConfig {
|
||||
#[serde(default = "default_morning_time")]
|
||||
|
|
@ -114,6 +143,9 @@ pub struct CalendarConfig {
|
|||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
/// WARNING: stored as plaintext in breadpad.toml. Restrict the file's permissions
|
||||
/// (`chmod 600 ~/.config/breadpad/breadpad.toml`) and keep it out of version control.
|
||||
/// A future release may support reading the password from the OS secret service instead.
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,3 +7,4 @@ pub mod scheduler;
|
|||
pub mod store;
|
||||
pub mod theme;
|
||||
pub mod types;
|
||||
pub mod util;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use crate::types::{ClassificationResult, NoteType, RecurrenceRule};
|
||||
use crate::util::local_naive_to_utc;
|
||||
use chrono::{DateTime, Datelike, Duration, Local, NaiveTime, Timelike, Utc, Weekday};
|
||||
use regex::Regex;
|
||||
use std::sync::OnceLock;
|
||||
|
|
@ -22,7 +23,7 @@ static PATTERNS: OnceLock<Patterns> = OnceLock::new();
|
|||
fn patterns() -> &'static Patterns {
|
||||
PATTERNS.get_or_init(|| Patterns {
|
||||
at_time: Regex::new(r"(?i)\bat\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?").unwrap(),
|
||||
in_duration: Regex::new(r"(?i)\bin\s+(\d+)\s+(minute|hour|day)s?").unwrap(),
|
||||
in_duration: Regex::new(r"(?i)\bin\s+(\d+)\s+(second|minute|hour|day|week)s?").unwrap(),
|
||||
// Word-form durations: "in an hour", "in a couple of hours", "in half an hour"
|
||||
in_duration_word: Regex::new(
|
||||
r"(?i)\bin\s+(?:an?\s+hour|a\s+couple\s+of\s+hours?|a\s+few\s+hours?|half\s+an?\s+hour|an?\s+minutes?|a\s+couple\s+of\s+minutes?)"
|
||||
|
|
@ -100,7 +101,7 @@ fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime<Utc> {
|
|||
};
|
||||
let target_date = local.date_naive() + Duration::days(days_ahead);
|
||||
let naive = target_date.and_time(time);
|
||||
naive.and_local_timezone(Local).unwrap().with_timezone(&Utc)
|
||||
local_naive_to_utc(naive)
|
||||
}
|
||||
|
||||
pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResult {
|
||||
|
|
@ -209,7 +210,7 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
|
|||
} else {
|
||||
(local.date_naive() + Duration::days(1)).and_time(t)
|
||||
};
|
||||
extracted_time = Some(naive.and_local_timezone(Local).unwrap().with_timezone(&Utc));
|
||||
extracted_time = Some(local_naive_to_utc(naive));
|
||||
let full_match = caps.get(0).unwrap().as_str();
|
||||
cleaned = cleaned.replacen(full_match, "", 1).trim().to_string();
|
||||
}
|
||||
|
|
@ -218,9 +219,11 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
|
|||
let n: i64 = caps.get(1).unwrap().as_str().parse().unwrap_or(1);
|
||||
let unit = caps.get(2).unwrap().as_str().to_lowercase();
|
||||
let delta = match unit.as_str() {
|
||||
"second" => Duration::seconds(n),
|
||||
"minute" => Duration::minutes(n),
|
||||
"hour" => Duration::hours(n),
|
||||
"day" => Duration::days(n),
|
||||
"week" => Duration::weeks(n),
|
||||
_ => Duration::minutes(n),
|
||||
};
|
||||
extracted_time = Some(Utc::now() + delta);
|
||||
|
|
@ -254,7 +257,7 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
|
|||
};
|
||||
let local = Local::now();
|
||||
let target = (local.date_naive() + Duration::days(1)).and_time(t);
|
||||
extracted_time = Some(target.and_local_timezone(Local).unwrap().with_timezone(&Utc));
|
||||
extracted_time = Some(local_naive_to_utc(target));
|
||||
cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
|
||||
}
|
||||
// One-off: next <weekday>
|
||||
|
|
@ -273,7 +276,7 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
|
|||
} else {
|
||||
(local.date_naive() + Duration::days(1)).and_time(anchor)
|
||||
};
|
||||
extracted_time = Some(target.and_local_timezone(Local).unwrap().with_timezone(&Utc));
|
||||
extracted_time = Some(local_naive_to_utc(target));
|
||||
cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
|
||||
}
|
||||
}
|
||||
|
|
@ -860,6 +863,23 @@ fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType {
|
|||
|| lower.starts_with("finish ")
|
||||
|| lower.starts_with("write ")
|
||||
|| lower.starts_with("update ")
|
||||
|| lower.starts_with("prepare ")
|
||||
|| lower.starts_with("schedule ")
|
||||
|| lower.starts_with("organize ")
|
||||
|| lower.starts_with("deploy ")
|
||||
|| lower.starts_with("install ")
|
||||
|| lower.starts_with("send ")
|
||||
|| lower.starts_with("submit ")
|
||||
|| lower.starts_with("create ")
|
||||
|| lower.starts_with("setup ")
|
||||
|| lower.starts_with("restore ")
|
||||
|| lower.starts_with("archive ")
|
||||
|| lower.starts_with("export ")
|
||||
|| lower.starts_with("import ")
|
||||
|| lower.starts_with("approve ")
|
||||
|| lower.starts_with("configure ")
|
||||
|| lower.starts_with("refactor ")
|
||||
|| lower.starts_with("review ")
|
||||
{
|
||||
return NoteType::Todo;
|
||||
}
|
||||
|
|
@ -867,13 +887,21 @@ fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType {
|
|||
|| lower.starts_with("idea:")
|
||||
|| lower.contains("could ")
|
||||
|| lower.contains("maybe ")
|
||||
|| lower.contains("should we ")
|
||||
|| lower.starts_with("should we ")
|
||||
{
|
||||
return NoteType::Idea;
|
||||
}
|
||||
if lower.starts_with("why ")
|
||||
|| lower.starts_with("how ")
|
||||
|| lower.starts_with("what ")
|
||||
|| (lower.starts_with("what ") && !lower.starts_with("what if "))
|
||||
|| lower.starts_with("when ")
|
||||
|| lower.starts_with("where ")
|
||||
|| lower.starts_with("who ")
|
||||
|| lower.starts_with("will ")
|
||||
|| lower.starts_with("is ")
|
||||
|| lower.starts_with("are ")
|
||||
|| lower.starts_with("did ")
|
||||
|| lower.starts_with("does ")
|
||||
|| lower.ends_with('?')
|
||||
{
|
||||
return NoteType::Question;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use crate::types::Note;
|
||||
use crate::util::local_naive_to_utc;
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Duration, Local, NaiveTime, Utc};
|
||||
use std::process::Command;
|
||||
|
|
@ -59,27 +60,63 @@ fn create_timer(id: &str, fire_time: DateTime<Utc>) -> Result<()> {
|
|||
|
||||
let timer_name = timer_unit_name(id);
|
||||
|
||||
// Find the breadpad binary. Order of preference:
|
||||
// 1. $BREADPAD_BIN override,
|
||||
// 2. a `breadpad` next to the currently running executable,
|
||||
// 3. standard install locations.
|
||||
let breadpad_exe = std::env::var_os("BREADPAD_BIN")
|
||||
.map(std::path::PathBuf::from)
|
||||
.filter(|p| p.exists())
|
||||
.or_else(|| {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|p| p.join("breadpad")))
|
||||
.filter(|p| p.exists())
|
||||
})
|
||||
.or_else(|| {
|
||||
let home_bin = dirs::home_dir().map(|h| h.join(".local/bin/breadpad"));
|
||||
["/usr/local/bin/breadpad", "/usr/bin/breadpad"]
|
||||
.iter()
|
||||
.map(std::path::PathBuf::from)
|
||||
.chain(home_bin)
|
||||
.find(|p| p.exists())
|
||||
})
|
||||
.context("breadpad binary not found in $BREADPAD_BIN, alongside this executable, or in standard locations")?;
|
||||
|
||||
// Use systemd-run to create both service + timer as a transient unit
|
||||
let status = Command::new("systemd-run")
|
||||
.arg("--user")
|
||||
// Pass necessary environment variables for notifications to work
|
||||
let mut cmd = Command::new("systemd-run");
|
||||
cmd.arg("--user")
|
||||
.arg("--unit")
|
||||
.arg(&timer_name.strip_suffix(".timer").unwrap_or(&timer_name))
|
||||
.arg(timer_name.strip_suffix(".timer").unwrap_or(&timer_name))
|
||||
.arg("--timer-property")
|
||||
.arg(format!("OnCalendar={}", on_calendar))
|
||||
.arg("--timer-property")
|
||||
.arg("Persistent=true")
|
||||
.arg("--")
|
||||
.arg("breadpad")
|
||||
.arg("Persistent=true");
|
||||
|
||||
// Pass DBUS and display environment variables so notify-send works
|
||||
if let Ok(dbus) = std::env::var("DBUS_SESSION_BUS_ADDRESS") {
|
||||
cmd.arg("--setenv").arg(format!("DBUS_SESSION_BUS_ADDRESS={}", dbus));
|
||||
}
|
||||
if let Ok(display) = std::env::var("DISPLAY") {
|
||||
cmd.arg("--setenv").arg(format!("DISPLAY={}", display));
|
||||
}
|
||||
if let Ok(wayland) = std::env::var("WAYLAND_DISPLAY") {
|
||||
cmd.arg("--setenv").arg(format!("WAYLAND_DISPLAY={}", wayland));
|
||||
}
|
||||
|
||||
cmd.arg("--")
|
||||
.arg(&breadpad_exe)
|
||||
.arg("fire")
|
||||
.arg(id)
|
||||
.status()
|
||||
.context("failed to run systemd-run")?;
|
||||
.arg(id);
|
||||
|
||||
let status = cmd.status().context("failed to run systemd-run")?;
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!("systemd-run failed for reminder {}", id);
|
||||
}
|
||||
|
||||
tracing::info!("scheduled reminder {} at {}", id, on_calendar);
|
||||
tracing::info!("scheduled reminder {} at {} using {}", id, on_calendar, breadpad_exe.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -131,17 +168,15 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O
|
|||
let now = Local::now();
|
||||
let fire_time = NaiveTime::from_hms_opt(hour, minute, 0)?;
|
||||
|
||||
let next = match freq {
|
||||
match freq {
|
||||
"DAILY" => {
|
||||
let today = now.date_naive().and_time(fire_time);
|
||||
if now.naive_local() < today {
|
||||
today.and_local_timezone(Local).unwrap()
|
||||
let naive = if now.naive_local() < today {
|
||||
today
|
||||
} else {
|
||||
(now.date_naive() + chrono::Duration::days(1))
|
||||
.and_time(fire_time)
|
||||
.and_local_timezone(Local)
|
||||
.unwrap()
|
||||
}
|
||||
(now.date_naive() + chrono::Duration::days(1)).and_time(fire_time)
|
||||
};
|
||||
return Some(local_naive_to_utc(naive));
|
||||
}
|
||||
"WEEKLY" => {
|
||||
use chrono::Datelike;
|
||||
|
|
@ -169,12 +204,10 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O
|
|||
};
|
||||
let target_date =
|
||||
(now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time);
|
||||
target_date.and_local_timezone(Local).unwrap()
|
||||
return Some(local_naive_to_utc(target_date));
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(next.with_timezone(&Utc))
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -331,6 +364,21 @@ mod tests {
|
|||
assert_eq!(local.weekday(), chrono::Weekday::Sat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_tuesday_is_tuesday() {
|
||||
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=TU;BYHOUR=10;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
|
||||
let local: chrono::DateTime<Local> = t.into();
|
||||
assert_eq!(local.weekday(), chrono::Weekday::Tue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_thursday_is_thursday() {
|
||||
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=TH;BYHOUR=11;BYMINUTE=30;BYSECOND=0", "08:00").unwrap();
|
||||
let local: chrono::DateTime<Local> = t.into();
|
||||
assert_eq!(local.weekday(), chrono::Weekday::Thu);
|
||||
assert_eq!(local.minute(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_sunday_is_sunday() {
|
||||
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=SU;BYHOUR=19;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
|
||||
|
|
@ -338,6 +386,22 @@ mod tests {
|
|||
assert_eq!(local.weekday(), chrono::Weekday::Sun);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_unknown_byday_falls_back_to_sunday() {
|
||||
// The match arm `_ => Weekday::Sun` handles unrecognised BYDAY values
|
||||
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=XX;BYHOUR=9;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
|
||||
let local: chrono::DateTime<Local> = t.into();
|
||||
assert_eq!(local.weekday(), chrono::Weekday::Sun);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_without_byhour_uses_default_morning() {
|
||||
let t = parse_next_from_rrule("RRULE:FREQ=DAILY", "06:45").unwrap();
|
||||
let local: chrono::DateTime<Local> = t.into();
|
||||
assert_eq!(local.hour(), 6);
|
||||
assert_eq!(local.minute(), 45);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_freq_returns_none() {
|
||||
assert!(parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none());
|
||||
|
|
|
|||
|
|
@ -36,6 +36,14 @@ impl Store {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn with_calendar_if_enabled(self, cfg: &crate::config::Config) -> Self {
|
||||
if cfg.calendar.enabled {
|
||||
self.with_calendar(cfg.calendar.clone())
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_all(&self) -> Result<Vec<Note>> {
|
||||
self.load_from(&self.notes_path)
|
||||
}
|
||||
|
|
@ -84,12 +92,14 @@ impl Store {
|
|||
|
||||
pub fn update_note(&self, updated: &Note) -> Result<()> {
|
||||
self.rewrite_notes(|note| {
|
||||
if note.id == updated.id {
|
||||
updated.clone()
|
||||
} else {
|
||||
note
|
||||
if note.id == updated.id { updated.clone() } else { note }
|
||||
})?;
|
||||
if let Some(cal_cfg) = &self.calendar {
|
||||
if cal_cfg.enabled && (updated.time.is_some() || updated.rrule.is_some()) {
|
||||
spawn_caldav_push(updated.clone(), cal_cfg.clone());
|
||||
}
|
||||
})
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_note(&self, id: &str) -> Result<()> {
|
||||
|
|
|
|||
|
|
@ -1,202 +1,118 @@
|
|||
use serde::Deserialize;
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
pub use bread_theme::{load_palette, Palette};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Palette {
|
||||
pub background: String,
|
||||
pub foreground: String,
|
||||
pub color0: String,
|
||||
pub color1: String,
|
||||
pub color2: String,
|
||||
pub color3: String,
|
||||
pub color4: String,
|
||||
pub color5: String,
|
||||
pub color6: String,
|
||||
pub color7: String,
|
||||
/// Apply breadpad/breadman's stylesheet and keep it live across palette changes.
|
||||
/// [`build_css`] bundles the shared component sheet with the app's own rules from
|
||||
/// the current pywal palette; `bread_theme::gtk::apply_app_css` re-runs this
|
||||
/// whenever `bread-theme reload` rewrites the shared theme file, so the UI
|
||||
/// recolours in place (and re-reads the user's `style.css` override too).
|
||||
pub fn apply_live() {
|
||||
bread_theme::gtk::apply_app_css(|| {
|
||||
let palette = load_palette();
|
||||
let user_css = std::fs::read_to_string(crate::config::style_css_path()).ok();
|
||||
build_css(&palette, user_css.as_deref())
|
||||
});
|
||||
}
|
||||
|
||||
// Catppuccin Mocha fallback
|
||||
impl Default for Palette {
|
||||
fn default() -> Self {
|
||||
Palette {
|
||||
background: "#1e1e2e".into(),
|
||||
foreground: "#cdd6f4".into(),
|
||||
color0: "#45475a".into(),
|
||||
color1: "#f38ba8".into(),
|
||||
color2: "#a6e3a1".into(),
|
||||
color3: "#f9e2af".into(),
|
||||
color4: "#89b4fa".into(),
|
||||
color5: "#f5c2e7".into(),
|
||||
color6: "#94e2d5".into(),
|
||||
color7: "#bac2de".into(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct WalColors {
|
||||
#[serde(default)]
|
||||
colors: HashMap<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(),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Generate the full breadpad/breadman CSS string. The base — `@define-color`
|
||||
/// palette, fonts, and generic widget styling — comes from the shared
|
||||
/// `bread_theme::stylesheet`, so breadpad and breadman look identical to the
|
||||
/// rest of the ecosystem. Only breadpad-specific component rules are appended.
|
||||
pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String {
|
||||
let mut css = format!(
|
||||
// Shared ecosystem base (define-colors incl. accent, font, buttons, entries,
|
||||
// switches, lists, cards, scrollbars). `overlay` here is color7 — consistent
|
||||
// with every other bread app (breadpad previously mapped it to color0).
|
||||
let mut css = bread_theme::stylesheet(palette);
|
||||
|
||||
css.push_str(
|
||||
r#"
|
||||
@define-color bg {bg};
|
||||
@define-color fg {fg};
|
||||
@define-color red {c1};
|
||||
@define-color green {c2};
|
||||
@define-color yellow {c3};
|
||||
@define-color blue {c4};
|
||||
@define-color pink {c5};
|
||||
@define-color teal {c6};
|
||||
@define-color overlay {c0};
|
||||
/* breadpad/breadman-specific components */
|
||||
window { border-radius: 8px; }
|
||||
|
||||
window {{
|
||||
background-color: @bg;
|
||||
color: @fg;
|
||||
border-radius: 12px;
|
||||
}}
|
||||
|
||||
.popup-entry {{
|
||||
.popup-entry {
|
||||
background: @bg;
|
||||
color: @fg;
|
||||
border: 2px solid @blue;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
caret-color: @fg;
|
||||
}}
|
||||
}
|
||||
|
||||
.popup-entry:focus {{
|
||||
.popup-entry:focus {
|
||||
outline: none;
|
||||
border-color: @teal;
|
||||
}}
|
||||
}
|
||||
|
||||
.type-chip {{
|
||||
.type-chip {
|
||||
background: @overlay;
|
||||
color: @fg;
|
||||
color: @on-overlay;
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
margin: 2px;
|
||||
}}
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
.type-chip.active {{
|
||||
.type-chip.active {
|
||||
background: @blue;
|
||||
color: @bg;
|
||||
}}
|
||||
color: @on-accent;
|
||||
}
|
||||
|
||||
.confirm-button {{
|
||||
background: @green;
|
||||
color: @bg;
|
||||
.confirm-button {
|
||||
background: @blue;
|
||||
color: @on-accent;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
font-weight: bold;
|
||||
}}
|
||||
}
|
||||
|
||||
.note-card {{
|
||||
.note-card {
|
||||
background: shade(@bg, 1.1);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin: 4px 8px;
|
||||
margin: 8px;
|
||||
border-left: 3px solid @blue;
|
||||
}}
|
||||
}
|
||||
|
||||
.note-card:hover {{
|
||||
.note-card:hover {
|
||||
background: shade(@bg, 1.2);
|
||||
}}
|
||||
}
|
||||
|
||||
.search-entry {{
|
||||
.search-entry {
|
||||
background: shade(@bg, 1.1);
|
||||
color: @fg;
|
||||
border: 1px solid @overlay;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
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 {
|
||||
background: shade(@bg, 0.93);
|
||||
.search-entry:focus {
|
||||
border-color: @blue;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.sidebar-row {
|
||||
padding: 6px 12px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
transition: background 100ms ease;
|
||||
}
|
||||
|
||||
.sidebar-row:hover:not(:selected) {
|
||||
background: shade(@bg, 1.08);
|
||||
background: shade(@bg, 1.1);
|
||||
}
|
||||
|
||||
.sidebar-row:selected {
|
||||
background: @blue;
|
||||
color: @bg;
|
||||
color: @on-accent;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-section-label {
|
||||
color: alpha(@fg, 0.4);
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
padding: 10px 14px 2px 14px;
|
||||
letter-spacing: 1px;
|
||||
color: alpha(@fg, 0.5);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 12px 12px 8px 12px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
|
|
@ -228,19 +144,61 @@ window {{
|
|||
.note-card-question { border-left-color: @teal; }
|
||||
.note-card-note { border-left-color: @blue; }
|
||||
|
||||
entry {
|
||||
background: shade(@bg, 1.1);
|
||||
color: @fg;
|
||||
.reminder-window {
|
||||
background: @bg;
|
||||
border: 1px solid @overlay;
|
||||
border-radius: 6px;
|
||||
caret-color: @fg;
|
||||
padding: 5px 10px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
entry:focus {
|
||||
border-color: @blue;
|
||||
outline: none;
|
||||
.reminder-emoji { font-size: 20px; }
|
||||
|
||||
.reminder-title {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: alpha(@fg, 0.6);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.reminder-time {
|
||||
font-size: 12px;
|
||||
color: alpha(@fg, 0.5);
|
||||
}
|
||||
|
||||
.reminder-body {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: @fg;
|
||||
}
|
||||
|
||||
.reminder-dismiss {
|
||||
background: transparent;
|
||||
border: 1px solid @overlay;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
color: alpha(@fg, 0.6);
|
||||
}
|
||||
|
||||
.reminder-dismiss:hover { background: shade(@bg, 1.1); }
|
||||
|
||||
.reminder-snooze {
|
||||
background: transparent;
|
||||
border: 1px solid @overlay;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
color: @fg;
|
||||
}
|
||||
|
||||
.reminder-snooze:hover { background: shade(@bg, 1.1); }
|
||||
|
||||
.snooze-option {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
color: @fg;
|
||||
}
|
||||
|
||||
.snooze-option:hover { background: shade(@bg, 1.2); }
|
||||
"#);
|
||||
|
||||
if let Some(extra) = user_css {
|
||||
|
|
@ -251,125 +209,21 @@ entry:focus {
|
|||
css
|
||||
}
|
||||
|
||||
fn wal_colors_path() -> PathBuf {
|
||||
dirs::cache_dir()
|
||||
.unwrap_or_else(|| PathBuf::from("~/.cache"))
|
||||
.join("wal")
|
||||
.join("colors.json")
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
const TOKYO_NIGHT_WAL: &str = r##"{
|
||||
"special": {
|
||||
"background": "#1a1b26",
|
||||
"foreground": "#c0caf5"
|
||||
},
|
||||
"colors": {
|
||||
"color0": "#15161e",
|
||||
"color1": "#f7768e",
|
||||
"color2": "#9ece6a",
|
||||
"color3": "#e0af68",
|
||||
"color4": "#7aa2f7",
|
||||
"color5": "#bb9af7",
|
||||
"color6": "#7dcfff",
|
||||
"color7": "#a9b1d6"
|
||||
}
|
||||
}"##;
|
||||
|
||||
// ---- Default palette (Catppuccin Mocha) ----
|
||||
|
||||
#[test]
|
||||
fn default_background_is_catppuccin_mocha() {
|
||||
assert_eq!(Palette::default().background, "#1e1e2e");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_foreground_is_catppuccin_mocha() {
|
||||
assert_eq!(Palette::default().foreground, "#cdd6f4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_red_is_catppuccin_mocha() {
|
||||
assert_eq!(Palette::default().color1, "#f38ba8");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_blue_is_catppuccin_mocha() {
|
||||
assert_eq!(Palette::default().color4, "#89b4fa");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_teal_is_catppuccin_mocha() {
|
||||
assert_eq!(Palette::default().color6, "#94e2d5");
|
||||
}
|
||||
|
||||
// ---- palette_from_wal_json ----
|
||||
|
||||
#[test]
|
||||
fn wal_json_parses_special_background() {
|
||||
let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap();
|
||||
assert_eq!(p.background, "#1a1b26");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wal_json_parses_special_foreground() {
|
||||
let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap();
|
||||
assert_eq!(p.foreground, "#c0caf5");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wal_json_parses_numbered_colors() {
|
||||
let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap();
|
||||
assert_eq!(p.color0, "#15161e");
|
||||
assert_eq!(p.color1, "#f7768e");
|
||||
assert_eq!(p.color4, "#7aa2f7");
|
||||
assert_eq!(p.color7, "#a9b1d6");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wal_json_missing_special_falls_back_to_defaults() {
|
||||
let json = r##"{"colors":{"color0":"#000000"}}"##;
|
||||
let p = palette_from_wal_json(json).unwrap();
|
||||
assert_eq!(p.background, "#1e1e2e");
|
||||
assert_eq!(p.foreground, "#cdd6f4");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn wal_json_missing_color_falls_back_to_default() {
|
||||
let json = r##"{"special":{"background":"#ff0000","foreground":"#ffffff"},"colors":{}}"##;
|
||||
let p = palette_from_wal_json(json).unwrap();
|
||||
assert_eq!(p.color4, "#89b4fa"); // default blue
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn invalid_wal_json_returns_none() {
|
||||
assert!(palette_from_wal_json("not json").is_none());
|
||||
assert!(palette_from_wal_json("").is_none());
|
||||
assert!(palette_from_wal_json("{}").is_some()); // empty but valid → all defaults
|
||||
}
|
||||
|
||||
// ---- build_css ----
|
||||
|
||||
#[test]
|
||||
fn css_defines_bg_color() {
|
||||
let css = build_css(&Palette::default(), None);
|
||||
assert!(css.contains("@define-color bg #1e1e2e"), "css missing bg: {}", &css[..300]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_defines_fg_color() {
|
||||
let css = build_css(&Palette::default(), None);
|
||||
assert!(css.contains("@define-color fg #cdd6f4"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_defines_all_named_colors() {
|
||||
let css = build_css(&Palette::default(), None);
|
||||
for name in &["red", "green", "yellow", "blue", "pink", "teal", "overlay"] {
|
||||
assert!(css.contains(&format!("@define-color {} ", name)), "missing @define-color {}", name);
|
||||
assert!(css.contains(&format!("@define-color {name} ")), "missing @define-color {name}");
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -392,18 +246,6 @@ mod tests {
|
|||
assert!(css.contains(".note-card {"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_contains_type_chip_class() {
|
||||
let css = build_css(&Palette::default(), None);
|
||||
assert!(css.contains(".type-chip {"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_contains_sidebar_row_class() {
|
||||
let css = build_css(&Palette::default(), None);
|
||||
assert!(css.contains(".sidebar-row {"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_appends_user_css() {
|
||||
let user = ".my-override { color: hotpink; }";
|
||||
|
|
@ -426,22 +268,4 @@ mod tests {
|
|||
assert!(css.contains("@define-color bg #deadbe"), "css: {}", &css[..300]);
|
||||
assert!(css.contains("@define-color blue #cafe00"), "css: {}", &css[..300]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn css_from_wal_palette_uses_wal_colors() {
|
||||
let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap();
|
||||
let css = build_css(&p, None);
|
||||
assert!(css.contains("@define-color bg #1a1b26"), "css: {}", &css[..300]);
|
||||
assert!(css.contains("@define-color fg #c0caf5"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_palette_returns_valid_palette() {
|
||||
// No wal file in CI/test env; should return non-empty strings starting with #
|
||||
let palette = load_palette();
|
||||
assert!(!palette.background.is_empty());
|
||||
assert!(palette.background.starts_with('#'), "bg: {}", palette.background);
|
||||
assert!(!palette.foreground.is_empty());
|
||||
assert!(palette.color4.starts_with('#'));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -72,7 +72,9 @@ pub struct Note {
|
|||
pub done: bool,
|
||||
pub workspace: Option<String>,
|
||||
pub created: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
pub snoozed_until: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub completed: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
|
|
@ -83,10 +85,14 @@ pub struct Note {
|
|||
impl Note {
|
||||
pub fn new(body: String, note_type: NoteType, workspace: Option<String>) -> Self {
|
||||
Note {
|
||||
// 12 hex chars (~48 bits) keeps IDs short and human-typable while making
|
||||
// collisions vanishingly unlikely — important because update/delete/get_by_id
|
||||
// all match notes purely by this id.
|
||||
id: uuid::Uuid::new_v4()
|
||||
.simple()
|
||||
.to_string()
|
||||
.chars()
|
||||
.take(6)
|
||||
.take(12)
|
||||
.collect(),
|
||||
body,
|
||||
note_type,
|
||||
|
|
@ -250,10 +256,15 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn note_id_is_six_chars() {
|
||||
fn note_id_is_twelve_chars() {
|
||||
for _ in 0..50 {
|
||||
let note = Note::new("x".into(), NoteType::Note, None);
|
||||
assert_eq!(note.id.len(), 6, "id '{}' is not 6 chars", note.id);
|
||||
assert_eq!(note.id.len(), 12, "id '{}' is not 12 chars", note.id);
|
||||
assert!(
|
||||
note.id.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"id '{}' is not all hex",
|
||||
note.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
63
breadpad-shared/src/util.rs
Normal file
63
breadpad-shared/src/util.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use chrono::{DateTime, Duration, Local, LocalResult, NaiveDateTime, TimeZone, Utc};
|
||||
|
||||
/// Resolve a naive *local* datetime to UTC without panicking on DST transitions.
|
||||
///
|
||||
/// `NaiveDateTime::and_local_timezone` (and `Local.from_local_datetime`) returns a
|
||||
/// `LocalResult`, which is not always `Single`:
|
||||
/// - `Single` — the normal case.
|
||||
/// - `Ambiguous` (a fall-back hour that occurs twice) — pick the earliest instant.
|
||||
/// - `None` (a spring-forward gap where the wall-clock time never happens) — advance
|
||||
/// an hour at a time until a valid instant is found, then fall back to treating the
|
||||
/// naive value as UTC.
|
||||
///
|
||||
/// Calling `.unwrap()` on the `None`/`Ambiguous` cases panics, which is what this helper
|
||||
/// exists to avoid (it bit us on the ~2 DST transition days per year).
|
||||
pub fn local_naive_to_utc(naive: NaiveDateTime) -> DateTime<Utc> {
|
||||
match Local.from_local_datetime(&naive) {
|
||||
LocalResult::Single(dt) => dt.with_timezone(&Utc),
|
||||
LocalResult::Ambiguous(earliest, _latest) => earliest.with_timezone(&Utc),
|
||||
LocalResult::None => {
|
||||
let mut shifted = naive;
|
||||
for _ in 0..3 {
|
||||
shifted += Duration::hours(1);
|
||||
if let LocalResult::Single(dt) = Local.from_local_datetime(&shifted) {
|
||||
return dt.with_timezone(&Utc);
|
||||
}
|
||||
}
|
||||
// Last resort: interpret the wall-clock value as UTC so we still return a time.
|
||||
Utc.from_utc_datetime(&naive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::NaiveDate;
|
||||
|
||||
#[test]
|
||||
fn ordinary_time_round_trips() {
|
||||
let naive = NaiveDate::from_ymd_opt(2026, 6, 15)
|
||||
.unwrap()
|
||||
.and_hms_opt(9, 30, 0)
|
||||
.unwrap();
|
||||
let utc = local_naive_to_utc(naive);
|
||||
// Converting back to local should yield the same wall-clock time.
|
||||
let local: DateTime<Local> = utc.with_timezone(&Local);
|
||||
assert_eq!(local.naive_local(), naive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_panics_across_a_full_year_of_hours() {
|
||||
// Walk every hour of a year through the helper; it must never panic regardless
|
||||
// of the host timezone's DST rules.
|
||||
let mut dt = NaiveDate::from_ymd_opt(2026, 1, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap();
|
||||
for _ in 0..(366 * 24) {
|
||||
let _ = local_naive_to_utc(dt);
|
||||
dt += Duration::hours(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,27 @@
|
|||
use breadpad_shared::classifier::Classifier;
|
||||
use breadpad_shared::classifier::{Classifier, ExecutionProvider};
|
||||
use breadpad_shared::types::NoteType;
|
||||
use chrono::Timelike;
|
||||
|
||||
fn cl() -> Classifier {
|
||||
Classifier::load("auto", "08:00")
|
||||
Classifier::load("08:00")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_provider_is_cpu() {
|
||||
// QNN and Vulkan EPs are not compiled in; CPU is always the fallback.
|
||||
fn active_provider_is_valid() {
|
||||
// The active provider depends on the host: a machine with the ONNX model present and
|
||||
// a working ROCm iGPU loads `Gpu`, otherwise `Cpu`. Either is valid — but when no
|
||||
// model is available we must be on CPU (no session => no GPU EP in use).
|
||||
let c = cl();
|
||||
assert_eq!(c.active_provider, breadpad_shared::classifier::ExecutionProvider::Cpu);
|
||||
assert!(matches!(
|
||||
c.active_provider,
|
||||
ExecutionProvider::Cpu | ExecutionProvider::Gpu
|
||||
));
|
||||
if !c.model_available() {
|
||||
assert!(
|
||||
matches!(c.active_provider, ExecutionProvider::Cpu),
|
||||
"no model loaded but provider was not CPU"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -63,7 +74,7 @@ fn classify_recurrence_via_fallback() {
|
|||
|
||||
#[test]
|
||||
fn classify_custom_morning_time() {
|
||||
let mut c = Classifier::load("auto", "07:15");
|
||||
let mut c = Classifier::load("07:15");
|
||||
let r = c.classify("sync tomorrow morning");
|
||||
let t = r.time.expect("should have a time for tomorrow morning");
|
||||
let local: chrono::DateTime<chrono::Local> = t.into();
|
||||
|
|
@ -71,6 +82,41 @@ fn classify_custom_morning_time() {
|
|||
assert_eq!(local.minute(), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_empty_string_does_not_panic() {
|
||||
let mut c = cl();
|
||||
let _ = c.classify("");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_whitespace_only_does_not_panic() {
|
||||
let mut c = cl();
|
||||
let _ = c.classify(" ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_in_duration_sets_time() {
|
||||
let mut c = cl();
|
||||
let r = c.classify("take a break in 30 minutes");
|
||||
assert!(r.time.is_some(), "should have a time for 'in 30 minutes'");
|
||||
assert_eq!(r.note_type, NoteType::Reminder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_tomorrow_sets_time() {
|
||||
let mut c = cl();
|
||||
let r = c.classify("submit the invoice tomorrow");
|
||||
assert!(r.time.is_some(), "tomorrow should produce a scheduled time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_returns_cleaned_body() {
|
||||
let mut c = cl();
|
||||
let r = c.classify("call mum at 6pm");
|
||||
assert!(r.body.contains("call mum"), "body: {}", r.body);
|
||||
assert!(!r.body.contains("6pm"), "time phrase should be stripped from body: {}", r.body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_path_points_to_expected_location() {
|
||||
let c = cl();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use breadpad_shared::config::{Config, ModelConfig, RemindersConfig, Settings};
|
||||
use breadpad_shared::config::{expand_path, CalendarConfig, Config, ModelConfig, RemindersConfig, Settings};
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ---- Default values ----
|
||||
|
|
@ -22,9 +22,9 @@ fn default_snooze_options_contains_all_three() {
|
|||
#[test]
|
||||
fn default_model_config() {
|
||||
let m = ModelConfig::default();
|
||||
assert_eq!(m.execution_provider, "auto");
|
||||
assert!(m.path.contains("classifier.onnx"));
|
||||
assert!(m.tokenizer.contains("tokenizer.json"));
|
||||
assert_eq!(m.ort_dylib_path, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -38,7 +38,6 @@ fn default_reminders_config() {
|
|||
fn default_config_composes_defaults() {
|
||||
let cfg = Config::default();
|
||||
assert_eq!(cfg.settings.default_type, "note");
|
||||
assert_eq!(cfg.model.execution_provider, "auto");
|
||||
assert_eq!(cfg.reminders.default_morning, "08:00");
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +55,7 @@ archive_after_days = 7
|
|||
[model]
|
||||
path = "/tmp/classifier.onnx"
|
||||
tokenizer = "/tmp/tokenizer.json"
|
||||
execution_provider = "cpu"
|
||||
ort_dylib_path = "/tmp/libonnxruntime.so"
|
||||
|
||||
[reminders]
|
||||
default_morning = "07:30"
|
||||
|
|
@ -67,8 +66,8 @@ missed_grace_minutes = 30
|
|||
assert!(!cfg.settings.workspace_tag);
|
||||
assert_eq!(cfg.settings.snooze_options, vec!["15m", "2h"]);
|
||||
assert_eq!(cfg.settings.archive_after_days, 7);
|
||||
assert_eq!(cfg.model.execution_provider, "cpu");
|
||||
assert_eq!(cfg.model.path, "/tmp/classifier.onnx");
|
||||
assert_eq!(cfg.model.ort_dylib_path, "/tmp/libonnxruntime.so");
|
||||
assert_eq!(cfg.reminders.default_morning, "07:30");
|
||||
assert_eq!(cfg.reminders.missed_grace_minutes, 30);
|
||||
}
|
||||
|
|
@ -78,7 +77,6 @@ fn empty_toml_uses_all_defaults() {
|
|||
let cfg: Config = toml::from_str("").unwrap();
|
||||
assert_eq!(cfg.settings.default_type, "note");
|
||||
assert!(cfg.settings.workspace_tag);
|
||||
assert_eq!(cfg.model.execution_provider, "auto");
|
||||
assert_eq!(cfg.reminders.default_morning, "08:00");
|
||||
}
|
||||
|
||||
|
|
@ -90,31 +88,9 @@ default_type = "reminder"
|
|||
"#;
|
||||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(cfg.settings.default_type, "reminder");
|
||||
// Other sections should still have defaults
|
||||
assert_eq!(cfg.model.execution_provider, "auto");
|
||||
assert_eq!(cfg.reminders.default_morning, "08:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_toml_only_model_section() {
|
||||
let toml = r#"
|
||||
[model]
|
||||
execution_provider = "npu"
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(cfg.model.execution_provider, "npu");
|
||||
assert_eq!(cfg.settings.default_type, "note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_provider_variants_accepted() {
|
||||
for ep in &["auto", "npu", "vulkan", "cpu"] {
|
||||
let toml = format!("[model]\nexecution_provider = \"{}\"", ep);
|
||||
let cfg: Config = toml::from_str(&toml).unwrap();
|
||||
assert_eq!(cfg.model.execution_provider, *ep);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- TOML serialization round-trip ----
|
||||
|
||||
#[test]
|
||||
|
|
@ -124,7 +100,6 @@ fn default_config_serializes_to_valid_toml() {
|
|||
let reparsed: Config = toml::from_str(&serialized).unwrap();
|
||||
assert_eq!(reparsed.settings.default_type, cfg.settings.default_type);
|
||||
assert_eq!(reparsed.settings.workspace_tag, cfg.settings.workspace_tag);
|
||||
assert_eq!(reparsed.model.execution_provider, cfg.model.execution_provider);
|
||||
assert_eq!(reparsed.reminders.default_morning, cfg.reminders.default_morning);
|
||||
}
|
||||
|
||||
|
|
@ -133,7 +108,6 @@ fn custom_config_round_trips() {
|
|||
let mut cfg = Config::default();
|
||||
cfg.settings.default_type = "idea".into();
|
||||
cfg.settings.archive_after_days = 14;
|
||||
cfg.model.execution_provider = "vulkan".into();
|
||||
cfg.reminders.default_morning = "06:45".into();
|
||||
cfg.reminders.missed_grace_minutes = 120;
|
||||
|
||||
|
|
@ -141,7 +115,6 @@ fn custom_config_round_trips() {
|
|||
let rt: Config = toml::from_str(&toml).unwrap();
|
||||
assert_eq!(rt.settings.default_type, "idea");
|
||||
assert_eq!(rt.settings.archive_after_days, 14);
|
||||
assert_eq!(rt.model.execution_provider, "vulkan");
|
||||
assert_eq!(rt.reminders.default_morning, "06:45");
|
||||
assert_eq!(rt.reminders.missed_grace_minutes, 120);
|
||||
}
|
||||
|
|
@ -155,24 +128,20 @@ fn save_and_load_round_trip() {
|
|||
|
||||
let mut cfg = Config::default();
|
||||
cfg.settings.default_type = "question".into();
|
||||
cfg.model.execution_provider = "cpu".into();
|
||||
cfg.reminders.missed_grace_minutes = 45;
|
||||
|
||||
// Manually save to a known path (Config::save uses the fixed XDG path,
|
||||
// so we use toml serialization + write here to test the round-trip logic)
|
||||
let toml = toml::to_string_pretty(&cfg).unwrap();
|
||||
std::fs::write(&config_path, &toml).unwrap();
|
||||
|
||||
let loaded: Config = toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
|
||||
assert_eq!(loaded.settings.default_type, "question");
|
||||
assert_eq!(loaded.model.execution_provider, "cpu");
|
||||
assert_eq!(loaded.reminders.missed_grace_minutes, 45);
|
||||
}
|
||||
|
||||
// ---- The example from the README ----
|
||||
|
||||
#[test]
|
||||
fn readme_example_toml_parses() {
|
||||
fn example_toml_parses() {
|
||||
let toml = r#"
|
||||
[settings]
|
||||
default_type = "note"
|
||||
|
|
@ -183,7 +152,7 @@ archive_after_days = 30
|
|||
[model]
|
||||
path = "~/.local/share/breadpad/model/classifier.onnx"
|
||||
tokenizer = "~/.local/share/breadpad/model/tokenizer.json"
|
||||
execution_provider = "auto"
|
||||
ort_dylib_path = ""
|
||||
|
||||
[reminders]
|
||||
default_morning = "08:00"
|
||||
|
|
@ -192,7 +161,146 @@ missed_grace_minutes = 60
|
|||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(cfg.settings.default_type, "note");
|
||||
assert!(cfg.settings.workspace_tag);
|
||||
assert_eq!(cfg.model.execution_provider, "auto");
|
||||
assert_eq!(cfg.reminders.default_morning, "08:00");
|
||||
assert_eq!(cfg.reminders.missed_grace_minutes, 60);
|
||||
}
|
||||
|
||||
// ---- CalendarConfig ----
|
||||
|
||||
#[test]
|
||||
fn default_calendar_config_is_disabled() {
|
||||
let c = CalendarConfig::default();
|
||||
assert!(!c.enabled);
|
||||
assert!(c.url.is_empty());
|
||||
assert!(c.username.is_empty());
|
||||
assert!(c.password.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_config_from_toml() {
|
||||
let toml = r#"
|
||||
[calendar]
|
||||
enabled = true
|
||||
url = "https://cloud.example.com/remote.php/dav/calendars/user/personal/"
|
||||
username = "user"
|
||||
password = "secret"
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert!(cfg.calendar.enabled);
|
||||
assert!(cfg.calendar.url.contains("dav/calendars"));
|
||||
assert_eq!(cfg.calendar.username, "user");
|
||||
assert_eq!(cfg.calendar.password, "secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_config_round_trips() {
|
||||
let mut cfg = Config::default();
|
||||
cfg.calendar.enabled = true;
|
||||
cfg.calendar.url = "https://example.com/cal".into();
|
||||
cfg.calendar.username = "alice".into();
|
||||
cfg.calendar.password = "hunter2".into();
|
||||
|
||||
let toml = toml::to_string_pretty(&cfg).unwrap();
|
||||
let rt: Config = toml::from_str(&toml).unwrap();
|
||||
assert!(rt.calendar.enabled);
|
||||
assert_eq!(rt.calendar.url, "https://example.com/cal");
|
||||
assert_eq!(rt.calendar.username, "alice");
|
||||
assert_eq!(rt.calendar.password, "hunter2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_config_calendar_disabled() {
|
||||
let cfg = Config::default();
|
||||
assert!(!cfg.calendar.enabled);
|
||||
}
|
||||
|
||||
// ---- OllamaConfig ----
|
||||
|
||||
#[test]
|
||||
fn default_ollama_config_enabled() {
|
||||
let m = ModelConfig::default();
|
||||
assert!(m.ollama.enabled);
|
||||
assert_eq!(m.ollama.endpoint, "http://localhost:11434");
|
||||
assert!(!m.ollama.model.is_empty());
|
||||
assert!(m.ollama.confidence_threshold > 0.0 && m.ollama.confidence_threshold <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_config_from_toml() {
|
||||
let toml = r#"
|
||||
[model.ollama]
|
||||
enabled = false
|
||||
endpoint = "http://localhost:9999"
|
||||
model = "llama3"
|
||||
confidence_threshold = 0.8
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert!(!cfg.model.ollama.enabled);
|
||||
assert_eq!(cfg.model.ollama.endpoint, "http://localhost:9999");
|
||||
assert_eq!(cfg.model.ollama.model, "llama3");
|
||||
assert!((cfg.model.ollama.confidence_threshold - 0.8).abs() < 1e-5);
|
||||
}
|
||||
|
||||
// ---- expand_path ----
|
||||
|
||||
#[test]
|
||||
fn expand_path_tilde_prefix_replaced_with_home() {
|
||||
let home = dirs::home_dir().unwrap();
|
||||
let expanded = expand_path("~/some/path");
|
||||
assert!(expanded.starts_with(&home));
|
||||
assert!(expanded.ends_with("some/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_path_bare_tilde_is_home() {
|
||||
let home = dirs::home_dir().unwrap();
|
||||
assert_eq!(expand_path("~"), home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_path_absolute_path_unchanged() {
|
||||
let p = expand_path("/usr/local/bin/breadpad");
|
||||
assert_eq!(p.to_str().unwrap(), "/usr/local/bin/breadpad");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_path_relative_path_unchanged() {
|
||||
let p = expand_path("relative/path");
|
||||
assert_eq!(p.to_str().unwrap(), "relative/path");
|
||||
}
|
||||
|
||||
// ---- ModelConfig::resolved_ort_dylib_path ----
|
||||
|
||||
#[test]
|
||||
fn resolved_ort_dylib_empty_returns_none() {
|
||||
let m = ModelConfig::default();
|
||||
assert!(m.resolved_ort_dylib_path().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_ort_dylib_whitespace_only_returns_none() {
|
||||
let mut m = ModelConfig::default();
|
||||
m.ort_dylib_path = " ".into();
|
||||
assert!(m.resolved_ort_dylib_path().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_ort_dylib_set_returns_some() {
|
||||
let mut m = ModelConfig::default();
|
||||
m.ort_dylib_path = "/usr/lib/libonnxruntime.so".into();
|
||||
assert_eq!(
|
||||
m.resolved_ort_dylib_path().unwrap().to_str().unwrap(),
|
||||
"/usr/lib/libonnxruntime.so"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- ModelConfig::resolved_paths ----
|
||||
|
||||
#[test]
|
||||
fn resolved_paths_expands_tildes() {
|
||||
let m = ModelConfig::default();
|
||||
let (model, tokenizer) = m.resolved_paths();
|
||||
let home = dirs::home_dir().unwrap();
|
||||
assert!(model.starts_with(&home), "model path should be under home: {:?}", model);
|
||||
assert!(tokenizer.starts_with(&home), "tokenizer path should be under home: {:?}", tokenizer);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use tempfile::TempDir;
|
|||
// Mirrors commit_note() in breadpad/src/main.rs.
|
||||
// `user_type` is the type the user selected in the chip row (default = NoteType::Note).
|
||||
fn capture(store: &Store, text: &str, user_type: NoteType) -> Note {
|
||||
let mut classifier = Classifier::load("auto", "08:00");
|
||||
let mut classifier = Classifier::load("08:00");
|
||||
let result = classifier.classify(text);
|
||||
|
||||
let mut note = Note::new(text.into(), user_type.clone(), None);
|
||||
|
|
|
|||
|
|
@ -301,6 +301,51 @@ fn rotate_archive_zero_when_nothing_qualifies() {
|
|||
assert_eq!(store.load_all().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_note_just_inside_boundary_stays() {
|
||||
let (_dir, store) = mk();
|
||||
// 29 days ago — threshold is 30 — should NOT be archived
|
||||
let mut n = note("fresh enough", NoteType::Todo);
|
||||
n.done = true;
|
||||
n.completed = Some(Utc::now() - Duration::days(29));
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
assert_eq!(store.rotate_archive(30).unwrap(), 0);
|
||||
assert_eq!(store.load_all().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_note_just_past_boundary_is_archived() {
|
||||
let (_dir, store) = mk();
|
||||
// 31 days ago — threshold is 30 — should be archived
|
||||
let mut n = note("old enough", NoteType::Todo);
|
||||
n.done = true;
|
||||
n.completed = Some(Utc::now() - Duration::days(31));
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
assert_eq!(store.rotate_archive(30).unwrap(), 1);
|
||||
assert!(store.load_all().unwrap().is_empty());
|
||||
assert_eq!(store.load_archive().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_zero_day_threshold_archives_completed_notes() {
|
||||
let (_dir, store) = mk();
|
||||
let mut done = note("done a second ago", NoteType::Todo);
|
||||
done.done = true;
|
||||
done.completed = Some(Utc::now() - Duration::seconds(1));
|
||||
store.save_note(&done).unwrap();
|
||||
|
||||
let undone = note("still active", NoteType::Todo);
|
||||
store.save_note(&undone).unwrap();
|
||||
|
||||
assert_eq!(store.rotate_archive(0).unwrap(), 1);
|
||||
let remaining = store.load_all().unwrap();
|
||||
assert_eq!(remaining.len(), 1);
|
||||
assert_eq!(remaining[0].body, "still active");
|
||||
assert_eq!(store.load_archive().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_ignores_undone_notes_no_matter_how_old() {
|
||||
let (_dir, store) = mk();
|
||||
|
|
|
|||
|
|
@ -18,3 +18,5 @@ chrono = { workspace = true }
|
|||
clap = { version = "4", features = ["derive"] }
|
||||
colored = "2"
|
||||
comfy-table = "7"
|
||||
ort = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -67,6 +67,9 @@ enum TierArg {
|
|||
Three,
|
||||
#[value(name = "all")]
|
||||
All,
|
||||
/// Production path: Tier 1 → Tier 2 (no Ollama)
|
||||
#[value(name = "pipeline")]
|
||||
Pipeline,
|
||||
}
|
||||
|
||||
impl TierArg {
|
||||
|
|
@ -76,6 +79,7 @@ impl TierArg {
|
|||
TierArg::Two => "2",
|
||||
TierArg::Three => "3",
|
||||
TierArg::All => "all",
|
||||
TierArg::Pipeline => "pipeline",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -142,7 +146,14 @@ fn classify_with_tier(text: &str, tier: &TierArg) -> ClassificationResult {
|
|||
match tier {
|
||||
TierArg::One => parse_rule_based(text, DEFAULT_MORNING),
|
||||
TierArg::Two => {
|
||||
let mut clf = Classifier::load("auto", DEFAULT_MORNING);
|
||||
let mut clf = Classifier::load(DEFAULT_MORNING);
|
||||
clf.classify_tier2_only(text).unwrap_or_else(|| {
|
||||
eprintln!("warning: ONNX model not loaded; Tier 2 unavailable");
|
||||
parse_rule_based(text, DEFAULT_MORNING)
|
||||
})
|
||||
}
|
||||
TierArg::Pipeline => {
|
||||
let mut clf = Classifier::load(DEFAULT_MORNING);
|
||||
clf.classify(text)
|
||||
}
|
||||
TierArg::Three | TierArg::All => {
|
||||
|
|
@ -152,7 +163,7 @@ fn classify_with_tier(text: &str, tier: &TierArg) -> ClassificationResult {
|
|||
confidence_threshold: 0.6,
|
||||
enabled: true,
|
||||
};
|
||||
let mut clf = Classifier::load("auto", DEFAULT_MORNING).with_ollama(ollama);
|
||||
let mut clf = Classifier::load(DEFAULT_MORNING).with_ollama(ollama);
|
||||
clf.classify(text)
|
||||
}
|
||||
}
|
||||
|
|
@ -444,8 +455,35 @@ fn cmd_edit(index: usize, corpus_path: &Path) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn init_ort() {
|
||||
use std::path::PathBuf;
|
||||
// Prefer the system CPU-only library for testing — no Ryzen AI startup overhead.
|
||||
// Fall back to the Ryzen AI SDK library if the system one isn't installed.
|
||||
let candidates: Vec<PathBuf> = {
|
||||
let mut v = vec![
|
||||
PathBuf::from("/usr/lib/libonnxruntime.so"),
|
||||
PathBuf::from("/usr/local/lib/libonnxruntime.so"),
|
||||
];
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
v.push(home.join(".local/share/ryzen-ai-1.7.1/lib/libonnxruntime.so"));
|
||||
v.push(home.join(".local/share/ryzen-ai/lib/libonnxruntime.so"));
|
||||
}
|
||||
if let Ok(root) = std::env::var("RYZEN_AI_INSTALLATION_PATH") {
|
||||
v.push(PathBuf::from(root).join("lib/libonnxruntime.so"));
|
||||
}
|
||||
v.push(PathBuf::from("/opt/ryzen-ai/lib/libonnxruntime.so"));
|
||||
v
|
||||
};
|
||||
if let Some(path) = candidates.into_iter().find(|p| p.is_file()) {
|
||||
if let Err(e) = ort::init_from(&path).map(|b| b.commit()) {
|
||||
eprintln!("warning: failed to load ORT from {:?}: {}", path, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
init_ort();
|
||||
|
||||
match cli.command {
|
||||
Commands::Run { corpus, tier, format } => {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,25 @@ archive_after_days = 30
|
|||
[model]
|
||||
path = "~/.local/share/breadpad/model/classifier.onnx"
|
||||
tokenizer = "~/.local/share/breadpad/model/tokenizer.json"
|
||||
execution_provider = "auto" # auto | npu | vulkan | cpu
|
||||
# ort_dylib_path: path to libonnxruntime.so. Leave empty to auto-discover from
|
||||
# standard system paths or $ORT_DYLIB_PATH. Tier 2 is disabled if no library is found.
|
||||
ort_dylib_path = ""
|
||||
|
||||
[model.ollama]
|
||||
endpoint = "http://localhost:11434"
|
||||
model = "fastflowlm"
|
||||
confidence_threshold = 0.6
|
||||
enabled = true
|
||||
|
||||
[reminders]
|
||||
default_morning = "08:00"
|
||||
missed_grace_minutes = 60
|
||||
|
||||
[calendar]
|
||||
enabled = false
|
||||
url = "" # e.g. https://cloud.example.com/remote.php/dav/calendars/user/personal/
|
||||
username = ""
|
||||
# WARNING: password is stored in plaintext. Restrict file permissions:
|
||||
# chmod 600 ~/.config/breadpad/breadpad.toml
|
||||
# and keep this file out of version control.
|
||||
password = ""
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ path = "src/main.rs"
|
|||
[dependencies]
|
||||
breadpad-shared = { path = "../breadpad-shared" }
|
||||
anyhow.workspace = true
|
||||
ort.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
serde.workspace = true
|
||||
|
|
|
|||
|
|
@ -2,17 +2,33 @@ use anyhow::Result;
|
|||
use breadpad_shared::{
|
||||
calendar::CalDavClient,
|
||||
classifier::Classifier,
|
||||
config::{style_css_path, Config},
|
||||
config::Config,
|
||||
scheduler::Scheduler,
|
||||
store::Store,
|
||||
theme::{build_css, load_palette},
|
||||
types::{Note, NoteType},
|
||||
};
|
||||
use gtk4::{glib, prelude::*};
|
||||
use gtk4_layer_shell::{Edge, KeyboardMode, Layer, LayerShell};
|
||||
use std::cell::RefCell;
|
||||
use std::rc::Rc;
|
||||
use std::sync::Arc;
|
||||
use std::sync::{Arc, Once};
|
||||
|
||||
static ORT_INIT: Once = Once::new();
|
||||
|
||||
fn init_ort_once(cfg: &Config) {
|
||||
ORT_INIT.call_once(|| {
|
||||
let Some(path) = cfg.model.resolved_ort_dylib_path() else { return; };
|
||||
if !path.exists() {
|
||||
tracing::warn!("ORT dylib not found at {:?}; Tier 2 disabled", path);
|
||||
return;
|
||||
}
|
||||
tracing::info!("loading ONNX Runtime from {:?}", path);
|
||||
match ort::init_from(&path) {
|
||||
Ok(builder) => { builder.commit(); }
|
||||
Err(e) => tracing::warn!("ORT init failed: {}; Tier 2 disabled", e),
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
mod args {
|
||||
#[derive(Debug)]
|
||||
|
|
@ -89,7 +105,7 @@ fn main() -> Result<()> {
|
|||
return cmd_status(&cfg);
|
||||
}
|
||||
if args.download_model {
|
||||
return cmd_download_model();
|
||||
return cmd_download_model(&cfg);
|
||||
}
|
||||
if args.model_info {
|
||||
return cmd_model_info(&cfg);
|
||||
|
|
@ -108,9 +124,15 @@ fn main() -> Result<()> {
|
|||
}
|
||||
|
||||
fn cmd_status(cfg: &Config) -> Result<()> {
|
||||
init_ort_once(cfg);
|
||||
let store = Store::new()?;
|
||||
let notes = store.load_all()?;
|
||||
let classifier = Classifier::load(&cfg.model.execution_provider, &cfg.reminders.default_morning);
|
||||
let (model_path, tokenizer_path) = cfg.model.resolved_paths();
|
||||
let classifier = Classifier::load_with_paths(
|
||||
&cfg.reminders.default_morning,
|
||||
model_path,
|
||||
tokenizer_path,
|
||||
);
|
||||
println!("breadpad status");
|
||||
println!(" notes: {}", notes.len());
|
||||
println!(
|
||||
|
|
@ -126,7 +148,13 @@ fn cmd_status(cfg: &Config) -> Result<()> {
|
|||
}
|
||||
|
||||
fn cmd_model_info(cfg: &Config) -> Result<()> {
|
||||
let classifier = Classifier::load(&cfg.model.execution_provider, &cfg.reminders.default_morning);
|
||||
init_ort_once(cfg);
|
||||
let (model_path, tokenizer_path) = cfg.model.resolved_paths();
|
||||
let classifier = Classifier::load_with_paths(
|
||||
&cfg.reminders.default_morning,
|
||||
model_path,
|
||||
tokenizer_path,
|
||||
);
|
||||
println!("model path: {:?}", classifier.model_path);
|
||||
println!("execution provider: {}", classifier.active_provider.as_str());
|
||||
println!(
|
||||
|
|
@ -136,16 +164,19 @@ fn cmd_model_info(cfg: &Config) -> Result<()> {
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn cmd_download_model() -> Result<()> {
|
||||
fn cmd_download_model(cfg: &Config) -> Result<()> {
|
||||
// Placeholder — a real implementation would download a quantised ONNX model.
|
||||
// The exact model URL is left for the user to configure.
|
||||
let dir = dirs::data_local_dir()
|
||||
.unwrap_or_else(|| std::path::PathBuf::from("~/.local/share"))
|
||||
.join("breadpad")
|
||||
.join("model");
|
||||
std::fs::create_dir_all(&dir)?;
|
||||
println!("Model directory: {}", dir.display());
|
||||
println!("Place classifier.onnx and tokenizer.json in that directory.");
|
||||
let (model_path, tokenizer_path) = cfg.model.resolved_paths();
|
||||
if let Some(dir) = model_path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
if let Some(dir) = tokenizer_path.parent() {
|
||||
std::fs::create_dir_all(dir)?;
|
||||
}
|
||||
println!("Model path: {}", model_path.display());
|
||||
println!("Tokenizer path: {}", tokenizer_path.display());
|
||||
println!("Place the classifier ONNX and tokenizer JSON at those paths.");
|
||||
println!("(Automatic download not yet configured — set a model URL in breadpad.toml)");
|
||||
Ok(())
|
||||
}
|
||||
|
|
@ -221,7 +252,7 @@ fn cmd_calendar_list_uid(note_id: &str, cfg: &Config) -> Result<()> {
|
|||
}
|
||||
|
||||
fn cmd_fire(id: &str, cfg: &Config) -> Result<()> {
|
||||
let store = Store::new()?;
|
||||
let store = Store::new()?.with_calendar_if_enabled(cfg);
|
||||
let note = match store.get_by_id(id)? {
|
||||
Some(n) => n,
|
||||
None => {
|
||||
|
|
@ -234,37 +265,7 @@ fn cmd_fire(id: &str, cfg: &Config) -> Result<()> {
|
|||
return Ok(());
|
||||
}
|
||||
|
||||
// Send notification via notify-send
|
||||
let title = format!("[{}] breadpad reminder", note.note_type);
|
||||
|
||||
let mut cmd = std::process::Command::new("notify-send");
|
||||
cmd.arg("--urgency=normal")
|
||||
.arg(format!("--app-name=breadpad"))
|
||||
.arg(&title)
|
||||
.arg(¬e.body);
|
||||
for opt in &cfg.settings.snooze_options {
|
||||
cmd.arg(format!("--action=snooze_{}={}", opt, humanize_snooze(opt)));
|
||||
}
|
||||
let output = cmd.output()?;
|
||||
|
||||
// If the user clicked a snooze action, notify-send prints the action key
|
||||
if let Ok(action) = String::from_utf8(output.stdout) {
|
||||
let action = action.trim();
|
||||
if action.starts_with("snooze_") {
|
||||
let key = action.trim_start_matches("snooze_");
|
||||
if let Some(until) = resolve_snooze(key, cfg) {
|
||||
let mut updated = note.clone();
|
||||
store.update_note({
|
||||
updated.snoozed_until = Some(until);
|
||||
&updated
|
||||
})?;
|
||||
Scheduler::schedule(&updated)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle recurrence
|
||||
// Schedule next recurrence before showing UI
|
||||
if note.rrule.is_some() {
|
||||
if let Some(next) = Scheduler::next_recurrence(¬e, &cfg.reminders.default_morning) {
|
||||
let mut updated = note.clone();
|
||||
|
|
@ -275,6 +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(())
|
||||
}
|
||||
|
||||
|
|
@ -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 tomorrow = local.date_naive() + chrono::Duration::days(1);
|
||||
let naive = tomorrow.and_hms_opt(h, m, 0)?;
|
||||
Some(naive.and_local_timezone(chrono::Local).unwrap().with_timezone(&chrono::Utc))
|
||||
Some(breadpad_shared::util::local_naive_to_utc(naive))
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
fn build_reminder_window(
|
||||
app: >k4::Application,
|
||||
note: Arc<breadpad_shared::types::Note>,
|
||||
cfg: Arc<Config>,
|
||||
) {
|
||||
let window = gtk4::ApplicationWindow::builder()
|
||||
.application(app)
|
||||
.title("breadpad reminder")
|
||||
.default_width(420)
|
||||
.default_height(1)
|
||||
.decorated(false)
|
||||
.resizable(false)
|
||||
.build();
|
||||
|
||||
window.init_layer_shell();
|
||||
window.set_layer(Layer::Overlay);
|
||||
window.set_keyboard_mode(KeyboardMode::Exclusive);
|
||||
window.auto_exclusive_zone_enable();
|
||||
|
||||
apply_css(&cfg);
|
||||
|
||||
let type_emoji = match note.note_type.as_str() {
|
||||
"reminder" => "🔔",
|
||||
"todo" => "✅",
|
||||
"idea" => "💡",
|
||||
"question" => "❓",
|
||||
_ => "📝",
|
||||
};
|
||||
|
||||
let outer = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.spacing(0)
|
||||
.css_classes(["reminder-window"])
|
||||
.build();
|
||||
|
||||
// Header strip
|
||||
let header = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.margin_top(16)
|
||||
.margin_bottom(8)
|
||||
.margin_start(20)
|
||||
.margin_end(20)
|
||||
.build();
|
||||
|
||||
header.append(
|
||||
>k4::Label::builder()
|
||||
.label(type_emoji)
|
||||
.css_classes(["reminder-emoji"])
|
||||
.build(),
|
||||
);
|
||||
header.append(
|
||||
>k4::Label::builder()
|
||||
.label("Reminder")
|
||||
.css_classes(["reminder-title"])
|
||||
.hexpand(true)
|
||||
.xalign(0.0)
|
||||
.build(),
|
||||
);
|
||||
|
||||
// Optional time label
|
||||
if let Some(t) = note.effective_time() {
|
||||
let local: chrono::DateTime<chrono::Local> = t.into();
|
||||
header.append(
|
||||
>k4::Label::builder()
|
||||
.label(&local.format("%H:%M").to_string())
|
||||
.css_classes(["reminder-time"])
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
|
||||
outer.append(&header);
|
||||
|
||||
// Body
|
||||
let body_label = gtk4::Label::builder()
|
||||
.label(¬e.body)
|
||||
.css_classes(["reminder-body"])
|
||||
.wrap(true)
|
||||
.xalign(0.0)
|
||||
.margin_start(20)
|
||||
.margin_end(20)
|
||||
.margin_bottom(16)
|
||||
.build();
|
||||
outer.append(&body_label);
|
||||
|
||||
// Separator
|
||||
outer.append(>k4::Separator::builder()
|
||||
.orientation(gtk4::Orientation::Horizontal)
|
||||
.build());
|
||||
|
||||
// Button row
|
||||
let btn_row = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.margin_top(12)
|
||||
.margin_bottom(12)
|
||||
.margin_start(16)
|
||||
.margin_end(16)
|
||||
.build();
|
||||
|
||||
let dismiss_btn = gtk4::Button::builder()
|
||||
.label("Dismiss")
|
||||
.css_classes(["reminder-dismiss"])
|
||||
.build();
|
||||
|
||||
// Snooze popover
|
||||
let snooze_popover = gtk4::Popover::new();
|
||||
let snooze_vbox = gtk4::Box::builder()
|
||||
.orientation(gtk4::Orientation::Vertical)
|
||||
.spacing(4)
|
||||
.margin_top(8)
|
||||
.margin_bottom(8)
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.build();
|
||||
|
||||
for opt in &cfg.settings.snooze_options {
|
||||
let label = humanize_snooze(opt).to_string();
|
||||
let btn = gtk4::Button::builder()
|
||||
.label(&label)
|
||||
.css_classes(["snooze-option"])
|
||||
.build();
|
||||
let key = opt.clone();
|
||||
let note_c = note.clone();
|
||||
let cfg_c = cfg.clone();
|
||||
let win_c = window.clone();
|
||||
let popover_c = snooze_popover.clone();
|
||||
btn.connect_clicked(move |_| {
|
||||
if let Some(until) = resolve_snooze(&key, &cfg_c) {
|
||||
if let Ok(store) = Store::new().map(|s| s.with_calendar_if_enabled(&cfg_c)) {
|
||||
let mut updated = note_c.as_ref().clone();
|
||||
updated.snoozed_until = Some(until);
|
||||
let _ = store.update_note(&updated);
|
||||
let _ = Scheduler::schedule(&updated);
|
||||
}
|
||||
}
|
||||
popover_c.popdown();
|
||||
win_c.close();
|
||||
});
|
||||
snooze_vbox.append(&btn);
|
||||
}
|
||||
snooze_popover.set_child(Some(&snooze_vbox));
|
||||
|
||||
let snooze_btn = gtk4::MenuButton::builder()
|
||||
.label("Snooze")
|
||||
.css_classes(["reminder-snooze"])
|
||||
.popover(&snooze_popover)
|
||||
.build();
|
||||
|
||||
let done_btn = gtk4::Button::builder()
|
||||
.label("Done ✓")
|
||||
.css_classes(["confirm-button", "reminder-done"])
|
||||
.hexpand(true)
|
||||
.build();
|
||||
|
||||
{
|
||||
let note_c = note.clone();
|
||||
let cfg_c = cfg.clone();
|
||||
let win_c = window.clone();
|
||||
done_btn.connect_clicked(move |_| {
|
||||
if let Ok(store) = Store::new().map(|s| s.with_calendar_if_enabled(&cfg_c)) {
|
||||
let mut updated = note_c.as_ref().clone();
|
||||
updated.mark_done();
|
||||
let _ = store.update_note(&updated);
|
||||
}
|
||||
win_c.close();
|
||||
});
|
||||
}
|
||||
|
||||
{
|
||||
let win_c = window.clone();
|
||||
dismiss_btn.connect_clicked(move |_| { win_c.close(); });
|
||||
}
|
||||
|
||||
btn_row.append(&dismiss_btn);
|
||||
btn_row.append(&snooze_btn);
|
||||
btn_row.append(&done_btn);
|
||||
outer.append(&btn_row);
|
||||
|
||||
window.set_child(Some(&outer));
|
||||
window.present();
|
||||
}
|
||||
|
||||
fn run_popup(preset_type: Option<String>, no_classify: bool, cfg: Config) -> Result<()> {
|
||||
// Try to get current Hyprland workspace
|
||||
let workspace = get_active_workspace();
|
||||
|
|
@ -321,10 +521,11 @@ fn run_popup(preset_type: Option<String>, no_classify: bool, cfg: Config) -> Res
|
|||
let cfg = Arc::new(cfg);
|
||||
|
||||
app.connect_activate(move |app| {
|
||||
let cfg = cfg.clone();
|
||||
let workspace = workspace.clone();
|
||||
let preset_type = preset_type.clone();
|
||||
build_window(app, cfg, workspace, preset_type, no_classify);
|
||||
if let Some(win) = app.windows().first().cloned() {
|
||||
win.close();
|
||||
return;
|
||||
}
|
||||
build_window(app, cfg.clone(), workspace.clone(), preset_type.clone(), no_classify);
|
||||
});
|
||||
|
||||
let code = app.run_with_args::<String>(&[]);
|
||||
|
|
@ -475,12 +676,13 @@ fn build_window(
|
|||
return;
|
||||
}
|
||||
let note_type = selected_type.borrow().clone();
|
||||
|
||||
// Classify and save synchronously. Tier 1 + 2 finish in <100ms.
|
||||
// Tier 3 (Ollama) only fires for ambiguous inputs; the brief pause
|
||||
// is acceptable since the user has already committed the note.
|
||||
save_note_classified(&text, note_type, no_classify, cfg.clone(), workspace.clone());
|
||||
let cfg_c = cfg.clone();
|
||||
let ws_c = workspace.clone();
|
||||
// Close first so the popup disappears immediately, then save.
|
||||
win.close();
|
||||
glib::idle_add_local_once(move || {
|
||||
save_note_classified(&text, note_type, no_classify, cfg_c, ws_c);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -523,9 +725,12 @@ fn save_note_classified(
|
|||
let mut note = Note::new(text.into(), user_type.clone(), workspace);
|
||||
|
||||
if !no_classify {
|
||||
let mut classifier = Classifier::load(
|
||||
&cfg.model.execution_provider,
|
||||
init_ort_once(&cfg);
|
||||
let (model_path, tokenizer_path) = cfg.model.resolved_paths();
|
||||
let mut classifier = Classifier::load_with_paths(
|
||||
&cfg.reminders.default_morning,
|
||||
model_path,
|
||||
tokenizer_path,
|
||||
)
|
||||
.with_ollama(cfg.model.ollama.clone());
|
||||
let result = classifier.classify(text);
|
||||
|
|
@ -559,15 +764,7 @@ fn save_note_classified(
|
|||
}
|
||||
|
||||
fn apply_css(_cfg: &Config) {
|
||||
let palette = load_palette();
|
||||
let user_css = std::fs::read_to_string(style_css_path()).ok();
|
||||
let css = build_css(&palette, user_css.as_deref());
|
||||
|
||||
let provider = gtk4::CssProvider::new();
|
||||
provider.load_from_string(&css);
|
||||
gtk4::style_context_add_provider_for_display(
|
||||
>k4::gdk::Display::default().unwrap(),
|
||||
&provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
// Hot-reloads on `bread-theme reload` (recolours to the new pywal palette
|
||||
// and re-reads the user's style.css). See breadpad_shared::theme::apply_live.
|
||||
breadpad_shared::theme::apply_live();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
./target/release/breadpad
|
||||
38
packaging/arch/PKGBUILD
Normal file
38
packaging/arch/PKGBUILD
Normal 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
102
svgs.txt
|
|
@ -1,102 +0,0 @@
|
|||
# SVG Icons for breadman
|
||||
# Replace the placeholder emojis in breadman/src/main.rs and breadman/src/editor.rs
|
||||
# with SVG-backed gtk4::Image widgets once you have the files.
|
||||
# All icons should be single-color/symbolic so GTK can recolor them with CSS.
|
||||
# Recommended source: Lucide (https://lucide.dev), Phosphor, or Material Symbols.
|
||||
|
||||
## Sidebar — navigation items
|
||||
|
||||
all-notes.svg
|
||||
Placeholder: 📋
|
||||
Use: "All" view — a stack of pages or a grid of squares
|
||||
Lucide suggestion: layout-grid, files, or layers
|
||||
|
||||
calendar-clock.svg
|
||||
Placeholder: 📅
|
||||
Use: "Upcoming" view — calendar with a clock overlay
|
||||
Lucide suggestion: calendar-clock
|
||||
|
||||
checkbox.svg
|
||||
Placeholder: ✅
|
||||
Use: "Todo" type — empty or checked checkbox
|
||||
Lucide suggestion: square-check or check-square
|
||||
|
||||
bell.svg
|
||||
Placeholder: 🔔
|
||||
Use: "Reminder" type — bell icon
|
||||
Lucide suggestion: bell
|
||||
|
||||
lightbulb.svg
|
||||
Placeholder: 💡
|
||||
Use: "Idea" type — lightbulb
|
||||
Lucide suggestion: lightbulb
|
||||
|
||||
pencil-line.svg
|
||||
Placeholder: 📝
|
||||
Use: "Note" type — pencil writing on a line
|
||||
Lucide suggestion: pencil-line or file-text
|
||||
|
||||
circle-help.svg
|
||||
Placeholder: ❓
|
||||
Use: "Question" type — question mark in a circle
|
||||
Lucide suggestion: circle-help or help-circle
|
||||
|
||||
archive-box.svg
|
||||
Placeholder: 📦
|
||||
Use: "Archive" view — box with down-arrow or archive tray
|
||||
Lucide suggestion: archive or archive-restore
|
||||
|
||||
settings-gear.svg
|
||||
Placeholder: ⚙
|
||||
Use: "Settings" view — gear/cog
|
||||
Lucide suggestion: settings or settings-2
|
||||
|
||||
triangle-alert.svg
|
||||
Placeholder: ⚠
|
||||
Use: "Errors" view — triangle with exclamation mark
|
||||
Lucide suggestion: triangle-alert or alert-triangle
|
||||
|
||||
## Note card action buttons
|
||||
|
||||
check.svg
|
||||
Placeholder: ✓
|
||||
Use: "Mark done" action button on note cards
|
||||
Lucide suggestion: check or circle-check
|
||||
|
||||
pencil.svg
|
||||
Placeholder: ✎
|
||||
Use: "Edit" action button on note cards
|
||||
Lucide suggestion: pencil or pen
|
||||
|
||||
trash.svg
|
||||
Placeholder: 🗑
|
||||
Use: "Delete" action button on note cards and archive
|
||||
Lucide suggestion: trash-2
|
||||
|
||||
## Note card metadata badges
|
||||
|
||||
clock.svg
|
||||
Placeholder: ⏰ (used inline in label text)
|
||||
Use: Scheduled time indicator on note cards
|
||||
Lucide suggestion: clock or alarm-clock
|
||||
|
||||
repeat.svg
|
||||
Placeholder: ↻ (used as type-chip label)
|
||||
Use: Recurrence indicator on note cards
|
||||
Lucide suggestion: repeat or refresh-cw
|
||||
|
||||
## New Note button
|
||||
|
||||
plus.svg
|
||||
Placeholder: ✚ (used in "✚ New Note" button label)
|
||||
Use: New note creation button in sidebar
|
||||
Lucide suggestion: plus or plus-circle
|
||||
|
||||
## Notes on integration
|
||||
# When switching from emoji/text to SVG icons:
|
||||
# 1. Use gtk4::Image::from_file() or load via gtk4::IconTheme for theme-aware icons.
|
||||
# 2. For action buttons, replace the label with a gtk4::Image child:
|
||||
# let btn = gtk4::Button::new();
|
||||
# btn.set_child(Some(>k4::Image::from_file("path/to/icon.svg")));
|
||||
# 3. Symbolic icons (named with "-symbolic" suffix in icon theme) follow the
|
||||
# CSS color property automatically.
|
||||
Loading…
Add table
Add a link
Reference in a new issue