e75ad6ca89
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>
170 lines
5.3 KiB
Rust
170 lines
5.3 KiB
Rust
//! Diary subsystem (Phase 16) — pure helpers used by the
|
|
//! `nuwiki.diary.*` `executeCommand` handlers.
|
|
//!
|
|
//! All path/URI math lives here so the command dispatcher stays a thin
|
|
//! wrapper. The diary navigation model is intentionally lightweight:
|
|
//! entries are identified by file stem (`YYYY-MM-DD`) living under
|
|
//! `<wiki_root>/<diary_rel_path>/`. The `WorkspaceIndex` tags each
|
|
//! `IndexedPage` with a `diary_date` so prev/next/list operations stay
|
|
//! O(n) over the indexed pages.
|
|
|
|
use std::path::Path;
|
|
|
|
use tower_lsp::lsp_types::Url;
|
|
|
|
use nuwiki_core::date::DiaryDate;
|
|
|
|
use crate::config::WikiConfig;
|
|
use crate::index::WorkspaceIndex;
|
|
|
|
/// Resolve a `DiaryDate` to its `file://` URI under the given wiki.
|
|
pub fn uri_for_date(cfg: &WikiConfig, date: &DiaryDate) -> Option<Url> {
|
|
Url::from_file_path(cfg.diary_path_for(date)).ok()
|
|
}
|
|
|
|
/// `file://` URI of the diary index page.
|
|
pub fn index_uri(cfg: &WikiConfig) -> Option<Url> {
|
|
Url::from_file_path(cfg.diary_index_path()).ok()
|
|
}
|
|
|
|
/// All diary entries currently indexed for `wiki`, sorted ascending by
|
|
/// date. The wiki's index must already have its `diary_rel_path` set
|
|
/// (handled by `Wiki::new`).
|
|
pub fn list_entries(index: &WorkspaceIndex) -> Vec<DiaryEntry> {
|
|
let mut out: Vec<DiaryEntry> = index
|
|
.pages_by_uri
|
|
.values()
|
|
.filter_map(|p| {
|
|
p.diary_date.map(|d| DiaryEntry {
|
|
date: d,
|
|
uri: p.uri.clone(),
|
|
})
|
|
})
|
|
.collect();
|
|
out.sort_by(|a, b| a.date.cmp(&b.date));
|
|
out
|
|
}
|
|
|
|
/// Entries for a given (year, month). `month = None` returns the whole
|
|
/// year; `year = None` matches every year.
|
|
pub fn list_entries_filtered(
|
|
index: &WorkspaceIndex,
|
|
year: Option<i32>,
|
|
month: Option<u8>,
|
|
) -> Vec<DiaryEntry> {
|
|
list_entries(index)
|
|
.into_iter()
|
|
.filter(|e| year.is_none_or(|y| e.date.year == y))
|
|
.filter(|e| month.is_none_or(|m| e.date.month == m))
|
|
.collect()
|
|
}
|
|
|
|
/// The diary entry chronologically after `from`. `from` doesn't need to
|
|
/// be indexed itself — we look for the smallest indexed date strictly
|
|
/// greater than `from`.
|
|
pub fn next_entry(index: &WorkspaceIndex, from: &DiaryDate) -> Option<DiaryEntry> {
|
|
list_entries(index).into_iter().find(|e| e.date > *from)
|
|
}
|
|
|
|
/// The diary entry chronologically before `from`.
|
|
pub fn prev_entry(index: &WorkspaceIndex, from: &DiaryDate) -> Option<DiaryEntry> {
|
|
list_entries(index)
|
|
.into_iter()
|
|
.rev()
|
|
.find(|e| e.date < *from)
|
|
}
|
|
|
|
/// Generate the body of the diary index page — a flat newest-first list
|
|
/// of `[[diary/YYYY-MM-DD]]` wikilinks, grouped under year and month
|
|
/// subheadings so the rendered page mirrors `:VimwikiDiaryGenerateLinks`.
|
|
pub fn build_index_body(
|
|
entries: &[DiaryEntry],
|
|
diary_rel_path: &str,
|
|
index_heading: &str,
|
|
) -> String {
|
|
let mut out = String::new();
|
|
out.push_str("= ");
|
|
out.push_str(index_heading);
|
|
out.push_str(" =\n");
|
|
|
|
if entries.is_empty() {
|
|
return out;
|
|
}
|
|
|
|
// Sort descending by date — newest first matches vimwiki.
|
|
let mut sorted: Vec<&DiaryEntry> = entries.iter().collect();
|
|
sorted.sort_by(|a, b| b.date.cmp(&a.date));
|
|
|
|
let mut current_year: Option<i32> = None;
|
|
let mut current_month: Option<u8> = None;
|
|
for e in sorted {
|
|
if current_year != Some(e.date.year) {
|
|
out.push_str(&format!("\n== {} ==\n", e.date.year));
|
|
current_year = Some(e.date.year);
|
|
current_month = None;
|
|
}
|
|
if current_month != Some(e.date.month) {
|
|
out.push_str(&format!("=== {} ===\n", month_name(e.date.month)));
|
|
current_month = Some(e.date.month);
|
|
}
|
|
out.push_str(&format!(
|
|
"- [[{}/{}]]\n",
|
|
diary_rel_path.trim_end_matches('/'),
|
|
e.date.format()
|
|
));
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Render the diary-index body for the given wiki's currently-indexed
|
|
/// entries. Convenience wrapper around [`build_index_body`].
|
|
pub fn render_index_body(cfg: &WikiConfig, index: &WorkspaceIndex) -> String {
|
|
build_index_body(&list_entries(index), &cfg.diary_rel_path, "Diary")
|
|
}
|
|
|
|
/// Cheap "is this path under the diary subdir of this wiki" check —
|
|
/// surface-level, no parse needed.
|
|
pub fn is_in_diary(cfg: &WikiConfig, path: &Path) -> bool {
|
|
path.starts_with(cfg.diary_dir())
|
|
}
|
|
|
|
/// One diary entry, returned by listing/navigation queries. The custom
|
|
/// `Serialize` emits `{ "date": "YYYY-MM-DD", "uri": "file://..." }` so
|
|
/// the client doesn't have to know about [`DiaryDate`]'s internal layout.
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct DiaryEntry {
|
|
pub date: DiaryDate,
|
|
pub uri: Url,
|
|
}
|
|
|
|
impl serde::Serialize for DiaryEntry {
|
|
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
|
|
where
|
|
S: serde::Serializer,
|
|
{
|
|
use serde::ser::SerializeStruct;
|
|
let mut s = serializer.serialize_struct("DiaryEntry", 2)?;
|
|
s.serialize_field("date", &self.date.format())?;
|
|
s.serialize_field("uri", &self.uri)?;
|
|
s.end()
|
|
}
|
|
}
|
|
|
|
fn month_name(m: u8) -> &'static str {
|
|
match m {
|
|
1 => "January",
|
|
2 => "February",
|
|
3 => "March",
|
|
4 => "April",
|
|
5 => "May",
|
|
6 => "June",
|
|
7 => "July",
|
|
8 => "August",
|
|
9 => "September",
|
|
10 => "October",
|
|
11 => "November",
|
|
12 => "December",
|
|
_ => "Unknown",
|
|
}
|
|
}
|