Skip to content
Merged
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
12 changes: 10 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ require("dotmd").setup({

---@class DotMd.Config.RolloverTodo
---@field enabled? boolean Rollover the nearest previous unchecked todos to today's date, default is `false`
---@field heading? string Heading to search for in your todos template to rollover, default is "Tasks"
---@field headings? string[] H2 Headings to search for in your todos template to rollover, default is { "Tasks" }

---@class DotMd.Config.DirNames
---@field notes? string Directory name for notes, default is "notes"
Expand All @@ -131,7 +131,7 @@ require("dotmd").setup({
default_split = "none",
rollover_todo = {
enabled = false,
heading = "Tasks",
heading = { "Tasks" },
},
picker = nil,
dir_names = {
Expand Down Expand Up @@ -446,6 +446,14 @@ When you create a new todo file, **dotmd.nvim**:
4. Applies the todo template.
5. Opens the file for editing.

> [!note]
>
> - You can configure the headings to search for in your todos template to rollover by setting `rollover_todo.headings` in the config.
> - Note that only `h2` or `##` is supported for now.
> - Make sure it actually matches the headings in your template.
> - If a heading does not exist in previous todo, it will get ignored.
> - For today's todo file, if a heading doesn't exist in the template, it will append the section at the end of the file (if there's any rollover).

### Inbox

The inbox is a special file that is used to dump thoughts, tasks, and references.
Expand Down
11 changes: 9 additions & 2 deletions doc/dotmd.nvim.txt
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ DEFAULT OPTIONS ~

---@class DotMd.Config.RolloverTodo
---@field enabled? boolean Rollover the nearest previous unchecked todos to today's date, default is `false`
---@field heading? string Heading to search for in your todos template to rollover, default is "Tasks"
---@field headings? string[] H2 Headings to search for in your todos template to rollover, default is { "Tasks" }

---@class DotMd.Config.DirNames
---@field notes? string Directory name for notes, default is "notes"
Expand All @@ -127,7 +127,7 @@ DEFAULT OPTIONS ~
default_split = "none",
rollover_todo = {
enabled = false,
heading = "Tasks",
heading = { "Tasks" },
},
picker = nil,
dir_names = {
Expand Down Expand Up @@ -451,6 +451,13 @@ When you create a new todo file, **dotmd.nvim**
5. Opens the file for editing.


[!note]
- You can configure the headings to search for in your todos template to rollover by setting `rollover_todo.headings` in the config.
- Note that only `h2` or `##` is supported for now.
- Make sure it actually matches the headings in your template.
- If a heading does not exist in previous todo, it will get ignored.
- For today’s todo file, if a heading doesn’t exist in the template, it will append the section at the end of the file (if there’s any rollover).

INBOX ~

The inbox is a special file that is used to dump thoughts, tasks, and
Expand Down
36 changes: 20 additions & 16 deletions lua/dotmd/commands.lua
Original file line number Diff line number Diff line change
Expand Up @@ -84,23 +84,27 @@ function M.create_todo_today(opts)
config.templates.todos
)

if config.rollover_todo.enabled == true then
local unchecked_tasks, source_path =
todos.rollover_previous_todo_to_today(todo_dir, today)
if unchecked_tasks and source_path then
local today_lines = vim.fn.readfile(todo_path)
if #today_lines > 0 and today_lines[#today_lines] ~= "" then
table.insert(today_lines, "")
end
vim.list_extend(today_lines, unchecked_tasks)
utils.safe_writefile(today_lines, todo_path)
vim.notify(
string.format(
"Rolled over %d unchecked todo(s) from %s",
#unchecked_tasks,
vim.fn.fnamemodify(source_path, ":t")
if
config.rollover_todo.enabled == true
and config.rollover_todo.headings ~= nil
and #config.rollover_todo.headings > 0
then
for _, heading in ipairs(config.rollover_todo.headings) do
local unchecked_tasks, source_path =
todos.rollover_previous_todo_to_today(
todo_dir,
today,
heading
)

if unchecked_tasks and source_path then
todos.apply_rollover_todo(
todo_path,
unchecked_tasks,
source_path,
heading
)
)
end
end
end

Expand Down
2 changes: 1 addition & 1 deletion lua/dotmd/config.lua
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ local defaults = {
default_split = "none",
rollover_todo = {
enabled = false,
heading = "Tasks",
headings = { "Tasks" },
},
picker = nil,
dir_names = {
Expand Down
92 changes: 84 additions & 8 deletions lua/dotmd/todos.lua
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,9 @@ local M = {}
--- Rollover nearest previous todo to today
---@param todo_dir string
---@param today string|osdate
---@param header_text string
---@return string[]|nil, string|nil
function M.rollover_previous_todo_to_today(todo_dir, today)
local config = require("dotmd.config").config
function M.rollover_previous_todo_to_today(todo_dir, today, header_text)
local previous_todo_file =
require("dotmd.directories").get_nearest_previous_from_date(
todo_dir,
Expand All @@ -19,10 +19,9 @@ function M.rollover_previous_todo_to_today(todo_dir, today)
local unchecked_tasks = {}
local new_lines = {}

-- Find the tasks section
local in_tasks = false
for _, line in ipairs(lines) do
if line:match("^##%s+" .. config.rollover_todo.heading) then
if line:match("^##%s+" .. header_text) then
in_tasks = true
table.insert(new_lines, line)
elseif in_tasks and line:match("^##") then
Expand All @@ -39,12 +38,89 @@ function M.rollover_previous_todo_to_today(todo_dir, today)
end
end

if #unchecked_tasks > 0 then
require("dotmd.utils").safe_writefile(new_lines, previous_todo_file)
return unchecked_tasks, previous_todo_file
if #unchecked_tasks == 0 then
return nil, nil
end

return nil, nil
require("dotmd.utils").safe_writefile(new_lines, previous_todo_file)
return unchecked_tasks, previous_todo_file
end

--- Apply rollover todo to unchecked tasks
---@param todo_path string
---@param unchecked_tasks string[]
---@param source_path string
---@param header_text string
---@return nil
function M.apply_rollover_todo(
todo_path,
unchecked_tasks,
source_path,
header_text
)
local utils = require("dotmd.utils")

local today_lines = vim.fn.filereadable(todo_path) == 1
and vim.fn.readfile(todo_path)
or {}

local inserted = false
local result_lines = {}
local header_found = false

for i = 1, #today_lines do
local line = today_lines[i]
table.insert(result_lines, line)

if not inserted and line:match("^##%s+" .. header_text) then
header_found = true

-- Check if the next line exists and is blank; if not, insert a blank line.
if (i + 1 > #today_lines) or (today_lines[i + 1] ~= "") then
table.insert(result_lines, "")
end

local insert_pos = i + 1

while
insert_pos <= #today_lines
and not today_lines[insert_pos]:match("^##")
do
table.insert(result_lines, today_lines[insert_pos])
insert_pos = insert_pos + 1
end

for _, task in ipairs(unchecked_tasks) do
table.insert(result_lines, task)
end

inserted = true

i = insert_pos - 1 -- Skip already inserted lines
end
end

if not header_found then
if #result_lines > 0 and result_lines[#result_lines] ~= "" then
table.insert(result_lines, "")
end
table.insert(result_lines, "## " .. header_text)
table.insert(result_lines, "")
for _, task in ipairs(unchecked_tasks) do
table.insert(result_lines, task)
end
end

utils.safe_writefile(result_lines, todo_path)

vim.notify(
string.format(
"Rolled over %d unchecked todo(s) under '%s' from %s",
#unchecked_tasks,
header_text,
vim.fn.fnamemodify(source_path, ":t")
)
)
end

return M
2 changes: 1 addition & 1 deletion lua/dotmd/types.lua
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@

---@class DotMd.Config.RolloverTodo
---@field enabled? boolean Rollover the nearest previous unchecked todos to today's date, default is `false`
---@field heading? string Heading to search for in your todos template to rollover, default is "Tasks"
---@field headings? string[] H2 Headings to search for in your todos template to rollover, default is { "Tasks" }

---@class DotMd.Config.DirNames
---@field notes? string Directory name for notes, default is "notes"
Expand Down
62 changes: 59 additions & 3 deletions tests/todos_spec.lua
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,8 @@ describe("dotmd.todos module", function()
local today = "2025-04-20"
local unchecked, path = todos.rollover_previous_todo_to_today(
config.config.root_dir,
today
today,
"Tasks"
)
assert.is_nil(unchecked)
assert.is_nil(path)
Expand Down Expand Up @@ -76,7 +77,8 @@ describe("dotmd.todos module", function()
local today = "2025-04-20"
local unchecked, path = todos.rollover_previous_todo_to_today(
config.config.root_dir,
today
today,
"Tasks"
)

-- Expect unchecked tasks to be those that match the pattern inside the tasks section.
Expand Down Expand Up @@ -113,11 +115,65 @@ describe("dotmd.todos module", function()
local today = "2025-04-18"
local unchecked, path = todos.rollover_previous_todo_to_today(
config.config.root_dir,
today
today,
"Tasks"
)
assert.is_nil(unchecked)
assert.is_nil(path)
end
)
end)

describe("apply_rollover_todo", function()
local original_notify

before_each(function()
original_notify = vim.notify
vim.notify = function(msg, level)
_G.last_notify = msg
end
end)

after_each(function()
vim.notify = original_notify
_G.last_notify = nil
end)
it(
"should insert a blank line after the header before the new tasks",
function()
local todo_path = config.config.root_dir .. "2025-04-20.md"
local header_text = "Tasks"
local initial_lines = {
"# Todo for 2025-04-20",
"## " .. header_text,
"- [ ] Existing task",
}
vim.fn.writefile(initial_lines, todo_path)
local unchecked_tasks = { "- [ ] New task" }
-- The source_path is arbitrary for this test.
todos.apply_rollover_todo(
todo_path,
unchecked_tasks,
"source.md",
header_text
)
local updated_lines = vim.fn.readfile(todo_path)
-- Find the index of the header line.
local header_index = nil
for i, line in ipairs(updated_lines) do
if line == "## " .. header_text then
header_index = i
break
end
end
assert.not_nil(header_index, "Header not found in updated file")
-- Check that a blank line exists immediately after the header.
assert.are.equal("", updated_lines[header_index + 1])
-- Check that the new task is inserted after the blank line.
assert.is_true(
vim.tbl_contains(updated_lines, "- [ ] New task")
)
end
)
end)
end)