From db24638cf468ae8e8fda26dce2cfa6e03bc6afe2 Mon Sep 17 00:00:00 2001 From: Anish Lakhwara Date: Mon, 16 Mar 2026 20:36:16 -0700 Subject: [PATCH] obsidian --- home/profiles/obsidian/default.nix | 12 +- home/profiles/obsidian/follow-or-create.js | 204 ++++++++++++++++++ .../{.obsidian.vimrc => obsidian.vimrc} | 32 +++ home/profiles/opencode/default.nix | 22 ++ pkgs/obsidian-plugins/default.nix | 16 ++ 5 files changed, 283 insertions(+), 3 deletions(-) create mode 100644 home/profiles/obsidian/follow-or-create.js rename home/profiles/obsidian/{.obsidian.vimrc => obsidian.vimrc} (56%) diff --git a/home/profiles/obsidian/default.nix b/home/profiles/obsidian/default.nix index 413dc23..455af64 100644 --- a/home/profiles/obsidian/default.nix +++ b/home/profiles/obsidian/default.nix @@ -511,8 +511,14 @@ in }; }; } - plugins.obsidian-vimrc-support + { + pkg = plugins.obsidian-vimrc-support; + settings = { + supportJsCommands = true; + }; + } plugins.obsidian-atmosphere + plugins.obsidian-front-matter-title-plugin ]; themes = [ @@ -557,10 +563,10 @@ in }; }; - # .obsidian.vimrc lives in the vault root, not inside .obsidian/ # Using home.file with source for symlink-based management home.file = { - "kitaab/markdown/.obsidian.vimrc".source = ./.obsidian.vimrc; + "kitaab/markdown/.obsidian.vimrc".source = ./obsidian.vimrc; + "kitaab/markdown/scripts/follow-or-create.js".source = ./follow-or-create.js; } // lib.optionalAttrs isDarwin { "usr/acreom/sourcegraph/.obsidian.vimrc".source = ./.obsidian.vimrc; diff --git a/home/profiles/obsidian/follow-or-create.js b/home/profiles/obsidian/follow-or-create.js new file mode 100644 index 0000000..1fd3e2e --- /dev/null +++ b/home/profiles/obsidian/follow-or-create.js @@ -0,0 +1,204 @@ +// follow-or-create.js — obsidian-vimrc-support jsfile command +// Args from plugin: editor, view, selection +// Executed via Function() constructor — return value is NOT awaited. + +(async function() { + try { + var app = view.app; + var activeFile = view.file; + var sourcePath = activeFile ? activeFile.path : ""; + var cur = editor.getCursor(); + var line = editor.getLine(cur.line); + + var text = ""; + var rangeFrom = null; + var rangeTo = null; + var insideWikilink = false; + + // --- Check if cursor is inside a [[wikilink]] --- + var before = line.substring(0, cur.ch); + var after = line.substring(cur.ch); + var openIdx = before.lastIndexOf("[["); + var closeInBefore = before.lastIndexOf("]]"); + var closeIdx = after.indexOf("]]"); + + if (openIdx !== -1 && (closeInBefore === -1 || closeInBefore < openIdx) && closeIdx !== -1) { + insideWikilink = true; + var fullLink = line.substring(openIdx + 2, cur.ch + closeIdx); + text = fullLink.split("|")[0].trim(); + rangeFrom = { line: cur.line, ch: openIdx }; + rangeTo = { line: cur.line, ch: cur.ch + closeIdx + 2 }; + } + + // --- Visual mode: use the `selection` parameter from the plugin --- + // The ':' keystroke collapses the editor selection before the ex-command + // runs, so editor.getSelection() returns "". However, the plugin captures + // the selection on cursorActivity (before collapse) and passes it as the + // `selection` argument. We use it when anchor !== head. + if (!insideWikilink && !text && selection && selection.anchor && selection.head) { + var a = selection.anchor; + var h = selection.head; + if (a.line !== h.line || a.ch !== h.ch) { + if (a.line < h.line || (a.line === h.line && a.ch <= h.ch)) { + rangeFrom = { line: a.line, ch: a.ch }; + rangeTo = { line: h.line, ch: h.ch }; + } else { + rangeFrom = { line: h.line, ch: h.ch }; + rangeTo = { line: a.line, ch: a.ch }; + } + text = editor.getRange(rangeFrom, rangeTo).trim(); + } + } + + // --- Normal mode: word under cursor --- + if (!insideWikilink && !text) { + var isWordChar = function(c) { + if (!c) return false; + if (c === "'" || c === "\u2019") return true; // straight and curly apostrophe + return /[\p{L}\p{N}_\-]/u.test(c); + }; + var start = cur.ch; + var end = cur.ch; + while (start > 0 && isWordChar(line[start - 1])) start--; + while (end < line.length && isWordChar(line[end])) end++; + text = line.substring(start, end); + rangeFrom = { line: cur.line, ch: start }; + rangeTo = { line: cur.line, ch: end }; + } + + if (!text) return; + + // --- If inside a wikilink, just open it in a new tab --- + if (insideWikilink) { + await app.workspace.openLinkText(text, sourcePath, "tab"); + return; + } + + // --- Sanitize text for use in [[wikilink|alias]] syntax --- + // Pipe and closing brackets would break the link. + var safeAlias = text.replace(/\|/g, "-").replace(/\]\]/g, ")"); + + // --- Search for existing note by link path OR resolved title --- + var foundFile = null; + + // First: try standard link-path resolution (handles basenames, paths, etc.) + foundFile = app.metadataCache.getFirstLinkpathDest(text, sourcePath); + + // Second: use obsidian-front-matter-title plugin's resolver if available. + // This respects the plugin's configured template (e.g. "title", "foo.bar") + // so we don't hardcode which frontmatter key holds the display name. + // Falls back to raw frontmatter.title if the plugin isn't installed. + if (!foundFile) { + var fmtPlugin = app.plugins.getPlugin("obsidian-front-matter-title-plugin"); + var resolver = null; + if (fmtPlugin && fmtPlugin.getDefer) { + var defer = fmtPlugin.getDefer(); + if (defer && defer.isPluginReady && defer.isPluginReady()) { + var api = defer.getApi(); + if (api) { + var factory = api.getResolverFactory(); + if (factory) { + resolver = factory.createResolver("explorer"); + } + } + } + } + + var allFiles = app.vault.getMarkdownFiles(); + var lowerText = text.toLowerCase(); + for (var i = 0; i < allFiles.length; i++) { + var resolved = null; + if (resolver) { + resolved = resolver.resolve(allFiles[i].path); + } + if (!resolved) { + var cache = app.metadataCache.getFileCache(allFiles[i]); + resolved = cache && cache.frontmatter && cache.frontmatter.title + ? String(cache.frontmatter.title) + : null; + } + if (resolved && resolved.toLowerCase() === lowerText) { + foundFile = allFiles[i]; + break; + } + } + } + + if (foundFile) { + var linkStr = foundFile.basename === text + ? "[[" + text + "]]" + : "[[" + foundFile.basename + "|" + safeAlias + "]]"; + editor.replaceRange(linkStr, rangeFrom, rangeTo); + await app.workspace.openLinkText(foundFile.path, sourcePath, "tab"); + return; + } + + // --- Create a new note with zk-prefixer style ID --- + var now = new Date(); + var pad = function(n, w) { return String(n).padStart(w, "0"); }; + var yy = pad(now.getFullYear() % 100, 2); + var mm = pad(now.getMonth() + 1, 2); + var dd = pad(now.getDate(), 2); + var hh = pad(now.getHours(), 2); + var mi = pad(now.getMinutes(), 2); + var zkId = yy + mm + dd + "-" + hh + mi; + var fileName = zkId + ".md"; + + // Handle collision: if file with same zkId exists, append a letter suffix + var suffix = ""; + var alphabet = "abcdefghijklmnopqrstuvwxyz"; + while (app.vault.getAbstractFileByPath(fileName)) { + if (suffix === "") { + suffix = "a"; + } else { + var idx = alphabet.indexOf(suffix); + if (idx >= alphabet.length - 1) { + throw new Error("Too many notes created this minute (" + zkId + "a-z exhausted)"); + } + suffix = alphabet[idx + 1]; + } + fileName = zkId + suffix + ".md"; + } + var noteId = suffix ? zkId + suffix : zkId; + + // Sanitize title for YAML: backslashes first, then double quotes, then newlines + var safeTitle = text + .replace(/\\/g, "\\\\") + .replace(/"/g, '\\"') + .replace(/[\r\n]+/g, " "); + + var frontmatter = [ + "---", + 'title: "' + safeTitle + '"', + "tags:", + 'date: "' + zkId + '"', + "update:", + "---", + "", + ].join("\n"); + + // Do editor replacement BEFORE async vault operation to avoid stale positions. + // Save original text for rollback if vault.create fails. + var originalText = editor.getRange(rangeFrom, rangeTo); + var linkStr = "[[" + noteId + "|" + safeAlias + "]]"; + editor.replaceRange(linkStr, rangeFrom, rangeTo); + + try { + await app.vault.create(fileName, frontmatter); + } catch(createErr) { + // Rollback: restore original text since the note wasn't created + var linkEnd = { + line: rangeFrom.line, + ch: rangeFrom.ch + linkStr.length + }; + editor.replaceRange(originalText, rangeFrom, linkEnd); + throw createErr; + } + + await app.workspace.openLinkText(noteId, sourcePath, "tab"); + + } catch(e) { + console.error("followOrCreate error:", e); + try { new Notice("followOrCreate: " + e.message, 5000); } catch(_) {} + } +})(); diff --git a/home/profiles/obsidian/.obsidian.vimrc b/home/profiles/obsidian/obsidian.vimrc similarity index 56% rename from home/profiles/obsidian/.obsidian.vimrc rename to home/profiles/obsidian/obsidian.vimrc index 51d5679..8a337fb 100644 --- a/home/profiles/obsidian/.obsidian.vimrc +++ b/home/profiles/obsidian/obsidian.vimrc @@ -38,6 +38,10 @@ nmap gT :prevTab exmap closeTab obcommand workspace:close nmap gd :closeTab +" Jump past frontmatter and enter insert mode +exmap goBody jscommand { var lines = editor.getValue().split("\n"); var count = 0; for (var i = 0; i < lines.length; i++) { if (lines[i].trim() === "---") { count++; if (count === 2) { editor.setCursor(i + 1, 0); return; } } } editor.setCursor(0, 0); } +nmap gi :goBodyi + " --- Notes --- " Daily note (Periodic Notes) exmap dailyNote obcommand periodic-notes:open-daily-note @@ -68,6 +72,34 @@ nmap za :toggleFold nmap zR :unfoldAll nmap zM :foldAll +" --- Spelling --- +" Open spelling suggestions via the editor suggest/context menu +exmap spellcheck jscommand { var pos = view.editor.cm.coordsAtPos(view.editor.cm.state.selection.main.head); view.editor.cm.dom.dispatchEvent(new MouseEvent("contextmenu", {bubbles: true, cancelable: true, clientX: pos.left, clientY: pos.top})); } +nmap z= :spellcheck + " --- Quick Switcher --- exmap quickSwitcher obcommand switcher:open nmap :quickSwitcher + +" --- Search --- +exmap globalSearch obcommand global-search:open +nmap fg :globalSearch + +" --- Surround (vim-surround style) --- +" The plugin's built-in surroundOperator acts as a Vim operator: +" ys{motion}{char} in normal mode (e.g. ysiw" to surround word with quotes) +" S{char} in visual mode (e.g. viwS( to surround selection with parens) +" It opens a prompt for the surround character; brackets auto-match. +nunmap s +vunmap s +nmap ys s +vmap S s + +" --- Follow or Create Note --- +" Enter in normal/visual mode: if on a [[wikilink]], open it in a new tab. +" Otherwise, check if a note with the word/selection as title exists: +" - If it does, wrap in [[link]] and open in a new tab. +" - If not, create a new zk-prefixed note with that title and link to it. +exmap followOrCreate jsfile scripts/follow-or-create.js +nmap :followOrCreate +vmap :followOrCreate diff --git a/home/profiles/opencode/default.nix b/home/profiles/opencode/default.nix index 41e0001..e4f736b 100644 --- a/home/profiles/opencode/default.nix +++ b/home/profiles/opencode/default.nix @@ -17,6 +17,23 @@ let # github-mcp-server binary path from nixpkgs githubMcpServer = "${pkgs.github-mcp-server}/bin/github-mcp-server"; + + # opencode-handoff plugin: fetch source and assemble into a single directory + # so the relative import from the entry point resolves correctly + opencode-handoff-src = pkgs.fetchFromGitHub { + owner = "Chickensoupwithrice"; + repo = "opencode-handoff"; + rev = "e66697d"; + hash = "sha256-/drpkGLxKmoYlo3MZqYnQSedwLWHl7TQuO+NkY21xuQ="; + }; + + opencode-handoff-plugin = pkgs.runCommand "opencode-handoff-plugin" { } '' + mkdir -p $out + cat > $out/handoff.ts <<'ENTRY' + export { HandoffPlugin } from "./handoff-src/plugin" + ENTRY + ln -s ${opencode-handoff-src}/src $out/handoff-src + ''; in { home.packages = [ @@ -66,6 +83,11 @@ in "opencode/agents".source = ./agents; "opencode/commands".source = ./commands; "opencode/skills".source = ./skills; + + # opencode-handoff plugin: single derivation with entry point + source + # so relative imports resolve correctly (home-manager would otherwise + # place them in separate nix store paths) + "opencode/plugins".source = opencode-handoff-plugin; }; home.file = lib.mkIf isBox { diff --git a/pkgs/obsidian-plugins/default.nix b/pkgs/obsidian-plugins/default.nix index 2ae23d3..0d63918 100644 --- a/pkgs/obsidian-plugins/default.nix +++ b/pkgs/obsidian-plugins/default.nix @@ -163,6 +163,22 @@ in meta.description = "Auto-load a startup file with Obsidian Vim commands"; }; + obsidian-front-matter-title-plugin = buildObsidianPlugin { + pname = "obsidian-front-matter-title-plugin"; + version = "3.13.1"; + owner = "Snezhig"; + repo = "obsidian-front-matter-title"; + mainJs = fetchurl { + url = "https://github.com/Snezhig/obsidian-front-matter-title/releases/download/3.13.1/main.js"; + sha256 = "0azbj27xyx8g3n7iyc481ndp9q1nqh6g7p34ngg892xc880a9gkn"; + }; + manifestJson = fetchurl { + url = "https://github.com/Snezhig/obsidian-front-matter-title/releases/download/3.13.1/manifest.json"; + sha256 = "0kbs75d0xs19cmhs3kpmd48wk5832z1krdlw1hv998ararj9ww6q"; + }; + meta.description = "Display frontmatter title instead of filename in explorer, graph, etc."; + }; + obsidian-atmosphere = buildObsidianPlugin { pname = "obsidian-atmosphere"; version = "0.1.19"; -- 2.51.2