Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
710cb768ea | ||
|
|
6a06872f09 | ||
|
|
369935515b | ||
|
|
6e5448e853 | ||
|
|
b7aed8a37c | ||
|
|
7394e65da5 | ||
|
|
6fea1af544 | ||
|
|
b828f8ec79 | ||
|
|
df42aba1d3 |
19 changed files with 1055 additions and 551 deletions
|
|
@ -14,8 +14,6 @@ jobs:
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
|
git clone --mirror "https://git.breadway.dev/${GITHUB_REPOSITORY}.git" repo.git
|
||||||
cd 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 \
|
git push --prune \
|
||||||
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadpad.git" \
|
"https://x-access-token:${{ secrets.MIRROR_TOKEN }}@github.com/Breadway/breadpad.git" \
|
||||||
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
|
'+refs/heads/*:refs/heads/*' '+refs/tags/*:refs/tags/*'
|
||||||
|
|
|
||||||
58
.forgejo/workflows/release.yml
Normal file
58
.forgejo/workflows/release.yml
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
name: release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: [self-hosted, hestia]
|
||||||
|
steps:
|
||||||
|
- name: checkout
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf src && mkdir src
|
||||||
|
git clone --branch "${GITHUB_REF_NAME}" --depth 1 \
|
||||||
|
"https://git.breadway.dev/${GITHUB_REPOSITORY}.git" src
|
||||||
|
|
||||||
|
- name: build
|
||||||
|
run: cd src && cargo build --release --locked
|
||||||
|
|
||||||
|
- name: prepare artifacts
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
PKG_DIR="/srv/breadway-dl/breadpad/${VERSION}"
|
||||||
|
mkdir -p "${PKG_DIR}"
|
||||||
|
for bin in breadpad breadman; do
|
||||||
|
cp "src/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 src/breadpad.example.toml "${PKG_DIR}/"
|
||||||
|
cp src/bakery.toml "${PKG_DIR}/bakery.toml"
|
||||||
|
ln -sfn "${VERSION}" "/srv/breadway-dl/breadpad/latest"
|
||||||
|
|
||||||
|
- name: regenerate index.json
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
rm -rf /tmp/bread-ecosystem-ci
|
||||||
|
git clone https://git.breadway.dev/Breadway/bread-ecosystem.git /tmp/bread-ecosystem-ci
|
||||||
|
bash /tmp/bread-ecosystem-ci/scripts/gen-index.sh
|
||||||
|
|
||||||
|
- name: upload to GitHub Release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ secrets.GH_RELEASE_TOKEN }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}"
|
||||||
|
PKG_DIR="/srv/breadway-dl/breadpad/${VERSION}"
|
||||||
|
gh release create "${GITHUB_REF_NAME}" --repo Breadway/breadpad \
|
||||||
|
--title "breadpad v${VERSION}" --generate-notes 2>/dev/null || true
|
||||||
|
gh release upload "${GITHUB_REF_NAME}" --repo Breadway/breadpad \
|
||||||
|
"${PKG_DIR}/breadpad-x86_64" \
|
||||||
|
"${PKG_DIR}/breadman-x86_64" \
|
||||||
|
"${PKG_DIR}/breadpad-x86_64.sha256" \
|
||||||
|
"${PKG_DIR}/breadman-x86_64.sha256" \
|
||||||
|
--clobber
|
||||||
66
.github/workflows/release.yml
vendored
66
.github/workflows/release.yml
vendored
|
|
@ -1,66 +0,0 @@
|
||||||
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
|
|
||||||
723
Cargo.lock
generated
723
Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -8,7 +8,7 @@ members = [
|
||||||
resolver = "2"
|
resolver = "2"
|
||||||
|
|
||||||
[workspace.package]
|
[workspace.package]
|
||||||
version = "0.3.4"
|
version = "0.5.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
authors = ["Breadway"]
|
authors = ["Breadway"]
|
||||||
|
|
@ -26,7 +26,11 @@ tokio = { version = "1", features = ["full"] }
|
||||||
zbus = { version = "4", default-features = false, features = ["tokio"] }
|
zbus = { version = "4", default-features = false, features = ["tokio"] }
|
||||||
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "ndarray", "tracing", "api-24", "rocm", "load-dynamic"] }
|
ort = { version = "2.0.0-rc.12", default-features = false, features = ["std", "ndarray", "tracing", "api-24", "rocm", "load-dynamic"] }
|
||||||
ndarray = "0.16"
|
ndarray = "0.16"
|
||||||
tokenizers = { version = "0.21", default-features = false, features = ["http", "fancy-regex"] }
|
# Bumped 0.21 -> 0.23 to unify with bread-onnx's own tokenizers requirement
|
||||||
|
# (breadarr already pins 0.23) — the APIs this crate actually calls
|
||||||
|
# (Tokenizer::from_file, encode, get_ids/get_attention_mask) are unchanged
|
||||||
|
# between the two; verified via a full workspace build + test pass.
|
||||||
|
tokenizers = { version = "0.23", default-features = false, features = ["http", "fancy-regex"] }
|
||||||
gtk4 = { version = "0.11", features = ["v4_12"] }
|
gtk4 = { version = "0.11", features = ["v4_12"] }
|
||||||
gtk4-layer-shell = "0.8"
|
gtk4-layer-shell = "0.8"
|
||||||
hyprland = "0.4.0-beta.3"
|
hyprland = "0.4.0-beta.3"
|
||||||
|
|
|
||||||
11
README.md
11
README.md
|
|
@ -56,7 +56,7 @@ User-defined tags can be added freely on top of the built-in types.
|
||||||
|
|
||||||
- **One-off reminders** — natural language time ("at 7pm", "in 30 minutes", "tomorrow morning") parsed at classification time; scheduled via a systemd user timer
|
- **One-off reminders** — natural language time ("at 7pm", "in 30 minutes", "tomorrow morning") parsed at classification time; scheduled via a systemd user timer
|
||||||
- **Recurring reminders** — "every Sunday at 9pm", "every weekday morning" — stored as an iCal-compatible RRULE and re-scheduled on each trigger
|
- **Recurring reminders** — "every Sunday at 9pm", "every weekday morning" — stored as an iCal-compatible RRULE and re-scheduled on each trigger
|
||||||
- **Snooze** — notification popup includes snooze actions: 15 min / 1 hour / tomorrow morning / custom; snoozing reschedules the timer without touching the original note
|
- **Snooze** — notification popup includes snooze actions drawn from `snooze_options` (default: 15 min / 1 hour / tomorrow morning); snoozing reschedules the timer without touching the original note
|
||||||
- **Missed reminders** — if the system was off or suspended at the scheduled time, the reminder fires on next login
|
- **Missed reminders** — if the system was off or suspended at the scheduled time, the reminder fires on next login
|
||||||
|
|
||||||
### Viewer (`breadman`)
|
### Viewer (`breadman`)
|
||||||
|
|
@ -139,14 +139,14 @@ breadpad model-info # shows active EP and model path
|
||||||
- systemd user session (for timer-backed reminders)
|
- systemd user session (for timer-backed reminders)
|
||||||
- Rust 1.80+
|
- 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 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.
|
- **Tier 3 only (optional):** [Ollama](https://ollama.com) running locally with your chosen model pulled (e.g. `ollama pull fastflowlm`). Tier 3 is silently skipped if Ollama is not running.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git clone https://github.com/breadway/breadpad
|
git clone https://git.breadway.dev/Breadway/breadpad
|
||||||
cd breadpad
|
cd breadpad
|
||||||
cargo build --release
|
cargo build --release
|
||||||
cp target/release/breadpad ~/.local/bin/
|
cp target/release/breadpad ~/.local/bin/
|
||||||
|
|
@ -183,7 +183,7 @@ ort_dylib_path = "" # optional: explicit path to libonnxruntime.so;
|
||||||
|
|
||||||
[model.ollama]
|
[model.ollama]
|
||||||
endpoint = "http://localhost:11434"
|
endpoint = "http://localhost:11434"
|
||||||
model = "llama3.2:3b" # any model you have pulled in Ollama
|
model = "fastflowlm" # any model you have pulled in Ollama
|
||||||
confidence_threshold = 0.6 # Tier 2 scores below this trigger Tier 3
|
confidence_threshold = 0.6 # Tier 2 scores below this trigger Tier 3
|
||||||
enabled = true # set false to never call Ollama
|
enabled = true # set false to never call Ollama
|
||||||
|
|
||||||
|
|
@ -243,6 +243,9 @@ breadpad --no-classify
|
||||||
|
|
||||||
# Show model and storage status
|
# Show model and storage status
|
||||||
breadpad --status
|
breadpad --status
|
||||||
|
|
||||||
|
# Print expected model paths (does not download automatically)
|
||||||
|
breadpad download-model
|
||||||
```
|
```
|
||||||
|
|
||||||
Hyprland keybind:
|
Hyprland keybind:
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ use breadpad_shared::{
|
||||||
store::Store,
|
store::Store,
|
||||||
types::{Note, NoteType, RecurrenceRule},
|
types::{Note, NoteType, RecurrenceRule},
|
||||||
};
|
};
|
||||||
use chrono::Local;
|
use chrono::{DateTime, Local, Utc};
|
||||||
use gtk4::{glib, prelude::*};
|
use gtk4::{glib, prelude::*};
|
||||||
use std::cell::RefCell;
|
use std::cell::RefCell;
|
||||||
use std::rc::Rc;
|
use std::rc::Rc;
|
||||||
|
|
@ -69,10 +69,21 @@ struct AppState {
|
||||||
errors: Rc<RefCell<Vec<(chrono::DateTime<Local>, String)>>>,
|
errors: Rc<RefCell<Vec<(chrono::DateTime<Local>, String)>>>,
|
||||||
active_view: Rc<RefCell<String>>,
|
active_view: Rc<RefCell<String>>,
|
||||||
stack: gtk4::Stack,
|
stack: gtk4::Stack,
|
||||||
|
/// Sidebar row id ("all", "upcoming", "archive", or a note type name) ->
|
||||||
|
/// its count `Label`, so counts can be refreshed in place after every
|
||||||
|
/// `rebuild_stack` without rebuilding the sidebar itself.
|
||||||
|
sidebar_counts: Rc<RefCell<Vec<(String, gtk4::Label)>>>,
|
||||||
|
status_label: gtk4::Label,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
fn new(store: Arc<Store>, notes: Vec<Note>, cfg: Config, stack: gtk4::Stack) -> Self {
|
fn new(
|
||||||
|
store: Arc<Store>,
|
||||||
|
notes: Vec<Note>,
|
||||||
|
cfg: Config,
|
||||||
|
stack: gtk4::Stack,
|
||||||
|
status_label: gtk4::Label,
|
||||||
|
) -> Self {
|
||||||
AppState {
|
AppState {
|
||||||
store,
|
store,
|
||||||
notes: Rc::new(RefCell::new(notes)),
|
notes: Rc::new(RefCell::new(notes)),
|
||||||
|
|
@ -80,6 +91,8 @@ impl AppState {
|
||||||
errors: Rc::new(RefCell::new(Vec::new())),
|
errors: Rc::new(RefCell::new(Vec::new())),
|
||||||
active_view: Rc::new(RefCell::new("all".to_string())),
|
active_view: Rc::new(RefCell::new("all".to_string())),
|
||||||
stack,
|
stack,
|
||||||
|
sidebar_counts: Rc::new(RefCell::new(Vec::new())),
|
||||||
|
status_label,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -192,6 +205,40 @@ fn rebuild_stack(state: &AppState) {
|
||||||
// Errors
|
// Errors
|
||||||
let errors_view = views::errors::build(&errors);
|
let errors_view = views::errors::build(&errors);
|
||||||
state.stack.add_named(&errors_view, Some("errors"));
|
state.stack.add_named(&errors_view, Some("errors"));
|
||||||
|
|
||||||
|
update_counts_and_status(state);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Refreshes the sidebar's per-row counts and the content pane's footer
|
||||||
|
/// note count from the current `state.notes`. Cheap enough to call on every
|
||||||
|
/// rebuild — five type counts plus all/upcoming/archive over a note list
|
||||||
|
/// that in practice stays small.
|
||||||
|
fn update_counts_and_status(state: &AppState) {
|
||||||
|
let notes = state.notes.borrow();
|
||||||
|
let total = notes.iter().filter(|n| !n.done).count();
|
||||||
|
state
|
||||||
|
.status_label
|
||||||
|
.set_label(&format!("{total} note{}", if total == 1 { "" } else { "s" }));
|
||||||
|
|
||||||
|
for (key, label) in state.sidebar_counts.borrow().iter() {
|
||||||
|
let n = match key.as_str() {
|
||||||
|
"all" => total,
|
||||||
|
"upcoming" => notes
|
||||||
|
.iter()
|
||||||
|
.filter(|n| {
|
||||||
|
!n.done
|
||||||
|
&& matches!(n.note_type, NoteType::Reminder | NoteType::Todo)
|
||||||
|
&& n.effective_time().is_some()
|
||||||
|
})
|
||||||
|
.count(),
|
||||||
|
"archive" => notes.iter().filter(|n| n.done).count(),
|
||||||
|
other => {
|
||||||
|
let nt = NoteType::from_str(other);
|
||||||
|
notes.iter().filter(|n| !n.done && n.note_type == nt).count()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
label.set_label(&n.to_string());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── main ─────────────────────────────────────────────────────────────────────
|
// ── main ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
@ -331,7 +378,12 @@ fn build_app_window(
|
||||||
));
|
));
|
||||||
row
|
row
|
||||||
};
|
};
|
||||||
let make_item = |id: &str, icon: &str, label: &str| {
|
// Returns the row plus its count `Label` when `counted` is set — callers
|
||||||
|
// collect these so counts can be kept live from `AppState.sidebar_counts`
|
||||||
|
// (monochrome geometric icons + a colored dot per type instead of the
|
||||||
|
// old full-color emoji, which always render in fixed colors no matter
|
||||||
|
// what the pywal palette says).
|
||||||
|
let make_item = |id: &str, icon: &str, icon_class: Option<&str>, label: &str, counted: bool| {
|
||||||
let row = gtk4::ListBoxRow::builder()
|
let row = gtk4::ListBoxRow::builder()
|
||||||
.css_classes(["sidebar-row"])
|
.css_classes(["sidebar-row"])
|
||||||
.build();
|
.build();
|
||||||
|
|
@ -340,13 +392,15 @@ fn build_app_window(
|
||||||
.orientation(gtk4::Orientation::Horizontal)
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
.spacing(10)
|
.spacing(10)
|
||||||
.build();
|
.build();
|
||||||
hbox.append(
|
let icon_label = gtk4::Label::builder()
|
||||||
>k4::Label::builder()
|
.label(icon)
|
||||||
.label(icon)
|
.width_chars(2)
|
||||||
.width_chars(2)
|
.xalign(0.5)
|
||||||
.xalign(0.5)
|
.build();
|
||||||
.build(),
|
if let Some(class) = icon_class {
|
||||||
);
|
icon_label.add_css_class(class);
|
||||||
|
}
|
||||||
|
hbox.append(&icon_label);
|
||||||
hbox.append(
|
hbox.append(
|
||||||
>k4::Label::builder()
|
>k4::Label::builder()
|
||||||
.label(label)
|
.label(label)
|
||||||
|
|
@ -354,23 +408,62 @@ fn build_app_window(
|
||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
.build(),
|
.build(),
|
||||||
);
|
);
|
||||||
|
let count_label = if counted {
|
||||||
|
let l = gtk4::Label::builder()
|
||||||
|
.label("")
|
||||||
|
.css_classes(["sidebar-count"])
|
||||||
|
.build();
|
||||||
|
hbox.append(&l);
|
||||||
|
Some(l)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
row.set_child(Some(&hbox));
|
row.set_child(Some(&hbox));
|
||||||
row
|
(row, count_label)
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let mut sidebar_counts: Vec<(String, gtk4::Label)> = Vec::new();
|
||||||
|
|
||||||
sidebar_list.append(&make_section("VIEWS"));
|
sidebar_list.append(&make_section("VIEWS"));
|
||||||
sidebar_list.append(&make_item("all", "📋", "All"));
|
{
|
||||||
sidebar_list.append(&make_item("upcoming", "📅", "Upcoming"));
|
let (row, count) = make_item("all", "▦", None, "All", true);
|
||||||
|
sidebar_counts.push(("all".into(), count.unwrap()));
|
||||||
|
sidebar_list.append(&row);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let (row, count) = make_item("upcoming", "◷", None, "Upcoming", true);
|
||||||
|
sidebar_counts.push(("upcoming".into(), count.unwrap()));
|
||||||
|
sidebar_list.append(&row);
|
||||||
|
}
|
||||||
sidebar_list.append(&make_section("TYPES"));
|
sidebar_list.append(&make_section("TYPES"));
|
||||||
sidebar_list.append(&make_item("todo", "✅", "Todo"));
|
for (id, icon_class, label) in [
|
||||||
sidebar_list.append(&make_item("reminder", "🔔", "Reminder"));
|
("todo", "icon-todo", "Todo"),
|
||||||
sidebar_list.append(&make_item("idea", "💡", "Idea"));
|
("reminder", "icon-reminder", "Reminder"),
|
||||||
sidebar_list.append(&make_item("note", "📝", "Note"));
|
("idea", "icon-idea", "Idea"),
|
||||||
sidebar_list.append(&make_item("question", "❓", "Question"));
|
("note", "icon-note", "Note"),
|
||||||
|
("question", "icon-question", "Question"),
|
||||||
|
] {
|
||||||
|
let (row, count) = make_item(id, "●", Some(icon_class), label, true);
|
||||||
|
sidebar_counts.push((id.to_string(), count.unwrap()));
|
||||||
|
sidebar_list.append(&row);
|
||||||
|
}
|
||||||
sidebar_list.append(&make_section("MORE"));
|
sidebar_list.append(&make_section("MORE"));
|
||||||
sidebar_list.append(&make_item("archive", "📦", "Archive"));
|
{
|
||||||
sidebar_list.append(&make_item("settings", "⚙", "Settings"));
|
let (row, count) = make_item("archive", "▢", None, "Archive", true);
|
||||||
sidebar_list.append(&make_item("errors", "⚠", "Errors"));
|
sidebar_counts.push(("archive".into(), count.unwrap()));
|
||||||
|
sidebar_list.append(&row);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
let (row, _) = make_item("settings", "⚙", None, "Settings", false);
|
||||||
|
sidebar_list.append(&row);
|
||||||
|
}
|
||||||
|
{
|
||||||
|
// De-emphasized: session-only debug info, not part of the daily
|
||||||
|
// triage flow the rest of the sidebar serves.
|
||||||
|
let (row, _) = make_item("errors", "⚠", None, "Errors", false);
|
||||||
|
row.add_css_class("sidebar-row-minor");
|
||||||
|
sidebar_list.append(&row);
|
||||||
|
}
|
||||||
sidebar_vbox.append(&sidebar_list);
|
sidebar_vbox.append(&sidebar_list);
|
||||||
|
|
||||||
// ── Content area ──────────────────────────────────────────────
|
// ── Content area ──────────────────────────────────────────────
|
||||||
|
|
@ -390,8 +483,19 @@ fn build_app_window(
|
||||||
|
|
||||||
let stack = gtk4::Stack::builder().hexpand(true).vexpand(true).build();
|
let stack = gtk4::Stack::builder().hexpand(true).vexpand(true).build();
|
||||||
|
|
||||||
|
let status_label = gtk4::Label::builder()
|
||||||
|
.label("0 notes")
|
||||||
|
.css_classes(["dim-label"])
|
||||||
|
.xalign(0.0)
|
||||||
|
.margin_start(12)
|
||||||
|
.margin_end(12)
|
||||||
|
.margin_top(6)
|
||||||
|
.margin_bottom(8)
|
||||||
|
.build();
|
||||||
|
|
||||||
content_vbox.append(&search_entry);
|
content_vbox.append(&search_entry);
|
||||||
content_vbox.append(&stack);
|
content_vbox.append(&stack);
|
||||||
|
content_vbox.append(&status_label);
|
||||||
|
|
||||||
hbox.append(&sidebar_vbox);
|
hbox.append(&sidebar_vbox);
|
||||||
hbox.append(>k4::Separator::builder()
|
hbox.append(>k4::Separator::builder()
|
||||||
|
|
@ -401,7 +505,8 @@ fn build_app_window(
|
||||||
window.set_child(Some(&hbox));
|
window.set_child(Some(&hbox));
|
||||||
|
|
||||||
// ── AppState ──────────────────────────────────────────────────
|
// ── AppState ──────────────────────────────────────────────────
|
||||||
let state = AppState::new(store, notes, cfg, stack.clone());
|
let state = AppState::new(store, notes, cfg, stack.clone(), status_label.clone());
|
||||||
|
state.sidebar_counts.replace(sidebar_counts);
|
||||||
|
|
||||||
// Initial build
|
// Initial build
|
||||||
rebuild_stack(&state);
|
rebuild_stack(&state);
|
||||||
|
|
@ -478,6 +583,9 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow {
|
||||||
.vexpand(true)
|
.vexpand(true)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
|
// Capped and centered so cards don't stretch edge-to-edge on a wide
|
||||||
|
// window — full-width rows left the type chip and action buttons
|
||||||
|
// hundreds of pixels from the title they belong to.
|
||||||
let list = gtk4::Box::builder()
|
let list = gtk4::Box::builder()
|
||||||
.orientation(gtk4::Orientation::Vertical)
|
.orientation(gtk4::Orientation::Vertical)
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
|
|
@ -485,18 +593,33 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow {
|
||||||
.margin_bottom(12)
|
.margin_bottom(12)
|
||||||
.margin_start(12)
|
.margin_start(12)
|
||||||
.margin_end(12)
|
.margin_end(12)
|
||||||
|
.width_request(700)
|
||||||
|
.halign(gtk4::Align::Center)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let mut sorted: Vec<Note> = notes.iter().filter(|n| !n.done).cloned().collect();
|
let mut sorted: Vec<Note> = notes.iter().filter(|n| !n.done).cloned().collect();
|
||||||
sorted.sort_by(|a, b| b.created.cmp(&a.created));
|
sorted.sort_by(|a, b| b.created.cmp(&a.created));
|
||||||
|
|
||||||
if sorted.is_empty() {
|
if sorted.is_empty() {
|
||||||
list.append(
|
let empty = gtk4::Box::builder()
|
||||||
|
.orientation(gtk4::Orientation::Vertical)
|
||||||
|
.spacing(6)
|
||||||
|
.halign(gtk4::Align::Center)
|
||||||
|
.margin_top(64)
|
||||||
|
.build();
|
||||||
|
empty.append(
|
||||||
>k4::Label::builder()
|
>k4::Label::builder()
|
||||||
.label("No notes here yet.")
|
.label("No notes here yet")
|
||||||
.margin_top(32)
|
.css_classes(["note-title"])
|
||||||
.build(),
|
.build(),
|
||||||
);
|
);
|
||||||
|
empty.append(
|
||||||
|
>k4::Label::builder()
|
||||||
|
.label("Capture something with breadpad and it'll show up here.")
|
||||||
|
.css_classes(["dim-label"])
|
||||||
|
.build(),
|
||||||
|
);
|
||||||
|
list.append(&empty);
|
||||||
} else {
|
} else {
|
||||||
for note in &sorted {
|
for note in &sorted {
|
||||||
list.append(&build_note_card(note, state.clone()));
|
list.append(&build_note_card(note, state.clone()));
|
||||||
|
|
@ -507,6 +630,26 @@ fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow {
|
||||||
scroll
|
scroll
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Short relative form for a recency-ordered list ("2h ago"); the exact
|
||||||
|
/// absolute timestamp is still available via tooltip. Falls back to a plain
|
||||||
|
/// date once a note is more than a week old, where "Nd ago" stops being
|
||||||
|
/// useful at a glance.
|
||||||
|
fn humanize_relative(dt: DateTime<Utc>) -> String {
|
||||||
|
let secs = Utc::now().signed_duration_since(dt).num_seconds().max(0);
|
||||||
|
if secs < 60 {
|
||||||
|
"just now".to_string()
|
||||||
|
} else if secs < 3600 {
|
||||||
|
format!("{}m ago", secs / 60)
|
||||||
|
} else if secs < 86_400 {
|
||||||
|
format!("{}h ago", secs / 3600)
|
||||||
|
} else if secs < 86_400 * 7 {
|
||||||
|
format!("{}d ago", secs / 86_400)
|
||||||
|
} else {
|
||||||
|
let local: DateTime<Local> = dt.into();
|
||||||
|
local.format("%b %d").to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
|
fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
|
||||||
let card = gtk4::Box::builder()
|
let card = gtk4::Box::builder()
|
||||||
.orientation(gtk4::Orientation::Vertical)
|
.orientation(gtk4::Orientation::Vertical)
|
||||||
|
|
@ -530,6 +673,7 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
|
||||||
.hexpand(true)
|
.hexpand(true)
|
||||||
.xalign(0.0)
|
.xalign(0.0)
|
||||||
.wrap(true)
|
.wrap(true)
|
||||||
|
.css_classes(["note-title"])
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let type_chip = gtk4::Label::builder()
|
let type_chip = gtk4::Label::builder()
|
||||||
|
|
@ -546,14 +690,15 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
|
||||||
.spacing(8)
|
.spacing(8)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
let created_str = {
|
let created_abs = {
|
||||||
let local: chrono::DateTime<Local> = note.created.into();
|
let local: chrono::DateTime<Local> = note.created.into();
|
||||||
local.format("%b %d %H:%M").to_string()
|
local.format("%b %d %H:%M").to_string()
|
||||||
};
|
};
|
||||||
let meta_label = gtk4::Label::builder()
|
let meta_label = gtk4::Label::builder()
|
||||||
.label(&created_str)
|
.label(&humanize_relative(note.created))
|
||||||
.css_classes(["dim-label"])
|
.css_classes(["dim-label"])
|
||||||
.xalign(0.0)
|
.xalign(0.0)
|
||||||
|
.tooltip_text(&created_abs)
|
||||||
.build();
|
.build();
|
||||||
|
|
||||||
// Date first, then chips
|
// Date first, then chips
|
||||||
|
|
@ -561,8 +706,9 @@ fn build_note_card(note: &Note, state: AppState) -> gtk4::Box {
|
||||||
if let Some(ws) = ¬e.workspace {
|
if let Some(ws) = ¬e.workspace {
|
||||||
bottom_row.append(
|
bottom_row.append(
|
||||||
>k4::Label::builder()
|
>k4::Label::builder()
|
||||||
.label(&format!("ws:{}", ws))
|
.label(&format!("ws {}", ws))
|
||||||
.css_classes(["type-chip"])
|
.css_classes(["type-chip"])
|
||||||
|
.tooltip_text(&format!("Workspace {}", ws))
|
||||||
.build(),
|
.build(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ authors.workspace = true
|
||||||
|
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.8", features = ["gtk"] }
|
bread-theme = { git = "https://github.com/Breadway/bread-ecosystem", tag = "v0.2.10", features = ["gtk"] }
|
||||||
anyhow.workspace = true
|
anyhow.workspace = true
|
||||||
tracing.workspace = true
|
tracing.workspace = true
|
||||||
serde.workspace = true
|
serde.workspace = true
|
||||||
|
|
@ -26,6 +26,8 @@ regex.workspace = true
|
||||||
ureq.workspace = true
|
ureq.workspace = true
|
||||||
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
|
||||||
ical = "0.11"
|
ical = "0.11"
|
||||||
|
bread-onnx = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0" }
|
||||||
|
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0" }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = "3"
|
tempfile = "3"
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,14 @@ impl OllamaClient {
|
||||||
"stream": false
|
"stream": false
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ureq 2's default agent has no overall request timeout, so a hung
|
||||||
|
// local Ollama endpoint would otherwise stall this call forever —
|
||||||
|
// and since classification now runs from an idle callback after the
|
||||||
|
// capture window has already closed (see `main.rs`), a hang here is
|
||||||
|
// invisible to the user, not just slow.
|
||||||
let response = ureq::post(&url)
|
let response = ureq::post(&url)
|
||||||
.set("Content-Type", "application/json")
|
.set("Content-Type", "application/json")
|
||||||
|
.timeout(std::time::Duration::from_secs(10))
|
||||||
.send_json(payload)
|
.send_json(payload)
|
||||||
.map_err(|e| anyhow::anyhow!("Ollama HTTP error: {}", e))?;
|
.map_err(|e| anyhow::anyhow!("Ollama HTTP error: {}", e))?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,13 @@ impl CalDavClient {
|
||||||
pub fn new(config: CalendarConfig) -> Self {
|
pub fn new(config: CalendarConfig) -> Self {
|
||||||
// `reqwest::Client::builder().build()` can only fail if the TLS backend can't be
|
// `reqwest::Client::builder().build()` can only fail if the TLS backend can't be
|
||||||
// initialised; fall back to `Client::new()` semantics rather than panicking.
|
// initialised; fall back to `Client::new()` semantics rather than panicking.
|
||||||
|
//
|
||||||
|
// A request-wide timeout is set here (rather than on `Client::new()`'s
|
||||||
|
// untimed defaults) so a hung/unreachable CalDAV server can't hang
|
||||||
|
// whatever's making the request indefinitely — `reqwest::Client::new()`
|
||||||
|
// has no timeout of its own.
|
||||||
let client = reqwest::Client::builder()
|
let client = reqwest::Client::builder()
|
||||||
|
.timeout(std::time::Duration::from_secs(15))
|
||||||
.build()
|
.build()
|
||||||
.unwrap_or_else(|e| {
|
.unwrap_or_else(|e| {
|
||||||
tracing::warn!("falling back to default HTTP client: {}", e);
|
tracing::warn!("falling back to default HTTP client: {}", e);
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,7 @@ pub enum ExecutionProvider {
|
||||||
impl ExecutionProvider {
|
impl ExecutionProvider {
|
||||||
pub fn as_str(&self) -> &str {
|
pub fn as_str(&self) -> &str {
|
||||||
match self {
|
match self {
|
||||||
ExecutionProvider::Gpu => "ROCm (iGPU)",
|
ExecutionProvider::Gpu => "MIGraphX (iGPU)",
|
||||||
ExecutionProvider::Cpu => "CPU",
|
ExecutionProvider::Cpu => "CPU",
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -32,10 +32,13 @@ pub struct Classifier {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn model_dir() -> PathBuf {
|
fn model_dir() -> PathBuf {
|
||||||
dirs::data_local_dir()
|
// Was `dirs::data_local_dir().unwrap_or_else(|| PathBuf::from("~/.local/share"))`
|
||||||
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
|
// — the same literal-tilde-fallback bug flagged (but not fixed) in
|
||||||
.join("breadpad")
|
// breadclip-core tonight: PathBuf/std::fs never expand `~`, so on the
|
||||||
.join("model")
|
// rare box where `dirs` can't resolve a home directory this silently
|
||||||
|
// resolved to a directory literally named `~` under the current working
|
||||||
|
// directory instead of the user's actual home.
|
||||||
|
bread_utils::xdg::data_dir("breadpad").join("model")
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Classifier {
|
impl Classifier {
|
||||||
|
|
@ -246,21 +249,46 @@ fn softmax_single(logits: &[f32], idx: usize) -> f32 {
|
||||||
fn try_load_session(
|
fn try_load_session(
|
||||||
path: &std::path::Path,
|
path: &std::path::Path,
|
||||||
) -> (Option<ort::session::Session>, ExecutionProvider) {
|
) -> (Option<ort::session::Session>, ExecutionProvider) {
|
||||||
// Try ROCm (iGPU) first, fall back to CPU.
|
// AMD iGPU via MIGraphX, falling back to CPU. This used to request the
|
||||||
let rocm_available = {
|
// classic `ort::ep::ROCm` (ROCMExecutionProvider) first — per this
|
||||||
|
// machine's own breadsearch-gpu-backends operator notes, that EP
|
||||||
|
// silently no-ops back to CPU on this class of system (distro ROCm
|
||||||
|
// onnxruntime builds, e.g. Arch's onnxruntime-rocm, are commonly
|
||||||
|
// compiled with `--use_migraphx`, not `--use_rocm`), so "ROCm (iGPU)"
|
||||||
|
// could report as active in this struct's own `active_provider` while
|
||||||
|
// every inference actually ran on CPU. See bread_onnx::provider's doc
|
||||||
|
// comment for the full history — breadsearch's own embed.rs already
|
||||||
|
// got this right.
|
||||||
|
//
|
||||||
|
// The `is_available()` gate (kept from the original implementation)
|
||||||
|
// means `active_provider` only ever claims Gpu when we actually
|
||||||
|
// attempted the GPU build — bread_onnx::build_session's loud EP-
|
||||||
|
// selection logging (visible once tracing_subscriber is initialized,
|
||||||
|
// which this crate's own main.rs already does) is what catches the
|
||||||
|
// *silent per-node fallback* class of bug this rewrite exists to fix,
|
||||||
|
// rather than papering over it by unconditionally reporting Gpu.
|
||||||
|
let migraphx_available = {
|
||||||
use ort::execution_providers::ExecutionProvider as _;
|
use ort::execution_providers::ExecutionProvider as _;
|
||||||
ort::ep::ROCm::default().is_available().unwrap_or(false)
|
ort::ep::MIGraphX::default().is_available().unwrap_or(false)
|
||||||
};
|
};
|
||||||
if rocm_available {
|
if migraphx_available {
|
||||||
match build_onnx_session(path, ort::ep::ROCm::default().build()) {
|
match bread_onnx::build_session(
|
||||||
|
path,
|
||||||
|
ort::session::builder::GraphOptimizationLevel::Level3,
|
||||||
|
&[bread_onnx::Provider::MiGraphX { device_id: 0 }],
|
||||||
|
) {
|
||||||
Ok(s) => {
|
Ok(s) => {
|
||||||
tracing::info!("ONNX session loaded (ROCm iGPU)");
|
tracing::info!("ONNX session loaded (MIGraphX iGPU)");
|
||||||
return (Some(s), ExecutionProvider::Gpu);
|
return (Some(s), ExecutionProvider::Gpu);
|
||||||
}
|
}
|
||||||
Err(e) => tracing::debug!("ROCm EP unavailable: {}; trying CPU", e),
|
Err(e) => tracing::debug!("MIGraphX EP unavailable: {}; trying CPU", e),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
match build_onnx_session(path, ort::ep::CPU::default().build()) {
|
match bread_onnx::build_session(
|
||||||
|
path,
|
||||||
|
ort::session::builder::GraphOptimizationLevel::Level3,
|
||||||
|
&[bread_onnx::Provider::Cpu],
|
||||||
|
) {
|
||||||
Ok(s) => {
|
Ok(s) => {
|
||||||
tracing::info!("ONNX session loaded (CPU)");
|
tracing::info!("ONNX session loaded (CPU)");
|
||||||
(Some(s), ExecutionProvider::Cpu)
|
(Some(s), ExecutionProvider::Cpu)
|
||||||
|
|
@ -271,14 +299,3 @@ fn try_load_session(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_onnx_session(
|
|
||||||
path: &std::path::Path,
|
|
||||||
ep: ort::ep::ExecutionProviderDispatch,
|
|
||||||
) -> anyhow::Result<ort::session::Session> {
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -193,20 +193,32 @@ impl Config {
|
||||||
}
|
}
|
||||||
let text = toml::to_string_pretty(self)?;
|
let text = toml::to_string_pretty(self)?;
|
||||||
fs::write(&path, text)?;
|
fs::write(&path, text)?;
|
||||||
|
|
||||||
|
// This file can hold the CalDAV password in plaintext (see
|
||||||
|
// `CalendarConfig`'s own doc comment) — `fs::write` creates it with
|
||||||
|
// the process's default umask, which on most setups means
|
||||||
|
// world-readable. Lock it down to owner-only rather than just
|
||||||
|
// telling the user to do it themselves.
|
||||||
|
#[cfg(unix)]
|
||||||
|
{
|
||||||
|
use std::os::unix::fs::PermissionsExt;
|
||||||
|
if let Err(e) = fs::set_permissions(&path, fs::Permissions::from_mode(0o600)) {
|
||||||
|
tracing::warn!("failed to restrict permissions on {}: {}", path.display(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn config_path() -> PathBuf {
|
pub fn config_path() -> PathBuf {
|
||||||
dirs::config_dir()
|
// Was `dirs::config_dir().unwrap_or_else(|| PathBuf::from("~/.config"))`
|
||||||
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
// — same literal-tilde-fallback bug as `classifier.rs::model_dir` (see
|
||||||
.join("breadpad")
|
// its doc comment) and breadclip-core's `data_dir`; PathBuf/std::fs
|
||||||
.join("breadpad.toml")
|
// never expand `~`.
|
||||||
|
bread_utils::xdg::config_dir("breadpad").join("breadpad.toml")
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn style_css_path() -> PathBuf {
|
pub fn style_css_path() -> PathBuf {
|
||||||
dirs::config_dir()
|
bread_utils::xdg::config_dir("breadpad").join("style.css")
|
||||||
.unwrap_or_else(|| PathBuf::from("~/.config"))
|
|
||||||
.join("breadpad")
|
|
||||||
.join("style.css")
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,43 @@ fn rrule_weekday(wd: Weekday) -> &'static str {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Explicit type prefixes, checked before any lexical heuristics. Short forms
|
||||||
|
/// exist so the capture popup can be driven without reaching for the mouse —
|
||||||
|
/// see `breadpad_shared::parser::detect_prefix_type`, which the popup's entry
|
||||||
|
/// uses to live-highlight the matching chip as the user types.
|
||||||
|
const TYPE_PREFIXES: &[(&str, NoteType)] = &[
|
||||||
|
("td:", NoteType::Todo),
|
||||||
|
("rem:", NoteType::Reminder),
|
||||||
|
("idea:", NoteType::Idea),
|
||||||
|
("note:", NoteType::Note),
|
||||||
|
("q:", NoteType::Question),
|
||||||
|
];
|
||||||
|
|
||||||
|
/// If `text` starts with one of [`TYPE_PREFIXES`] (case-insensitive), returns
|
||||||
|
/// the type it forces. Used both to classify at save time and to live-drive
|
||||||
|
/// the popup's chip highlighting as the user types.
|
||||||
|
pub fn detect_prefix_type(text: &str) -> Option<NoteType> {
|
||||||
|
let lower = text.trim_start().to_lowercase();
|
||||||
|
TYPE_PREFIXES
|
||||||
|
.iter()
|
||||||
|
.find(|(prefix, _)| lower.starts_with(prefix))
|
||||||
|
.map(|(_, nt)| nt.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Strips a leading explicit type prefix (if any), returning the forced type
|
||||||
|
/// and the remaining text with the prefix and any following whitespace removed.
|
||||||
|
fn strip_explicit_prefix(text: &str) -> (Option<NoteType>, String) {
|
||||||
|
let trimmed = text.trim_start();
|
||||||
|
let lower = trimmed.to_lowercase();
|
||||||
|
for (prefix, nt) in TYPE_PREFIXES {
|
||||||
|
if lower.starts_with(prefix) {
|
||||||
|
let rest = trimmed[prefix.len()..].trim_start().to_string();
|
||||||
|
return (Some(nt.clone()), rest);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
(None, text.to_string())
|
||||||
|
}
|
||||||
|
|
||||||
fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime<Utc> {
|
fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime<Utc> {
|
||||||
let local = Local::now();
|
let local = Local::now();
|
||||||
let days_ahead = (wd.num_days_from_monday() as i64
|
let days_ahead = (wd.num_days_from_monday() as i64
|
||||||
|
|
@ -105,6 +142,8 @@ fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime<Utc> {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResult {
|
pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResult {
|
||||||
|
let (forced_type, text_owned) = strip_explicit_prefix(text);
|
||||||
|
let text: &str = &text_owned;
|
||||||
let p = patterns();
|
let p = patterns();
|
||||||
let morning_time: NaiveTime = default_morning
|
let morning_time: NaiveTime = default_morning
|
||||||
.split(':')
|
.split(':')
|
||||||
|
|
@ -290,8 +329,9 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
|
||||||
.to_string();
|
.to_string();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Infer note type
|
// Infer note type — an explicit prefix (`td:`, `rem:`, …) always wins.
|
||||||
let note_type = infer_type(text, extracted_time.is_some(), rrule.is_some());
|
let note_type =
|
||||||
|
forced_type.clone().unwrap_or_else(|| infer_type(text, extracted_time.is_some(), rrule.is_some()));
|
||||||
|
|
||||||
// Trim artifacts
|
// Trim artifacts
|
||||||
cleaned = cleaned
|
cleaned = cleaned
|
||||||
|
|
@ -303,7 +343,9 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
|
||||||
|
|
||||||
// Calibrated confidence: high when structural signals drove the decision,
|
// Calibrated confidence: high when structural signals drove the decision,
|
||||||
// low when we fell back to "note" with no positive evidence.
|
// low when we fell back to "note" with no positive evidence.
|
||||||
let confidence = if rrule.is_some() || extracted_time.is_some() {
|
let confidence = if forced_type.is_some() {
|
||||||
|
0.99 // explicit prefix — unambiguous
|
||||||
|
} else if rrule.is_some() || extracted_time.is_some() {
|
||||||
0.95 // time/recurrence extraction is deterministic
|
0.95 // time/recurrence extraction is deterministic
|
||||||
} else {
|
} else {
|
||||||
match ¬e_type {
|
match ¬e_type {
|
||||||
|
|
@ -410,6 +452,70 @@ mod tests {
|
||||||
assert_eq!(p("idea: reactive state module in Lua").note_type, NoteType::Idea);
|
assert_eq!(p("idea: reactive state module in Lua").note_type, NoteType::Idea);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn idea_prefix_stripped_from_body() {
|
||||||
|
let r = p("idea: reactive state module in Lua");
|
||||||
|
assert_eq!(r.body, "reactive state module in Lua");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Explicit short prefixes (td:, rem:, note:, q:) ----
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn td_prefix_is_todo() {
|
||||||
|
let r = p("td: buy milk");
|
||||||
|
assert_eq!(r.note_type, NoteType::Todo);
|
||||||
|
assert_eq!(r.body, "buy milk");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rem_prefix_is_reminder() {
|
||||||
|
let r = p("rem: water the plants");
|
||||||
|
assert_eq!(r.note_type, NoteType::Reminder);
|
||||||
|
assert_eq!(r.body, "water the plants");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn note_prefix_is_note() {
|
||||||
|
// Without the prefix this would classify as Todo ("check ...").
|
||||||
|
let r = p("note: check engine light has been on for a week");
|
||||||
|
assert_eq!(r.note_type, NoteType::Note);
|
||||||
|
assert_eq!(r.body, "check engine light has been on for a week");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn q_prefix_is_question() {
|
||||||
|
// Without the prefix this has no strong signal and would fall to Note.
|
||||||
|
let r = p("q: ONNX rocm vs cpu perf");
|
||||||
|
assert_eq!(r.note_type, NoteType::Question);
|
||||||
|
assert_eq!(r.body, "ONNX rocm vs cpu perf");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_prefix_confidence_is_high() {
|
||||||
|
assert_eq!(p("td: buy milk").confidence, 0.99);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_prefix_is_case_insensitive() {
|
||||||
|
assert_eq!(p("TD: buy milk").note_type, NoteType::Todo);
|
||||||
|
assert_eq!(p("Rem: standup").note_type, NoteType::Reminder);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn explicit_prefix_overrides_time_extraction_type() {
|
||||||
|
// "at 7pm" alone would infer Reminder; an explicit td: prefix wins.
|
||||||
|
let r = p("td: pack bag at 7pm");
|
||||||
|
assert_eq!(r.note_type, NoteType::Todo);
|
||||||
|
assert!(r.time.is_some(), "time should still be extracted");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn detect_prefix_type_matches_parse() {
|
||||||
|
assert_eq!(detect_prefix_type("td: buy milk"), Some(NoteType::Todo));
|
||||||
|
assert_eq!(detect_prefix_type("rem: call mum"), Some(NoteType::Reminder));
|
||||||
|
assert_eq!(detect_prefix_type("no prefix here"), None);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn idea_maybe() {
|
fn idea_maybe() {
|
||||||
assert_eq!(p("maybe we could cache the ONNX model").note_type, NoteType::Idea);
|
assert_eq!(p("maybe we could cache the ONNX model").note_type, NoteType::Idea);
|
||||||
|
|
@ -884,7 +990,6 @@ fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType {
|
||||||
return NoteType::Todo;
|
return NoteType::Todo;
|
||||||
}
|
}
|
||||||
if lower.starts_with("what if ")
|
if lower.starts_with("what if ")
|
||||||
|| lower.starts_with("idea:")
|
|
||||||
|| lower.contains("could ")
|
|| lower.contains("could ")
|
||||||
|| lower.contains("maybe ")
|
|| lower.contains("maybe ")
|
||||||
|| lower.starts_with("should we ")
|
|| lower.starts_with("should we ")
|
||||||
|
|
|
||||||
|
|
@ -206,6 +206,42 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O
|
||||||
(now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time);
|
(now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time);
|
||||||
return Some(local_naive_to_utc(target_date));
|
return Some(local_naive_to_utc(target_date));
|
||||||
}
|
}
|
||||||
|
"MONTHLY" => {
|
||||||
|
use chrono::Datelike;
|
||||||
|
// BYMONTHDAY isn't guaranteed to be present — breadman's note
|
||||||
|
// editor lets a user type an arbitrary RRULE by hand, and
|
||||||
|
// "FREQ=MONTHLY" alone is a perfectly valid (if under-specified)
|
||||||
|
// one. Fall back to today's day-of-month, mirroring how WEEKLY
|
||||||
|
// above defaults BYDAY to "MO" when absent.
|
||||||
|
let day: u32 = parts
|
||||||
|
.get("BYMONTHDAY")
|
||||||
|
.and_then(|v| v.parse().ok())
|
||||||
|
.filter(|d: &u32| (1..=31).contains(d))
|
||||||
|
.unwrap_or_else(|| now.day());
|
||||||
|
|
||||||
|
let mut year = now.year();
|
||||||
|
let mut month = now.month();
|
||||||
|
|
||||||
|
// Walk forward month by month for the next calendar date that
|
||||||
|
// (a) actually has this day-of-month (a 31st skips e.g. April)
|
||||||
|
// and (b) is still in the future. Bounded to 24 months as a
|
||||||
|
// defensive cap — every valid day (1-31) recurs well within a
|
||||||
|
// year, so this should never come close to firing.
|
||||||
|
for _ in 0..24 {
|
||||||
|
if let Some(date) = chrono::NaiveDate::from_ymd_opt(year, month, day) {
|
||||||
|
let candidate = date.and_time(fire_time);
|
||||||
|
if now.naive_local() < candidate {
|
||||||
|
return Some(local_naive_to_utc(candidate));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
month += 1;
|
||||||
|
if month > 12 {
|
||||||
|
month = 1;
|
||||||
|
year += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
_ => None,
|
_ => None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -404,7 +440,40 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn unknown_freq_returns_none() {
|
fn unknown_freq_returns_none() {
|
||||||
assert!(parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none());
|
assert!(parse_next_from_rrule("RRULE:FREQ=YEARLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn monthly_reschedules_instead_of_returning_none() {
|
||||||
|
// This used to be the exact bug: MONTHLY fell into the `_ => None`
|
||||||
|
// arm, so a monthly reminder fired once and never rescheduled.
|
||||||
|
let t = parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYMONTHDAY=15;BYHOUR=9;BYMINUTE=0", "08:00");
|
||||||
|
assert!(t.is_some(), "MONTHLY must produce a next occurrence, not None");
|
||||||
|
let local: chrono::DateTime<Local> = t.unwrap().into();
|
||||||
|
assert_eq!(local.day(), 15);
|
||||||
|
assert_eq!(local.hour(), 9);
|
||||||
|
assert_eq!(local.minute(), 0);
|
||||||
|
assert!(local > Local::now());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn monthly_without_bymonthday_uses_todays_day_of_month() {
|
||||||
|
let t = parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=23;BYMINUTE=59", "08:00").unwrap();
|
||||||
|
let local: chrono::DateTime<Local> = t.into();
|
||||||
|
assert_eq!(local.day(), Local::now().day());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn monthly_on_the_31st_skips_shorter_months() {
|
||||||
|
// Every candidate month/day combination this walks must actually
|
||||||
|
// exist (from_ymd_opt returning None for e.g. April 31 is skipped
|
||||||
|
// internally) — this mostly guards against a panic/infinite loop
|
||||||
|
// regression rather than a specific date, since "next Feb 31" et al
|
||||||
|
// must fall through to a month that really has a 31st.
|
||||||
|
let t = parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYMONTHDAY=31;BYHOUR=9;BYMINUTE=0", "08:00");
|
||||||
|
assert!(t.is_some());
|
||||||
|
let local: chrono::DateTime<Local> = t.unwrap().into();
|
||||||
|
assert_eq!(local.day(), 31);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,43 @@ impl Store {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Path to the sidecar lock file guarding the whole note store — a
|
||||||
|
/// single lock covers both `notes.jsonl` and `archive.jsonl` since
|
||||||
|
/// `rotate_archive` moves notes between them in one logical operation.
|
||||||
|
fn lock_path(&self) -> PathBuf {
|
||||||
|
self.notes_path.with_extension("lock")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocks until an exclusive advisory lock (`flock`) is held on the
|
||||||
|
/// sidecar lock file, and holds it for as long as the returned `File`
|
||||||
|
/// stays alive (the lock is released automatically when it's dropped,
|
||||||
|
/// same as it would be on process exit/crash).
|
||||||
|
///
|
||||||
|
/// breadpad (capture/fire/snooze), breadman (edit), and multiple
|
||||||
|
/// concurrent reminder-fire processes all read and rewrite the same
|
||||||
|
/// JSONL file with no coordination otherwise: two concurrent
|
||||||
|
/// load-modify-rewrite cycles racing `write_all`'s rename can silently
|
||||||
|
/// lose one side's change. This is intentionally one lock for the
|
||||||
|
/// entire store rather than per-note or per-file locking — contention
|
||||||
|
/// is expected to be rare (a handful of short-lived CLI-ish processes,
|
||||||
|
/// not a server), so simplicity wins over fine-grained locking here.
|
||||||
|
fn acquire_lock(&self) -> Result<fs::File> {
|
||||||
|
let file = OpenOptions::new()
|
||||||
|
.create(true)
|
||||||
|
.write(true)
|
||||||
|
.open(self.lock_path())
|
||||||
|
.context("failed to open notes store lock file")?;
|
||||||
|
file.lock().context("failed to acquire notes store lock")?;
|
||||||
|
Ok(file)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn load_all(&self) -> Result<Vec<Note>> {
|
pub fn load_all(&self) -> Result<Vec<Note>> {
|
||||||
|
let _lock = self.acquire_lock()?;
|
||||||
self.load_from(&self.notes_path)
|
self.load_from(&self.notes_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_archive(&self) -> Result<Vec<Note>> {
|
pub fn load_archive(&self) -> Result<Vec<Note>> {
|
||||||
|
let _lock = self.acquire_lock()?;
|
||||||
self.load_from(&self.archive_path)
|
self.load_from(&self.archive_path)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,12 +106,15 @@ impl Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn save_note(&self, note: &Note) -> Result<()> {
|
pub fn save_note(&self, note: &Note) -> Result<()> {
|
||||||
let mut file = OpenOptions::new()
|
{
|
||||||
.create(true)
|
let _lock = self.acquire_lock()?;
|
||||||
.append(true)
|
let mut file = OpenOptions::new()
|
||||||
.open(&self.notes_path)?;
|
.create(true)
|
||||||
let line = serde_json::to_string(note)?;
|
.append(true)
|
||||||
writeln!(file, "{}", line)?;
|
.open(&self.notes_path)?;
|
||||||
|
let line = serde_json::to_string(note)?;
|
||||||
|
writeln!(file, "{}", line)?;
|
||||||
|
}
|
||||||
|
|
||||||
if let Some(cal_cfg) = &self.calendar {
|
if let Some(cal_cfg) = &self.calendar {
|
||||||
if cal_cfg.enabled && (note.time.is_some() || note.rrule.is_some()) {
|
if cal_cfg.enabled && (note.time.is_some() || note.rrule.is_some()) {
|
||||||
|
|
@ -103,13 +138,18 @@ impl Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_note(&self, id: &str) -> Result<()> {
|
pub fn delete_note(&self, id: &str) -> Result<()> {
|
||||||
let all = self.load_all()?;
|
let to_delete_note = {
|
||||||
let (to_delete, keep): (Vec<Note>, Vec<Note>) = all.into_iter().partition(|n| n.id == id);
|
let _lock = self.acquire_lock()?;
|
||||||
self.write_all(&self.notes_path, &keep)?;
|
let all = self.load_from(&self.notes_path)?;
|
||||||
|
let (to_delete, keep): (Vec<Note>, Vec<Note>) =
|
||||||
|
all.into_iter().partition(|n| n.id == id);
|
||||||
|
self.write_all(&self.notes_path, &keep)?;
|
||||||
|
to_delete.into_iter().next()
|
||||||
|
};
|
||||||
|
|
||||||
if let Some(cal_cfg) = &self.calendar {
|
if let Some(cal_cfg) = &self.calendar {
|
||||||
if cal_cfg.enabled {
|
if cal_cfg.enabled {
|
||||||
if let Some(note) = to_delete.into_iter().next() {
|
if let Some(note) = to_delete_note {
|
||||||
spawn_caldav_delete(caldav_uid(¬e), cal_cfg.clone());
|
spawn_caldav_delete(caldav_uid(¬e), cal_cfg.clone());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -118,11 +158,17 @@ impl Store {
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Holds the store lock across the whole load-modify-write span (not
|
||||||
|
/// just the write) — `load_from` is used directly here rather than the
|
||||||
|
/// public, self-locking `load_all`, since re-acquiring the same
|
||||||
|
/// process-wide advisory lock while already holding it would block
|
||||||
|
/// forever (`flock` isn't re-entrant across separate file descriptors).
|
||||||
fn rewrite_notes<F>(&self, mut f: F) -> Result<()>
|
fn rewrite_notes<F>(&self, mut f: F) -> Result<()>
|
||||||
where
|
where
|
||||||
F: FnMut(Note) -> Note,
|
F: FnMut(Note) -> Note,
|
||||||
{
|
{
|
||||||
let notes: Vec<Note> = self.load_all()?.into_iter().map(|n| f(n)).collect();
|
let _lock = self.acquire_lock()?;
|
||||||
|
let notes: Vec<Note> = self.load_from(&self.notes_path)?.into_iter().map(|n| f(n)).collect();
|
||||||
self.write_all(&self.notes_path, ¬es)
|
self.write_all(&self.notes_path, ¬es)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,8 +187,9 @@ impl Store {
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn rotate_archive(&self, archive_after_days: i64) -> Result<usize> {
|
pub fn rotate_archive(&self, archive_after_days: i64) -> Result<usize> {
|
||||||
|
let _lock = self.acquire_lock()?;
|
||||||
let cutoff = Utc::now() - Duration::days(archive_after_days);
|
let cutoff = Utc::now() - Duration::days(archive_after_days);
|
||||||
let notes = self.load_all()?;
|
let notes = self.load_from(&self.notes_path)?;
|
||||||
let (to_archive, keep): (Vec<Note>, Vec<Note>) = notes
|
let (to_archive, keep): (Vec<Note>, Vec<Note>) = notes
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.partition(|n| n.done && n.completed.map_or(false, |c| c < cutoff));
|
.partition(|n| n.done && n.completed.map_or(false, |c| c < cutoff));
|
||||||
|
|
|
||||||
|
|
@ -43,9 +43,14 @@ window { border-radius: 8px; }
|
||||||
border-color: @teal;
|
border-color: @teal;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Shared "selected/active" language: a ghost/ outline default state that
|
||||||
|
stays quiet, and a solid, high-contrast accent fill for whatever is
|
||||||
|
currently selected — used identically by .type-chip.active and
|
||||||
|
.sidebar-row:selected so the two windows read as one system. */
|
||||||
.type-chip {
|
.type-chip {
|
||||||
background: @overlay;
|
background: transparent;
|
||||||
color: @on-overlay;
|
color: alpha(@fg, 0.6);
|
||||||
|
border: 1px solid alpha(@fg, 0.18);
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
|
@ -55,27 +60,69 @@ window { border-radius: 8px; }
|
||||||
.type-chip.active {
|
.type-chip.active {
|
||||||
background: @blue;
|
background: @blue;
|
||||||
color: @on-accent;
|
color: @on-accent;
|
||||||
|
border-color: @blue;
|
||||||
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Per-type tint so info badges (note cards, workspace/recur tags) stay
|
||||||
|
scannable at a glance without the old full-color emoji. */
|
||||||
|
.note-card-todo .type-chip { color: @green; border-color: alpha(@green, 0.4); }
|
||||||
|
.note-card-reminder .type-chip { color: @yellow; border-color: alpha(@yellow, 0.4); }
|
||||||
|
.note-card-idea .type-chip { color: @pink; border-color: alpha(@pink, 0.4); }
|
||||||
|
.note-card-question .type-chip { color: @teal; border-color: alpha(@teal, 0.4); }
|
||||||
|
.note-card-note .type-chip { color: @blue; border-color: alpha(@blue, 0.4); }
|
||||||
|
|
||||||
.confirm-button {
|
.confirm-button {
|
||||||
background: @blue;
|
background: @blue;
|
||||||
color: @on-accent;
|
color: @on-accent;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
padding: 8px 16px;
|
padding: 10px 22px;
|
||||||
|
min-height: 20px;
|
||||||
|
font-size: 15px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.confirm-button:hover { background: shade(@blue, 1.1); }
|
||||||
|
|
||||||
|
/* Separates the primary action from the chip row it sits beside so it
|
||||||
|
doesn't read as just another pill. */
|
||||||
|
.confirm-wrap {
|
||||||
|
border-left: 1px solid alpha(@fg, 0.12);
|
||||||
|
padding-left: 12px;
|
||||||
|
margin-left: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.prefix-hint {
|
||||||
|
color: alpha(@fg, 0.4);
|
||||||
|
font-size: 10px;
|
||||||
|
letter-spacing: 0.3px;
|
||||||
|
}
|
||||||
|
|
||||||
.note-card {
|
.note-card {
|
||||||
background: shade(@bg, 1.1);
|
background: shade(@bg, 1.12);
|
||||||
border-radius: 8px;
|
border: 1px solid alpha(@fg, 0.07);
|
||||||
|
border-radius: 10px;
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
margin: 8px;
|
margin: 6px 0;
|
||||||
border-left: 3px solid @blue;
|
border-left: 3px solid @blue;
|
||||||
}
|
}
|
||||||
|
|
||||||
.note-card:hover {
|
.note-card:hover {
|
||||||
background: shade(@bg, 1.2);
|
background: shade(@bg, 1.22);
|
||||||
|
border-color: alpha(@fg, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card .action-btn {
|
||||||
|
opacity: 0.45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card:hover .action-btn {
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-entry {
|
.search-entry {
|
||||||
|
|
@ -83,7 +130,8 @@ window { border-radius: 8px; }
|
||||||
color: @fg;
|
color: @fg;
|
||||||
border: 1px solid @overlay;
|
border: 1px solid @overlay;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 8px 12px;
|
padding: 5px 10px;
|
||||||
|
font-size: 13px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-entry:focus {
|
.search-entry:focus {
|
||||||
|
|
@ -104,7 +152,27 @@ window { border-radius: 8px; }
|
||||||
.sidebar-row:selected {
|
.sidebar-row:selected {
|
||||||
background: @blue;
|
background: @blue;
|
||||||
color: @on-accent;
|
color: @on-accent;
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-row-minor {
|
||||||
|
opacity: 0.5;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-count {
|
||||||
|
color: alpha(@fg, 0.45);
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-todo { color: @green; }
|
||||||
|
.icon-reminder { color: @yellow; }
|
||||||
|
.icon-idea { color: @pink; }
|
||||||
|
.icon-note { color: @blue; }
|
||||||
|
.icon-question { color: @teal; }
|
||||||
|
|
||||||
|
.sidebar-row:selected .sidebar-count {
|
||||||
|
color: alpha(@on-accent, 0.75);
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-section-label {
|
.sidebar-section-label {
|
||||||
|
|
@ -119,14 +187,15 @@ window { border-radius: 8px; }
|
||||||
background: transparent;
|
background: transparent;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 6px;
|
border-radius: 6px;
|
||||||
padding: 2px 7px;
|
padding: 3px 8px;
|
||||||
min-width: 28px;
|
min-width: 32px;
|
||||||
min-height: 28px;
|
min-height: 32px;
|
||||||
font-size: 14px;
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-btn:hover {
|
.action-btn:hover {
|
||||||
background: shade(@bg, 1.3);
|
background: shade(@bg, 1.3);
|
||||||
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.done-btn { color: @green; }
|
.done-btn { color: @green; }
|
||||||
|
|
@ -215,8 +284,14 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn css_defines_bg_color() {
|
fn css_defines_bg_color() {
|
||||||
|
// #1e1e2e (Catppuccin Mocha) was the default before bread-theme
|
||||||
|
// v0.2.10, which deliberately fixed background/surface/overlay to
|
||||||
|
// BOS's own dark theme (#0c0c0c etc.) instead of tracking pywal —
|
||||||
|
// see bread-theme's `palette.rs` `FIXED_BACKGROUND` doc comment.
|
||||||
|
// This assertion was never updated for that bump, so it started
|
||||||
|
// failing the moment breadpad's Cargo.toml pin moved to v0.2.10.
|
||||||
let css = build_css(&Palette::default(), None);
|
let css = build_css(&Palette::default(), None);
|
||||||
assert!(css.contains("@define-color bg #1e1e2e"), "css missing bg: {}", &css[..300]);
|
assert!(css.contains("@define-color bg #0c0c0c"), "css missing bg: {}", &css[..300]);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -23,3 +23,4 @@ gtk4-layer-shell.workspace = true
|
||||||
hyprland.workspace = true
|
hyprland.workspace = true
|
||||||
dirs.workspace = true
|
dirs.workspace = true
|
||||||
tokio.workspace = true
|
tokio.workspace = true
|
||||||
|
bread-utils = { git = "https://git.breadway.dev/Breadway/bread-ecosystem", tag = "v0.3.0" }
|
||||||
|
|
|
||||||
|
|
@ -536,13 +536,14 @@ fn run_popup(preset_type: Option<String>, no_classify: bool, cfg: Config) -> Res
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_active_workspace() -> Option<String> {
|
fn get_active_workspace() -> Option<String> {
|
||||||
// Use hyprctl via CLI since the async API would require a runtime here
|
// Was a `Command::new("hyprctl").output()` call with no timeout (the
|
||||||
let out = std::process::Command::new("hyprctl")
|
// `hyprland` crate's async API would require a runtime here, which this
|
||||||
.args(["activeworkspace", "-j"])
|
// call site doesn't have) — bread_utils::hypr's socket1 client is
|
||||||
.output()
|
// synchronous and needs neither a subprocess nor a runtime.
|
||||||
.ok()?;
|
bread_utils::hypr::request_json("j/activeworkspace")?
|
||||||
let val: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
|
.get("id")
|
||||||
val.get("id").and_then(|v| v.as_i64()).map(|id| id.to_string())
|
.and_then(|v| v.as_i64())
|
||||||
|
.map(|id| id.to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_window(
|
fn build_window(
|
||||||
|
|
@ -636,11 +637,43 @@ fn build_window(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live prefix grammar: typing "td: ", "rem: ", "idea: ", "note: ", or
|
||||||
|
// "q: " at the start of the entry drives the chip selection without
|
||||||
|
// ever touching the mouse — the chips become feedback for what you
|
||||||
|
// typed rather than the only way to pick a type.
|
||||||
|
{
|
||||||
|
let selected_type_clone = selected_type.clone();
|
||||||
|
let chips_clone: Vec<(gtk4::Button, NoteType)> = chips.clone();
|
||||||
|
entry.connect_changed(move |e| {
|
||||||
|
let Some(nt) = breadpad_shared::parser::detect_prefix_type(&e.text()) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
*selected_type_clone.borrow_mut() = nt.clone();
|
||||||
|
for (btn, chip_nt) in &chips_clone {
|
||||||
|
if *chip_nt == nt {
|
||||||
|
btn.add_css_class("active");
|
||||||
|
} else {
|
||||||
|
btn.remove_css_class("active");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let hint = gtk4::Label::builder()
|
||||||
|
.label("td: · rem: · idea: · note: · q:")
|
||||||
|
.css_classes(["prefix-hint"])
|
||||||
|
.xalign(0.0)
|
||||||
|
.build();
|
||||||
|
|
||||||
// Confirm button
|
// Confirm button
|
||||||
let confirm_btn = gtk4::Button::builder()
|
let confirm_btn = gtk4::Button::builder()
|
||||||
.label("✓")
|
.label("✓")
|
||||||
.css_classes(["confirm-button"])
|
.css_classes(["confirm-button"])
|
||||||
.build();
|
.build();
|
||||||
|
let confirm_wrap = gtk4::Box::builder()
|
||||||
|
.css_classes(["confirm-wrap"])
|
||||||
|
.build();
|
||||||
|
confirm_wrap.append(&confirm_btn);
|
||||||
|
|
||||||
let bottom_row = gtk4::Box::builder()
|
let bottom_row = gtk4::Box::builder()
|
||||||
.orientation(gtk4::Orientation::Horizontal)
|
.orientation(gtk4::Orientation::Horizontal)
|
||||||
|
|
@ -650,9 +683,10 @@ fn build_window(
|
||||||
|
|
||||||
let spacer = gtk4::Box::builder().hexpand(true).build();
|
let spacer = gtk4::Box::builder().hexpand(true).build();
|
||||||
bottom_row.append(&spacer);
|
bottom_row.append(&spacer);
|
||||||
bottom_row.append(&confirm_btn);
|
bottom_row.append(&confirm_wrap);
|
||||||
|
|
||||||
vbox.append(&entry);
|
vbox.append(&entry);
|
||||||
|
vbox.append(&hint);
|
||||||
vbox.append(&bottom_row);
|
vbox.append(&bottom_row);
|
||||||
window.set_child(Some(&vbox));
|
window.set_child(Some(&vbox));
|
||||||
|
|
||||||
|
|
@ -661,6 +695,7 @@ fn build_window(
|
||||||
let selected_type_clone = selected_type.clone();
|
let selected_type_clone = selected_type.clone();
|
||||||
let cfg_clone = cfg.clone();
|
let cfg_clone = cfg.clone();
|
||||||
let workspace_clone = workspace.clone();
|
let workspace_clone = workspace.clone();
|
||||||
|
let app_clone = app.clone();
|
||||||
|
|
||||||
let save_and_close = {
|
let save_and_close = {
|
||||||
let win = win_clone.clone();
|
let win = win_clone.clone();
|
||||||
|
|
@ -668,6 +703,7 @@ fn build_window(
|
||||||
let selected_type = selected_type_clone.clone();
|
let selected_type = selected_type_clone.clone();
|
||||||
let cfg = cfg_clone.clone();
|
let cfg = cfg_clone.clone();
|
||||||
let workspace = workspace_clone.clone();
|
let workspace = workspace_clone.clone();
|
||||||
|
let app = app_clone.clone();
|
||||||
|
|
||||||
move || {
|
move || {
|
||||||
let text = entry.text().to_string();
|
let text = entry.text().to_string();
|
||||||
|
|
@ -678,10 +714,20 @@ fn build_window(
|
||||||
let note_type = selected_type.borrow().clone();
|
let note_type = selected_type.borrow().clone();
|
||||||
let cfg_c = cfg.clone();
|
let cfg_c = cfg.clone();
|
||||||
let ws_c = workspace.clone();
|
let ws_c = workspace.clone();
|
||||||
// Close first so the popup disappears immediately, then save.
|
// Close first so the popup disappears immediately, then save —
|
||||||
|
// but `hold()` the application across the gap first. Without
|
||||||
|
// this, closing the only open window can let the whole process
|
||||||
|
// (and with it, the `idle_add_local_once` callback below that
|
||||||
|
// actually writes the note) exit before that callback ever
|
||||||
|
// runs, silently losing the note the user just typed. `hold()`
|
||||||
|
// returns an RAII guard that keeps the app alive with zero
|
||||||
|
// windows open until it's dropped, right after the save
|
||||||
|
// finishes below.
|
||||||
|
let hold_guard = app.hold();
|
||||||
win.close();
|
win.close();
|
||||||
glib::idle_add_local_once(move || {
|
glib::idle_add_local_once(move || {
|
||||||
save_note_classified(&text, note_type, no_classify, cfg_c, ws_c);
|
save_note_classified(&text, note_type, no_classify, cfg_c, ws_c);
|
||||||
|
drop(hold_guard);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
# Maintainer: Breadway <rileyhorsham@gmail.com>
|
# Maintainer: Breadway <plasticbread849@gmail.com>
|
||||||
|
|
||||||
pkgname=breadpad
|
pkgname=breadpad
|
||||||
pkgver=0.3.1
|
pkgver=0.3.1
|
||||||
pkgrel=1
|
pkgrel=1
|
||||||
pkgdesc="Quick-capture scratchpad and note viewer with AI classification"
|
pkgdesc="Quick-capture scratchpad and note viewer with AI classification"
|
||||||
arch=('x86_64')
|
arch=('x86_64')
|
||||||
url="https://github.com/Breadway/breadpad"
|
url="https://git.breadway.dev/Breadway/breadpad"
|
||||||
license=('MIT')
|
license=('MIT')
|
||||||
# Some Rust deps (ring/mlua) build vendored C/asm into static archives; makepkg's
|
# 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,
|
# default -flto=auto emits GCC LTO bitcode the Rust (lld) link cannot read,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue