phase 16: diary path conventions + nav commands
CI / cargo fmt --check (push) Successful in 27s
CI / cargo clippy (push) Successful in 1m14s
CI / cargo test (push) Successful in 1m20s

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:
2026-05-11 21:04:55 +00:00
parent ce3ac8c4f8
commit e75ad6ca89
10 changed files with 1089 additions and 2 deletions
+282
View File
@@ -43,6 +43,15 @@ pub const COMMANDS: &[&str] = &[
"nuwiki.links.generate",
"nuwiki.workspace.checkLinks",
"nuwiki.workspace.findOrphans",
"nuwiki.diary.openToday",
"nuwiki.diary.openYesterday",
"nuwiki.diary.openTomorrow",
"nuwiki.diary.openIndex",
"nuwiki.diary.generateIndex",
"nuwiki.diary.next",
"nuwiki.diary.prev",
"nuwiki.diary.listEntries",
"nuwiki.diary.openForDate",
];
pub(crate) async fn execute(
@@ -80,6 +89,30 @@ pub(crate) async fn execute(
"nuwiki.workspace.findOrphans" => {
workspace_find_orphans(backend, args).map(|o| o.map(CommandOutcome::Value))
}
"nuwiki.diary.openToday" => diary_open_relative(backend, args, RelativeDay::Today)
.map(|o| o.map(CommandOutcome::Value)),
"nuwiki.diary.openYesterday" => diary_open_relative(backend, args, RelativeDay::Yesterday)
.map(|o| o.map(CommandOutcome::Value)),
"nuwiki.diary.openTomorrow" => diary_open_relative(backend, args, RelativeDay::Tomorrow)
.map(|o| o.map(CommandOutcome::Value)),
"nuwiki.diary.openIndex" => {
diary_open_index(backend, args).map(|o| o.map(CommandOutcome::Value))
}
"nuwiki.diary.generateIndex" => {
diary_generate_index(backend, args).map(|o| o.map(CommandOutcome::Edit))
}
"nuwiki.diary.next" => {
diary_step(backend, args, true).map(|o| o.map(CommandOutcome::Value))
}
"nuwiki.diary.prev" => {
diary_step(backend, args, false).map(|o| o.map(CommandOutcome::Value))
}
"nuwiki.diary.listEntries" => {
diary_list(backend, args).map(|o| o.map(CommandOutcome::Value))
}
"nuwiki.diary.openForDate" => {
diary_open_for_date(backend, args).map(|o| o.map(CommandOutcome::Value))
}
other => Err(format!("unknown nuwiki command: {other}")),
}
}
@@ -249,6 +282,176 @@ fn workspace_check_links(backend: &Backend, args: Vec<Value>) -> Result<Option<V
})?))
}
#[derive(Clone, Copy)]
enum RelativeDay {
Today,
Yesterday,
Tomorrow,
}
fn resolve_diary_wiki(backend: &Backend, uri: Option<&Url>) -> Option<crate::wiki::Wiki> {
if let Some(u) = uri {
if let Some(w) = backend.wiki_for_uri(u) {
return Some(w);
}
}
backend.default_wiki()
}
fn diary_open_relative(
backend: &Backend,
args: Vec<Value>,
rel: RelativeDay,
) -> Result<Option<Value>, String> {
let p = parse_optional_uri_arg(args)?;
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 Some(uri) = crate::diary::uri_for_date(&wiki.config, &date) else {
return Ok(None);
};
Ok(Some(serde_json::json!({
"uri": uri,
"date": date.format(),
})))
}
fn diary_open_index(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, String> {
let p = parse_optional_uri_arg(args)?;
let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref()) else {
return Ok(None);
};
let Some(uri) = crate::diary::index_uri(&wiki.config) else {
return Ok(None);
};
Ok(Some(serde_json::json!({ "uri": uri })))
}
fn diary_generate_index(
backend: &Backend,
args: Vec<Value>,
) -> Result<Option<WorkspaceEdit>, String> {
let p = parse_optional_uri_arg(args)?;
let Some(wiki) = resolve_diary_wiki(backend, p.uri.as_ref()) else {
return Ok(None);
};
let utf8 = backend.use_utf8.load(Ordering::Relaxed);
let Some(index_uri) = crate::diary::index_uri(&wiki.config) else {
return Ok(None);
};
let body = {
let idx = wiki
.index
.read()
.map_err(|_| "index lock poisoned".to_string())?;
crate::diary::render_index_body(&wiki.config, &idx)
};
Ok(Some(ops::diary_generate_index_edit(
backend, &index_uri, &body, utf8,
)))
}
fn diary_step(backend: &Backend, args: Vec<Value>, forward: bool) -> Result<Option<Value>, String> {
let p = parse_uri_arg(args)?;
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 = {
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)
};
let next = {
let idx = wiki
.index
.read()
.map_err(|_| "index lock poisoned".to_string())?;
if forward {
crate::diary::next_entry(&idx, &pivot)
} else {
crate::diary::prev_entry(&idx, &pivot)
}
};
match next {
None => Ok(None),
Some(entry) => Ok(Some(serde_json::to_value(&entry).map_err(|e| {
format!("nuwiki.diary.next/prev: serialization failed — {e}")
})?)),
}
}
fn diary_list(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, String> {
#[derive(Deserialize, Default)]
struct Args {
#[serde(default)]
uri: Option<Url>,
#[serde(default)]
year: Option<i32>,
#[serde(default)]
month: Option<u8>,
}
let parsed: Args = match args.into_iter().next() {
Some(v) => serde_json::from_value(v).map_err(|e| format!("invalid args: {e}"))?,
None => Args::default(),
};
let Some(wiki) = resolve_diary_wiki(backend, parsed.uri.as_ref()) else {
return Ok(Some(serde_json::json!([])));
};
let entries = {
let idx = wiki
.index
.read()
.map_err(|_| "index lock poisoned".to_string())?;
crate::diary::list_entries_filtered(&idx, parsed.year, parsed.month)
};
Ok(Some(serde_json::to_value(&entries).map_err(|e| {
format!("nuwiki.diary.listEntries: serialization failed — {e}")
})?))
}
fn diary_open_for_date(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, String> {
#[derive(Deserialize)]
struct Args {
date: String,
#[serde(default)]
uri: Option<Url>,
}
let raw = args
.into_iter()
.next()
.ok_or_else(|| "nuwiki.diary.openForDate: missing { date } argument".to_string())?;
let parsed: Args = serde_json::from_value(raw)
.map_err(|e| format!("nuwiki.diary.openForDate: invalid args — {e}"))?;
let Some(date) = nuwiki_core::date::DiaryDate::parse(&parsed.date) else {
return Err(format!(
"nuwiki.diary.openForDate: '{}' is not a YYYY-MM-DD date",
parsed.date
));
};
let Some(wiki) = resolve_diary_wiki(backend, parsed.uri.as_ref()) else {
return Ok(None);
};
let Some(uri) = crate::diary::uri_for_date(&wiki.config, &date) else {
return Ok(None);
};
Ok(Some(
serde_json::json!({ "uri": uri, "date": date.format() }),
))
}
fn workspace_find_orphans(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, String> {
let p = parse_optional_uri_arg(args)?;
let wiki = match p.uri.as_ref().and_then(|u| backend.wiki_for_uri(u)) {
@@ -824,4 +1027,83 @@ pub mod ops {
/// Re-exported AST link walker so tests can drive it without depending
/// on the diagnostics module's internals.
pub use crate::diagnostics::collect_wiki_links;
// ===== Phase 16: diary helpers =====
use crate::edits::op_create;
/// Build the `WorkspaceEdit` that materialises the diary index page.
///
/// Decision tree:
/// - If the index URI is open in `backend.documents`, the live text is
/// the source of truth: emit a single full-document `TextEdit`
/// replacing it with `body`.
/// - If the index file exists on disk but isn't open: same full-doc
/// replace, span computed from the on-disk bytes.
/// - If neither: a `CreateFile` op + an insert at `0:0`.
pub(crate) fn diary_generate_index_edit(
backend: &Backend,
index_uri: &Url,
body: &str,
utf8: bool,
) -> WorkspaceEdit {
let mut b = WorkspaceEditBuilder::new();
if let Some(doc) = backend.documents.get(index_uri) {
let span = whole_doc_span(&doc.text);
b.edit(
index_uri.clone(),
text_edit_replace(span, body.to_string(), &doc.text, utf8),
);
return b.build();
}
if let Ok(path) = index_uri.to_file_path() {
if let Ok(existing) = std::fs::read_to_string(&path) {
let span = whole_doc_span(&existing);
b.edit(
index_uri.clone(),
text_edit_replace(span, body.to_string(), &existing, utf8),
);
return b.build();
}
}
// File does not exist — create it and insert the body. The text
// edit applies *after* CreateFile via the builder's ordering.
b.file_op(op_create(index_uri.clone()));
b.edit(
index_uri.clone(),
crate::edits::text_edit_insert(
LspPosition {
line: 0,
character: 0,
},
body.to_string(),
),
);
b.build()
}
fn whole_doc_span(text: &str) -> Span {
let bytes = text.as_bytes();
let mut line = 0u32;
let mut last_nl = 0usize;
for (i, b) in bytes.iter().enumerate() {
if *b == b'\n' {
line += 1;
last_nl = i + 1;
}
}
let column = (bytes.len() - last_nl) as u32;
Span::new(
AstPosition {
line: 0,
column: 0,
offset: 0,
},
AstPosition {
line,
column,
offset: bytes.len(),
},
)
}
}