summaryrefslogtreecommitdiffstats
path: root/plugin
diff options
context:
space:
mode:
Diffstat (limited to 'plugin')
-rw-r--r--plugin/50-notes.lua447
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()