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:
@@ -38,7 +38,7 @@ See [`SPEC.md`](./SPEC.md) for the full project specification.
|
|||||||
| 13 | Workspace edits + executeCommand | ✅ done |
|
| 13 | Workspace edits + executeCommand | ✅ done |
|
||||||
| 14 | List & table edit commands | ✅ done (focused subset; table/list-renumber/colorize deferred) |
|
| 14 | List & table edit commands | ✅ done (focused subset; table/list-renumber/colorize deferred) |
|
||||||
| 15 | Link health + TOC/index generation | ✅ done |
|
| 15 | Link health + TOC/index generation | ✅ done |
|
||||||
| 16 | Diary | ⏳ |
|
| 16 | Diary | ✅ done |
|
||||||
| 17 | HTML export commands | ⏳ |
|
| 17 | HTML export commands | ⏳ |
|
||||||
| 18 | Multi-wiki | ⏳ |
|
| 18 | Multi-wiki | ⏳ |
|
||||||
| 19 | Editor glue v2 (`:Vimwiki*` compat, keymaps, text objects, folding) | ⏳ |
|
| 19 | Editor glue v2 (`:Vimwiki*` compat, keymaps, text objects, folding) | ⏳ |
|
||||||
|
|||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,5 +4,6 @@
|
|||||||
//! `nuwiki-ls`. See SPEC.md §6.2 for the dependency rules.
|
//! `nuwiki-ls`. See SPEC.md §6.2 for the dependency rules.
|
||||||
|
|
||||||
pub mod ast;
|
pub mod ast;
|
||||||
|
pub mod date;
|
||||||
pub mod render;
|
pub mod render;
|
||||||
pub mod syntax;
|
pub mod syntax;
|
||||||
|
|||||||
@@ -43,6 +43,15 @@ pub const COMMANDS: &[&str] = &[
|
|||||||
"nuwiki.links.generate",
|
"nuwiki.links.generate",
|
||||||
"nuwiki.workspace.checkLinks",
|
"nuwiki.workspace.checkLinks",
|
||||||
"nuwiki.workspace.findOrphans",
|
"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(
|
pub(crate) async fn execute(
|
||||||
@@ -80,6 +89,30 @@ pub(crate) async fn execute(
|
|||||||
"nuwiki.workspace.findOrphans" => {
|
"nuwiki.workspace.findOrphans" => {
|
||||||
workspace_find_orphans(backend, args).map(|o| o.map(CommandOutcome::Value))
|
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}")),
|
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> {
|
fn workspace_find_orphans(backend: &Backend, args: Vec<Value>) -> Result<Option<Value>, String> {
|
||||||
let p = parse_optional_uri_arg(args)?;
|
let p = parse_optional_uri_arg(args)?;
|
||||||
let wiki = match p.uri.as_ref().and_then(|u| backend.wiki_for_uri(u)) {
|
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
|
/// Re-exported AST link walker so tests can drive it without depending
|
||||||
/// on the diagnostics module's internals.
|
/// on the diagnostics module's internals.
|
||||||
pub use crate::diagnostics::collect_wiki_links;
|
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(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,13 @@ pub struct WikiConfig {
|
|||||||
pub root: PathBuf,
|
pub root: PathBuf,
|
||||||
pub file_extension: String,
|
pub file_extension: String,
|
||||||
pub syntax: String,
|
pub syntax: String,
|
||||||
|
/// Subdirectory under `root` where diary entries live. Matches
|
||||||
|
/// vimwiki's `g:vimwiki_diary_rel_path` (default `"diary"`).
|
||||||
|
pub diary_rel_path: String,
|
||||||
|
/// Stem of the diary index page within the diary subdirectory.
|
||||||
|
/// Matches vimwiki's `g:vimwiki_diary_index` (default `"diary"`).
|
||||||
|
/// The full path is `<root>/<diary_rel_path>/<diary_index>.wiki`.
|
||||||
|
pub diary_index: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -53,6 +60,8 @@ impl WikiConfig {
|
|||||||
root: PathBuf::new(),
|
root: PathBuf::new(),
|
||||||
file_extension: ".wiki".into(),
|
file_extension: ".wiki".into(),
|
||||||
syntax: "vimwiki".into(),
|
syntax: "vimwiki".into(),
|
||||||
|
diary_rel_path: default_diary_rel_path(),
|
||||||
|
diary_index: default_diary_index(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -65,8 +74,37 @@ impl WikiConfig {
|
|||||||
root,
|
root,
|
||||||
file_extension: ".wiki".into(),
|
file_extension: ".wiki".into(),
|
||||||
syntax: "vimwiki".into(),
|
syntax: "vimwiki".into(),
|
||||||
|
diary_rel_path: default_diary_rel_path(),
|
||||||
|
diary_index: default_diary_index(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Absolute path to the diary subdirectory under this wiki's root.
|
||||||
|
pub fn diary_dir(&self) -> PathBuf {
|
||||||
|
self.root.join(&self.diary_rel_path)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absolute path to this wiki's diary index page.
|
||||||
|
pub fn diary_index_path(&self) -> PathBuf {
|
||||||
|
let mut p = self.diary_dir();
|
||||||
|
p.push(format!("{}{}", self.diary_index, self.file_extension));
|
||||||
|
p
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Absolute path for a diary entry on `date`.
|
||||||
|
pub fn diary_path_for(&self, date: &nuwiki_core::date::DiaryDate) -> PathBuf {
|
||||||
|
let mut p = self.diary_dir();
|
||||||
|
p.push(format!("{}{}", date.format(), self.file_extension));
|
||||||
|
p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_diary_rel_path() -> String {
|
||||||
|
"diary".to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_diary_index() -> String {
|
||||||
|
"diary".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Config {
|
impl Config {
|
||||||
@@ -97,6 +135,8 @@ impl Config {
|
|||||||
root,
|
root,
|
||||||
file_extension: raw.file_extension.unwrap_or_else(|| ".wiki".into()),
|
file_extension: raw.file_extension.unwrap_or_else(|| ".wiki".into()),
|
||||||
syntax: raw.syntax.unwrap_or_else(|| "vimwiki".into()),
|
syntax: raw.syntax.unwrap_or_else(|| "vimwiki".into()),
|
||||||
|
diary_rel_path: default_diary_rel_path(),
|
||||||
|
diary_index: default_diary_index(),
|
||||||
}]
|
}]
|
||||||
} else if let Some(folder) = first_workspace_folder(params) {
|
} else if let Some(folder) = first_workspace_folder(params) {
|
||||||
vec![WikiConfig::from_root(folder)]
|
vec![WikiConfig::from_root(folder)]
|
||||||
@@ -155,6 +195,10 @@ struct RawWiki {
|
|||||||
file_extension: Option<String>,
|
file_extension: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
syntax: Option<String>,
|
syntax: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
diary_rel_path: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
diary_index: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<RawWiki> for WikiConfig {
|
impl From<RawWiki> for WikiConfig {
|
||||||
@@ -169,6 +213,8 @@ impl From<RawWiki> for WikiConfig {
|
|||||||
root,
|
root,
|
||||||
file_extension: r.file_extension.unwrap_or_else(|| ".wiki".into()),
|
file_extension: r.file_extension.unwrap_or_else(|| ".wiki".into()),
|
||||||
syntax: r.syntax.unwrap_or_else(|| "vimwiki".into()),
|
syntax: r.syntax.unwrap_or_else(|| "vimwiki".into()),
|
||||||
|
diary_rel_path: r.diary_rel_path.unwrap_or_else(default_diary_rel_path),
|
||||||
|
diary_index: r.diary_index.unwrap_or_else(default_diary_index),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
//! 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",
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ use nuwiki_core::ast::{
|
|||||||
BlockNode, BlockquoteNode, DocumentNode, InlineNode, LinkKind, LinkTarget, ListNode, Span,
|
BlockNode, BlockquoteNode, DocumentNode, InlineNode, LinkKind, LinkTarget, ListNode, Span,
|
||||||
TableNode, TagScope,
|
TableNode, TagScope,
|
||||||
};
|
};
|
||||||
|
use nuwiki_core::date::DiaryDate;
|
||||||
use tower_lsp::lsp_types::Url;
|
use tower_lsp::lsp_types::Url;
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@@ -31,6 +32,10 @@ pub struct IndexedPage {
|
|||||||
/// Phase 12: every `TagNode` on the page. `tags_by_name` on the
|
/// Phase 12: every `TagNode` on the page. `tags_by_name` on the
|
||||||
/// containing `WorkspaceIndex` is the reverse map.
|
/// containing `WorkspaceIndex` is the reverse map.
|
||||||
pub tags: Vec<TagInfo>,
|
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
|
/// One tag occurrence on a page. `name` is the bare tag string (no
|
||||||
@@ -81,6 +86,10 @@ pub struct TagOccurrence {
|
|||||||
#[derive(Debug, Default)]
|
#[derive(Debug, Default)]
|
||||||
pub struct WorkspaceIndex {
|
pub struct WorkspaceIndex {
|
||||||
pub root: Option<PathBuf>,
|
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_uri: HashMap<Url, IndexedPage>,
|
||||||
pub pages_by_name: HashMap<String, Url>,
|
pub pages_by_name: HashMap<String, Url>,
|
||||||
pub backlinks: HashMap<String, Vec<Backlink>>,
|
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
|
/// Insert or update an indexed page. Removes any prior indexing for
|
||||||
/// the same URI first (handles renames and re-parses without leaking
|
/// the same URI first (handles renames and re-parses without leaking
|
||||||
/// stale backlinks).
|
/// stale backlinks).
|
||||||
pub fn upsert(&mut self, uri: Url, ast: &DocumentNode) {
|
pub fn upsert(&mut self, uri: Url, ast: &DocumentNode) {
|
||||||
self.remove(&uri);
|
self.remove(&uri);
|
||||||
let name = page_name_from_uri(&uri, self.root.as_deref());
|
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 {
|
let mut page = IndexedPage {
|
||||||
uri: uri.clone(),
|
uri: uri.clone(),
|
||||||
name: name.clone(),
|
name: name.clone(),
|
||||||
@@ -111,6 +127,7 @@ impl WorkspaceIndex {
|
|||||||
headings: Vec::new(),
|
headings: Vec::new(),
|
||||||
outgoing: Vec::new(),
|
outgoing: Vec::new(),
|
||||||
tags: Vec::new(),
|
tags: Vec::new(),
|
||||||
|
diary_date,
|
||||||
};
|
};
|
||||||
index_blocks(&ast.children, &mut page);
|
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
|
/// 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
|
/// is the URL's path relative to root, sans `.wiki`. Without a root it's
|
||||||
/// just the filename stem.
|
/// just the filename stem.
|
||||||
|
|||||||
@@ -24,6 +24,7 @@
|
|||||||
pub mod commands;
|
pub mod commands;
|
||||||
pub mod config;
|
pub mod config;
|
||||||
pub mod diagnostics;
|
pub mod diagnostics;
|
||||||
|
pub mod diary;
|
||||||
pub mod edits;
|
pub mod edits;
|
||||||
pub mod index;
|
pub mod index;
|
||||||
pub mod nav;
|
pub mod nav;
|
||||||
|
|||||||
@@ -25,7 +25,9 @@ pub struct Wiki {
|
|||||||
|
|
||||||
impl Wiki {
|
impl Wiki {
|
||||||
pub fn new(id: WikiId, config: WikiConfig) -> Self {
|
pub fn new(id: WikiId, config: WikiConfig) -> Self {
|
||||||
let index = Arc::new(RwLock::new(WorkspaceIndex::new(Some(config.root.clone()))));
|
let index = WorkspaceIndex::new(Some(config.root.clone()))
|
||||||
|
.with_diary_rel_path(Some(config.diary_rel_path.clone()));
|
||||||
|
let index = Arc::new(RwLock::new(index));
|
||||||
Self { id, config, index }
|
Self { id, config, index }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,378 @@
|
|||||||
|
//! Phase 16: diary date primitives + path conventions + nav helpers +
|
||||||
|
//! IndexedPage diary classification.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
use nuwiki_core::date::DiaryDate;
|
||||||
|
use nuwiki_core::syntax::vimwiki::VimwikiSyntax;
|
||||||
|
use nuwiki_core::syntax::SyntaxPlugin;
|
||||||
|
use nuwiki_lsp::config::WikiConfig;
|
||||||
|
use nuwiki_lsp::diary;
|
||||||
|
use nuwiki_lsp::index::WorkspaceIndex;
|
||||||
|
use tower_lsp::lsp_types::Url;
|
||||||
|
|
||||||
|
fn parse(src: &str) -> nuwiki_core::ast::DocumentNode {
|
||||||
|
VimwikiSyntax::new().parse(src)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wiki_cfg(root: &str) -> WikiConfig {
|
||||||
|
let mut cfg = WikiConfig::from_root(PathBuf::from(root));
|
||||||
|
cfg.diary_rel_path = "diary".into();
|
||||||
|
cfg.diary_index = "diary".into();
|
||||||
|
cfg
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_index_with_entries(root: &str, dates: &[&str]) -> WorkspaceIndex {
|
||||||
|
let mut idx =
|
||||||
|
WorkspaceIndex::new(Some(PathBuf::from(root))).with_diary_rel_path(Some("diary".into()));
|
||||||
|
for d in dates {
|
||||||
|
let path = format!("{root}/diary/{d}.wiki");
|
||||||
|
let uri = Url::from_file_path(&path).unwrap();
|
||||||
|
idx.upsert(uri, &parse("= entry =\n"));
|
||||||
|
}
|
||||||
|
idx
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== DiaryDate parser =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_accepts_canonical_iso8601() {
|
||||||
|
let d = DiaryDate::parse("2026-05-11").unwrap();
|
||||||
|
assert_eq!(d.year, 2026);
|
||||||
|
assert_eq!(d.month, 5);
|
||||||
|
assert_eq!(d.day, 11);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_rejects_missing_zero_padding() {
|
||||||
|
assert!(DiaryDate::parse("2026-5-11").is_none());
|
||||||
|
assert!(DiaryDate::parse("2026-05-1").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_rejects_alternate_separators() {
|
||||||
|
assert!(DiaryDate::parse("2026/05/11").is_none());
|
||||||
|
assert!(DiaryDate::parse("2026.05.11").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_rejects_extra_whitespace() {
|
||||||
|
assert!(DiaryDate::parse(" 2026-05-11").is_none());
|
||||||
|
assert!(DiaryDate::parse("2026-05-11 ").is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_rejects_invalid_calendar_dates() {
|
||||||
|
assert!(DiaryDate::parse("2026-13-01").is_none(), "month 13");
|
||||||
|
assert!(DiaryDate::parse("2026-02-30").is_none(), "feb 30");
|
||||||
|
assert!(DiaryDate::parse("2026-04-31").is_none(), "april 31");
|
||||||
|
assert!(DiaryDate::parse("2026-00-10").is_none(), "month 0");
|
||||||
|
assert!(DiaryDate::parse("2026-05-00").is_none(), "day 0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parse_accepts_leap_day_in_leap_year() {
|
||||||
|
assert!(DiaryDate::parse("2024-02-29").is_some(), "2024 is leap");
|
||||||
|
assert!(DiaryDate::parse("2023-02-29").is_none(), "2023 not leap");
|
||||||
|
assert!(DiaryDate::parse("2000-02-29").is_some(), "div by 400");
|
||||||
|
assert!(
|
||||||
|
DiaryDate::parse("1900-02-29").is_none(),
|
||||||
|
"div by 100 not 400"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn format_round_trip() {
|
||||||
|
let d = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||||
|
assert_eq!(d.format(), "2026-05-11");
|
||||||
|
assert_eq!(DiaryDate::parse(&d.format()), Some(d));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Date arithmetic =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_day_wraps_month_boundary() {
|
||||||
|
let last_of_jan = DiaryDate::from_ymd(2026, 1, 31).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
last_of_jan.next_day(),
|
||||||
|
DiaryDate::from_ymd(2026, 2, 1).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_day_wraps_year_boundary() {
|
||||||
|
let nye = DiaryDate::from_ymd(2025, 12, 31).unwrap();
|
||||||
|
assert_eq!(nye.next_day(), DiaryDate::from_ymd(2026, 1, 1).unwrap());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prev_day_walks_back_across_month() {
|
||||||
|
let first_of_mar = DiaryDate::from_ymd(2026, 3, 1).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
first_of_mar.prev_day(),
|
||||||
|
DiaryDate::from_ymd(2026, 2, 28).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prev_day_walks_back_across_leap() {
|
||||||
|
let first_of_mar = DiaryDate::from_ymd(2024, 3, 1).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
first_of_mar.prev_day(),
|
||||||
|
DiaryDate::from_ymd(2024, 2, 29).unwrap()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ordering_is_chronological() {
|
||||||
|
let a = DiaryDate::from_ymd(2026, 1, 1).unwrap();
|
||||||
|
let b = DiaryDate::from_ymd(2026, 1, 2).unwrap();
|
||||||
|
let c = DiaryDate::from_ymd(2026, 2, 1).unwrap();
|
||||||
|
let d = DiaryDate::from_ymd(2027, 1, 1).unwrap();
|
||||||
|
assert!(a < b);
|
||||||
|
assert!(b < c);
|
||||||
|
assert!(c < d);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn today_utc_is_a_real_calendar_date() {
|
||||||
|
let t = DiaryDate::today_utc();
|
||||||
|
assert!(t.year >= 2020 && t.year <= 2100);
|
||||||
|
assert!((1..=12).contains(&t.month));
|
||||||
|
assert!((1..=31).contains(&t.day));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== WikiConfig diary paths =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diary_dir_joins_root_and_rel_path() {
|
||||||
|
let cfg = wiki_cfg("/tmp/wiki");
|
||||||
|
assert_eq!(cfg.diary_dir(), PathBuf::from("/tmp/wiki/diary"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diary_index_path_uses_diary_index_filename_and_extension() {
|
||||||
|
let cfg = wiki_cfg("/tmp/wiki");
|
||||||
|
assert_eq!(
|
||||||
|
cfg.diary_index_path(),
|
||||||
|
PathBuf::from("/tmp/wiki/diary/diary.wiki")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diary_path_for_uses_iso_date_as_stem() {
|
||||||
|
let cfg = wiki_cfg("/tmp/wiki");
|
||||||
|
let date = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
cfg.diary_path_for(&date),
|
||||||
|
PathBuf::from("/tmp/wiki/diary/2026-05-11.wiki")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diary_uri_for_date_is_file_url() {
|
||||||
|
let cfg = wiki_cfg("/tmp/wiki");
|
||||||
|
let date = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||||
|
let uri = diary::uri_for_date(&cfg, &date).unwrap();
|
||||||
|
assert!(uri.as_str().ends_with("/diary/2026-05-11.wiki"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diary_index_uri_resolves() {
|
||||||
|
let cfg = wiki_cfg("/tmp/wiki");
|
||||||
|
let uri = diary::index_uri(&cfg).unwrap();
|
||||||
|
assert!(uri.as_str().ends_with("/diary/diary.wiki"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Index integration: diary_date on IndexedPage =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upsert_marks_diary_entries() {
|
||||||
|
let root = "/tmp/diary1";
|
||||||
|
let idx = build_index_with_entries(root, &["2026-05-11"]);
|
||||||
|
let uri = Url::from_file_path(format!("{root}/diary/2026-05-11.wiki")).unwrap();
|
||||||
|
let page = idx.page(&uri).expect("page");
|
||||||
|
let date = page.diary_date.expect("diary_date");
|
||||||
|
assert_eq!(date.format(), "2026-05-11");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upsert_does_not_mark_non_diary_files() {
|
||||||
|
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/tmp/diary2")))
|
||||||
|
.with_diary_rel_path(Some("diary".into()));
|
||||||
|
let uri = Url::from_file_path("/tmp/diary2/Home.wiki").unwrap();
|
||||||
|
idx.upsert(uri.clone(), &parse("= Home =\n"));
|
||||||
|
assert!(idx.page(&uri).unwrap().diary_date.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn upsert_does_not_mark_dates_outside_diary_subdir() {
|
||||||
|
// A file at root named like a date but not under diary/ → not a
|
||||||
|
// diary entry.
|
||||||
|
let mut idx = WorkspaceIndex::new(Some(PathBuf::from("/tmp/diary3")))
|
||||||
|
.with_diary_rel_path(Some("diary".into()));
|
||||||
|
let uri = Url::from_file_path("/tmp/diary3/2026-05-11.wiki").unwrap();
|
||||||
|
idx.upsert(uri.clone(), &parse("= note =\n"));
|
||||||
|
assert!(idx.page(&uri).unwrap().diary_date.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Diary listing + navigation =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_entries_returns_sorted_ascending() {
|
||||||
|
let idx = build_index_with_entries("/tmp/diary4", &["2026-05-11", "2026-04-30", "2026-05-12"]);
|
||||||
|
let entries = diary::list_entries(&idx);
|
||||||
|
let dates: Vec<String> = entries.iter().map(|e| e.date.format()).collect();
|
||||||
|
assert_eq!(dates, vec!["2026-04-30", "2026-05-11", "2026-05-12"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn list_entries_filtered_by_year_and_month() {
|
||||||
|
let idx = build_index_with_entries(
|
||||||
|
"/tmp/diary5",
|
||||||
|
&["2025-12-31", "2026-01-01", "2026-05-11", "2026-05-12"],
|
||||||
|
);
|
||||||
|
let may = diary::list_entries_filtered(&idx, Some(2026), Some(5));
|
||||||
|
assert_eq!(may.len(), 2);
|
||||||
|
let y_only = diary::list_entries_filtered(&idx, Some(2026), None);
|
||||||
|
assert_eq!(y_only.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_entry_finds_strictly_greater() {
|
||||||
|
let idx = build_index_with_entries("/tmp/diary6", &["2026-05-10", "2026-05-12", "2026-05-15"]);
|
||||||
|
let from = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||||
|
let next = diary::next_entry(&idx, &from).unwrap();
|
||||||
|
assert_eq!(next.date.format(), "2026-05-12");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn next_entry_none_when_no_future_entries() {
|
||||||
|
let idx = build_index_with_entries("/tmp/diary7", &["2026-05-10"]);
|
||||||
|
let from = DiaryDate::from_ymd(2026, 5, 11).unwrap();
|
||||||
|
assert!(diary::next_entry(&idx, &from).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prev_entry_finds_strictly_lesser() {
|
||||||
|
let idx = build_index_with_entries("/tmp/diary8", &["2026-05-10", "2026-05-12", "2026-05-15"]);
|
||||||
|
let from = DiaryDate::from_ymd(2026, 5, 13).unwrap();
|
||||||
|
let prev = diary::prev_entry(&idx, &from).unwrap();
|
||||||
|
assert_eq!(prev.date.format(), "2026-05-12");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Index body generation =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_index_body_is_newest_first_grouped_by_year_month() {
|
||||||
|
let idx = build_index_with_entries(
|
||||||
|
"/tmp/diary9",
|
||||||
|
&["2025-12-31", "2026-05-11", "2026-05-12", "2026-04-30"],
|
||||||
|
);
|
||||||
|
let entries = diary::list_entries(&idx);
|
||||||
|
let body = diary::build_index_body(&entries, "diary", "Diary");
|
||||||
|
let lines: Vec<&str> = body.lines().collect();
|
||||||
|
assert_eq!(lines[0], "= Diary =");
|
||||||
|
// First date in output should be the latest: 2026-05-12
|
||||||
|
let earliest_2026_pos = body.find("2026-05-12").unwrap();
|
||||||
|
let latest_2025_pos = body.find("2025-12-31").unwrap();
|
||||||
|
assert!(earliest_2026_pos < latest_2025_pos, "newer entries first");
|
||||||
|
// Year headings appear
|
||||||
|
assert!(body.contains("== 2026 =="));
|
||||||
|
assert!(body.contains("== 2025 =="));
|
||||||
|
// Month headings appear
|
||||||
|
assert!(body.contains("=== May ==="));
|
||||||
|
assert!(body.contains("=== December ==="));
|
||||||
|
assert!(body.contains("=== April ==="));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn build_index_body_empty_when_no_entries() {
|
||||||
|
let body = diary::build_index_body(&[], "diary", "Diary");
|
||||||
|
assert_eq!(body, "= Diary =\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn render_index_body_round_trip() {
|
||||||
|
let cfg = wiki_cfg("/tmp/diaryA");
|
||||||
|
let idx = build_index_with_entries("/tmp/diaryA", &["2026-05-11"]);
|
||||||
|
let body = diary::render_index_body(&cfg, &idx);
|
||||||
|
assert!(body.contains("= Diary ="));
|
||||||
|
assert!(body.contains("[[diary/2026-05-11]]"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== is_in_diary =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn is_in_diary_recognises_subdir_paths() {
|
||||||
|
let cfg = wiki_cfg("/tmp/wiki");
|
||||||
|
assert!(diary::is_in_diary(
|
||||||
|
&cfg,
|
||||||
|
&PathBuf::from("/tmp/wiki/diary/2026-05-11.wiki")
|
||||||
|
));
|
||||||
|
assert!(!diary::is_in_diary(
|
||||||
|
&cfg,
|
||||||
|
&PathBuf::from("/tmp/wiki/Home.wiki")
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Serde =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diary_entry_serializes_to_date_string_and_uri() {
|
||||||
|
let uri = Url::parse("file:///tmp/wiki/diary/2026-05-11.wiki").unwrap();
|
||||||
|
let entry = diary::DiaryEntry {
|
||||||
|
date: DiaryDate::from_ymd(2026, 5, 11).unwrap(),
|
||||||
|
uri,
|
||||||
|
};
|
||||||
|
let v = serde_json::to_value(&entry).unwrap();
|
||||||
|
assert_eq!(v["date"], "2026-05-11");
|
||||||
|
assert!(v["uri"].as_str().unwrap().contains("2026-05-11.wiki"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== COMMANDS list completeness =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn commands_list_includes_phase16_entries() {
|
||||||
|
let names: Vec<&str> = nuwiki_lsp::commands::COMMANDS.to_vec();
|
||||||
|
for name in [
|
||||||
|
"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",
|
||||||
|
] {
|
||||||
|
assert!(names.contains(&name), "missing: {name}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== Config wire format =====
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wiki_config_defaults_match_vimwiki() {
|
||||||
|
let cfg = WikiConfig::empty();
|
||||||
|
assert_eq!(cfg.diary_rel_path, "diary");
|
||||||
|
assert_eq!(cfg.diary_index, "diary");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wiki_config_from_root_uses_default_diary_paths() {
|
||||||
|
let cfg = WikiConfig::from_root(PathBuf::from("/tmp/x"));
|
||||||
|
assert_eq!(cfg.diary_rel_path, "diary");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn config_from_json_accepts_custom_diary_paths() {
|
||||||
|
use nuwiki_lsp::config::config_from_json;
|
||||||
|
let cfg = config_from_json(serde_json::json!({
|
||||||
|
"wikis": [
|
||||||
|
{ "root": "/tmp/p", "diary_rel_path": "journal", "diary_index": "index" },
|
||||||
|
],
|
||||||
|
}));
|
||||||
|
assert_eq!(cfg.wikis[0].diary_rel_path, "journal");
|
||||||
|
assert_eq!(cfg.wikis[0].diary_index, "index");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user