Problem: type annotations repeated inline unions with no aliases, used `table<string, any>` where structured types exist, and had loose `string` where union types should be used. Solution: add `pending.TaskStatus`, `pending.RecurMode`, `pending.TaskExtra`, `pending.ForgeType`, `pending.ForgeState`, `pending.ForgeAuthStatus` aliases and `pending.SyncBackend` interface. Replace inline unions and loose types with the new aliases in `store.lua`, `forge.lua`, `config.lua`, `diff.lua`, `views.lua`, `parse.lua`, `init.lua`, and `oauth.lua`.
697 lines
17 KiB
Lua
697 lines
17 KiB
Lua
local config = require('pending.config')
|
|
|
|
---@class pending.Metadata
|
|
---@field due? string
|
|
---@field cat? string
|
|
---@field rec? string
|
|
---@field rec_mode? pending.RecurMode
|
|
---@field priority? integer
|
|
|
|
---@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 pending.Metadata 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 pri_bangs = token:match('^%+(!+)$')
|
|
if pri_bangs then
|
|
if metadata.priority then
|
|
break
|
|
end
|
|
local max = config.get().max_priority or 3
|
|
metadata.priority = math.min(#pri_bangs, max)
|
|
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
|
|
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 pending.Metadata 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
|
|
|
|
---@param s? string
|
|
---@return integer?
|
|
function M.parse_duration_to_days(s)
|
|
if s == nil or s == '' then
|
|
return nil
|
|
end
|
|
local n = s:match('^(%d+)d$')
|
|
if n then
|
|
return tonumber(n) --[[@as integer]]
|
|
end
|
|
n = s:match('^(%d+)w$')
|
|
if n then
|
|
return tonumber(n) --[[@as integer]]
|
|
* 7
|
|
end
|
|
n = s:match('^(%d+)m$')
|
|
if n then
|
|
return tonumber(n) --[[@as integer]]
|
|
* 30
|
|
end
|
|
n = s:match('^(%d+)$')
|
|
if n then
|
|
return tonumber(n) --[[@as integer]]
|
|
end
|
|
return nil
|
|
end
|
|
|
|
return M
|