diff --git a/lua/orgmode/capture/init.lua b/lua/orgmode/capture/init.lua index 9f89c837d..326407774 100644 --- a/lua/orgmode/capture/init.lua +++ b/lua/orgmode/capture/init.lua @@ -73,6 +73,7 @@ end ---@param template OrgCaptureTemplate ---@return OrgPromise function Capture:open_template(template) + template.files = self.files local window = CaptureWindow:new({ template = template, on_open = function(capture_window) diff --git a/lua/orgmode/capture/template/init.lua b/lua/orgmode/capture/template/init.lua index 5bf95b3fd..d8f3ec225 100644 --- a/lua/orgmode/capture/template/init.lua +++ b/lua/orgmode/capture/template/init.lua @@ -5,6 +5,56 @@ local Calendar = require('orgmode.objects.calendar') local Promise = require('orgmode.utils.promise') local Input = require('orgmode.ui.input') +---@description For `%^g` expansion in capture templates: gets all tags in the targeted file. +---@param template? table +---@return string[] +local function get_target_tags(template) + local files = template and template.files + if not files or not template or template.target == '' then + return {} + end + + local ok, file = pcall(function() + return files:get(template:get_target()) + end) + + if not ok or not file then + return {} + end + + return file:get_tags() +end + +---@description For `%^G` expansion in capture templates: gets all tags in all agenda files. +---@param template? table +---@return string[] +local function get_all_tags(template) + local files = template and template.files + if not files then + return {} + end + return files:get_tags() +end + +---@param tags_source string[] +---@return OrgPromise +local function prompt_tags(tags_source) + local completion = function(arg_lead) + return utils.prompt_autocomplete(arg_lead, tags_source, { ':' }) + end + return Input.open('Tags: ', '', completion):next(function(input) + if input == nil then + return nil + end + if input == '' then + return '' + end + + local tags = utils.parse_tags_string(input) + return utils.tags_to_string(tags) + end) +end + local expansions = { ['%%f'] = function() return vim.fn.expand('%') @@ -73,6 +123,12 @@ local expansions = { return date and date:to_wrapped_string(false) or nil end) end, + ['%%%^g'] = function(_, template) + return prompt_tags(get_target_tags(template)) + end, + ['%%%^G'] = function(_, template) + return prompt_tags(get_all_tags(template)) + end, ['%%a'] = function() return string.format('[[file:%s::%s]]', utils.current_file_path(), vim.api.nvim_win_get_cursor(0)[1]) end, @@ -90,7 +146,8 @@ local expansions = { ---@field whole_file? boolean ---@class OrgCaptureTemplate:OrgCaptureTemplateOpts ----@field private _compile_hooks (fun(content:string, content_type: 'target' | 'content'):string | nil)[] +---@field files? OrgFiles +---@field private _compile_hooks? (fun(content:string, content_type: 'target' | 'content'):string | nil)[] local Template = {} ---@param opts OrgCaptureTemplateOpts @@ -306,7 +363,7 @@ function Template:_compile_expansions(content) local match = ('%' .. exp):match(expansion) if match then table.insert(compiled_expansions, function() - return Promise.resolve(compiler(match)):next(function(replacement) + return Promise.resolve(compiler(match, self)):next(function(replacement) if not proceed or not replacement then return Promise.reject('canceled') end @@ -357,14 +414,28 @@ end ---@return OrgPromise function Template:_compile_prompts(content) local prepared_inputs = {} - for exp in content:gmatch('%%%^%b{}') do + -- Match %^{...} with optional g/G suffix for tag prompts + for exp in content:gmatch('%%%^%b{}[gG]?') do local details = exp:match('%{(.*)%}') local parts = vim.split(details, '|') local title, default = parts[1], parts[2] + + -- Check if this is a tag prompt (ends with g or G) + local is_tag_prompt = exp:sub(-1, -1) == 'g' or exp:sub(-1, -1) == 'G' + + local original_exp = exp + -- If it's a tag prompt, remove the g/G from the expression for replacement + if is_tag_prompt then + exp = exp:sub(1, -2) -- Remove just the g/G, keep the closing brace + end + local input = { fallback_value = default, exp = exp, + original_exp = original_exp, -- Keep the original (with g/G) for replacement + is_tag_prompt = is_tag_prompt, } + if #parts > 2 then input.prompt = string.format('%s [%s]: ', title, default) input.completion = function() @@ -391,7 +462,21 @@ function Template:_compile_prompts(content) if not response or #response == 0 then response = prepared_input.fallback_value end - content = content:gsub(vim.pesc(prepared_input.exp), response) + + -- Handle tag prompts specially - format with colons + if prepared_input.is_tag_prompt and response and response ~= '' then + response = utils.tags_to_string(utils.parse_tags_string(response)) + end + + -- For tag prompts, we need to search for the original expression (with g/G) + -- but use the response which is already formatted (with :tags:) + -- Don't escape for tag prompts since we need the % to match literally + if prepared_input.is_tag_prompt then + -- Manually escape % for tag prompts (other chars dont need escaping for literal match) + content = content:gsub(prepared_input.original_exp:gsub('%%', '%%%%'), response) + else + content = content:gsub(vim.pesc(prepared_input.original_exp), response) + end end) end, prepared_inputs):next(function() return content diff --git a/lua/orgmode/files/file.lua b/lua/orgmode/files/file.lua index ad5f6bc40..45673158f 100644 --- a/lua/orgmode/files/file.lua +++ b/lua/orgmode/files/file.lua @@ -612,6 +612,30 @@ function OrgFile:get_filetags() return utils.parse_tags_string(self:_get_directive('filetags')) end +memoize('get_tags') +--- Get all unique tags from the file (file-level tags + headline tags) +--- @return string[] +function OrgFile:get_tags() + local tags = {} + local file_tags = self:get_filetags() + if file_tags and #file_tags > 0 then + for _, tag in ipairs(file_tags) do + tags[tag] = 1 + end + end + for _, headline in ipairs(self:get_headlines()) do + local htags = headline:get_tags() + if htags and #htags > 0 then + for _, tag in ipairs(htags) do + tags[tag] = 1 + end + end + end + local taglist = vim.tbl_keys(tags) + table.sort(taglist) + return taglist +end + memoize('get_blocks') --- @return OrgBlock[] function OrgFile:get_blocks() diff --git a/lua/orgmode/files/init.lua b/lua/orgmode/files/init.lua index 93dad134e..ca58cfa72 100644 --- a/lua/orgmode/files/init.lua +++ b/lua/orgmode/files/init.lua @@ -96,19 +96,8 @@ function OrgFiles:get_tags() local tags = {} for _, orgfile in ipairs(self:all()) do if not orgfile:is_archive_file() then - local file_tags = orgfile:get_filetags() - if file_tags and #file_tags > 0 then - for _, tag in ipairs(file_tags) do - tags[tag] = 1 - end - end - for _, headline in ipairs(orgfile:get_headlines()) do - local htags = headline:get_tags() - if htags and #htags > 0 then - for _, tag in ipairs(htags) do - tags[tag] = 1 - end - end + for _, tag in ipairs(orgfile:get_tags()) do + tags[tag] = 1 end end end diff --git a/tests/plenary/capture/templates_spec.lua b/tests/plenary/capture/templates_spec.lua index fbd5a5ecc..498af241c 100644 --- a/tests/plenary/capture/templates_spec.lua +++ b/tests/plenary/capture/templates_spec.lua @@ -1,5 +1,8 @@ local Template = require('orgmode.capture.template') local Date = require('orgmode.objects.date') +local helpers = require('tests.plenary.helpers') +local Input = require('orgmode.ui.input') +local Promise = require('orgmode.utils.promise') describe('Capture template', function() it('should compile expression', function() @@ -87,4 +90,106 @@ describe('Capture template', function() end) assert.is.Nil(template:compile():wait()) end) + + it('should prompt for single tag with %^g', function() + helpers.with_var(Input, 'open', function(_prompt, _default, _completion) + return Promise.resolve('mytag') + end, function() + local template = Template:new({ + template = '* TODO %^g', + }) + assert.are.same({ '* TODO :mytag:' }, template:compile():wait()) + end) + end) + + it('should prompt for multiple tags with %^G', function() + helpers.with_var(Input, 'open', function(_prompt, _default, _completion) + return Promise.resolve('tag1:tag2') + end, function() + local template = Template:new({ + template = '* TODO %^G', + }) + assert.are.same({ '* TODO :tag1:tag2:' }, template:compile():wait()) + end) + end) + + it('should prompt for restricted tags with %^{tag1|tag2}G', function() + helpers.with_var(Input, 'open', function(_prompt, _default, _completion) + return Promise.resolve('tag1') + end, function() + local template = Template:new({ + template = '* TODO %^{tag1|tag2}G', + }) + assert.are.same({ '* TODO :tag1:' }, template:compile():wait()) + end) + end) + + it('should not cancel capture when %^g input is empty', function() + helpers.with_var(Input, 'open', function(_prompt, _default, _completion) + return Promise.resolve('') + end, function() + local template = Template:new({ + template = '* TODO %^g', + }) + assert.are.same({ '* TODO ' }, template:compile():wait()) + end) + end) + it('should complete %^g from target file tags only', function() + local fixtures, org_files = helpers.create_agenda_files({ + { + filename = 'target.org', + content = { + '#+FILETAGS: :target_file:', + '* TODO target item :target_headline:', + }, + }, + { + filename = 'other.org', + content = { + '* TODO other item :other_headline:', + }, + }, + }) + + helpers.with_var(Input, 'open', function(_prompt, _default, completion) + assert.are.same({ 'target_file', 'target_headline' }, completion('')) + return Promise.resolve('target_headline') + end, function() + local template = Template:new({ + template = '* TODO %^g', + target = fixtures['target.org'], + }) + template.files = org_files + assert.are.same({ '* TODO :target_headline:' }, template:compile():wait()) + end) + end) + + it('should complete %^G from all loaded agenda file tags', function() + local _, org_files = helpers.create_agenda_files({ + { + filename = 'target.org', + content = { + '#+FILETAGS: :target_file:', + '* TODO target item :target_headline:', + }, + }, + { + filename = 'other.org', + content = { + '* TODO other item :other_headline:', + }, + }, + }) + + helpers.with_var(Input, 'open', function(_prompt, _default, completion) + assert.are.same({ 'other_headline', 'target_file', 'target_headline' }, completion('')) + return Promise.resolve('target_headline:other_headline') + end, function() + local template = Template:new({ + template = '* TODO %^G', + }) + template.files = org_files + assert.are.same({ '* TODO :target_headline:other_headline:' }, template:compile():wait()) + end) + end) end) diff --git a/tests/plenary/helpers.lua b/tests/plenary/helpers.lua index d9aa8f427..516c15846 100644 --- a/tests/plenary/helpers.lua +++ b/tests/plenary/helpers.lua @@ -1,5 +1,3 @@ -local orgmode = require('orgmode') - local M = {} ---Temporarily change a variable. @@ -28,6 +26,7 @@ end ---@param path string function M.load_file(path) + local orgmode = require('orgmode') vim.cmd.edit(vim.fn.fnameescape(path)) return orgmode.files:get(path) end @@ -47,6 +46,7 @@ end ---@param config? table ---@return OrgFile function M.create_agenda_file(lines, config) + local orgmode = require('orgmode') local fname = vim.fn.tempname() .. '.org' vim.fn.writefile(lines or {}, fname) @@ -60,8 +60,9 @@ end ---@param fixtures {filename: string, content: string[] }[] ---@param config? table ----@return table +---@return table, OrgFiles function M.create_agenda_files(fixtures, config) + local orgmode = require('orgmode') -- NOTE: content is only 1 line for 1 file local temp_fname = vim.fn.tempname() local temp_dir = vim.fn.fnamemodify(temp_fname, ':p:h') @@ -85,7 +86,7 @@ function M.create_agenda_files(fixtures, config) }, config or {}) local org = orgmode.setup(cfg) org:init() - return files + return files, org.files end return M