2026-05-30 18:35:40 +00:00
|
|
|
//! Diary subsystem — pure helpers used by the
|
2026-05-11 21:04:55 +00:00
|
|
|
//! `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;
|
|
|
|
|
|
2026-05-12 22:09:14 +00:00
|
|
|
use nuwiki_core::date::{DiaryDate, DiaryPeriod};
|
2026-05-11 21:04:55 +00:00
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 22:09:14 +00:00
|
|
|
/// 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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-11 21:04:55 +00:00
|
|
|
/// `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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-12 22:09:14 +00:00
|
|
|
/// 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()
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-30 23:12:08 -03:00
|
|
|
/// 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
|
2026-05-11 21:04:55 +00:00
|
|
|
/// subheadings so the rendered page mirrors `:VimwikiDiaryGenerateLinks`.
|
2026-05-30 23:12:08 -03:00
|
|
|
///
|
|
|
|
|
/// `caption_level` sets the level of the top caption; the year and month
|
|
|
|
|
/// subheadings nest one and two levels below it. `sort` controls whether
|
|
|
|
|
/// the flat list runs newest- or oldest-first.
|
2026-05-11 21:04:55 +00:00
|
|
|
pub fn build_index_body(
|
|
|
|
|
entries: &[DiaryEntry],
|
|
|
|
|
diary_rel_path: &str,
|
|
|
|
|
index_heading: &str,
|
2026-05-30 23:12:08 -03:00
|
|
|
sort: DiarySort,
|
|
|
|
|
caption_level: u8,
|
2026-05-11 21:04:55 +00:00
|
|
|
) -> String {
|
|
|
|
|
let mut out = String::new();
|
2026-05-30 23:12:08 -03:00
|
|
|
out.push_str(&heading(caption_level, index_heading));
|
|
|
|
|
out.push('\n');
|
2026-05-11 21:04:55 +00:00
|
|
|
|
|
|
|
|
if entries.is_empty() {
|
|
|
|
|
return out;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
let mut sorted: Vec<&DiaryEntry> = entries.iter().collect();
|
2026-05-30 23:12:08 -03:00
|
|
|
sorted.sort_by(|a, b| match sort {
|
|
|
|
|
DiarySort::Desc => b.date.cmp(&a.date),
|
|
|
|
|
DiarySort::Asc => a.date.cmp(&b.date),
|
|
|
|
|
});
|
2026-05-11 21:04:55 +00:00
|
|
|
|
2026-05-30 23:12:08 -03:00
|
|
|
let year_level = caption_level.saturating_add(1);
|
|
|
|
|
let month_level = caption_level.saturating_add(2);
|
2026-05-11 21:04:55 +00:00
|
|
|
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) {
|
2026-05-30 23:12:08 -03:00
|
|
|
out.push('\n');
|
|
|
|
|
out.push_str(&heading(year_level, &e.date.year.to_string()));
|
|
|
|
|
out.push('\n');
|
2026-05-11 21:04:55 +00:00
|
|
|
current_year = Some(e.date.year);
|
|
|
|
|
current_month = None;
|
|
|
|
|
}
|
|
|
|
|
if current_month != Some(e.date.month) {
|
2026-05-30 23:12:08 -03:00
|
|
|
out.push_str(&heading(month_level, month_name(e.date.month)));
|
|
|
|
|
out.push('\n');
|
2026-05-11 21:04:55 +00:00
|
|
|
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
|
2026-05-30 23:12:08 -03:00
|
|
|
/// entries. Convenience wrapper around [`build_index_body`] that honours
|
|
|
|
|
/// the wiki's `diary_header`, `diary_sort`, and `diary_caption_level`.
|
2026-05-11 21:04:55 +00:00
|
|
|
pub fn render_index_body(cfg: &WikiConfig, index: &WorkspaceIndex) -> String {
|
2026-05-30 23:12:08 -03:00
|
|
|
build_index_body(
|
|
|
|
|
&list_entries(index),
|
|
|
|
|
&cfg.diary_rel_path,
|
|
|
|
|
&cfg.diary_header,
|
|
|
|
|
DiarySort::parse(&cfg.diary_sort),
|
|
|
|
|
cfg.diary_caption_level,
|
|
|
|
|
)
|
2026-05-11 21:04:55 +00:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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",
|
|
|
|
|
}
|
|
|
|
|
}
|