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:
@@ -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(),
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user