my neovim config, who would've thought
1local config = {
2 label = "done:%Y%m%d-%H%M",
3 archive_header = "# Archive",
4 tasks_file = vim.fn.stdpath "config" .. "/tasks.json",
5}
6
7local ns = vim.api.nvim_create_namespace "scratch.tasks"
8
9---@return string[]
10local function get_tasks_files()
11 local f = io.open(config.tasks_file, "r")
12 if not f then
13 error("cannot read " .. config.tasks_file)
14 end
15 return vim.json.decode(f:read "*a")["files"] or error "'files' is not found"
16end
17
18local function display_filename(str)
19 local res = vim.fs.basename(str)
20 res = res:gsub("%.%w+$", "")
21 return res
22end
23
24---@param str string
25local function is_task(str)
26 return str:match "^%s*%- %[[x ]%]" ~= nil
27end
28
29---@param str string
30local function is_task_labled(str)
31 return str:match "^%s*%- %[[x ]%] `" ~= nil
32end
33
34---@param str string
35local function has_next_tag(str)
36 return str:match "%#n[ext]*" ~= nil
37end
38
39---@param str string
40local function is_task_complete(str)
41 return str:match "^(%s*%- )%[x%]" ~= nil
42end
43
44---@param str string
45local function remove_task_link(str)
46 local res = str:gsub("%[%[(.-)%]%]", "[%1]")
47 return res
48end
49
50---@param str string
51local function remove_next_tag(str)
52 local res = str:gsub(" %#n[ext]*", "")
53 return res
54end
55
56---@param str string
57local function to_complete_task(str)
58 local task_prefix = str:match "^(%s*%- %[[x ]%])"
59 if not task_prefix then
60 return nil
61 end
62
63 local label = os.date(config.label) --[[@as string]]
64 str = task_prefix .. " `" .. label .. "`" .. str:sub(#task_prefix + 1)
65 str = str:gsub("^(%s*%- )%[%s*%]", "%1[x]") -- mark task as complete
66 str = remove_task_link(str)
67 str = remove_next_tag(str)
68 str = str:gsub("%s+$", "") -- white space in the end
69 return str
70end
71
72---@param lines string[]
73---@return number? - Line of the heading, nil if not found
74local function find_archive_heading(lines)
75 return vim.iter(ipairs(lines)):find(function(lnum, line)
76 return line:match("^%s*" .. config.archive_header) ~= nil and lnum
77 end)
78end
79
80local tasks = {}
81function tasks.agenda()
82 local qf_items = vim
83 .iter(get_tasks_files())
84 :map(function(fname)
85 return vim
86 .iter(ipairs(vim.fn.readfile(fname)))
87 :filter(function(_, line)
88 return is_task(line) and has_next_tag(line)
89 end)
90 :map(function(lnum, line)
91 local task = remove_next_tag(line)
92 task = remove_task_link(task)
93 task = task:gsub("^%- %[ %] ", "") -- remove task prefix
94 task = task:gsub("`", "")
95
96 return {
97 lnum = lnum,
98 filename = fname,
99 text = task,
100 user_data = { filename = fname },
101 } --[[@as vim.quickfix.entry]]
102 end)
103 :totable()
104 end)
105 :flatten()
106 :totable()
107
108 vim.fn.setqflist({}, "r", {
109 nr = "$",
110 title = "scratch.tasks",
111 items = qf_items,
112 quickfixtextfunc = function(info)
113 local items = vim.fn.getqflist({ id = info.id, items = 1 }).items
114 local lines, highlights = {}, {}
115 for item = info.start_idx, info.end_idx do
116 local entry = items[item]
117 local fname = display_filename(entry.user_data.filename)
118 local fname_paded = fname .. string.rep(" ", 7 - #fname) .. " "
119
120 table.insert(lines, fname_paded .. entry.text)
121 table.insert(highlights, { fname_len = #fname_paded })
122 end
123
124 vim.schedule(function()
125 vim.api.nvim_buf_clear_namespace(0, ns, 0, -1)
126 for i, hl in ipairs(highlights) do
127 local line = info.start_idx + i - 2
128 vim.hl.range(0, ns, "qfFileName", { line, 0 }, { line, hl.fname_len })
129 vim.hl.range(0, ns, "Bold", { line, hl.fname_len }, { line, -1 })
130 end
131 end)
132
133 return lines
134 end,
135 })
136
137 vim.cmd.copen()
138end
139
140function tasks.complete()
141 local bufnr = vim.api.nvim_get_current_buf()
142 local task_idx = vim.api.nvim_win_get_cursor(0)[1]
143 local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
144 local task = lines[task_idx]
145
146 if not is_task(task) then
147 vim.notify("Not a task", vim.log.levels.ERROR)
148 return
149 end
150
151 if is_task_complete(task) and is_task_labled(task) then
152 vim.notify("Task already completed", vim.log.levels.ERROR)
153 return
154 end
155
156 local archived_heading = find_archive_heading(lines)
157 if archived_heading == nil then
158 table.insert(lines, "")
159 table.insert(lines, config.archive_header)
160 archived_heading = #lines
161 end
162
163 local completed_task = to_complete_task(task)
164 table.remove(lines, task_idx)
165 table.insert(lines, archived_heading, completed_task)
166 vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines)
167
168 vim.cmd.update()
169end
170
171function tasks.clear_archive()
172 local bufnr = vim.api.nvim_get_current_buf()
173 local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
174
175 local archived_heading = find_archive_heading(lines)
176 if not archived_heading then
177 vim.notify("Looks like there's no archive of tasks", vim.log.levels.ERROR)
178 end
179
180 -- minus 2 because = 1(the archive heading) + 1(empty line before it)
181 lines = vim.list_slice(lines, 1, archived_heading - 2)
182 vim.api.nvim_buf_set_lines(bufnr, 0, -1, true, lines)
183
184 vim.cmd.update()
185end
186
187return tasks