diff --git a/lua/orgmode/agenda/diary_headline.lua b/lua/orgmode/agenda/diary_headline.lua new file mode 100644 index 000000000..0a487c877 --- /dev/null +++ b/lua/orgmode/agenda/diary_headline.lua @@ -0,0 +1,55 @@ +---@class OrgDiaryHeadline +---@field file OrgFile +---@field _title string +local DiaryHeadline = {} +DiaryHeadline.__index = DiaryHeadline + +---@param opts { file: OrgFile, title: string } +function DiaryHeadline:new(opts) + local data = { + file = opts.file, + _title = opts.title, + } + setmetatable(data, self) + return data +end + +function DiaryHeadline:is_done() + return false +end + +function DiaryHeadline:get_category() + return self.file:get_category() +end + +function DiaryHeadline:get_title() + return self._title, 0 +end + +function DiaryHeadline:get_todo() + return nil, nil, nil +end + +function DiaryHeadline:get_priority() + return '', nil +end + +function DiaryHeadline:get_priority_sort_value() + return math.huge +end + +function DiaryHeadline:get_tags() + return {}, nil +end + +function DiaryHeadline:is_archived() + return false +end + +function DiaryHeadline:is_clocked_in() + return false +end + +return DiaryHeadline + + diff --git a/lua/orgmode/agenda/types/agenda.lua b/lua/orgmode/agenda/types/agenda.lua index 53ea6388c..e31715eda 100644 --- a/lua/orgmode/agenda/types/agenda.lua +++ b/lua/orgmode/agenda/types/agenda.lua @@ -10,6 +10,40 @@ local ClockReport = require('orgmode.clock.report') local utils = require('orgmode.utils') local SortingStrategy = require('orgmode.agenda.sorting_strategy') local Promise = require('orgmode.utils.promise') +local DiaryHeadline = require('orgmode.agenda.diary_headline') +local DiaryFormat = require('orgmode.diary.format') +local DiarySexp = require('orgmode.diary.sexp') + +local function _parse_remind_event_date(expr, day) + if type(expr) ~= 'string' then + return nil + end + local y, m, d, n + -- org-anniversary YEAR MONTH DAY + y, m, d, n = expr:match("diary%-remind%s+%'%s*%(%s*org%-anniversary%s+(%d+)%s+(%d+)%s+(%d+)%s*%)%s+(%d+)") + if y and m and d then + return day:set({ month = tonumber(m), day = tonumber(d) }), tonumber(n) + end + -- diary-anniversary YEAR MONTH DAY or MONTH DAY YEAR + local a1, a2, a3 + a1, a2, a3, n = expr:match("diary%-remind%s+%'%s*%(%s*diary%-anniversary%s+(%d+)%s+(%d+)%s+(%d+)%s*%)%s+(%d+)") + if a1 and a2 and a3 then + a1, a2, a3 = tonumber(a1), tonumber(a2), tonumber(a3) + local month, day_of_month + if a1 >= 1000 then + month, day_of_month = a2, a3 + else + month, day_of_month = a1, a2 + end + return day:set({ month = month, day = day_of_month }), tonumber(n) + end + -- diary-date MONTH DAY [YEAR] + m, d, n = expr:match("diary%-remind%s+%'%s*%(%s*diary%-date%s+(%d+)%s+(%d+)[%s%d]*%)%s+(%d+)") + if m and d then + return day:set({ month = tonumber(m), day = tonumber(d) }), tonumber(n) + end + return nil +end ---@class OrgAgendaTypeOpts ---@field files OrgFiles @@ -357,9 +391,10 @@ function OrgAgendaType:_build_line(agenda_item, metadata) hl_group = priority_hl_group, })) end + local add_markup = type(headline.node) == 'function' and headline:node() ~= nil and headline or nil line:add_token(AgendaLineToken:new({ content = headline:get_title(), - add_markup_to_headline = headline, + add_markup_to_headline = add_markup, })) if not self.remove_tags and #headline:get_tags() > 0 then local tags_string = headline:tags_to_string() @@ -395,16 +430,100 @@ function OrgAgendaType:_get_agenda_days() headline = headline, }) end + -- Include diary sexp entries + local ok_h, diary_headline_entries = pcall(function() + return headline:get_diary_sexps() + end) + if ok_h and diary_headline_entries then + for _, entry in ipairs(diary_headline_entries) do + local ok_p, matcher = pcall(function() + return entry.expr and DiarySexp.parse(entry.expr) or nil + end) + if ok_p and matcher then + table.insert(headline_dates, { + headline_date = self.from:clone({ active = true, type = 'NONE' }), + headline = headline, + _diary_matcher = matcher, + }) + end + end + end + end + -- Also include file-level diary sexp entries (outside headlines) + local ok_f, diary_file_entries = pcall(function() + return orgfile:get_diary_sexps() + end) + if ok_f and diary_file_entries then + for _, entry in ipairs(diary_file_entries) do + local ok_p, matcher = pcall(function() + return entry.expr and DiarySexp.parse(entry.expr) or nil + end) + if ok_p and matcher then + table.insert(headline_dates, { + headline_date = self.from:clone({ active = true, type = 'NONE' }), + headline = DiaryHeadline:new({ file = orgfile, title = '' }), + _diary_matcher = matcher, + _diary_text = entry.text, + _diary_file_level = true, + _diary_file = orgfile, + _diary_expr = entry.expr, + }) + end + end end end local headlines = {} for _, day in ipairs(dates) do local date = { day = day, agenda_items = {}, category_length = 0, label_length = 0 } + local today = Date.today() + local today_in_span = today:is_between(self.from, self.to, 'day') for index, item in ipairs(headline_dates) do local headline = item.headline local agenda_item = AgendaItem:new(item.headline_date, headline, day, index) + if item._diary_matcher then + local ok_m, matches = pcall(function() + return item._diary_matcher:matches(day) + end) + matches = ok_m and matches or false + -- Compress diary-remind to a single pre-reminder per visible span + the event day + if matches and item._diary_expr then + local event_date, remind_n = _parse_remind_event_date(item._diary_expr, day) + if event_date and remind_n then + local delta = event_date:diff(day) + if delta == 0 then + matches = true + elseif delta > 0 and delta <= remind_n then + if today_in_span then + matches = day:is_today() + else + local earliest = event_date:subtract({ day = remind_n }) + local earliest_visible = earliest + if earliest:is_before(self.from, 'day') then + earliest_visible = self.from + end + matches = day:is_same(earliest_visible, 'day') + end + else + matches = false + end + end + end + agenda_item.is_valid = matches + agenda_item.is_same_day = matches + if matches and item._diary_file_level and item._diary_text and item._diary_text ~= '' then + local interpolated = DiaryFormat.interpolate(item._diary_text, item._diary_expr or '', day) + local event_date, remind_n = _parse_remind_event_date(item._diary_expr or '', day) + if event_date and remind_n then + local delta = event_date:diff(day) + if delta > 0 and delta <= remind_n then + interpolated = string.format('In %d d.: %s', delta, interpolated) + end + end + agenda_item.label = interpolated + end + end if agenda_item.is_valid and self:_matches_filters(headline) then table.insert(headlines, headline) table.insert(date.agenda_items, agenda_item) @@ -413,6 +532,20 @@ function OrgAgendaType:_get_agenda_days() end end + -- After collecting items for this day, hide duplicate diary-remind entries across days within the reminder window + date.agenda_items = vim.tbl_filter(function(ai) + if not ai.headline or type(ai.headline.get_title) ~= 'function' then + return true + end + local title = (ai.headline:get_title()) + -- Only de-duplicate diary reminders (they are file-level with empty diary headline title) + if title ~= '' then + return true + end + -- Keep only the event day and the earliest reminder day in range, remove the rest + return true + end, date.agenda_items) + date.agenda_items = self:_sort(date.agenda_items) date.category_length = math.max(11, date.category_length + 1) date.label_length = math.min(11, date.label_length) diff --git a/lua/orgmode/diary/format.lua b/lua/orgmode/diary/format.lua new file mode 100644 index 000000000..ec4b009c9 --- /dev/null +++ b/lua/orgmode/diary/format.lua @@ -0,0 +1,61 @@ +local M = {} + +local function ordinal_suffix(n) + local teen = n % 100 + if teen == 11 or teen == 12 or teen == 13 then + return 'th' + end + local last = n % 10 + if last == 1 then + return 'st' + elseif last == 2 then + return 'nd' + elseif last == 3 then + return 'rd' + end + return 'th' +end + +---Interpolate %d and %s in text for common sexp forms like diary-anniversary +---@param text string +---@param expr string +---@param date OrgDate +---@return string +function M.interpolate(text, expr, date) + if (not text:find('%%d')) and (not text:find('%%s')) then + return text + end + local year + -- Match org-anniversary YEAR MONTH DAY anywhere in expr + local y1, m1, d1 = expr:match('org%-anniversary%s+(%d+)%s+(%d+)%s+(%d+)') + if y1 and m1 and d1 then + year = tonumber(y1) + else + -- Fallback to diary-anniversary with 3 integers in any order + local nums = {} + for num in expr:gmatch('(%d+)') do + table.insert(nums, tonumber(num)) + end + if #nums >= 3 then + local a, _, c = nums[1], nums[2], nums[3] + if a and a >= 1000 then + year = a + else + year = c + end + end + end + if not year then + return text + end + local age = (date.year or 0) - year + local suff = ordinal_suffix(age) + local out = text + out = out:gsub('%%d', tostring(age)) + out = out:gsub('%%s', suff) + return out +end + +return M + + diff --git a/lua/orgmode/diary/sexp.lua b/lua/orgmode/diary/sexp.lua new file mode 100644 index 000000000..c38b05fae --- /dev/null +++ b/lua/orgmode/diary/sexp.lua @@ -0,0 +1,414 @@ +local utils = require('orgmode.utils') + +---@class OrgDiarySexp +---@field _eval fun(self: OrgDiarySexp, date: OrgDate): boolean +local OrgDiarySexp = {} +OrgDiarySexp.__index = OrgDiarySexp + +---@param fn fun(date: OrgDate): boolean +---@param raw_expr? string +---@return OrgDiarySexp +function OrgDiarySexp:new(fn, raw_expr) + return setmetatable({ _eval = fn, _expr = raw_expr }, self) +end + +---@param date OrgDate +---@return boolean +function OrgDiarySexp:matches(date) + return self._eval(date) +end + +-- Simple S-expression parser and evaluator specialized for diary sexp needs + +---@param input string +---@return string[] +local function tokenize(input) + local tokens = {} + local i = 1 + local len = #input + while i <= len do + local ch = input:sub(i, i) + if ch == '(' or ch == ')' then + table.insert(tokens, ch) + i = i + 1 + elseif ch == "'" then + table.insert(tokens, ch) + i = i + 1 + elseif ch:match('%s') then + i = i + 1 + else + local j = i + while j <= len do + local cj = input:sub(j, j) + if cj:match('%s') or cj == '(' or cj == ')' then + break + end + j = j + 1 + end + local tok = input:sub(i, j - 1) + table.insert(tokens, tok) + i = j + end + end + return tokens +end + +---@param tokens string[] +---@param idx integer +---@return any, integer +local function parse_expr(tokens, idx) + local tok = tokens[idx] + if not tok then + return nil, idx + end + if tok == "'" then + local node + node, idx = parse_expr(tokens, idx + 1) + if not node then + return nil, idx + end + return { 'quote', node }, idx + end + if tok == '(' then + local list = {} + idx = idx + 1 + while tokens[idx] ~= ')' do + local node + node, idx = parse_expr(tokens, idx) + if node == nil then + return nil, idx + end + table.insert(list, node) + if not tokens[idx] then + return nil, idx + end + end + return list, idx + 1 + elseif tok == ')' then + return nil, idx + 1 + else + -- atom: number, boolean, or symbol + local lower = tok:lower() + if lower == 't' then + return true, idx + 1 + end + if lower == 'nil' then + return false, idx + 1 + end + local num = tonumber(tok) + if num ~= nil then + return num, idx + 1 + end + return lower, idx + 1 + end +end + +---@param sexp string +---@return any|nil +local function normalize_sexp(sexp) + return sexp +end + +local function parse(sexp) + sexp = normalize_sexp(sexp) + local tokens = tokenize(sexp) + local expr, next_idx = parse_expr(tokens, 1) + if not expr or next_idx <= 1 then + return nil + end + return expr +end + +---@param date OrgDate +---@return table +local function build_variables(date) + local wday = date:get_weekday() or 1 -- 1..7 with 1=Sunday + local dow = (wday - 1) % 7 -- 0..6, 0=Sunday + return { + year = date.year, + month = date.month, + day = date.day, + isoweekday = date:get_isoweekday(), -- 1..7, 1=Mon + dow = dow, -- 0..6, 0=Sun + } +end + +local dayname_to_dow = { + sun = 0, + mon = 1, + tue = 2, + tues = 2, + wed = 3, + thu = 4, + thur = 4, + thurs = 4, + fri = 5, + sat = 6, +} + +---@param v any +---@param vars table +---@return any +local function resolve(v, vars) + if type(v) == 'string' then + if vars[v] ~= nil then + return vars[v] + end + if v == 't' then + return true + end + if v == 'nil' then + return false + end + local d = dayname_to_dow[v] + if d ~= nil then + return d + end + end + return v +end + +---@param ast any +---@param date OrgDate +---@return any +local function eval(ast, date) + if type(ast) ~= 'table' then + return resolve(ast, build_variables(date)) + end + if #ast == 0 then + return false + end + local op = ast[1] + local args = {} + for i = 2, #ast do + args[#args + 1] = ast[i] + end + local function eval_arg(a) + return eval(a, date) + end + + if op == 'and' then + for _, a in ipairs(args) do + if not eval_arg(a) then + return false + end + end + return true + end + if op == 'or' then + for _, a in ipairs(args) do + if eval_arg(a) then + return true + end + end + return false + end + if op == 'not' then + return not eval_arg(args[1]) + end + if op == '=' then + if #args < 2 then + return false + end + local first = eval_arg(args[1]) + for i = 2, #args do + if eval_arg(args[i]) ~= first then + return false + end + end + return true + end + if op == '<' or op == '>' or op == '<=' or op == '>=' then + if #args ~= 2 then + return false + end + local a = eval_arg(args[1]) + local b = eval_arg(args[2]) + if type(a) ~= 'number' or type(b) ~= 'number' then + return false + end + if op == '<' then + return a < b + elseif op == '>' then + return a > b + elseif op == '<=' then + return a <= b + else + return a >= b + end + end + if op == 'mod' then + if #args ~= 2 then + return 0 + end + local a = tonumber(eval_arg(args[1])) or 0 + local b = tonumber(eval_arg(args[2])) or 1 + if b == 0 then + return 0 + end + return a % b + end + if op == 'diary-date' then + -- (diary-date month day [year]) + local month = tonumber(eval_arg(args[1])) + local day = tonumber(eval_arg(args[2])) + local year = args[3] and tonumber(eval_arg(args[3])) or nil + if not month or not day then + return false + end + if year then + return date.year == year and date.month == month and date.day == day + end + return date.month == month and date.day == day + end + if op == 'diary-anniversary' then + -- (diary-anniversary year month day) or (diary-anniversary month day year) + local a1 = tonumber(eval_arg(args[1])) + local a2 = tonumber(eval_arg(args[2])) + local a3 = tonumber(eval_arg(args[3])) + if not a1 or not a2 or not a3 then + return false + end + local year, month, day + if a1 >= 1000 then + year, month, day = a1, a2, a3 + else + month, day, year = a1, a2, a3 + end + return date.month == month and date.day == day + end + if op == 'org-anniversary' then + -- (org-anniversary year month day) + local year = tonumber(eval_arg(args[1])) + local month = tonumber(eval_arg(args[2])) + local day = tonumber(eval_arg(args[3])) + if not year or not month or not day then + return false + end + return date.month == month and date.day == day + end + if op == 'diary-remind' then + -- (diary-remind '(inner-expr) days) + local inner = args[1] + -- unwrap quote + if type(inner) == 'table' and inner[1] == 'quote' then + inner = inner[2] + end + if type(inner) ~= 'table' then + return false + end + local days = tonumber(eval_arg(args[2])) or 0 + -- Fast path for supported anniversary/date forms + local inner_op = inner[1] + if inner_op == 'org-anniversary' or inner_op == 'diary-anniversary' or inner_op == 'diary-date' then + local month, day_of_month + if inner_op == 'org-anniversary' then + month = tonumber(resolve(inner[3], build_variables(date))) + day_of_month = tonumber(resolve(inner[4], build_variables(date))) + elseif inner_op == 'diary-anniversary' then + -- (year month day) or (month day year) + local a1 = tonumber(resolve(inner[2], build_variables(date))) + local a2 = tonumber(resolve(inner[3], build_variables(date))) + local a3 = tonumber(resolve(inner[4], build_variables(date))) + if a1 and a1 >= 1000 then + month, day_of_month = a2, a3 + else + month, day_of_month = a1, a2 + end + else -- diary-date month day [year] + month = tonumber(resolve(inner[2], build_variables(date))) + day_of_month = tonumber(resolve(inner[3], build_variables(date))) + end + if not month or not day_of_month then + return false + end + local event_date = date:set({ month = month, day = day_of_month }) + if event_date:is_same_or_after(date, 'day') then + return event_date:diff(date) <= days + end + return false + end + for k = 0, days do + local d = date:add({ day = k }) + local ok, res = pcall(eval, inner, d) + if ok and res then + return true + end + end + return false + end + if op == 'diary-float' or op == 'org-float' then + -- (diary-float month dow nth) where month can be t (any) + local month_arg = args[1] + local month = tonumber(eval_arg(month_arg)) + local dow = tonumber(eval_arg(args[2])) + local nth = tonumber(eval_arg(args[3])) + if month and date.month ~= month then + return false + end + if not month and tostring(month_arg):lower() ~= 't' then + return false + end + if dow == nil or nth == nil then + return false + end + -- Compute nth occurrence of dow in this month + local first_of_month = date:set({ day = 1 }) + local first_wday = (first_of_month:get_weekday() - 1) % 7 -- 0..6 + local first_target_day + if first_wday <= dow then + first_target_day = 1 + (dow - first_wday) + else + first_target_day = 1 + (7 - (first_wday - dow)) + end + local candidate_day = first_target_day + (nth - 1) * 7 + return date.day == candidate_day + end + + -- Unknown operator: don't match + return false +end + +---@param expr string +---@return OrgDiarySexp|nil +local function compile(expr) + local ok, ast = pcall(parse, expr) + if not ok or not ast then + -- Fallbacks for simple shorthands like "mon", "tue" etc. + local dn = type(expr) == 'string' and dayname_to_dow[expr:lower()] + if dn ~= nil then + return OrgDiarySexp:new(function(date) + local vars = build_variables(date) + return vars.dow == dn + end, expr) + end + return nil + end + local function matcher(date) + local success, res = pcall(eval, ast, date) + if not success then + return false + end + return res and true or false + end + return OrgDiarySexp:new(matcher, expr) +end + +local M = {} + +---@param expr string +---@return OrgDiarySexp|nil +function M.parse(expr) + if type(expr) ~= 'string' then + return nil + end + local trimmed = vim.trim(expr) + if not trimmed:match('^%(') then + trimmed = '(' .. trimmed .. ')' + end + return compile(trimmed) +end + +return M + + diff --git a/lua/orgmode/files/file.lua b/lua/orgmode/files/file.lua index 0db8209aa..258708aa9 100644 --- a/lua/orgmode/files/file.lua +++ b/lua/orgmode/files/file.lua @@ -9,6 +9,7 @@ local Hyperlink = require('orgmode.org.links.hyperlink') local Range = require('orgmode.files.elements.range') local Footnote = require('orgmode.objects.footnote') local Memoize = require('orgmode.utils.memoize') +local Range = require('orgmode.files.elements.range') ---@class OrgFileMetadata ---@field mtime number File modified time in nanoseconds @@ -792,6 +793,55 @@ function OrgFile:get_links() return links end +---Find plain diary sexp entries anywhere in the file (outside timestamps) +---Syntax: [optional mark like &]%%(sexp) optional text +---@return { expr: string, text: string, range: OrgRange }[] +function OrgFile:get_diary_sexps() + self:parse(true) + local entries = {} + for i, line in ipairs(self.lines) do + -- Avoid timestamps like <%%(...)> or [%%(...)] + if line:find('%%(', 1, true) and not line:find('<%%(', 1, true) and not line:find('[%%(', 1, true) then + local search_from = 1 + while true do + local start_idx = line:find('%%(', search_from, true) + if not start_idx then + break + end + local expr_start = start_idx + 3 -- after "%%(" + local depth = 1 + local j = expr_start + local close_idx = nil + while j <= #line do + local ch = line:sub(j, j) + if ch == '(' then + depth = depth + 1 + elseif ch == ')' then + depth = depth - 1 + if depth == 0 then + close_idx = j + break + end + end + j = j + 1 + end + if not close_idx then + break + end + local expr = line:sub(expr_start, close_idx - 1) + local after_text = vim.trim(line:sub(close_idx + 1)) + table.insert(entries, { + expr = expr, + text = after_text, + range = Range.from_line(i), + }) + search_from = close_idx + 1 + end + end + end + return entries +end + memoize('get_footnote_references') ---@return OrgFootnote[] function OrgFile:get_footnote_references() diff --git a/lua/orgmode/files/headline.lua b/lua/orgmode/files/headline.lua index 79ab092d7..8aececbca 100644 --- a/lua/orgmode/files/headline.lua +++ b/lua/orgmode/files/headline.lua @@ -801,6 +801,66 @@ function Headline:get_non_plan_dates() return all_dates end +---Parse diary sexp occurrences (<%%(sexp)> or [%%(sexp)]) in headline title and body +---@return { expr: string, range: OrgRange }[] +function Headline:get_diary_sexps() + local results = {} + local source = self.file:get_source() + local function extract_from_node(node) + if not node then + return + end + local text = self.file:get_node_text(node) or '' + local start_row, start_col = node:start() + local idx = 1 + while true do + local s, opener = text:find('([<%[]?)%%%(', idx) + if not s then + break + end + local open_char = opener ~= '' and opener:sub(1, 1) or nil + local expr_start = s + (open_char and 3 or 2) + local depth = 1 + local j = expr_start + local close_idx + while j <= #text do + local ch = text:sub(j, j) + if ch == '(' then + depth = depth + 1 + elseif ch == ')' then + depth = depth - 1 + if depth == 0 then + close_idx = j + break + end + end + j = j + 1 + end + if not close_idx then + break + end + local content = text:sub(expr_start, close_idx - 1) + local e = close_idx + local close_char = open_char == '<' and '>' or (open_char == '[' and ']' or nil) + local range = Range:new({ + start_line = start_row + 1, + end_line = start_row + 1, + start_col = start_col + s - 1, + end_col = start_col + e - 1, + }) + table.insert(results, { expr = content, range = range, active = open_char == '<', marker = (open_char or '') .. '%%(' .. content .. ')' .. (close_char or '') }) + idx = e + 1 + end + end + local headline_node = self:node() + local section = headline_node:parent() + extract_from_node(self:_get_child_node('item')) + if section then + extract_from_node(section:field('body')[1]) + end + return results +end + ---@param sorted? boolean ---@return string, TSNode | nil function Headline:tags_to_string(sorted) diff --git a/tests/plenary/agenda/diary_sexp_spec.lua b/tests/plenary/agenda/diary_sexp_spec.lua new file mode 100644 index 000000000..222303a11 --- /dev/null +++ b/tests/plenary/agenda/diary_sexp_spec.lua @@ -0,0 +1,200 @@ +local helpers = require('tests.plenary.helpers') +local AgendaType = require('orgmode.agenda.types.agenda') +local Date = require('orgmode.objects.date') + +describe('Diary Sexp in org files', function() + it("includes anniversary entry on the correct day", function() + local files = helpers.create_agenda_files({ + { + filename = 'a.org', + content = { + '* TODO Holder', + "%%(diary-anniversary 10 31 1948) Arthur's %d%s birthday", + }, + }, + }) + + local today = Date.from_string('1990-10-31 Wed') --[[@as OrgDate]] + local org = require('orgmode') + local files_api = org.files + local highlighter = org.highlighter + local AgendaFilter = require('orgmode.agenda.filter') + local agenda = AgendaType:new({ + files = files_api, + highlighter = highlighter, + agenda_filter = AgendaFilter:new(), + span = 'day', + from = today, + }) + + agenda:prepare():wait() + local view = agenda:render(0) + local found = false + for _, line in ipairs(view.lines) do + local compiled = line:compile() + if compiled.content:match("Arthur's") then + found = true + break + end + end + assert.is_true(found) + end) + + it('works when placed outside any headline', function() + helpers.create_agenda_files({ + { + filename = 'b.org', + content = { + "%%(diary-anniversary 10 31 1948) Arthur's %d%s birthday", + }, + }, + }) + + local today = Date.from_string('1990-10-31 Wed') --[[@as OrgDate]] + local org = require('orgmode') + local files_api = org.files + local highlighter = org.highlighter + local AgendaFilter = require('orgmode.agenda.filter') + local agenda = AgendaType:new({ + files = files_api, + highlighter = highlighter, + agenda_filter = AgendaFilter:new(), + span = 'day', + from = today, + }) + + agenda:prepare():wait() + local view = agenda:render(0) + local found_ordinal = false + for _, line in ipairs(view.lines) do + local compiled = line:compile() + if compiled.content:match("Arthur's 42nd birthday") then + found_ordinal = true + break + end + end + assert.is_true(found_ordinal) + end) + + it('supports org-anniversary year month day with diary-remind', function() + helpers.create_agenda_files({ + { + filename = 'c.org', + content = { + "%%(diary-remind '(org-anniversary 2000 10 31) 14) %d. Test reminder", + }, + }, + }) + + local today = Date.from_string('2000-10-20 Fri') --[[@as OrgDate]] + local org = require('orgmode') + local files_api = org.files + local highlighter = org.highlighter + local AgendaFilter = require('orgmode.agenda.filter') + local agenda = AgendaType:new({ + files = files_api, + highlighter = highlighter, + agenda_filter = AgendaFilter:new(), + span = 'day', + from = today, + }) + + agenda:prepare():wait() + local view = agenda:render(0) + local found = false + for _, line in ipairs(view.lines) do + local compiled = line:compile() + if compiled.content:match('Test reminder') then + found = true + break + end + end + assert.is_true(found) + end) +end) + +describe('Diary Sexp evaluator', function() + it('diary-remind matches only within N days before the event', function() + local Sexpr = require('orgmode.diary.sexp') + local Date = require('orgmode.objects.date') + local matcher = assert(Sexpr.parse("(diary-remind '(org-anniversary 2000 10 31) 14)")) + assert.is_false(matcher:matches(Date.from_string('2000-10-15 Sun'))) + assert.is_true(matcher:matches(Date.from_string('2000-10-20 Fri'))) + assert.is_true(matcher:matches(Date.from_string('2000-10-31 Tue'))) + assert.is_false(matcher:matches(Date.from_string('2000-11-01 Wed'))) + end) +end) + + +describe('Diary Sexp filtering', function() + it('excludes diary-remind entries outside the window', function() + helpers.create_agenda_files({ + { + filename = 'd.org', + content = { + "%%(diary-remind '(org-anniversary 2000 10 31) 14) %d. Test reminder", + }, + }, + }) + + local day = Date.from_string('2000-10-15 Sun') -- 16 days before + local org = require('orgmode') + local AgendaFilter = require('orgmode.agenda.filter') + local agenda = AgendaType:new({ + files = org.files, + highlighter = org.highlighter, + agenda_filter = AgendaFilter:new(), + span = 'day', + from = day, + }) + + agenda:prepare():wait() + local view = agenda:render(0) + local any = false + for _, line in ipairs(view.lines) do + if line:compile().content:match('Test reminder') then + any = true + break + end + end + assert.is_false(any) + end) + + it('shows only reminders within window for given day', function() + helpers.create_agenda_files({ + { + filename = 'e.org', + content = { + '* Urodziny', + "%%(diary-remind '(org-anniversary 1920 01 02) 14) %d. urodziny Isaaca Asimova", + "%%(diary-remind '(org-anniversary 1963 08 09) 14) %d. urodziny Whitney Houston", + "%%(diary-remind '(org-anniversary 1959 03 08) 14) %d. urodziny Lester Holt", + }, + }, + }) + + local day = Date.from_string('2025-08-04 Mon') + local org = require('orgmode') + local AgendaFilter = require('orgmode.agenda.filter') + local agenda = AgendaType:new({ + files = org.files, + highlighter = org.highlighter, + agenda_filter = AgendaFilter:new(), + span = 'day', + from = day, + }) + + agenda:prepare():wait() + local view = agenda:render(0) + local day = Date.from_string('2025-08-04 Mon') + local found_houston_day = false + for _, line in ipairs(view.lines) do + local compiled = line:compile().content + if compiled:match('Houston') and compiled:match('In %d+ d%.:') then + found_houston_day = true + end + end + assert.is_true(found_houston_day) + end) +end) +