update script api

This commit is contained in:
Moritz Jung 2026-05-31 22:08:50 +02:00
parent 60d9162e25
commit 5906e155f9
15 changed files with 179 additions and 222 deletions

View file

@ -51,12 +51,6 @@ Read vault structure and file contents. These are allowed to call `Vault` read m
- [x] `api.vault.list(path?)` mirrors `app.vault.getAllLoadedFiles()` and returns file/folder DTOs.
- [x] `api.vault.stat(path)` mirrors `app.vault.getAbstractFileByPath(path)` plus `TFile.stat`.
- [x] `api.vault.exists(path)` mirrors `app.vault.getAbstractFileByPath(path) !== null`.
- [x] `api.vault.getFile(path)` mirrors `app.vault.getFileByPath(path)` and returns a file DTO or `null`.
- [x] `api.vault.getFolder(path)` mirrors `app.vault.getFolderByPath(path)` and returns a folder DTO or `null`.
- [x] `api.vault.getRoot()` mirrors `app.vault.getRoot()` and returns a folder DTO.
- [x] `api.vault.getFiles()` mirrors `app.vault.getFiles()` and returns file DTOs.
- [x] `api.vault.getMarkdownFiles()` mirrors `app.vault.getMarkdownFiles()` and returns file DTOs.
- [x] `api.vault.getFolders(options?)` mirrors `app.vault.getAllFolders(includeRoot?)` and returns folder DTOs.
Read-only implementation guardrail:
@ -65,6 +59,7 @@ Read-only implementation guardrail:
Do not mirror under `vault:read`:
- [x] `app.vault.getFileByPath`, `getFolderByPath`, `getRoot`, `getFiles`, `getMarkdownFiles`, and `getAllFolders` as separate script methods: `api.vault.list`, `api.vault.stat`, and `api.vault.exists` cover the same read surface with fewer aliases.
- [ ] `app.vault.getResourcePath(file)`: returns a browser resource URI for vault content. Consider a separate `vault:resource` permission if needed.
- [ ] `app.vault.adapter.read/list/stat/exists`: prefer `Vault` methods so the boundary stays inside Obsidian's vault model.
- [ ] Vault events: defer until subscription cleanup and data volume limits are designed.
@ -74,7 +69,6 @@ Do not mirror under `vault:read`:
Read Obsidian's parsed cache. This can reveal filenames, links, tags, headings, blocks, frontmatter, and note structure, so keep it separate from pure vault existence checks.
- [x] `api.metadata.getFileCache(path)` mirrors `app.metadataCache.getFileCache(file)` and returns a JSON-safe cache DTO.
- [x] `api.metadata.getCache(path)` mirrors `app.metadataCache.getCache(path)` and returns a JSON-safe cache DTO.
- [x] `api.metadata.getFirstLinkpathDest(linkpath, sourcePath)` mirrors `app.metadataCache.getFirstLinkpathDest(...)` and returns a file DTO or `null`.
- [x] `api.metadata.fileToLinktext(path, sourcePath, options?)` mirrors `app.metadataCache.fileToLinktext(...)`.
- [x] `api.metadata.getResolvedLinks()` mirrors `app.metadataCache.resolvedLinks`.
@ -89,6 +83,7 @@ Read Obsidian's parsed cache. This can reveal filenames, links, tags, headings,
Do not mirror:
- [x] `app.metadataCache.getCache(path)` as a separate script method: `api.metadata.getFileCache(path)` is the safer existing-file path.
- [ ] Metadata events: defer until subscription cleanup and throttling are designed.
- [ ] `LinkValue.parseFromString(app, ...)`: returns an Obsidian value object. Use plain link parsing DTOs instead.
@ -125,23 +120,23 @@ Do not mirror directly:
Rename or move files and folders.
- [x] `api.vault.rename(path, newPath)` mirrors `app.vault.rename(...)`.
- [x] `api.fileManager.renameFile(path, newPath)` mirrors `app.fileManager.renameFile(...)` and may update links according to user settings.
Implementation guardrail:
- [x] Do not expose `app.vault.rename(...)` as a separate script method; `api.fileManager.renameFile` is the safer default because it uses Obsidian's link-update behavior.
- [x] Document that `api.fileManager.renameFile` can modify links in other notes and should not be treated as a simple path metadata change.
## `vault:delete`
Trash or delete vault entries.
Trash vault entries using Obsidian deletion settings.
- [x] `api.vault.trash(path, system)` mirrors `app.vault.trash(...)`.
- [x] `api.vault.delete(path, options?)` mirrors `app.vault.delete(...)`.
- [x] `api.fileManager.trashFile(path)` mirrors `app.fileManager.trashFile(...)`.
Do not mirror:
- [x] `app.vault.trash(...)` as a separate script method: it asks scripts to choose Obsidian vs system trash instead of respecting the user's configured deletion behavior.
- [x] `app.vault.delete(...)` as a separate script method: permanent deletion is unnecessary while `api.fileManager.trashFile` exists.
- [ ] `app.fileManager.promptForDeletion(file)`: host UI flow is better owned by Safe JS permission approval or a dedicated confirmation API.
## `workspace:read`
@ -249,11 +244,11 @@ Do not mirror:
Network is off by default and must be explicitly requested and documented.
- [x] `api.network.request(urlOrOptions)` mirrors `request(...)`.
- [x] `api.network.requestUrl(urlOrOptions)` mirrors `requestUrl(...)` and returns a JSON-safe response DTO.
Implementation guardrail:
- [x] Do not expose `request(...)` as a separate script method; `api.network.requestUrl(...)` returns response text plus status, headers, JSON, and base64 data.
- [x] Approval copy must say that network access can send script-provided data to external services.
- [x] If combined with `vault:read`, `metadata:read`, `workspace:read`, or `editor:read`, approval copy must say vault or editor data can be exfiltrated.
@ -275,6 +270,7 @@ Implementation guardrail:
Read Safe JS storage scoped to the script source hash only. Do not expose arbitrary vault localStorage keys. Do NOT expose the permission storage.
- [x] `api.storage.get(key)` mirrors a Safe JS-owned source-scoped wrapper over `app.loadLocalStorage(...)`.
- [x] `api.storage.keys()` lists source-scoped Safe JS storage keys.
## `storage:write`
@ -282,12 +278,14 @@ Write Safe JS storage scoped to the script source hash only.
- [x] `api.storage.set(key, value)` mirrors a Safe JS-owned source-scoped wrapper over `app.saveLocalStorage(...)`.
- [x] `api.storage.delete(key)` mirrors `app.saveLocalStorage(key, null)` through a Safe JS-owned source-scoped wrapper.
- [x] `api.storage.clear()` deletes all source-scoped Safe JS storage keys.
## `storage:global-read`
Read Safe JS storage shared across approved scripts on this device.
- [x] `api.globalStorage.get(key)` mirrors a Safe JS-owned global wrapper over `app.loadLocalStorage(...)`.
- [x] `api.globalStorage.keys()` lists global Safe JS storage keys.
## `storage:global-write`
@ -295,6 +293,7 @@ Write Safe JS storage shared across approved scripts on this device.
- [x] `api.globalStorage.set(key, value)` mirrors a Safe JS-owned global wrapper over `app.saveLocalStorage(...)`.
- [x] `api.globalStorage.delete(key)` mirrors `app.saveLocalStorage(key, null)` through a Safe JS-owned global wrapper.
- [x] `api.globalStorage.clear()` deletes all global Safe JS storage keys.
Implementation guardrail:
@ -307,13 +306,13 @@ These mirror pure Obsidian helper functions through normal permission-gated RPC
- [x] `api.path.normalize(path)` mirrors `normalizePath(path)`.
- [x] `api.link.parseLinktext(linktext)` mirrors `parseLinktext(linktext)`.
- [x] `api.link.getLinkpath(linktext)` mirrors `getLinkpath(linktext)`.
- [x] `api.search.prepareSimpleSearch(query, text)` mirrors `prepareSimpleSearch(query)(text)`.
- [x] `api.search.prepareFuzzySearch(query, text)` mirrors `prepareFuzzySearch(query)(text)`.
- [x] `api.yaml.parse(yaml)` mirrors `parseYaml(yaml)`.
- [x] `api.yaml.stringify(value)` mirrors `stringifyYaml(value)`.
- [ ] `api.html.toMarkdown(html)` mirrors `htmlToMarkdown(html)` if it can run without DOM leakage.
- [ ] `api.html.sanitize(html)` mirrors `sanitizeHTMLToDom(html)` only if returning sanitized HTML text, not DOM nodes.
- [x] Do not expose `getLinkpath(linktext)` as a separate script method; `api.link.parseLinktext(linktext).path` covers it.
## Explicitly out of scope

View file

@ -1,18 +1,17 @@
# Find broken links
This Safe JS block scans only notes in this folder and reports links that point to missing notes in the same folder.
This Safe JS block scans only notes in this folder and reports links that point to missing notes in the same folder as a nested Markdown list.
```safe-js
// @permission vault:read
// @permission metadata:read
// @permission helpers:use
// @permission output:render-rich
const folder = "Broken links finder";
const listed = await api.vault.list(folder);
const notes = listed.files
.filter(file => file.type === "file" && file.extension === "md")
.sort((left, right) => left.path.localeCompare(right.path));
const brokenLinks = [];
const brokenLinksByNote = new Map();
function decodeLinkTarget(target) {
try {
@ -27,7 +26,8 @@ function isExternalLink(target) {
}
async function scopedTargetPath(linkText, sourcePath) {
let target = await api.link.getLinkpath(linkText);
const link = await api.link.parseLinktext(linkText);
let target = link.path;
if (target.length === 0 || target.startsWith("#") || isExternalLink(target)) {
return null;
}
@ -53,6 +53,24 @@ async function scopedTargetPath(linkText, sourcePath) {
return await api.path.normalize(`${folder}/${target}`);
}
function basename(path) {
const parts = path.split("/");
return parts[parts.length - 1] ?? path;
}
function displayName(path) {
const name = basename(path);
return name.endsWith(".md") ? name.slice(0, -3) : name;
}
function escapeWikiAlias(alias) {
return alias.replace(/\|/gu, "\\|").replace(/\n/gu, " ");
}
function wikiLink(path, alias = displayName(path)) {
return `[[${path}|${escapeWikiAlias(alias)}]]`;
}
function collectCachedLinks(cache) {
const links = [];
for (const entry of [...(cache?.links ?? []), ...(cache?.embeds ?? [])]) {
@ -74,14 +92,30 @@ for (const note of notes) {
const targetPath = await scopedTargetPath(link, note.path);
const resolved = targetPath === null ? null : await api.metadata.getFirstLinkpathDest(targetPath, note.path);
if (targetPath !== null && isInScope(targetPath) && resolved === null) {
brokenLinks.push(`${note.path} -> ${link}`);
const brokenLinks = brokenLinksByNote.get(note.path) ?? [];
brokenLinks.push({ link, targetPath });
brokenLinksByNote.set(note.path, brokenLinks);
}
}
}
if (brokenLinks.length === 0) {
return `No broken links found in ${folder}.`;
if (brokenLinksByNote.size === 0) {
return {
format: "markdown",
content: `No broken links found in ${folder}.`,
};
}
return [`Broken links in ${folder}:`, "", ...brokenLinks].join("\n");
const lines = [`Broken links in ${folder}:`, ""];
for (const [notePath, brokenLinks] of [...brokenLinksByNote.entries()].sort(([left], [right]) => left.localeCompare(right))) {
lines.push(`- ${wikiLink(notePath)}`);
for (const brokenLink of brokenLinks.sort((left, right) => left.targetPath.localeCompare(right.targetPath))) {
lines.push(` - ${wikiLink(brokenLink.targetPath, brokenLink.link)}`);
}
}
return {
format: "markdown",
content: lines.join("\n"),
};
```

View file

@ -1,5 +1,3 @@
# Implementation brief
The current plan follows the [[Roadmap]].
Capture lessons in [[Retrospective]] after the sample is complete.

View file

@ -1,5 +1,3 @@
# Project index
Start with the [[Roadmap]] and the [[Research notes]].
The [implementation brief](Implementation%20brief.md) is ready for review.

View file

@ -1,5 +1,3 @@
# Research notes
Return to the [[Project index|index]] or compare against the [roadmap](Roadmap.md).
## Open questions

View file

@ -50,7 +50,7 @@ export const PERMISSION_DEFINITIONS: PermissionDefinition[] = [
{
id: 'vault:delete',
name: 'Delete vault items',
description: 'Trash or permanently delete files and folders from the vault.',
description: 'Move files and folders to trash using Obsidian deletion settings.',
severity: 'critical',
grantGuidance: "Grant this only when deletion is the script's main purpose and you trust the source.",
},

View file

@ -1,4 +1,4 @@
import { getLinkpath, normalizePath, parseLinktext, parseYaml, prepareFuzzySearch, prepareSimpleSearch, stringifyYaml } from 'obsidian';
import { normalizePath, parseLinktext, parseYaml, prepareFuzzySearch, prepareSimpleSearch, stringifyYaml } from 'obsidian';
import { jsonValueSchema } from 'packages/obsidian/src/execution/contracts';
import { toJsonValue } from 'packages/obsidian/src/execution/json';
import { method } from 'packages/obsidian/src/rpc/rpc-method-helpers';
@ -42,18 +42,6 @@ export function createHelperMethods(): RpcMethodDefinition[] {
responseSchema: linktextPartsSchema,
handler: params => parseLinktext(params.linktext),
}),
method({
method: 'link:getLinkpath',
permission: 'helpers:use',
description: 'Return the path portion of wikilink text.',
usage: 'api.link.getLinkpath(linktext)',
namespace: 'link',
functionName: 'getLinkpath',
argNames: ['linktext'],
requestSchema: z.object({ linktext: z.string() }),
responseSchema: z.string(),
handler: params => getLinkpath(params.linktext),
}),
method({
method: 'search:prepareSimpleSearch',
permission: 'helpers:use',

View file

@ -38,21 +38,6 @@ export function createMetadataMethods(app: App): RpcMethodDefinition[] {
responseSchema: jsonValueResponseSchema,
handler: params => ({ value: toJsonValue(app.metadataCache.getFileCache(requireFile(app, params.path))) }),
}),
method({
method: 'metadata:getCache',
permission: 'metadata:read',
description: 'Read Obsidian metadata cache by path.',
usage: 'api.metadata.getCache(path)',
namespace: 'metadata',
functionName: 'getCache',
paramStyle: 'path',
requestSchema: pathParamsSchema,
responseSchema: jsonValueResponseSchema,
handler(params) {
const path = validateVaultPath(params.path, { configDir: app.vault.configDir });
return { value: toJsonValue(app.metadataCache.getCache(path)) };
},
}),
method({
method: 'metadata:getFirstLinkpathDest',
permission: 'metadata:read',

View file

@ -1,7 +1,7 @@
import { request, requestUrl } from 'obsidian';
import { requestUrl } from 'obsidian';
import { jsonValueSchema } from 'packages/obsidian/src/execution/contracts';
import { encodeArrayBuffer, toJsonValue } from 'packages/obsidian/src/rpc/rpc-common';
import { httpUrlSchema, method, optionalBooleanSchema, optionalStringSchema, stringResponseSchema } from 'packages/obsidian/src/rpc/rpc-method-helpers';
import { httpUrlSchema, method, optionalBooleanSchema, optionalStringSchema } from 'packages/obsidian/src/rpc/rpc-method-helpers';
import type { RpcMethodDefinition } from 'packages/obsidian/src/rpc/rpc-registry';
import { z } from 'zod';
@ -16,20 +16,6 @@ export function createNetworkMethods(): RpcMethodDefinition[] {
});
return [
method({
method: 'network:request',
permission: 'network:request',
description: 'Make an HTTP or HTTPS request and return response text.',
usage: 'api.network.request(urlOrOptions)',
namespace: 'network',
functionName: 'request',
argNames: ['urlOrOptions'],
requestSchema: z.object({ urlOrOptions: z.union([httpUrlSchema, requestParamsSchema]) }),
responseSchema: stringResponseSchema,
async handler(params) {
return { value: await request(params.urlOrOptions) };
},
}),
method({
method: 'network:requestUrl',
permission: 'network:request',

View file

@ -21,6 +21,21 @@ export function createStorageMethods(app: App): RpcMethodDefinition[] {
responseSchema: jsonValueResponseSchema,
handler: (params, context) => ({ value: createScopedStorageManager(app, context).get(params.key) }),
}),
method({
method: 'storage:keys',
permission: 'storage:read',
description: 'List Safe JS storage keys scoped to this script source.',
usage: 'api.storage.keys()',
namespace: 'storage',
functionName: 'keys',
requestSchema: z.object({}),
responseSchema: z.object({ keys: z.array(storageKeySchema) }),
handler: (_params, context) => ({
keys: createScopedStorageManager(app, context)
.list()
.map(entry => entry.key),
}),
}),
method({
method: 'storage:set',
permission: 'storage:write',
@ -51,6 +66,19 @@ export function createStorageMethods(app: App): RpcMethodDefinition[] {
return ok();
},
}),
method({
method: 'storage:clear',
permission: 'storage:write',
description: 'Delete all Safe JS storage values scoped to this script source.',
usage: 'api.storage.clear()',
namespace: 'storage',
functionName: 'clear',
requestSchema: z.object({}),
responseSchema: z.object({ deleted: z.number().int().min(0) }),
handler(_params, context) {
return { deleted: createScopedStorageManager(app, context).deleteAll() };
},
}),
method({
method: 'globalStorage:get',
permission: 'storage:global-read',
@ -63,6 +91,17 @@ export function createStorageMethods(app: App): RpcMethodDefinition[] {
responseSchema: jsonValueResponseSchema,
handler: params => ({ value: storageManager.get(params.key) }),
}),
method({
method: 'globalStorage:keys',
permission: 'storage:global-read',
description: 'List Safe JS storage keys shared across scripts on this device.',
usage: 'api.globalStorage.keys()',
namespace: 'globalStorage',
functionName: 'keys',
requestSchema: z.object({}),
responseSchema: z.object({ keys: z.array(storageKeySchema) }),
handler: () => ({ keys: storageManager.list().map(entry => entry.key) }),
}),
method({
method: 'globalStorage:set',
permission: 'storage:global-write',
@ -93,6 +132,19 @@ export function createStorageMethods(app: App): RpcMethodDefinition[] {
return ok();
},
}),
method({
method: 'globalStorage:clear',
permission: 'storage:global-write',
description: 'Delete all Safe JS storage values shared across scripts on this device.',
usage: 'api.globalStorage.clear()',
namespace: 'globalStorage',
functionName: 'clear',
requestSchema: z.object({}),
responseSchema: z.object({ deleted: z.number().int().min(0) }),
handler() {
return { deleted: storageManager.deleteAll() };
},
}),
];
}

View file

@ -2,40 +2,9 @@ import type { App } from 'obsidian';
import { ok, okResponseSchema, pathParamsSchema, requireAbstractFile } from 'packages/obsidian/src/rpc/rpc-common';
import { method } from 'packages/obsidian/src/rpc/rpc-method-helpers';
import type { RpcMethodDefinition } from 'packages/obsidian/src/rpc/rpc-registry';
import { z } from 'zod';
export function createVaultDeleteMethods(app: App): RpcMethodDefinition[] {
return [
method({
method: 'vault:trash',
permission: 'vault:delete',
description: 'Move a vault file or folder to system trash.',
usage: 'api.vault.trash(path, system)',
namespace: 'vault',
functionName: 'trash',
argNames: ['path', 'system'],
requestSchema: z.object({ path: z.string(), system: z.boolean() }),
responseSchema: okResponseSchema,
async handler(params) {
await app.vault.trash(requireAbstractFile(app, params.path), params.system);
return ok();
},
}),
method({
method: 'vault:delete',
permission: 'vault:delete',
description: 'Permanently delete a vault file or folder.',
usage: 'api.vault.delete(path, options?)',
namespace: 'vault',
functionName: 'delete',
argNames: ['path', 'options'],
requestSchema: z.object({ path: z.string(), options: z.object({ force: z.boolean().optional() }).optional() }),
responseSchema: okResponseSchema,
async handler(params) {
await app.vault.delete(requireAbstractFile(app, params.path), params.options?.force);
return ok();
},
}),
method({
method: 'fileManager:trashFile',
permission: 'vault:delete',

View file

@ -6,21 +6,6 @@ import { z } from 'zod';
export function createVaultMoveMethods(app: App): RpcMethodDefinition[] {
return [
method({
method: 'vault:rename',
permission: 'vault:move',
description: 'Rename or move a vault file or folder without automatic link updates.',
usage: 'api.vault.rename(path, newPath)',
namespace: 'vault',
functionName: 'rename',
argNames: ['path', 'newPath'],
requestSchema: z.object({ path: z.string(), newPath: z.string() }),
responseSchema: okResponseSchema,
async handler(params) {
await app.vault.rename(requireAbstractFile(app, params.path), assertTargetDoesNotExist(app, params.newPath));
return ok();
},
}),
method({
method: 'fileManager:renameFile',
permission: 'vault:move',

View file

@ -2,14 +2,7 @@ import type { App } from 'obsidian';
import {
abstractFileDtoSchema,
abstractFileToDto,
emptyParamsSchema,
encodeArrayBuffer,
fileDtoSchema,
fileToDto,
folderDtoSchema,
folderToDto,
nullableFileDtoSchema,
nullableFolderDtoSchema,
optionalPathParamsSchema,
pathParamsSchema,
requireAbstractFile,
@ -17,7 +10,7 @@ import {
isSafeVaultPath,
validateVaultPath,
} from 'packages/obsidian/src/rpc/rpc-common';
import { booleanResponseSchema, method, sortByPath } from 'packages/obsidian/src/rpc/rpc-method-helpers';
import { booleanResponseSchema, method } from 'packages/obsidian/src/rpc/rpc-method-helpers';
import type { RpcMethodDefinition } from 'packages/obsidian/src/rpc/rpc-registry';
import { z } from 'zod';
@ -117,99 +110,5 @@ export function createVaultReadMethods(app: App): RpcMethodDefinition[] {
return { value: app.vault.getAbstractFileByPath(path) !== null };
},
}),
method({
method: 'vault:getFile',
permission: 'vault:read',
description: 'Read file metadata for a path, or null when it is not a file.',
usage: 'api.vault.getFile(path)',
namespace: 'vault',
functionName: 'getFile',
paramStyle: 'path',
requestSchema: pathParamsSchema,
responseSchema: nullableFileDtoSchema,
handler(params) {
const path = validateVaultPath(params.path, { configDir: app.vault.configDir });
const file = app.vault.getFileByPath(path);
return file === null ? null : fileToDto(file);
},
}),
method({
method: 'vault:getFolder',
permission: 'vault:read',
description: 'Read folder metadata for a path, or null when it is not a folder.',
usage: 'api.vault.getFolder(path)',
namespace: 'vault',
functionName: 'getFolder',
paramStyle: 'path',
requestSchema: pathParamsSchema,
responseSchema: nullableFolderDtoSchema,
handler(params) {
const path = validateVaultPath(params.path, { allowEmpty: true, configDir: app.vault.configDir, label: 'Folder path' });
const folder = path === '' ? app.vault.getRoot() : app.vault.getFolderByPath(path);
return folder === null ? null : folderToDto(folder, true, child => isSafeVaultPath(app, child.path));
},
}),
method({
method: 'vault:getRoot',
permission: 'vault:read',
description: 'Read metadata for the vault root folder.',
usage: 'api.vault.getRoot()',
namespace: 'vault',
functionName: 'getRoot',
requestSchema: emptyParamsSchema,
responseSchema: folderDtoSchema,
handler: () => folderToDto(app.vault.getRoot(), true, child => isSafeVaultPath(app, child.path)),
}),
method({
method: 'vault:getFiles',
permission: 'vault:read',
description: 'List all vault files.',
usage: 'api.vault.getFiles()',
namespace: 'vault',
functionName: 'getFiles',
requestSchema: emptyParamsSchema,
responseSchema: z.object({ files: z.array(fileDtoSchema) }),
handler: () => ({
files: app.vault
.getFiles()
.filter(file => isSafeVaultPath(app, file.path))
.map(fileToDto)
.sort(sortByPath),
}),
}),
method({
method: 'vault:getMarkdownFiles',
permission: 'vault:read',
description: 'List all Markdown files in the vault.',
usage: 'api.vault.getMarkdownFiles()',
namespace: 'vault',
functionName: 'getMarkdownFiles',
requestSchema: emptyParamsSchema,
responseSchema: z.object({ files: z.array(fileDtoSchema) }),
handler: () => ({
files: app.vault
.getMarkdownFiles()
.filter(file => isSafeVaultPath(app, file.path))
.map(fileToDto)
.sort(sortByPath),
}),
}),
method({
method: 'vault:getFolders',
permission: 'vault:read',
description: 'List all vault folders, optionally including the root folder.',
usage: 'api.vault.getFolders({ includeRoot: true })',
namespace: 'vault',
functionName: 'getFolders',
requestSchema: z.object({ includeRoot: z.boolean().optional() }),
responseSchema: z.object({ folders: z.array(folderDtoSchema) }),
handler: params => ({
folders: app.vault
.getAllFolders(params.includeRoot)
.filter(folder => folder.isRoot() || isSafeVaultPath(app, folder.path))
.map(folder => folderToDto(folder))
.sort(sortByPath),
}),
}),
];
}

View file

@ -56,6 +56,7 @@ test('registers helper methods under the helper permission', async () => {
expect(bindings.map(binding => binding.method)).toContain('path:normalize');
expect(bindings.map(binding => binding.method)).toContain('yaml:parse');
expect(bindings.map(binding => binding.method)).not.toContain('link:getLinkpath');
expect(bindings.every(binding => binding.permission === 'helpers:use')).toBe(true);
});

View file

@ -1,8 +1,44 @@
import { expect, test } from 'bun:test';
import { expect, mock, test } from 'bun:test';
import type { App } from 'obsidian';
import { RpcRegistry } from 'packages/obsidian/src/rpc/rpc-registry';
import { storageKeySchema } from 'packages/obsidian/src/storage/storage-validation';
import { ScriptStorageManager, scopedScriptStorageKey, scriptStorageKey } from 'packages/obsidian/src/storage/script-storage';
mock.module('obsidian', () => ({
TFile: class TFile {},
TFolder: class TFolder {},
arrayBufferToBase64(_buffer: ArrayBuffer): string {
return '';
},
base64ToArrayBuffer(_base64: string): ArrayBuffer {
return new ArrayBuffer(0);
},
normalizePath(path: string): string {
return path.replace(/\\/gu, '/').replace(/\/+/gu, '/').replace('/./', '/');
},
parseLinktext(linktext: string): { path: string; subpath: string } {
const withoutAlias = linktext.split('|')[0] ?? '';
const [path = '', subpath = ''] = withoutAlias.split('#');
return { path, subpath: subpath === '' ? '' : `#${subpath}` };
},
parseYaml(_yaml: string): unknown {
return {
fruit: 'apple',
count: 2,
};
},
prepareFuzzySearch(_query: string): (text: string) => { score: number; matches: [number, number][] } | null {
return text => (text.length > 0 ? { score: 1, matches: [[0, 5]] } : null);
},
prepareSimpleSearch(_query: string): (text: string) => { score: number; matches: [number, number][] } | null {
return text => (text.length > 0 ? { score: 1, matches: [[0, 5]] } : null);
},
stringifyYaml(value: unknown): string {
const record = value as { fruit?: string };
return `fruit: ${record.fruit ?? ''}\n`;
},
}));
class FakeAppLocalStorage {
private readonly values = new Map<string, unknown>();
@ -61,3 +97,32 @@ test('rejects reserved storage keys that can cross storage scopes', () => {
expect(storageKeySchema.safeParse('__scopes').success).toBe(false);
expect(storageKeySchema.safeParse('scoped:hash:key').success).toBe(false);
});
test('storage RPC can list and clear scoped and global keys', async () => {
const app = new FakeAppLocalStorage() as unknown as App;
const { createStorageMethods } = await import('packages/obsidian/src/rpc/obsidian/storage-rpc');
const registry = new RpcRegistry({
methods: createStorageMethods(app),
validators: { getConfigDir: () => '.obsidian' },
});
await registry.dispatch('storage:set', { key: 'scoped-key', value: 'scoped' }, { codeHash: 'hash:a', grantedPermissions: new Set(['storage:write']) });
await registry.dispatch('globalStorage:set', { key: 'global-key', value: 'global' }, { grantedPermissions: new Set(['storage:global-write']) });
expect(await registry.dispatch('storage:keys', {}, { codeHash: 'hash:a', grantedPermissions: new Set(['storage:read']) })).toEqual({
ok: true,
result: { keys: ['scoped-key'] },
});
expect(await registry.dispatch('globalStorage:keys', {}, { grantedPermissions: new Set(['storage:global-read']) })).toEqual({
ok: true,
result: { keys: ['global-key'] },
});
expect(await registry.dispatch('storage:clear', {}, { codeHash: 'hash:a', grantedPermissions: new Set(['storage:write']) })).toEqual({
ok: true,
result: { deleted: 1 },
});
expect(await registry.dispatch('globalStorage:clear', {}, { grantedPermissions: new Set(['storage:global-write']) })).toEqual({
ok: true,
result: { deleted: 1 },
});
});