Skip to content

Commit 0c71ff5

Browse files
feat(capture): Add support for template datetree (#682)
Closes #672
1 parent 6eb7fa4 commit 0c71ff5

File tree

14 files changed

+707
-128
lines changed

14 files changed

+707
-128
lines changed

DOCS.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,8 @@ Templates have the following fields:
488488
* `template` (`string|string[]`) — body of the template that will be used when creating capture
489489
* `target` (`string?`) — name of the file to which the capture content will be added. If the target is not specified, the content will be added to the [`org_default_notes_file`](#orgdefaultnotesfile) file
490490
* `headline` (`string?`) — title of the headline after which the capture content will be added. If no headline is specified, the content will be appended to the end of the file
491+
* `datetree (boolean | { time_prompt: boolean })` — Create a [date tree](https://orgmode.org/manual/Template-elements.html#FOOT84) with current day in the target file and put the capture content there.
492+
When `time_prompt = true`, open up a date picker to select a date before opening up a capture buffer.
491493
* `properties` (`table?`):
492494
* `empty_lines` (`table|number?`) — if the value is a number, then empty lines are added before and after the content. If the value is a table, then the following fields are expected:
493495
* `before` (`integer?`) — add empty lines to the beginning of the content

lua/orgmode/capture/init.lua

Lines changed: 24 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ local Menu = require('orgmode.ui.menu')
66
local Range = require('orgmode.files.elements.range')
77
local CaptureWindow = require('orgmode.capture.window')
88
local Date = require('orgmode.objects.date')
9+
local Datetree = require('orgmode.capture.template.datetree')
910

1011
---@class OrgProcessRefileOpts
1112
---@field source_headline OrgHeadline
@@ -18,7 +19,7 @@ local Date = require('orgmode.objects.date')
1819
---@field template OrgCaptureTemplate
1920
---@field source_file OrgFile
2021
---@field source_headline? OrgHeadline
21-
---@field destination_file? OrgFile
22+
---@field destination_file OrgFile
2223
---@field destination_headline? OrgHeadline
2324

2425
---@class OrgCapture
@@ -55,6 +56,15 @@ function Capture:open_template(template)
5556
end,
5657
})
5758

59+
if template:has_input_prompts() then
60+
return template:prompt_for_inputs():next(function(proceed)
61+
if not proceed then
62+
return utils.echo_info('Canceled.')
63+
end
64+
return self._window:open()
65+
end)
66+
end
67+
5868
return self._window:open()
5969
end
6070

@@ -124,31 +134,37 @@ end
124134
function Capture:_refile_from_capture_buffer(opts)
125135
local target_level = 0
126136
local target_line = -1
137+
local destination_file = opts.destination_file
138+
local destination_headline = opts.destination_headline
127139

128-
if opts.destination_headline then
129-
target_level = opts.destination_headline:get_level()
130-
target_line = opts.destination_headline:get_range().end_line
140+
if opts.template.datetree then
141+
destination_headline = Datetree:new({ files = self.files }):create(opts.template)
142+
end
143+
144+
if destination_headline then
145+
target_level = destination_headline:get_level()
146+
target_line = destination_headline:get_range().end_line
131147
end
132148

133149
local lines = opts.source_file.lines
134150

135151
if opts.source_headline then
136152
lines = opts.source_headline:get_lines()
137-
if opts.destination_headline or opts.source_headline:get_level() > 1 then
153+
if destination_headline or opts.source_headline:get_level() > 1 then
138154
lines = self:_adapt_headline_level(opts.source_headline, target_level, false)
139155
end
140156
end
141157

142158
lines = opts.template:apply_properties_to_lines(lines)
143159

144160
self.files
145-
:update_file(opts.destination_file.filename, function(file)
161+
:update_file(destination_file.filename, function(file)
146162
local range = self:_get_destination_range_without_empty_lines(Range.from_line(target_line))
147163
vim.api.nvim_buf_set_lines(file:bufnr(), range.start_line, range.end_line, false, lines)
148164
end)
149165
:wait()
150166

151-
utils.echo_info(('Wrote %s'):format(opts.destination_file.filename))
167+
utils.echo_info(('Wrote %s'):format(destination_file.filename))
152168
self:kill()
153169
return true
154170
end
@@ -413,8 +429,7 @@ end
413429
---@private
414430
---@return OrgProcessCaptureOpts | false
415431
function Capture:_get_refile_vars()
416-
local target = self._window.template.target
417-
local file = vim.fn.resolve(vim.fn.fnamemodify(target, ':p'))
432+
local file = self._window.template:get_target()
418433

419434
if vim.fn.filereadable(file) == 0 then
420435
local choice = vim.fn.confirm(('Refile destination %s does not exist. Create now?'):format(file), '&Yes\n&No')
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
local utils = require('orgmode.utils')
2+
local Date = require('orgmode.objects.date')
3+
4+
---@class OrgDatetree
5+
---@field files OrgFiles
6+
local Datetree = {}
7+
Datetree.__index = Datetree
8+
9+
---@param opts { files: OrgFiles }
10+
function Datetree:new(opts)
11+
return setmetatable({
12+
files = opts.files,
13+
}, Datetree)
14+
end
15+
16+
---@param template OrgCaptureTemplate
17+
---@return OrgHeadline
18+
function Datetree:create(template)
19+
local date = template:get_datetree_date()
20+
local destination_file = self.files:get(template:get_target())
21+
local result = self:_get_datetree_destination(destination_file, date)
22+
23+
if result.create then
24+
self.files
25+
:update_file(destination_file.filename, function(file)
26+
vim.api.nvim_buf_set_lines(file:bufnr(), result.target_line, result.target_line, false, result.content)
27+
end)
28+
:wait()
29+
destination_file = destination_file:reload_sync()
30+
end
31+
32+
return destination_file:get_closest_headline({ result.headline_at, 0 })
33+
end
34+
35+
---@private
36+
---@param destination_file OrgFile
37+
---@param date OrgDate
38+
---@return { create: boolean, target_line: number, content: string[], headline_at: number }
39+
function Datetree:_get_datetree_destination(destination_file, date)
40+
local year_date = date:format('%Y')
41+
local month_date = date:start_of('month')
42+
local month_date_str = date:format('%Y-%m %B')
43+
local day_date = date:format('%Y-%m-%d %A')
44+
local year_headline = utils.find(destination_file:get_top_level_headlines(), function(headline)
45+
return headline:get_title() == year_date
46+
end)
47+
48+
if not year_headline then
49+
local target_line = self:_get_insert_year_at(destination_file, year_date)
50+
return {
51+
create = true,
52+
target_line = target_line,
53+
headline_at = target_line + 3,
54+
content = {
55+
'* ' .. year_date,
56+
'** ' .. month_date_str,
57+
'*** ' .. day_date,
58+
},
59+
}
60+
end
61+
62+
local month_headline = utils.find(year_headline:get_child_headlines(), function(month)
63+
return month:get_title() == month_date_str
64+
end)
65+
66+
if not month_headline then
67+
local target_line = self:_get_insert_month_at(year_headline, month_date)
68+
return {
69+
create = true,
70+
target_line = target_line,
71+
headline_at = target_line + 2,
72+
content = {
73+
'** ' .. month_date_str,
74+
'*** ' .. day_date,
75+
},
76+
}
77+
end
78+
79+
local month_headlines = month_headline:get_child_headlines()
80+
local day_headline = utils.find(month_headlines, function(day)
81+
return day:get_title() == day_date
82+
end)
83+
84+
if not day_headline then
85+
local target_line = self:_get_insert_day_at(month_headline, date)
86+
87+
return {
88+
create = true,
89+
target_line = target_line,
90+
headline_at = target_line + 1,
91+
content = {
92+
'*** ' .. day_date,
93+
},
94+
}
95+
end
96+
97+
return {
98+
create = false,
99+
headline_at = day_headline:get_range().start_line,
100+
}
101+
end
102+
103+
---@private
104+
---@param destination_file OrgFile
105+
---@param year_date string -- year in format YYYY
106+
---@return number
107+
function Datetree:_get_insert_year_at(destination_file, year_date)
108+
local future_year_headline = utils.find(destination_file:get_top_level_headlines(), function(headline)
109+
local get_year = headline:get_title():match('^%d%d%d%d$')
110+
return get_year and tonumber(get_year) > tonumber(year_date)
111+
end)
112+
113+
if future_year_headline then
114+
return future_year_headline:get_range().start_line - 1
115+
end
116+
117+
return #destination_file.lines
118+
end
119+
120+
---@private
121+
---@param year_headline OrgHeadline
122+
---@param month_date OrgDate
123+
---@return number
124+
function Datetree:_get_insert_month_at(year_headline, month_date)
125+
local future_month_headline = utils.find(year_headline:get_child_headlines(), function(headline)
126+
local year_num, month_num = headline:get_title():match('^(%d%d%d%d)%-(%d%d)%s+%w+$')
127+
if year_num and month_num then
128+
local timestamp = os.time({ year = year_num, month = month_num, day = 1 })
129+
return timestamp > month_date.timestamp
130+
end
131+
return false
132+
end)
133+
134+
if future_month_headline then
135+
return future_month_headline:get_range().start_line - 1
136+
end
137+
138+
return year_headline:get_range().end_line
139+
end
140+
141+
---@private
142+
---@param month_headline OrgHeadline
143+
---@param date OrgDate
144+
---@return number
145+
function Datetree:_get_insert_day_at(month_headline, date)
146+
local future_day_headline = utils.find(month_headline:get_child_headlines(), function(headline)
147+
local year_num, month_num, day_num = headline:get_title():match('^(%d%d%d%d)%-(%d%d)%-(%d%d)%s+%w+$')
148+
return year_num
149+
and Date.from_table({
150+
year = year_num,
151+
month = month_num,
152+
day = day_num,
153+
}):is_after(date, 'day')
154+
end)
155+
156+
if future_day_headline then
157+
return future_day_headline:get_range().start_line - 1
158+
end
159+
160+
return month_headline:get_range().end_line
161+
end
162+
163+
return Datetree

lua/orgmode/capture/template/init.lua

Lines changed: 45 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
local TemplateProperties = require('orgmode.capture.template.template_properties')
22
local Date = require('orgmode.objects.date')
33
local utils = require('orgmode.utils')
4+
local Calendar = require('orgmode.objects.calendar')
5+
local Promise = require('orgmode.utils.promise')
46

57
local expansions = {
68
['%f'] = function()
@@ -32,15 +34,20 @@ local expansions = {
3234
end,
3335
}
3436

37+
---@alias OrgCaptureTemplateDatetree boolean | { time_prompt: boolean, date?: OrgDate }
38+
3539
---@class OrgCaptureTemplate
36-
---@field description string
37-
---@field template string|string[]
38-
---@field target string?
39-
---@field headline string?
40-
---@field properties OrgCaptureTemplateProperties
41-
---@field subtemplates table<string, OrgCaptureTemplate>
40+
---@field description? string
41+
---@field template? string|string[]
42+
---@field target? string
43+
---@field datetree? OrgCaptureTemplateDatetree
44+
---@field headline? string
45+
---@field properties? OrgCaptureTemplateProperties
46+
---@field subtemplates? table<string, OrgCaptureTemplate>
4247
local Template = {}
4348

49+
---@param opts OrgCaptureTemplate
50+
---@return OrgCaptureTemplate
4451
function Template:new(opts)
4552
opts = opts or {}
4653

@@ -51,6 +58,7 @@ function Template:new(opts)
5158
headline = { opts.headline, 'string', true },
5259
properties = { opts.properties, 'table', true },
5360
subtemplates = { opts.subtemplates, 'table', true },
61+
datetree = { opts.datetree, { 'boolean', 'table' }, true },
5462
})
5563

5664
local this = {}
@@ -59,6 +67,7 @@ function Template:new(opts)
5967
this.target = self:_compile(opts.target or '')
6068
this.headline = opts.headline
6169
this.properties = TemplateProperties:new(opts.properties)
70+
this.datetree = opts.datetree
6271

6372
this.subtemplates = {}
6473
for key, subtemplate in pairs(opts.subtemplates or {}) do
@@ -89,10 +98,39 @@ function Template:compile()
8998
if type(content) == 'table' then
9099
content = table.concat(content, '\n')
91100
end
92-
content = self:_compile(content)
101+
content = self:_compile(content or '')
93102
return vim.split(content, '\n', { plain = true })
94103
end
95104

105+
function Template:has_input_prompts()
106+
return self.datetree and type(self.datetree) == 'table' and self.datetree.time_prompt
107+
end
108+
109+
function Template:prompt_for_inputs()
110+
if not self:has_input_prompts() then
111+
return Promise.resolve(true)
112+
end
113+
return Calendar.new({ date = Date.now() }):open():next(function(date)
114+
if date then
115+
self.datetree.date = date
116+
return true
117+
end
118+
return false
119+
end)
120+
end
121+
122+
function Template:get_datetree_date()
123+
if self:has_input_prompts() then
124+
return self.datetree.date
125+
end
126+
return Date.today()
127+
end
128+
129+
---@return string
130+
function Template:get_target()
131+
return vim.fn.resolve(vim.fn.fnamemodify(self.target, ':p'))
132+
end
133+
96134
---@param lines string[]
97135
---@return string[]
98136
function Template:apply_properties_to_lines(lines)

lua/orgmode/files/file.lua

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,18 @@ function OrgFile:get_headlines()
170170
end, matches)
171171
end
172172

173+
memoize('get_top_level_headlines')
174+
---@return OrgHeadline[]
175+
function OrgFile:get_top_level_headlines()
176+
if self:is_archive_file() then
177+
return {}
178+
end
179+
local matches = self:get_ts_matches('(document (section (headline) @headline))')
180+
return vim.tbl_map(function(match)
181+
return Headline:new(match.headline.node, self)
182+
end, matches)
183+
end
184+
173185
memoize('get_headlines_including_archived')
174186
---@return OrgHeadline[]
175187
function OrgFile:get_headlines_including_archived()
@@ -181,8 +193,9 @@ end
181193

182194
---@param title string
183195
---@param exact? boolean
196+
---@param search_from_end? boolean
184197
---@return OrgHeadline[]
185-
function OrgFile:find_headlines_by_title(title, exact)
198+
function OrgFile:find_headlines_by_title(title, exact, search_from_end)
186199
return vim.tbl_filter(function(item)
187200
local pattern = '^' .. vim.pesc(title:lower())
188201
if exact then
@@ -195,7 +208,9 @@ end
195208
---@param title string
196209
---@return OrgHeadline | nil
197210
function OrgFile:find_headline_by_title(title)
198-
return self:find_headlines_by_title(title, true)[1]
211+
return utils.find(self:get_headlines(), function(item)
212+
return item:get_title():lower() == title:lower()
213+
end)
199214
end
200215

201216
---@return OrgHeadline[]

0 commit comments

Comments
 (0)