Problem: the task editing surface had gaps — category and recurrence had no keymaps, `:Pending edit` required knowing the task ID, tasks couldn't be reordered with a keymap, priority was binary (0/1), and `wip`/`blocked` states were documented but unimplemented. Solution: fill every cell so every property is editable in every way. - `gc`/`gr` keymaps for category select and recurrence prompt - cursor-aware `:Pending edit` (omit ID to use task under cursor) - `J`/`K` keymaps to reorder tasks within a category - multi-level priorities (`max_priority` config, `g!` cycles 0→1→2→3→0) - `+!!`/`+!!!` tokens in `:Pending edit`, `:Pending add`, `parse.body()` - `PendingPriority2`/`PendingPriority3` highlight groups - `gw`/`gb` keymaps toggle `wip`/`blocked` status - `>`/`=` state chars in buffer rendering and diff parsing - `PendingWip`/`PendingBlocked` highlight groups - sort order: wip → pending → blocked → done - `wip`/`blocked` filter predicates and icons
193 lines
5 KiB
Lua
193 lines
5 KiB
Lua
local config = require('pending.config')
|
|
local parse = require('pending.parse')
|
|
|
|
---@class pending.ParsedEntry
|
|
---@field type 'task'|'header'|'blank'
|
|
---@field id? integer
|
|
---@field description? string
|
|
---@field priority? integer
|
|
---@field status? string
|
|
---@field category? string
|
|
---@field due? string
|
|
---@field rec? string
|
|
---@field rec_mode? string
|
|
---@field lnum integer
|
|
|
|
---@class pending.diff
|
|
local M = {}
|
|
|
|
---@return string
|
|
local function timestamp()
|
|
return os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
|
|
end
|
|
|
|
---@param lines string[]
|
|
---@return pending.ParsedEntry[]
|
|
function M.parse_buffer(lines)
|
|
local result = {}
|
|
local current_category = nil
|
|
local start = 1
|
|
if lines[1] and lines[1]:match('^FILTER:') then
|
|
start = 2
|
|
end
|
|
|
|
for i = start, #lines do
|
|
local line = lines[i]
|
|
local id, body = line:match('^/(%d+)/(- %[.?%] .*)$')
|
|
if not id then
|
|
body = line:match('^(- %[.?%] .*)$')
|
|
end
|
|
if line == '' then
|
|
table.insert(result, { type = 'blank', lnum = i })
|
|
elseif id or body then
|
|
local stripped = body:match('^- %[.?%] (.*)$') or body
|
|
local state_char = body:match('^- %[(.-)%]') or ' '
|
|
local priority = state_char == '!' and 1 or 0
|
|
local status
|
|
if state_char == 'x' then
|
|
status = 'done'
|
|
elseif state_char == '>' then
|
|
status = 'wip'
|
|
elseif state_char == '=' then
|
|
status = 'blocked'
|
|
else
|
|
status = 'pending'
|
|
end
|
|
local description, metadata = parse.body(stripped)
|
|
if description and description ~= '' then
|
|
table.insert(result, {
|
|
type = 'task',
|
|
id = id and tonumber(id) or nil,
|
|
description = description,
|
|
priority = priority,
|
|
status = status,
|
|
category = metadata.cat or current_category or config.get().default_category,
|
|
due = metadata.due,
|
|
rec = metadata.rec,
|
|
rec_mode = metadata.rec_mode,
|
|
lnum = i,
|
|
})
|
|
end
|
|
elseif line:match('^# (.+)$') then
|
|
current_category = line:match('^# (.+)$')
|
|
table.insert(result, { type = 'header', category = current_category, lnum = i })
|
|
end
|
|
end
|
|
|
|
return result
|
|
end
|
|
|
|
---@param lines string[]
|
|
---@param s pending.Store
|
|
---@param hidden_ids? table<integer, true>
|
|
---@return nil
|
|
function M.apply(lines, s, hidden_ids)
|
|
local parsed = M.parse_buffer(lines)
|
|
local now = timestamp()
|
|
local data = s:data()
|
|
|
|
local old_by_id = {}
|
|
for _, task in ipairs(data.tasks) do
|
|
if task.status ~= 'deleted' then
|
|
old_by_id[task.id] = task
|
|
end
|
|
end
|
|
|
|
local seen_ids = {}
|
|
local order_counter = 0
|
|
|
|
for _, entry in ipairs(parsed) do
|
|
if entry.type ~= 'task' then
|
|
goto continue
|
|
end
|
|
|
|
order_counter = order_counter + 1
|
|
|
|
if entry.id and old_by_id[entry.id] then
|
|
if seen_ids[entry.id] then
|
|
s:add({
|
|
description = entry.description,
|
|
category = entry.category,
|
|
priority = entry.priority,
|
|
due = entry.due,
|
|
recur = entry.rec,
|
|
recur_mode = entry.rec_mode,
|
|
order = order_counter,
|
|
})
|
|
else
|
|
seen_ids[entry.id] = true
|
|
local task = old_by_id[entry.id]
|
|
local changed = false
|
|
if task.description ~= entry.description then
|
|
task.description = entry.description
|
|
changed = true
|
|
end
|
|
if task.category ~= entry.category then
|
|
task.category = entry.category
|
|
changed = true
|
|
end
|
|
if entry.priority == 0 and task.priority > 0 then
|
|
task.priority = 0
|
|
changed = true
|
|
elseif entry.priority > 0 and task.priority == 0 then
|
|
task.priority = entry.priority
|
|
changed = true
|
|
end
|
|
if entry.due ~= nil and task.due ~= entry.due then
|
|
task.due = entry.due
|
|
changed = true
|
|
end
|
|
if entry.rec ~= nil then
|
|
if task.recur ~= entry.rec then
|
|
task.recur = entry.rec
|
|
changed = true
|
|
end
|
|
if task.recur_mode ~= entry.rec_mode then
|
|
task.recur_mode = entry.rec_mode
|
|
changed = true
|
|
end
|
|
end
|
|
if entry.status and task.status ~= entry.status then
|
|
task.status = entry.status
|
|
if entry.status == 'done' then
|
|
task['end'] = now
|
|
else
|
|
task['end'] = nil
|
|
end
|
|
changed = true
|
|
end
|
|
if task.order ~= order_counter then
|
|
task.order = order_counter
|
|
changed = true
|
|
end
|
|
if changed then
|
|
task.modified = now
|
|
end
|
|
end
|
|
else
|
|
s:add({
|
|
description = entry.description,
|
|
category = entry.category,
|
|
priority = entry.priority,
|
|
due = entry.due,
|
|
recur = entry.rec,
|
|
recur_mode = entry.rec_mode,
|
|
order = order_counter,
|
|
})
|
|
end
|
|
|
|
::continue::
|
|
end
|
|
|
|
for id, task in pairs(old_by_id) do
|
|
if not seen_ids[id] and not (hidden_ids and hidden_ids[id]) then
|
|
task.status = 'deleted'
|
|
task['end'] = now
|
|
task.modified = now
|
|
end
|
|
end
|
|
|
|
s:save()
|
|
end
|
|
|
|
return M
|