diff options
| author | Thomas Vanbesien <tvanbesi@proton.me> | 2026-06-24 09:34:05 +0200 |
|---|---|---|
| committer | Thomas Vanbesien <tvanbesi@proton.me> | 2026-06-24 17:02:50 +0200 |
| commit | 6874e79f571bfd66867c40752abe69a80e4ea1b7 (patch) | |
| tree | 266e68c61009ec3bb1a327f1624de304311340a7 /plugin | |
| parent | 2a92017b40e0adfa75a2d35dc36e3429be19877e (diff) | |
| download | nvim-config-6874e79f571bfd66867c40752abe69a80e4ea1b7.tar.gz nvim-config-6874e79f571bfd66867c40752abe69a80e4ea1b7.zip | |
refactor(nvim): clean up notes plugin
Diffstat (limited to 'plugin')
| -rw-r--r-- | plugin/50-notes.lua | 447 |
1 files changed, 267 insertions, 180 deletions
diff --git a/plugin/50-notes.lua b/plugin/50-notes.lua index af8e8de..418f49b 100644 --- a/plugin/50-notes.lua +++ b/plugin/50-notes.lua @@ -1,13 +1,36 @@ +-- +-- 50-notes.lua +-- +-- * Turns the directory in `$NOTES_DIR` into a wiki of markdown notes linked with `[[wiki-links]]`. +-- * Generates ctags for the notes and regenerates them whenever a note is written. +-- * Provides 'omnifunc' wiki-link completion: note names inside `[[`, headings after `#`. +-- * Provides fzf-lua pickers to find notes by tag/name/section, by file name, or by content. +-- * Provides rename commands that move the note (or rewrite a heading) and update every +-- `[[wiki-link]]` pointing to it across all notes, editing loaded buffers in place and the rest +-- on disk. +-- +-- User commands: +-- `NotesRename`: rename the current note and update wiki-links +-- `NotesRenameSection`: rename the heading at the cursor and update wiki-links +-- `NotesFindTags`: fuzzy-find notes (tag, name, section) +-- `NotesFindFile`: fuzzy-find notes (file name) +-- `NotesFindGrep`: fuzzy-find notes (content) +-- `NotesOpen`: open the notes directory and start finding +-- +-- Keymaps: +-- `<Leader>nn`: fuzzy-find notes +-- `<Leader>nf`: fuzzy-find notes by file name +-- `<Leader>ng`: fuzzy-find notes by content +-- + if vim.env.NOTES_DIR == nil then vim.notify("NOTES_DIR is not set", vim.log.levels.ERROR) return end -vim.g.notes_dir = vim.env.NOTES_DIR:sub(-1) == "/" and vim.env.NOTES_DIR:sub(1, -2) - or vim.env.NOTES_DIR - -local notes_dir = vim.g.notes_dir +local notes_dir = vim.fs.normalize(vim.env.NOTES_DIR) local tagfile = vim.fs.joinpath(vim.fn.stdpath("state"), "notes-tags") +-- Generates ctags for (markdown) files in `notes_dir` local function generate_tags() vim.system({ "ctags", @@ -32,8 +55,8 @@ local function note_candidates() return out end --- Headings of a note, read from the ctags tagfile so `#` comments inside fenced --- code blocks aren't mistaken for headings (hashtag `h` / footnote `n` kinds excluded). +-- Headings of a note, read from the ctags tagfile so `#` comments inside fenced code blocks aren't +-- mistaken for headings (hashtag `h` / footnote `n` kinds excluded). local function heading_candidates(note) local out = {} local target = notes_dir .. "/" .. note .. ".md" @@ -53,8 +76,8 @@ local function heading_candidates(note) return out end --- 'omnifunc' for wiki-links: inside `[[`, complete note names; after `#`, complete --- the target note's headings. Wired per notes buffer in the autocmd below. +-- 'omnifunc' for wiki-links: inside `[[`, complete note names; after `#`, complete the target +-- note's headings. function _G.dotfiles_wikilink_source(findstart, base) local line = vim.api.nvim_get_current_line() local col = vim.api.nvim_win_get_cursor(0)[2] @@ -87,63 +110,19 @@ function _G.dotfiles_wikilink_source(findstart, base) return candidates end -vim.api.nvim_create_autocmd({ "BufReadPost", "BufNewFile" }, { - desc = "Set up notes buffer (tagfile, wiki-link completion)", - group = vim.g.dotfiles.augroup, - pattern = notes_dir .. "/*", - callback = function() - if not vim.tbl_contains(vim.opt_local.tags:get(), tagfile) then - vim.opt_local.tags:append(tagfile) - end - vim.bo.omnifunc = "v:lua.dotfiles_wikilink_source" - end, -}) - -vim.api.nvim_create_autocmd("BufWritePost", { - desc = "Regenerate notes ctags", - group = vim.g.dotfiles.augroup, - pattern = notes_dir .. "/*", - callback = generate_tags, -}) - -generate_tags() - -vim.api.nvim_create_user_command("NotesFindTags", function() - require("fzf-lua").tags({ - ctags_file = tagfile, - cwd = notes_dir, - winopts = { preview = { layout = "vertical" } }, - fzf_opts = { ["--delimiter"] = "[\t]", ["--with-nth"] = "{2}\t{1}", ["--tabstop"] = "48" }, - }) -end, { desc = "Fuzzy-find notes (tag,name,section)" }) -vim.api.nvim_create_user_command("NotesFindFile", function() - require("fzf-lua").files({ cwd = notes_dir }) -end, { desc = "Fuzzy-find notes (name)" }) -vim.api.nvim_create_user_command("NotesFindGrep", function() - require("fzf-lua").live_grep({ cwd = notes_dir }) -end, { desc = "Fuzzy-find notes (content)" }) -vim.api.nvim_create_user_command("NotesOpen", function() - vim.cmd.cd(notes_dir) - vim.cmd.NotesFindTags() -end, { desc = "Open notes" }) - -vim.keymap.set("n", "<Leader>nn", vim.cmd.NotesFindTags, { desc = "Fuzzy-find notes" }) -vim.keymap.set("n", "<Leader>nf", vim.cmd.NotesFindFile, { desc = "Fuzzy-find notes file name" }) -vim.keymap.set("n", "<Leader>ng", vim.cmd.NotesFindGrep, { desc = "Fuzzy-find notes content" }) - --- A note's path relative to the notes dir, without `.md`, or nil if not a note. -local function note_name(path) - local prefix = vim.fs.normalize(notes_dir) .. "/" - path = vim.fs.normalize(path) - if not vim.startswith(path, prefix) then - return nil +-- Initializes a notes buffer +-- * Include generated notes tags in 'tags' option +-- * Enable wikilink completion +local function init_notes_buffer() + if not vim.tbl_contains(vim.opt_local.tags:get(), tagfile) then + vim.opt_local.tags:append(tagfile) end - return (path:sub(#prefix + 1):gsub("%.md$", "")) + vim.bo.omnifunc = "v:lua.dotfiles_wikilink_source" end --- Rewrite `[[oldname]]` / `[[oldname#heading]]` targets to `newname`, matching the --- target exactly so `[[oldname_extra]]` is left alone. The gsub replacement is a --- function, so its return value is used literally (no `%` escaping needed). +-- Rewrite `[[oldname]]` / `[[oldname#heading]]` targets to `newname`, matching the target exactly +-- so `[[oldname_extra]]` is left alone. The gsub replacement is a function, so its return value is +-- used literally (no `%` escaping needed). -- Returns the new text and the number of links rewritten. local function rewrite_links(text, oldname, newname) local count = 0 @@ -157,13 +136,53 @@ local function rewrite_links(text, oldname, newname) return out, count end --- Apply `rewriter(text) -> new_text, n` to every note, editing loaded buffers in --- place (preserving unsaved changes) and others on disk. Returns total links and files. -local function update_all_notes(rewriter) +-- Rewrite loaded buffer `buf` with `rewriter` +-- Returns the number of elements rewritten +local function rewrite_loaded_buf(buf, rewriter) + local was_modified = vim.bo[buf].modified + local text = table.concat(vim.api.nvim_buf_get_lines(buf, 0, -1, false), "\n") + local new_text, n + new_text, n = rewriter(text) + if n == 0 then + return n + end + vim.api.nvim_buf_set_lines(buf, 0, -1, false, vim.split(new_text, "\n", { plain = true })) + if not was_modified then -- buffer matched disk; keep it that way + vim.api.nvim_buf_call(buf, function() + vim.cmd("silent! write!") + end) + end + return n +end + +local function rewrite_unloaded_file(file, rewriter) + local fd = io.open(file, "r") + if not fd then + vim.notify("Could not open file " .. file, vim.log.levels.ERROR) + return 0 + end + local content = fd:read("*a") + fd:close() + local new_content, n + new_content, n = rewriter(content) + if n == 0 then + return n + end + local wf = io.open(file, "w") + if wf then + wf:write(new_content) + wf:close() + end + return n +end + +-- Apply `rewriter(text) -> new_text, n` to every note, editing loaded buffers in place (preserving +-- unsaved changes) and others on disk. Returns total edited links and files. +local function rewrite_all_notes(rewriter) local loaded = {} - for _, b in ipairs(vim.api.nvim_list_bufs()) do - if vim.api.nvim_buf_is_loaded(b) then - loaded[vim.fs.normalize(vim.api.nvim_buf_get_name(b))] = b + for _, buf in ipairs(vim.api.nvim_list_bufs()) do + if vim.api.nvim_buf_is_loaded(buf) then + loaded[vim.fs.normalize(vim.api.nvim_buf_get_name(buf))] = buf end end local files = vim.fs.find(function(name) @@ -172,43 +191,9 @@ local function update_all_notes(rewriter) local link_count, file_count = 0, 0 for _, file in ipairs(files) do - local b = loaded[vim.fs.normalize(file)] + local buf = loaded[vim.fs.normalize(file)] local n = 0 - if b then - local was_modified = vim.bo[b].modified - local text = table.concat(vim.api.nvim_buf_get_lines(b, 0, -1, false), "\n") - local new_text - new_text, n = rewriter(text) - if n > 0 then - vim.api.nvim_buf_set_lines( - b, - 0, - -1, - false, - vim.split(new_text, "\n", { plain = true }) - ) - if not was_modified then -- buffer matched disk; keep it that way - vim.api.nvim_buf_call(b, function() - vim.cmd("silent! write!") - end) - end - end - else - local fd = io.open(file, "r") - if fd then - local content = fd:read("*a") - fd:close() - local new_content - new_content, n = rewriter(content) - if n > 0 then - local wf = io.open(file, "w") - if wf then - wf:write(new_content) - wf:close() - end - end - end - end + n = buf and rewrite_loaded_buf(buf, rewriter) or rewrite_unloaded_file(file, rewriter) if n > 0 then link_count = link_count + n file_count = file_count + 1 @@ -217,79 +202,85 @@ local function update_all_notes(rewriter) return link_count, file_count end --- Rename the current note to `new_rel` (relative to the notes dir, `.md` optional) --- and update every wiki-link pointing to it across all notes. -local function rename_current_note(new_rel) - local buf = vim.api.nvim_get_current_buf() - local oldname = note_name(vim.api.nvim_buf_get_name(buf)) - if oldname == nil then - vim.notify("Current buffer is not a note", vim.log.levels.ERROR) - return - end - new_rel = (new_rel or ""):gsub("%.md$", "") - if new_rel == "" then - return - end - - local old_path = notes_dir .. "/" .. oldname .. ".md" - local new_path = notes_dir .. "/" .. new_rel .. ".md" - if vim.uv.fs_stat(new_path) then - vim.notify("Target already exists: " .. new_rel, vim.log.levels.ERROR) - return - end - - if vim.bo[buf].modified then - vim.cmd.write() +-- Returns a note's path relative to the notes dir, without `.md`, or nil if not a note. +local function note_rel_path(path) + local prefix = vim.fs.normalize(notes_dir) .. "/" + path = vim.fs.normalize(path) + if not vim.startswith(path, prefix) then + return nil end + return (path:sub(#prefix + 1):gsub("%.md$", "")) +end - -- Move the file on disk, then repoint the current buffer at it. +-- Move a file on disk. Returns `true` on success, or `false, err` on failure. +local function move_file(old_path, new_path) vim.fn.mkdir(vim.fs.dirname(new_path), "p") - local ok, err = vim.uv.fs_rename(old_path, new_path) - if not ok then - vim.notify("Rename failed: " .. tostring(err), vim.log.levels.ERROR) - return - end + return vim.uv.fs_rename(old_path, new_path) +end + +-- Renames buffer `buf` and delete old stale buffers with the original name +local function rename_buffer(buf, new_path) + local old_path = vim.api.nvim_buf_get_name(buf) vim.api.nvim_buf_set_name(buf, new_path) vim.cmd("keepalt write!") - -- Renaming a buffer can leave a stale empty buffer under the old name. for _, b in ipairs(vim.api.nvim_list_bufs()) do if b ~= buf and vim.api.nvim_buf_get_name(b) == old_path then pcall(vim.api.nvim_buf_delete, b, { force = true }) end end +end - local link_count, file_count = update_all_notes(function(text) - return rewrite_links(text, oldname, new_rel) +-- Rename note `old_path` → `new_path` and update every wiki-link pointing to it across all notes. +-- When `buf` is given (the note is open), it is repointed at `new_path` *before* links are rewritten, +-- so the rewrite reaches the buffer (incl. its own self-links) instead of being clobbered by a later +-- write. Regenerates the notes tags after renaming. +local function rename_note(old_path, new_path, buf) + if not vim.startswith(old_path, notes_dir) or not vim.startswith(new_path, notes_dir) then + vim.notify("old_path and new_path must both be inside " .. notes_dir, vim.log.levels.ERROR) + return + end + if not vim.uv.fs_stat(old_path) then + vim.notify("file to rename " .. old_path .. " does not exists", vim.log.levels.ERROR) + return + end + if vim.uv.fs_stat(new_path) then + vim.notify("target path " .. new_path .. " already exists", vim.log.levels.ERROR) + return + end + local ok, err = move_file(old_path, new_path) + if not ok then + vim.notify("rename failed: " .. tostring(err), vim.log.levels.ERROR) + return + end + if buf then + rename_buffer(buf, new_path) + end + -- Wiki-links hold the note's rel path (no `.md`), so rewrite against those, not the full paths. + local oldname, newname = note_rel_path(old_path), note_rel_path(new_path) + local link_count, file_count = rewrite_all_notes(function(text) + return rewrite_links(text, oldname, newname) end) - generate_tags() - vim.notify( - string.format( - "Renamed to %s — updated %d link(s) in %d file(s)", - new_rel, - link_count, - file_count - ) + + local notification = string.format( + "Renamed to %s — updated %d link(s) in %d file(s)", + newname, + link_count, + file_count ) + vim.notify(notification) end -vim.api.nvim_create_user_command("NotesRename", function(opts) - if opts.args ~= "" then - rename_current_note(opts.args) - else - vim.ui.input( - { prompt = "Rename note to: ", default = note_name(vim.api.nvim_buf_get_name(0)) }, - function(input) - if input and input ~= "" then - rename_current_note(input) - end - end - ) - end -end, { nargs = "?", desc = "Rename current note and update wiki-links" }) +-- Rename the current note to `new_path` (repointing this buffer at it). +local function rename_current_note(new_path) + local buf = vim.api.nvim_get_current_buf() + local old_path = vim.api.nvim_buf_get_name(buf) + new_path = vim.fs.joinpath(notes_dir, new_path) + rename_note(old_path, new_path, buf) +end --- Rewrite `[[note#oldhead]]` heading anchors to `newhead` (only for this note's --- own headings; links to other notes' identically-named headings are untouched). +-- Rewrites `[[note#oldhead]]` heading anchors to `newhead` in `text`. +-- Returns the edited text and the number of substitutions performed. local function rewrite_heading_links(text, note, oldhead, newhead) local count = 0 local out = text:gsub("%[%[(.-)%]%]", function(inner) @@ -302,9 +293,9 @@ local function rewrite_heading_links(text, note, oldhead, newhead) return out, count end --- The markdown heading at or above the cursor as `{ row, level, text }` (row is --- 1-based), or nil. Returning one value keeps the fields narrowed together. -local function heading_at_cursor(buf) +-- Returns the markdown heading at or above the cursor as `{ row, level, text }` (row is 1-based), +-- or nil if not found. +local function heading_at_or_above_cursor(buf) local row = vim.api.nvim_win_get_cursor(0)[1] local lines = vim.api.nvim_buf_get_lines(buf, 0, row, false) for r = #lines, 1, -1 do @@ -316,15 +307,32 @@ local function heading_at_cursor(buf) return nil end +-- Rename section `old_head` → `new_head` in note `note` (rel path) and update every wiki-link +-- pointing to it across all notes. Regenerates the notes tags after renaming. +local function rename_section(note, old_head, new_head) + local link_count, file_count = rewrite_all_notes(function(text) + return rewrite_heading_links(text, note, old_head, new_head) + end) + generate_tags() + + local notification = string.format( + "Renamed section to %s — updated %d link(s) in %d file(s)", + new_head, + link_count, + file_count + ) + vim.notify(notification) +end + -- Rename the heading at the cursor to `new_head` and update wiki-links to it. local function rename_current_section(new_head) local buf = vim.api.nvim_get_current_buf() - local note = note_name(vim.api.nvim_buf_get_name(buf)) + local note = note_rel_path(vim.api.nvim_buf_get_name(buf)) if note == nil then vim.notify("Current buffer is not a note", vim.log.levels.ERROR) return end - local heading = heading_at_cursor(buf) + local heading = heading_at_or_above_cursor(buf) if heading == nil then vim.notify("No heading at or above the cursor", vim.log.levels.ERROR) return @@ -333,32 +341,38 @@ local function rename_current_section(new_head) if new_head == "" or new_head == heading.text then return end - -- Rewrite the heading line itself, then persist before touching links. local line = string.rep("#", heading.level) .. " " .. new_head vim.api.nvim_buf_set_lines(buf, heading.row - 1, heading.row, false, { line }) vim.cmd("write") - local link_count, file_count = update_all_notes(function(text) - return rewrite_heading_links(text, note, heading.text, new_head) - end) + rename_section(note, heading.text, new_head) +end - generate_tags() - vim.notify( - string.format( - "Renamed section to %s — updated %d link(s) in %d file(s)", - new_head, - link_count, - file_count +-- `:NotesRename` implementation: take the new note name from the command argument, or prompt for it +-- (defaulting to the current note's name), then rename via `rename_current_note`. +local function rename_note_command(opts) + if opts.args ~= "" then + rename_current_note(opts.args) + else + vim.ui.input( + { prompt = "Rename note to: ", default = note_rel_path(vim.api.nvim_buf_get_name(0)) }, + function(input) + if input and input ~= "" then + rename_current_note(input) + end + end ) - ) + end end -vim.api.nvim_create_user_command("NotesRenameSection", function(opts) +-- `:NotesRenameSection` implementation: take the new heading from the command argument, or prompt +-- for it (defaulting to the heading at the cursor), then rename via `rename_current_section`. +local function rename_section_command(opts) if opts.args ~= "" then rename_current_section(opts.args) else - local heading = heading_at_cursor(vim.api.nvim_get_current_buf()) + local heading = heading_at_or_above_cursor(vim.api.nvim_get_current_buf()) vim.ui.input( { prompt = "Rename section to: ", default = heading and heading.text }, function(input) @@ -368,4 +382,77 @@ vim.api.nvim_create_user_command("NotesRenameSection", function(opts) end ) end -end, { nargs = "*", desc = "Rename the heading at the cursor and update wiki-links" }) +end + +local function find_notes_by_tag() + require("fzf-lua").tags({ + ctags_file = tagfile, + cwd = notes_dir, + winopts = { preview = { layout = "vertical" } }, + fzf_opts = { ["--delimiter"] = "[\t]", ["--with-nth"] = "{2}\t{1}", ["--tabstop"] = "48" }, + }) +end + +local function find_notes_by_name() + require("fzf-lua").files({ cwd = notes_dir }) +end + +local function find_notes_by_grep() + require("fzf-lua").live_grep({ cwd = notes_dir }) +end + +local function open_notes() + vim.cmd.cd(notes_dir) + vim.cmd.NotesFindTags() +end + +---------------------------------------------------------------------------------------------------- + +-- Initializes note buffer +vim.api.nvim_create_autocmd({ "BufReadPost", "BufNewFile" }, { + desc = "Set up notes buffer (tagfile, wiki-link completion)", + group = vim.g.dotfiles.augroup, + pattern = notes_dir .. "/*", + callback = init_notes_buffer, +}) + +-- Regenerate tags everytime a note (i.e. file inside `notes_dir`) is written +vim.api.nvim_create_autocmd("BufWritePost", { + desc = "Regenerate notes ctags", + group = vim.g.dotfiles.augroup, + pattern = notes_dir .. "/*", + callback = generate_tags, +}) + +vim.api.nvim_create_user_command( + "NotesRename", + rename_note_command, + { nargs = "?", desc = "Rename current note and update wiki-links" } +) +vim.api.nvim_create_user_command( + "NotesRenameSection", + rename_section_command, + { nargs = "*", desc = "Rename the heading at the cursor and update wiki-links" } +) +vim.api.nvim_create_user_command( + "NotesFindTags", + find_notes_by_tag, + { desc = "Fuzzy-find notes (tag,name,section)" } +) +vim.api.nvim_create_user_command( + "NotesFindFile", + find_notes_by_name, + { desc = "Fuzzy-find notes (name)" } +) +vim.api.nvim_create_user_command( + "NotesFindGrep", + find_notes_by_grep, + { desc = "Fuzzy-find notes (grep content)" } +) +vim.api.nvim_create_user_command("NotesOpen", open_notes, { desc = "Open notes" }) + +vim.keymap.set("n", "<Leader>nn", vim.cmd.NotesFindTags, { desc = "Fuzzy-find notes" }) +vim.keymap.set("n", "<Leader>nf", vim.cmd.NotesFindFile, { desc = "Fuzzy-find notes file name" }) +vim.keymap.set("n", "<Leader>ng", vim.cmd.NotesFindGrep, { desc = "Fuzzy-find notes content" }) + +generate_tags() |
