diff --git a/README.md b/README.md index d33b099..d83518f 100644 --- a/README.md +++ b/README.md @@ -231,6 +231,9 @@ require('nuwiki').setup({ css_name = 'style.css', auto_export = false, -- export on save auto_toc = false, -- auto-refresh TOC on save + auto_generate_links = false, -- regen Generated Links on save + auto_generate_tags = false, -- regen Generated Tags index on save + auto_diary_index = false, -- regen diary index on diary-entry save toc_header = 'Contents', -- :VimwikiTOC caption (+ toc_header_level = 1) links_header = 'Generated Links', tags_header = 'Generated Tags', @@ -336,6 +339,9 @@ Each is a boolean. All default to `true` except `mouse`. | `list_margin` | int | `-1` | `-1` (auto) or `≥ 0` | | `links_space_char` | string | `' '` | char that replaces spaces in synthesised link paths | | `auto_toc` | bool | `false` | `true` \| `false` | +| `auto_generate_links` | bool | `false` | regenerate an existing Generated Links section on save | +| `auto_generate_tags` | bool | `false` | regenerate an existing Generated Tags index on save | +| `auto_diary_index` | bool | `false` | regenerate the diary index when a diary entry is saved | | `toc_header` / `toc_header_level` | string / int | `'Contents'` / `1` | `:VimwikiTOC` caption + heading level | | `links_header` / `links_header_level` | string / int | `'Generated Links'` / `1` | `:VimwikiGenerateLinks` caption + level | | `tags_header` / `tags_header_level` | string / int | `'Generated Tags'` / `1` | `:VimwikiGenerateTagLinks` index caption + level | diff --git a/crates/nuwiki-lsp/src/commands.rs b/crates/nuwiki-lsp/src/commands.rs index e7b17ec..9306830 100644 --- a/crates/nuwiki-lsp/src/commands.rs +++ b/crates/nuwiki-lsp/src/commands.rs @@ -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 { + 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>, + utf8: bool, + header: &str, + level: u8, + ) -> Option { + 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)] diff --git a/crates/nuwiki-lsp/src/config.rs b/crates/nuwiki-lsp/src/config.rs index 08312b2..46596c3 100644 --- a/crates/nuwiki-lsp/src/config.rs +++ b/crates/nuwiki-lsp/src/config.rs @@ -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, #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] auto_toc: Option, + #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] + auto_generate_links: Option, + #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] + auto_generate_tags: Option, + #[serde(default, deserialize_with = "opt_bool_or_int::deserialize")] + auto_diary_index: Option, #[serde(default)] toc_header: Option, #[serde(default)] @@ -714,6 +741,9 @@ impl From 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), diff --git a/crates/nuwiki-lsp/src/lib.rs b/crates/nuwiki-lsp/src/lib.rs index 599cc85..0f6d5b1 100644 --- a/crates/nuwiki-lsp/src/lib.rs +++ b/crates/nuwiki-lsp/src/lib.rs @@ -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 diff --git a/crates/nuwiki-lsp/tests/commands_tags.rs b/crates/nuwiki-lsp/tests/commands_tags.rs index d586570..120e86f 100644 --- a/crates/nuwiki-lsp/tests/commands_tags.rs +++ b/crates/nuwiki-lsp/tests/commands_tags.rs @@ -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"; diff --git a/crates/nuwiki-lsp/tests/index_and_config.rs b/crates/nuwiki-lsp/tests/index_and_config.rs index b69f0d5..9aa2f3b 100644 --- a/crates/nuwiki-lsp/tests/index_and_config.rs +++ b/crates/nuwiki-lsp/tests/index_and_config.rs @@ -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"); diff --git a/crates/nuwiki-lsp/tests/link_health.rs b/crates/nuwiki-lsp/tests/link_health.rs index f74c637..3acb366 100644 --- a/crates/nuwiki-lsp/tests/link_health.rs +++ b/crates/nuwiki-lsp/tests/link_health.rs @@ -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 diff --git a/development/vimwiki-gap.md b/development/vimwiki-gap.md index ba99a40..f3d58d8 100644 --- a/development/vimwiki-gap.md +++ b/development/vimwiki-gap.md @@ -98,9 +98,18 @@ fix site. auto_toc save hook) and `find_section_range` matches the configured text so regen stays idempotent. Tests: `captions_honour_custom_header_and_level` (`link_health.rs`) + config round-trip in `index_and_config.rs`. -- [ ] **On-save autoregen family** — `auto_diary_index`, `auto_generate_links`, - `auto_generate_tags`, `auto_tags` (only `auto_toc` / `auto_export` exist). - _Fix:_ `config.rs` `RawWiki`/`WikiConfig` + the `didSave` path. +- [x] **On-save autoregen family** — added per-wiki `auto_generate_links`, + `auto_generate_tags`, `auto_diary_index` (all default `false`, like upstream), + wired into the `did_save` hook (`crates/nuwiki-lsp/src/lib.rs`) mirroring + `auto_toc`: regen runs only when the section already exists + (`links_rebuild_edit` / `tag_links_rebuild_edit`), and `auto_diary_index` + regenerates the diary index when a dated diary *entry* is saved + (`diary_generate_index_edit`, which handles an open/closed/absent index file). + `auto_tags` is an **intentional divergence** (see below): nuwiki re-indexes on + every change, so tag metadata is always fresh — there's no on-disk metadata + file to update on save. Tests: `links_rebuild_edit_only_acts_when_section_present` + (`link_health.rs`), `tag_links_rebuild_edit_only_acts_when_index_present` + (`commands_tags.rs`), config round-trip + defaults (`index_and_config.rs`). - [x] **`listsym_rejected`** — the rejected glyph was a hardcoded `const '-'`. _Fix:_ `ListSyms` gained a `rejected` field + `new_with_rejected()` (`crates/nuwiki-core/src/listsyms.rs`); per-wiki `listsym_rejected` key (default @@ -276,6 +285,11 @@ audits don't re-flag them. - `key_mappings` (dict) — replaced by Lua `mappings.` + the `g:nuwiki_no__mappings` globals. - `use_calendar` — no calendar.vim integration. +- `auto_tags` (upstream `0`) — upstream auto-updates an on-disk tag *metadata* + file on save. nuwiki has no such file: the LSP re-indexes tags on every + `didChange`, so tag search/jump/completion are always fresh. The setting's + intent is therefore always satisfied (the `auto_generate_tags` regen of the + in-buffer links section is the configurable on-save piece). - Mouse maps (`<2-LeftMouse>`, ``, …) — upstream binds them unconditionally; nuwiki ships them **opt-in** (`mappings.mouse` / `g:nuwiki_mouse_mappings`). Deliberate, so they're not always-on. diff --git a/doc/nuwiki.txt b/doc/nuwiki.txt index 2f13fff..51ce036 100644 --- a/doc/nuwiki.txt +++ b/doc/nuwiki.txt @@ -196,6 +196,12 @@ match upstream vimwiki. `css_name` `'style.css'` `auto_export` `false` — export to HTML on save. `auto_toc` `false` — refresh TOC on save. +`auto_generate_links` `false` — regen an existing Generated Links section + on save. +`auto_generate_tags` `false` — regen an existing Generated Tags index on + save. +`auto_diary_index` `false` — regen the diary index when a diary entry is + saved. `toc_header` `'Contents'` (+ `toc_header_level` `1`) — :VimwikiTOC. `links_header` `'Generated Links'` (+ `links_header_level` `1`). `tags_header` `'Generated Tags'` (+ `tags_header_level` `1`). diff --git a/lua/nuwiki/config.lua b/lua/nuwiki/config.lua index 4b05f9d..0295603 100644 --- a/lua/nuwiki/config.lua +++ b/lua/nuwiki/config.lua @@ -25,6 +25,7 @@ M.defaults = { -- diary_caption_level, diary_sort, diary_header -- html_path, template_path, template_default, template_ext, -- template_date_format, css_name, auto_export, auto_toc, + -- auto_generate_links, auto_generate_tags, auto_diary_index, -- html_filename_parameterization, exclude_files, color_dic -- toc_header, toc_header_level, links_header, links_header_level, -- tags_header, tags_header_level,