diff --git a/dist/folder-notes-cli.cjs b/dist/folder-notes-cli.cjs new file mode 100755 index 0000000..e08e4a7 --- /dev/null +++ b/dist/folder-notes-cli.cjs @@ -0,0 +1,608 @@ +#!/usr/bin/env node +var __create = Object.create; +var __defProp = Object.defineProperty; +var __getOwnPropDesc = Object.getOwnPropertyDescriptor; +var __getOwnPropNames = Object.getOwnPropertyNames; +var __getProtoOf = Object.getPrototypeOf; +var __hasOwnProp = Object.prototype.hasOwnProperty; +var __copyProps = (to, from, except, desc) => { + if (from && typeof from === "object" || typeof from === "function") { + for (let key of __getOwnPropNames(from)) + if (!__hasOwnProp.call(to, key) && key !== except) + __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); + } + return to; +}; +var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, mod)); + +// src/cli/folder-notes.ts +var fs = __toESM(require("fs/promises")); +var path = __toESM(require("path")); + +// src/core/folderNotesCore.ts +var CORE_DEFAULT_SETTINGS = { + folderNoteName: "{{folder_name}}", + folderNoteType: ".md", + supportedFileTypes: ["md", "canvas", "base"], + storageLocation: "insideFolder", + templatePath: "", + excludeFolders: [] +}; +function mergeCoreSettings(settings) { + return { + ...CORE_DEFAULT_SETTINGS, + ...settings, + supportedFileTypes: (settings == null ? void 0 : settings.supportedFileTypes) ?? CORE_DEFAULT_SETTINGS.supportedFileTypes, + excludeFolders: (settings == null ? void 0 : settings.excludeFolders) ?? CORE_DEFAULT_SETTINGS.excludeFolders + }; +} +function normalizeVaultPath(input) { + const normalized = input.replace(/\\/g, "/").replace(/^\.\/+/, "").replace(/^\/+/, ""); + const parts = normalized.split("/").filter((part) => part.length > 0 && part !== "."); + if (parts.some((part) => part === "..")) { + throw new Error(`Vault-relative paths cannot contain '..': ${input}`); + } + return parts.join("/"); +} +function getFolderName(folderPath) { + const normalized = normalizeVaultPath(folderPath); + const parts = normalized.split("/").filter(Boolean); + return parts[parts.length - 1] ?? ""; +} +function getParentFolderPath(vaultPath) { + const normalized = normalizeVaultPath(vaultPath); + const index = normalized.lastIndexOf("/"); + if (index < 0) + return ""; + return normalized.slice(0, index); +} +function normalizeExtension(extension) { + const trimmed = extension.trim(); + if (!trimmed) + return ".md"; + return trimmed.startsWith(".") ? trimmed : `.${trimmed}`; +} +function normalizeFolderNoteType(type) { + return normalizeExtension(type) === ".excalidraw" ? ".md" : normalizeExtension(type); +} +function extensionForSupportedType(type) { + const extension = normalizeExtension(type); + return extension === ".excalidraw" ? ".md" : extension; +} +function buildFolderNoteResolution(folderPathInput, settingsInput, extensionInput) { + const settings = mergeCoreSettings(settingsInput); + const folderPath = normalizeVaultPath(folderPathInput); + const folderName = getFolderName(folderPath); + if (!folderPath || !folderName) { + throw new Error("A non-root folder path is required"); + } + const fileName = settings.folderNoteName.replace("{{folder_name}}", folderName); + const extension = normalizeFolderNoteType(extensionInput ?? settings.folderNoteType); + let noteFolder = folderPath; + if (settings.storageLocation === "parentFolder") { + noteFolder = getParentFolderPath(folderPath); + } else if (settings.storageLocation === "vaultFolder") { + noteFolder = ""; + } + const notePath = normalizeVaultPath(noteFolder ? `${noteFolder}/${fileName}${extension}` : `${fileName}${extension}`); + return { + folderPath, + folderName, + fileName, + extension, + notePath, + storageLocation: settings.storageLocation + }; +} +function candidateFolderNotePaths(folderPathInput, settingsInput) { + const settings = mergeCoreSettings(settingsInput); + const primary = buildFolderNoteResolution(folderPathInput, settings); + const extensions = [ + primary.extension, + ...settings.supportedFileTypes.map(extensionForSupportedType) + ].filter((extension, index, all) => all.indexOf(extension) === index); + return extensions.map((extension) => buildFolderNoteResolution(folderPathInput, settings, extension)); +} +function detachFolderInSettings(settingsInput, folderPathInput, notePathInput) { + const settings = mergeCoreSettings(settingsInput); + const folderPath = normalizeVaultPath(folderPathInput); + const notePath = normalizeVaultPath(notePathInput); + const excludeFolders = settings.excludeFolders.filter((folder) => !(folder.path === folderPath && folder.detached)); + excludeFolders.push({ + type: "folder", + id: createId(), + path: folderPath, + position: excludeFolders.length, + subFolders: false, + disableSync: true, + disableFolderNote: true, + disableAutoCreate: true, + enableCollapsing: false, + excludeFromFolderOverview: false, + hideInSettings: true, + detached: true, + detachedFilePath: notePath, + showFolderNote: true + }); + return { + ...settings, + excludeFolders + }; +} +function reattachFolderInSettings(settingsInput, folderPathInput) { + const settings = mergeCoreSettings(settingsInput); + const folderPath = normalizeVaultPath(folderPathInput); + return { + ...settings, + excludeFolders: settings.excludeFolders.filter((folder) => !(folder.path === folderPath && folder.detached)) + }; +} +function createId() { + if (typeof crypto !== "undefined" && crypto.randomUUID) { + return crypto.randomUUID(); + } + return `fn-${Date.now()}-${Math.floor(Math.random() * 1e6)}`; +} + +// src/cli/folder-notes.ts +var PLUGIN_SETTINGS_PATH = ".obsidian/plugins/folder-notes/data.json"; +main().catch((error) => { + const message = error instanceof Error ? error.message : String(error); + printJson({ ok: false, error: { code: "unexpected_error", message } }); + process.exitCode = 1; +}); +async function main() { + const options = parseArgs(process.argv.slice(2)); + if (options.command === "help" || options.command === "--help" || options.command === "-h") { + printHelp(); + return; + } + const context = await createContext(options); + switch (options.command) { + case "get": + return getCommand(context); + case "ensure": + return ensureCommand(context); + case "list": + return listCommand(context); + case "attach": + return attachCommand(context); + case "delete": + return deleteCommand(context); + case "detach": + return detachCommand(context); + case "reattach": + return reattachCommand(context); + case "move-folder": + return moveFolderCommand(context); + case "rename-folder": + return renameFolderCommand(context); + default: + throw new Error(`Unknown command: ${options.command}`); + } +} +function parseArgs(args) { + const [command = "help", ...rest] = args; + const options = { + command, + dryRun: false, + force: false, + permanent: false + }; + for (let i = 0; i < rest.length; i++) { + const arg = rest[i]; + switch (arg) { + case "--vault": + options.vault = requireValue(rest, ++i, arg); + break; + case "--folder": + options.folder = requireValue(rest, ++i, arg); + break; + case "--file": + options.file = requireValue(rest, ++i, arg); + break; + case "--to": + options.to = requireValue(rest, ++i, arg); + break; + case "--name": + options.name = requireValue(rest, ++i, arg); + break; + case "--extension": + options.extension = requireValue(rest, ++i, arg); + break; + case "--dry-run": + options.dryRun = true; + break; + case "--force": + options.force = true; + break; + case "--permanent": + options.permanent = true; + break; + default: + throw new Error(`Unknown option: ${arg}`); + } + } + return options; +} +function requireValue(args, index, option) { + const value = args[index]; + if (!value || value.startsWith("--")) { + throw new Error(`${option} requires a value`); + } + return value; +} +async function createContext(options) { + if (!options.vault) { + throw new Error("--vault is required"); + } + const vault = path.resolve(options.vault); + const stat2 = await fs.stat(vault).catch(() => null); + if (!(stat2 == null ? void 0 : stat2.isDirectory())) { + throw new Error(`Vault does not exist or is not a directory: ${vault}`); + } + const settingsPath = path.join(vault, PLUGIN_SETTINGS_PATH); + const rawSettings = await readJson(settingsPath); + const settings = mergeCoreSettings(rawSettings); + return { + vault, + settingsPath, + settings, + rawSettings, + options + }; +} +async function getCommand(context) { + const folder = requireFolder(context); + const existing = await findExistingFolderNote(context, folder); + printJson({ + ok: true, + command: "get", + vault: context.vault, + folder, + exists: Boolean(existing), + notePath: (existing == null ? void 0 : existing.notePath) ?? buildFolderNoteResolution(folder, context.settings).notePath + }); +} +async function ensureCommand(context) { + const folder = requireFolder(context); + await assertFolderExists(context, folder); + const existing = await findExistingFolderNote(context, folder); + if (existing) { + printJson({ + ok: true, + command: "ensure", + vault: context.vault, + folder, + notePath: existing.notePath, + actions: [] + }); + return; + } + const resolution = buildFolderNoteResolution(folder, context.settings, context.options.extension); + const absoluteNotePath = vaultAbsolutePath(context, resolution.notePath); + const actions = [{ type: "create", path: resolution.notePath }]; + if (!context.options.dryRun) { + await fs.mkdir(path.dirname(absoluteNotePath), { recursive: true }); + await fs.writeFile(absoluteNotePath, await initialContent(context, resolution.notePath)); + } + printJson({ + ok: true, + command: "ensure", + vault: context.vault, + folder, + notePath: resolution.notePath, + actions, + dryRun: context.options.dryRun + }); +} +async function listCommand(context) { + const folders = await listFolders(context.vault); + const items = []; + for (const folder of folders) { + const existing = await findExistingFolderNote(context, folder); + if (existing) { + items.push({ + folder, + notePath: existing.notePath, + storageLocation: existing.storageLocation + }); + } + } + printJson({ + ok: true, + command: "list", + vault: context.vault, + count: items.length, + items + }); +} +async function attachCommand(context) { + const folder = requireFolder(context); + const file = requireOption(context.options.file, "--file"); + await assertFolderExists(context, folder); + const filePath = normalizeVaultPath(file); + const absoluteFilePath = vaultAbsolutePath(context, filePath); + const fileStat = await fs.stat(absoluteFilePath).catch(() => null); + if (!(fileStat == null ? void 0 : fileStat.isFile())) { + throw new Error(`File does not exist in vault: ${filePath}`); + } + const extension = path.extname(filePath) || context.settings.folderNoteType; + const resolution = buildFolderNoteResolution(folder, context.settings, extension); + const existing = await findExistingFolderNote(context, folder); + const actions = []; + if (existing && existing.notePath !== filePath && !context.options.force) { + throw new Error(`Folder already has a note: ${existing.notePath}. Use --force to replace it.`); + } + if (existing && existing.notePath !== filePath) { + actions.push({ type: "delete-existing", path: existing.notePath }); + if (!context.options.dryRun) { + await removePath(context, existing.notePath, context.options.permanent); + } + } + actions.push({ type: "move", from: filePath, to: resolution.notePath }); + if (!context.options.dryRun && filePath !== resolution.notePath) { + await fs.mkdir(path.dirname(vaultAbsolutePath(context, resolution.notePath)), { recursive: true }); + await fs.rename(absoluteFilePath, vaultAbsolutePath(context, resolution.notePath)); + } + printJson({ + ok: true, + command: "attach", + vault: context.vault, + folder, + notePath: resolution.notePath, + actions, + dryRun: context.options.dryRun + }); +} +async function deleteCommand(context) { + const folder = requireFolder(context); + const existing = await findExistingFolderNote(context, folder); + if (!existing) { + throw new Error(`Folder has no folder note: ${folder}`); + } + const action = context.options.permanent ? "delete" : "trash"; + if (!context.options.dryRun) { + await removePath(context, existing.notePath, context.options.permanent); + } + printJson({ + ok: true, + command: "delete", + vault: context.vault, + folder, + notePath: existing.notePath, + actions: [{ type: action, path: existing.notePath }], + dryRun: context.options.dryRun + }); +} +async function detachCommand(context) { + const folder = requireFolder(context); + const existing = await findExistingFolderNote(context, folder); + if (!existing) { + throw new Error(`Folder has no folder note to detach: ${folder}`); + } + const settings = detachFolderInSettings(context.settings, folder, existing.notePath); + if (!context.options.dryRun) { + await writeSettings(context, settings); + } + printJson({ + ok: true, + command: "detach", + vault: context.vault, + folder, + notePath: existing.notePath, + actions: [{ type: "update-settings", path: PLUGIN_SETTINGS_PATH }], + dryRun: context.options.dryRun + }); +} +async function reattachCommand(context) { + const folder = requireFolder(context); + const settings = reattachFolderInSettings(context.settings, folder); + if (!context.options.dryRun) { + await writeSettings(context, settings); + } + printJson({ + ok: true, + command: "reattach", + vault: context.vault, + folder, + actions: [{ type: "update-settings", path: PLUGIN_SETTINGS_PATH }], + dryRun: context.options.dryRun + }); +} +async function moveFolderCommand(context) { + const folder = requireFolder(context); + const to = normalizeVaultPath(requireOption(context.options.to, "--to")); + await assertFolderExists(context, folder); + if (await pathExists(vaultAbsolutePath(context, to))) { + throw new Error(`Target folder already exists: ${to}`); + } + const existing = await findExistingFolderNote(context, folder); + const actions = [{ type: "move-folder", from: folder, to }]; + let movedNotePath = null; + if (existing && context.settings.storageLocation !== "insideFolder") { + const movedResolution = buildFolderNoteResolution(to, context.settings, existing.matchedExtension); + movedNotePath = movedResolution.notePath; + if (movedNotePath !== existing.notePath && await pathExists(vaultAbsolutePath(context, movedNotePath)) && !context.options.force) { + throw new Error(`Target folder note already exists: ${movedNotePath}. Use --force to replace it.`); + } + actions.push({ type: "move-note", from: existing.notePath, to: movedResolution.notePath }); + } + if (!context.options.dryRun) { + await fs.mkdir(path.dirname(vaultAbsolutePath(context, to)), { recursive: true }); + await fs.rename(vaultAbsolutePath(context, folder), vaultAbsolutePath(context, to)); + if (existing && movedNotePath && movedNotePath !== existing.notePath) { + await fs.mkdir(path.dirname(vaultAbsolutePath(context, movedNotePath)), { recursive: true }); + if (context.options.force && await pathExists(vaultAbsolutePath(context, movedNotePath))) { + await removePath(context, movedNotePath, context.options.permanent); + } + await fs.rename(vaultAbsolutePath(context, existing.notePath), vaultAbsolutePath(context, movedNotePath)); + } + } + printJson({ + ok: true, + command: "move-folder", + vault: context.vault, + folder: to, + actions, + dryRun: context.options.dryRun + }); +} +async function renameFolderCommand(context) { + const folder = requireFolder(context); + const name = requireOption(context.options.name, "--name"); + if (name.includes("/") || name.includes("\\") || name === "." || name === "..") { + throw new Error("--name must be a folder name, not a path"); + } + const parent = path.posix.dirname(folder); + const target = parent === "." ? name : `${parent}/${name}`; + if (await pathExists(vaultAbsolutePath(context, target))) { + throw new Error(`Target folder already exists: ${target}`); + } + const before = await findExistingFolderNote(context, folder); + const actions = [{ type: "rename-folder", from: folder, to: target }]; + let renamedNotePath = null; + if (before) { + const after = buildFolderNoteResolution(target, context.settings, before.matchedExtension); + renamedNotePath = after.notePath; + if (context.settings.storageLocation !== "insideFolder" && renamedNotePath !== before.notePath && await pathExists(vaultAbsolutePath(context, renamedNotePath)) && !context.options.force) { + throw new Error(`Target folder note already exists: ${renamedNotePath}. Use --force to replace it.`); + } + actions.push({ type: "rename-note", from: before.notePath, to: after.notePath }); + } + if (!context.options.dryRun) { + await fs.rename(vaultAbsolutePath(context, folder), vaultAbsolutePath(context, target)); + if (before && renamedNotePath && context.settings.storageLocation !== "insideFolder" && renamedNotePath !== before.notePath) { + if (context.options.force && await pathExists(vaultAbsolutePath(context, renamedNotePath))) { + await removePath(context, renamedNotePath, context.options.permanent); + } + await fs.rename(vaultAbsolutePath(context, before.notePath), vaultAbsolutePath(context, renamedNotePath)); + } + } + printJson({ + ok: true, + command: "rename-folder", + vault: context.vault, + folder: target, + actions, + dryRun: context.options.dryRun + }); +} +async function findExistingFolderNote(context, folderPath) { + for (const candidate of candidateFolderNotePaths(folderPath, context.settings)) { + const absolutePath = vaultAbsolutePath(context, candidate.notePath); + const stat2 = await fs.stat(absolutePath).catch(() => null); + if (stat2 == null ? void 0 : stat2.isFile()) { + return { + ...candidate, + exists: true, + matchedExtension: candidate.extension + }; + } + } + return null; +} +async function initialContent(context, notePath) { + const extension = path.extname(notePath); + if (context.settings.templatePath) { + const templatePath = normalizeVaultPath(context.settings.templatePath); + if (path.extname(templatePath) === extension) { + return fs.readFile(vaultAbsolutePath(context, templatePath), "utf8").catch(() => ""); + } + } + if (extension === ".canvas") + return "{}"; + return ""; +} +async function removePath(context, vaultPath, permanent) { + const absolutePath = vaultAbsolutePath(context, vaultPath); + if (permanent) { + await fs.unlink(absolutePath); + return; + } + const trashPath = normalizeVaultPath(`.trash/folder-notes/${Date.now()}-${path.basename(vaultPath)}`); + const absoluteTrashPath = vaultAbsolutePath(context, trashPath); + await fs.mkdir(path.dirname(absoluteTrashPath), { recursive: true }); + await fs.rename(absolutePath, absoluteTrashPath); +} +async function listFolders(vault) { + const result = []; + async function visit(relativePath) { + const absolutePath = path.join(vault, relativePath); + const entries = await fs.readdir(absolutePath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) + continue; + if (entry.name === ".obsidian" || entry.name === ".trash" || entry.name === ".git") + continue; + const child = normalizeVaultPath(relativePath ? `${relativePath}/${entry.name}` : entry.name); + result.push(child); + await visit(child); + } + } + await visit(""); + return result; +} +async function assertFolderExists(context, folder) { + const stat2 = await fs.stat(vaultAbsolutePath(context, folder)).catch(() => null); + if (!(stat2 == null ? void 0 : stat2.isDirectory())) { + throw new Error(`Folder does not exist in vault: ${folder}`); + } +} +function requireFolder(context) { + return normalizeVaultPath(requireOption(context.options.folder, "--folder")); +} +function requireOption(value, name) { + if (!value) + throw new Error(`${name} is required`); + return value; +} +function vaultAbsolutePath(context, vaultPath) { + const absolutePath = path.resolve(context.vault, normalizeVaultPath(vaultPath)); + if (absolutePath !== context.vault && !absolutePath.startsWith(`${context.vault}${path.sep}`)) { + throw new Error(`Path escapes vault: ${vaultPath}`); + } + return absolutePath; +} +async function pathExists(absolutePath) { + return Boolean(await fs.stat(absolutePath).catch(() => null)); +} +async function readJson(filePath) { + const content = await fs.readFile(filePath, "utf8").catch((error) => { + if (error.code === "ENOENT") + return "{}"; + throw error; + }); + return JSON.parse(content); +} +async function writeSettings(context, settings) { + const content = { + ...context.rawSettings, + ...settings + }; + await fs.mkdir(path.dirname(context.settingsPath), { recursive: true }); + await fs.writeFile(context.settingsPath, `${JSON.stringify(content, null, 2)} +`); +} +function printJson(value) { + process.stdout.write(`${JSON.stringify(value, null, 2)} +`); +} +function printHelp() { + process.stdout.write(`folder-notes CLI + +Usage: + folder-notes --vault [options] + +Commands: + get --folder + ensure --folder [--extension ] [--dry-run] + list + attach --folder --file [--force] [--dry-run] + delete --folder [--permanent] [--dry-run] + detach --folder [--dry-run] + reattach --folder [--dry-run] + move-folder --folder --to [--force] [--dry-run] + rename-folder --folder --name [--dry-run] +`); +} diff --git a/esbuild.cli.config.mjs b/esbuild.cli.config.mjs new file mode 100644 index 0000000..9f6b887 --- /dev/null +++ b/esbuild.cli.config.mjs @@ -0,0 +1,20 @@ +import esbuild from "esbuild"; +import process from "process"; +import builtins from "builtin-modules"; + +const prod = process.argv[2] === "production"; + +esbuild.build({ + entryPoints: ["./src/cli/folder-notes.ts"], + bundle: true, + platform: "node", + format: "cjs", + target: "node16", + logLevel: "info", + sourcemap: prod ? false : "inline", + outfile: "dist/folder-notes-cli.cjs", + external: [...builtins, ...builtins.map((name) => `node:${name}`)], + banner: { + js: "#!/usr/bin/env node", + }, +}).catch(() => process.exit(1)); diff --git a/openclaw-skills/obsidian-folder-notes-cli/SKILL.md b/openclaw-skills/obsidian-folder-notes-cli/SKILL.md new file mode 100644 index 0000000..fd63eea --- /dev/null +++ b/openclaw-skills/obsidian-folder-notes-cli/SKILL.md @@ -0,0 +1,122 @@ +--- +name: folder-notes-cli +description: Use this skill when an OpenClaw agent needs to create, find, list, attach, delete, detach, reattach, rename, or move Obsidian Folder Notes in a vault through the folder-notes CLI. Use it for agent-safe automation against vault folders without opening Obsidian. +--- + +# Folder Notes CLI + +Use the repository's `folder-notes` CLI to manage Obsidian Folder Notes from automation. The CLI operates directly on a vault directory and reads plugin settings from: + +```text +/.obsidian/plugins/folder-notes/data.json +``` + +Always pass `--vault `. Commands return JSON by default; parse `ok`, `notePath`, `items`, and `error.message`. + +## Setup + +From the plugin repository, build the CLI before first use: + +```bash +npm run cli-build +``` + +Invoke it either through the built file: + +```bash +node dist/folder-notes-cli.cjs --vault ... +``` + +or through the package binary when installed/linked: + +```bash +folder-notes --vault ... +``` + +## Common Workflows + +Create or reuse a folder note and return its path: + +```bash +node dist/folder-notes-cli.cjs ensure --vault /path/to/vault --folder "Projects/Alpha" +``` + +Find the folder note for a folder: + +```bash +node dist/folder-notes-cli.cjs get --vault /path/to/vault --folder "Projects/Alpha" +``` + +List all folders in the vault that currently have folder notes: + +```bash +node dist/folder-notes-cli.cjs list --vault /path/to/vault +``` + +Use an existing file as the folder note: + +```bash +node dist/folder-notes-cli.cjs attach --vault /path/to/vault --folder "Projects/Alpha" --file "Projects/Alpha/Brief.md" +``` + +Delete a folder note. By default this moves it into vault-local trash: + +```bash +node dist/folder-notes-cli.cjs delete --vault /path/to/vault --folder "Projects/Alpha" +``` + +Only use permanent deletion when explicitly requested: + +```bash +node dist/folder-notes-cli.cjs delete --vault /path/to/vault --folder "Projects/Alpha" --permanent +``` + +Detach or reattach a folder note by updating plugin settings: + +```bash +node dist/folder-notes-cli.cjs detach --vault /path/to/vault --folder "Projects/Alpha" +node dist/folder-notes-cli.cjs reattach --vault /path/to/vault --folder "Projects/Alpha" +``` + +Move or rename a folder while preserving folder-note conventions: + +```bash +node dist/folder-notes-cli.cjs move-folder --vault /path/to/vault --folder "Projects/Alpha" --to "Archive/Alpha" +node dist/folder-notes-cli.cjs rename-folder --vault /path/to/vault --folder "Projects/Alpha" --name "Beta" +``` + +## Safety Rules + +- Prefer `--dry-run` before destructive or broad operations. +- Do not use `--force` unless the user explicitly wants to replace an existing folder note. +- Do not use `--permanent` unless the user explicitly asks for permanent deletion. +- Treat `ok: false` or any nonzero exit code as a failed operation and report `error.message`. +- Vault-relative paths must not contain `..`; pass folder and file paths relative to the vault root. +- The CLI follows plugin settings for `folderNoteName`, `folderNoteType`, `supportedFileTypes`, `storageLocation`, and `templatePath`. + +## JSON Shapes + +Successful commands include: + +```json +{ + "ok": true, + "command": "ensure", + "vault": "/path/to/vault", + "folder": "Projects/Alpha", + "notePath": "Projects/Alpha/Alpha.md", + "actions": [] +} +``` + +Failures include: + +```json +{ + "ok": false, + "error": { + "code": "unexpected_error", + "message": "Folder already has a note: Projects/Alpha/Alpha.md. Use --force to replace it." + } +} +``` diff --git a/package.json b/package.json index 22564a3..340dc17 100644 --- a/package.json +++ b/package.json @@ -3,10 +3,15 @@ "version": "1.0.1", "description": "Adds Folder Notes to the default file tree.", "main": "main.js", + "bin": { + "folder-notes": "./dist/folder-notes-cli.cjs" + }, "scripts": { "fn-dev": "node esbuild.config.mjs", "dev": "npm run fn-dev", "fn-build": "tsc -noEmit -skipLibCheck && node esbuild.config.mjs production", + "cli-build": "node esbuild.cli.config.mjs production", + "build": "npm run fn-build && npm run cli-build", "fv-build": "tsc -noEmit -skipLibCheck && node ./src/obsidian-folder-overview/esbuild.config.mjs production", "version": "node version-bump.mjs && git add manifest.json versions.json", "folder-overview": "node ./src/obsidian-folder-overview/esbuild.config.mjs", diff --git a/src/cli/folder-notes.ts b/src/cli/folder-notes.ts new file mode 100644 index 0000000..6e515fd --- /dev/null +++ b/src/cli/folder-notes.ts @@ -0,0 +1,552 @@ +import * as fs from 'fs/promises'; +import * as path from 'path'; +import { + type ExistingFolderNote, + type FolderNotesCoreSettings, + buildFolderNoteResolution, + candidateFolderNotePaths, + detachFolderInSettings, + mergeCoreSettings, + normalizeVaultPath, + reattachFolderInSettings, +} from '../core/folderNotesCore'; + +interface CliOptions { + command: string; + vault?: string; + folder?: string; + file?: string; + to?: string; + name?: string; + dryRun: boolean; + force: boolean; + permanent: boolean; + extension?: string; +} + +interface CommandContext { + vault: string; + settingsPath: string; + settings: FolderNotesCoreSettings; + rawSettings: Record; + options: CliOptions; +} + +const PLUGIN_SETTINGS_PATH = '.obsidian/plugins/folder-notes/data.json'; + +main().catch((error: unknown) => { + const message = error instanceof Error ? error.message : String(error); + printJson({ ok: false, error: { code: 'unexpected_error', message } }); + process.exitCode = 1; +}); + +async function main(): Promise { + const options = parseArgs(process.argv.slice(2)); + if (options.command === 'help' || options.command === '--help' || options.command === '-h') { + printHelp(); + return; + } + + const context = await createContext(options); + + switch (options.command) { + case 'get': + return getCommand(context); + case 'ensure': + return ensureCommand(context); + case 'list': + return listCommand(context); + case 'attach': + return attachCommand(context); + case 'delete': + return deleteCommand(context); + case 'detach': + return detachCommand(context); + case 'reattach': + return reattachCommand(context); + case 'move-folder': + return moveFolderCommand(context); + case 'rename-folder': + return renameFolderCommand(context); + default: + throw new Error(`Unknown command: ${options.command}`); + } +} + +function parseArgs(args: string[]): CliOptions { + const [command = 'help', ...rest] = args; + const options: CliOptions = { + command, + dryRun: false, + force: false, + permanent: false, + }; + + for (let i = 0; i < rest.length; i++) { + const arg = rest[i]; + switch (arg) { + case '--vault': + options.vault = requireValue(rest, ++i, arg); + break; + case '--folder': + options.folder = requireValue(rest, ++i, arg); + break; + case '--file': + options.file = requireValue(rest, ++i, arg); + break; + case '--to': + options.to = requireValue(rest, ++i, arg); + break; + case '--name': + options.name = requireValue(rest, ++i, arg); + break; + case '--extension': + options.extension = requireValue(rest, ++i, arg); + break; + case '--dry-run': + options.dryRun = true; + break; + case '--force': + options.force = true; + break; + case '--permanent': + options.permanent = true; + break; + default: + throw new Error(`Unknown option: ${arg}`); + } + } + + return options; +} + +function requireValue(args: string[], index: number, option: string): string { + const value = args[index]; + if (!value || value.startsWith('--')) { + throw new Error(`${option} requires a value`); + } + return value; +} + +async function createContext(options: CliOptions): Promise { + if (!options.vault) { + throw new Error('--vault is required'); + } + const vault = path.resolve(options.vault); + const stat = await fs.stat(vault).catch(() => null); + if (!stat?.isDirectory()) { + throw new Error(`Vault does not exist or is not a directory: ${vault}`); + } + + const settingsPath = path.join(vault, PLUGIN_SETTINGS_PATH); + const rawSettings = await readJson(settingsPath); + const settings = mergeCoreSettings(rawSettings as Partial); + return { + vault, + settingsPath, + settings, + rawSettings, + options, + }; +} + +async function getCommand(context: CommandContext): Promise { + const folder = requireFolder(context); + const existing = await findExistingFolderNote(context, folder); + printJson({ + ok: true, + command: 'get', + vault: context.vault, + folder, + exists: Boolean(existing), + notePath: existing?.notePath ?? buildFolderNoteResolution(folder, context.settings).notePath, + }); +} + +async function ensureCommand(context: CommandContext): Promise { + const folder = requireFolder(context); + await assertFolderExists(context, folder); + const existing = await findExistingFolderNote(context, folder); + if (existing) { + printJson({ + ok: true, + command: 'ensure', + vault: context.vault, + folder, + notePath: existing.notePath, + actions: [], + }); + return; + } + + const resolution = buildFolderNoteResolution(folder, context.settings, context.options.extension); + const absoluteNotePath = vaultAbsolutePath(context, resolution.notePath); + const actions = [{ type: 'create', path: resolution.notePath }]; + if (!context.options.dryRun) { + await fs.mkdir(path.dirname(absoluteNotePath), { recursive: true }); + await fs.writeFile(absoluteNotePath, await initialContent(context, resolution.notePath)); + } + + printJson({ + ok: true, + command: 'ensure', + vault: context.vault, + folder, + notePath: resolution.notePath, + actions, + dryRun: context.options.dryRun, + }); +} + +async function listCommand(context: CommandContext): Promise { + const folders = await listFolders(context.vault); + const items = []; + for (const folder of folders) { + const existing = await findExistingFolderNote(context, folder); + if (existing) { + items.push({ + folder, + notePath: existing.notePath, + storageLocation: existing.storageLocation, + }); + } + } + printJson({ + ok: true, + command: 'list', + vault: context.vault, + count: items.length, + items, + }); +} + +async function attachCommand(context: CommandContext): Promise { + const folder = requireFolder(context); + const file = requireOption(context.options.file, '--file'); + await assertFolderExists(context, folder); + const filePath = normalizeVaultPath(file); + const absoluteFilePath = vaultAbsolutePath(context, filePath); + const fileStat = await fs.stat(absoluteFilePath).catch(() => null); + if (!fileStat?.isFile()) { + throw new Error(`File does not exist in vault: ${filePath}`); + } + + const extension = path.extname(filePath) || context.settings.folderNoteType; + const resolution = buildFolderNoteResolution(folder, context.settings, extension); + const existing = await findExistingFolderNote(context, folder); + const actions = []; + if (existing && existing.notePath !== filePath && !context.options.force) { + throw new Error(`Folder already has a note: ${existing.notePath}. Use --force to replace it.`); + } + + if (existing && existing.notePath !== filePath) { + actions.push({ type: 'delete-existing', path: existing.notePath }); + if (!context.options.dryRun) { + await removePath(context, existing.notePath, context.options.permanent); + } + } + + actions.push({ type: 'move', from: filePath, to: resolution.notePath }); + if (!context.options.dryRun && filePath !== resolution.notePath) { + await fs.mkdir(path.dirname(vaultAbsolutePath(context, resolution.notePath)), { recursive: true }); + await fs.rename(absoluteFilePath, vaultAbsolutePath(context, resolution.notePath)); + } + + printJson({ + ok: true, + command: 'attach', + vault: context.vault, + folder, + notePath: resolution.notePath, + actions, + dryRun: context.options.dryRun, + }); +} + +async function deleteCommand(context: CommandContext): Promise { + const folder = requireFolder(context); + const existing = await findExistingFolderNote(context, folder); + if (!existing) { + throw new Error(`Folder has no folder note: ${folder}`); + } + const action = context.options.permanent ? 'delete' : 'trash'; + if (!context.options.dryRun) { + await removePath(context, existing.notePath, context.options.permanent); + } + printJson({ + ok: true, + command: 'delete', + vault: context.vault, + folder, + notePath: existing.notePath, + actions: [{ type: action, path: existing.notePath }], + dryRun: context.options.dryRun, + }); +} + +async function detachCommand(context: CommandContext): Promise { + const folder = requireFolder(context); + const existing = await findExistingFolderNote(context, folder); + if (!existing) { + throw new Error(`Folder has no folder note to detach: ${folder}`); + } + + const settings = detachFolderInSettings(context.settings, folder, existing.notePath); + if (!context.options.dryRun) { + await writeSettings(context, settings); + } + printJson({ + ok: true, + command: 'detach', + vault: context.vault, + folder, + notePath: existing.notePath, + actions: [{ type: 'update-settings', path: PLUGIN_SETTINGS_PATH }], + dryRun: context.options.dryRun, + }); +} + +async function reattachCommand(context: CommandContext): Promise { + const folder = requireFolder(context); + const settings = reattachFolderInSettings(context.settings, folder); + if (!context.options.dryRun) { + await writeSettings(context, settings); + } + printJson({ + ok: true, + command: 'reattach', + vault: context.vault, + folder, + actions: [{ type: 'update-settings', path: PLUGIN_SETTINGS_PATH }], + dryRun: context.options.dryRun, + }); +} + +async function moveFolderCommand(context: CommandContext): Promise { + const folder = requireFolder(context); + const to = normalizeVaultPath(requireOption(context.options.to, '--to')); + await assertFolderExists(context, folder); + if (await pathExists(vaultAbsolutePath(context, to))) { + throw new Error(`Target folder already exists: ${to}`); + } + const existing = await findExistingFolderNote(context, folder); + const actions = [{ type: 'move-folder', from: folder, to }]; + let movedNotePath: string | null = null; + if (existing && context.settings.storageLocation !== 'insideFolder') { + const movedResolution = buildFolderNoteResolution(to, context.settings, existing.matchedExtension); + movedNotePath = movedResolution.notePath; + if ( + movedNotePath !== existing.notePath && + await pathExists(vaultAbsolutePath(context, movedNotePath)) && + !context.options.force + ) { + throw new Error(`Target folder note already exists: ${movedNotePath}. Use --force to replace it.`); + } + actions.push({ type: 'move-note', from: existing.notePath, to: movedResolution.notePath }); + } + if (!context.options.dryRun) { + await fs.mkdir(path.dirname(vaultAbsolutePath(context, to)), { recursive: true }); + await fs.rename(vaultAbsolutePath(context, folder), vaultAbsolutePath(context, to)); + if (existing && movedNotePath && movedNotePath !== existing.notePath) { + await fs.mkdir(path.dirname(vaultAbsolutePath(context, movedNotePath)), { recursive: true }); + if (context.options.force && await pathExists(vaultAbsolutePath(context, movedNotePath))) { + await removePath(context, movedNotePath, context.options.permanent); + } + await fs.rename(vaultAbsolutePath(context, existing.notePath), vaultAbsolutePath(context, movedNotePath)); + } + } + printJson({ + ok: true, + command: 'move-folder', + vault: context.vault, + folder: to, + actions, + dryRun: context.options.dryRun, + }); +} + +async function renameFolderCommand(context: CommandContext): Promise { + const folder = requireFolder(context); + const name = requireOption(context.options.name, '--name'); + if (name.includes('/') || name.includes('\\') || name === '.' || name === '..') { + throw new Error('--name must be a folder name, not a path'); + } + const parent = path.posix.dirname(folder); + const target = parent === '.' ? name : `${parent}/${name}`; + if (await pathExists(vaultAbsolutePath(context, target))) { + throw new Error(`Target folder already exists: ${target}`); + } + const before = await findExistingFolderNote(context, folder); + const actions = [{ type: 'rename-folder', from: folder, to: target }]; + let renamedNotePath: string | null = null; + if (before) { + const after = buildFolderNoteResolution(target, context.settings, before.matchedExtension); + renamedNotePath = after.notePath; + if ( + context.settings.storageLocation !== 'insideFolder' && + renamedNotePath !== before.notePath && + await pathExists(vaultAbsolutePath(context, renamedNotePath)) && + !context.options.force + ) { + throw new Error(`Target folder note already exists: ${renamedNotePath}. Use --force to replace it.`); + } + actions.push({ type: 'rename-note', from: before.notePath, to: after.notePath }); + } + if (!context.options.dryRun) { + await fs.rename(vaultAbsolutePath(context, folder), vaultAbsolutePath(context, target)); + if ( + before && + renamedNotePath && + context.settings.storageLocation !== 'insideFolder' && + renamedNotePath !== before.notePath + ) { + if (context.options.force && await pathExists(vaultAbsolutePath(context, renamedNotePath))) { + await removePath(context, renamedNotePath, context.options.permanent); + } + await fs.rename(vaultAbsolutePath(context, before.notePath), vaultAbsolutePath(context, renamedNotePath)); + } + } + printJson({ + ok: true, + command: 'rename-folder', + vault: context.vault, + folder: target, + actions, + dryRun: context.options.dryRun, + }); +} + +async function findExistingFolderNote( + context: CommandContext, + folderPath: string, +): Promise { + for (const candidate of candidateFolderNotePaths(folderPath, context.settings)) { + const absolutePath = vaultAbsolutePath(context, candidate.notePath); + const stat = await fs.stat(absolutePath).catch(() => null); + if (stat?.isFile()) { + return { + ...candidate, + exists: true, + matchedExtension: candidate.extension, + }; + } + } + return null; +} + +async function initialContent(context: CommandContext, notePath: string): Promise { + const extension = path.extname(notePath); + if (context.settings.templatePath) { + const templatePath = normalizeVaultPath(context.settings.templatePath); + if (path.extname(templatePath) === extension) { + return fs.readFile(vaultAbsolutePath(context, templatePath), 'utf8').catch(() => ''); + } + } + if (extension === '.canvas') return '{}'; + return ''; +} + +async function removePath( + context: CommandContext, + vaultPath: string, + permanent: boolean, +): Promise { + const absolutePath = vaultAbsolutePath(context, vaultPath); + if (permanent) { + await fs.unlink(absolutePath); + return; + } + + const trashPath = normalizeVaultPath(`.trash/folder-notes/${Date.now()}-${path.basename(vaultPath)}`); + const absoluteTrashPath = vaultAbsolutePath(context, trashPath); + await fs.mkdir(path.dirname(absoluteTrashPath), { recursive: true }); + await fs.rename(absolutePath, absoluteTrashPath); +} + +async function listFolders(vault: string): Promise { + const result: string[] = []; + async function visit(relativePath: string): Promise { + const absolutePath = path.join(vault, relativePath); + const entries = await fs.readdir(absolutePath, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (entry.name === '.obsidian' || entry.name === '.trash' || entry.name === '.git') continue; + const child = normalizeVaultPath(relativePath ? `${relativePath}/${entry.name}` : entry.name); + result.push(child); + await visit(child); + } + } + await visit(''); + return result; +} + +async function assertFolderExists(context: CommandContext, folder: string): Promise { + const stat = await fs.stat(vaultAbsolutePath(context, folder)).catch(() => null); + if (!stat?.isDirectory()) { + throw new Error(`Folder does not exist in vault: ${folder}`); + } +} + +function requireFolder(context: CommandContext): string { + return normalizeVaultPath(requireOption(context.options.folder, '--folder')); +} + +function requireOption(value: string | undefined, name: string): string { + if (!value) throw new Error(`${name} is required`); + return value; +} + +function vaultAbsolutePath(context: CommandContext, vaultPath: string): string { + const absolutePath = path.resolve(context.vault, normalizeVaultPath(vaultPath)); + if (absolutePath !== context.vault && !absolutePath.startsWith(`${context.vault}${path.sep}`)) { + throw new Error(`Path escapes vault: ${vaultPath}`); + } + return absolutePath; +} + +async function pathExists(absolutePath: string): Promise { + return Boolean(await fs.stat(absolutePath).catch(() => null)); +} + +async function readJson(filePath: string): Promise> { + const content = await fs.readFile(filePath, 'utf8').catch((error: NodeJS.ErrnoException) => { + if (error.code === 'ENOENT') return '{}'; + throw error; + }); + return JSON.parse(content) as Record; +} + +async function writeSettings( + context: CommandContext, + settings: FolderNotesCoreSettings, +): Promise { + const content = { + ...context.rawSettings, + ...settings, + }; + await fs.mkdir(path.dirname(context.settingsPath), { recursive: true }); + await fs.writeFile(context.settingsPath, `${JSON.stringify(content, null, 2)}\n`); +} + +function printJson(value: unknown): void { + process.stdout.write(`${JSON.stringify(value, null, 2)}\n`); +} + +function printHelp(): void { + process.stdout.write(`folder-notes CLI + +Usage: + folder-notes --vault [options] + +Commands: + get --folder + ensure --folder [--extension ] [--dry-run] + list + attach --folder --file [--force] [--dry-run] + delete --folder [--permanent] [--dry-run] + detach --folder [--dry-run] + reattach --folder [--dry-run] + move-folder --folder --to [--force] [--dry-run] + rename-folder --folder --name [--dry-run] +`); +} diff --git a/src/core/folderNotesCore.ts b/src/core/folderNotesCore.ts new file mode 100644 index 0000000..259f3cf --- /dev/null +++ b/src/core/folderNotesCore.ts @@ -0,0 +1,216 @@ +export type StorageLocation = 'insideFolder' | 'parentFolder' | 'vaultFolder'; + +export interface FolderNotesCoreSettings { + folderNoteName: string; + folderNoteType: string; + supportedFileTypes: string[]; + storageLocation: StorageLocation; + templatePath: string; + excludeFolders: Array<{ + type?: string; + id?: string; + path?: string; + detached?: boolean; + detachedFilePath?: string; + showFolderNote?: boolean; + hideInSettings?: boolean; + disableFolderNote?: boolean; + disableSync?: boolean; + subFolders?: boolean; + position?: number; + [key: string]: unknown; + }>; +} + +export interface FolderNoteResolution { + folderPath: string; + folderName: string; + fileName: string; + extension: string; + notePath: string; + storageLocation: StorageLocation; +} + +export interface ExistingFolderNote extends FolderNoteResolution { + exists: true; + matchedExtension: string; +} + +export const CORE_DEFAULT_SETTINGS: FolderNotesCoreSettings = { + folderNoteName: '{{folder_name}}', + folderNoteType: '.md', + supportedFileTypes: ['md', 'canvas', 'base'], + storageLocation: 'insideFolder', + templatePath: '', + excludeFolders: [], +}; + +export function mergeCoreSettings( + settings: Partial | null | undefined, +): FolderNotesCoreSettings { + return { + ...CORE_DEFAULT_SETTINGS, + ...settings, + supportedFileTypes: settings?.supportedFileTypes ?? CORE_DEFAULT_SETTINGS.supportedFileTypes, + excludeFolders: settings?.excludeFolders ?? CORE_DEFAULT_SETTINGS.excludeFolders, + }; +} + +export function normalizeVaultPath(input: string): string { + const normalized = input.replace(/\\/g, '/').replace(/^\.\/+/, '').replace(/^\/+/, ''); + const parts = normalized.split('/').filter((part) => part.length > 0 && part !== '.'); + if (parts.some((part) => part === '..')) { + throw new Error(`Vault-relative paths cannot contain '..': ${input}`); + } + return parts.join('/'); +} + +export function getFolderName(folderPath: string): string { + const normalized = normalizeVaultPath(folderPath); + const parts = normalized.split('/').filter(Boolean); + return parts[parts.length - 1] ?? ''; +} + +export function getParentFolderPath(vaultPath: string): string { + const normalized = normalizeVaultPath(vaultPath); + const index = normalized.lastIndexOf('/'); + if (index < 0) return ''; + return normalized.slice(0, index); +} + +export function normalizeExtension(extension: string): string { + const trimmed = extension.trim(); + if (!trimmed) return '.md'; + return trimmed.startsWith('.') ? trimmed : `.${trimmed}`; +} + +export function normalizeFolderNoteType(type: string): string { + return normalizeExtension(type) === '.excalidraw' ? '.md' : normalizeExtension(type); +} + +export function extensionForSupportedType(type: string): string { + const extension = normalizeExtension(type); + return extension === '.excalidraw' ? '.md' : extension; +} + +export function buildFolderNoteResolution( + folderPathInput: string, + settingsInput?: Partial, + extensionInput?: string, +): FolderNoteResolution { + const settings = mergeCoreSettings(settingsInput); + const folderPath = normalizeVaultPath(folderPathInput); + const folderName = getFolderName(folderPath); + if (!folderPath || !folderName) { + throw new Error('A non-root folder path is required'); + } + + const fileName = settings.folderNoteName.replace('{{folder_name}}', folderName); + const extension = normalizeFolderNoteType(extensionInput ?? settings.folderNoteType); + let noteFolder = folderPath; + + if (settings.storageLocation === 'parentFolder') { + noteFolder = getParentFolderPath(folderPath); + } else if (settings.storageLocation === 'vaultFolder') { + noteFolder = ''; + } + + const notePath = normalizeVaultPath( + noteFolder ? `${noteFolder}/${fileName}${extension}` : `${fileName}${extension}`, + ); + + return { + folderPath, + folderName, + fileName, + extension, + notePath, + storageLocation: settings.storageLocation, + }; +} + +export function candidateFolderNotePaths( + folderPathInput: string, + settingsInput?: Partial, +): FolderNoteResolution[] { + const settings = mergeCoreSettings(settingsInput); + const primary = buildFolderNoteResolution(folderPathInput, settings); + const extensions = [ + primary.extension, + ...settings.supportedFileTypes.map(extensionForSupportedType), + ].filter((extension, index, all) => all.indexOf(extension) === index); + + return extensions.map((extension) => buildFolderNoteResolution(folderPathInput, settings, extension)); +} + +export function extractFolderName(template: string, changedFileName: string): string | null { + const [prefix, suffix] = template.split('{{folder_name}}'); + if (prefix.trim() === '' && suffix.trim() === '') { + return changedFileName; + } + if (!changedFileName.startsWith(prefix) || !changedFileName.endsWith(suffix)) { + return null; + } + if (changedFileName.startsWith(prefix) && prefix.trim() !== '') { + return changedFileName.slice(prefix.length).replace(suffix, ''); + } else if (changedFileName.endsWith(suffix) && suffix.trim() !== '') { + return changedFileName.slice(0, -suffix.length); + } + return null; +} + +export function detachFolderInSettings( + settingsInput: Partial, + folderPathInput: string, + notePathInput: string, +): FolderNotesCoreSettings { + const settings = mergeCoreSettings(settingsInput); + const folderPath = normalizeVaultPath(folderPathInput); + const notePath = normalizeVaultPath(notePathInput); + const excludeFolders = settings.excludeFolders.filter( + (folder) => !(folder.path === folderPath && folder.detached), + ); + + excludeFolders.push({ + type: 'folder', + id: createId(), + path: folderPath, + position: excludeFolders.length, + subFolders: false, + disableSync: true, + disableFolderNote: true, + disableAutoCreate: true, + enableCollapsing: false, + excludeFromFolderOverview: false, + hideInSettings: true, + detached: true, + detachedFilePath: notePath, + showFolderNote: true, + }); + + return { + ...settings, + excludeFolders, + }; +} + +export function reattachFolderInSettings( + settingsInput: Partial, + folderPathInput: string, +): FolderNotesCoreSettings { + const settings = mergeCoreSettings(settingsInput); + const folderPath = normalizeVaultPath(folderPathInput); + return { + ...settings, + excludeFolders: settings.excludeFolders.filter( + (folder) => !(folder.path === folderPath && folder.detached), + ), + }; +} + +function createId(): string { + if (typeof crypto !== 'undefined' && crypto.randomUUID) { + return crypto.randomUUID(); + } + return `fn-${Date.now()}-${Math.floor(Math.random() * 1000000)}`; +}