Committing before copilot touches this

This commit is contained in:
Breadway 2026-05-25 19:53:50 +08:00
commit feefdb81b9
36 changed files with 12338 additions and 0 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
target/

4507
Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

36
Cargo.toml Normal file
View file

@ -0,0 +1,36 @@
[workspace]
members = [
"breadpad-shared",
"breadpad",
"breadman",
"breadpad-test",
]
resolver = "2"
[workspace.package]
version = "0.1.0"
edition = "2021"
license = "MIT"
authors = ["Breadway"]
[workspace.dependencies]
anyhow = "1"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
uuid = { version = "1", features = ["v4"] }
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"] }
ndarray = "0.16"
tokenizers = { version = "0.21", default-features = false, features = ["http", "fancy-regex"] }
gtk4 = { version = "0.11", features = ["v4_12"] }
gtk4-layer-shell = "0.8"
hyprland = "0.4.0-beta.3"
toml = "0.8"
dirs = "5"
regex = "1"
ureq = { version = "2", features = ["json"] }

414
README.md Normal file
View file

@ -0,0 +1,414 @@
# breadpad / breadman
A quick-capture scratchpad and structured note viewer for Hyprland / Wayland, with AI-powered classification, reminders, recurrence, and snooze.
Two entry points, one binary, one shared workspace:
| Binary | Purpose |
|--------|---------|
| `breadpad` | Layer-shell capture popup — type a note, press Enter, done |
| `breadman` | Full note viewer and manager |
---
## Workspace layout
```
breadpad-shared shared types, storage, classification, scheduler
breadpad GTK4 layer-shell capture popup
breadman GTK4 note viewer / manager
```
---
## Features
### Capture (`breadpad`)
- Layer-shell popup, centered, keyboard-exclusive — appears instantly on your keybind
- Single text field; press **Enter** or click **✓** to save, **Escape** to dismiss
- Optional manual type override before saving (defaults to AI classification)
- Timestamp and active Hyprland workspace recorded automatically
### Classification
Every note passes through a three-tier pipeline at capture time:
1. **Rule-based parser** — always runs first; handles time extraction ("at 7pm", "in 30 minutes", "tomorrow morning", "next Friday"), recurrence ("every Sunday at 9pm", "every weekday morning"), and strong type signals ("?" → question, "idea:" prefix → idea, action verbs → todo). High-confidence results skip the remaining tiers entirely.
2. **Small local ONNX model** — runs when Tier 1 can't confidently assign a type. Responsible for type classification only; Tier 1's extracted time, recurrence rule, and cleaned body are always preserved.
3. **Large local model via Ollama** — runs only when Tier 2 confidence falls below a configurable threshold. Communicates with a locally running Ollama instance over HTTP. If Ollama is unreachable, the Tier 2 result is used. No cloud APIs are involved.
Manual override always available — the AI-assigned type is shown as a chip you can tap to change before saving.
### Note types (built-in)
| Type | Example |
|------|---------|
| `todo` | "buy milk on the way home" |
| `reminder` | "pack calculator in bag at 7pm" |
| `idea` | "what if breadman had a calendar view" |
| `note` | "meeting went well, follow up Friday" |
| `question` | "why does nmcli drop on suspend?" |
User-defined tags can be added freely on top of the built-in types.
### Reminders, recurrence, and snooze
- **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
- **Snooze** — notification popup includes snooze actions: 15 min / 1 hour / tomorrow morning / custom; 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
### Viewer (`breadman`)
- Sidebar with one entry per type + "All" and "Upcoming"
- Each note card shows: body, type chip, timestamp, workspace tag, recurrence badge if set
- **Upcoming** view: chronological list of all pending reminders and todos with times
- 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
### Theming
- Reads `~/.cache/wal/colors.json` (pywal) on startup — matches the rest of the bread ecosystem
- Falls back to Catppuccin Mocha
- CSS override: `~/.config/breadpad/style.css`
- `SIGHUP` reloads theme at runtime
---
## Storage
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}
```
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.
---
## AI classification
### Three-tier pipeline
#### Tier 1 — Rule-based parser
Always runs. Handles:
- **Time extraction**: "at 7pm", "in 30 minutes", "tomorrow morning", "next Friday at 9am"
- **Recurrence**: "every Sunday at 9pm", "every weekday morning" → stored as RRULE
- **Type signals**: leading "?" or "why/how/what" → `question`; "idea:" prefix or "what if" → `idea`; action verbs → `todo`; time present → `reminder`
Returns a calibrated confidence. If ≥ 0.82, Tiers 2 and 3 are skipped.
#### Tier 2 — Small local ONNX model
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`.
#### Tier 3 — Large local model via Ollama
Runs only when Tier 2 confidence falls below `model.ollama.confidence_threshold` (default 0.6). Sends a structured prompt to a locally running Ollama instance over HTTP and parses the JSON response for `type`, `body`, and `confidence`. The Ollama model runs on the iGPU via Ollama's own backend — breadpad does not manage GPU allocation for this tier.
If Ollama is unreachable or returns an invalid response, breadpad logs a warning and uses the Tier 2 result. No cloud APIs are used anywhere.
### Model location (Tier 2)
```
~/.local/share/breadpad/model/classifier.onnx
~/.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.
```bash
breadpad download-model # fetches default model (~150 MB)
breadpad model-info # shows active EP, model path, last inference time
```
---
## Requirements
- Linux with a running Hyprland compositor
- 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)
- **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.
---
## Installation
```bash
git clone https://github.com/breadway/breadpad
cd breadpad
cargo build --release
cp target/release/breadpad ~/.local/bin/
cp target/release/breadman ~/.local/bin/
# Fetch the default classifier model
breadpad download-model
```
On Arch Linux, install GTK4 dependencies first:
```bash
sudo pacman -S gtk4 gtk4-layer-shell
```
---
## Configuration
On first run, breadpad writes `~/.config/breadpad/breadpad.toml`:
```toml
[settings]
default_type = "note" # fallback type if classification is skipped
workspace_tag = true # tag notes with active Hyprland workspace
snooze_options = ["15m", "1h", "tomorrow_morning"] # shown in notification actions
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
[model.ollama]
endpoint = "http://localhost:11434"
model = "llama3.2:3b" # any model you have pulled in Ollama
confidence_threshold = 0.6 # Tier 2 scores below this trigger Tier 3
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
```
---
## Usage
### breadpad (capture)
```bash
# Open the capture popup (bind this to a key in hyprland.conf)
breadpad
# Open with a pre-selected type
breadpad --type todo
# Skip AI classification (save as plain note)
breadpad --no-classify
# Show model and storage status
breadpad --status
```
Hyprland keybind:
```
bind = $mainMod, N, exec, breadpad
```
### breadman (viewer)
```bash
# Open the note viewer
breadman
# Open directly to a specific type view
breadman --view todo
breadman --view upcoming
# Mark a note done by ID (scriptable)
breadman done <id>
# List upcoming reminders in the terminal
breadman upcoming --plain
```
---
## Scheduler
breadpad manages reminders via systemd user timers. Each scheduled note gets a transient timer unit:
```
breadpad-reminder-<id>.timer
breadpad-reminder-<id>.service
```
The service unit runs `breadpad fire <id>`, which sends a `notify-send` notification with snooze actions. Snoozing writes the new time back to the note and creates a replacement timer. Recurring notes create the next timer immediately on fire.
You can inspect pending timers:
```bash
systemctl --user list-timers 'breadpad-*'
```
---
## Testing
`breadpad-test` is a CLI test harness for the classification pipeline. It runs a JSON corpus of labelled inputs through any tier of the pipeline and reports pass/fail.
```bash
# Run Tier 1 (rule-based only) — fast, no model needed
breadpad-test run
# See only failing cases
breadpad-test run --format failures
# Run Tier 2 (+ ONNX model)
breadpad-test run --tier 2
# Run full pipeline including Ollama
breadpad-test run --tier all
# Machine-readable output
breadpad-test run --format json
```
### Corpus format
Default path: `breadpad-test/corpus.json`. Override with `--corpus <path>`.
```json
[
{
"input": "pack my calculator in my bag tonight",
"expected_type": "todo",
"expected_time": null,
"expected_body": "pack my calculator in my bag",
"expected_rrule": null,
"notes": "no time specified, should not infer one"
}
]
```
- `expected_time``HH:MM`; date component is ignored so tests are never date-sensitive
- `expected_rrule` — matched as substring of the actual RRULE string
- Any `null` field is skipped — only non-null fields are asserted
### Tier modes
| `--tier` | What runs |
|----------|-----------|
| `1` (default) | Tier 1 rule-based parser only — no model required |
| `2` | Tiers 1 + 2 (ONNX classifier) |
| `3` / `all` | Full pipeline including Tier 3 Ollama |
### Corpus management
```bash
# Interactively add an entry
breadpad-test add
# Show entry #5 and the pipeline's actual output
breadpad-test show 5
# Open corpus file in $EDITOR at entry #5
breadpad-test edit 5
```
### Typical tuning workflow
```bash
# 1. See what Tier 1 gets wrong
breadpad-test run --tier 1 --format failures
# 2. Edit parser.rs, then rerun
cargo build -p breadpad-shared && breadpad-test run --tier 1 --format failures
# 3. Once Tier 1 is stable, audit Tier 2 regressions
breadpad-test run --tier 2 --format failures
```
---
## Nextcloud Calendar integration
breadpad can push scheduled notes and recurring reminders to a CalDAV calendar (Nextcloud or any RFC 4791-compliant server). No cloud APIs are used — everything goes directly to your own server over HTTPS.
### What gets pushed
Notes with a scheduled time (`time` field) or recurrence rule (`rrule`) are pushed as VEVENT entries when saved. Notes without a time are not pushed. Deleting a note also deletes the corresponding calendar event.
### Configuration
Add a `[calendar]` section to `~/.config/breadpad/breadpad.toml`:
```toml
[calendar]
enabled = true
url = "https://nextcloud.example.com/remote.php/dav/calendars/you/breadpad/"
username = "you"
password = "app-password-here" # use a Nextcloud app password, not your login password
```
The calendar must already exist on the server. Create it in the Nextcloud Calendar app before enabling this integration.
### CLI commands
```bash
# Verify the CalDAV connection and credentials
breadpad calendar test
# List CalDAV UIDs for all scheduled notes (queries the server if enabled, local store if not)
breadpad calendar list-uid
# Show the CalDAV UID for a specific note by its local ID
breadpad calendar list-uid <note-id>
```
### Event format
Each note is pushed as a VEVENT with:
- `UID``<note-id>@breadpad` (stable and deterministic)
- `SUMMARY` — note body
- `DTSTART` / `DTEND` — scheduled time (or creation time for recurring notes without a fixed start)
- `RRULE` — recurrence rule if set
- `DESCRIPTION``type=<note-type>`
### Security note
Store your CalDAV password using a Nextcloud app password rather than your account password. App passwords can be revoked individually from the Nextcloud security settings.
---
## Module layout
| Crate / module | Responsibility |
|----------------|---------------|
| `breadpad-shared/src/types.rs` | `Note`, `NoteType`, `RecurrenceRule`, `SnoozeState` |
| `breadpad-shared/src/store.rs` | JSONL read/write, atomic saves, archive rotation |
| `breadpad-shared/src/classifier.rs` | Three-tier pipeline orchestration (Tier 1 → 2 → 3) |
| `breadpad-shared/src/parser.rs` | Tier 1: rule-based time/recurrence/type parsing |
| `breadpad-shared/src/ai.rs` | Tier 3: Ollama HTTP client, prompt construction, response parsing |
| `breadpad-shared/src/calendar.rs` | CalDAV client: push, delete, list events; iCal VEVENT builder |
| `breadpad-shared/src/scheduler.rs` | systemd timer creation, snooze, recurrence next-occurrence |
| `breadpad/src/main.rs` | GTK4 layer-shell popup, text field, type chip selector |
| `breadman/src/main.rs` | GTK4 app entry, sidebar, note list, search |
| `breadman/src/views/` | `upcoming.rs`, `archive.rs`, per-type list views |
| `breadman/src/editor.rs` | Inline note editor popover |
---
## License
MIT

BIN
bread.zip Normal file

Binary file not shown.

22
breadman/Cargo.toml Normal file
View file

@ -0,0 +1,22 @@
[package]
name = "breadman"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[[bin]]
name = "breadman"
path = "src/main.rs"
[dependencies]
breadpad-shared = { path = "../breadpad-shared" }
anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
serde.workspace = true
serde_json.workspace = true
tokio.workspace = true
chrono.workspace = true
gtk4.workspace = true
dirs.workspace = true

162
breadman/src/editor.rs Normal file
View file

@ -0,0 +1,162 @@
use breadpad_shared::{
parser::parse_rule_based,
store::Store,
types::{Note, NoteType, RecurrenceRule},
};
use chrono::{Local, TimeZone, Utc};
use gtk4::prelude::*;
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
pub fn build_editor_popover(
note: &Note,
store: Arc<Store>,
morning: String,
on_save: impl Fn(Note) + 'static,
on_delete: impl Fn() + 'static,
) -> gtk4::Popover {
let popover = gtk4::Popover::new();
popover.set_has_arrow(false);
let vbox = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(8)
.margin_top(12)
.margin_bottom(12)
.margin_start(12)
.margin_end(12)
.width_request(420)
.build();
vbox.append(&gtk4::Label::builder().label("Body").xalign(0.0).build());
let body_entry = gtk4::Entry::builder()
.text(&note.body)
.hexpand(true)
.build();
vbox.append(&body_entry);
vbox.append(&gtk4::Label::builder().label("Type").xalign(0.0).build());
let type_combo = gtk4::DropDown::from_strings(NoteType::all_builtin());
let current_idx = NoteType::all_builtin()
.iter()
.position(|&s| s == note.note_type.as_str())
.unwrap_or(3) as u32;
type_combo.set_selected(current_idx);
vbox.append(&type_combo);
vbox.append(&gtk4::Label::builder().label("Time").xalign(0.0).build());
let time_text = note
.time
.map(|t| {
let local: chrono::DateTime<Local> = t.into();
local.format("%Y-%m-%d %H:%M").to_string()
})
.unwrap_or_default();
let time_entry = gtk4::Entry::builder()
.text(&time_text)
.placeholder_text("YYYY-MM-DD HH:MM or tomorrow 9am (blank = no time)")
.hexpand(true)
.build();
vbox.append(&time_entry);
vbox.append(&gtk4::Label::builder().label("Recurrence").xalign(0.0).build());
let rrule_entry = gtk4::Entry::builder()
.text(note.rrule.as_ref().map(|r| r.as_str()).unwrap_or(""))
.placeholder_text("RRULE:FREQ=WEEKLY;BYDAY=MO (blank = none)")
.build();
vbox.append(&rrule_entry);
// Button row: [Delete] [Save]
let btn_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.build();
let delete_btn = gtk4::Button::builder()
.label("🗑 Delete")
.css_classes(["danger-btn"])
.build();
let save_btn = gtk4::Button::builder()
.label("Save")
.css_classes(["confirm-button"])
.hexpand(true)
.build();
btn_row.append(&delete_btn);
btn_row.append(&save_btn);
vbox.append(&btn_row);
// Delete: two-click confirm using a single handler and shared state
let confirming = Rc::new(RefCell::new(false));
{
let confirming = confirming.clone();
let delete_btn_label = delete_btn.clone();
let note_id = note.id.clone();
let store_del = store.clone();
let popover_del = popover.clone();
delete_btn.connect_clicked(move |_| {
let currently = *confirming.borrow();
if currently {
if let Err(e) = store_del.delete_note(&note_id) {
tracing::error!("failed to delete note: {}", e);
} else {
on_delete();
}
popover_del.popdown();
} else {
*confirming.borrow_mut() = true;
delete_btn_label.set_label("Sure?");
}
});
}
// Save
let note_clone = note.clone();
let popover_save = popover.clone();
save_btn.connect_clicked(move |_| {
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))
};
if let Err(e) = store.update_note(&updated) {
tracing::error!("failed to update note: {}", e);
} else {
on_save(updated);
}
popover_save.popdown();
});
popover.set_child(Some(&vbox));
popover
}
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) {
return Some(local.with_timezone(&Utc));
}
}
parse_rule_based(s, morning).time
}

906
breadman/src/main.rs Normal file
View file

@ -0,0 +1,906 @@
use anyhow::Result;
use breadpad_shared::{
config::Config,
parser::parse_rule_based,
scheduler::Scheduler,
store::Store,
theme::{build_css, load_palette},
types::{Note, NoteType, RecurrenceRule},
};
use chrono::Local;
use gtk4::{glib, prelude::*};
use std::cell::RefCell;
use std::rc::Rc;
use std::sync::Arc;
mod editor;
mod views;
// ── Args ─────────────────────────────────────────────────────────────────────
mod args {
#[derive(Debug)]
pub struct Args {
pub view: Option<String>,
pub done_id: Option<String>,
pub upcoming_plain: bool,
}
pub fn parse() -> Args {
let mut args = Args {
view: None,
done_id: None,
upcoming_plain: false,
};
let raw: Vec<String> = std::env::args().skip(1).collect();
let mut i = 0;
while i < raw.len() {
match raw[i].as_str() {
"--view" | "-v" => {
i += 1;
args.view = raw.get(i).cloned();
}
"done" => {
i += 1;
args.done_id = raw.get(i).cloned();
}
"upcoming" => {
if raw.get(i + 1).map(|s| s.as_str()) == Some("--plain") {
args.upcoming_plain = true;
i += 1;
}
args.view = Some("upcoming".into());
}
_ => {}
}
i += 1;
}
args
}
}
// ── AppState ──────────────────────────────────────────────────────────────────
/// Shared UI state, cheap to clone (all fields are Rc/Arc).
#[derive(Clone)]
struct AppState {
store: Arc<Store>,
notes: Rc<RefCell<Vec<Note>>>,
cfg: Rc<RefCell<Config>>,
errors: Rc<RefCell<Vec<(chrono::DateTime<Local>, String)>>>,
active_view: Rc<RefCell<String>>,
stack: gtk4::Stack,
}
impl AppState {
fn new(store: Arc<Store>, notes: Vec<Note>, cfg: Config, stack: gtk4::Stack) -> Self {
AppState {
store,
notes: Rc::new(RefCell::new(notes)),
cfg: Rc::new(RefCell::new(cfg)),
errors: Rc::new(RefCell::new(Vec::new())),
active_view: Rc::new(RefCell::new("all".to_string())),
stack,
}
}
fn log_error(&self, msg: impl Into<String>) {
self.errors.borrow_mut().push((Local::now(), msg.into()));
}
fn reload_notes(&self) {
match self.store.load_all() {
Ok(fresh) => *self.notes.borrow_mut() = fresh,
Err(e) => self.log_error(format!("failed to reload notes: {}", e)),
}
}
/// Returns a Store clone with CalDAV wired in if enabled in config.
fn write_store(&self) -> Store {
let base = self.store.as_ref().clone();
let cfg = self.cfg.borrow();
if cfg.calendar.enabled {
base.with_calendar(cfg.calendar.clone())
} else {
base
}
}
}
// ── Refresh ───────────────────────────────────────────────────────────────────
fn refresh(state: &AppState) {
state.reload_notes();
rebuild_stack(state);
let active = state.active_view.borrow().clone();
state.stack.set_visible_child_name(&active);
}
fn rebuild_stack(state: &AppState) {
while let Some(child) = state.stack.first_child() {
state.stack.remove(&child);
}
let notes: Vec<Note> = state.notes.borrow().clone();
let cfg: Config = state.cfg.borrow().clone();
let errors: Vec<_> = state.errors.borrow().clone();
// All
let all_scroll = build_note_list(&notes, state.clone());
state.stack.add_named(&all_scroll, Some("all"));
// Upcoming
let upcoming = views::upcoming::build(&notes);
state.stack.add_named(&upcoming, Some("upcoming"));
// Per-type
for type_name in NoteType::all_builtin() {
let nt = NoteType::from_str(type_name);
let filtered: Vec<Note> = notes
.iter()
.filter(|n| n.note_type == nt && !n.done)
.cloned()
.collect();
let scroll = build_note_list(&filtered, state.clone());
state.stack.add_named(&scroll, Some(type_name));
}
// Archive
let archive = views::archive::build(&notes, state.clone());
state.stack.add_named(&archive, Some("archive"));
// Settings
let state_s = state.clone();
let settings = views::settings::build(&cfg, move |new_cfg| {
*state_s.cfg.borrow_mut() = new_cfg;
});
state.stack.add_named(&settings, Some("settings"));
// Errors
let errors_view = views::errors::build(&errors);
state.stack.add_named(&errors_view, Some("errors"));
}
// ── main ─────────────────────────────────────────────────────────────────────
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("breadman=info".parse().unwrap()),
)
.init();
let args = args::parse();
let cfg = Config::load()?;
if let Some(id) = &args.done_id {
return cmd_done(id);
}
if args.upcoming_plain {
return cmd_upcoming_plain();
}
run_app(args.view, cfg)
}
fn cmd_done(id: &str) -> Result<()> {
let store = Store::new()?;
let note = match store.get_by_id(id)? {
Some(n) => n,
None => anyhow::bail!("note {} not found", id),
};
let mut updated = note;
updated.mark_done();
store.update_note(&updated)?;
println!("marked {} as done", id);
Ok(())
}
fn cmd_upcoming_plain() -> Result<()> {
let store = Store::new()?;
let mut notes: Vec<Note> = store
.load_all()?
.into_iter()
.filter(|n| {
!n.done
&& matches!(n.note_type, NoteType::Reminder | NoteType::Todo)
&& n.effective_time().is_some()
})
.collect();
notes.sort_by_key(|n| n.effective_time().unwrap());
for note in &notes {
let t = note.effective_time().unwrap();
let local: chrono::DateTime<Local> = t.into();
println!("[{}] {}{}", note.id, local.format("%a %b %d %H:%M"), note.body);
}
Ok(())
}
fn run_app(initial_view: Option<String>, cfg: Config) -> Result<()> {
let app = gtk4::Application::builder()
.application_id("com.breadway.breadman")
.build();
let cfg = Arc::new(cfg);
let initial_view = Arc::new(initial_view);
app.connect_activate(move |app| {
let cfg = cfg.as_ref().clone();
let initial_view = initial_view.as_deref().map(|s| s.to_string());
if let Err(e) = build_app_window(app, cfg, initial_view) {
tracing::error!("failed to build window: {}", e);
}
});
let code = app.run_with_args::<String>(&[]);
if code != glib::ExitCode::SUCCESS {
anyhow::bail!("GTK application exited with error");
}
Ok(())
}
// ── Window ────────────────────────────────────────────────────────────────────
fn build_app_window(
app: &gtk4::Application,
cfg: Config,
initial_view: Option<String>,
) -> Result<()> {
apply_css(&cfg);
let store = Arc::new(Store::new()?);
let notes = store.load_all()?;
let window = gtk4::ApplicationWindow::builder()
.application(app)
.title("breadman")
.default_width(960)
.default_height(640)
.build();
let hbox = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.build();
// ── Sidebar ───────────────────────────────────────────────────
let sidebar_vbox = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.width_request(190)
.build();
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)
.build();
sidebar_vbox.append(&new_note_btn);
let sidebar_list = gtk4::ListBox::builder()
.selection_mode(gtk4::SelectionMode::Single)
.css_classes(["sidebar"])
.build();
let make_section = |title: &str| {
let row = gtk4::ListBoxRow::builder()
.selectable(false)
.activatable(false)
.build();
row.set_child(Some(
&gtk4::Label::builder()
.label(title)
.xalign(0.0)
.css_classes(["sidebar-section-label"])
.build(),
));
row
};
let make_item = |id: &str, icon: &str, label: &str| {
let row = gtk4::ListBoxRow::builder()
.css_classes(["sidebar-row"])
.build();
row.set_widget_name(id);
let hbox = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(10)
.build();
hbox.append(
&gtk4::Label::builder()
.label(icon)
.width_chars(2)
.xalign(0.5)
.build(),
);
hbox.append(
&gtk4::Label::builder()
.label(label)
.xalign(0.0)
.hexpand(true)
.build(),
);
row.set_child(Some(&hbox));
row
};
sidebar_list.append(&make_section("VIEWS"));
sidebar_list.append(&make_item("all", "📋", "All"));
sidebar_list.append(&make_item("upcoming", "📅", "Upcoming"));
sidebar_list.append(&make_section("TYPES"));
sidebar_list.append(&make_item("todo", "", "Todo"));
sidebar_list.append(&make_item("reminder", "🔔", "Reminder"));
sidebar_list.append(&make_item("idea", "💡", "Idea"));
sidebar_list.append(&make_item("note", "📝", "Note"));
sidebar_list.append(&make_item("question", "", "Question"));
sidebar_list.append(&make_section("MORE"));
sidebar_list.append(&make_item("archive", "📦", "Archive"));
sidebar_list.append(&make_item("settings", "", "Settings"));
sidebar_list.append(&make_item("errors", "", "Errors"));
sidebar_vbox.append(&sidebar_list);
// ── Content area ──────────────────────────────────────────────
let content_vbox = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.hexpand(true)
.build();
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)
.build();
let stack = gtk4::Stack::builder().hexpand(true).vexpand(true).build();
content_vbox.append(&search_entry);
content_vbox.append(&stack);
hbox.append(&sidebar_vbox);
hbox.append(&gtk4::Separator::builder()
.orientation(gtk4::Orientation::Vertical)
.build());
hbox.append(&content_vbox);
window.set_child(Some(&hbox));
// ── AppState ──────────────────────────────────────────────────
let state = AppState::new(store, notes, cfg, stack.clone());
// Initial build
rebuild_stack(&state);
// ── Sidebar selection ─────────────────────────────────────────
{
let state_c = state.clone();
sidebar_list.connect_row_selected(move |_, row| {
if let Some(row) = row {
let view = row.widget_name().to_string();
if view.is_empty() { return; }
*state_c.active_view.borrow_mut() = view.clone();
refresh(&state_c);
}
});
}
// ── Search ────────────────────────────────────────────────────
{
let state_c = state.clone();
search_entry.connect_search_changed(move |entry| {
let query = entry.text().to_string();
let all_notes = state_c.notes.borrow().clone();
let filtered: Vec<Note> = if query.trim().is_empty() {
all_notes
} else {
let q = query.to_lowercase();
all_notes
.into_iter()
.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(&notes_snap);
state_c.stack.add_named(&upcoming, Some("upcoming"));
for type_name in NoteType::all_builtin() {
let nt = NoteType::from_str(type_name);
let typed: Vec<Note> = notes_snap
.iter()
.filter(|n| n.note_type == nt && !n.done)
.cloned()
.collect();
state_c.stack.add_named(&build_note_list(&typed, state_c.clone()), Some(type_name));
}
state_c.stack.add_named(&views::archive::build(&notes_snap, state_c.clone()), Some("archive"));
let state_s = state_c.clone();
state_c.stack.add_named(
&views::settings::build(&cfg_snap, move |nc| { *state_s.cfg.borrow_mut() = nc; }),
Some("settings"),
);
state_c.stack.add_named(&views::errors::build(&errors_snap), Some("errors"));
state_c.stack.set_visible_child_name("all");
});
}
// ── New Note button ───────────────────────────────────────────
{
let state_c = state.clone();
let window_c = window.clone();
new_note_btn.connect_clicked(move |_| {
show_add_note_window(&window_c, state_c.clone());
});
}
// ── Select initial view ───────────────────────────────────────
let initial = initial_view.as_deref().unwrap_or("all");
*state.active_view.borrow_mut() = initial.to_string();
for row in sidebar_list
.observe_children()
.snapshot()
.iter()
.filter_map(|o| o.clone().downcast::<gtk4::ListBoxRow>().ok())
{
if row.widget_name() == initial {
sidebar_list.select_row(Some(&row));
break;
}
}
stack.set_visible_child_name(initial);
window.present();
Ok(())
}
// ── Note list & cards ─────────────────────────────────────────────────────────
fn build_note_list(notes: &[Note], state: AppState) -> gtk4::ScrolledWindow {
let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.vscrollbar_policy(gtk4::PolicyType::Automatic)
.vexpand(true)
.build();
let list = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(4)
.margin_top(8)
.margin_bottom(8)
.build();
let mut sorted: Vec<Note> = notes.iter().filter(|n| !n.done).cloned().collect();
sorted.sort_by(|a, b| b.created.cmp(&a.created));
if sorted.is_empty() {
list.append(
&gtk4::Label::builder()
.label("No notes here yet.")
.margin_top(32)
.build(),
);
} else {
for note in &sorted {
list.append(&build_note_card(note, state.clone()));
}
}
scroll.set_child(Some(&list));
scroll
}
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)
.css_classes(["note-card"])
.build();
card.add_css_class(&format!("note-card-{}", note.note_type.as_str()));
// Top row: body + type chip
let top_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.build();
let body_label = gtk4::Label::builder()
.label(&note.body)
.hexpand(true)
.xalign(0.0)
.wrap(true)
.build();
let type_chip = gtk4::Label::builder()
.label(note.note_type.as_str())
.css_classes(["type-chip"])
.build();
top_row.append(&body_label);
top_row.append(&type_chip);
// Bottom row: metadata + action buttons
let bottom_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.build();
let created_str = {
let local: chrono::DateTime<Local> = note.created.into();
local.format("%b %d %H:%M").to_string()
};
let meta_label = gtk4::Label::builder()
.label(&created_str)
.css_classes(["dim-label"])
.xalign(0.0)
.build();
// Date first, then chips
bottom_row.append(&meta_label);
if let Some(ws) = &note.workspace {
bottom_row.append(
&gtk4::Label::builder()
.label(&format!("ws:{}", ws))
.css_classes(["type-chip"])
.build(),
);
}
if let Some(t) = note.time {
let local: chrono::DateTime<Local> = t.into();
bottom_row.append(
&gtk4::Label::builder()
.label(&local.format("⏰ %b %d %H:%M").to_string())
.css_classes(["dim-label"])
.build(),
);
}
if note.rrule.is_some() {
bottom_row.append(
&gtk4::Label::builder()
.label("")
.css_classes(["type-chip"])
.build(),
);
}
bottom_row.append(&gtk4::Box::builder().hexpand(true).build());
// ✓ Done button
let done_btn = gtk4::Button::builder()
.label("")
.css_classes(["action-btn", "done-btn"])
.tooltip_text("Mark done")
.build();
{
let note_id = note.id.clone();
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(&note_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();
});
}
bottom_row.append(&done_btn);
// ✎ Edit button
let edit_btn = gtk4::Button::builder()
.label("")
.css_classes(["action-btn", "edit-btn"])
.tooltip_text("Edit")
.build();
{
let note_c = note.clone();
let state_c = state.clone();
let body_label_c = body_label.clone();
let card_c = card.clone();
edit_btn.connect_clicked(move |btn| {
let morning = state_c.cfg.borrow().reminders.default_morning.clone();
let store = Arc::new(state_c.write_store());
let state_save = state_c.clone();
let body_label_save = body_label_c.clone();
let state_del = state_c.clone();
let card_del = card_c.clone();
let popover = editor::build_editor_popover(
&note_c,
store,
morning,
move |updated: Note| {
body_label_save.set_label(&updated.body);
state_save.reload_notes();
},
move || {
card_del.set_visible(false);
state_del.reload_notes();
},
);
popover.set_parent(btn);
popover.popup();
});
}
bottom_row.append(&edit_btn);
// 🗑 Delete button — two-click confirm: first click → "Sure?", second → delete
let delete_btn = gtk4::Button::builder()
.label("🗑")
.css_classes(["action-btn", "danger-btn"])
.tooltip_text("Delete")
.build();
{
use std::cell::RefCell;
use std::rc::Rc;
let confirming = Rc::new(RefCell::new(false));
let note_id = note.id.clone();
let card_c = card.clone();
let state_c = state.clone();
let btn_c = delete_btn.clone();
delete_btn.connect_clicked(move |_| {
if *confirming.borrow() {
let store = state_c.write_store();
if let Err(e) = store.delete_note(&note_id) {
state_c.log_error(format!("delete failed: {}", e));
}
card_c.set_visible(false);
state_c.reload_notes();
} else {
*confirming.borrow_mut() = true;
btn_c.set_label("Sure?");
}
});
}
bottom_row.append(&delete_btn);
card.append(&top_row);
card.append(&bottom_row);
card
}
// ── Add note window ───────────────────────────────────────────────────────────
fn show_add_note_window(parent: &gtk4::ApplicationWindow, state: AppState) {
let win = gtk4::Window::builder()
.title("New Note")
.transient_for(parent)
.modal(true)
.default_width(500)
.build();
let vbox = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(10)
.margin_top(16)
.margin_bottom(16)
.margin_start(16)
.margin_end(16)
.build();
vbox.append(&gtk4::Label::builder().label("Body").xalign(0.0).build());
let body_entry = gtk4::Entry::builder()
.placeholder_text("What's on your mind?")
.hexpand(true)
.build();
vbox.append(&body_entry);
// Type chips
let chip_box = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(4)
.build();
let selected_type: Rc<RefCell<NoteType>> = Rc::new(RefCell::new(NoteType::Note));
let chips: Vec<(gtk4::Button, NoteType)> = NoteType::all_builtin()
.iter()
.map(|&name| {
let btn = gtk4::Button::builder()
.label(name)
.css_classes(["type-chip"])
.build();
(btn, NoteType::from_str(name))
})
.collect();
for (btn, nt) in &chips {
let sel = selected_type.clone();
let nt_c = nt.clone();
let all_btns: Vec<gtk4::Button> = chips.iter().map(|(b, _)| b.clone()).collect();
btn.connect_clicked(move |clicked| {
*sel.borrow_mut() = nt_c.clone();
for b in &all_btns { b.remove_css_class("active"); }
clicked.add_css_class("active");
});
chip_box.append(btn);
}
if let Some((btn, _)) = chips.iter().find(|(_, nt)| *nt == NoteType::Note) {
btn.add_css_class("active");
}
vbox.append(&chip_box);
vbox.append(&gtk4::Label::builder().label("Time (optional)").xalign(0.0).build());
let time_entry = gtk4::Entry::builder()
.placeholder_text("tomorrow 9am / at 7pm / in 30 minutes")
.hexpand(true)
.build();
vbox.append(&time_entry);
vbox.append(&gtk4::Label::builder().label("Recurrence (optional)").xalign(0.0).build());
let rrule_entry = gtk4::Entry::builder()
.placeholder_text("RRULE:FREQ=WEEKLY;BYDAY=MO")
.hexpand(true)
.build();
vbox.append(&rrule_entry);
let status_label = gtk4::Label::builder()
.label("")
.xalign(0.0)
.css_classes(["dim-label"])
.build();
vbox.append(&status_label);
let btn_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.build();
let cancel_btn = gtk4::Button::builder().label("Cancel").build();
let add_btn = gtk4::Button::builder()
.label("Add Note")
.css_classes(["confirm-button"])
.build();
btn_row.append(&gtk4::Box::builder().hexpand(true).build());
btn_row.append(&cancel_btn);
btn_row.append(&add_btn);
vbox.append(&btn_row);
win.set_child(Some(&vbox));
// Cancel
{
let win_c = win.clone();
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();
let do_add = move || {
let body_text = body_c.text().to_string();
if body_text.trim().is_empty() {
status_c.set_label("Body is required.");
return;
}
let morning = state_c.cfg.borrow().reminders.default_morning.clone();
// Tier 1 classification on body
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 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();
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();
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(&note) {
state_c.log_error(format!("save failed: {}", e));
return;
}
if note.time.is_some() {
if let Err(e) = Scheduler::schedule(&note) {
state_c.log_error(format!("schedule failed: {}", e));
}
}
win_c.close();
// 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));
};
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();
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(&note) {
state_c2.log_error(format!("save failed: {}", e));
return;
}
if note.time.is_some() {
if let Err(e) = Scheduler::schedule(&note) {
state_c2.log_error(format!("schedule failed: {}", e));
}
}
win_c2.close();
let sr = state_c2.clone();
glib::idle_add_local_once(move || refresh(&sr));
}
});
}
win.present();
body_entry.grab_focus();
}
// ── 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(
&gtk4::gdk::Display::default().unwrap(),
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}

View file

@ -0,0 +1,109 @@
use breadpad_shared::types::Note;
use gtk4::prelude::*;
use std::cell::RefCell;
use std::rc::Rc;
pub fn build(notes: &[Note], state: crate::AppState) -> gtk4::ScrolledWindow {
let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.vscrollbar_policy(gtk4::PolicyType::Automatic)
.vexpand(true)
.build();
let list = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(4)
.margin_top(8)
.margin_bottom(8)
.build();
let mut archived: Vec<&Note> = notes.iter().filter(|n| n.done).collect();
archived.sort_by(|a, b| b.created.cmp(&a.created));
if archived.is_empty() {
list.append(
&gtk4::Label::builder()
.label("Archive is empty.")
.margin_top(32)
.build(),
);
} else {
for note in archived {
list.append(&build_archive_card(note, state.clone()));
}
}
scroll.set_child(Some(&list));
scroll
}
fn build_archive_card(note: &Note, state: crate::AppState) -> gtk4::Box {
let row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.margin_start(8)
.margin_end(8)
.margin_top(2)
.margin_bottom(2)
.css_classes(["note-card"])
.build();
let completed_str = note
.completed
.map(|t| {
let local: chrono::DateTime<chrono::Local> = t.into();
format!("done {}", local.format("%b %d"))
})
.unwrap_or_else(|| "done".into());
let done_label = gtk4::Label::builder()
.label(&completed_str)
.width_chars(12)
.xalign(0.0)
.build();
let body_label = gtk4::Label::builder()
.label(&note.body)
.hexpand(true)
.xalign(0.0)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
let type_label = gtk4::Label::builder()
.label(note.note_type.as_str())
.css_classes(["type-chip"])
.build();
// 🗑 Delete — two-click confirm
let delete_btn = gtk4::Button::builder()
.label("🗑")
.css_classes(["action-btn", "danger-btn"])
.tooltip_text("Delete permanently")
.build();
{
let confirming = Rc::new(RefCell::new(false));
let note_id = note.id.clone();
let row_c = row.clone();
let btn_c = delete_btn.clone();
delete_btn.connect_clicked(move |_| {
if *confirming.borrow() {
let store = state.write_store();
if let Err(e) = store.delete_note(&note_id) {
state.log_error(format!("delete failed: {}", e));
}
row_c.set_visible(false);
state.reload_notes();
} else {
*confirming.borrow_mut() = true;
btn_c.set_label("Sure?");
}
});
}
row.append(&done_label);
row.append(&body_label);
row.append(&type_label);
row.append(&delete_btn);
row
}

View file

@ -0,0 +1,60 @@
use chrono::DateTime;
use gtk4::prelude::*;
pub fn build(entries: &[(DateTime<chrono::Local>, String)]) -> gtk4::ScrolledWindow {
let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.vscrollbar_policy(gtk4::PolicyType::Automatic)
.vexpand(true)
.build();
let list = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(4)
.margin_top(8)
.margin_bottom(8)
.margin_start(8)
.margin_end(8)
.build();
if entries.is_empty() {
list.append(
&gtk4::Label::builder()
.label("No errors or warnings this session.")
.margin_top(32)
.build(),
);
} else {
for (ts, msg) in entries.iter().rev() {
let row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.css_classes(["note-card"])
.margin_top(2)
.margin_bottom(2)
.build();
let time_label = gtk4::Label::builder()
.label(&ts.format("%H:%M:%S").to_string())
.width_chars(10)
.xalign(0.0)
.css_classes(["dim-label"])
.build();
let msg_label = gtk4::Label::builder()
.label(msg)
.hexpand(true)
.xalign(0.0)
.wrap(true)
.selectable(true)
.build();
row.append(&time_label);
row.append(&msg_label);
list.append(&row);
}
}
scroll.set_child(Some(&list));
scroll
}

View file

@ -0,0 +1,4 @@
pub mod archive;
pub mod errors;
pub mod settings;
pub mod upcoming;

View file

@ -0,0 +1,271 @@
use breadpad_shared::config::{
CalendarConfig, Config, ModelConfig, OllamaConfig, RemindersConfig, Settings,
};
use gtk4::prelude::*;
pub fn build(cfg: &Config, on_save: impl Fn(Config) + 'static) -> gtk4::ScrolledWindow {
let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.vscrollbar_policy(gtk4::PolicyType::Automatic)
.vexpand(true)
.build();
let outer = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(16)
.margin_top(16)
.margin_bottom(16)
.margin_start(16)
.margin_end(16)
.build();
// ── General ──────────────────────────────────────────────────
let (general_frame, general_grid) = make_section("General");
let type_options = ["note", "todo", "reminder", "idea", "question"];
let default_type_combo = gtk4::DropDown::from_strings(&type_options);
let dt_idx = type_options
.iter()
.position(|&s| s == cfg.settings.default_type.as_str())
.unwrap_or(0) as u32;
default_type_combo.set_selected(dt_idx);
attach_row(&general_grid, 0, "Default type", &default_type_combo);
let ws_tag_switch = gtk4::Switch::builder()
.active(cfg.settings.workspace_tag)
.valign(gtk4::Align::Center)
.build();
attach_row(&general_grid, 1, "Workspace tag", &ws_tag_switch);
let archive_spin = gtk4::SpinButton::with_range(1.0, 365.0, 1.0);
archive_spin.set_value(cfg.settings.archive_after_days as f64);
attach_row(&general_grid, 2, "Archive after (days)", &archive_spin);
let snooze_entry = gtk4::Entry::builder()
.text(&cfg.settings.snooze_options.join(", "))
.hexpand(true)
.build();
attach_row(&general_grid, 3, "Snooze options", &snooze_entry);
outer.append(&general_frame);
// ── Reminders ────────────────────────────────────────────────
let (rem_frame, rem_grid) = make_section("Reminders");
let morning_entry = gtk4::Entry::builder()
.text(&cfg.reminders.default_morning)
.placeholder_text("HH:MM")
.build();
attach_row(&rem_grid, 0, "Default morning", &morning_entry);
let grace_spin = gtk4::SpinButton::with_range(0.0, 1440.0, 5.0);
grace_spin.set_value(cfg.reminders.missed_grace_minutes as f64);
attach_row(&rem_grid, 1, "Missed grace (minutes)", &grace_spin);
outer.append(&rem_frame);
// ── Model ─────────────────────────────────────────────────────
let (model_frame, model_grid) = make_section("Model (Tier 2 ONNX)");
let model_path_entry = gtk4::Entry::builder()
.text(&cfg.model.path)
.hexpand(true)
.build();
attach_row(&model_grid, 0, "ONNX path", &model_path_entry);
let tokenizer_entry = gtk4::Entry::builder()
.text(&cfg.model.tokenizer)
.hexpand(true)
.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);
outer.append(&model_frame);
// ── Ollama (Tier 3) ───────────────────────────────────────────
let (ollama_frame, ollama_grid) = make_section("Ollama (Tier 3)");
let ollama_enabled = gtk4::Switch::builder()
.active(cfg.model.ollama.enabled)
.valign(gtk4::Align::Center)
.build();
attach_row(&ollama_grid, 0, "Enabled", &ollama_enabled);
let ollama_endpoint = gtk4::Entry::builder()
.text(&cfg.model.ollama.endpoint)
.hexpand(true)
.build();
attach_row(&ollama_grid, 1, "Endpoint", &ollama_endpoint);
let ollama_model = gtk4::Entry::builder()
.text(&cfg.model.ollama.model)
.build();
attach_row(&ollama_grid, 2, "Model", &ollama_model);
let ollama_thresh = gtk4::SpinButton::with_range(0.0, 1.0, 0.05);
ollama_thresh.set_value(cfg.model.ollama.confidence_threshold as f64);
ollama_thresh.set_digits(2);
attach_row(&ollama_grid, 3, "Confidence threshold", &ollama_thresh);
outer.append(&ollama_frame);
// ── Calendar ─────────────────────────────────────────────────
let (cal_frame, cal_grid) = make_section("Nextcloud Calendar (CalDAV)");
let cal_enabled = gtk4::Switch::builder()
.active(cfg.calendar.enabled)
.valign(gtk4::Align::Center)
.build();
attach_row(&cal_grid, 0, "Enabled", &cal_enabled);
let cal_url = gtk4::Entry::builder()
.text(&cfg.calendar.url)
.placeholder_text("https://nextcloud.example.com/remote.php/dav/calendars/you/personal/")
.hexpand(true)
.build();
attach_row(&cal_grid, 1, "Calendar URL", &cal_url);
let cal_user = gtk4::Entry::builder()
.text(&cfg.calendar.username)
.build();
attach_row(&cal_grid, 2, "Username", &cal_user);
let cal_pass = gtk4::Entry::builder()
.text(&cfg.calendar.password)
.input_purpose(gtk4::InputPurpose::Password)
.visibility(false)
.build();
attach_row(&cal_grid, 3, "App password", &cal_pass);
outer.append(&cal_frame);
// ── Save ──────────────────────────────────────────────────────
let status_label = gtk4::Label::builder()
.label("")
.xalign(0.0)
.css_classes(["dim-label"])
.build();
let save_btn = gtk4::Button::builder()
.label("Save Settings")
.css_classes(["confirm-button"])
.halign(gtk4::Align::End)
.build();
{
let dtc = default_type_combo.clone();
let wts = ws_tag_switch.clone();
let ars = archive_spin.clone();
let sne = snooze_entry.clone();
let moe = morning_entry.clone();
let grs = grace_spin.clone();
let mpe = model_path_entry.clone();
let tke = tokenizer_entry.clone();
let epc = ep_combo.clone();
let oec = ollama_enabled.clone();
let oee = ollama_endpoint.clone();
let ome = ollama_model.clone();
let ots = ollama_thresh.clone();
let cec = cal_enabled.clone();
let cuc = cal_url.clone();
let csc = cal_user.clone();
let cpc = cal_pass.clone();
let sl = status_label.clone();
save_btn.connect_clicked(move |_| {
let new_cfg = Config {
settings: Settings {
default_type: type_options
.get(dtc.selected() as usize)
.copied()
.unwrap_or("note")
.to_string(),
workspace_tag: wts.is_active(),
snooze_options: sne
.text()
.split(',')
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.collect(),
archive_after_days: ars.value() as i64,
},
reminders: RemindersConfig {
default_morning: moe.text().to_string(),
missed_grace_minutes: grs.value() as i64,
},
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(),
ollama: OllamaConfig {
enabled: oec.is_active(),
endpoint: oee.text().to_string(),
model: ome.text().to_string(),
confidence_threshold: ots.value() as f32,
},
},
calendar: CalendarConfig {
enabled: cec.is_active(),
url: cuc.text().to_string(),
username: csc.text().to_string(),
password: cpc.text().to_string(),
},
};
match new_cfg.save() {
Ok(()) => {
sl.set_label("Settings saved.");
on_save(new_cfg);
}
Err(e) => sl.set_label(&format!("Save failed: {}", e)),
}
});
}
let btn_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.build();
btn_row.append(&status_label);
btn_row.append(&gtk4::Box::builder().hexpand(true).build());
btn_row.append(&save_btn);
outer.append(&btn_row);
scroll.set_child(Some(&outer));
scroll
}
fn make_section(title: &str) -> (gtk4::Frame, gtk4::Grid) {
let frame = gtk4::Frame::builder().label(title).build();
let grid = gtk4::Grid::builder()
.row_spacing(8)
.column_spacing(16)
.margin_top(8)
.margin_bottom(8)
.margin_start(8)
.margin_end(8)
.build();
frame.set_child(Some(&grid));
(frame, grid)
}
fn attach_row(grid: &gtk4::Grid, row: i32, label: &str, widget: &impl gtk4::prelude::IsA<gtk4::Widget>) {
let lbl = gtk4::Label::builder()
.label(label)
.xalign(0.0)
.hexpand(false)
.width_chars(24)
.build();
grid.attach(&lbl, 0, row, 1, 1);
grid.attach(widget, 1, row, 1, 1);
}

View file

@ -0,0 +1,86 @@
use breadpad_shared::types::{Note, NoteType};
use gtk4::prelude::*;
pub fn build(notes: &[Note]) -> gtk4::ScrolledWindow {
let scroll = gtk4::ScrolledWindow::builder()
.hscrollbar_policy(gtk4::PolicyType::Never)
.vscrollbar_policy(gtk4::PolicyType::Automatic)
.vexpand(true)
.build();
let list = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(4)
.margin_top(8)
.margin_bottom(8)
.build();
let mut upcoming: Vec<&Note> = notes
.iter()
.filter(|n| {
!n.done
&& matches!(n.note_type, NoteType::Reminder | NoteType::Todo)
&& n.effective_time().is_some()
})
.collect();
upcoming.sort_by_key(|n| n.effective_time().unwrap());
if upcoming.is_empty() {
let label = gtk4::Label::builder()
.label("No upcoming reminders or todos.")
.margin_top(32)
.build();
list.append(&label);
} else {
for note in upcoming {
let card = build_upcoming_card(note);
list.append(&card);
}
}
scroll.set_child(Some(&list));
scroll
}
fn build_upcoming_card(note: &Note) -> gtk4::Box {
let row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.margin_start(8)
.margin_end(8)
.margin_top(4)
.margin_bottom(4)
.css_classes(["note-card"])
.build();
let time_str = note
.effective_time()
.map(|t| {
let local: chrono::DateTime<chrono::Local> = t.into();
local.format("%a %b %d, %H:%M").to_string()
})
.unwrap_or_default();
let time_label = gtk4::Label::builder()
.label(&time_str)
.width_chars(18)
.xalign(0.0)
.build();
let body_label = gtk4::Label::builder()
.label(&note.body)
.hexpand(true)
.xalign(0.0)
.ellipsize(gtk4::pango::EllipsizeMode::End)
.build();
let type_label = gtk4::Label::builder()
.label(note.note_type.as_str())
.css_classes(["type-chip"])
.build();
row.append(&time_label);
row.append(&body_label);
row.append(&type_label);
row
}

View file

@ -0,0 +1,29 @@
[package]
name = "breadpad-shared"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[dependencies]
anyhow.workspace = true
tracing.workspace = true
serde.workspace = true
serde_json.workspace = true
uuid.workspace = true
chrono.workspace = true
rrule.workspace = true
tokio.workspace = true
zbus.workspace = true
ort.workspace = true
tokenizers.workspace = true
ndarray.workspace = true
toml.workspace = true
dirs.workspace = true
regex.workspace = true
ureq.workspace = true
reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "json"] }
ical = "0.11"
[dev-dependencies]
tempfile = "3"

118
breadpad-shared/src/ai.rs Normal file
View file

@ -0,0 +1,118 @@
use crate::config::OllamaConfig;
use crate::types::{ClassificationResult, NoteType};
use serde::Deserialize;
pub struct OllamaClient {
endpoint: String,
model: String,
}
#[derive(Deserialize)]
struct OllamaGenerateResponse {
response: String,
#[allow(dead_code)]
done: bool,
}
#[derive(Deserialize)]
struct OllamaClassification {
#[serde(rename = "type")]
note_type: Option<String>,
body: Option<String>,
confidence: Option<f32>,
}
impl OllamaClient {
pub fn new(cfg: &OllamaConfig) -> Self {
OllamaClient {
endpoint: cfg.endpoint.trim_end_matches('/').to_string(),
model: cfg.model.clone(),
}
}
/// Run Tier 3 classification. Returns `fallback` if Ollama is unreachable or returns
/// an unparseable response. Time, rrule, and body from Tier 1 are always preserved.
pub fn classify(&self, text: &str, fallback: &ClassificationResult) -> ClassificationResult {
match self.try_classify(text, fallback) {
Ok(r) => r,
Err(e) => {
tracing::warn!("Tier 3 (Ollama) unavailable: {}; using Tier 2 result", e);
fallback.clone()
}
}
}
fn try_classify(&self, text: &str, fallback: &ClassificationResult) -> anyhow::Result<ClassificationResult> {
let url = format!("{}/api/generate", self.endpoint);
let prompt = format!(
"Classify the following note into exactly one type.\n\
Valid types: todo, reminder, idea, note, question.\n\
Note: \"{}\"\n\
Respond with JSON only, using this exact format: \
{{\"type\": \"TYPENAME\", \"body\": \"cleaned text\", \"confidence\": 0.0}}",
text
);
let payload = serde_json::json!({
"model": self.model,
"prompt": prompt,
"format": "json",
"stream": false
});
let response = ureq::post(&url)
.set("Content-Type", "application/json")
.send_json(payload)
.map_err(|e| anyhow::anyhow!("Ollama HTTP error: {}", e))?;
let ollama_resp: OllamaGenerateResponse = response
.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,
&ollama_resp.response
))?;
let note_type = classification
.note_type
.as_deref()
.map(NoteType::from_str)
.unwrap_or_else(|| fallback.note_type.clone());
let confidence = classification
.confidence
.unwrap_or(0.75)
.clamp(0.0, 1.0);
// Use Tier 1's time/rrule/body as the ground truth; optionally use the LLM's
// cleaned body if it provided one and Tier 1 didn't already strip anything.
let body = if let Some(llm_body) = classification.body.filter(|b| !b.trim().is_empty()) {
if fallback.body == text {
// Tier 1 didn't clean the body — accept LLM's version
llm_body
} else {
// Tier 1 already stripped time phrases — keep its result
fallback.body.clone()
}
} else {
fallback.body.clone()
};
tracing::info!(
"Tier 3: classified {:?} as {:?} (conf={:.2})",
text, note_type, confidence
);
Ok(ClassificationResult {
note_type,
confidence,
time: fallback.time,
rrule: fallback.rrule.clone(),
body,
})
}
}

View file

@ -0,0 +1,221 @@
use crate::config::CalendarConfig;
use crate::types::Note;
use anyhow::{Context, Result};
use ical::IcalParser;
use std::io::BufReader;
pub struct CalDavClient {
config: CalendarConfig,
client: reqwest::Client,
}
pub struct CalDavEventInfo {
pub uid: String,
pub summary: String,
}
impl CalDavClient {
pub fn new(config: CalendarConfig) -> Self {
let client = reqwest::Client::builder()
.build()
.expect("failed to build HTTP client");
CalDavClient { config, client }
}
pub async fn test_connection(&self) -> Result<()> {
let body = r#"<?xml version="1.0"?><d:propfind xmlns:d="DAV:"><d:prop><d:displayname/></d:prop></d:propfind>"#;
let resp = self
.client
.request(
reqwest::Method::from_bytes(b"PROPFIND").unwrap(),
&self.config.url,
)
.basic_auth(&self.config.username, Some(&self.config.password))
.header("Depth", "0")
.header("Content-Type", "application/xml; charset=utf-8")
.body(body)
.send()
.await
.context("CalDAV PROPFIND request failed")?;
let status = resp.status();
if status.is_success() || status.as_u16() == 207 {
Ok(())
} else {
anyhow::bail!("CalDAV server returned {}", status);
}
}
pub async fn push_event(&self, note: &Note) -> Result<String> {
let uid = caldav_uid(note);
let ical = build_ical(note, &uid);
let url = event_url(&self.config.url, &uid);
let resp = self
.client
.put(&url)
.basic_auth(&self.config.username, Some(&self.config.password))
.header("Content-Type", "text/calendar; charset=utf-8")
.body(ical)
.send()
.await
.context("CalDAV PUT request failed")?;
let status = resp.status();
if status.is_success() || status.as_u16() == 201 || status.as_u16() == 204 {
Ok(uid)
} else {
anyhow::bail!("CalDAV PUT returned {}", status);
}
}
pub async fn delete_event(&self, uid: &str) -> Result<()> {
let url = event_url(&self.config.url, uid);
let resp = self
.client
.delete(&url)
.basic_auth(&self.config.username, Some(&self.config.password))
.send()
.await
.context("CalDAV DELETE request failed")?;
let status = resp.status();
if status.is_success() || status.as_u16() == 204 || status.as_u16() == 404 {
Ok(())
} else {
anyhow::bail!("CalDAV DELETE returned {}", status);
}
}
pub async fn list_events(&self) -> Result<Vec<CalDavEventInfo>> {
let body = r#"<?xml version="1.0"?>
<c:calendar-query xmlns:d="DAV:" xmlns:c="urn:ietf:params:xml:ns:caldav">
<d:prop>
<d:getetag/>
<c:calendar-data/>
</d:prop>
<c:filter>
<c:comp-filter name="VCALENDAR">
<c:comp-filter name="VEVENT"/>
</c:comp-filter>
</c:filter>
</c:calendar-query>"#;
let resp = self
.client
.request(
reqwest::Method::from_bytes(b"REPORT").unwrap(),
&self.config.url,
)
.basic_auth(&self.config.username, Some(&self.config.password))
.header("Depth", "1")
.header("Content-Type", "application/xml; charset=utf-8")
.body(body)
.send()
.await
.context("CalDAV REPORT request failed")?;
let status = resp.status();
if !status.is_success() && status.as_u16() != 207 {
anyhow::bail!("CalDAV REPORT returned {}", status);
}
let xml = resp.text().await.context("failed to read CalDAV REPORT body")?;
parse_report_response(&xml)
}
}
pub fn caldav_uid(note: &Note) -> String {
note.caldav_uid
.clone()
.unwrap_or_else(|| format!("{}@breadpad", note.id))
}
fn event_url(base: &str, uid: &str) -> String {
format!("{}/{}.ics", base.trim_end_matches('/'), uid)
}
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(&note.body);
let description = escape_ical(&format!("type={}", note.note_type.as_str()));
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"
);
if let Some(rrule) = &note.rrule {
ical.push_str(rrule.as_str());
ical.push_str("\r\n");
}
ical.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n");
ical
}
fn escape_ical(s: &str) -> String {
s.replace('\\', "\\\\")
.replace(';', "\\;")
.replace(',', "\\,")
.replace('\n', "\\n")
}
fn parse_report_response(xml: &str) -> Result<Vec<CalDavEventInfo>> {
let mut events = Vec::new();
let mut search_from = 0;
while let Some(start) = xml[search_from..].find("BEGIN:VCALENDAR") {
let abs_start = search_from + start;
let tail = &xml[abs_start..];
let end = match tail.find("END:VCALENDAR") {
Some(e) => abs_start + e + "END:VCALENDAR".len(),
None => break,
};
let ical_block = &xml[abs_start..end];
events.extend(parse_ical_block(ical_block));
search_from = end;
}
Ok(events)
}
fn parse_ical_block(data: &str) -> Vec<CalDavEventInfo> {
let reader = BufReader::new(data.as_bytes());
let parser = IcalParser::new(reader);
let mut out = Vec::new();
for item in parser {
match item {
Ok(cal) => {
for event in cal.events {
let uid = event
.properties
.iter()
.find(|p| p.name == "UID")
.and_then(|p| p.value.clone())
.unwrap_or_default();
let summary = event
.properties
.iter()
.find(|p| p.name == "SUMMARY")
.and_then(|p| p.value.clone())
.unwrap_or_default();
out.push(CalDavEventInfo { uid, summary });
}
}
Err(e) => tracing::warn!("CalDAV: failed to parse VCALENDAR block: {}", e),
}
}
out
}

View file

@ -0,0 +1,284 @@
use crate::ai::OllamaClient;
use crate::config::OllamaConfig;
use crate::parser::parse_rule_based;
use crate::types::{ClassificationResult, NoteType};
use std::path::PathBuf;
/// Minimum Tier 1 confidence needed to skip Tier 2 entirely.
const TIER1_SKIP_THRESHOLD: f32 = 0.82;
#[derive(Debug, Clone, PartialEq)]
pub enum ExecutionProvider {
Qnn,
Vulkan,
Cpu,
}
impl ExecutionProvider {
pub fn as_str(&self) -> &str {
match self {
ExecutionProvider::Qnn => "QNN (NPU)",
ExecutionProvider::Vulkan => "Vulkan",
ExecutionProvider::Cpu => "CPU",
}
}
}
pub struct Classifier {
session: Option<ort::session::Session>,
tokenizer: Option<tokenizers::Tokenizer>,
pub active_provider: ExecutionProvider,
pub model_path: PathBuf,
pub default_morning: String,
ollama: Option<OllamaConfig>,
}
fn model_dir() -> PathBuf {
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("~/.local/share"))
.join("breadpad")
.join("model")
}
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 {
let dir = model_dir();
let onnx_path = dir.join("classifier.onnx");
let tok_path = dir.join("tokenizer.json");
let (session, active_provider) = if onnx_path.exists() {
try_load_session(&onnx_path, ep_pref)
} else {
tracing::warn!("model not found at {:?}; Tier 2 disabled", onnx_path);
(None, ExecutionProvider::Cpu)
};
let tokenizer = if tok_path.exists() && session.is_some() {
match tokenizers::Tokenizer::from_file(&tok_path) {
Ok(tok) => Some(tok),
Err(e) => {
tracing::warn!("failed to load tokenizer: {}", e);
None
}
}
} else {
None
};
Classifier {
session,
tokenizer,
active_provider,
model_path: onnx_path,
default_morning: default_morning.to_string(),
ollama: None,
}
}
/// Enable Tier 3 (Ollama). Only has an effect if `cfg.enabled` is true.
pub fn with_ollama(mut self, cfg: OllamaConfig) -> Self {
self.ollama = if cfg.enabled { Some(cfg) } else { None };
self
}
/// Three-tier classification pipeline:
///
/// - **Tier 1** (rule-based parser): always runs; handles time/recurrence extraction
/// and obvious type signals. If confidence ≥ 0.82, result is returned immediately.
/// - **Tier 2** (small ONNX model): runs when Tier 1 is uncertain about the type.
/// Responsible for type classification only; Tier 1's time/rrule/body are preserved.
/// - **Tier 3** (Ollama LLM): runs when Tier 2 confidence is below the configured
/// threshold. Falls back to the Tier 2 result if Ollama is unreachable.
pub fn classify(&mut self, text: &str) -> ClassificationResult {
// ── Tier 1 ───────────────────────────────────────────────────────────
let tier1 = parse_rule_based(text, &self.default_morning);
tracing::debug!("Tier 1: {:?} conf={:.2}", tier1.note_type, tier1.confidence);
if tier1.confidence >= TIER1_SKIP_THRESHOLD {
return tier1;
}
// ── Tier 2 ───────────────────────────────────────────────────────────
// ONNX model classifies the type only; Tier 1's time/rrule/body are kept.
let tier2 = if let (Some(session), Some(tokenizer)) =
(&mut self.session, &self.tokenizer)
{
match run_onnx(session, tokenizer, text) {
Ok(r) => {
tracing::debug!("Tier 2: {:?} conf={:.2}", r.note_type, r.confidence);
ClassificationResult {
note_type: r.note_type,
confidence: r.confidence,
time: tier1.time,
rrule: tier1.rrule.clone(),
body: tier1.body.clone(),
}
}
Err(e) => {
tracing::warn!("Tier 2 inference failed: {}; using Tier 1 result", e);
tier1.clone()
}
}
} else {
tier1.clone()
};
// ── Tier 3 ───────────────────────────────────────────────────────────
if let Some(ollama_cfg) = &self.ollama {
if tier2.confidence < ollama_cfg.confidence_threshold {
tracing::debug!(
"Tier 2 confidence {:.2} < threshold {:.2}; trying Tier 3",
tier2.confidence,
ollama_cfg.confidence_threshold
);
let client = OllamaClient::new(ollama_cfg);
return client.classify(text, &tier2);
}
}
tier2
}
pub fn model_available(&self) -> bool {
self.session.is_some()
}
}
// NLI hypotheses paired with their note types. The model scores each as
// entailment (label 0) vs not_entailment (label 1); we pick the highest
// entailment score across all five passes.
const HYPOTHESES: [(&str, &str); 5] = [
("This note is a task or action item to complete.", "todo"),
("This note is a reminder with a specific time or deadline.", "reminder"),
("This note is an idea, suggestion, or creative thought.", "idea"),
("This note is a general observation or piece of information.", "note"),
("This note is a question that needs an answer.", "question"),
];
fn run_onnx(
session: &mut ort::session::Session,
tokenizer: &tokenizers::Tokenizer,
text: &str,
) -> anyhow::Result<ClassificationResult> {
const ENTAILMENT_IDX: usize = 0;
let mut entailment_scores = [0.0f32; 5];
for (i, (hypothesis, _)) in HYPOTHESES.iter().enumerate() {
let encoding = tokenizer
.encode((text, *hypothesis), true)
.map_err(|e| anyhow::anyhow!("tokenize: {}", e))?;
let ids: Vec<i64> = encoding.get_ids().iter().map(|&x| x as i64).collect();
let mask: Vec<i64> = encoding.get_attention_mask().iter().map(|&x| x as i64).collect();
let len = ids.len();
let ids_tensor = ort::value::Tensor::<i64>::from_array(
(vec![1i64, len as i64], ids)
).map_err(|e| anyhow::anyhow!("ids tensor: {}", e))?;
let mask_tensor = ort::value::Tensor::<i64>::from_array(
(vec![1i64, len as i64], mask)
).map_err(|e| anyhow::anyhow!("mask tensor: {}", e))?;
let inputs = ort::inputs![
"input_ids" => ids_tensor,
"attention_mask" => mask_tensor,
];
let outputs = session
.run(inputs)
.map_err(|e| anyhow::anyhow!("run: {}", e))?;
let logits = outputs["logits"]
.try_extract_tensor::<f32>()
.map_err(|e| anyhow::anyhow!("extract logits: {}", e))?;
let (_, logits_slice) = logits;
entailment_scores[i] = logits_slice
.get(ENTAILMENT_IDX)
.copied()
.unwrap_or(0.0);
}
let best_idx = entailment_scores
.iter()
.enumerate()
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
.map(|(i, _)| i)
.unwrap_or(3);
let note_type = NoteType::from_str(HYPOTHESES[best_idx].1);
let confidence = softmax_single(&entailment_scores, best_idx);
Ok(ClassificationResult {
note_type,
confidence,
// Time/rrule/body are merged by the caller from Tier 1's result.
time: None,
rrule: None,
body: text.to_string(),
})
}
fn softmax_single(logits: &[f32], idx: usize) -> f32 {
if logits.is_empty() || idx >= logits.len() {
return 0.5;
}
let max = logits.iter().cloned().fold(f32::NEG_INFINITY, f32::max);
let exps: Vec<f32> = logits.iter().map(|&x| (x - max).exp()).collect();
let sum: f32 = exps.iter().sum();
exps[idx] / sum
}
fn try_load_session(
path: &std::path::Path,
ep_pref: &str,
) -> (Option<ort::session::Session>, ExecutionProvider) {
let providers: &[(&str, ExecutionProvider)] = &[
("qnn", ExecutionProvider::Qnn),
("vulkan", ExecutionProvider::Vulkan),
("cpu", ExecutionProvider::Cpu),
];
let to_try: Vec<&(&str, ExecutionProvider)> = match ep_pref {
"npu" => providers[..1].iter().collect(),
"vulkan" => providers[1..2].iter().collect(),
"cpu" => providers[2..].iter().collect(),
_ => providers.iter().collect(),
};
for (ep_name, ep) in to_try {
match build_session(path, ep_name) {
Ok(session) => {
tracing::info!("ONNX session loaded with {} EP", ep.as_str());
return (Some(session), ep.clone());
}
Err(e) => {
tracing::debug!("{} EP unavailable: {}", ep_name, e);
}
}
}
(None, ExecutionProvider::Cpu)
}
fn build_session(
path: &std::path::Path,
ep_name: &str,
) -> 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)),
}
}

View file

@ -0,0 +1,180 @@
use anyhow::Result;
use serde::{Deserialize, Serialize};
use std::fs;
use std::path::PathBuf;
fn default_type_str() -> String { "note".into() }
fn default_workspace_tag() -> bool { true }
fn default_snooze_options() -> Vec<String> {
vec!["15m".into(), "1h".into(), "tomorrow_morning".into()]
}
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_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_confidence_threshold() -> f32 { 0.6 }
fn default_ollama_enabled() -> bool { true }
fn default_calendar_enabled() -> bool { false }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings {
#[serde(default = "default_type_str")]
pub default_type: String,
#[serde(default = "default_workspace_tag")]
pub workspace_tag: bool,
#[serde(default = "default_snooze_options")]
pub snooze_options: Vec<String>,
#[serde(default = "default_archive_after_days")]
pub archive_after_days: i64,
}
impl Default for Settings {
fn default() -> Self {
Settings {
default_type: default_type_str(),
workspace_tag: default_workspace_tag(),
snooze_options: default_snooze_options(),
archive_after_days: default_archive_after_days(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OllamaConfig {
#[serde(default = "default_ollama_endpoint")]
pub endpoint: String,
#[serde(default = "default_ollama_model")]
pub model: String,
#[serde(default = "default_ollama_confidence_threshold")]
pub confidence_threshold: f32,
#[serde(default = "default_ollama_enabled")]
pub enabled: bool,
}
impl Default for OllamaConfig {
fn default() -> Self {
OllamaConfig {
endpoint: default_ollama_endpoint(),
model: default_ollama_model(),
confidence_threshold: default_ollama_confidence_threshold(),
enabled: default_ollama_enabled(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ModelConfig {
#[serde(default = "default_model_path")]
pub path: String,
#[serde(default = "default_tokenizer_path")]
pub tokenizer: String,
#[serde(default = "default_execution_provider")]
pub execution_provider: String,
#[serde(default)]
pub ollama: OllamaConfig,
}
impl Default for ModelConfig {
fn default() -> Self {
ModelConfig {
path: default_model_path(),
tokenizer: default_tokenizer_path(),
execution_provider: default_execution_provider(),
ollama: OllamaConfig::default(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RemindersConfig {
#[serde(default = "default_morning_time")]
pub default_morning: String,
#[serde(default = "default_missed_grace_minutes")]
pub missed_grace_minutes: i64,
}
impl Default for RemindersConfig {
fn default() -> Self {
RemindersConfig {
default_morning: default_morning_time(),
missed_grace_minutes: default_missed_grace_minutes(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct CalendarConfig {
#[serde(default = "default_calendar_enabled")]
pub enabled: bool,
#[serde(default)]
pub url: String,
#[serde(default)]
pub username: String,
#[serde(default)]
pub password: String,
}
impl Default for CalendarConfig {
fn default() -> Self {
CalendarConfig {
enabled: false,
url: String::new(),
username: String::new(),
password: String::new(),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct Config {
#[serde(default)]
pub settings: Settings,
#[serde(default)]
pub model: ModelConfig,
#[serde(default)]
pub reminders: RemindersConfig,
#[serde(default)]
pub calendar: CalendarConfig,
}
impl Config {
pub fn load() -> Result<Self> {
let path = config_path();
if !path.exists() {
let cfg = Config::default();
cfg.save()?;
return Ok(cfg);
}
let text = fs::read_to_string(&path)?;
let cfg: Config = toml::from_str(&text)?;
Ok(cfg)
}
pub fn save(&self) -> Result<()> {
let path = config_path();
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let text = toml::to_string_pretty(self)?;
fs::write(&path, text)?;
Ok(())
}
}
pub fn config_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("breadpad")
.join("breadpad.toml")
}
pub fn style_css_path() -> PathBuf {
dirs::config_dir()
.unwrap_or_else(|| PathBuf::from("~/.config"))
.join("breadpad")
.join("style.css")
}

View file

@ -0,0 +1,9 @@
pub mod ai;
pub mod calendar;
pub mod classifier;
pub mod config;
pub mod parser;
pub mod scheduler;
pub mod store;
pub mod theme;
pub mod types;

View file

@ -0,0 +1,882 @@
use crate::types::{ClassificationResult, NoteType, RecurrenceRule};
use chrono::{DateTime, Datelike, Duration, Local, NaiveTime, Timelike, Utc, Weekday};
use regex::Regex;
use std::sync::OnceLock;
struct Patterns {
at_time: Regex,
in_duration: Regex,
in_duration_word: Regex,
tomorrow: Regex,
next_weekday: Regex,
tonight: Regex,
every_weekdays: Regex,
every_weekday: Regex,
every_week: Regex,
every_day: Regex,
morning_evening: Regex,
}
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(),
// 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?)"
).unwrap(),
tomorrow: Regex::new(r"(?i)\btomorrow(?:\s+morning|\s+evening|\s+afternoon)?").unwrap(),
next_weekday: Regex::new(r"(?i)\bnext\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)").unwrap(),
// "tonight" or "this evening" — maps to a fixed 21:00 anchor
tonight: Regex::new(r"(?i)\b(?:tonight|this\s+evening)\b").unwrap(),
// "every weekday [at H:MM|morning|afternoon|evening]" → MonFri RRULE
every_weekdays: Regex::new(
r"(?i)\bevery\s+weekday(?:\s+(?:at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?|(morning|afternoon|evening)))?"
).unwrap(),
every_weekday: Regex::new(r"(?i)\bevery\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)(?:\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?").unwrap(),
// \bweek\b prevents "weekday" from being matched here
every_week: Regex::new(r"(?i)\bevery\s+week\b(?:\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?").unwrap(),
every_day: Regex::new(r"(?i)\bevery\s+day(?:\s+at\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?)?").unwrap(),
// Strips stray time-of-day words after a time has been extracted.
// Handles compound forms ("this morning") before bare words to avoid partial matches.
morning_evening: Regex::new(
r"(?i)\b(?:this\s+(?:morning|evening|afternoon|night)|tonight|morning|evening|afternoon|night)\b"
).unwrap(),
})
}
fn parse_time_of_day(hour: &str, min: Option<&str>, ampm: Option<&str>) -> NaiveTime {
let mut h: u32 = hour.parse().unwrap_or(9);
let m: u32 = min.and_then(|s| s.parse().ok()).unwrap_or(0);
if let Some(ap) = ampm {
if ap.eq_ignore_ascii_case("pm") && h < 12 {
h += 12;
} else if ap.eq_ignore_ascii_case("am") && h == 12 {
h = 0;
}
}
NaiveTime::from_hms_opt(h, m, 0).unwrap_or(NaiveTime::from_hms_opt(9, 0, 0).unwrap())
}
fn weekday_from_str(s: &str) -> Weekday {
match s.to_lowercase().as_str() {
"monday" => Weekday::Mon,
"tuesday" => Weekday::Tue,
"wednesday" => Weekday::Wed,
"thursday" => Weekday::Thu,
"friday" => Weekday::Fri,
"saturday" => Weekday::Sat,
_ => Weekday::Sun,
}
}
fn rrule_weekday(wd: Weekday) -> &'static str {
match wd {
Weekday::Mon => "MO",
Weekday::Tue => "TU",
Weekday::Wed => "WE",
Weekday::Thu => "TH",
Weekday::Fri => "FR",
Weekday::Sat => "SA",
Weekday::Sun => "SU",
}
}
fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime<Utc> {
let local = Local::now();
let days_ahead = (wd.num_days_from_monday() as i64
- local.weekday().num_days_from_monday() as i64)
.rem_euclid(7);
let days_ahead = if days_ahead == 0 {
if local.time() < time {
0
} else {
7
}
} else {
days_ahead
};
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)
}
pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResult {
let p = patterns();
let morning_time: NaiveTime = default_morning
.split(':')
.collect::<Vec<_>>()
.as_slice()
.get(..2)
.and_then(|parts| {
let h: u32 = parts[0].parse().ok()?;
let m: u32 = parts[1].parse().ok()?;
NaiveTime::from_hms_opt(h, m, 0)
})
.unwrap_or(NaiveTime::from_hms_opt(8, 0, 0).unwrap());
let evening_time = NaiveTime::from_hms_opt(18, 0, 0).unwrap();
let mut extracted_time: Option<DateTime<Utc>> = None;
let mut rrule: Option<RecurrenceRule> = None;
let mut cleaned = text.to_string();
// Recurrence: every day
if let Some(m) = p.every_day.find(text) {
let caps = p.every_day.captures(text).unwrap();
let t = if let (Some(h), mp, ap) = (caps.get(1), caps.get(2), caps.get(3)) {
parse_time_of_day(h.as_str(), mp.map(|x| x.as_str()), ap.map(|x| x.as_str()))
} else {
morning_time
};
rrule = Some(RecurrenceRule::new(format!(
"RRULE:FREQ=DAILY;BYHOUR={};BYMINUTE={};BYSECOND=0",
t.hour(),
t.minute()
)));
cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
}
// Recurrence: every week
else if let Some(m) = p.every_week.find(text) {
let caps = p.every_week.captures(text).unwrap();
let t = if let (Some(h), mp, ap) = (caps.get(1), caps.get(2), caps.get(3)) {
parse_time_of_day(h.as_str(), mp.map(|x| x.as_str()), ap.map(|x| x.as_str()))
} else {
morning_time
};
let now = Local::now();
let wd = rrule_weekday(now.weekday());
rrule = Some(RecurrenceRule::new(format!(
"RRULE:FREQ=WEEKLY;BYDAY={};BYHOUR={};BYMINUTE={};BYSECOND=0",
wd,
t.hour(),
t.minute()
)));
cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
}
// Recurrence: every weekday (MonFri)
else if let Some(caps) = p.every_weekdays.captures(text) {
let t = if let Some(h) = caps.get(1) {
parse_time_of_day(h.as_str(), caps.get(2).map(|x| x.as_str()), caps.get(3).map(|x| x.as_str()))
} else {
match caps.get(4).map(|x| x.as_str().to_lowercase()).as_deref() {
Some("afternoon") => NaiveTime::from_hms_opt(14, 0, 0).unwrap(),
Some("evening") => evening_time,
_ => morning_time,
}
};
rrule = Some(RecurrenceRule::new(format!(
"RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;BYHOUR={};BYMINUTE={};BYSECOND=0",
t.hour(),
t.minute()
)));
let full_match = caps.get(0).unwrap().as_str();
cleaned = cleaned.replacen(full_match, "", 1).trim().to_string();
}
// Recurrence: every <weekday>
else if let Some(caps) = p.every_weekday.captures(text) {
let wd_str = caps.get(1).unwrap().as_str();
let wd = weekday_from_str(wd_str);
let t = if let (Some(h), mp, ap) = (caps.get(2), caps.get(3), caps.get(4)) {
parse_time_of_day(h.as_str(), mp.map(|x| x.as_str()), ap.map(|x| x.as_str()))
} else {
morning_time
};
rrule = Some(RecurrenceRule::new(format!(
"RRULE:FREQ=WEEKLY;BYDAY={};BYHOUR={};BYMINUTE={};BYSECOND=0",
rrule_weekday(wd),
t.hour(),
t.minute()
)));
extracted_time = Some(next_occurrence_of_weekday(wd, t));
let full_match = caps.get(0).unwrap().as_str();
cleaned = cleaned.replacen(full_match, "", 1).trim().to_string();
}
// One-off: at <time>
if extracted_time.is_none() {
if let Some(caps) = p.at_time.captures(text) {
let t = parse_time_of_day(
caps.get(1).unwrap().as_str(),
caps.get(2).map(|x| x.as_str()),
caps.get(3).map(|x| x.as_str()),
);
let local = Local::now();
let naive = if local.time() < t {
local.date_naive().and_time(t)
} else {
(local.date_naive() + Duration::days(1)).and_time(t)
};
extracted_time = Some(naive.and_local_timezone(Local).unwrap().with_timezone(&Utc));
let full_match = caps.get(0).unwrap().as_str();
cleaned = cleaned.replacen(full_match, "", 1).trim().to_string();
}
// One-off: in <n> minutes/hours/days
else if let Some(caps) = p.in_duration.captures(text) {
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() {
"minute" => Duration::minutes(n),
"hour" => Duration::hours(n),
"day" => Duration::days(n),
_ => Duration::minutes(n),
};
extracted_time = Some(Utc::now() + delta);
let full_match = caps.get(0).unwrap().as_str();
cleaned = cleaned.replacen(full_match, "", 1).trim().to_string();
}
// One-off: word-form durations — "in an hour", "in a couple of hours", "in half an hour"
else if let Some(m) = p.in_duration_word.find(text) {
let phrase = m.as_str().to_lowercase();
let delta = if phrase.contains("half") {
Duration::minutes(30)
} else if phrase.contains("couple") {
if phrase.contains("hour") { Duration::hours(2) } else { Duration::minutes(2) }
} else if phrase.contains("few") {
Duration::hours(3)
} else if phrase.contains("hour") {
Duration::hours(1)
} else {
Duration::minutes(1)
};
extracted_time = Some(Utc::now() + delta);
cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
}
// One-off: tomorrow [morning/evening]
else if let Some(m) = p.tomorrow.find(text) {
let lower = m.as_str().to_lowercase();
let t = if lower.contains("evening") || lower.contains("afternoon") {
evening_time
} else {
morning_time
};
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));
cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
}
// One-off: next <weekday>
else if let Some(caps) = p.next_weekday.captures(text) {
let wd = weekday_from_str(caps.get(1).unwrap().as_str());
extracted_time = Some(next_occurrence_of_weekday(wd, morning_time));
let full_match = caps.get(0).unwrap().as_str();
cleaned = cleaned.replacen(full_match, "", 1).trim().to_string();
}
// One-off: "tonight" / "this evening" — anchors to 21:00
else if let Some(m) = p.tonight.find(text) {
let local = Local::now();
let anchor = NaiveTime::from_hms_opt(21, 0, 0).unwrap();
let target = if local.time() < anchor {
local.date_naive().and_time(anchor)
} else {
(local.date_naive() + Duration::days(1)).and_time(anchor)
};
extracted_time = Some(target.and_local_timezone(Local).unwrap().with_timezone(&Utc));
cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
}
}
// Strip stray time-of-day words once any time or recurrence signal was found
if extracted_time.is_some() || rrule.is_some() {
cleaned = p
.morning_evening
.replace_all(&cleaned, "")
.trim()
.to_string();
}
// Infer note type
let note_type = infer_type(text, extracted_time.is_some(), rrule.is_some());
// Trim artifacts
cleaned = cleaned
.trim_matches(|c: char| c.is_whitespace() || c == ',')
.to_string();
if cleaned.is_empty() {
cleaned = text.to_string();
}
// Calibrated confidence: high when structural signals drove the decision,
// low when we fell back to "note" with no positive evidence.
let confidence = if rrule.is_some() || extracted_time.is_some() {
0.95 // time/recurrence extraction is deterministic
} else {
match &note_type {
NoteType::Todo | NoteType::Question => 0.88, // strong lexical anchors
NoteType::Idea => 0.84,
NoteType::Reminder => 0.95, // shouldn't reach here without time
NoteType::Note => 0.45, // no signal — Tier 2 should weigh in
_ => 0.60,
}
};
ClassificationResult {
note_type,
time: extracted_time,
rrule,
body: cleaned,
confidence,
}
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Datelike, Local, Timelike, Utc};
fn p(text: &str) -> ClassificationResult {
parse_rule_based(text, "08:00")
}
fn p_morning(text: &str, morning: &str) -> ClassificationResult {
parse_rule_based(text, morning)
}
// ---- NoteType inference ----
#[test]
fn todo_buy() {
assert_eq!(p("buy groceries").note_type, NoteType::Todo);
}
#[test]
fn todo_pick_up() {
assert_eq!(p("pick up dry cleaning").note_type, NoteType::Todo);
}
#[test]
fn todo_fix() {
assert_eq!(p("fix the broken test").note_type, NoteType::Todo);
}
#[test]
fn todo_check() {
assert_eq!(p("check the deploy status").note_type, NoteType::Todo);
}
#[test]
fn todo_call() {
assert_eq!(p("call the dentist").note_type, NoteType::Todo);
}
#[test]
fn todo_finish() {
assert_eq!(p("finish the PR description").note_type, NoteType::Todo);
}
#[test]
fn todo_write() {
assert_eq!(p("write release notes").note_type, NoteType::Todo);
}
#[test]
fn todo_update() {
assert_eq!(p("update breadman dependencies").note_type, NoteType::Todo);
}
#[test]
fn question_why() {
assert_eq!(p("why does nmcli drop on suspend").note_type, NoteType::Question);
}
#[test]
fn question_how() {
assert_eq!(p("how do I configure zbus async").note_type, NoteType::Question);
}
#[test]
fn question_what_prefix() {
assert_eq!(p("what is the difference between Arc and Rc").note_type, NoteType::Question);
}
#[test]
fn question_ends_with_mark() {
assert_eq!(p("is this thread safe?").note_type, NoteType::Question);
assert_eq!(p("does GTK4 run on Wayland?").note_type, NoteType::Question);
}
#[test]
fn idea_what_if() {
assert_eq!(p("what if breadman had a calendar view").note_type, NoteType::Idea);
}
#[test]
fn idea_prefix() {
assert_eq!(p("idea: reactive state module in Lua").note_type, NoteType::Idea);
}
#[test]
fn idea_maybe() {
assert_eq!(p("maybe we could cache the ONNX model").note_type, NoteType::Idea);
}
#[test]
fn idea_could() {
assert_eq!(p("the sidebar could show counts per type").note_type, NoteType::Idea);
}
#[test]
fn note_generic_observation() {
assert_eq!(p("meeting went well").note_type, NoteType::Note);
assert_eq!(p("the new keyboard feels great").note_type, NoteType::Note);
}
// ---- Time extraction: at <time> ----
#[test]
fn at_time_pm_is_reminder_type() {
assert_eq!(p("pack bag at 7pm").note_type, NoteType::Reminder);
}
#[test]
fn at_time_am_is_reminder_type() {
assert_eq!(p("standup at 9am").note_type, NoteType::Reminder);
}
#[test]
fn at_time_pm_correct_hour() {
let r = p("dinner reservation at 7pm");
let local: chrono::DateTime<Local> = r.time.unwrap().into();
assert_eq!(local.hour(), 19);
}
#[test]
fn at_time_am_correct_hour() {
let r = p("meeting at 10am");
let local: chrono::DateTime<Local> = r.time.unwrap().into();
assert_eq!(local.hour(), 10);
}
#[test]
fn at_time_noon() {
let r = p("lunch at 12pm");
let local: chrono::DateTime<Local> = r.time.unwrap().into();
assert_eq!(local.hour(), 12);
}
#[test]
fn at_time_with_minutes() {
let r = p("call mum at 6:30pm");
let local: chrono::DateTime<Local> = r.time.unwrap().into();
assert_eq!(local.hour(), 18);
assert_eq!(local.minute(), 30);
}
#[test]
fn at_time_no_ampm_bare_number() {
// "at 14" should parse as 14:00
let r = p("check logs at 14");
assert!(r.time.is_some());
let local: chrono::DateTime<Local> = r.time.unwrap().into();
assert_eq!(local.hour(), 14);
}
#[test]
fn at_time_is_in_the_future() {
let r = p("check servers at 11pm");
assert!(r.time.unwrap() > Utc::now());
}
// ---- Time extraction: in <duration> ----
#[test]
fn in_30_minutes_correct_delta() {
let before = Utc::now();
let r = p("take a break in 30 minutes");
let t = r.time.unwrap();
let delta = (t - before).num_seconds();
assert!(delta >= 29 * 60 && delta <= 31 * 60, "delta was {}s", delta);
}
#[test]
fn in_1_minute() {
let before = Utc::now();
let r = p("ping in 1 minute");
let delta = (r.time.unwrap() - before).num_seconds();
assert!(delta >= 55 && delta <= 65, "delta was {}s", delta);
}
#[test]
fn in_2_hours_correct_delta() {
let before = Utc::now();
let r = p("review PR in 2 hours");
let delta_min = (r.time.unwrap() - before).num_minutes();
assert!(delta_min >= 119 && delta_min <= 121, "delta was {}min", delta_min);
}
#[test]
fn in_3_days_correct_delta() {
let before = Utc::now();
let r = p("follow up in 3 days");
let delta_h = (r.time.unwrap() - before).num_hours();
assert!(delta_h >= 71 && delta_h <= 73, "delta was {}h", delta_h);
}
// ---- Time extraction: tomorrow ----
#[test]
fn tomorrow_morning_correct_date_and_hour() {
let r = p("sync tomorrow morning");
let local: chrono::DateTime<Local> = r.time.unwrap().into();
let expected = (Local::now() + Duration::days(1)).date_naive();
assert_eq!(local.date_naive(), expected);
assert_eq!(local.hour(), 8);
assert_eq!(local.minute(), 0);
}
#[test]
fn tomorrow_morning_respects_custom_morning_time() {
let r = p_morning("standup tomorrow morning", "09:30");
let local: chrono::DateTime<Local> = r.time.unwrap().into();
assert_eq!(local.hour(), 9);
assert_eq!(local.minute(), 30);
}
#[test]
fn tomorrow_evening_hour() {
let r = p("call family tomorrow evening");
let local: chrono::DateTime<Local> = r.time.unwrap().into();
let expected = (Local::now() + Duration::days(1)).date_naive();
assert_eq!(local.date_naive(), expected);
assert_eq!(local.hour(), 18);
}
#[test]
fn tomorrow_alone_uses_morning_time() {
let r = p("dentist appointment tomorrow");
let local: chrono::DateTime<Local> = r.time.unwrap().into();
assert_eq!(local.hour(), 8);
}
#[test]
fn tomorrow_type_is_reminder() {
assert_eq!(p("gym tomorrow morning").note_type, NoteType::Reminder);
}
// ---- Time extraction: next <weekday> ----
#[test]
fn next_monday_is_monday() {
let r = p("dentist next monday");
let local: chrono::DateTime<Local> = r.time.unwrap().into();
assert_eq!(local.weekday(), chrono::Weekday::Mon);
assert!(r.time.unwrap() > Utc::now());
}
#[test]
fn next_friday_is_friday() {
let r = p("team lunch next friday");
let local: chrono::DateTime<Local> = r.time.unwrap().into();
assert_eq!(local.weekday(), chrono::Weekday::Fri);
}
#[test]
fn next_weekday_at_morning_hour() {
let r = p("meeting next wednesday");
let local: chrono::DateTime<Local> = r.time.unwrap().into();
assert_eq!(local.hour(), 8);
}
// ---- Recurrence rules ----
#[test]
fn every_day_daily_rrule() {
let r = p("take medication every day");
let rule = r.rrule.expect("rrule should be set");
assert!(rule.as_str().contains("FREQ=DAILY"), "rule: {}", rule.as_str());
}
#[test]
fn every_day_type_is_reminder() {
assert_eq!(p("take vitamin every day").note_type, NoteType::Reminder);
}
#[test]
fn every_day_at_time_byhour() {
let r = p("stand every day at 7am");
let rule = r.rrule.unwrap();
assert!(rule.as_str().contains("BYHOUR=7"), "rule: {}", rule.as_str());
assert!(rule.as_str().contains("BYMINUTE=0"), "rule: {}", rule.as_str());
}
#[test]
fn every_monday_weekly_with_byday() {
let r = p("standup every monday");
let rule = r.rrule.unwrap();
assert!(rule.as_str().contains("FREQ=WEEKLY"), "rule: {}", rule.as_str());
assert!(rule.as_str().contains("BYDAY=MO"), "rule: {}", rule.as_str());
}
#[test]
fn every_friday_at_time_rrule() {
let r = p("team lunch every friday at 1pm");
let rule = r.rrule.unwrap();
assert!(rule.as_str().contains("BYDAY=FR"), "rule: {}", rule.as_str());
assert!(rule.as_str().contains("BYHOUR=13"), "rule: {}", rule.as_str());
}
#[test]
fn every_sunday_evening_rrule() {
let r = p("family call every sunday at 7pm");
let rule = r.rrule.unwrap();
assert!(rule.as_str().contains("BYDAY=SU"), "rule: {}", rule.as_str());
assert!(rule.as_str().contains("BYHOUR=19"), "rule: {}", rule.as_str());
}
#[test]
fn every_wednesday_sets_first_time() {
let r = p("review PRs every wednesday at 10am");
assert!(r.time.is_some(), "recurrence should set initial time");
let local: chrono::DateTime<Local> = r.time.unwrap().into();
assert_eq!(local.weekday(), chrono::Weekday::Wed);
}
#[test]
fn every_week_at_time_rrule() {
let r = p("retro every week at 4pm");
let rule = r.rrule.unwrap();
assert!(rule.as_str().contains("FREQ=WEEKLY"), "rule: {}", rule.as_str());
assert!(rule.as_str().contains("BYHOUR=16"), "rule: {}", rule.as_str());
}
// ---- Body cleaning ----
#[test]
fn at_time_removed_from_body() {
let r = p("pack calculator in bag at 7pm");
assert!(!r.body.to_lowercase().contains("at 7pm"), "body: {:?}", r.body);
assert!(r.body.contains("pack calculator"), "body: {:?}", r.body);
}
#[test]
fn in_duration_removed_from_body() {
let r = p("call dentist in 30 minutes");
assert!(!r.body.contains("in 30 minutes"), "body: {:?}", r.body);
assert!(r.body.contains("call dentist"), "body: {:?}", r.body);
}
#[test]
fn every_day_removed_from_body() {
let r = p("take vitamin every day at 8am");
assert!(!r.body.to_lowercase().contains("every day"), "body: {:?}", r.body);
assert!(r.body.contains("take vitamin"), "body: {:?}", r.body);
}
#[test]
fn body_never_empty_after_stripping() {
for text in &["tomorrow morning", "every day at 8am", "at 9pm", "in 5 minutes"] {
let r = p(text);
assert!(!r.body.is_empty(), "body was empty for '{}'", text);
}
}
#[test]
fn plain_text_body_unchanged() {
let r = p("meeting went well, follow up needed");
assert_eq!(r.body, "meeting went well, follow up needed");
}
// ---- Edge cases ----
#[test]
fn empty_string_does_not_panic() {
let r = p("");
assert!(!r.body.is_empty() || r.body.is_empty()); // just don't panic
}
#[test]
fn whitespace_only_does_not_panic() {
let _ = p(" ");
}
#[test]
fn confidence_is_in_range() {
for text in &["buy milk", "why?", "idea: tabs", "meeting done"] {
let r = p(text);
assert!(r.confidence >= 0.0 && r.confidence <= 1.0, "confidence {} for '{}'", r.confidence, text);
}
}
#[test]
fn buy_with_at_store_not_parsed_as_time() {
// "at the store" should NOT parse as a time expression since there is no digit after "at"
let r = p("buy milk at the store");
// Should still be a Todo (no time extracted)
assert_eq!(r.note_type, NoteType::Todo);
}
// ---- Word-form durations (in_duration_word) ----
#[test]
fn in_an_hour_sets_time() {
let before = Utc::now();
let r = p("check on the server in an hour");
let delta_min = (r.time.unwrap() - before).num_minutes();
assert!(delta_min >= 59 && delta_min <= 61, "delta was {}min", delta_min);
}
#[test]
fn in_an_hour_type_is_reminder() {
assert_eq!(p("check on the deployment in an hour").note_type, NoteType::Reminder);
}
#[test]
fn in_an_hour_stripped_from_body() {
let r = p("check on the deployment in an hour");
assert!(!r.body.to_lowercase().contains("in an hour"), "body: {:?}", r.body);
assert!(r.body.contains("check on the deployment"), "body: {:?}", r.body);
}
#[test]
fn in_a_couple_of_hours_sets_two_hours() {
let before = Utc::now();
let r = p("in a couple of hours remind me to check the oven");
let delta_min = (r.time.unwrap() - before).num_minutes();
assert!(delta_min >= 119 && delta_min <= 121, "delta was {}min", delta_min);
}
#[test]
fn in_a_couple_of_hours_is_reminder() {
assert_eq!(p("in a couple of hours remind me to check the oven").note_type, NoteType::Reminder);
}
#[test]
fn in_a_few_hours_sets_three_hours() {
let before = Utc::now();
let r = p("in a few hours we need to submit this");
let delta_h = (r.time.unwrap() - before).num_hours();
assert_eq!(delta_h, 3, "expected 3h, got {}", delta_h);
}
#[test]
fn in_half_an_hour_sets_thirty_minutes() {
let before = Utc::now();
let r = p("in half an hour submit the report");
let delta_min = (r.time.unwrap() - before).num_minutes();
assert!(delta_min >= 29 && delta_min <= 31, "delta was {}min", delta_min);
}
// ---- Tonight / this evening ----
#[test]
fn tonight_type_is_reminder() {
assert_eq!(p("tonight watch the football").note_type, NoteType::Reminder);
}
#[test]
fn tonight_anchors_to_21h() {
let r = p("tonight put the bins out");
let local: chrono::DateTime<Local> = r.time.unwrap().into();
assert_eq!(local.hour(), 21);
}
#[test]
fn tonight_stripped_from_body() {
let r = p("tonight put the bins out");
assert!(!r.body.to_lowercase().contains("tonight"), "body: {:?}", r.body);
assert!(r.body.contains("put the bins out"), "body: {:?}", r.body);
}
#[test]
fn this_evening_type_is_reminder() {
assert_eq!(p("this evening water the plants").note_type, NoteType::Reminder);
}
#[test]
fn this_evening_stripped_from_body() {
let r = p("this evening water the plants");
assert!(!r.body.to_lowercase().contains("this evening"), "body: {:?}", r.body);
assert!(r.body.contains("water the plants"), "body: {:?}", r.body);
}
// ---- Every weekday (MonFri) ----
#[test]
fn every_weekday_is_not_matched_by_every_week() {
let r = p("every weekday morning check email");
let rule = r.rrule.unwrap();
assert!(rule.as_str().contains("BYDAY=MO,TU,WE,TH,FR"), "rule: {}", rule.as_str());
}
#[test]
fn every_weekday_type_is_reminder() {
assert_eq!(p("every weekday morning check email").note_type, NoteType::Reminder);
}
#[test]
fn every_weekday_morning_uses_morning_time() {
let r = p("every weekday morning standup");
let rule = r.rrule.unwrap();
assert!(rule.as_str().contains("BYHOUR=8"), "rule: {}", rule.as_str());
}
#[test]
fn every_weekday_at_time_uses_explicit_hour() {
let r = p("every weekday at 9am standup");
let rule = r.rrule.unwrap();
assert!(rule.as_str().contains("BYDAY=MO,TU,WE,TH,FR"), "rule: {}", rule.as_str());
assert!(rule.as_str().contains("BYHOUR=9"), "rule: {}", rule.as_str());
}
#[test]
fn every_weekday_body_cleaned() {
let r = p("every weekday morning check email");
assert!(!r.body.to_lowercase().contains("every weekday"), "body: {:?}", r.body);
assert!(!r.body.to_lowercase().contains("morning"), "body: {:?}", r.body);
assert!(r.body.contains("check email"), "body: {:?}", r.body);
}
#[test]
fn every_week_not_confused_by_weekday() {
// "every weekday" should NOT produce a BYDAY equal to the current weekday
let r = p("every weekday morning check email");
let rule = r.rrule.unwrap();
assert!(!rule.as_str().contains("BYDAY=MO;"), "every_week matched weekday: {}", rule.as_str());
}
#[test]
fn every_week_still_works_after_fix() {
let r = p("retro every week at 4pm");
let rule = r.rrule.unwrap();
assert!(rule.as_str().contains("FREQ=WEEKLY"), "rule: {}", rule.as_str());
assert!(rule.as_str().contains("BYHOUR=16"), "rule: {}", rule.as_str());
}
}
fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType {
let lower = text.to_lowercase();
if has_rrule || has_time {
return NoteType::Reminder;
}
if lower.contains("buy ")
|| lower.contains("pick up")
|| lower.contains("clean ")
|| lower.starts_with("call ")
|| lower.starts_with("email ")
|| lower.starts_with("fix ")
|| lower.starts_with("check ")
|| lower.starts_with("finish ")
|| lower.starts_with("write ")
|| lower.starts_with("update ")
{
return NoteType::Todo;
}
if lower.starts_with("what if ")
|| lower.starts_with("idea:")
|| lower.contains("could ")
|| lower.contains("maybe ")
|| lower.contains("should we ")
{
return NoteType::Idea;
}
if lower.starts_with("why ")
|| lower.starts_with("how ")
|| lower.starts_with("what ")
|| lower.ends_with('?')
{
return NoteType::Question;
}
NoteType::Note
}

View file

@ -0,0 +1,369 @@
use crate::types::Note;
use anyhow::{Context, Result};
use chrono::{DateTime, Duration, Local, NaiveTime, Utc};
use std::process::Command;
pub struct Scheduler;
impl Scheduler {
pub fn schedule(note: &Note) -> Result<()> {
let fire_time = note.effective_time().context("note has no scheduled time")?;
create_timer(&note.id, fire_time)
}
pub fn cancel(note_id: &str) -> Result<()> {
let timer_name = timer_unit_name(note_id);
let service_name = service_unit_name(note_id);
stop_unit(&timer_name)?;
disable_unit(&timer_name)?;
stop_unit(&service_name)?;
Ok(())
}
pub fn snooze(note: &mut Note, snooze_until: DateTime<Utc>) -> Result<()> {
Self::cancel(&note.id).ok();
note.snoozed_until = Some(snooze_until);
create_timer(&note.id, snooze_until)
}
pub fn fire(note: &Note, missed_grace_minutes: i64) -> bool {
let now = Utc::now();
if let Some(t) = note.effective_time() {
let diff = now.signed_duration_since(t);
if diff > Duration::minutes(missed_grace_minutes) {
tracing::info!("reminder {} missed ({}m ago), skipping", note.id, diff.num_minutes());
return false;
}
}
true
}
pub fn next_recurrence(note: &Note, default_morning: &str) -> Option<DateTime<Utc>> {
let rrule = note.rrule.as_ref()?;
parse_next_from_rrule(rrule.as_str(), default_morning)
}
}
fn timer_unit_name(id: &str) -> String {
format!("breadpad-reminder-{}.timer", id)
}
fn service_unit_name(id: &str) -> String {
format!("breadpad-reminder-{}.service", id)
}
fn create_timer(id: &str, fire_time: DateTime<Utc>) -> Result<()> {
// Convert to local time for systemd OnCalendar
let local: chrono::DateTime<Local> = fire_time.with_timezone(&Local);
let on_calendar = local.format("%Y-%m-%d %H:%M:%S").to_string();
let timer_name = timer_unit_name(id);
// Use systemd-run to create both service + timer as a transient unit
let status = Command::new("systemd-run")
.arg("--user")
.arg("--unit")
.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("fire")
.arg(id)
.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);
Ok(())
}
fn stop_unit(unit: &str) -> Result<()> {
Command::new("systemctl")
.args(["--user", "stop", unit])
.status()
.context("systemctl stop")?;
Ok(())
}
fn disable_unit(unit: &str) -> Result<()> {
Command::new("systemctl")
.args(["--user", "disable", "--now", unit])
.status()
.context("systemctl disable")?;
Ok(())
}
pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> Option<DateTime<Utc>> {
// (see tests module below for coverage)
// Parse RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0 etc.
// We extract FREQ, BYDAY, BYHOUR, BYMINUTE to compute next occurrence.
if rrule_str.trim().is_empty() {
return None;
}
let parts: std::collections::HashMap<&str, &str> = rrule_str
.trim_start_matches("RRULE:")
.split(';')
.filter_map(|part| {
let mut kv = part.splitn(2, '=');
Some((kv.next()?, kv.next()?))
})
.collect();
let freq = parts.get("FREQ")?;
let freq = *freq;
let (default_h, default_m): (u32, u32) = {
let mut it = default_morning.splitn(2, ':');
let h = it.next().and_then(|s| s.parse().ok()).unwrap_or(8);
let m = it.next().and_then(|s| s.parse().ok()).unwrap_or(0);
(h, m)
};
let hour: u32 = parts.get("BYHOUR").and_then(|v| v.parse().ok()).unwrap_or(default_h);
let minute: u32 = parts.get("BYMINUTE").and_then(|v| v.parse().ok()).unwrap_or(default_m);
let now = Local::now();
let fire_time = NaiveTime::from_hms_opt(hour, minute, 0)?;
let next = match freq {
"DAILY" => {
let today = now.date_naive().and_time(fire_time);
if now.naive_local() < today {
today.and_local_timezone(Local).unwrap()
} else {
(now.date_naive() + chrono::Duration::days(1))
.and_time(fire_time)
.and_local_timezone(Local)
.unwrap()
}
}
"WEEKLY" => {
use chrono::Datelike;
let byday = parts.get("BYDAY").unwrap_or(&"MO");
let target_wd = match *byday {
"MO" => chrono::Weekday::Mon,
"TU" => chrono::Weekday::Tue,
"WE" => chrono::Weekday::Wed,
"TH" => chrono::Weekday::Thu,
"FR" => chrono::Weekday::Fri,
"SA" => chrono::Weekday::Sat,
_ => chrono::Weekday::Sun,
};
let days_ahead = (target_wd.num_days_from_monday() as i64
- now.weekday().num_days_from_monday() as i64)
.rem_euclid(7);
let days_ahead = if days_ahead == 0 {
if now.time() < fire_time {
0
} else {
7
}
} else {
days_ahead
};
let target_date =
(now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time);
target_date.and_local_timezone(Local).unwrap()
}
_ => return None,
};
Some(next.with_timezone(&Utc))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{Note, NoteType, RecurrenceRule};
use chrono::{Datelike, Local, Timelike, Utc};
fn reminder(id: &str) -> Note {
let mut n = Note::new("test reminder".into(), NoteType::Reminder, None);
// Override auto-generated id for readable test names
n.id = id.to_string();
n
}
// ---- Scheduler::fire ----
#[test]
fn fire_note_without_time_always_fires() {
let note = reminder("no-time");
// effective_time() is None → fire returns true (no time = no missed check)
assert!(Scheduler::fire(&note, 60));
}
#[test]
fn fire_future_reminder_fires() {
let mut note = reminder("future");
note.time = Some(Utc::now() + Duration::minutes(10));
assert!(Scheduler::fire(&note, 60));
}
#[test]
fn fire_recent_past_reminder_fires() {
let mut note = reminder("recent");
note.time = Some(Utc::now() - Duration::minutes(5));
// 5 min ago, grace = 60 min → should fire
assert!(Scheduler::fire(&note, 60));
}
#[test]
fn fire_exactly_at_grace_boundary_fires() {
let mut note = reminder("boundary");
// Use 59 min (well inside the 60-min grace) to avoid a race with wall time
note.time = Some(Utc::now() - Duration::minutes(59));
assert!(Scheduler::fire(&note, 60));
}
#[test]
fn fire_missed_reminder_beyond_grace_skips() {
let mut note = reminder("missed");
note.time = Some(Utc::now() - Duration::minutes(90));
// 90 min ago, grace = 60 → should NOT fire
assert!(!Scheduler::fire(&note, 60));
}
#[test]
fn fire_uses_snoozed_until_if_set() {
let mut note = reminder("snoozed");
note.time = Some(Utc::now() - Duration::hours(5)); // original would be missed
note.snoozed_until = Some(Utc::now() - Duration::minutes(5)); // snooze is recent
// effective_time = snoozed_until (5 min ago), grace = 60 → fires
assert!(Scheduler::fire(&note, 60));
}
#[test]
fn fire_zero_grace_only_fires_future() {
let mut future = reminder("zero-future");
future.time = Some(Utc::now() + Duration::seconds(1));
assert!(Scheduler::fire(&future, 0));
let mut past = reminder("zero-past");
past.time = Some(Utc::now() - Duration::seconds(1));
assert!(!Scheduler::fire(&past, 0));
}
// ---- Scheduler::next_recurrence ----
#[test]
fn next_recurrence_none_without_rrule() {
let note = reminder("no-rrule");
assert!(Scheduler::next_recurrence(&note, "08:00").is_none());
}
#[test]
fn next_recurrence_daily_in_future() {
let mut note = reminder("daily");
note.rrule = Some(RecurrenceRule::new("RRULE:FREQ=DAILY;BYHOUR=8;BYMINUTE=0;BYSECOND=0"));
let t = Scheduler::next_recurrence(&note, "08:00").unwrap();
assert!(t >= Utc::now());
}
#[test]
fn next_recurrence_weekly_is_correct_weekday() {
let mut note = reminder("weekly-mon");
note.rrule = Some(RecurrenceRule::new("RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0"));
let t = Scheduler::next_recurrence(&note, "08:00").unwrap();
let local: chrono::DateTime<Local> = t.into();
assert_eq!(local.weekday(), chrono::Weekday::Mon);
}
// ---- parse_next_from_rrule ----
#[test]
fn daily_rrule_next_is_in_future() {
let t = parse_next_from_rrule("RRULE:FREQ=DAILY;BYHOUR=14;BYMINUTE=0;BYSECOND=0", "08:00");
assert!(t.is_some());
assert!(t.unwrap() >= Utc::now());
}
#[test]
fn daily_rrule_correct_hour() {
let t = parse_next_from_rrule("RRULE:FREQ=DAILY;BYHOUR=14;BYMINUTE=30;BYSECOND=0", "08:00").unwrap();
let local: chrono::DateTime<Local> = t.into();
assert_eq!(local.hour(), 14);
assert_eq!(local.minute(), 30);
}
#[test]
fn daily_rrule_defaults_hour_from_morning() {
// No BYHOUR in rrule — should fall back to default_morning
let t = parse_next_from_rrule("RRULE:FREQ=DAILY", "07:15").unwrap();
let local: chrono::DateTime<Local> = t.into();
assert_eq!(local.hour(), 7);
assert_eq!(local.minute(), 15);
}
#[test]
fn weekly_monday_is_monday() {
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
let local: chrono::DateTime<Local> = t.into();
assert_eq!(local.weekday(), chrono::Weekday::Mon);
assert!(t >= Utc::now());
}
#[test]
fn weekly_friday_is_friday() {
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=FR;BYHOUR=12;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
let local: chrono::DateTime<Local> = t.into();
assert_eq!(local.weekday(), chrono::Weekday::Fri);
}
#[test]
fn weekly_wednesday_correct_hour() {
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=WE;BYHOUR=15;BYMINUTE=45;BYSECOND=0", "08:00").unwrap();
let local: chrono::DateTime<Local> = t.into();
assert_eq!(local.hour(), 15);
assert_eq!(local.minute(), 45);
}
#[test]
fn weekly_saturday_is_saturday() {
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=SA;BYHOUR=10;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
let local: chrono::DateTime<Local> = t.into();
assert_eq!(local.weekday(), chrono::Weekday::Sat);
}
#[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();
let local: chrono::DateTime<Local> = t.into();
assert_eq!(local.weekday(), chrono::Weekday::Sun);
}
#[test]
fn unknown_freq_returns_none() {
assert!(parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none());
}
#[test]
fn empty_rrule_string_returns_none() {
assert!(parse_next_from_rrule("", "08:00").is_none());
}
#[test]
fn rrule_without_rrule_prefix_still_parses() {
// trim_start_matches("RRULE:") handles the prefix; without it the key would be "RRULE:FREQ" which won't match
// just verify we don't panic
let _ = parse_next_from_rrule("FREQ=DAILY;BYHOUR=8;BYMINUTE=0", "08:00");
}
// ---- unit name helpers ----
#[test]
fn timer_unit_name_format() {
assert_eq!(timer_unit_name("abc123"), "breadpad-reminder-abc123.timer");
}
#[test]
fn service_unit_name_format() {
assert_eq!(service_unit_name("abc123"), "breadpad-reminder-abc123.service");
}
}

View file

@ -0,0 +1,204 @@
use crate::calendar::{caldav_uid, CalDavClient};
use crate::config::CalendarConfig;
use crate::types::Note;
use anyhow::{Context, Result};
use chrono::{Duration, Utc};
use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
#[derive(Clone)]
pub struct Store {
notes_path: PathBuf,
archive_path: PathBuf,
calendar: Option<CalendarConfig>,
}
impl Store {
pub fn new() -> Result<Self> {
let data_dir = dirs::data_local_dir()
.context("no XDG data dir")?
.join("breadpad");
Self::from_dir(&data_dir)
}
pub fn from_dir(dir: &Path) -> Result<Self> {
fs::create_dir_all(dir)?;
Ok(Store {
notes_path: dir.join("notes.jsonl"),
archive_path: dir.join("archive.jsonl"),
calendar: None,
})
}
pub fn with_calendar(mut self, cfg: CalendarConfig) -> Self {
self.calendar = Some(cfg);
self
}
pub fn load_all(&self) -> Result<Vec<Note>> {
self.load_from(&self.notes_path)
}
pub fn load_archive(&self) -> Result<Vec<Note>> {
self.load_from(&self.archive_path)
}
fn load_from(&self, path: &Path) -> Result<Vec<Note>> {
if !path.exists() {
return Ok(Vec::new());
}
let file = fs::File::open(path)?;
let reader = BufReader::new(file);
let mut notes = Vec::new();
for (i, line) in reader.lines().enumerate() {
let line = line?;
let trimmed = line.trim();
if trimmed.is_empty() {
continue;
}
match serde_json::from_str::<Note>(trimmed) {
Ok(note) => notes.push(note),
Err(e) => tracing::warn!("skipping malformed note at line {}: {}", i + 1, e),
}
}
Ok(notes)
}
pub fn save_note(&self, note: &Note) -> Result<()> {
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.notes_path)?;
let line = serde_json::to_string(note)?;
writeln!(file, "{}", line)?;
if let Some(cal_cfg) = &self.calendar {
if cal_cfg.enabled && (note.time.is_some() || note.rrule.is_some()) {
spawn_caldav_push(note.clone(), cal_cfg.clone());
}
}
Ok(())
}
pub fn update_note(&self, updated: &Note) -> Result<()> {
self.rewrite_notes(|note| {
if note.id == updated.id {
updated.clone()
} else {
note
}
})
}
pub fn delete_note(&self, id: &str) -> Result<()> {
let all = self.load_all()?;
let (to_delete, keep): (Vec<Note>, Vec<Note>) = all.into_iter().partition(|n| n.id == id);
self.write_all(&self.notes_path, &keep)?;
if let Some(cal_cfg) = &self.calendar {
if cal_cfg.enabled {
if let Some(note) = to_delete.into_iter().next() {
spawn_caldav_delete(caldav_uid(&note), cal_cfg.clone());
}
}
}
Ok(())
}
fn rewrite_notes<F>(&self, mut f: F) -> Result<()>
where
F: FnMut(Note) -> Note,
{
let notes: Vec<Note> = self.load_all()?.into_iter().map(|n| f(n)).collect();
self.write_all(&self.notes_path, &notes)
}
fn write_all(&self, path: &Path, notes: &[Note]) -> Result<()> {
let tmp_path = path.with_extension("tmp");
{
let mut file = fs::File::create(&tmp_path)?;
for note in notes {
let line = serde_json::to_string(note)?;
writeln!(file, "{}", line)?;
}
file.flush()?;
}
fs::rename(&tmp_path, path)?;
Ok(())
}
pub fn rotate_archive(&self, archive_after_days: i64) -> Result<usize> {
let cutoff = Utc::now() - Duration::days(archive_after_days);
let notes = self.load_all()?;
let (to_archive, keep): (Vec<Note>, Vec<Note>) = notes
.into_iter()
.partition(|n| n.done && n.completed.map_or(false, |c| c < cutoff));
if to_archive.is_empty() {
return Ok(0);
}
let count = to_archive.len();
let mut archive_file = OpenOptions::new()
.create(true)
.append(true)
.open(&self.archive_path)?;
for note in &to_archive {
writeln!(archive_file, "{}", serde_json::to_string(note)?)?;
}
self.write_all(&self.notes_path, &keep)?;
Ok(count)
}
pub fn get_by_id(&self, id: &str) -> Result<Option<Note>> {
Ok(self.load_all()?.into_iter().find(|n| n.id == id))
}
}
fn spawn_caldav_push(note: Note, cfg: CalendarConfig) {
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
tracing::warn!("CalDAV: failed to create runtime: {}", e);
return;
}
};
rt.block_on(async {
let client = CalDavClient::new(cfg);
match client.push_event(&note).await {
Ok(uid) => tracing::info!("CalDAV: pushed note {} as {}", note.id, uid),
Err(e) => tracing::warn!("CalDAV: push failed for note {}: {}", note.id, e),
}
});
});
}
fn spawn_caldav_delete(uid: String, cfg: CalendarConfig) {
std::thread::spawn(move || {
let rt = match tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()
{
Ok(rt) => rt,
Err(e) => {
tracing::warn!("CalDAV: failed to create runtime: {}", e);
return;
}
};
rt.block_on(async {
let client = CalDavClient::new(cfg);
match client.delete_event(&uid).await {
Ok(()) => tracing::info!("CalDAV: deleted event {}", uid),
Err(e) => tracing::warn!("CalDAV: delete failed for {}: {}", uid, e),
}
});
});
}

View file

@ -0,0 +1,447 @@
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
#[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,
}
// 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(),
}
}
pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String {
let mut css = format!(
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};
window {{
background-color: @bg;
color: @fg;
border-radius: 12px;
}}
.popup-entry {{
background: @bg;
color: @fg;
border: 2px solid @blue;
border-radius: 8px;
padding: 12px 16px;
font-size: 16px;
caret-color: @fg;
}}
.popup-entry:focus {{
outline: none;
border-color: @teal;
}}
.type-chip {{
background: @overlay;
color: @fg;
border-radius: 999px;
padding: 2px 10px;
font-size: 12px;
margin: 2px;
}}
.type-chip.active {{
background: @blue;
color: @bg;
}}
.confirm-button {{
background: @green;
color: @bg;
border: none;
border-radius: 8px;
padding: 8px 16px;
font-weight: bold;
}}
.note-card {{
background: shade(@bg, 1.1);
border-radius: 8px;
padding: 12px;
margin: 4px 8px;
border-left: 3px solid @blue;
}}
.note-card:hover {{
background: shade(@bg, 1.2);
}}
.search-entry {{
background: shade(@bg, 1.1);
color: @fg;
border: 1px solid @overlay;
border-radius: 8px;
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);
}
.sidebar-row {
padding: 6px 12px;
font-size: 14px;
}
.sidebar-row:hover:not(:selected) {
background: shade(@bg, 1.08);
}
.sidebar-row:selected {
background: @blue;
color: @bg;
}
.sidebar-section-label {
color: alpha(@fg, 0.4);
font-size: 10px;
font-weight: bold;
padding: 10px 14px 2px 14px;
letter-spacing: 1px;
}
.action-btn {
background: transparent;
border: none;
border-radius: 6px;
padding: 2px 7px;
min-width: 28px;
min-height: 28px;
font-size: 14px;
}
.action-btn:hover {
background: shade(@bg, 1.3);
}
.done-btn { color: @green; }
.done-btn:hover { background: alpha(@green, 0.15); }
.edit-btn { color: @blue; }
.edit-btn:hover { background: alpha(@blue, 0.15); }
.danger-btn { color: @red; }
.danger-btn:hover { background: alpha(@red, 0.15); }
.note-card-todo { border-left-color: @green; }
.note-card-reminder { border-left-color: @yellow; }
.note-card-idea { border-left-color: @pink; }
.note-card-question { border-left-color: @teal; }
.note-card-note { border-left-color: @blue; }
entry {
background: shade(@bg, 1.1);
color: @fg;
border: 1px solid @overlay;
border-radius: 6px;
caret-color: @fg;
padding: 5px 10px;
}
entry:focus {
border-color: @blue;
outline: none;
}
"#);
if let Some(extra) = user_css {
css.push('\n');
css.push_str(extra);
}
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);
}
}
#[test]
fn css_contains_window_rule() {
let css = build_css(&Palette::default(), None);
assert!(css.contains("window {"));
assert!(css.contains("background-color: @bg"));
}
#[test]
fn css_contains_popup_entry_class() {
let css = build_css(&Palette::default(), None);
assert!(css.contains(".popup-entry {"), "css: {}", &css[300..600]);
}
#[test]
fn css_contains_note_card_class() {
let css = build_css(&Palette::default(), None);
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; }";
let css = build_css(&Palette::default(), Some(user));
assert!(css.contains(".my-override { color: hotpink; }"));
}
#[test]
fn css_without_user_css_omits_user_rules() {
let css = build_css(&Palette::default(), None);
assert!(!css.contains(".my-override"));
}
#[test]
fn css_reflects_custom_palette_colors() {
let mut p = Palette::default();
p.background = "#deadbe".into();
p.color4 = "#cafe00".into();
let css = build_css(&p, None);
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('#'));
}
}

View file

@ -0,0 +1,404 @@
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::fmt;
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum NoteType {
Todo,
Reminder,
Idea,
Note,
Question,
#[serde(untagged)]
Tag(String),
}
impl NoteType {
pub fn from_str(s: &str) -> Self {
match s.to_lowercase().as_str() {
"todo" => NoteType::Todo,
"reminder" => NoteType::Reminder,
"idea" => NoteType::Idea,
"note" => NoteType::Note,
"question" => NoteType::Question,
other => NoteType::Tag(other.to_string()),
}
}
pub fn as_str(&self) -> &str {
match self {
NoteType::Todo => "todo",
NoteType::Reminder => "reminder",
NoteType::Idea => "idea",
NoteType::Note => "note",
NoteType::Question => "question",
NoteType::Tag(s) => s.as_str(),
}
}
pub fn all_builtin() -> &'static [&'static str] {
&["todo", "reminder", "idea", "note", "question"]
}
}
impl fmt::Display for NoteType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(self.as_str())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct RecurrenceRule(pub String);
impl RecurrenceRule {
pub fn new(rrule: impl Into<String>) -> Self {
RecurrenceRule(rrule.into())
}
pub fn as_str(&self) -> &str {
&self.0
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Note {
pub id: String,
pub body: String,
#[serde(rename = "type")]
pub note_type: NoteType,
pub time: Option<DateTime<Utc>>,
pub rrule: Option<RecurrenceRule>,
pub done: bool,
pub workspace: Option<String>,
pub created: DateTime<Utc>,
pub snoozed_until: Option<DateTime<Utc>>,
pub completed: Option<DateTime<Utc>>,
#[serde(default)]
pub tags: Vec<String>,
#[serde(default)]
pub caldav_uid: Option<String>,
}
impl Note {
pub fn new(body: String, note_type: NoteType, workspace: Option<String>) -> Self {
Note {
id: uuid::Uuid::new_v4()
.to_string()
.chars()
.take(6)
.collect(),
body,
note_type,
time: None,
rrule: None,
done: false,
workspace,
created: Utc::now(),
snoozed_until: None,
completed: None,
tags: Vec::new(),
caldav_uid: None,
}
}
pub fn effective_time(&self) -> Option<DateTime<Utc>> {
self.snoozed_until.or(self.time)
}
pub fn mark_done(&mut self) {
self.done = true;
self.completed = Some(Utc::now());
}
}
#[derive(Debug, Clone)]
pub struct ClassificationResult {
pub note_type: NoteType,
pub time: Option<DateTime<Utc>>,
pub rrule: Option<RecurrenceRule>,
pub body: String,
pub confidence: f32,
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::{Duration, Utc};
// ---- NoteType ----
#[test]
fn note_type_from_str_all_builtins() {
assert_eq!(NoteType::from_str("todo"), NoteType::Todo);
assert_eq!(NoteType::from_str("reminder"), NoteType::Reminder);
assert_eq!(NoteType::from_str("idea"), NoteType::Idea);
assert_eq!(NoteType::from_str("note"), NoteType::Note);
assert_eq!(NoteType::from_str("question"), NoteType::Question);
}
#[test]
fn note_type_from_str_case_insensitive() {
assert_eq!(NoteType::from_str("TODO"), NoteType::Todo);
assert_eq!(NoteType::from_str("Reminder"), NoteType::Reminder);
assert_eq!(NoteType::from_str("IDEA"), NoteType::Idea);
assert_eq!(NoteType::from_str("Note"), NoteType::Note);
assert_eq!(NoteType::from_str("QUESTION"), NoteType::Question);
}
#[test]
fn note_type_custom_tag_preserved() {
let nt = NoteType::from_str("standup");
assert!(matches!(nt, NoteType::Tag(ref s) if s == "standup"));
assert_eq!(nt.as_str(), "standup");
}
#[test]
fn note_type_empty_string_becomes_tag() {
let nt = NoteType::from_str("");
assert!(matches!(nt, NoteType::Tag(ref s) if s.is_empty()));
}
#[test]
fn note_type_all_builtin_round_trip() {
for &s in NoteType::all_builtin() {
assert_eq!(NoteType::from_str(s).as_str(), s, "round-trip failed for '{}'", s);
}
}
#[test]
fn note_type_display_matches_as_str() {
for &s in NoteType::all_builtin() {
let nt = NoteType::from_str(s);
assert_eq!(nt.to_string(), nt.as_str());
}
let tag = NoteType::Tag("weekly".into());
assert_eq!(tag.to_string(), "weekly");
}
#[test]
fn note_type_serializes_lowercase() {
let json = serde_json::to_string(&NoteType::Todo).unwrap();
assert_eq!(json, r#""todo""#);
let json = serde_json::to_string(&NoteType::Reminder).unwrap();
assert_eq!(json, r#""reminder""#);
}
#[test]
fn note_type_tag_serializes_as_string() {
let json = serde_json::to_string(&NoteType::Tag("meeting".into())).unwrap();
assert_eq!(json, r#""meeting""#);
}
#[test]
fn note_type_deserializes_from_string() {
let nt: NoteType = serde_json::from_str(r#""todo""#).unwrap();
assert_eq!(nt, NoteType::Todo);
let nt: NoteType = serde_json::from_str(r#""question""#).unwrap();
assert_eq!(nt, NoteType::Question);
}
#[test]
fn note_type_unknown_deserializes_as_tag() {
let nt: NoteType = serde_json::from_str(r#""standup""#).unwrap();
assert_eq!(nt, NoteType::Tag("standup".into()));
}
// ---- RecurrenceRule ----
#[test]
fn recurrence_rule_new_stores_value() {
let r = RecurrenceRule::new("RRULE:FREQ=DAILY");
assert_eq!(r.as_str(), "RRULE:FREQ=DAILY");
}
#[test]
fn recurrence_rule_serde_round_trip() {
let r = RecurrenceRule::new("RRULE:FREQ=WEEKLY;BYDAY=FR");
let json = serde_json::to_string(&r).unwrap();
let decoded: RecurrenceRule = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.as_str(), r.as_str());
}
#[test]
fn recurrence_rule_from_string_owned() {
let s = String::from("RRULE:FREQ=MONTHLY");
let r = RecurrenceRule::new(s);
assert_eq!(r.as_str(), "RRULE:FREQ=MONTHLY");
}
// ---- Note::new ----
#[test]
fn note_new_defaults() {
let note = Note::new("body text".into(), NoteType::Note, Some("3".into()));
assert_eq!(note.body, "body text");
assert_eq!(note.note_type, NoteType::Note);
assert_eq!(note.workspace, Some("3".into()));
assert!(!note.done);
assert!(note.completed.is_none());
assert!(note.time.is_none());
assert!(note.rrule.is_none());
assert!(note.snoozed_until.is_none());
assert!(note.tags.is_empty());
}
#[test]
fn note_new_without_workspace() {
let note = Note::new("x".into(), NoteType::Idea, None);
assert!(note.workspace.is_none());
}
#[test]
fn note_id_is_six_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);
}
}
#[test]
fn note_id_is_unique() {
let ids: Vec<String> = (0..100).map(|_| Note::new("x".into(), NoteType::Note, None).id).collect();
let unique: std::collections::HashSet<&str> = ids.iter().map(|s| s.as_str()).collect();
assert_eq!(unique.len(), 100, "found duplicate IDs in 100 notes");
}
#[test]
fn note_created_is_recent() {
let before = Utc::now();
let note = Note::new("x".into(), NoteType::Note, None);
let after = Utc::now();
assert!(note.created >= before && note.created <= after);
}
// ---- Note::mark_done ----
#[test]
fn note_mark_done_sets_done_and_completed() {
let before = Utc::now();
let mut note = Note::new("task".into(), NoteType::Todo, None);
note.mark_done();
let after = Utc::now();
assert!(note.done);
let completed = note.completed.expect("completed should be set after mark_done");
assert!(completed >= before && completed <= after);
}
#[test]
fn note_mark_done_twice_updates_timestamp() {
let mut note = Note::new("task".into(), NoteType::Todo, None);
note.mark_done();
let first = note.completed.unwrap();
std::thread::sleep(std::time::Duration::from_millis(2));
note.mark_done();
let second = note.completed.unwrap();
assert!(second >= first);
}
// ---- Note::effective_time ----
#[test]
fn effective_time_none_when_nothing_set() {
let note = Note::new("x".into(), NoteType::Note, None);
assert_eq!(note.effective_time(), None);
}
#[test]
fn effective_time_returns_time_when_no_snooze() {
let mut note = Note::new("x".into(), NoteType::Reminder, None);
let t = Utc::now() + Duration::hours(1);
note.time = Some(t);
assert_eq!(note.effective_time(), Some(t));
}
#[test]
fn effective_time_prefers_snoozed_over_time() {
let mut note = Note::new("x".into(), NoteType::Reminder, None);
let original = Utc::now() + Duration::hours(1);
let snoozed = Utc::now() + Duration::hours(2);
note.time = Some(original);
note.snoozed_until = Some(snoozed);
assert_eq!(note.effective_time(), Some(snoozed));
}
#[test]
fn effective_time_snoozed_without_original() {
let mut note = Note::new("x".into(), NoteType::Reminder, None);
let snoozed = Utc::now() + Duration::hours(3);
note.snoozed_until = Some(snoozed);
assert_eq!(note.effective_time(), Some(snoozed));
}
// ---- Note serde ----
#[test]
fn note_serde_round_trip_minimal() {
let note = Note::new("buy milk".into(), NoteType::Todo, None);
let json = serde_json::to_string(&note).unwrap();
let decoded: Note = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.id, note.id);
assert_eq!(decoded.body, note.body);
assert_eq!(decoded.note_type, note.note_type);
assert!(!decoded.done);
assert!(decoded.time.is_none());
assert!(decoded.rrule.is_none());
assert!(decoded.tags.is_empty());
}
#[test]
fn note_serde_with_rrule_and_workspace() {
let mut note = Note::new("standup".into(), NoteType::Reminder, Some("1".into()));
note.rrule = Some(RecurrenceRule::new("RRULE:FREQ=WEEKLY;BYDAY=MO"));
let json = serde_json::to_string(&note).unwrap();
let decoded: Note = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.workspace, Some("1".into()));
assert_eq!(decoded.rrule.unwrap().as_str(), "RRULE:FREQ=WEEKLY;BYDAY=MO");
}
#[test]
fn note_serde_done_with_completed() {
let mut note = Note::new("chore".into(), NoteType::Todo, None);
note.mark_done();
let json = serde_json::to_string(&note).unwrap();
let decoded: Note = serde_json::from_str(&json).unwrap();
assert!(decoded.done);
assert!(decoded.completed.is_some());
}
#[test]
fn note_serde_with_tags() {
let mut note = Note::new("x".into(), NoteType::Note, None);
note.tags = vec!["work".into(), "urgent".into()];
let json = serde_json::to_string(&note).unwrap();
let decoded: Note = serde_json::from_str(&json).unwrap();
assert_eq!(decoded.tags, vec!["work", "urgent"]);
}
#[test]
fn note_json_uses_type_key() {
let note = Note::new("x".into(), NoteType::Reminder, None);
let json = serde_json::to_string(&note).unwrap();
assert!(json.contains(r#""type":"reminder""#), "json: {}", json);
}
#[test]
fn note_json_missing_tags_defaults_to_empty() {
// Older stored notes may not have tags field
let json = r#"{"id":"abc123","body":"test","type":"note","time":null,"rrule":null,"done":false,"workspace":null,"created":"2026-01-01T00:00:00Z","snoozed_until":null,"completed":null}"#;
let note: Note = serde_json::from_str(json).unwrap();
assert!(note.tags.is_empty());
}
#[test]
fn note_full_jsonl_example_from_readme() {
let line = r#"{"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}"#;
let note: Note = serde_json::from_str(line).unwrap();
assert_eq!(note.id, "a1b2c3");
assert_eq!(note.note_type, NoteType::Reminder);
assert_eq!(note.workspace, Some("1".into()));
assert!(!note.done);
assert!(note.time.is_some());
}
}

View file

@ -0,0 +1,83 @@
use breadpad_shared::classifier::Classifier;
use breadpad_shared::types::NoteType;
use chrono::Timelike;
fn cl() -> Classifier {
Classifier::load("auto", "08:00")
}
#[test]
fn active_provider_is_cpu() {
// QNN and Vulkan EPs are not compiled in; CPU is always the fallback.
let c = cl();
assert_eq!(c.active_provider, breadpad_shared::classifier::ExecutionProvider::Cpu);
}
#[test]
fn classify_falls_back_to_rule_based() {
let mut c = cl();
let r = c.classify("buy milk");
assert_eq!(r.note_type, NoteType::Todo);
assert!(r.time.is_none());
}
#[test]
fn classify_todo_via_fallback() {
let mut c = cl();
assert_eq!(c.classify("fix the segfault").note_type, NoteType::Todo);
}
#[test]
fn classify_reminder_via_fallback() {
let mut c = cl();
let r = c.classify("call mum at 6pm");
assert_eq!(r.note_type, NoteType::Reminder);
assert!(r.time.is_some());
}
#[test]
fn classify_idea_via_fallback() {
let mut c = cl();
assert_eq!(c.classify("what if we added a calendar view").note_type, NoteType::Idea);
}
#[test]
fn classify_question_via_fallback() {
let mut c = cl();
assert_eq!(c.classify("why does this fail?").note_type, NoteType::Question);
}
#[test]
fn classify_note_via_fallback() {
let mut c = cl();
assert_eq!(c.classify("meeting went well today").note_type, NoteType::Note);
}
#[test]
fn classify_recurrence_via_fallback() {
let mut c = cl();
let r = c.classify("standup every monday at 9am");
assert!(r.rrule.is_some(), "expected rrule from fallback parser");
assert_eq!(r.note_type, NoteType::Reminder);
}
#[test]
fn classify_custom_morning_time() {
let mut c = Classifier::load("auto", "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();
assert_eq!(local.hour(), 7);
assert_eq!(local.minute(), 15);
}
#[test]
fn model_path_points_to_expected_location() {
let c = cl();
assert!(
c.model_path.to_str().unwrap().contains("breadpad"),
"model path: {:?}",
c.model_path
);
assert!(c.model_path.to_str().unwrap().ends_with("classifier.onnx"));
}

View file

@ -0,0 +1,198 @@
use breadpad_shared::config::{Config, ModelConfig, RemindersConfig, Settings};
use tempfile::TempDir;
// ---- Default values ----
#[test]
fn default_settings() {
let s = Settings::default();
assert_eq!(s.default_type, "note");
assert!(s.workspace_tag);
assert_eq!(s.archive_after_days, 30);
}
#[test]
fn default_snooze_options_contains_all_three() {
let s = Settings::default();
assert!(s.snooze_options.iter().any(|x| x == "15m"));
assert!(s.snooze_options.iter().any(|x| x == "1h"));
assert!(s.snooze_options.iter().any(|x| x == "tomorrow_morning"));
}
#[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"));
}
#[test]
fn default_reminders_config() {
let r = RemindersConfig::default();
assert_eq!(r.default_morning, "08:00");
assert_eq!(r.missed_grace_minutes, 60);
}
#[test]
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");
}
// ---- TOML deserialization ----
#[test]
fn full_config_from_toml() {
let toml = r#"
[settings]
default_type = "todo"
workspace_tag = false
snooze_options = ["15m", "2h"]
archive_after_days = 7
[model]
path = "/tmp/classifier.onnx"
tokenizer = "/tmp/tokenizer.json"
execution_provider = "cpu"
[reminders]
default_morning = "07:30"
missed_grace_minutes = 30
"#;
let cfg: Config = toml::from_str(toml).unwrap();
assert_eq!(cfg.settings.default_type, "todo");
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.reminders.default_morning, "07:30");
assert_eq!(cfg.reminders.missed_grace_minutes, 30);
}
#[test]
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");
}
#[test]
fn partial_toml_only_settings_section() {
let toml = r#"
[settings]
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]
fn default_config_serializes_to_valid_toml() {
let cfg = Config::default();
let serialized = toml::to_string_pretty(&cfg).unwrap();
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);
}
#[test]
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;
let toml = toml::to_string_pretty(&cfg).unwrap();
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);
}
// ---- Config::save + Config::load ----
#[test]
fn save_and_load_round_trip() {
let dir = TempDir::new().unwrap();
let config_path = dir.path().join("breadpad.toml");
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() {
let toml = r#"
[settings]
default_type = "note"
workspace_tag = true
snooze_options = ["15m", "1h", "tomorrow_morning"]
archive_after_days = 30
[model]
path = "~/.local/share/breadpad/model/classifier.onnx"
tokenizer = "~/.local/share/breadpad/model/tokenizer.json"
execution_provider = "auto"
[reminders]
default_morning = "08:00"
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);
}

View file

@ -0,0 +1,236 @@
// End-to-end pipeline tests: classify → save → reload
//
// These mirror what breadpad (capture) and breadman (display) do in production.
// Both apps share the same Store path; we prove here that a note typed in the
// popup survives the classify+save step and is visible to a fresh store handle,
// exactly as breadman would see it on startup.
use breadpad_shared::classifier::Classifier;
use breadpad_shared::store::Store;
use breadpad_shared::types::{Note, NoteType};
use chrono::Timelike;
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 result = classifier.classify(text);
let mut note = Note::new(text.into(), user_type.clone(), None);
// When the user left the type at the default, let the classifier override it.
if user_type == NoteType::from_str("note") {
note.note_type = result.note_type;
}
note.time = result.time;
note.rrule = result.rrule;
note.body = result.body;
store.save_note(&note).unwrap();
note
}
fn setup() -> (TempDir, Store) {
let dir = TempDir::new().unwrap();
let store = Store::from_dir(dir.path()).unwrap();
(dir, store)
}
// Open a second Store handle pointing at the same directory — this simulates
// breadman reading from the path that breadpad wrote to.
fn breadman_store(dir: &TempDir) -> Store {
Store::from_dir(dir.path()).unwrap()
}
// ---- basic round-trip ----
#[test]
fn todo_note_appears_in_store() {
let (dir, store) = setup();
let saved = capture(&store, "buy groceries", NoteType::from_str("note"));
let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].id, saved.id);
assert_eq!(notes[0].note_type, NoteType::Todo);
assert_eq!(notes[0].body, "buy groceries");
assert!(!notes[0].done);
}
#[test]
fn idea_note_appears_in_store() {
let (dir, store) = setup();
capture(&store, "what if we added dark mode", NoteType::from_str("note"));
let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].note_type, NoteType::Idea);
}
#[test]
fn question_note_appears_in_store() {
let (dir, store) = setup();
capture(&store, "why does the cache miss on cold start?", NoteType::from_str("note"));
let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].note_type, NoteType::Question);
}
#[test]
fn plain_note_appears_in_store() {
let (dir, store) = setup();
capture(&store, "retro went well today", NoteType::from_str("note"));
let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].note_type, NoteType::Note);
}
// ---- reminder with time ----
#[test]
fn reminder_has_time_set() {
let (dir, store) = setup();
capture(&store, "call mum at 6pm", NoteType::from_str("note"));
let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes[0].note_type, NoteType::Reminder);
assert!(notes[0].time.is_some(), "reminder should have a scheduled time");
let local: chrono::DateTime<chrono::Local> = notes[0].time.unwrap().into();
assert_eq!(local.hour(), 18);
}
#[test]
fn reminder_body_has_time_stripped() {
let (dir, store) = setup();
capture(&store, "call mum at 6pm", NoteType::from_str("note"));
let notes = breadman_store(&dir).load_all().unwrap();
assert!(!notes[0].body.contains("6pm"), "time phrase should be removed from body");
assert!(notes[0].body.contains("call mum"));
}
#[test]
fn in_duration_reminder_has_time() {
let (dir, store) = setup();
capture(&store, "check on the build in 30 minutes", NoteType::from_str("note"));
let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes[0].note_type, NoteType::Reminder);
assert!(notes[0].time.is_some());
}
// ---- recurring reminder ----
#[test]
fn recurring_reminder_has_rrule() {
let (dir, store) = setup();
capture(&store, "standup every monday at 9am", NoteType::from_str("note"));
let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes[0].note_type, NoteType::Reminder);
let rrule = notes[0].rrule.as_ref().expect("should have rrule");
assert!(rrule.as_str().contains("FREQ=WEEKLY"));
assert!(rrule.as_str().contains("BYDAY=MO"));
}
#[test]
fn daily_reminder_has_rrule() {
let (dir, store) = setup();
capture(&store, "drink water every day at 8am", NoteType::from_str("note"));
let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes[0].note_type, NoteType::Reminder);
assert!(notes[0].rrule.as_ref().unwrap().as_str().contains("FREQ=DAILY"));
}
// ---- user-forced type is respected ----
#[test]
fn user_selected_type_overrides_classifier() {
let (dir, store) = setup();
// Text would classify as Todo, but user explicitly chose Idea
capture(&store, "fix the login bug", NoteType::Idea);
let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes[0].note_type, NoteType::Idea, "user chip selection should win over classifier");
}
#[test]
fn user_selected_reminder_overrides_classifier() {
let (dir, store) = setup();
capture(&store, "team meeting notes from today", NoteType::Reminder);
let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes[0].note_type, NoteType::Reminder);
}
// ---- multiple notes all appear ----
#[test]
fn three_notes_all_visible_to_breadman() {
let (dir, store) = setup();
capture(&store, "buy milk", NoteType::from_str("note"));
capture(&store, "what if we rewrote in Zig", NoteType::from_str("note"));
capture(&store, "team standup went well", NoteType::from_str("note"));
let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes.len(), 3);
let types: Vec<NoteType> = notes.iter().map(|n| n.note_type.clone()).collect();
assert!(types.contains(&NoteType::Todo));
assert!(types.contains(&NoteType::Idea));
assert!(types.contains(&NoteType::Note));
}
#[test]
fn notes_written_sequentially_all_survive() {
let (dir, store) = setup();
let n = 10u32;
for i in 0..n {
capture(&store, &format!("note number {}", i), NoteType::Note);
}
let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes.len() as u32, n);
}
// ---- note fields are fully preserved ----
#[test]
fn note_id_is_stable_after_reload() {
let (dir, store) = setup();
let saved = capture(&store, "check the logs", NoteType::Todo);
let notes = breadman_store(&dir).load_all().unwrap();
assert_eq!(notes[0].id, saved.id);
}
#[test]
fn note_created_timestamp_preserved() {
let (dir, store) = setup();
let saved = capture(&store, "morning standup", NoteType::Note);
let notes = breadman_store(&dir).load_all().unwrap();
// Timestamps should be equal within 1 second (serde round-trips subsecond precision)
let diff = (notes[0].created - saved.created).num_seconds().abs();
assert!(diff <= 1, "created timestamp drifted by {}s", diff);
}
// ---- store isolation: two separate runs don't bleed ----
#[test]
fn separate_store_dirs_are_isolated() {
let (dir_a, store_a) = setup();
let (dir_b, store_b) = setup();
capture(&store_a, "note for session A", NoteType::Note);
capture(&store_b, "note for session B", NoteType::Note);
let notes_a = breadman_store(&dir_a).load_all().unwrap();
let notes_b = breadman_store(&dir_b).load_all().unwrap();
assert_eq!(notes_a.len(), 1);
assert_eq!(notes_b.len(), 1);
assert_ne!(notes_a[0].id, notes_b[0].id);
}

View file

@ -0,0 +1,382 @@
use breadpad_shared::store::Store;
use breadpad_shared::types::{Note, NoteType, RecurrenceRule};
use chrono::{Duration, Utc};
use std::fs;
use tempfile::TempDir;
fn mk() -> (TempDir, Store) {
let dir = TempDir::new().unwrap();
let store = Store::from_dir(dir.path()).unwrap();
(dir, store)
}
fn note(body: &str, nt: NoteType) -> Note {
Note::new(body.into(), nt, None)
}
// ---- Empty state ----
#[test]
fn empty_store_loads_empty_vec() {
let (_dir, store) = mk();
let notes = store.load_all().unwrap();
assert!(notes.is_empty());
}
#[test]
fn empty_archive_loads_empty_vec() {
let (_dir, store) = mk();
let archive = store.load_archive().unwrap();
assert!(archive.is_empty());
}
#[test]
fn get_by_id_returns_none_on_empty_store() {
let (_dir, store) = mk();
assert!(store.get_by_id("missing").unwrap().is_none());
}
// ---- save_note + load_all ----
#[test]
fn save_and_load_single() {
let (_dir, store) = mk();
let n = note("buy milk", NoteType::Todo);
store.save_note(&n).unwrap();
let loaded = store.load_all().unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].id, n.id);
assert_eq!(loaded[0].body, "buy milk");
assert_eq!(loaded[0].note_type, NoteType::Todo);
assert!(!loaded[0].done);
}
#[test]
fn save_three_notes_all_loaded() {
let (_dir, store) = mk();
let a = note("alpha", NoteType::Idea);
let b = note("beta", NoteType::Note);
let c = note("gamma", NoteType::Question);
store.save_note(&a).unwrap();
store.save_note(&b).unwrap();
store.save_note(&c).unwrap();
let loaded = store.load_all().unwrap();
assert_eq!(loaded.len(), 3);
let bodies: Vec<&str> = loaded.iter().map(|n| n.body.as_str()).collect();
assert!(bodies.contains(&"alpha"));
assert!(bodies.contains(&"beta"));
assert!(bodies.contains(&"gamma"));
}
#[test]
fn saved_note_preserves_all_fields() {
let (_dir, store) = mk();
let mut n = Note::new("standup".into(), NoteType::Reminder, Some("2".into()));
n.rrule = Some(RecurrenceRule::new("RRULE:FREQ=WEEKLY;BYDAY=MO"));
n.tags = vec!["work".into()];
let t = Utc::now();
n.time = Some(t);
store.save_note(&n).unwrap();
let loaded = store.get_by_id(&n.id).unwrap().unwrap();
assert_eq!(loaded.workspace, Some("2".into()));
assert_eq!(loaded.rrule.unwrap().as_str(), "RRULE:FREQ=WEEKLY;BYDAY=MO");
assert_eq!(loaded.tags, vec!["work"]);
assert!(loaded.time.is_some());
}
// ---- update_note ----
#[test]
fn update_note_changes_body() {
let (_dir, store) = mk();
let n = note("original", NoteType::Note);
store.save_note(&n).unwrap();
let mut updated = n.clone();
updated.body = "updated".into();
store.update_note(&updated).unwrap();
let loaded = store.load_all().unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].body, "updated");
}
#[test]
fn update_note_changes_type() {
let (_dir, store) = mk();
let n = note("task", NoteType::Note);
store.save_note(&n).unwrap();
let mut updated = n.clone();
updated.note_type = NoteType::Todo;
store.update_note(&updated).unwrap();
let loaded = store.get_by_id(&n.id).unwrap().unwrap();
assert_eq!(loaded.note_type, NoteType::Todo);
}
#[test]
fn update_note_does_not_affect_other_notes() {
let (_dir, store) = mk();
let n1 = note("first", NoteType::Note);
let n2 = note("second", NoteType::Todo);
store.save_note(&n1).unwrap();
store.save_note(&n2).unwrap();
let mut updated = n1.clone();
updated.body = "first-updated".into();
store.update_note(&updated).unwrap();
let second = store.get_by_id(&n2.id).unwrap().unwrap();
assert_eq!(second.body, "second");
}
#[test]
fn update_nonexistent_id_leaves_store_intact() {
let (_dir, store) = mk();
let n = note("real", NoteType::Note);
store.save_note(&n).unwrap();
let mut ghost = n.clone();
ghost.id = "ghost1".into();
ghost.body = "ghost".into();
store.update_note(&ghost).unwrap();
let notes = store.load_all().unwrap();
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].body, "real");
}
// ---- mark_done via update ----
#[test]
fn mark_done_persists_through_update() {
let (_dir, store) = mk();
let n = note("finish task", NoteType::Todo);
store.save_note(&n).unwrap();
let mut done = n.clone();
done.mark_done();
store.update_note(&done).unwrap();
let loaded = store.get_by_id(&n.id).unwrap().unwrap();
assert!(loaded.done);
assert!(loaded.completed.is_some());
}
// ---- delete_note ----
#[test]
fn delete_removes_only_target() {
let (_dir, store) = mk();
let keep = note("keep", NoteType::Note);
let del = note("delete me", NoteType::Note);
store.save_note(&keep).unwrap();
store.save_note(&del).unwrap();
store.delete_note(&del.id).unwrap();
let loaded = store.load_all().unwrap();
assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].id, keep.id);
}
#[test]
fn delete_all_leaves_empty_store() {
let (_dir, store) = mk();
let n = note("only note", NoteType::Note);
store.save_note(&n).unwrap();
store.delete_note(&n.id).unwrap();
assert!(store.load_all().unwrap().is_empty());
}
#[test]
fn delete_nonexistent_id_is_noop() {
let (_dir, store) = mk();
let n = note("real note", NoteType::Note);
store.save_note(&n).unwrap();
store.delete_note("no-such-id").unwrap();
assert_eq!(store.load_all().unwrap().len(), 1);
}
// ---- get_by_id ----
#[test]
fn get_by_id_finds_correct_note() {
let (_dir, store) = mk();
let a = note("alpha", NoteType::Idea);
let b = note("beta", NoteType::Idea);
store.save_note(&a).unwrap();
store.save_note(&b).unwrap();
let found = store.get_by_id(&a.id).unwrap().unwrap();
assert_eq!(found.body, "alpha");
}
#[test]
fn get_by_id_returns_none_for_missing() {
let (_dir, store) = mk();
store.save_note(&note("x", NoteType::Note)).unwrap();
assert!(store.get_by_id("nope").unwrap().is_none());
}
// ---- rotate_archive ----
#[test]
fn rotate_archive_moves_old_done_notes() {
let (_dir, store) = mk();
let mut old_done = note("old task", NoteType::Todo);
old_done.done = true;
old_done.completed = Some(Utc::now() - Duration::days(40));
store.save_note(&old_done).unwrap();
let mut recent_done = note("recent task", NoteType::Todo);
recent_done.done = true;
recent_done.completed = Some(Utc::now() - Duration::days(1));
store.save_note(&recent_done).unwrap();
let active = note("active task", NoteType::Todo);
store.save_note(&active).unwrap();
let moved = store.rotate_archive(30).unwrap();
assert_eq!(moved, 1);
let remaining = store.load_all().unwrap();
assert_eq!(remaining.len(), 2);
let remaining_ids: Vec<&str> = remaining.iter().map(|n| n.id.as_str()).collect();
assert!(!remaining_ids.contains(&old_done.id.as_str()), "old note should be archived");
assert!(remaining_ids.contains(&recent_done.id.as_str()));
assert!(remaining_ids.contains(&active.id.as_str()));
}
#[test]
fn rotate_archive_writes_to_archive_file() {
let (_dir, store) = mk();
let mut old = note("archived task", NoteType::Todo);
old.done = true;
old.completed = Some(Utc::now() - Duration::days(35));
store.save_note(&old).unwrap();
store.rotate_archive(30).unwrap();
let archived = store.load_archive().unwrap();
assert_eq!(archived.len(), 1);
assert_eq!(archived[0].id, old.id);
}
#[test]
fn rotate_archive_appends_to_existing_archive() {
let (_dir, store) = mk();
for i in 0..3u32 {
let mut n = note(&format!("old {}", i), NoteType::Todo);
n.done = true;
n.completed = Some(Utc::now() - Duration::days(40));
store.save_note(&n).unwrap();
}
store.rotate_archive(30).unwrap();
// Add more old notes and rotate again
for i in 3..5u32 {
let mut n = note(&format!("old {}", i), NoteType::Todo);
n.done = true;
n.completed = Some(Utc::now() - Duration::days(40));
store.save_note(&n).unwrap();
}
store.rotate_archive(30).unwrap();
let archived = store.load_archive().unwrap();
assert_eq!(archived.len(), 5);
}
#[test]
fn rotate_archive_zero_when_nothing_qualifies() {
let (_dir, store) = mk();
let n = note("active", NoteType::Note);
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_ignores_undone_notes_no_matter_how_old() {
let (_dir, store) = mk();
let mut n = note("old but undone", NoteType::Todo);
n.done = false;
// Set created to far past but not done
n.completed = Some(Utc::now() - Duration::days(100));
store.save_note(&n).unwrap();
assert_eq!(store.rotate_archive(30).unwrap(), 0);
}
// ---- Fault tolerance ----
#[test]
fn malformed_jsonl_line_is_skipped() {
let dir = TempDir::new().unwrap();
let notes_path = dir.path().join("notes.jsonl");
let valid = note("valid note", NoteType::Note);
let valid_line = serde_json::to_string(&valid).unwrap();
fs::write(
&notes_path,
format!("{}\n{{not valid json}}\n{}\n", valid_line, valid_line),
).unwrap();
let store = Store::from_dir(dir.path()).unwrap();
let loaded = store.load_all().unwrap();
// Two valid lines, one bad line skipped
assert_eq!(loaded.len(), 2);
assert!(loaded.iter().all(|n| n.body == "valid note"));
}
#[test]
fn blank_lines_in_jsonl_are_skipped() {
let dir = TempDir::new().unwrap();
let notes_path = dir.path().join("notes.jsonl");
let n = note("hello", NoteType::Note);
let line = serde_json::to_string(&n).unwrap();
fs::write(&notes_path, format!("\n\n{}\n\n", line)).unwrap();
let store = Store::from_dir(dir.path()).unwrap();
let loaded = store.load_all().unwrap();
assert_eq!(loaded.len(), 1);
}
// ---- Atomic write ----
#[test]
fn no_tmp_file_left_after_update() {
let (dir, store) = mk();
let n = note("task", NoteType::Todo);
store.save_note(&n).unwrap();
let mut updated = n.clone();
updated.body = "updated".into();
store.update_note(&updated).unwrap();
let tmp = dir.path().join("notes.tmp");
assert!(!tmp.exists(), ".tmp file should be renamed after write");
}
#[test]
fn update_writes_atomically_via_rename() {
// Verify the file content is consistent after an update (no partial writes visible)
let (_dir, store) = mk();
for i in 0..10u32 {
store.save_note(&note(&format!("note {}", i), NoteType::Note)).unwrap();
}
let first = store.load_all().unwrap()[0].clone();
let mut updated = first.clone();
updated.body = "modified".into();
store.update_note(&updated).unwrap();
let loaded = store.load_all().unwrap();
assert_eq!(loaded.len(), 10);
assert!(loaded.iter().any(|n| n.body == "modified"));
}

20
breadpad-test/Cargo.toml Normal file
View file

@ -0,0 +1,20 @@
[package]
name = "breadpad-test"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[[bin]]
name = "breadpad-test"
path = "src/main.rs"
[dependencies]
breadpad-shared = { path = "../breadpad-shared" }
serde = { workspace = true }
serde_json = { workspace = true }
anyhow = { workspace = true }
chrono = { workspace = true }
clap = { version = "4", features = ["derive"] }
colored = "2"
comfy-table = "7"

506
breadpad-test/corpus.json Normal file
View file

@ -0,0 +1,506 @@
[
{
"input": "buy milk on the way home",
"expected_type": "todo",
"expected_time": null,
"expected_body": "buy milk on the way home",
"expected_rrule": null,
"notes": "plain todo, no time signal"
},
{
"input": "pick up dry cleaning",
"expected_type": "todo",
"expected_time": null,
"expected_body": "pick up dry cleaning",
"expected_rrule": null,
"notes": null
},
{
"input": "fix the leaky tap in the bathroom",
"expected_type": "todo",
"expected_time": null,
"expected_body": "fix the leaky tap in the bathroom",
"expected_rrule": null,
"notes": "starts with 'fix'"
},
{
"input": "write release notes for v0.2",
"expected_type": "todo",
"expected_time": null,
"expected_body": "write release notes for v0.2",
"expected_rrule": null,
"notes": "starts with 'write'"
},
{
"input": "email the accountant about last quarter",
"expected_type": "todo",
"expected_time": null,
"expected_body": "email the accountant about last quarter",
"expected_rrule": null,
"notes": "starts with 'email'"
},
{
"input": "check the breadpad logs for errors",
"expected_type": "todo",
"expected_time": null,
"expected_body": "check the breadpad logs for errors",
"expected_rrule": null,
"notes": "starts with 'check'"
},
{
"input": "finish implementing the notification popup",
"expected_type": "todo",
"expected_time": null,
"expected_body": "finish implementing the notification popup",
"expected_rrule": null,
"notes": "starts with 'finish'"
},
{
"input": "update the workspace dependencies",
"expected_type": "todo",
"expected_time": null,
"expected_body": "update the workspace dependencies",
"expected_rrule": null,
"notes": "starts with 'update'"
},
{
"input": "clean up the old git branches",
"expected_type": "todo",
"expected_time": null,
"expected_body": "clean up the old git branches",
"expected_rrule": null,
"notes": "contains 'clean '"
},
{
"input": "call mum at 7pm",
"expected_type": "reminder",
"expected_time": "19:00",
"expected_body": "call mum",
"expected_rrule": null,
"notes": "12h reminder; body should have time phrase stripped"
},
{
"input": "dentist appointment at 3pm",
"expected_type": "reminder",
"expected_time": "15:00",
"expected_body": "dentist appointment",
"expected_rrule": null,
"notes": null
},
{
"input": "pack calculator in bag at 7pm",
"expected_type": "reminder",
"expected_time": "19:00",
"expected_body": "pack calculator in bag",
"expected_rrule": null,
"notes": "time stripped from middle of sentence"
},
{
"input": "meeting prep at 14:30",
"expected_type": "reminder",
"expected_time": "14:30",
"expected_body": "meeting prep",
"expected_rrule": null,
"notes": "24h time with minutes"
},
{
"input": "check the deploy at 23:00",
"expected_type": "reminder",
"expected_time": "23:00",
"expected_body": "check the deploy",
"expected_rrule": null,
"notes": "24h late-night reminder"
},
{
"input": "lunch at 12pm",
"expected_type": "reminder",
"expected_time": "12:00",
"expected_body": "lunch",
"expected_rrule": null,
"notes": "noon edge case"
},
{
"input": "buy milk at the store",
"expected_type": "todo",
"expected_time": null,
"expected_body": "buy milk at the store",
"expected_rrule": null,
"notes": "'at the store' has no digit — should not be parsed as time"
},
{
"input": "take a break in 20 minutes",
"expected_type": "reminder",
"expected_time": null,
"expected_body": "take a break",
"expected_rrule": null,
"notes": "numeric relative time; expected_time null because offset from now is not assertable"
},
{
"input": "review the PR in 2 hours",
"expected_type": "reminder",
"expected_time": null,
"expected_body": "review the PR",
"expected_rrule": null,
"notes": "relative hours"
},
{
"input": "follow up in 3 days",
"expected_type": "reminder",
"expected_time": null,
"expected_body": "follow up",
"expected_rrule": null,
"notes": "relative days"
},
{
"input": "check on the deployment in an hour",
"expected_type": "reminder",
"expected_time": null,
"expected_body": "check on the deployment",
"expected_rrule": null,
"notes": "word-form duration: 'an hour' = 1h"
},
{
"input": "in a couple of hours remind me to check the oven",
"expected_type": "reminder",
"expected_time": null,
"expected_body": "remind me to check the oven",
"expected_rrule": null,
"notes": "word-form duration: 'a couple of hours' = 2h"
},
{
"input": "in a few hours I need to submit this form",
"expected_type": "reminder",
"expected_time": null,
"expected_body": null,
"expected_rrule": null,
"notes": "word-form duration: 'a few hours' = 3h"
},
{
"input": "in half an hour submit the report",
"expected_type": "reminder",
"expected_time": null,
"expected_body": "submit the report",
"expected_rrule": null,
"notes": "word-form duration: 'half an hour' = 30 min"
},
{
"input": "standup tomorrow morning",
"expected_type": "reminder",
"expected_time": null,
"expected_body": "standup",
"expected_rrule": null,
"notes": "tomorrow morning → tomorrow at 08:00; time not asserted (date-relative)"
},
{
"input": "dentist appointment tomorrow",
"expected_type": "reminder",
"expected_time": null,
"expected_body": "dentist appointment",
"expected_rrule": null,
"notes": "bare 'tomorrow' uses morning default"
},
{
"input": "call family tomorrow evening",
"expected_type": "reminder",
"expected_time": null,
"expected_body": "call family",
"expected_rrule": null,
"notes": "tomorrow evening → tomorrow at 18:00"
},
{
"input": "dentist next monday",
"expected_type": "reminder",
"expected_time": null,
"expected_body": "dentist",
"expected_rrule": null,
"notes": "next weekday; time not asserted (date-relative)"
},
{
"input": "team lunch next friday",
"expected_type": "reminder",
"expected_time": null,
"expected_body": "team lunch",
"expected_rrule": null,
"notes": null
},
{
"input": "tonight put the bins out",
"expected_type": "reminder",
"expected_time": "21:00",
"expected_body": "put the bins out",
"expected_rrule": null,
"notes": "'tonight' anchors to 21:00; word stripped from body"
},
{
"input": "watch the football tonight",
"expected_type": "reminder",
"expected_time": "21:00",
"expected_body": "watch the football",
"expected_rrule": null,
"notes": "'tonight' at end of sentence"
},
{
"input": "this evening water the plants",
"expected_type": "reminder",
"expected_time": "21:00",
"expected_body": "water the plants",
"expected_rrule": null,
"notes": "'this evening' synonym for tonight"
},
{
"input": "call dad tonight at 8pm",
"expected_type": "reminder",
"expected_time": "20:00",
"expected_body": "call dad",
"expected_rrule": null,
"notes": "explicit 'at 8pm' overrides 'tonight'; at_time takes precedence"
},
{
"input": "later today sort out the inbox",
"expected_type": null,
"expected_time": null,
"expected_body": null,
"expected_rrule": null,
"notes": "vague relative time; Tier 1 cannot parse 'later today' — no assertion"
},
{
"input": "every sunday night check the bins",
"expected_type": "reminder",
"expected_time": null,
"expected_body": "check the bins",
"expected_rrule": "BYDAY=SU",
"notes": "weekly recurrence; 'night' stripped from body via morning_evening cleanup"
},
{
"input": "every weekday morning check email",
"expected_type": "reminder",
"expected_time": null,
"expected_body": "check email",
"expected_rrule": "BYDAY=MO,TU,WE,TH,FR",
"notes": "MonFri recurrence; 'morning' maps to default_morning time"
},
{
"input": "every weekday at 9am standup",
"expected_type": "reminder",
"expected_time": null,
"expected_body": "standup",
"expected_rrule": "BYDAY=MO,TU,WE,TH,FR",
"notes": "MonFri recurrence with explicit time"
},
{
"input": "every tuesday at 6pm call dad",
"expected_type": "reminder",
"expected_time": null,
"expected_body": "call dad",
"expected_rrule": "BYDAY=TU",
"notes": "named weekday recurrence with time"
},
{
"input": "every friday at 1pm team lunch",
"expected_type": "reminder",
"expected_time": null,
"expected_body": "team lunch",
"expected_rrule": "BYDAY=FR",
"notes": null
},
{
"input": "every saturday morning review the week",
"expected_type": "reminder",
"expected_time": null,
"expected_body": "review the week",
"expected_rrule": "BYDAY=SA",
"notes": "named weekday recurrence; 'morning' stripped from body"
},
{
"input": "take my meds every day at 8am",
"expected_type": "reminder",
"expected_time": null,
"expected_body": "take my meds",
"expected_rrule": "FREQ=DAILY",
"notes": "daily recurrence with explicit time"
},
{
"input": "every day take vitamins",
"expected_type": "reminder",
"expected_time": null,
"expected_body": "take vitamins",
"expected_rrule": "FREQ=DAILY",
"notes": "daily recurrence; 'every day' at start"
},
{
"input": "retro every week at 4pm",
"expected_type": "reminder",
"expected_time": null,
"expected_body": "retro",
"expected_rrule": "FREQ=WEEKLY",
"notes": "every_week pattern still works after adding every_weekdays"
},
{
"input": "what if breadman had a calendar view",
"expected_type": "idea",
"expected_time": null,
"expected_body": "what if breadman had a calendar view",
"expected_rrule": null,
"notes": "classic what-if idea"
},
{
"input": "idea: dark mode toggle in breadman",
"expected_type": "idea",
"expected_time": null,
"expected_body": null,
"expected_rrule": null,
"notes": "explicit idea: prefix"
},
{
"input": "maybe we could add export to CSV",
"expected_type": "idea",
"expected_time": null,
"expected_body": "maybe we could add export to CSV",
"expected_rrule": null,
"notes": "contains 'maybe'"
},
{
"input": "could the sidebar show counts per type",
"expected_type": "idea",
"expected_time": null,
"expected_body": "could the sidebar show counts per type",
"expected_rrule": null,
"notes": "contains 'could'"
},
{
"input": "should we add a dark mode to breadman",
"expected_type": "idea",
"expected_time": null,
"expected_body": "should we add a dark mode to breadman",
"expected_rrule": null,
"notes": "contains 'should we '"
},
{
"input": "what if we switched to SQLite instead of JSONL",
"expected_type": "idea",
"expected_time": null,
"expected_body": "what if we switched to SQLite instead of JSONL",
"expected_rrule": null,
"notes": "starts with 'what if'"
},
{
"input": "why does nmcli drop on suspend?",
"expected_type": "question",
"expected_time": null,
"expected_body": "why does nmcli drop on suspend?",
"expected_rrule": null,
"notes": "starts with 'why'"
},
{
"input": "how do I configure zbus async",
"expected_type": "question",
"expected_time": null,
"expected_body": "how do I configure zbus async",
"expected_rrule": null,
"notes": "starts with 'how'"
},
{
"input": "what is the best way to handle GTK signals in Rust?",
"expected_type": "question",
"expected_time": null,
"expected_body": null,
"expected_rrule": null,
"notes": "starts with 'what' (not 'what if') — question, not idea"
},
{
"input": "is this thread safe?",
"expected_type": "question",
"expected_time": null,
"expected_body": "is this thread safe?",
"expected_rrule": null,
"notes": "ends with ? without a known question-word prefix"
},
{
"input": "does the ONNX runtime cache model weights between runs?",
"expected_type": "question",
"expected_time": null,
"expected_body": null,
"expected_rrule": null,
"notes": "ends with ?"
},
{
"input": "meeting went well, follow up needed",
"expected_type": "note",
"expected_time": null,
"expected_body": "meeting went well, follow up needed",
"expected_rrule": null,
"notes": "plain observation; no action verbs, no time, no question"
},
{
"input": "the new mechanical keyboard is noisy but I like it",
"expected_type": "note",
"expected_time": null,
"expected_body": "the new mechanical keyboard is noisy but I like it",
"expected_rrule": null,
"notes": "general note, no strong signals"
},
{
"input": "finally got the ONNX model running on CPU",
"expected_type": "note",
"expected_time": null,
"expected_body": "finally got the ONNX model running on CPU",
"expected_rrule": null,
"notes": "observation without actionable signal"
},
{
"input": "i should probably sort out that thing with the lights",
"expected_type": null,
"expected_time": null,
"expected_body": null,
"expected_rrule": null,
"notes": "ambiguous; Tier 1 returns note — no assertion, good Tier 2 test case"
},
{
"input": "get around to fixing the shelf at some point",
"expected_type": null,
"expected_time": null,
"expected_body": null,
"expected_rrule": null,
"notes": "'at some point' has no digit after 'at' so time not extracted; 'fixing' not a recognised prefix"
},
{
"input": "idea: remind me to think about this every monday",
"expected_type": "reminder",
"expected_time": null,
"expected_body": null,
"expected_rrule": "BYDAY=MO",
"notes": "mixed signals: idea: prefix but recurrence takes precedence — type must be reminder"
},
{
"input": "buy milk tonight and remind me to call the doctor tomorrow",
"expected_type": "reminder",
"expected_time": null,
"expected_body": null,
"expected_rrule": null,
"notes": "multi-intent: 'tomorrow' triggers time extraction; 'tonight' word stripped from body"
},
{
"input": "sort out that thing with the router at some point",
"expected_type": null,
"expected_time": null,
"expected_body": null,
"expected_rrule": null,
"notes": "unstructured; 'at some point' not parsed as time"
},
{
"input": "rmind me abt dentist appt tmrw",
"expected_type": null,
"expected_time": null,
"expected_body": null,
"expected_rrule": null,
"notes": "typo-heavy; Tier 1 cannot parse this; Tier 2/3 may or may not handle it"
},
{
"input": "pls remind me abt the call at 3pm",
"expected_type": "reminder",
"expected_time": "15:00",
"expected_body": null,
"expected_rrule": null,
"notes": "'at 3pm' matches rule-based despite informal phrasing around it"
}
]

474
breadpad-test/src/main.rs Normal file
View file

@ -0,0 +1,474 @@
use anyhow::{Context, Result};
use breadpad_shared::{
classifier::Classifier,
config::OllamaConfig,
parser::parse_rule_based,
types::ClassificationResult,
};
use chrono::{DateTime, Local, Timelike, Utc};
use clap::{Parser, Subcommand, ValueEnum};
use colored::Colorize;
use comfy_table::{Cell, Color, Table};
use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
const DEFAULT_CORPUS: &str = "breadpad-test/corpus.json";
const DEFAULT_MORNING: &str = "08:00";
#[derive(Parser)]
#[command(
name = "breadpad-test",
about = "Test harness for the breadpad classification pipeline"
)]
struct Cli {
#[command(subcommand)]
command: Commands,
}
#[derive(Subcommand)]
enum Commands {
/// Run tests against the corpus
Run {
#[arg(long, default_value = DEFAULT_CORPUS)]
corpus: PathBuf,
/// Which classification tiers to invoke: 1 (rule-based only), 2 (+ ONNX), 3/all (+ Ollama)
#[arg(long, default_value = "1")]
tier: TierArg,
/// Output format: table, json, or failures (failing cases only)
#[arg(long, default_value = "table")]
format: FormatArg,
},
/// Interactively add a new corpus entry
Add {
#[arg(long, default_value = DEFAULT_CORPUS)]
corpus: PathBuf,
},
/// Show a corpus entry and the pipeline's actual output side by side
Show {
index: usize,
#[arg(long, default_value = DEFAULT_CORPUS)]
corpus: PathBuf,
},
/// Open the corpus file in $EDITOR at the given entry
Edit {
index: usize,
#[arg(long, default_value = DEFAULT_CORPUS)]
corpus: PathBuf,
},
}
#[derive(ValueEnum, Clone, Debug)]
enum TierArg {
#[value(name = "1")]
One,
#[value(name = "2")]
Two,
#[value(name = "3")]
Three,
#[value(name = "all")]
All,
}
impl TierArg {
fn label(&self) -> &'static str {
match self {
TierArg::One => "1",
TierArg::Two => "2",
TierArg::Three => "3",
TierArg::All => "all",
}
}
}
#[derive(ValueEnum, Clone, Debug)]
enum FormatArg {
Table,
Json,
Failures,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
struct CorpusEntry {
input: String,
#[serde(default)]
expected_type: Option<String>,
/// Expected time as HH:MM — date component is ignored so tests are not date-sensitive
#[serde(default)]
expected_time: Option<String>,
/// Expected body text (checked as substring of actual body)
#[serde(default)]
expected_body: Option<String>,
/// Expected rrule (checked as substring of actual rrule string)
#[serde(default)]
expected_rrule: Option<String>,
#[serde(default)]
notes: Option<String>,
}
#[derive(Debug, Serialize)]
struct TestResult {
index: usize,
input: String,
tier_used: String,
actual_type: String,
actual_time: Option<String>,
actual_rrule: Option<String>,
actual_body: String,
type_pass: Option<bool>,
time_pass: Option<bool>,
rrule_pass: Option<bool>,
body_pass: Option<bool>,
pass: bool,
failure_reason: Option<String>,
}
fn load_corpus(path: &Path) -> Result<Vec<CorpusEntry>> {
let data = std::fs::read_to_string(path)
.with_context(|| format!("could not read corpus at {}", path.display()))?;
serde_json::from_str(&data).context("invalid corpus JSON")
}
fn save_corpus(path: &Path, entries: &[CorpusEntry]) -> Result<()> {
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
std::fs::create_dir_all(parent)?;
}
}
std::fs::write(path, serde_json::to_string_pretty(entries)?)?;
Ok(())
}
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);
clf.classify(text)
}
TierArg::Three | TierArg::All => {
let ollama = OllamaConfig {
endpoint: "http://localhost:11434".to_string(),
model: "llama3.2:3b".to_string(),
confidence_threshold: 0.6,
enabled: true,
};
let mut clf = Classifier::load("auto", DEFAULT_MORNING).with_ollama(ollama);
clf.classify(text)
}
}
}
fn format_time(t: Option<DateTime<Utc>>) -> Option<String> {
t.map(|dt| {
let local: DateTime<Local> = dt.into();
format!("{:02}:{:02}", local.hour(), local.minute())
})
}
fn run_tests(entries: &[CorpusEntry], tier: &TierArg) -> Vec<TestResult> {
entries
.iter()
.enumerate()
.map(|(i, entry)| {
let result = classify_with_tier(&entry.input, tier);
let actual_type = result.note_type.as_str().to_string();
let actual_time = format_time(result.time);
let actual_rrule = result.rrule.as_ref().map(|r| r.as_str().to_string());
let actual_body = result.body.clone();
let type_pass = entry
.expected_type
.as_ref()
.map(|et| et.to_lowercase() == actual_type);
let time_pass = entry.expected_time.as_ref().map(|et| {
actual_time.as_deref().map(|at| at == et).unwrap_or(false)
});
let rrule_pass = entry.expected_rrule.as_ref().map(|er| {
actual_rrule
.as_deref()
.map(|ar| ar.contains(er.as_str()))
.unwrap_or(false)
});
let body_pass = entry.expected_body.as_ref().map(|eb| {
actual_body
.to_lowercase()
.contains(&eb.to_lowercase())
});
let mut failure_parts: Vec<String> = Vec::new();
if type_pass == Some(false) {
failure_parts.push(format!(
"type: expected {}, got {}",
entry.expected_type.as_deref().unwrap_or("?"),
actual_type
));
}
if time_pass == Some(false) {
failure_parts.push(format!(
"time: expected {}, got {}",
entry.expected_time.as_deref().unwrap_or("none"),
actual_time.as_deref().unwrap_or("none")
));
}
if rrule_pass == Some(false) {
failure_parts.push(format!(
"rrule: expected to contain {:?}",
entry.expected_rrule.as_deref().unwrap_or("")
));
}
if body_pass == Some(false) {
failure_parts.push(format!(
"body: {:?} not found in {:?}",
entry.expected_body.as_deref().unwrap_or(""),
actual_body
));
}
let pass = failure_parts.is_empty();
TestResult {
index: i + 1,
input: entry.input.clone(),
tier_used: tier.label().to_string(),
actual_type,
actual_time,
actual_rrule,
actual_body,
type_pass,
time_pass,
rrule_pass,
body_pass,
pass,
failure_reason: if failure_parts.is_empty() {
None
} else {
Some(failure_parts.join("; "))
},
}
})
.collect()
}
fn check_mark(v: Option<bool>) -> &'static str {
match v {
Some(true) => "",
Some(false) => "",
None => "-",
}
}
fn print_table(results: &[TestResult], failures_only: bool) {
let to_show: Vec<&TestResult> = if failures_only {
results.iter().filter(|r| !r.pass).collect()
} else {
results.iter().collect()
};
let mut table = Table::new();
table.set_header(vec!["#", "input", "tier", "type", "time", "rrule", "body", "result"]);
for r in &to_show {
let input_display = if r.input.len() > 42 {
format!("{}", &r.input[..41])
} else {
r.input.clone()
};
let result_cell = if r.pass {
Cell::new("PASS").fg(Color::Green)
} else {
let reason = r.failure_reason.as_deref().unwrap_or("");
Cell::new(format!("FAIL {reason}")).fg(Color::Red)
};
table.add_row(vec![
Cell::new(r.index),
Cell::new(&input_display),
Cell::new(&r.tier_used),
Cell::new(&r.actual_type),
Cell::new(check_mark(r.time_pass)),
Cell::new(check_mark(r.rrule_pass)),
Cell::new(check_mark(r.body_pass)),
result_cell,
]);
}
if !to_show.is_empty() {
println!("{table}");
}
let passed = results.iter().filter(|r| r.pass).count();
let failed = results.iter().filter(|r| !r.pass).count();
if failed == 0 {
println!("{}", format!("{passed} passed").green());
} else {
println!("{}, {}", format!("{passed} passed").green(), format!("{failed} failed").red());
}
}
fn prompt(label: &str) -> Result<String> {
use std::io::Write;
print!("{label}");
std::io::stdout().flush()?;
let mut s = String::new();
std::io::stdin().read_line(&mut s)?;
Ok(s.trim().to_string())
}
fn prompt_opt(label: &str) -> Result<Option<String>> {
let s = prompt(label)?;
Ok(if s.is_empty() { None } else { Some(s) })
}
fn cmd_add(corpus_path: &Path) -> Result<()> {
let mut entries = if corpus_path.exists() {
load_corpus(corpus_path)?
} else {
vec![]
};
println!("Adding a new corpus entry. Press Enter to leave a field null.\n");
let input = prompt("input: ")?;
if input.is_empty() {
anyhow::bail!("input is required");
}
let expected_type = prompt_opt("expected_type (todo/reminder/idea/note/question or Enter): ")?;
let expected_time = prompt_opt("expected_time (HH:MM or Enter): ")?;
let expected_body = prompt_opt("expected_body (substring or Enter): ")?;
let expected_rrule = prompt_opt("expected_rrule (substring or Enter): ")?;
let notes = prompt_opt("notes (or Enter): ")?;
entries.push(CorpusEntry {
input,
expected_type,
expected_time,
expected_body,
expected_rrule,
notes,
});
save_corpus(corpus_path, &entries)?;
println!("\nAdded entry #{}", entries.len());
Ok(())
}
fn cmd_show(index: usize, corpus_path: &Path, tier: &TierArg) -> Result<()> {
let entries = load_corpus(corpus_path)?;
let entry = entries.get(index.saturating_sub(1)).ok_or_else(|| {
anyhow::anyhow!(
"index {} out of range (corpus has {} entries)",
index,
entries.len()
)
})?;
let result = classify_with_tier(&entry.input, tier);
let actual_time = format_time(result.time);
let actual_rrule = result.rrule.as_ref().map(|r| r.as_str().to_string());
println!("─── Entry {} (tier {}) ───", index, tier.label());
println!("input: {}", entry.input);
if let Some(n) = &entry.notes {
println!("notes: {}", n);
}
println!();
let sep = "".repeat(62);
println!("{:<14} {:<26} {}", "field", "expected", "actual");
println!("{sep}");
println!(
"{:<14} {:<26} {}",
"type",
entry.expected_type.as_deref().unwrap_or("(any)"),
result.note_type.as_str()
);
println!(
"{:<14} {:<26} {}",
"time",
entry.expected_time.as_deref().unwrap_or("(any)"),
actual_time.as_deref().unwrap_or("none")
);
println!(
"{:<14} {:<26} {}",
"rrule",
entry.expected_rrule.as_deref().unwrap_or("(any)"),
actual_rrule.as_deref().unwrap_or("none")
);
println!(
"{:<14} {:<26} {}",
"body",
entry.expected_body.as_deref().unwrap_or("(any)"),
result.body
);
println!("{:<14} {:<26} {:.2}", "confidence", "", result.confidence);
Ok(())
}
fn find_entry_line(corpus_path: &Path, index: usize) -> Result<Option<usize>> {
let content = std::fs::read_to_string(corpus_path)?;
let mut count = 0usize;
for (line_num, line) in content.lines().enumerate() {
if line.trim_start().starts_with('{') {
count += 1;
if count == index {
return Ok(Some(line_num + 1));
}
}
}
Ok(None)
}
fn cmd_edit(index: usize, corpus_path: &Path) -> Result<()> {
let editor = std::env::var("EDITOR").unwrap_or_else(|_| "vi".to_string());
let line = find_entry_line(corpus_path, index)?;
let status = if let Some(n) = line {
std::process::Command::new(&editor)
.arg(format!("+{n}"))
.arg(corpus_path)
.status()?
} else {
std::process::Command::new(&editor)
.arg(corpus_path)
.status()?
};
if !status.success() {
anyhow::bail!("editor exited with non-zero status");
}
Ok(())
}
fn main() -> Result<()> {
let cli = Cli::parse();
match cli.command {
Commands::Run { corpus, tier, format } => {
let entries = load_corpus(&corpus)?;
if entries.is_empty() {
println!("corpus is empty");
return Ok(());
}
let results = run_tests(&entries, &tier);
match format {
FormatArg::Table => print_table(&results, false),
FormatArg::Failures => print_table(&results, true),
FormatArg::Json => println!("{}", serde_json::to_string_pretty(&results)?),
}
let failed = results.iter().filter(|r| !r.pass).count();
if failed > 0 {
std::process::exit(1);
}
}
Commands::Add { corpus } => cmd_add(&corpus)?,
Commands::Show { index, corpus } => cmd_show(index, &corpus, &TierArg::One)?,
Commands::Edit { index, corpus } => cmd_edit(index, &corpus)?,
}
Ok(())
}

14
breadpad.example.toml Normal file
View file

@ -0,0 +1,14 @@
[settings]
default_type = "note" # fallback type if classification is skipped
workspace_tag = true # tag notes with active Hyprland workspace
snooze_options = ["15m", "1h", "tomorrow_morning"]
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
[reminders]
default_morning = "08:00"
missed_grace_minutes = 60

24
breadpad/Cargo.toml Normal file
View file

@ -0,0 +1,24 @@
[package]
name = "breadpad"
version.workspace = true
edition.workspace = true
license.workspace = true
authors.workspace = true
[[bin]]
name = "breadpad"
path = "src/main.rs"
[dependencies]
breadpad-shared = { path = "../breadpad-shared" }
anyhow.workspace = true
tracing.workspace = true
tracing-subscriber.workspace = true
serde.workspace = true
serde_json.workspace = true
chrono.workspace = true
gtk4.workspace = true
gtk4-layer-shell.workspace = true
hyprland.workspace = true
dirs.workspace = true
tokio.workspace = true

573
breadpad/src/main.rs Normal file
View file

@ -0,0 +1,573 @@
use anyhow::Result;
use breadpad_shared::{
calendar::CalDavClient,
classifier::Classifier,
config::{style_css_path, 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;
mod args {
#[derive(Debug)]
pub struct Args {
pub note_type: Option<String>,
pub no_classify: bool,
pub status: bool,
pub fire_id: Option<String>,
pub download_model: bool,
pub model_info: bool,
pub calendar_test: bool,
pub calendar_list_uid: Option<String>,
}
pub fn parse() -> Args {
let mut args = Args {
note_type: None,
no_classify: false,
status: false,
fire_id: None,
download_model: false,
model_info: false,
calendar_test: false,
calendar_list_uid: None,
};
let raw: Vec<String> = std::env::args().skip(1).collect();
let mut i = 0;
while i < raw.len() {
match raw[i].as_str() {
"--type" | "-t" => {
i += 1;
args.note_type = raw.get(i).cloned();
}
"--no-classify" => args.no_classify = true,
"--status" => args.status = true,
"download-model" => args.download_model = true,
"model-info" => args.model_info = true,
"fire" => {
i += 1;
args.fire_id = raw.get(i).cloned();
}
"calendar" => {
i += 1;
match raw.get(i).map(|s| s.as_str()) {
Some("test") => args.calendar_test = true,
Some("list-uid") => {
i += 1;
args.calendar_list_uid =
Some(raw.get(i).cloned().unwrap_or_default());
}
_ => {}
}
}
_ => {}
}
i += 1;
}
args
}
}
fn main() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(
tracing_subscriber::EnvFilter::from_default_env()
.add_directive("breadpad=info".parse().unwrap()),
)
.init();
let args = args::parse();
let cfg = Config::load()?;
if args.status {
return cmd_status(&cfg);
}
if args.download_model {
return cmd_download_model();
}
if args.model_info {
return cmd_model_info(&cfg);
}
if let Some(id) = args.fire_id {
return cmd_fire(&id, &cfg);
}
if args.calendar_test {
return cmd_calendar_test(&cfg);
}
if let Some(note_id) = args.calendar_list_uid {
return cmd_calendar_list_uid(&note_id, &cfg);
}
run_popup(args.note_type, args.no_classify, cfg)
}
fn cmd_status(cfg: &Config) -> Result<()> {
let store = Store::new()?;
let notes = store.load_all()?;
let classifier = Classifier::load(&cfg.model.execution_provider, &cfg.reminders.default_morning);
println!("breadpad status");
println!(" notes: {}", notes.len());
println!(
" model: {}",
if classifier.model_available() {
format!("loaded ({:?})", classifier.model_path)
} else {
"not loaded — run 'breadpad download-model'".into()
}
);
println!(" execution provider: {}", classifier.active_provider.as_str());
Ok(())
}
fn cmd_model_info(cfg: &Config) -> Result<()> {
let classifier = Classifier::load(&cfg.model.execution_provider, &cfg.reminders.default_morning);
println!("model path: {:?}", classifier.model_path);
println!("execution provider: {}", classifier.active_provider.as_str());
println!(
"model available: {}",
if classifier.model_available() { "yes" } else { "no" }
);
Ok(())
}
fn cmd_download_model() -> 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.");
println!("(Automatic download not yet configured — set a model URL in breadpad.toml)");
Ok(())
}
fn cmd_calendar_test(cfg: &Config) -> Result<()> {
if !cfg.calendar.enabled {
println!("Calendar integration is disabled. Set [calendar] enabled = true in breadpad.toml.");
return Ok(());
}
let cal_cfg = cfg.calendar.clone();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
rt.block_on(async {
let client = CalDavClient::new(cal_cfg);
match client.test_connection().await {
Ok(()) => println!("CalDAV connection OK"),
Err(e) => println!("CalDAV connection failed: {}", e),
}
});
Ok(())
}
fn cmd_calendar_list_uid(note_id: &str, cfg: &Config) -> Result<()> {
use breadpad_shared::calendar::caldav_uid;
if note_id.is_empty() {
// List all notes that would have CalDAV events (have time or rrule)
if cfg.calendar.enabled {
let cal_cfg = cfg.calendar.clone();
let rt = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let events = rt.block_on(async {
let client = CalDavClient::new(cal_cfg);
client.list_events().await
});
match events {
Ok(evs) => {
if evs.is_empty() {
println!("No events found on CalDAV server.");
} else {
for ev in &evs {
println!("{}\t{}", ev.uid, ev.summary);
}
}
}
Err(e) => println!("CalDAV list failed: {}", e),
}
} else {
let store = Store::new()?;
let notes = store.load_all()?;
let scheduled: Vec<_> = notes
.iter()
.filter(|n| n.time.is_some() || n.rrule.is_some())
.collect();
if scheduled.is_empty() {
println!("No notes with scheduled times or recurrence rules.");
} else {
for note in scheduled {
println!("{}\t{}", caldav_uid(note), note.body);
}
}
}
} else {
let store = Store::new()?;
match store.get_by_id(note_id)? {
Some(note) => println!("{}", caldav_uid(&note)),
None => println!("note '{}' not found", note_id),
}
}
Ok(())
}
fn cmd_fire(id: &str, cfg: &Config) -> Result<()> {
let store = Store::new()?;
let note = match store.get_by_id(id)? {
Some(n) => n,
None => {
tracing::error!("note {} not found", id);
return Ok(());
}
};
if !Scheduler::fire(&note, cfg.reminders.missed_grace_minutes) {
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(&note.body);
for opt in &cfg.settings.snooze_options {
cmd.arg(format!("--action=snooze_{}={}", opt, humanize_snooze(opt)));
}
let output = cmd.output()?;
// If the user clicked a snooze action, notify-send prints the action key
if let Ok(action) = String::from_utf8(output.stdout) {
let action = action.trim();
if action.starts_with("snooze_") {
let key = action.trim_start_matches("snooze_");
if let Some(until) = resolve_snooze(key, cfg) {
let mut updated = note.clone();
store.update_note({
updated.snoozed_until = Some(until);
&updated
})?;
Scheduler::schedule(&updated)?;
return Ok(());
}
}
}
// Handle recurrence
if note.rrule.is_some() {
if let Some(next) = Scheduler::next_recurrence(&note, &cfg.reminders.default_morning) {
let mut updated = note.clone();
updated.time = Some(next);
updated.snoozed_until = None;
store.update_note(&updated)?;
Scheduler::schedule(&updated)?;
}
}
Ok(())
}
fn humanize_snooze(s: &str) -> &str {
match s {
"15m" => "15 minutes",
"1h" => "1 hour",
"tomorrow_morning" => "Tomorrow morning",
other => other,
}
}
fn resolve_snooze(key: &str, cfg: &Config) -> Option<chrono::DateTime<chrono::Utc>> {
let now = chrono::Utc::now();
match key {
"15m" => Some(now + chrono::Duration::minutes(15)),
"1h" => Some(now + chrono::Duration::hours(1)),
"tomorrow_morning" => {
let local = chrono::Local::now();
let parts: Vec<u32> = cfg
.reminders
.default_morning
.split(':')
.filter_map(|s| s.parse().ok())
.collect();
let h = parts.first().copied().unwrap_or(8);
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))
}
_ => None,
}
}
fn run_popup(preset_type: Option<String>, no_classify: bool, cfg: Config) -> Result<()> {
// Try to get current Hyprland workspace
let workspace = get_active_workspace();
let app = gtk4::Application::builder()
.application_id("com.breadway.breadpad")
.build();
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);
});
let code = app.run_with_args::<String>(&[]);
if code != glib::ExitCode::SUCCESS {
anyhow::bail!("GTK application exited with error");
}
Ok(())
}
fn get_active_workspace() -> Option<String> {
// Use hyprctl via CLI since the async API would require a runtime here
let out = std::process::Command::new("hyprctl")
.args(["activeworkspace", "-j"])
.output()
.ok()?;
let val: serde_json::Value = serde_json::from_slice(&out.stdout).ok()?;
val.get("id").and_then(|v| v.as_i64()).map(|id| id.to_string())
}
fn build_window(
app: &gtk4::Application,
cfg: Arc<Config>,
workspace: Option<String>,
preset_type: Option<String>,
no_classify: bool,
) {
let window = gtk4::ApplicationWindow::builder()
.application(app)
.title("breadpad")
.default_width(600)
.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();
window.set_anchor(Edge::Top, false);
window.set_anchor(Edge::Bottom, false);
window.set_anchor(Edge::Left, false);
window.set_anchor(Edge::Right, false);
apply_css(&cfg);
let vbox = gtk4::Box::builder()
.orientation(gtk4::Orientation::Vertical)
.spacing(8)
.margin_top(16)
.margin_bottom(16)
.margin_start(16)
.margin_end(16)
.build();
let entry = gtk4::Entry::builder()
.placeholder_text("What's on your mind?")
.css_classes(["popup-entry"])
.hexpand(true)
.build();
let selected_type: Rc<RefCell<NoteType>> = Rc::new(RefCell::new(
preset_type
.as_deref()
.map(NoteType::from_str)
.unwrap_or(NoteType::from_str(&cfg.settings.default_type)),
));
// Type chip row
let chip_box = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(4)
.build();
let chips: Vec<(gtk4::Button, NoteType)> = NoteType::all_builtin()
.iter()
.map(|&name| {
let btn = gtk4::Button::builder()
.label(name)
.css_classes(["type-chip"])
.build();
(btn, NoteType::from_str(name))
})
.collect();
for (btn, nt) in &chips {
let selected_type_clone = selected_type.clone();
let nt_clone = nt.clone();
let chips_clone: Vec<gtk4::Button> = chips.iter().map(|(b, _)| b.clone()).collect();
btn.connect_clicked(move |clicked| {
*selected_type_clone.borrow_mut() = nt_clone.clone();
for b in &chips_clone {
b.remove_css_class("active");
}
clicked.add_css_class("active");
});
chip_box.append(btn);
}
// Mark the initial chip active
{
let current = selected_type.borrow().clone();
for (btn, nt) in &chips {
if *nt == current {
btn.add_css_class("active");
}
}
}
// Confirm button
let confirm_btn = gtk4::Button::builder()
.label("")
.css_classes(["confirm-button"])
.build();
let bottom_row = gtk4::Box::builder()
.orientation(gtk4::Orientation::Horizontal)
.spacing(8)
.build();
bottom_row.append(&chip_box);
let spacer = gtk4::Box::builder().hexpand(true).build();
bottom_row.append(&spacer);
bottom_row.append(&confirm_btn);
vbox.append(&entry);
vbox.append(&bottom_row);
window.set_child(Some(&vbox));
let win_clone = window.clone();
let entry_clone = entry.clone();
let selected_type_clone = selected_type.clone();
let cfg_clone = cfg.clone();
let workspace_clone = workspace.clone();
let save_and_close = {
let win = win_clone.clone();
let entry = entry_clone.clone();
let selected_type = selected_type_clone.clone();
let cfg = cfg_clone.clone();
let workspace = workspace_clone.clone();
move || {
let text = entry.text().to_string();
if text.trim().is_empty() {
win.close();
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());
win.close();
}
};
// Confirm button click
{
let save = save_and_close.clone();
confirm_btn.connect_clicked(move |_| save());
}
// Entry activate (Enter key)
{
let save = save_and_close.clone();
entry.connect_activate(move |_| save());
}
// Escape key
let key_ctrl = gtk4::EventControllerKey::new();
let win_for_key = window.clone();
key_ctrl.connect_key_pressed(move |_, key, _, _| {
if key == gtk4::gdk::Key::Escape {
win_for_key.close();
return glib::Propagation::Stop;
}
glib::Propagation::Proceed
});
window.add_controller(key_ctrl);
window.present();
entry.grab_focus();
}
fn save_note_classified(
text: &str,
user_type: NoteType,
no_classify: bool,
cfg: Arc<Config>,
workspace: Option<String>,
) {
let default_type = NoteType::from_str(&cfg.settings.default_type);
let mut note = Note::new(text.into(), user_type.clone(), workspace);
if !no_classify {
let mut classifier = Classifier::load(
&cfg.model.execution_provider,
&cfg.reminders.default_morning,
)
.with_ollama(cfg.model.ollama.clone());
let result = classifier.classify(text);
if user_type == default_type {
note.note_type = result.note_type;
}
note.time = result.time;
note.rrule = result.rrule;
note.body = result.body;
}
let store = match Store::new() {
Ok(s) => {
if cfg.calendar.enabled {
s.with_calendar(cfg.calendar.clone())
} else {
s
}
}
Err(e) => { tracing::error!("failed to open store: {}", e); return; }
};
if let Err(e) = store.save_note(&note) {
tracing::error!("failed to save note: {}", e);
return;
}
if note.time.is_some() {
if let Err(e) = Scheduler::schedule(&note) {
tracing::warn!("failed to schedule reminder: {}", e);
}
}
}
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(
&gtk4::gdk::Display::default().unwrap(),
&provider,
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
);
}

1
breadpadcli Symbolic link
View file

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

102
svgs.txt Normal file
View file

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