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:
2026-05-12 22:09:14 +00:00
parent bcef714805
commit d8fa59a63a
7 changed files with 844 additions and 27 deletions
+18 -15
View File
@@ -379,18 +379,20 @@ fn diary_open_relative(
let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref()) else {
return Ok(None);
};
let today = nuwiki_core::date::DiaryDate::today_utc();
let date = match rel {
RelativeDay::Today => today,
RelativeDay::Yesterday => today.prev_day(),
RelativeDay::Tomorrow => today.next_day(),
let freq = wiki.config.frequency();
let today_period = nuwiki_core::date::DiaryPeriod::today_utc(freq);
let period = match rel {
RelativeDay::Today => today_period,
RelativeDay::Yesterday => today_period.prev(),
RelativeDay::Tomorrow => today_period.next(),
};
let Some(uri) = crate::diary::uri_for_date(&wiki.config, &date) else {
let Some(uri) = crate::diary::uri_for_period(&wiki.config, &period) else {
return Ok(None);
};
Ok(Some(serde_json::json!({
"uri": uri,
"date": date.format(),
"date": period.format(),
"frequency": freq.as_str(),
})))
}
@@ -434,17 +436,18 @@ fn diary_step(backend: &Backend, args: Vec<Value>, forward: bool) -> Result<Opti
let Some(wiki) = backend.wiki_for_uri(&p.uri) else {
return Ok(None);
};
// Pivot date: the current page's `diary_date` when it's an entry,
// otherwise today — matches vimwiki's "navigate from whatever date
// I'm currently viewing".
let pivot = {
// Pivot period: prefer the current page's own period (any flavour),
// falling back to the wiki's configured frequency for "today" when
// the cursor isn't on a diary page. This makes <C-Down> on a weekly
// entry jump to the next weekly entry, not the next daily one.
let pivot: nuwiki_core::date::DiaryPeriod = {
let idx = wiki
.index
.read()
.map_err(|_| "index lock poisoned".to_string())?;
idx.page(&p.uri)
.and_then(|page| page.diary_date)
.unwrap_or_else(nuwiki_core::date::DiaryDate::today_utc)
.and_then(|page| page.diary_period)
.unwrap_or_else(|| nuwiki_core::date::DiaryPeriod::today_utc(wiki.config.frequency()))
};
let next = {
let idx = wiki
@@ -452,9 +455,9 @@ fn diary_step(backend: &Backend, args: Vec<Value>, forward: bool) -> Result<Opti
.read()
.map_err(|_| "index lock poisoned".to_string())?;
if forward {
crate::diary::next_entry(&idx, &pivot)
crate::diary::next_period(&idx, &pivot)
} else {
crate::diary::prev_entry(&idx, &pivot)
crate::diary::prev_period(&idx, &pivot)
}
};
match next {
+14
View File
@@ -232,6 +232,20 @@ impl WikiConfig {
p.push(format!("{}{}", date.format(), self.file_extension));
p
}
/// Absolute path for a diary entry covering `period`. Honours the
/// wiki's `diary_frequency` indirectly — callers compute the period
/// at the right cadence and we just pick the stem from its format.
pub fn diary_path_for_period(&self, period: &nuwiki_core::date::DiaryPeriod) -> PathBuf {
let mut p = self.diary_dir();
p.push(format!("{}{}", period.format(), self.file_extension));
p
}
/// `DiaryFrequency` parsed from this wiki's `diary_frequency` field.
pub fn frequency(&self) -> nuwiki_core::date::DiaryFrequency {
nuwiki_core::date::DiaryFrequency::parse(&self.diary_frequency)
}
}
fn default_diary_rel_path() -> String {
+95 -1
View File
@@ -12,7 +12,7 @@ use std::path::Path;
use tower_lsp::lsp_types::Url;
use nuwiki_core::date::DiaryDate;
use nuwiki_core::date::{DiaryDate, DiaryPeriod};
use crate::config::WikiConfig;
use crate::index::WorkspaceIndex;
@@ -22,6 +22,12 @@ pub fn uri_for_date(cfg: &WikiConfig, date: &DiaryDate) -> Option<Url> {
Url::from_file_path(cfg.diary_path_for(date)).ok()
}
/// Resolve a `DiaryPeriod` to its `file://` URI under the given wiki.
/// Picks the right stem for the period's flavour (day / week / month / year).
pub fn uri_for_period(cfg: &WikiConfig, period: &DiaryPeriod) -> Option<Url> {
Url::from_file_path(cfg.diary_path_for_period(period)).ok()
}
/// `file://` URI of the diary index page.
pub fn index_uri(cfg: &WikiConfig) -> Option<Url> {
Url::from_file_path(cfg.diary_index_path()).ok()
@@ -74,6 +80,94 @@ pub fn prev_entry(index: &WorkspaceIndex, from: &DiaryDate) -> Option<DiaryEntry
.find(|e| e.date < *from)
}
/// All indexed diary periods of a *specific* flavour (daily / weekly /
/// monthly / yearly), sorted ascending. Used by next/prev navigation so
/// `<C-Down>` on a weekly page jumps to the next weekly entry, not the
/// next daily one.
pub fn list_periods_of_kind(index: &WorkspaceIndex, kind: PeriodKind) -> Vec<PeriodEntry> {
let mut out: Vec<PeriodEntry> = index
.pages_by_uri
.values()
.filter_map(|p| {
let period = p.diary_period?;
if PeriodKind::of(&period) != kind {
return None;
}
Some(PeriodEntry {
period,
uri: p.uri.clone(),
})
})
.collect();
out.sort_by_key(|e| period_first_day(&e.period));
out
}
pub fn next_period(index: &WorkspaceIndex, from: &DiaryPeriod) -> Option<PeriodEntry> {
let kind = PeriodKind::of(from);
let pivot = period_first_day(from);
list_periods_of_kind(index, kind)
.into_iter()
.find(|e| period_first_day(&e.period) > pivot)
}
pub fn prev_period(index: &WorkspaceIndex, from: &DiaryPeriod) -> Option<PeriodEntry> {
let kind = PeriodKind::of(from);
let pivot = period_first_day(from);
list_periods_of_kind(index, kind)
.into_iter()
.rev()
.find(|e| period_first_day(&e.period) < pivot)
}
/// Cluster 4: `(period, uri)` pair for non-daily diary navigation.
#[derive(Debug, Clone)]
pub struct PeriodEntry {
pub period: DiaryPeriod,
pub uri: Url,
}
impl serde::Serialize for PeriodEntry {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
// For back-compat with daily-only clients, emit `date` AND
// `period` — the former is just the first-day string of the
// period, the latter is the canonical stem.
let mut s = serializer.serialize_struct("PeriodEntry", 3)?;
s.serialize_field("date", &self.period.first_day().format())?;
s.serialize_field("period", &self.period.format())?;
s.serialize_field("uri", &self.uri)?;
s.end()
}
}
/// Which flavour of `DiaryPeriod` we're looking at.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PeriodKind {
Day,
Week,
Month,
Year,
}
impl PeriodKind {
pub fn of(p: &DiaryPeriod) -> Self {
match p {
DiaryPeriod::Day(_) => Self::Day,
DiaryPeriod::Week { .. } => Self::Week,
DiaryPeriod::Month { .. } => Self::Month,
DiaryPeriod::Year { .. } => Self::Year,
}
}
}
fn period_first_day(p: &DiaryPeriod) -> DiaryDate {
p.first_day()
}
/// Generate the body of the diary index page — a flat newest-first list
/// of `[[diary/YYYY-MM-DD]]` wikilinks, grouped under year and month
/// subheadings so the rendered page mirrors `:VimwikiDiaryGenerateLinks`.
+43 -11
View File
@@ -32,10 +32,16 @@ pub struct IndexedPage {
/// Phase 12: every `TagNode` on the page. `tags_by_name` on the
/// containing `WorkspaceIndex` is the reverse map.
pub tags: Vec<TagInfo>,
/// Phase 16: `Some(date)` when this page is a diary entry — i.e. its
/// file lives under `<root>/<diary_rel_path>/` and its stem parses as
/// `YYYY-MM-DD`. Used by the diary navigation commands.
/// Phase 16: `Some(date)` when this page is a *daily* diary entry —
/// stem parses as `YYYY-MM-DD`. Equivalent to
/// `diary_period.and_then(|p| if let DiaryPeriod::Day(d) = p { Some(d) } else { None })`.
/// Kept as its own field for back-compat with prev/next/list callers
/// that pre-date the multi-frequency upgrade.
pub diary_date: Option<DiaryDate>,
/// Cluster 4 (vimwiki parity): `Some(period)` for any diary entry
/// (daily, weekly, monthly, yearly). The stem must match the
/// canonical format for one of the four frequencies.
pub diary_period: Option<nuwiki_core::date::DiaryPeriod>,
}
/// One tag occurrence on a page. `name` is the bare tag string (no
@@ -118,8 +124,12 @@ impl WorkspaceIndex {
pub fn upsert(&mut self, uri: Url, ast: &DocumentNode) {
self.remove(&uri);
let name = page_name_from_uri(&uri, self.root.as_deref());
let diary_date =
diary_date_for_uri(&uri, self.root.as_deref(), self.diary_rel_path.as_deref());
let diary_period =
diary_period_for_uri(&uri, self.root.as_deref(), self.diary_rel_path.as_deref());
let diary_date = diary_period.and_then(|p| match p {
nuwiki_core::date::DiaryPeriod::Day(d) => Some(d),
_ => None,
});
let mut page = IndexedPage {
uri: uri.clone(),
name: name.clone(),
@@ -128,6 +138,7 @@ impl WorkspaceIndex {
outgoing: Vec::new(),
tags: Vec::new(),
diary_date,
diary_period,
};
index_blocks(&ast.children, &mut page);
@@ -224,19 +235,40 @@ impl WorkspaceIndex {
}
}
/// Return a parsed [`DiaryDate`] when `uri` is a diary entry — its file
/// path is under `<root>/<diary_rel_path>/` and its stem parses as
/// `YYYY-MM-DD`. Anything else returns `None`.
/// Return a parsed [`DiaryDate`] when `uri` is a daily diary entry —
/// the path lives under `<root>/<diary_rel_path>/` and its stem parses
/// as `YYYY-MM-DD`. For weekly/monthly/yearly entries see
/// [`diary_period_for_uri`].
pub fn diary_date_for_uri(
uri: &Url,
root: Option<&Path>,
diary_rel_path: Option<&str>,
) -> Option<DiaryDate> {
match diary_period_for_uri(uri, root, diary_rel_path)? {
nuwiki_core::date::DiaryPeriod::Day(d) => Some(d),
_ => None,
}
}
/// Return a parsed [`DiaryPeriod`] when `uri` is a diary entry at any of
/// the four supported frequencies. Recognises:
///
/// `YYYY-MM-DD` → Day
/// `YYYY-Www` → Week (ISO)
/// `YYYY-MM` → Month
/// `YYYY` → Year
///
/// As with `diary_date_for_uri`, the path must sit under
/// `<root>/<diary_rel_path>/` to be accepted; without a `root` we accept
/// any stem (so ad-hoc test setups still work).
pub fn diary_period_for_uri(
uri: &Url,
root: Option<&Path>,
diary_rel_path: Option<&str>,
) -> Option<nuwiki_core::date::DiaryPeriod> {
let path = uri.to_file_path().ok()?;
let stem = path.file_stem()?.to_str()?;
let parsed = DiaryDate::parse(stem)?;
// Without a root we can't enforce the subdir requirement — accept any
// file whose stem looks like a date so that ad-hoc test setups work.
let parsed = nuwiki_core::date::DiaryPeriod::parse(stem)?;
let Some(root) = root else {
return Some(parsed);
};