Committing before copilot touches this
This commit is contained in:
commit
feefdb81b9
36 changed files with 12338 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
|||
target/
|
||||
4507
Cargo.lock
generated
Normal file
4507
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load diff
36
Cargo.toml
Normal file
36
Cargo.toml
Normal 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
414
README.md
Normal 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
BIN
bread.zip
Normal file
Binary file not shown.
22
breadman/Cargo.toml
Normal file
22
breadman/Cargo.toml
Normal 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
162
breadman/src/editor.rs
Normal 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(>k4::Label::builder().label("Body").xalign(0.0).build());
|
||||
let body_entry = gtk4::Entry::builder()
|
||||
.text(¬e.body)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
vbox.append(&body_entry);
|
||||
|
||||
vbox.append(>k4::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(>k4::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(>k4::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(¬e_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
906
breadman/src/main.rs
Normal 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(¬es, state.clone());
|
||||
state.stack.add_named(&all_scroll, Some("all"));
|
||||
|
||||
// Upcoming
|
||||
let upcoming = views::upcoming::build(¬es);
|
||||
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(¬es, 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 ¬es {
|
||||
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: >k4::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(
|
||||
>k4::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(
|
||||
>k4::Label::builder()
|
||||
.label(icon)
|
||||
.width_chars(2)
|
||||
.xalign(0.5)
|
||||
.build(),
|
||||
);
|
||||
hbox.append(
|
||||
>k4::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(>k4::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(¬es_snap);
|
||||
state_c.stack.add_named(&upcoming, Some("upcoming"));
|
||||
for type_name in NoteType::all_builtin() {
|
||||
let nt = NoteType::from_str(type_name);
|
||||
let typed: Vec<Note> = notes_snap
|
||||
.iter()
|
||||
.filter(|n| n.note_type == nt && !n.done)
|
||||
.cloned()
|
||||
.collect();
|
||||
state_c.stack.add_named(&build_note_list(&typed, state_c.clone()), Some(type_name));
|
||||
}
|
||||
state_c.stack.add_named(&views::archive::build(¬es_snap, state_c.clone()), Some("archive"));
|
||||
let state_s = state_c.clone();
|
||||
state_c.stack.add_named(
|
||||
&views::settings::build(&cfg_snap, move |nc| { *state_s.cfg.borrow_mut() = nc; }),
|
||||
Some("settings"),
|
||||
);
|
||||
state_c.stack.add_named(&views::errors::build(&errors_snap), Some("errors"));
|
||||
state_c.stack.set_visible_child_name("all");
|
||||
});
|
||||
}
|
||||
|
||||
// ── 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(
|
||||
>k4::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(¬e.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) = ¬e.workspace {
|
||||
bottom_row.append(
|
||||
>k4::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(
|
||||
>k4::Label::builder()
|
||||
.label(&local.format("⏰ %b %d %H:%M").to_string())
|
||||
.css_classes(["dim-label"])
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
if note.rrule.is_some() {
|
||||
bottom_row.append(
|
||||
>k4::Label::builder()
|
||||
.label("↻")
|
||||
.css_classes(["type-chip"])
|
||||
.build(),
|
||||
);
|
||||
}
|
||||
|
||||
bottom_row.append(>k4::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(¬e_id) {
|
||||
n.mark_done();
|
||||
if let Err(e) = state_c.store.update_note(&n) {
|
||||
state_c.log_error(format!("mark done failed: {}", e));
|
||||
}
|
||||
}
|
||||
card_c.set_visible(false);
|
||||
state_c.reload_notes();
|
||||
});
|
||||
}
|
||||
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(
|
||||
¬e_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(¬e_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: >k4::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(>k4::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(>k4::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(>k4::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(>k4::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(¬e) {
|
||||
state_c.log_error(format!("save failed: {}", e));
|
||||
return;
|
||||
}
|
||||
if note.time.is_some() {
|
||||
if let Err(e) = Scheduler::schedule(¬e) {
|
||||
state_c.log_error(format!("schedule failed: {}", e));
|
||||
}
|
||||
}
|
||||
|
||||
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(¬e) {
|
||||
state_c2.log_error(format!("save failed: {}", e));
|
||||
return;
|
||||
}
|
||||
if note.time.is_some() {
|
||||
if let Err(e) = Scheduler::schedule(¬e) {
|
||||
state_c2.log_error(format!("schedule failed: {}", e));
|
||||
}
|
||||
}
|
||||
win_c2.close();
|
||||
let sr = state_c2.clone();
|
||||
glib::idle_add_local_once(move || refresh(&sr));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
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(
|
||||
>k4::gdk::Display::default().unwrap(),
|
||||
&provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
}
|
||||
109
breadman/src/views/archive.rs
Normal file
109
breadman/src/views/archive.rs
Normal 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(
|
||||
>k4::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(¬e.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(¬e_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
|
||||
}
|
||||
60
breadman/src/views/errors.rs
Normal file
60
breadman/src/views/errors.rs
Normal 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(
|
||||
>k4::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
|
||||
}
|
||||
4
breadman/src/views/mod.rs
Normal file
4
breadman/src/views/mod.rs
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
pub mod archive;
|
||||
pub mod errors;
|
||||
pub mod settings;
|
||||
pub mod upcoming;
|
||||
271
breadman/src/views/settings.rs
Normal file
271
breadman/src/views/settings.rs
Normal 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(>k4::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: >k4::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);
|
||||
}
|
||||
86
breadman/src/views/upcoming.rs
Normal file
86
breadman/src/views/upcoming.rs
Normal 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(¬e.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
|
||||
}
|
||||
29
breadpad-shared/Cargo.toml
Normal file
29
breadpad-shared/Cargo.toml
Normal 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
118
breadpad-shared/src/ai.rs
Normal 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,
|
||||
})
|
||||
}
|
||||
}
|
||||
221
breadpad-shared/src/calendar.rs
Normal file
221
breadpad-shared/src/calendar.rs
Normal 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(¬e.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) = ¬e.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
|
||||
}
|
||||
284
breadpad-shared/src/classifier.rs
Normal file
284
breadpad-shared/src/classifier.rs
Normal 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)),
|
||||
}
|
||||
}
|
||||
180
breadpad-shared/src/config.rs
Normal file
180
breadpad-shared/src/config.rs
Normal 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")
|
||||
}
|
||||
9
breadpad-shared/src/lib.rs
Normal file
9
breadpad-shared/src/lib.rs
Normal 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;
|
||||
882
breadpad-shared/src/parser.rs
Normal file
882
breadpad-shared/src/parser.rs
Normal 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]" → Mon–Fri 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 (Mon–Fri)
|
||||
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 ¬e_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 (Mon–Fri) ----
|
||||
|
||||
#[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
|
||||
}
|
||||
369
breadpad-shared/src/scheduler.rs
Normal file
369
breadpad-shared/src/scheduler.rs
Normal 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(¬e.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(¬e.id).ok();
|
||||
note.snoozed_until = Some(snooze_until);
|
||||
create_timer(¬e.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(¬e, 60));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fire_future_reminder_fires() {
|
||||
let mut note = reminder("future");
|
||||
note.time = Some(Utc::now() + Duration::minutes(10));
|
||||
assert!(Scheduler::fire(¬e, 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(¬e, 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(¬e, 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(¬e, 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(¬e, 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(¬e, "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(¬e, "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(¬e, "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");
|
||||
}
|
||||
}
|
||||
204
breadpad-shared/src/store.rs
Normal file
204
breadpad-shared/src/store.rs
Normal 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(¬e), 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, ¬es)
|
||||
}
|
||||
|
||||
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(¬e).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),
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
447
breadpad-shared/src/theme.rs
Normal file
447
breadpad-shared/src/theme.rs
Normal 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('#'));
|
||||
}
|
||||
}
|
||||
404
breadpad-shared/src/types.rs
Normal file
404
breadpad-shared/src/types.rs
Normal 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(¬e).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(¬e).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(¬e).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(¬e).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(¬e).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());
|
||||
}
|
||||
}
|
||||
83
breadpad-shared/tests/classifier.rs
Normal file
83
breadpad-shared/tests/classifier.rs
Normal 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"));
|
||||
}
|
||||
198
breadpad-shared/tests/config.rs
Normal file
198
breadpad-shared/tests/config.rs
Normal 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);
|
||||
}
|
||||
236
breadpad-shared/tests/pipeline.rs
Normal file
236
breadpad-shared/tests/pipeline.rs
Normal 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(¬e).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);
|
||||
}
|
||||
382
breadpad-shared/tests/store.rs
Normal file
382
breadpad-shared/tests/store.rs
Normal 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(¬e("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(
|
||||
¬es_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(¬es_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(¬e(&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
20
breadpad-test/Cargo.toml
Normal 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
506
breadpad-test/corpus.json
Normal 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": "Mon–Fri 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": "Mon–Fri 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
474
breadpad-test/src/main.rs
Normal 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
14
breadpad.example.toml
Normal 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
24
breadpad/Cargo.toml
Normal 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
573
breadpad/src/main.rs
Normal 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(¬e_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(¬e)),
|
||||
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(¬e, 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(¬e.body);
|
||||
for opt in &cfg.settings.snooze_options {
|
||||
cmd.arg(format!("--action=snooze_{}={}", opt, humanize_snooze(opt)));
|
||||
}
|
||||
let output = cmd.output()?;
|
||||
|
||||
// If the user clicked a snooze action, notify-send prints the action key
|
||||
if let Ok(action) = String::from_utf8(output.stdout) {
|
||||
let action = action.trim();
|
||||
if action.starts_with("snooze_") {
|
||||
let key = action.trim_start_matches("snooze_");
|
||||
if let Some(until) = resolve_snooze(key, cfg) {
|
||||
let mut updated = note.clone();
|
||||
store.update_note({
|
||||
updated.snoozed_until = Some(until);
|
||||
&updated
|
||||
})?;
|
||||
Scheduler::schedule(&updated)?;
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Handle recurrence
|
||||
if note.rrule.is_some() {
|
||||
if let Some(next) = Scheduler::next_recurrence(¬e, &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: >k4::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(¬e) {
|
||||
tracing::error!("failed to save note: {}", e);
|
||||
return;
|
||||
}
|
||||
if note.time.is_some() {
|
||||
if let Err(e) = Scheduler::schedule(¬e) {
|
||||
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(
|
||||
>k4::gdk::Display::default().unwrap(),
|
||||
&provider,
|
||||
gtk4::STYLE_PROVIDER_PRIORITY_APPLICATION,
|
||||
);
|
||||
}
|
||||
1
breadpadcli
Symbolic link
1
breadpadcli
Symbolic link
|
|
@ -0,0 +1 @@
|
|||
./target/release/breadpad
|
||||
102
svgs.txt
Normal file
102
svgs.txt
Normal 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(>k4::Image::from_file("path/to/icon.svg")));
|
||||
# 3. Symbolic icons (named with "-symbolic" suffix in icon theme) follow the
|
||||
# CSS color property automatically.
|
||||
Loading…
Add table
Add a link
Reference in a new issue