Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why do we need this change?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'll double check, but I was running into some issue with g/G still being with the resulting tag(s).

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To clarify: without this, tests were failing and the outputted tags when I tested would include g/G.

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