feat(store): add JSON data store with CRUD operations
Problem: need persistent task storage with forward-compatible
schema and unknown field preservation.
Solution: add store module with load/save, add/update/delete,
ID allocation, and UDA round-trip preservation. Data stored at
stdpath('data')/todo/tasks.json with version 1 schema.
This commit is contained in:
parent
116e3c5b25
commit
607a9868d9
1 changed files with 296 additions and 0 deletions
296
lua/todo/store.lua
Normal file
296
lua/todo/store.lua
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
local config = require('todo.config')
|
||||
|
||||
---@class todo.Task
|
||||
---@field id integer
|
||||
---@field description string
|
||||
---@field status 'pending'|'done'|'deleted'
|
||||
---@field category? string
|
||||
---@field priority integer
|
||||
---@field due? string
|
||||
---@field entry string
|
||||
---@field modified string
|
||||
---@field end? string
|
||||
---@field order integer
|
||||
---@field _extra? table<string, any>
|
||||
|
||||
---@class todo.Data
|
||||
---@field version integer
|
||||
---@field next_id integer
|
||||
---@field tasks task.Task[]
|
||||
|
||||
---@class todo.store
|
||||
local M = {}
|
||||
|
||||
local SUPPORTED_VERSION = 1
|
||||
|
||||
---@type todo.Data?
|
||||
local _data = nil
|
||||
|
||||
---@return todo.Data
|
||||
local function empty_data()
|
||||
return {
|
||||
version = SUPPORTED_VERSION,
|
||||
next_id = 1,
|
||||
tasks = {},
|
||||
}
|
||||
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')
|
||||
end
|
||||
|
||||
---@type table<string, true>
|
||||
local known_fields = {
|
||||
id = true,
|
||||
description = true,
|
||||
status = true,
|
||||
category = true,
|
||||
priority = true,
|
||||
due = true,
|
||||
entry = true,
|
||||
modified = true,
|
||||
['end'] = true,
|
||||
order = true,
|
||||
}
|
||||
|
||||
---@param task todo.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['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 todo.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,
|
||||
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 todo.Data
|
||||
function M.load()
|
||||
local path = config.get().data_path
|
||||
local f = io.open(path, 'r')
|
||||
if not f then
|
||||
_data = empty_data()
|
||||
return _data
|
||||
end
|
||||
local content = f:read('*a')
|
||||
f:close()
|
||||
if content == '' then
|
||||
_data = empty_data()
|
||||
return _data
|
||||
end
|
||||
local ok, decoded = pcall(vim.json.decode, content)
|
||||
if not ok then
|
||||
error('todo.nvim: failed to parse ' .. path .. ': ' .. tostring(decoded))
|
||||
end
|
||||
if decoded.version and decoded.version > SUPPORTED_VERSION then
|
||||
error(
|
||||
'todo.nvim: data file version '
|
||||
.. decoded.version
|
||||
.. ' is newer than supported version '
|
||||
.. SUPPORTED_VERSION
|
||||
.. '. Please update the plugin.'
|
||||
)
|
||||
end
|
||||
_data = {
|
||||
version = decoded.version or SUPPORTED_VERSION,
|
||||
next_id = decoded.next_id or 1,
|
||||
tasks = {},
|
||||
}
|
||||
for _, t in ipairs(decoded.tasks or {}) do
|
||||
table.insert(_data.tasks, table_to_task(t))
|
||||
end
|
||||
return _data
|
||||
end
|
||||
|
||||
function M.save()
|
||||
if not _data then
|
||||
return
|
||||
end
|
||||
local path = config.get().data_path
|
||||
ensure_dir(path)
|
||||
local out = {
|
||||
version = _data.version,
|
||||
next_id = _data.next_id,
|
||||
tasks = {},
|
||||
}
|
||||
for _, task in ipairs(_data.tasks) do
|
||||
table.insert(out.tasks, task_to_table(task))
|
||||
end
|
||||
local encoded = vim.json.encode(out)
|
||||
local f = io.open(path, 'w')
|
||||
if not f then
|
||||
error('todo.nvim: cannot write to ' .. path)
|
||||
end
|
||||
f:write(encoded)
|
||||
f:close()
|
||||
end
|
||||
|
||||
---@return todo.Data
|
||||
function M.data()
|
||||
if not _data then
|
||||
M.load()
|
||||
end
|
||||
return _data
|
||||
end
|
||||
|
||||
---@return todo.Task[]
|
||||
function M.tasks()
|
||||
return M.data().tasks
|
||||
end
|
||||
|
||||
---@return todo.Task[]
|
||||
function M.active_tasks()
|
||||
local result = {}
|
||||
for _, task in ipairs(M.tasks()) do
|
||||
if task.status ~= 'deleted' then
|
||||
table.insert(result, task)
|
||||
end
|
||||
end
|
||||
return result
|
||||
end
|
||||
|
||||
---@param id integer
|
||||
---@return todo.Task?
|
||||
function M.get(id)
|
||||
for _, task in ipairs(M.tasks()) do
|
||||
if task.id == id then
|
||||
return task
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
---@param fields { description: string, status?: string, category?: string, priority?: integer, due?: string, order?: integer, _extra?: table }
|
||||
---@return todo.Task
|
||||
function M.add(fields)
|
||||
local data = M.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,
|
||||
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 todo.Task?
|
||||
function M.update(id, fields)
|
||||
local task = M.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
|
||||
task[k] = v
|
||||
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 todo.Task?
|
||||
function M.delete(id)
|
||||
return M.update(id, { status = 'deleted', ['end'] = timestamp() })
|
||||
end
|
||||
|
||||
---@param id integer
|
||||
---@return integer?
|
||||
function M.find_index(id)
|
||||
for i, task in ipairs(M.tasks()) do
|
||||
if task.id == id then
|
||||
return i
|
||||
end
|
||||
end
|
||||
return nil
|
||||
end
|
||||
|
||||
---@param tasks todo.Task[]
|
||||
function M.replace_tasks(tasks)
|
||||
M.data().tasks = tasks
|
||||
end
|
||||
|
||||
---@param id integer
|
||||
function M.set_next_id(id)
|
||||
M.data().next_id = id
|
||||
end
|
||||
|
||||
function M.unload()
|
||||
_data = nil
|
||||
end
|
||||
|
||||
return M
|
||||
Loading…
Add table
Add a link
Reference in a new issue