Skip to content

Commit f685995

Browse files
feat: Add org id support and org store link (#654)
* feat: Add org id module * feat: Add set/get property to api * feat: Add org_store_link * chore: Fix some types * feat: Support id: hyperlink format * feat: Expose setting headline id in api * docs: Add docs for org id
1 parent 651078a commit f685995

File tree

24 files changed

+537
-75
lines changed

24 files changed

+537
-75
lines changed

DOCS.md

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -344,6 +344,34 @@ Determine if blank line should be prepended when:
344344
* Adding heading via `org_meta_return` and `org_insert_*` mappings
345345
* Adding a list item via `org_meta_return`
346346

347+
#### **org_id_uuid_program**
348+
*type*: `string`<br />
349+
*default value*: `uuidgen`<br />
350+
External program used to generate uuid's for id module
351+
352+
#### **org_id_ts_format**
353+
*type*: `string`<br />
354+
*default value*: `%Y%m%d%H%M%S`<br />
355+
Format of the id generated when [org_id_method](#org_id_method) is set to `ts`.
356+
357+
#### **org_id_method**
358+
*type*: `'uuid' | 'ts' | 'org'`<br />
359+
*default value*: `uuid`<br />
360+
What method to use to generate ids via org id module.
361+
* `uuid` - Use [org_id_uuid_program](#org_id_uuid_program) to generate the id
362+
* `ts` - Generate id from current timestamp using format [org_id_ts_format](#org_id_ts_format)
363+
* `org` - Generate a random 12 digit number and prepend [org_id_prefix](#org_id_prefix)
364+
365+
#### **org_id_prefix**
366+
*type*: `string | nil`<br />
367+
*default value*: `nil`<br />
368+
Prefix added to the generated id when [org_id_method](#org_id_method) is set to `org`.
369+
370+
#### **org_id_link_to_org_use_id**
371+
*type*: `boolean`<br />
372+
*default value*: `false`<br />
373+
If `true`, generate ID with the Org ID module and append it to the headline as property. More info on [org_store_link](#org_store_link)
374+
347375
#### **calendar_week_start_day**
348376
*type*: `number`<br />
349377
*default value*: `1`<br />
@@ -896,8 +924,15 @@ Toggle current line checkbox state
896924
*mapped to*: `<Leader>o*`<br />
897925
Toggle current line to headline and vice versa. Checkboxes will turn into TODO headlines.
898926
#### **org_insert_link**
899-
*mapped to*: `<Leader>oil`<br />
927+
*mapped to*: `<Leader>oli`<br />
900928
Insert a hyperlink at cursor position. When the cursor is on a hyperlink, edit that hyperlink.<br />
929+
If there are any links stored with [org_store_link](#org_store_link), pressing `<TAB>` to autocomplete the input
930+
will show list of all stored links to select. Links generated with ID are properly expanded to valid links after selection.
931+
#### **org_store_link**
932+
*mapped to*: `<Leader>ols`<br />
933+
Generate a link to the closest headline. If [org_id_link_to_org_use_id](#org_id_link_to_org_use_id) is `true`,
934+
it appends the `ID` property to the headline, and generates link with that id to be inserted via [org_insert_link](#org_insert_link).
935+
When [org_id_link_to_org_use_id](#org_id_link_to_org_use_id) is `false`, it generates the standard file::*headline link (example: `file:/path/to/my/todos.org::*My headline`)
901936
#### **org_open_at_point**
902937
*mapped to*: `<Leader>oo`<br />
903938
Open hyperlink or date under cursor. When date is under the cursor, open the agenda for that day.<br />

lua/orgmode/api/headline.lua

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ local Promise = require('orgmode.utils.promise')
1616
---@field tags string[] List of own tags
1717
---@field deadline Date|nil
1818
---@field scheduled Date|nil
19+
---@field properties table<string, string> Table containing all properties. All keys are lowercased
1920
---@field closed Date|nil
2021
---@field dates Date[] List of all dates that are not "plan" dates
2122
---@field position Range
@@ -44,6 +45,7 @@ function OrgHeadline:_new(opts)
4445
data.all_tags = opts.all_tags
4546
data.priority = opts.priority
4647
data.deadline = opts.deadline
48+
data.properties = opts.properties
4749
data.scheduled = opts.scheduled
4850
data.closed = opts.closed
4951
data.dates = opts.dates
@@ -71,6 +73,7 @@ function OrgHeadline._build_from_internal_section(section, index)
7173
all_tags = { unpack(section.tags) },
7274
tags = section:get_own_tags(),
7375
position = OrgPosition:_build_from_internal_range(section.range),
76+
properties = section:get_properties(),
7477
deadline = section:get_deadline_date(),
7578
scheduled = section:get_scheduled_date(),
7679
closed = section:get_closed_date(),
@@ -213,6 +216,35 @@ function OrgHeadline:set_scheduled(date)
213216
end)
214217
end
215218

219+
--- Set property on a headline
220+
---@param key string
221+
---@param value string
222+
function OrgHeadline:set_property(key, value)
223+
return self:_do_action(function()
224+
local headline = ts_org.closest_headline()
225+
return headline:set_property(key, value)
226+
end)
227+
end
228+
229+
--- Get headline property
230+
---@param key string
231+
---@return string | nil
232+
function OrgHeadline:get_property(key)
233+
return self.properties[key:lower()]
234+
end
235+
236+
--- Get headline id or create a new one if it doesn't exist
237+
--- @return string
238+
function OrgHeadline:id_get_or_create()
239+
local id = self:get_property('id')
240+
if id then
241+
return id
242+
end
243+
local org_id = require('orgmode.org.id').new()
244+
self:set_property('ID', org_id)
245+
return org_id
246+
end
247+
216248
---@param action function
217249
---@private
218250
function OrgHeadline:_do_action(action)

lua/orgmode/config/defaults.lua

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
---@class DefaultConfig
2+
---@field org_id_method 'uuid' | 'ts' | 'org'
23
local DefaultConfig = {
34
org_agenda_files = '',
45
org_default_notes_file = '',
@@ -42,6 +43,11 @@ local DefaultConfig = {
4243
},
4344
org_src_window_setup = 'top 16new',
4445
org_edit_src_content_indentation = 0,
46+
org_id_uuid_program = 'uuidgen',
47+
org_id_ts_format = '%Y%m%d%H%M%S',
48+
org_id_method = 'uuid',
49+
org_id_prefix = nil,
50+
org_id_link_to_org_use_id = false,
4551
win_split_mode = 'horizontal',
4652
win_border = 'single',
4753
notifications = {
@@ -144,7 +150,8 @@ local DefaultConfig = {
144150
org_schedule = '<prefix>is',
145151
org_time_stamp = '<prefix>i.',
146152
org_time_stamp_inactive = '<prefix>i!',
147-
org_insert_link = '<prefix>il',
153+
org_insert_link = '<prefix>li',
154+
org_store_link = '<prefix>ls',
148155
org_clock_in = '<prefix>xi',
149156
org_clock_out = '<prefix>xo',
150157
org_clock_cancel = '<prefix>xq',

lua/orgmode/config/init.lua

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -393,8 +393,8 @@ function Config:ts_highlights_enabled()
393393
end
394394

395395
---@param content table
396-
---@param option string
397-
---@param prepend_content any
396+
---@param option? string
397+
---@param prepend_content? any
398398
---@return table
399399
function Config:respect_blank_before_new_entry(content, option, prepend_content)
400400
if self.opts.org_blank_before_new_entry[option or 'heading'] then

lua/orgmode/config/mappings/init.lua

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -132,6 +132,7 @@ return {
132132
{ args = { true }, opts = { desc = 'org timestamp (inactive)' } }
133133
),
134134
org_insert_link = m.action('org_mappings.insert_link', { opts = { desc = 'org insert link' } }),
135+
org_store_link = m.action('org_mappings.store_link', { opts = { desc = 'org store link' } }),
135136
org_clock_in = m.action('clock.org_clock_in', { opts = { desc = 'org clock in' } }),
136137
org_clock_out = m.action('clock.org_clock_out', { opts = { desc = 'org clock out' } }),
137138
org_clock_cancel = m.action('clock.org_clock_cancel', { opts = { desc = 'org clock cancel' } }),

lua/orgmode/objects/link.lua

Lines changed: 15 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,7 @@
11
local Url = require('orgmode.objects.url')
2+
local utils = require('orgmode.utils')
3+
local config = require('orgmode.config')
4+
local Range = require('orgmode.parser.range')
25

36
---@class Link
47
---@field url Url
@@ -10,6 +13,7 @@ function Link:init(str)
1013
local parts = vim.split(str, '][', true)
1114
self.url = Url.new(parts[1] or '')
1215
self.desc = parts[2]
16+
return self
1317
end
1418

1519
---@return string
@@ -22,29 +26,35 @@ function Link:to_str()
2226
end
2327

2428
---@param str string
29+
---@return Link
2530
function Link.new(str)
2631
local self = setmetatable({}, { __index = Link })
27-
self:init(str)
28-
return self
32+
return self:init(str)
2933
end
3034

3135
---@param line string
3236
---@param pos number
33-
---@return Link | nil
37+
---@return Link | nil, table | nil
3438
function Link.at_pos(line, pos)
3539
local links = {}
3640
local found_link = nil
3741
local pattern = '%[%[([^%]]+.-)%]%]'
42+
local position
3843
for link in line:gmatch(pattern) do
3944
local start_from = #links > 0 and links[#links].to or nil
4045
local from, to = line:find(pattern, start_from)
46+
local current_pos = { from = from, to = to }
4147
if pos >= from and pos <= to then
4248
found_link = link
49+
position = current_pos
4350
break
4451
end
45-
table.insert(links, { link = link, from = from, to = to })
52+
table.insert(links, current_pos)
53+
end
54+
if not found_link then
55+
return nil, nil
4656
end
47-
return (found_link and Link.new(found_link) or nil)
57+
return Link.new(found_link), position
4858
end
4959

5060
return Link

lua/orgmode/objects/url.lua

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
local utils = require('orgmode.utils')
21
local fs = require('orgmode.utils.fs')
32

43
---@class Url
@@ -27,16 +26,22 @@ end
2726

2827
---@return boolean
2928
function Url:is_file_headline()
30-
return self:is_file() and self:get_headline() and true
29+
return self:is_file() and self:get_headline() and true or false
3130
end
3231

32+
---@return boolean
3333
function Url:is_custom_id()
34-
return self:is_file_custom_id() or self:is_internal_custom_id()
34+
return (self:is_file_custom_id() or self:is_internal_custom_id()) and true or false
35+
end
36+
37+
---@return boolean
38+
function Url:is_id()
39+
return self.str:find('^id:') and true or false
3540
end
3641

3742
---@return boolean
3843
function Url:is_file_custom_id()
39-
return self:is_file() and self:get_custom_id() and true
44+
return self:is_file() and self:get_custom_id() and true or false
4045
end
4146

4247
---@return boolean
@@ -64,7 +69,7 @@ end
6469

6570
---@return boolean
6671
function Url:is_internal_headline()
67-
return self.str:find('^*') and true
72+
return self.str:find('^*') and true or false
6873
end
6974

7075
function Url:is_internal_custom_id()
@@ -122,6 +127,10 @@ function Url:get_custom_id()
122127
or self.str:match('^#(.-)$')
123128
end
124129

130+
function Url:get_id()
131+
return self.str:match('^id:(%S+)')
132+
end
133+
125134
---@return number | false
126135
function Url:get_linenumber()
127136
-- official orgmode convention
@@ -181,4 +190,9 @@ function Url:get_http_url()
181190
return self.str:match('^https?://.+$')
182191
end
183192

193+
---@return string | false
194+
function Url:extract_target()
195+
return self:get_headline() or self:get_custom_id() or self:get_dedicated_target()
196+
end
197+
184198
return Url

lua/orgmode/org/hyperlinks.lua

Lines changed: 53 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,11 @@
11
local Files = require('orgmode.parser.files')
22
local utils = require('orgmode.utils')
33
local fs = require('orgmode.utils.fs')
4-
local Hyperlinks = {}
4+
local Url = require('orgmode.objects.url')
5+
local config = require('orgmode.config')
6+
local Hyperlinks = {
7+
stored_links = {},
8+
}
59

610
---@param url Url
711
local function get_file_from_url(url)
@@ -66,7 +70,7 @@ function Hyperlinks.as_custom_id_anchors(headlines)
6670
and '#' .. headline.properties.items.custom_id
6771
end, headlines)
6872
end
69-
--
73+
7074
---@param headlines Section[]
7175
---@param omit_prefix? boolean
7276
---@return string[]
@@ -166,4 +170,51 @@ function Hyperlinks.find_matching_links(url)
166170
return result, mapper
167171
end
168172

173+
---@param headline Headline
174+
---@param path? string
175+
function Hyperlinks.get_link_to_headline(headline, path)
176+
path = path or utils.current_file_path()
177+
local title = headline:title()
178+
local id
179+
if config.org_id_link_to_org_use_id then
180+
id = headline:id_get_or_create()
181+
end
182+
return Hyperlinks._generate_link_to_headline(title, id, path)
183+
end
184+
185+
---@private
186+
function Hyperlinks._generate_link_to_headline(title, id, path)
187+
if not config.org_id_link_to_org_use_id or not id then
188+
return ('file:%s::*%s'):format(path, title)
189+
end
190+
return ('id:%s %s'):format(id, title)
191+
end
192+
193+
---@param headline Headline
194+
function Hyperlinks.store_link_to_headline(headline)
195+
local title = headline:title()
196+
Hyperlinks.stored_links[Hyperlinks.get_link_to_headline(headline)] = title
197+
end
198+
199+
---@param arg_lead string
200+
---@return string[]
201+
function Hyperlinks.autocomplete_links(arg_lead)
202+
local url = Url.new(arg_lead)
203+
local result, mapper = Hyperlinks.find_matching_links(url)
204+
205+
if url:is_file_plain() then
206+
return mapper(result)
207+
end
208+
209+
if url:is_custom_id() or url:is_headline() then
210+
local file = get_file_from_url(url)
211+
local results = mapper(result)
212+
return vim.tbl_map(function(value)
213+
return ('file:%s::%s'):format(file.filename, value)
214+
end, results)
215+
end
216+
217+
return vim.tbl_keys(Hyperlinks.stored_links)
218+
end
219+
169220
return Hyperlinks

lua/orgmode/org/id.lua

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
local config = require('orgmode.config')
2+
local utils = require('orgmode.utils')
3+
local state = require('orgmode.state.state')
4+
5+
local OrgId = {
6+
uuid_pattern = '%x%x%x%x%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%-%x%x%x%x%x%x%x%x%x%x%x%x',
7+
}
8+
9+
---@return string
10+
function OrgId.new()
11+
return OrgId._generate()
12+
end
13+
14+
---@return boolean
15+
function OrgId.is_valid_uuid(value)
16+
if not value or vim.trim(value) == '' then
17+
return false
18+
end
19+
20+
return value:match(OrgId.uuid_pattern) ~= nil
21+
end
22+
23+
---@private
24+
---@return string
25+
function OrgId._generate()
26+
if config.org_id_method == 'uuid' then
27+
if vim.fn.executable(config.org_id_uuid_program) ~= 1 then
28+
utils.echo_error('org_id_uuid_program is not executable: ' .. config.org_id_uuid_program)
29+
return ''
30+
end
31+
return tostring(vim.fn.system(config.org_id_uuid_program):gsub('%s+', ''))
32+
end
33+
34+
if config.org_id_method == 'ts' then
35+
return tostring(os.date(config.org_id_ts_format))
36+
end
37+
38+
if config.org_id_method == 'org' then
39+
math.randomseed(os.clock() * 100000000000)
40+
return ('%s%s'):format(vim.trim(config.org_id_prefix or ''), math.random(100000000000000))
41+
end
42+
43+
utils.echo_error('Invalid org_id_method: ' .. config.org_id_method)
44+
return ''
45+
end
46+
47+
return OrgId

0 commit comments

Comments
 (0)