/** * QuickAdd user script that recreates the core commands from the * luhman-obsidian-plugin without requiring the original plugin. * * Exports individual helpers (`::createChildZettel`, `::insertZettelLink`, ...) * and a default `entry` function that lets you pick an action via a suggester. */ const CHECK_SETTINGS_MESSAGE = "Try adjusting the script settings if this seems wrong."; const idOnlyRegex = /([0-9]+|[a-z]+)/g; const lettersIDComponentSuccessors = { a: "b", b: "c", c: "d", d: "e", e: "f", f: "g", g: "h", h: "i", i: "j", j: "k", k: "l", l: "m", m: "n", n: "o", o: "p", p: "q", q: "r", r: "s", s: "t", t: "u", u: "v", v: "w", w: "x", x: "y", y: "z", z: "aa", }; const DEFAULT_SETTINGS = { matchRule: "strict", separator: "⁝ ", addTitle: false, addAlias: false, useLinkAlias: false, templateFile: "", templateRequireTitle: true, templateRequireLink: true, }; const OPTION_KEYS = { matchRule: "ID Matching Rule", separator: "ID Separator", addTitle: "Append Title To Filename", addAlias: "Add Title Alias To Frontmatter", useLinkAlias: "Use Title Alias In Inserted Links", templateFile: "Template File Path", templateRequireTitle: "Template Requires {{title}}", templateRequireLink: "Template Requires {{link}}", }; const ACTIONS = [ { key: "createSibling", label: "Create sibling zettel", runner: (engine) => engine.createSibling(true), }, { key: "createSiblingSilent", label: "Create sibling zettel (stay on current note)", runner: (engine) => engine.createSibling(false), }, { key: "createChild", label: "Create child zettel", runner: (engine) => engine.createChild(true), }, { key: "createChildSilent", label: "Create child zettel (stay on current note)", runner: (engine) => engine.createChild(false), }, { key: "insertZettelLink", label: "Insert link to existing zettel", runner: (engine) => engine.insertZettelLink(), }, { key: "openZettelByTitle", label: "Open zettel by markdown title", runner: (engine) => engine.openZettelByTitle(), }, { key: "openParentZettel", label: "Open parent zettel", runner: (engine) => engine.openParentZettel(), }, { key: "outdentZettel", label: "Outdent current zettel", runner: (engine) => engine.outdentActiveZettel(), }, ]; const ACTION_LOOKUP = Object.fromEntries( ACTIONS.map((action) => [action.key, action.runner]) ); const script = { entry: async (params, settings = {}) => { const engine = new LuhmannQuickAddEngine(params, settings); const labels = ACTIONS.map((action) => action.label); const keys = ACTIONS.map((action) => action.key); const selection = await params.quickAddApi.suggester( labels, keys, "Select a Luhmann action" ); if (!selection) return; await ACTION_LOOKUP[selection](engine); }, settings: { name: "Luhmann QuickAdd Toolkit", author: "QuickAdd", options: { [OPTION_KEYS.matchRule]: { type: "dropdown", options: ["strict", "separator", "fuzzy"], defaultValue: DEFAULT_SETTINGS.matchRule, description: "Strict = filename is only the ID. Separator = enforce the separator after the ID. Fuzzy = stop at the first non-alphanumeric character.", }, [OPTION_KEYS.separator]: { type: "text", defaultValue: DEFAULT_SETTINGS.separator, description: "Used between the ID and the title when titles are appended.", }, [OPTION_KEYS.addTitle]: { type: "toggle", defaultValue: DEFAULT_SETTINGS.addTitle, description: "Append the title after the ID when creating files.", }, [OPTION_KEYS.addAlias]: { type: "toggle", defaultValue: DEFAULT_SETTINGS.addAlias, description: "Add the created title to the note's frontmatter aliases.", }, [OPTION_KEYS.useLinkAlias]: { type: "toggle", defaultValue: DEFAULT_SETTINGS.useLinkAlias, description: "Use the title as an alias in links that get inserted into the current note.", }, [OPTION_KEYS.templateFile]: { type: "text", defaultValue: DEFAULT_SETTINGS.templateFile, description: "Optional vault path to a template containing {{title}} and/or {{link}} placeholders.", }, [OPTION_KEYS.templateRequireTitle]: { type: "toggle", defaultValue: DEFAULT_SETTINGS.templateRequireTitle, description: "Require {{title}} to exist inside the template file before running.", }, [OPTION_KEYS.templateRequireLink]: { type: "toggle", defaultValue: DEFAULT_SETTINGS.templateRequireLink, description: "Require {{link}} to exist inside the template file before running.", }, }, }, }; const ACTION_EXPORTS = [ { exportName: "createSiblingZettel", key: "createSibling" }, { exportName: "createSiblingZettelNoOpen", key: "createSiblingSilent" }, { exportName: "createChildZettel", key: "createChild" }, { exportName: "createChildZettelNoOpen", key: "createChildSilent" }, { exportName: "insertZettelLink", key: "insertZettelLink" }, { exportName: "openZettel", key: "openZettelByTitle" }, { exportName: "openParentZettel", key: "openParentZettel" }, { exportName: "outdentZettel", key: "outdentZettel" }, ]; for (const { exportName, key } of ACTION_EXPORTS) { script[exportName] = createRunnerForAction(key); } module.exports = script; function createRunnerForAction(actionKey) { const runner = ACTION_LOOKUP[actionKey]; return async (params, settings = {}) => { const engine = new LuhmannQuickAddEngine(params, settings); return await runner(engine); }; } class LuhmannQuickAddEngine { constructor(params, rawSettings = {}) { this.app = params.app; this.params = params; this.quickAddApi = params.quickAddApi; this.obsidian = params.obsidian || globalThis.obsidian || {}; this.MarkdownView = this.obsidian.MarkdownView; this.Notice = this.obsidian.Notice || globalThis.Notice || FakeNotice; this.settings = normalizeSettings(rawSettings); } notice(message, duration = 5000) { try { new this.Notice(`[Luhmann QuickAdd] ${message}`, duration); } catch (error) { console.log(`[Luhmann QuickAdd] ${message}`); } } currentFile() { return this.app.workspace.getActiveFile(); } getEditor() { if (this.MarkdownView) { return this.app.workspace.getActiveViewOfType(this.MarkdownView)?.editor; } const leaf = this.app.workspace.getActiveViewOfType?.("markdown"); return leaf?.editor; } isZettelFile(name) { const match = /(.*)\.md$/.exec(name); return Boolean(match && this.fileToId(match[1])); } fileToId(basename) { const separator = escapeRegExp(this.settings.separator || ""); const ruleRegexes = { strict: /^((?:[0-9]+|[a-z]+)+)$/, separator: new RegExp(`^((?:[0-9]+|[a-z]+)+)${separator}.*`), fuzzy: /^((?:[0-9]+|[a-z]+)+).*/, }; const rule = ruleRegexes[this.settings.matchRule] || ruleRegexes.strict; const match = basename.match(rule); return match ? match[1] : ""; } idExists(id) { return this.app.vault .getMarkdownFiles() .some((file) => this.fileToId(file.basename) === id); } firstAvailableID(startingID) { let next = startingID; while (this.idExists(next)) { next = this.incrementID(next); } return next; } incrementID(id) { const parts = id.match(idOnlyRegex); if (!parts || parts.length === 0) return id; const last = parts.pop(); return parts.concat([this.incrementIDComponent(last)]).join(""); } incrementIDComponent(idComponent) { if (/^\d+$/.test(idComponent)) { return (parseInt(idComponent, 10) + 1).toString(); } return this.incrementStringIDComponent(idComponent); } incrementStringIDComponent(component) { const bits = component.split(""); const last = bits.pop(); const next = lettersIDComponentSuccessors[last]; if (!next) { return component + "a"; } return bits.concat([next]).join(""); } parentID(id) { const parts = id.match(idOnlyRegex); if (!parts || parts.length === 0) return ""; parts.pop(); return parts.join(""); } nextComponentOf(id) { const parts = id.match(idOnlyRegex); if (!parts || parts.length === 0) return "1"; const last = parts.pop(); return /^\d+$/.test(last) ? "a" : "1"; } firstChildOf(parentID) { return parentID + this.nextComponentOf(parentID); } makeNoteForNextSiblingOf(file) { return this.firstAvailableID( this.incrementID(this.fileToId(file.basename)) ); } makeNoteForNextChildOf(file) { return this.firstAvailableID( this.firstChildOf(this.fileToId(file.basename)) ); } async createSibling(openNewFile) { return this.createLinkedZettel({ openNewFile, idFactory: (file) => this.makeNoteForNextSiblingOf(file), }); } async createChild(openNewFile) { return this.createLinkedZettel({ openNewFile, idFactory: (file) => this.makeNoteForNextChildOf(file), }); } async createLinkedZettel({ openNewFile, idFactory }) { const file = this.currentFile(); if (!file) { this.notice("No active file."); return; } if (!this.isZettelFile(file.name)) { this.notice(`Couldn't find an ID in "${file.basename}". ${CHECK_SETTINGS_MESSAGE}`); return; } const editor = this.getEditor(); if (!editor) { this.notice("Open a Markdown editor pane before running this script."); return; } const nextID = idFactory(file); const parentLink = `[[${file.basename}]]`; const selectionText = editor.getSelection(); let replacementRange = null; let spacesBefore = 0; let spacesAfter = 0; let title = ""; if (selectionText) { const info = this.extractSelectionInfo(selectionText); title = info.title; spacesBefore = info.spacesBefore; spacesAfter = info.spacesAfter; replacementRange = this.getOrderedSelectionRange(editor); } else { title = await this.promptForTitle(); } const filename = this.buildFilename(nextID, title); const folder = this.app.fileManager.getNewFileParent(file.path).path; const path = joinPath(folder, `${filename}.md`); const linkText = this.buildLinkText(nextID, title); const successCallback = replacementRange ? () => { const prefix = " ".repeat(spacesBefore); const suffix = " ".repeat(spacesAfter); editor.replaceRange( `${prefix}${linkText}${suffix}`, replacementRange.from, replacementRange.to ); } : () => this.insertLinkAtCursor(editor, linkText); await this.makeNote({ path, title, fileLink: parentLink, placeCursorAtStart: true, openZettel: openNewFile, successCallback, }); } async insertZettelLink() { const entries = await this.getZettelTitleEntries(); if (entries.length === 0) { this.notice("No zettels found."); return; } const display = entries.map( (entry) => `${entry.title} (${entry.file.basename})` ); const selected = await this.quickAddApi.suggester( display, entries, "Insert which zettel?" ); if (!selected) return; const editor = this.getEditor(); if (!editor) { this.notice("Open a Markdown editor pane before inserting links."); return; } const text = `[[${selected.file.basename}]]`; this.insertLinkAtCursor(editor, text); } async openZettelByTitle() { const entries = await this.getZettelTitleEntries(); if (entries.length === 0) { this.notice("No zettels available to open."); return; } const display = entries.map( (entry) => `${entry.title} (${entry.file.basename})` ); const selected = await this.quickAddApi.suggester( display, entries, "Open which zettel?" ); if (!selected) return; await this.app.workspace.getLeaf().openFile(selected.file); } async openParentZettel() { const file = this.currentFile(); if (!file) { this.notice("No active file."); return; } const id = this.fileToId(file.basename); const parentId = this.parentID(id); if (!parentId) { this.notice(`No parent found for "${file.basename}". ${CHECK_SETTINGS_MESSAGE}`); return; } const parentFile = this.findZettelById(parentId); if (!parentFile) { this.notice(`Couldn't find file for ID ${parentId}. ${CHECK_SETTINGS_MESSAGE}`); return; } await this.app.workspace.getLeaf().openFile(parentFile); } async outdentActiveZettel() { const file = this.currentFile(); if (!file) { this.notice("No active file."); return; } const id = this.fileToId(file.basename); if (!id) { this.notice(`Couldn't read the ID from "${file.basename}". ${CHECK_SETTINGS_MESSAGE}`); return; } await this.outdentZettel(id); } findZettelById(id) { return this.app.vault .getMarkdownFiles() .find((file) => this.fileToId(file.basename) === id); } async renameZettel(id, toId) { const target = this.findZettelById(id); if (!target) { this.notice(`Couldn't find file for ID ${id}. ${CHECK_SETTINGS_MESSAGE}`); return; } const rest = target.basename.replace(this.fileToId(target.basename), ""); const dir = target.parent?.path || ""; const nextPath = joinPath(dir, `${toId}${rest}.${target.extension}`); await this.app.fileManager.renameFile(target, nextPath); } async moveChildrenDown(id) { const children = this.getDirectChildZettels(id); for (const child of children) { await this.moveZettelDown(this.fileToId(child.basename)); } } async moveZettelDown(id) { await this.moveChildrenDown(id); await this.renameZettel(id, this.firstAvailableID(id)); } async outdentZettel(id) { const newID = this.incrementID(this.parentID(id)); if (this.idExists(newID)) { await this.moveZettelDown(newID); } const childZettels = this.getDirectChildZettels(id); for (const child of childZettels) { const childID = this.firstAvailableID(this.firstChildOf(newID)); await this.renameZettel(this.fileToId(child.basename), childID); } await this.renameZettel(id, newID); } getZettels() { return this.app.vault.getMarkdownFiles().filter((file) => { const shouldIgnore = /^(_layouts|templates|scripts)/.test(file.path); return !shouldIgnore && this.fileToId(file.basename) !== ""; }); } getDirectChildZettels(parentId) { return this.getZettels().filter((file) => { return this.parentID(this.fileToId(file.basename)) === parentId; }); } async getZettelTitleEntries() { const regex = /^#\s+(.+)$/m; const entries = []; for (const file of this.getZettels()) { const text = await this.app.vault.cachedRead(file); const match = text.match(regex); entries.push({ title: match ? match[1].trim() : file.basename, file, }); } return entries.sort((a, b) => a.title.localeCompare(b.title)); } buildFilename(id, title) { if (this.settings.addTitle && title) { return `${id}${this.settings.separator}${title}`; } return id; } buildLinkText(id, title) { const alias = this.settings.useLinkAlias && title ? `|${title}` : ""; const suffix = this.settings.addTitle && title ? `${this.settings.separator}${title}` : ""; return `[[${id}${suffix}${alias}]]`; } insertLinkAtCursor(editor, text) { const cursor = editor.getCursor(); editor.replaceRange(text, cursor, cursor); } getOrderedSelectionRange(editor) { const selection = editor.listSelections?.()[0]; if (!selection) return null; const { anchor, head } = selection; const anchorBeforeHead = anchor.line < head.line || (anchor.line === head.line && anchor.ch <= head.ch); return anchorBeforeHead ? { from: anchor, to: head } : { from: head, to: anchor }; } extractSelectionInfo(selectionText) { const trimStart = selectionText.trimStart(); const trimBoth = trimStart.trimEnd(); const spacesBefore = selectionText.length - trimStart.length; const spacesAfter = trimStart.length - trimBoth.length; return { spacesBefore, spacesAfter, title: toTitleCase(trimBoth), }; } async promptForTitle() { try { const value = await this.quickAddApi.inputPrompt( "Zettel title", "Title (optional)", "" ); return value || ""; } catch (error) { throw error; } } async makeNote({ path, title, fileLink, placeCursorAtStart, openZettel, successCallback, }) { const useTemplate = Boolean(this.settings.templateFile.trim()); const headingText = title ? (useTemplate ? title.trimStart() : `# ${title.trimStart()}`) : ""; const backlinkRegex = /{{link}}/g; const titleRegex = /{{title}}/g; let file; if (useTemplate) { const templatePath = this.settings.templateFile.trim(); let templateContent = ""; try { templateContent = await this.app.vault.adapter.read(templatePath); } catch (error) { this.notice( `Couldn't read template file at "${templatePath}". Check the script settings.`, 15000 ); return; } const requiresTitle = this.settings.templateRequireTitle; const requiresLink = this.settings.templateRequireLink; if ( (requiresTitle && !titleRegex.test(templateContent)) || (requiresLink && !backlinkRegex.test(templateContent)) ) { this.notice( "Template is missing required {{title}} and/or {{link}} placeholders.", 15000 ); return; } const content = templateContent .replace(titleRegex, headingText) .replace(backlinkRegex, fileLink || ""); file = await this.app.vault.create(path, content); } else { const fullContent = `${headingText}\n\n${fileLink || ""}`; file = await this.app.vault.create(path, fullContent); } if (typeof successCallback === "function") { await successCallback(); } if (this.settings.addAlias && file && title) { await this.app.fileManager.processFrontMatter(file, (frontmatter = {}) => { const aliases = Array.isArray(frontmatter.aliases) ? frontmatter.aliases : frontmatter.aliases ? [frontmatter.aliases] : []; if (!aliases.includes(title)) { aliases.push(title); } frontmatter.aliases = aliases; return frontmatter; }); } if (!openZettel) return; const leaf = this.app.workspace.getLeaf(); if (!leaf) return; await leaf.openFile(file); if (placeCursorAtStart && !useTemplate) { const editor = this.getEditor(); if (!editor) return; let line = 2; if (this.settings.addAlias) { line += 4; } editor.setCursor({ line, ch: 0 }); } else { const editor = this.getEditor(); if (editor?.exec) { editor.exec("goEnd"); } else if (editor) { const lastLine = editor.lastLine ? editor.lastLine() : editor.lineCount?.() - 1 || 0; const lastCh = editor.getLine ? editor.getLine(lastLine).length : 0; editor.setCursor({ line: lastLine, ch: lastCh }); } } } } function normalizeSettings(settings = {}) { return { matchRule: settings[OPTION_KEYS.matchRule] || DEFAULT_SETTINGS.matchRule, separator: settings[OPTION_KEYS.separator] ?? DEFAULT_SETTINGS.separator, addTitle: Boolean( settings[OPTION_KEYS.addTitle] ?? DEFAULT_SETTINGS.addTitle ), addAlias: Boolean( settings[OPTION_KEYS.addAlias] ?? DEFAULT_SETTINGS.addAlias ), useLinkAlias: Boolean( settings[OPTION_KEYS.useLinkAlias] ?? DEFAULT_SETTINGS.useLinkAlias ), templateFile: (settings[OPTION_KEYS.templateFile] ?? DEFAULT_SETTINGS.templateFile).trim(), templateRequireTitle: Boolean( settings[OPTION_KEYS.templateRequireTitle] ?? DEFAULT_SETTINGS.templateRequireTitle ), templateRequireLink: Boolean( settings[OPTION_KEYS.templateRequireLink] ?? DEFAULT_SETTINGS.templateRequireLink ), }; } function escapeRegExp(text) { return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); } function toTitleCase(text) { if (!text) return ""; return text .split(/\s+/) .filter(Boolean) .map((word) => word[0].toUpperCase() + word.slice(1)) .join(" "); } function joinPath(folder, filename) { if (!folder) return filename; return folder.endsWith("/") ? `${folder}${filename}` : `${folder}/${filename}`; } class FakeNotice { constructor(message) { console.log(message); } }