From 04a96c721e996f2a606f37917947b89c77255c55 Mon Sep 17 00:00:00 2001 From: Alec Stewart Date: Mon, 20 Apr 2026 18:39:28 -0500 Subject: [PATCH 1/5] feat(capture): Make sure tags are written as `:tag:` and not just plain text. --- lua/orgmode/capture/template/init.lua | 89 +++++++++++++++++++ tests/plenary/capture/templates_spec.lua | 104 +++++++++++++++++++++++ 2 files changed, 193 insertions(+) diff --git a/lua/orgmode/capture/template/init.lua b/lua/orgmode/capture/template/init.lua index 5bf95b3fd..f6e9de705 100644 --- a/lua/orgmode/capture/template/init.lua +++ b/lua/orgmode/capture/template/init.lua @@ -5,6 +5,89 @@ local Calendar = require('orgmode.objects.calendar') local Promise = require('orgmode.utils.promise') local Input = require('orgmode.ui.input') +---@description Make sure to deduplicate the tags grabbed by `get_target_tags` and `get_all_tags` +---@param tags string[] +---@return string[] +local function uniq_tags(tags) + local unique = {} + for _, tag in ipairs(tags or {}) do + if tag ~= '' then + unique[tag] = true + end + end + local list = vim.tbl_keys(unique) + table.sort(list) + return list +end + +---@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 org = require('orgmode') + if not org.files or not template or template.target == '' then + return {} + end + + local ok, file = pcall(function() + return org.files:get(template:get_target()) + end) + + if not ok or not file then + return {} + end + + local tags = {} + for _, tag in ipairs(file:get_filetags()) do + table.insert(tags, tag) + end + for _, headline in ipairs(file:get_headlines()) do + local own_tags = headline:get_own_tags() + for _, tag in ipairs(own_tags) do + table.insert(tags, tag) + end + end + + return uniq_tags(tags) +end + +---@description For `%^G` expansion in capture templates: gets all tags in all agenda files. +---@return string[] +local function get_all_tags() + local org = require('orgmode') + if not org.files then + return {} + end + return org.files:get_tags() +end + +---@param single boolean +---@param tags_source string[] +---@return OrgPromise +local function prompt_tags(single, tags_source) + local completion = function(arg_lead) + return vim.tbl_filter(function(tag) + return tag:match('^' .. vim.pesc(arg_lead)) + end, tags_source) + end + local prompt = single and 'Tag: ' or 'Tags: ' + return Input.open(prompt, '', completion):next(function(input) + if input == nil then + return nil + end + if input == '' then + return '' + end + + local tags = utils.parse_tags_string(input) + if single then + return tags[1] and utils.tags_to_string({ tags[1] }) or '' + end + + return utils.tags_to_string(tags) + end) +end + local expansions = { ['%%f'] = function() return vim.fn.expand('%') @@ -73,6 +156,12 @@ local expansions = { return date and date:to_wrapped_string(false) or nil end) end, + ['%%%^g'] = function(_, template) + return prompt_tags(true, get_target_tags(template)) + end, + ['%%%^G'] = function() + return prompt_tags(false, get_all_tags()) + end, ['%%a'] = function() return string.format('[[file:%s::%s]]', utils.current_file_path(), vim.api.nvim_win_get_cursor(0)[1]) end, diff --git a/tests/plenary/capture/templates_spec.lua b/tests/plenary/capture/templates_spec.lua index fbd5a5ecc..e7dcf312b 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,105 @@ 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 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 = files['target.org'], + }) + assert.are.same({ '* TODO :target_headline:' }, template:compile():wait()) + end) + end) + + it('should complete %^G from all loaded agenda file tags', function() + 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', + }) + assert.are.same({ '* TODO :target_headline:other_headline:' }, template:compile():wait()) + end) + end) end) From 5720d704b26b8eabd533869513ebfd4aee5a77d9 Mon Sep 17 00:00:00 2001 From: Alec Stewart Date: Thu, 30 Apr 2026 12:40:07 -0500 Subject: [PATCH 2/5] fix(capture): fix checking for tag prompts and replacing g/G in result. Maybe need to remove comments. --- lua/orgmode/capture/template/init.lua | 43 ++++++++++++++++++++++++--- 1 file changed, 39 insertions(+), 4 deletions(-) diff --git a/lua/orgmode/capture/template/init.lua b/lua/orgmode/capture/template/init.lua index f6e9de705..e8eaeb475 100644 --- a/lua/orgmode/capture/template/init.lua +++ b/lua/orgmode/capture/template/init.lua @@ -179,7 +179,7 @@ local expansions = { ---@field whole_file? boolean ---@class OrgCaptureTemplate:OrgCaptureTemplateOpts ----@field private _compile_hooks (fun(content:string, content_type: 'target' | 'content'):string | nil)[] +---@field private _compile_hooks? (fun(content:string, content_type: 'target' | 'content'):string | nil)[] local Template = {} ---@param opts OrgCaptureTemplateOpts @@ -395,7 +395,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 @@ -446,14 +446,30 @@ 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 is_single_tag = is_tag_prompt and 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, + is_single_tag = is_single_tag, } + if #parts > 2 then input.prompt = string.format('%s [%s]: ', title, default) input.completion = function() @@ -480,7 +496,26 @@ 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 + local tags = utils.parse_tags_string(response) + if prepared_input.is_single_tag then + response = tags[1] and utils.tags_to_string({ tags[1] }) or '' + else + response = utils.tags_to_string(tags) + end + 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 From dc324711ab80ef6cb081a6ce96e1600a1091aaca Mon Sep 17 00:00:00 2001 From: Alec Stewart Date: Sat, 2 May 2026 10:09:12 -0500 Subject: [PATCH 3/5] refactor(capture): don't require("org") to get files. --- lua/orgmode/capture/init.lua | 1 + lua/orgmode/capture/template/init.lua | 20 +++++++++++--------- 2 files changed, 12 insertions(+), 9 deletions(-) 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 e8eaeb475..fa758b9d9 100644 --- a/lua/orgmode/capture/template/init.lua +++ b/lua/orgmode/capture/template/init.lua @@ -24,13 +24,13 @@ end ---@param template? table ---@return string[] local function get_target_tags(template) - local org = require('orgmode') - if not org.files or not template or template.target == '' then + local files = template and template.files + if not files or not template or template.target == '' then return {} end local ok, file = pcall(function() - return org.files:get(template:get_target()) + return files:get(template:get_target()) end) if not ok or not file then @@ -52,13 +52,14 @@ local function get_target_tags(template) 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() - local org = require('orgmode') - if not org.files then +local function get_all_tags(template) + local files = template and template.files + if not files then return {} end - return org.files:get_tags() + return files:get_tags() end ---@param single boolean @@ -159,8 +160,8 @@ local expansions = { ['%%%^g'] = function(_, template) return prompt_tags(true, get_target_tags(template)) end, - ['%%%^G'] = function() - return prompt_tags(false, get_all_tags()) + ['%%%^G'] = function(_, template) + return prompt_tags(false, 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]) @@ -179,6 +180,7 @@ local expansions = { ---@field whole_file? boolean ---@class OrgCaptureTemplate:OrgCaptureTemplateOpts +---@field files? OrgFiles ---@field private _compile_hooks? (fun(content:string, content_type: 'target' | 'content'):string | nil)[] local Template = {} From f5bd53672f5d800ce012051acb9d0be090fa5c7d Mon Sep 17 00:00:00 2001 From: Alec Stewart Date: Sat, 2 May 2026 10:30:47 -0500 Subject: [PATCH 4/5] fix(capture): update tests. --- tests/plenary/capture/templates_spec.lua | 9 +++++---- tests/plenary/helpers.lua | 4 ++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/tests/plenary/capture/templates_spec.lua b/tests/plenary/capture/templates_spec.lua index e7dcf312b..498af241c 100644 --- a/tests/plenary/capture/templates_spec.lua +++ b/tests/plenary/capture/templates_spec.lua @@ -134,9 +134,8 @@ describe('Capture template', function() assert.are.same({ '* TODO ' }, template:compile():wait()) end) end) - it('should complete %^g from target file tags only', function() - local files = helpers.create_agenda_files({ + local fixtures, org_files = helpers.create_agenda_files({ { filename = 'target.org', content = { @@ -158,14 +157,15 @@ describe('Capture template', function() end, function() local template = Template:new({ template = '* TODO %^g', - target = files['target.org'], + 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() - helpers.create_agenda_files({ + local _, org_files = helpers.create_agenda_files({ { filename = 'target.org', content = { @@ -188,6 +188,7 @@ describe('Capture template', 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) diff --git a/tests/plenary/helpers.lua b/tests/plenary/helpers.lua index d9aa8f427..ee163c04f 100644 --- a/tests/plenary/helpers.lua +++ b/tests/plenary/helpers.lua @@ -60,7 +60,7 @@ end ---@param fixtures {filename: string, content: string[] }[] ---@param config? table ----@return table +---@return table, OrgFiles function M.create_agenda_files(fixtures, config) -- NOTE: content is only 1 line for 1 file local temp_fname = vim.fn.tempname() @@ -85,7 +85,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 From d7eac0e7b865eb1ae9a6b952955396654fcdafe8 Mon Sep 17 00:00:00 2001 From: Alec Stewart Date: Mon, 4 May 2026 10:32:05 -0500 Subject: [PATCH 5/5] refactor(capture): fixes from code review: refactor getting tags, use existing autocomplete functionality, and match what Emacs does better. --- lua/orgmode/capture/template/init.lua | 55 ++++----------------------- lua/orgmode/files/file.lua | 24 ++++++++++++ lua/orgmode/files/init.lua | 15 +------- tests/plenary/helpers.lua | 5 ++- 4 files changed, 36 insertions(+), 63 deletions(-) diff --git a/lua/orgmode/capture/template/init.lua b/lua/orgmode/capture/template/init.lua index fa758b9d9..d8f3ec225 100644 --- a/lua/orgmode/capture/template/init.lua +++ b/lua/orgmode/capture/template/init.lua @@ -5,21 +5,6 @@ local Calendar = require('orgmode.objects.calendar') local Promise = require('orgmode.utils.promise') local Input = require('orgmode.ui.input') ----@description Make sure to deduplicate the tags grabbed by `get_target_tags` and `get_all_tags` ----@param tags string[] ----@return string[] -local function uniq_tags(tags) - local unique = {} - for _, tag in ipairs(tags or {}) do - if tag ~= '' then - unique[tag] = true - end - end - local list = vim.tbl_keys(unique) - table.sort(list) - return list -end - ---@description For `%^g` expansion in capture templates: gets all tags in the targeted file. ---@param template? table ---@return string[] @@ -37,18 +22,7 @@ local function get_target_tags(template) return {} end - local tags = {} - for _, tag in ipairs(file:get_filetags()) do - table.insert(tags, tag) - end - for _, headline in ipairs(file:get_headlines()) do - local own_tags = headline:get_own_tags() - for _, tag in ipairs(own_tags) do - table.insert(tags, tag) - end - end - - return uniq_tags(tags) + return file:get_tags() end ---@description For `%^G` expansion in capture templates: gets all tags in all agenda files. @@ -62,17 +36,13 @@ local function get_all_tags(template) return files:get_tags() end ----@param single boolean ---@param tags_source string[] ---@return OrgPromise -local function prompt_tags(single, tags_source) +local function prompt_tags(tags_source) local completion = function(arg_lead) - return vim.tbl_filter(function(tag) - return tag:match('^' .. vim.pesc(arg_lead)) - end, tags_source) + return utils.prompt_autocomplete(arg_lead, tags_source, { ':' }) end - local prompt = single and 'Tag: ' or 'Tags: ' - return Input.open(prompt, '', completion):next(function(input) + return Input.open('Tags: ', '', completion):next(function(input) if input == nil then return nil end @@ -81,10 +51,6 @@ local function prompt_tags(single, tags_source) end local tags = utils.parse_tags_string(input) - if single then - return tags[1] and utils.tags_to_string({ tags[1] }) or '' - end - return utils.tags_to_string(tags) end) end @@ -158,10 +124,10 @@ local expansions = { end) end, ['%%%^g'] = function(_, template) - return prompt_tags(true, get_target_tags(template)) + return prompt_tags(get_target_tags(template)) end, ['%%%^G'] = function(_, template) - return prompt_tags(false, get_all_tags(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]) @@ -456,7 +422,6 @@ function Template:_compile_prompts(content) -- 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 is_single_tag = is_tag_prompt and exp:sub(-1, -1) == 'g' local original_exp = exp -- If it's a tag prompt, remove the g/G from the expression for replacement @@ -469,7 +434,6 @@ function Template:_compile_prompts(content) exp = exp, original_exp = original_exp, -- Keep the original (with g/G) for replacement is_tag_prompt = is_tag_prompt, - is_single_tag = is_single_tag, } if #parts > 2 then @@ -501,12 +465,7 @@ function Template:_compile_prompts(content) -- Handle tag prompts specially - format with colons if prepared_input.is_tag_prompt and response and response ~= '' then - local tags = utils.parse_tags_string(response) - if prepared_input.is_single_tag then - response = tags[1] and utils.tags_to_string({ tags[1] }) or '' - else - response = utils.tags_to_string(tags) - end + 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) diff --git a/lua/orgmode/files/file.lua b/lua/orgmode/files/file.lua index 3829ce21b..95f5481c0 100644 --- a/lua/orgmode/files/file.lua +++ b/lua/orgmode/files/file.lua @@ -607,6 +607,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/helpers.lua b/tests/plenary/helpers.lua index ee163c04f..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) @@ -62,6 +62,7 @@ end ---@param config? 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')