diff --git a/README.md b/README.md index b0710ab7..ed28fbbc 100644 --- a/README.md +++ b/README.md @@ -97,6 +97,8 @@ The fork aims to stay close to the original, but fix bugs, include and merge use - `:Obsidian toc` to load the table of contents of the current note into a picker list. +- `:Obsidian tasks [STATUS]` to load the list of tasks of the current vault into a picker list. + ### Demo [![2024-01-31 14 22 52](https://github.com/epwalsh/obsidian.nvim/assets/8812459/2986e1d2-13e8-40e2-9c9e-75691a3b662e)](https://github.com/epwalsh/obsidian.nvim/assets/8812459/2986e1d2-13e8-40e2-9c9e-75691a3b662e) @@ -520,12 +522,12 @@ This is a complete list of all of the options that can be passed to `require("ob max_file_length = 5000, -- disable UI features for files with more than this many lines -- Define how various check-boxes are displayed checkboxes = { - -- NOTE: the 'char' value has to be a single character, and the highlight groups are defined below. - [" "] = { char = "󰄱", hl_group = "ObsidianTodo" }, - ["x"] = { char = "", hl_group = "ObsidianDone" }, - [">"] = { char = "", hl_group = "ObsidianRightArrow" }, - ["~"] = { char = "󰰱", hl_group = "ObsidianTilde" }, - ["!"] = { char = "", hl_group = "ObsidianImportant" }, + -- NOTE: the 'char' value and the status has to be a single character, and the highlight groups are defined below. + [" "] = { char = "󰄱", order = 1, name = "todo", hl_group = "ObsidianTodo" }, + ["x"] = { char = "", order = 2, name = "done", hl_group = "ObsidianDone" }, + [">"] = { char = "", order = 3, name = "doing", hl_group = "ObsidianRightArrow" }, + ["~"] = { char = "󰰱", order = 4, name = "cancelled", hl_group = "ObsidianTilde" }, + ["!"] = { char = "", order = 5, name = "important", hl_group = "ObsidianImportant" }, -- Replace the above with this if you don't have a patched font: -- [" "] = { char = "☐", hl_group = "ObsidianTodo" }, -- ["x"] = { char = "✔", hl_group = "ObsidianDone" }, diff --git a/doc/obsidian.txt b/doc/obsidian.txt index c5cba6fd..1a92b23f 100644 --- a/doc/obsidian.txt +++ b/doc/obsidian.txt @@ -135,6 +135,8 @@ COMMANDS *obsidian-commands* command has one optional argument: the title of the new note. - `:Obsidian toc` to load the table of contents of the current note into a picker list. +- `:Obsidian tasks [STATUS]` to load the list of tasks of the current vault into + a picker list. DEMO *obsidian-demo* @@ -580,12 +582,12 @@ carefully and customize it to your needs: max_file_length = 5000, -- disable UI features for files with more than this many lines -- Define how various check-boxes are displayed checkboxes = { - -- NOTE: the 'char' value has to be a single character, and the highlight groups are defined below. - [" "] = { char = "󰄱", hl_group = "ObsidianTodo" }, - ["x"] = { char = "", hl_group = "ObsidianDone" }, - [">"] = { char = "", hl_group = "ObsidianRightArrow" }, - ["~"] = { char = "󰰱", hl_group = "ObsidianTilde" }, - ["!"] = { char = "", hl_group = "ObsidianImportant" }, + -- NOTE: the 'char' value and the status has to be a single character, and the highlight groups are defined below. + [" "] = { char = "󰄱", order = 1, name = "todo", hl_group = "ObsidianTodo" }, + ["x"] = { char = "", order = 2, name = "done", hl_group = "ObsidianDone" }, + [">"] = { char = "", order = 3, name = "doing", hl_group = "ObsidianRightArrow" }, + ["~"] = { char = "󰰱", order = 4, name = "cancelled", hl_group = "ObsidianTilde" }, + ["!"] = { char = "", order = 5, name = "important", hl_group = "ObsidianImportant" }, -- Replace the above with this if you don't have a patched font: -- [" "] = { char = "☐", hl_group = "ObsidianTodo" }, -- ["x"] = { char = "✔", hl_group = "ObsidianDone" }, diff --git a/doc/obsidian_api.txt b/doc/obsidian_api.txt index 00b72f1c..308bc8b9 100644 --- a/doc/obsidian_api.txt +++ b/doc/obsidian_api.txt @@ -665,6 +665,29 @@ Parameters ~ Return ~ `(obsidian.Picker| `(optional))`` +------------------------------------------------------------------------------ +Class ~ +{obsidian.Task} + +Fields ~ +{path} `(string)` The path to the note. +{line} `(integer)` The line number (1-indexed) where the task was found. +{description} `(string)` The text of the line where the task was found. +{status} `(string)` The status of the task. + +------------------------------------------------------------------------------ + *obsidian.Client.find_tasks()* + `Client.find_tasks`({self}) +Return ~ +`(obsidian.Task[])` + +------------------------------------------------------------------------------ + *obsidian.Client.get_task_status_names()* + `Client.get_task_status_names`({self}) +Build the list of task status names sorted by order +Return ~ +`(string[])` + ------------------------------------------------------------------------------ Class ~ {obsidian.note.HeaderAnchor} diff --git a/lua/obsidian/client.lua b/lua/obsidian/client.lua index 2661c011..e1179f8e 100644 --- a/lua/obsidian/client.lua +++ b/lua/obsidian/client.lua @@ -2075,4 +2075,53 @@ Client.picker = function(self, picker_name) return require("obsidian.pickers").get(self, picker_name) end +---@class obsidian.Task +--- +---@field path string The path to the note. +---@field line integer The line number (1-indexed) where the task was found. +---@field description string The text of the line where the task was found. +---@field status string The status of the task. + +---@return obsidian.Task[] +Client.find_tasks = function(self) + local openTasks = search.search(self.dir, "^\\s*([-+*]|\\d+[\\.)]) \\[.\\]", search.SearchOpts.default()) + --- @type obsidian.Task[] + local result = {} + + --- @type MatchData|? + local taskMatch = openTasks() + while taskMatch do + -- matching in lua since ripgrep doesn't support capturing groups in the --json output + local status, description = string.match(taskMatch.lines.text, "%[(.)%] (.*)") + result[#result + 1] = { + status = status, + description = string.gsub(description, "\n", ""), + line = taskMatch.line_number, + path = taskMatch.path.text, + } + taskMatch = openTasks() + end + return result +end + +--- Build the list of task status names sorted by order +---@return string[] +Client.get_task_status_names = function(self) + local checkboxes = self.opts.ui.checkboxes + -- index by status name + local task_by_status_name = {} + local status_names = {} + for _, c in pairs(checkboxes) do + task_by_status_name[c.name or c.char] = c + status_names[#status_names + 1] = c.name or c.char + end + + -- sort list of status names + table.sort(status_names, function(a, b) + return (task_by_status_name[a].order or 0) < (task_by_status_name[b].order or 0) + end) + + return status_names +end + return Client diff --git a/lua/obsidian/commands/init-legacy.lua b/lua/obsidian/commands/init-legacy.lua index 75b273cc..92dba1b6 100644 --- a/lua/obsidian/commands/init-legacy.lua +++ b/lua/obsidian/commands/init-legacy.lua @@ -26,6 +26,7 @@ local command_lookups = { ObsidianExtractNote = "obsidian.commands.extract_note", ObsidianDebug = "obsidian.commands.debug", ObsidianTOC = "obsidian.commands.toc", + ObsidianTasks = "obsidian.commands.tasks", } local M = setmetatable({ @@ -128,6 +129,12 @@ M.complete_args_search = function(client, _, cmd_line, _) return completions end +---@param client obsidian.Client +---@return string[] +M.task_status_name_complete = function(client, _, _, _) + return client:get_task_status_names() +end + M.register("ObsidianCheck", { opts = { nargs = 0, desc = "Check for issues in your vault" } }) M.register("ObsidianToday", { opts = { nargs = "?", desc = "Open today's daily note" } }) @@ -191,4 +198,9 @@ M.register("ObsidianDebug", { opts = { nargs = 0, desc = "Log some information f M.register("ObsidianTOC", { opts = { nargs = 0, desc = "Load the table of contents into a picker" } }) +M.register("ObsidianTasks", { + opts = { nargs = "?", desc = "List all tasks" }, + complete = M.task_status_name_complete, +}) + return M diff --git a/lua/obsidian/commands/init.lua b/lua/obsidian/commands/init.lua index 83aa9808..a40cce06 100644 --- a/lua/obsidian/commands/init.lua +++ b/lua/obsidian/commands/init.lua @@ -27,6 +27,7 @@ local cmds = { "tomorrow", "workspace", "yesterday", + "tasks", } -- TODO: this will be context-sensitive in the future @@ -199,6 +200,12 @@ M.note_complete = function(client, cmd_arg) return completions end +---@param client obsidian.Client +---@return string[] +M.task_status_name_complete = function(client, _, _, _) + return client:get_task_status_names() +end + M.register("check", { nargs = 0 }) M.register("today", { nargs = "?" }) @@ -247,4 +254,6 @@ M.register("debug", { nargs = 0 }) M.register("toc", { nargs = 0 }) +M.register("tasks", { nargs = "?", desc = "List all tasks", complete = M.task_status_name_complete }) + return M diff --git a/lua/obsidian/commands/tasks.lua b/lua/obsidian/commands/tasks.lua new file mode 100644 index 00000000..97fdf8c5 --- /dev/null +++ b/lua/obsidian/commands/tasks.lua @@ -0,0 +1,53 @@ +---@param current string|nil +---@param status_names table +local function get_next_status(current, status_names) + for i, v in ipairs(status_names) do + if v == current then + return status_names[i + 1] + end + end + return status_names[1] +end + +--- Show tasks with optional filtering +---@param client obsidian.Client +---@param data table +local function showTasks(client, data) + assert(client, "Client is required") + + local filter = data.fargs[1] + local picker = assert(client:picker(), "No picker configured") + + local checkboxes = client.opts.ui.checkboxes + local status_names = client:get_task_status_names() + + local tasks = client:find_tasks() + local toShow = {} + + for _, task in ipairs(tasks) do + local tStatus = checkboxes[task.status] + if tStatus and (not filter or tStatus.name == filter) then + table.insert(toShow, { + display = string.format(" %s", task.description), + filename = task.path, + lnum = task.line, + icon = tStatus.char, + }) + end + end + + picker:pick(toShow, { + prompt_title = filter and (filter .. " tasks") or "tasks", + query_mappings = { + [""] = { + desc = "Toggle task filter", + callback = function() + local next_state_name = get_next_status(filter, status_names) + showTasks(client, { fargs = { next_state_name } }) + end, + }, + }, + }) +end + +return showTasks diff --git a/lua/obsidian/config.lua b/lua/obsidian/config.lua index 539fe3b1..b6b344c5 100644 --- a/lua/obsidian/config.lua +++ b/lua/obsidian/config.lua @@ -438,6 +438,7 @@ config.UIOpts = {} ---@field char string ---@field hl_group string ---@field order integer +---@field name string ---@class obsidian.config.UIStyleSpec --- @@ -450,11 +451,11 @@ config.UIOpts.default = function() update_debounce = 200, max_file_length = 5000, checkboxes = { - [" "] = { order = 1, char = "󰄱", hl_group = "ObsidianTodo" }, - ["~"] = { order = 2, char = "󰰱", hl_group = "ObsidianTilde" }, - ["!"] = { order = 3, char = "", hl_group = "ObsidianImportant" }, - [">"] = { order = 4, char = "", hl_group = "ObsidianRightArrow" }, - ["x"] = { order = 5, char = "", hl_group = "ObsidianDone" }, + [" "] = { order = 1, char = "󰄱", hl_group = "ObsidianTodo", name = "todo" }, + ["!"] = { order = 2, char = "", hl_group = "ObsidianImportant", name = "important" }, + [">"] = { order = 3, char = "", hl_group = "ObsidianRightArrow", name = "doing" }, + ["~"] = { order = 4, char = "󰰱", hl_group = "ObsidianTilde", name = "cancelled" }, + ["x"] = { order = 5, char = "", hl_group = "ObsidianDone", name = "done" }, }, bullets = { char = "•", hl_group = "ObsidianBullet" }, external_link_icon = { char = "", hl_group = "ObsidianExtLinkIcon" }, diff --git a/test/obsidian/client_spec.lua b/test/obsidian/client_spec.lua index a2bc1d1f..56d88e7f 100644 --- a/test/obsidian/client_spec.lua +++ b/test/obsidian/client_spec.lua @@ -160,7 +160,7 @@ describe("Client:parse_title_id_path()", function() with_tmp_client(function(client) client.opts.note_path_func = function(_) return "foo-bar-123.md" - end; + end (client.dir / "notes"):mkdir { exist_ok = true } @@ -227,3 +227,38 @@ describe("Client:daily_note_path()", function() end) end) end) + +describe("Client:find_tasks()", function() + it("should match any task list", function() + with_tmp_client(function(client) + -- create a test file with a task list + local test_file_name = tostring(client.dir) .. "/list.md" + local file = io.open(test_file_name, "a+") + if file == nil then + error("Failed to open file: " .. test_file_name) + end + file:write( + "- [ ] first\n" + .. " - [ ] second\n" + .. "+ [ ] plus\n" + .. "* [ ] star\n" + .. "- [x] normal x-ed\n" + .. "- [!] normal !-ed\n" + .. "- [~] normal ~-ed (render-markdown)\n" + .. "1. [ ] ordered\n" + .. "1) [ ] ordered\n" + .. "12) [ ] ordered\n" + .. " 22. [x] ordered x-ed\n" + ) + file:close() + + -- search for tasks + local tasks = client:find_tasks() + + -- len of tasks should be 11 + assert(#tasks == 11, "Wrong number of taks found:" .. #tasks) + assert(tasks[1].description == "first") + assert(tasks[#tasks].description == "ordered x-ed") + end) + end) +end)