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',