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
|
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`].
|
/// Inverse of [`to_days`].
|
||||||
fn from_days(days: i64) -> DiaryDate {
|
fn from_days(days: i64) -> DiaryDate {
|
||||||
let z = days + 719_468;
|
let z = days + 719_468;
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
//! Cluster 4 — diary frequency / period primitives.
|
||||||
|
//! Tests the date math for ISO weeks, monthly, yearly periods.
|
||||||
|
|
||||||
|
use nuwiki_core::date::{DiaryDate, DiaryFrequency, DiaryPeriod};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frequency_parses_canonical_strings() {
|
||||||
|
assert_eq!(DiaryFrequency::parse("daily"), DiaryFrequency::Daily);
|
||||||
|
assert_eq!(DiaryFrequency::parse("weekly"), DiaryFrequency::Weekly);
|
||||||
|
assert_eq!(DiaryFrequency::parse("monthly"), DiaryFrequency::Monthly);
|
||||||
|
assert_eq!(DiaryFrequency::parse("yearly"), DiaryFrequency::Yearly);
|
||||||
|
assert_eq!(DiaryFrequency::parse("WeEkLy"), DiaryFrequency::Weekly);
|
||||||
|
// Unknown falls back to Daily (mirrors vimwiki's permissive behaviour).
|
||||||
|
assert_eq!(DiaryFrequency::parse("hourly"), DiaryFrequency::Daily);
|
||||||
|
assert_eq!(DiaryFrequency::parse(""), DiaryFrequency::Daily);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn period_format_round_trips() {
|
||||||
|
let cases = [
|
||||||
|
(
|
||||||
|
"2026-05-12",
|
||||||
|
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"2026-W19",
|
||||||
|
DiaryPeriod::Week {
|
||||||
|
iso_year: 2026,
|
||||||
|
iso_week: 19,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"2026-05",
|
||||||
|
DiaryPeriod::Month {
|
||||||
|
year: 2026,
|
||||||
|
month: 5,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
("2026", DiaryPeriod::Year { year: 2026 }),
|
||||||
|
];
|
||||||
|
for (s, want) in cases {
|
||||||
|
let parsed = DiaryPeriod::parse(s).unwrap_or_else(|| panic!("parse {s}"));
|
||||||
|
assert_eq!(parsed, want, "parse({s})");
|
||||||
|
assert_eq!(parsed.format(), s, "format({s})");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn period_parse_rejects_garbage() {
|
||||||
|
assert!(DiaryPeriod::parse("garbage").is_none());
|
||||||
|
assert!(DiaryPeriod::parse("2026-W00").is_none());
|
||||||
|
assert!(DiaryPeriod::parse("2026-13").is_none()); // month 13
|
||||||
|
assert!(DiaryPeriod::parse("2026-").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn iso_week_jan_1_can_belong_to_prior_year() {
|
||||||
|
// 2021-01-01 was a Friday → ISO week 53 of 2020.
|
||||||
|
let d = DiaryDate::from_ymd(2021, 1, 1).unwrap();
|
||||||
|
let p = DiaryPeriod::week_containing(d);
|
||||||
|
assert_eq!(
|
||||||
|
p,
|
||||||
|
DiaryPeriod::Week {
|
||||||
|
iso_year: 2020,
|
||||||
|
iso_week: 53
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn iso_week_dec_31_can_belong_to_next_year() {
|
||||||
|
// 2024-12-30 (Monday) is in ISO week 1 of 2025.
|
||||||
|
let d = DiaryDate::from_ymd(2024, 12, 30).unwrap();
|
||||||
|
let p = DiaryPeriod::week_containing(d);
|
||||||
|
assert_eq!(
|
||||||
|
p,
|
||||||
|
DiaryPeriod::Week {
|
||||||
|
iso_year: 2025,
|
||||||
|
iso_week: 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn iso_week_mid_year_is_consistent() {
|
||||||
|
// 2026-05-12 is a Tuesday in ISO week 20.
|
||||||
|
let d = DiaryDate::from_ymd(2026, 5, 12).unwrap();
|
||||||
|
let p = DiaryPeriod::week_containing(d);
|
||||||
|
assert_eq!(
|
||||||
|
p,
|
||||||
|
DiaryPeriod::Week {
|
||||||
|
iso_year: 2026,
|
||||||
|
iso_week: 20
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn week_next_and_prev_step_by_seven_days() {
|
||||||
|
let p = DiaryPeriod::Week {
|
||||||
|
iso_year: 2026,
|
||||||
|
iso_week: 20,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
p.next(),
|
||||||
|
DiaryPeriod::Week {
|
||||||
|
iso_year: 2026,
|
||||||
|
iso_week: 21
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
p.prev(),
|
||||||
|
DiaryPeriod::Week {
|
||||||
|
iso_year: 2026,
|
||||||
|
iso_week: 19
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn week_next_crosses_year_boundary() {
|
||||||
|
// Week 53 of 2020 → week 53 ends; next should be week 1 of 2021.
|
||||||
|
let p = DiaryPeriod::Week {
|
||||||
|
iso_year: 2020,
|
||||||
|
iso_week: 53,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
p.next(),
|
||||||
|
DiaryPeriod::Week {
|
||||||
|
iso_year: 2021,
|
||||||
|
iso_week: 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn month_next_wraps_to_january() {
|
||||||
|
let dec = DiaryPeriod::Month {
|
||||||
|
year: 2026,
|
||||||
|
month: 12,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
dec.next(),
|
||||||
|
DiaryPeriod::Month {
|
||||||
|
year: 2027,
|
||||||
|
month: 1
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn month_prev_wraps_to_december() {
|
||||||
|
let jan = DiaryPeriod::Month {
|
||||||
|
year: 2026,
|
||||||
|
month: 1,
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
jan.prev(),
|
||||||
|
DiaryPeriod::Month {
|
||||||
|
year: 2025,
|
||||||
|
month: 12
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn year_next_and_prev_increment_by_one() {
|
||||||
|
let y = DiaryPeriod::Year { year: 2026 };
|
||||||
|
assert_eq!(y.next(), DiaryPeriod::Year { year: 2027 });
|
||||||
|
assert_eq!(y.prev(), DiaryPeriod::Year { year: 2025 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn day_next_and_prev_still_work() {
|
||||||
|
let d = DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap());
|
||||||
|
assert_eq!(
|
||||||
|
d.next(),
|
||||||
|
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 13).unwrap())
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
d.prev(),
|
||||||
|
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 11).unwrap())
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -379,18 +379,20 @@ fn diary_open_relative(
|
|||||||
let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref()) else {
|
let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref()) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
let today = nuwiki_core::date::DiaryDate::today_utc();
|
let freq = wiki.config.frequency();
|
||||||
let date = match rel {
|
let today_period = nuwiki_core::date::DiaryPeriod::today_utc(freq);
|
||||||
RelativeDay::Today => today,
|
let period = match rel {
|
||||||
RelativeDay::Yesterday => today.prev_day(),
|
RelativeDay::Today => today_period,
|
||||||
RelativeDay::Tomorrow => today.next_day(),
|
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);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
Ok(Some(serde_json::json!({
|
Ok(Some(serde_json::json!({
|
||||||
"uri": uri,
|
"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 {
|
let Some(wiki) = backend.wiki_for_uri(&p.uri) else {
|
||||||
return Ok(None);
|
return Ok(None);
|
||||||
};
|
};
|
||||||
// Pivot date: the current page's `diary_date` when it's an entry,
|
// Pivot period: prefer the current page's own period (any flavour),
|
||||||
// otherwise today — matches vimwiki's "navigate from whatever date
|
// falling back to the wiki's configured frequency for "today" when
|
||||||
// I'm currently viewing".
|
// the cursor isn't on a diary page. This makes <C-Down> on a weekly
|
||||||
let pivot = {
|
// entry jump to the next weekly entry, not the next daily one.
|
||||||
|
let pivot: nuwiki_core::date::DiaryPeriod = {
|
||||||
let idx = wiki
|
let idx = wiki
|
||||||
.index
|
.index
|
||||||
.read()
|
.read()
|
||||||
.map_err(|_| "index lock poisoned".to_string())?;
|
.map_err(|_| "index lock poisoned".to_string())?;
|
||||||
idx.page(&p.uri)
|
idx.page(&p.uri)
|
||||||
.and_then(|page| page.diary_date)
|
.and_then(|page| page.diary_period)
|
||||||
.unwrap_or_else(nuwiki_core::date::DiaryDate::today_utc)
|
.unwrap_or_else(|| nuwiki_core::date::DiaryPeriod::today_utc(wiki.config.frequency()))
|
||||||
};
|
};
|
||||||
let next = {
|
let next = {
|
||||||
let idx = wiki
|
let idx = wiki
|
||||||
@@ -452,9 +455,9 @@ fn diary_step(backend: &Backend, args: Vec<Value>, forward: bool) -> Result<Opti
|
|||||||
.read()
|
.read()
|
||||||
.map_err(|_| "index lock poisoned".to_string())?;
|
.map_err(|_| "index lock poisoned".to_string())?;
|
||||||
if forward {
|
if forward {
|
||||||
crate::diary::next_entry(&idx, &pivot)
|
crate::diary::next_period(&idx, &pivot)
|
||||||
} else {
|
} else {
|
||||||
crate::diary::prev_entry(&idx, &pivot)
|
crate::diary::prev_period(&idx, &pivot)
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
match next {
|
match next {
|
||||||
|
|||||||
@@ -232,6 +232,20 @@ impl WikiConfig {
|
|||||||
p.push(format!("{}{}", date.format(), self.file_extension));
|
p.push(format!("{}{}", date.format(), self.file_extension));
|
||||||
p
|
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 {
|
fn default_diary_rel_path() -> String {
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ use std::path::Path;
|
|||||||
|
|
||||||
use tower_lsp::lsp_types::Url;
|
use tower_lsp::lsp_types::Url;
|
||||||
|
|
||||||
use nuwiki_core::date::DiaryDate;
|
use nuwiki_core::date::{DiaryDate, DiaryPeriod};
|
||||||
|
|
||||||
use crate::config::WikiConfig;
|
use crate::config::WikiConfig;
|
||||||
use crate::index::WorkspaceIndex;
|
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()
|
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.
|
/// `file://` URI of the diary index page.
|
||||||
pub fn index_uri(cfg: &WikiConfig) -> Option<Url> {
|
pub fn index_uri(cfg: &WikiConfig) -> Option<Url> {
|
||||||
Url::from_file_path(cfg.diary_index_path()).ok()
|
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)
|
.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
|
/// Generate the body of the diary index page — a flat newest-first list
|
||||||
/// of `[[diary/YYYY-MM-DD]]` wikilinks, grouped under year and month
|
/// of `[[diary/YYYY-MM-DD]]` wikilinks, grouped under year and month
|
||||||
/// subheadings so the rendered page mirrors `:VimwikiDiaryGenerateLinks`.
|
/// subheadings so the rendered page mirrors `:VimwikiDiaryGenerateLinks`.
|
||||||
|
|||||||
@@ -32,10 +32,16 @@ pub struct IndexedPage {
|
|||||||
/// Phase 12: every `TagNode` on the page. `tags_by_name` on the
|
/// Phase 12: every `TagNode` on the page. `tags_by_name` on the
|
||||||
/// containing `WorkspaceIndex` is the reverse map.
|
/// containing `WorkspaceIndex` is the reverse map.
|
||||||
pub tags: Vec<TagInfo>,
|
pub tags: Vec<TagInfo>,
|
||||||
/// Phase 16: `Some(date)` when this page is a diary entry — i.e. its
|
/// Phase 16: `Some(date)` when this page is a *daily* diary entry —
|
||||||
/// file lives under `<root>/<diary_rel_path>/` and its stem parses as
|
/// stem parses as `YYYY-MM-DD`. Equivalent to
|
||||||
/// `YYYY-MM-DD`. Used by the diary navigation commands.
|
/// `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>,
|
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
|
/// 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) {
|
pub fn upsert(&mut self, uri: Url, ast: &DocumentNode) {
|
||||||
self.remove(&uri);
|
self.remove(&uri);
|
||||||
let name = page_name_from_uri(&uri, self.root.as_deref());
|
let name = page_name_from_uri(&uri, self.root.as_deref());
|
||||||
let diary_date =
|
let diary_period =
|
||||||
diary_date_for_uri(&uri, self.root.as_deref(), self.diary_rel_path.as_deref());
|
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 {
|
let mut page = IndexedPage {
|
||||||
uri: uri.clone(),
|
uri: uri.clone(),
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
@@ -128,6 +138,7 @@ impl WorkspaceIndex {
|
|||||||
outgoing: Vec::new(),
|
outgoing: Vec::new(),
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
diary_date,
|
diary_date,
|
||||||
|
diary_period,
|
||||||
};
|
};
|
||||||
index_blocks(&ast.children, &mut page);
|
index_blocks(&ast.children, &mut page);
|
||||||
|
|
||||||
@@ -224,19 +235,40 @@ impl WorkspaceIndex {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Return a parsed [`DiaryDate`] when `uri` is a diary entry — its file
|
/// Return a parsed [`DiaryDate`] when `uri` is a daily diary entry —
|
||||||
/// path is under `<root>/<diary_rel_path>/` and its stem parses as
|
/// the path lives under `<root>/<diary_rel_path>/` and its stem parses
|
||||||
/// `YYYY-MM-DD`. Anything else returns `None`.
|
/// as `YYYY-MM-DD`. For weekly/monthly/yearly entries see
|
||||||
|
/// [`diary_period_for_uri`].
|
||||||
pub fn diary_date_for_uri(
|
pub fn diary_date_for_uri(
|
||||||
uri: &Url,
|
uri: &Url,
|
||||||
root: Option<&Path>,
|
root: Option<&Path>,
|
||||||
diary_rel_path: Option<&str>,
|
diary_rel_path: Option<&str>,
|
||||||
) -> Option<DiaryDate> {
|
) -> 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 path = uri.to_file_path().ok()?;
|
||||||
let stem = path.file_stem()?.to_str()?;
|
let stem = path.file_stem()?.to_str()?;
|
||||||
let parsed = DiaryDate::parse(stem)?;
|
let parsed = nuwiki_core::date::DiaryPeriod::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 Some(root) = root else {
|
let Some(root) = root else {
|
||||||
return Some(parsed);
|
return Some(parsed);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
//! Cluster 4 — diary frequency wiring at the WikiConfig + index layer.
|
||||||
|
//!
|
||||||
|
//! These tests exercise the path/URI shape for each frequency and the
|
||||||
|
//! index's ability to recognise non-daily diary entries. The
|
||||||
|
//! integration through `executeCommand` is exercised in the LSP-level
|
||||||
|
//! tests via the Neovim keymap harness; here we stay at the pure-Rust
|
||||||
|
//! layer so failures land with a clean stack.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use nuwiki_core::date::{DiaryDate, DiaryFrequency, DiaryPeriod};
|
||||||
|
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||||
|
use nuwiki_core::syntax::SyntaxPlugin;
|
||||||
|
use nuwiki_lsp::config::WikiConfig;
|
||||||
|
use nuwiki_lsp::diary;
|
||||||
|
use nuwiki_lsp::index::{diary_period_for_uri, WorkspaceIndex};
|
||||||
|
use tower_lsp::lsp_types::Url;
|
||||||
|
|
||||||
|
fn cfg(root: &str, frequency: &str) -> WikiConfig {
|
||||||
|
let mut c = WikiConfig::from_root(PathBuf::from(root));
|
||||||
|
c.diary_rel_path = "diary".into();
|
||||||
|
c.diary_frequency = frequency.into();
|
||||||
|
c
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn frequency_helper_picks_up_config_value() {
|
||||||
|
assert_eq!(cfg("/tmp", "daily").frequency(), DiaryFrequency::Daily);
|
||||||
|
assert_eq!(cfg("/tmp", "weekly").frequency(), DiaryFrequency::Weekly);
|
||||||
|
assert_eq!(cfg("/tmp", "monthly").frequency(), DiaryFrequency::Monthly);
|
||||||
|
assert_eq!(cfg("/tmp", "yearly").frequency(), DiaryFrequency::Yearly);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diary_path_for_period_picks_right_stem_per_frequency() {
|
||||||
|
let c = cfg("/wiki", "daily");
|
||||||
|
let day = DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap());
|
||||||
|
let wk = DiaryPeriod::Week {
|
||||||
|
iso_year: 2026,
|
||||||
|
iso_week: 19,
|
||||||
|
};
|
||||||
|
let mo = DiaryPeriod::Month {
|
||||||
|
year: 2026,
|
||||||
|
month: 5,
|
||||||
|
};
|
||||||
|
let yr = DiaryPeriod::Year { year: 2026 };
|
||||||
|
assert_eq!(
|
||||||
|
c.diary_path_for_period(&day),
|
||||||
|
PathBuf::from("/wiki/diary/2026-05-12.wiki")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
c.diary_path_for_period(&wk),
|
||||||
|
PathBuf::from("/wiki/diary/2026-W19.wiki")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
c.diary_path_for_period(&mo),
|
||||||
|
PathBuf::from("/wiki/diary/2026-05.wiki")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
c.diary_path_for_period(&yr),
|
||||||
|
PathBuf::from("/wiki/diary/2026.wiki")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uri_for_period_produces_file_url_per_frequency() {
|
||||||
|
let c = cfg("/wiki", "weekly");
|
||||||
|
let wk = DiaryPeriod::Week {
|
||||||
|
iso_year: 2026,
|
||||||
|
iso_week: 19,
|
||||||
|
};
|
||||||
|
let u = diary::uri_for_period(&c, &wk).unwrap();
|
||||||
|
assert!(
|
||||||
|
u.as_str().ends_with("/wiki/diary/2026-W19.wiki"),
|
||||||
|
"got: {u}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Index recognition =====
|
||||||
|
|
||||||
|
fn make_index_with(entries: &[&str]) -> WorkspaceIndex {
|
||||||
|
let root = PathBuf::from("/wiki");
|
||||||
|
let mut idx = WorkspaceIndex::new(Some(root.clone())).with_diary_rel_path(Some("diary".into()));
|
||||||
|
for stem in entries {
|
||||||
|
let path = format!("/wiki/diary/{stem}.wiki");
|
||||||
|
let uri = Url::from_file_path(&path).unwrap();
|
||||||
|
let ast = VimwikiSyntax::new().parse("= entry =\n");
|
||||||
|
idx.upsert(uri, &ast);
|
||||||
|
}
|
||||||
|
idx
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn index_recognises_all_four_frequency_stems() {
|
||||||
|
let idx = make_index_with(&["2026-05-12", "2026-W19", "2026-05", "2026"]);
|
||||||
|
for (stem, expected) in [
|
||||||
|
(
|
||||||
|
"2026-05-12",
|
||||||
|
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"2026-W19",
|
||||||
|
DiaryPeriod::Week {
|
||||||
|
iso_year: 2026,
|
||||||
|
iso_week: 19,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"2026-05",
|
||||||
|
DiaryPeriod::Month {
|
||||||
|
year: 2026,
|
||||||
|
month: 5,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
("2026", DiaryPeriod::Year { year: 2026 }),
|
||||||
|
] {
|
||||||
|
let uri = Url::from_file_path(format!("/wiki/diary/{stem}.wiki")).unwrap();
|
||||||
|
let page = idx.page(&uri).expect(stem);
|
||||||
|
assert_eq!(
|
||||||
|
page.diary_period,
|
||||||
|
Some(expected),
|
||||||
|
"stem {stem} should index as {expected:?}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn index_only_sets_diary_date_for_daily_entries() {
|
||||||
|
let idx = make_index_with(&["2026-05-12", "2026-W19"]);
|
||||||
|
let daily = Url::from_file_path("/wiki/diary/2026-05-12.wiki").unwrap();
|
||||||
|
let weekly = Url::from_file_path("/wiki/diary/2026-W19.wiki").unwrap();
|
||||||
|
assert!(idx.page(&daily).unwrap().diary_date.is_some());
|
||||||
|
assert!(idx.page(&weekly).unwrap().diary_date.is_none());
|
||||||
|
assert!(idx.page(&weekly).unwrap().diary_period.is_some());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diary_period_for_uri_rejects_paths_outside_diary_dir() {
|
||||||
|
// Non-diary path with a date-looking stem — should NOT count.
|
||||||
|
let uri = Url::from_file_path("/wiki/notes/2026-05-12.wiki").unwrap();
|
||||||
|
let root = PathBuf::from("/wiki");
|
||||||
|
assert!(diary_period_for_uri(&uri, Some(&root), Some("diary")).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_prev_period_navigate_at_the_same_frequency() {
|
||||||
|
// Mix entries at different frequencies. Navigation should stay
|
||||||
|
// within the same flavour as the pivot.
|
||||||
|
let idx = make_index_with(&[
|
||||||
|
"2026-W18",
|
||||||
|
"2026-W19",
|
||||||
|
"2026-W20", // weekly
|
||||||
|
"2026-04",
|
||||||
|
"2026-05",
|
||||||
|
"2026-06", // monthly
|
||||||
|
"2026-05-10",
|
||||||
|
"2026-05-12", // daily
|
||||||
|
]);
|
||||||
|
let pivot_w = DiaryPeriod::Week {
|
||||||
|
iso_year: 2026,
|
||||||
|
iso_week: 19,
|
||||||
|
};
|
||||||
|
let next_w = diary::next_period(&idx, &pivot_w).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
next_w.period,
|
||||||
|
DiaryPeriod::Week {
|
||||||
|
iso_year: 2026,
|
||||||
|
iso_week: 20
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let prev_w = diary::prev_period(&idx, &pivot_w).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
prev_w.period,
|
||||||
|
DiaryPeriod::Week {
|
||||||
|
iso_year: 2026,
|
||||||
|
iso_week: 18
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
let pivot_m = DiaryPeriod::Month {
|
||||||
|
year: 2026,
|
||||||
|
month: 5,
|
||||||
|
};
|
||||||
|
let next_m = diary::next_period(&idx, &pivot_m).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
next_m.period,
|
||||||
|
DiaryPeriod::Month {
|
||||||
|
year: 2026,
|
||||||
|
month: 6
|
||||||
|
}
|
||||||
|
);
|
||||||
|
let prev_m = diary::prev_period(&idx, &pivot_m).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
prev_m.period,
|
||||||
|
DiaryPeriod::Month {
|
||||||
|
year: 2026,
|
||||||
|
month: 4
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_prev_period_returns_none_past_the_edge() {
|
||||||
|
let idx = make_index_with(&["2026-W19"]);
|
||||||
|
let only = DiaryPeriod::Week {
|
||||||
|
iso_year: 2026,
|
||||||
|
iso_week: 19,
|
||||||
|
};
|
||||||
|
assert!(diary::next_period(&idx, &only).is_none());
|
||||||
|
assert!(diary::prev_period(&idx, &only).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diary_period_for_uri_handles_each_frequency_under_diary_dir() {
|
||||||
|
let root = PathBuf::from("/wiki");
|
||||||
|
for (stem, expected) in [
|
||||||
|
(
|
||||||
|
"2026-05-12",
|
||||||
|
DiaryPeriod::Day(DiaryDate::from_ymd(2026, 5, 12).unwrap()),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"2026-W19",
|
||||||
|
DiaryPeriod::Week {
|
||||||
|
iso_year: 2026,
|
||||||
|
iso_week: 19,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"2026-05",
|
||||||
|
DiaryPeriod::Month {
|
||||||
|
year: 2026,
|
||||||
|
month: 5,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
("2026", DiaryPeriod::Year { year: 2026 }),
|
||||||
|
] {
|
||||||
|
let uri = Url::from_file_path(format!("/wiki/diary/{stem}.wiki")).unwrap();
|
||||||
|
let got = diary_period_for_uri(&uri, Some(&root), Some("diary"));
|
||||||
|
assert_eq!(got, Some(expected), "stem {stem}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user