diff --git a/src/digest.rs b/src/digest.rs index 16ec353..7275e1a 100644 --- a/src/digest.rs +++ b/src/digest.rs @@ -12,21 +12,23 @@ use crate::util::memory_subdir; use chrono::{Datelike, Duration, Local, NaiveDate}; use regex::Regex; -use std::collections::{BTreeMap, BTreeSet}; +use std::collections::BTreeSet; use std::fs; use std::path::{Path, PathBuf}; // --- Digest level descriptors --- struct DigestLevel { - name: &'static str, // lowercase, used for filenames and display - title: &'static str, // capitalized, used in prompts - period: &'static str, // "Date", "Week", "Month" + name: &'static str, + title: &'static str, + period: &'static str, input_title: &'static str, timeout: u64, - journal_input: bool, // true for daily (journal entries), false for child digests - gather: fn(&Store, &str) -> Result<(String, Vec<(String, String)>), String>, - find_args: fn(&[String], &str) -> Vec, + child_name: Option<&'static str>, // None = journal (leaf), Some = child digest files + /// Expand an arg into (canonical_label, dates covered). + label_dates: fn(&str) -> Result<(String, Vec), String>, + /// Map a YYYY-MM-DD date to this level's label. + date_to_label: fn(&str) -> Option, } const DAILY: DigestLevel = DigestLevel { @@ -35,9 +37,9 @@ const DAILY: DigestLevel = DigestLevel { period: "Date", input_title: "Journal entries", timeout: 300, - journal_input: true, - gather: gather_daily, - find_args: find_daily_args, + child_name: None, + label_dates: |date| Ok((date.to_string(), vec![date.to_string()])), + date_to_label: |date| Some(date.to_string()), }; const WEEKLY: DigestLevel = DigestLevel { @@ -46,9 +48,9 @@ const WEEKLY: DigestLevel = DigestLevel { period: "Week", input_title: "Daily digests", timeout: 300, - journal_input: false, - gather: gather_weekly, - find_args: find_weekly_args, + child_name: Some("daily"), + label_dates: weekly_label_dates, + date_to_label: |date| week_dates(date).ok().map(|(l, _)| l), }; const MONTHLY: DigestLevel = DigestLevel { @@ -57,9 +59,10 @@ const MONTHLY: DigestLevel = DigestLevel { period: "Month", input_title: "Weekly digests", timeout: 600, - journal_input: false, - gather: gather_monthly, - find_args: find_monthly_args, + child_name: Some("weekly"), + label_dates: monthly_label_dates, + date_to_label: |date| NaiveDate::parse_from_str(date, "%Y-%m-%d") + .ok().map(|d| format!("{}-{:02}", d.year(), d.month())), }; // --- Input gathering --- @@ -77,79 +80,51 @@ fn load_child_digests(prefix: &str, labels: &[String]) -> Result Vec { - dates.iter() - .filter(|d| d.as_str() != today) - .cloned() - .collect() -} +/// Unified: gather inputs for any digest level. +fn gather(level: &DigestLevel, store: &Store, arg: &str) -> Result<(String, Vec<(String, String)>), String> { + let (label, dates) = (level.label_dates)(arg)?; -fn find_weekly_args(dates: &[String], today: &str) -> Vec { - let mut weeks: BTreeMap = BTreeMap::new(); - for date in dates { - if let Ok((wl, _)) = week_dates(date) { - weeks.entry(wl).or_insert_with(|| date.clone()); - } - } - weeks.into_values() - .filter(|date| { - week_dates(date).map_or(false, |(_, days)| - days.last().unwrap() < &today.to_string()) - }) - .collect() -} - -fn find_monthly_args(dates: &[String], _today: &str) -> Vec { - let now = Local::now(); - let cur = (now.year(), now.month()); - let mut months: BTreeSet<(i32, u32)> = BTreeSet::new(); - for date in dates { - if let Ok(nd) = NaiveDate::parse_from_str(date, "%Y-%m-%d") { - months.insert((nd.year(), nd.month())); - } - } - months.into_iter() - .filter(|ym| *ym < cur) - .map(|(y, m)| format!("{}-{:02}", y, m)) - .collect() -} - -fn gather_daily(store: &Store, date: &str) -> Result<(String, Vec<(String, String)>), String> { - let date_re = Regex::new(&format!( - r"^journal\.md#j-{}", regex::escape(date) - )).unwrap(); - let mut entries: Vec<_> = store.nodes.values() - .filter(|n| date_re.is_match(&n.key)) - .map(|n| { - let label = n.key.strip_prefix("journal.md#j-").unwrap_or(&n.key); - (label.to_string(), n.content.clone()) - }) - .collect(); - entries.sort_by(|a, b| a.0.cmp(&b.0)); - Ok((date.to_string(), entries)) -} - -fn gather_weekly(_store: &Store, date: &str) -> Result<(String, Vec<(String, String)>), String> { - let (week_label, dates) = week_dates(date)?; - let inputs = load_child_digests("daily", &dates)?; - Ok((week_label, inputs)) -} - -fn gather_monthly(_store: &Store, arg: &str) -> Result<(String, Vec<(String, String)>), String> { - let (year, month) = if arg.is_empty() { - let now = Local::now(); - (now.year(), now.month()) + let inputs = if let Some(child_name) = level.child_name { + // Map parent's dates through child's date_to_label → child labels + let child = LEVELS.iter() + .find(|l| l.name == child_name) + .expect("invalid child_name"); + let child_labels: Vec = dates.iter() + .filter_map(|d| (child.date_to_label)(d)) + .collect::>() + .into_iter() + .collect(); + load_child_digests(child_name, &child_labels)? } else { - let d = NaiveDate::parse_from_str(&format!("{}-01", arg), "%Y-%m-%d") - .map_err(|e| format!("bad month '{}': {} (expected YYYY-MM)", arg, e))?; - (d.year(), d.month()) + // Leaf level: scan store for journal entries matching label + let date_re = Regex::new(&format!( + r"^journal\.md#j-{}", regex::escape(&label) + )).unwrap(); + let mut entries: Vec<_> = store.nodes.values() + .filter(|n| date_re.is_match(&n.key)) + .map(|n| { + let ts = n.key.strip_prefix("journal.md#j-").unwrap_or(&n.key); + (ts.to_string(), n.content.clone()) + }) + .collect(); + entries.sort_by(|a, b| a.0.cmp(&b.0)); + entries }; - let label = format!("{}-{:02}", year, month); - let child_labels = weeks_in_month(year, month); - let inputs = load_child_digests("weekly", &child_labels)?; + Ok((label, inputs)) } +/// Unified: find candidate labels for auto-generation (past, not yet generated). +fn find_candidates(level: &DigestLevel, dates: &[String], today: &str) -> Vec { + let today_label = (level.date_to_label)(today); + dates.iter() + .filter_map(|d| (level.date_to_label)(d)) + .collect::>() + .into_iter() + .filter(|l| Some(l) != today_label.as_ref()) + .collect() +} + // --- Unified generator --- fn format_inputs(inputs: &[(String, String)], daily: bool) -> String { @@ -184,7 +159,7 @@ fn generate_digest( .collect::>() .join("\n"); - let content = format_inputs(inputs, level.journal_input); + let content = format_inputs(inputs, level.child_name.is_none()); let covered = inputs.iter() .map(|(l, _)| l.as_str()) .collect::>() @@ -219,24 +194,17 @@ fn generate_digest( // --- Public API --- -pub fn generate_daily(store: &mut Store, date: &str) -> Result<(), String> { - let (label, inputs) = (DAILY.gather)(store, date)?; - generate_digest(store, &DAILY, &label, &inputs) -} - -pub fn generate_weekly(store: &mut Store, date: &str) -> Result<(), String> { - let (label, inputs) = (WEEKLY.gather)(store, date)?; - generate_digest(store, &WEEKLY, &label, &inputs) -} - -pub fn generate_monthly(store: &mut Store, month_arg: &str) -> Result<(), String> { - let (label, inputs) = (MONTHLY.gather)(store, month_arg)?; - generate_digest(store, &MONTHLY, &label, &inputs) +pub fn generate(store: &mut Store, level_name: &str, arg: &str) -> Result<(), String> { + let level = LEVELS.iter() + .find(|l| l.name == level_name) + .ok_or_else(|| format!("unknown digest level: {}", level_name))?; + let (label, inputs) = gather(level, store, arg)?; + generate_digest(store, level, &label, &inputs) } // --- Date helpers --- -/// Get ISO week label and the 7 dates (Mon-Sun) for the week containing `date`. +/// Week label and 7 dates (Mon-Sun) for the week containing `date`. fn week_dates(date: &str) -> Result<(String, Vec), String> { let nd = NaiveDate::parse_from_str(date, "%Y-%m-%d") .map_err(|e| format!("bad date '{}': {}", date, e))?; @@ -249,16 +217,44 @@ fn week_dates(date: &str) -> Result<(String, Vec), String> { Ok((week_label, dates)) } -fn weeks_in_month(year: i32, month: u32) -> Vec { - let mut weeks = BTreeSet::new(); +/// label_dates for weekly: accepts "YYYY-MM-DD" or "YYYY-WNN". +fn weekly_label_dates(arg: &str) -> Result<(String, Vec), String> { + if !arg.contains('W') { + return week_dates(arg); + } + // Parse "YYYY-WNN" + let (y, w) = arg.split_once("-W") + .ok_or_else(|| format!("bad week label: {}", arg))?; + let year: i32 = y.parse().map_err(|_| format!("bad week year: {}", arg))?; + let week: u32 = w.parse().map_err(|_| format!("bad week number: {}", arg))?; + let monday = NaiveDate::from_isoywd_opt(year, week, chrono::Weekday::Mon) + .ok_or_else(|| format!("invalid week: {}", arg))?; + let dates = (0..7) + .map(|i| (monday + Duration::days(i)).format("%Y-%m-%d").to_string()) + .collect(); + Ok((arg.to_string(), dates)) +} + +/// label_dates for monthly: accepts "YYYY-MM" or "YYYY-MM-DD". +fn monthly_label_dates(arg: &str) -> Result<(String, Vec), String> { + let (year, month) = if arg.len() <= 7 { + let d = NaiveDate::parse_from_str(&format!("{}-01", arg), "%Y-%m-%d") + .map_err(|e| format!("bad month '{}': {}", arg, e))?; + (d.year(), d.month()) + } else { + let d = NaiveDate::parse_from_str(arg, "%Y-%m-%d") + .map_err(|e| format!("bad date '{}': {}", arg, e))?; + (d.year(), d.month()) + }; + let label = format!("{}-{:02}", year, month); + let mut dates = Vec::new(); let mut d = 1u32; while let Some(date) = NaiveDate::from_ymd_opt(year, month, d) { if date.month() != month { break; } - let iso = date.iso_week(); - weeks.insert(format!("{}-W{:02}", iso.year(), iso.week())); + dates.push(date.format("%Y-%m-%d").to_string()); d += 1; } - weeks.into_iter().collect() + Ok((label, dates)) } // --- Auto-detect and generate missing digests --- @@ -284,12 +280,12 @@ pub fn digest_auto(store: &mut Store) -> Result<(), String> { let mut total = 0u32; for level in LEVELS { - let args = (level.find_args)(&dates, &today); + let candidates = find_candidates(level, &dates, &today); let mut generated = 0u32; let mut skipped = 0u32; - for arg in &args { - let (label, inputs) = (level.gather)(store, arg)?; + for arg in &candidates { + let (label, inputs) = gather(level, store, arg)?; if epi.join(format!("{}-{}.md", level.name, label)).exists() { skipped += 1; continue; diff --git a/src/main.rs b/src/main.rs index 6531e1b..56903fc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -765,27 +765,15 @@ fn cmd_digest(args: &[String]) -> Result<(), String> { let date_arg = args.get(1).map(|s| s.as_str()).unwrap_or(""); match args[0].as_str() { - "daily" => { - let date = if date_arg.is_empty() { - store::format_date(store::now_epoch()) - } else { - date_arg.to_string() - }; - digest::generate_daily(&mut store, &date) - } - "weekly" => { - let date = if date_arg.is_empty() { - store::format_date(store::now_epoch()) - } else { - date_arg.to_string() - }; - digest::generate_weekly(&mut store, &date) - } - "monthly" => { - let month = if date_arg.is_empty() { "" } else { date_arg }; - digest::generate_monthly(&mut store, month) - } "auto" => digest::digest_auto(&mut store), + name @ ("daily" | "weekly" | "monthly") => { + let arg = if date_arg.is_empty() { + store::format_date(store::now_epoch()) + } else { + date_arg.to_string() + }; + digest::generate(&mut store, name, &arg) + } _ => Err(format!("Unknown digest type: {}. Use: daily, weekly, monthly, auto", args[0])), } }