* fix(diff): preserve due/rec when absent from buffer line Problem: `diff.apply` overwrites `task.due` and `task.recur` with `nil` whenever those fields aren't present as inline tokens in the buffer line. Because metadata is rendered as virtual text (never in the line text), every description edit silently clears due dates and recurrence rules. Solution: Only update `due`, `recur`, and `recur_mode` in the existing- task branch when the parsed entry actually contains them (non-nil). Users can still set/change these inline by typing `due:<date>` or `rec:<rule>`; clearing them requires `:Pending edit <id> -due`. * refactor: remove project-local store discovery Problem: `store.resolve_path()` searched upward for `.pending.json`, silently splitting task data across multiple files depending on CWD. Solution: `resolve_path()` now always returns `config.get().data_path`. Remove `M.init()` and the `:Pending init` command and tab-completion entry. Remove the project-local health message. * refactor: extract log.lua, standardise [pending.nvim]: prefix Problem: Notifications were scattered across files using bare `vim.notify` with inconsistent `pending.nvim: ` prefixes, and the `debug` guard in `textobj.lua` and `init.lua` was duplicated inline. Solution: Add `lua/pending/log.lua` with `info`, `warn`, `error`, and `debug` functions (prefix `[pending.nvim]: `). `log.debug` only fires when `config.debug = true` or the optional `override` param is `true`. Replace all `vim.notify` callsites and remove inline debug guards. * feat(parse): configurable input date formats Problem: `due:` only accepted ISO `YYYY-MM-DD` and built-in keywords; users expecting locale-style dates like `03/15/2026` or `15-Mar-2026` had no way to configure alternative input formats. Solution: Add `input_date_formats` config field (string[]). Each entry is a strftime-like format string supporting `%Y`, `%y`, `%m`, `%d`, `%e`, `%b`, `%B`. Formats are tried in order after built-in keywords fail. When no year specifier is present the current or next year is inferred. Update vimdoc and add 8 parse_spec tests.
653 lines
16 KiB
Lua
653 lines
16 KiB
Lua
local config = require('pending.config')
|
|
|
|
---@class pending.parse
|
|
local M = {}
|
|
|
|
---@param s string
|
|
---@return boolean
|
|
local function is_valid_date(s)
|
|
local y, m, d = s:match('^(%d%d%d%d)-(%d%d)-(%d%d)$')
|
|
if not y then
|
|
return false
|
|
end
|
|
local yn = tonumber(y) --[[@as integer]]
|
|
local mn = tonumber(m) --[[@as integer]]
|
|
local dn = tonumber(d) --[[@as integer]]
|
|
if mn < 1 or mn > 12 then
|
|
return false
|
|
end
|
|
if dn < 1 or dn > 31 then
|
|
return false
|
|
end
|
|
local t = os.time({ year = yn, month = mn, day = dn })
|
|
local check = os.date('*t', t) --[[@as osdate]]
|
|
return check.year == yn and check.month == mn and check.day == dn
|
|
end
|
|
|
|
---@param s string
|
|
---@return boolean
|
|
local function is_valid_time(s)
|
|
local h, m = s:match('^(%d%d):(%d%d)$')
|
|
if not h then
|
|
return false
|
|
end
|
|
local hn = tonumber(h) --[[@as integer]]
|
|
local mn = tonumber(m) --[[@as integer]]
|
|
return hn >= 0 and hn <= 23 and mn >= 0 and mn <= 59
|
|
end
|
|
|
|
---@param s string
|
|
---@return string|nil
|
|
local function normalize_time(s)
|
|
local h, m, period
|
|
|
|
h, m, period = s:match('^(%d+):(%d%d)([ap]m)$')
|
|
if not h then
|
|
h, period = s:match('^(%d+)([ap]m)$')
|
|
if h then
|
|
m = '00'
|
|
end
|
|
end
|
|
if not h then
|
|
h, m = s:match('^(%d%d):(%d%d)$')
|
|
end
|
|
if not h then
|
|
h, m = s:match('^(%d):(%d%d)$')
|
|
end
|
|
if not h then
|
|
h = s:match('^(%d+)$')
|
|
if h then
|
|
m = '00'
|
|
end
|
|
end
|
|
|
|
if not h then
|
|
return nil
|
|
end
|
|
|
|
local hn = tonumber(h) --[[@as integer]]
|
|
local mn = tonumber(m) --[[@as integer]]
|
|
|
|
if period then
|
|
if hn < 1 or hn > 12 then
|
|
return nil
|
|
end
|
|
if period == 'am' then
|
|
hn = hn == 12 and 0 or hn
|
|
else
|
|
hn = hn == 12 and 12 or hn + 12
|
|
end
|
|
else
|
|
if hn < 0 or hn > 23 then
|
|
return nil
|
|
end
|
|
end
|
|
|
|
if mn < 0 or mn > 59 then
|
|
return nil
|
|
end
|
|
|
|
return string.format('%02d:%02d', hn, mn)
|
|
end
|
|
|
|
---@param s string
|
|
---@return boolean
|
|
local function is_valid_datetime(s)
|
|
local date_part, time_part = s:match('^(.+)T(.+)$')
|
|
if not date_part then
|
|
return is_valid_date(s)
|
|
end
|
|
return is_valid_date(date_part) and is_valid_time(time_part)
|
|
end
|
|
|
|
---@return string
|
|
local function date_key()
|
|
return config.get().date_syntax or 'due'
|
|
end
|
|
|
|
---@return string
|
|
local function recur_key()
|
|
return config.get().recur_syntax or 'rec'
|
|
end
|
|
|
|
local weekday_map = {
|
|
sun = 1,
|
|
mon = 2,
|
|
tue = 3,
|
|
wed = 4,
|
|
thu = 5,
|
|
fri = 6,
|
|
sat = 7,
|
|
}
|
|
|
|
local month_map = {
|
|
jan = 1,
|
|
feb = 2,
|
|
mar = 3,
|
|
apr = 4,
|
|
may = 5,
|
|
jun = 6,
|
|
jul = 7,
|
|
aug = 8,
|
|
sep = 9,
|
|
oct = 10,
|
|
nov = 11,
|
|
dec = 12,
|
|
}
|
|
|
|
---@param today osdate
|
|
---@return string
|
|
local function today_str(today)
|
|
return os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day })) --[[@as string]]
|
|
end
|
|
|
|
---@param date_part string
|
|
---@param time_suffix? string
|
|
---@return string
|
|
local function append_time(date_part, time_suffix)
|
|
if time_suffix then
|
|
return date_part .. 'T' .. time_suffix
|
|
end
|
|
return date_part
|
|
end
|
|
|
|
---@param name string
|
|
---@return integer?
|
|
local function month_name_to_num(name)
|
|
return month_map[name:lower():sub(1, 3)]
|
|
end
|
|
|
|
---@param fmt string
|
|
---@return string, string[]
|
|
local function input_format_to_pattern(fmt)
|
|
local fields = {}
|
|
local parts = {}
|
|
local i = 1
|
|
while i <= #fmt do
|
|
local c = fmt:sub(i, i)
|
|
if c == '%' and i < #fmt then
|
|
local spec = fmt:sub(i + 1, i + 1)
|
|
if spec == '%' then
|
|
parts[#parts + 1] = '%%'
|
|
i = i + 2
|
|
elseif spec == 'Y' then
|
|
fields[#fields + 1] = 'year'
|
|
parts[#parts + 1] = '(%d%d%d%d)'
|
|
i = i + 2
|
|
elseif spec == 'y' then
|
|
fields[#fields + 1] = 'year2'
|
|
parts[#parts + 1] = '(%d%d)'
|
|
i = i + 2
|
|
elseif spec == 'm' then
|
|
fields[#fields + 1] = 'month_num'
|
|
parts[#parts + 1] = '(%d%d?)'
|
|
i = i + 2
|
|
elseif spec == 'd' or spec == 'e' then
|
|
fields[#fields + 1] = 'day'
|
|
parts[#parts + 1] = '(%d%d?)'
|
|
i = i + 2
|
|
elseif spec == 'b' or spec == 'B' then
|
|
fields[#fields + 1] = 'month_name'
|
|
parts[#parts + 1] = '(%a+)'
|
|
i = i + 2
|
|
else
|
|
parts[#parts + 1] = vim.pesc(c)
|
|
i = i + 1
|
|
end
|
|
else
|
|
parts[#parts + 1] = vim.pesc(c)
|
|
i = i + 1
|
|
end
|
|
end
|
|
return '^' .. table.concat(parts) .. '$', fields
|
|
end
|
|
|
|
---@param date_input string
|
|
---@param time_suffix? string
|
|
---@return string?
|
|
local function try_input_date_formats(date_input, time_suffix)
|
|
local fmts = config.get().input_date_formats
|
|
if not fmts or #fmts == 0 then
|
|
return nil
|
|
end
|
|
local today = os.date('*t') --[[@as osdate]]
|
|
for _, fmt in ipairs(fmts) do
|
|
local pat, fields = input_format_to_pattern(fmt)
|
|
local caps = { date_input:match(pat) }
|
|
if caps[1] ~= nil then
|
|
local year, month, day
|
|
for j = 1, #fields do
|
|
local field = fields[j]
|
|
local val = caps[j]
|
|
if field == 'year' then
|
|
year = tonumber(val)
|
|
elseif field == 'year2' then
|
|
local y = tonumber(val) --[[@as integer]]
|
|
year = y + (y >= 70 and 1900 or 2000)
|
|
elseif field == 'month_num' then
|
|
month = tonumber(val)
|
|
elseif field == 'day' then
|
|
day = tonumber(val)
|
|
elseif field == 'month_name' then
|
|
month = month_name_to_num(val)
|
|
end
|
|
end
|
|
if month and day then
|
|
if not year then
|
|
year = today.year
|
|
if month < today.month or (month == today.month and day < today.day) then
|
|
year = year + 1
|
|
end
|
|
end
|
|
local t = os.time({ year = year, month = month, day = day })
|
|
local check = os.date('*t', t) --[[@as osdate]]
|
|
if check.year == year and check.month == month and check.day == day then
|
|
return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix)
|
|
end
|
|
end
|
|
end
|
|
end
|
|
return nil
|
|
end
|
|
|
|
---@param text string
|
|
---@return string|nil
|
|
function M.resolve_date(text)
|
|
local date_input, time_suffix = text:match('^(.+)@(.+)$')
|
|
if time_suffix then
|
|
time_suffix = normalize_time(time_suffix)
|
|
if not time_suffix then
|
|
return nil
|
|
end
|
|
else
|
|
date_input = text
|
|
end
|
|
|
|
local dt = date_input:match('^(%d%d%d%d%-%d%d%-%d%dT%d%d:%d%d)$')
|
|
if dt then
|
|
local dp, tp = dt:match('^(.+)T(.+)$')
|
|
if is_valid_date(dp) and is_valid_time(tp) then
|
|
return dt
|
|
end
|
|
return nil
|
|
end
|
|
|
|
if is_valid_date(date_input) then
|
|
return append_time(date_input, time_suffix)
|
|
end
|
|
|
|
local lower = date_input:lower()
|
|
local today = os.date('*t') --[[@as osdate]]
|
|
|
|
if lower == 'today' or lower == 'eod' then
|
|
return append_time(today_str(today), time_suffix)
|
|
end
|
|
|
|
if lower == 'yesterday' then
|
|
return append_time(
|
|
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day - 1 })) --[[@as string]],
|
|
time_suffix
|
|
)
|
|
end
|
|
|
|
if lower == 'tomorrow' then
|
|
return append_time(
|
|
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = today.day + 1 })) --[[@as string]],
|
|
time_suffix
|
|
)
|
|
end
|
|
|
|
if lower == 'sow' then
|
|
local delta = -((today.wday - 2) % 7)
|
|
return append_time(
|
|
os.date(
|
|
'%Y-%m-%d',
|
|
os.time({ year = today.year, month = today.month, day = today.day + delta })
|
|
) --[[@as string]],
|
|
time_suffix
|
|
)
|
|
end
|
|
|
|
if lower == 'eow' then
|
|
local delta = (1 - today.wday) % 7
|
|
return append_time(
|
|
os.date(
|
|
'%Y-%m-%d',
|
|
os.time({ year = today.year, month = today.month, day = today.day + delta })
|
|
) --[[@as string]],
|
|
time_suffix
|
|
)
|
|
end
|
|
|
|
if lower == 'som' then
|
|
return append_time(
|
|
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month, day = 1 })) --[[@as string]],
|
|
time_suffix
|
|
)
|
|
end
|
|
|
|
if lower == 'eom' then
|
|
return append_time(
|
|
os.date('%Y-%m-%d', os.time({ year = today.year, month = today.month + 1, day = 0 })) --[[@as string]],
|
|
time_suffix
|
|
)
|
|
end
|
|
|
|
if lower == 'soq' then
|
|
local q = math.ceil(today.month / 3)
|
|
local first_month = (q - 1) * 3 + 1
|
|
return append_time(
|
|
os.date('%Y-%m-%d', os.time({ year = today.year, month = first_month, day = 1 })) --[[@as string]],
|
|
time_suffix
|
|
)
|
|
end
|
|
|
|
if lower == 'eoq' then
|
|
local q = math.ceil(today.month / 3)
|
|
local last_month = q * 3
|
|
return append_time(
|
|
os.date('%Y-%m-%d', os.time({ year = today.year, month = last_month + 1, day = 0 })) --[[@as string]],
|
|
time_suffix
|
|
)
|
|
end
|
|
|
|
if lower == 'soy' then
|
|
return append_time(
|
|
os.date('%Y-%m-%d', os.time({ year = today.year, month = 1, day = 1 })) --[[@as string]],
|
|
time_suffix
|
|
)
|
|
end
|
|
|
|
if lower == 'eoy' then
|
|
return append_time(
|
|
os.date('%Y-%m-%d', os.time({ year = today.year, month = 12, day = 31 })) --[[@as string]],
|
|
time_suffix
|
|
)
|
|
end
|
|
|
|
if lower == 'later' or lower == 'someday' then
|
|
return append_time(config.get().someday_date, time_suffix)
|
|
end
|
|
|
|
local n = lower:match('^%+(%d+)d$')
|
|
if n then
|
|
return append_time(
|
|
os.date(
|
|
'%Y-%m-%d',
|
|
os.time({
|
|
year = today.year,
|
|
month = today.month,
|
|
day = today.day + (
|
|
tonumber(n) --[[@as integer]]
|
|
),
|
|
})
|
|
) --[[@as string]],
|
|
time_suffix
|
|
)
|
|
end
|
|
|
|
n = lower:match('^%+(%d+)w$')
|
|
if n then
|
|
return append_time(
|
|
os.date(
|
|
'%Y-%m-%d',
|
|
os.time({
|
|
year = today.year,
|
|
month = today.month,
|
|
day = today.day + (
|
|
tonumber(n) --[[@as integer]]
|
|
) * 7,
|
|
})
|
|
) --[[@as string]],
|
|
time_suffix
|
|
)
|
|
end
|
|
|
|
n = lower:match('^%+(%d+)m$')
|
|
if n then
|
|
return append_time(
|
|
os.date(
|
|
'%Y-%m-%d',
|
|
os.time({
|
|
year = today.year,
|
|
month = today.month + (
|
|
tonumber(n) --[[@as integer]]
|
|
),
|
|
day = today.day,
|
|
})
|
|
) --[[@as string]],
|
|
time_suffix
|
|
)
|
|
end
|
|
|
|
n = lower:match('^%-(%d+)d$')
|
|
if n then
|
|
return append_time(
|
|
os.date(
|
|
'%Y-%m-%d',
|
|
os.time({
|
|
year = today.year,
|
|
month = today.month,
|
|
day = today.day - (
|
|
tonumber(n) --[[@as integer]]
|
|
),
|
|
})
|
|
) --[[@as string]],
|
|
time_suffix
|
|
)
|
|
end
|
|
|
|
n = lower:match('^%-(%d+)w$')
|
|
if n then
|
|
return append_time(
|
|
os.date(
|
|
'%Y-%m-%d',
|
|
os.time({
|
|
year = today.year,
|
|
month = today.month,
|
|
day = today.day - (
|
|
tonumber(n) --[[@as integer]]
|
|
) * 7,
|
|
})
|
|
) --[[@as string]],
|
|
time_suffix
|
|
)
|
|
end
|
|
|
|
local ord = lower:match('^(%d+)[snrt][tdh]$')
|
|
if ord then
|
|
local day_num = tonumber(ord) --[[@as integer]]
|
|
if day_num >= 1 and day_num <= 31 then
|
|
local m, y = today.month, today.year
|
|
if today.day >= day_num then
|
|
m = m + 1
|
|
if m > 12 then
|
|
m = 1
|
|
y = y + 1
|
|
end
|
|
end
|
|
local t = os.time({ year = y, month = m, day = day_num })
|
|
local check = os.date('*t', t) --[[@as osdate]]
|
|
if check.day == day_num then
|
|
return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix)
|
|
end
|
|
m = m + 1
|
|
if m > 12 then
|
|
m = 1
|
|
y = y + 1
|
|
end
|
|
t = os.time({ year = y, month = m, day = day_num })
|
|
check = os.date('*t', t) --[[@as osdate]]
|
|
if check.day == day_num then
|
|
return append_time(os.date('%Y-%m-%d', t) --[[@as string]], time_suffix)
|
|
end
|
|
return nil
|
|
end
|
|
end
|
|
|
|
local target_month = month_map[lower]
|
|
if target_month then
|
|
local y = today.year
|
|
if today.month >= target_month then
|
|
y = y + 1
|
|
end
|
|
return append_time(
|
|
os.date('%Y-%m-%d', os.time({ year = y, month = target_month, day = 1 })) --[[@as string]],
|
|
time_suffix
|
|
)
|
|
end
|
|
|
|
local target_wday = weekday_map[lower]
|
|
if target_wday then
|
|
local current_wday = today.wday
|
|
local delta = (target_wday - current_wday) % 7
|
|
return append_time(
|
|
os.date(
|
|
'%Y-%m-%d',
|
|
os.time({ year = today.year, month = today.month, day = today.day + delta })
|
|
) --[[@as string]],
|
|
time_suffix
|
|
)
|
|
end
|
|
|
|
return try_input_date_formats(date_input, time_suffix)
|
|
end
|
|
|
|
---@param text string
|
|
---@return string description
|
|
---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } metadata
|
|
function M.body(text)
|
|
local tokens = {}
|
|
for token in text:gmatch('%S+') do
|
|
table.insert(tokens, token)
|
|
end
|
|
|
|
local metadata = {}
|
|
local i = #tokens
|
|
local dk = date_key()
|
|
local rk = recur_key()
|
|
local date_pattern_strict = '^' .. vim.pesc(dk) .. ':(%d%d%d%d%-%d%d%-%d%d[T%d:]*)$'
|
|
local date_pattern_any = '^' .. vim.pesc(dk) .. ':(.+)$'
|
|
local rec_pattern = '^' .. vim.pesc(rk) .. ':(%S+)$'
|
|
|
|
while i >= 1 do
|
|
local token = tokens[i]
|
|
local due_val = token:match(date_pattern_strict)
|
|
if due_val then
|
|
if metadata.due then
|
|
break
|
|
end
|
|
if not is_valid_datetime(due_val) then
|
|
break
|
|
end
|
|
metadata.due = due_val
|
|
i = i - 1
|
|
else
|
|
local raw_val = token:match(date_pattern_any)
|
|
if raw_val then
|
|
if metadata.due then
|
|
break
|
|
end
|
|
local resolved = M.resolve_date(raw_val)
|
|
if not resolved then
|
|
break
|
|
end
|
|
metadata.due = resolved
|
|
i = i - 1
|
|
else
|
|
local cat_val = token:match('^cat:(%S+)$')
|
|
if cat_val then
|
|
if metadata.cat then
|
|
break
|
|
end
|
|
metadata.cat = cat_val
|
|
i = i - 1
|
|
else
|
|
local rec_val = token:match(rec_pattern)
|
|
if rec_val then
|
|
if metadata.rec then
|
|
break
|
|
end
|
|
local recur = require('pending.recur')
|
|
local raw_spec = rec_val
|
|
if raw_spec:sub(1, 1) == '!' then
|
|
metadata.rec_mode = 'completion'
|
|
raw_spec = raw_spec:sub(2)
|
|
end
|
|
if not recur.validate(raw_spec) then
|
|
break
|
|
end
|
|
metadata.rec = raw_spec
|
|
i = i - 1
|
|
else
|
|
break
|
|
end
|
|
end
|
|
end
|
|
end
|
|
end
|
|
|
|
local desc_tokens = {}
|
|
for j = 1, i do
|
|
table.insert(desc_tokens, tokens[j])
|
|
end
|
|
local description = table.concat(desc_tokens, ' ')
|
|
|
|
return description, metadata
|
|
end
|
|
|
|
---@param text string
|
|
---@return string description
|
|
---@return { due?: string, cat?: string, rec?: string, rec_mode?: 'scheduled'|'completion' } metadata
|
|
function M.command_add(text)
|
|
local cat_prefix = text:match('^(%S.-):%s')
|
|
if cat_prefix then
|
|
local first_char = cat_prefix:sub(1, 1)
|
|
if first_char == first_char:upper() and first_char ~= first_char:lower() then
|
|
local rest = text:sub(#cat_prefix + 2):match('^%s*(.+)$')
|
|
if rest then
|
|
local desc, meta = M.body(rest)
|
|
meta.cat = meta.cat or cat_prefix
|
|
return desc, meta
|
|
end
|
|
end
|
|
end
|
|
return M.body(text)
|
|
end
|
|
|
|
---@param due string
|
|
---@return boolean
|
|
function M.is_overdue(due)
|
|
local now = os.date('*t') --[[@as osdate]]
|
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
|
local date_part, time_part = due:match('^(.+)T(.+)$')
|
|
if not date_part then
|
|
return due < today
|
|
end
|
|
if date_part < today then
|
|
return true
|
|
end
|
|
if date_part > today then
|
|
return false
|
|
end
|
|
local current_time = string.format('%02d:%02d', now.hour, now.min)
|
|
return time_part < current_time
|
|
end
|
|
|
|
---@param due string
|
|
---@return boolean
|
|
function M.is_today(due)
|
|
local now = os.date('*t') --[[@as osdate]]
|
|
local today = os.date('%Y-%m-%d') --[[@as string]]
|
|
local date_part, time_part = due:match('^(.+)T(.+)$')
|
|
if not date_part then
|
|
return due == today
|
|
end
|
|
if date_part ~= today then
|
|
return false
|
|
end
|
|
local current_time = string.format('%02d:%02d', now.hour, now.min)
|
|
return time_part >= current_time
|
|
end
|
|
|
|
return M
|