//! 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 //! `//`. 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::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::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::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 { let mut out: Vec = 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, month: Option, ) -> Vec { 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 { 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 { 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 /// `` 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 { let mut out: Vec = 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 { 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 { 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(&self, serializer: S) -> Result 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 = None; let mut current_month: Option = 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(&self, serializer: S) -> Result 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", } }