* 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
188 lines
5.1 KiB
Lua
188 lines
5.1 KiB
Lua
---@class pending.RecurSpec
|
|
---@field freq 'daily'|'weekly'|'monthly'|'yearly'
|
|
---@field interval integer
|
|
---@field byday? string[]
|
|
---@field from_completion boolean
|
|
---@field _raw? string
|
|
|
|
---@class pending.recur
|
|
local M = {}
|
|
|
|
---@type table<string, pending.RecurSpec>
|
|
local named = {
|
|
daily = { freq = 'daily', interval = 1, from_completion = false },
|
|
weekdays = {
|
|
freq = 'weekly',
|
|
interval = 1,
|
|
byday = { 'MO', 'TU', 'WE', 'TH', 'FR' },
|
|
from_completion = false,
|
|
},
|
|
weekly = { freq = 'weekly', interval = 1, from_completion = false },
|
|
biweekly = { freq = 'weekly', interval = 2, from_completion = false },
|
|
monthly = { freq = 'monthly', interval = 1, from_completion = false },
|
|
quarterly = { freq = 'monthly', interval = 3, from_completion = false },
|
|
yearly = { freq = 'yearly', interval = 1, from_completion = false },
|
|
annual = { freq = 'yearly', interval = 1, from_completion = false },
|
|
}
|
|
|
|
---@param spec string
|
|
---@return pending.RecurSpec?
|
|
function M.parse(spec)
|
|
local from_completion = false
|
|
local s = spec
|
|
|
|
if s:sub(1, 1) == '!' then
|
|
from_completion = true
|
|
s = s:sub(2)
|
|
end
|
|
|
|
local lower = s:lower()
|
|
|
|
local base = named[lower]
|
|
if base then
|
|
return {
|
|
freq = base.freq,
|
|
interval = base.interval,
|
|
byday = base.byday,
|
|
from_completion = from_completion,
|
|
}
|
|
end
|
|
|
|
local n, unit = lower:match('^(%d+)([dwmy])$')
|
|
if n then
|
|
local num = tonumber(n) --[[@as integer]]
|
|
if num < 1 then
|
|
return nil
|
|
end
|
|
local freq_map = { d = 'daily', w = 'weekly', m = 'monthly', y = 'yearly' }
|
|
return {
|
|
freq = freq_map[unit],
|
|
interval = num,
|
|
from_completion = from_completion,
|
|
}
|
|
end
|
|
|
|
if s:match('^FREQ=') then
|
|
return {
|
|
freq = 'daily',
|
|
interval = 1,
|
|
from_completion = from_completion,
|
|
_raw = s,
|
|
}
|
|
end
|
|
|
|
return nil
|
|
end
|
|
|
|
---@param spec string
|
|
---@return boolean
|
|
function M.validate(spec)
|
|
return M.parse(spec) ~= nil
|
|
end
|
|
|
|
---@param due string
|
|
---@return string date_part
|
|
---@return string? time_part
|
|
local function split_datetime(due)
|
|
local dp, tp = due:match('^(.+)T(.+)$')
|
|
if dp then
|
|
return dp, tp
|
|
end
|
|
return due, nil
|
|
end
|
|
|
|
---@param base_date string
|
|
---@param freq string
|
|
---@param interval integer
|
|
---@return string
|
|
local function advance_date(base_date, freq, interval)
|
|
local date_part, time_part = split_datetime(base_date)
|
|
local y, m, d = date_part:match('^(%d+)-(%d+)-(%d+)$')
|
|
local yn = tonumber(y) --[[@as integer]]
|
|
local mn = tonumber(m) --[[@as integer]]
|
|
local dn = tonumber(d) --[[@as integer]]
|
|
|
|
local result
|
|
if freq == 'daily' then
|
|
result = os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval })) --[[@as string]]
|
|
elseif freq == 'weekly' then
|
|
result = os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval * 7 })) --[[@as string]]
|
|
elseif freq == 'monthly' then
|
|
local new_m = mn + interval
|
|
local new_y = yn
|
|
while new_m > 12 do
|
|
new_m = new_m - 12
|
|
new_y = new_y + 1
|
|
end
|
|
local last_day = os.date('*t', os.time({ year = new_y, month = new_m + 1, day = 0 })) --[[@as osdate]]
|
|
local clamped_d = math.min(dn, last_day.day --[[@as integer]])
|
|
result = os.date('%Y-%m-%d', os.time({ year = new_y, month = new_m, day = clamped_d })) --[[@as string]]
|
|
elseif freq == 'yearly' then
|
|
local new_y = yn + interval
|
|
local last_day = os.date('*t', os.time({ year = new_y, month = mn + 1, day = 0 })) --[[@as osdate]]
|
|
local clamped_d = math.min(dn, last_day.day --[[@as integer]])
|
|
result = os.date('%Y-%m-%d', os.time({ year = new_y, month = mn, day = clamped_d })) --[[@as string]]
|
|
else
|
|
return base_date
|
|
end
|
|
|
|
if time_part then
|
|
return result .. 'T' .. time_part
|
|
end
|
|
return result
|
|
end
|
|
|
|
---@param base_date string
|
|
---@param spec string
|
|
---@param mode 'scheduled'|'completion'
|
|
---@return string
|
|
function M.next_due(base_date, spec, mode)
|
|
local parsed = M.parse(spec)
|
|
if not parsed then
|
|
return base_date
|
|
end
|
|
|
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
|
local _, time_part = split_datetime(base_date)
|
|
|
|
if mode == 'completion' then
|
|
local base = time_part and (today .. 'T' .. time_part) or today
|
|
return advance_date(base, parsed.freq, parsed.interval)
|
|
end
|
|
|
|
local next_date = advance_date(base_date, parsed.freq, parsed.interval)
|
|
local compare_today = time_part and (today .. 'T' .. time_part) or today
|
|
while next_date <= compare_today do
|
|
next_date = advance_date(next_date, parsed.freq, parsed.interval)
|
|
end
|
|
return next_date
|
|
end
|
|
|
|
---@param spec string
|
|
---@return string
|
|
function M.to_rrule(spec)
|
|
local parsed = M.parse(spec)
|
|
if not parsed then
|
|
return ''
|
|
end
|
|
|
|
if parsed._raw then
|
|
return 'RRULE:' .. parsed._raw
|
|
end
|
|
|
|
local parts = { 'FREQ=' .. parsed.freq:upper() }
|
|
if parsed.interval > 1 then
|
|
table.insert(parts, 'INTERVAL=' .. parsed.interval)
|
|
end
|
|
if parsed.byday then
|
|
table.insert(parts, 'BYDAY=' .. table.concat(parsed.byday, ','))
|
|
end
|
|
return 'RRULE:' .. table.concat(parts, ';')
|
|
end
|
|
|
|
---@return string[]
|
|
function M.shorthand_list()
|
|
return { 'daily', 'weekdays', 'weekly', 'biweekly', 'monthly', 'quarterly', 'yearly', 'annual' }
|
|
end
|
|
|
|
return M
|