* refactor: tighten LuaCATS annotations across modules Problem: type annotations repeated inline unions with no aliases, used `table<string, any>` where structured types exist, and had loose `string` where union types should be used. Solution: add `pending.TaskStatus`, `pending.RecurMode`, `pending.TaskExtra`, `pending.ForgeType`, `pending.ForgeState`, `pending.ForgeAuthStatus` aliases and `pending.SyncBackend` interface. Replace inline unions and loose types with the new aliases in `store.lua`, `forge.lua`, `config.lua`, `diff.lua`, `views.lua`, `parse.lua`, `init.lua`, and `oauth.lua`. * refactor: canonicalize internal metadata field names Problem: `pending.Metadata` used shorthand field names (`cat`, `rec`, `rec_mode`) matching user-facing token syntax, coupling internal representation to config. `RecurSpec.from_completion` used a boolean where a `pending.RecurMode` alias exists. `category_syntax` was hardcoded to `'cat'` with no config option. Solution: rename `Metadata` fields to `category`/`recur`/`recur_mode`, add `category_syntax` config option (default `'cat'`), rename `ParsedEntry` fields to match, replace `RecurSpec.from_completion` with `mode: pending.RecurMode`, and restore `[string]` indexer on `pending.ForgeConfig` alongside explicit fields.
229 lines
6.4 KiB
Lua
229 lines
6.4 KiB
Lua
local config = require('pending.config')
|
|
local forge = require('pending.forge')
|
|
local parse = require('pending.parse')
|
|
|
|
---@class pending.ParsedEntry
|
|
---@field type 'task'|'header'|'blank'
|
|
---@field id? integer
|
|
---@field description? string
|
|
---@field priority? integer
|
|
---@field status? pending.TaskStatus
|
|
---@field category? string
|
|
---@field due? string
|
|
---@field recur? string
|
|
---@field recur_mode? pending.RecurMode
|
|
---@field forge_ref? pending.ForgeRef
|
|
---@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
|
|
local refs = forge.find_refs(description)
|
|
local forge_ref = refs[1] and refs[1].ref or nil
|
|
table.insert(result, {
|
|
type = 'task',
|
|
id = id and tonumber(id) or nil,
|
|
description = description,
|
|
priority = priority,
|
|
status = status,
|
|
category = metadata.category or current_category or config.get().default_category,
|
|
due = metadata.due,
|
|
recur = metadata.recur,
|
|
recur_mode = metadata.recur_mode,
|
|
forge_ref = forge_ref,
|
|
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 a? pending.ForgeRef
|
|
---@param b? pending.ForgeRef
|
|
---@return boolean
|
|
local function refs_equal(a, b)
|
|
if not a or not b then
|
|
return false
|
|
end
|
|
return a.forge == b.forge and a.owner == b.owner and a.repo == b.repo and a.number == b.number
|
|
end
|
|
|
|
---@param lines string[]
|
|
---@param s pending.Store
|
|
---@param hidden_ids? table<integer, true>
|
|
---@return pending.ForgeRef[]
|
|
function M.apply(lines, s, hidden_ids)
|
|
local parsed = M.parse_buffer(lines)
|
|
local now = timestamp()
|
|
local data = s:data()
|
|
local new_refs = {} ---@type pending.ForgeRef[]
|
|
|
|
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.recur,
|
|
recur_mode = entry.recur_mode,
|
|
order = order_counter,
|
|
_extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil,
|
|
})
|
|
if entry.forge_ref then
|
|
table.insert(new_refs, entry.forge_ref)
|
|
end
|
|
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.recur ~= nil then
|
|
if task.recur ~= entry.recur then
|
|
task.recur = entry.recur
|
|
changed = true
|
|
end
|
|
if task.recur_mode ~= entry.recur_mode then
|
|
task.recur_mode = entry.recur_mode
|
|
changed = true
|
|
end
|
|
end
|
|
if entry.forge_ref ~= nil then
|
|
local old_ref = task._extra and task._extra._forge_ref or nil
|
|
if not refs_equal(old_ref, entry.forge_ref) then
|
|
table.insert(new_refs, entry.forge_ref)
|
|
end
|
|
if not task._extra then
|
|
task._extra = {}
|
|
end
|
|
task._extra._forge_ref = entry.forge_ref
|
|
changed = true
|
|
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.recur,
|
|
recur_mode = entry.recur_mode,
|
|
order = order_counter,
|
|
_extra = entry.forge_ref and { _forge_ref = entry.forge_ref } or nil,
|
|
})
|
|
if entry.forge_ref then
|
|
table.insert(new_refs, entry.forge_ref)
|
|
end
|
|
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()
|
|
return new_refs
|
|
end
|
|
|
|
return M
|