From 0b6605f7992c61392fa9252c5078eea07ade1088 Mon Sep 17 00:00:00 2001 From: Ben Burgess Date: Wed, 28 May 2025 04:38:55 -0500 Subject: [PATCH 1/8] test(templates): properly clean up temp files I found it helpful to have a shared testing library for creating a temporary client because previously we had multiple implementations for doing so. This current implementation, though minimal, covers the bases for managing creation/teardown. --- lua/test_utils.lua | 29 +++++++++++++++++++++++++++++ test/obsidian/templates_spec.lua | 4 ++-- 2 files changed, 31 insertions(+), 2 deletions(-) create mode 100644 lua/test_utils.lua diff --git a/lua/test_utils.lua b/lua/test_utils.lua new file mode 100644 index 00000000..d4ddf7ba --- /dev/null +++ b/lua/test_utils.lua @@ -0,0 +1,29 @@ +local M = {} + +local obsidian = require "obsidian" + +---Get a client in a temporary directory. +---@param testid string A unique ID for the tests which prevents directory collision +---@param templates_dir string The template directory +---@return obsidian.Client +M.get_tmp_client = function(testid, templates_dir) + templates_dir = templates_dir or "templates" + + local tmpdir = "tmp-vault-" .. testid + + vim.loop.fs_mkdir(tmpdir, 448) -- <-- octal representation of 700 (RWX) + vim.loop.fs_mkdir(tmpdir .. "/" .. templates_dir, 448) + + local client = obsidian.new_from_dir(tmpdir) + client.opts.templates.folder = "templates" + return client +end + +--- Clean up a client, removing any resources created during `get_tmp_client` +--- @param client obsidian.Client The Client +M.cleanup_tmp_client = function(client) + local path = client.dir:resolve() + vim.fs.rm(tostring(path), { recursive = true, force = true }) +end + +return M diff --git a/test/obsidian/templates_spec.lua b/test/obsidian/templates_spec.lua index 411d7485..eb02d01a 100644 --- a/test/obsidian/templates_spec.lua +++ b/test/obsidian/templates_spec.lua @@ -1,5 +1,5 @@ -local obsidian = require "obsidian" -local Path = require "obsidian.path" +local get_tmp_client = require("test_utils").get_tmp_client +local cleanup_tmp_client = require("test_utils").cleanup_tmp_client local Note = require "obsidian.note" local templates = require "obsidian.templates" From 5bbaf80a167b4f3a2c987efb28365b4897343743 Mon Sep 17 00:00:00 2001 From: Ben Burgess Date: Wed, 28 May 2025 04:42:01 -0500 Subject: [PATCH 2/8] feat: add `customization` option for templates --- lua/obsidian/config.lua | 7 ++ lua/obsidian/templates.lua | 56 +++++++++++ test/obsidian/templates_spec.lua | 167 ++++++++++++++++++++++++------- 3 files changed, 194 insertions(+), 36 deletions(-) diff --git a/lua/obsidian/config.lua b/lua/obsidian/config.lua index c07802da..925eefb4 100644 --- a/lua/obsidian/config.lua +++ b/lua/obsidian/config.lua @@ -427,6 +427,7 @@ end ---@field date_format string|? ---@field time_format string|? ---@field substitutions table|? +---@field customizations table config.TemplateOpts = {} --- Get defaults. @@ -438,9 +439,15 @@ config.TemplateOpts.default = function() date_format = nil, time_format = nil, substitutions = {}, + customizations = {}, } end +---@class obsidian.config.CustomTemplateOpts +--- +---@field dir string|? +---@field note_id_func function|? + ---@class obsidian.config.UIOpts --- ---@field enable boolean diff --git a/lua/obsidian/templates.lua b/lua/obsidian/templates.lua index d7e9f3e7..d4c4287a 100644 --- a/lua/obsidian/templates.lua +++ b/lua/obsidian/templates.lua @@ -1,8 +1,10 @@ local Path = require "obsidian.path" local Note = require "obsidian.note" local util = require "obsidian.util" +local config = require "obsidian.config" local M = {} +local restore_client_key = "__restore_client_key" --- Resolve a template name to a path. --- @@ -197,4 +199,58 @@ M.insert_template = function(opts) return Note.from_buffer(buf) end +--- Loads the client with customizations for a template identified by `template_name` if present +--- +--- @param template_name string The template name +--- @param client obsidian.Client The client +M.load_template_customizations = function(template_name, client) + local success, template_path = pcall(resolve_template, template_name, client) + + if not success then + return + end + + --- @type obsidian.config.CustomTemplateOpts|? + local customization = nil + + -- Check if the configuration has a custom key for this template + for template_key, template_config in pairs(client.opts.templates.customizations) do + if template_key:lower() == template_path.stem:lower() then + customization = template_config + break + end + end + + if not customization then + return + end + + local restore_values = { + dir = client.opts.notes_subdir, + note_id_func = client.opts.note_id_func, + new_notes_location = client.opts.new_notes_location, + } + + client[restore_client_key] = restore_values + client.opts.notes_subdir = customization.dir + client.opts.note_id_func = customization.note_id_func + client.opts.new_notes_location = config.NewNotesLocation.notes_subdir + return nil +end + +--- Restores the client's configuration if saved previously (during `load_template_customizations`), does nothing otherwise +--- @param client obsidian.Client The client +M.restore_client_configurations = function(client) + --- @type unknown + local restore_values = rawget(client, restore_client_key) + + if not restore_values then + return + end + + client.opts.notes_subdir = restore_values.dir + client.opts.note_id_func = restore_values.note_id_func + client.opts.new_notes_location = restore_values.new_notes_location +end + return M diff --git a/test/obsidian/templates_spec.lua b/test/obsidian/templates_spec.lua index eb02d01a..30fb051d 100644 --- a/test/obsidian/templates_spec.lua +++ b/test/obsidian/templates_spec.lua @@ -2,44 +2,139 @@ local get_tmp_client = require("test_utils").get_tmp_client local cleanup_tmp_client = require("test_utils").cleanup_tmp_client local Note = require "obsidian.note" local templates = require "obsidian.templates" +local NewNotesLocation = require("obsidian.config").NewNotesLocation ----Get a client in a temporary directory. ---- ----@return obsidian.Client -local tmp_client = function() - -- This gives us a tmp file name, but we really want a directory. - -- So we delete that file immediately. - local tmpname = os.tmpname() - os.remove(tmpname) - - local dir = Path:new(tmpname .. "-obsidian/") - dir:mkdir { parents = true } - - return obsidian.new_from_dir(tostring(dir)) -end - -describe("templates.substitute_template_variables()", function() - it("should substitute built-in variables", function() - local client = tmp_client() - local text = "today is {{date}} and the title of the note is {{title}}" - assert.equal( - string.format("today is %s and the title of the note is %s", os.date "%Y-%m-%d", "FOO"), - templates.substitute_template_variables(text, client, Note.new("FOO", { "FOO" }, {})) - ) +local templates_dir = "templates" + +--- @type obsidian.config.CustomTemplateOpts +local zettelConfig = { + dir = "/custom/path/to/zettels", + note_id_func = function() + return "hummus" + end, +} + +describe("template", function() + --- @type obsidian.Client|? + local client = nil + + before_each(function() + client = get_tmp_client("templates", templates_dir) + end) + + after_each(function() + if client then + cleanup_tmp_client(client) + end + end) + + describe("templates.load_template_customizations()", function() + before_each(function() + client.opts.templates.customizations = { + Zettel = zettelConfig, + } + end) + + after_each(function() + client.opts.templates.customizations = nil + end) + + it("should not load customizations for non-existant templates", function() + -- Arrange + local old_id_func = client.opts.note_id_func + + -- Act + templates.load_template_customizations("Zettel", client) + + -- Assert + assert.falsy(client.opts.notes_subdir) + assert.are_not.equal(zettelConfig.note_id_func, client.opts.note_id_func) + assert.equal(old_id_func, client.opts.note_id_func) + end) + + it("should load customizations for existing template", function() + -- Arrange + client:create_note { dir = templates_dir, id = "zettel" } + + -- Act + templates.load_template_customizations("Zettel", client) + + -- Assert + assert.equal(zettelConfig.dir, client.opts.notes_subdir) + assert.equal(zettelConfig.note_id_func, client.opts.note_id_func) + end) + + it("should load customizations case-insensitively if template exists", function() + -- Arrange + client:create_note { dir = templates_dir, id = "zettel" } + + -- Act + templates.load_template_customizations("zettel", client) + + -- Assert + assert.equal(zettelConfig.dir, client.opts.notes_subdir) + assert.equal(zettelConfig.note_id_func, client.opts.note_id_func) + end) end) - it("should substitute custom variables", function() - local client = tmp_client() - client.opts.templates.substitutions = { - weekday = function() - return "Monday" - end, - } - local text = "today is {{weekday}}" - assert.equal("today is Monday", templates.substitute_template_variables(text, client, Note.new("foo", {}, {}))) - - -- Make sure the client opts has not been modified. - assert.equal(1, vim.tbl_count(client.opts.templates.substitutions)) - assert.equal("function", type(client.opts.templates.substitutions.weekday)) + describe("templates.restore_client_configurations()", function() + it("should do nothing if no configuration is cached", function() + -- Arrange + local old_id_func = client.opts.note_id_func + local notes_subdir = client.opts.notes_subdir + + -- Act + templates.restore_client_configurations(client) + + -- Assert + assert.equal(old_id_func, client.opts.note_id_func) + assert.equal(notes_subdir, client.opts.notes_subdir) + end) + + it("should reload client configuration after successfully loading previously", function() + -- Arrange + client:create_note { dir = templates_dir, id = "zettel" } + local old_id_func = client.opts.note_id_func + local notes_subdir = client.opts.notes_subdir + client.opts.templates.customizations = { + Zettel = zettelConfig, + } + + -- Act + templates.load_template_customizations("Zettel", client) + assert.equal(zettelConfig.dir, client.opts.notes_subdir) + assert.equal(NewNotesLocation.notes_subdir, client.opts.new_notes_location) + assert.equal(zettelConfig.note_id_func, client.opts.note_id_func) + templates.restore_client_configurations(client) + + -- Assert + assert.equal(old_id_func, client.opts.note_id_func) + assert.equal(NewNotesLocation.current_dir, client.opts.new_notes_location) + assert.equal(notes_subdir, client.opts.notes_subdir) + end) + end) + + describe("templates.substitute_template_variables()", function() + it("should substitute built-in variables", function() + local text = "today is {{date}} and the title of the note is {{title}}" + assert.equal( + string.format("today is %s and the title of the note is %s", os.date "%Y-%m-%d", "FOO"), + templates.substitute_template_variables(text, client, Note.new("FOO", { "FOO" }, {})) + ) + end) + + it("should substitute custom variables", function() + client.opts.templates.substitutions = { + weekday = function() + return "Monday" + end, + } + local text = "today is {{weekday}}" + assert.equal("today is Monday", templates.substitute_template_variables(text, client, Note.new("foo", {}, {}))) + + -- Make sure the client opts has not been modified. + assert.equal(1, vim.tbl_count(client.opts.templates.substitutions)) + assert.equal("function", type(client.opts.templates.substitutions.weekday)) + end) end) end) From cb19b7d20a9e2b1144337ff26e2de05be58a620e Mon Sep 17 00:00:00 2001 From: Ben Burgess Date: Wed, 28 May 2025 08:16:14 -0500 Subject: [PATCH 3/8] feat: allow users to add more template customizations --- lua/obsidian/commands/new_from_template.lua | 32 ++++--- .../commands/new_from_template_spec.lua | 84 +++++++++++++++++++ 2 files changed, 105 insertions(+), 11 deletions(-) create mode 100644 test/obsidian/commands/new_from_template_spec.lua diff --git a/lua/obsidian/commands/new_from_template.lua b/lua/obsidian/commands/new_from_template.lua index 19fe230f..852a8c85 100644 --- a/lua/obsidian/commands/new_from_template.lua +++ b/lua/obsidian/commands/new_from_template.lua @@ -1,5 +1,6 @@ local util = require "obsidian.util" local log = require "obsidian.log" +local templates = require "obsidian.templates" ---@param client obsidian.Client ---@param data CommandArgs @@ -12,32 +13,41 @@ return function(client, data) local title = data.fargs[1] local template = data.fargs[2] - if title ~= nil and template ~= nil then + templates.load_template_customizations(template, client) local note = client:create_note { title = title, template = template, no_write = false } client:open_note(note, { sync = true }) + templates.restore_client_configurations(client) return end - if title == nil or title == "" then - title = util.input("Enter title or path (optional): ", { completion = "file" }) - if not title then - log.warn "Aborted" - return - elseif title == "" then - title = nil - end - end - picker:find_templates { callback = function(name) + templates.load_template_customizations(name, client) + if title == nil or title == "" then + -- Must use pcall in case of KeyboardInterrupt + -- We cannot place `title` where `safe_title` is because it would be redeclaring it + local success, safe_title = pcall(util.input, "Enter title or path (optional): ", { completion = "file" }) + title = safe_title + if not success or not safe_title then + log.warn "Aborted" + templates.restore_client_configurations(client) + return + elseif safe_title == "" then + title = nil + end + end + if name == nil or name == "" then log.warn "Aborted" + templates.restore_client_configurations(client) return end + ---@type obsidian.Note local note = client:create_note { title = title, template = name, no_write = false } client:open_note(note, { sync = false }) + templates.restore_client_configurations(client) end, } end diff --git a/test/obsidian/commands/new_from_template_spec.lua b/test/obsidian/commands/new_from_template_spec.lua new file mode 100644 index 00000000..27272fc3 --- /dev/null +++ b/test/obsidian/commands/new_from_template_spec.lua @@ -0,0 +1,84 @@ +local get_tmp_client = require("test_utils").get_tmp_client +local cleanup_tmp_client = require("test_utils").cleanup_tmp_client +local new_from_template = require "obsidian.commands.new_from_template" +local spy = require "luassert.spy" + +local templates_dir = "templates" + +--- @type obsidian.config.CustomTemplateOpts +local zettelConfig = { + dir = "31 Atomic", + note_id_func = function(title) + return "31.01 - " .. title + end, +} + +describe("new_from_template", function() + --- @type obsidian.Client + local client = nil + + before_each(function() + client = get_tmp_client("new_from_template", templates_dir) + vim.loop.fs_mkdir(tostring(client.dir) .. "/" .. zettelConfig.dir, 448) + client.picker = function(_) + return { + find_templates = function(_, opts) + opts.callback() + end, + } + end + client.opts.templates.customizations = { + Zettel = zettelConfig, + } + client.opts.new_notes_location = "notes_subdir" + end) + + after_each(function() + if client then + cleanup_tmp_client(client) + end + end) + + it("should always try to load and restore template configurations", function() + -- Arrange + local templates = require "obsidian.templates" + client:create_note { dir = templates_dir, id = "zettel" } + spy.on(templates, "load_template_customizations") + spy.on(templates, "restore_client_configurations") + + -- Act + ---@diagnostic disable-next-line: missing-fields + new_from_template(client, { fargs = { "Special Title", "Zettel" } }) + + -- Assert + assert.spy(templates.load_template_customizations).was.called() + assert.spy(templates.restore_client_configurations).was.called() + end) + + it("should place matched templates in the custom directory", function() + -- Arrange + client:create_note { dir = templates_dir, id = "zettel" } + local expectedDir = client.dir / zettelConfig.dir + local title = "The Big Bang" + local id = zettelConfig.note_id_func(title) + local expected = string.format("%s/%s.md", expectedDir, id) + client.picker = function(_) + return { + find_templates = function(_, opts) + opts.callback "zettel" + end, + } + end + + -- Act + ---@diagnostic disable-next-line: missing-fields + + -- Must pass path here because we mock the fuzzy finder + new_from_template(client, { fargs = { title, "Zettel" } }) + local f = io.open(expected, "r") + + -- Assert + assert.truthy(f) + io.close(f) + end) +end) From 7d9ce5ec2bd33df61de2d06f8b3b9bd53eb37499 Mon Sep 17 00:00:00 2001 From: Ben Burgess Date: Wed, 28 May 2025 08:19:24 -0500 Subject: [PATCH 4/8] chore: changelog & readme --- CHANGELOG.md | 2 ++ README.md | 3 +++ 2 files changed, 5 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0c6c723d..5f6403af 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Added `backlinks` config table with the associated `obsidian.config.BacklinkOpts` - Added `parse_headers` toggle that disables markdown header parsing for `ObsidianBacklinks`. - Added autocmd events for user scripting, see https://github.com/obsidian-nvim/obsidian.nvim/wiki/Autocmds +- Allow custom directory and ID logic for templates ### Changed @@ -29,6 +30,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Fixed types in `_snacks.lua` - Fixed command documentation +- Fixed improper tmp-file creation during template tests ## [v3.11.0](https://github.com/obsidian-nvim/obsidian.nvim/releases/tag/v3.11.0) - 2025-05-04 diff --git a/README.md b/README.md index 82e52c92..4df3bb37 100644 --- a/README.md +++ b/README.md @@ -448,6 +448,9 @@ require("obsidian").setup { time_format = "%H:%M", -- A map for custom variables, the key should be the variable and the value a function substitutions = {}, + + -- A map for configuring unique directories and paths for specific templates + customizations = {}, }, -- Sets how you follow URLs From 3dae677428af61edd0e5d93d8c3d9e069ec9de6c Mon Sep 17 00:00:00 2001 From: Ben Burgess Date: Wed, 28 May 2025 08:41:28 -0500 Subject: [PATCH 5/8] test(fix): use correct template casing --- test/obsidian/commands/new_from_template_spec.lua | 7 +++---- test/obsidian/templates_spec.lua | 6 +++--- 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/test/obsidian/commands/new_from_template_spec.lua b/test/obsidian/commands/new_from_template_spec.lua index 27272fc3..2ae0453b 100644 --- a/test/obsidian/commands/new_from_template_spec.lua +++ b/test/obsidian/commands/new_from_template_spec.lua @@ -48,7 +48,7 @@ describe("new_from_template", function() -- Act ---@diagnostic disable-next-line: missing-fields - new_from_template(client, { fargs = { "Special Title", "Zettel" } }) + new_from_template(client, { fargs = { "Special Title", "zettel" } }) -- Assert assert.spy(templates.load_template_customizations).was.called() @@ -71,10 +71,9 @@ describe("new_from_template", function() end -- Act - ---@diagnostic disable-next-line: missing-fields - -- Must pass path here because we mock the fuzzy finder - new_from_template(client, { fargs = { title, "Zettel" } }) + ---@diagnostic disable-next-line: missing-fields + new_from_template(client, { fargs = { title, "zettel" } }) local f = io.open(expected, "r") -- Assert diff --git a/test/obsidian/templates_spec.lua b/test/obsidian/templates_spec.lua index 30fb051d..620ffaa7 100644 --- a/test/obsidian/templates_spec.lua +++ b/test/obsidian/templates_spec.lua @@ -44,7 +44,7 @@ describe("template", function() local old_id_func = client.opts.note_id_func -- Act - templates.load_template_customizations("Zettel", client) + templates.load_template_customizations("zettel", client) -- Assert assert.falsy(client.opts.notes_subdir) @@ -57,7 +57,7 @@ describe("template", function() client:create_note { dir = templates_dir, id = "zettel" } -- Act - templates.load_template_customizations("Zettel", client) + templates.load_template_customizations("zettel", client) -- Assert assert.equal(zettelConfig.dir, client.opts.notes_subdir) @@ -101,7 +101,7 @@ describe("template", function() } -- Act - templates.load_template_customizations("Zettel", client) + templates.load_template_customizations("zettel", client) assert.equal(zettelConfig.dir, client.opts.notes_subdir) assert.equal(NewNotesLocation.notes_subdir, client.opts.new_notes_location) assert.equal(zettelConfig.note_id_func, client.opts.note_id_func) From 9f4ef358d752ea691f822be22ed2e7d22d44160e Mon Sep 17 00:00:00 2001 From: Ben Burgess Date: Wed, 4 Jun 2025 08:20:45 -0500 Subject: [PATCH 6/8] fix(test): use `equality` instead of `falsy` --- tests/test_templates.lua | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_templates.lua b/tests/test_templates.lua index 13244672..e9bd9ad9 100644 --- a/tests/test_templates.lua +++ b/tests/test_templates.lua @@ -44,8 +44,8 @@ describe("template", function() templates.load_template_customizations("zettel", client) - MiniTest.expect.falsy(client.opts.notes_subdir) - MiniTest.expect.ne(zettelConfig.note_id_func, client.opts.note_id_func) + MiniTest.expect.equality(client.opts.notes_subdir, nil) + MiniTest.expect.no_equality(zettelConfig.note_id_func, client.opts.note_id_func) MiniTest.expect.equality(old_id_func, client.opts.note_id_func) end) From 572b6dd90cfd34ee749d5c19869c58d6b5822e06 Mon Sep 17 00:00:00 2001 From: Ben Burgess Date: Wed, 4 Jun 2025 08:47:51 -0500 Subject: [PATCH 7/8] fix: add `after_each` to luacheck globals --- .luacheckrc | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.luacheckrc b/.luacheckrc index 363b68da..4bda2d9c 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -7,6 +7,7 @@ self = false exclude_files = { "_neovim/*", "_runtime/*", + "deps/*", } -- Glorious list of warnings: https://luacheck.readthedocs.io/en/stable/warnings.html @@ -24,4 +25,5 @@ read_globals = { "it", "describe", "before_each", + "after_each", } From 712735da852b026ccca338f68857a49de49d1872 Mon Sep 17 00:00:00 2001 From: Ben Burgess Date: Wed, 4 Jun 2025 08:48:28 -0500 Subject: [PATCH 8/8] chore: add `deps` to `.gitignore` --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index d3041f86..a51bbf61 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ tmp.* doc/tags _neovim _runtime +deps/