Files
nuwiki/crates/nuwiki-lsp/src/diary.rs
T
gffranco 9441fe918c
CI / cargo fmt --check (push) Successful in 19s
CI / cargo clippy (push) Successful in 37s
CI / cargo test (push) Successful in 29s
CI / editor keymaps (push) Successful in 1m36s
fix(parity): close the 2026-06-02 re-audit config/command findings
Implements the remaining fourth-pass findings (gap doc updated):

- list_margin: rework to upstream's buffer-side meaning. Drop the
  nuwiki-only HTML em-margin (renderer field/method + render_page_html
  param removed) and prepend max(0, list_margin) leading spaces to every
  generated bullet in build_toc_text / build_links_text /
  build_tag_links_text / diary::build_index_body; headings stay at col 0.
  From<RawWiki> derives 0 for markdown wikis when unset. Negatives can't
  resolve 'shiftwidth' server-side, so they collapse to zero indent
  (documented divergence).

- diary_months: per-wiki Vec<String> (default 12 English names), threaded
  into the diary-index month labels; missing/empty slots fall back to the
  English name.

- diary_caption_level: widen u8 -> i8 so vimwiki's -1 (min: -1) parses;
  build_index_body clamps < 0 to base tree level 0.

- VimwikiRemoveDone: regain upstream's -range. All four defs are now
  -bang -range, dispatched via remove_done(bang, range, l1, l2) in both
  clients: ! -> whole buffer, explicit range -> new list_remove_done_range
  ({range:[l1-1,l2-1]}), else current list. Server remove_done_edit gained
  an Option<(u32,u32)> range that filters whole-doc victims by start line.

markdown_header_style is deferred: the generators emit vimwiki syntax only
(caption_line never writes markdown headers), so there's no markdown header
to attach the style to. Logged as the "generated-content is vimwiki-only"
intentional divergence pending a later markdown generated-content effort.

Tests: list_margin indent, markdown list_margin-0 default, diary_months
custom + fallback, negative caption_level clamp/parse, ranged remove-done
(server + both keymap harnesses). 553 lsp/core tests pass; clippy clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 10:50:38 +00:00

330 lines
10 KiB
Rust

//! Diary subsystem — 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, DiaryPeriod};
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()
}
/// Resolve a `DiaryPeriod` to its `file://` URI under the given wiki.
/// Picks the right stem for the period's flavour (day / week / month / year).
pub fn uri_for_period(cfg: &WikiConfig, period: &DiaryPeriod) -> Option<Url> {
Url::from_file_path(cfg.diary_path_for_period(period)).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)
}
/// All indexed diary periods of a *specific* flavour (daily / weekly /
/// monthly / yearly), sorted ascending. Used by next/prev navigation so
/// `<C-Down>` on a weekly page jumps to the next weekly entry, not the
/// next daily one.
pub fn list_periods_of_kind(index: &WorkspaceIndex, kind: PeriodKind) -> Vec<PeriodEntry> {
let mut out: Vec<PeriodEntry> = index
.pages_by_uri
.values()
.filter_map(|p| {
let period = p.diary_period?;
if PeriodKind::of(&period) != kind {
return None;
}
Some(PeriodEntry {
period,
uri: p.uri.clone(),
})
})
.collect();
out.sort_by_key(|e| period_first_day(&e.period));
out
}
pub fn next_period(index: &WorkspaceIndex, from: &DiaryPeriod) -> Option<PeriodEntry> {
let kind = PeriodKind::of(from);
let pivot = period_first_day(from);
list_periods_of_kind(index, kind)
.into_iter()
.find(|e| period_first_day(&e.period) > pivot)
}
pub fn prev_period(index: &WorkspaceIndex, from: &DiaryPeriod) -> Option<PeriodEntry> {
let kind = PeriodKind::of(from);
let pivot = period_first_day(from);
list_periods_of_kind(index, kind)
.into_iter()
.rev()
.find(|e| period_first_day(&e.period) < pivot)
}
/// Cluster 4: `(period, uri)` pair for non-daily diary navigation.
#[derive(Debug, Clone)]
pub struct PeriodEntry {
pub period: DiaryPeriod,
pub uri: Url,
}
impl serde::Serialize for PeriodEntry {
fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
where
S: serde::Serializer,
{
use serde::ser::SerializeStruct;
// For back-compat with daily-only clients, emit `date` AND
// `period` — the former is just the first-day string of the
// period, the latter is the canonical stem.
let mut s = serializer.serialize_struct("PeriodEntry", 3)?;
s.serialize_field("date", &self.period.first_day().format())?;
s.serialize_field("period", &self.period.format())?;
s.serialize_field("uri", &self.uri)?;
s.end()
}
}
/// Which flavour of `DiaryPeriod` we're looking at.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PeriodKind {
Day,
Week,
Month,
Year,
}
impl PeriodKind {
pub fn of(p: &DiaryPeriod) -> Self {
match p {
DiaryPeriod::Day(_) => Self::Day,
DiaryPeriod::Week { .. } => Self::Week,
DiaryPeriod::Month { .. } => Self::Month,
DiaryPeriod::Year { .. } => Self::Year,
}
}
}
fn period_first_day(p: &DiaryPeriod) -> DiaryDate {
p.first_day()
}
/// Ordering of entries in the diary index page.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiarySort {
/// Newest first (vimwiki default).
Desc,
/// Oldest first.
Asc,
}
impl DiarySort {
/// Parse a wiki's `diary_sort` field. Anything other than `asc`
/// (case-insensitive) is treated as the default `desc`.
pub fn parse(s: &str) -> Self {
if s.trim().eq_ignore_ascii_case("asc") {
Self::Asc
} else {
Self::Desc
}
}
}
/// Render a heading line at `level` (1 = `= … =`). Clamped to vimwiki's
/// 1..=6 range so a stray config value can't emit a 200-equals heading.
fn heading(level: u8, text: &str) -> String {
let n = level.clamp(1, 6) as usize;
let eq = "=".repeat(n);
format!("{eq} {text} {eq}")
}
/// Generate the body of the diary index page — a flat list of
/// `[[diary/YYYY-MM-DD]]` wikilinks, grouped under year and month
/// subheadings so the rendered page mirrors `:VimwikiDiaryGenerateLinks`.
///
/// `caption_level` sets the level of the top caption; the year and month
/// subheadings nest one and two levels below it. A `caption_level < 0`
/// clamps to `0` (nuwiki builds the tree from dates, so upstream's `-1`
/// "no per-page captions" mode doesn't apply). `months` supplies the
/// month-number → display name table; an empty slice (or a short list)
/// falls back to the English `month_name` for any missing slot. `sort`
/// controls whether the flat list runs newest- or oldest-first. `margin`
/// is the number of leading spaces before each entry bullet (vimwiki's
/// `list_margin`).
pub fn build_index_body(
entries: &[DiaryEntry],
diary_rel_path: &str,
index_heading: &str,
sort: DiarySort,
caption_level: i8,
months: &[String],
margin: usize,
) -> String {
let base_level = caption_level.max(0) as u8;
let pad = " ".repeat(margin);
let mut out = String::new();
out.push_str(&heading(base_level, index_heading));
out.push('\n');
if entries.is_empty() {
return out;
}
let mut sorted: Vec<&DiaryEntry> = entries.iter().collect();
sorted.sort_by(|a, b| match sort {
DiarySort::Desc => b.date.cmp(&a.date),
DiarySort::Asc => a.date.cmp(&b.date),
});
let year_level = base_level.saturating_add(1);
let month_level = base_level.saturating_add(2);
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('\n');
out.push_str(&heading(year_level, &e.date.year.to_string()));
out.push('\n');
current_year = Some(e.date.year);
current_month = None;
}
if current_month != Some(e.date.month) {
let label = months
.get((e.date.month.saturating_sub(1)) as usize)
.map(String::as_str)
.filter(|s| !s.is_empty())
.unwrap_or_else(|| month_name(e.date.month));
out.push_str(&heading(month_level, label));
out.push('\n');
current_month = Some(e.date.month);
}
out.push_str(&format!(
"{pad}- [[{}/{}]]\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`] that honours
/// the wiki's `diary_header`, `diary_sort`, `diary_caption_level`, and
/// `diary_months`.
pub fn render_index_body(cfg: &WikiConfig, index: &WorkspaceIndex) -> String {
build_index_body(
&list_entries(index),
&cfg.diary_rel_path,
&cfg.diary_header,
DiarySort::parse(&cfg.diary_sort),
cfg.diary_caption_level,
&cfg.diary_months,
cfg.list_margin.max(0) as usize,
)
}
/// 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",
}
}