-- -- 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 `#`; -- auto-triggered as soon as `[[` or `#` is typed in a notes buffer. -- * 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`: move to the notes directory and open index -- -- Keymaps (global): -- `N`: open notes in current window -- Keymaps (notes buffers only; shadow the matching `f…` fzf defaults from 50-fzf.lua): -- `ft`: fuzzy-find notes by tags -- `ff`: fuzzy-find notes by file name -- `fg`: fuzzy-find notes by content -- `` / ``: go to next/previous followable entity -- local notes_dir = require("dotfiles.notes").dir if notes_dir == nil then vim.notify("50-notes.lua: NOTES_DIR is not set, notes wiki disabled", vim.log.levels.WARN) return end local tagfile = vim.fs.joinpath(vim.fn.stdpath("state"), "notes-tags") local follow = require("dotfiles.follow") ---------------------------------------------------------------------------------------------------- -- Tags -------------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- -- Generates ctags for (markdown) files in `notes_dir` local function generate_tags() vim.system({ "ctags", "--languages=Markdown", "--tag-relative=no", "--recurse", "-f", tagfile, notes_dir, }) end ---------------------------------------------------------------------------------------------------- -- Completion -------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- -- Completion of all `*.md` note names (relative to the notes dir, extension stripped). local function note_candidates() local out = {} local files = vim.fs.find(function(name) return name:match("%.md$") end, { path = notes_dir, type = "file", limit = math.huge }) for _, f in ipairs(files) do out[#out + 1] = f:sub(#notes_dir + 2):gsub("%.md$", "") end 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). local function heading_candidates(note) local out = {} local target = notes_dir .. "/" .. note .. ".md" local fd = io.open(tagfile, "r") if fd == nil then return out end for line in fd:lines() do if line:sub(1, 1) ~= "!" then local name, file, _, kind = line:match('^(.-)\t(.-)\t(.-);"\t(%a)') if file == target and kind ~= "h" and kind ~= "n" then out[#out + 1] = name end end end fd:close() return out end -- '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] local before = line:sub(1, col) -- Locate the innermost still-open `[[` to the left of the cursor. local open, i = nil, 1 while true do local s = before:find("%[%[", i) if s == nil then break end open, i = s, s + 2 end local inner = open and before:sub(open + 2) if inner == nil or inner:find("%]") then -- not inside an open `[[ ... ]]` return findstart == 1 and -3 or {} end local hash = inner:find("#") if findstart == 1 then -- 0-based byte column where the completed text starts (after `[[` or after `#`). return hash and (open + 1 + hash) or (open + 1) end local candidates = hash and heading_candidates(inner:sub(1, hash - 1)) or note_candidates() if base ~= "" then candidates = vim.fn.matchfuzzy(candidates, base) end return candidates end ---------------------------------------------------------------------------------------------------- -- Link rewriting ---------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- -- Generic engine: apply a `rewriter(text) -> new_text, n` to notes, editing loaded buffers in place -- and the rest on disk. Shared by note rename and section rename below. -- 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 _, 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) return name:match("%.md$") end, { path = notes_dir, type = "file", limit = math.huge }) local link_count, file_count = 0, 0 for _, file in ipairs(files) do local buf = loaded[vim.fs.normalize(file)] local n = 0 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 end end return link_count, file_count end -- 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 = 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 ---------------------------------------------------------------------------------------------------- -- Rename note ------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- -- 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 local out = text:gsub("%[%[(.-)%]%]", function(inner) local target, rest = inner:match("^([^#]*)(.*)$") if target == oldname then count = count + 1 return "[[" .. newname .. rest .. "]]" end end) return out, count end -- 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") return vim.uv.fs_rename(old_path, new_path) end -- Returns the loaded buffer editing `path` (normalized match), or nil if none is open. local function buf_for_path(path) path = vim.fs.normalize(path) for _, b in ipairs(vim.api.nvim_list_bufs()) do if vim.api.nvim_buf_is_loaded(b) and vim.fs.normalize(vim.api.nvim_buf_get_name(b)) == path then return b end end return nil 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!") 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 -- Rename note `old_path` → `new_path` and update every wiki-link pointing to it across all notes. -- If the note is open in a buffer, that buffer 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) 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 -- Moving on disk doesn't rename the buffer, so it's still found under `old_path` here. local buf = buf_for_path(old_path) 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() local notification = string.format( "Renamed to %s — updated %d link(s) in %d file(s)", newname, link_count, file_count ) vim.notify(notification) end -- Rename the current note to `new_rel` (relative to the notes dir, `.md` optional). local function rename_current_note(new_rel) local old_path = vim.api.nvim_buf_get_name(0) -- Tolerate a missing (or already-present) `.md` so the note never loses its extension. new_rel = (new_rel:gsub("%.md$", "")) .. ".md" rename_note(old_path, vim.fs.joinpath(notes_dir, new_rel)) end -- `: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 ---------------------------------------------------------------------------------------------------- -- Rename section ---------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- -- 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) local target, head = inner:match("^([^#]*)#(.*)$") if target == note and head == oldhead then count = count + 1 return "[[" .. target .. "#" .. newhead .. "]]" end end) return out, count end -- 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 local hashes, text = lines[r]:match("^(#+)%s+(.-)%s*$") if hashes then return { row = r, level = #hashes, text = text } end end 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_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_or_above_cursor(buf) if heading == nil then vim.notify("No heading at or above the cursor", vim.log.levels.ERROR) return end new_head = vim.trim(new_head or "") 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") rename_section(note, heading.text, new_head) end -- `: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_or_above_cursor(vim.api.nvim_get_current_buf()) vim.ui.input( { prompt = "Rename section to: ", default = heading and heading.text }, function(input) if input and input ~= "" then rename_current_section(input) end end ) end end ---------------------------------------------------------------------------------------------------- -- Buffer init & pickers --------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- -- Initializes a notes buffer -- * Include generated notes tags in 'tags' option -- * Enable wikilink completion -- * Map / to jump between followable entities -- * Shadow fzf's `ft`/`ff`/`fg` with the notes pickers, buffer-locally so -- the global fzf defaults (50-fzf.lua) stay untouched everywhere else. local function init_notes_buffer() 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" vim.keymap.set("n", "", function() follow.next(false) end, { buffer = true, desc = "Go to next followable entity" }) vim.keymap.set("n", "", function() follow.next(true) end, { buffer = true, desc = "Go to previous followable entity" }) vim.keymap.set("n", "ft", vim.cmd.NotesFindTags, { buffer = true, desc = "Fuzzy-find notes", }) vim.keymap.set("n", "ff", vim.cmd.NotesFindFile, { buffer = true, desc = "Fuzzy-find notes file name", }) vim.keymap.set("n", "fg", vim.cmd.NotesFindGrep, { buffer = true, desc = "Fuzzy-find notes content", }) 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 -- Enters the notes dir and opens its index, creating it with a `# Index` header on first use. local function open_notes() vim.cmd.lcd(notes_dir) local index = notes_dir .. "/index.md" if not vim.uv.fs_stat(index) then local f = assert(io.open(index, "w")) f:write("# Index\n") f:close() end vim.cmd.edit({ args = { index } }) end ---------------------------------------------------------------------------------------------------- -- Setup ------------------------------------------------------------------------------------------- ---------------------------------------------------------------------------------------------------- -- 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, }) -- Pop wiki-link completion as soon as `[[` (note name) or `#` (heading) is typed. 'autocomplete' -- only triggers on keyword characters, so it would otherwise wait for the first note-name char. -- This watches the typed text instead of mapping `[`, so nvim-autopairs keeps its `[` -> `[]` rule; -- the omnifunc silently cancels (returns -3) when the cursor is not inside an open `[[`, so the `#` -- triggers on plain headings are harmless. vim.api.nvim_create_autocmd("TextChangedI", { desc = "Trigger wiki-link completion after `[[` or `#`", group = vim.g.dotfiles.augroup, callback = function() if vim.bo.omnifunc ~= "v:lua.dotfiles_wikilink_source" or vim.fn.pumvisible() == 1 then return end local col = vim.api.nvim_win_get_cursor(0)[2] local before = vim.api.nvim_get_current_line():sub(1, col) if before:sub(-2) == "[[" or before:sub(-1) == "#" then vim.api.nvim_feedkeys(vim.keycode(""), "n", false) end end, }) 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" }) -- Only the notes-open map is global; the find pickers (`ft`/`ff`/`fg`) are set buffer-locally in -- `init_notes_buffer` so they shadow the fzf defaults only inside notes buffers. vim.keymap.set("n", "N", vim.cmd.NotesOpen, { desc = "Open notes in current window" }) generate_tags()