Problem: tasks only have a one-line description. There is no way to attach extended notes, checklists, or context to a task. Solution: add `ge` keymap to open a `pending://task/<id>` markdown buffer that replaces the task list in the same split. The buffer shows a read-only metadata header (status, priority, category, due, recurrence) rendered via extmarks, a `---` separator, and editable notes below. `:w` saves notes to a new top-level `notes` field on the task stored in the single `tasks.json`. `q` returns to the task list.
351 lines
8.7 KiB
Lua
351 lines
8.7 KiB
Lua
local config = require('pending.config')
|
|
local forge = require('pending.forge')
|
|
local parse = require('pending.parse')
|
|
|
|
---@class pending.ForgeLineMeta
|
|
---@field ref pending.ForgeRef
|
|
---@field cache? pending.ForgeCache
|
|
---@field col_start integer
|
|
---@field col_end integer
|
|
|
|
---@class pending.LineMeta
|
|
---@field type 'task'|'header'|'blank'|'filter'
|
|
---@field id? integer
|
|
---@field due? string
|
|
---@field raw_due? string
|
|
---@field status? pending.TaskStatus
|
|
---@field category? string
|
|
---@field overdue? boolean
|
|
---@field show_category? boolean
|
|
---@field priority? integer
|
|
---@field recur? string
|
|
---@field forge_spans? pending.ForgeLineMeta[]
|
|
|
|
---@class pending.views
|
|
local M = {}
|
|
|
|
---@param due? string
|
|
---@return string?
|
|
local function format_due(due)
|
|
if not due then
|
|
return nil
|
|
end
|
|
local y, m, d, hh, mm = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)T(%d%d):(%d%d)$')
|
|
if not y then
|
|
y, m, d = due:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
|
|
end
|
|
if not y then
|
|
return due
|
|
end
|
|
local t = os.time({
|
|
year = tonumber(y) --[[@as integer]],
|
|
month = tonumber(m) --[[@as integer]],
|
|
day = tonumber(d) --[[@as integer]],
|
|
})
|
|
local formatted = os.date(config.get().date_format, t) --[[@as string]]
|
|
if hh then
|
|
formatted = formatted .. ' ' .. hh .. ':' .. mm
|
|
end
|
|
return formatted
|
|
end
|
|
|
|
---@param task pending.Task
|
|
---@param prefix_len integer
|
|
---@return pending.ForgeLineMeta[]?
|
|
local function compute_forge_spans(task, prefix_len)
|
|
local refs = forge.find_refs(task.description)
|
|
if #refs == 0 then
|
|
return nil
|
|
end
|
|
local cache = task._extra and task._extra._forge_cache or nil
|
|
local spans = {}
|
|
for _, r in ipairs(refs) do
|
|
table.insert(spans, {
|
|
ref = r.ref,
|
|
cache = cache,
|
|
col_start = prefix_len + r.start_byte,
|
|
col_end = prefix_len + r.end_byte,
|
|
})
|
|
end
|
|
return spans
|
|
end
|
|
|
|
---@type table<string, integer>
|
|
local status_rank = { wip = 0, pending = 1, blocked = 2, done = 3, cancelled = 4 }
|
|
|
|
---@param task pending.Task
|
|
---@return string
|
|
local function state_char(task)
|
|
local icons = config.get().icons
|
|
if task.status == 'done' then
|
|
return icons.done
|
|
elseif task.status == 'cancelled' then
|
|
return icons.cancelled
|
|
elseif task.status == 'wip' then
|
|
return icons.wip
|
|
elseif task.status == 'blocked' then
|
|
return icons.blocked
|
|
elseif task.priority > 0 then
|
|
return icons.priority
|
|
end
|
|
return icons.pending
|
|
end
|
|
|
|
---@param tasks pending.Task[]
|
|
local function sort_tasks(tasks)
|
|
table.sort(tasks, function(a, b)
|
|
local ra = status_rank[a.status] or 1
|
|
local rb = status_rank[b.status] or 1
|
|
if ra ~= rb then
|
|
return ra < rb
|
|
end
|
|
if a.priority ~= b.priority then
|
|
return a.priority > b.priority
|
|
end
|
|
if a.order ~= b.order then
|
|
return a.order < b.order
|
|
end
|
|
return a.id < b.id
|
|
end)
|
|
end
|
|
|
|
---@type table<string, fun(a: pending.Task, b: pending.Task): boolean?>
|
|
local sort_key_comparators = {
|
|
status = function(a, b)
|
|
local ra = status_rank[a.status] or 1
|
|
local rb = status_rank[b.status] or 1
|
|
if ra ~= rb then
|
|
return ra < rb
|
|
end
|
|
return nil
|
|
end,
|
|
priority = function(a, b)
|
|
if a.priority ~= b.priority then
|
|
return a.priority > b.priority
|
|
end
|
|
return nil
|
|
end,
|
|
due = function(a, b)
|
|
local a_due = a.due or ''
|
|
local b_due = b.due or ''
|
|
if a_due ~= b_due then
|
|
if a_due == '' then
|
|
return false
|
|
end
|
|
if b_due == '' then
|
|
return true
|
|
end
|
|
return a_due < b_due
|
|
end
|
|
return nil
|
|
end,
|
|
order = function(a, b)
|
|
if a.order ~= b.order then
|
|
return a.order < b.order
|
|
end
|
|
return nil
|
|
end,
|
|
id = function(a, b)
|
|
if a.id ~= b.id then
|
|
return a.id < b.id
|
|
end
|
|
return nil
|
|
end,
|
|
age = function(a, b)
|
|
if a.id ~= b.id then
|
|
return a.id < b.id
|
|
end
|
|
return nil
|
|
end,
|
|
}
|
|
|
|
---@return fun(a: pending.Task, b: pending.Task): boolean
|
|
local function build_queue_comparator()
|
|
local log = require('pending.log')
|
|
local keys = config.get().view.queue.sort or { 'status', 'priority', 'due', 'order', 'id' }
|
|
local comparators = {}
|
|
local unknown = {}
|
|
for _, key in ipairs(keys) do
|
|
local cmp = sort_key_comparators[key]
|
|
if cmp then
|
|
table.insert(comparators, cmp)
|
|
else
|
|
table.insert(unknown, key)
|
|
end
|
|
end
|
|
if #unknown > 0 then
|
|
local label = #unknown == 1 and 'unknown queue sort key: ' or 'unknown queue sort keys: '
|
|
log.warn(label .. table.concat(unknown, ', '))
|
|
end
|
|
return function(a, b)
|
|
for _, cmp in ipairs(comparators) do
|
|
local result = cmp(a, b)
|
|
if result ~= nil then
|
|
return result
|
|
end
|
|
end
|
|
return false
|
|
end
|
|
end
|
|
|
|
---@param tasks pending.Task[]
|
|
local function sort_tasks_priority(tasks)
|
|
local cmp = build_queue_comparator()
|
|
table.sort(tasks, cmp)
|
|
end
|
|
|
|
---@param tasks pending.Task[]
|
|
---@return string[] lines
|
|
---@return pending.LineMeta[] meta
|
|
function M.category_view(tasks)
|
|
local by_cat = {}
|
|
local cat_order = {}
|
|
local cat_seen = {}
|
|
local done_by_cat = {}
|
|
|
|
for _, task in ipairs(tasks) do
|
|
local cat = task.category or config.get().default_category
|
|
if not cat_seen[cat] then
|
|
cat_seen[cat] = true
|
|
table.insert(cat_order, cat)
|
|
by_cat[cat] = {}
|
|
done_by_cat[cat] = {}
|
|
end
|
|
if task.status == 'done' or task.status == 'deleted' or task.status == 'cancelled' then
|
|
table.insert(done_by_cat[cat], task)
|
|
else
|
|
table.insert(by_cat[cat], task)
|
|
end
|
|
end
|
|
|
|
local cfg_order = config.get().view.category.order
|
|
if cfg_order and #cfg_order > 0 then
|
|
local ordered = {}
|
|
local seen = {}
|
|
for _, name in ipairs(cfg_order) do
|
|
if cat_seen[name] then
|
|
table.insert(ordered, name)
|
|
seen[name] = true
|
|
end
|
|
end
|
|
for _, name in ipairs(cat_order) do
|
|
if not seen[name] then
|
|
table.insert(ordered, name)
|
|
end
|
|
end
|
|
cat_order = ordered
|
|
end
|
|
|
|
for _, cat in ipairs(cat_order) do
|
|
sort_tasks(by_cat[cat])
|
|
sort_tasks(done_by_cat[cat])
|
|
end
|
|
|
|
local lines = {}
|
|
local meta = {}
|
|
|
|
for i, cat in ipairs(cat_order) do
|
|
if i > 1 then
|
|
table.insert(lines, '')
|
|
table.insert(meta, { type = 'blank' })
|
|
end
|
|
table.insert(lines, '# ' .. cat)
|
|
table.insert(meta, { type = 'header', category = cat })
|
|
|
|
local all = {}
|
|
for _, t in ipairs(by_cat[cat]) do
|
|
table.insert(all, t)
|
|
end
|
|
for _, t in ipairs(done_by_cat[cat]) do
|
|
table.insert(all, t)
|
|
end
|
|
|
|
for _, task in ipairs(all) do
|
|
local prefix = '/' .. task.id .. '/'
|
|
local state = state_char(task)
|
|
local line = prefix .. '- [' .. state .. '] ' .. task.description
|
|
local prefix_len = #prefix + #('- [' .. state .. '] ')
|
|
table.insert(lines, line)
|
|
table.insert(meta, {
|
|
type = 'task',
|
|
id = task.id,
|
|
due = format_due(task.due),
|
|
raw_due = task.due,
|
|
status = task.status,
|
|
category = cat,
|
|
priority = task.priority,
|
|
overdue = task.status ~= 'done'
|
|
and task.status ~= 'cancelled'
|
|
and task.due ~= nil
|
|
and parse.is_overdue(task.due)
|
|
or nil,
|
|
recur = task.recur,
|
|
forge_spans = compute_forge_spans(task, prefix_len),
|
|
})
|
|
end
|
|
end
|
|
|
|
return lines, meta
|
|
end
|
|
|
|
---@param tasks pending.Task[]
|
|
---@return string[] lines
|
|
---@return pending.LineMeta[] meta
|
|
function M.priority_view(tasks)
|
|
local pending = {}
|
|
local done = {}
|
|
|
|
for _, task in ipairs(tasks) do
|
|
if task.status == 'done' or task.status == 'cancelled' then
|
|
table.insert(done, task)
|
|
else
|
|
table.insert(pending, task)
|
|
end
|
|
end
|
|
|
|
sort_tasks_priority(pending)
|
|
sort_tasks_priority(done)
|
|
|
|
local lines = {}
|
|
local meta = {}
|
|
|
|
local all = {}
|
|
for _, t in ipairs(pending) do
|
|
table.insert(all, t)
|
|
end
|
|
for _, t in ipairs(done) do
|
|
table.insert(all, t)
|
|
end
|
|
|
|
for _, task in ipairs(all) do
|
|
local prefix = '/' .. task.id .. '/'
|
|
local state = state_char(task)
|
|
local line = prefix .. '- [' .. state .. '] ' .. task.description
|
|
local prefix_len = #prefix + #('- [' .. state .. '] ')
|
|
table.insert(lines, line)
|
|
table.insert(meta, {
|
|
type = 'task',
|
|
id = task.id,
|
|
due = format_due(task.due),
|
|
raw_due = task.due,
|
|
status = task.status,
|
|
category = task.category,
|
|
priority = task.priority,
|
|
overdue = task.status ~= 'done'
|
|
and task.status ~= 'cancelled'
|
|
and task.due ~= nil
|
|
and parse.is_overdue(task.due)
|
|
or nil,
|
|
show_category = true,
|
|
recur = task.recur,
|
|
forge_ref = task._extra and task._extra._forge_ref or nil,
|
|
forge_cache = task._extra and task._extra._forge_cache or nil,
|
|
forge_spans = compute_forge_spans(task, prefix_len),
|
|
has_notes = task.notes ~= nil and task.notes ~= '',
|
|
})
|
|
end
|
|
|
|
return lines, meta
|
|
end
|
|
|
|
return M
|