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(),
},
)
}
}
+46
View File
@@ -27,6 +27,13 @@ pub struct WikiConfig {
pub root: PathBuf,
pub file_extension: 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)]
@@ -53,6 +60,8 @@ impl WikiConfig {
root: PathBuf::new(),
file_extension: ".wiki".into(),
syntax: "vimwiki".into(),
diary_rel_path: default_diary_rel_path(),
diary_index: default_diary_index(),
}
}
@@ -65,8 +74,37 @@ impl WikiConfig {
root,
file_extension: ".wiki".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 {
@@ -97,6 +135,8 @@ impl Config {
root,
file_extension: raw.file_extension.unwrap_or_else(|| ".wiki".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) {
vec![WikiConfig::from_root(folder)]
@@ -155,6 +195,10 @@ struct RawWiki {
file_extension: Option<String>,
#[serde(default)]
syntax: Option<String>,
#[serde(default)]
diary_rel_path: Option<String>,
#[serde(default)]
diary_index: Option<String>,
}
impl From<RawWiki> for WikiConfig {
@@ -169,6 +213,8 @@ impl From<RawWiki> for WikiConfig {
root,
file_extension: r.file_extension.unwrap_or_else(|| ".wiki".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),
}
}
}
+169
View File
@@ -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",
}
}
+42
View File
@@ -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.
+1
View File
@@ -24,6 +24,7 @@
pub mod commands;
pub mod config;
pub mod diagnostics;
pub mod diary;
pub mod edits;
pub mod index;
pub mod nav;
+3 -1
View File
@@ -25,7 +25,9 @@ pub struct Wiki {
impl Wiki {
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 }
}