From 5906e155f936b62d5eb0ed698cc0b09d26edc3db Mon Sep 17 00:00:00 2001 From: Moritz Jung Date: Sun, 31 May 2026 22:08:50 +0200 Subject: [PATCH] update script api --- docs/SAFE_JS_API_MIRROR.md | 25 ++--- .../Broken links finder/Find broken links.md | 52 +++++++-- .../Implementation brief.md | 2 - .../Broken links finder/Project index.md | 2 - .../Broken links finder/Research notes.md | 2 - .../obsidian/src/permissions/permissions.ts | 2 +- .../obsidian/src/rpc/obsidian/helper-rpc.ts | 14 +-- .../obsidian/src/rpc/obsidian/metadata-rpc.ts | 15 --- .../obsidian/src/rpc/obsidian/network-rpc.ts | 18 +-- .../obsidian/src/rpc/obsidian/storage-rpc.ts | 52 +++++++++ .../src/rpc/obsidian/vault-delete-rpc.ts | 31 ------ .../src/rpc/obsidian/vault-move-rpc.ts | 15 --- .../src/rpc/obsidian/vault-read-rpc.ts | 103 +----------------- test/helper-rpc.test.ts | 1 + test/script-storage.test.ts | 67 +++++++++++- 15 files changed, 179 insertions(+), 222 deletions(-) diff --git a/docs/SAFE_JS_API_MIRROR.md b/docs/SAFE_JS_API_MIRROR.md index 9610c66..5903ab2 100644 --- a/docs/SAFE_JS_API_MIRROR.md +++ b/docs/SAFE_JS_API_MIRROR.md @@ -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 diff --git a/exampleVault/Broken links finder/Find broken links.md b/exampleVault/Broken links finder/Find broken links.md index 57515c7..0b46433 100644 --- a/exampleVault/Broken links finder/Find broken links.md +++ b/exampleVault/Broken links finder/Find broken links.md @@ -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"), +}; ``` diff --git a/exampleVault/Broken links finder/Implementation brief.md b/exampleVault/Broken links finder/Implementation brief.md index 4b6c119..a1e57bc 100644 --- a/exampleVault/Broken links finder/Implementation brief.md +++ b/exampleVault/Broken links finder/Implementation brief.md @@ -1,5 +1,3 @@ -# Implementation brief - The current plan follows the [[Roadmap]]. Capture lessons in [[Retrospective]] after the sample is complete. diff --git a/exampleVault/Broken links finder/Project index.md b/exampleVault/Broken links finder/Project index.md index 35fd586..5eaa7b5 100644 --- a/exampleVault/Broken links finder/Project index.md +++ b/exampleVault/Broken links finder/Project index.md @@ -1,5 +1,3 @@ -# Project index - Start with the [[Roadmap]] and the [[Research notes]]. The [implementation brief](Implementation%20brief.md) is ready for review. diff --git a/exampleVault/Broken links finder/Research notes.md b/exampleVault/Broken links finder/Research notes.md index c64d590..79415a0 100644 --- a/exampleVault/Broken links finder/Research notes.md +++ b/exampleVault/Broken links finder/Research notes.md @@ -1,5 +1,3 @@ -# Research notes - Return to the [[Project index|index]] or compare against the [roadmap](Roadmap.md). ## Open questions diff --git a/packages/obsidian/src/permissions/permissions.ts b/packages/obsidian/src/permissions/permissions.ts index 75392eb..bd52bcb 100644 --- a/packages/obsidian/src/permissions/permissions.ts +++ b/packages/obsidian/src/permissions/permissions.ts @@ -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.", }, diff --git a/packages/obsidian/src/rpc/obsidian/helper-rpc.ts b/packages/obsidian/src/rpc/obsidian/helper-rpc.ts index f339113..405df6f 100644 --- a/packages/obsidian/src/rpc/obsidian/helper-rpc.ts +++ b/packages/obsidian/src/rpc/obsidian/helper-rpc.ts @@ -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', diff --git a/packages/obsidian/src/rpc/obsidian/metadata-rpc.ts b/packages/obsidian/src/rpc/obsidian/metadata-rpc.ts index 48464a4..8e16222 100644 --- a/packages/obsidian/src/rpc/obsidian/metadata-rpc.ts +++ b/packages/obsidian/src/rpc/obsidian/metadata-rpc.ts @@ -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', diff --git a/packages/obsidian/src/rpc/obsidian/network-rpc.ts b/packages/obsidian/src/rpc/obsidian/network-rpc.ts index de63d4d..4d62db9 100644 --- a/packages/obsidian/src/rpc/obsidian/network-rpc.ts +++ b/packages/obsidian/src/rpc/obsidian/network-rpc.ts @@ -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', diff --git a/packages/obsidian/src/rpc/obsidian/storage-rpc.ts b/packages/obsidian/src/rpc/obsidian/storage-rpc.ts index 535fdfa..1238bd0 100644 --- a/packages/obsidian/src/rpc/obsidian/storage-rpc.ts +++ b/packages/obsidian/src/rpc/obsidian/storage-rpc.ts @@ -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() }; + }, + }), ]; } diff --git a/packages/obsidian/src/rpc/obsidian/vault-delete-rpc.ts b/packages/obsidian/src/rpc/obsidian/vault-delete-rpc.ts index 4332c3c..e6fe75a 100644 --- a/packages/obsidian/src/rpc/obsidian/vault-delete-rpc.ts +++ b/packages/obsidian/src/rpc/obsidian/vault-delete-rpc.ts @@ -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', diff --git a/packages/obsidian/src/rpc/obsidian/vault-move-rpc.ts b/packages/obsidian/src/rpc/obsidian/vault-move-rpc.ts index b04f2b2..f449b0c 100644 --- a/packages/obsidian/src/rpc/obsidian/vault-move-rpc.ts +++ b/packages/obsidian/src/rpc/obsidian/vault-move-rpc.ts @@ -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', diff --git a/packages/obsidian/src/rpc/obsidian/vault-read-rpc.ts b/packages/obsidian/src/rpc/obsidian/vault-read-rpc.ts index d2d6003..1281104 100644 --- a/packages/obsidian/src/rpc/obsidian/vault-read-rpc.ts +++ b/packages/obsidian/src/rpc/obsidian/vault-read-rpc.ts @@ -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), - }), - }), ]; } diff --git a/test/helper-rpc.test.ts b/test/helper-rpc.test.ts index c52504a..43e3384 100644 --- a/test/helper-rpc.test.ts +++ b/test/helper-rpc.test.ts @@ -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); }); diff --git a/test/script-storage.test.ts b/test/script-storage.test.ts index c47be4f..823725c 100644 --- a/test/script-storage.test.ts +++ b/test/script-storage.test.ts @@ -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(); @@ -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 }, + }); +});