diff --git a/lua/orgmode/files/file.lua b/lua/orgmode/files/file.lua index 5bb0c9b66..00dc6fdb4 100644 --- a/lua/orgmode/files/file.lua +++ b/lua/orgmode/files/file.lua @@ -271,18 +271,28 @@ end memoize('get_todo_keywords') function OrgFile:get_todo_keywords() - local todo_directive = self:_get_directive('todo') - if not todo_directive then + local todo_directives = self:_get_directive('todo', true) + + -- Fall back to config if no TODO directives were found + if not todo_directives then return config:get_todo_keywords() end - local keywords = vim.split(vim.trim(todo_directive), '%s+') - local todo_keywords = require('orgmode.objects.todo_keywords'):new({ - org_todo_keywords = keywords, + -- Only one TODO directive defined in file + if type(todo_directives) ~= 'table' then + todo_directives = { todo_directives } + end + + local keywords_data = {} + for _, directive in ipairs(todo_directives) do + local keywords = vim.split(vim.trim(directive), '%s+') + table.insert(keywords_data, keywords) + end + + return require('orgmode.objects.todo_keywords'):new({ + org_todo_keywords = keywords_data, org_todo_keyword_faces = config.org_todo_keyword_faces, }) - - return todo_keywords end ---@return OrgHeadline[] @@ -843,7 +853,7 @@ end memoize('get_directive') ---@param directive_name string ----@return string | nil +---@return string[] | string | nil function OrgFile:get_directive(directive_name) return self:_get_directive(directive_name) end @@ -861,8 +871,10 @@ function OrgFile:id_get_or_create() end ---@private ----@return string | nil -function OrgFile:_get_directive(directive_name) +---@param directive_name string +---@param all_matches? boolean If true, returns an array of all matching directive values +---@return string[] | string | nil +function OrgFile:_get_directive(directive_name, all_matches) self:parse(true) local directives_body = self.root:field('body')[1] if not directives_body then @@ -873,14 +885,30 @@ function OrgFile:_get_directive(directive_name) return nil end - for _, directive in ipairs(directives) do - local name = directive:field('name')[1] - local value = directive:field('value')[1] + if all_matches then + local results = {} + for _, directive in ipairs(directives) do + local name = directive:field('name')[1] + local value = directive:field('value')[1] - if name and value then - local name_text = self:get_node_text(name) - if name_text:lower() == directive_name:lower() then - return self:get_node_text(value) + if name and value then + local name_text = self:get_node_text(name) + if name_text:lower() == directive_name:lower() then + table.insert(results, self:get_node_text(value)) + end + end + end + return #results > 0 and results or nil + else + for _, directive in ipairs(directives) do + local name = directive:field('name')[1] + local value = directive:field('value')[1] + + if name and value then + local name_text = self:get_node_text(name) + if name_text:lower() == directive_name:lower() then + return self:get_node_text(value) + end end end end diff --git a/lua/orgmode/files/headline.lua b/lua/orgmode/files/headline.lua index 79ab092d7..f494b1483 100644 --- a/lua/orgmode/files/headline.lua +++ b/lua/orgmode/files/headline.lua @@ -1200,4 +1200,18 @@ function Headline:_handle_promote_demote(recursive, modifier, dryRun) return self:refresh() end +---@param drawer_name string +---@param content string +---@return OrgHeadline +function Headline:add_to_drawer(drawer_name, content) + local append_line = self:get_drawer_append_line(drawer_name) + local bufnr = self.file:get_valid_bufnr() + + -- Add the content indented appropriately + local indented_content = self:_apply_indent(content) --[[ @as string ]] + vim.api.nvim_buf_set_lines(bufnr, append_line, append_line, false, { indented_content }) + + return self:refresh() +end + return Headline diff --git a/lua/orgmode/objects/todo_keywords/init.lua b/lua/orgmode/objects/todo_keywords/init.lua index bb6a85bfd..c3d713f09 100644 --- a/lua/orgmode/objects/todo_keywords/init.lua +++ b/lua/orgmode/objects/todo_keywords/init.lua @@ -2,18 +2,20 @@ local utils = require('orgmode.utils') local TodoKeyword = require('orgmode.objects.todo_keywords.todo_keyword') ---@class OrgTodoKeywords ----@field org_todo_keywords string[] +---@field org_todo_keywords string[][]|string[] ---@field org_todo_keyword_faces table ---@field todo_keywords OrgTodoKeyword[] +---@field sequences OrgTodoKeyword[][] Array of todo keyword sequences local TodoKeywords = {} TodoKeywords.__index = TodoKeywords ----@param opts { org_todo_keywords: string[], org_todo_keyword_faces: table } +---@param opts { org_todo_keywords: string[][]|string[], org_todo_keyword_faces: table } ---@return OrgTodoKeywords function TodoKeywords:new(opts) local this = setmetatable({ org_todo_keywords = opts.org_todo_keywords, org_todo_keyword_faces = opts.org_todo_keyword_faces, + sequences = {}, }, self) this:_parse() return this @@ -44,6 +46,19 @@ function TodoKeywords:find(keyword) end) end +---@param keyword string +---@return number | nil sequence index this keyword belongs to +function TodoKeywords:find_sequence_index(keyword) + for seq_idx, seq in ipairs(self.sequences) do + for _, todo_keyword in ipairs(seq) do + if todo_keyword.value == keyword then + return seq_idx + end + end + end + return nil +end + ---@param type OrgTodoKeywordType ---@return OrgTodoKeyword function TodoKeywords:first_by_type(type) @@ -60,6 +75,12 @@ function TodoKeywords:all() return self.todo_keywords end +---@param sequence_idx? number +---@return OrgTodoKeyword[] +function TodoKeywords:sequence(sequence_idx) + return self.sequences[sequence_idx or 1] or {} +end + ---@return OrgTodoKeyword function TodoKeywords:first() return self.todo_keywords[1] @@ -79,29 +100,105 @@ end ---@private function TodoKeywords:_parse() - local todo, done = self:_split_todo_and_done() + local first_sequence = self.org_todo_keywords[1] + + if type(first_sequence) ~= 'table' then + self:_parse_single_sequence(self.org_todo_keywords) + return + end + + if #self.org_todo_keywords == 1 then + self:_parse_single_sequence(first_sequence) + return + end + + self:_parse_multiple_sequences(self.org_todo_keywords) +end + +---@private +---@param keyword string +---@param status_type string 'TODO' or 'DONE' +---@param index number +---@param seq_idx number +---@param used_shortcuts table +---@return OrgTodoKeyword +function TodoKeywords:_create_keyword(keyword, status_type, index, seq_idx, used_shortcuts) + local todo_keyword = TodoKeyword:new({ + type = status_type, + keyword = keyword, + index = index, + sequence_index = seq_idx, + }) + + -- Track used shortcuts to avoid conflicts + if todo_keyword.has_fast_access then + used_shortcuts[todo_keyword.shortcut] = true + elseif not used_shortcuts[todo_keyword.shortcut] then + -- Mark it as a fast access key when we have multiple sequences + if type(self.org_todo_keywords[1]) == 'table' and #self.org_todo_keywords > 1 then + todo_keyword.has_fast_access = true + used_shortcuts[todo_keyword.shortcut] = true + end + end + + todo_keyword.hl = self:_get_hl(todo_keyword.value, status_type) + return todo_keyword +end + +---@private +---@param keywords string[] +---@param seq_idx number +---@param used_shortcuts table +---@param keyword_offset number +---@return OrgTodoKeyword[] keywords for the sequence +---@return OrgTodoKeyword[] seq_keywords keywords in this sequence +function TodoKeywords:_parse_sequence(keywords, seq_idx, used_shortcuts, keyword_offset) + keyword_offset = keyword_offset or 0 + local todo, done = self:_split_todo_and_done(keywords) local list = {} + local seq_keywords = {} + + -- Process TODO keywords for i, keyword in ipairs(todo) do - local todo_keyword = TodoKeyword:new({ - type = 'TODO', - keyword = keyword, - index = i, - }) - todo_keyword.hl = self:_get_hl(todo_keyword.value, 'TODO') + local todo_keyword = self:_create_keyword(keyword, 'TODO', keyword_offset + i, seq_idx, used_shortcuts) table.insert(list, todo_keyword) + table.insert(seq_keywords, todo_keyword) end + -- Process DONE keywords for i, keyword in ipairs(done) do - local todo_keyword = TodoKeyword:new({ - type = 'DONE', - keyword = keyword, - index = #todo + i, - }) - todo_keyword.hl = self:_get_hl(todo_keyword.value, 'DONE') + local todo_keyword = self:_create_keyword(keyword, 'DONE', keyword_offset + #todo + i, seq_idx, used_shortcuts) table.insert(list, todo_keyword) + table.insert(seq_keywords, todo_keyword) end - self.todo_keywords = list + return list, seq_keywords +end + +---@param todo_keywords string[] +function TodoKeywords:_parse_single_sequence(todo_keywords) + local keywords, seq_keywords = self:_parse_sequence(todo_keywords, 1, {}, 0) + self.todo_keywords = keywords + self.sequences = { seq_keywords } +end + +---@param todo_keywords string[][] +---@private +function TodoKeywords:_parse_multiple_sequences(todo_keywords) + self.todo_keywords = {} + self.sequences = {} + local used_shortcuts = {} + + for seq_idx, sequence in ipairs(todo_keywords) do + local keyword_offset = #self.todo_keywords + local keywords, seq_keywords = self:_parse_sequence(sequence, seq_idx, used_shortcuts, keyword_offset) + + -- Add keywords to the main list and the sequence + for _, keyword in ipairs(keywords) do + table.insert(self.todo_keywords, keyword) + end + table.insert(self.sequences, seq_keywords) + end end ---@private @@ -116,9 +213,9 @@ function TodoKeywords:_get_hl(keyword, type) end ---@private +---@param keywords string[] ---@return string[], string[] -function TodoKeywords:_split_todo_and_done() - local keywords = self.org_todo_keywords +function TodoKeywords:_split_todo_and_done(keywords) local has_separator = vim.tbl_contains(keywords, '|') if not has_separator then return { unpack(keywords, 1, #keywords - 1) }, { keywords[#keywords] } diff --git a/lua/orgmode/objects/todo_keywords/todo_keyword.lua b/lua/orgmode/objects/todo_keywords/todo_keyword.lua index 148363a87..396db3135 100644 --- a/lua/orgmode/objects/todo_keywords/todo_keyword.lua +++ b/lua/orgmode/objects/todo_keywords/todo_keyword.lua @@ -8,10 +8,11 @@ ---@field shortcut string ---@field hl string ---@field has_fast_access boolean +---@field sequence_index number The sequence this keyword belongs to local TodoKeyword = {} TodoKeyword.__index = TodoKeyword ----@param opts { type: OrgTodoKeywordType, keyword: string, index: number } +---@param opts { type: OrgTodoKeywordType, keyword: string, index: number, sequence_index?: number } ---@return OrgTodoKeyword function TodoKeyword:new(opts) local this = setmetatable({ @@ -19,6 +20,7 @@ function TodoKeyword:new(opts) type = opts.type, index = opts.index, has_fast_access = false, + sequence_index = opts.sequence_index or 1, }, self) this:parse() return this @@ -32,6 +34,7 @@ function TodoKeyword:empty() index = 1, has_fast_access = false, hl = '', + sequence_index = 1, }, self) end diff --git a/lua/orgmode/objects/todo_state.lua b/lua/orgmode/objects/todo_state.lua index 41ef7e643..14608e8b5 100644 --- a/lua/orgmode/objects/todo_state.lua +++ b/lua/orgmode/objects/todo_state.lua @@ -1,5 +1,4 @@ local config = require('orgmode.config') -local utils = require('orgmode.utils') local TodoKeyword = require('orgmode.objects.todo_keywords.todo_keyword') ---@class OrgTodoState @@ -7,12 +6,29 @@ local TodoKeyword = require('orgmode.objects.todo_keywords.todo_keyword') ---@field todos OrgTodoKeywords local TodoState = {} ----@param data { current_state: string | nil, todos: table | nil } +---@param data { current_state: string | OrgTodoKeyword | nil, todos: table | nil } ---@return OrgTodoState function TodoState:new(data) local opts = {} opts.todos = data.todos or config:get_todo_keywords() - opts.current_state = data.current_state and opts.todos:find(data.current_state) or TodoKeyword:empty() + + -- Assign it locally to make the type checker happy. + local current_state = data.current_state + + if current_state then + -- Find the keyword by string value + if type(current_state) == 'string' then + opts.current_state = opts.todos:find(current_state) or TodoKeyword:empty() + -- Direct assignment of a TodoKeyword + elseif type(current_state) == 'table' and current_state.value then + opts.current_state = current_state + else + opts.current_state = TodoKeyword:empty() + end + else + opts.current_state = TodoKeyword:empty() + end + setmetatable(opts, self) self.__index = self return opts @@ -20,22 +36,56 @@ end ---@return boolean function TodoState:has_fast_access() - return self.todos:has_fast_access() + -- Enable fast access mode if: + -- 1. There are multiple sequences defined, OR + -- 2. At least one keyword has an explicit shortcut defined + if #self.todos.sequences > 1 or self.todos:has_fast_access() then + return true + end + return false end ---@return OrgTodoKeyword | nil function TodoState:open_fast_access() local output = {} + + -- Group keywords by sequence + local sequences = {} + for seq_idx = 1, #self.todos.sequences do + sequences[seq_idx] = {} + end + + -- Add each keyword to its respective sequence group for _, todo in ipairs(self.todos:all()) do - table.insert(output, { '[' }) - table.insert(output, { todo.shortcut, 'Title' }) - table.insert(output, { ']' }) - table.insert(output, { ' ' }) - table.insert(output, { todo.value, todo.hl }) - table.insert(output, { ' ' }) + local seq_idx = todo.sequence_index + if not sequences[seq_idx] then + sequences[seq_idx] = {} + end + + local entry = {} + table.insert(entry, { '[' }) + table.insert(entry, { todo.shortcut, 'Title' }) + table.insert(entry, { ']' }) + table.insert(entry, { ' ' }) + table.insert(entry, { todo.value, todo.hl }) + table.insert(entry, { ' ' }) + + table.insert(sequences[seq_idx], entry) + end + + -- Display each sequence on a separate line + for seq_idx, seq_entries in ipairs(sequences) do + -- Flatten the sequence entries + for _, entry in ipairs(seq_entries) do + for _, part in ipairs(entry) do + table.insert(output, part) + end + end + + -- Add a newline after each sequence (except the last one) + table.insert(output, { '\n' }) end - table.insert(output, { '\n' }) vim.api.nvim_echo(output, true, {}) local raw = vim.fn.nr2char(vim.fn.getchar()) @@ -64,22 +114,75 @@ function TodoState:get_prev() end ---@private ----@param direction 1 | -1 +---@param direction number 1 for next, -1 for previous ---@return OrgTodoKeyword | nil function TodoState:_get_direction(direction) + -- When starting from an empty state, get the first/last keyword if self.current_state:is_empty() then - local keyword = direction == 1 and self.todos:first() or self.todos:last() + return self:_handle_empty_state_navigation(direction) + end + + -- Get the keyword sequence this state belongs to + return self:_navigate_within_sequence(direction) +end + +---@private +---@param direction number 1 for next, -1 for previous +---@return OrgTodoKeyword +function TodoState:_handle_empty_state_navigation(direction) + -- When we're starting from an empty state and moving forward, + -- go to the first todo keyword of the first sequence + if direction == 1 then + local keyword = self.todos:first() + self.current_state = keyword + return keyword + -- When we're starting from an empty state and moving backward, + -- go to the last todo keyword of the last sequence + else + local keyword = self.todos:last() self.current_state = keyword return keyword end +end + +---@private +---@param direction number 1 for next, -1 for previous +---@return OrgTodoKeyword +function TodoState:_navigate_within_sequence(direction) + -- Get the sequence this keyword belongs to + local sequence_idx = self.current_state.sequence_index + local seq_keywords = self.todos:sequence(sequence_idx) + + -- Find the position of the current keyword in its sequence + local current_idx = nil + for idx, keyword in ipairs(seq_keywords) do + if keyword.value == self.current_state.value then + current_idx = idx + break + end + end + + if not current_idx then + -- Fallback to the default behavior if we can't find the keyword in its sequence + local next_state = self.todos:all()[self.current_state.index + direction] + if not next_state then + self.current_state = TodoKeyword:empty() + return self.current_state + end + self.current_state = next_state + return next_state + end - local next_state = self.todos:all()[self.current_state.index + direction] - if not next_state then + -- Get the next keyword in the sequence or cycle to empty + local next_idx = current_idx + direction + if next_idx < 1 or next_idx > #seq_keywords then + -- If we go beyond sequence boundaries, cycle to empty state self.current_state = TodoKeyword:empty() return self.current_state end - self.current_state = next_state - return next_state + + self.current_state = seq_keywords[next_idx] + return self.current_state end ---@param headline OrgHeadline|nil @@ -93,6 +196,18 @@ function TodoState:get_reset_todo(headline) return todo_keyword end + -- If the headline has a current state, use first todo keyword from the same sequence + if headline and self.current_state and not self.current_state:is_empty() then + local seq_idx = self.current_state.sequence_index + local seq = self.todos:sequence(seq_idx) + for _, keyword in ipairs(seq) do + if keyword.type == 'TODO' then + return keyword + end + end + end + + -- Default fallback to the first todo keyword return self.todos:first() end diff --git a/lua/orgmode/org/mappings.lua b/lua/orgmode/org/mappings.lua index 492051c2a..8d746665b 100644 --- a/lua/orgmode/org/mappings.lua +++ b/lua/orgmode/org/mappings.lua @@ -351,7 +351,7 @@ function OrgMappings:todo_next_state() end function OrgMappings:todo_prev_state() - self:_todo_change_state('prev') + return self:_todo_change_state('prev') end function OrgMappings:toggle_heading() @@ -414,96 +414,135 @@ function OrgMappings:_get_note(template, indent, title) end) end -function OrgMappings:_todo_change_state(direction) - local headline = self.files:get_closest_headline() - local old_state = headline:get_todo() - local was_done = headline:is_done() - local changed = self:_change_todo_state(direction, true) +---@private +---@param headline OrgHeadline +---@param old_state string +---@param new_state string +function OrgMappings:_handle_repeating_task(headline, old_state, new_state) + local now = Date.now() + local log_repeat_enabled = config.org_log_repeat ~= false + local repeater_dates = headline:get_repeater_dates() - if not changed then - return + -- Update dates based on repeaters + for _, date in ipairs(repeater_dates) do + if date:is_deadline() then + headline:set_deadline_date(date:apply_repeater()) + elseif date:is_scheduled() then + headline:set_scheduled_date(date:apply_repeater()) + end end - local item = self.files:get_closest_headline() - EventManager.dispatch(events.TodoChanged:new(item, old_state, was_done)) + -- Add the LAST_REPEAT property if logging is enabled + if log_repeat_enabled then + headline:set_property('LAST_REPEAT', '[' .. now:to_string() .. ']') - local is_done = item:is_done() and not was_done - local is_undone = not item:is_done() and was_done + -- Create the state change log entry + local repeat_note_template = ('- State %-12s from %-12s [%s]'):format( + [["]] .. new_state .. [["]], + [["]] .. old_state .. [["]], + now:to_string() + ) - -- State was changed in the same group (TODO NEXT | DONE) - -- For example: Changed from TODO to NEXT - if not is_done and not is_undone then - return item + -- Add the state change to the appropriate drawer + if config.org_log_into_drawer then + headline:add_to_drawer(config.org_log_into_drawer, repeat_note_template) + else + local indent = headline:get_indent() + headline:add_note({ indent .. repeat_note_template }) + end end +end - local prompt_done_note = config.org_log_done == 'note' - local log_closed_time = config.org_log_done == 'time' - local indent = headline:get_indent() +function OrgMappings:_todo_change_state(direction, use_fast_access) + local headline = self.files:get_closest_headline() + local current_keyword = headline:get_todo() or '' + local todos = headline.file:get_todo_keywords() + local old_state = current_keyword + local was_done = headline:is_done() - local closing_note_text = ('%s- CLOSING NOTE %s \\\\'):format(indent, Date.now():to_wrapped_string(false)) - local closed_title = 'Insert note for closed todo item' + -- Create TodoState using the current keyword as starting state + local todo_state = TodoState:new({ + current_state = current_keyword, + todos = todos, + }) - local repeater_dates = item:get_repeater_dates() + local next_state = nil - -- No dates with a repeater. Add closed date and note if enabled. - if #repeater_dates == 0 then - local set_closed_date = prompt_done_note or log_closed_time - if set_closed_date then - if is_done then - headline:set_closed_date() - elseif is_undone then - headline:remove_closed_date() - end - item = self.files:get_closest_headline() - end + -- Always use fast access mode when: + -- 1. Multiple sequences are defined, OR + -- 2. At least one keyword has an explicit shortcut + local has_fast_access = todo_state:has_fast_access() + + -- Override use_fast_access if we have fast access capabilities + if has_fast_access then + use_fast_access = true + end - if is_undone or not prompt_done_note then - return item + if use_fast_access then + next_state = todo_state:open_fast_access() + else + if direction == 'next' then + next_state = todo_state:get_next() + elseif direction == 'prev' then + next_state = todo_state:get_prev() + elseif direction == 'reset' then + next_state = todo_state:get_reset_todo(headline) end + end - return self:_get_note(closing_note_text, indent, closed_title):next(function(closing_note) - return item:add_note(closing_note) - end) + if not next_state then + return false end - for _, date in ipairs(repeater_dates) do - self:_replace_date(date:apply_repeater()) + if next_state.value == current_keyword then + if current_keyword ~= '' then + utils.echo_info('TODO state was already ', { { + next_state.value, + next_state.hl, + } }) + end + return false end - local new_todo = item:get_todo() - self:_change_todo_state('reset') - local prompt_repeat_note = config.org_log_repeat == 'note' - local log_repeat_enabled = config.org_log_repeat ~= false - local repeat_note_template = ('%s- State %-12s from %-12s [%s]'):format( - indent, - [["]] .. new_todo .. [["]], - [["]] .. old_state .. [["]], - Date.now():to_string() - ) - local repeat_note_title = ('Insert note for state change from "%s" to "%s"'):format(old_state, new_todo) + local new_state = next_state.value + local is_done = next_state.type == 'DONE' + local becoming_done = is_done and not was_done + local becoming_undone = not is_done and was_done - if log_repeat_enabled then - item:set_property('LAST_REPEAT', Date.now():to_wrapped_string(false)) - end + -- Update the todo state + headline:set_todo(new_state) + + -- Handle becoming done: add CLOSED timestamp + if becoming_done then + headline:set_closed_date() - if not prompt_repeat_note and not prompt_done_note then - -- If user is not prompted for a note, use a default repeat note - if log_repeat_enabled then - return item:add_note({ repeat_note_template }) + -- Special handling for repeating tasks when they're marked as done + local has_repeater = #headline:get_repeater_dates() > 0 + + if has_repeater then + -- Process repeating task + self:_handle_repeating_task(headline, old_state, new_state) + + -- Get the state to reset to (from property or config) + local reset_todo = todo_state:get_reset_todo(headline) + + -- Reset state based on REPEAT_TO_STATE property or config + if reset_todo then + headline:set_todo(reset_todo.value) + end + + -- Remove the CLOSED date after we've applied repeaters and reset the state + headline:remove_closed_date() end - return item + -- Handle becoming undone: remove CLOSED timestamp + elseif becoming_undone then + headline:remove_closed_date() end - -- Done note has precedence over repeat note - if prompt_done_note then - return self:_get_note(closing_note_text, indent, closed_title):next(function(closing_note) - return item:add_note(closing_note) - end) - end + -- Dispatch event for other components to react to the change + EventManager.dispatch(events.TodoChanged:new(headline, old_state, was_done)) - return self:_get_note(repeat_note_template .. ' \\\\', indent, repeat_note_title):next(function(closing_note) - return item:add_note(closing_note) - end) + return true end function OrgMappings:do_promote(whole_subtree) @@ -1049,10 +1088,21 @@ end ---@return boolean function OrgMappings:_change_todo_state(direction, use_fast_access) local headline = self.files:get_closest_headline() - local current_keyword = headline:get_todo() + local current_keyword = headline:get_todo() or '' + local todos = headline.file:get_todo_keywords() + + -- Store the sequence index of the original keyword, if any + local original_sequence_index = nil + local current_keyword_obj = todos:find(current_keyword) + + if current_keyword_obj then + original_sequence_index = current_keyword_obj.sequence_index + end + local todo_state = TodoState:new({ current_state = current_keyword, todos = todos }) local next_state = nil + if use_fast_access and todo_state:has_fast_access() then next_state = todo_state:open_fast_access() else diff --git a/tests/plenary/files/file_spec.lua b/tests/plenary/files/file_spec.lua index 436eff168..8d54f7fae 100644 --- a/tests/plenary/files/file_spec.lua +++ b/tests/plenary/files/file_spec.lua @@ -864,7 +864,8 @@ describe('OrgFile', function() local todos = file:get_todo_keywords() has_correct_type(todos) has_correct_values(todos) - assert.are.same({ 'OPEN', 'DOING', '|', 'FINISHED', 'ABORTED' }, todos.org_todo_keywords) + assert.are.equal(1, #todos.org_todo_keywords) + assert.are.same({ 'OPEN', 'DOING', '|', 'FINISHED', 'ABORTED' }, todos.org_todo_keywords[1]) end) it('should handle todo keywords with shortcut keys', function() @@ -875,7 +876,22 @@ describe('OrgFile', function() local todos = file:get_todo_keywords() has_correct_type(todos) has_correct_values(todos) - assert.are.same({ 'OPEN(o)', 'DOING(d)', '|', 'FINISHED(f)', 'ABORTED(a)' }, todos.org_todo_keywords) + assert.are.equal(1, #todos.org_todo_keywords) + assert.are.same({ 'OPEN(o)', 'DOING(d)', '|', 'FINISHED(f)', 'ABORTED(a)' }, todos.org_todo_keywords[1]) + end) + it('should handle multiple todo keyword sequences from file directives', function() + local file = load_file_sync({ + '#+TODO: OPEN DOING | FINISHED ABORTED', + '#+TODO: MEETING PHONE | COMPLETED', + '* OPEN Headline 1', + }) + local todos = file:get_todo_keywords() + has_correct_type(todos) + has_correct_values(todos) + assert.are.same({ + { 'OPEN', 'DOING', '|', 'FINISHED', 'ABORTED' }, + { 'MEETING', 'PHONE', '|', 'COMPLETED' }, + }, todos.org_todo_keywords) end) end) end) diff --git a/tests/plenary/object/todo_state_spec.lua b/tests/plenary/object/todo_state_spec.lua index 4bd479f52..9e8024106 100644 --- a/tests/plenary/object/todo_state_spec.lua +++ b/tests/plenary/object/todo_state_spec.lua @@ -1,6 +1,7 @@ local config = require('orgmode.config') local TodoState = require('orgmode.objects.todo_state') local TodoKeyword = require('orgmode.objects.todo_keywords.todo_keyword') +local helpers = require('tests.plenary.helpers') describe('Todo state', function() local todo_keywords = config:get_todo_keywords() @@ -52,4 +53,324 @@ describe('Todo state', function() assert.are.same(todo_keywords:find('WAITING'), prev_state:get_prev()) assert.are.same(todo_keywords:find('TODO'), prev_state:get_prev()) end) + + describe('Multiple todo sequences', function() + after_each(function() + vim.cmd([[silent! %bw!]]) + end) + + it('should properly parse multiple todo sequences from config', function() + -- Setup config with multiple sequences + config:extend({ + org_todo_keywords = { + { 'TODO', 'NEXT', '|', 'DONE' }, + { 'MEETING', 'PHONE', '|', 'COMPLETED' }, + }, + }) + + local file_todo_keywords = config:get_todo_keywords() + + -- Check if sequences were properly parsed + assert.are.equal(2, #file_todo_keywords.sequences) + + -- First sequence + assert.are.equal('TODO', file_todo_keywords.sequences[1][1].value) + assert.are.equal('NEXT', file_todo_keywords.sequences[1][2].value) + assert.are.equal('DONE', file_todo_keywords.sequences[1][3].value) + assert.are.equal(1, file_todo_keywords.sequences[1][1].sequence_index) + + -- Second sequence + assert.are.equal('MEETING', file_todo_keywords.sequences[2][1].value) + assert.are.equal('PHONE', file_todo_keywords.sequences[2][2].value) + assert.are.equal('COMPLETED', file_todo_keywords.sequences[2][3].value) + assert.are.equal(2, file_todo_keywords.sequences[2][1].sequence_index) + end) + + it('should properly cycle through states within the same sequence', function() + config:extend({ + org_todo_keywords = { + { 'TODO', 'NEXT', '|', 'DONE' }, + { 'MEETING', 'PHONE', '|', 'COMPLETED' }, + }, + }) + + -- Create TodoState with 'TODO' current state (from sequence 1) + local todo_state = TodoState:new({ + current_state = 'TODO', + todos = config:get_todo_keywords(), + }) + + -- Cycling through sequence 1 + assert.are.equal('NEXT', todo_state:get_next().value) + assert.are.equal('DONE', todo_state:get_next().value) + assert.are.equal('', todo_state:get_next().value) -- Empty state after last one + assert.are.equal('TODO', todo_state:get_next().value) -- Back to first + + -- Create TodoState with 'MEETING' current state (from sequence 2) + local meeting_state = TodoState:new({ + current_state = 'MEETING', + todos = config:get_todo_keywords(), + }) + + -- Cycling through sequence 2 + assert.are.equal('PHONE', meeting_state:get_next().value) + assert.are.equal('COMPLETED', meeting_state:get_next().value) + assert.are.equal('', meeting_state:get_next().value) -- Empty state after last one + assert.are.equal('TODO', meeting_state:get_next().value) -- After empty, always go to first sequence + end) + + it('should enable fast access mode for multiple sequences without explicit shortcuts', function() + config:extend({ + org_todo_keywords = { + { 'TODO', 'NEXT', '|', 'DONE' }, + { 'MEETING', 'PHONE', '|', 'COMPLETED' }, + }, + }) + + local todos = config:get_todo_keywords() + local todo_state = TodoState:new({ + current_state = '', + todos = todos, + }) + + -- Verify fast access is enabled when multiple sequences exist + assert.is_true(todo_state:has_fast_access()) + end) + + it('should parse multiple todo sequences from file directives', function() + -- Create a test file with multiple TODO directives + local file = helpers.create_file({ + '#+TITLE: Test Multiple Sequences', + '#+TODO: TODO NEXT | DONE', + '#+TODO: MEETING PHONE | COMPLETED', + '', + '* TODO Task one', + '* MEETING Meeting with team', + }) + + local file_todo_keywords = file:get_todo_keywords() + + -- Check if sequences were properly parsed + assert.are.equal(2, #file_todo_keywords.sequences) + + -- First sequence + assert.are.equal('TODO', file_todo_keywords.sequences[1][1].value) + assert.are.equal('NEXT', file_todo_keywords.sequences[1][2].value) + assert.are.equal('DONE', file_todo_keywords.sequences[1][3].value) + + -- Second sequence + assert.are.equal('MEETING', file_todo_keywords.sequences[2][1].value) + assert.are.equal('PHONE', file_todo_keywords.sequences[2][2].value) + assert.are.equal('COMPLETED', file_todo_keywords.sequences[2][3].value) + end) + + it('should use the first todo of the same sequence when resetting repeatable task', function() + config:extend({ + org_todo_keywords = { + { 'TODO', 'NEXT', '|', 'DONE' }, + { 'MEETING', 'PHONE', '|', 'COMPLETED' }, + }, + }) + + -- Create a test file with repeating tasks from different sequences + local file = helpers.create_file({ + '#+TITLE: Test Repeatable Tasks', + '', + '* TODO Regular Task', + ' SCHEDULED: <2023-05-03 Wed +1d>', + '* MEETING Daily Meeting', + ' SCHEDULED: <2023-05-03 Wed +1d>', + }) + + -- Test with task from sequence 1 + local headline1 = file:get_closest_headline({ 3, 0 }) + local todo_state1 = TodoState:new({ + current_state = headline1:get_todo(), + todos = file:get_todo_keywords(), + }) + local reset_state1 = todo_state1:get_reset_todo(headline1) + + -- It should reset to TODO, which is the first state in the first sequence + assert.are.equal('TODO', reset_state1.value) + + -- Test with task from sequence 2 + local headline2 = file:get_closest_headline({ 5, 0 }) + local todo_state2 = TodoState:new({ + current_state = headline2:get_todo(), + todos = file:get_todo_keywords(), + }) + local reset_state2 = todo_state2:get_reset_todo(headline2) + + -- It should reset to MEETING, which is the first state in the second sequence + assert.are.equal('MEETING', reset_state2.value) + end) + + it('should auto-generate shortcuts from first letters when no shortcuts are defined', function() + config:extend({ + org_todo_keywords = { + { 'TODO', 'NEXT', '|', 'DONE' }, + { 'MEETING', 'PHONE', '|', 'COMPLETED' }, + }, + }) + + local todos = config:get_todo_keywords() + + -- Check if auto-generated shortcuts are created properly + -- They should be lowercase first letters of todo keywords + assert.are.equal('t', todos:find('TODO').shortcut) + assert.are.equal('n', todos:find('NEXT').shortcut) + assert.are.equal('d', todos:find('DONE').shortcut) + + assert.are.equal('m', todos:find('MEETING').shortcut) + assert.are.equal('p', todos:find('PHONE').shortcut) + assert.are.equal('c', todos:find('COMPLETED').shortcut) + end) + + it('should handle shortcut conflicts by giving priority to first sequence', function() + config:extend({ + org_todo_keywords = { + { 'TODO', 'NEXT', '|', 'DONE' }, + { 'TEST', 'NEW', '|', 'DROPPED' }, -- T conflicts with TODO, N conflicts with NEXT, D conflicts with DONE + }, + }) + + local todos = config:get_todo_keywords() + + -- Check that the first sequence gets the conflicting shortcuts + assert.are.equal('t', todos:find('TODO').shortcut) + assert.are.equal('n', todos:find('NEXT').shortcut) + assert.are.equal('d', todos:find('DONE').shortcut) + + -- The second sequence should get some other shortcuts or possibly none + -- but system shouldn't crash with duplicate shortcuts + local test_keyword = todos:find('TEST') + assert.is_truthy(test_keyword) + + -- Fast access mode should still be enabled with multiple sequences + local todo_state = TodoState:new({ + current_state = '', + todos = todos, + }) + assert.is_true(todo_state:has_fast_access()) + end) + + it('should respect manually defined shortcuts', function() + config:extend({ + org_todo_keywords = { + { 'TODO(o)', 'NEXT(x)', '|', 'DONE(e)' }, -- Custom shortcuts, not first letters + { 'MEETING(g)', 'PHONE(h)', '|', 'COMPLETED(i)' }, + }, + }) + + local todos = config:get_todo_keywords() + + -- Check if explicitly defined shortcuts are used + assert.are.equal('o', todos:find('TODO').shortcut) + assert.are.equal('x', todos:find('NEXT').shortcut) + assert.are.equal('e', todos:find('DONE').shortcut) + + assert.are.equal('g', todos:find('MEETING').shortcut) + assert.are.equal('h', todos:find('PHONE').shortcut) + assert.are.equal('i', todos:find('COMPLETED').shortcut) + + -- Confirm fast access is enabled + local todo_state = TodoState:new({ + current_state = '', + todos = todos, + }) + assert.is_true(todo_state:has_fast_access()) + end) + + it('should handle mixed shortcut definition (some explicit, some auto-generated)', function() + config:extend({ + org_todo_keywords = { + { 'TODO(o)', 'NEXT', '|', 'DONE(e)' }, -- Mixed: explicit, auto, explicit + { 'MEETING', 'PHONE(h)', '|', 'COMPLETED' }, -- Mixed: auto, explicit, auto + }, + }) + + local todos = config:get_todo_keywords() + + -- Check explicitly defined shortcuts + assert.are.equal('o', todos:find('TODO').shortcut) + assert.are.equal('e', todos:find('DONE').shortcut) + assert.are.equal('h', todos:find('PHONE').shortcut) + + -- Check auto-generated shortcuts + assert.are.equal('n', todos:find('NEXT').shortcut) + assert.are.equal('m', todos:find('MEETING').shortcut) + assert.are.equal('c', todos:find('COMPLETED').shortcut) + + -- Confirm fast access is enabled + local todo_state = TodoState:new({ + current_state = '', + todos = todos, + }) + assert.is_true(todo_state:has_fast_access()) + end) + + it('should properly toggle todo states using fast access when multiple sequences exist', function() + local file = helpers.create_file({ + '#+TITLE: Test Multiple Sequences', + '#+TODO: TODO NEXT | DONE', + '#+TODO: MEETING PHONE | COMPLETED', + '', + '* TODO Task one', + '* MEETING Meeting with team', + }) + + -- The test now validates that multiple sequences automatically trigger fast access mode + local todo_state = TodoState:new({ + current_state = 'TODO', + todos = file:get_todo_keywords(), + }) + + -- Check that fast access is enabled with multiple sequences + assert.are.same(true, todo_state:has_fast_access()) + + -- Test that all keywords from all sequences have fast access shortcuts + for _, keyword in ipairs(file:get_todo_keywords():all()) do + assert.are.same(true, keyword.has_fast_access) + end + end) + + it('should correctly identify todo keywords from different sequences', function() + local file = helpers.create_file({ + '#+TITLE: Test Multiple Sequences', + '#+TODO: TODO NEXT | DONE', + '#+TODO: MEETING PHONE | COMPLETED', + '', + '* TODO Task one', + '* MEETING Meeting with team', + }) + + local file_todo_keywords = file:get_todo_keywords() + + -- Verify first sequence + local todo_state1 = TodoState:new({ + current_state = 'TODO', + todos = file_todo_keywords, + }) + local next_state = todo_state1:get_next() + if next_state == nil then -- for the type checker + assert.is.truthy(next_state) + return + end + assert.are.same('NEXT', next_state.value) + assert.are.same(1, next_state.sequence_index) + + -- Verify second sequence + local todo_state2 = TodoState:new({ + current_state = 'MEETING', + todos = file_todo_keywords, + }) + local phone_state = todo_state2:get_next() + if phone_state == nil then -- for the type checker + assert.is.truthy(phone_state) + return + end + assert.are.same('PHONE', phone_state.value) + assert.are.same(2, phone_state.sequence_index) + end) + end) end) diff --git a/tests/plenary/ui/mappings/todo_spec.lua b/tests/plenary/ui/mappings/todo_spec.lua index 51450bca8..f643138cc 100644 --- a/tests/plenary/ui/mappings/todo_spec.lua +++ b/tests/plenary/ui/mappings/todo_spec.lua @@ -589,4 +589,74 @@ describe('Todo mappings', function() '** DOING Subtask', }, vim.api.nvim_buf_get_lines(0, 0, 3, false)) end) + + it('should properly toggle todo states using cycling behavior with a single sequence', function() + config:extend({ + org_todo_keywords = { 'TODO', 'NEXT', '|', 'DONE' }, + }) + + helpers.create_file({ + '#+TITLE: Test Single Sequence', + '', + '* TODO Task one', + }) + + -- Test cycling through the sequence + vim.fn.cursor(3, 1) + vim.cmd([[norm cit]]) + assert.are.same('* NEXT Task one', vim.fn.getline(3)) + vim.cmd([[norm cit]]) + assert.are.same('* DONE Task one', vim.fn.getline(3)) + vim.cmd([[norm cit]]) + assert.are.same('* Task one', vim.fn.getline(3)) + vim.cmd([[norm cit]]) + assert.are.same('* TODO Task one', vim.fn.getline(3)) + end) + + it('should use fast access mode when multiple sequences are defined', function() + config:extend({ + org_todo_keywords = { + { 'TODO', 'NEXT', '|', 'DONE' }, + { 'MEETING', 'PHONE', '|', 'COMPLETED' }, + }, + }) + + helpers.create_file({ + '#+TITLE: Test Multiple Sequences', + '', + '* Task one', + }) + + -- Test changing states using fast access keys generated from first character + vim.fn.cursor(3, 1) + vim.cmd([[norm citn]]) + assert.are.same('* NEXT Task one', vim.fn.getline(3)) + vim.cmd([[norm citm]]) + assert.are.same('* MEETING Task one', vim.fn.getline(3)) + vim.cmd([[norm citp]]) + assert.are.same('* PHONE Task one', vim.fn.getline(3)) + vim.cmd([[exe "norm cit\"]]) + assert.are.same('* Task one', vim.fn.getline(3)) + end) + + it('should use fast access mode when at least one todo has explicit shortcut', function() + config:extend({ + org_todo_keywords = { 'TODO(x)', 'NEXT(y)', '|', 'DONE(z)' }, + }) + + helpers.create_file({ + '#+TITLE: Test Single Sequence with Shortcuts', + '', + '* Task one', + }) + + -- Test changing states using explicitly defined fast access keys + vim.fn.cursor(3, 1) + vim.cmd([[norm citx]]) + assert.are.same('* TODO Task one', vim.fn.getline(3)) + vim.cmd([[norm city]]) + assert.are.same('* NEXT Task one', vim.fn.getline(3)) + vim.cmd([[norm citz]]) + assert.are.same('* DONE Task one', vim.fn.getline(3)) + end) end)