Prepare repo for GitHub publication
- Add MIT LICENSE file - Expand .gitignore with standard Rust/Linux entries - Remove dangling symlinks (breadmancli, breadpadcli) and dev scratchpad (svgs.txt) from git tracking - Replace unsafe unwrap() calls with expect() in breadman CLI (guarded by prior filter) Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
feefdb81b9
commit
347508828f
34 changed files with 2825 additions and 771 deletions
|
|
@ -5,6 +5,7 @@ edition.workspace = true
|
|||
license.workspace = true
|
||||
authors.workspace = true
|
||||
|
||||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
tracing.workspace = true
|
||||
|
|
|
|||
|
|
@ -70,10 +70,9 @@ impl OllamaClient {
|
|||
.into_json()
|
||||
.map_err(|e| anyhow::anyhow!("deserialize Ollama envelope: {}", e))?;
|
||||
|
||||
let classification: OllamaClassification = serde_json::from_str(&ollama_resp.response)
|
||||
.map_err(|e| anyhow::anyhow!(
|
||||
"parse Ollama classification JSON: {} — raw: {:?}",
|
||||
e,
|
||||
let classification: OllamaClassification = extract_json(&ollama_resp.response)
|
||||
.ok_or_else(|| anyhow::anyhow!(
|
||||
"no JSON object found in response — raw: {:?}",
|
||||
&ollama_resp.response
|
||||
))?;
|
||||
|
||||
|
|
@ -116,3 +115,12 @@ impl OllamaClient {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Some backends (e.g. FastFlowLM) ignore `"format": "json"` and may wrap the
|
||||
// JSON in prose. Find the first `{...}` span and parse that.
|
||||
fn extract_json<T: serde::de::DeserializeOwned>(s: &str) -> Option<T> {
|
||||
let start = s.find('{')?;
|
||||
let end = s.rfind('}')?;
|
||||
if end < start { return None; }
|
||||
serde_json::from_str(&s[start..=end]).ok()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,9 +16,14 @@ pub struct CalDavEventInfo {
|
|||
|
||||
impl CalDavClient {
|
||||
pub fn new(config: CalendarConfig) -> Self {
|
||||
// `reqwest::Client::builder().build()` can only fail if the TLS backend can't be
|
||||
// initialised; fall back to `Client::new()` semantics rather than panicking.
|
||||
let client = reqwest::Client::builder()
|
||||
.build()
|
||||
.expect("failed to build HTTP client");
|
||||
.unwrap_or_else(|e| {
|
||||
tracing::warn!("falling back to default HTTP client: {}", e);
|
||||
reqwest::Client::new()
|
||||
});
|
||||
CalDavClient { config, client }
|
||||
}
|
||||
|
||||
|
|
@ -139,28 +144,56 @@ fn event_url(base: &str, uid: &str) -> String {
|
|||
fn build_ical(note: &Note, uid: &str) -> String {
|
||||
let dt = note.time.unwrap_or(note.created);
|
||||
let dtstart = dt.format("%Y%m%dT%H%M%SZ").to_string();
|
||||
let summary = escape_ical(¬e.body);
|
||||
let description = escape_ical(&format!("type={}", note.note_type.as_str()));
|
||||
let dtstamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ").to_string();
|
||||
|
||||
let mut ical = format!(
|
||||
"BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
PRODID:-//breadpad//EN\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
UID:{uid}\r\n\
|
||||
SUMMARY:{summary}\r\n\
|
||||
DTSTART:{dtstart}\r\n\
|
||||
DTEND:{dtstart}\r\n\
|
||||
DESCRIPTION:{description}\r\n"
|
||||
);
|
||||
let mut lines: Vec<String> = vec![
|
||||
"BEGIN:VCALENDAR".into(),
|
||||
"VERSION:2.0".into(),
|
||||
"PRODID:-//breadpad//EN".into(),
|
||||
"BEGIN:VEVENT".into(),
|
||||
format!("UID:{}", uid),
|
||||
fold_line(&format!("SUMMARY:{}", escape_ical(¬e.body))),
|
||||
format!("DTSTART:{}", dtstart),
|
||||
format!("DTEND:{}", dtstart),
|
||||
format!("DTSTAMP:{}", dtstamp),
|
||||
fold_line(&format!("DESCRIPTION:{}", escape_ical(&format!("type={}", note.note_type.as_str())))),
|
||||
];
|
||||
|
||||
if let Some(rrule) = ¬e.rrule {
|
||||
ical.push_str(rrule.as_str());
|
||||
ical.push_str("\r\n");
|
||||
lines.push(rrule.as_str().to_string());
|
||||
}
|
||||
|
||||
ical.push_str("END:VEVENT\r\nEND:VCALENDAR\r\n");
|
||||
ical
|
||||
lines.push("END:VEVENT".into());
|
||||
lines.push("END:VCALENDAR".into());
|
||||
|
||||
lines.join("\r\n") + "\r\n"
|
||||
}
|
||||
|
||||
/// Fold an iCal property line per RFC 5545 §3.1: lines longer than 75 octets
|
||||
/// are split with CRLF + a single space continuation character.
|
||||
fn fold_line(line: &str) -> String {
|
||||
let bytes = line.as_bytes();
|
||||
if bytes.len() <= 75 {
|
||||
return line.to_string();
|
||||
}
|
||||
let mut out = String::with_capacity(line.len() + line.len() / 75 * 3);
|
||||
let mut pos = 0;
|
||||
let mut first = true;
|
||||
while pos < bytes.len() {
|
||||
if !first {
|
||||
out.push_str("\r\n ");
|
||||
}
|
||||
let limit = if first { 75 } else { 74 }; // continuation lines lose 1 octet to the space
|
||||
let mut end = (pos + limit).min(bytes.len());
|
||||
// Step back if we landed in the middle of a multi-byte UTF-8 sequence.
|
||||
while end > pos && end < bytes.len() && (bytes[end] & 0xC0) == 0x80 {
|
||||
end -= 1;
|
||||
}
|
||||
out.push_str(std::str::from_utf8(&bytes[pos..end]).unwrap_or(""));
|
||||
pos = end;
|
||||
first = false;
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
fn escape_ical(s: &str) -> String {
|
||||
|
|
@ -219,3 +252,241 @@ fn parse_ical_block(data: &str) -> Vec<CalDavEventInfo> {
|
|||
|
||||
out
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::types::{Note, NoteType, RecurrenceRule};
|
||||
use chrono::{TimeZone, Utc};
|
||||
|
||||
fn reminder(body: &str) -> Note {
|
||||
let mut n = Note::new(body.into(), NoteType::Reminder, None);
|
||||
n.time = Some(Utc::now());
|
||||
n
|
||||
}
|
||||
|
||||
// ---- escape_ical ----
|
||||
|
||||
#[test]
|
||||
fn escape_ical_clean_string_unchanged() {
|
||||
assert_eq!(escape_ical("hello world"), "hello world");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_ical_empty_string() {
|
||||
assert_eq!(escape_ical(""), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_ical_escapes_backslash() {
|
||||
assert_eq!(escape_ical("back\\slash"), "back\\\\slash");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_ical_escapes_semicolon() {
|
||||
assert_eq!(escape_ical("a;b"), "a\\;b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_ical_escapes_comma() {
|
||||
assert_eq!(escape_ical("apples,oranges"), "apples\\,oranges");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_ical_escapes_newline() {
|
||||
assert_eq!(escape_ical("line1\nline2"), "line1\\nline2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn escape_ical_multiple_special_chars() {
|
||||
assert_eq!(escape_ical("a;b,c\nd"), "a\\;b\\,c\\nd");
|
||||
}
|
||||
|
||||
// ---- caldav_uid ----
|
||||
|
||||
#[test]
|
||||
fn caldav_uid_uses_existing_field() {
|
||||
let mut n = Note::new("test".into(), NoteType::Reminder, None);
|
||||
n.caldav_uid = Some("my-custom-uid".into());
|
||||
assert_eq!(caldav_uid(&n), "my-custom-uid");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn caldav_uid_falls_back_to_id_at_breadpad() {
|
||||
let n = Note::new("test".into(), NoteType::Reminder, None);
|
||||
assert_eq!(caldav_uid(&n), format!("{}@breadpad", n.id));
|
||||
}
|
||||
|
||||
// ---- event_url ----
|
||||
|
||||
#[test]
|
||||
fn event_url_with_trailing_slash() {
|
||||
let url = event_url("https://cloud.example.com/cal/", "abc@breadpad");
|
||||
assert_eq!(url, "https://cloud.example.com/cal/abc@breadpad.ics");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn event_url_without_trailing_slash() {
|
||||
let url = event_url("https://cloud.example.com/cal", "abc@breadpad");
|
||||
assert_eq!(url, "https://cloud.example.com/cal/abc@breadpad.ics");
|
||||
}
|
||||
|
||||
// ---- build_ical ----
|
||||
|
||||
#[test]
|
||||
fn build_ical_contains_vcalendar_markers() {
|
||||
let n = reminder("team sync");
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("BEGIN:VCALENDAR"), "missing BEGIN:VCALENDAR");
|
||||
assert!(ical.contains("END:VCALENDAR"), "missing END:VCALENDAR");
|
||||
assert!(ical.contains("BEGIN:VEVENT"), "missing BEGIN:VEVENT");
|
||||
assert!(ical.contains("END:VEVENT"), "missing END:VEVENT");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_contains_uid() {
|
||||
let n = reminder("team sync");
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains(&format!("UID:{}", uid)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_contains_summary() {
|
||||
let n = reminder("team sync");
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("SUMMARY:team sync"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_description_contains_type() {
|
||||
let n = reminder("team sync");
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("DESCRIPTION:type=reminder"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_uses_note_time_for_dtstart() {
|
||||
let mut n = Note::new("dentist".into(), NoteType::Reminder, None);
|
||||
n.time = Some(Utc.with_ymd_and_hms(2026, 6, 15, 14, 30, 0).unwrap());
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("DTSTART:20260615T143000Z"), "ical: {}", &ical[..400]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_falls_back_to_created_when_no_time() {
|
||||
let n = Note::new("no time set".into(), NoteType::Reminder, None);
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("DTSTART:"), "DTSTART should be present");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_includes_rrule_when_set() {
|
||||
let mut n = reminder("standup");
|
||||
n.rrule = Some(RecurrenceRule::new("RRULE:FREQ=WEEKLY;BYDAY=MO;BYHOUR=9;BYMINUTE=0;BYSECOND=0"));
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("RRULE:FREQ=WEEKLY;BYDAY=MO"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_no_rrule_when_not_set() {
|
||||
let n = reminder("one-off");
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(!ical.contains("RRULE:"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_escapes_special_chars_in_summary() {
|
||||
let n = Note::new("dentist; bring card, and ID".into(), NoteType::Reminder, None);
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("SUMMARY:dentist\\; bring card\\, and ID"), "ical: {}", &ical[..400]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_contains_dtstamp() {
|
||||
let n = reminder("team sync");
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
assert!(ical.contains("DTSTAMP:"), "missing DTSTAMP in:\n{}", ical);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_line_short_unchanged() {
|
||||
let line = "SUMMARY:short";
|
||||
assert_eq!(fold_line(line), line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_line_exactly_75_unchanged() {
|
||||
let line = "A".repeat(75);
|
||||
assert_eq!(fold_line(&line), line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fold_line_76_chars_splits() {
|
||||
let line = "X".repeat(76);
|
||||
let folded = fold_line(&line);
|
||||
assert!(folded.contains("\r\n "), "expected fold in: {:?}", folded);
|
||||
// Reassembled content should equal the original.
|
||||
let rejoined: String = folded.split("\r\n ").collect();
|
||||
assert_eq!(rejoined, line);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn build_ical_long_summary_is_folded() {
|
||||
let long_body = "a".repeat(200);
|
||||
let n = Note::new(long_body.clone(), NoteType::Reminder, None);
|
||||
let uid = caldav_uid(&n);
|
||||
let ical = build_ical(&n, &uid);
|
||||
// Every line (split on CRLF) must be at most 75 octets.
|
||||
for line in ical.split("\r\n") {
|
||||
assert!(
|
||||
line.len() <= 75,
|
||||
"line too long ({} octets): {:?}",
|
||||
line.len(),
|
||||
line
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- parse_report_response ----
|
||||
|
||||
#[test]
|
||||
fn parse_report_response_empty_xml_returns_empty() {
|
||||
let events = parse_report_response("").unwrap();
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_report_response_single_event() {
|
||||
let xml = "\
|
||||
BEGIN:VCALENDAR\r\n\
|
||||
VERSION:2.0\r\n\
|
||||
BEGIN:VEVENT\r\n\
|
||||
UID:abc123@breadpad\r\n\
|
||||
SUMMARY:team sync\r\n\
|
||||
DTSTART:20260615T140000Z\r\n\
|
||||
DTEND:20260615T140000Z\r\n\
|
||||
END:VEVENT\r\n\
|
||||
END:VCALENDAR\r\n";
|
||||
let events = parse_report_response(xml).unwrap();
|
||||
assert_eq!(events.len(), 1);
|
||||
assert_eq!(events[0].uid, "abc123@breadpad");
|
||||
assert_eq!(events[0].summary, "team sync");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_report_response_no_vcalendar_block_returns_empty() {
|
||||
let xml = "<multistatus><response><status>HTTP/1.1 200 OK</status></response></multistatus>";
|
||||
let events = parse_report_response(xml).unwrap();
|
||||
assert!(events.is_empty());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,16 +9,14 @@ const TIER1_SKIP_THRESHOLD: f32 = 0.82;
|
|||
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
pub enum ExecutionProvider {
|
||||
Qnn,
|
||||
Vulkan,
|
||||
Gpu,
|
||||
Cpu,
|
||||
}
|
||||
|
||||
impl ExecutionProvider {
|
||||
pub fn as_str(&self) -> &str {
|
||||
match self {
|
||||
ExecutionProvider::Qnn => "QNN (NPU)",
|
||||
ExecutionProvider::Vulkan => "Vulkan",
|
||||
ExecutionProvider::Gpu => "ROCm (iGPU)",
|
||||
ExecutionProvider::Cpu => "CPU",
|
||||
}
|
||||
}
|
||||
|
|
@ -43,20 +41,27 @@ fn model_dir() -> PathBuf {
|
|||
impl Classifier {
|
||||
/// Load with Tier 1 + optional Tier 2 (ONNX). Tier 3 disabled unless
|
||||
/// `.with_ollama()` is called on the returned value.
|
||||
pub fn load(ep_pref: &str, default_morning: &str) -> Self {
|
||||
pub fn load(default_morning: &str) -> Self {
|
||||
let dir = model_dir();
|
||||
let onnx_path = dir.join("classifier.onnx");
|
||||
let tok_path = dir.join("tokenizer.json");
|
||||
Self::load_with_paths(default_morning, onnx_path, tok_path)
|
||||
}
|
||||
|
||||
let (session, active_provider) = if onnx_path.exists() {
|
||||
try_load_session(&onnx_path, ep_pref)
|
||||
pub fn load_with_paths(
|
||||
default_morning: &str,
|
||||
model_path: PathBuf,
|
||||
tokenizer_path: PathBuf,
|
||||
) -> Self {
|
||||
let (session, active_provider) = if model_path.exists() {
|
||||
try_load_session(&model_path)
|
||||
} else {
|
||||
tracing::warn!("model not found at {:?}; Tier 2 disabled", onnx_path);
|
||||
tracing::warn!("model not found at {:?}; Tier 2 disabled", model_path);
|
||||
(None, ExecutionProvider::Cpu)
|
||||
};
|
||||
|
||||
let tokenizer = if tok_path.exists() && session.is_some() {
|
||||
match tokenizers::Tokenizer::from_file(&tok_path) {
|
||||
let tokenizer = if tokenizer_path.exists() && session.is_some() {
|
||||
match tokenizers::Tokenizer::from_file(&tokenizer_path) {
|
||||
Ok(tok) => Some(tok),
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to load tokenizer: {}", e);
|
||||
|
|
@ -71,7 +76,7 @@ impl Classifier {
|
|||
session,
|
||||
tokenizer,
|
||||
active_provider,
|
||||
model_path: onnx_path,
|
||||
model_path,
|
||||
default_morning: default_morning.to_string(),
|
||||
ollama: None,
|
||||
}
|
||||
|
|
@ -144,6 +149,13 @@ impl Classifier {
|
|||
pub fn model_available(&self) -> bool {
|
||||
self.session.is_some()
|
||||
}
|
||||
|
||||
/// Run only the ONNX model (Tier 2) with no Tier 1 pre-processing or fallback.
|
||||
/// Returns `None` if no model is loaded.
|
||||
pub fn classify_tier2_only(&mut self, text: &str) -> Option<ClassificationResult> {
|
||||
let (session, tokenizer) = (self.session.as_mut()?, self.tokenizer.as_ref()?);
|
||||
run_onnx(session, tokenizer, text).ok()
|
||||
}
|
||||
}
|
||||
|
||||
// NLI hypotheses paired with their note types. The model scores each as
|
||||
|
|
@ -204,7 +216,7 @@ fn run_onnx(
|
|||
let best_idx = entailment_scores
|
||||
.iter()
|
||||
.enumerate()
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap())
|
||||
.max_by(|a, b| a.1.partial_cmp(b.1).unwrap_or(std::cmp::Ordering::Less))
|
||||
.map(|(i, _)| i)
|
||||
.unwrap_or(3);
|
||||
|
||||
|
|
@ -233,52 +245,34 @@ fn softmax_single(logits: &[f32], idx: usize) -> f32 {
|
|||
|
||||
fn try_load_session(
|
||||
path: &std::path::Path,
|
||||
ep_pref: &str,
|
||||
) -> (Option<ort::session::Session>, ExecutionProvider) {
|
||||
let providers: &[(&str, ExecutionProvider)] = &[
|
||||
("qnn", ExecutionProvider::Qnn),
|
||||
("vulkan", ExecutionProvider::Vulkan),
|
||||
("cpu", ExecutionProvider::Cpu),
|
||||
];
|
||||
|
||||
let to_try: Vec<&(&str, ExecutionProvider)> = match ep_pref {
|
||||
"npu" => providers[..1].iter().collect(),
|
||||
"vulkan" => providers[1..2].iter().collect(),
|
||||
"cpu" => providers[2..].iter().collect(),
|
||||
_ => providers.iter().collect(),
|
||||
};
|
||||
|
||||
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);
|
||||
}
|
||||
// Try ROCm (iGPU) first, fall back to CPU.
|
||||
match build_onnx_session(path, ort::ep::ROCm::default().build()) {
|
||||
Ok(s) => {
|
||||
tracing::info!("ONNX session loaded (ROCm iGPU)");
|
||||
return (Some(s), ExecutionProvider::Gpu);
|
||||
}
|
||||
Err(e) => tracing::debug!("ROCm EP unavailable: {}; trying CPU", e),
|
||||
}
|
||||
match build_onnx_session(path, ort::ep::CPU::default().build()) {
|
||||
Ok(s) => {
|
||||
tracing::info!("ONNX session loaded (CPU)");
|
||||
(Some(s), ExecutionProvider::Cpu)
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("failed to load ONNX session: {}; Tier 2 disabled", e);
|
||||
(None, ExecutionProvider::Cpu)
|
||||
}
|
||||
}
|
||||
|
||||
(None, ExecutionProvider::Cpu)
|
||||
}
|
||||
|
||||
fn build_session(
|
||||
fn build_onnx_session(
|
||||
path: &std::path::Path,
|
||||
ep_name: &str,
|
||||
ep: ort::ep::ExecutionProviderDispatch,
|
||||
) -> anyhow::Result<ort::session::Session> {
|
||||
match ep_name {
|
||||
"cpu" => {
|
||||
let builder = ort::session::Session::builder()
|
||||
.map_err(|e| anyhow::anyhow!("builder: {}", e))?;
|
||||
let mut builder = builder
|
||||
.with_execution_providers([ort::ep::CPU::default().build()])
|
||||
.map_err(|e| anyhow::anyhow!("ep: {}", e))?;
|
||||
let session = builder
|
||||
.commit_from_file(path)
|
||||
.map_err(|e| anyhow::anyhow!("load: {}", e))?;
|
||||
Ok(session)
|
||||
}
|
||||
_ => Err(anyhow::anyhow!("EP '{}' not available in this build", ep_name)),
|
||||
}
|
||||
let mut builder = ort::session::Session::builder()
|
||||
.map_err(|e| anyhow::anyhow!("builder: {}", e))?
|
||||
.with_execution_providers([ep])
|
||||
.map_err(|e| anyhow::anyhow!("ep: {}", e))?;
|
||||
builder.commit_from_file(path).map_err(|e| anyhow::anyhow!("load: {}", e))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,15 +11,29 @@ fn default_snooze_options() -> Vec<String> {
|
|||
fn default_archive_after_days() -> i64 { 30 }
|
||||
fn default_model_path() -> String { "~/.local/share/breadpad/model/classifier.onnx".into() }
|
||||
fn default_tokenizer_path() -> String { "~/.local/share/breadpad/model/tokenizer.json".into() }
|
||||
fn default_execution_provider() -> String { "auto".into() }
|
||||
fn default_ort_dylib_path() -> String { "".into() }
|
||||
fn default_morning_time() -> String { "08:00".into() }
|
||||
fn default_missed_grace_minutes() -> i64 { 60 }
|
||||
fn default_ollama_endpoint() -> String { "http://localhost:11434".into() }
|
||||
fn default_ollama_model() -> String { "llama3.2:3b".into() }
|
||||
fn default_ollama_model() -> String { "fastflowlm".into() }
|
||||
fn default_ollama_confidence_threshold() -> f32 { 0.6 }
|
||||
fn default_ollama_enabled() -> bool { true }
|
||||
fn default_calendar_enabled() -> bool { false }
|
||||
|
||||
pub fn expand_path(path: &str) -> PathBuf {
|
||||
if path == "~" {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home;
|
||||
}
|
||||
}
|
||||
if let Some(stripped) = path.strip_prefix("~/") {
|
||||
if let Some(home) = dirs::home_dir() {
|
||||
return home.join(stripped);
|
||||
}
|
||||
}
|
||||
PathBuf::from(path)
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct Settings {
|
||||
#[serde(default = "default_type_str")]
|
||||
|
|
@ -72,8 +86,9 @@ pub struct ModelConfig {
|
|||
pub path: String,
|
||||
#[serde(default = "default_tokenizer_path")]
|
||||
pub tokenizer: String,
|
||||
#[serde(default = "default_execution_provider")]
|
||||
pub execution_provider: String,
|
||||
/// Path to `libonnxruntime.so`. Auto-discovered when empty.
|
||||
#[serde(default = "default_ort_dylib_path")]
|
||||
pub ort_dylib_path: String,
|
||||
#[serde(default)]
|
||||
pub ollama: OllamaConfig,
|
||||
}
|
||||
|
|
@ -83,12 +98,26 @@ impl Default for ModelConfig {
|
|||
ModelConfig {
|
||||
path: default_model_path(),
|
||||
tokenizer: default_tokenizer_path(),
|
||||
execution_provider: default_execution_provider(),
|
||||
ort_dylib_path: default_ort_dylib_path(),
|
||||
ollama: OllamaConfig::default(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ModelConfig {
|
||||
pub fn resolved_paths(&self) -> (PathBuf, PathBuf) {
|
||||
(expand_path(&self.path), expand_path(&self.tokenizer))
|
||||
}
|
||||
|
||||
pub fn resolved_ort_dylib_path(&self) -> Option<PathBuf> {
|
||||
let raw = self.ort_dylib_path.trim();
|
||||
if raw.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(expand_path(raw))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct RemindersConfig {
|
||||
#[serde(default = "default_morning_time")]
|
||||
|
|
@ -114,6 +143,9 @@ pub struct CalendarConfig {
|
|||
pub url: String,
|
||||
#[serde(default)]
|
||||
pub username: String,
|
||||
/// WARNING: stored as plaintext in breadpad.toml. Restrict the file's permissions
|
||||
/// (`chmod 600 ~/.config/breadpad/breadpad.toml`) and keep it out of version control.
|
||||
/// A future release may support reading the password from the OS secret service instead.
|
||||
#[serde(default)]
|
||||
pub password: String,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,3 +7,4 @@ pub mod scheduler;
|
|||
pub mod store;
|
||||
pub mod theme;
|
||||
pub mod types;
|
||||
pub mod util;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use crate::types::{ClassificationResult, NoteType, RecurrenceRule};
|
||||
use crate::util::local_naive_to_utc;
|
||||
use chrono::{DateTime, Datelike, Duration, Local, NaiveTime, Timelike, Utc, Weekday};
|
||||
use regex::Regex;
|
||||
use std::sync::OnceLock;
|
||||
|
|
@ -22,7 +23,7 @@ static PATTERNS: OnceLock<Patterns> = OnceLock::new();
|
|||
fn patterns() -> &'static Patterns {
|
||||
PATTERNS.get_or_init(|| Patterns {
|
||||
at_time: Regex::new(r"(?i)\bat\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?").unwrap(),
|
||||
in_duration: Regex::new(r"(?i)\bin\s+(\d+)\s+(minute|hour|day)s?").unwrap(),
|
||||
in_duration: Regex::new(r"(?i)\bin\s+(\d+)\s+(second|minute|hour|day|week)s?").unwrap(),
|
||||
// Word-form durations: "in an hour", "in a couple of hours", "in half an hour"
|
||||
in_duration_word: Regex::new(
|
||||
r"(?i)\bin\s+(?:an?\s+hour|a\s+couple\s+of\s+hours?|a\s+few\s+hours?|half\s+an?\s+hour|an?\s+minutes?|a\s+couple\s+of\s+minutes?)"
|
||||
|
|
@ -100,7 +101,7 @@ fn next_occurrence_of_weekday(wd: Weekday, time: NaiveTime) -> DateTime<Utc> {
|
|||
};
|
||||
let target_date = local.date_naive() + Duration::days(days_ahead);
|
||||
let naive = target_date.and_time(time);
|
||||
naive.and_local_timezone(Local).unwrap().with_timezone(&Utc)
|
||||
local_naive_to_utc(naive)
|
||||
}
|
||||
|
||||
pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResult {
|
||||
|
|
@ -209,7 +210,7 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
|
|||
} else {
|
||||
(local.date_naive() + Duration::days(1)).and_time(t)
|
||||
};
|
||||
extracted_time = Some(naive.and_local_timezone(Local).unwrap().with_timezone(&Utc));
|
||||
extracted_time = Some(local_naive_to_utc(naive));
|
||||
let full_match = caps.get(0).unwrap().as_str();
|
||||
cleaned = cleaned.replacen(full_match, "", 1).trim().to_string();
|
||||
}
|
||||
|
|
@ -218,9 +219,11 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
|
|||
let n: i64 = caps.get(1).unwrap().as_str().parse().unwrap_or(1);
|
||||
let unit = caps.get(2).unwrap().as_str().to_lowercase();
|
||||
let delta = match unit.as_str() {
|
||||
"second" => Duration::seconds(n),
|
||||
"minute" => Duration::minutes(n),
|
||||
"hour" => Duration::hours(n),
|
||||
"day" => Duration::days(n),
|
||||
"week" => Duration::weeks(n),
|
||||
_ => Duration::minutes(n),
|
||||
};
|
||||
extracted_time = Some(Utc::now() + delta);
|
||||
|
|
@ -254,7 +257,7 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
|
|||
};
|
||||
let local = Local::now();
|
||||
let target = (local.date_naive() + Duration::days(1)).and_time(t);
|
||||
extracted_time = Some(target.and_local_timezone(Local).unwrap().with_timezone(&Utc));
|
||||
extracted_time = Some(local_naive_to_utc(target));
|
||||
cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
|
||||
}
|
||||
// One-off: next <weekday>
|
||||
|
|
@ -273,7 +276,7 @@ pub fn parse_rule_based(text: &str, default_morning: &str) -> ClassificationResu
|
|||
} else {
|
||||
(local.date_naive() + Duration::days(1)).and_time(anchor)
|
||||
};
|
||||
extracted_time = Some(target.and_local_timezone(Local).unwrap().with_timezone(&Utc));
|
||||
extracted_time = Some(local_naive_to_utc(target));
|
||||
cleaned = cleaned.replacen(m.as_str(), "", 1).trim().to_string();
|
||||
}
|
||||
}
|
||||
|
|
@ -860,6 +863,23 @@ fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType {
|
|||
|| lower.starts_with("finish ")
|
||||
|| lower.starts_with("write ")
|
||||
|| lower.starts_with("update ")
|
||||
|| lower.starts_with("prepare ")
|
||||
|| lower.starts_with("schedule ")
|
||||
|| lower.starts_with("organize ")
|
||||
|| lower.starts_with("deploy ")
|
||||
|| lower.starts_with("install ")
|
||||
|| lower.starts_with("send ")
|
||||
|| lower.starts_with("submit ")
|
||||
|| lower.starts_with("create ")
|
||||
|| lower.starts_with("setup ")
|
||||
|| lower.starts_with("restore ")
|
||||
|| lower.starts_with("archive ")
|
||||
|| lower.starts_with("export ")
|
||||
|| lower.starts_with("import ")
|
||||
|| lower.starts_with("approve ")
|
||||
|| lower.starts_with("configure ")
|
||||
|| lower.starts_with("refactor ")
|
||||
|| lower.starts_with("review ")
|
||||
{
|
||||
return NoteType::Todo;
|
||||
}
|
||||
|
|
@ -867,13 +887,21 @@ fn infer_type(text: &str, has_time: bool, has_rrule: bool) -> NoteType {
|
|||
|| lower.starts_with("idea:")
|
||||
|| lower.contains("could ")
|
||||
|| lower.contains("maybe ")
|
||||
|| lower.contains("should we ")
|
||||
|| lower.starts_with("should we ")
|
||||
{
|
||||
return NoteType::Idea;
|
||||
}
|
||||
if lower.starts_with("why ")
|
||||
|| lower.starts_with("how ")
|
||||
|| lower.starts_with("what ")
|
||||
|| (lower.starts_with("what ") && !lower.starts_with("what if "))
|
||||
|| lower.starts_with("when ")
|
||||
|| lower.starts_with("where ")
|
||||
|| lower.starts_with("who ")
|
||||
|| lower.starts_with("will ")
|
||||
|| lower.starts_with("is ")
|
||||
|| lower.starts_with("are ")
|
||||
|| lower.starts_with("did ")
|
||||
|| lower.starts_with("does ")
|
||||
|| lower.ends_with('?')
|
||||
{
|
||||
return NoteType::Question;
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
use crate::types::Note;
|
||||
use crate::util::local_naive_to_utc;
|
||||
use anyhow::{Context, Result};
|
||||
use chrono::{DateTime, Duration, Local, NaiveTime, Utc};
|
||||
use std::process::Command;
|
||||
|
|
@ -59,27 +60,63 @@ fn create_timer(id: &str, fire_time: DateTime<Utc>) -> Result<()> {
|
|||
|
||||
let timer_name = timer_unit_name(id);
|
||||
|
||||
// Find the breadpad binary. Order of preference:
|
||||
// 1. $BREADPAD_BIN override,
|
||||
// 2. a `breadpad` next to the currently running executable,
|
||||
// 3. standard install locations.
|
||||
let breadpad_exe = std::env::var_os("BREADPAD_BIN")
|
||||
.map(std::path::PathBuf::from)
|
||||
.filter(|p| p.exists())
|
||||
.or_else(|| {
|
||||
std::env::current_exe()
|
||||
.ok()
|
||||
.and_then(|exe| exe.parent().map(|p| p.join("breadpad")))
|
||||
.filter(|p| p.exists())
|
||||
})
|
||||
.or_else(|| {
|
||||
let home_bin = dirs::home_dir().map(|h| h.join(".local/bin/breadpad"));
|
||||
["/usr/local/bin/breadpad", "/usr/bin/breadpad"]
|
||||
.iter()
|
||||
.map(std::path::PathBuf::from)
|
||||
.chain(home_bin)
|
||||
.find(|p| p.exists())
|
||||
})
|
||||
.context("breadpad binary not found in $BREADPAD_BIN, alongside this executable, or in standard locations")?;
|
||||
|
||||
// Use systemd-run to create both service + timer as a transient unit
|
||||
let status = Command::new("systemd-run")
|
||||
.arg("--user")
|
||||
// Pass necessary environment variables for notifications to work
|
||||
let mut cmd = Command::new("systemd-run");
|
||||
cmd.arg("--user")
|
||||
.arg("--unit")
|
||||
.arg(&timer_name.strip_suffix(".timer").unwrap_or(&timer_name))
|
||||
.arg(timer_name.strip_suffix(".timer").unwrap_or(&timer_name))
|
||||
.arg("--timer-property")
|
||||
.arg(format!("OnCalendar={}", on_calendar))
|
||||
.arg("--timer-property")
|
||||
.arg("Persistent=true")
|
||||
.arg("--")
|
||||
.arg("breadpad")
|
||||
.arg("Persistent=true");
|
||||
|
||||
// Pass DBUS and display environment variables so notify-send works
|
||||
if let Ok(dbus) = std::env::var("DBUS_SESSION_BUS_ADDRESS") {
|
||||
cmd.arg("--setenv").arg(format!("DBUS_SESSION_BUS_ADDRESS={}", dbus));
|
||||
}
|
||||
if let Ok(display) = std::env::var("DISPLAY") {
|
||||
cmd.arg("--setenv").arg(format!("DISPLAY={}", display));
|
||||
}
|
||||
if let Ok(wayland) = std::env::var("WAYLAND_DISPLAY") {
|
||||
cmd.arg("--setenv").arg(format!("WAYLAND_DISPLAY={}", wayland));
|
||||
}
|
||||
|
||||
cmd.arg("--")
|
||||
.arg(&breadpad_exe)
|
||||
.arg("fire")
|
||||
.arg(id)
|
||||
.status()
|
||||
.context("failed to run systemd-run")?;
|
||||
.arg(id);
|
||||
|
||||
let status = cmd.status().context("failed to run systemd-run")?;
|
||||
|
||||
if !status.success() {
|
||||
anyhow::bail!("systemd-run failed for reminder {}", id);
|
||||
}
|
||||
|
||||
tracing::info!("scheduled reminder {} at {}", id, on_calendar);
|
||||
tracing::info!("scheduled reminder {} at {} using {}", id, on_calendar, breadpad_exe.display());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
|
@ -131,17 +168,15 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O
|
|||
let now = Local::now();
|
||||
let fire_time = NaiveTime::from_hms_opt(hour, minute, 0)?;
|
||||
|
||||
let next = match freq {
|
||||
match freq {
|
||||
"DAILY" => {
|
||||
let today = now.date_naive().and_time(fire_time);
|
||||
if now.naive_local() < today {
|
||||
today.and_local_timezone(Local).unwrap()
|
||||
let naive = if now.naive_local() < today {
|
||||
today
|
||||
} else {
|
||||
(now.date_naive() + chrono::Duration::days(1))
|
||||
.and_time(fire_time)
|
||||
.and_local_timezone(Local)
|
||||
.unwrap()
|
||||
}
|
||||
(now.date_naive() + chrono::Duration::days(1)).and_time(fire_time)
|
||||
};
|
||||
return Some(local_naive_to_utc(naive));
|
||||
}
|
||||
"WEEKLY" => {
|
||||
use chrono::Datelike;
|
||||
|
|
@ -169,12 +204,10 @@ pub(crate) fn parse_next_from_rrule(rrule_str: &str, default_morning: &str) -> O
|
|||
};
|
||||
let target_date =
|
||||
(now.date_naive() + chrono::Duration::days(days_ahead)).and_time(fire_time);
|
||||
target_date.and_local_timezone(Local).unwrap()
|
||||
return Some(local_naive_to_utc(target_date));
|
||||
}
|
||||
_ => return None,
|
||||
};
|
||||
|
||||
Some(next.with_timezone(&Utc))
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
|
@ -331,6 +364,21 @@ mod tests {
|
|||
assert_eq!(local.weekday(), chrono::Weekday::Sat);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_tuesday_is_tuesday() {
|
||||
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=TU;BYHOUR=10;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
|
||||
let local: chrono::DateTime<Local> = t.into();
|
||||
assert_eq!(local.weekday(), chrono::Weekday::Tue);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_thursday_is_thursday() {
|
||||
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=TH;BYHOUR=11;BYMINUTE=30;BYSECOND=0", "08:00").unwrap();
|
||||
let local: chrono::DateTime<Local> = t.into();
|
||||
assert_eq!(local.weekday(), chrono::Weekday::Thu);
|
||||
assert_eq!(local.minute(), 30);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_sunday_is_sunday() {
|
||||
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=SU;BYHOUR=19;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
|
||||
|
|
@ -338,6 +386,22 @@ mod tests {
|
|||
assert_eq!(local.weekday(), chrono::Weekday::Sun);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn weekly_unknown_byday_falls_back_to_sunday() {
|
||||
// The match arm `_ => Weekday::Sun` handles unrecognised BYDAY values
|
||||
let t = parse_next_from_rrule("RRULE:FREQ=WEEKLY;BYDAY=XX;BYHOUR=9;BYMINUTE=0;BYSECOND=0", "08:00").unwrap();
|
||||
let local: chrono::DateTime<Local> = t.into();
|
||||
assert_eq!(local.weekday(), chrono::Weekday::Sun);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn daily_without_byhour_uses_default_morning() {
|
||||
let t = parse_next_from_rrule("RRULE:FREQ=DAILY", "06:45").unwrap();
|
||||
let local: chrono::DateTime<Local> = t.into();
|
||||
assert_eq!(local.hour(), 6);
|
||||
assert_eq!(local.minute(), 45);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_freq_returns_none() {
|
||||
assert!(parse_next_from_rrule("RRULE:FREQ=MONTHLY;BYHOUR=9;BYMINUTE=0", "08:00").is_none());
|
||||
|
|
|
|||
|
|
@ -36,6 +36,14 @@ impl Store {
|
|||
self
|
||||
}
|
||||
|
||||
pub fn with_calendar_if_enabled(self, cfg: &crate::config::Config) -> Self {
|
||||
if cfg.calendar.enabled {
|
||||
self.with_calendar(cfg.calendar.clone())
|
||||
} else {
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
pub fn load_all(&self) -> Result<Vec<Note>> {
|
||||
self.load_from(&self.notes_path)
|
||||
}
|
||||
|
|
@ -84,12 +92,14 @@ impl Store {
|
|||
|
||||
pub fn update_note(&self, updated: &Note) -> Result<()> {
|
||||
self.rewrite_notes(|note| {
|
||||
if note.id == updated.id {
|
||||
updated.clone()
|
||||
} else {
|
||||
note
|
||||
if note.id == updated.id { updated.clone() } else { note }
|
||||
})?;
|
||||
if let Some(cal_cfg) = &self.calendar {
|
||||
if cal_cfg.enabled && (updated.time.is_some() || updated.rrule.is_some()) {
|
||||
spawn_caldav_push(updated.clone(), cal_cfg.clone());
|
||||
}
|
||||
})
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn delete_note(&self, id: &str) -> Result<()> {
|
||||
|
|
|
|||
|
|
@ -91,19 +91,23 @@ pub fn build_css(palette: &Palette, user_css: Option<&str>) -> String {
|
|||
@define-color teal {c6};
|
||||
@define-color overlay {c0};
|
||||
|
||||
* {{
|
||||
font-family: 'Varela Round', sans-serif;
|
||||
}}
|
||||
|
||||
window {{
|
||||
background-color: @bg;
|
||||
color: @fg;
|
||||
border-radius: 12px;
|
||||
border-radius: 8px;
|
||||
}}
|
||||
|
||||
.popup-entry {{
|
||||
background: @bg;
|
||||
color: @fg;
|
||||
border: 2px solid @blue;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
padding: 12px 16px;
|
||||
font-size: 16px;
|
||||
font-size: 14px;
|
||||
caret-color: @fg;
|
||||
}}
|
||||
|
||||
|
|
@ -116,9 +120,9 @@ window {{
|
|||
background: @overlay;
|
||||
color: @fg;
|
||||
border-radius: 999px;
|
||||
padding: 2px 10px;
|
||||
padding: 4px 12px;
|
||||
font-size: 12px;
|
||||
margin: 2px;
|
||||
margin: 4px;
|
||||
}}
|
||||
|
||||
.type-chip.active {{
|
||||
|
|
@ -127,7 +131,7 @@ window {{
|
|||
}}
|
||||
|
||||
.confirm-button {{
|
||||
background: @green;
|
||||
background: @blue;
|
||||
color: @bg;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
|
|
@ -139,7 +143,7 @@ window {{
|
|||
background: shade(@bg, 1.1);
|
||||
border-radius: 8px;
|
||||
padding: 12px;
|
||||
margin: 4px 8px;
|
||||
margin: 8px;
|
||||
border-left: 3px solid @blue;
|
||||
}}
|
||||
|
||||
|
|
@ -151,9 +155,13 @@ window {{
|
|||
background: shade(@bg, 1.1);
|
||||
color: @fg;
|
||||
border: 1px solid @overlay;
|
||||
border-radius: 8px;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
margin: 8px;
|
||||
}}
|
||||
|
||||
.search-entry:focus {{
|
||||
border-color: @blue;
|
||||
outline: none;
|
||||
}}
|
||||
"#,
|
||||
bg = palette.background,
|
||||
|
|
@ -178,25 +186,27 @@ window {{
|
|||
}
|
||||
|
||||
.sidebar-row {
|
||||
padding: 6px 12px;
|
||||
padding: 8px 12px;
|
||||
font-size: 14px;
|
||||
transition: background 100ms ease;
|
||||
}
|
||||
|
||||
.sidebar-row:hover:not(:selected) {
|
||||
background: shade(@bg, 1.08);
|
||||
background: shade(@bg, 1.1);
|
||||
}
|
||||
|
||||
.sidebar-row:selected {
|
||||
background: @blue;
|
||||
color: @bg;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sidebar-section-label {
|
||||
color: alpha(@fg, 0.4);
|
||||
font-size: 10px;
|
||||
font-weight: bold;
|
||||
padding: 10px 14px 2px 14px;
|
||||
letter-spacing: 1px;
|
||||
color: alpha(@fg, 0.5);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
padding: 12px 12px 8px 12px;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
|
|
@ -228,6 +238,62 @@ window {{
|
|||
.note-card-question { border-left-color: @teal; }
|
||||
.note-card-note { border-left-color: @blue; }
|
||||
|
||||
.reminder-window {
|
||||
background: @bg;
|
||||
border: 1px solid @overlay;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.reminder-emoji { font-size: 20px; }
|
||||
|
||||
.reminder-title {
|
||||
font-size: 12px;
|
||||
font-weight: bold;
|
||||
color: alpha(@fg, 0.6);
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.reminder-time {
|
||||
font-size: 12px;
|
||||
color: alpha(@fg, 0.5);
|
||||
}
|
||||
|
||||
.reminder-body {
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
color: @fg;
|
||||
}
|
||||
|
||||
.reminder-dismiss {
|
||||
background: transparent;
|
||||
border: 1px solid @overlay;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
color: alpha(@fg, 0.6);
|
||||
}
|
||||
|
||||
.reminder-dismiss:hover { background: shade(@bg, 1.1); }
|
||||
|
||||
.reminder-snooze {
|
||||
background: transparent;
|
||||
border: 1px solid @overlay;
|
||||
border-radius: 8px;
|
||||
padding: 8px 16px;
|
||||
color: @fg;
|
||||
}
|
||||
|
||||
.reminder-snooze:hover { background: shade(@bg, 1.1); }
|
||||
|
||||
.snooze-option {
|
||||
background: transparent;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
padding: 8px 12px;
|
||||
color: @fg;
|
||||
}
|
||||
|
||||
.snooze-option:hover { background: shade(@bg, 1.2); }
|
||||
|
||||
entry {
|
||||
background: shade(@bg, 1.1);
|
||||
color: @fg;
|
||||
|
|
|
|||
|
|
@ -72,7 +72,9 @@ pub struct Note {
|
|||
pub done: bool,
|
||||
pub workspace: Option<String>,
|
||||
pub created: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
pub snoozed_until: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub completed: Option<DateTime<Utc>>,
|
||||
#[serde(default)]
|
||||
pub tags: Vec<String>,
|
||||
|
|
@ -83,10 +85,14 @@ pub struct Note {
|
|||
impl Note {
|
||||
pub fn new(body: String, note_type: NoteType, workspace: Option<String>) -> Self {
|
||||
Note {
|
||||
// 12 hex chars (~48 bits) keeps IDs short and human-typable while making
|
||||
// collisions vanishingly unlikely — important because update/delete/get_by_id
|
||||
// all match notes purely by this id.
|
||||
id: uuid::Uuid::new_v4()
|
||||
.simple()
|
||||
.to_string()
|
||||
.chars()
|
||||
.take(6)
|
||||
.take(12)
|
||||
.collect(),
|
||||
body,
|
||||
note_type,
|
||||
|
|
@ -250,10 +256,15 @@ mod tests {
|
|||
}
|
||||
|
||||
#[test]
|
||||
fn note_id_is_six_chars() {
|
||||
fn note_id_is_twelve_chars() {
|
||||
for _ in 0..50 {
|
||||
let note = Note::new("x".into(), NoteType::Note, None);
|
||||
assert_eq!(note.id.len(), 6, "id '{}' is not 6 chars", note.id);
|
||||
assert_eq!(note.id.len(), 12, "id '{}' is not 12 chars", note.id);
|
||||
assert!(
|
||||
note.id.chars().all(|c| c.is_ascii_hexdigit()),
|
||||
"id '{}' is not all hex",
|
||||
note.id
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
63
breadpad-shared/src/util.rs
Normal file
63
breadpad-shared/src/util.rs
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
use chrono::{DateTime, Duration, Local, LocalResult, NaiveDateTime, TimeZone, Utc};
|
||||
|
||||
/// Resolve a naive *local* datetime to UTC without panicking on DST transitions.
|
||||
///
|
||||
/// `NaiveDateTime::and_local_timezone` (and `Local.from_local_datetime`) returns a
|
||||
/// `LocalResult`, which is not always `Single`:
|
||||
/// - `Single` — the normal case.
|
||||
/// - `Ambiguous` (a fall-back hour that occurs twice) — pick the earliest instant.
|
||||
/// - `None` (a spring-forward gap where the wall-clock time never happens) — advance
|
||||
/// an hour at a time until a valid instant is found, then fall back to treating the
|
||||
/// naive value as UTC.
|
||||
///
|
||||
/// Calling `.unwrap()` on the `None`/`Ambiguous` cases panics, which is what this helper
|
||||
/// exists to avoid (it bit us on the ~2 DST transition days per year).
|
||||
pub fn local_naive_to_utc(naive: NaiveDateTime) -> DateTime<Utc> {
|
||||
match Local.from_local_datetime(&naive) {
|
||||
LocalResult::Single(dt) => dt.with_timezone(&Utc),
|
||||
LocalResult::Ambiguous(earliest, _latest) => earliest.with_timezone(&Utc),
|
||||
LocalResult::None => {
|
||||
let mut shifted = naive;
|
||||
for _ in 0..3 {
|
||||
shifted += Duration::hours(1);
|
||||
if let LocalResult::Single(dt) = Local.from_local_datetime(&shifted) {
|
||||
return dt.with_timezone(&Utc);
|
||||
}
|
||||
}
|
||||
// Last resort: interpret the wall-clock value as UTC so we still return a time.
|
||||
Utc.from_utc_datetime(&naive)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use chrono::NaiveDate;
|
||||
|
||||
#[test]
|
||||
fn ordinary_time_round_trips() {
|
||||
let naive = NaiveDate::from_ymd_opt(2026, 6, 15)
|
||||
.unwrap()
|
||||
.and_hms_opt(9, 30, 0)
|
||||
.unwrap();
|
||||
let utc = local_naive_to_utc(naive);
|
||||
// Converting back to local should yield the same wall-clock time.
|
||||
let local: DateTime<Local> = utc.with_timezone(&Local);
|
||||
assert_eq!(local.naive_local(), naive);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn never_panics_across_a_full_year_of_hours() {
|
||||
// Walk every hour of a year through the helper; it must never panic regardless
|
||||
// of the host timezone's DST rules.
|
||||
let mut dt = NaiveDate::from_ymd_opt(2026, 1, 1)
|
||||
.unwrap()
|
||||
.and_hms_opt(0, 0, 0)
|
||||
.unwrap();
|
||||
for _ in 0..(366 * 24) {
|
||||
let _ = local_naive_to_utc(dt);
|
||||
dt += Duration::hours(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,16 +1,27 @@
|
|||
use breadpad_shared::classifier::Classifier;
|
||||
use breadpad_shared::classifier::{Classifier, ExecutionProvider};
|
||||
use breadpad_shared::types::NoteType;
|
||||
use chrono::Timelike;
|
||||
|
||||
fn cl() -> Classifier {
|
||||
Classifier::load("auto", "08:00")
|
||||
Classifier::load("08:00")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn active_provider_is_cpu() {
|
||||
// QNN and Vulkan EPs are not compiled in; CPU is always the fallback.
|
||||
fn active_provider_is_valid() {
|
||||
// The active provider depends on the host: a machine with the ONNX model present and
|
||||
// a working ROCm iGPU loads `Gpu`, otherwise `Cpu`. Either is valid — but when no
|
||||
// model is available we must be on CPU (no session => no GPU EP in use).
|
||||
let c = cl();
|
||||
assert_eq!(c.active_provider, breadpad_shared::classifier::ExecutionProvider::Cpu);
|
||||
assert!(matches!(
|
||||
c.active_provider,
|
||||
ExecutionProvider::Cpu | ExecutionProvider::Gpu
|
||||
));
|
||||
if !c.model_available() {
|
||||
assert!(
|
||||
matches!(c.active_provider, ExecutionProvider::Cpu),
|
||||
"no model loaded but provider was not CPU"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -63,7 +74,7 @@ fn classify_recurrence_via_fallback() {
|
|||
|
||||
#[test]
|
||||
fn classify_custom_morning_time() {
|
||||
let mut c = Classifier::load("auto", "07:15");
|
||||
let mut c = Classifier::load("07:15");
|
||||
let r = c.classify("sync tomorrow morning");
|
||||
let t = r.time.expect("should have a time for tomorrow morning");
|
||||
let local: chrono::DateTime<chrono::Local> = t.into();
|
||||
|
|
@ -71,6 +82,41 @@ fn classify_custom_morning_time() {
|
|||
assert_eq!(local.minute(), 15);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_empty_string_does_not_panic() {
|
||||
let mut c = cl();
|
||||
let _ = c.classify("");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_whitespace_only_does_not_panic() {
|
||||
let mut c = cl();
|
||||
let _ = c.classify(" ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_in_duration_sets_time() {
|
||||
let mut c = cl();
|
||||
let r = c.classify("take a break in 30 minutes");
|
||||
assert!(r.time.is_some(), "should have a time for 'in 30 minutes'");
|
||||
assert_eq!(r.note_type, NoteType::Reminder);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_tomorrow_sets_time() {
|
||||
let mut c = cl();
|
||||
let r = c.classify("submit the invoice tomorrow");
|
||||
assert!(r.time.is_some(), "tomorrow should produce a scheduled time");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn classify_returns_cleaned_body() {
|
||||
let mut c = cl();
|
||||
let r = c.classify("call mum at 6pm");
|
||||
assert!(r.body.contains("call mum"), "body: {}", r.body);
|
||||
assert!(!r.body.contains("6pm"), "time phrase should be stripped from body: {}", r.body);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_path_points_to_expected_location() {
|
||||
let c = cl();
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
use breadpad_shared::config::{Config, ModelConfig, RemindersConfig, Settings};
|
||||
use breadpad_shared::config::{expand_path, CalendarConfig, Config, ModelConfig, RemindersConfig, Settings};
|
||||
use tempfile::TempDir;
|
||||
|
||||
// ---- Default values ----
|
||||
|
|
@ -22,9 +22,9 @@ fn default_snooze_options_contains_all_three() {
|
|||
#[test]
|
||||
fn default_model_config() {
|
||||
let m = ModelConfig::default();
|
||||
assert_eq!(m.execution_provider, "auto");
|
||||
assert!(m.path.contains("classifier.onnx"));
|
||||
assert!(m.tokenizer.contains("tokenizer.json"));
|
||||
assert_eq!(m.ort_dylib_path, "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -38,7 +38,6 @@ fn default_reminders_config() {
|
|||
fn default_config_composes_defaults() {
|
||||
let cfg = Config::default();
|
||||
assert_eq!(cfg.settings.default_type, "note");
|
||||
assert_eq!(cfg.model.execution_provider, "auto");
|
||||
assert_eq!(cfg.reminders.default_morning, "08:00");
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +55,7 @@ archive_after_days = 7
|
|||
[model]
|
||||
path = "/tmp/classifier.onnx"
|
||||
tokenizer = "/tmp/tokenizer.json"
|
||||
execution_provider = "cpu"
|
||||
ort_dylib_path = "/tmp/libonnxruntime.so"
|
||||
|
||||
[reminders]
|
||||
default_morning = "07:30"
|
||||
|
|
@ -67,8 +66,8 @@ missed_grace_minutes = 30
|
|||
assert!(!cfg.settings.workspace_tag);
|
||||
assert_eq!(cfg.settings.snooze_options, vec!["15m", "2h"]);
|
||||
assert_eq!(cfg.settings.archive_after_days, 7);
|
||||
assert_eq!(cfg.model.execution_provider, "cpu");
|
||||
assert_eq!(cfg.model.path, "/tmp/classifier.onnx");
|
||||
assert_eq!(cfg.model.ort_dylib_path, "/tmp/libonnxruntime.so");
|
||||
assert_eq!(cfg.reminders.default_morning, "07:30");
|
||||
assert_eq!(cfg.reminders.missed_grace_minutes, 30);
|
||||
}
|
||||
|
|
@ -78,7 +77,6 @@ fn empty_toml_uses_all_defaults() {
|
|||
let cfg: Config = toml::from_str("").unwrap();
|
||||
assert_eq!(cfg.settings.default_type, "note");
|
||||
assert!(cfg.settings.workspace_tag);
|
||||
assert_eq!(cfg.model.execution_provider, "auto");
|
||||
assert_eq!(cfg.reminders.default_morning, "08:00");
|
||||
}
|
||||
|
||||
|
|
@ -90,31 +88,9 @@ default_type = "reminder"
|
|||
"#;
|
||||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(cfg.settings.default_type, "reminder");
|
||||
// Other sections should still have defaults
|
||||
assert_eq!(cfg.model.execution_provider, "auto");
|
||||
assert_eq!(cfg.reminders.default_morning, "08:00");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_toml_only_model_section() {
|
||||
let toml = r#"
|
||||
[model]
|
||||
execution_provider = "npu"
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(cfg.model.execution_provider, "npu");
|
||||
assert_eq!(cfg.settings.default_type, "note");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn execution_provider_variants_accepted() {
|
||||
for ep in &["auto", "npu", "vulkan", "cpu"] {
|
||||
let toml = format!("[model]\nexecution_provider = \"{}\"", ep);
|
||||
let cfg: Config = toml::from_str(&toml).unwrap();
|
||||
assert_eq!(cfg.model.execution_provider, *ep);
|
||||
}
|
||||
}
|
||||
|
||||
// ---- TOML serialization round-trip ----
|
||||
|
||||
#[test]
|
||||
|
|
@ -124,7 +100,6 @@ fn default_config_serializes_to_valid_toml() {
|
|||
let reparsed: Config = toml::from_str(&serialized).unwrap();
|
||||
assert_eq!(reparsed.settings.default_type, cfg.settings.default_type);
|
||||
assert_eq!(reparsed.settings.workspace_tag, cfg.settings.workspace_tag);
|
||||
assert_eq!(reparsed.model.execution_provider, cfg.model.execution_provider);
|
||||
assert_eq!(reparsed.reminders.default_morning, cfg.reminders.default_morning);
|
||||
}
|
||||
|
||||
|
|
@ -133,7 +108,6 @@ fn custom_config_round_trips() {
|
|||
let mut cfg = Config::default();
|
||||
cfg.settings.default_type = "idea".into();
|
||||
cfg.settings.archive_after_days = 14;
|
||||
cfg.model.execution_provider = "vulkan".into();
|
||||
cfg.reminders.default_morning = "06:45".into();
|
||||
cfg.reminders.missed_grace_minutes = 120;
|
||||
|
||||
|
|
@ -141,7 +115,6 @@ fn custom_config_round_trips() {
|
|||
let rt: Config = toml::from_str(&toml).unwrap();
|
||||
assert_eq!(rt.settings.default_type, "idea");
|
||||
assert_eq!(rt.settings.archive_after_days, 14);
|
||||
assert_eq!(rt.model.execution_provider, "vulkan");
|
||||
assert_eq!(rt.reminders.default_morning, "06:45");
|
||||
assert_eq!(rt.reminders.missed_grace_minutes, 120);
|
||||
}
|
||||
|
|
@ -155,24 +128,20 @@ fn save_and_load_round_trip() {
|
|||
|
||||
let mut cfg = Config::default();
|
||||
cfg.settings.default_type = "question".into();
|
||||
cfg.model.execution_provider = "cpu".into();
|
||||
cfg.reminders.missed_grace_minutes = 45;
|
||||
|
||||
// Manually save to a known path (Config::save uses the fixed XDG path,
|
||||
// so we use toml serialization + write here to test the round-trip logic)
|
||||
let toml = toml::to_string_pretty(&cfg).unwrap();
|
||||
std::fs::write(&config_path, &toml).unwrap();
|
||||
|
||||
let loaded: Config = toml::from_str(&std::fs::read_to_string(&config_path).unwrap()).unwrap();
|
||||
assert_eq!(loaded.settings.default_type, "question");
|
||||
assert_eq!(loaded.model.execution_provider, "cpu");
|
||||
assert_eq!(loaded.reminders.missed_grace_minutes, 45);
|
||||
}
|
||||
|
||||
// ---- The example from the README ----
|
||||
|
||||
#[test]
|
||||
fn readme_example_toml_parses() {
|
||||
fn example_toml_parses() {
|
||||
let toml = r#"
|
||||
[settings]
|
||||
default_type = "note"
|
||||
|
|
@ -183,7 +152,7 @@ archive_after_days = 30
|
|||
[model]
|
||||
path = "~/.local/share/breadpad/model/classifier.onnx"
|
||||
tokenizer = "~/.local/share/breadpad/model/tokenizer.json"
|
||||
execution_provider = "auto"
|
||||
ort_dylib_path = ""
|
||||
|
||||
[reminders]
|
||||
default_morning = "08:00"
|
||||
|
|
@ -192,7 +161,146 @@ missed_grace_minutes = 60
|
|||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert_eq!(cfg.settings.default_type, "note");
|
||||
assert!(cfg.settings.workspace_tag);
|
||||
assert_eq!(cfg.model.execution_provider, "auto");
|
||||
assert_eq!(cfg.reminders.default_morning, "08:00");
|
||||
assert_eq!(cfg.reminders.missed_grace_minutes, 60);
|
||||
}
|
||||
|
||||
// ---- CalendarConfig ----
|
||||
|
||||
#[test]
|
||||
fn default_calendar_config_is_disabled() {
|
||||
let c = CalendarConfig::default();
|
||||
assert!(!c.enabled);
|
||||
assert!(c.url.is_empty());
|
||||
assert!(c.username.is_empty());
|
||||
assert!(c.password.is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_config_from_toml() {
|
||||
let toml = r#"
|
||||
[calendar]
|
||||
enabled = true
|
||||
url = "https://cloud.example.com/remote.php/dav/calendars/user/personal/"
|
||||
username = "user"
|
||||
password = "secret"
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert!(cfg.calendar.enabled);
|
||||
assert!(cfg.calendar.url.contains("dav/calendars"));
|
||||
assert_eq!(cfg.calendar.username, "user");
|
||||
assert_eq!(cfg.calendar.password, "secret");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn calendar_config_round_trips() {
|
||||
let mut cfg = Config::default();
|
||||
cfg.calendar.enabled = true;
|
||||
cfg.calendar.url = "https://example.com/cal".into();
|
||||
cfg.calendar.username = "alice".into();
|
||||
cfg.calendar.password = "hunter2".into();
|
||||
|
||||
let toml = toml::to_string_pretty(&cfg).unwrap();
|
||||
let rt: Config = toml::from_str(&toml).unwrap();
|
||||
assert!(rt.calendar.enabled);
|
||||
assert_eq!(rt.calendar.url, "https://example.com/cal");
|
||||
assert_eq!(rt.calendar.username, "alice");
|
||||
assert_eq!(rt.calendar.password, "hunter2");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_config_calendar_disabled() {
|
||||
let cfg = Config::default();
|
||||
assert!(!cfg.calendar.enabled);
|
||||
}
|
||||
|
||||
// ---- OllamaConfig ----
|
||||
|
||||
#[test]
|
||||
fn default_ollama_config_enabled() {
|
||||
let m = ModelConfig::default();
|
||||
assert!(m.ollama.enabled);
|
||||
assert_eq!(m.ollama.endpoint, "http://localhost:11434");
|
||||
assert!(!m.ollama.model.is_empty());
|
||||
assert!(m.ollama.confidence_threshold > 0.0 && m.ollama.confidence_threshold <= 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ollama_config_from_toml() {
|
||||
let toml = r#"
|
||||
[model.ollama]
|
||||
enabled = false
|
||||
endpoint = "http://localhost:9999"
|
||||
model = "llama3"
|
||||
confidence_threshold = 0.8
|
||||
"#;
|
||||
let cfg: Config = toml::from_str(toml).unwrap();
|
||||
assert!(!cfg.model.ollama.enabled);
|
||||
assert_eq!(cfg.model.ollama.endpoint, "http://localhost:9999");
|
||||
assert_eq!(cfg.model.ollama.model, "llama3");
|
||||
assert!((cfg.model.ollama.confidence_threshold - 0.8).abs() < 1e-5);
|
||||
}
|
||||
|
||||
// ---- expand_path ----
|
||||
|
||||
#[test]
|
||||
fn expand_path_tilde_prefix_replaced_with_home() {
|
||||
let home = dirs::home_dir().unwrap();
|
||||
let expanded = expand_path("~/some/path");
|
||||
assert!(expanded.starts_with(&home));
|
||||
assert!(expanded.ends_with("some/path"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_path_bare_tilde_is_home() {
|
||||
let home = dirs::home_dir().unwrap();
|
||||
assert_eq!(expand_path("~"), home);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_path_absolute_path_unchanged() {
|
||||
let p = expand_path("/usr/local/bin/breadpad");
|
||||
assert_eq!(p.to_str().unwrap(), "/usr/local/bin/breadpad");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn expand_path_relative_path_unchanged() {
|
||||
let p = expand_path("relative/path");
|
||||
assert_eq!(p.to_str().unwrap(), "relative/path");
|
||||
}
|
||||
|
||||
// ---- ModelConfig::resolved_ort_dylib_path ----
|
||||
|
||||
#[test]
|
||||
fn resolved_ort_dylib_empty_returns_none() {
|
||||
let m = ModelConfig::default();
|
||||
assert!(m.resolved_ort_dylib_path().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_ort_dylib_whitespace_only_returns_none() {
|
||||
let mut m = ModelConfig::default();
|
||||
m.ort_dylib_path = " ".into();
|
||||
assert!(m.resolved_ort_dylib_path().is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolved_ort_dylib_set_returns_some() {
|
||||
let mut m = ModelConfig::default();
|
||||
m.ort_dylib_path = "/usr/lib/libonnxruntime.so".into();
|
||||
assert_eq!(
|
||||
m.resolved_ort_dylib_path().unwrap().to_str().unwrap(),
|
||||
"/usr/lib/libonnxruntime.so"
|
||||
);
|
||||
}
|
||||
|
||||
// ---- ModelConfig::resolved_paths ----
|
||||
|
||||
#[test]
|
||||
fn resolved_paths_expands_tildes() {
|
||||
let m = ModelConfig::default();
|
||||
let (model, tokenizer) = m.resolved_paths();
|
||||
let home = dirs::home_dir().unwrap();
|
||||
assert!(model.starts_with(&home), "model path should be under home: {:?}", model);
|
||||
assert!(tokenizer.starts_with(&home), "tokenizer path should be under home: {:?}", tokenizer);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use tempfile::TempDir;
|
|||
// Mirrors commit_note() in breadpad/src/main.rs.
|
||||
// `user_type` is the type the user selected in the chip row (default = NoteType::Note).
|
||||
fn capture(store: &Store, text: &str, user_type: NoteType) -> Note {
|
||||
let mut classifier = Classifier::load("auto", "08:00");
|
||||
let mut classifier = Classifier::load("08:00");
|
||||
let result = classifier.classify(text);
|
||||
|
||||
let mut note = Note::new(text.into(), user_type.clone(), None);
|
||||
|
|
|
|||
|
|
@ -301,6 +301,51 @@ fn rotate_archive_zero_when_nothing_qualifies() {
|
|||
assert_eq!(store.load_all().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_note_just_inside_boundary_stays() {
|
||||
let (_dir, store) = mk();
|
||||
// 29 days ago — threshold is 30 — should NOT be archived
|
||||
let mut n = note("fresh enough", NoteType::Todo);
|
||||
n.done = true;
|
||||
n.completed = Some(Utc::now() - Duration::days(29));
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
assert_eq!(store.rotate_archive(30).unwrap(), 0);
|
||||
assert_eq!(store.load_all().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_note_just_past_boundary_is_archived() {
|
||||
let (_dir, store) = mk();
|
||||
// 31 days ago — threshold is 30 — should be archived
|
||||
let mut n = note("old enough", NoteType::Todo);
|
||||
n.done = true;
|
||||
n.completed = Some(Utc::now() - Duration::days(31));
|
||||
store.save_note(&n).unwrap();
|
||||
|
||||
assert_eq!(store.rotate_archive(30).unwrap(), 1);
|
||||
assert!(store.load_all().unwrap().is_empty());
|
||||
assert_eq!(store.load_archive().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_zero_day_threshold_archives_completed_notes() {
|
||||
let (_dir, store) = mk();
|
||||
let mut done = note("done a second ago", NoteType::Todo);
|
||||
done.done = true;
|
||||
done.completed = Some(Utc::now() - Duration::seconds(1));
|
||||
store.save_note(&done).unwrap();
|
||||
|
||||
let undone = note("still active", NoteType::Todo);
|
||||
store.save_note(&undone).unwrap();
|
||||
|
||||
assert_eq!(store.rotate_archive(0).unwrap(), 1);
|
||||
let remaining = store.load_all().unwrap();
|
||||
assert_eq!(remaining.len(), 1);
|
||||
assert_eq!(remaining[0].body, "still active");
|
||||
assert_eq!(store.load_archive().unwrap().len(), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rotate_archive_ignores_undone_notes_no_matter_how_old() {
|
||||
let (_dir, store) = mk();
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue