digest: unify gather/find with composable date_range + date_to_label

Each DigestLevel now carries two date-math fn pointers:
- label_dates: expand an arg into (label, dates covered)
- date_to_label: map any date to this level's label

Parent gather works by expanding its date range then mapping those
dates through the child level's date_to_label to derive child labels.
find_candidates groups journal dates through date_to_label and skips
the current period. This eliminates six per-level functions
(gather_daily/weekly/monthly, find_daily/weekly/monthly_args) and the
three generate_daily/weekly/monthly public entry points in favor of
one generic gather, one generic find_candidates, and one public
generate(store, level_name, arg).
This commit is contained in:
ProofOfConcept 2026-03-03 18:04:21 -05:00
parent 31c1bca7d7
commit a9b90f881e
2 changed files with 110 additions and 126 deletions

View file

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

View file

@ -765,27 +765,15 @@ fn cmd_digest(args: &[String]) -> Result<(), String> {
let date_arg = args.get(1).map(|s| s.as_str()).unwrap_or(""); let date_arg = args.get(1).map(|s| s.as_str()).unwrap_or("");
match args[0].as_str() { 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), "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])), _ => Err(format!("Unknown digest type: {}. Use: daily, weekly, monthly, auto", args[0])),
} }
} }