Skip to content
1 change: 1 addition & 0 deletions lua/orgmode/capture/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ end
---@param template OrgCaptureTemplate
---@return OrgPromise<OrgCaptureWindow>
function Capture:open_template(template)
template.files = self.files
local window = CaptureWindow:new({
template = template,
on_open = function(capture_window)
Expand Down
93 changes: 89 additions & 4 deletions lua/orgmode/capture/template/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>
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('%')
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -357,14 +414,28 @@ end
---@return OrgPromise<string>
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
Comment thread
alecStewart1 marked this conversation as resolved.
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()
Expand All @@ -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
Expand Down
24 changes: 24 additions & 0 deletions lua/orgmode/files/file.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
15 changes: 2 additions & 13 deletions lua/orgmode/files/init.lua
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions tests/plenary/capture/templates_spec.lua
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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)
9 changes: 5 additions & 4 deletions tests/plenary/helpers.lua
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
local orgmode = require('orgmode')

local M = {}

---Temporarily change a variable.
Expand Down Expand Up @@ -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
Expand All @@ -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)

Expand All @@ -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')
Expand All @@ -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
Comment thread
alecStewart1 marked this conversation as resolved.
end

return M
Loading