mirror of
https://github.com/lostpaul/obsidian-folder-notes.git
synced 2026-07-22 07:40:24 +00:00
Add folder notes CLI for agents
This commit is contained in:
parent
3fce80d6fb
commit
7fa210534f
6 changed files with 1523 additions and 0 deletions
608
dist/folder-notes-cli.cjs
vendored
Executable file
608
dist/folder-notes-cli.cjs
vendored
Executable file
|
|
@ -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 <command> --vault <path> [options]
|
||||
|
||||
Commands:
|
||||
get --folder <folder>
|
||||
ensure --folder <folder> [--extension <ext>] [--dry-run]
|
||||
list
|
||||
attach --folder <folder> --file <file> [--force] [--dry-run]
|
||||
delete --folder <folder> [--permanent] [--dry-run]
|
||||
detach --folder <folder> [--dry-run]
|
||||
reattach --folder <folder> [--dry-run]
|
||||
move-folder --folder <folder> --to <folder> [--force] [--dry-run]
|
||||
rename-folder --folder <folder> --name <name> [--dry-run]
|
||||
`);
|
||||
}
|
||||
20
esbuild.cli.config.mjs
Normal file
20
esbuild.cli.config.mjs
Normal file
|
|
@ -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));
|
||||
122
openclaw-skills/obsidian-folder-notes-cli/SKILL.md
Normal file
122
openclaw-skills/obsidian-folder-notes-cli/SKILL.md
Normal file
|
|
@ -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
|
||||
<vault>/.obsidian/plugins/folder-notes/data.json
|
||||
```
|
||||
|
||||
Always pass `--vault <absolute-or-relative-vault-path>`. 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 <command> --vault <vault> ...
|
||||
```
|
||||
|
||||
or through the package binary when installed/linked:
|
||||
|
||||
```bash
|
||||
folder-notes <command> --vault <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."
|
||||
}
|
||||
}
|
||||
```
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
552
src/cli/folder-notes.ts
Normal file
552
src/cli/folder-notes.ts
Normal file
|
|
@ -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<string, unknown>;
|
||||
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<void> {
|
||||
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<CommandContext> {
|
||||
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<FolderNotesCoreSettings>);
|
||||
return {
|
||||
vault,
|
||||
settingsPath,
|
||||
settings,
|
||||
rawSettings,
|
||||
options,
|
||||
};
|
||||
}
|
||||
|
||||
async function getCommand(context: CommandContext): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<ExistingFolderNote | null> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
const result: string[] = [];
|
||||
async function visit(relativePath: string): Promise<void> {
|
||||
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<void> {
|
||||
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<boolean> {
|
||||
return Boolean(await fs.stat(absolutePath).catch(() => null));
|
||||
}
|
||||
|
||||
async function readJson(filePath: string): Promise<Record<string, unknown>> {
|
||||
const content = await fs.readFile(filePath, 'utf8').catch((error: NodeJS.ErrnoException) => {
|
||||
if (error.code === 'ENOENT') return '{}';
|
||||
throw error;
|
||||
});
|
||||
return JSON.parse(content) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
async function writeSettings(
|
||||
context: CommandContext,
|
||||
settings: FolderNotesCoreSettings,
|
||||
): Promise<void> {
|
||||
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 <command> --vault <path> [options]
|
||||
|
||||
Commands:
|
||||
get --folder <folder>
|
||||
ensure --folder <folder> [--extension <ext>] [--dry-run]
|
||||
list
|
||||
attach --folder <folder> --file <file> [--force] [--dry-run]
|
||||
delete --folder <folder> [--permanent] [--dry-run]
|
||||
detach --folder <folder> [--dry-run]
|
||||
reattach --folder <folder> [--dry-run]
|
||||
move-folder --folder <folder> --to <folder> [--force] [--dry-run]
|
||||
rename-folder --folder <folder> --name <name> [--dry-run]
|
||||
`);
|
||||
}
|
||||
216
src/core/folderNotesCore.ts
Normal file
216
src/core/folderNotesCore.ts
Normal file
|
|
@ -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<FolderNotesCoreSettings> | 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<FolderNotesCoreSettings>,
|
||||
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<FolderNotesCoreSettings>,
|
||||
): 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<FolderNotesCoreSettings>,
|
||||
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<FolderNotesCoreSettings>,
|
||||
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)}`;
|
||||
}
|
||||
Loading…
Reference in a new issue