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 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)?
|
||||||
})
|
|
||||||
.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())
|
|
||||||
} else {
|
} else {
|
||||||
let d = NaiveDate::parse_from_str(&format!("{}-01", arg), "%Y-%m-%d")
|
// Leaf level: scan store for journal entries matching label
|
||||||
.map_err(|e| format!("bad month '{}': {} (expected YYYY-MM)", arg, e))?;
|
let date_re = Regex::new(&format!(
|
||||||
(d.year(), d.month())
|
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))
|
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;
|
||||||
|
|
|
||||||
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("");
|
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])),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue