├── plugin └── dooing.vim ├── lua └── dooing │ ├── ui.lua │ ├── ui │ ├── constants.lua │ ├── init.lua │ ├── keymaps.lua │ ├── highlights.lua │ ├── utils.lua │ ├── window.lua │ ├── rendering.lua │ ├── due_notification.lua │ ├── calendar.lua │ ├── components.lua │ └── actions.lua │ ├── config.lua │ ├── server.lua │ ├── init.lua │ └── state.lua ├── .github └── FUNDING.yml ├── LICENSE ├── CONTRIBUTING.md ├── doc └── dooing.txt └── README.md /plugin/dooing.vim: -------------------------------------------------------------------------------- 1 | if exists('g:loaded_dooing') | finish | endif 2 | let g:loaded_dooing = 1 3 | 4 | lua require('dooing').setup() 5 | -------------------------------------------------------------------------------- /lua/dooing/ui.lua: -------------------------------------------------------------------------------- 1 | ---@diagnostic disable: undefined-global, param-type-mismatch, deprecated 2 | -- UI Module for Dooing Plugin - Compatibility wrapper 3 | -- This file maintains backward compatibility while delegating to the new modular structure 4 | 5 | -- Simply require and return the new modular UI 6 | return require("dooing.ui.init") 7 | -------------------------------------------------------------------------------- /lua/dooing/ui/constants.lua: -------------------------------------------------------------------------------- 1 | ---@diagnostic disable: undefined-global, param-type-mismatch, deprecated 2 | -- Constants and shared state for UI module 3 | 4 | local M = {} 5 | 6 | -- Namespace for highlighting 7 | M.ns_id = vim.api.nvim_create_namespace("dooing") 8 | 9 | -- Cache for highlight groups 10 | M.highlight_cache = {} 11 | 12 | -- Window and buffer IDs 13 | M.win_id = nil 14 | M.buf_id = nil 15 | M.help_win_id = nil 16 | M.help_buf_id = nil 17 | M.tag_win_id = nil 18 | M.tag_buf_id = nil 19 | M.search_win_id = nil 20 | M.search_buf_id = nil 21 | 22 | return M -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: atiladefreitas 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Átila de Freitas 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /lua/dooing/ui/init.lua: -------------------------------------------------------------------------------- 1 | ---@diagnostic disable: undefined-global, param-type-mismatch, deprecated 2 | -- UI Module for Dooing Plugin 3 | -- Main entry point that coordinates all UI functionality 4 | 5 | ---@class DoingUI 6 | ---@field toggle_todo_window function 7 | ---@field render_todos function 8 | ---@field close_window function 9 | ---@field new_todo function 10 | ---@field toggle_todo function 11 | ---@field delete_todo function 12 | ---@field delete_completed function 13 | local M = {} 14 | 15 | -- Load all modules 16 | local constants = require("dooing.ui.constants") 17 | local window = require("dooing.ui.window") 18 | local rendering = require("dooing.ui.rendering") 19 | local actions = require("dooing.ui.actions") 20 | local keymaps = require("dooing.ui.keymaps") 21 | 22 | -- Re-export utility functions that are used externally 23 | M.parse_time_estimation = require("dooing.ui.utils").parse_time_estimation 24 | 25 | -- Main public interface functions 26 | 27 | -- Toggles the main todo window visibility 28 | function M.toggle_todo_window() 29 | if constants.win_id and vim.api.nvim_win_is_valid(constants.win_id) then 30 | M.close_window() 31 | else 32 | window.create_window() 33 | keymaps.setup_keymaps() 34 | M.render_todos() 35 | end 36 | end 37 | 38 | -- Main function for todos rendering 39 | function M.render_todos() 40 | rendering.render_todos() 41 | end 42 | 43 | -- Closes all plugin windows 44 | function M.close_window() 45 | window.close_window() 46 | end 47 | 48 | -- Check if the window is currently open 49 | function M.is_window_open() 50 | return window.is_window_open() 51 | end 52 | 53 | -- Function to reload todos and refresh UI if window is open 54 | function M.reload_todos() 55 | actions.reload_todos() 56 | end 57 | 58 | -- Creates a new todo item 59 | function M.new_todo() 60 | actions.new_todo() 61 | end 62 | 63 | -- Toggles the completion status of the current todo 64 | function M.toggle_todo() 65 | actions.toggle_todo() 66 | end 67 | 68 | -- Deletes the current todo item 69 | function M.delete_todo() 70 | actions.delete_todo() 71 | end 72 | 73 | -- Deletes all completed todos 74 | function M.delete_completed() 75 | actions.delete_completed() 76 | end 77 | 78 | -- Delete all duplicated todos 79 | function M.remove_duplicates() 80 | actions.remove_duplicates() 81 | end 82 | 83 | -- Open global todo list 84 | function M.open_global_todo() 85 | require("dooing").open_global_todo() 86 | end 87 | 88 | -- Open project-specific todo list 89 | function M.open_project_todo() 90 | require("dooing").open_project_todo() 91 | end 92 | 93 | return M -------------------------------------------------------------------------------- /lua/dooing/ui/keymaps.lua: -------------------------------------------------------------------------------- 1 | ---@diagnostic disable: undefined-global, param-type-mismatch, deprecated 2 | -- Keymaps setup for UI module 3 | 4 | local M = {} 5 | local constants = require("dooing.ui.constants") 6 | local config = require("dooing.config") 7 | local actions = require("dooing.ui.actions") 8 | local components = require("dooing.ui.components") 9 | local state = require("dooing.state") 10 | local server = require("dooing.server") 11 | 12 | -- Setup keymaps for the main window 13 | function M.setup_keymaps() 14 | -- Helper function to setup keymap 15 | local function setup_keymap(key_option, callback) 16 | if config.options.keymaps[key_option] then 17 | vim.keymap.set("n", config.options.keymaps[key_option], callback, { buffer = constants.buf_id, nowait = true }) 18 | end 19 | end 20 | 21 | -- Server functionality 22 | setup_keymap("share_todos", function() 23 | server.start_qr_server() 24 | end) 25 | 26 | -- Main actions 27 | setup_keymap("new_todo", actions.new_todo) 28 | setup_keymap("create_nested_task", actions.new_nested_todo) 29 | setup_keymap("toggle_todo", actions.toggle_todo) 30 | setup_keymap("delete_todo", actions.delete_todo) 31 | setup_keymap("delete_completed", actions.delete_completed) 32 | setup_keymap("undo_delete", actions.undo_delete) 33 | setup_keymap("refresh_todos", actions.reload_todos) 34 | 35 | -- Window and view management 36 | setup_keymap("toggle_help", components.create_help_window) 37 | setup_keymap("toggle_tags", components.create_tag_window) 38 | setup_keymap("clear_filter", function() 39 | state.set_filter(nil) 40 | local rendering = require("dooing.ui.rendering") 41 | rendering.render_todos() 42 | end) 43 | 44 | -- Todo editing and management 45 | setup_keymap("edit_todo", actions.edit_todo) 46 | setup_keymap("edit_priorities", actions.edit_priorities) 47 | setup_keymap("add_due_date", actions.add_due_date) 48 | setup_keymap("remove_due_date", actions.remove_due_date) 49 | setup_keymap("add_time_estimation", actions.add_time_estimation) 50 | setup_keymap("remove_time_estimation", actions.remove_time_estimation) 51 | setup_keymap("open_todo_scratchpad", components.open_todo_scratchpad) 52 | 53 | -- Import/Export functionality 54 | setup_keymap("import_todos", actions.prompt_import) 55 | setup_keymap("export_todos", actions.prompt_export) 56 | setup_keymap("remove_duplicates", actions.remove_duplicates) 57 | setup_keymap("search_todos", components.create_search_window) 58 | 59 | -- Window close 60 | setup_keymap("close_window", function() 61 | local window = require("dooing.ui.window") 62 | window.close_window() 63 | end) 64 | end 65 | 66 | return M -------------------------------------------------------------------------------- /lua/dooing/ui/highlights.lua: -------------------------------------------------------------------------------- 1 | ---@diagnostic disable: undefined-global, param-type-mismatch, deprecated 2 | -- Highlights management for UI module 3 | 4 | local M = {} 5 | local constants = require("dooing.ui.constants") 6 | local config = require("dooing.config") 7 | 8 | -- Set up highlights 9 | function M.setup_highlights() 10 | -- Clear highlight cache 11 | constants.highlight_cache = {} 12 | 13 | -- Set up base highlights 14 | vim.api.nvim_set_hl(0, "DooingPending", { link = "Question", default = true }) 15 | vim.api.nvim_set_hl(0, "DooingDone", { link = "Comment", default = true }) 16 | vim.api.nvim_set_hl(0, "DooingHelpText", { link = "Directory", default = true }) 17 | vim.api.nvim_set_hl(0, "DooingTimestamp", { link = "Comment", default = true }) 18 | 19 | -- Cache the base highlight groups 20 | constants.highlight_cache.pending = "DooingPending" 21 | constants.highlight_cache.done = "DooingDone" 22 | constants.highlight_cache.help = "DooingHelpText" 23 | end 24 | 25 | -- Get highlight group for a set of priorities 26 | function M.get_priority_highlight(priorities) 27 | if not priorities or #priorities == 0 then 28 | return constants.highlight_cache.pending 29 | end 30 | 31 | -- Sort priority groups by number of members (descending) 32 | local sorted_groups = {} 33 | for name, group in pairs(config.options.priority_groups) do 34 | table.insert(sorted_groups, { name = name, group = group }) 35 | end 36 | table.sort(sorted_groups, function(a, b) 37 | return #a.group.members > #b.group.members 38 | end) 39 | 40 | -- Check priority groups from largest to smallest 41 | for _, group_data in ipairs(sorted_groups) do 42 | local group = group_data.group 43 | -- Check if all group members are present in the priorities 44 | local all_members_match = true 45 | for _, member in ipairs(group.members) do 46 | local found = false 47 | for _, priority in ipairs(priorities) do 48 | if priority == member then 49 | found = true 50 | break 51 | end 52 | end 53 | if not found then 54 | all_members_match = false 55 | break 56 | end 57 | end 58 | 59 | if all_members_match then 60 | -- Create cache key from group definition 61 | local cache_key = table.concat(group.members, "_") 62 | if constants.highlight_cache[cache_key] then 63 | return constants.highlight_cache[cache_key] 64 | end 65 | 66 | local hl_group = constants.highlight_cache.pending 67 | if group.color and type(group.color) == "string" and group.color:match("^#%x%x%x%x%x%x$") then 68 | local hl_name = "Dooing" .. group.color:gsub("#", "") 69 | vim.api.nvim_set_hl(0, hl_name, { fg = group.color }) 70 | hl_group = hl_name 71 | elseif group.hl_group then 72 | hl_group = group.hl_group 73 | end 74 | 75 | constants.highlight_cache[cache_key] = hl_group 76 | return hl_group 77 | end 78 | end 79 | 80 | return constants.highlight_cache.pending 81 | end 82 | 83 | return M -------------------------------------------------------------------------------- /lua/dooing/config.lua: -------------------------------------------------------------------------------- 1 | -- In config.lua, add PRIORITIES to the defaults 2 | local M = {} 3 | 4 | M.defaults = { 5 | window = { 6 | width = 55, 7 | height = 20, 8 | border = "rounded", 9 | position = "center", 10 | padding = { 11 | top = 1, 12 | bottom = 1, 13 | left = 2, 14 | right = 2, 15 | }, 16 | }, 17 | quick_keys = true, 18 | notes = { 19 | icon = "󱞁", 20 | }, 21 | timestamp = { 22 | enabled = true, 23 | }, 24 | formatting = { 25 | pending = { 26 | icon = "○", 27 | format = { "notes_icon", "icon", "text", "ect", "due_date", "relative_time" }, 28 | }, 29 | in_progress = { 30 | icon = "◐", 31 | format = { "notes_icon", "icon", "text", "ect", "due_date", "relative_time" }, 32 | }, 33 | done = { 34 | icon = "✓", 35 | format = { "notes_icon", "icon", "text", "ect", "due_date", "relative_time" }, 36 | }, 37 | }, 38 | priorities = { 39 | { 40 | name = "important", 41 | weight = 4, 42 | }, 43 | { 44 | name = "urgent", 45 | weight = 2, 46 | }, 47 | }, 48 | priority_groups = { 49 | high = { 50 | members = { "important", "urgent" }, 51 | color = nil, 52 | hl_group = "DiagnosticError", 53 | }, 54 | medium = { 55 | members = { "important" }, 56 | color = nil, 57 | hl_group = "DiagnosticWarn", 58 | }, 59 | low = { 60 | members = { "urgent" }, 61 | color = nil, 62 | hl_group = "DiagnosticInfo", 63 | }, 64 | }, 65 | hour_score_value = 1 / 8, 66 | done_sort_by_completed_time = false, 67 | nested_tasks = { 68 | enabled = true, 69 | indent = 2, 70 | retain_structure_on_complete = true, 71 | move_completed_to_end = true, 72 | }, 73 | due_notifications = { 74 | enabled = true, 75 | on_startup = true, 76 | on_open = true, 77 | }, 78 | save_path = vim.fn.stdpath("data") .. "/dooing_todos.json", 79 | per_project = { 80 | enabled = true, 81 | default_filename = "dooing.json", 82 | auto_gitignore = false, 83 | on_missing = "prompt", 84 | auto_open_project_todos = false, 85 | }, 86 | keymaps = { 87 | toggle_window = "td", 88 | open_project_todo = "tD", 89 | show_due_notification = "tN", 90 | new_todo = "i", 91 | create_nested_task = "tn", 92 | toggle_todo = "x", 93 | delete_todo = "d", 94 | delete_completed = "D", 95 | close_window = "q", 96 | undo_delete = "u", 97 | add_due_date = "H", 98 | remove_due_date = "r", 99 | toggle_help = "?", 100 | toggle_tags = "t", 101 | toggle_priority = "", 102 | clear_filter = "c", 103 | edit_todo = "e", 104 | edit_tag = "e", 105 | edit_priorities = "p", 106 | delete_tag = "d", 107 | search_todos = "/", 108 | add_time_estimation = "T", 109 | remove_time_estimation = "R", 110 | import_todos = "I", 111 | export_todos = "E", 112 | remove_duplicates = "D", 113 | open_todo_scratchpad = "p", 114 | refresh_todos = "f", 115 | }, 116 | calendar = { 117 | language = "en", 118 | icon = "", 119 | keymaps = { 120 | previous_day = "h", 121 | next_day = "l", 122 | previous_week = "k", 123 | next_week = "j", 124 | previous_month = "H", 125 | next_month = "L", 126 | select_day = "", 127 | close_calendar = "q", 128 | }, 129 | }, 130 | scratchpad = { 131 | syntax_highlight = "markdown", 132 | }, 133 | } 134 | 135 | M.options = {} 136 | 137 | function M.setup(opts) 138 | M.options = vim.tbl_deep_extend("force", M.defaults, opts or {}) 139 | end 140 | 141 | return M 142 | -------------------------------------------------------------------------------- /lua/dooing/server.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | 3 | local config = require("dooing.config") 4 | local uv = vim.loop 5 | local api = vim.api 6 | 7 | local function get_todos_file_path() 8 | return config.options.save_path or vim.fn.stdpath("data") .. "/dooing_todos.json" 9 | end 10 | 11 | local function get_local_ip() 12 | local socket = uv.new_udp() 13 | socket:connect("8.8.8.8", 80) 14 | local sockname = socket:getsockname() 15 | socket:close() 16 | 17 | if not sockname or not sockname.ip then 18 | vim.notify("Could not determine local IP address", vim.log.levels.ERROR) 19 | return "127.0.0.1" -- Fallback to localhost 20 | end 21 | return sockname.ip 22 | end 23 | 24 | local function debug_log(message) 25 | -- Utility function to log debugging messages to Neovim's command line 26 | vim.notify("[Dooing Debug] " .. message, vim.log.levels.DEBUG) 27 | end 28 | 29 | local function start_server(port, todos_json) 30 | local server = uv.new_tcp() 31 | 32 | local success, err = pcall(function() 33 | server:bind("0.0.0.0", port) -- Ensure binding to all interfaces 34 | debug_log("Server bound to 0.0.0.0 on port " .. port) 35 | end) 36 | 37 | if not success then 38 | vim.notify("Failed to bind server: " .. err, vim.log.levels.ERROR) 39 | return nil 40 | end 41 | 42 | server:listen(128, function() 43 | debug_log("Server listening for connections") 44 | local client = uv.new_tcp() 45 | server:accept(client) 46 | 47 | client:read_start(function(err, chunk) 48 | if err then 49 | debug_log("Error reading from client: " .. err) 50 | client:close() 51 | return 52 | end 53 | if chunk then 54 | local path = chunk:match("GET (%S+) HTTP") 55 | 56 | local response_content 57 | local content_type 58 | 59 | local local_ip = get_local_ip() 60 | if path == "/todos" then 61 | response_content = todos_json 62 | content_type = "application/json" 63 | else 64 | response_content = string.format( 65 | [[ 66 | 67 | 68 | 69 | 70 | Dooing QR Code 71 | 72 | 77 | 78 | 79 |
80 |
Server IP: %s:%d
81 | 88 | 89 | 90 | ]], 91 | local_ip, 92 | port, 93 | local_ip, 94 | port 95 | ) 96 | content_type = "text/html" 97 | end 98 | 99 | local response = table.concat({ 100 | "HTTP/1.1 200 OK", 101 | "Content-Type: " .. content_type, 102 | "Access-Control-Allow-Origin: *", 103 | "Connection: close", 104 | "", 105 | response_content, 106 | }, "\r\n") 107 | 108 | client:write(response) 109 | client:shutdown() 110 | client:close() 111 | end 112 | end) 113 | end) 114 | 115 | return server 116 | end 117 | 118 | function M.start_qr_server() 119 | local file = io.open(get_todos_file_path(), "r") 120 | if not file then 121 | vim.notify("Could not read todos file", vim.log.levels.ERROR) 122 | return 123 | end 124 | 125 | local todos_json = file:read("*all") 126 | file:close() 127 | 128 | local port = 7283 129 | local local_ip = get_local_ip() 130 | if local_ip == "127.0.0.1" then 131 | vim.notify("Warning: Server is only accessible on localhost", vim.log.levels.WARN) 132 | end 133 | 134 | local server = start_server(port, todos_json) 135 | 136 | if server then 137 | local buf = api.nvim_create_buf(false, true) 138 | local url = string.format("http://%s:%d", local_ip, port) 139 | 140 | api.nvim_buf_set_lines(buf, 0, -1, false, { 141 | "", 142 | " Server running at:", 143 | " " .. url, 144 | "", 145 | " Make sure your phone is on the same network", 146 | " [q] to close window and stop server", 147 | " [e] to exit and keep server running", 148 | "", 149 | }) 150 | 151 | local win = api.nvim_open_win(buf, true, { 152 | relative = "editor", 153 | width = 50, 154 | height = 8, 155 | row = math.floor((vim.o.lines - 8) / 2), 156 | col = math.floor((vim.o.columns - 50) / 2), 157 | style = "minimal", 158 | border = config.options.window.border, 159 | title = " Dooing Share ", 160 | title_pos = "center", 161 | }) 162 | 163 | vim.keymap.set("n", "q", function() 164 | api.nvim_win_close(win, true) 165 | server:close() 166 | debug_log("Server stopped by user") 167 | end, { buffer = buf, nowait = true }) 168 | 169 | vim.keymap.set("n", "e", function() 170 | api.nvim_win_close(win, true) 171 | debug_log("Window closed, server still running") 172 | vim.notify("Server still running at " .. url, vim.log.levels.INFO) 173 | end, { buffer = buf, nowait = true }) 174 | 175 | vim.defer_fn(function() 176 | if vim.fn.has("mac") == 1 then 177 | os.execute("open " .. url) 178 | elseif vim.fn.has("unix") == 1 then 179 | os.execute("xdg-open " .. url) 180 | elseif vim.fn.has("win32") == 1 then 181 | os.execute("start " .. url) 182 | end 183 | end, 100) 184 | else 185 | vim.notify("Failed to start server", vim.log.levels.ERROR) 186 | end 187 | end 188 | 189 | return M 190 | -------------------------------------------------------------------------------- /lua/dooing/ui/utils.lua: -------------------------------------------------------------------------------- 1 | ---@diagnostic disable: undefined-global, param-type-mismatch, deprecated 2 | -- Utility functions for UI module 3 | 4 | local M = {} 5 | local calendar = require("dooing.ui.calendar") 6 | local config = require("dooing.config") 7 | 8 | -- Helper function to format relative time 9 | function M.format_relative_time(timestamp) 10 | local now = os.time() 11 | local diff = now - timestamp 12 | 13 | -- Less than a minute 14 | if diff < 60 then 15 | return "just now" 16 | end 17 | -- Less than an hour 18 | if diff < 3600 then 19 | local mins = math.floor(diff / 60) 20 | return mins .. "m ago" 21 | end 22 | -- Less than a day 23 | if diff < 86400 then 24 | local hours = math.floor(diff / 3600) 25 | return hours .. "h ago" 26 | end 27 | -- Less than a week 28 | if diff < 604800 then 29 | local days = math.floor(diff / 86400) 30 | return days .. "d ago" 31 | end 32 | -- More than a week 33 | local weeks = math.floor(diff / 604800) 34 | return weeks .. "w ago" 35 | end 36 | 37 | -- Parse time estimation string (e.g., "2h", "1d", "0.5w") 38 | function M.parse_time_estimation(time_str) 39 | local number, unit = time_str:match("^(%d+%.?%d*)([mhdw])$") 40 | 41 | if not (number and unit) then 42 | return nil, 43 | "Invalid format. Use number followed by m (minutes), h (hours), d (days), or w (weeks). E.g., 30m, 2h, 1d, 0.5w" 44 | end 45 | 46 | local hours = tonumber(number) 47 | if not hours then 48 | return nil, "Invalid number format" 49 | end 50 | 51 | -- Convert to hours 52 | if unit == "m" then 53 | hours = hours / 60 54 | elseif unit == "d" then 55 | hours = hours * 24 56 | elseif unit == "w" then 57 | hours = hours * 24 * 7 58 | end 59 | 60 | return hours 61 | end 62 | 63 | -- Helper function to clean up priority selection resources 64 | function M.cleanup_priority_selection(select_buf, select_win, keymaps) 65 | -- Remove all keymaps 66 | for _, keymap in ipairs(keymaps) do 67 | pcall(vim.keymap.del, "n", keymap, { buffer = select_buf }) 68 | end 69 | 70 | -- Close window if it's still valid 71 | if select_win and vim.api.nvim_win_is_valid(select_win) then 72 | vim.api.nvim_win_close(select_win, true) 73 | end 74 | 75 | -- Delete buffer if it still exists 76 | if select_buf and vim.api.nvim_buf_is_valid(select_buf) then 77 | vim.api.nvim_buf_delete(select_buf, { force = true }) 78 | end 79 | end 80 | 81 | -- Helper function for formatting based on format config 82 | function M.render_todo(todo, formatting, lang, notes_icon, window_width) 83 | if not formatting or not formatting.pending or not formatting.done then 84 | error("Invalid 'formatting' configuration in config.lua") 85 | end 86 | 87 | local components = {} 88 | local timestamp = "" 89 | 90 | -- Get config formatting 91 | local format = todo.done and formatting.done.format or formatting.pending.format 92 | if not format then 93 | format = { "notes_icon", "icon", "text", "ect", "relative_time" } 94 | end 95 | 96 | -- Breakdown config format and get dynamic text based on other configs 97 | for _, part in ipairs(format) do 98 | if part == "icon" then 99 | local icon 100 | if todo.done then 101 | icon = formatting.done.icon 102 | elseif todo.in_progress then 103 | icon = formatting.in_progress.icon 104 | else 105 | icon = formatting.pending.icon 106 | end 107 | table.insert(components, icon) 108 | elseif part == "text" then 109 | table.insert(components, (todo.text:gsub("\n", " "))) 110 | elseif part == "notes_icon" then 111 | table.insert(components, notes_icon) 112 | elseif part == "relative_time" then 113 | if todo.created_at and config.options.timestamp and config.options.timestamp.enabled then 114 | timestamp = "@" .. M.format_relative_time(todo.created_at) 115 | end 116 | elseif part == "due_date" then 117 | -- Format due date if exists 118 | if todo.due_at then 119 | local date = os.date("*t", todo.due_at) 120 | local month = calendar.MONTH_NAMES[lang][date.month] 121 | local formatted_date 122 | if lang == "pt" or lang == "es" then 123 | formatted_date = string.format("%d de %s de %d", date.day, month, date.year) 124 | elseif lang == "fr" then 125 | formatted_date = string.format("%d %s %d", date.day, month, date.year) 126 | elseif lang == "de" or lang == "it" then 127 | formatted_date = string.format("%d %s %d", date.day, month, date.year) 128 | elseif lang == "jp" then 129 | formatted_date = string.format("%d年%s%d日", date.year, month, date.day) 130 | else 131 | formatted_date = string.format("%s %d, %d", month, date.day, date.year) 132 | end 133 | 134 | local current_time = os.time() 135 | local is_overdue = not todo.done and todo.due_at < current_time 136 | local due_date_str 137 | 138 | if config.options.calendar.icon ~= "" then 139 | if is_overdue then 140 | due_date_str = "[!" .. config.options.calendar.icon .. " " .. formatted_date .. "]" 141 | else 142 | due_date_str = "[" .. config.options.calendar.icon .. " " .. formatted_date .. "]" 143 | end 144 | else 145 | if is_overdue then 146 | due_date_str = "[!" .. formatted_date .. "]" 147 | else 148 | due_date_str = "[" .. formatted_date .. "]" 149 | end 150 | end 151 | table.insert(components, due_date_str) 152 | end 153 | elseif part == "priority" then 154 | local state = require("dooing.state") 155 | local score = state.get_priority_score(todo) 156 | table.insert(components, string.format("Priority: %d", score)) 157 | elseif part == "ect" then 158 | if todo.estimated_hours then 159 | local time_str 160 | if todo.estimated_hours >= 168 then -- more than a week 161 | local weeks = todo.estimated_hours / 168 162 | time_str = string.format("[≈ %gw]", weeks) 163 | elseif todo.estimated_hours >= 24 then -- more than a day 164 | local days = todo.estimated_hours / 24 165 | time_str = string.format("[≈ %gd]", days) 166 | elseif todo.estimated_hours >= 1 then -- more than an hour 167 | time_str = string.format("[≈ %gh]", todo.estimated_hours) 168 | else -- less than an hour 169 | time_str = string.format("[≈ %.0fm]", todo.estimated_hours * 60) 170 | end 171 | table.insert(components, time_str) 172 | end 173 | end 174 | end 175 | 176 | -- Join the main components (without timestamp) 177 | local main_content = table.concat(components, " ") 178 | 179 | -- If we have a timestamp and window width, position it at the right 180 | if timestamp ~= "" and window_width then 181 | local main_length = vim.fn.strdisplaywidth(main_content) 182 | local timestamp_length = vim.fn.strdisplaywidth(timestamp) 183 | local available_space = window_width - main_length - timestamp_length - 4 -- Account for padding and borders 184 | 185 | if available_space > 1 then 186 | -- Add spaces to push timestamp to the right 187 | main_content = main_content .. string.rep(" ", available_space) .. timestamp 188 | else 189 | -- If not enough space, just append normally 190 | main_content = main_content .. " " .. timestamp 191 | end 192 | elseif timestamp ~= "" then 193 | -- Fallback if no window width provided 194 | main_content = main_content .. " " .. timestamp 195 | end 196 | 197 | return main_content 198 | end 199 | 200 | return M 201 | 202 | -------------------------------------------------------------------------------- /lua/dooing/ui/window.lua: -------------------------------------------------------------------------------- 1 | ---@diagnostic disable: undefined-global, param-type-mismatch, deprecated 2 | -- Window management for UI module 3 | 4 | local M = {} 5 | local constants = require("dooing.ui.constants") 6 | local highlights = require("dooing.ui.highlights") 7 | local config = require("dooing.config") 8 | local state = require("dooing.state") 9 | 10 | -- Creates and configures the small keys window 11 | local function create_small_keys_window(main_win_pos) 12 | if not config.options.quick_keys then 13 | return nil 14 | end 15 | 16 | local keys = config.options.keymaps 17 | local small_buf = vim.api.nvim_create_buf(false, true) 18 | local width = config.options.window.width 19 | 20 | -- Define two separate line arrays for each column 21 | local lines_1 = { 22 | "", 23 | string.format(" %-6s - New todo", keys.new_todo), 24 | string.format(" %-6s - Nested todo", keys.create_nested_task), 25 | string.format(" %-6s - Toggle todo", keys.toggle_todo), 26 | string.format(" %-6s - Delete todo", keys.delete_todo), 27 | string.format(" %-6s - Undo delete", keys.undo_delete), 28 | string.format(" %-6s - Add due date", keys.add_due_date), 29 | "", 30 | } 31 | 32 | local lines_2 = { 33 | "", 34 | string.format(" %-6s - Add time", keys.add_time_estimation), 35 | string.format(" %-6s - Tags", keys.toggle_tags), 36 | string.format(" %-6s - Search", keys.search_todos), 37 | string.format(" %-6s - Import", keys.import_todos), 38 | string.format(" %-6s - Export", keys.export_todos), 39 | "", 40 | } 41 | 42 | -- Calculate middle point for even spacing 43 | local mid_point = math.floor(width / 2) 44 | local padding = 2 45 | 46 | -- Create combined lines with centered columns 47 | local lines = {} 48 | for i = 1, #lines_1 do 49 | local line1 = lines_1[i] .. string.rep(" ", mid_point - #lines_1[i] - padding) 50 | local line2 = lines_2[i] or "" 51 | lines[i] = line1 .. line2 52 | end 53 | 54 | vim.api.nvim_buf_set_lines(small_buf, 0, -1, false, lines) 55 | vim.api.nvim_buf_set_option(small_buf, "modifiable", false) 56 | vim.api.nvim_buf_set_option(small_buf, "buftype", "nofile") 57 | 58 | -- Position it under the main window 59 | local row = main_win_pos.row + main_win_pos.height + 1 60 | 61 | local small_win = vim.api.nvim_open_win(small_buf, false, { 62 | relative = "editor", 63 | row = row, 64 | col = main_win_pos.col, 65 | width = width, 66 | height = #lines, 67 | style = "minimal", 68 | border = config.options.window.border, 69 | focusable = false, 70 | zindex = 45, 71 | footer = " Quick Keys ", 72 | footer_pos = "center", 73 | }) 74 | 75 | -- Add highlights 76 | local ns = vim.api.nvim_create_namespace("dooing_small_keys") 77 | 78 | -- Highlight title 79 | vim.api.nvim_buf_add_highlight(small_buf, ns, "DooingQuickTitle", 0, 0, -1) 80 | 81 | -- Highlight each key and description in both columns 82 | for i = 1, #lines - 1 do 83 | if i > 0 then 84 | -- Left column 85 | vim.api.nvim_buf_add_highlight(small_buf, ns, "DooingQuickKey", i, 2, 3) -- Key 86 | vim.api.nvim_buf_add_highlight(small_buf, ns, "DooingQuickDesc", i, 5, mid_point - padding) -- Description 87 | 88 | -- Right column 89 | local right_key_start = mid_point 90 | vim.api.nvim_buf_add_highlight(small_buf, ns, "DooingQuickKey", i, right_key_start + 2, right_key_start + 3) -- Key 91 | vim.api.nvim_buf_add_highlight(small_buf, ns, "DooingQuickDesc", i, right_key_start + 5, -1) -- Description 92 | end 93 | end 94 | 95 | return small_win 96 | end 97 | 98 | -- Creates and configures the main todo window 99 | function M.create_window() 100 | local ui = vim.api.nvim_list_uis()[1] 101 | local width = config.options.window.width 102 | local height = config.options.window.height 103 | local position = config.options.window.position or "right" 104 | local padding = 2 -- padding from screen edges 105 | 106 | -- Calculate position based on config 107 | local col, row 108 | if position == "right" then 109 | col = ui.width - width - padding 110 | row = math.floor((ui.height - height) / 2) 111 | elseif position == "left" then 112 | col = padding 113 | row = math.floor((ui.height - height) / 2) 114 | elseif position == "top" then 115 | col = math.floor((ui.width - width) / 2) 116 | row = padding 117 | elseif position == "bottom" then 118 | col = math.floor((ui.width - width) / 2) 119 | row = ui.height - height - padding 120 | elseif position == "top-right" then 121 | col = ui.width - width - padding 122 | row = padding 123 | elseif position == "top-left" then 124 | col = padding 125 | row = padding 126 | elseif position == "bottom-right" then 127 | col = ui.width - width - padding 128 | row = ui.height - height - padding 129 | elseif position == "bottom-left" then 130 | col = padding 131 | row = ui.height - height - padding 132 | else -- center or invalid position 133 | col = math.floor((ui.width - width) / 2) 134 | row = math.floor((ui.height - height) / 2) 135 | end 136 | 137 | highlights.setup_highlights() 138 | 139 | constants.buf_id = vim.api.nvim_create_buf(false, true) 140 | 141 | constants.win_id = vim.api.nvim_open_win(constants.buf_id, true, { 142 | relative = "editor", 143 | row = row, 144 | col = col, 145 | width = width, 146 | height = height, 147 | style = "minimal", 148 | border = config.options.window.border, 149 | title = state.get_window_title(), 150 | title_pos = "center", 151 | footer = " [?] for help ", 152 | footer_pos = "center", 153 | }) 154 | 155 | -- Create small keys window with main window position 156 | local small_win = create_small_keys_window({ 157 | row = row, 158 | col = col, 159 | width = width, 160 | height = height, 161 | }) 162 | 163 | -- Close small window when main window is closed 164 | if small_win then 165 | vim.api.nvim_create_autocmd("WinClosed", { 166 | pattern = tostring(constants.win_id), 167 | callback = function() 168 | if vim.api.nvim_win_is_valid(small_win) then 169 | vim.api.nvim_win_close(small_win, true) 170 | end 171 | end, 172 | }) 173 | end 174 | 175 | vim.api.nvim_win_set_option(constants.win_id, "wrap", true) 176 | vim.api.nvim_win_set_option(constants.win_id, "linebreak", true) 177 | vim.api.nvim_win_set_option(constants.win_id, "breakindent", true) 178 | vim.api.nvim_win_set_option(constants.win_id, "breakindentopt", "shift:2") 179 | vim.api.nvim_win_set_option(constants.win_id, "showbreak", " ") 180 | 181 | -- Set up folding for nested tasks 182 | vim.api.nvim_win_set_option(constants.win_id, "foldmethod", "indent") 183 | vim.api.nvim_win_set_option(constants.win_id, "foldlevel", 99) -- Start with all folds open 184 | vim.api.nvim_win_set_option(constants.win_id, "foldenable", true) 185 | end 186 | 187 | -- Check if the window is currently open 188 | function M.is_window_open() 189 | return constants.win_id ~= nil and vim.api.nvim_win_is_valid(constants.win_id) 190 | end 191 | 192 | -- Closes all plugin windows 193 | function M.close_window() 194 | if constants.help_win_id and vim.api.nvim_win_is_valid(constants.help_win_id) then 195 | vim.api.nvim_win_close(constants.help_win_id, true) 196 | constants.help_win_id = nil 197 | constants.help_buf_id = nil 198 | end 199 | 200 | if constants.win_id and vim.api.nvim_win_is_valid(constants.win_id) then 201 | vim.api.nvim_win_close(constants.win_id, true) 202 | constants.win_id = nil 203 | constants.buf_id = nil 204 | end 205 | end 206 | 207 | -- Update window title without recreating the window 208 | function M.update_window_title() 209 | if constants.win_id and vim.api.nvim_win_is_valid(constants.win_id) then 210 | vim.api.nvim_win_set_config(constants.win_id, { 211 | title = state.get_window_title(), 212 | }) 213 | end 214 | end 215 | 216 | return M -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing to Dooing 2 | 3 | Thank you for your interest in contributing to Dooing! This document will guide you through the development process and help you understand the codebase structure. 4 | 5 | ## 🚀 Getting Started 6 | 7 | ### Prerequisites 8 | 9 | - Neovim >= 0.10.0 10 | - Git 11 | - Basic knowledge of Lua and Neovim plugin development 12 | 13 | ### Development Setup 14 | 15 | 1. Fork the repository on GitHub 16 | 2. Clone your fork locally: 17 | ```bash 18 | git clone https://github.com/yourusername/dooing.git 19 | cd dooing 20 | ``` 21 | 3. Create a symlink to your local development version: 22 | ```bash 23 | # Option 1: Using Lazy.nvim's dev option 24 | # Add `dev = true` to your plugin config in Lazy.nvim 25 | 26 | # Option 2: Manual symlink 27 | ln -s /path/to/your/dooing ~/.local/share/nvim/lazy/dooing 28 | ``` 29 | 30 | ## 📁 Project Structure 31 | 32 | Dooing uses a modular architecture where the main functionality is organized into focused modules: 33 | 34 | ``` 35 | dooing/ 36 | ├── lua/dooing/ 37 | │ ├── ui/ # UI-related modules (NEW STRUCTURE) 38 | │ │ ├── init.lua # Main UI interface and coordination 39 | │ │ ├── constants.lua # Shared constants and window IDs 40 | │ │ ├── highlights.lua # Highlight management and priority colors 41 | │ │ ├── utils.lua # Utility functions (time, parsing, rendering) 42 | │ │ ├── window.lua # Main window creation and management 43 | │ │ ├── rendering.lua # Todo rendering and highlighting logic 44 | │ │ ├── actions.lua # Todo CRUD operations 45 | │ │ ├── components.lua # UI components (help, tags, search, scratchpad) 46 | │ │ ├── keymaps.lua # Keymap setup and management 47 | │ │ └── calendar.lua # Calendar functionality 48 | │ ├── init.lua # Main plugin entry point 49 | │ ├── config.lua # Configuration management 50 | │ ├── state.lua # State management 51 | │ └── server.lua # Server functionality 52 | ├── plugin/dooing.vim # Vim plugin bootstrap 53 | └── doc/dooing.txt # Help documentation 54 | ``` 55 | 56 | ## 🏗️ Module Responsibilities 57 | 58 | ### Core Modules 59 | 60 | - **`init.lua`**: Main plugin entry point and setup 61 | - **`config.lua`**: Configuration management and defaults 62 | - **`state.lua`**: Global state management and data persistence 63 | - **`server.lua`**: Server-side functionality and data operations 64 | 65 | ### UI Modules 66 | 67 | - **`ui/init.lua`**: Main UI interface that coordinates all UI modules 68 | - **`ui/constants.lua`**: Shared constants, namespaces, and window IDs 69 | - **`ui/highlights.lua`**: Highlight group management and priority colors 70 | - **`ui/utils.lua`**: Utility functions for time formatting, parsing, and todo rendering 71 | - **`ui/window.lua`**: Main window creation, sizing, and management 72 | - **`ui/rendering.lua`**: Todo rendering logic and highlighting 73 | - **`ui/actions.lua`**: Todo CRUD operations (create, update, delete) 74 | - **`ui/components.lua`**: UI components (help window, tags window, search, scratchpad) 75 | - **`ui/keymaps.lua`**: Keymap setup and management 76 | - **`ui/calendar.lua`**: Calendar functionality for due dates 77 | 78 | ## 🔧 Development Guidelines 79 | 80 | ### Adding New Features 81 | 82 | 1. **Identify the appropriate module**: Determine which module should contain your new feature 83 | 2. **UI features**: Add to the appropriate `ui/` module 84 | 3. **Core functionality**: Add to the appropriate core module 85 | 4. **New UI components**: Consider adding to `ui/components.lua` or create a new module if substantial 86 | 87 | ### Modifying Existing Features 88 | 89 | 1. **Locate the feature**: Use the module responsibilities guide above 90 | 2. **Update related modules**: Ensure changes are reflected in all dependent modules 91 | 3. **Test thoroughly**: Verify the feature works across different scenarios 92 | 93 | ### Code Style 94 | 95 | - Follow standard Lua conventions 96 | - Use meaningful variable and function names 97 | - Add comments for complex logic 98 | - Keep functions focused and single-purpose 99 | - Use local variables and functions when possible 100 | 101 | ### Module Communication 102 | 103 | - **Constants**: Use `ui/constants.lua` for shared values 104 | - **State**: Access global state through `state.lua` 105 | - **Configuration**: Access config through `config.lua` 106 | - **Inter-module communication**: Use require() and return public APIs 107 | 108 | ## 🧪 Testing 109 | 110 | ### Manual Testing 111 | 112 | 1. Test your changes with different configurations 113 | 2. Verify keymaps work correctly 114 | 3. Test with various todo scenarios (empty lists, many todos, etc.) 115 | 4. Test window resizing and positioning 116 | 5. Verify persistence across Neovim sessions 117 | 118 | ### Testing Checklist 119 | 120 | - [ ] Basic functionality works 121 | - [ ] Keymaps are responsive 122 | - [ ] No Lua errors in `:messages` 123 | - [ ] Configuration changes are respected 124 | - [ ] UI components render correctly 125 | - [ ] Data persistence works 126 | - [ ] Performance is acceptable 127 | 128 | ## 📝 Documentation 129 | 130 | ### Code Documentation 131 | 132 | - Add comments for complex functions 133 | - Document public APIs 134 | - Update help text when adding new features 135 | 136 | ### User Documentation 137 | 138 | - Update `README.md` for new features 139 | - Update `doc/dooing.txt` for help documentation 140 | - Update keymaps tables when adding new keybindings 141 | 142 | ## 🔄 Submitting Changes 143 | 144 | ### Before Submitting 145 | 146 | 1. **Test thoroughly**: Follow the testing checklist above 147 | 2. **Update documentation**: Ensure all documentation is current 148 | 3. **Check for conflicts**: Rebase against the latest main branch 149 | 4. **Follow commit conventions**: Use clear, descriptive commit messages 150 | 151 | ### Pull Request Process 152 | 153 | 1. Create a feature branch from `main` 154 | 2. Make your changes following the guidelines above 155 | 3. Test your changes thoroughly 156 | 4. Update documentation as needed 157 | 5. Submit a pull request with: 158 | - Clear description of changes 159 | - Testing performed 160 | - Any breaking changes 161 | - Screenshots if UI changes are involved 162 | 163 | ### Pull Request Template 164 | 165 | ```markdown 166 | ## Description 167 | Brief description of changes made. 168 | 169 | ## Type of Change 170 | - [ ] Bug fix 171 | - [ ] New feature 172 | - [ ] Breaking change 173 | - [ ] Documentation update 174 | 175 | ## Testing 176 | - [ ] Manual testing performed 177 | - [ ] No Lua errors 178 | - [ ] All existing features work 179 | - [ ] New features work as expected 180 | 181 | ## Documentation 182 | - [ ] Updated README.md (if applicable) 183 | - [ ] Updated doc/dooing.txt (if applicable) 184 | - [ ] Updated keymaps documentation (if applicable) 185 | ``` 186 | 187 | ## 🐛 Bug Reports 188 | 189 | When reporting bugs, please include: 190 | 191 | 1. **Neovim version**: Output of `:version` 192 | 2. **Plugin version**: Git commit hash or version tag 193 | 3. **Configuration**: Your dooing configuration 194 | 4. **Steps to reproduce**: Clear steps to reproduce the issue 195 | 5. **Expected behavior**: What should happen 196 | 6. **Actual behavior**: What actually happens 197 | 7. **Error messages**: Any error messages from `:messages` 198 | 199 | ## 💡 Feature Requests 200 | 201 | When requesting features: 202 | 203 | 1. **Use case**: Describe why this feature would be useful 204 | 2. **Proposed solution**: How you envision the feature working 205 | 3. **Alternatives**: Any alternative solutions you've considered 206 | 4. **Implementation hints**: If you have ideas about implementation 207 | 208 | ## 🔍 Code Review 209 | 210 | All contributions go through code review. Reviewers will check for: 211 | 212 | - Code quality and style 213 | - Proper module organization 214 | - Testing completeness 215 | - Documentation updates 216 | - Backward compatibility 217 | 218 | ## 📞 Getting Help 219 | 220 | If you need help: 221 | 222 | 1. Check existing issues on GitHub 223 | 2. Read the documentation in `doc/dooing.txt` 224 | 3. Create a discussion on GitHub 225 | 4. Join the community discussions 226 | 227 | ## 🎯 Development Priorities 228 | 229 | Current focus areas: 230 | 231 | 1. **Performance improvements**: Optimizing rendering and state management 232 | 2. **UI enhancements**: Improving user experience and visual design 233 | 3. **Feature completeness**: Implementing planned features from the backlog 234 | 4. **Code quality**: Improving maintainability and test coverage 235 | 236 | Thank you for contributing to Dooing! Your efforts help make this plugin better for everyone. -------------------------------------------------------------------------------- /doc/dooing.txt: -------------------------------------------------------------------------------- 1 | *dooing.txt* A Minimalist Todo List Manager *dooing* 2 | 3 | ============================================================================== 4 | Table of Contents *dooing-contents* 5 | 6 | 1. Dooing |dooing-intro| 7 | - Features |dooing-features| 8 | - Requirements |dooing-requirements| 9 | - Installation |dooing-installation| 10 | - Configuration |dooing-configuration| 11 | - Usage |dooing-usage| 12 | - Commands |dooing-commands| 13 | - Keybindings |dooing-keybindings| 14 | 2. Advanced |dooing-advanced| 15 | - Calendar |dooing-calendar| 16 | - Priority System |dooing-priorities| 17 | - Import/Export |dooing-import| 18 | 3. Colors |dooing-colors| 19 | 20 | ============================================================================== 21 | 1. Dooing *dooing-intro* 22 | 23 | A minimalist todo list manager for Neovim, designed with simplicity and 24 | efficiency in mind. It provides a clean, distraction-free interface to manage 25 | your tasks directly within Neovim. 26 | 27 | FEATURES *dooing-features* 28 | 29 | - 📝 Manage todos in a clean floating window 30 | - 🏷️ Categorize tasks with #tags 31 | - ✅ Simple task management with clear visual feedback 32 | - 💾 Persistent storage of your todos 33 | - 🎨 Adapts to your Neovim colorscheme 34 | - 📅 Due dates with calendar integration 35 | - ⚡ Priority system with customizable weights 36 | - 🔍 Search and filter capabilities 37 | - 📤 Import/Export functionality 38 | - 🔄 Undo/Redo support for deleted todos 39 | 40 | REQUIREMENTS *dooing-requirements* 41 | 42 | - Neovim >= 0.9.0 43 | - A patched font for icons (recommended) 44 | - Optional: nvim-web-devicons for file icons 45 | 46 | INSTALLATION *dooing-installation* 47 | 48 | Using lazy.nvim: >lua 49 | { 50 | "atiladefreitas/dooing", 51 | config = function() 52 | require("dooing").setup({ 53 | -- your configuration here 54 | }) 55 | end, 56 | } 57 | < 58 | 59 | CONFIGURATION *dooing-configuration* 60 | 61 | The plugin can be configured by passing a table to the setup function: 62 | > 63 | require('dooing').setup({ 64 | window = { 65 | width = 55, -- Width of the todo window 66 | height = 20, -- Height of the todo window 67 | border = "rounded", -- Border style 68 | position = "right", -- Window position: "right", "left", "top", "bottom", "center", 69 | -- "top-right", "top-left", "bottom-right", "bottom-left" 70 | padding = { 71 | top = 1, 72 | bottom = 1, 73 | left = 2, 74 | right = 2, 75 | }, 76 | }, 77 | quick_keys = true, 78 | formatting = { 79 | pending = { 80 | icon = "○", 81 | format = { "icon", "text", "due_date", "ect" }, 82 | }, 83 | done = { 84 | icon = "✓", 85 | format = { "icon", "text", "due_date", "ect" }, 86 | }, 87 | }, 88 | priorities = { 89 | { 90 | name = "important", 91 | weight = 4, 92 | }, 93 | { 94 | name = "urgent", 95 | weight = 2, 96 | }, 97 | }, 98 | save_path = vim.fn.stdpath("data") .. "/dooing_todos.json", 99 | calendar = { 100 | language = "en", 101 | icon = "", 102 | }, 103 | }) 104 | < 105 | 106 | USAGE *dooing-usage* 107 | 108 | Basic Operations: 109 | 1. Open the todo window with `:Dooing` 110 | 2. Add new todos with `i` 111 | 3. Toggle completion with `x` 112 | 4. Delete todos with `d` 113 | 5. Add due dates with `H` 114 | 6. Add priorities during creation 115 | 7. Filter by tags with `t` 116 | 117 | COMMANDS *dooing-commands* 118 | 119 | *:Dooing* 120 | Main command to interact with the plugin. 121 | 122 | Arguments:~ 123 | none Opens/toggles the todo window 124 | add [text] Creates a new todo 125 | list Lists all todos in the command line 126 | set Modifies todo properties 127 | 128 | Examples: >vim 129 | :Dooing 130 | :Dooing add My new task #work 131 | :Dooing add -p important,urgent My priority task 132 | :Dooing list 133 | :Dooing set 1 priorities important,urgent 134 | < 135 | 136 | KEYBINDINGS *dooing-keybindings* 137 | 138 | Main Window~ 139 | td Toggle todo window 140 | i Add new todo 141 | x Toggle todo status 142 | d Delete current todo 143 | D Delete all completed todos 144 | q Close window 145 | H Add due date 146 | r Remove due date 147 | T Add time estimation 148 | R Remove time estimation 149 | ? Toggle help window 150 | t Toggle tags window 151 | c Clear active tag filter 152 | e Edit todo 153 | p Edit priorities 154 | u Undo deletion 155 | / Search todos 156 | I Import todos 157 | E Export todos 158 | D Remove duplicates 159 | Toggle priority 160 | p Open todo scratchpad 161 | f Refresh todo list 162 | s Share todos 163 | 164 | Tags Window~ 165 | e Edit tag 166 | d Delete tag 167 | Filter by tag 168 | q Close window 169 | 170 | Calendar Window~ 171 | h Previous day 172 | l Next day 173 | k Previous week 174 | j Next week 175 | H Previous month 176 | L Next month 177 | Select date 178 | q Close calendar 179 | 180 | ============================================================================== 181 | 2. Advanced *dooing-advanced* 182 | 183 | CALENDAR *dooing-calendar* 184 | 185 | The calendar feature supports multiple languages and provides a visual way to 186 | set due dates. Supported languages: en, pt, es, fr, de, it, jp 187 | 188 | Configure the calendar: >lua 189 | calendar = { 190 | language = "en", -- Calendar language 191 | icon = "", -- Icon for due dates 192 | keymaps = { 193 | previous_day = "h", 194 | next_day = "l", 195 | previous_week = "k", 196 | next_week = "j", 197 | previous_month = "H", 198 | next_month = "L", 199 | select_day = "", 200 | close_calendar = "q", 201 | }, 202 | } 203 | < 204 | 205 | PRIORITY SYSTEM *dooing-priorities* 206 | 207 | Configure priorities and their weights: >lua 208 | priorities = { 209 | { 210 | name = "important", 211 | weight = 4, 212 | }, 213 | { 214 | name = "urgent", 215 | weight = 2, 216 | }, 217 | }, 218 | priority_groups = { 219 | high = { 220 | members = { "important", "urgent" }, 221 | hl_group = "DiagnosticError", 222 | }, 223 | medium = { 224 | members = { "important" }, 225 | hl_group = "DiagnosticWarn", 226 | }, 227 | } 228 | < 229 | 230 | IMPORT/EXPORT *dooing-import* 231 | 232 | Import and export todos using JSON format: >vim 233 | :Dooing export ~/todos.json 234 | :Dooing import ~/todos.json 235 | < 236 | 237 | ============================================================================== 238 | 3. Colors *dooing-colors* 239 | 240 | Highlight Groups~ 241 | `DooingPending` Pending todo items 242 | `DooingDone` Completed todo items 243 | `DooingHelpText` Help window text 244 | `DooingQuickTitle` Quick keys title 245 | `DooingQuickKey` Quick keys keybindings 246 | `DooingQuickDesc` Quick keys descriptions 247 | 248 | vim:tw=78:ts=8:ft=help:norl: 249 | -------------------------------------------------------------------------------- /lua/dooing/ui/rendering.lua: -------------------------------------------------------------------------------- 1 | ---@diagnostic disable: undefined-global, param-type-mismatch, deprecated 2 | -- Rendering module for UI 3 | 4 | local M = {} 5 | local constants = require("dooing.ui.constants") 6 | local highlights = require("dooing.ui.highlights") 7 | local utils = require("dooing.ui.utils") 8 | local state = require("dooing.state") 9 | local config = require("dooing.config") 10 | local calendar = require("dooing.ui.calendar") 11 | 12 | -- Save the current fold state 13 | local function save_fold_state() 14 | if not constants.win_id or not vim.api.nvim_win_is_valid(constants.win_id) then 15 | return {} 16 | end 17 | 18 | local ok, result = pcall(function() 19 | local folds = {} 20 | local line_count = vim.api.nvim_buf_line_count(constants.buf_id) 21 | 22 | -- Save the current window to restore later 23 | local current_win = vim.api.nvim_get_current_win() 24 | vim.api.nvim_set_current_win(constants.win_id) 25 | 26 | -- Build a map of line numbers to todo indices 27 | local line_to_todo = {} 28 | local todo_line = state.active_filter and 3 or 1 -- Start after header 29 | 30 | for i, todo in ipairs(state.todos) do 31 | if not state.active_filter or todo.text:match("#" .. state.active_filter) then 32 | todo_line = todo_line + 1 33 | line_to_todo[todo_line] = i 34 | end 35 | end 36 | 37 | -- Check which todos have closed folds 38 | for line = 1, line_count do 39 | local fold_level = vim.fn.foldlevel(line) 40 | local is_closed = vim.fn.foldclosed(line) > 0 41 | local todo_index = line_to_todo[line] 42 | 43 | if fold_level > 0 and is_closed and todo_index then 44 | local todo = state.todos[todo_index] 45 | if todo and todo.id then 46 | folds[todo.id] = true 47 | end 48 | end 49 | end 50 | 51 | -- Restore the previous window 52 | if vim.api.nvim_win_is_valid(current_win) then 53 | vim.api.nvim_set_current_win(current_win) 54 | end 55 | 56 | return folds 57 | end) 58 | 59 | return ok and result or {} 60 | end 61 | 62 | -- Restore the fold state 63 | local function restore_fold_state(folds) 64 | if not constants.win_id or not vim.api.nvim_win_is_valid(constants.win_id) or not folds or vim.tbl_isempty(folds) then 65 | return 66 | end 67 | 68 | pcall(function() 69 | -- Save the current window to restore later 70 | local current_win = vim.api.nvim_get_current_win() 71 | vim.api.nvim_set_current_win(constants.win_id) 72 | 73 | -- First, open all folds 74 | vim.cmd("normal! zR") 75 | 76 | -- Build a map of todo IDs to line numbers 77 | local todo_to_line = {} 78 | local todo_line = state.active_filter and 3 or 1 -- Start after header 79 | 80 | for i, todo in ipairs(state.todos) do 81 | if not state.active_filter or todo.text:match("#" .. state.active_filter) then 82 | todo_line = todo_line + 1 83 | if todo.id then 84 | todo_to_line[todo.id] = todo_line 85 | end 86 | end 87 | end 88 | 89 | -- Close folds for todos that were previously folded 90 | for todo_id, _ in pairs(folds) do 91 | local line = todo_to_line[todo_id] 92 | if line and line <= vim.api.nvim_buf_line_count(constants.buf_id) then 93 | vim.api.nvim_win_set_cursor(constants.win_id, {line, 0}) 94 | if vim.fn.foldlevel(line) > 0 then 95 | vim.cmd("normal! zc") 96 | end 97 | end 98 | end 99 | 100 | -- Restore the previous window 101 | if vim.api.nvim_win_is_valid(current_win) then 102 | vim.api.nvim_set_current_win(current_win) 103 | end 104 | end) 105 | end 106 | 107 | -- Main function for todos rendering 108 | function M.render_todos() 109 | if not constants.buf_id then 110 | return 111 | end 112 | 113 | -- Save fold state and cursor position before rendering 114 | local fold_state = save_fold_state() 115 | local cursor_pos = nil 116 | if constants.win_id and vim.api.nvim_win_is_valid(constants.win_id) then 117 | local current_win = vim.api.nvim_get_current_win() 118 | vim.api.nvim_set_current_win(constants.win_id) 119 | cursor_pos = vim.api.nvim_win_get_cursor(constants.win_id) 120 | if vim.api.nvim_win_is_valid(current_win) then 121 | vim.api.nvim_set_current_win(current_win) 122 | end 123 | end 124 | 125 | -- Create the buffer 126 | vim.api.nvim_buf_set_option(constants.buf_id, "modifiable", true) 127 | vim.api.nvim_buf_clear_namespace(constants.buf_id, constants.ns_id, 0, -1) 128 | local lines = { "" } 129 | 130 | -- Sort todos and get config 131 | state.sort_todos() 132 | local lang = calendar and calendar.get_language() 133 | local formatting = config.options.formatting 134 | local done_icon = config.options.formatting.done.icon 135 | local pending_icon = config.options.formatting.pending.icon 136 | local notes_icon = config.options.notes.icon 137 | local tmp_notes_icon = "" 138 | local in_progress_icon = config.options.formatting.in_progress.icon 139 | 140 | -- Get window width for timestamp positioning 141 | local window_width = config.options.window.width 142 | 143 | -- Loop through all todos and render them using the format 144 | for _, todo in ipairs(state.todos) do 145 | if not state.active_filter or todo.text:match("#" .. state.active_filter) then 146 | -- use the appropriate format based on the todo's status and lang 147 | if todo.notes == nil or todo.notes == "" then 148 | tmp_notes_icon = "" 149 | else 150 | tmp_notes_icon = notes_icon 151 | end 152 | 153 | -- Calculate indentation based on depth 154 | local depth = todo.depth or 0 155 | local indent_size = config.options.nested_tasks and config.options.nested_tasks.indent or 2 156 | local base_indent = " " -- Base indentation for all todos 157 | local nested_indent = string.rep(" ", depth * indent_size) 158 | local total_indent = base_indent .. nested_indent 159 | 160 | -- Adjust window width for indentation 161 | local effective_width = window_width - vim.fn.strdisplaywidth(total_indent) 162 | 163 | local todo_text = utils.render_todo(todo, formatting, lang, tmp_notes_icon, effective_width) 164 | 165 | table.insert(lines, total_indent .. todo_text) 166 | end 167 | end 168 | 169 | if state.active_filter then 170 | table.insert(lines, 1, "") 171 | table.insert(lines, 1, " Filtered by: #" .. state.active_filter) 172 | end 173 | 174 | table.insert(lines, "") 175 | 176 | for i, line in ipairs(lines) do 177 | lines[i] = line:gsub("\n", " ") 178 | end 179 | vim.api.nvim_buf_set_lines(constants.buf_id, 0, -1, false, lines) 180 | 181 | -- Helper function to add highlight 182 | local function add_hl(line_nr, start_col, end_col, hl_group) 183 | vim.api.nvim_buf_add_highlight(constants.buf_id, constants.ns_id, hl_group, line_nr, start_col, end_col) 184 | end 185 | 186 | -- Helper function to find pattern and highlight 187 | local function highlight_pattern(line, line_nr, pattern, hl_group) 188 | local start_idx = line:find(pattern) 189 | if start_idx then 190 | add_hl(line_nr, start_idx - 1, -1, hl_group) 191 | end 192 | end 193 | 194 | for i, line in ipairs(lines) do 195 | local line_nr = i - 1 196 | if line:match("%s+[" .. done_icon .. pending_icon .. in_progress_icon .. "]") then 197 | local todo_index = i - (state.active_filter and 3 or 1) 198 | local todo = state.todos[todo_index] 199 | 200 | if todo then 201 | -- Base todo highlight 202 | if todo.done then 203 | add_hl(line_nr, 0, -1, "DooingDone") 204 | else 205 | -- Get highlight based on priorities 206 | local hl_group = highlights.get_priority_highlight(todo.priorities) 207 | add_hl(line_nr, 0, -1, hl_group) 208 | end 209 | 210 | -- Tags highlight 211 | for tag in line:gmatch("#(%w+)") do 212 | local tag_pattern = "#" .. tag 213 | local start_idx = line:find(tag_pattern) - 1 214 | add_hl(line_nr, start_idx, start_idx + #tag_pattern, "Type") 215 | end 216 | 217 | -- Due date highlights 218 | -- Match various due date formats: [icon date], [date], [@ date] 219 | local due_date_patterns = { 220 | "%[!.-%d+.-%d+.-%d+%]", -- Overdue date pattern with ! prefix 221 | "%[.-%d+.-%d+.-%d+%]", -- General date pattern with brackets 222 | "%[@ .-%]" -- @ format pattern 223 | } 224 | for _, pattern in ipairs(due_date_patterns) do 225 | local start_idx = line:find(pattern) 226 | if start_idx then 227 | local match = line:match(pattern) 228 | if match:find("^%[!") then 229 | -- Overdue date - highlight in red 230 | add_hl(line_nr, start_idx - 1, start_idx + #match - 1, "ErrorMsg") 231 | else 232 | -- Normal date - highlight in grey 233 | add_hl(line_nr, start_idx - 1, start_idx + #match - 1, "DooingTimestamp") 234 | end 235 | end 236 | end 237 | 238 | -- Time estimation highlight 239 | local ect_pattern = "%[≈ [%d%.]+[mhdw]%]" 240 | local start_idx = line:find(ect_pattern) 241 | if start_idx then 242 | local match = line:match(ect_pattern) 243 | add_hl(line_nr, start_idx - 1, start_idx + #match - 1, "DooingTimestamp") 244 | end 245 | 246 | -- Timestamp highlight (now positioned at the right) 247 | if config.options.timestamp and config.options.timestamp.enabled then 248 | local timestamp_pattern = "@[%w%s]+ago" 249 | local start_idx = line:find(timestamp_pattern) 250 | if start_idx then 251 | local match = line:match(timestamp_pattern) 252 | add_hl(line_nr, start_idx - 1, start_idx + #match - 1, "DooingTimestamp") 253 | end 254 | end 255 | end 256 | elseif line:match("Filtered by:") then 257 | add_hl(line_nr, 0, -1, "WarningMsg") 258 | end 259 | end 260 | 261 | vim.api.nvim_buf_set_option(constants.buf_id, "modifiable", false) 262 | 263 | -- Restore fold state and cursor position after rendering (with a small delay to ensure buffer is ready) 264 | vim.defer_fn(function() 265 | restore_fold_state(fold_state) 266 | -- Restore cursor position if it was saved 267 | if cursor_pos and constants.win_id and vim.api.nvim_win_is_valid(constants.win_id) then 268 | local current_win = vim.api.nvim_get_current_win() 269 | vim.api.nvim_set_current_win(constants.win_id) 270 | local line_count = vim.api.nvim_buf_line_count(constants.buf_id) 271 | -- Ensure cursor position is valid 272 | if cursor_pos[1] <= line_count then 273 | pcall(vim.api.nvim_win_set_cursor, constants.win_id, cursor_pos) 274 | end 275 | if vim.api.nvim_win_is_valid(current_win) then 276 | vim.api.nvim_set_current_win(current_win) 277 | end 278 | end 279 | end, 15) 280 | end 281 | 282 | return M 283 | -------------------------------------------------------------------------------- /lua/dooing/ui/due_notification.lua: -------------------------------------------------------------------------------- 1 | ---@diagnostic disable: undefined-global, param-type-mismatch, deprecated 2 | -- Due items notification window 3 | 4 | local M = {} 5 | local config = require("dooing.config") 6 | local state = require("dooing.state") 7 | local highlights = require("dooing.ui.highlights") 8 | local utils = require("dooing.ui.utils") 9 | local calendar = require("dooing.ui.calendar") 10 | 11 | -- Store window and buffer IDs 12 | local due_win_id = nil 13 | local due_buf_id = nil 14 | local ns_id = vim.api.nvim_create_namespace("dooing_due_notification") 15 | 16 | -- Get todos that are due today or overdue 17 | local function get_due_todos() 18 | local due_todos = {} 19 | local now = os.time() 20 | local today_start = os.time(os.date("*t", now)) 21 | local today_end = today_start + 86400 -- 24 hours 22 | 23 | for i, todo in ipairs(state.todos) do 24 | if todo.due_at and not todo.done then 25 | -- Include overdue and due today 26 | if todo.due_at <= today_end then 27 | table.insert(due_todos, { 28 | index = i, 29 | todo = todo, 30 | is_overdue = todo.due_at < today_start 31 | }) 32 | end 33 | end 34 | end 35 | 36 | -- Sort by due date (earliest first) 37 | table.sort(due_todos, function(a, b) 38 | return a.todo.due_at < b.todo.due_at 39 | end) 40 | 41 | return due_todos 42 | end 43 | 44 | -- Check if notification window is open 45 | function M.is_notification_open() 46 | return due_win_id ~= nil and vim.api.nvim_win_is_valid(due_win_id) 47 | end 48 | 49 | -- Close notification window 50 | function M.close_notification() 51 | if due_win_id and vim.api.nvim_win_is_valid(due_win_id) then 52 | vim.api.nvim_win_close(due_win_id, true) 53 | due_win_id = nil 54 | due_buf_id = nil 55 | end 56 | end 57 | 58 | -- Create and display due items notification 59 | function M.show_due_notification() 60 | -- If window is already open, close it 61 | if M.is_notification_open() then 62 | M.close_notification() 63 | return 64 | end 65 | 66 | -- Get due todos 67 | local due_todos = get_due_todos() 68 | 69 | -- If no due items, show brief notification 70 | if #due_todos == 0 then 71 | vim.notify("No items are due today", vim.log.levels.INFO, { title = "Dooing" }) 72 | return 73 | end 74 | 75 | -- Create buffer 76 | due_buf_id = vim.api.nvim_create_buf(false, true) 77 | vim.api.nvim_buf_set_option(due_buf_id, "modifiable", true) 78 | vim.api.nvim_buf_set_option(due_buf_id, "buftype", "nofile") 79 | 80 | -- Prepare lines 81 | local lines = { "" } 82 | local line_map = {} -- Map line numbers to todo indices 83 | 84 | -- Get formatting config 85 | local formatting = config.options.formatting 86 | local lang = calendar.get_language() 87 | local window_width = config.options.window.width 88 | local notes_icon = config.options.notes.icon 89 | 90 | -- Render each due todo 91 | for _, item in ipairs(due_todos) do 92 | local todo = item.todo 93 | local tmp_notes_icon = (todo.notes and todo.notes ~= "") and notes_icon or "" 94 | 95 | -- Calculate indentation for nested tasks 96 | local depth = todo.depth or 0 97 | local indent_size = config.options.nested_tasks and config.options.nested_tasks.indent or 2 98 | local base_indent = " " 99 | local nested_indent = string.rep(" ", depth * indent_size) 100 | local total_indent = base_indent .. nested_indent 101 | 102 | -- Adjust window width for indentation 103 | local effective_width = window_width - vim.fn.strdisplaywidth(total_indent) 104 | 105 | -- Render todo using existing utility 106 | local todo_text = utils.render_todo(todo, formatting, lang, tmp_notes_icon, effective_width) 107 | 108 | table.insert(lines, total_indent .. todo_text) 109 | line_map[#lines] = item.index 110 | end 111 | 112 | table.insert(lines, "") 113 | 114 | -- Set buffer content 115 | vim.api.nvim_buf_set_lines(due_buf_id, 0, -1, false, lines) 116 | 117 | -- Calculate window dimensions 118 | local ui = vim.api.nvim_list_uis()[1] 119 | local width = config.options.window.width 120 | local height = math.min(#lines + 2, math.floor(ui.height * 0.6)) 121 | local position = config.options.window.position or "center" 122 | local padding = 2 123 | 124 | -- Calculate position based on config 125 | local col, row 126 | if position == "right" then 127 | col = ui.width - width - padding 128 | row = math.floor((ui.height - height) / 2) 129 | elseif position == "left" then 130 | col = padding 131 | row = math.floor((ui.height - height) / 2) 132 | elseif position == "top" then 133 | col = math.floor((ui.width - width) / 2) 134 | row = padding 135 | elseif position == "bottom" then 136 | col = math.floor((ui.width - width) / 2) 137 | row = ui.height - height - padding 138 | elseif position == "top-right" then 139 | col = ui.width - width - padding 140 | row = padding 141 | elseif position == "top-left" then 142 | col = padding 143 | row = padding 144 | elseif position == "bottom-right" then 145 | col = ui.width - width - padding 146 | row = ui.height - height - padding 147 | elseif position == "bottom-left" then 148 | col = padding 149 | row = ui.height - height - padding 150 | else -- center 151 | col = math.floor((ui.width - width) / 2) 152 | row = math.floor((ui.height - height) / 2) 153 | end 154 | 155 | -- Setup highlights 156 | highlights.setup_highlights() 157 | 158 | -- Create window 159 | local overdue_count = 0 160 | for _, item in ipairs(due_todos) do 161 | if item.is_overdue then 162 | overdue_count = overdue_count + 1 163 | end 164 | end 165 | 166 | local title = string.format(" %d item%s due today ", #due_todos, #due_todos == 1 and "" or "s") 167 | if overdue_count > 0 then 168 | title = string.format(" %d overdue, %d due today ", overdue_count, #due_todos - overdue_count) 169 | end 170 | 171 | due_win_id = vim.api.nvim_open_win(due_buf_id, true, { 172 | relative = "editor", 173 | row = row, 174 | col = col, 175 | width = width, 176 | height = height, 177 | style = "minimal", 178 | border = config.options.window.border, 179 | title = title, 180 | title_pos = "center", 181 | footer = " [q] close | [] jump to todo ", 182 | footer_pos = "center", 183 | zindex = 50, 184 | }) 185 | 186 | vim.api.nvim_win_set_option(due_win_id, "wrap", true) 187 | vim.api.nvim_win_set_option(due_win_id, "linebreak", true) 188 | vim.api.nvim_win_set_option(due_win_id, "breakindent", true) 189 | 190 | -- Apply highlights 191 | local done_icon = formatting.done.icon 192 | local pending_icon = formatting.pending.icon 193 | local in_progress_icon = formatting.in_progress.icon 194 | 195 | for i, line in ipairs(lines) do 196 | local line_nr = i - 1 197 | if line:match("%s+[" .. done_icon .. pending_icon .. in_progress_icon .. "]") then 198 | local todo_index = line_map[i] 199 | if todo_index then 200 | local todo = state.todos[todo_index] 201 | 202 | if todo then 203 | -- Base todo highlight 204 | if todo.done then 205 | vim.api.nvim_buf_add_highlight(due_buf_id, ns_id, "DooingDone", line_nr, 0, -1) 206 | else 207 | local hl_group = highlights.get_priority_highlight(todo.priorities) 208 | vim.api.nvim_buf_add_highlight(due_buf_id, ns_id, hl_group, line_nr, 0, -1) 209 | end 210 | 211 | -- Tags highlight 212 | for tag in line:gmatch("#(%w+)") do 213 | local tag_pattern = "#" .. tag 214 | local start_idx = line:find(tag_pattern) 215 | if start_idx then 216 | vim.api.nvim_buf_add_highlight(due_buf_id, ns_id, "Type", line_nr, start_idx - 1, start_idx + #tag_pattern - 1) 217 | end 218 | end 219 | 220 | -- Due date highlights 221 | local due_date_patterns = { 222 | "%[!.-%d+.-%d+.-%d+%]", -- Overdue with ! 223 | "%[.-%d+.-%d+.-%d+%]", -- Normal date 224 | } 225 | for _, pattern in ipairs(due_date_patterns) do 226 | local start_idx = line:find(pattern) 227 | if start_idx then 228 | local match = line:match(pattern) 229 | if match:find("^%[!") then 230 | vim.api.nvim_buf_add_highlight(due_buf_id, ns_id, "ErrorMsg", line_nr, start_idx - 1, start_idx + #match - 1) 231 | else 232 | vim.api.nvim_buf_add_highlight(due_buf_id, ns_id, "DooingTimestamp", line_nr, start_idx - 1, start_idx + #match - 1) 233 | end 234 | end 235 | end 236 | 237 | -- Time estimation highlight 238 | local ect_pattern = "%[≈ [%d%.]+[mhdw]%]" 239 | local start_idx = line:find(ect_pattern) 240 | if start_idx then 241 | local match = line:match(ect_pattern) 242 | vim.api.nvim_buf_add_highlight(due_buf_id, ns_id, "DooingTimestamp", line_nr, start_idx - 1, start_idx + #match - 1) 243 | end 244 | 245 | -- Timestamp highlight 246 | if config.options.timestamp and config.options.timestamp.enabled then 247 | local timestamp_pattern = "@[%w%s]+ago" 248 | local start_idx = line:find(timestamp_pattern) 249 | if start_idx then 250 | local match = line:match(timestamp_pattern) 251 | vim.api.nvim_buf_add_highlight(due_buf_id, ns_id, "DooingTimestamp", line_nr, start_idx - 1, start_idx + #match - 1) 252 | end 253 | end 254 | end 255 | end 256 | end 257 | end 258 | 259 | vim.api.nvim_buf_set_option(due_buf_id, "modifiable", false) 260 | 261 | -- Keymaps 262 | local opts = { buffer = due_buf_id, nowait = true } 263 | 264 | -- Close window 265 | vim.keymap.set("n", "q", function() 266 | M.close_notification() 267 | end, opts) 268 | 269 | vim.keymap.set("n", "", function() 270 | M.close_notification() 271 | end, opts) 272 | 273 | -- Jump to todo in main window 274 | vim.keymap.set("n", "", function() 275 | local cursor = vim.api.nvim_win_get_cursor(due_win_id) 276 | local todo_index = line_map[cursor[1]] 277 | 278 | if todo_index then 279 | M.close_notification() 280 | 281 | -- Open the appropriate todo window (global or project) 282 | local ui_module = require("dooing.ui") 283 | if not ui_module.is_window_open() then 284 | -- If no window is open, open the appropriate one based on current context 285 | if state.current_context == "global" then 286 | require("dooing").open_global_todo() 287 | else 288 | require("dooing").open_project_todo() 289 | end 290 | end 291 | 292 | -- Jump to the todo 293 | local constants = require("dooing.ui.constants") 294 | if constants.win_id and vim.api.nvim_win_is_valid(constants.win_id) then 295 | vim.api.nvim_set_current_win(constants.win_id) 296 | 297 | -- Calculate line position (accounting for filter header if present) 298 | local line_pos = todo_index + (state.active_filter and 2 or 0) 299 | vim.api.nvim_win_set_cursor(constants.win_id, { line_pos, 0 }) 300 | end 301 | end 302 | end, opts) 303 | 304 | -- Auto-close on buffer leave 305 | vim.api.nvim_create_autocmd("BufLeave", { 306 | buffer = due_buf_id, 307 | callback = function() 308 | M.close_notification() 309 | end, 310 | once = true, 311 | }) 312 | end 313 | 314 | return M 315 | -------------------------------------------------------------------------------- /lua/dooing/ui/calendar.lua: -------------------------------------------------------------------------------- 1 | local Cal = {} 2 | 3 | local config = require("dooing.config") 4 | 5 | -- Month names in different languages 6 | Cal.MONTH_NAMES = { 7 | en = { 8 | "January", 9 | "February", 10 | "March", 11 | "April", 12 | "May", 13 | "June", 14 | "July", 15 | "August", 16 | "September", 17 | "October", 18 | "November", 19 | "December", 20 | }, 21 | pt = { 22 | "Janeiro", 23 | "Fevereiro", 24 | "Março", 25 | "Abril", 26 | "Maio", 27 | "Junho", 28 | "Julho", 29 | "Agosto", 30 | "Setembro", 31 | "Outubro", 32 | "Novembro", 33 | "Dezembro", 34 | }, 35 | es = { 36 | "Enero", 37 | "Febrero", 38 | "Marzo", 39 | "Abril", 40 | "Mayo", 41 | "Junio", 42 | "Julio", 43 | "Agosto", 44 | "Septiembre", 45 | "Octubre", 46 | "Noviembre", 47 | "Diciembre", 48 | }, 49 | fr = { 50 | "Janvier", 51 | "Février", 52 | "Mars", 53 | "Avril", 54 | "Mai", 55 | "Juin", 56 | "Juillet", 57 | "Août", 58 | "Septembre", 59 | "Octobre", 60 | "Novembre", 61 | "Décembre", 62 | }, 63 | de = { 64 | "Januar", 65 | "Februar", 66 | "März", 67 | "April", 68 | "Mai", 69 | "Juni", 70 | "Juli", 71 | "August", 72 | "September", 73 | "Oktober", 74 | "November", 75 | "Dezember", 76 | }, 77 | it = { 78 | "Gennaio", 79 | "Febbraio", 80 | "Marzo", 81 | "Aprile", 82 | "Maggio", 83 | "Giugno", 84 | "Luglio", 85 | "Agosto", 86 | "Settembre", 87 | "Ottobre", 88 | "Novembre", 89 | "Dicembre", 90 | }, 91 | jp = { 92 | "一月", 93 | "二月", 94 | "三月", 95 | "四月", 96 | "ご月", 97 | "六月", 98 | "七月", 99 | "八月", 100 | "九月", 101 | "十月", 102 | "十一月", 103 | "十二月", 104 | }, 105 | } 106 | 107 | -- Helper function get calendar language to use on ui 108 | function Cal.get_language() 109 | local calendar_opts = config.options.calendar or {} 110 | return calendar_opts.language or "en" 111 | end 112 | 113 | ---Calculates the number of days in a given month and year 114 | local function get_days_in_month(month, year) 115 | local days_in_month = { 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 } 116 | if month == 2 then 117 | if (year % 4 == 0 and year % 100 ~= 0) or year % 400 == 0 then 118 | return 29 119 | end 120 | end 121 | return days_in_month[month] 122 | end 123 | 124 | ---Calculates the day of week for a given date 125 | local function get_day_of_week(year, month, day) 126 | local t = { 0, 3, 2, 5, 0, 3, 5, 1, 4, 6, 2, 4 } 127 | if month < 3 then 128 | year = year - 1 129 | end 130 | return (year + math.floor(year / 4) - math.floor(year / 100) + math.floor(year / 400) + t[month] + day) % 7 131 | end 132 | 133 | ---Sets up the calendar highlight groups 134 | local function setup_highlights() 135 | vim.api.nvim_set_hl(0, "CalendarHeader", { link = "Title" }) 136 | vim.api.nvim_set_hl(0, "CalendarWeekday", { link = "Normal" }) 137 | vim.api.nvim_set_hl(0, "CalendarWeekend", { link = "Special" }) 138 | vim.api.nvim_set_hl(0, "CalendarCurrentDay", { link = "Visual" }) 139 | vim.api.nvim_set_hl(0, "CalendarSelectedDay", { link = "Search" }) 140 | vim.api.nvim_set_hl(0, "CalendarToday", { link = "Directory" }) 141 | end 142 | 143 | function Cal.create(callback, opts) 144 | opts = opts or {} 145 | local calendar_opts = config.options.calendar 146 | local language = calendar_opts.language or "en" 147 | 148 | local cal = { 149 | year = os.date("*t").year, 150 | month = os.date("*t").month, 151 | day = os.date("*t").day, 152 | today = { 153 | year = os.date("*t").year, 154 | month = os.date("*t").month, 155 | day = os.date("*t").day, 156 | }, 157 | win_id = nil, 158 | buf_id = nil, 159 | ns_id = vim.api.nvim_create_namespace("calendar_highlights"), 160 | } 161 | 162 | setup_highlights() 163 | 164 | cal.buf_id = vim.api.nvim_create_buf(false, true) 165 | vim.api.nvim_buf_set_option(cal.buf_id, "bufhidden", "wipe") 166 | 167 | local width = 26 168 | local height = 9 169 | local parent_win = vim.api.nvim_get_current_win() 170 | local cursor_pos = vim.api.nvim_win_get_cursor(parent_win) 171 | 172 | cal.win_id = vim.api.nvim_open_win(cal.buf_id, true, { 173 | relative = "win", 174 | win = parent_win, 175 | row = cursor_pos[1], 176 | col = 3, 177 | width = width, 178 | height = height, 179 | style = "minimal", 180 | border = "single", 181 | title = string.format(" %s %d ", Cal.MONTH_NAMES[language][cal.month], cal.year), 182 | title_pos = "center", 183 | }) 184 | 185 | --- Gets the cursor position for a given day 186 | local function get_cursor_position(day) 187 | if not day then 188 | return nil 189 | end 190 | 191 | local first_day = get_day_of_week(cal.year, cal.month, 1) 192 | local days_in_month = get_days_in_month(cal.month, cal.year) 193 | 194 | if day < 1 or day > days_in_month then 195 | return nil 196 | end 197 | 198 | local pos = first_day + day - 1 199 | local row = math.floor(pos / 7) + 3 200 | local col = (pos % 7) * 3 + 2 201 | 202 | return row, col 203 | end 204 | 205 | --- Gets the day from a given cursor position 206 | local function get_day_from_position(row, col) 207 | if row <= 2 then 208 | return nil 209 | end 210 | 211 | col = col - 2 212 | local col_index = math.floor(col / 3) 213 | local first_day = get_day_of_week(cal.year, cal.month, 1) 214 | local day = (row - 3) * 7 + col_index - first_day + 1 215 | 216 | if day < 1 or day > get_days_in_month(cal.month, cal.year) then 217 | return nil 218 | end 219 | 220 | return day 221 | end 222 | 223 | --- Renders the calendar 224 | local function render() 225 | local lines = {} 226 | 227 | table.insert(lines, "") 228 | table.insert(lines, " Su Mo Tu We Th Fr Sa ") 229 | 230 | local first_day = get_day_of_week(cal.year, cal.month, 1) 231 | local days_in_month = get_days_in_month(cal.month, cal.year) 232 | local day_count = 1 233 | 234 | while day_count <= days_in_month do 235 | local current_line = " " 236 | for i = 0, 6 do 237 | if day_count == 1 and i < first_day then 238 | current_line = current_line .. " " 239 | elseif day_count <= days_in_month then 240 | current_line = current_line .. string.format("%2d ", day_count) 241 | day_count = day_count + 1 242 | else 243 | current_line = current_line .. " " 244 | end 245 | end 246 | current_line = current_line .. " " 247 | table.insert(lines, current_line) 248 | end 249 | 250 | while #lines < height do 251 | table.insert(lines, string.rep(" ", width)) 252 | end 253 | 254 | vim.api.nvim_buf_set_lines(cal.buf_id, 0, -1, false, lines) 255 | vim.api.nvim_buf_clear_namespace(cal.buf_id, cal.ns_id, 0, -1) 256 | vim.api.nvim_buf_add_highlight(cal.buf_id, cal.ns_id, "CalendarHeader", 1, 0, -1) 257 | 258 | for row = 3, #lines do 259 | local line = lines[row] 260 | for col = 0, 6 do 261 | local start_col = col * 3 + 2 262 | local day_str = line:sub(start_col + 1, start_col + 2) 263 | local day_num = tonumber(day_str) 264 | 265 | if day_num then 266 | if col == 0 or col == 6 then 267 | vim.api.nvim_buf_add_highlight( 268 | cal.buf_id, 269 | cal.ns_id, 270 | "CalendarWeekend", 271 | row - 1, 272 | start_col, 273 | start_col + 2 274 | ) 275 | else 276 | vim.api.nvim_buf_add_highlight( 277 | cal.buf_id, 278 | cal.ns_id, 279 | "CalendarWeekday", 280 | row - 1, 281 | start_col, 282 | start_col + 2 283 | ) 284 | end 285 | 286 | if day_num == cal.day then 287 | vim.api.nvim_buf_add_highlight( 288 | cal.buf_id, 289 | cal.ns_id, 290 | "CalendarCurrentDay", 291 | row - 1, 292 | start_col, 293 | start_col + 2 294 | ) 295 | end 296 | 297 | if cal.year == cal.today.year and cal.month == cal.today.month and day_num == cal.today.day then 298 | vim.api.nvim_buf_add_highlight( 299 | cal.buf_id, 300 | cal.ns_id, 301 | "CalendarToday", 302 | row - 1, 303 | start_col, 304 | start_col + 2 305 | ) 306 | end 307 | end 308 | end 309 | end 310 | 311 | vim.api.nvim_win_set_config(cal.win_id, { 312 | title = string.format(" %s %d ", Cal.MONTH_NAMES[language][cal.month], cal.year), 313 | title_pos = "center", 314 | }) 315 | 316 | local row, col = get_cursor_position(cal.day) 317 | if row and col then 318 | vim.api.nvim_win_set_cursor(cal.win_id, { row, col }) 319 | end 320 | end 321 | 322 | -- Navigates to a different day 323 | local function navigate_day(direction) 324 | local current_pos = vim.api.nvim_win_get_cursor(cal.win_id) 325 | local current_day = get_day_from_position(current_pos[1], current_pos[2]) 326 | 327 | if not current_day then 328 | cal.day = 1 329 | else 330 | cal.day = current_day 331 | 332 | if direction == "left" then 333 | cal.day = cal.day - 1 334 | elseif direction == "right" then 335 | cal.day = cal.day + 1 336 | elseif direction == "up" then 337 | cal.day = cal.day - 7 338 | elseif direction == "down" then 339 | cal.day = cal.day + 7 340 | end 341 | end 342 | 343 | local days_in_month = get_days_in_month(cal.month, cal.year) 344 | if cal.day < 1 then 345 | cal.month = cal.month - 1 346 | if cal.month < 1 then 347 | cal.month = 12 348 | cal.year = cal.year - 1 349 | end 350 | cal.day = get_days_in_month(cal.month, cal.year) 351 | render() 352 | elseif cal.day > days_in_month then 353 | cal.month = cal.month + 1 354 | if cal.month > 12 then 355 | cal.month = 1 356 | cal.year = cal.year + 1 357 | end 358 | cal.day = 1 359 | render() 360 | else 361 | local row, col = get_cursor_position(cal.day) 362 | if row and col then 363 | vim.api.nvim_win_set_cursor(cal.win_id, { row, col }) 364 | end 365 | render() 366 | end 367 | end 368 | 369 | -- Set up keymaps 370 | local keymaps = calendar_opts.keymaps 371 | local keyopts = { buffer = cal.buf_id, nowait = true } 372 | 373 | if keymaps.previous_day then 374 | vim.keymap.set("n", keymaps.previous_day, function() 375 | navigate_day("left") 376 | end, keyopts) 377 | end 378 | if keymaps.next_day then 379 | vim.keymap.set("n", keymaps.next_day, function() 380 | navigate_day("right") 381 | end, keyopts) 382 | end 383 | if keymaps.previous_week then 384 | vim.keymap.set("n", keymaps.previous_week, function() 385 | navigate_day("up") 386 | end, keyopts) 387 | end 388 | if keymaps.next_week then 389 | vim.keymap.set("n", keymaps.next_week, function() 390 | navigate_day("down") 391 | end, keyopts) 392 | end 393 | 394 | if keymaps.previous_month then 395 | vim.keymap.set("n", keymaps.previous_month, function() 396 | cal.month = cal.month - 1 397 | if cal.month < 1 then 398 | cal.month = 12 399 | cal.year = cal.year - 1 400 | end 401 | render() 402 | end, keyopts) 403 | end 404 | 405 | if keymaps.next_month then 406 | vim.keymap.set("n", keymaps.next_month, function() 407 | cal.month = cal.month + 1 408 | if cal.month > 12 then 409 | cal.month = 1 410 | cal.year = cal.year + 1 411 | end 412 | render() 413 | end, keyopts) 414 | end 415 | 416 | if keymaps.select_day then 417 | vim.keymap.set("n", keymaps.select_day, function() 418 | local cursor = vim.api.nvim_win_get_cursor(cal.win_id) 419 | local day = get_day_from_position(cursor[1], cursor[2]) 420 | 421 | if day then 422 | local date_str = string.format("%02d/%02d/%04d", cal.month, day, cal.year) 423 | vim.api.nvim_win_close(cal.win_id, true) 424 | callback(date_str) 425 | end 426 | end, keyopts) 427 | end 428 | 429 | if keymaps.close_calendar then 430 | vim.keymap.set("n", keymaps.close_calendar, function() 431 | vim.api.nvim_win_close(cal.win_id, true) 432 | end, keyopts) 433 | end 434 | 435 | render() 436 | 437 | return cal 438 | end 439 | 440 | return Cal 441 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Dooing 2 | 3 | Dooing is a minimalist todo list manager for Neovim, designed with simplicity and efficiency in mind. It provides a clean, distraction-free interface to manage your tasks directly within Neovim. Perfect for users who want to keep track of their todos without leaving their editor. 4 | 5 | ![dooing demo](https://github.com/user-attachments/assets/ffb921d6-6dd8-4a01-8aaa-f2440891b22e) 6 | 7 | 8 | 9 | ## 🚀 Features 10 | 11 | - 📝 Manage todos in a clean **floating window** 12 | - 🏷️ Categorize tasks with **#tags** 13 | - ✅ Simple task management with clear visual feedback 14 | - 💾 **Persistent storage** of your todos 15 | - 🎨 Adapts to your Neovim **colorscheme** 16 | - 🛠️ Compatible with **Lazy.nvim** for effortless installation 17 | - ⏰ **Relative timestamps** showing when todos were created 18 | - 📂 **Per-project todos** with git integration 19 | - 🔔 **Smart due date notifications** on startup and when opening todos 20 | - 📅 **Due items window** to view and jump to all due tasks 21 | 22 | --- 23 | 24 | ## 📦 Installation 25 | 26 | ### Prerequisites 27 | 28 | - Neovim `>= 0.10.0` 29 | - [Lazy.nvim](https://github.com/folke/lazy.nvim) as your plugin manager 30 | 31 | ### Using Lazy.nvim 32 | 33 | ```lua 34 | return { 35 | "atiladefreitas/dooing", 36 | config = function() 37 | require("dooing").setup({ 38 | -- your custom config here (optional) 39 | }) 40 | end, 41 | } 42 | ``` 43 | 44 | Run the following commands in Neovim to install Dooing: 45 | 46 | ```vim 47 | :Lazy sync 48 | ``` 49 | 50 | ### Default Configuration 51 | Dooing comes with sensible defaults that you can override: 52 | ```lua 53 | { 54 | -- Core settings 55 | save_path = vim.fn.stdpath("data") .. "/dooing_todos.json", 56 | 57 | -- Timestamp settings 58 | timestamp = { 59 | enabled = true, -- Show relative timestamps (e.g., @5m ago, @2h ago) 60 | }, 61 | 62 | -- Window settings 63 | window = { 64 | width = 55, -- Width of the floating window 65 | height = 20, -- Height of the floating window 66 | border = 'rounded', -- Border style: 'single', 'double', 'rounded', 'solid' 67 | position = 'center', -- Window position: 'right', 'left', 'top', 'bottom', 'center', 68 | -- 'top-right', 'top-left', 'bottom-right', 'bottom-left' 69 | padding = { 70 | top = 1, 71 | bottom = 1, 72 | left = 2, 73 | right = 2, 74 | }, 75 | }, 76 | 77 | -- To-do formatting 78 | formatting = { 79 | pending = { 80 | icon = "○", 81 | format = { "icon", "notes_icon", "text", "due_date", "ect" }, 82 | }, 83 | in_progress = { 84 | icon = "◐", 85 | format = { "icon", "text", "due_date", "ect" }, 86 | }, 87 | done = { 88 | icon = "✓", 89 | format = { "icon", "notes_icon", "text", "due_date", "ect" }, 90 | }, 91 | }, 92 | 93 | quick_keys = true, -- Quick keys window 94 | 95 | notes = { 96 | icon = "📓", 97 | }, 98 | 99 | scratchpad = { 100 | syntax_highlight = "markdown", 101 | }, 102 | 103 | -- Per-project todos 104 | per_project = { 105 | enabled = true, -- Enable per-project todos 106 | default_filename = "dooing.json", -- Default filename for project todos 107 | auto_gitignore = false, -- Auto-add to .gitignore (true/false/"prompt") 108 | on_missing = "prompt", -- What to do when file missing ("prompt"/"auto_create") 109 | auto_open_project_todos = false, -- Auto-open project todos on startup if they exist 110 | }, 111 | 112 | -- Nested tasks 113 | nested_tasks = { 114 | enabled = true, -- Enable nested subtasks 115 | indent = 2, -- Spaces per nesting level 116 | retain_structure_on_complete = true, -- Keep nested structure when completing tasks 117 | move_completed_to_end = true, -- Move completed nested tasks to end of parent group 118 | }, 119 | 120 | -- Due date notifications 121 | due_notifications = { 122 | enabled = true, -- Enable due date notifications 123 | on_startup = true, -- Show notification on Neovim startup 124 | on_open = true, -- Show notification when opening todos 125 | }, 126 | 127 | -- Keymaps 128 | keymaps = { 129 | toggle_window = "td", -- Toggle global todos 130 | open_project_todo = "tD", -- Toggle project-specific todos 131 | show_due_notification = "tN", -- Show due items window 132 | new_todo = "i", 133 | create_nested_task = "tn", -- Create nested subtask under current todo 134 | toggle_todo = "x", 135 | delete_todo = "d", 136 | delete_completed = "D", 137 | close_window = "q", 138 | undo_delete = "u", 139 | add_due_date = "H", 140 | remove_due_date = "r", 141 | toggle_help = "?", 142 | toggle_tags = "t", 143 | toggle_priority = "", 144 | clear_filter = "c", 145 | edit_todo = "e", 146 | edit_tag = "e", 147 | edit_priorities = "p", 148 | delete_tag = "d", 149 | search_todos = "/", 150 | add_time_estimation = "T", 151 | remove_time_estimation = "R", 152 | import_todos = "I", 153 | export_todos = "E", 154 | remove_duplicates = "D", 155 | open_todo_scratchpad = "p", 156 | refresh_todos = "f", 157 | }, 158 | 159 | calendar = { 160 | language = "en", 161 | icon = "", 162 | keymaps = { 163 | previous_day = "h", 164 | next_day = "l", 165 | previous_week = "k", 166 | next_week = "j", 167 | previous_month = "H", 168 | next_month = "L", 169 | select_day = "", 170 | close_calendar = "q", 171 | }, 172 | }, 173 | 174 | -- Priority settings 175 | priorities = { 176 | { 177 | name = "important", 178 | weight = 4, 179 | }, 180 | { 181 | name = "urgent", 182 | weight = 2, 183 | }, 184 | }, 185 | priority_groups = { 186 | high = { 187 | members = { "important", "urgent" }, 188 | color = nil, 189 | hl_group = "DiagnosticError", 190 | }, 191 | medium = { 192 | members = { "important" }, 193 | color = nil, 194 | hl_group = "DiagnosticWarn", 195 | }, 196 | low = { 197 | members = { "urgent" }, 198 | color = nil, 199 | hl_group = "DiagnosticInfo", 200 | }, 201 | }, 202 | hour_score_value = 1/8, 203 | done_sort_by_completed_time = false, 204 | } 205 | ``` 206 | 207 | ## 📂 Per-Project Todos 208 | 209 | Dooing supports project-specific todo lists that are separate from your global todos. This feature integrates with git repositories to automatically detect project boundaries. 210 | 211 | ### Usage 212 | 213 | - **`td`** - Open/toggle **global** todos (works everywhere) 214 | - **`tD`** - Open/toggle **project-specific** todos (only in git repositories) 215 | 216 | ### How it works 217 | 218 | 1. When you press `tD` in a git repository, Dooing looks for a todo file in the project root 219 | 2. If the file exists, it loads those todos 220 | 3. If not, it prompts you to create one with an optional custom filename 221 | 4. Project todos are completely separate from global todos 222 | 5. Switch between them anytime using the different keymaps 223 | 224 | ### Configuration Options 225 | 226 | ```lua 227 | per_project = { 228 | enabled = true, -- Enable/disable per-project todos 229 | default_filename = "dooing.json", -- Default filename for new project todo files 230 | auto_gitignore = false, -- Automatically add to .gitignore 231 | -- Set to true for auto-add, "prompt" to ask, false to skip 232 | on_missing = "prompt", -- What to do when project todo file doesn't exist 233 | -- "prompt" = ask user, "auto_create" = create automatically 234 | auto_open_project_todos = false, -- Auto-open project todos on startup if they exist 235 | -- Opens window automatically when entering a git project with todos 236 | } 237 | ``` 238 | 239 | --- 240 | 241 | ## Commands 242 | 243 | Dooing provides several commands for task management: 244 | 245 | - `:Dooing` - Opens the global todo window 246 | - `:DooingLocal` - Opens the project-specific todo window (git repositories only) 247 | - `:DooingDue` - Opens a window showing all due and overdue items 248 | - `:Dooing add [text]` - Adds a new task 249 | - `-p, --priorities [list]` - Comma-separated list of priorities (e.g. "important,urgent") 250 | - `:Dooing list` - Lists all todos with their indices and metadata 251 | - `:Dooing set [index] [field] [value]` - Modifies todo properties 252 | - `priorities` - Set/update priorities (use "nil" to clear) 253 | - `ect` - Set estimated completion time (e.g. "30m", "2h", "1d", "0.5w") 254 | 255 | --- 256 | 257 | ## 🔑 Keybindings 258 | 259 | Dooing comes with intuitive keybindings: 260 | 261 | #### Main Window 262 | | Key | Action | 263 | |--------------|------------------------------| 264 | | `td` | Toggle global todo window | 265 | | `tD` | Toggle project todo window | 266 | | `tN` | Show due items window | 267 | | `i` | Add new todo | 268 | | `tn` | Create nested subtask | 269 | | `x` | Toggle todo status | 270 | | `d` | Delete current todo | 271 | | `D` | Delete all completed todos | 272 | | `q` | Close window | 273 | | `H` | Add due date | 274 | | `r` | Remove due date | 275 | | `T` | Add time estimation | 276 | | `R` | Remove time estimation | 277 | | `?` | Toggle help window | 278 | | `t` | Toggle tags window | 279 | | `c` | Clear active tag filter | 280 | | `e` | Edit todo | 281 | | `p` | Edit priorities | 282 | | `u` | Undo delete | 283 | | `/` | Search todos | 284 | | `I` | Import todos | 285 | | `E` | Export todos | 286 | | `D` | Remove duplicates | 287 | | `p` | Open todo scratchpad | 288 | | `f` | Refresh todo list | 289 | 290 | #### Tags Window 291 | | Key | Action | 292 | |--------|--------------| 293 | | `e` | Edit tag | 294 | | `d` | Delete tag | 295 | | `` | Filter by tag| 296 | | `q` | Close window | 297 | 298 | #### Calendar Window 299 | | Key | Action | 300 | |--------|-------------------| 301 | | `h` | Previous day | 302 | | `l` | Next day | 303 | | `k` | Previous week | 304 | | `j` | Next week | 305 | | `H` | Previous month | 306 | | `L` | Next month | 307 | | `` | Select date | 308 | | `q` | Close calendar | 309 | 310 | --- 311 | 312 | ## 🔔 Due Date Notifications 313 | 314 | Dooing includes smart notifications to keep you aware of upcoming and overdue tasks. 315 | 316 | ### How it works 317 | 318 | - **On Startup**: Automatically checks for due items when Neovim starts 319 | - Shows project todos if you're in a git repository with a todo file 320 | - Falls back to global todos otherwise 321 | - **When Opening Todos**: Shows notification when you open global or project todos 322 | - **Due Items Window**: Press `tN` to see all due items in an interactive window 323 | - Navigate through items 324 | - Press `` to jump to a specific todo 325 | 326 | ### Notification Format 327 | 328 | Notifications appear in red and show: 329 | ``` 330 | 3 items due 331 | ``` 332 | 333 | ### Configuration 334 | 335 | ```lua 336 | due_notifications = { 337 | enabled = true, -- Master switch for due notifications 338 | on_startup = true, -- Show notification when Neovim starts 339 | on_open = true, -- Show notification when opening todo windows 340 | } 341 | ``` 342 | 343 | To disable notifications entirely: 344 | ```lua 345 | due_notifications = { 346 | enabled = false, 347 | } 348 | ``` 349 | 350 | --- 351 | 352 | ## 📥 Backlog 353 | 354 | Planned features and improvements for future versions of Dooing: 355 | 356 | #### Core Features 357 | 358 | - [x] Due Dates Support 359 | - [x] Priority Levels 360 | - [x] Todo Filtering by Tags 361 | - [x] Todo Search 362 | - [x] Todo List Per Project 363 | 364 | #### UI Enhancements 365 | 366 | - [x] Tag Highlighting 367 | - [ ] Custom Todo Colors 368 | - [ ] Todo Categories View 369 | 370 | #### Quality of Life 371 | 372 | - [x] Multiple Todo Lists 373 | - [X] Import/Export Features 374 | 375 | --- 376 | 377 | ## 📝 License 378 | 379 | This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for details. 380 | 381 | --- 382 | 383 | ## 🔖 Versioning 384 | 385 | We use [Semantic Versioning](https://semver.org/) for versioning. For the available versions, see the [tags on this repository](https://github.com/atiladefreitas/dooing/tags). 386 | 387 | --- 388 | 389 | ## 🤝 Contributing 390 | 391 | Contributions are welcome! If you'd like to improve Dooing, please read our [Contributing Guide](CONTRIBUTING.md) for detailed information about: 392 | 393 | - Setting up the development environment 394 | - Understanding the modular codebase structure 395 | - Adding new features and fixing bugs 396 | - Testing and documentation guidelines 397 | - Submitting pull requests 398 | 399 | For quick contributions: 400 | - Submit an issue for bugs or feature requests 401 | - Create a pull request with your enhancements 402 | 403 | --- 404 | 405 | ## 🌟 Acknowledgments 406 | 407 | Dooing was built with the Neovim community in mind. Special thanks to all the developers who contribute to the Neovim ecosystem and plugins like [Lazy.nvim](https://github.com/folke/lazy.nvim). 408 | 409 | --- 410 | 411 | ## All my plugins 412 | | Repository | Description | Stars | 413 | |------------|-------------|-------| 414 | | [LazyClip](https://github.com/atiladefreitas/lazyclip) | A Simple Clipboard Manager | ![Stars](https://img.shields.io/github/stars/atiladefreitas/lazyclip?style=social) | 415 | | [Dooing](https://github.com/atiladefreitas/dooing) | A Minimalist Todo List Manager | ![Stars](https://img.shields.io/github/stars/atiladefreitas/dooing?style=social) | 416 | | [TinyUnit](https://github.com/atiladefreitas/tinyunit) | A Practical CSS Unit Converter | ![Stars](https://img.shields.io/github/stars/atiladefreitas/tinyunit?style=social) | 417 | 418 | --- 419 | 420 | ## 📬 Contact 421 | 422 | If you have any questions, feel free to reach out: 423 | - [LinkedIn](https://linkedin.com/in/atilafreitas) 424 | - Email: [contact@atiladefreitas.com](mailto:contact@atiladefreitas.com) 425 | -------------------------------------------------------------------------------- /lua/dooing/init.lua: -------------------------------------------------------------------------------- 1 | local M = {} 2 | local config = require("dooing.config") 3 | local ui = require("dooing.ui") 4 | local state = require("dooing.state") 5 | 6 | function M.setup(opts) 7 | config.setup(opts) 8 | state.load_todos() 9 | 10 | -- Check for due items on startup and notify 11 | -- Skip if auto_open_project_todos is enabled (it will show notification when opening) 12 | if config.options.due_notifications and config.options.due_notifications.enabled and config.options.due_notifications.on_startup then 13 | local should_auto_open = config.options.per_project.enabled and 14 | config.options.per_project.auto_open_project_todos and 15 | state.has_project_todos() 16 | 17 | if not should_auto_open then 18 | vim.defer_fn(function() 19 | -- Check if we have project todos 20 | local has_project = config.options.per_project.enabled and state.has_project_todos() 21 | 22 | if has_project then 23 | -- Load project todos to check for due items 24 | local project_path = state.get_project_todo_path() 25 | local current_save = state.current_save_path 26 | state.load_todos_from_path(project_path) 27 | state.show_due_notification() 28 | -- Restore previous state 29 | if current_save then 30 | state.load_todos_from_path(current_save) 31 | else 32 | state.load_todos() 33 | end 34 | else 35 | -- Check global todos for due items 36 | state.show_due_notification() 37 | end 38 | end, 200) -- Small delay after Neovim startup 39 | end 40 | end 41 | 42 | -- Auto-open project todos if configured 43 | if config.options.per_project.enabled and config.options.per_project.auto_open_project_todos then 44 | -- Defer to avoid startup conflicts 45 | vim.defer_fn(function() 46 | -- Only auto-open if no todo window is already open 47 | if not ui.is_window_open() and state.has_project_todos() then 48 | -- Load project todos 49 | local project_path = state.get_project_todo_path() 50 | state.load_todos_from_path(project_path) 51 | 52 | -- Open the todo window 53 | ui.toggle_todo_window() 54 | 55 | -- Notify user 56 | local git_root = state.get_git_root() 57 | local project_name = vim.fn.fnamemodify(git_root, ":t") 58 | vim.notify("Auto-opened project todos for: " .. project_name, vim.log.levels.INFO, { title = "Dooing" }) 59 | end 60 | end, 100) -- Small delay to ensure everything is loaded 61 | end 62 | 63 | vim.api.nvim_create_user_command("Dooing", function(opts) 64 | local args = vim.split(opts.args, "%s+", { trimempty = true }) 65 | if #args == 0 then 66 | M.open_global_todo() 67 | return 68 | end 69 | 70 | local command = args[1] 71 | table.remove(args, 1) -- Remove command 72 | 73 | if command == "add" then 74 | -- Parse priorities if -p or --priorities flag is present 75 | local priorities = nil 76 | local todo_text = "" 77 | 78 | local i = 1 79 | while i <= #args do 80 | if args[i] == "-p" or args[i] == "--priorities" then 81 | if i + 1 <= #args then 82 | -- Get and validate priorities 83 | local priority_str = args[i + 1] 84 | local priority_list = vim.split(priority_str, ",", { trimempty = true }) 85 | 86 | -- Validate each priority against config 87 | local valid_priorities = {} 88 | local invalid_priorities = {} 89 | for _, p in ipairs(priority_list) do 90 | local is_valid = false 91 | for _, config_p in ipairs(config.options.priorities) do 92 | if p == config_p.name then 93 | is_valid = true 94 | table.insert(valid_priorities, p) 95 | break 96 | end 97 | end 98 | if not is_valid then 99 | table.insert(invalid_priorities, p) 100 | end 101 | end 102 | 103 | -- Notify about invalid priorities 104 | if #invalid_priorities > 0 then 105 | vim.notify( 106 | "Invalid priorities: " .. table.concat(invalid_priorities, ", "), 107 | vim.log.levels.WARN, 108 | { 109 | title = "Dooing", 110 | } 111 | ) 112 | end 113 | 114 | if #valid_priorities > 0 then 115 | priorities = valid_priorities 116 | end 117 | 118 | i = i + 2 -- Skip priority flag and value 119 | else 120 | vim.notify("Missing priority value after " .. args[i], vim.log.levels.ERROR, { 121 | title = "Dooing", 122 | }) 123 | return 124 | end 125 | else 126 | todo_text = todo_text .. " " .. args[i] 127 | i = i + 1 128 | end 129 | end 130 | 131 | todo_text = vim.trim(todo_text) 132 | if todo_text ~= "" then 133 | state.add_todo(todo_text, priorities) 134 | local msg = "Todo created: " .. todo_text 135 | if priorities then 136 | msg = msg .. " (priorities: " .. table.concat(priorities, ", ") .. ")" 137 | end 138 | vim.notify(msg, vim.log.levels.INFO, { 139 | title = "Dooing", 140 | }) 141 | end 142 | elseif command == "list" then 143 | -- Print all todos with their indices 144 | for i, todo in ipairs(state.todos) do 145 | local status = todo.done and "✓" or "○" 146 | 147 | -- Build metadata string 148 | local metadata = {} 149 | if todo.priorities and #todo.priorities > 0 then 150 | table.insert(metadata, "priorities: " .. table.concat(todo.priorities, ", ")) 151 | end 152 | if todo.due_date then 153 | table.insert(metadata, "due: " .. todo.due_date) 154 | end 155 | if todo.estimated_hours then 156 | table.insert(metadata, string.format("estimate: %.1fh", todo.estimated_hours)) 157 | end 158 | 159 | local score = state.get_priority_score(todo) 160 | table.insert(metadata, string.format("score: %.1f", score)) 161 | 162 | local metadata_text = #metadata > 0 and " (" .. table.concat(metadata, ", ") .. ")" or "" 163 | 164 | vim.notify(string.format("%d. %s %s%s", i, status, todo.text, metadata_text), vim.log.levels.INFO) 165 | end 166 | elseif command == "set" then 167 | if #args < 3 then 168 | vim.notify("Usage: Dooing set ", vim.log.levels.ERROR) 169 | return 170 | end 171 | 172 | local index = tonumber(args[1]) 173 | if not index or not state.todos[index] then 174 | vim.notify("Invalid todo index: " .. args[1], vim.log.levels.ERROR) 175 | return 176 | end 177 | 178 | local field = args[2] 179 | local value = args[3] 180 | 181 | if field == "priorities" then 182 | -- Handle priority setting 183 | if value == "nil" then 184 | -- Clear priorities 185 | state.todos[index].priorities = nil 186 | state.save_todos() 187 | vim.notify("Cleared priorities for todo " .. index, vim.log.levels.INFO) 188 | else 189 | -- Handle priority setting 190 | local priority_list = vim.split(value, ",", { trimempty = true }) 191 | local valid_priorities = {} 192 | local invalid_priorities = {} 193 | 194 | for _, p in ipairs(priority_list) do 195 | local is_valid = false 196 | for _, config_p in ipairs(config.options.priorities) do 197 | if p == config_p.name then 198 | is_valid = true 199 | table.insert(valid_priorities, p) 200 | break 201 | end 202 | end 203 | if not is_valid then 204 | table.insert(invalid_priorities, p) 205 | end 206 | end 207 | 208 | if #invalid_priorities > 0 then 209 | vim.notify( 210 | "Invalid priorities: " .. table.concat(invalid_priorities, ", "), 211 | vim.log.levels.WARN 212 | ) 213 | end 214 | 215 | if #valid_priorities > 0 then 216 | state.todos[index].priorities = valid_priorities 217 | state.save_todos() 218 | vim.notify("Updated priorities for todo " .. index, vim.log.levels.INFO) 219 | end 220 | end 221 | elseif field == "ect" then 222 | -- Handle estimated completion time setting 223 | local hours, err = ui.parse_time_estimation(value) 224 | if hours then 225 | state.todos[index].estimated_hours = hours 226 | state.save_todos() 227 | vim.notify("Updated estimated completion time for todo " .. index, vim.log.levels.INFO) 228 | else 229 | vim.notify("Error: " .. (err or "Invalid time format"), vim.log.levels.ERROR) 230 | end 231 | else 232 | vim.notify("Unknown field: " .. field, vim.log.levels.ERROR) 233 | end 234 | else 235 | M.open_global_todo() 236 | end 237 | end, { 238 | desc = "Toggle Global Todo List window or add new todo", 239 | nargs = "*", 240 | complete = function(arglead, cmdline, cursorpos) 241 | local args = vim.split(cmdline, "%s+", { trimempty = true }) 242 | if #args <= 2 then 243 | return { "add", "list", "set" } 244 | elseif args[1] == "set" and #args == 3 then 245 | return { "priorities", "ect" } 246 | elseif args[1] == "set" and (args[3] == "priorities") then 247 | local priorities = { "nil" } -- Add nil as an option 248 | for _, p in ipairs(config.options.priorities) do 249 | table.insert(priorities, p.name) 250 | end 251 | return priorities 252 | elseif args[#args - 1] == "-p" or args[#args - 1] == "--priorities" then 253 | -- Return available priorities for completion 254 | local priorities = {} 255 | for _, p in ipairs(config.options.priorities) do 256 | table.insert(priorities, p.name) 257 | end 258 | return priorities 259 | elseif #args == 3 then 260 | return { "-p", "--priorities" } 261 | end 262 | return {} 263 | end, 264 | }) 265 | 266 | -- Create DooingLocal command for project todos 267 | vim.api.nvim_create_user_command("DooingLocal", function() 268 | M.open_project_todo() 269 | end, { 270 | desc = "Open project-specific todo list", 271 | }) 272 | 273 | -- Create DooingDue command for due notifications 274 | vim.api.nvim_create_user_command("DooingDue", function() 275 | M.show_due_notification() 276 | end, { 277 | desc = "Show due and overdue items notification", 278 | }) 279 | 280 | -- Only set up keymap if it's enabled in config 281 | if config.options.keymaps.toggle_window then 282 | vim.keymap.set("n", config.options.keymaps.toggle_window, function() 283 | M.open_global_todo() 284 | end, { desc = "Toggle Global Todo List" }) 285 | end 286 | 287 | -- Set up project todo keymap if enabled 288 | if config.options.keymaps.open_project_todo and config.options.per_project.enabled then 289 | vim.keymap.set("n", config.options.keymaps.open_project_todo, function() 290 | M.open_project_todo() 291 | end, { desc = "Open Local Project Todo List" }) 292 | end 293 | 294 | -- Set up due notification keymap if enabled 295 | if config.options.keymaps.show_due_notification then 296 | vim.keymap.set("n", config.options.keymaps.show_due_notification, function() 297 | M.show_due_notification() 298 | end, { desc = "Show Due Items Notification" }) 299 | end 300 | end 301 | 302 | -- Open global todo list 303 | function M.open_global_todo() 304 | -- Always load global todos regardless of current state 305 | state.load_todos() 306 | 307 | -- If window is already open, update title and render, otherwise toggle 308 | if ui.is_window_open() then 309 | local window = require("dooing.ui.window") 310 | window.update_window_title() 311 | ui.render_todos() 312 | else 313 | ui.toggle_todo_window() 314 | end 315 | 316 | vim.notify("Opened global todos", vim.log.levels.INFO, { title = "Dooing" }) 317 | 318 | -- Show due items notification if enabled 319 | if config.options.due_notifications and config.options.due_notifications.enabled and config.options.due_notifications.on_open then 320 | vim.defer_fn(function() 321 | state.show_due_notification() 322 | end, 100) 323 | end 324 | end 325 | 326 | -- Open project-specific todo list 327 | function M.open_project_todo() 328 | if not config.options.per_project.enabled then 329 | vim.notify("Per-project todos are disabled", vim.log.levels.WARN, { title = "Dooing" }) 330 | return 331 | end 332 | 333 | local git_root = state.get_git_root() 334 | if not git_root then 335 | vim.notify("Not in a git repository", vim.log.levels.ERROR, { title = "Dooing" }) 336 | return 337 | end 338 | 339 | local project_path = state.get_project_todo_path() 340 | 341 | if state.project_todo_exists() then 342 | -- Load existing project todos 343 | state.load_todos_from_path(project_path) 344 | 345 | -- If window is already open, update title and render, otherwise toggle 346 | if ui.is_window_open() then 347 | local window = require("dooing.ui.window") 348 | window.update_window_title() 349 | ui.render_todos() 350 | else 351 | ui.toggle_todo_window() 352 | end 353 | 354 | local project_name = vim.fn.fnamemodify(git_root, ":t") 355 | vim.notify("Opened project todos for: " .. project_name, vim.log.levels.INFO, { title = "Dooing" }) 356 | 357 | -- Show due items notification if enabled 358 | if config.options.due_notifications and config.options.due_notifications.enabled and config.options.due_notifications.on_open then 359 | vim.defer_fn(function() 360 | state.show_due_notification() 361 | end, 100) 362 | end 363 | else 364 | -- Handle missing project todo file 365 | if config.options.per_project.on_missing == "auto_create" then 366 | -- Auto-create the file 367 | M.create_project_todo(project_path) 368 | elseif config.options.per_project.on_missing == "prompt" then 369 | -- Prompt user 370 | M.prompt_create_project_todo(project_path) 371 | end 372 | end 373 | end 374 | 375 | -- Create project todo file 376 | function M.create_project_todo(path, custom_filename) 377 | local final_path = path 378 | if custom_filename then 379 | local git_root = state.get_git_root() 380 | final_path = git_root .. "/" .. custom_filename 381 | end 382 | 383 | -- Create empty todo file 384 | state.load_todos_from_path(final_path) 385 | state.save_todos_to_current_path() 386 | 387 | -- Handle gitignore 388 | local filename = vim.fn.fnamemodify(final_path, ":t") 389 | if config.options.per_project.auto_gitignore == true then 390 | local success, msg = state.add_to_gitignore(filename) 391 | if success then 392 | vim.notify("Created " .. filename .. " and " .. msg, vim.log.levels.INFO, { title = "Dooing" }) 393 | else 394 | vim.notify("Created " .. filename .. " but failed to add to .gitignore: " .. msg, vim.log.levels.WARN, { title = "Dooing" }) 395 | end 396 | elseif config.options.per_project.auto_gitignore == "prompt" then 397 | vim.ui.select({"Yes", "No"}, { 398 | prompt = "Add " .. filename .. " to .gitignore?", 399 | }, function(choice) 400 | if choice == "Yes" then 401 | local success, msg = state.add_to_gitignore(filename) 402 | vim.notify(msg, success and vim.log.levels.INFO or vim.log.levels.WARN, { title = "Dooing" }) 403 | end 404 | end) 405 | else 406 | vim.notify("Created " .. filename .. ". Run 'echo \"" .. filename .. "\" >> .gitignore' to ignore it.", 407 | vim.log.levels.INFO, { title = "Dooing" }) 408 | end 409 | 410 | -- Open the todo window with updated title 411 | if ui.is_window_open() then 412 | local window = require("dooing.ui.window") 413 | window.update_window_title() 414 | ui.render_todos() 415 | else 416 | ui.toggle_todo_window() 417 | end 418 | 419 | -- Notify about project todos 420 | local git_root = state.get_git_root() 421 | local project_name = vim.fn.fnamemodify(git_root, ":t") 422 | vim.notify("Opened project todos for: " .. project_name, vim.log.levels.INFO, { title = "Dooing" }) 423 | end 424 | 425 | -- Prompt user to create project todo 426 | function M.prompt_create_project_todo(path) 427 | vim.ui.select({"Yes", "No"}, { 428 | prompt = "No local TODO found. Create one?", 429 | }, function(choice) 430 | if choice == "Yes" then 431 | vim.ui.input({ 432 | prompt = "Filename (default: " .. config.options.per_project.default_filename .. "): ", 433 | default = "", 434 | }, function(input) 435 | if input == nil then 436 | return -- User cancelled 437 | end 438 | 439 | local filename = vim.trim(input) 440 | if filename == "" then 441 | filename = config.options.per_project.default_filename 442 | end 443 | 444 | M.create_project_todo(path, filename) 445 | end) 446 | end 447 | end) 448 | end 449 | 450 | -- Show due items notification 451 | function M.show_due_notification() 452 | local due_notification = require("dooing.ui.due_notification") 453 | due_notification.show_due_notification() 454 | end 455 | 456 | return M 457 | -------------------------------------------------------------------------------- /lua/dooing/ui/components.lua: -------------------------------------------------------------------------------- 1 | ---@diagnostic disable: undefined-global, param-type-mismatch, deprecated 2 | -- UI Components (help, tags, search windows, etc.) 3 | 4 | local M = {} 5 | local constants = require("dooing.ui.constants") 6 | local state = require("dooing.state") 7 | local config = require("dooing.config") 8 | local calendar = require("dooing.ui.calendar") 9 | 10 | -- Creates and manages the help window 11 | function M.create_help_window() 12 | if constants.help_win_id and vim.api.nvim_win_is_valid(constants.help_win_id) then 13 | vim.api.nvim_win_close(constants.help_win_id, true) 14 | constants.help_win_id = nil 15 | constants.help_buf_id = nil 16 | return 17 | end 18 | 19 | constants.help_buf_id = vim.api.nvim_create_buf(false, true) 20 | 21 | local width = 50 22 | local height = 45 23 | local ui = vim.api.nvim_list_uis()[1] 24 | local col = math.floor((ui.width - width) / 2) + width + 2 25 | local row = math.floor((ui.height - height) / 2) 26 | 27 | constants.help_win_id = vim.api.nvim_open_win(constants.help_buf_id, false, { 28 | relative = "editor", 29 | row = row, 30 | col = col, 31 | width = width, 32 | height = height, 33 | style = "minimal", 34 | border = config.options.window.border, 35 | title = " help ", 36 | title_pos = "center", 37 | zindex = 100, 38 | }) 39 | 40 | local keys = config.options.keymaps 41 | local help_content = { 42 | " Main window:", 43 | string.format(" %-12s - Add new to-do", keys.new_todo), 44 | string.format(" %-12s - Add nested sub-task", keys.create_nested_task), 45 | string.format(" %-12s - Toggle to-do status", keys.toggle_todo), 46 | string.format(" %-12s - Delete current to-do", keys.delete_todo), 47 | string.format(" %-12s - Delete all completed todos", keys.delete_completed), 48 | string.format(" %-12s - Close window", keys.close_window), 49 | string.format(" %-12s - Add due date to to-do", keys.add_due_date), 50 | string.format(" %-12s - Remove to-do due date", keys.remove_due_date), 51 | string.format(" %-12s - Add time estimation", keys.add_time_estimation), 52 | string.format(" %-12s - Remove time estimation", keys.remove_time_estimation), 53 | string.format(" %-12s - Toggle this help window", keys.toggle_help), 54 | string.format(" %-12s - Toggle tags window", keys.toggle_tags), 55 | string.format(" %-12s - Clear active tag filter", keys.clear_filter), 56 | string.format(" %-12s - Edit to-do item", keys.edit_todo), 57 | string.format(" %-12s - Edit to-do priorities", keys.edit_priorities), 58 | string.format(" %-12s - Undo deletion", keys.undo_delete), 59 | string.format(" %-12s - Search todos", keys.search_todos), 60 | string.format(" %-12s - Import todos", keys.import_todos), 61 | string.format(" %-12s - Export todos", keys.export_todos), 62 | string.format(" %-12s - Remove duplicates", keys.remove_duplicates), 63 | string.format(" %-12s - Open todo scratchpad", keys.open_todo_scratchpad), 64 | string.format(" %-12s - Toggle priority on add todo", keys.toggle_priority), 65 | string.format(" %-12s - Refresh todo list", keys.refresh_todos), 66 | "", 67 | " Tags window:", 68 | string.format(" %-12s - Edit tag", keys.edit_tag), 69 | string.format(" %-12s - Delete tag", keys.delete_tag), 70 | string.format(" %-12s - Filter by tag", " "), 71 | string.format(" %-12s - Close window", keys.close_window), 72 | "", 73 | " Calendar window:", 74 | string.format(" %-12s - Previous day", config.options.calendar.keymaps.previous_day), 75 | string.format(" %-12s - Next day", config.options.calendar.keymaps.next_day), 76 | string.format(" %-12s - Previous week", config.options.calendar.keymaps.previous_week), 77 | string.format(" %-12s - Next week", config.options.calendar.keymaps.next_week), 78 | string.format(" %-12s - Previous month", config.options.calendar.keymaps.previous_month), 79 | string.format(" %-12s - Next month", config.options.calendar.keymaps.next_month), 80 | string.format(" %-12s - Select date", config.options.calendar.keymaps.select_day), 81 | string.format(" %-12s - Close calendar", config.options.calendar.keymaps.close_calendar), 82 | "", 83 | } 84 | 85 | vim.api.nvim_buf_set_lines(constants.help_buf_id, 0, -1, false, help_content) 86 | vim.api.nvim_buf_set_option(constants.help_buf_id, "modifiable", false) 87 | vim.api.nvim_buf_set_option(constants.help_buf_id, "buftype", "nofile") 88 | 89 | for i = 0, #help_content - 1 do 90 | vim.api.nvim_buf_add_highlight(constants.help_buf_id, constants.ns_id, "DooingHelpText", i, 0, -1) 91 | end 92 | 93 | vim.api.nvim_create_autocmd("BufLeave", { 94 | buffer = constants.help_buf_id, 95 | callback = function() 96 | if constants.help_win_id and vim.api.nvim_win_is_valid(constants.help_win_id) then 97 | vim.api.nvim_win_close(constants.help_win_id, true) 98 | constants.help_win_id = nil 99 | constants.help_buf_id = nil 100 | end 101 | return true 102 | end, 103 | }) 104 | 105 | local function close_help() 106 | if constants.help_win_id and vim.api.nvim_win_is_valid(constants.help_win_id) then 107 | vim.api.nvim_win_close(constants.help_win_id, true) 108 | constants.help_win_id = nil 109 | constants.help_buf_id = nil 110 | end 111 | end 112 | 113 | vim.keymap.set("n", config.options.keymaps.close_window, close_help, { buffer = constants.help_buf_id, nowait = true }) 114 | vim.keymap.set("n", config.options.keymaps.toggle_help, close_help, { buffer = constants.help_buf_id, nowait = true }) 115 | end 116 | 117 | -- Creates and manages the tags window 118 | function M.create_tag_window() 119 | if constants.tag_win_id and vim.api.nvim_win_is_valid(constants.tag_win_id) then 120 | vim.api.nvim_win_close(constants.tag_win_id, true) 121 | constants.tag_win_id = nil 122 | constants.tag_buf_id = nil 123 | return 124 | end 125 | 126 | constants.tag_buf_id = vim.api.nvim_create_buf(false, true) 127 | 128 | local width = 30 129 | local height = 10 130 | local ui = vim.api.nvim_list_uis()[1] 131 | local main_width = 40 132 | local main_col = math.floor((ui.width - main_width) / 2) 133 | local col = main_col - width - 2 134 | local row = math.floor((ui.height - height) / 2) 135 | 136 | constants.tag_win_id = vim.api.nvim_open_win(constants.tag_buf_id, true, { 137 | relative = "editor", 138 | row = row, 139 | col = col, 140 | width = width, 141 | height = height, 142 | style = "minimal", 143 | border = config.options.window.border, 144 | title = " tags ", 145 | title_pos = "center", 146 | }) 147 | 148 | local tags = state.get_all_tags() 149 | if #tags == 0 then 150 | tags = { "No tags found" } 151 | end 152 | 153 | vim.api.nvim_buf_set_lines(constants.tag_buf_id, 0, -1, false, tags) 154 | vim.api.nvim_buf_set_option(constants.tag_buf_id, "modifiable", true) 155 | 156 | vim.keymap.set("n", "", function() 157 | local cursor = vim.api.nvim_win_get_cursor(constants.tag_win_id) 158 | local tag = vim.api.nvim_buf_get_lines(constants.tag_buf_id, cursor[1] - 1, cursor[1], false)[1] 159 | if tag ~= "No tags found" then 160 | state.set_filter(tag) 161 | vim.api.nvim_win_close(constants.tag_win_id, true) 162 | constants.tag_win_id = nil 163 | constants.tag_buf_id = nil 164 | local rendering = require("dooing.ui.rendering") 165 | rendering.render_todos() 166 | end 167 | end, { buffer = constants.tag_buf_id }) 168 | 169 | vim.keymap.set("n", config.options.keymaps.edit_tag, function() 170 | local cursor = vim.api.nvim_win_get_cursor(constants.tag_win_id) 171 | local old_tag = vim.api.nvim_buf_get_lines(constants.tag_buf_id, cursor[1] - 1, cursor[1], false)[1] 172 | if old_tag ~= "No tags found" then 173 | vim.ui.input({ prompt = "Edit tag: ", default = old_tag }, function(new_tag) 174 | if new_tag and new_tag ~= "" and new_tag ~= old_tag then 175 | state.rename_tag(old_tag, new_tag) 176 | local tags = state.get_all_tags() 177 | vim.api.nvim_buf_set_lines(constants.tag_buf_id, 0, -1, false, tags) 178 | local rendering = require("dooing.ui.rendering") 179 | rendering.render_todos() 180 | end 181 | end) 182 | end 183 | end, { buffer = constants.tag_buf_id }) 184 | 185 | vim.keymap.set("n", config.options.keymaps.delete_tag, function() 186 | local cursor = vim.api.nvim_win_get_cursor(constants.tag_win_id) 187 | local tag = vim.api.nvim_buf_get_lines(constants.tag_buf_id, cursor[1] - 1, cursor[1], false)[1] 188 | if tag ~= "No tags found" then 189 | state.delete_tag(tag) 190 | local tags = state.get_all_tags() 191 | if #tags == 0 then 192 | tags = { "No tags found" } 193 | end 194 | vim.api.nvim_buf_set_lines(constants.tag_buf_id, 0, -1, false, tags) 195 | local rendering = require("dooing.ui.rendering") 196 | rendering.render_todos() 197 | end 198 | end, { buffer = constants.tag_buf_id }) 199 | 200 | vim.keymap.set("n", "q", function() 201 | vim.api.nvim_win_close(constants.tag_win_id, true) 202 | constants.tag_win_id = nil 203 | constants.tag_buf_id = nil 204 | vim.api.nvim_set_current_win(constants.win_id) 205 | end, { buffer = constants.tag_buf_id }) 206 | end 207 | 208 | -- Handle search queries 209 | local function handle_search_query(query) 210 | if not query or query == "" then 211 | if constants.search_win_id and vim.api.nvim_win_is_valid(constants.search_win_id) then 212 | vim.api.nvim_win_close(constants.search_win_id, true) 213 | vim.api.nvim_set_current_win(constants.win_id) 214 | constants.search_win_id = nil 215 | constants.search_buf_id = nil 216 | end 217 | return 218 | end 219 | 220 | local done_icon = config.options.formatting.done.icon 221 | local pending_icon = config.options.formatting.pending.icon 222 | local in_progress_icon = config.options.formatting.in_progress.icon 223 | 224 | -- Prepare the search results 225 | local results = state.search_todos(query) 226 | vim.api.nvim_buf_set_option(constants.search_buf_id, "modifiable", true) 227 | local lines = { "Search Results for: " .. query, "" } 228 | local valid_lines = {} -- Store valid todo lines 229 | if #results > 0 then 230 | for _, result in ipairs(results) do 231 | local icon = result.todo.done and done_icon or pending_icon 232 | local line = string.format(" %s %s", icon, result.todo.text) 233 | table.insert(lines, line) 234 | table.insert(valid_lines, { line_index = #lines, result = result }) 235 | end 236 | else 237 | table.insert(lines, " No results found") 238 | vim.api.nvim_set_current_win(constants.win_id) 239 | end 240 | 241 | -- Add search results to window 242 | vim.api.nvim_buf_set_lines(constants.search_buf_id, 0, -1, false, lines) 243 | 244 | -- After adding search results, make it unmodifiable 245 | vim.api.nvim_buf_set_option(constants.search_buf_id, "modifiable", false) 246 | 247 | -- Highlight todos on search results 248 | for i, line in ipairs(lines) do 249 | if line:match("%s+[" .. done_icon .. pending_icon .. in_progress_icon .. "]") then 250 | local hl_group = line:match(done_icon) and "DooingDone" or "DooingPending" 251 | vim.api.nvim_buf_add_highlight(constants.search_buf_id, constants.ns_id, hl_group, i - 1, 0, -1) 252 | for tag in line:gmatch("#(%w+)") do 253 | local start_idx = line:find("#" .. tag) - 1 254 | vim.api.nvim_buf_add_highlight(constants.search_buf_id, constants.ns_id, "Type", i - 1, start_idx, 255 | start_idx + #tag + 1) 256 | end 257 | elseif line:match("Search Results") then 258 | vim.api.nvim_buf_add_highlight(constants.search_buf_id, constants.ns_id, "WarningMsg", i - 1, 0, -1) 259 | end 260 | end 261 | 262 | -- Close search window 263 | vim.keymap.set("n", "q", function() 264 | vim.api.nvim_win_close(constants.search_win_id, true) 265 | constants.search_win_id = nil 266 | constants.search_buf_id = nil 267 | if constants.win_id and vim.api.nvim_win_is_valid(constants.win_id) then 268 | vim.api.nvim_set_current_win(constants.win_id) 269 | end 270 | end, { buffer = constants.search_buf_id, nowait = true }) 271 | 272 | -- Jump to todo in main window 273 | vim.keymap.set("n", "", function() 274 | local current_line = vim.api.nvim_win_get_cursor(constants.search_win_id)[1] 275 | local matched_result = nil 276 | for _, item in ipairs(valid_lines) do 277 | if item.line_index == current_line then 278 | matched_result = item.result 279 | break 280 | end 281 | end 282 | if matched_result then 283 | vim.api.nvim_win_close(constants.search_win_id, true) 284 | constants.search_win_id = nil 285 | constants.search_buf_id = nil 286 | vim.api.nvim_set_current_win(constants.win_id) 287 | vim.api.nvim_win_set_cursor(constants.win_id, { matched_result.lnum + 1, 3 }) 288 | end 289 | end, { buffer = constants.search_buf_id, nowait = true }) 290 | end 291 | 292 | -- Search for todos 293 | function M.create_search_window() 294 | -- If search window exists and is valid, focus on the existing window and return 295 | if constants.search_win_id and vim.api.nvim_win_is_valid(constants.search_win_id) then 296 | vim.api.nvim_set_current_win(constants.search_win_id) 297 | vim.ui.input({ prompt = "Search todos: " }, function(query) 298 | handle_search_query(query) 299 | end) 300 | return 301 | end 302 | 303 | -- If search window exists but is not valid, reset IDs 304 | if constants.search_win_id and vim.api.nvim_win_is_valid(constants.search_win_id) then 305 | constants.search_win_id = nil 306 | constants.search_buf_id = nil 307 | end 308 | 309 | -- Create search results buffer 310 | constants.search_buf_id = vim.api.nvim_create_buf(false, true) 311 | vim.api.nvim_buf_set_option(constants.search_buf_id, "buflisted", true) 312 | vim.api.nvim_buf_set_option(constants.search_buf_id, "modifiable", false) 313 | vim.api.nvim_buf_set_option(constants.search_buf_id, "filetype", "todo_search") 314 | local width = 40 315 | local height = 10 316 | local ui = vim.api.nvim_list_uis()[1] 317 | local main_width = 40 318 | local main_col = math.floor((ui.width - main_width) / 2) 319 | local col = main_col - width - 2 320 | local row = math.floor((ui.height - height) / 2) 321 | constants.search_win_id = vim.api.nvim_open_win(constants.search_buf_id, true, { 322 | relative = "editor", 323 | row = row, 324 | col = col, 325 | width = width, 326 | height = height, 327 | style = "minimal", 328 | border = config.options.window.border, 329 | title = " Search Todos ", 330 | title_pos = "center", 331 | }) 332 | 333 | -- Create search query pane 334 | vim.ui.input({ prompt = "Search todos: " }, function(query) 335 | handle_search_query(query) 336 | end) 337 | 338 | -- Close the search window if main window is closed 339 | vim.api.nvim_create_autocmd("WinClosed", { 340 | pattern = tostring(constants.win_id), 341 | callback = function() 342 | if constants.search_win_id and vim.api.nvim_win_is_valid(constants.search_win_id) then 343 | vim.api.nvim_win_close(constants.search_win_id, true) 344 | constants.search_win_id = nil 345 | constants.search_buf_id = nil 346 | end 347 | end, 348 | }) 349 | end 350 | 351 | -- Scratchpad component 352 | function M.open_todo_scratchpad() 353 | local cursor = vim.api.nvim_win_get_cursor(constants.win_id) 354 | local todo_index = cursor[1] - 1 355 | local todo = state.todos[todo_index] 356 | 357 | if not todo then 358 | vim.notify("No todo selected", vim.log.levels.WARN) 359 | return 360 | end 361 | 362 | if todo.notes == nil then 363 | todo.notes = "" 364 | end 365 | 366 | local function is_valid_filetype(filetype) 367 | local syntax_file = vim.fn.globpath(vim.o.runtimepath, "syntax/" .. filetype .. ".vim") 368 | return syntax_file ~= "" 369 | end 370 | 371 | local scratch_buf = vim.api.nvim_create_buf(false, true) 372 | vim.api.nvim_buf_set_option(scratch_buf, "buftype", "acwrite") 373 | vim.api.nvim_buf_set_option(scratch_buf, "swapfile", false) 374 | 375 | local syntax_highlight = config.options.scratchpad.syntax_highlight 376 | if not is_valid_filetype(syntax_highlight) then 377 | vim.notify( 378 | "Invalid scratchpad syntax highlight '" .. syntax_highlight .. "'. Using default 'markdown'.", 379 | vim.log.levels.WARN 380 | ) 381 | syntax_highlight = "markdown" 382 | end 383 | 384 | vim.api.nvim_buf_set_option(scratch_buf, "filetype", syntax_highlight) 385 | 386 | local ui = vim.api.nvim_list_uis()[1] 387 | local width = math.floor(ui.width * 0.6) 388 | local height = math.floor(ui.height * 0.6) 389 | local row = math.floor((ui.height - height) / 2) 390 | local col = math.floor((ui.width - width) / 2) 391 | 392 | local scratch_win = vim.api.nvim_open_win(scratch_buf, true, { 393 | relative = "editor", 394 | width = width, 395 | height = height, 396 | row = row, 397 | col = col, 398 | style = "minimal", 399 | border = config.options.window.border, 400 | title = " Scratchpad ", 401 | title_pos = "center", 402 | }) 403 | 404 | local initial_notes = todo.notes or "" 405 | vim.api.nvim_buf_set_lines(scratch_buf, 0, -1, false, vim.split(initial_notes, "\n")) 406 | 407 | local function close_notes() 408 | if vim.api.nvim_win_is_valid(scratch_win) then 409 | vim.api.nvim_win_close(scratch_win, true) 410 | end 411 | 412 | if vim.api.nvim_buf_is_valid(scratch_buf) then 413 | vim.api.nvim_buf_delete(scratch_buf, { force = true }) 414 | end 415 | end 416 | 417 | local function save_notes() 418 | local lines = vim.api.nvim_buf_get_lines(scratch_buf, 0, -1, false) 419 | local new_notes = table.concat(lines, "\n") 420 | 421 | if new_notes ~= initial_notes then 422 | todo.notes = new_notes 423 | state.save_todos() 424 | vim.notify("Notes saved", vim.log.levels.INFO) 425 | end 426 | 427 | close_notes() 428 | end 429 | 430 | vim.api.nvim_create_autocmd("WinLeave", { 431 | buffer = scratch_buf, 432 | callback = save_notes, 433 | }) 434 | 435 | vim.keymap.set("n", "", save_notes, { buffer = scratch_buf }) 436 | vim.keymap.set("n", "", save_notes, { buffer = scratch_buf }) 437 | end 438 | 439 | return M 440 | -------------------------------------------------------------------------------- /lua/dooing/ui/actions.lua: -------------------------------------------------------------------------------- 1 | ---@diagnostic disable: undefined-global, param-type-mismatch, deprecated 2 | -- Actions module for todo CRUD operations 3 | 4 | local M = {} 5 | local constants = require("dooing.ui.constants") 6 | local utils = require("dooing.ui.utils") 7 | local state = require("dooing.state") 8 | local config = require("dooing.config") 9 | local calendar = require("dooing.ui.calendar") 10 | local server = require("dooing.server") 11 | 12 | -- Handles editing of existing todos 13 | function M.edit_todo() 14 | local cursor = vim.api.nvim_win_get_cursor(constants.win_id) 15 | local todo_index = cursor[1] - 1 16 | local line_content = vim.api.nvim_buf_get_lines(constants.buf_id, todo_index, todo_index + 1, false)[1] 17 | 18 | local done_icon = config.options.formatting.done.icon 19 | local pending_icon = config.options.formatting.pending.icon 20 | local in_progress_icon = config.options.formatting.in_progress.icon 21 | 22 | if line_content:match("%s+[" .. done_icon .. pending_icon .. in_progress_icon .. "]") then 23 | if state.active_filter then 24 | local visible_index = 0 25 | for i, todo in ipairs(state.todos) do 26 | if todo.text:match("#" .. state.active_filter) then 27 | visible_index = visible_index + 1 28 | if visible_index == todo_index - 2 then 29 | todo_index = i 30 | break 31 | end 32 | end 33 | end 34 | end 35 | 36 | vim.ui.input({ zindex = 300, prompt = "Edit to-do: ", default = state.todos[todo_index].text }, function(input) 37 | if input and input ~= "" then 38 | state.todos[todo_index].text = input 39 | state.save_todos() 40 | local rendering = require("dooing.ui.rendering") 41 | rendering.render_todos() 42 | end 43 | end) 44 | end 45 | end 46 | 47 | -- Handles editing priorities 48 | function M.edit_priorities() 49 | local cursor = vim.api.nvim_win_get_cursor(constants.win_id) 50 | local todo_index = cursor[1] - 1 51 | local line_content = vim.api.nvim_buf_get_lines(constants.buf_id, todo_index, todo_index + 1, false)[1] 52 | local done_icon = config.options.formatting.done.icon 53 | local pending_icon = config.options.formatting.pending.icon 54 | local in_progress_icon = config.options.formatting.in_progress.icon 55 | 56 | if line_content:match("%s+[" .. done_icon .. pending_icon .. in_progress_icon .. "]") then 57 | if state.active_filter then 58 | local visible_index = 0 59 | for i, todo in ipairs(state.todos) do 60 | if todo.text:match("#" .. state.active_filter) then 61 | visible_index = visible_index + 1 62 | if visible_index == todo_index - 2 then 63 | todo_index = i 64 | break 65 | end 66 | end 67 | end 68 | end 69 | 70 | -- Check if priorities are configured 71 | if config.options.priorities and #config.options.priorities > 0 then 72 | local priorities = config.options.priorities 73 | local priority_options = {} 74 | local selected_priorities = {} 75 | local current_todo = state.todos[todo_index] 76 | 77 | -- Pre-select existing priorities 78 | for i, priority in ipairs(priorities) do 79 | local is_selected = false 80 | if current_todo.priorities then 81 | for _, existing_priority in ipairs(current_todo.priorities) do 82 | if existing_priority == priority.name then 83 | is_selected = true 84 | selected_priorities[i] = true 85 | break 86 | end 87 | end 88 | end 89 | priority_options[i] = string.format("[%s] %s", is_selected and "x" or " ", priority.name) 90 | end 91 | 92 | -- Create buffer for priority selection 93 | local select_buf = vim.api.nvim_create_buf(false, true) 94 | local ui = vim.api.nvim_list_uis()[1] 95 | local width = 40 96 | local height = #priority_options + 2 97 | local row = math.floor((ui.height - height) / 2) 98 | local col = math.floor((ui.width - width) / 2) 99 | 100 | -- Store keymaps for cleanup 101 | local keymaps = { 102 | config.options.keymaps.toggle_priority, 103 | "", 104 | "q", 105 | "", 106 | } 107 | 108 | local select_win = vim.api.nvim_open_win(select_buf, true, { 109 | relative = "editor", 110 | width = width, 111 | height = height, 112 | row = row, 113 | col = col, 114 | style = "minimal", 115 | border = config.options.window.border, 116 | title = " Select Priorities ", 117 | title_pos = "center", 118 | footer = string.format(" %s: toggle | : confirm ", config.options.keymaps.toggle_priority), 119 | footer_pos = "center", 120 | }) 121 | 122 | -- Set buffer content 123 | vim.api.nvim_buf_set_lines(select_buf, 0, -1, false, priority_options) 124 | vim.api.nvim_buf_set_option(select_buf, "modifiable", false) 125 | 126 | -- Add keymaps for selection 127 | vim.keymap.set("n", config.options.keymaps.toggle_priority, function() 128 | if not (select_win and vim.api.nvim_win_is_valid(select_win)) then 129 | return 130 | end 131 | 132 | local cursor = vim.api.nvim_win_get_cursor(select_win) 133 | local line_num = cursor[1] 134 | local current_line = vim.api.nvim_buf_get_lines(select_buf, line_num - 1, line_num, false)[1] 135 | 136 | vim.api.nvim_buf_set_option(select_buf, "modifiable", true) 137 | if current_line:match("^%[%s%]") then 138 | -- Select item 139 | local new_line = current_line:gsub("^%[%s%]", "[x]") 140 | selected_priorities[line_num] = true 141 | vim.api.nvim_buf_set_lines(select_buf, line_num - 1, line_num, false, { new_line }) 142 | else 143 | -- Deselect item 144 | local new_line = current_line:gsub("^%[x%]", "[ ]") 145 | selected_priorities[line_num] = nil 146 | vim.api.nvim_buf_set_lines(select_buf, line_num - 1, line_num, false, { new_line }) 147 | end 148 | vim.api.nvim_buf_set_option(select_buf, "modifiable", false) 149 | end, { buffer = select_buf, nowait = true }) 150 | 151 | -- Add keymap for confirmation 152 | vim.keymap.set("n", "", function() 153 | if not (select_win and vim.api.nvim_win_is_valid(select_win)) then 154 | return 155 | end 156 | 157 | local selected_priority_names = {} 158 | for idx, _ in pairs(selected_priorities) do 159 | local priority = config.options.priorities[idx] 160 | if priority then 161 | table.insert(selected_priority_names, priority.name) 162 | end 163 | end 164 | 165 | -- Clean up resources before proceeding 166 | utils.cleanup_priority_selection(select_buf, select_win, keymaps) 167 | 168 | -- Update todo priorities 169 | state.todos[todo_index].priorities = #selected_priority_names > 0 and selected_priority_names or nil 170 | state.save_todos() 171 | local rendering = require("dooing.ui.rendering") 172 | rendering.render_todos() 173 | end, { buffer = select_buf, nowait = true }) 174 | 175 | -- Add escape/quit keymaps 176 | local function close_window() 177 | utils.cleanup_priority_selection(select_buf, select_win, keymaps) 178 | end 179 | 180 | vim.keymap.set("n", "q", close_window, { buffer = select_buf, nowait = true }) 181 | vim.keymap.set("n", "", close_window, { buffer = select_buf, nowait = true }) 182 | 183 | -- Add autocmd for cleanup when leaving buffer 184 | vim.api.nvim_create_autocmd("BufLeave", { 185 | buffer = select_buf, 186 | callback = function() 187 | utils.cleanup_priority_selection(select_buf, select_win, keymaps) 188 | return true 189 | end, 190 | once = true, 191 | }) 192 | end 193 | end 194 | end 195 | 196 | -- Creates a new todo item 197 | function M.new_todo() 198 | vim.ui.input({ prompt = "New to-do: " }, function(input) 199 | if not input or input == "" then 200 | return 201 | end 202 | 203 | input = input:gsub("\n", " ") 204 | if input and input ~= "" then 205 | -- Check if priorities are configured 206 | if config.options.priorities and #config.options.priorities > 0 then 207 | local priorities = config.options.priorities 208 | local priority_options = {} 209 | local selected_priorities = {} 210 | 211 | for i, priority in ipairs(priorities) do 212 | priority_options[i] = string.format("[ ] %s", priority.name) 213 | end 214 | 215 | -- Create a buffer for priority selection 216 | local select_buf = vim.api.nvim_create_buf(false, true) 217 | local ui = vim.api.nvim_list_uis()[1] 218 | local width = 40 219 | local height = #priority_options + 2 220 | local row = math.floor((ui.height - height) / 2) 221 | local col = math.floor((ui.width - width) / 2) 222 | 223 | -- Store keymaps for cleanup 224 | local keymaps = { 225 | config.options.keymaps.toggle_priority, 226 | "", 227 | "q", 228 | "", 229 | } 230 | 231 | local select_win = vim.api.nvim_open_win(select_buf, true, { 232 | relative = "editor", 233 | width = width, 234 | height = height, 235 | row = row, 236 | col = col, 237 | style = "minimal", 238 | border = config.options.window.border, 239 | title = " Select Priorities ", 240 | title_pos = "center", 241 | footer = string.format(" %s: toggle | : confirm ", config.options.keymaps.toggle_priority), 242 | footer_pos = "center", 243 | }) 244 | 245 | -- Set buffer content 246 | vim.api.nvim_buf_set_lines(select_buf, 0, -1, false, priority_options) 247 | vim.api.nvim_buf_set_option(select_buf, "modifiable", false) 248 | 249 | -- Add keymaps for selection 250 | vim.keymap.set("n", config.options.keymaps.toggle_priority, function() 251 | if not (select_win and vim.api.nvim_win_is_valid(select_win)) then 252 | return 253 | end 254 | 255 | local cursor = vim.api.nvim_win_get_cursor(select_win) 256 | local line_num = cursor[1] 257 | local current_line = vim.api.nvim_buf_get_lines(select_buf, line_num - 1, line_num, false)[1] 258 | 259 | vim.api.nvim_buf_set_option(select_buf, "modifiable", true) 260 | if current_line:match("^%[%s%]") then 261 | -- Select item 262 | local new_line = current_line:gsub("^%[%s%]", "[x]") 263 | selected_priorities[line_num] = true 264 | vim.api.nvim_buf_set_lines(select_buf, line_num - 1, line_num, false, { new_line }) 265 | else 266 | -- Deselect item 267 | local new_line = current_line:gsub("^%[x%]", "[ ]") 268 | selected_priorities[line_num] = nil 269 | vim.api.nvim_buf_set_lines(select_buf, line_num - 1, line_num, false, { new_line }) 270 | end 271 | vim.api.nvim_buf_set_option(select_buf, "modifiable", false) 272 | end, { buffer = select_buf, nowait = true }) 273 | 274 | -- Add keymap for confirmation 275 | vim.keymap.set("n", "", function() 276 | if not (select_win and vim.api.nvim_win_is_valid(select_win)) then 277 | return 278 | end 279 | 280 | local selected_priority_names = {} 281 | for idx, _ in pairs(selected_priorities) do 282 | local priority = config.options.priorities[idx] 283 | if priority then 284 | table.insert(selected_priority_names, priority.name) 285 | end 286 | end 287 | 288 | -- Clean up resources before proceeding 289 | utils.cleanup_priority_selection(select_buf, select_win, keymaps) 290 | 291 | -- Add todo with priority names 292 | local priorities_to_add = #selected_priority_names > 0 and selected_priority_names or nil 293 | state.add_todo(input, priorities_to_add) 294 | local rendering = require("dooing.ui.rendering") 295 | rendering.render_todos() 296 | 297 | -- Make sure we're focusing on the main window 298 | if constants.win_id and vim.api.nvim_win_is_valid(constants.win_id) then 299 | vim.api.nvim_set_current_win(constants.win_id) 300 | 301 | -- Position cursor at the new todo 302 | local total_lines = vim.api.nvim_buf_line_count(constants.buf_id) 303 | local target_line = nil 304 | for i = 1, total_lines do 305 | local line = vim.api.nvim_buf_get_lines(constants.buf_id, i - 1, i, false)[1] 306 | if line:match("%s+" .. config.options.formatting.pending.icon .. ".*" .. vim.pesc(input)) then 307 | target_line = i 308 | break 309 | end 310 | end 311 | 312 | if target_line and constants.win_id and vim.api.nvim_win_is_valid(constants.win_id) then 313 | vim.api.nvim_win_set_cursor(constants.win_id, { target_line, 0 }) 314 | end 315 | end 316 | end, { buffer = select_buf, nowait = true }) 317 | 318 | -- Add escape/quit keymaps 319 | local function close_window() 320 | utils.cleanup_priority_selection(select_buf, select_win, keymaps) 321 | end 322 | 323 | vim.keymap.set("n", "q", close_window, { buffer = select_buf, nowait = true }) 324 | vim.keymap.set("n", "", close_window, { buffer = select_buf, nowait = true }) 325 | 326 | -- Add autocmd for cleanup when leaving buffer 327 | vim.api.nvim_create_autocmd("BufLeave", { 328 | buffer = select_buf, 329 | callback = function() 330 | utils.cleanup_priority_selection(select_buf, select_win, keymaps) 331 | return true -- Remove the autocmd after execution 332 | end, 333 | once = true, 334 | }) 335 | else 336 | -- If prioritization is disabled, just add the todo without priority 337 | state.add_todo(input) 338 | local rendering = require("dooing.ui.rendering") 339 | rendering.render_todos() 340 | 341 | -- Make sure we're focusing on the main window 342 | if constants.win_id and vim.api.nvim_win_is_valid(constants.win_id) then 343 | vim.api.nvim_set_current_win(constants.win_id) 344 | 345 | -- Position cursor at the new todo 346 | local total_lines = vim.api.nvim_buf_line_count(constants.buf_id) 347 | local target_line = nil 348 | for i = 1, total_lines do 349 | local line = vim.api.nvim_buf_get_lines(constants.buf_id, i - 1, i, false)[1] 350 | if line:match("%s+" .. config.options.formatting.pending.icon .. ".*" .. vim.pesc(input)) then 351 | target_line = i 352 | break 353 | end 354 | end 355 | 356 | if target_line and constants.win_id and vim.api.nvim_win_is_valid(constants.win_id) then 357 | vim.api.nvim_win_set_cursor(constants.win_id, { target_line, 0 }) 358 | end 359 | end 360 | end 361 | end 362 | end) 363 | end 364 | 365 | -- Creates a nested todo item under the current todo 366 | function M.new_nested_todo() 367 | -- Check if nested tasks are enabled 368 | if not config.options.nested_tasks or not config.options.nested_tasks.enabled then 369 | vim.notify("Nested tasks are disabled in configuration", vim.log.levels.WARN) 370 | return 371 | end 372 | 373 | local cursor = vim.api.nvim_win_get_cursor(constants.win_id) 374 | local todo_index = cursor[1] - 1 375 | local line_content = vim.api.nvim_buf_get_lines(constants.buf_id, todo_index, todo_index + 1, false)[1] 376 | 377 | local done_icon = config.options.formatting.done.icon 378 | local pending_icon = config.options.formatting.pending.icon 379 | local in_progress_icon = config.options.formatting.in_progress.icon 380 | 381 | -- Check if cursor is on a todo line 382 | if not line_content:match("%s+[" .. done_icon .. pending_icon .. in_progress_icon .. "]") then 383 | vim.notify("Cursor must be on a todo item to create nested task", vim.log.levels.WARN) 384 | return 385 | end 386 | 387 | -- Adjust index for filtered view 388 | if state.active_filter then 389 | local visible_index = 0 390 | for i, todo in ipairs(state.todos) do 391 | if todo.text:match("#" .. state.active_filter) then 392 | visible_index = visible_index + 1 393 | if visible_index == todo_index - 2 then 394 | todo_index = i 395 | break 396 | end 397 | end 398 | end 399 | end 400 | 401 | vim.ui.input({ prompt = "New sub-task: " }, function(input) 402 | input = input:gsub("\n", " ") 403 | if input and input ~= "" then 404 | -- Check if priorities are configured 405 | if config.options.priorities and #config.options.priorities > 0 then 406 | local priorities = config.options.priorities 407 | local priority_options = {} 408 | local selected_priorities = {} 409 | 410 | for i, priority in ipairs(priorities) do 411 | priority_options[i] = string.format("[ ] %s", priority.name) 412 | end 413 | 414 | -- Create a buffer for priority selection 415 | local select_buf = vim.api.nvim_create_buf(false, true) 416 | local ui = vim.api.nvim_list_uis()[1] 417 | local width = 40 418 | local height = #priority_options + 2 419 | local row = math.floor((ui.height - height) / 2) 420 | local col = math.floor((ui.width - width) / 2) 421 | 422 | -- Store keymaps for cleanup 423 | local keymaps = { 424 | config.options.keymaps.toggle_priority, 425 | "", 426 | "q", 427 | "", 428 | } 429 | 430 | local select_win = vim.api.nvim_open_win(select_buf, true, { 431 | relative = "editor", 432 | width = width, 433 | height = height, 434 | row = row, 435 | col = col, 436 | style = "minimal", 437 | border = config.options.window.border, 438 | title = " Select Priorities ", 439 | title_pos = "center", 440 | footer = string.format(" %s: toggle | : confirm ", config.options.keymaps.toggle_priority), 441 | footer_pos = "center", 442 | }) 443 | 444 | -- Set buffer content 445 | vim.api.nvim_buf_set_lines(select_buf, 0, -1, false, priority_options) 446 | vim.api.nvim_buf_set_option(select_buf, "modifiable", false) 447 | 448 | -- Add keymaps for selection 449 | vim.keymap.set("n", config.options.keymaps.toggle_priority, function() 450 | if not (select_win and vim.api.nvim_win_is_valid(select_win)) then 451 | return 452 | end 453 | 454 | local cursor = vim.api.nvim_win_get_cursor(select_win) 455 | local line_num = cursor[1] 456 | local current_line = vim.api.nvim_buf_get_lines(select_buf, line_num - 1, line_num, false)[1] 457 | 458 | vim.api.nvim_buf_set_option(select_buf, "modifiable", true) 459 | if current_line:match("^%[%s%]") then 460 | -- Select item 461 | local new_line = current_line:gsub("^%[%s%]", "[x]") 462 | selected_priorities[line_num] = true 463 | vim.api.nvim_buf_set_lines(select_buf, line_num - 1, line_num, false, { new_line }) 464 | else 465 | -- Deselect item 466 | local new_line = current_line:gsub("^%[x%]", "[ ]") 467 | selected_priorities[line_num] = nil 468 | vim.api.nvim_buf_set_lines(select_buf, line_num - 1, line_num, false, { new_line }) 469 | end 470 | vim.api.nvim_buf_set_option(select_buf, "modifiable", false) 471 | end, { buffer = select_buf, nowait = true }) 472 | 473 | -- Add keymap for confirmation 474 | vim.keymap.set("n", "", function() 475 | if not (select_win and vim.api.nvim_win_is_valid(select_win)) then 476 | return 477 | end 478 | 479 | local selected_priority_names = {} 480 | for idx, _ in pairs(selected_priorities) do 481 | local priority = config.options.priorities[idx] 482 | if priority then 483 | table.insert(selected_priority_names, priority.name) 484 | end 485 | end 486 | 487 | -- Clean up resources before proceeding 488 | utils.cleanup_priority_selection(select_buf, select_win, keymaps) 489 | 490 | -- Add nested todo with priority names 491 | local priorities_to_add = #selected_priority_names > 0 and selected_priority_names or nil 492 | local success = state.add_nested_todo(input, todo_index, priorities_to_add) 493 | 494 | if success then 495 | local rendering = require("dooing.ui.rendering") 496 | rendering.render_todos() 497 | vim.notify("Nested task created", vim.log.levels.INFO) 498 | 499 | -- Focus back to main window 500 | if constants.win_id and vim.api.nvim_win_is_valid(constants.win_id) then 501 | vim.api.nvim_set_current_win(constants.win_id) 502 | end 503 | else 504 | vim.notify("Failed to create nested task", vim.log.levels.ERROR) 505 | end 506 | end, { buffer = select_buf, nowait = true }) 507 | 508 | -- Add escape/quit keymaps 509 | local function close_window() 510 | utils.cleanup_priority_selection(select_buf, select_win, keymaps) 511 | end 512 | 513 | vim.keymap.set("n", "q", close_window, { buffer = select_buf, nowait = true }) 514 | vim.keymap.set("n", "", close_window, { buffer = select_buf, nowait = true }) 515 | 516 | -- Add autocmd for cleanup when leaving buffer 517 | vim.api.nvim_create_autocmd("BufLeave", { 518 | buffer = select_buf, 519 | callback = function() 520 | utils.cleanup_priority_selection(select_buf, select_win, keymaps) 521 | return true -- Remove the autocmd after execution 522 | end, 523 | once = true, 524 | }) 525 | else 526 | -- If prioritization is disabled, just add the nested todo without priority 527 | local success = state.add_nested_todo(input, todo_index, nil) 528 | 529 | if success then 530 | local rendering = require("dooing.ui.rendering") 531 | rendering.render_todos() 532 | vim.notify("Nested task created", vim.log.levels.INFO) 533 | 534 | -- Focus back to main window 535 | if constants.win_id and vim.api.nvim_win_is_valid(constants.win_id) then 536 | vim.api.nvim_set_current_win(constants.win_id) 537 | end 538 | else 539 | vim.notify("Failed to create nested task", vim.log.levels.ERROR) 540 | end 541 | end 542 | end 543 | end) 544 | end 545 | 546 | -- Toggles the completion status of the current todo 547 | function M.toggle_todo() 548 | local cursor = vim.api.nvim_win_get_cursor(constants.win_id) 549 | local todo_index = cursor[1] - 1 550 | local line_content = vim.api.nvim_buf_get_lines(constants.buf_id, todo_index, todo_index + 1, false)[1] 551 | local done_icon = config.options.formatting.done.icon 552 | local pending_icon = config.options.formatting.pending.icon 553 | local in_progress_icon = config.options.formatting.in_progress.icon 554 | 555 | if line_content:match("%s+[" .. done_icon .. pending_icon .. in_progress_icon .. "]") then 556 | if state.active_filter then 557 | local visible_index = 0 558 | for i, todo in ipairs(state.todos) do 559 | if todo.text:match("#" .. state.active_filter) then 560 | visible_index = visible_index + 1 561 | if visible_index == todo_index - 2 then -- -2 for filter header 562 | state.toggle_todo(i) 563 | break 564 | end 565 | end 566 | end 567 | else 568 | state.toggle_todo(todo_index) 569 | end 570 | local rendering = require("dooing.ui.rendering") 571 | rendering.render_todos() 572 | end 573 | end 574 | 575 | -- Deletes the current todo item 576 | function M.delete_todo() 577 | local cursor = vim.api.nvim_win_get_cursor(constants.win_id) 578 | local todo_index = cursor[1] - 1 579 | local line_content = vim.api.nvim_buf_get_lines(constants.buf_id, todo_index, todo_index + 1, false)[1] 580 | local done_icon = config.options.formatting.done.icon 581 | local pending_icon = config.options.formatting.pending.icon 582 | local in_progress_icon = config.options.formatting.in_progress.icon 583 | 584 | if line_content:match("%s+[" .. done_icon .. pending_icon .. in_progress_icon .. "]") then 585 | if state.active_filter then 586 | local visible_index = 0 587 | for i, todo in ipairs(state.todos) do 588 | if todo.text:match("#" .. state.active_filter) then 589 | visible_index = visible_index + 1 590 | if visible_index == todo_index - 2 then 591 | todo_index = 1 592 | break 593 | end 594 | end 595 | end 596 | else 597 | state.delete_todo_with_confirmation(todo_index, constants.win_id, calendar, function() 598 | local rendering = require("dooing.ui.rendering") 599 | rendering.render_todos() 600 | end) 601 | end 602 | local rendering = require("dooing.ui.rendering") 603 | rendering.render_todos() 604 | end 605 | end 606 | 607 | -- Deletes all completed todos 608 | function M.delete_completed() 609 | state.delete_completed() 610 | local rendering = require("dooing.ui.rendering") 611 | rendering.render_todos() 612 | end 613 | 614 | -- Delete all duplicated todos 615 | function M.remove_duplicates() 616 | local dups = state.remove_duplicates() 617 | vim.notify("Removed " .. dups .. " duplicates.", vim.log.levels.INFO) 618 | local rendering = require("dooing.ui.rendering") 619 | rendering.render_todos() 620 | end 621 | 622 | -- Add due date to to-do in the format MM/DD/YYYY 623 | function M.add_due_date() 624 | local current_line = vim.api.nvim_win_get_cursor(0)[1] 625 | local todo_index = current_line - (state.active_filter and 3 or 1) 626 | 627 | calendar.create(function(date_str) 628 | if date_str and date_str ~= "" then 629 | local success, err = state.add_due_date(todo_index, date_str) 630 | 631 | if success then 632 | vim.notify("Due date added successfully", vim.log.levels.INFO) 633 | local rendering = require("dooing.ui.rendering") 634 | rendering.render_todos() 635 | else 636 | vim.notify("Error adding due date: " .. (err or "Unknown error"), vim.log.levels.ERROR) 637 | end 638 | end 639 | end, { language = "en" }) 640 | end 641 | 642 | -- Remove due date from to-do 643 | function M.remove_due_date() 644 | local current_line = vim.api.nvim_win_get_cursor(0)[1] 645 | local todo_index = current_line - (state.active_filter and 3 or 1) 646 | 647 | local success = state.remove_due_date(todo_index) 648 | 649 | if success then 650 | vim.notify("Due date removed successfully", vim.log.levels.INFO) 651 | local rendering = require("dooing.ui.rendering") 652 | rendering.render_todos() 653 | else 654 | vim.notify("Error removing due date", vim.log.levels.ERROR) 655 | end 656 | end 657 | 658 | -- Add estimated completion time to todo 659 | function M.add_time_estimation() 660 | local current_line = vim.api.nvim_win_get_cursor(0)[1] 661 | local todo_index = current_line - (state.active_filter and 3 or 1) 662 | 663 | vim.ui.input({ 664 | prompt = "Estimated completion time (e.g., 15m, 2h, 1d, 0.5w): ", 665 | default = "", 666 | }, function(input) 667 | if input and input ~= "" then 668 | local hours, err = utils.parse_time_estimation(input) 669 | if hours then 670 | state.todos[todo_index].estimated_hours = hours 671 | state.save_todos() 672 | vim.notify("Time estimation added successfully", vim.log.levels.INFO) 673 | local rendering = require("dooing.ui.rendering") 674 | rendering.render_todos() 675 | else 676 | vim.notify("Error adding time estimation: " .. (err or "Unknown error"), vim.log.levels.ERROR) 677 | end 678 | end 679 | end) 680 | end 681 | 682 | -- Remove estimated completion time from todo 683 | function M.remove_time_estimation() 684 | local current_line = vim.api.nvim_win_get_cursor(0)[1] 685 | local todo_index = current_line - (state.active_filter and 3 or 1) 686 | 687 | if state.todos[todo_index] then 688 | state.todos[todo_index].estimated_hours = nil 689 | state.save_todos() 690 | vim.notify("Time estimation removed successfully", vim.log.levels.INFO) 691 | local rendering = require("dooing.ui.rendering") 692 | rendering.render_todos() 693 | else 694 | vim.notify("Error removing time estimation", vim.log.levels.ERROR) 695 | end 696 | end 697 | 698 | -- Import/Export functions 699 | function M.prompt_export() 700 | local default_path = vim.fn.expand(config.options.save_path) 701 | 702 | vim.ui.input({ 703 | prompt = "Export todos to file: ", 704 | default = default_path, 705 | completion = "file", 706 | }, function(file_path) 707 | if not file_path or file_path == "" then 708 | vim.notify("Export cancelled", vim.log.levels.INFO) 709 | return 710 | end 711 | 712 | -- expand ~ to full home directory path 713 | file_path = vim.fn.expand(file_path) 714 | 715 | local success, message = state.export_todos(file_path) 716 | if success then 717 | vim.notify(message, vim.log.levels.INFO) 718 | else 719 | vim.notify(message, vim.log.levels.ERROR) 720 | end 721 | end) 722 | end 723 | 724 | function M.prompt_import() 725 | local default_path = vim.fn.expand(config.options.save_path) 726 | 727 | vim.ui.input({ 728 | prompt = "Import todos from file: ", 729 | default = default_path, 730 | completion = "file", 731 | }, function(file_path) 732 | if not file_path or file_path == "" then 733 | vim.notify("Import cancelled", vim.log.levels.INFO) 734 | return 735 | end 736 | 737 | -- expand ~ to full home directory path 738 | file_path = vim.fn.expand(file_path) 739 | 740 | local success, message = state.import_todos(file_path) 741 | if success then 742 | vim.notify(message, vim.log.levels.INFO) 743 | local rendering = require("dooing.ui.rendering") 744 | rendering.render_todos() 745 | else 746 | vim.notify(message, vim.log.levels.ERROR) 747 | end 748 | end) 749 | end 750 | 751 | -- Function to reload todos and refresh UI if window is open 752 | function M.reload_todos() 753 | state.load_todos() 754 | local window = require("dooing.ui.window") 755 | if window.is_window_open() then 756 | local rendering = require("dooing.ui.rendering") 757 | rendering.render_todos() 758 | vim.notify("Todo list refreshed", vim.log.levels.INFO, { title = "Dooing" }) 759 | end 760 | end 761 | 762 | -- Undo delete 763 | function M.undo_delete() 764 | if state.undo_delete() then 765 | local rendering = require("dooing.ui.rendering") 766 | rendering.render_todos() 767 | vim.notify("Todo restored", vim.log.levels.INFO) 768 | end 769 | end 770 | 771 | return M 772 | -------------------------------------------------------------------------------- /lua/dooing/state.lua: -------------------------------------------------------------------------------- 1 | -- Declare vim locally at the top 2 | local vim = vim 3 | 4 | local M = {} 5 | local config = require("dooing.config") 6 | 7 | -- Cache frequently accessed values 8 | local priority_weights = {} 9 | 10 | M.todos = {} 11 | M.current_save_path = nil 12 | M.current_context = "global" -- Track current context: "global" or project name 13 | 14 | -- Update priority weights cache when config changes 15 | local function update_priority_weights() 16 | priority_weights = {} 17 | for _, p in ipairs(config.options.priorities) do 18 | priority_weights[p.name] = p.weight or 1 19 | end 20 | end 21 | 22 | local function save_todos() 23 | local save_path = M.current_save_path or config.options.save_path 24 | local file = io.open(save_path, "w") 25 | if file then 26 | file:write(vim.fn.json_encode(M.todos)) 27 | file:close() 28 | end 29 | end 30 | 31 | -- Expose it as part of the module 32 | M.save_todos = save_todos 33 | M.save_todos_to_current_path = function() 34 | local save_path = M.current_save_path or config.options.save_path 35 | local file = io.open(save_path, "w") 36 | if file then 37 | file:write(vim.fn.json_encode(M.todos)) 38 | file:close() 39 | end 40 | end 41 | 42 | -- Get git root directory 43 | function M.get_git_root() 44 | local devnull = (vim.uv.os_uname().sysname == "Windows_NT") and "NUL" or "/dev/null" 45 | local handle = io.popen("git rev-parse --show-toplevel 2>" .. devnull) 46 | if not handle then 47 | return nil 48 | end 49 | 50 | local result = handle:read("*a") 51 | handle:close() 52 | 53 | if result and result ~= "" then 54 | return vim.trim(result) 55 | end 56 | 57 | return nil 58 | end 59 | 60 | -- Get project todo file path 61 | function M.get_project_todo_path() 62 | local git_root = M.get_git_root() 63 | if not git_root then 64 | return nil 65 | end 66 | 67 | return git_root .. "/" .. config.options.per_project.default_filename 68 | end 69 | 70 | -- Check if project todo file exists 71 | function M.project_todo_exists() 72 | local path = M.get_project_todo_path() 73 | if not path then 74 | return false 75 | end 76 | 77 | local file = io.open(path, "r") 78 | if file then 79 | file:close() 80 | return true 81 | end 82 | return false 83 | end 84 | 85 | -- Check if project has todos (file exists and contains todos) 86 | function M.has_project_todos() 87 | -- Check if we're in a git repository 88 | local git_root = M.get_git_root() 89 | if not git_root then 90 | return false 91 | end 92 | 93 | -- Check if project todo file exists 94 | local path = M.get_project_todo_path() 95 | if not path then 96 | return false 97 | end 98 | 99 | -- Check if file exists and has content 100 | local file = io.open(path, "r") 101 | if not file then 102 | return false 103 | end 104 | 105 | local content = file:read("*all") 106 | file:close() 107 | 108 | -- Check if file has actual todos 109 | if not content or content == "" then 110 | return false 111 | end 112 | 113 | -- Try to parse the JSON content 114 | local success, todos = pcall(vim.fn.json_decode, content) 115 | if not success or not todos or type(todos) ~= "table" or #todos == 0 then 116 | return false 117 | end 118 | 119 | return true 120 | end 121 | 122 | -- Load todos from specific path 123 | function M.load_todos_from_path(path) 124 | M.current_save_path = path 125 | 126 | -- Set context based on path 127 | local git_root = M.get_git_root() 128 | if git_root then 129 | M.current_context = vim.fn.fnamemodify(git_root, ":t") 130 | else 131 | M.current_context = "project" 132 | end 133 | 134 | update_priority_weights() 135 | local file = io.open(path, "r") 136 | if file then 137 | local content = file:read("*all") 138 | file:close() 139 | if content and content ~= "" then 140 | M.todos = vim.fn.json_decode(content) 141 | -- Migrate existing todos to new format 142 | M.migrate_todos() 143 | else 144 | M.todos = {} 145 | end 146 | else 147 | M.todos = {} 148 | end 149 | end 150 | 151 | -- Get window title based on current context 152 | function M.get_window_title() 153 | if M.current_context == "global" then 154 | return " Global to-dos " 155 | else 156 | return " " .. M.current_context .. " to-dos " 157 | end 158 | end 159 | 160 | -- Add gitignore entry 161 | function M.add_to_gitignore(filename) 162 | local git_root = M.get_git_root() 163 | if not git_root then 164 | return false, "Not in a git repository" 165 | end 166 | 167 | local gitignore_path = git_root .. "/.gitignore" 168 | local file = io.open(gitignore_path, "r") 169 | local content = "" 170 | 171 | if file then 172 | content = file:read("*all") 173 | file:close() 174 | 175 | -- Check if already ignored 176 | if content:find(filename, 1, true) then 177 | return true, "Already in .gitignore" 178 | end 179 | end 180 | 181 | -- Append to gitignore 182 | file = io.open(gitignore_path, "a") 183 | if file then 184 | if content ~= "" and not content:match("\n$") then 185 | file:write("\n") 186 | end 187 | file:write(filename .. "\n") 188 | file:close() 189 | return true, "Added to .gitignore" 190 | end 191 | 192 | return false, "Failed to write to .gitignore" 193 | end 194 | 195 | function M.load_todos() 196 | M.current_save_path = config.options.save_path 197 | M.current_context = "global" 198 | update_priority_weights() 199 | local file = io.open(M.current_save_path, "r") 200 | if file then 201 | local content = file:read("*all") 202 | file:close() 203 | if content and content ~= "" then 204 | M.todos = vim.fn.json_decode(content) 205 | -- Migrate existing todos to new format 206 | M.migrate_todos() 207 | else 208 | M.todos = {} 209 | end 210 | else 211 | M.todos = {} 212 | end 213 | end 214 | 215 | -- Migrate existing todos to support nested structure 216 | function M.migrate_todos() 217 | for i, todo in ipairs(M.todos) do 218 | -- Add unique ID if missing 219 | if not todo.id then 220 | todo.id = os.time() .. "_" .. i .. "_" .. math.random(1000, 9999) 221 | end 222 | 223 | -- Add nesting fields if missing 224 | if todo.parent_id == nil then 225 | todo.parent_id = nil 226 | end 227 | if todo.depth == nil then 228 | todo.depth = 0 229 | end 230 | end 231 | -- Save migrated data 232 | save_todos() 233 | end 234 | 235 | function M.add_todo(text, priority_names) 236 | -- Generate unique ID using timestamp and random component 237 | local unique_id = os.time() .. "_" .. math.random(1000, 9999) 238 | 239 | table.insert(M.todos, { 240 | id = unique_id, 241 | text = text, 242 | done = false, 243 | in_progress = false, 244 | category = text:match("#(%w+)") or "", 245 | created_at = os.time(), 246 | priorities = priority_names, 247 | estimated_hours = nil, -- Add estimated_hours field 248 | notes = "", 249 | parent_id = nil, -- For nested tasks: ID of parent task 250 | depth = 0, -- Nesting depth (0 = top level, 1 = first level subtask, etc.) 251 | }) 252 | save_todos() 253 | end 254 | 255 | -- Add nested todo under a parent task 256 | function M.add_nested_todo(text, parent_index, priority_names) 257 | -- Check if nested tasks are enabled 258 | if not config.options.nested_tasks or not config.options.nested_tasks.enabled then 259 | return false, "Nested tasks are disabled" 260 | end 261 | 262 | if not M.todos[parent_index] then 263 | return false, "Parent todo not found" 264 | end 265 | 266 | local parent_todo = M.todos[parent_index] 267 | local parent_depth = parent_todo.depth or 0 268 | local parent_id = parent_todo.id -- Use stable ID instead of index 269 | 270 | -- Generate unique ID for nested todo 271 | local unique_id = os.time() .. "_" .. math.random(1000, 9999) 272 | 273 | -- Create nested todo 274 | local nested_todo = { 275 | id = unique_id, 276 | text = text, 277 | done = false, 278 | in_progress = false, 279 | category = text:match("#(%w+)") or "", 280 | created_at = os.time(), 281 | priorities = priority_names, 282 | estimated_hours = nil, 283 | notes = "", 284 | parent_id = parent_id, 285 | depth = parent_depth + 1, 286 | } 287 | 288 | -- Insert after parent and its existing children 289 | local insert_position = parent_index + 1 290 | while insert_position <= #M.todos and M.todos[insert_position].parent_id == parent_id do 291 | insert_position = insert_position + 1 292 | end 293 | 294 | table.insert(M.todos, insert_position, nested_todo) 295 | save_todos() 296 | return true 297 | end 298 | 299 | function M.toggle_todo(index) 300 | if M.todos[index] then 301 | -- Cycle through states: pending -> in_progress -> done -> pending 302 | if not M.todos[index].in_progress and not M.todos[index].done then 303 | -- From pending to in_progress 304 | M.todos[index].in_progress = true 305 | elseif M.todos[index].in_progress then 306 | -- From in_progress to done 307 | M.todos[index].in_progress = false 308 | M.todos[index].done = true 309 | -- Track completion time 310 | M.todos[index].completed_at = os.time() 311 | else 312 | -- From done back to pending 313 | M.todos[index].done = false 314 | M.todos[index].completed_at = nil 315 | end 316 | save_todos() 317 | end 318 | end 319 | 320 | -- Parse date string in the format MM/DD/YYYY 321 | local function parse_date(date_str, format) 322 | local month, day, year = date_str:match("^(%d%d?)/(%d%d?)/(%d%d%d%d)$") 323 | 324 | print(month, day, year) 325 | if not (month and day and year) then 326 | return nil, "Invalid date format" 327 | end 328 | 329 | month, day, year = tonumber(month), tonumber(day), tonumber(year) 330 | 331 | local function is_leap_year(y) 332 | return (y % 4 == 0 and y % 100 ~= 0) or (y % 400 == 0) 333 | end 334 | 335 | -- Handle days and months, with leap year check 336 | local days_in_month = { 337 | 31, -- January 338 | is_leap_year(year) and 29 or 28, -- February 339 | 31, -- March 340 | 30, -- April 341 | 31, -- May 342 | 30, -- June 343 | 31, -- July 344 | 31, -- August 345 | 30, -- September 346 | 31, -- October 347 | 30, -- November 348 | 31, -- December 349 | } 350 | if month < 1 or month > 12 then 351 | return nil, "Invalid month" 352 | end 353 | if day < 1 or day > days_in_month[month] then 354 | return nil, "Invalid day for month" 355 | end 356 | 357 | -- Convert to Unix timestamp 358 | local timestamp = os.time({ year = year, month = month, day = day, hour = 0, min = 0, sec = 0 }) 359 | return timestamp 360 | end 361 | 362 | function M.add_due_date(index, date_str) 363 | if not M.todos[index] then 364 | return false, "Todo not found" 365 | end 366 | 367 | local timestamp, err = parse_date(date_str) 368 | if timestamp then 369 | local date_table = os.date("*t", timestamp) 370 | date_table.hour = 23 371 | date_table.min = 59 372 | date_table.sec = 59 373 | timestamp = os.time(date_table) 374 | 375 | M.todos[index].due_at = timestamp 376 | M.save_todos() 377 | return true 378 | else 379 | return false, err 380 | end 381 | end 382 | 383 | function M.remove_due_date(index) 384 | if M.todos[index] then 385 | M.todos[index].due_at = nil 386 | M.save_todos() 387 | return true 388 | end 389 | return false 390 | end 391 | 392 | -- Add estimated completion time to a todo 393 | function M.add_time_estimation(index, hours) 394 | if not M.todos[index] then 395 | return false, "Todo not found" 396 | end 397 | 398 | if type(hours) ~= "number" or hours < 0 then 399 | return false, "Invalid time estimation" 400 | end 401 | 402 | M.todos[index].estimated_hours = hours 403 | M.save_todos() 404 | return true 405 | end 406 | 407 | -- Remove estimated completion time from a todo 408 | function M.remove_time_estimation(index) 409 | if M.todos[index] then 410 | M.todos[index].estimated_hours = nil 411 | M.save_todos() 412 | return true 413 | end 414 | return false 415 | end 416 | 417 | function M.get_all_tags() 418 | local tags = {} 419 | local seen = {} 420 | for _, todo in ipairs(M.todos) do 421 | -- Remove unused todo_tags variable 422 | for tag in todo.text:gmatch("#(%w+)") do 423 | if not seen[tag] then 424 | seen[tag] = true 425 | table.insert(tags, tag) 426 | end 427 | end 428 | end 429 | table.sort(tags) 430 | return tags 431 | end 432 | 433 | function M.set_filter(tag) 434 | M.active_filter = tag 435 | end 436 | 437 | function M.delete_todo(index) 438 | if M.todos[index] then 439 | table.remove(M.todos, index) 440 | save_todos() 441 | end 442 | end 443 | 444 | function M.delete_completed() 445 | if M.nested_tasks_enabled() then 446 | M.delete_completed_structure_aware() 447 | else 448 | M.delete_completed_flat() 449 | end 450 | end 451 | 452 | -- Original delete completed (preserves old behavior when nested tasks disabled) 453 | function M.delete_completed_flat() 454 | local remaining_todos = {} 455 | for _, todo in ipairs(M.todos) do 456 | if not todo.done then 457 | table.insert(remaining_todos, todo) 458 | end 459 | end 460 | M.todos = remaining_todos 461 | save_todos() 462 | end 463 | 464 | -- Structure-aware delete completed that handles orphaned nested tasks 465 | function M.delete_completed_structure_aware() 466 | local remaining_todos = {} 467 | local orphaned_todos = {} 468 | 469 | -- First pass: collect remaining todos and identify orphans 470 | for _, todo in ipairs(M.todos) do 471 | if not todo.done then 472 | table.insert(remaining_todos, todo) 473 | elseif todo.parent_id then 474 | -- This is a completed nested task, check if parent still exists 475 | local parent_exists = false 476 | for _, remaining in ipairs(remaining_todos) do 477 | if remaining.id == todo.parent_id then 478 | parent_exists = true 479 | break 480 | end 481 | end 482 | -- If parent doesn't exist yet, we'll check in the final list 483 | table.insert(orphaned_todos, todo) 484 | end 485 | end 486 | 487 | -- Second pass: handle orphaned nested tasks 488 | for _, orphan in ipairs(orphaned_todos) do 489 | local parent_exists = false 490 | for _, remaining in ipairs(remaining_todos) do 491 | if remaining.id == orphan.parent_id then 492 | parent_exists = true 493 | break 494 | end 495 | end 496 | 497 | if parent_exists then 498 | -- Parent still exists, keep the orphaned task 499 | table.insert(remaining_todos, orphan) 500 | else 501 | -- Parent was deleted, promote orphan to top-level if not completed 502 | if not orphan.done then 503 | orphan.parent_id = nil 504 | orphan.depth = 0 505 | table.insert(remaining_todos, orphan) 506 | end 507 | end 508 | end 509 | 510 | M.todos = remaining_todos 511 | save_todos() 512 | end 513 | 514 | -- Helper function for hashing a todo object 515 | local function gen_hash(todo) 516 | local todo_string = vim.inspect(todo) 517 | return vim.fn.sha256(todo_string) 518 | end 519 | 520 | -- Remove duplicate todos based on hash 521 | function M.remove_duplicates() 522 | local seen = {} 523 | local uniques = {} 524 | local removed = 0 525 | 526 | for _, todo in ipairs(M.todos) do 527 | if type(todo) == "table" then 528 | local hash = gen_hash(todo) 529 | if not seen[hash] then 530 | seen[hash] = true 531 | table.insert(uniques, todo) 532 | else 533 | removed = removed + 1 534 | end 535 | end 536 | end 537 | 538 | M.todos = uniques 539 | save_todos() 540 | return tostring(removed) 541 | end 542 | 543 | -- Calculate priority score for a todo item 544 | function M.get_priority_score(todo) 545 | if todo.done then 546 | return 0 547 | end 548 | 549 | if not config.options.priorities or #config.options.priorities == 0 then 550 | return 0 551 | end 552 | 553 | -- Calculate base score from priorities 554 | local score = 0 555 | if todo.priorities and type(todo.priorities) == "table" then 556 | for _, priority_name in ipairs(todo.priorities) do 557 | score = score + (priority_weights[priority_name] or 0) 558 | end 559 | end 560 | 561 | -- Calculate estimated completion time multiplier 562 | local ect_multiplier = 1 563 | if todo.estimated_hours and todo.estimated_hours > 0 then 564 | ect_multiplier = 1 / (todo.estimated_hours * config.options.hour_score_value) 565 | end 566 | 567 | return score * ect_multiplier 568 | end 569 | 570 | -- Helper function to check if nested tasks are enabled 571 | function M.nested_tasks_enabled() 572 | return config.options.nested_tasks 573 | and config.options.nested_tasks.enabled 574 | and config.options.nested_tasks.retain_structure_on_complete 575 | end 576 | 577 | function M.sort_todos() 578 | -- Check if nested tasks are enabled and structure should be preserved 579 | if M.nested_tasks_enabled() then 580 | M.sort_todos_with_structure() 581 | else 582 | M.sort_todos_flat() 583 | end 584 | end 585 | 586 | -- Original flat sorting (preserves old behavior when nested tasks disabled) 587 | function M.sort_todos_flat() 588 | table.sort(M.todos, function(a, b) 589 | -- First sort by completion status 590 | if a.done ~= b.done then 591 | return not a.done -- Undone items come first 592 | end 593 | 594 | -- For completed items, sort by completion time (most recent first) 595 | if config.options.done_sort_by_completed_time and a.done and b.done then 596 | -- Use completed_at if available, otherwise fall back to created_at 597 | local a_time = a.completed_at or a.created_at or 0 598 | local b_time = b.completed_at or b.created_at or 0 599 | return a_time > b_time -- Most recently completed first 600 | end 601 | 602 | -- Then sort by priority score if configured 603 | if config.options.priorities and #config.options.priorities > 0 then 604 | local a_score = M.get_priority_score(a) 605 | local b_score = M.get_priority_score(b) 606 | 607 | if a_score ~= b_score then 608 | return a_score > b_score 609 | end 610 | end 611 | 612 | -- Then sort by due date if both have one 613 | if a.due_at and b.due_at then 614 | if a.due_at ~= b.due_at then 615 | return a.due_at < b.due_at 616 | end 617 | elseif a.due_at then 618 | return true -- Items with due date come first 619 | elseif b.due_at then 620 | return false 621 | end 622 | 623 | -- Finally sort by creation time 624 | return a.created_at < b.created_at 625 | end) 626 | end 627 | 628 | -- Structure-preserving sorting for nested tasks (supports multi-level nesting) 629 | function M.sort_todos_with_structure() 630 | -- Group todos by their hierarchical structure 631 | local top_level = {} 632 | local nested_groups = {} 633 | 634 | -- Separate top-level todos and group nested ones by parent 635 | for i, todo in ipairs(M.todos) do 636 | if not todo.parent_id then 637 | -- Top-level todo 638 | table.insert(top_level, { todo = todo, original_index = i }) 639 | else 640 | -- Nested todo, group by its direct parent_id 641 | if not nested_groups[todo.parent_id] then 642 | nested_groups[todo.parent_id] = {} 643 | end 644 | table.insert(nested_groups[todo.parent_id], { todo = todo, original_index = i }) 645 | end 646 | end 647 | 648 | -- Sort top-level todos 649 | table.sort(top_level, M.compare_todos) 650 | 651 | -- Sort each nested group using the appropriate comparison 652 | for _, children in pairs(nested_groups) do 653 | if config.options.nested_tasks.move_completed_to_end then 654 | -- Sort children but keep completed at end within their group 655 | table.sort(children, M.compare_todos) 656 | else 657 | -- Sort children without moving completed ones 658 | table.sort(children, function(a, b) 659 | return M.compare_todos_ignore_completion(a, b) 660 | end) 661 | end 662 | end 663 | 664 | -- Recursively add a parent and all of its descendants in depth-first order 665 | local new_todos = {} 666 | local function add_with_children(parent_todo) 667 | -- Insert the parent itself 668 | table.insert(new_todos, parent_todo) 669 | 670 | -- Then insert all its direct children (if any), each followed by their own children 671 | local children = nested_groups[parent_todo.id] 672 | if children then 673 | for _, child_data in ipairs(children) do 674 | add_with_children(child_data.todo) 675 | end 676 | end 677 | end 678 | 679 | for _, parent_data in ipairs(top_level) do 680 | add_with_children(parent_data.todo) 681 | end 682 | 683 | M.todos = new_todos 684 | end 685 | 686 | -- Comparison function for todos 687 | function M.compare_todos(a, b) 688 | local todo_a = a.todo 689 | local todo_b = b.todo 690 | 691 | -- First sort by completion status 692 | if todo_a.done ~= todo_b.done then 693 | return not todo_a.done -- Undone items come first 694 | end 695 | 696 | -- For completed items, sort by completion time (most recent first) 697 | if config.options.done_sort_by_completed_time and todo_a.done and todo_b.done then 698 | local a_time = todo_a.completed_at or todo_a.created_at or 0 699 | local b_time = todo_b.completed_at or todo_b.created_at or 0 700 | return a_time > b_time 701 | end 702 | 703 | -- Then sort by priority score if configured 704 | if config.options.priorities and #config.options.priorities > 0 then 705 | local a_score = M.get_priority_score(todo_a) 706 | local b_score = M.get_priority_score(todo_b) 707 | 708 | if a_score ~= b_score then 709 | return a_score > b_score 710 | end 711 | end 712 | 713 | -- Then sort by due date if both have one 714 | if todo_a.due_at and todo_b.due_at then 715 | if todo_a.due_at ~= todo_b.due_at then 716 | return todo_a.due_at < todo_b.due_at 717 | end 718 | elseif todo_a.due_at then 719 | return true 720 | elseif todo_b.due_at then 721 | return false 722 | end 723 | 724 | -- Finally sort by creation time 725 | return todo_a.created_at < todo_b.created_at 726 | end 727 | 728 | -- Comparison function that ignores completion status (for nested tasks when move_completed_to_end is false) 729 | function M.compare_todos_ignore_completion(a, b) 730 | local todo_a = a.todo 731 | local todo_b = b.todo 732 | 733 | -- Sort by priority score if configured 734 | if config.options.priorities and #config.options.priorities > 0 then 735 | local a_score = M.get_priority_score(todo_a) 736 | local b_score = M.get_priority_score(todo_b) 737 | 738 | if a_score ~= b_score then 739 | return a_score > b_score 740 | end 741 | end 742 | 743 | -- Then sort by due date if both have one 744 | if todo_a.due_at and todo_b.due_at then 745 | if todo_a.due_at ~= todo_b.due_at then 746 | return todo_a.due_at < todo_b.due_at 747 | end 748 | elseif todo_a.due_at then 749 | return true 750 | elseif todo_b.due_at then 751 | return false 752 | end 753 | 754 | -- Finally sort by creation time 755 | return todo_a.created_at < todo_b.created_at 756 | end 757 | 758 | function M.rename_tag(old_tag, new_tag) 759 | for _, todo in ipairs(M.todos) do 760 | todo.text = todo.text:gsub("#" .. old_tag, "#" .. new_tag) 761 | end 762 | save_todos() 763 | end 764 | 765 | function M.delete_tag(tag) 766 | local remaining_todos = {} 767 | for _, todo in ipairs(M.todos) do 768 | todo.text = todo.text:gsub("#" .. tag .. "(%s)", "%1") 769 | todo.text = todo.text:gsub("#" .. tag .. "$", "") 770 | table.insert(remaining_todos, todo) 771 | end 772 | M.todos = remaining_todos 773 | save_todos() 774 | end 775 | 776 | function M.search_todos(query) 777 | local results = {} 778 | query = query:lower() 779 | 780 | for index, todo in ipairs(M.todos) do 781 | if todo.text:lower():find(query) then 782 | table.insert(results, { lnum = index, todo = todo }) 783 | end 784 | end 785 | 786 | return results 787 | end 788 | 789 | function M.import_todos(file_path) 790 | local file = io.open(file_path, "r") 791 | if not file then 792 | return false, "Could not open file: " .. file_path 793 | end 794 | 795 | local content = file:read("*all") 796 | file:close() 797 | 798 | local status, imported_todos = pcall(vim.fn.json_decode, content) 799 | if not status then 800 | return false, "Error parsing JSON file" 801 | end 802 | 803 | -- merge imported todos with existing todos 804 | for _, todo in ipairs(imported_todos) do 805 | table.insert(M.todos, todo) 806 | end 807 | 808 | M.sort_todos() 809 | M.save_todos() 810 | 811 | return true, string.format("Imported %d todos", #imported_todos) 812 | end 813 | 814 | function M.export_todos(file_path) 815 | local file = io.open(file_path, "w") 816 | if not file then 817 | return false, "Could not open file for writing: " .. file_path 818 | end 819 | 820 | local json_content = vim.fn.json_encode(M.todos) 821 | file:write(json_content) 822 | file:close() 823 | 824 | return true, string.format("Exported %d todos to %s", #M.todos, file_path) 825 | end 826 | 827 | -- Helper function to get the priority-based highlights 828 | local function get_priority_highlights(todo) 829 | -- First check if the todo is done 830 | if todo.done then 831 | return "DooingDone" 832 | end 833 | 834 | -- Then check if it's in progress 835 | if todo.in_progress then 836 | return "DooingInProgress" 837 | end 838 | 839 | -- If there are no priorities configured, return the default pending highlight 840 | if not config.options.priorities or #config.options.priorities == 0 then 841 | return "DooingPending" 842 | end 843 | 844 | -- If the todo has priorities, check priority groups 845 | if todo.priorities and #todo.priorities > 0 and config.options.priority_groups then 846 | -- Sort priority groups by number of members (descending) 847 | local sorted_groups = {} 848 | for name, group in pairs(config.options.priority_groups) do 849 | table.insert(sorted_groups, { name = name, group = group }) 850 | end 851 | table.sort(sorted_groups, function(a, b) 852 | return #a.group.members > #b.group.members 853 | end) 854 | 855 | -- Check each priority group 856 | for _, group_data in ipairs(sorted_groups) do 857 | local group = group_data.group 858 | local all_members_match = true 859 | 860 | -- Check if all group members are in todo's priorities 861 | for _, member in ipairs(group.members) do 862 | local found = false 863 | for _, priority in ipairs(todo.priorities) do 864 | if priority == member then 865 | found = true 866 | break 867 | end 868 | end 869 | if not found then 870 | all_members_match = false 871 | break 872 | end 873 | end 874 | 875 | if all_members_match then 876 | return group.hl_group or "DooingPending" 877 | end 878 | end 879 | end 880 | 881 | -- Default to pending highlight if no other conditions met 882 | return "DooingPending" 883 | end 884 | 885 | -- Delete todo with confirmation for incomplete items 886 | function M.delete_todo_with_confirmation(todo_index, win_id, calendar, callback) 887 | local current_todo = M.todos[todo_index] 888 | if not current_todo then 889 | return 890 | end 891 | 892 | -- If todo is completed, delete without confirmation 893 | if current_todo.done then 894 | M.delete_todo(todo_index) 895 | if callback then 896 | callback() 897 | end 898 | return 899 | end 900 | 901 | -- Create confirmation buffer 902 | local confirm_buf = vim.api.nvim_create_buf(false, true) 903 | 904 | -- Format todo text with due date 905 | local safe_todo_text = current_todo.text:gsub("\n", " ") 906 | local todo_display_text = " ○ " .. safe_todo_text 907 | local lang = calendar.get_language() 908 | lang = calendar.MONTH_NAMES[lang] and lang or "en" 909 | 910 | if current_todo.due_at then 911 | local date = os.date("*t", current_todo.due_at) 912 | local month = calendar.MONTH_NAMES[lang][date.month] 913 | 914 | local formatted_date 915 | if lang == "pt" then 916 | formatted_date = string.format("%d de %s de %d", date.day, month, date.year) 917 | else 918 | formatted_date = string.format("%s %d, %d", month, date.day, date.year) 919 | end 920 | todo_display_text = todo_display_text .. " [@ " .. formatted_date .. "]" 921 | 922 | -- Add overdue status if applicable 923 | if current_todo.due_at < os.time() then 924 | todo_display_text = todo_display_text .. " [OVERDUE]" 925 | end 926 | end 927 | 928 | local lines = { 929 | "", 930 | "", 931 | todo_display_text, 932 | "", 933 | "", 934 | "", 935 | } 936 | 937 | -- Set buffer content 938 | vim.api.nvim_buf_set_lines(confirm_buf, 0, -1, false, lines) 939 | vim.api.nvim_buf_set_option(confirm_buf, "modifiable", false) 940 | vim.api.nvim_buf_set_option(confirm_buf, "buftype", "nofile") 941 | 942 | -- Calculate window dimensions 943 | local ui = vim.api.nvim_list_uis()[1] 944 | local width = 60 945 | local height = #lines 946 | local row = math.floor((ui.height - height) / 2) 947 | local col = math.floor((ui.width - width) / 2) 948 | 949 | -- Create confirmation window 950 | local confirm_win = vim.api.nvim_open_win(confirm_buf, true, { 951 | relative = "editor", 952 | row = row, 953 | col = col, 954 | width = width, 955 | height = height, 956 | style = "minimal", 957 | border = config.options.window.border, 958 | title = " Delete incomplete todo? ", 959 | title_pos = "center", 960 | footer = " [Y]es - [N]o ", 961 | footer_pos = "center", 962 | noautocmd = true, 963 | }) 964 | 965 | -- Window options 966 | vim.api.nvim_win_set_option(confirm_win, "cursorline", false) 967 | vim.api.nvim_win_set_option(confirm_win, "cursorcolumn", false) 968 | vim.api.nvim_win_set_option(confirm_win, "number", false) 969 | vim.api.nvim_win_set_option(confirm_win, "relativenumber", false) 970 | vim.api.nvim_win_set_option(confirm_win, "signcolumn", "no") 971 | vim.api.nvim_win_set_option(confirm_win, "mousemoveevent", false) 972 | 973 | -- Add highlights 974 | local ns = vim.api.nvim_create_namespace("dooing_confirm") 975 | vim.api.nvim_buf_add_highlight(confirm_buf, ns, "WarningMsg", 0, 0, -1) 976 | 977 | local main_hl = get_priority_highlights(current_todo) 978 | vim.api.nvim_buf_add_highlight(confirm_buf, ns, main_hl, 2, 0, #todo_display_text) 979 | 980 | -- Tag highlights 981 | for tag in current_todo.text:gmatch("#(%w+)") do 982 | local start_idx = todo_display_text:find("#" .. tag) 983 | if start_idx then 984 | vim.api.nvim_buf_add_highlight(confirm_buf, ns, "Type", 2, start_idx - 1, start_idx + #tag) 985 | end 986 | end 987 | 988 | -- Due date highlight 989 | if current_todo.due_at then 990 | local due_date_start = todo_display_text:find("%[@") 991 | local overdue_start = todo_display_text:find("%[OVERDUE%]") 992 | 993 | if due_date_start then 994 | vim.api.nvim_buf_add_highlight( 995 | confirm_buf, 996 | ns, 997 | "Comment", 998 | 2, 999 | due_date_start - 1, 1000 | overdue_start and overdue_start - 1 or -1 1001 | ) 1002 | end 1003 | 1004 | if overdue_start then 1005 | vim.api.nvim_buf_add_highlight(confirm_buf, ns, "ErrorMsg", 2, overdue_start - 1, -1) 1006 | end 1007 | end 1008 | 1009 | -- Options highlights 1010 | vim.api.nvim_buf_add_highlight(confirm_buf, ns, "Question", 4, 1, 2) 1011 | vim.api.nvim_buf_add_highlight(confirm_buf, ns, "Normal", 4, 0, 1) 1012 | vim.api.nvim_buf_add_highlight(confirm_buf, ns, "Normal", 4, 2, 5) 1013 | vim.api.nvim_buf_add_highlight(confirm_buf, ns, "Normal", 4, 5, 9) 1014 | vim.api.nvim_buf_add_highlight(confirm_buf, ns, "Question", 4, 10, 11) 1015 | vim.api.nvim_buf_add_highlight(confirm_buf, ns, "Normal", 4, 9, 10) 1016 | vim.api.nvim_buf_add_highlight(confirm_buf, ns, "Normal", 4, 11, 12) 1017 | 1018 | -- Prevent cursor movement 1019 | local movement_keys = { 1020 | "h", 1021 | "j", 1022 | "k", 1023 | "l", 1024 | "", 1025 | "", 1026 | "", 1027 | "", 1028 | "", 1029 | "", 1030 | "", 1031 | "", 1032 | "w", 1033 | "b", 1034 | "e", 1035 | "ge", 1036 | "0", 1037 | "$", 1038 | "^", 1039 | "gg", 1040 | "G", 1041 | } 1042 | 1043 | local opts = { buffer = confirm_buf, nowait = true } 1044 | for _, key in ipairs(movement_keys) do 1045 | vim.keymap.set("n", key, function() end, opts) 1046 | end 1047 | 1048 | -- Close confirmation window 1049 | local function close_confirm() 1050 | if vim.api.nvim_win_is_valid(confirm_win) then 1051 | vim.api.nvim_win_close(confirm_win, true) 1052 | vim.api.nvim_set_current_win(win_id) 1053 | end 1054 | end 1055 | 1056 | -- Handle responses 1057 | vim.keymap.set("n", "y", function() 1058 | close_confirm() 1059 | M.delete_todo(todo_index) 1060 | if callback then 1061 | callback() 1062 | end 1063 | end, opts) 1064 | 1065 | vim.keymap.set("n", "Y", function() 1066 | close_confirm() 1067 | M.delete_todo(todo_index) 1068 | if callback then 1069 | callback() 1070 | end 1071 | end, opts) 1072 | 1073 | vim.keymap.set("n", "n", close_confirm, opts) 1074 | vim.keymap.set("n", "N", close_confirm, opts) 1075 | vim.keymap.set("n", "q", close_confirm, opts) 1076 | vim.keymap.set("n", "", close_confirm, opts) 1077 | 1078 | -- Auto-close on buffer leave 1079 | vim.api.nvim_create_autocmd("BufLeave", { 1080 | buffer = confirm_buf, 1081 | callback = close_confirm, 1082 | once = true, 1083 | }) 1084 | end 1085 | -- In state.lua, add these at the top with other local variables: 1086 | local deleted_todos = {} 1087 | local MAX_UNDO_HISTORY = 100 1088 | 1089 | -- Get count of due and overdue todos 1090 | function M.get_due_count() 1091 | local now = os.time() 1092 | local today_start = os.time(os.date("*t", now)) 1093 | local today_end = today_start + 86400 -- 24 hours 1094 | 1095 | local due_today = 0 1096 | local overdue = 0 1097 | 1098 | for _, todo in ipairs(M.todos) do 1099 | if todo.due_at and not todo.done then 1100 | if todo.due_at < today_start then 1101 | overdue = overdue + 1 1102 | elseif todo.due_at <= today_end then 1103 | due_today = due_today + 1 1104 | end 1105 | end 1106 | end 1107 | 1108 | return { 1109 | overdue = overdue, 1110 | due_today = due_today, 1111 | total = overdue + due_today, 1112 | } 1113 | end 1114 | 1115 | -- Show due items notification 1116 | function M.show_due_notification() 1117 | local due_count = M.get_due_count() 1118 | 1119 | if due_count.total == 0 then 1120 | return -- Don't show notification if nothing is due 1121 | end 1122 | 1123 | local config = require("dooing.config") 1124 | if not config.options.due_notifications or not config.options.due_notifications.enabled then 1125 | return 1126 | end 1127 | 1128 | local message = string.format("%d item%s due", due_count.total, due_count.total == 1 and "" or "s") 1129 | 1130 | vim.notify(message, vim.log.levels.ERROR, { title = "Dooing" }) 1131 | end 1132 | 1133 | -- Add these functions to state.lua: 1134 | function M.store_deleted_todo(todo, index) 1135 | table.insert(deleted_todos, 1, { 1136 | todo = vim.deepcopy(todo), 1137 | index = index, 1138 | timestamp = os.time(), 1139 | }) 1140 | -- Keep only the last MAX_UNDO_HISTORY deletions 1141 | if #deleted_todos > MAX_UNDO_HISTORY then 1142 | table.remove(deleted_todos) 1143 | end 1144 | end 1145 | 1146 | function M.undo_delete() 1147 | if #deleted_todos == 0 then 1148 | vim.notify("No more todos to restore", vim.log.levels.INFO) 1149 | return false 1150 | end 1151 | 1152 | local last_deleted = table.remove(deleted_todos, 1) 1153 | 1154 | -- If index is greater than current todos length, append to end 1155 | local insert_index = math.min(last_deleted.index, #M.todos + 1) 1156 | 1157 | -- Insert the todo at the original position 1158 | table.insert(M.todos, insert_index, last_deleted.todo) 1159 | 1160 | -- Save the updated todos 1161 | M.save_todos() 1162 | 1163 | -- Return true to indicate successful undo 1164 | return true 1165 | end 1166 | 1167 | -- Modify the delete_todo function in state.lua: 1168 | function M.delete_todo(index) 1169 | if M.todos[index] then 1170 | local todo = M.todos[index] 1171 | M.store_deleted_todo(todo, index) 1172 | table.remove(M.todos, index) 1173 | save_todos() 1174 | end 1175 | end 1176 | 1177 | -- Add to delete_completed in state.lua: 1178 | function M.delete_completed() 1179 | local remaining_todos = {} 1180 | local removed_count = 0 1181 | 1182 | for i, todo in ipairs(M.todos) do 1183 | if todo.done then 1184 | M.store_deleted_todo(todo, i - removed_count) 1185 | removed_count = removed_count + 1 1186 | else 1187 | table.insert(remaining_todos, todo) 1188 | end 1189 | end 1190 | 1191 | M.todos = remaining_todos 1192 | save_todos() 1193 | end 1194 | 1195 | return M 1196 | --------------------------------------------------------------------------------