feat(config): on-save autoregen family — links/tags/diary-index (P2)
Added per-wiki auto_generate_links, auto_generate_tags, auto_diary_index (default false, like upstream), wired into the did_save hook mirroring auto_toc: - auto_generate_links / auto_generate_tags rebuild the Generated Links / Generated Tags section ONLY when it already exists (new links_rebuild_edit / tag_links_rebuild_edit, like toc_rebuild_edit) — never inserts one into a page that lacks it. - auto_diary_index regenerates the diary index page when a dated diary entry (not the index itself) is saved, via diary_generate_index_edit (handles the index file open / on-disk / absent). auto_tags is recorded as an intentional divergence: nuwiki re-indexes tags on every change, so the tag metadata is always fresh (no on-disk file to update). Tests: links_rebuild_edit_only_acts_when_section_present, tag_links_rebuild_edit_only_acts_when_index_present, config round-trip + defaults. README/doc/lua comment updated. fmt/clippy clean; 0 Rust failures. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2041,6 +2041,33 @@ pub mod ops {
|
||||
Some(b.build())
|
||||
}
|
||||
|
||||
/// Like [`links_edit`], but only when a `Generated Links` section already
|
||||
/// exists — the `auto_generate_links`-on-save hook, mirroring
|
||||
/// [`toc_rebuild_edit`]. Never inserts one into a page that lacks it.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub fn links_rebuild_edit(
|
||||
text: &str,
|
||||
ast: &DocumentNode,
|
||||
uri: &Url,
|
||||
current_page: &str,
|
||||
all_pages: &[String],
|
||||
utf8: bool,
|
||||
heading: &str,
|
||||
level: u8,
|
||||
) -> Option<WorkspaceEdit> {
|
||||
find_section_range(ast, heading)?;
|
||||
links_edit(
|
||||
text,
|
||||
ast,
|
||||
uri,
|
||||
current_page,
|
||||
all_pages,
|
||||
utf8,
|
||||
heading,
|
||||
level,
|
||||
)
|
||||
}
|
||||
|
||||
// ===== Workspace queries =====
|
||||
|
||||
#[derive(Debug, Serialize)]
|
||||
@@ -3220,6 +3247,22 @@ pub mod ops {
|
||||
Some(b.build())
|
||||
}
|
||||
|
||||
/// Like [`tag_links_edit`] for the no-arg index, but only when a
|
||||
/// `Generated Tags` section already exists — the `auto_generate_tags`
|
||||
/// on-save hook. Never inserts one into a page that lacks it.
|
||||
pub fn tag_links_rebuild_edit(
|
||||
text: &str,
|
||||
ast: &DocumentNode,
|
||||
uri: &Url,
|
||||
pages_by_tag: &std::collections::BTreeMap<String, Vec<String>>,
|
||||
utf8: bool,
|
||||
header: &str,
|
||||
level: u8,
|
||||
) -> Option<WorkspaceEdit> {
|
||||
find_section_range(ast, header)?;
|
||||
tag_links_edit(text, ast, uri, None, pages_by_tag, utf8, header, level)
|
||||
}
|
||||
|
||||
// Keep the import live for downstream use even when the local module
|
||||
// doesn't reference it directly.
|
||||
#[allow(dead_code)]
|
||||
|
||||
@@ -75,6 +75,15 @@ pub struct WikiConfig {
|
||||
pub links_space_char: String,
|
||||
/// Rebuild the page's TOC on save when set.
|
||||
pub auto_toc: bool,
|
||||
/// On save, regenerate an existing `Generated Links` section
|
||||
/// (vimwiki's `auto_generate_links`).
|
||||
pub auto_generate_links: bool,
|
||||
/// On save, regenerate an existing `Generated Tags` index section
|
||||
/// (vimwiki's `auto_generate_tags`).
|
||||
pub auto_generate_tags: bool,
|
||||
/// On save of a diary entry, regenerate the diary index page
|
||||
/// (vimwiki's `auto_diary_index`).
|
||||
pub auto_diary_index: bool,
|
||||
/// Heading text + level for the generated `:VimwikiTOC` section
|
||||
/// (vimwiki's `toc_header` / `toc_header_level`).
|
||||
pub toc_header: String,
|
||||
@@ -208,6 +217,9 @@ impl WikiConfig {
|
||||
list_margin: d.list_margin,
|
||||
links_space_char: d.links_space_char,
|
||||
auto_toc: d.auto_toc,
|
||||
auto_generate_links: d.auto_generate_links,
|
||||
auto_generate_tags: d.auto_generate_tags,
|
||||
auto_diary_index: d.auto_diary_index,
|
||||
toc_header: d.toc_header,
|
||||
toc_header_level: d.toc_header_level,
|
||||
links_header: d.links_header,
|
||||
@@ -244,6 +256,9 @@ impl WikiConfig {
|
||||
list_margin: d.list_margin,
|
||||
links_space_char: d.links_space_char,
|
||||
auto_toc: d.auto_toc,
|
||||
auto_generate_links: d.auto_generate_links,
|
||||
auto_generate_tags: d.auto_generate_tags,
|
||||
auto_diary_index: d.auto_diary_index,
|
||||
toc_header: d.toc_header,
|
||||
toc_header_level: d.toc_header_level,
|
||||
links_header: d.links_header,
|
||||
@@ -384,6 +399,9 @@ fn wiki_defaults() -> WikiDefaults {
|
||||
list_margin: -1,
|
||||
links_space_char: default_links_space_char(),
|
||||
auto_toc: false,
|
||||
auto_generate_links: false,
|
||||
auto_generate_tags: false,
|
||||
auto_diary_index: false,
|
||||
toc_header: default_toc_header(),
|
||||
toc_header_level: 1,
|
||||
links_header: default_links_header(),
|
||||
@@ -409,6 +427,9 @@ struct WikiDefaults {
|
||||
list_margin: i32,
|
||||
links_space_char: String,
|
||||
auto_toc: bool,
|
||||
auto_generate_links: bool,
|
||||
auto_generate_tags: bool,
|
||||
auto_diary_index: bool,
|
||||
toc_header: String,
|
||||
toc_header_level: u8,
|
||||
links_header: String,
|
||||
@@ -641,6 +662,12 @@ struct RawWiki {
|
||||
links_space_char: Option<String>,
|
||||
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
|
||||
auto_toc: Option<bool>,
|
||||
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
|
||||
auto_generate_links: Option<bool>,
|
||||
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
|
||||
auto_generate_tags: Option<bool>,
|
||||
#[serde(default, deserialize_with = "opt_bool_or_int::deserialize")]
|
||||
auto_diary_index: Option<bool>,
|
||||
#[serde(default)]
|
||||
toc_header: Option<String>,
|
||||
#[serde(default)]
|
||||
@@ -714,6 +741,9 @@ impl From<RawWiki> for WikiConfig {
|
||||
list_margin: r.list_margin.unwrap_or(d.list_margin),
|
||||
links_space_char: r.links_space_char.unwrap_or(d.links_space_char),
|
||||
auto_toc: r.auto_toc.unwrap_or(d.auto_toc),
|
||||
auto_generate_links: r.auto_generate_links.unwrap_or(d.auto_generate_links),
|
||||
auto_generate_tags: r.auto_generate_tags.unwrap_or(d.auto_generate_tags),
|
||||
auto_diary_index: r.auto_diary_index.unwrap_or(d.auto_diary_index),
|
||||
toc_header: r.toc_header.unwrap_or(d.toc_header),
|
||||
toc_header_level: r.toc_header_level.unwrap_or(d.toc_header_level),
|
||||
links_header: r.links_header.unwrap_or(d.links_header),
|
||||
|
||||
@@ -635,6 +635,84 @@ impl LanguageServer for Backend {
|
||||
}
|
||||
}
|
||||
|
||||
let utf8 = self.use_utf8.load(Ordering::Relaxed);
|
||||
|
||||
// `auto_generate_links`: rebuild an existing `Generated Links` section.
|
||||
if wiki.config.auto_generate_links {
|
||||
let edit = self.documents.get(&uri).and_then(|doc| {
|
||||
let current = index::page_name_from_uri(&uri, Some(&wiki.config.root));
|
||||
let pages = wiki
|
||||
.index
|
||||
.read()
|
||||
.ok()
|
||||
.map(|i| i.page_names())
|
||||
.unwrap_or_default();
|
||||
commands::ops::links_rebuild_edit(
|
||||
&doc.text,
|
||||
&doc.ast,
|
||||
&uri,
|
||||
¤t,
|
||||
&pages,
|
||||
utf8,
|
||||
&wiki.config.links_header,
|
||||
wiki.config.links_header_level,
|
||||
)
|
||||
});
|
||||
if let Some(edit) = edit {
|
||||
let _ = self.client.apply_edit(edit).await;
|
||||
}
|
||||
}
|
||||
|
||||
// `auto_generate_tags`: rebuild an existing `Generated Tags` index.
|
||||
if wiki.config.auto_generate_tags {
|
||||
let edit = self.documents.get(&uri).and_then(|doc| {
|
||||
let snap = wiki
|
||||
.index
|
||||
.read()
|
||||
.ok()
|
||||
.map(|i| commands::ops::tag_pages_snapshot(&i))
|
||||
.unwrap_or_default();
|
||||
commands::ops::tag_links_rebuild_edit(
|
||||
&doc.text,
|
||||
&doc.ast,
|
||||
&uri,
|
||||
&snap,
|
||||
utf8,
|
||||
&wiki.config.tags_header,
|
||||
wiki.config.tags_header_level,
|
||||
)
|
||||
});
|
||||
if let Some(edit) = edit {
|
||||
let _ = self.client.apply_edit(edit).await;
|
||||
}
|
||||
}
|
||||
|
||||
// `auto_diary_index`: when a diary *entry* (a dated page, not the
|
||||
// index itself) is saved, regenerate the diary index page.
|
||||
if wiki.config.auto_diary_index {
|
||||
if let Some(index_uri) = crate::diary::index_uri(&wiki.config) {
|
||||
let is_diary_entry = uri != index_uri
|
||||
&& wiki
|
||||
.index
|
||||
.read()
|
||||
.ok()
|
||||
.and_then(|i| i.page(&uri).and_then(|p| p.diary_period))
|
||||
.is_some();
|
||||
if is_diary_entry {
|
||||
let body = wiki
|
||||
.index
|
||||
.read()
|
||||
.ok()
|
||||
.map(|i| crate::diary::render_index_body(&wiki.config, &i));
|
||||
if let Some(body) = body {
|
||||
let edit =
|
||||
commands::ops::diary_generate_index_edit(self, &index_uri, &body, utf8);
|
||||
let _ = self.client.apply_edit(edit).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// When `auto_export` is set on the resolved wiki, run
|
||||
// the equivalent of `nuwiki.export.currentToHtml` after the file
|
||||
// hits disk. Best-effort — failures are logged but do not bubble
|
||||
|
||||
@@ -301,6 +301,38 @@ fn tag_links_edit_full_index_replaces_existing_tags_section() {
|
||||
assert!(!te.new_text.contains("[[old]]"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_links_rebuild_edit_only_acts_when_index_present() {
|
||||
let mut snap = BTreeMap::new();
|
||||
snap.insert("a".to_string(), vec!["P".into()]);
|
||||
let uri = Url::parse("file:///tmp/p.wiki").unwrap();
|
||||
let without = "= Home =\nbody\n";
|
||||
assert!(
|
||||
ops::tag_links_rebuild_edit(
|
||||
without,
|
||||
&parse(without),
|
||||
&uri,
|
||||
&snap,
|
||||
true,
|
||||
"Generated Tags",
|
||||
1
|
||||
)
|
||||
.is_none(),
|
||||
"auto_generate_tags must not insert an index into a page that lacks one"
|
||||
);
|
||||
let with = "= Generated Tags =\n- [[old]]\n";
|
||||
assert!(ops::tag_links_rebuild_edit(
|
||||
with,
|
||||
&parse(with),
|
||||
&uri,
|
||||
&snap,
|
||||
true,
|
||||
"Generated Tags",
|
||||
1
|
||||
)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tag_links_edit_returns_none_for_unknown_tag() {
|
||||
let src = "hi\n";
|
||||
|
||||
@@ -275,6 +275,9 @@ fn defaults_carry_vimwiki_per_wiki_keys() {
|
||||
assert_eq!(cfg.list_margin, -1);
|
||||
assert_eq!(cfg.links_space_char, " ");
|
||||
assert!(!cfg.auto_toc);
|
||||
assert!(!cfg.auto_generate_links);
|
||||
assert!(!cfg.auto_generate_tags);
|
||||
assert!(!cfg.auto_diary_index);
|
||||
// Generated-section captions match upstream defaults.
|
||||
assert_eq!(cfg.toc_header, "Contents");
|
||||
assert_eq!(cfg.toc_header_level, 1);
|
||||
@@ -301,6 +304,9 @@ fn raw_wiki_parses_every_new_key() {
|
||||
"list_margin": 2,
|
||||
"links_space_char": "_",
|
||||
"auto_toc": true,
|
||||
"auto_generate_links": true,
|
||||
"auto_generate_tags": 1,
|
||||
"auto_diary_index": true,
|
||||
"toc_header": "Table of Contents",
|
||||
"toc_header_level": 2,
|
||||
"links_header": "All Pages",
|
||||
@@ -323,6 +329,9 @@ fn raw_wiki_parses_every_new_key() {
|
||||
assert_eq!(w.list_margin, 2);
|
||||
assert_eq!(w.links_space_char, "_");
|
||||
assert!(w.auto_toc);
|
||||
assert!(w.auto_generate_links);
|
||||
assert!(w.auto_generate_tags); // int 1 → true
|
||||
assert!(w.auto_diary_index);
|
||||
assert_eq!(w.toc_header, "Table of Contents");
|
||||
assert_eq!(w.toc_header_level, 2);
|
||||
assert_eq!(w.links_header, "All Pages");
|
||||
|
||||
@@ -305,6 +305,39 @@ fn build_toc_text_with_no_headings_is_just_the_heading() {
|
||||
assert_eq!(out, "= Contents =\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn links_rebuild_edit_only_acts_when_section_present() {
|
||||
let pages = vec!["Home".to_string(), "About".to_string()];
|
||||
let uri = Url::from_file_path("/w/Home.wiki").unwrap();
|
||||
let without = "= Home =\nbody\n";
|
||||
assert!(
|
||||
ops::links_rebuild_edit(
|
||||
without,
|
||||
&parse(without),
|
||||
&uri,
|
||||
"Home",
|
||||
&pages,
|
||||
true,
|
||||
"Generated Links",
|
||||
1
|
||||
)
|
||||
.is_none(),
|
||||
"must not insert a links section into a page that lacks one"
|
||||
);
|
||||
let with = "= Generated Links =\n- [[old]]\n";
|
||||
assert!(ops::links_rebuild_edit(
|
||||
with,
|
||||
&parse(with),
|
||||
&uri,
|
||||
"Home",
|
||||
&pages,
|
||||
true,
|
||||
"Generated Links",
|
||||
1
|
||||
)
|
||||
.is_some());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn find_section_range_stops_at_same_level_sibling() {
|
||||
// A non-level-1 generated section (e.g. a level-2 tags index) must not
|
||||
|
||||
Reference in New Issue
Block a user