* 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.
428 lines
9.2 KiB
Lua
428 lines
9.2 KiB
Lua
local config = require('pending.config')
|
|
|
|
---@alias pending.TaskStatus 'pending'|'done'|'deleted'|'wip'|'blocked'
|
|
---@alias pending.RecurMode 'scheduled'|'completion'
|
|
|
|
---@class pending.TaskExtra
|
|
---@field _forge_ref? pending.ForgeRef
|
|
---@field _forge_cache? pending.ForgeCache
|
|
---@field _gtasks_task_id? string
|
|
---@field _gtasks_list_id? string
|
|
---@field _gcal_event_id? string
|
|
---@field _gcal_calendar_id? string
|
|
---@field [string] any
|
|
|
|
---@class pending.Task
|
|
---@field id integer
|
|
---@field description string
|
|
---@field status pending.TaskStatus
|
|
---@field category? string
|
|
---@field priority integer
|
|
---@field due? string
|
|
---@field recur? string
|
|
---@field recur_mode? pending.RecurMode
|
|
---@field entry string
|
|
---@field modified string
|
|
---@field end? string
|
|
---@field order integer
|
|
---@field _extra? pending.TaskExtra
|
|
|
|
---@class pending.Data
|
|
---@field version integer
|
|
---@field next_id integer
|
|
---@field tasks pending.Task[]
|
|
---@field undo pending.Task[][]
|
|
---@field folded_categories string[]
|
|
|
|
---@class pending.TaskFields
|
|
---@field description string
|
|
---@field status? pending.TaskStatus
|
|
---@field category? string
|
|
---@field priority? integer
|
|
---@field due? string
|
|
---@field recur? string
|
|
---@field recur_mode? pending.RecurMode
|
|
---@field order? integer
|
|
---@field _extra? pending.TaskExtra
|
|
|
|
---@class pending.Store
|
|
---@field path string
|
|
---@field _data pending.Data?
|
|
local Store = {}
|
|
Store.__index = Store
|
|
|
|
---@class pending.store
|
|
local M = {}
|
|
|
|
local SUPPORTED_VERSION = 1
|
|
|
|
---@return pending.Data
|
|
local function empty_data()
|
|
return {
|
|
version = SUPPORTED_VERSION,
|
|
next_id = 1,
|
|
tasks = {},
|
|
undo = {},
|
|
folded_categories = {},
|
|
}
|
|
end
|
|
|
|
---@param path string
|
|
local function ensure_dir(path)
|
|
local dir = vim.fn.fnamemodify(path, ':h')
|
|
if vim.fn.isdirectory(dir) == 0 then
|
|
vim.fn.mkdir(dir, 'p')
|
|
end
|
|
end
|
|
|
|
---@return string
|
|
local function timestamp()
|
|
return os.date('!%Y-%m-%dT%H:%M:%SZ') --[[@as string]]
|
|
end
|
|
|
|
---@type table<string, true>
|
|
local known_fields = {
|
|
id = true,
|
|
description = true,
|
|
status = true,
|
|
category = true,
|
|
priority = true,
|
|
due = true,
|
|
recur = true,
|
|
recur_mode = true,
|
|
entry = true,
|
|
modified = true,
|
|
['end'] = true,
|
|
order = true,
|
|
}
|
|
|
|
---@param task pending.Task
|
|
---@return table
|
|
local function task_to_table(task)
|
|
local t = {
|
|
id = task.id,
|
|
description = task.description,
|
|
status = task.status,
|
|
entry = task.entry,
|
|
modified = task.modified,
|
|
}
|
|
if task.category then
|
|
t.category = task.category
|
|
end
|
|
if task.priority and task.priority ~= 0 then
|
|
t.priority = task.priority
|
|
end
|
|
if task.due then
|
|
t.due = task.due
|
|
end
|
|
if task.recur then
|
|
t.recur = task.recur
|
|
end
|
|
if task.recur_mode then
|
|
t.recur_mode = task.recur_mode
|
|
end
|
|
if task['end'] then
|
|
t['end'] = task['end']
|
|
end
|
|
if task.order and task.order ~= 0 then
|
|
t.order = task.order
|
|
end
|
|
if task._extra then
|
|
for k, v in pairs(task._extra) do
|
|
t[k] = v
|
|
end
|
|
end
|
|
return t
|
|
end
|
|
|
|
---@param t table
|
|
---@return pending.Task
|
|
local function table_to_task(t)
|
|
local task = {
|
|
id = t.id,
|
|
description = t.description,
|
|
status = t.status or 'pending',
|
|
category = t.category,
|
|
priority = t.priority or 0,
|
|
due = t.due,
|
|
recur = t.recur,
|
|
recur_mode = t.recur_mode,
|
|
entry = t.entry,
|
|
modified = t.modified,
|
|
['end'] = t['end'],
|
|
order = t.order or 0,
|
|
_extra = {},
|
|
}
|
|
for k, v in pairs(t) do
|
|
if not known_fields[k] then
|
|
task._extra[k] = v
|
|
end
|
|
end
|
|
if next(task._extra) == nil then
|
|
task._extra = nil
|
|
end
|
|
return task
|
|
end
|
|
|
|
---@return pending.Data
|
|
function Store:load()
|
|
local path = self.path
|
|
local f = io.open(path, 'r')
|
|
if not f then
|
|
self._data = empty_data()
|
|
return self._data
|
|
end
|
|
local content = f:read('*a')
|
|
f:close()
|
|
if content == '' then
|
|
self._data = empty_data()
|
|
return self._data
|
|
end
|
|
local ok, decoded = pcall(vim.json.decode, content)
|
|
if not ok then
|
|
error('pending.nvim: failed to parse ' .. path .. ': ' .. tostring(decoded))
|
|
end
|
|
if decoded.version and decoded.version > SUPPORTED_VERSION then
|
|
error(
|
|
'pending.nvim: data file version '
|
|
.. decoded.version
|
|
.. ' is newer than supported version '
|
|
.. SUPPORTED_VERSION
|
|
.. '. Please update the plugin.'
|
|
)
|
|
end
|
|
self._data = {
|
|
version = decoded.version or SUPPORTED_VERSION,
|
|
next_id = decoded.next_id or 1,
|
|
tasks = {},
|
|
undo = {},
|
|
folded_categories = decoded.folded_categories or {},
|
|
}
|
|
for _, t in ipairs(decoded.tasks or {}) do
|
|
table.insert(self._data.tasks, table_to_task(t))
|
|
end
|
|
for _, snapshot in ipairs(decoded.undo or {}) do
|
|
if type(snapshot) == 'table' then
|
|
local tasks = {}
|
|
for _, raw in ipairs(snapshot) do
|
|
table.insert(tasks, table_to_task(raw))
|
|
end
|
|
table.insert(self._data.undo, tasks)
|
|
end
|
|
end
|
|
return self._data
|
|
end
|
|
|
|
---@return nil
|
|
function Store:save()
|
|
if not self._data then
|
|
return
|
|
end
|
|
local path = self.path
|
|
ensure_dir(path)
|
|
local out = {
|
|
version = self._data.version,
|
|
next_id = self._data.next_id,
|
|
tasks = {},
|
|
undo = {},
|
|
folded_categories = self._data.folded_categories,
|
|
}
|
|
for _, task in ipairs(self._data.tasks) do
|
|
table.insert(out.tasks, task_to_table(task))
|
|
end
|
|
for _, snapshot in ipairs(self._data.undo) do
|
|
local serialized = {}
|
|
for _, task in ipairs(snapshot) do
|
|
table.insert(serialized, task_to_table(task))
|
|
end
|
|
table.insert(out.undo, serialized)
|
|
end
|
|
local encoded = vim.json.encode(out)
|
|
local tmp = path .. '.tmp'
|
|
local f = io.open(tmp, 'w')
|
|
if not f then
|
|
error('pending.nvim: cannot write to ' .. tmp)
|
|
end
|
|
f:write(encoded)
|
|
f:close()
|
|
local ok, rename_err = os.rename(tmp, path)
|
|
if not ok then
|
|
os.remove(tmp)
|
|
error('pending.nvim: cannot rename ' .. tmp .. ' to ' .. path .. ': ' .. tostring(rename_err))
|
|
end
|
|
end
|
|
|
|
---@return pending.Data
|
|
function Store:data()
|
|
if not self._data then
|
|
self:load()
|
|
end
|
|
return self._data --[[@as pending.Data]]
|
|
end
|
|
|
|
---@return pending.Task[]
|
|
function Store:tasks()
|
|
return self:data().tasks
|
|
end
|
|
|
|
---@return pending.Task[]
|
|
function Store:active_tasks()
|
|
local result = {}
|
|
for _, task in ipairs(self:tasks()) do
|
|
if task.status ~= 'deleted' then
|
|
table.insert(result, task)
|
|
end
|
|
end
|
|
return result
|
|
end
|
|
|
|
---@param id integer
|
|
---@return pending.Task?
|
|
function Store:get(id)
|
|
for _, task in ipairs(self:tasks()) do
|
|
if task.id == id then
|
|
return task
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
---@param fields pending.TaskFields
|
|
---@return pending.Task
|
|
function Store:add(fields)
|
|
local data = self:data()
|
|
local now = timestamp()
|
|
local task = {
|
|
id = data.next_id,
|
|
description = fields.description,
|
|
status = fields.status or 'pending',
|
|
category = fields.category or config.get().default_category,
|
|
priority = fields.priority or 0,
|
|
due = fields.due,
|
|
recur = fields.recur,
|
|
recur_mode = fields.recur_mode,
|
|
entry = now,
|
|
modified = now,
|
|
['end'] = nil,
|
|
order = fields.order or 0,
|
|
_extra = fields._extra,
|
|
}
|
|
data.next_id = data.next_id + 1
|
|
table.insert(data.tasks, task)
|
|
return task
|
|
end
|
|
|
|
---@param id integer
|
|
---@param fields table<string, any>
|
|
---@return pending.Task?
|
|
function Store:update(id, fields)
|
|
local task = self:get(id)
|
|
if not task then
|
|
return nil
|
|
end
|
|
local now = timestamp()
|
|
for k, v in pairs(fields) do
|
|
if k ~= 'id' and k ~= 'entry' then
|
|
if v == vim.NIL then
|
|
task[k] = nil
|
|
else
|
|
task[k] = v
|
|
end
|
|
end
|
|
end
|
|
task.modified = now
|
|
if fields.status == 'done' or fields.status == 'deleted' then
|
|
task['end'] = task['end'] or now
|
|
end
|
|
return task
|
|
end
|
|
|
|
---@param id integer
|
|
---@return pending.Task?
|
|
function Store:delete(id)
|
|
return self:update(id, { status = 'deleted', ['end'] = timestamp() })
|
|
end
|
|
|
|
---@param id integer
|
|
---@return integer?
|
|
function Store:find_index(id)
|
|
for i, task in ipairs(self:tasks()) do
|
|
if task.id == id then
|
|
return i
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
---@param tasks pending.Task[]
|
|
---@return nil
|
|
function Store:replace_tasks(tasks)
|
|
self:data().tasks = tasks
|
|
end
|
|
|
|
---@return pending.Task[]
|
|
function Store:snapshot()
|
|
local result = {}
|
|
for _, task in ipairs(self:active_tasks()) do
|
|
local copy = {}
|
|
for k, v in pairs(task) do
|
|
if k ~= '_extra' then
|
|
copy[k] = v
|
|
end
|
|
end
|
|
if task._extra then
|
|
copy._extra = {}
|
|
for k, v in pairs(task._extra) do
|
|
copy._extra[k] = v
|
|
end
|
|
end
|
|
table.insert(result, copy --[[@as pending.Task]])
|
|
end
|
|
return result
|
|
end
|
|
|
|
---@return pending.Task[][]
|
|
function Store:undo_stack()
|
|
return self:data().undo
|
|
end
|
|
|
|
---@param stack pending.Task[][]
|
|
---@return nil
|
|
function Store:set_undo_stack(stack)
|
|
self:data().undo = stack
|
|
end
|
|
|
|
---@param id integer
|
|
---@return nil
|
|
function Store:set_next_id(id)
|
|
self:data().next_id = id
|
|
end
|
|
|
|
---@return string[]
|
|
function Store:get_folded_categories()
|
|
return self:data().folded_categories
|
|
end
|
|
|
|
---@param cats string[]
|
|
---@return nil
|
|
function Store:set_folded_categories(cats)
|
|
self:data().folded_categories = cats
|
|
end
|
|
|
|
---@return nil
|
|
function Store:unload()
|
|
self._data = nil
|
|
end
|
|
|
|
---@param path string
|
|
---@return pending.Store
|
|
function M.new(path)
|
|
return setmetatable({ path = path, _data = nil }, Store)
|
|
end
|
|
|
|
---@return string
|
|
function M.resolve_path()
|
|
return config.get().data_path
|
|
end
|
|
|
|
return M
|