diff --git a/DOCS.md b/DOCS.md
index 9d7f581b2..1006c9180 100644
--- a/DOCS.md
+++ b/DOCS.md
@@ -344,6 +344,34 @@ Determine if blank line should be prepended when:
* Adding heading via `org_meta_return` and `org_insert_*` mappings
* Adding a list item via `org_meta_return`
+#### **org_id_uuid_program**
+*type*: `string`
+*default value*: `uuidgen`
+External program used to generate uuid's for id module
+
+#### **org_id_ts_format**
+*type*: `string`
+*default value*: `%Y%m%d%H%M%S`
+Format of the id generated when [org_id_method](#org_id_method) is set to `ts`.
+
+#### **org_id_method**
+*type*: `'uuid' | 'ts' | 'org'`
+*default value*: `uuid`
+What method to use to generate ids via org id module.
+* `uuid` - Use [org_id_uuid_program](#org_id_uuid_program) to generate the id
+* `ts` - Generate id from current timestamp using format [org_id_ts_format](#org_id_ts_format)
+* `org` - Generate a random 12 digit number and prepend [org_id_prefix](#org_id_prefix)
+
+#### **org_id_prefix**
+*type*: `string | nil`
+*default value*: `nil`
+Prefix added to the generated id when [org_id_method](#org_id_method) is set to `org`.
+
+#### **org_id_link_to_org_use_id**
+*type*: `boolean`
+*default value*: `false`
+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)
+
#### **calendar_week_start_day**
*type*: `number`
*default value*: `1`
@@ -896,8 +924,15 @@ Toggle current line checkbox state
*mapped to*: `o*`
Toggle current line to headline and vice versa. Checkboxes will turn into TODO headlines.
#### **org_insert_link**
-*mapped to*: `oil`
+*mapped to*: `oli`
Insert a hyperlink at cursor position. When the cursor is on a hyperlink, edit that hyperlink.
+If there are any links stored with [org_store_link](#org_store_link), pressing `` to autocomplete the input
+will show list of all stored links to select. Links generated with ID are properly expanded to valid links after selection.
+#### **org_store_link**
+*mapped to*: `ols`
+Generate a link to the closest headline. If [org_id_link_to_org_use_id](#org_id_link_to_org_use_id) is `true`,
+it appends the `ID` property to the headline, and generates link with that id to be inserted via [org_insert_link](#org_insert_link).
+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`)
#### **org_open_at_point**
*mapped to*: `oo`
Open hyperlink or date under cursor. When date is under the cursor, open the agenda for that day.
diff --git a/lua/orgmode/api/headline.lua b/lua/orgmode/api/headline.lua
index c0f14e92b..e59aaa6eb 100644
--- a/lua/orgmode/api/headline.lua
+++ b/lua/orgmode/api/headline.lua
@@ -16,6 +16,7 @@ local Promise = require('orgmode.utils.promise')
---@field tags string[] List of own tags
---@field deadline Date|nil
---@field scheduled Date|nil
+---@field properties table Table containing all properties. All keys are lowercased
---@field closed Date|nil
---@field dates Date[] List of all dates that are not "plan" dates
---@field position Range
@@ -44,6 +45,7 @@ function OrgHeadline:_new(opts)
data.all_tags = opts.all_tags
data.priority = opts.priority
data.deadline = opts.deadline
+ data.properties = opts.properties
data.scheduled = opts.scheduled
data.closed = opts.closed
data.dates = opts.dates
@@ -71,6 +73,7 @@ function OrgHeadline._build_from_internal_section(section, index)
all_tags = { unpack(section.tags) },
tags = section:get_own_tags(),
position = OrgPosition:_build_from_internal_range(section.range),
+ properties = section:get_properties(),
deadline = section:get_deadline_date(),
scheduled = section:get_scheduled_date(),
closed = section:get_closed_date(),
@@ -213,6 +216,35 @@ function OrgHeadline:set_scheduled(date)
end)
end
+--- Set property on a headline
+---@param key string
+---@param value string
+function OrgHeadline:set_property(key, value)
+ return self:_do_action(function()
+ local headline = ts_org.closest_headline()
+ return headline:set_property(key, value)
+ end)
+end
+
+--- Get headline property
+---@param key string
+---@return string | nil
+function OrgHeadline:get_property(key)
+ return self.properties[key:lower()]
+end
+
+--- Get headline id or create a new one if it doesn't exist
+--- @return string
+function OrgHeadline:id_get_or_create()
+ local id = self:get_property('id')
+ if id then
+ return id
+ end
+ local org_id = require('orgmode.org.id').new()
+ self:set_property('ID', org_id)
+ return org_id
+end
+
---@param action function
---@private
function OrgHeadline:_do_action(action)
diff --git a/lua/orgmode/config/defaults.lua b/lua/orgmode/config/defaults.lua
index 49334e387..63eefa6f2 100644
--- a/lua/orgmode/config/defaults.lua
+++ b/lua/orgmode/config/defaults.lua
@@ -1,4 +1,5 @@
---@class DefaultConfig
+---@field org_id_method 'uuid' | 'ts' | 'org'
local DefaultConfig = {
org_agenda_files = '',
org_default_notes_file = '',
@@ -42,6 +43,11 @@ local DefaultConfig = {
},
org_src_window_setup = 'top 16new',
org_edit_src_content_indentation = 0,
+ org_id_uuid_program = 'uuidgen',
+ org_id_ts_format = '%Y%m%d%H%M%S',
+ org_id_method = 'uuid',
+ org_id_prefix = nil,
+ org_id_link_to_org_use_id = false,
win_split_mode = 'horizontal',
win_border = 'single',
notifications = {
@@ -144,7 +150,8 @@ local DefaultConfig = {
org_schedule = 'is',
org_time_stamp = 'i.',
org_time_stamp_inactive = 'i!',
- org_insert_link = 'il',
+ org_insert_link = 'li',
+ org_store_link = 'ls',
org_clock_in = 'xi',
org_clock_out = 'xo',
org_clock_cancel = 'xq',
diff --git a/lua/orgmode/config/init.lua b/lua/orgmode/config/init.lua
index ee22fc196..e2e321b8e 100644
--- a/lua/orgmode/config/init.lua
+++ b/lua/orgmode/config/init.lua
@@ -393,8 +393,8 @@ function Config:ts_highlights_enabled()
end
---@param content table
----@param option string
----@param prepend_content any
+---@param option? string
+---@param prepend_content? any
---@return table
function Config:respect_blank_before_new_entry(content, option, prepend_content)
if self.opts.org_blank_before_new_entry[option or 'heading'] then
diff --git a/lua/orgmode/config/mappings/init.lua b/lua/orgmode/config/mappings/init.lua
index 6402ad7ff..6c3d5d908 100644
--- a/lua/orgmode/config/mappings/init.lua
+++ b/lua/orgmode/config/mappings/init.lua
@@ -132,6 +132,7 @@ return {
{ args = { true }, opts = { desc = 'org timestamp (inactive)' } }
),
org_insert_link = m.action('org_mappings.insert_link', { opts = { desc = 'org insert link' } }),
+ org_store_link = m.action('org_mappings.store_link', { opts = { desc = 'org store link' } }),
org_clock_in = m.action('clock.org_clock_in', { opts = { desc = 'org clock in' } }),
org_clock_out = m.action('clock.org_clock_out', { opts = { desc = 'org clock out' } }),
org_clock_cancel = m.action('clock.org_clock_cancel', { opts = { desc = 'org clock cancel' } }),
diff --git a/lua/orgmode/objects/link.lua b/lua/orgmode/objects/link.lua
index 5efdf75c1..10b4ba3ad 100644
--- a/lua/orgmode/objects/link.lua
+++ b/lua/orgmode/objects/link.lua
@@ -1,4 +1,7 @@
local Url = require('orgmode.objects.url')
+local utils = require('orgmode.utils')
+local config = require('orgmode.config')
+local Range = require('orgmode.parser.range')
---@class Link
---@field url Url
@@ -10,6 +13,7 @@ function Link:init(str)
local parts = vim.split(str, '][', true)
self.url = Url.new(parts[1] or '')
self.desc = parts[2]
+ return self
end
---@return string
@@ -22,29 +26,35 @@ function Link:to_str()
end
---@param str string
+---@return Link
function Link.new(str)
local self = setmetatable({}, { __index = Link })
- self:init(str)
- return self
+ return self:init(str)
end
---@param line string
---@param pos number
----@return Link | nil
+---@return Link | nil, table | nil
function Link.at_pos(line, pos)
local links = {}
local found_link = nil
local pattern = '%[%[([^%]]+.-)%]%]'
+ local position
for link in line:gmatch(pattern) do
local start_from = #links > 0 and links[#links].to or nil
local from, to = line:find(pattern, start_from)
+ local current_pos = { from = from, to = to }
if pos >= from and pos <= to then
found_link = link
+ position = current_pos
break
end
- table.insert(links, { link = link, from = from, to = to })
+ table.insert(links, current_pos)
+ end
+ if not found_link then
+ return nil, nil
end
- return (found_link and Link.new(found_link) or nil)
+ return Link.new(found_link), position
end
return Link
diff --git a/lua/orgmode/objects/url.lua b/lua/orgmode/objects/url.lua
index b437b4256..4aba74227 100644
--- a/lua/orgmode/objects/url.lua
+++ b/lua/orgmode/objects/url.lua
@@ -1,4 +1,3 @@
-local utils = require('orgmode.utils')
local fs = require('orgmode.utils.fs')
---@class Url
@@ -27,16 +26,22 @@ end
---@return boolean
function Url:is_file_headline()
- return self:is_file() and self:get_headline() and true
+ return self:is_file() and self:get_headline() and true or false
end
+---@return boolean
function Url:is_custom_id()
- return self:is_file_custom_id() or self:is_internal_custom_id()
+ return (self:is_file_custom_id() or self:is_internal_custom_id()) and true or false
+end
+
+---@return boolean
+function Url:is_id()
+ return self.str:find('^id:') and true or false
end
---@return boolean
function Url:is_file_custom_id()
- return self:is_file() and self:get_custom_id() and true
+ return self:is_file() and self:get_custom_id() and true or false
end
---@return boolean
@@ -64,7 +69,7 @@ end
---@return boolean
function Url:is_internal_headline()
- return self.str:find('^*') and true
+ return self.str:find('^*') and true or false
end
function Url:is_internal_custom_id()
@@ -122,6 +127,10 @@ function Url:get_custom_id()
or self.str:match('^#(.-)$')
end
+function Url:get_id()
+ return self.str:match('^id:(%S+)')
+end
+
---@return number | false
function Url:get_linenumber()
-- official orgmode convention
@@ -181,4 +190,9 @@ function Url:get_http_url()
return self.str:match('^https?://.+$')
end
+---@return string | false
+function Url:extract_target()
+ return self:get_headline() or self:get_custom_id() or self:get_dedicated_target()
+end
+
return Url
diff --git a/lua/orgmode/org/hyperlinks.lua b/lua/orgmode/org/hyperlinks.lua
index d713ce655..447ade26b 100644
--- a/lua/orgmode/org/hyperlinks.lua
+++ b/lua/orgmode/org/hyperlinks.lua
@@ -1,7 +1,11 @@
local Files = require('orgmode.parser.files')
local utils = require('orgmode.utils')
local fs = require('orgmode.utils.fs')
-local Hyperlinks = {}
+local Url = require('orgmode.objects.url')
+local config = require('orgmode.config')
+local Hyperlinks = {
+ stored_links = {},
+}
---@param url Url
local function get_file_from_url(url)
@@ -66,7 +70,7 @@ function Hyperlinks.as_custom_id_anchors(headlines)
and '#' .. headline.properties.items.custom_id
end, headlines)
end
---
+
---@param headlines Section[]
---@param omit_prefix? boolean
---@return string[]
@@ -166,4 +170,51 @@ function Hyperlinks.find_matching_links(url)
return result, mapper
end
+---@param headline Headline
+---@param path? string
+function Hyperlinks.get_link_to_headline(headline, path)
+ path = path or utils.current_file_path()
+ local title = headline:title()
+ local id
+ if config.org_id_link_to_org_use_id then
+ id = headline:id_get_or_create()
+ end
+ return Hyperlinks._generate_link_to_headline(title, id, path)
+end
+
+---@private
+function Hyperlinks._generate_link_to_headline(title, id, path)
+ if not config.org_id_link_to_org_use_id or not id then
+ return ('file:%s::*%s'):format(path, title)
+ end
+ return ('id:%s %s'):format(id, title)
+end
+
+---@param headline Headline
+function Hyperlinks.store_link_to_headline(headline)
+ local title = headline:title()
+ Hyperlinks.stored_links[Hyperlinks.get_link_to_headline(headline)] = title
+end
+
+---@param arg_lead string
+---@return string[]
+function Hyperlinks.autocomplete_links(arg_lead)
+ local url = Url.new(arg_lead)
+ local result, mapper = Hyperlinks.find_matching_links(url)
+
+ if url:is_file_plain() then
+ return mapper(result)
+ end
+
+ if url:is_custom_id() or url:is_headline() then
+ local file = get_file_from_url(url)
+ local results = mapper(result)
+ return vim.tbl_map(function(value)
+ return ('file:%s::%s'):format(file.filename, value)
+ end, results)
+ end
+
+ return vim.tbl_keys(Hyperlinks.stored_links)
+end
+
return Hyperlinks
diff --git a/lua/orgmode/org/id.lua b/lua/orgmode/org/id.lua
new file mode 100644
index 000000000..029b011ad
--- /dev/null
+++ b/lua/orgmode/org/id.lua
@@ -0,0 +1,47 @@
+local config = require('orgmode.config')
+local utils = require('orgmode.utils')
+local state = require('orgmode.state.state')
+
+local OrgId = {
+ 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',
+}
+
+---@return string
+function OrgId.new()
+ return OrgId._generate()
+end
+
+---@return boolean
+function OrgId.is_valid_uuid(value)
+ if not value or vim.trim(value) == '' then
+ return false
+ end
+
+ return value:match(OrgId.uuid_pattern) ~= nil
+end
+
+---@private
+---@return string
+function OrgId._generate()
+ if config.org_id_method == 'uuid' then
+ if vim.fn.executable(config.org_id_uuid_program) ~= 1 then
+ utils.echo_error('org_id_uuid_program is not executable: ' .. config.org_id_uuid_program)
+ return ''
+ end
+ return tostring(vim.fn.system(config.org_id_uuid_program):gsub('%s+', ''))
+ end
+
+ if config.org_id_method == 'ts' then
+ return tostring(os.date(config.org_id_ts_format))
+ end
+
+ if config.org_id_method == 'org' then
+ math.randomseed(os.clock() * 100000000000)
+ return ('%s%s'):format(vim.trim(config.org_id_prefix or ''), math.random(100000000000000))
+ end
+
+ utils.echo_error('Invalid org_id_method: ' .. config.org_id_method)
+ return ''
+end
+
+return OrgId
diff --git a/lua/orgmode/org/mappings.lua b/lua/orgmode/org/mappings.lua
index 12f2e8585..0ea370591 100644
--- a/lua/orgmode/org/mappings.lua
+++ b/lua/orgmode/org/mappings.lua
@@ -104,7 +104,7 @@ end
function OrgMappings:cycle()
local file = Files.get_current_file()
- local line = vim.fn.line('.')
+ local line = vim.fn.line('.') or 0
if not vim.wo.foldenable then
vim.wo.foldenable = true
vim.cmd([[silent! norm!zx]])
@@ -204,7 +204,7 @@ function OrgMappings:_adjust_date_part(direction, amount, fallback)
end
local minute_adj = get_adj('M', tonumber(config.org_time_stamp_rounding_minutes) * amount)
local do_replacement = function(date)
- local col = vim.fn.col('.')
+ local col = vim.fn.col('.') or 0
local char = vim.fn.getline('.'):sub(col, col)
local raw_date_value = vim.fn.getline('.'):sub(date.range.start_col + 1, date.range.end_col - 1)
if col == date.range.start_col or col == date.range.end_col then
@@ -569,7 +569,7 @@ function OrgMappings:handle_return(suffix)
end
if item.type == 'headline' then
- local linenr = vim.fn.line('.')
+ local linenr = vim.fn.line('.') or 0
local content = config:respect_blank_before_new_entry({ string.rep('*', item.level) .. ' ' .. suffix })
vim.fn.append(linenr, content)
vim.fn.cursor(linenr + #content, 0)
@@ -690,7 +690,7 @@ end
function OrgMappings:_insert_heading_from_plain_line(suffix)
suffix = suffix or ''
- local linenr = vim.fn.line('.')
+ local linenr = vim.fn.line('.') or 0
local line = vim.fn.getline(linenr)
local heading_prefix = '* ' .. suffix
@@ -707,7 +707,7 @@ function OrgMappings:_insert_heading_from_plain_line(suffix)
else
-- split at cursor
local left = string.sub(line, 0, vim.fn.col('.') - 1)
- local right = string.sub(line, vim.fn.col('.'), #line)
+ local right = string.sub(line, vim.fn.col('.') or 0, #line)
line = heading_prefix .. right
vim.fn.setline(linenr, left)
vim.fn.append(linenr, line)
@@ -719,14 +719,24 @@ end
-- Inserts a new link after the cursor position or modifies the link the cursor is
-- currently on
function OrgMappings:insert_link()
- local link_location = vim.fn.OrgmodeInput('Links: ', '')
- if vim.trim(link_location) ~= '' then
- link_location = '[' .. link_location .. ']'
- else
+ local link_location = vim.fn.OrgmodeInput('Links: ', '', Hyperlinks.autocomplete_links)
+ if vim.trim(link_location) == '' then
utils.echo_warning('No Link selected')
return
end
- local link_description = vim.trim(vim.fn.OrgmodeInput('Description: ', ''))
+
+ local selected_link = Link.new(link_location)
+ local desc = selected_link.url:extract_target()
+ if selected_link.url:is_id() then
+ local id_link = ('id:%s'):format(selected_link.url:get_id())
+ desc = link_location:gsub('^' .. vim.pesc(id_link) .. '%s+', '')
+ link_location = id_link
+ end
+
+ local link_description = vim.trim(vim.fn.OrgmodeInput('Description: ', desc or ''))
+
+ link_location = '[' .. vim.trim(link_location) .. ']'
+
if link_description ~= '' then
link_description = '[' .. link_description .. ']'
end
@@ -736,11 +746,11 @@ function OrgMappings:insert_link()
local target_col = #link_location + #link_description + 2
-- check if currently on link
- local link = self:_get_link_under_cursor()
- if link then
- insert_from = link.from - 1
- insert_to = link.to + 1
- target_col = target_col + link.from
+ local link, position = self:_get_link_under_cursor()
+ if link and position then
+ insert_from = position.from - 1
+ insert_to = position.to + 1
+ target_col = target_col + position.from
else
local colnr = vim.fn.col('.')
insert_from = colnr
@@ -748,7 +758,7 @@ function OrgMappings:insert_link()
target_col = target_col + colnr
end
- local linenr = vim.fn.line('.')
+ local linenr = vim.fn.line('.') or 0
local curr_line = vim.fn.getline(linenr)
local new_line = string.sub(curr_line, 0, insert_from)
.. '['
@@ -761,6 +771,12 @@ function OrgMappings:insert_link()
vim.fn.cursor(linenr, target_col)
end
+function OrgMappings:store_link()
+ local headline = ts_org.closest_headline()
+ Hyperlinks.store_link_to_headline(headline)
+ return utils.echo_info('Stored: ' .. headline:title())
+end
+
function OrgMappings:move_subtree_up()
local item = Files.get_closest_headline()
local prev_headline = item:get_prev_headline_same_level()
@@ -819,6 +835,17 @@ function OrgMappings:open_at_point()
local cmd = string.format('edit +%s %s', line_number, fs.get_real_path(file_path))
vim.cmd(cmd)
return vim.cmd([[normal! zv]])
+ elseif link.url:is_id() then
+ local id = link.url:get_id()
+ local headlines = Files.find_headlines_with_property_matching('id', id)
+ if #headlines == 0 then
+ return utils.echo_warning(string.format('No headline found with id: %s', id))
+ end
+ if #headlines > 1 then
+ return utils.echo_warning(string.format('Multiple headlines found with id: %s', id))
+ end
+ local headline = headlines[1]
+ return self:_goto_headline(headline)
elseif link.url:is_http_url() then
if not vim.g.loaded_netrwPlugin then
return utils.echo_warning('Netrw plugin must be loaded in order to open urls.')
@@ -856,13 +883,7 @@ function OrgMappings:open_at_point()
headline = headlines[choice]
end
- if link.url:is_file() then
- vim.cmd(string.format('edit %s', headline.file))
- else
- vim.cmd([[normal! m']]) -- add link source to jumplist
- end
- vim.fn.cursor({ headline.range.start_line, 0 })
- vim.cmd([[normal! zv]])
+ return self:_goto_headline(headline)
end
function OrgMappings:export()
@@ -974,7 +995,7 @@ end
---@param direction string
---@param use_fast_access? boolean
----@return string
+---@return boolean
function OrgMappings:_change_todo_state(direction, use_fast_access)
local headline = ts_org.closest_headline()
local todo, current_keyword = headline:todo()
@@ -997,7 +1018,7 @@ function OrgMappings:_change_todo_state(direction, use_fast_access)
end
if next_state.value == current_keyword then
- if todo.value ~= '' then
+ if todo ~= '' then
utils.echo_info('TODO state was already ', { { next_state.value, next_state.hl } })
end
return false
@@ -1028,7 +1049,7 @@ end
function OrgMappings:_get_date_under_cursor(col_offset)
col_offset = col_offset or 0
local col = vim.fn.col('.') + col_offset
- local line = vim.fn.line('.')
+ local line = vim.fn.line('.') or 0
local item = Files.get_closest_headline()
local dates = {}
if item then
@@ -1050,7 +1071,6 @@ end
---@param amount number
---@param span string
---@param fallback string
----@return string
function OrgMappings:_adjust_date(amount, span, fallback)
local adjustment = string.format('%s%d%s', amount > 0 and '+' or '', amount, span)
local date = self:_get_date_under_cursor()
@@ -1078,11 +1098,23 @@ function OrgMappings:_adjust_date(amount, span, fallback)
return vim.api.nvim_feedkeys(utils.esc(fallback), 'n', true)
end
----@return Link|nil
+---@return Link|nil, table | nil
function OrgMappings:_get_link_under_cursor()
local line = vim.fn.getline('.')
- local col = vim.fn.col('.')
+ local col = vim.fn.col('.') or 0
return Link.at_pos(line, col)
end
+---@param headline Section
+function OrgMappings:_goto_headline(headline)
+ local current_file_path = utils.current_file_path()
+ if headline.file ~= current_file_path then
+ vim.cmd(string.format('edit %s', headline.file))
+ else
+ vim.cmd([[normal! m']]) -- add link source to jumplist
+ end
+ vim.fn.cursor({ headline.range.start_line, 0 })
+ vim.cmd([[normal! zv]])
+end
+
return OrgMappings
diff --git a/lua/orgmode/parser/file.lua b/lua/orgmode/parser/file.lua
index e01c911ca..ee571d4c8 100644
--- a/lua/orgmode/parser/file.lua
+++ b/lua/orgmode/parser/file.lua
@@ -5,7 +5,7 @@ local config = require('orgmode.config')
local utils = require('orgmode.utils')
---@class File
----@field tree userdata
+---@field tree TSTree
---@field file_content string[]
---@field file_content_str string
---@field category string
@@ -23,7 +23,7 @@ local File = {}
function File:new(tree, file_content, file_content_str, category, filename, is_archive_file)
local changedtick = 0
if filename then
- local bufnr = vim.fn.bufnr(filename)
+ local bufnr = vim.fn.bufnr(filename) or -1
if bufnr > 0 then
changedtick = vim.api.nvim_buf_get_var(bufnr, 'changedtick')
end
@@ -107,20 +107,20 @@ function File:get_unfinished_todo_entries()
end, self.sections)
end
----@param node userdata
+---@param node TSNode
---@return string
function File:get_node_text(node)
return utils.get_node_text(node, self.file_content)[1] or ''
end
----@param node userdata
+---@param node TSNode
---@return string[]
function File:get_node_text_list(node)
return utils.get_node_text(node, self.file_content) or {}
end
---@param query string
----@param node userdata|nil
+---@param node TSNode|nil
---@return table[]
function File:get_ts_matches(query, node)
return utils.get_ts_matches(query, node or self.tree:root(), self.file_content, self.file_content_str)
@@ -136,7 +136,7 @@ end
---@return boolean
function File:should_reload()
- local bufnr = vim.fn.bufnr(self.filename)
+ local bufnr = vim.fn.bufnr(self.filename) or -1
if bufnr < 0 then
return false
end
@@ -183,7 +183,7 @@ function File:refresh()
if not self:should_reload() then
return self
end
- local bufnr = vim.fn.bufnr(self.filename)
+ local bufnr = vim.fn.bufnr(self.filename) or -1
local content = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false)
local refreshed_file = File.from_content(content, self.category, self.filename, self.is_archive_file)
refreshed_file.changedtick = vim.api.nvim_buf_get_var(bufnr, 'changedtick')
@@ -282,7 +282,7 @@ function File:get_opened_unfinished_headlines()
end, self.sections)
end
----@return userdata
+---@return TSNode
function File:get_node_at_cursor(cursor)
cursor = cursor or vim.api.nvim_win_get_cursor(0)
local cursor_range = { cursor[1] - 1, cursor[2] }
diff --git a/lua/orgmode/parser/files.lua b/lua/orgmode/parser/files.lua
index acf68d321..ade8451aa 100644
--- a/lua/orgmode/parser/files.lua
+++ b/lua/orgmode/parser/files.lua
@@ -16,6 +16,7 @@ local Files = {
}
function Files.new()
+ Files.loaded = false
Files.load()
return Files
end
@@ -226,7 +227,7 @@ function Files.get_closest_headline(id)
return headline
end
----@return userdata
+---@return TSNode
function Files.get_node_at_cursor()
return Files.get_current_file():get_node_at_cursor()
end
diff --git a/lua/orgmode/parser/logbook.lua b/lua/orgmode/parser/logbook.lua
index 99cbf54c8..338475105 100644
--- a/lua/orgmode/parser/logbook.lua
+++ b/lua/orgmode/parser/logbook.lua
@@ -134,7 +134,7 @@ function Logbook:recalculate_estimate(line)
end
---@param lines string
----@param node userdata
+---@param node TSNode
---@param dates Date[]
---@return Logbook
function Logbook.parse(lines, node, dates)
diff --git a/lua/orgmode/parser/section.lua b/lua/orgmode/parser/section.lua
index e8550e3e4..4d78d0369 100644
--- a/lua/orgmode/parser/section.lua
+++ b/lua/orgmode/parser/section.lua
@@ -14,7 +14,7 @@ local config = require('orgmode.config')
---@field id string
---@field line_number number
---@field level number
----@field node userdata
+---@field node TSNode
---@field root File
---@field parent Section
---@field line string
@@ -37,7 +37,7 @@ local Section = {}
---@class SectionProperties
---@field items table
---@field range Range
----@field node userdata
+---@field node TSNode
---@field valid boolean
---@class SectionTodoKeyword
@@ -51,7 +51,7 @@ local Section = {}
---@field level number
---@field line string
---@field logbook Logbook
----@field node userdata
+---@field node TSNode
---@field own_tags string[]
---@field parent Section
---@field priority string
@@ -309,6 +309,11 @@ function Section:get_property(name)
return self.properties.items[name:lower()]
end
+---@return table
+function Section:get_properties()
+ return self.properties and self.properties.items or {}
+end
+
function Section:matches_search_term(term)
if self.title:lower():match(term) then
return true
diff --git a/lua/orgmode/state/state.lua b/lua/orgmode/state/state.lua
index d29ff022f..6d3332ebd 100644
--- a/lua/orgmode/state/state.lua
+++ b/lua/orgmode/state/state.lua
@@ -2,7 +2,7 @@ local utils = require('orgmode.utils')
local Promise = require('orgmode.utils.promise')
---@class OrgState
-local OrgState = { data = {}, _ctx = { loaded = false, saved = false, curr_loader = nil, savers = 0 } }
+local OrgState = { data = {}, _ctx = { loaded = false, saved = false, curr_loader = nil, savers = 0, dirty = false } }
local cache_path = vim.fs.normalize(vim.fn.stdpath('cache') .. '/org-cache.json', { expand_env = false })
@@ -16,6 +16,9 @@ function OrgState.new()
return tbl.data[key]
end,
__newindex = function(tbl, key, value)
+ if tbl.data[key] ~= value then
+ tbl._ctx.dirty = true
+ end
tbl.data[key] = value
end,
})
@@ -28,6 +31,9 @@ end
---Save the current state to cache
---@return Promise
function OrgState:save()
+ if not OrgState._ctx.dirty then
+ return Promise.resolve(self)
+ end
OrgState._ctx.saved = false
--- We want to ensure the state was loaded before saving.
self:load()
@@ -38,6 +44,7 @@ function OrgState:save()
self._ctx.savers = self._ctx.savers - 1
if self._ctx.savers == 0 then
OrgState._ctx.saved = true
+ OrgState._ctx.dirty = false
end
end)
:catch(function(err_msg)
@@ -86,6 +93,7 @@ function OrgState:load()
vim.schedule(function()
utils.echo_warning('OrgState cache load failure, error: ' .. vim.inspect(err_msg))
-- Try to 'repair' the cache by saving the current state
+ self._ctx.dirty = true
self:save()
end)
end
@@ -107,6 +115,7 @@ function OrgState:load()
-- If the file didn't exist then go ahead and save
-- our current cache and as a side effect create the file
if type(err) == 'string' and err:match([[^ENOENT.*]]) then
+ self._ctx.dirty = true
self:save()
return self
end
@@ -116,6 +125,7 @@ function OrgState:load()
:finally(function()
self._ctx.loaded = true
self._ctx.curr_loader = nil
+ self._ctx.dirty = false
end)
return self._ctx.curr_loader
@@ -160,6 +170,7 @@ function OrgState:wipe(overwrite)
self._ctx.curr_loader = nil
self._ctx.loaded = false
self._ctx.saved = false
+ self._ctx.dirty = true
if overwrite then
state:save_sync()
end
diff --git a/lua/orgmode/treesitter/headline.lua b/lua/orgmode/treesitter/headline.lua
index 48df63637..e2fb94186 100644
--- a/lua/orgmode/treesitter/headline.lua
+++ b/lua/orgmode/treesitter/headline.lua
@@ -7,10 +7,10 @@ local config = require('orgmode.config')
local ts = vim.treesitter
---@class Headline
----@field headline userdata
+---@field headline TSNode
local Headline = {}
----@param headline_node userdata tree sitter headline node
+---@param headline_node TSNode tree sitter headline node
function Headline:new(headline_node)
local data = { headline = headline_node }
setmetatable(data, self)
@@ -28,7 +28,7 @@ function Headline.from_cursor(cursor)
return Headline:new(ts_headline)
end
----@return userdata stars node
+---@return TSNode stars node
function Headline:stars()
return self.headline:field('stars')[1]
end
@@ -123,7 +123,7 @@ function Headline:_handle_promote_demote(recursive, modifier)
return self:refresh()
end
----@return userdata, string
+---@return TsNode, string
function Headline:tags()
local node = self.headline:field('tags')[1]
local text = ''
@@ -277,10 +277,14 @@ function Headline:title()
if todo then
title = title:gsub('^' .. vim.pesc(word) .. '%s*', '')
end
+ local _, priority = self:priority()
+ if priority then
+ title = title:gsub('^' .. vim.pesc(priority) .. '%s*', '')
+ end
return title
end
----@return userdata|nil
+---@return TSNode|nil
function Headline:plan()
local section = self.headline:parent()
for _, node in ipairs(ts_utils.get_named_children(section)) do
@@ -290,7 +294,7 @@ function Headline:plan()
end
end
----@return userdata|nil
+---@return TSNode|nil
function Headline:properties()
local section = self.headline:parent()
for _, node in ipairs(ts_utils.get_named_children(section)) do
@@ -359,7 +363,7 @@ function Headline:get_append_line()
return self.headline:end_()
end
----@return Table
+---@return Table
function Headline:dates()
local plan = self:plan()
local dates = {}
@@ -375,7 +379,7 @@ function Headline:dates()
return dates
end
----@return userdata[]
+---@return TSNode[]
function Headline:repeater_dates()
return vim.tbl_filter(function(entry)
local timestamp = entry:field('timestamp')[1]
@@ -582,4 +586,14 @@ function Headline:_apply_indent(text)
return config:apply_indent(text, self:level() + 1)
end
+function Headline:id_get_or_create()
+ local id_prop = self:get_property('ID')
+ if id_prop then
+ return vim.trim(id_prop.value)
+ end
+ local org_id = require('orgmode.org.id').new()
+ self:set_property('ID', org_id)
+ return org_id
+end
+
return Headline
diff --git a/lua/orgmode/treesitter/listitem.lua b/lua/orgmode/treesitter/listitem.lua
index 8e8ac7b3c..6cf5fa4da 100644
--- a/lua/orgmode/treesitter/listitem.lua
+++ b/lua/orgmode/treesitter/listitem.lua
@@ -4,7 +4,7 @@ local ts = vim.treesitter
local Headline = require('orgmode.treesitter.headline')
---@class Listitem
----@field listitem userdata
+---@field listitem TSNode
local Listitem = {}
function Listitem:new(listitem_node)
diff --git a/lua/orgmode/treesitter/table.lua b/lua/orgmode/treesitter/table.lua
index f81060289..61a973b25 100644
--- a/lua/orgmode/treesitter/table.lua
+++ b/lua/orgmode/treesitter/table.lua
@@ -4,7 +4,7 @@ local utils = require('orgmode.utils')
local config = require('orgmode.config')
---@class TsTable
----@field node userdata
+---@field node TSNode
---@field data table[]
---@field tbl Table
local TsTable = {}
diff --git a/lua/orgmode/utils/init.lua b/lua/orgmode/utils/init.lua
index ff463bd7b..c029e9b72 100644
--- a/lua/orgmode/utils/init.lua
+++ b/lua/orgmode/utils/init.lua
@@ -257,7 +257,7 @@ function utils.humanize_minutes(minutes)
end
---@param query string
----@param node userdata
+---@param node TSNode
---@param file_content string[]
---@param file_content_str string
---@return table[]
@@ -284,7 +284,7 @@ function utils.get_ts_matches(query, node, file_content, file_content_str)
return matches
end
----@param node userdata
+---@param node TSNode
---@param content string[]
---@return string[]
function utils.get_node_text(node, content)
@@ -327,9 +327,9 @@ function utils.get_node_text(node, content)
end
end
----@param node userdata
+---@param node TSNode
---@param type string
----@return userdata | nil
+---@return TSNode | nil
function utils.get_closest_parent_of_type(node, type, accept_at_cursor)
local parent = node
@@ -430,7 +430,7 @@ function utils.choose(items)
end
---@param file File
----@param parent_node userdata
+---@param parent_node TSNode
---@param children_names table
---@return table
function utils.get_named_children_nodes(file, parent_node, children_names)
diff --git a/lua/orgmode/utils/treesitter.lua b/lua/orgmode/utils/treesitter.lua
index 4d0b41412..86e09af32 100644
--- a/lua/orgmode/utils/treesitter.lua
+++ b/lua/orgmode/utils/treesitter.lua
@@ -118,7 +118,7 @@ function M.set_node_text(node, text, front_trim)
pcall(vim.api.nvim_buf_set_text, 0, sr, sc, er, ec, lines)
end
----@param node userdata
+---@param node TSNode
---@param lines string[]
function M.set_node_lines(node, lines)
local start_row, _, end_row, _ = node:range()
diff --git a/tests/plenary/api/api_spec.lua b/tests/plenary/api/api_spec.lua
index f4ccbca26..7a97d1c78 100644
--- a/tests/plenary/api/api_spec.lua
+++ b/tests/plenary/api/api_spec.lua
@@ -1,6 +1,7 @@
local helpers = require('tests.plenary.ui.helpers')
local api = require('orgmode.api')
local Date = require('orgmode.objects.date')
+local OrgId = require('orgmode.org.id')
describe('Api', function()
it('should parse current file through api', function()
@@ -248,4 +249,42 @@ describe('Api', function()
expect = vim.pesc(' DEADLINE: <2021-07-21 Wed 22:02>')
assert.Is.True(vim.fn.getline(3):match(expect) ~= nil)
end)
+
+ it('sets the property on the headline', function()
+ helpers.load_file_content({
+ '* TODO Test orgmode',
+ ' SCHEDULED: <2021-07-21 Wed 22:02>',
+ '** TODO Second level :NESTEDTAG:',
+ ' DEADLINE: <2021-07-21 Wed 22:02>',
+ '* TODO Some task',
+ })
+
+ api.current().headlines[2]:set_property('NAME', 'test')
+ assert.are.same({
+ '* TODO Test orgmode',
+ ' SCHEDULED: <2021-07-21 Wed 22:02>',
+ '** TODO Second level :NESTEDTAG:',
+ ' DEADLINE: <2021-07-21 Wed 22:02>',
+ ' :PROPERTIES:',
+ ' :NAME: test',
+ ' :END:',
+ '* TODO Some task',
+ }, vim.api.nvim_buf_get_lines(0, 0, -1, false))
+ assert.are.same(api.current().headlines[2]:get_property('NAME'), 'test')
+ end)
+
+ it('sets the id on headline', function()
+ helpers.load_file_content({
+ '* TODO Test orgmode',
+ ' SCHEDULED: <2021-07-21 Wed 22:02>',
+ '** TODO Second level :NESTEDTAG:',
+ ' DEADLINE: <2021-07-21 Wed 22:02>',
+ '* TODO Some task',
+ })
+
+ local id = api.current().headlines[2]:id_get_or_create()
+ assert.is.True(OrgId.is_valid_uuid(id))
+ assert.are.same(api.current().headlines[2]:get_property('ID'), id)
+ assert.are.same(vim.fn.getline(6), (' :ID: %s'):format(id))
+ end)
end)
diff --git a/tests/plenary/org/id_spec.lua b/tests/plenary/org/id_spec.lua
new file mode 100644
index 000000000..19f71b4bd
--- /dev/null
+++ b/tests/plenary/org/id_spec.lua
@@ -0,0 +1,61 @@
+describe('org id', function()
+ local org_id = require('orgmode.org.id')
+ it('should generate an id using uuid method', function()
+ local uuid = org_id.new()
+ assert.are.same(36, #uuid)
+ assert.is.True(uuid:match('%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') ~= nil)
+ end)
+
+ it('should validate an uuid', function()
+ local valid_uuid = 'f47ac10b-58cc-4372-a567-0e02b2c3d479'
+ assert.is.True(org_id.is_valid_uuid(valid_uuid))
+ assert.is.False(org_id.is_valid_uuid(nil))
+ assert.is.False(org_id.is_valid_uuid(''))
+ assert.is.False(org_id.is_valid_uuid(' '))
+ assert.is.False(org_id.is_valid_uuid('not an uuid'))
+ end)
+
+ it('should generate an id using "ts" method', function()
+ require('orgmode').setup({
+ org_id_method = 'ts',
+ })
+ local ts_id = org_id.new()
+ assert.is.True(ts_id:match('%d%d%d%d%d%d%d%d%d%d%d%d%d%d') ~= nil)
+ assert.is.True(ts_id:match(os.date('%Y%m%d%H')) ~= nil)
+ end)
+
+ it('should generate an id using "ts" method and custom format', function()
+ require('orgmode').setup({
+ org_id_method = 'ts',
+ org_id_ts_format = '%Y_%m_%d_%H_%M_%S',
+ })
+ local ts_id = org_id.new()
+ assert.is.True(ts_id:match('%d%d%d%d_%d%d_%d%d_%d%d_%d%d_%d%d') ~= nil)
+ assert.is.True(ts_id:match(os.date('%Y_%m_%d_%H')) ~= nil)
+ end)
+
+ it('should generate an id using "org" format', function()
+ require('orgmode').setup({
+ org_id_method = 'org',
+ })
+
+ local oid = org_id.new()
+ -- Ensure it does not generate a timestamp format
+ assert.is.Nil(oid:match(os.date('%Y%m%d%H')))
+ assert.is.True(oid:match('%d+') ~= nil)
+ assert.is.True(oid:len() >= 1)
+ end)
+
+ it('should generate an id using "org" format with custom prefix', function()
+ require('orgmode').setup({
+ org_id_method = 'org',
+ org_id_prefix = 'org_tests_',
+ })
+
+ local oid = org_id.new()
+ -- Ensure it does not generate a timestamp format
+ assert.is.Nil(oid:match('org_tests_' .. os.date('%Y%m%d%H')))
+ assert.is.True(oid:match('org_tests_%d+') ~= nil)
+ assert.is.True(oid:len() >= 11)
+ end)
+end)
diff --git a/tests/plenary/state/state_spec.lua b/tests/plenary/state/state_spec.lua
index 9be15aa5a..c341156ee 100644
--- a/tests/plenary/state/state_spec.lua
+++ b/tests/plenary/state/state_spec.lua
@@ -2,6 +2,7 @@ local utils = require('orgmode.utils')
---@type OrgState
local state = nil
local cache_path = vim.fs.normalize(vim.fn.stdpath('cache') .. '/org-cache.json', { expand_env = false })
+local spy = require('luassert.spy')
describe('State', function()
before_each(function()
@@ -76,6 +77,35 @@ describe('State', function()
assert.are.equal('hello world', state.my_var)
end)
+ it('should set the dirty state when a variable is set', function()
+ -- By default it's dirty
+ assert.is.True(state._ctx.dirty)
+ state:save_sync()
+ assert.is.False(state._ctx.dirty)
+
+ state.my_var = 'hello world'
+ assert.is.True(state._ctx.dirty)
+ state:save_sync()
+ assert.is.False(state._ctx.dirty)
+
+ -- Ensure writefile is not called if state is not dirty
+ local s = spy.on(utils, 'writefile')
+ state:save_sync()
+ assert.spy(s).was.called(0)
+
+ -- Ensure writefile is not called if state was not changed
+ state:save_sync()
+ state.my_var = 'hello world'
+ assert.spy(s).was.called(0)
+
+ -- Ensure writefile is called if state prop was changed
+ state.my_var = 'hello worlds'
+ state:save_sync()
+ assert.spy(s).was.called(1)
+
+ s:revert()
+ end)
+
it('should be able to self-heal from an invalid state file', function()
state:save_sync()
diff --git a/tests/plenary/ui/mappings/hyperlink_spec.lua b/tests/plenary/ui/mappings/hyperlink_spec.lua
index 67ae0f129..82678c959 100644
--- a/tests/plenary/ui/mappings/hyperlink_spec.lua
+++ b/tests/plenary/ui/mappings/hyperlink_spec.lua
@@ -1,4 +1,5 @@
local helpers = require('tests.plenary.ui.helpers')
+local OrgId = require('orgmode.org.id')
describe('Hyperlink mappings', function()
after_each(function()
@@ -50,6 +51,77 @@ describe('Hyperlink mappings', function()
assert.is.same('** headline of target custom_id', vim.api.nvim_get_current_line())
end)
+ it('should follow link to id', function()
+ local target_path = helpers.load_file_content({
+ '* Test hyperlink',
+ ' - some',
+ ' - boiler',
+ ' - plate',
+ '** headline of target id',
+ ' :PROPERTIES:',
+ ' :ID: 8ce79e8c-0b5d-4fd6-9eea-ab47c93398ba',
+ ' :END:',
+ ' - more',
+ ' - boiler',
+ ' - plate',
+ })
+ local source_path = helpers.load_file_content({
+ 'This link should lead to [[id:8ce79e8c-0b5d-4fd6-9eea-ab47c93398ba][headline of target with id]]',
+ })
+ local org = require('orgmode').setup({
+ org_agenda_files = {
+ vim.fn.fnamemodify(target_path, ':p:h') .. '**/*',
+ },
+ })
+ org:init()
+ vim.fn.cursor(1, 30)
+ vim.cmd([[norm ,oo]])
+ assert.is.same('** headline of target id', vim.api.nvim_get_current_line())
+ end)
+
+ it('should store link to a headline', function()
+ local target_path = helpers.load_file_content({
+ '* Test hyperlink',
+ ' - some',
+ '** headline of target id',
+ ' - more',
+ ' - boiler',
+ ' - plate',
+ '* Test hyperlink 2',
+ })
+ vim.fn.cursor(4, 10)
+ vim.cmd([[norm ,ols]])
+ assert.are.same({
+ [('file:%s::*headline of target id'):format(target_path)] = 'headline of target id',
+ }, require('orgmode.org.hyperlinks').stored_links)
+ end)
+
+ it('should store link to a headline with id', function()
+ require('orgmode.org.hyperlinks').stored_links = {}
+ local org = require('orgmode').setup({
+ org_id_link_to_org_use_id = true,
+ })
+ helpers.load_file_content({
+ '* Test hyperlink',
+ ' - some',
+ '** headline of target id',
+ ' - more',
+ ' - boiler',
+ ' - plate',
+ '* Test hyperlink 2',
+ })
+
+ org:init()
+ vim.fn.cursor(4, 10)
+ vim.cmd([[norm ,ols]])
+ local stored_links = require('orgmode.org.hyperlinks').stored_links
+ local keys = vim.tbl_keys(stored_links)
+ local values = vim.tbl_values(stored_links)
+ assert.is.True(keys[1]:match('^id:' .. OrgId.uuid_pattern .. '.*$') ~= nil)
+ assert.is.True(vim.fn.getline(5):match('%s+:ID: ' .. OrgId.uuid_pattern .. '$') ~= nil)
+ assert.is.same(values[1], 'headline of target id')
+ end)
+
it('should follow link to headline of given custom_id in given org file (no "file:" prefix)', function()
local target_path = helpers.load_file_content({
'* Test hyperlink',