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:
@@ -17,6 +17,7 @@ use nuwiki_core::ast::{
|
||||
BlockNode, BlockquoteNode, DocumentNode, InlineNode, LinkKind, LinkTarget, ListNode, Span,
|
||||
TableNode, TagScope,
|
||||
};
|
||||
use nuwiki_core::date::DiaryDate;
|
||||
use tower_lsp::lsp_types::Url;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
@@ -31,6 +32,10 @@ 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.
|
||||
pub diary_date: Option<DiaryDate>,
|
||||
}
|
||||
|
||||
/// One tag occurrence on a page. `name` is the bare tag string (no
|
||||
@@ -81,6 +86,10 @@ pub struct TagOccurrence {
|
||||
#[derive(Debug, Default)]
|
||||
pub struct WorkspaceIndex {
|
||||
pub root: Option<PathBuf>,
|
||||
/// Phase 16: subdir relative to `root` that holds diary entries.
|
||||
/// Mirrors `WikiConfig::diary_rel_path`; stored here so `upsert` can
|
||||
/// classify each indexed URI without a back-reference to the config.
|
||||
pub diary_rel_path: Option<String>,
|
||||
pub pages_by_uri: HashMap<Url, IndexedPage>,
|
||||
pub pages_by_name: HashMap<String, Url>,
|
||||
pub backlinks: HashMap<String, Vec<Backlink>>,
|
||||
@@ -98,12 +107,19 @@ impl WorkspaceIndex {
|
||||
}
|
||||
}
|
||||
|
||||
pub fn with_diary_rel_path(mut self, rel: Option<String>) -> Self {
|
||||
self.diary_rel_path = rel;
|
||||
self
|
||||
}
|
||||
|
||||
/// Insert or update an indexed page. Removes any prior indexing for
|
||||
/// the same URI first (handles renames and re-parses without leaking
|
||||
/// stale backlinks).
|
||||
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 mut page = IndexedPage {
|
||||
uri: uri.clone(),
|
||||
name: name.clone(),
|
||||
@@ -111,6 +127,7 @@ impl WorkspaceIndex {
|
||||
headings: Vec::new(),
|
||||
outgoing: Vec::new(),
|
||||
tags: Vec::new(),
|
||||
diary_date,
|
||||
};
|
||||
index_blocks(&ast.children, &mut page);
|
||||
|
||||
@@ -207,6 +224,31 @@ 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`.
|
||||
pub fn diary_date_for_uri(
|
||||
uri: &Url,
|
||||
root: Option<&Path>,
|
||||
diary_rel_path: Option<&str>,
|
||||
) -> Option<DiaryDate> {
|
||||
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 Some(root) = root else {
|
||||
return Some(parsed);
|
||||
};
|
||||
let parent = path.parent()?;
|
||||
let expected = root.join(diary_rel_path.unwrap_or("diary"));
|
||||
if parent.starts_with(&expected) {
|
||||
Some(parsed)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// Derive a page name from a `file://` URL. With a workspace root the name
|
||||
/// is the URL's path relative to root, sans `.wiki`. Without a root it's
|
||||
/// just the filename stem.
|
||||
|
||||
Reference in New Issue
Block a user