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:
parent
31c1bca7d7
commit
a9b90f881e
2 changed files with 110 additions and 126 deletions
208
src/digest.rs
208
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<String>,
|
||||
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>), String>,
|
||||
/// Map a YYYY-MM-DD date to this level's label.
|
||||
date_to_label: fn(&str) -> Option<String>,
|
||||
}
|
||||
|
||||
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<(String, St
|
|||
Ok(digests)
|
||||
}
|
||||
|
||||
fn find_daily_args(dates: &[String], today: &str) -> Vec<String> {
|
||||
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<String> {
|
||||
let mut weeks: BTreeMap<String, String> = 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<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!(
|
||||
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<String> = dates.iter()
|
||||
.filter_map(|d| (child.date_to_label)(d))
|
||||
.collect::<BTreeSet<_>>()
|
||||
.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<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 ---
|
||||
|
||||
fn format_inputs(inputs: &[(String, String)], daily: bool) -> String {
|
||||
|
|
@ -184,7 +159,7 @@ fn generate_digest(
|
|||
.collect::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
|
|
@ -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>), 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>), String> {
|
|||
Ok((week_label, dates))
|
||||
}
|
||||
|
||||
fn weeks_in_month(year: i32, month: u32) -> Vec<String> {
|
||||
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>), 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;
|
||||
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;
|
||||
|
|
|
|||
28
src/main.rs
28
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])),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue