From 9807c7af80b5d162be3b18c9cfdc31c075971f69 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:09:43 -0500 Subject: [PATCH] feat: add commands, mappings, and plugin entry point Problem: need user-facing :Todo command, buffer-local keymaps, Plug mappings, completion toggle, help float, archive, and syntax highlighting. Solution: add init.lua with command dispatcher, toggle complete/ priority, date prompt, archive purge, and help float. Add plugin/todo.lua entry point with :Todo command and Plug mappings. Add syntax/todo.vim for conceal and priority highlighting. --- lua/todo/init.lua | 265 ++++++++++++++++++++++++++++++++++++++++++++++ plugin/todo.lua | 39 +++++++ syntax/todo.vim | 14 +++ 3 files changed, 318 insertions(+) create mode 100644 lua/todo/init.lua create mode 100644 plugin/todo.lua create mode 100644 syntax/todo.vim diff --git a/lua/todo/init.lua b/lua/todo/init.lua new file mode 100644 index 0000000..f084837 --- /dev/null +++ b/lua/todo/init.lua @@ -0,0 +1,265 @@ +local buffer = require('todo.buffer') +local diff = require('todo.diff') +local parse = require('todo.parse') +local store = require('todo.store') + +---@class task +local M = {} + +---@type todo.Task[]? +local undo_state = nil + +---@return integer bufnr +function M.open() + local bufnr = buffer.open() + M._setup_autocmds(bufnr) + M._setup_buf_mappings(bufnr) + return bufnr +end + +---@param bufnr integer +function M._setup_autocmds(bufnr) + local group = vim.api.nvim_create_augroup('TodoBuffer', { clear = true }) + vim.api.nvim_create_autocmd('BufWriteCmd', { + group = group, + buffer = bufnr, + callback = function() + M._on_write(bufnr) + end, + }) +end + +---@param bufnr integer +function M._setup_buf_mappings(bufnr) + local opts = { buffer = bufnr, silent = true } + vim.keymap.set('n', '', function() + M.toggle_complete() + end, opts) + vim.keymap.set('n', '', function() + buffer.toggle_view() + end, opts) + vim.keymap.set('n', 'g?', function() + M.show_help() + end, opts) +end + +---@param bufnr integer +function M._on_write(bufnr) + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + undo_state = store.active_tasks() + diff.apply(lines) + buffer.render(bufnr) +end + +function M.toggle_complete() + local bufnr = buffer.bufnr() + if not bufnr then + return + end + local row = vim.api.nvim_win_get_cursor(0)[1] + local meta = buffer.meta() + if not meta[row] or meta[row].type ~= 'task' then + return + end + local id = meta[row].id + local task = store.get(id) + if not task then + return + end + if task.status == 'done' then + store.update(id, { status = 'pending', ['end'] = vim.NIL }) + else + store.update(id, { status = 'done' }) + end + store.save() + buffer.render(bufnr) +end + +function M.toggle_priority() + local bufnr = buffer.bufnr() + if not bufnr then + return + end + local row = vim.api.nvim_win_get_cursor(0)[1] + local meta = buffer.meta() + if not meta[row] or meta[row].type ~= 'task' then + return + end + local id = meta[row].id + local task = store.get(id) + if not task then + return + end + local new_priority = task.priority == 1 and 0 or 1 + store.update(id, { priority = new_priority }) + store.save() + buffer.render(bufnr) +end + +function M.prompt_date() + local bufnr = buffer.bufnr() + if not bufnr then + return + end + local row = vim.api.nvim_win_get_cursor(0)[1] + local meta = buffer.meta() + if not meta[row] or meta[row].type ~= 'task' then + return + end + local id = meta[row].id + vim.ui.input({ prompt = 'Due date (YYYY-MM-DD): ' }, function(input) + if not input then + return + end + local due = input ~= '' and input or nil + if due then + local y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$') + if not y then + vim.notify('Invalid date format. Use YYYY-MM-DD.', vim.log.levels.ERROR) + return + end + end + store.update(id, { due = due }) + store.save() + buffer.render(bufnr) + end) +end + +---@param text string +function M.add(text) + if not text or text == '' then + vim.notify('Usage: :Todo add ', vim.log.levels.ERROR) + return + end + store.load() + local description, metadata = parse.command_add(text) + if not description or description == '' then + vim.notify('Todo must have a description.', vim.log.levels.ERROR) + return + end + store.add({ + description = description, + category = metadata.cat, + due = metadata.due, + }) + store.save() + local bufnr = buffer.bufnr() + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + buffer.render(bufnr) + end + vim.notify('Todo added: ' .. description) +end + +function M.sync() + local ok, gcal = pcall(require, 'todo.sync.gcal') + if not ok then + vim.notify('Google Calendar sync module not available.', vim.log.levels.ERROR) + return + end + gcal.sync() +end + +---@param days? integer +function M.archive(days) + days = days or 30 + local cutoff = os.time() - (days * 86400) + local tasks = store.tasks() + local archived = 0 + local kept = {} + for _, task in ipairs(tasks) do + if (task.status == 'done' or task.status == 'deleted') and task['end'] then + local y, mo, d, h, mi, s = task['end']:match('^(%d+)-(%d+)-(%d+)T(%d+):(%d+):(%d+)Z$') + if y then + local t = os.time({ + year = tonumber(y), + month = tonumber(mo), + day = tonumber(d), + hour = tonumber(h), + min = tonumber(mi), + sec = tonumber(s), + }) + if t < cutoff then + archived = archived + 1 + goto skip + end + end + end + table.insert(kept, task) + ::skip:: + end + store.replace_tasks(kept) + store.save() + vim.notify('Archived ' .. archived .. ' tasks.') + local bufnr = buffer.bufnr() + if bufnr and vim.api.nvim_buf_is_valid(bufnr) then + buffer.render(bufnr) + end +end + +function M.show_help() + local cfg = require('todo.config').get() + local dk = cfg.date_syntax or 'due' + local lines = { + 'todo.nvim keybindings', + '', + ' Toggle complete/uncomplete', + ' Switch category/priority view', + 'o / O Add new task line', + 'dd Delete task (on :w)', + 'p / P Paste (duplicates get new IDs)', + ':w Save all changes', + '', + ':Todo add Quick-add task', + ':Todo add Cat: Quick-add with category', + ':Todo sync Push to Google Calendar', + ':Todo archive [days] Purge old done tasks', + '', + 'Inline metadata (on new lines before :w):', + ' ' .. dk .. ':YYYY-MM-DD Set due date', + ' cat:Name Set category', + '', + 'Press q or to close', + } + local buf = vim.api.nvim_create_buf(false, true) + vim.api.nvim_buf_set_lines(buf, 0, -1, false, lines) + vim.bo[buf].modifiable = false + vim.bo[buf].bufhidden = 'wipe' + local width = 50 + local height = #lines + local win = vim.api.nvim_open_win(buf, true, { + relative = 'editor', + width = width, + height = height, + col = math.floor((vim.o.columns - width) / 2), + row = math.floor((vim.o.lines - height) / 2), + style = 'minimal', + border = 'rounded', + }) + vim.keymap.set('n', 'q', function() + vim.api.nvim_win_close(win, true) + end, { buffer = buf, silent = true }) + vim.keymap.set('n', '', function() + vim.api.nvim_win_close(win, true) + end, { buffer = buf, silent = true }) +end + +---@param args string +function M.command(args) + if not args or args == '' then + M.open() + return + end + local cmd, rest = args:match('^(%S+)%s*(.*)') + if cmd == 'add' then + M.add(rest) + elseif cmd == 'sync' then + M.sync() + elseif cmd == 'archive' then + local d = rest ~= '' and tonumber(rest) or nil + M.archive(d) + else + vim.notify('Unknown Todo subcommand: ' .. cmd, vim.log.levels.ERROR) + end +end + +return M diff --git a/plugin/todo.lua b/plugin/todo.lua new file mode 100644 index 0000000..d687923 --- /dev/null +++ b/plugin/todo.lua @@ -0,0 +1,39 @@ +if vim.g.loaded_todo then + return +end +vim.g.loaded_todo = true + +vim.api.nvim_create_user_command('Todo', function(opts) + require('todo').command(opts.args) +end, { + nargs = '*', + complete = function(arg_lead, cmd_line) + local subcmds = { 'add', 'sync', 'archive' } + if not cmd_line:match('^Todo%s+%S') then + return vim.tbl_filter(function(s) + return s:find(arg_lead, 1, true) == 1 + end, subcmds) + end + return {} + end, +}) + +vim.keymap.set('n', '(todo-open)', function() + require('todo').open() +end) + +vim.keymap.set('n', '(todo-toggle)', function() + require('todo').toggle_complete() +end) + +vim.keymap.set('n', '(todo-view)', function() + require('todo.buffer').toggle_view() +end) + +vim.keymap.set('n', '(todo-priority)', function() + require('todo').toggle_priority() +end) + +vim.keymap.set('n', '(todo-date)', function() + require('todo').prompt_date() +end) diff --git a/syntax/todo.vim b/syntax/todo.vim new file mode 100644 index 0000000..8f59b67 --- /dev/null +++ b/syntax/todo.vim @@ -0,0 +1,14 @@ +if exists('b:current_syntax') + finish +endif + +syntax match taskId /^\/\d\+\// conceal +syntax match taskHeader /^\S.*$/ contains=taskId +syntax match taskPriority /!\ze / contained +syntax match taskLine /^\/\d\+\/ .*$/ contains=taskId,taskPriority + +highlight default link taskHeader TodoHeader +highlight default link taskPriority TodoPriority +highlight default link taskLine Normal + +let b:current_syntax = 'task'