-- Markdown task management -- Keymaps are registered only in markdown buffers via FileType autocmd. -- -- File structure expected: -- # TODO Lists -- ## General Section ✅2 ❌1 -- - [ ] undated task -- ## DD-MM-YYYY ✅0 ❌3 (newest date first) -- - [ ] task -- - [x] done task local M = {} -- ── Core task operations ───────────────────────────────────────────────────── local function new_task() local line = vim.api.nvim_get_current_line() if line:match("^%s*$") then vim.api.nvim_set_current_line("- [ ] ") vim.cmd("startinsert!") else vim.cmd("normal! o- [ ] ") vim.cmd("startinsert!") end end local function toggle_task() local line = vim.api.nvim_get_current_line() if line:find("%[ %]") then vim.api.nvim_set_current_line((line:gsub("%[ %]", "[x]", 1))) elseif line:find("%[x%]") then vim.api.nvim_set_current_line((line:gsub("%[x%]", "[ ]", 1))) else return end -- stats updated below after toggle local update = require("config.markdown-tasks")._update_stats if update then update() end end local function toggle_task_format() local line = vim.api.nvim_get_current_line() if line:find("^%s*%- %[.%] ") then -- "- [ ] text" → "- text" vim.api.nvim_set_current_line((line:gsub("^(%s*)%- %[.%] ", "%1- ", 1))) elseif line:find("^%s*%- ") then -- "- text" → "- [ ] text" vim.api.nvim_set_current_line((line:gsub("^(%s*)%- ", "%1- [ ] ", 1))) end end local function jump_next_task() vim.fn.search("- \\[ \\]", "W") end local function jump_prev_task() vim.fn.search("- \\[ \\]", "bW") end local function list_tasks_in_file() require("snacks").picker.grep({ search = "- \\[ \\]", buffers = true }) end local function search_all_tasks() require("snacks").picker.grep({ search = "- \\[ \\]" }) end -- ── Section task stats ──────────────────────────────────────────────────────── -- Strips the stats suffix from a heading line, returning the bare heading. local function strip_stats(line) return (line:gsub("%s+✅%d+ ❌%d+%s*$", "")) end -- Counts [x] and [ ] tasks in a section starting at header_line (1-indexed). local function count_section_tasks(lines, header_line) local done, todo = 0, 0 for i = header_line + 1, #lines do if lines[i]:match("^## ") then break end if lines[i]:match("^%s*%- %[x%]") then done = done + 1 elseif lines[i]:match("^%s*%- %[ %]") then todo = todo + 1 end end return done, todo end -- Updates every ## heading with a live task count suffix. local function update_section_stats() local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) local changed = false for i, line in ipairs(lines) do if line:match("^## ") then local base = strip_stats(line) local done, todo = count_section_tasks(lines, i) local new_line = base .. " ✅" .. done .. " ❌" .. todo if new_line ~= line then lines[i] = new_line changed = true end end end if changed then local cursor = vim.api.nvim_win_get_cursor(0) vim.api.nvim_buf_set_lines(0, 0, -1, false, lines) vim.api.nvim_win_set_cursor(0, cursor) end end -- Expose so toggle_task can call it without a forward-ref issue. M._update_stats = update_section_stats -- ── Spacing normalizer ─────────────────────────────────────────────────────── -- Enforces: one blank line before and after every heading line, no consecutive -- blank lines, no blank lines between sibling task lines. local function normalize_spacing() local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) -- Strip all blank lines first so we can re-insert them deterministically. local stripped = {} for _, l in ipairs(lines) do if not l:match("^%s*$") then table.insert(stripped, l) end end local result = {} local function ensure_blank() if #result > 0 and not result[#result]:match("^%s*$") then table.insert(result, "") end end for i, l in ipairs(stripped) do local is_heading = l:match("^#") if is_heading and #result > 0 then ensure_blank() end table.insert(result, l) if is_heading and i < #stripped then ensure_blank() end end vim.api.nvim_buf_set_lines(0, 0, -1, false, result) update_section_stats() end -- ── Date helpers ───────────────────────────────────────────────────────────── -- Returns date string "DD-MM-YYYY" with an optional day offset. local function date_string(offset_days) local t = os.time() + (offset_days or 0) * 86400 return os.date("%d-%m-%Y", t) end -- Returns 1-indexed line number of the "## DD-MM-YYYY" header, or nil. local function find_date_section(date_str) local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) for i, line in ipairs(lines) do if strip_stats(line) == "## " .. date_str then return i end end end -- Returns the 1-indexed line of the last non-empty content line within a -- section (from header_line until the next ## header or EOF). local function section_end(header_line) local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) local last = header_line for i = header_line + 1, #lines do if lines[i]:match("^## ") then return i - 1 end if not lines[i]:match("^%s*$") then last = i end end return last end -- Parses a "DD-MM-YYYY" string into a Unix timestamp for comparison. local function date_to_time(s) local d, m, y = s:match("(%d+)-(%d+)-(%d+)") if not d then return 0 end return os.time({ year = tonumber(y), month = tonumber(m), day = tonumber(d) }) end -- Inserts a new "## date_str" line in descending date order (newest first). -- Blank lines are left to normalize_spacing. local function insert_date_section(date_str) local new_time = date_to_time(date_str) local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) -- Find the first existing date header that is strictly older → insert before it. for i, line in ipairs(lines) do local existing = strip_stats(line):match("^## (%d%d%-%d%d%-%d%d%d%d)$") if existing and date_to_time(existing) < new_time then vim.api.nvim_buf_set_lines(0, i - 1, i - 1, false, { "## " .. date_str }) return end end -- All existing dates are newer (or there are none) → append at end. vim.api.nvim_buf_set_lines(0, #lines, #lines, false, { "## " .. date_str }) end -- ── Insert today's date header ──────────────────────────────────────────────── local function insert_date() local header = "## " .. date_string(0) local row = vim.api.nvim_win_get_cursor(0)[1] local line = vim.api.nvim_get_current_line() if line:match("^%s*$") then vim.api.nvim_set_current_line(header) else vim.api.nvim_buf_set_lines(0, row, row, false, { header }) vim.api.nvim_win_set_cursor(0, { row + 1, 0 }) end normalize_spacing() end -- ── Move task helpers ───────────────────────────────────────────────────────── -- Removes the task at row (1-indexed), then deletes the owning date header if -- it is now empty. Returns the task text. local function remove_task(row) local line = vim.api.nvim_buf_get_lines(0, row - 1, row, false)[1] -- Find the ## header that owns this task before deleting it. local all = vim.api.nvim_buf_get_lines(0, 0, -1, false) local src_hdr = nil for i = row, 1, -1 do if all[i] and all[i]:match("^##") then src_hdr = i; break end end vim.api.nvim_buf_set_lines(0, row - 1, row, false, {}) -- If the source was a date section that is now empty, remove its header. if src_hdr then local updated = vim.api.nvim_buf_get_lines(0, 0, -1, false) local hdr = updated[src_hdr] if hdr and strip_stats(hdr):match("^## %d%d%-%d%d%-%d%d%d%d$") then local empty = true for i = src_hdr + 1, #updated do if updated[i]:match("^## ") then break end if not updated[i]:match("^%s*$") then empty = false; break end end if empty then vim.api.nvim_buf_set_lines(0, src_hdr - 1, src_hdr, false, {}) end end end return line end -- ── Move task to a date section ─────────────────────────────────────────────── local function move_task_to(date_str) local row = vim.api.nvim_win_get_cursor(0)[1] local line = vim.api.nvim_get_current_line() if not line:match("^%s*%- %[") then vim.notify("Not on a task line", vim.log.levels.WARN) return end remove_task(row) local target = find_date_section(date_str) if not target then insert_date_section(date_str) target = find_date_section(date_str) end local insert_at = section_end(target) vim.api.nvim_buf_set_lines(0, insert_at, insert_at, false, { line }) normalize_spacing() end local function move_to_general() local row = vim.api.nvim_win_get_cursor(0)[1] local line = vim.api.nvim_get_current_line() if not line:match("^%s*%- %[") then vim.notify("Not on a task line", vim.log.levels.WARN) return end remove_task(row) -- The general section is the first ## heading that isn't a date heading. local lines = vim.api.nvim_buf_get_lines(0, 0, -1, false) local general = nil for i, l in ipairs(lines) do local base = strip_stats(l) if base:match("^## ") and not base:match("^## %d%d%-%d%d%-%d%d%d%d$") then general = i; break end end if not general then vim.notify("No general section found", vim.log.levels.WARN) return end local insert_at = section_end(general) vim.api.nvim_buf_set_lines(0, insert_at, insert_at, false, { line }) normalize_spacing() end local function move_to_today() move_task_to(date_string(0)) end local function move_to_tomorrow() move_task_to(date_string(1)) end local function move_to_yesterday() move_task_to(date_string(-1)) end -- Parses flexible date input into "DD-MM-YYYY". Accepts separators - / or none. -- If the year is omitted (DDMM or DD-MM or DD/MM), assumes the current year. local function parse_date_input(input) if not input or input == "" then return nil end input = input:match("^%s*(.-)%s*$") -- trim local current_year = tonumber(os.date("%Y")) local d, m, y -- With separator: DD-MM-YYYY or DD/MM/YYYY (year optional) d, m, y = input:match("^(%d%d?)[%-%/](%d%d?)[%-%/](%d%d%d%d)$") if d then return string.format("%02d-%02d-%04d", tonumber(d), tonumber(m), tonumber(y)) end d, m = input:match("^(%d%d?)[%-%/](%d%d?)$") if d then return string.format("%02d-%02d-%04d", tonumber(d), tonumber(m), current_year) end -- No separator: DDMMYYYY or DDMM d, m, y = input:match("^(%d%d)(%d%d)(%d%d%d%d)$") if d then return string.format("%02d-%02d-%04d", tonumber(d), tonumber(m), tonumber(y)) end d, m = input:match("^(%d%d)(%d%d)$") if d then return string.format("%02d-%02d-%04d", tonumber(d), tonumber(m), current_year) end return nil end local function move_to_date() vim.ui.input({ prompt = "Date (DD-MM-YYYY): " }, function(input) local parsed = parse_date_input(input) if parsed then move_task_to(parsed) elseif input and input ~= "" then vim.notify("Unrecognised date: " .. input, vim.log.levels.WARN) end end) end -- ── Setup ──────────────────────────────────────────────────────────────────── function M.setup() local group = vim.api.nvim_create_augroup("MarkdownTasks", { clear = true }) vim.api.nvim_create_autocmd("FileType", { pattern = "markdown", group = group, callback = function(ev) local buf = ev.buf local o = function(desc) return { buffer = buf, desc = desc } end vim.keymap.set("n", "mn", new_task, o("New task")) vim.keymap.set("n", "mx", toggle_task, o("Toggle done")) vim.keymap.set("n", "mf", toggle_task_format, o("Toggle task format")) vim.keymap.set("n", "mj", jump_next_task, o("Next task")) vim.keymap.set("n", "mk", jump_prev_task, o("Prev task")) vim.keymap.set("n", "ml", list_tasks_in_file, o("List tasks (file)")) vim.keymap.set("n", "ms", search_all_tasks, o("Search all tasks")) vim.keymap.set("n", "mt", insert_date, o("Insert date header")) vim.keymap.set("n", "mmg", move_to_general, o("Move → general")) vim.keymap.set("n", "mmt", move_to_today, o("Move → today")) vim.keymap.set("n", "mmT", move_to_tomorrow, o("Move → tomorrow")) vim.keymap.set("n", "mmy", move_to_yesterday, o("Move → yesterday")) vim.keymap.set("n", "mmd", move_to_date, o("Move → date…")) -- Update stats after leaving insert mode (covers new_task completions). vim.api.nvim_create_autocmd("InsertLeave", { buffer = buf, group = group, callback = update_section_stats, }) end, }) end return M