phase 16: diary path conventions + nav commands
Hand-rolled date primitive (no chrono/time dep) plus the diary
command surface that closes the `:VimwikiMakeDiaryNote` ergonomics gap.
nuwiki-core/src/date.rs:
- `DiaryDate { year, month, day }` with strict `YYYY-MM-DD` parsing
(rejects missing zero-padding, alternate separators, whitespace,
impossible calendar dates including the leap-year exceptions).
- `next_day` / `prev_day` via Howard Hinnant's days-from-civil
algorithm — proleptic Gregorian, walks across month/year/leap-day
boundaries.
- `today_utc()` from `SystemTime::now()`. UTC-only by design: a
local-tz implementation would need a tz database we don't ship.
- `Ord` by epoch days, `Display`/`format` round-trip with `parse`.
WikiConfig (P15 resolution):
- `diary_rel_path: String` (default `"diary"`) — mirrors vimwiki's
`g:vimwiki_diary_rel_path`.
- `diary_index: String` (default `"diary"`) — the stem inside the
diary subdir.
- `diary_dir()`, `diary_index_path()`, `diary_path_for(date)` —
centralise the path math so commands stay declarative.
- `RawWiki` (init-options wire format) accepts both fields, so users
with `g:vimwiki_diary_rel_path = "journal"` migrate cleanly.
WorkspaceIndex:
- New `diary_rel_path: Option<String>` field set by `Wiki::new`.
- `IndexedPage.diary_date: Option<DiaryDate>` — populated in `upsert`
when the URI is under `<root>/<diary_rel_path>/` and the stem
parses. `diary_date_for_uri` is the standalone classifier.
- The classifier *requires* the URI to be inside the diary subdir
when a root is known; with no root it accepts any file whose stem
is a date (kept for the ad-hoc test/scratch case).
nuwiki-lsp/src/diary.rs:
- `uri_for_date` / `index_uri` — bridge from `DiaryDate` and config
to `file://` URIs.
- `list_entries(index) → Vec<DiaryEntry>` — ascending, plus
`list_entries_filtered(year, month)` for calendar hooks.
- `next_entry` / `prev_entry` — strict less-than/greater-than
comparison so navigating from a non-diary page (uses today as
pivot) still moves to the right entry.
- `build_index_body` — newest-first, grouped under `== YYYY ==` /
`=== Month ===` subheadings, emits `- [[diary/YYYY-MM-DD]]`. Matches
`:VimwikiDiaryGenerateLinks`.
- `DiaryEntry` JSON shape: `{ date: "YYYY-MM-DD", uri }`. Manual
serde impl so DiaryDate stays serde-free in nuwiki-core.
Commands (9 new):
- `nuwiki.diary.openToday` / `openYesterday` / `openTomorrow` →
`{ uri, date }`.
- `nuwiki.diary.openIndex` → `{ uri }`.
- `nuwiki.diary.generateIndex` → WorkspaceEdit. Three paths:
live document in `backend.documents` → full-doc replace; on-disk
index → read + full-doc replace; missing → CreateFile + insert.
- `nuwiki.diary.next` / `prev` → pivots on the current page's
`diary_date`, falls back to today.
- `nuwiki.diary.listEntries` → optional `year` / `month` filter.
- `nuwiki.diary.openForDate` → strict `YYYY-MM-DD` arg.
All commands accept an optional `uri` to scope to a particular wiki
when multi-wiki lands; the dispatcher falls back to `default_wiki()`.
Tests: 35 new in `phase16_diary.rs` (date parser exhaustively,
calendar arithmetic across boundaries + leaps, path conventions,
upsert classification including the "date filename outside diary dir"
no-op case, list/filter/nav, index body grouping, serde shape, command
list completeness). Total 309 tests pass.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,166 @@
|
||||
//! Date primitives for the diary subsystem (Phase 16).
|
||||
//!
|
||||
//! v1.1 deliberately doesn't pull in `chrono` or `time`. The diary feature
|
||||
//! only needs `YYYY-MM-DD` parsing/formatting and ±1 day arithmetic, which
|
||||
//! is small enough that the dependency cost outweighs the convenience.
|
||||
//!
|
||||
//! Date math uses Howard Hinnant's days-from-civil algorithm
|
||||
//! (<https://howardhinnant.github.io/date_algorithms.html>) — proleptic
|
||||
//! Gregorian, valid for any year representable in `i32`.
|
||||
|
||||
use std::cmp::Ordering;
|
||||
use std::fmt;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
/// A calendar day, decoupled from any time zone or wall-clock context.
|
||||
///
|
||||
/// Validation: `parse` and `from_ymd` reject impossible dates (month 0,
|
||||
/// day 0, day > days-in-month, etc.). The struct fields are public so
|
||||
/// callers can read them, but constructing one with bogus values bypasses
|
||||
/// validation — use the constructors.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||
pub struct DiaryDate {
|
||||
pub year: i32,
|
||||
pub month: u8,
|
||||
pub day: u8,
|
||||
}
|
||||
|
||||
impl DiaryDate {
|
||||
/// Build a date from its components, validating the calendar.
|
||||
pub fn from_ymd(year: i32, month: u8, day: u8) -> Option<Self> {
|
||||
if !(1..=12).contains(&month) {
|
||||
return None;
|
||||
}
|
||||
if day < 1 || day > days_in_month(year, month) {
|
||||
return None;
|
||||
}
|
||||
Some(Self { year, month, day })
|
||||
}
|
||||
|
||||
/// Parse a strict `YYYY-MM-DD` string. Rejects any deviation — extra
|
||||
/// whitespace, missing zero-padding, alternate separators, etc.
|
||||
pub fn parse(s: &str) -> Option<Self> {
|
||||
let b = s.as_bytes();
|
||||
if b.len() != 10 || b[4] != b'-' || b[7] != b'-' {
|
||||
return None;
|
||||
}
|
||||
let y = parse_digits(&b[0..4])?;
|
||||
let m = parse_digits(&b[5..7])? as u8;
|
||||
let d = parse_digits(&b[8..10])? as u8;
|
||||
Self::from_ymd(y as i32, m, d)
|
||||
}
|
||||
|
||||
/// Format as `YYYY-MM-DD`. Years outside 0..=9999 still render
|
||||
/// numerically; the diary use case never produces those.
|
||||
pub fn format(&self) -> String {
|
||||
format!("{:04}-{:02}-{:02}", self.year, self.month, self.day)
|
||||
}
|
||||
|
||||
/// UTC "today" computed from `SystemTime::now()`. Phase 16's diary
|
||||
/// commands intentionally use UTC — local-time semantics depend on a
|
||||
/// timezone DB we don't ship and would surprise users crossing DST
|
||||
/// boundaries.
|
||||
pub fn today_utc() -> Self {
|
||||
let secs = SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let days = (secs / 86_400) as i64;
|
||||
from_days(days)
|
||||
}
|
||||
|
||||
pub fn next_day(&self) -> Self {
|
||||
from_days(to_days(self.year, self.month, self.day) + 1)
|
||||
}
|
||||
|
||||
pub fn prev_day(&self) -> Self {
|
||||
from_days(to_days(self.year, self.month, self.day) - 1)
|
||||
}
|
||||
|
||||
/// Internal — exposed for tests that want to verify ordering against
|
||||
/// the days-from-epoch representation.
|
||||
pub fn to_days_epoch(&self) -> i64 {
|
||||
to_days(self.year, self.month, self.day)
|
||||
}
|
||||
}
|
||||
|
||||
impl PartialOrd for DiaryDate {
|
||||
fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
|
||||
Some(self.cmp(other))
|
||||
}
|
||||
}
|
||||
|
||||
impl Ord for DiaryDate {
|
||||
fn cmp(&self, other: &Self) -> Ordering {
|
||||
self.to_days_epoch().cmp(&other.to_days_epoch())
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Display for DiaryDate {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
write!(f, "{}", self.format())
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_digits(b: &[u8]) -> Option<u32> {
|
||||
if b.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let mut n: u32 = 0;
|
||||
for ch in b {
|
||||
if !ch.is_ascii_digit() {
|
||||
return None;
|
||||
}
|
||||
n = n.checked_mul(10)?.checked_add((*ch - b'0') as u32)?;
|
||||
}
|
||||
Some(n)
|
||||
}
|
||||
|
||||
fn is_leap(year: i32) -> bool {
|
||||
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
|
||||
}
|
||||
|
||||
fn days_in_month(year: i32, month: u8) -> u8 {
|
||||
match month {
|
||||
1 | 3 | 5 | 7 | 8 | 10 | 12 => 31,
|
||||
4 | 6 | 9 | 11 => 30,
|
||||
2 => {
|
||||
if is_leap(year) {
|
||||
29
|
||||
} else {
|
||||
28
|
||||
}
|
||||
}
|
||||
_ => 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Howard Hinnant's `days_from_civil`: 1970-01-01 → 0.
|
||||
fn to_days(year: i32, month: u8, day: u8) -> i64 {
|
||||
let y = year as i64 - if (month as i64) <= 2 { 1 } else { 0 };
|
||||
let era = y.div_euclid(400);
|
||||
let yoe = y.rem_euclid(400);
|
||||
let m = month as i64;
|
||||
let doy = (153 * (if m > 2 { m - 3 } else { m + 9 }) + 2) / 5 + day as i64 - 1;
|
||||
let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
|
||||
era * 146_097 + doe - 719_468
|
||||
}
|
||||
|
||||
/// Inverse of [`to_days`].
|
||||
fn from_days(days: i64) -> DiaryDate {
|
||||
let z = days + 719_468;
|
||||
let era = if z >= 0 { z } else { z - 146_096 } / 146_097;
|
||||
let doe = (z - era * 146_097) as u64;
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe as i64 + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u8;
|
||||
let m_int = if mp < 10 { mp + 3 } else { mp - 9 };
|
||||
let year = y + if m_int <= 2 { 1 } else { 0 };
|
||||
DiaryDate {
|
||||
year: year as i32,
|
||||
month: m_int as u8,
|
||||
day: d,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user