diff --git a/docs/configuration.org b/docs/configuration.org index 5f1aa7bb6..4dae1b877 100644 --- a/docs/configuration.org +++ b/docs/configuration.org @@ -531,6 +531,22 @@ Prefix added to the generated id when [[#org_id_method][org_id_method]] is set t 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]] +*** org_use_property_inheritance +:PROPERTIES: +:CUSTOM_ID: org_use_property_inheritance +:END: +- Type: =boolean | string | string[]= +- Default: =false= +Determine whether properties of one headline are inherited by sub-headlines. + +- =false= - properties only pertain to the file or headline that defines them +- =true= - properties of a headlines also pertain to all its sub-headlines +- =string[]= - only the properties named in the given list are inherited +- =string= - only properties matching the given regex are inherited + +Note that for a select few properties, the inheritance behavior is hard-coded withing their special applications. +See [[https://orgmode.org/manual/Property-Inheritance.html][Property Inheritance]] for details. + *** org_babel_default_header_args :PROPERTIES: :CUSTOM_ID: org_babel_default_header_args diff --git a/lua/orgmode/api/headline.lua b/lua/orgmode/api/headline.lua index 5b4d83daf..9c3e646bf 100644 --- a/lua/orgmode/api/headline.lua +++ b/lua/orgmode/api/headline.lua @@ -64,7 +64,7 @@ end ---@private function OrgHeadline._build_from_internal_headline(section, index) local todo, _, type = section:get_todo() - local properties = section:get_properties() + local properties = section:get_own_properties() return OrgHeadline:_new({ title = section:get_title(), line = section:get_headline_line_content(), diff --git a/lua/orgmode/clock/init.lua b/lua/orgmode/clock/init.lua index 339345d96..f6802c689 100644 --- a/lua/orgmode/clock/init.lua +++ b/lua/orgmode/clock/init.lua @@ -118,7 +118,7 @@ function Clock:get_statusline() return '' end - local effort = self.clocked_headline:get_property('effort') + local effort = self.clocked_headline:get_property('effort', false) local total = self.clocked_headline:get_logbook():get_total_with_active():to_string() if effort then return string.format('(Org) [%s/%s] (%s)', total, effort or '', self.clocked_headline:get_title()) diff --git a/lua/orgmode/config/_meta.lua b/lua/orgmode/config/_meta.lua index b8ea0e415..61276506c 100644 --- a/lua/orgmode/config/_meta.lua +++ b/lua/orgmode/config/_meta.lua @@ -235,6 +235,7 @@ ---@field org_id_method? 'uuid' | 'ts' | 'org' What method to use to generate ids via org.id module. Default: 'uuid' ---@field org_id_prefix? string | nil Prefix to apply to id when `org_id_method = 'org'`. Default: nil ---@field org_id_link_to_org_use_id? boolean If true, Storing a link to the headline will automatically generate ID for that headline. Default: false +---@field org_use_property_inheritance boolean | string | string[] If true, properties are inherited by sub-headlines; may also be a regex or list of property names. Default: false ---@field org_babel_default_header_args? table Default header args for org-babel blocks: Default: { [':tangle'] = 'no', [':noweb'] = 'no' } ---@field win_split_mode? 'horizontal' | 'vertical' | 'auto' | 'float' | string[] How to open agenda and capture windows. Default: 'horizontal' ---@field win_border? 'none' | 'single' | 'double' | 'rounded' | 'solid' | 'shadow' | string[] Border configuration for `win_split_mode = 'float'`. Default: 'single' diff --git a/lua/orgmode/config/defaults.lua b/lua/orgmode/config/defaults.lua index f3e9d6fb3..921533e8c 100644 --- a/lua/orgmode/config/defaults.lua +++ b/lua/orgmode/config/defaults.lua @@ -66,6 +66,7 @@ local DefaultConfig = { org_id_method = 'uuid', org_id_prefix = nil, org_id_link_to_org_use_id = false, + org_use_property_inheritance = false, org_babel_default_header_args = { [':tangle'] = 'no', [':noweb'] = 'no', diff --git a/lua/orgmode/config/init.lua b/lua/orgmode/config/init.lua index 0b89f884a..62db0296e 100644 --- a/lua/orgmode/config/init.lua +++ b/lua/orgmode/config/init.lua @@ -539,6 +539,25 @@ function Config:parse_header_args(args) return results end +---@param property_name string +---@return boolean uses_inheritance +function Config:use_property_inheritance(property_name) + property_name = string.lower(property_name) + + local use_inheritance = self.opts.org_use_property_inheritance or false + + if type(use_inheritance) == 'table' then + return vim.tbl_contains(use_inheritance, function(value) + return vim.stricmp(value, property_name) == 0 + end, { predicate = true }) + elseif type(use_inheritance) == 'string' then + local regex = vim.regex(use_inheritance) + return regex:match_str(property_name) and true or false + else + return use_inheritance and true or false + end +end + ---@type OrgConfig instance = Config:new() return instance diff --git a/lua/orgmode/files/file.lua b/lua/orgmode/files/file.lua index 17255398d..743092b07 100644 --- a/lua/orgmode/files/file.lua +++ b/lua/orgmode/files/file.lua @@ -278,7 +278,7 @@ function OrgFile:apply_search(search, todo_only) local deadline = item:get_deadline_date() local scheduled = item:get_scheduled_date() local closed = item:get_closed_date() - local properties = item:get_properties() + local properties = item:get_own_properties() local priority = item:get_priority() return search:check({ diff --git a/lua/orgmode/files/headline.lua b/lua/orgmode/files/headline.lua index 1a3fe6ce3..d54987249 100644 --- a/lua/orgmode/files/headline.lua +++ b/lua/orgmode/files/headline.lua @@ -390,9 +390,9 @@ function Headline:get_title_with_priority() return title end -memoize('get_properties') +memoize('get_own_properties') ---@return table, TSNode | nil -function Headline:get_properties() +function Headline:get_own_properties() local section = self:node():parent() local properties_node = section and section:field('property_drawer')[1] @@ -416,34 +416,60 @@ function Headline:get_properties() return properties, properties_node end +memoize('get_properties') +---@return table, TSNode | nil +function Headline:get_properties() + local properties, own_properties_node = self:get_own_properties() + + if not config.org_use_property_inheritance then + return properties, own_properties_node + end + + local parent_section = self:node():parent():parent() + while parent_section do + local headline_node = parent_section:field('headline')[1] + if headline_node then + local headline = Headline:new(headline_node, self.file) + for name, value in pairs(headline:get_own_properties()) do + if properties[name] == nil and config:use_property_inheritance(name) then + properties[name] = value + end + end + end + parent_section = parent_section:parent() + end + + return properties, own_properties_node +end + ---@param name string ---@param value? string ---@return OrgHeadline function Headline:set_property(name, value) local bufnr = self.file:get_valid_bufnr() if not value then - local existing_property, property_node = self:get_property(name) + local existing_property, property_node = self:get_property(name, false) if existing_property and property_node then vim.fn.deletebufline(bufnr, property_node:start() + 1) end self:refresh() - local properties, properties_node = self:get_properties() + local properties, properties_node = self:get_own_properties() if vim.tbl_isempty(properties) then self:_set_node_lines(properties_node, {}) end return self:refresh() end - local _, properties = self:get_properties() + local _, properties = self:get_own_properties() if not properties then local append_line = self:get_append_line() local property_drawer = self:_apply_indent({ ':PROPERTIES:', ':END:' }) --[[ @as string[] ]] vim.api.nvim_buf_set_lines(bufnr, append_line, append_line, false, property_drawer) - _, properties = self:refresh():get_properties() + _, properties = self:refresh():get_own_properties() end local property = (':%s: %s'):format(name, value) - local existing_property, property_node = self:get_property(name) + local existing_property, property_node = self:get_property(name, false) if existing_property then return self:_set_node_text(property_node, property) end @@ -472,10 +498,13 @@ function Headline:add_note(note) end ---@param property_name string ----@param search_parents? boolean +---@param search_parents? boolean if true, search parent headlines; +--- if false, only search this headline; +--- if nil (default), check +--- `org_use_property_inheritance` ---@return string | nil, TSNode | nil function Headline:get_property(property_name, search_parents) - local _, properties = self:get_properties() + local _, properties = self:get_own_properties() if properties then for _, node in ipairs(ts_utils.get_named_children(properties)) do local name = node:field('name')[1] @@ -486,6 +515,10 @@ function Headline:get_property(property_name, search_parents) end end + if search_parents == nil then + search_parents = config:use_property_inheritance(property_name) + end + if not search_parents then return nil, nil end @@ -495,7 +528,7 @@ function Headline:get_property(property_name, search_parents) local headline_node = parent_section:field('headline')[1] if headline_node then local headline = Headline:new(headline_node, self.file) - local property, property_node = headline:get_property(property_name) + local property, property_node = headline:get_property(property_name, false) if property then return property, property_node end @@ -543,7 +576,7 @@ memoize('get_tags') function Headline:get_tags() local tags, own_tags_node = self:get_own_tags() if not config.org_use_tag_inheritance then - return config:exclude_tags(tags), own_tags_node + return tags, own_tags_node end local parent_tags = {} @@ -629,7 +662,7 @@ end ---@return number function Headline:get_append_line() - local _, properties = self:get_properties() + local _, properties = self:get_own_properties() if properties then local row = properties:end_() return row @@ -918,7 +951,7 @@ function Headline:is_same(other_headline) end function Headline:id_get_or_create() - local id_prop = self:get_property('ID') + local id_prop = self:get_property('ID', false) if id_prop then return vim.trim(id_prop) end diff --git a/lua/orgmode/org/hyperlinks/init.lua b/lua/orgmode/org/hyperlinks/init.lua index de509b245..3783ab8dd 100644 --- a/lua/orgmode/org/hyperlinks/init.lua +++ b/lua/orgmode/org/hyperlinks/init.lua @@ -57,7 +57,7 @@ function Hyperlinks.as_custom_id_anchors(url) return function(headlines) return vim.tbl_map(function(headline) ---@cast headline OrgHeadline - local custom_id = headline:get_property('custom_id') + local custom_id = headline:get_property('custom_id', false) return ('%s#%s'):format(prefix, custom_id) end, headlines) end diff --git a/lua/orgmode/org/links/types/custom_id.lua b/lua/orgmode/org/links/types/custom_id.lua index ba9f74ecc..45123c13a 100644 --- a/lua/orgmode/org/links/types/custom_id.lua +++ b/lua/orgmode/org/links/types/custom_id.lua @@ -60,7 +60,7 @@ function OrgLinkCustomId:autocomplete(link) local prefix = opts.type == 'internal' and '' or opts.link_url:get_path_with_protocol() .. '::' return vim.tbl_map(function(headline) - local custom_id = headline:get_property('custom_id') + local custom_id = headline:get_property('custom_id', false) return prefix .. '#' .. custom_id end, headlines) end diff --git a/lua/orgmode/org/links/types/id.lua b/lua/orgmode/org/links/types/id.lua index 0ff8740e7..19b2ae94b 100644 --- a/lua/orgmode/org/links/types/id.lua +++ b/lua/orgmode/org/links/types/id.lua @@ -56,7 +56,7 @@ end ---@private ---@param link string ----@return string +---@return string? function OrgLinkId:_parse(link) return link:match('^id:(.+)$') end diff --git a/lua/orgmode/ui/menu.lua b/lua/orgmode/ui/menu.lua index f5744c861..71e3ebbec 100644 --- a/lua/orgmode/ui/menu.lua +++ b/lua/orgmode/ui/menu.lua @@ -23,6 +23,7 @@ local config = require('orgmode.config') local Menu = {} ---@param data OrgMenuOpts +---@return OrgMenu function Menu:new(data) self:_validate_data(data) diff --git a/lua/orgmode/utils/init.lua b/lua/orgmode/utils/init.lua index e7174a7c0..69ae69b6f 100644 --- a/lua/orgmode/utils/init.lua +++ b/lua/orgmode/utils/init.lua @@ -53,6 +53,9 @@ function utils.readfile(file, opts) end) end +---@param file string +---@param data string|string[] +---@return OrgPromise bytes function utils.writefile(file, data) return Promise.new(function(resolve, reject) uv.fs_open(file, 'w', 438, function(err1, fd) @@ -502,6 +505,7 @@ function utils.is_list(value) if vim.islist then return vim.islist(value) end + ---@diagnostic disable-next-line: deprecated return vim.tbl_islist(value) end diff --git a/tests/plenary/files/headline_spec.lua b/tests/plenary/files/headline_spec.lua index f0bd1ab6a..47adf874a 100644 --- a/tests/plenary/files/headline_spec.lua +++ b/tests/plenary/files/headline_spec.lua @@ -1,4 +1,5 @@ local helpers = require('tests.plenary.helpers') +local config = require('orgmode.config') describe('Headline', function() describe('get_category', function() @@ -54,6 +55,73 @@ describe('Headline', function() end) end) + describe('use_property_inheritance', function() + local file = helpers.create_file_instance({ + '#+CATEGORY: file_category', + '* Headline 1', + ':PROPERTIES:', + ':DIR: some/dir/', + ':THING: 0', + ':COLUMNS:', + ':END:', + '** Headline 2', + ' some body text', + }, 'category.org') + after_each(function() + config:extend({ org_use_property_inheritance = false }) + end) + it('is false by default', function() + assert.is.Nil(file:get_headlines()[2]:get_property('dir')) + end) + it('is active if true', function() + config:extend({ org_use_property_inheritance = true }) + assert.are.same('some/dir/', file:get_headlines()[2]:get_property('dir')) + assert.are.same('0', file:get_headlines()[2]:get_property('thing')) + end) + it('is selective if a list', function() + config:extend({ org_use_property_inheritance = { 'dir' } }) + assert.are.same('some/dir/', file:get_headlines()[2]:get_property('dir')) + assert.is.Nil(file:get_headlines()[2]:get_property('thing')) + end) + it('is selective if a regex', function() + config:extend({ org_use_property_inheritance = '^di.$' }) + assert.are.same('some/dir/', file:get_headlines()[2]:get_property('dir')) + assert.is.Nil(file:get_headlines()[2]:get_property('thing')) + end) + it('can be overridden with true', function() + assert.is.Nil(file:get_headlines()[2]:get_property('dir')) + assert.are.same('some/dir/', file:get_headlines()[2]:get_property('dir', true)) + end) + it('can be overridden with false', function() + config:extend({ org_use_property_inheritance = true }) + assert.are.same('some/dir/', file:get_headlines()[2]:get_property('dir')) + assert.is.Nil(file:get_headlines()[2]:get_property('dir', false)) + end) + it('does not affect get_own_properties', function() + config:extend({ org_use_property_inheritance = true }) + file.metadata.mtime = file.metadata.mtime + 1 -- invalidate cache + assert.are.same({}, file:get_headlines()[2]:get_own_properties()) + end) + it('affects get_properties', function() + config:extend({ org_use_property_inheritance = true }) + file.metadata.mtime = file.metadata.mtime + 1 -- invalidate cache + local expected = { dir = 'some/dir/', thing = '0', columns = '' } + assert.are.same(expected, file:get_headlines()[2]:get_properties()) + end) + it('makes get_properties selective if a list', function() + config:extend({ org_use_property_inheritance = { 'dir' } }) + file.metadata.mtime = file.metadata.mtime + 1 -- invalidate cache + local expected = { dir = 'some/dir/' } + assert.are.same(expected, file:get_headlines()[2]:get_properties()) + end) + it('makes get_properties selective if a regex', function() + config:extend({ org_use_property_inheritance = '^th...$' }) + file.metadata.mtime = file.metadata.mtime + 1 -- invalidate cache + local expected = { thing = '0' } + assert.are.same(expected, file:get_headlines()[2]:get_properties()) + end) + end) + describe('get_all_dates', function() it('should properly parse dates from the headline and body', function() local file = helpers.create_file({