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 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 nil 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