From 62b1b020a3dca17e070f0db8d3157bafc360f040 Mon Sep 17 00:00:00 2001 From: Barrett Ruth Date: Tue, 24 Feb 2026 15:09:07 -0500 Subject: [PATCH] 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. --- lua/todo/store.lua | 296 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 296 insertions(+) create mode 100644 lua/todo/store.lua diff --git a/lua/todo/store.lua b/lua/todo/store.lua new file mode 100644 index 0000000..b352896 --- /dev/null +++ b/lua/todo/store.lua @@ -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 + +---@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 +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 +---@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