Skip to content

Commit d0d9791

Browse files
committed
Support diary sexp entries
1 parent 2b91d9a commit d0d9791

File tree

7 files changed

+974
-1
lines changed

7 files changed

+974
-1
lines changed

lua/orgmode/agenda/diary_headline.lua

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
---@class OrgDiaryHeadline
2+
---@field file OrgFile
3+
---@field _title string
4+
local DiaryHeadline = {}
5+
DiaryHeadline.__index = DiaryHeadline
6+
7+
---@param opts { file: OrgFile, title: string }
8+
function DiaryHeadline:new(opts)
9+
local data = {
10+
file = opts.file,
11+
_title = opts.title,
12+
}
13+
setmetatable(data, self)
14+
return data
15+
end
16+
17+
function DiaryHeadline:is_done()
18+
return false
19+
end
20+
21+
function DiaryHeadline:get_category()
22+
return self.file:get_category()
23+
end
24+
25+
function DiaryHeadline:get_title()
26+
return self._title, 0
27+
end
28+
29+
function DiaryHeadline:get_todo()
30+
return nil, nil, nil
31+
end
32+
33+
function DiaryHeadline:get_priority()
34+
return '', nil
35+
end
36+
37+
function DiaryHeadline:get_priority_sort_value()
38+
return math.huge
39+
end
40+
41+
function DiaryHeadline:get_tags()
42+
return {}, nil
43+
end
44+
45+
function DiaryHeadline:is_archived()
46+
return false
47+
end
48+
49+
function DiaryHeadline:is_clocked_in()
50+
return false
51+
end
52+
53+
return DiaryHeadline
54+
55+

lua/orgmode/agenda/types/agenda.lua

Lines changed: 134 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,40 @@ local ClockReport = require('orgmode.clock.report')
1010
local utils = require('orgmode.utils')
1111
local SortingStrategy = require('orgmode.agenda.sorting_strategy')
1212
local Promise = require('orgmode.utils.promise')
13+
local DiaryHeadline = require('orgmode.agenda.diary_headline')
14+
local DiaryFormat = require('orgmode.diary.format')
15+
local DiarySexp = require('orgmode.diary.sexp')
16+
17+
local function _parse_remind_event_date(expr, day)
18+
if type(expr) ~= 'string' then
19+
return nil
20+
end
21+
local y, m, d, n
22+
-- org-anniversary YEAR MONTH DAY
23+
y, m, d, n = expr:match("diary%-remind%s+%'%s*%(%s*org%-anniversary%s+(%d+)%s+(%d+)%s+(%d+)%s*%)%s+(%d+)")
24+
if y and m and d then
25+
return day:set({ month = tonumber(m), day = tonumber(d) }), tonumber(n)
26+
end
27+
-- diary-anniversary YEAR MONTH DAY or MONTH DAY YEAR
28+
local a1, a2, a3
29+
a1, a2, a3, n = expr:match("diary%-remind%s+%'%s*%(%s*diary%-anniversary%s+(%d+)%s+(%d+)%s+(%d+)%s*%)%s+(%d+)")
30+
if a1 and a2 and a3 then
31+
a1, a2, a3 = tonumber(a1), tonumber(a2), tonumber(a3)
32+
local month, day_of_month
33+
if a1 >= 1000 then
34+
month, day_of_month = a2, a3
35+
else
36+
month, day_of_month = a1, a2
37+
end
38+
return day:set({ month = month, day = day_of_month }), tonumber(n)
39+
end
40+
-- diary-date MONTH DAY [YEAR]
41+
m, d, n = expr:match("diary%-remind%s+%'%s*%(%s*diary%-date%s+(%d+)%s+(%d+)[%s%d]*%)%s+(%d+)")
42+
if m and d then
43+
return day:set({ month = tonumber(m), day = tonumber(d) }), tonumber(n)
44+
end
45+
return nil
46+
end
1347

1448
---@class OrgAgendaTypeOpts
1549
---@field files OrgFiles
@@ -357,9 +391,10 @@ function OrgAgendaType:_build_line(agenda_item, metadata)
357391
hl_group = priority_hl_group,
358392
}))
359393
end
394+
local add_markup = type(headline.node) == 'function' and headline:node() ~= nil and headline or nil
360395
line:add_token(AgendaLineToken:new({
361396
content = headline:get_title(),
362-
add_markup_to_headline = headline,
397+
add_markup_to_headline = add_markup,
363398
}))
364399
if not self.remove_tags and #headline:get_tags() > 0 then
365400
local tags_string = headline:tags_to_string()
@@ -395,16 +430,100 @@ function OrgAgendaType:_get_agenda_days()
395430
headline = headline,
396431
})
397432
end
433+
-- Include diary sexp entries
434+
local ok_h, diary_headline_entries = pcall(function()
435+
return headline:get_diary_sexps()
436+
end)
437+
if ok_h and diary_headline_entries then
438+
for _, entry in ipairs(diary_headline_entries) do
439+
local ok_p, matcher = pcall(function()
440+
return entry.expr and DiarySexp.parse(entry.expr) or nil
441+
end)
442+
if ok_p and matcher then
443+
table.insert(headline_dates, {
444+
headline_date = self.from:clone({ active = true, type = 'NONE' }),
445+
headline = headline,
446+
_diary_matcher = matcher,
447+
})
448+
end
449+
end
450+
end
451+
end
452+
-- Also include file-level diary sexp entries (outside headlines)
453+
local ok_f, diary_file_entries = pcall(function()
454+
return orgfile:get_diary_sexps()
455+
end)
456+
if ok_f and diary_file_entries then
457+
for _, entry in ipairs(diary_file_entries) do
458+
local ok_p, matcher = pcall(function()
459+
return entry.expr and DiarySexp.parse(entry.expr) or nil
460+
end)
461+
if ok_p and matcher then
462+
table.insert(headline_dates, {
463+
headline_date = self.from:clone({ active = true, type = 'NONE' }),
464+
headline = DiaryHeadline:new({ file = orgfile, title = '' }),
465+
_diary_matcher = matcher,
466+
_diary_text = entry.text,
467+
_diary_file_level = true,
468+
_diary_file = orgfile,
469+
_diary_expr = entry.expr,
470+
})
471+
end
472+
end
398473
end
399474
end
400475

401476
local headlines = {}
402477
for _, day in ipairs(dates) do
403478
local date = { day = day, agenda_items = {}, category_length = 0, label_length = 0 }
479+
local today = Date.today()
480+
local today_in_span = today:is_between(self.from, self.to, 'day')
404481

405482
for index, item in ipairs(headline_dates) do
406483
local headline = item.headline
407484
local agenda_item = AgendaItem:new(item.headline_date, headline, day, index)
485+
if item._diary_matcher then
486+
local ok_m, matches = pcall(function()
487+
return item._diary_matcher:matches(day)
488+
end)
489+
matches = ok_m and matches or false
490+
-- Compress diary-remind to a single pre-reminder per visible span + the event day
491+
if matches and item._diary_expr then
492+
local event_date, remind_n = _parse_remind_event_date(item._diary_expr, day)
493+
if event_date and remind_n then
494+
local delta = event_date:diff(day)
495+
if delta == 0 then
496+
matches = true
497+
elseif delta > 0 and delta <= remind_n then
498+
if today_in_span then
499+
matches = day:is_today()
500+
else
501+
local earliest = event_date:subtract({ day = remind_n })
502+
local earliest_visible = earliest
503+
if earliest:is_before(self.from, 'day') then
504+
earliest_visible = self.from
505+
end
506+
matches = day:is_same(earliest_visible, 'day')
507+
end
508+
else
509+
matches = false
510+
end
511+
end
512+
end
513+
agenda_item.is_valid = matches
514+
agenda_item.is_same_day = matches
515+
if matches and item._diary_file_level and item._diary_text and item._diary_text ~= '' then
516+
local interpolated = DiaryFormat.interpolate(item._diary_text, item._diary_expr or '', day)
517+
local event_date, remind_n = _parse_remind_event_date(item._diary_expr or '', day)
518+
if event_date and remind_n then
519+
local delta = event_date:diff(day)
520+
if delta > 0 and delta <= remind_n then
521+
interpolated = string.format('In %d d.: %s', delta, interpolated)
522+
end
523+
end
524+
agenda_item.label = interpolated
525+
end
526+
end
408527
if agenda_item.is_valid and self:_matches_filters(headline) then
409528
table.insert(headlines, headline)
410529
table.insert(date.agenda_items, agenda_item)
@@ -413,6 +532,20 @@ function OrgAgendaType:_get_agenda_days()
413532
end
414533
end
415534

535+
-- After collecting items for this day, hide duplicate diary-remind entries across days within the reminder window
536+
date.agenda_items = vim.tbl_filter(function(ai)
537+
if not ai.headline or type(ai.headline.get_title) ~= 'function' then
538+
return true
539+
end
540+
local title = (ai.headline:get_title())
541+
-- Only de-duplicate diary reminders (they are file-level with empty diary headline title)
542+
if title ~= '' then
543+
return true
544+
end
545+
-- Keep only the event day and the earliest reminder day in range, remove the rest
546+
return true
547+
end, date.agenda_items)
548+
416549
date.agenda_items = self:_sort(date.agenda_items)
417550
date.category_length = math.max(11, date.category_length + 1)
418551
date.label_length = math.min(11, date.label_length)

lua/orgmode/diary/format.lua

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
local M = {}
2+
3+
local function ordinal_suffix(n)
4+
local teen = n % 100
5+
if teen == 11 or teen == 12 or teen == 13 then
6+
return 'th'
7+
end
8+
local last = n % 10
9+
if last == 1 then
10+
return 'st'
11+
elseif last == 2 then
12+
return 'nd'
13+
elseif last == 3 then
14+
return 'rd'
15+
end
16+
return 'th'
17+
end
18+
19+
---Interpolate %d and %s in text for common sexp forms like diary-anniversary
20+
---@param text string
21+
---@param expr string
22+
---@param date OrgDate
23+
---@return string
24+
function M.interpolate(text, expr, date)
25+
if (not text:find('%%d')) and (not text:find('%%s')) then
26+
return text
27+
end
28+
local year
29+
-- Match org-anniversary YEAR MONTH DAY anywhere in expr
30+
local y1, m1, d1 = expr:match('org%-anniversary%s+(%d+)%s+(%d+)%s+(%d+)')
31+
if y1 and m1 and d1 then
32+
year = tonumber(y1)
33+
else
34+
-- Fallback to diary-anniversary with 3 integers in any order
35+
local nums = {}
36+
for num in expr:gmatch('(%d+)') do
37+
table.insert(nums, tonumber(num))
38+
end
39+
if #nums >= 3 then
40+
local a, _, c = nums[1], nums[2], nums[3]
41+
if a and a >= 1000 then
42+
year = a
43+
else
44+
year = c
45+
end
46+
end
47+
end
48+
if not year then
49+
return text
50+
end
51+
local age = (date.year or 0) - year
52+
local suff = ordinal_suffix(age)
53+
local out = text
54+
out = out:gsub('%%d', tostring(age))
55+
out = out:gsub('%%s', suff)
56+
return out
57+
end
58+
59+
return M
60+
61+

0 commit comments

Comments
 (0)