Skip to content

Commit 30c09b3

Browse files
Expose api for manipulating headline scheduled and deadline date
1 parent 760beef commit 30c09b3

File tree

6 files changed

+301
-34
lines changed

6 files changed

+301
-34
lines changed

lua/orgmode/api/headline.lua

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ local utils = require('orgmode.utils')
33
local ts_org = require('orgmode.treesitter')
44
local OrgPosition = require('orgmode.api.position')
55
local PriorityState = require('orgmode.objects.priority_state')
6+
local Date = require('orgmode.objects.date')
7+
local Calendar = require('orgmode.objects.calendar')
68

79
---@class OrgHeadline
810
---@field title string headline title without todo keyword, tags and priority. Ex. `* TODO I am a headline :SOMETAG:` returns `I am a headline`
@@ -122,6 +124,77 @@ function OrgHeadline:set_priority(priority)
122124
end)
123125
end
124126

127+
--- Set deadline date
128+
---@param date? Date|string|nil If ommited, opens the datepicker. Empty string removes the date. String must follow org date convention (YYYY-MM-DD HH:mm...)
129+
---@return Promise
130+
function OrgHeadline:set_deadline(date)
131+
return self:_do_action(function()
132+
local headline = ts_org.closest_headline()
133+
local deadline_date = headline:deadline()
134+
if not date then
135+
return Calendar.new({ date = deadline_date or Date.today() }).open():next(function(new_date)
136+
if not new_date then
137+
return
138+
end
139+
return headline:set_deadline_date(new_date)
140+
end)
141+
end
142+
143+
if type(date) == 'string' then
144+
if date == '' then
145+
return headline:remove_deadline_date()
146+
end
147+
local date_instance = Date.from_string(date)
148+
if date_instance then
149+
return headline:set_deadline_date(date_instance)
150+
end
151+
error('Invalid string format for deadline date')
152+
end
153+
154+
if Date.is_date_instance(date) then
155+
return headline:set_deadline_date(date)
156+
end
157+
158+
error('Invalid argument to set_deadline')
159+
end)
160+
end
161+
162+
163+
--- Set scheduled date
164+
---@param date? Date|string|nil If ommited, opens the datepicker. Empty string removes the date. String must follow org date convention (YYYY-MM-DD HH:mm...)
165+
---@return Promise
166+
function OrgHeadline:set_scheduled(date)
167+
return self:_do_action(function()
168+
local headline = ts_org.closest_headline()
169+
local scheduled_date = headline:scheduled()
170+
if not date then
171+
return Calendar.new({ date = scheduled_date or Date.today() }).open():next(function(new_date)
172+
if not new_date then
173+
return
174+
end
175+
return headline:set_scheduled_date(new_date)
176+
end)
177+
end
178+
179+
if type(date) == 'string' then
180+
if date == '' then
181+
return headline:remove_scheduled_date()
182+
end
183+
local date_instance = Date.from_string(date)
184+
if date_instance then
185+
return headline:set_scheduled_date(date_instance)
186+
end
187+
error('Invalid string format for schedule date')
188+
end
189+
190+
if Date.is_date_instance(date) then
191+
return headline:set_scheduled_date(date)
192+
end
193+
194+
error('Invalid argument to set_scheduled')
195+
end)
196+
end
197+
125198
---@param action function
126199
---@private
127200
function OrgHeadline:_do_action(action)

lua/orgmode/objects/date.lua

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -889,6 +889,11 @@ local function parse_all_from_line(line, lnum)
889889
return dates
890890
end
891891

892+
---@param value any
893+
local function is_date_instance(value)
894+
return getmetatable(value) == Date
895+
end
896+
892897
return {
893898
parse_parts = parse_parts,
894899
from_org_date = from_org_date,
@@ -897,6 +902,7 @@ return {
897902
today = today,
898903
parse_all_from_line = parse_all_from_line,
899904
is_valid_date = is_valid_date,
905+
is_date_instance = is_date_instance,
900906
from_match = from_match,
901907
pattern = pattern,
902908
}

lua/orgmode/org/mappings.lua

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -749,28 +749,26 @@ function OrgMappings:outline_up_heading()
749749
end
750750

751751
function OrgMappings:org_deadline()
752-
local item = Files.get_closest_headline()
753-
local deadline_date = item:get_deadline_date()
752+
local headline = ts_org.closest_headline()
753+
local deadline_date = headline:deadline()
754754
return Calendar.new({ date = deadline_date or Date.today() }).open():next(function(new_date)
755755
if not new_date then
756756
return
757757
end
758-
item:remove_closed_date()
759-
item = Files.get_closest_headline()
760-
item:add_deadline_date(new_date)
758+
headline:remove_closed_date()
759+
headline:set_deadline_date(new_date)
761760
end)
762761
end
763762

764763
function OrgMappings:org_schedule()
765-
local item = Files.get_closest_headline()
766-
local scheduled_date = item:get_scheduled_date()
764+
local headline = ts_org.closest_headline()
765+
local scheduled_date = headline:scheduled()
767766
return Calendar.new({ date = scheduled_date or Date.today() }).open():next(function(new_date)
768767
if not new_date then
769768
return
770769
end
771-
item:remove_closed_date()
772-
item = Files.get_closest_headline()
773-
item:add_scheduled_date(new_date)
770+
headline:remove_closed_date()
771+
headline:set_scheduled_date(new_date)
774772
end)
775773
end
776774

lua/orgmode/treesitter/headline.lua

Lines changed: 108 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
local ts_utils = require('nvim-treesitter.ts_utils')
22
local tree_utils = require('orgmode.utils.treesitter')
33
local Date = require('orgmode.objects.date')
4+
local Range = require('orgmode.parser.range')
45
local config = require('orgmode.config')
56
local query = vim.treesitter.query
67

8+
---@class Headline
9+
---@field headline userdata
710
local Headline = {}
811

912
---@param headline_node userdata tree sitter headline node
@@ -14,6 +17,7 @@ function Headline:new(headline_node)
1417
return data
1518
end
1619

20+
---@return userdata stars node
1721
function Headline:stars()
1822
return self.headline:field('stars')[1]
1923
end
@@ -28,6 +32,7 @@ function Headline:priority()
2832
return self:parse('%[#(%w+)%]')
2933
end
3034

35+
---@return userdata, string
3136
function Headline:tags()
3237
local node = self.headline:field('tags')[1]
3338
local text = ''
@@ -37,6 +42,7 @@ function Headline:tags()
3742
return node, text
3843
end
3944

45+
---@param tags string
4046
function Headline:set_tags(tags)
4147
local predecessor = nil
4248
for _, node in ipairs(ts_utils.get_named_children(self.headline)) do
@@ -76,6 +82,7 @@ function Headline:align_tags()
7682
end
7783
end
7884

85+
---@param priority string
7986
function Headline:set_priority(priority)
8087
local current_priority = self:priority()
8188
if current_priority then
@@ -96,6 +103,7 @@ function Headline:set_priority(priority)
96103
tree_utils.set_node_text(stars, ('%s [#%s]'):format(text, priority))
97104
end
98105

106+
---@param keyword string
99107
function Headline:set_todo(keyword)
100108
local current_todo = self:todo()
101109
if current_todo then
@@ -128,14 +136,15 @@ function Headline:todo()
128136
local text = query.get_node_text(todo_node, 0)
129137
for _, word in ipairs(keywords) do
130138
-- there may be multiple substitutions necessary
131-
escaped_word = vim.pesc(word)
139+
local escaped_word = vim.pesc(word)
132140
local todo = text:match(escaped_word)
133141
if todo then
134142
return todo_node, word, vim.tbl_contains(done_keywords, word)
135143
end
136144
end
137145
end
138146

147+
---@return userdata
139148
function Headline:plan()
140149
local section = self.headline:parent()
141150
for _, node in ipairs(ts_utils.get_named_children(section)) do
@@ -145,6 +154,7 @@ function Headline:plan()
145154
end
146155
end
147156

157+
---@return Table<string, userdata>
148158
function Headline:dates()
149159
local plan = self:plan()
150160
local dates = {}
@@ -160,6 +170,7 @@ function Headline:dates()
160170
return dates
161171
end
162172

173+
---@return userdata[]
163174
function Headline:repeater_dates()
164175
return vim.tbl_filter(function(entry)
165176
local timestamp = entry:field('timestamp')[1]
@@ -171,37 +182,44 @@ function Headline:repeater_dates()
171182
end, self:dates())
172183
end
173184

185+
---@return Date|nil
186+
function Headline:deadline()
187+
return self:_get_date('DEADLINE')
188+
end
189+
190+
---@return Date|nil
191+
function Headline:scheduled()
192+
return self:_get_date('SCHEDULED')
193+
end
194+
195+
---@param date Date
196+
function Headline:set_deadline_date(date)
197+
return self:_add_date('DEADLINE', date, true)
198+
end
199+
200+
---@param date Date
201+
function Headline:set_scheduled_date(date)
202+
return self:_add_date('SCHEDULED', date, true)
203+
end
204+
174205
function Headline:add_closed_date()
175206
local dates = self:dates()
176207
if dates['CLOSED'] then
177208
return
178209
end
179-
local closed_text = 'CLOSED: ' .. Date.now():to_wrapped_string(false)
180-
if vim.tbl_isempty(dates) then
181-
local indent = config:get_indent(self:level() + 1)
182-
local start_line = self.headline:start()
183-
return vim.api.nvim_call_function('append', {
184-
start_line + 1,
185-
string.format('%s%s', indent, closed_text),
186-
})
187-
end
188-
local keys = vim.tbl_keys(dates)
189-
local last_child = dates['DEADLINE'] or dates['SCHEDULED'] or dates[keys[#keys]]
190-
local ptext = query.get_node_text(last_child, 0)
191-
local text = ptext .. ' ' .. closed_text
192-
tree_utils.set_node_text(last_child, text)
210+
return self:_add_date('CLOSED', Date.now(), false)
193211
end
194212

195213
function Headline:remove_closed_date()
196-
local dates = self:dates()
197-
if vim.tbl_count(dates) == 0 or not dates['CLOSED'] then
198-
return
199-
end
200-
local line_nr = dates['CLOSED']:start() + 1
201-
tree_utils.set_node_text(dates['CLOSED'], '', true)
202-
if vim.trim(vim.fn.getline(line_nr)) == '' then
203-
return vim.api.nvim_call_function('deletebufline', { vim.api.nvim_get_current_buf(), line_nr })
204-
end
214+
return self:_remove_date('CLOSED')
215+
end
216+
217+
function Headline:remove_deadline_date()
218+
return self:_remove_date('DEADLINE')
219+
end
220+
221+
function Headline:remove_scheduled_date()
222+
return self:_remove_date('SCHEDULED')
205223
end
206224

207225
function Headline:cookie()
@@ -251,4 +269,70 @@ function Headline:parse(pattern)
251269
return matching_nodes[1], match
252270
end
253271

272+
---@param type string | "DEADLINE" | "SCHEDULED" | "CLOSED"
273+
---@return Date|nil
274+
function Headline:_get_date(type)
275+
local dates = self:dates()
276+
local date_node = dates[type]
277+
if not date_node then
278+
return nil
279+
end
280+
local timestamp_node = date_node:field('timestamp')[1]
281+
if not timestamp_node then
282+
return nil
283+
end
284+
local parsed_date = Date.from_org_date(query.get_node_text(timestamp_node, 0), {
285+
range = Range.from_node(timestamp_node),
286+
})
287+
return parsed_date and parsed_date[1] or nil
288+
end
289+
290+
---@param type string | "DEADLINE" | "SCHEDULED" | "CLOSED"
291+
---@param date Date
292+
---@param active? boolean
293+
---@private
294+
function Headline:_add_date(type, date, active)
295+
local dates = self:dates()
296+
local text = type .. ': ' .. date:to_wrapped_string(active)
297+
if vim.tbl_isempty(dates) then
298+
local indent = config:get_indent(self:level() + 1)
299+
local start_line = self.headline:start()
300+
return vim.api.nvim_call_function('append', {
301+
start_line + 1,
302+
string.format('%s%s', indent, text),
303+
})
304+
end
305+
if dates[type] then
306+
return tree_utils.set_node_text(dates[type], text, true)
307+
end
308+
309+
local keys = vim.tbl_keys(dates)
310+
local other_types = vim.tbl_filter(function(t)
311+
return t ~= type
312+
end, { 'DEADLINE', 'SCHEDULED', 'CLOSED' })
313+
local last_child = dates[keys[#keys]]
314+
for _, date_type in ipairs(other_types) do
315+
if dates[date_type] then
316+
last_child = dates[date_type]
317+
break
318+
end
319+
end
320+
local ptext = query.get_node_text(last_child, 0)
321+
tree_utils.set_node_text(last_child, ptext .. ' ' .. text)
322+
end
323+
324+
---@param type string | "DEADLINE" | "SCHEDULED" | "CLOSED"
325+
---@private
326+
function Headline:_remove_date(type)
327+
local dates = self:dates()
328+
if vim.tbl_count(dates) == 0 or not dates[type] then
329+
return
330+
end
331+
local line_nr = dates[type]:start() + 1
332+
tree_utils.set_node_text(dates[type], '', true)
333+
if vim.trim(vim.fn.getline(line_nr)) == '' then
334+
return vim.api.nvim_call_function('deletebufline', { vim.api.nvim_get_current_buf(), line_nr })
335+
end
336+
end
337+
254338
return Headline

lua/orgmode/treesitter/init.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ local tree_utils = require('orgmode.utils.treesitter')
22
local Headline = require('orgmode.treesitter.headline')
33
local Listitem = require('orgmode.treesitter.listitem')
44

5+
---@return Headline
56
local function closest_headline()
67
local ts_headline = tree_utils.closest_headline()
78
if not ts_headline then

0 commit comments

Comments
 (0)