---@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 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 base_date string ---@param freq string ---@param interval integer ---@return string local function advance_date(base_date, freq, interval) local y, m, d = base_date:match('^(%d+)-(%d+)-(%d+)$') local yn = tonumber(y) --[[@as integer]] local mn = tonumber(m) --[[@as integer]] local dn = tonumber(d) --[[@as integer]] if freq == 'daily' then return os.date('%Y-%m-%d', os.time({ year = yn, month = mn, day = dn + interval })) --[[@as string]] elseif freq == 'weekly' then return 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]]) return 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]]) return os.date('%Y-%m-%d', os.time({ year = new_y, month = mn, day = clamped_d })) --[[@as string]] end return base_date 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]] if mode == 'completion' then return advance_date(today, parsed.freq, parsed.interval) end local next_date = advance_date(base_date, parsed.freq, parsed.interval) while next_date <= 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