Committing before copilot touches this

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,447 @@
use serde::Deserialize;
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Debug, Clone)]
pub struct Palette {
pub background: String,
pub foreground: String,
pub color0: String,
pub color1: String,
pub color2: String,
pub color3: String,
pub color4: String,
pub color5: String,
pub color6: String,
pub color7: String,
}
// Catppuccin Mocha fallback
impl Default for Palette {
fn default() -> Self {
Palette {
background: "#1e1e2e".into(),
foreground: "#cdd6f4".into(),
color0: "#45475a".into(),
color1: "#f38ba8".into(),
color2: "#a6e3a1".into(),
color3: "#f9e2af".into(),
color4: "#89b4fa".into(),
color5: "#f5c2e7".into(),
color6: "#94e2d5".into(),
color7: "#bac2de".into(),
}
}
}
#[derive(Debug, Deserialize)]
struct WalColors {
#[serde(default)]
colors: HashMap<String, String>,
special: Option<WalSpecial>,
}
#[derive(Debug, Deserialize)]
struct WalSpecial {
background: Option<String>,
foreground: Option<String>,
}
pub(crate) fn palette_from_wal_json(json: &str) -> Option<Palette> {
let wal: WalColors = serde_json::from_str(json).ok()?;
Some(Palette {
background: wal.special.as_ref().and_then(|s| s.background.clone()).unwrap_or_else(|| "#1e1e2e".into()),
foreground: wal.special.as_ref().and_then(|s| s.foreground.clone()).unwrap_or_else(|| "#cdd6f4".into()),
color0: wal.colors.get("color0").cloned().unwrap_or_else(|| "#45475a".into()),
color1: wal.colors.get("color1").cloned().unwrap_or_else(|| "#f38ba8".into()),
color2: wal.colors.get("color2").cloned().unwrap_or_else(|| "#a6e3a1".into()),
color3: wal.colors.get("color3").cloned().unwrap_or_else(|| "#f9e2af".into()),
color4: wal.colors.get("color4").cloned().unwrap_or_else(|| "#89b4fa".into()),
color5: wal.colors.get("color5").cloned().unwrap_or_else(|| "#f5c2e7".into()),
color6: wal.colors.get("color6").cloned().unwrap_or_else(|| "#94e2d5".into()),
color7: wal.colors.get("color7").cloned().unwrap_or_else(|| "#bac2de".into()),
})
}
pub fn load_palette() -> Palette {
let wal_path = wal_colors_path();
if !wal_path.exists() {
return Palette::default();
}
match std::fs::read_to_string(&wal_path)
.ok()
.and_then(|s| palette_from_wal_json(&s))
{
Some(wal) => wal,
None => Palette::default(),
}
}
pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String {
let mut css = format!(
r#"
@define-color bg {bg};
@define-color fg {fg};
@define-color red {c1};
@define-color green {c2};
@define-color yellow {c3};
@define-color blue {c4};
@define-color pink {c5};
@define-color teal {c6};
@define-color overlay {c0};
window {{
background-color: @bg;
color: @fg;
border-radius: 12px;
}}
.popup-entry {{
background: @bg;
color: @fg;
border: 2px solid @blue;
border-radius: 8px;
padding: 12px 16px;
font-size: 16px;
caret-color: @fg;
}}
.popup-entry:focus {{
outline: none;
border-color: @teal;
}}
.type-chip {{
background: @overlay;
color: @fg;
border-radius: 999px;
padding: 2px 10px;
font-size: 12px;
margin: 2px;
}}
.type-chip.active {{
background: @blue;
color: @bg;
}}
.confirm-button {{
background: @green;
color: @bg;
border: none;
border-radius: 8px;
padding: 8px 16px;
font-weight: bold;
}}
.note-card {{
background: shade(@bg, 1.1);
border-radius: 8px;
padding: 12px;
margin: 4px 8px;
border-left: 3px solid @blue;
}}
.note-card:hover {{
background: shade(@bg, 1.2);
}}
.search-entry {{
background: shade(@bg, 1.1);
color: @fg;
border: 1px solid @overlay;
border-radius: 8px;
padding: 8px 12px;
margin: 8px;
}}
"#,
bg = palette.background,
fg = palette.foreground,
c0 = palette.color0,
c1 = palette.color1,
c2 = palette.color2,
c3 = palette.color3,
c4 = palette.color4,
c5 = palette.color5,
c6 = palette.color6,
);
css.push_str(r#"
.dim-label {
color: alpha(@fg, 0.5);
font-size: 12px;
}
.sidebar {
background: shade(@bg, 0.93);
}
.sidebar-row {
padding: 6px 12px;
font-size: 14px;
}
.sidebar-row:hover:not(:selected) {
background: shade(@bg, 1.08);
}
.sidebar-row:selected {
background: @blue;
color: @bg;
}
.sidebar-section-label {
color: alpha(@fg, 0.4);
font-size: 10px;
font-weight: bold;
padding: 10px 14px 2px 14px;
letter-spacing: 1px;
}
.action-btn {
background: transparent;
border: none;
border-radius: 6px;
padding: 2px 7px;
min-width: 28px;
min-height: 28px;
font-size: 14px;
}
.action-btn:hover {
background: shade(@bg, 1.3);
}
.done-btn { color: @green; }
.done-btn:hover { background: alpha(@green, 0.15); }
.edit-btn { color: @blue; }
.edit-btn:hover { background: alpha(@blue, 0.15); }
.danger-btn { color: @red; }
.danger-btn:hover { background: alpha(@red, 0.15); }
.note-card-todo { border-left-color: @green; }
.note-card-reminder { border-left-color: @yellow; }
.note-card-idea { border-left-color: @pink; }
.note-card-question { border-left-color: @teal; }
.note-card-note { border-left-color: @blue; }
entry {
background: shade(@bg, 1.1);
color: @fg;
border: 1px solid @overlay;
border-radius: 6px;
caret-color: @fg;
padding: 5px 10px;
}
entry:focus {
border-color: @blue;
outline: none;
}
"#);
if let Some(extra) = user_css {
css.push('\n');
css.push_str(extra);
}
css
}
fn wal_colors_path() -> PathBuf {
dirs::cache_dir()
.unwrap_or_else(|| PathBuf::from("~/.cache"))
.join("wal")
.join("colors.json")
}
#[cfg(test)]
mod tests {
use super::*;
const TOKYO_NIGHT_WAL: &str = r##"{
"special": {
"background": "#1a1b26",
"foreground": "#c0caf5"
},
"colors": {
"color0": "#15161e",
"color1": "#f7768e",
"color2": "#9ece6a",
"color3": "#e0af68",
"color4": "#7aa2f7",
"color5": "#bb9af7",
"color6": "#7dcfff",
"color7": "#a9b1d6"
}
}"##;
// ---- Default palette (Catppuccin Mocha) ----
#[test]
fn default_background_is_catppuccin_mocha() {
assert_eq!(Palette::default().background, "#1e1e2e");
}
#[test]
fn default_foreground_is_catppuccin_mocha() {
assert_eq!(Palette::default().foreground, "#cdd6f4");
}
#[test]
fn default_red_is_catppuccin_mocha() {
assert_eq!(Palette::default().color1, "#f38ba8");
}
#[test]
fn default_blue_is_catppuccin_mocha() {
assert_eq!(Palette::default().color4, "#89b4fa");
}
#[test]
fn default_teal_is_catppuccin_mocha() {
assert_eq!(Palette::default().color6, "#94e2d5");
}
// ---- palette_from_wal_json ----
#[test]
fn wal_json_parses_special_background() {
let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap();
assert_eq!(p.background, "#1a1b26");
}
#[test]
fn wal_json_parses_special_foreground() {
let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap();
assert_eq!(p.foreground, "#c0caf5");
}
#[test]
fn wal_json_parses_numbered_colors() {
let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap();
assert_eq!(p.color0, "#15161e");
assert_eq!(p.color1, "#f7768e");
assert_eq!(p.color4, "#7aa2f7");
assert_eq!(p.color7, "#a9b1d6");
}
#[test]
fn wal_json_missing_special_falls_back_to_defaults() {
let json = r##"{"colors":{"color0":"#000000"}}"##;
let p = palette_from_wal_json(json).unwrap();
assert_eq!(p.background, "#1e1e2e");
assert_eq!(p.foreground, "#cdd6f4");
}
#[test]
fn wal_json_missing_color_falls_back_to_default() {
let json = r##"{"special":{"background":"#ff0000","foreground":"#ffffff"},"colors":{}}"##;
let p = palette_from_wal_json(json).unwrap();
assert_eq!(p.color4, "#89b4fa"); // default blue
}
#[test]
fn invalid_wal_json_returns_none() {
assert!(palette_from_wal_json("not json").is_none());
assert!(palette_from_wal_json("").is_none());
assert!(palette_from_wal_json("{}").is_some()); // empty but valid → all defaults
}
// ---- build_css ----
#[test]
fn css_defines_bg_color() {
let css = build_css(&Palette::default(), None);
assert!(css.contains("@define-color bg #1e1e2e"), "css missing bg: {}", &css[..300]);
}
#[test]
fn css_defines_fg_color() {
let css = build_css(&Palette::default(), None);
assert!(css.contains("@define-color fg #cdd6f4"));
}
#[test]
fn css_defines_all_named_colors() {
let css = build_css(&Palette::default(), None);
for name in &["red", "green", "yellow", "blue", "pink", "teal", "overlay"] {
assert!(css.contains(&format!("@define-color {} ", name)), "missing @define-color {}", name);
}
}
#[test]
fn css_contains_window_rule() {
let css = build_css(&Palette::default(), None);
assert!(css.contains("window {"));
assert!(css.contains("background-color: @bg"));
}
#[test]
fn css_contains_popup_entry_class() {
let css = build_css(&Palette::default(), None);
assert!(css.contains(".popup-entry {"), "css: {}", &css[300..600]);
}
#[test]
fn css_contains_note_card_class() {
let css = build_css(&Palette::default(), None);
assert!(css.contains(".note-card {"));
}
#[test]
fn css_contains_type_chip_class() {
let css = build_css(&Palette::default(), None);
assert!(css.contains(".type-chip {"));
}
#[test]
fn css_contains_sidebar_row_class() {
let css = build_css(&Palette::default(), None);
assert!(css.contains(".sidebar-row {"));
}
#[test]
fn css_appends_user_css() {
let user = ".my-override { color: hotpink; }";
let css = build_css(&Palette::default(), Some(user));
assert!(css.contains(".my-override { color: hotpink; }"));
}
#[test]
fn css_without_user_css_omits_user_rules() {
let css = build_css(&Palette::default(), None);
assert!(!css.contains(".my-override"));
}
#[test]
fn css_reflects_custom_palette_colors() {
let mut p = Palette::default();
p.background = "#deadbe".into();
p.color4 = "#cafe00".into();
let css = build_css(&p, None);
assert!(css.contains("@define-color bg #deadbe"), "css: {}", &css[..300]);
assert!(css.contains("@define-color blue #cafe00"), "css: {}", &css[..300]);
}
#[test]
fn css_from_wal_palette_uses_wal_colors() {
let p = palette_from_wal_json(TOKYO_NIGHT_WAL).unwrap();
let css = build_css(&p, None);
assert!(css.contains("@define-color bg #1a1b26"), "css: {}", &css[..300]);
assert!(css.contains("@define-color fg #c0caf5"));
}
#[test]
fn load_palette_returns_valid_palette() {
// No wal file in CI/test env; should return non-empty strings starting with #
let palette = load_palette();
assert!(!palette.background.is_empty());
assert!(palette.background.starts_with('#'), "bg: {}", palette.background);
assert!(!palette.foreground.is_empty());
assert!(palette.color4.starts_with('#'));
}
}

View file

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