mirror of
https://github.com/mprojectscode/obsidian-safe-js-plugin.git
synced 2026-07-22 06:55:38 +00:00
update script api
This commit is contained in:
parent
60d9162e25
commit
5906e155f9
15 changed files with 179 additions and 222 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
};
|
||||
```
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
# Implementation brief
|
||||
|
||||
The current plan follows the [[Roadmap]].
|
||||
|
||||
Capture lessons in [[Retrospective]] after the sample is complete.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
# Project index
|
||||
|
||||
Start with the [[Roadmap]] and the [[Research notes]].
|
||||
|
||||
The [implementation brief](Implementation%20brief.md) is ready for review.
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
# Research notes
|
||||
|
||||
Return to the [[Project index|index]] or compare against the [roadmap](Roadmap.md).
|
||||
|
||||
## Open questions
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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() };
|
||||
},
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
}),
|
||||
}),
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue