parity(4): diary weekly / monthly / yearly frequency support
vimwiki's `diary_frequency` config has been honoured by the existing
`nuwiki.diary.openToday` / `openYesterday` / `openTomorrow` commands
end-to-end. Setting `diary_frequency = 'weekly'` (or `monthly` /
`yearly`) now creates and addresses entries at that cadence — file
stems follow the canonical formats:
daily YYYY-MM-DD 2026-05-12.wiki
weekly YYYY-Www 2026-W19.wiki (ISO 8601 week)
monthly YYYY-MM 2026-05.wiki
yearly YYYY 2026.wiki
Core additions (nuwiki_core::date):
- DiaryFrequency enum + permissive `parse` (unknown → Daily)
- DiaryPeriod enum unifying Day / Week / Month / Year
- format / parse / next / prev / today_utc + first_day, with
proper ISO-week math (Thursday rule, year-boundary handling)
LSP wiring:
- WikiConfig::frequency() and diary_path_for_period()
- crate::diary::uri_for_period
- Index now records `diary_period` for any of the four flavours;
`diary_date` is preserved (filtered to Day-only) for back-compat
with existing daily-only callers
- `nuwiki.diary.next` / `nuwiki.diary.prev` now navigate at the
*same flavour* as the current entry (or at the wiki's configured
frequency when off a diary page), via new
`crate::diary::next_period` / `prev_period` helpers
- `nuwiki.diary.openToday` returns the period stem + frequency in
its response payload alongside the URI
Tests:
- 12 in crates/nuwiki-core/tests/diary_period.rs covering the date
math (ISO week boundaries, format round-trips, next/prev across
year edges, etc.)
- 9 in crates/nuwiki-lsp/tests/diary_frequency.rs covering
WikiConfig path computation, index recognition for all four
stems, period navigation, and diary-dir filtering
Gates: 450 Rust / 39 Neovim / 12 Vim.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -146,6 +146,255 @@ fn to_days(year: i32, month: u8, day: u8) -> i64 {
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Diary frequency / period
|
||||
// ============================================================
|
||||
//
|
||||
// `DiaryFrequency` is the user-configured cadence (mirrors vimwiki's
|
||||
// `diary_frequency` setting). `DiaryPeriod` is the *concrete* diary
|
||||
// entry — a specific day, ISO week, calendar month, or year — that
|
||||
// the LSP commands compute and the renderer addresses.
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum DiaryFrequency {
|
||||
Daily,
|
||||
Weekly,
|
||||
Monthly,
|
||||
Yearly,
|
||||
}
|
||||
|
||||
impl DiaryFrequency {
|
||||
/// Parse the string form used in `WikiConfig.diary_frequency`.
|
||||
/// Falls back to `Daily` for unrecognised values — vimwiki's
|
||||
/// permissive behaviour for stale configs.
|
||||
pub fn parse(s: &str) -> Self {
|
||||
match s.trim().to_ascii_lowercase().as_str() {
|
||||
"weekly" | "week" => Self::Weekly,
|
||||
"monthly" | "month" => Self::Monthly,
|
||||
"yearly" | "year" => Self::Yearly,
|
||||
_ => Self::Daily,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn as_str(self) -> &'static str {
|
||||
match self {
|
||||
Self::Daily => "daily",
|
||||
Self::Weekly => "weekly",
|
||||
Self::Monthly => "monthly",
|
||||
Self::Yearly => "yearly",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A specific diary entry, identified by its calendar period. The four
|
||||
/// variants share the same `format` / `parse` / `next` / `prev` /
|
||||
/// `today_utc` surface so the LSP dispatcher can stay frequency-agnostic.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub enum DiaryPeriod {
|
||||
Day(DiaryDate),
|
||||
/// ISO 8601 week — year/week pair, week ∈ 1..=53.
|
||||
Week {
|
||||
iso_year: i32,
|
||||
iso_week: u8,
|
||||
},
|
||||
Month {
|
||||
year: i32,
|
||||
month: u8,
|
||||
},
|
||||
Year {
|
||||
year: i32,
|
||||
},
|
||||
}
|
||||
|
||||
impl DiaryPeriod {
|
||||
/// "Now" for the given frequency, in UTC. See `DiaryDate::today_utc`
|
||||
/// for the timezone rationale.
|
||||
pub fn today_utc(freq: DiaryFrequency) -> Self {
|
||||
let today = DiaryDate::today_utc();
|
||||
match freq {
|
||||
DiaryFrequency::Daily => Self::Day(today),
|
||||
DiaryFrequency::Weekly => Self::week_containing(today),
|
||||
DiaryFrequency::Monthly => Self::Month {
|
||||
year: today.year,
|
||||
month: today.month,
|
||||
},
|
||||
DiaryFrequency::Yearly => Self::Year { year: today.year },
|
||||
}
|
||||
}
|
||||
|
||||
/// File-stem format — also the format vimwiki uses for diary link
|
||||
/// targets (`[[diary:2026-W19]]`, `[[diary:2026-05]]`, etc.).
|
||||
///
|
||||
/// Day(2026-05-12) → `2026-05-12`
|
||||
/// Week(2026, 19) → `2026-W19`
|
||||
/// Month(2026, 5) → `2026-05`
|
||||
/// Year(2026) → `2026`
|
||||
pub fn format(&self) -> String {
|
||||
match self {
|
||||
Self::Day(d) => d.format(),
|
||||
Self::Week { iso_year, iso_week } => format!("{iso_year:04}-W{iso_week:02}"),
|
||||
Self::Month { year, month } => format!("{year:04}-{month:02}"),
|
||||
Self::Year { year } => format!("{year:04}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Inverse of `format` — recognise any of the four stems. Returns
|
||||
/// `None` for inputs that don't match any flavour.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
// Day: `YYYY-MM-DD`
|
||||
if let Some(d) = DiaryDate::parse(s) {
|
||||
return Some(Self::Day(d));
|
||||
}
|
||||
let b = s.as_bytes();
|
||||
// Week: `YYYY-Www`
|
||||
if b.len() == 8 && b[4] == b'-' && (b[5] == b'W' || b[5] == b'w') {
|
||||
let y = parse_digits(&b[0..4])? as i32;
|
||||
let w = parse_digits(&b[6..8])? as u8;
|
||||
if (1..=53).contains(&w) {
|
||||
return Some(Self::Week {
|
||||
iso_year: y,
|
||||
iso_week: w,
|
||||
});
|
||||
}
|
||||
}
|
||||
// Month: `YYYY-MM`
|
||||
if b.len() == 7 && b[4] == b'-' {
|
||||
let y = parse_digits(&b[0..4])? as i32;
|
||||
let m = parse_digits(&b[5..7])? as u8;
|
||||
if (1..=12).contains(&m) {
|
||||
return Some(Self::Month { year: y, month: m });
|
||||
}
|
||||
}
|
||||
// Year: `YYYY`
|
||||
if b.len() == 4 {
|
||||
let y = parse_digits(&b[0..4])? as i32;
|
||||
return Some(Self::Year { year: y });
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub fn next(&self) -> Self {
|
||||
match *self {
|
||||
Self::Day(d) => Self::Day(d.next_day()),
|
||||
Self::Week { iso_year, iso_week } => {
|
||||
// Step forward one week: take the Monday of this ISO week,
|
||||
// add 7 days, recompute the (year, week) it falls in.
|
||||
let mon = monday_of_iso_week(iso_year, iso_week);
|
||||
let next_mon = from_days(to_days(mon.year, mon.month, mon.day) + 7);
|
||||
let (iso_year, iso_week) = iso_year_week(&next_mon);
|
||||
Self::Week { iso_year, iso_week }
|
||||
}
|
||||
Self::Month { year, month } => {
|
||||
if month == 12 {
|
||||
Self::Month {
|
||||
year: year + 1,
|
||||
month: 1,
|
||||
}
|
||||
} else {
|
||||
Self::Month {
|
||||
year,
|
||||
month: month + 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::Year { year } => Self::Year { year: year + 1 },
|
||||
}
|
||||
}
|
||||
|
||||
pub fn prev(&self) -> Self {
|
||||
match *self {
|
||||
Self::Day(d) => Self::Day(d.prev_day()),
|
||||
Self::Week { iso_year, iso_week } => {
|
||||
let mon = monday_of_iso_week(iso_year, iso_week);
|
||||
let prev_mon = from_days(to_days(mon.year, mon.month, mon.day) - 7);
|
||||
let (iso_year, iso_week) = iso_year_week(&prev_mon);
|
||||
Self::Week { iso_year, iso_week }
|
||||
}
|
||||
Self::Month { year, month } => {
|
||||
if month == 1 {
|
||||
Self::Month {
|
||||
year: year - 1,
|
||||
month: 12,
|
||||
}
|
||||
} else {
|
||||
Self::Month {
|
||||
year,
|
||||
month: month - 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
Self::Year { year } => Self::Year { year: year - 1 },
|
||||
}
|
||||
}
|
||||
|
||||
/// Compute the ISO week containing the given calendar date.
|
||||
pub fn week_containing(d: DiaryDate) -> Self {
|
||||
let (iso_year, iso_week) = iso_year_week(&d);
|
||||
Self::Week { iso_year, iso_week }
|
||||
}
|
||||
|
||||
/// First calendar day of this period — useful for chronological
|
||||
/// sorting across mixed-frequency entries.
|
||||
///
|
||||
/// Day(d) → d
|
||||
/// Week(y,w) → Monday of that ISO week
|
||||
/// Month(y,m) → 1st of that month
|
||||
/// Year(y) → Jan 1st of that year
|
||||
pub fn first_day(&self) -> DiaryDate {
|
||||
match *self {
|
||||
Self::Day(d) => d,
|
||||
Self::Week { iso_year, iso_week } => monday_of_iso_week(iso_year, iso_week),
|
||||
Self::Month { year, month } => {
|
||||
DiaryDate::from_ymd(year, month, 1).expect("validated month")
|
||||
}
|
||||
Self::Year { year } => DiaryDate::from_ymd(year, 1, 1).expect("Jan 1st always valid"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DiaryPeriod {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.format())
|
||||
}
|
||||
}
|
||||
|
||||
/// Day-of-week with Monday=1 … Sunday=7 (ISO 8601).
|
||||
fn iso_weekday(d: &DiaryDate) -> u8 {
|
||||
// Zeller-style via days-from-epoch: 1970-01-01 was a Thursday.
|
||||
let days = to_days(d.year, d.month, d.day);
|
||||
// (days + 3) mod 7 puts Monday at 0 … Sunday at 6; shift to 1..=7.
|
||||
let r = (days + 3).rem_euclid(7);
|
||||
(r as u8) + 1
|
||||
}
|
||||
|
||||
/// ISO 8601 `(year, week)` for a calendar date. The ISO year may differ
|
||||
/// from the calendar year for early January / late December dates.
|
||||
fn iso_year_week(d: &DiaryDate) -> (i32, u8) {
|
||||
// Standard algorithm: shift to the Thursday of the same ISO week,
|
||||
// then ISO year = thursday.year, ISO week = ordinal_day(thursday) / 7 + 1.
|
||||
let weekday = iso_weekday(d) as i64; // 1..=7
|
||||
let days = to_days(d.year, d.month, d.day);
|
||||
let thursday = from_days(days + 4 - weekday);
|
||||
let jan1 = to_days(thursday.year, 1, 1);
|
||||
let ordinal = to_days(thursday.year, thursday.month, thursday.day) - jan1 + 1;
|
||||
let week = ((ordinal - 1) / 7 + 1) as u8;
|
||||
(thursday.year, week)
|
||||
}
|
||||
|
||||
/// Date of the Monday that opens the given ISO `(year, week)`.
|
||||
fn monday_of_iso_week(iso_year: i32, iso_week: u8) -> DiaryDate {
|
||||
// Find Jan 4th of iso_year — always in ISO week 1 by definition.
|
||||
let jan4 = DiaryDate {
|
||||
year: iso_year,
|
||||
month: 1,
|
||||
day: 4,
|
||||
};
|
||||
let jan4_weekday = iso_weekday(&jan4) as i64; // 1..=7
|
||||
let week1_monday_days = to_days(jan4.year, jan4.month, jan4.day) - (jan4_weekday - 1);
|
||||
let target = week1_monday_days + (iso_week as i64 - 1) * 7;
|
||||
from_days(target)
|
||||
}
|
||||
|
||||
/// Inverse of [`to_days`].
|
||||
fn from_days(days: i64) -> DiaryDate {
|
||||
let z = days + 719_468;
|
||||
|
||||
Reference in New Issue
Block a user