* feat(config): add category_order field Problem: category display order was always insertion order with no way to configure it. Solution: add category_order to config defaults so users can declare a preferred category ordering; unspecified categories append after. * feat(parse): add relative date resolution Problem: due dates required full YYYY-MM-DD input, adding friction for common cases like "today" or "next monday". Solution: add resolve_date() supporting today, tomorrow, +Nd, and weekday abbreviations; extend inline token parsing to resolve relative values before falling back to strict date validation. * feat(views): overdue flag, category in priority view, category ordering Problem: overdue tasks were visually indistinct from upcoming ones; priority view had no category context; category display order was not configurable. Solution: compute overdue meta flag for pending tasks past their due date; set show_category on priority view task meta; reorder categories according to config.category_order when present. * feat(buffer): overdue highlight, category virt text in priority view Problem: overdue tasks had no visual distinction; priority view showed no category context alongside due dates. Solution: add PendingOverdue highlight group; render category name as right-aligned virtual text in priority view, composited with the due date when both are present. * feat(init): undo write and buffer-local default mappings Problem: _undo_state was captured on every save but never consumed; toggle_priority and prompt_date had no buffer-local defaults, requiring manual <Plug> configuration. Solution: implement undo_write() to restore pre-save task state; add !, d, and U as buffer-local defaults following fugitive's philosophy of owning the buffer; expose :Pending undo as a command alias. * test(views): add views spec Problem: views.lua had no test coverage. Solution: add 26 tests covering category_view and priority_view including sort order, line format, overdue detection, show_category meta, and category_order config behavior. * test(archive): add archive spec Problem: archive had no test coverage. Solution: add 9 tests covering cutoff logic, custom day counts, pending task preservation, deleted task cleanup, and notify output. * docs: add vimdoc Problem: no :help documentation existed. Solution: add doc/pending.txt covering all features — commands, mappings, views, configuration, Google Calendar sync, highlight groups, data format, and health check — following standard vimdoc conventions. * ci: format * fix: resolve lint and type check errors Problem: selene flagged unused variables in new spec files; LuaLS flagged os.date/os.time return type mismatches, integer? assignments, and stale task.Task/task.GcalConfig type references. Solution: prefix unused spec variables with _ or drop unnecessary assignments; add --[[@as string/integer]] casts for os.date and os.time calls; add category_order field to pending.Config annotation; fix task.GcalConfig -> pending.GcalConfig and task.Task[] -> pending.Task[]; add nil guards on meta[row].id before store calls; cast store.data() return to non-optional. * ci: format * fix: sync * ci: format
147 lines
3.6 KiB
Lua
147 lines
3.6 KiB
Lua
local config = require('pending.config')
|
|
local parse = require('pending.parse')
|
|
local store = require('pending.store')
|
|
|
|
---@class pending.ParsedEntry
|
|
---@field type 'task'|'header'|'blank'
|
|
---@field id? integer
|
|
---@field description? string
|
|
---@field priority? integer
|
|
---@field category? string
|
|
---@field due? 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
|
|
|
|
for i, line in ipairs(lines) do
|
|
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 priority = 0
|
|
if stripped:match('^! ') then
|
|
priority = 1
|
|
stripped = stripped:sub(3)
|
|
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,
|
|
category = metadata.cat or current_category or config.get().default_category,
|
|
due = metadata.due,
|
|
lnum = i,
|
|
})
|
|
end
|
|
elseif line:match('^%S') then
|
|
current_category = line
|
|
table.insert(result, { type = 'header', category = line, lnum = i })
|
|
end
|
|
end
|
|
|
|
return result
|
|
end
|
|
|
|
---@param lines string[]
|
|
function M.apply(lines)
|
|
local parsed = M.parse_buffer(lines)
|
|
local now = timestamp()
|
|
local data = store.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
|
|
store.add({
|
|
description = entry.description,
|
|
category = entry.category,
|
|
priority = entry.priority,
|
|
due = entry.due,
|
|
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 task.priority ~= entry.priority then
|
|
task.priority = entry.priority
|
|
changed = true
|
|
end
|
|
if task.due ~= entry.due then
|
|
task.due = entry.due
|
|
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
|
|
store.add({
|
|
description = entry.description,
|
|
category = entry.category,
|
|
priority = entry.priority,
|
|
due = entry.due,
|
|
order = order_counter,
|
|
})
|
|
end
|
|
|
|
::continue::
|
|
end
|
|
|
|
for id, task in pairs(old_by_id) do
|
|
if not seen_ids[id] then
|
|
task.status = 'deleted'
|
|
task['end'] = now
|
|
task.modified = now
|
|
end
|
|
end
|
|
|
|
store.save()
|
|
end
|
|
|
|
return M
|