* refactor(store): convert singleton to Store.new() factory
Problem: store.lua used module-level _data singleton, making
project-local stores impossible and creating hidden global state.
Solution: introduce Store metatable with all operations as instance
methods. M.new(path) constructs an instance; M.resolve_path()
searches upward for .pending.json and falls back to
config.get().data_path. Singleton module API is removed.
* refactor(diff): accept store instance as parameter
Problem: diff.apply called store singleton methods directly, coupling
it to global state and preventing use with project-local stores.
Solution: change signature to apply(lines, s, hidden_ids?) where s is
a pending.Store instance. All store operations now go through s.
* refactor(buffer): add set_store/store accessors, drop singleton dep
Problem: buffer.lua imported store directly and called singleton
methods, preventing it from working with per-project store instances.
Solution: add module-level _store, M.set_store(s), and M.store()
accessors. open() and render() use _store instead of the singleton.
init.lua will call buffer.set_store(s) before buffer.open().
* refactor(complete,health,sync,plugin): update callers to store instance API
Problem: complete.lua, health.lua, sync/gcal.lua, and plugin/pending.lua
all called singleton store methods directly.
Solution: complete.lua uses buffer.store() for category lookups;
health.lua uses store.new(store.resolve_path()) and reports the
resolved path; gcal.lua calls require('pending').store() for task
access; plugin tab-completion creates ephemeral store instances via
store.new(store.resolve_path()). Add 'init' to the subcommands list.
* feat(init): thread Store instance through init, add :Pending init
Problem: init.lua called singleton store methods throughout, and there
was no way to create a project-local .pending.json file.
Solution: add module-level _store and private get_store() that
lazy-constructs via store.new(store.resolve_path()). Add public
M.store() accessor used by specs and sync backends. M.open() calls
buffer.set_store(get_store()) before buffer.open(). All store
callsites converted to get_store():method(). goto_file() and
add_here() derive the data directory from get_store().path.
Add M.init() which creates .pending.json in cwd and dispatches from
M.command() as ':Pending init'.
* test: update all specs for Store instance API
Problem: every spec used the old singleton API (store.unload(),
store.load(), store.add(), etc.) and diff.apply(lines, hidden).
Solution: lower-level specs (store, diff, views, complete, file) use
s = store.new(path); s:load() directly. Higher-level specs (archive,
edit, filter, status, sync) reset package.loaded['pending'] in
before_each and use pending.store() to access the live instance.
diff.apply calls updated to diff.apply(lines, s, hidden_ids).
* docs(pending): document :Pending init and store resolution
Add *pending-store-resolution* section explaining upward .pending.json
discovery and fallback to the global data_path. Document :Pending init
under COMMANDS. Add a cross-reference from the data_path config field.
* ci: format
* ci: remove unused variable
194 lines
5.1 KiB
Lua
194 lines
5.1 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 file? 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 = state_char == 'x' and 'done' or 'pending'
|
|
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,
|
|
file = metadata.file,
|
|
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 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.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
|
|
local old_file = (task._extra and task._extra.file) or nil
|
|
if entry.file ~= old_file then
|
|
task._extra = task._extra or {}
|
|
if entry.file then
|
|
task._extra.file = entry.file
|
|
else
|
|
task._extra.file = nil
|
|
if next(task._extra) == nil then
|
|
task._extra = nil
|
|
end
|
|
end
|
|
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.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
|