From 7624d54f51907d5b0185f834c26a7b9a00ec7d86 Mon Sep 17 00:00:00 2001 From: 0xrsydn Date: Sun, 20 Sep 2026 21:26:48 +0700 Subject: [PATCH] feat(nvim): add automatic tagging for draft content --- modules/home/programs/nvim/NOTES.md | 19 +++- .../home/programs/nvim/lua/config/notes.lua | 93 +++++++++++++------ 2 files changed, 77 insertions(+), 35 deletions(-) diff --git a/modules/home/programs/nvim/NOTES.md b/modules/home/programs/nvim/NOTES.md index f69964d..c0ee976 100644 --- a/modules/home/programs/nvim/NOTES.md +++ b/modules/home/programs/nvim/NOTES.md @@ -13,6 +13,8 @@ calls the existing Bun scripts. `~/Development/MyWeb/magnum-opus/main`. - `direnv` is installed and the website `.envrc` is allowed. - The website development shell provides Bun. +- `TYPESAFE_API_KEY` or `TYPESAFEAI_API_KEY` is available for tag classification. +- `OPENROUTER_API_KEY`, `OPENAI_API_KEY`, or `TAG_GENERATOR_API_KEY` is available when a new tag must be created. Set `RASYIDANAF_SITE_ROOT` before starting Neovim to use another checkout: @@ -26,9 +28,10 @@ The Notes group uses the local leader (`\`). | Command | Mapping | Action | | --- | --- | --- | -| `:NotesNewLog` | `\nl` | Prompt for an optional title and tags, create a Log draft, and open it | -| `:NotesNewNote` | `\nn` | Prompt for a required title and optional tags, create a Note draft, and open it | +| `:NotesNewLog` | `\nl` | Prompt for an optional title, create an untagged Log draft, and open it | +| `:NotesNewNote` | `\nn` | Prompt for a required title, create an untagged Note draft, and open it | | `:NotesPublish` | `\np` | Save and publish the current Entry after confirmation | +| `:ContentAutoTag` | `\nt` | Save and automatically tag the current Draft Note or Blog Post | | `:NotesValidate` | `\nv` | Validate public Notes metadata, routes, lifecycle, tags, and media | | `:NotesBuild` | `\nb` | Run the complete production build | @@ -36,13 +39,19 @@ The Notes group uses the local leader (`\`). does not commit, push, or deploy. Review the Jujutsu diff and push the website repository separately. +`ContentAutoTag` accepts Drafts in `vault/public-notes/` and `vault/blog/`. +TypeSafe selects and validates tags. The command reuses tags collected from both +content areas. If no existing tag fits, an OpenAI-compatible generator proposes +new tags and TypeSafe validates them before the command updates frontmatter. + ## Normal workflow 1. Press `\nl` for a Log or `\nn` for a titled Note. 2. Write and save the Entry. -3. Press `\np` and confirm publication. -4. Press `\nv`, then `\nb`. -5. Review, describe, and push the website change with Jujutsu. +3. Press `\nt` to generate tags and review the notification. +4. Press `\np` and confirm publication. +5. Press `\nv`, then `\nb`. +6. Review, describe, and push the website change with Jujutsu. The publication command reloads the current buffer after the repository script writes `publishedAt` and processes referenced images. diff --git a/modules/home/programs/nvim/lua/config/notes.lua b/modules/home/programs/nvim/lua/config/notes.lua index d2cf4e5..f9cf0df 100644 --- a/modules/home/programs/nvim/lua/config/notes.lua +++ b/modules/home/programs/nvim/lua/config/notes.lua @@ -3,6 +3,7 @@ local M = {} local default_root = vim.fn.expand("~/Development/MyWeb/magnum-opus/main") local root = vim.fs.normalize(vim.env.RASYIDANAF_SITE_ROOT or default_root) local notes_directory = vim.fs.normalize(root .. "/vault/public-notes") +local blog_directory = vim.fs.normalize(root .. "/vault/blog") local function notify(message, level) vim.notify(message, level or vim.log.levels.INFO, { title = "Notes" }) @@ -78,18 +79,6 @@ local function run(arguments, options) ) end -local function prompt_tags(callback) - vim.ui.input({ - prompt = "Tags (comma-separated, optional): ", - }, function(tags) - if tags == nil then - return - end - - callback(vim.trim(tags)) - end) -end - local function open_created_entry(output) local relative_path = last_line(output) if not relative_path then @@ -106,17 +95,13 @@ local function open_created_entry(output) notify("Draft created: " .. relative_path) end -local function create_entry(kind, title, tags) +local function create_entry(kind, title) local arguments = { "notes:new", "--", "--kind", kind } if title and title ~= "" then vim.list_extend(arguments, { "--title", title }) end - if tags ~= "" then - vim.list_extend(arguments, { "--tags", tags }) - end - run(arguments, { progress = "Creating " .. kind .. " draft…", on_success = open_created_entry, @@ -131,9 +116,7 @@ function M.new_log() return end - prompt_tags(function(tags) - create_entry("log", vim.trim(title), tags) - end) + create_entry("log", vim.trim(title)) end) end @@ -151,18 +134,24 @@ function M.new_note() return end - prompt_tags(function(tags) - create_entry("note", title, tags) - end) + create_entry("note", title) end) end -local function current_entry() +local function current_path() local path = vim.fs.normalize(vim.api.nvim_buf_get_name(0)) if path == "" then notify("The current buffer has no file.", vim.log.levels.ERROR) return nil end + return path +end + +local function current_entry() + local path = current_path() + if not path then + return nil + end if not vim.startswith(path, notes_directory .. "/") or not path:match("%.md$") then notify("Open a Markdown file inside vault/public-notes/ first.", vim.log.levels.ERROR) @@ -172,18 +161,43 @@ local function current_entry() return path end +local function current_taggable_content() + local path = current_path() + if not path then + return nil + end + + local is_public_note = vim.startswith(path, notes_directory .. "/") and path:match("%.md$") + local is_blog_post = vim.startswith(path, blog_directory .. "/") + and (path:match("/index%.md$") or path:match("/index%.mdx$")) + if not is_public_note and not is_blog_post then + notify("Open a Draft in vault/public-notes/ or vault/blog/ first.", vim.log.levels.ERROR) + return nil + end + + return path +end + +local function save_current_buffer() + if not vim.bo.modified then + return true + end + local saved, error_message = pcall(vim.cmd.write) + if not saved then + notify("Could not save the content: " .. tostring(error_message), vim.log.levels.ERROR) + return false + end + return true +end + function M.publish() local path = current_entry() if not path then return end - if vim.bo.modified then - local saved, error_message = pcall(vim.cmd.write) - if not saved then - notify("Could not save the Entry: " .. tostring(error_message), vim.log.levels.ERROR) - return - end + if not save_current_buffer() then + return end vim.ui.select({ "Publish", "Cancel" }, { @@ -203,6 +217,21 @@ function M.publish() end) end +function M.auto_tag() + local path = current_taggable_content() + if not path or not save_current_buffer() then + return + end + + run({ "content:tag", path }, { + progress = "Generating tags…", + on_success = function(output) + vim.cmd.edit(vim.fn.fnameescape(path)) + notify(last_line(output) or "Tags applied.") + end, + }) +end + function M.validate() run({ "notes:validate" }, { progress = "Validating Notes…", @@ -226,6 +255,9 @@ vim.api.nvim_create_user_command("NotesNewNote", M.new_note, { vim.api.nvim_create_user_command("NotesPublish", M.publish, { desc = "Publish the current public Notes Entry", }) +vim.api.nvim_create_user_command("ContentAutoTag", M.auto_tag, { + desc = "Automatically tag the current Draft Note or Blog Post", +}) vim.api.nvim_create_user_command("NotesValidate", M.validate, { desc = "Validate public Notes", }) @@ -236,6 +268,7 @@ vim.api.nvim_create_user_command("NotesBuild", M.build, { vim.keymap.set("n", "nl", M.new_log, { desc = "New log" }) vim.keymap.set("n", "nn", M.new_note, { desc = "New note" }) vim.keymap.set("n", "np", M.publish, { desc = "Publish current note" }) +vim.keymap.set("n", "nt", M.auto_tag, { desc = "Auto-tag current draft" }) vim.keymap.set("n", "nv", M.validate, { desc = "Validate notes" }) vim.keymap.set("n", "nb", M.build, { desc = "Build website" })