local config = require('pending.config') local forge = require('pending.forge') ---@class pending.Metadata ---@field due? string ---@field category? string ---@field recur? string ---@field recur_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 category_key() return config.get().category_syntax or 'cat' 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 ck = category_key() local dk = date_key() local rk = recur_key() local cat_pattern = '^' .. vim.pesc(ck) .. ':(%S+)$' 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+)$' local desc_tokens = {} local forge_tokens = {} for _, token in ipairs(tokens) do local consumed = false local due_val = token:match(date_pattern_strict) if due_val and is_valid_datetime(due_val) then if not metadata.due then metadata.due = due_val end consumed = true end if not consumed then local raw_val = token:match(date_pattern_any) if raw_val then local resolved = M.resolve_date(raw_val) if resolved then if not metadata.due then metadata.due = resolved end consumed = true end end end if not consumed then local cat_val = token:match(cat_pattern) if cat_val then if not metadata.category then metadata.category = cat_val end consumed = true end end if not consumed then local pri_bangs = token:match('^%+(!+)$') if pri_bangs then if not metadata.priority then local max = config.get().max_priority or 3 metadata.priority = math.min(#pri_bangs, max) end consumed = true end end if not consumed then local rec_val = token:match(rec_pattern) if rec_val then local recur = require('pending.recur') local raw_spec = rec_val if raw_spec:sub(1, 1) == '!' then raw_spec = raw_spec:sub(2) end if recur.validate(raw_spec) then if not metadata.recur then metadata.recur_mode = rec_val:sub(1, 1) == '!' and 'completion' or nil metadata.recur = raw_spec end consumed = true end end end if not consumed then if forge.parse_ref(token) then table.insert(forge_tokens, token) else table.insert(desc_tokens, token) end end end for _, ft in ipairs(forge_tokens) do table.insert(desc_tokens, ft) 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.category = meta.category 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