pending.nvim/lua/pending/diff.lua
Barrett Ruth c57cc0845b
feat: time-aware due dates, persistent undo, @return audit (#33)
* fix(plugin): allow command chaining with bar separator

Problem: :Pending|only failed because the command definition lacked the
bar attribute, causing | to be consumed as an argument.

Solution: Add bar = true to nvim_create_user_command so | is treated as
a command separator, matching fugitive's :Git behavior.

* refactor(buffer): remove opinionated window options

Problem: The plugin hardcoded number, relativenumber, wrap, spell,
signcolumn, foldcolumn, and cursorline in set_win_options, overriding
user preferences with no way to opt out.

Solution: Remove all cosmetic window options. Users who want them can
set them in after/ftplugin/pending.lua. Only conceallevel,
concealcursor, and winfixheight remain as functionally required.

* feat: time-aware due dates, persistent undo, @return audit

Problem: Due dates had no time component, the undo stack was lost on
restart and stored in a separate file, and many public functions lacked
required @return annotations.

Solution: Add YYYY-MM-DDThh:mm support across parse, views, recur,
complete, and init with time-aware overdue checks. Merge the undo stack
into the task store JSON so a single file holds all state. Add @return
nil annotations to all 27 void public functions across every module.

* feat(parse): flexible time parsing for @ suffix

Problem: the @HH:MM time suffix required zero-padded 24-hour format,
forcing users to write due:tomorrow@14:00 instead of due:tomorrow@2pm.

Solution: add normalize_time() that accepts bare hours (9, 14),
H:MM (9:30), am/pm (2pm, 9:30am, 12am), and existing HH:MM format,
normalizing all to canonical HH:MM on save.

* feat(complete): add info descriptions to omnifunc items

Problem: completion menu items had no description, making it hard to
distinguish between similar entries like date shorthands and recurrence
patterns.

Solution: return { word, info } tables from date_completions() and
recur_completions(), surfacing human-readable descriptions in the
completion popup.

* ci: format
2026-02-25 20:37:50 -05:00

173 lines
4.5 KiB
Lua

local config = require('pending.config')
local parse = require('pending.parse')
local store = require('pending.store')
---@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 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
for i, line in ipairs(lines) do
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,
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[]
---@return nil
function M.apply(lines)
local parsed = M.parse_buffer(lines)
local now = timestamp()
local data = store.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
store.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
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
store.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] then
task.status = 'deleted'
task['end'] = now
task.modified = now
end
end
store.save()
end
return M