From 62038c6330ff16e91c609e1d1694fb954806dd02 Mon Sep 17 00:00:00 2001 From: tianma-if Date: Tue, 1 Sep 2026 18:04:15 +0800 Subject: [PATCH] feat(plugins): expand generic plugin APIs --- apps/web/src/components/EditorPane.tsx | 34 ++- apps/web/src/components/WorkspaceApp.tsx | 53 ++++- .../plugin-host-capabilities.fixture.mjs | 44 +++- .../plugins/plugin-host-events.fixture.mjs | 9 +- apps/web/src/lib/plugins/plugin-host.test.mjs | 126 ++++++++++- apps/web/src/lib/plugins/plugin-host.ts | 208 +++++++++++++++++- apps/web/src/lib/repository-events.ts | 20 +- docs/plugin-development.md | 70 +++++- docs/plugin-development.zh-CN.md | 70 +++++- packages/plugin-api/README.md | 14 ++ packages/plugin-api/dist/index.d.ts | 82 ++++++- packages/plugin-api/dist/index.js | 5 + packages/plugin-api/src/index.test.mjs | 4 +- packages/plugin-api/src/index.ts | 71 ++++++ 14 files changed, 787 insertions(+), 23 deletions(-) diff --git a/apps/web/src/components/EditorPane.tsx b/apps/web/src/components/EditorPane.tsx index 82ec7cd7..06b0d35d 100644 --- a/apps/web/src/components/EditorPane.tsx +++ b/apps/web/src/components/EditorPane.tsx @@ -451,6 +451,7 @@ type EditorPaneProps = { onOpenMemo?: (memoId: string) => void; onOpenAiPrompts?: () => void; pluginHost: EdgeEverPluginHost; + pluginNavigationRequest?: { id: number; noteId: string; search: string } | null; }; type RichEditorPaneProps = EditorPaneProps & { @@ -524,6 +525,7 @@ const RichEditorPane = ({ onOpenMemo, onOpenAiPrompts, pluginHost, + pluginNavigationRequest, onRequestMobileNativeEdit, }: RichEditorPaneProps) => { const { t, i18n } = useTranslation(); @@ -575,6 +577,7 @@ const RichEditorPane = ({ const [noteSearchReplaceOpen, setNoteSearchReplaceOpen] = useState(false); const [noteSearchReplacement, setNoteSearchReplacement] = useState(""); const [noteSearchIndex, setNoteSearchIndex] = useState(0); + const handledPluginNavigationRequestRef = useRef(0); const [noteLinkPickerOpen, setNoteLinkPickerOpen] = useState(false); const [noteLinkQuery, setNoteLinkQuery] = useState(""); const [noteLinkHintPosition, setNoteLinkHintPosition] = useState(null); @@ -1742,6 +1745,23 @@ const RichEditorPane = ({ setNoteSearchReplaceOpen, }); + useEffect(() => { + if ( + !pluginNavigationRequest + || !memo + || pluginNavigationRequest.id === handledPluginNavigationRequestRef.current + || pluginNavigationRequest.noteId !== memo.id + || hydratedEditorMemoId !== memo.id + || !isEditorReady(editor) + ) return; + handledPluginNavigationRequestRef.current = pluginNavigationRequest.id; + setNoteSearchQuery(pluginNavigationRequest.search); + setNoteSearchIndex(0); + setNoteSearchReplaceOpen(false); + setNoteSearchOpen(true); + window.requestAnimationFrame(() => noteSearchInputRef.current?.focus()); + }, [editor, hydratedEditorMemoId, memo?.id, pluginNavigationRequest]); + useEffect(() => { if (!isEditorReady(editor)) { return; @@ -2905,6 +2925,18 @@ const RichEditorPane = ({ contentMarkdown: context?.contentMarkdown ?? "", }; }, + getDocument: () => ({ + noteId: pluginEditorMemoId, + contentMarkdown: isMarkdownMode + ? markdownSource + : docToMarkdown(editor.getJSON() as TiptapDoc), + hasUnsavedChanges: hasUnsavedChanges || saveMutation.isPending, + }), + replaceDocument: (contentMarkdown) => { + if (isMarkdownMode) setMarkdownSource(contentMarkdown); + else editor.commands.setContent(markdownToDoc(contentMarkdown)); + markDirty(); + }, replaceSelection: (contentMarkdown) => { const { selection, doc } = editor.state; const context = getRichTextAiSelectionContext(doc, selection); @@ -2917,7 +2949,7 @@ const RichEditorPane = ({ }, }; return pluginHost.setEditorAdapter(adapter); - }, [editor, effectiveReadOnly, hydratedEditorMemoId, pluginEditorMemoId, pluginHost]); + }, [editor, effectiveReadOnly, hasUnsavedChanges, hydratedEditorMemoId, isMarkdownMode, markdownSource, markDirty, pluginEditorMemoId, pluginHost, saveMutation.isPending]); // useMutation returns a new result object on every render. Depending on the // whole object makes autosave timers restart during unrelated renders and // can starve a recovered draft indefinitely. These members are stable (or diff --git a/apps/web/src/components/WorkspaceApp.tsx b/apps/web/src/components/WorkspaceApp.tsx index 7309e58c..f3605eb1 100644 --- a/apps/web/src/components/WorkspaceApp.tsx +++ b/apps/web/src/components/WorkspaceApp.tsx @@ -27,6 +27,7 @@ import { import { MemoListPane, MemoSelectionActionBar } from "./MemoListPane"; import { QuickMemoSwitcher } from "./QuickMemoSwitcher"; import { AppConfirmDialog, MemoDeleteConfirmDialog, NotebookNameDialog } from "./dialogs/ConfirmDialogs"; +import { PluginPanelDialog } from "./plugins/PluginPanelDialog"; import { api } from "@/lib/api"; import { clearMobileEditorReturnPreview, @@ -94,7 +95,7 @@ import { useWorkspaceRoute } from "@/hooks/useWorkspaceRoute"; import { useWorkspacePreferences } from "@/hooks/useWorkspacePreferences"; import { useWorkspaceSelection } from "@/hooks/useWorkspaceSelection"; import { useWorkspaceQueuedSync } from "@/hooks/useWorkspaceQueuedSync"; -import { EdgeEverPluginHost } from "@/lib/plugins/plugin-host"; +import { EdgeEverPluginHost, type RegisteredPluginPanel } from "@/lib/plugins/plugin-host"; import { clearRendererRecoveryRequired, isRendererRecoveryRequired } from "@/lib/renderer-recovery"; import { EditorPaneErrorBoundary, EditorRecoveryPane } from "./EditorPaneErrorBoundary"; @@ -733,9 +734,13 @@ export const WorkspaceApp = ({ queryClient.invalidateQueries({ queryKey: ["memo"] }), queryClient.invalidateQueries({ queryKey: ["notebooks"] }), queryClient.invalidateQueries({ queryKey: ["tags"] }), + queryClient.invalidateQueries({ queryKey: ["templates"] }), ]); }, }), [localDataScope, queryClient, repository, t]); + const [requestedPluginPanel, setRequestedPluginPanel] = useState(null); + const pluginNavigationRequestIdRef = useRef(0); + const [pluginNavigationRequest, setPluginNavigationRequest] = useState<{ id: number; noteId: string; search: string } | null>(null); useEffect(() => { void pluginHost.activateEnabled(); @@ -744,6 +749,26 @@ export const WorkspaceApp = ({ }; }, [pluginHost]); + useEffect(() => pluginHost.setPanelAdapter({ + openPanel(pluginId, panelId) { + const panel = pluginHost.getSnapshot().panels.find((candidate) => + candidate.pluginId === pluginId && candidate.id === panelId); + if (!panel) throw new Error("Plugin panel is not registered."); + setRequestedPluginPanel(panel); + }, + }), [pluginHost]); + + useEffect(() => { + const unsubscribe = pluginHost.subscribe(() => { + setRequestedPluginPanel((current) => current && pluginHost.getSnapshot().panels.some( + (panel) => panel.pluginId === current.pluginId && panel.id === current.id, + ) ? current : null); + }); + return () => { + unsubscribe(); + }; + }, [pluginHost]); + const resetDemoMutation = useMutation({ mutationFn: async () => { await api.resetDemo(); @@ -2296,6 +2321,30 @@ export const WorkspaceApp = ({ setActivePane("editor"); }, [clearMemoSelection, clearPendingCreatedMemo, navigateWorkspaceHome, setSelectedMemoId, setSelectedNotebookId]); + const handleOpenPluginNote = useCallback((memoId: string, notebookId: string, options?: { search?: string }) => { + navigateWorkspaceHome(); + setMemoView("notebook"); + setSelectedTag(null); + setSelectedNotebookId(notebookId); + setSearch(""); + setMemoFilterMode("all"); + setRightView("editor"); + setMobileBottomNavActive("home"); + clearMemoSelection(); + clearPendingCreatedMemo(); + setCreatedMemoEditId(null); + setSelectedMemoId(memoId); + setActivePane("editor"); + if (options?.search) { + pluginNavigationRequestIdRef.current += 1; + setPluginNavigationRequest({ id: pluginNavigationRequestIdRef.current, noteId: memoId, search: options.search }); + } else { + setPluginNavigationRequest(null); + } + }, [clearMemoSelection, clearPendingCreatedMemo, navigateWorkspaceHome, setSelectedMemoId, setSelectedNotebookId]); + + useEffect(() => pluginHost.setNavigationAdapter({ openNote: handleOpenPluginNote }), [handleOpenPluginNote, pluginHost]); + const handleCancelMobileSearch = () => { setSearch(""); setMobileBottomNavActive("home"); @@ -3154,6 +3203,7 @@ export const WorkspaceApp = ({ memo={selectedMemo} repository={repository} pluginHost={pluginHost} + pluginNavigationRequest={pluginNavigationRequest} onOpenAiPrompts={handleOpenAiPrompts} desktopFocusMode={desktopFocusModeActive} onToggleDesktopFocusMode={toggleDesktopFocusMode} @@ -3335,6 +3385,7 @@ export const WorkspaceApp = ({ onConfirm={() => resetDemoMutation.mutate()} /> )} + setRequestedPluginPanel(null)} /> {visibleActivePane !== "editor" && !memoSelectionModeActive && ( { + disposeExercise(); + disposeStaleEdit(); + disposePanel(); + }; }, }; diff --git a/apps/web/src/lib/plugins/plugin-host-events.fixture.mjs b/apps/web/src/lib/plugins/plugin-host-events.fixture.mjs index 0c59defc..1d4d5a54 100644 --- a/apps/web/src/lib/plugins/plugin-host-events.fixture.mjs +++ b/apps/web/src/lib/plugins/plugin-host-events.fixture.mjs @@ -1,7 +1,14 @@ export default { activate(context) { - return context.events.on("note.updated", ({ note }) => { + const disposeNote = context.events.on("note.updated", ({ note }) => { globalThis.edgeeverPluginObservedNote = note; }); + const disposeTemplate = context.events.on("template.created", ({ template }) => { + globalThis.edgeeverPluginObservedTemplate = template; + }); + return () => { + disposeNote(); + disposeTemplate(); + }; }, }; diff --git a/apps/web/src/lib/plugins/plugin-host.test.mjs b/apps/web/src/lib/plugins/plugin-host.test.mjs index eaafccf1..35145675 100644 --- a/apps/web/src/lib/plugins/plugin-host.test.mjs +++ b/apps/web/src/lib/plugins/plugin-host.test.mjs @@ -36,7 +36,7 @@ globalThis.MutationObserver = class { disconnect() {} }; -const { EdgeEverPluginHost } = await import("./plugin-host.ts"); +const { EdgeEverPluginHost, applyPluginMarkdownEdits } = await import("./plugin-host.ts"); const { sha256Hex } = await import("./github-plugin-distribution.ts"); const { withRepositoryMutationEvents } = await import("../repository-events.ts"); @@ -96,6 +96,8 @@ describe("EdgeEverPluginHost", () => { let replacement = null; host.setEditorAdapter({ getSelection: () => ({ noteId: "note-1", from: 1, to: 6, empty: false, text: "hello", contentMarkdown: "hello" }), + getDocument: () => ({ noteId: "note-1", contentMarkdown: "hello", hasUnsavedChanges: false }), + replaceDocument: () => undefined, replaceSelection: (value) => { replacement = value; }, insertAtCursor: () => undefined, }); @@ -306,8 +308,46 @@ describe("EdgeEverPluginHost", () => { updatedAt: "2026-09-01T00:00:00.000Z", url: "https://edgeever.example/resource-1", }; + const note = { + id: "note-1", + notebookId: "notebook-1", + title: "First", + excerpt: "First note", + tags: ["test"], + isPinned: false, + isArchived: false, + isDeleted: false, + revision: 3, + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + deletedAt: null, + contentJson: { type: "doc", content: [] }, + contentMarkdown: "First note", + contentText: "First note", + contentHash: "first-hash", + sourceMemoIds: [], + mergeSourceCount: 0, + mergedIntoMemoId: null, + }; + let updateInput = null; + const template = { + id: "template-1", + name: "Existing template", + description: null, + title: null, + contentMarkdown: "Template", + tags: [], + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + }; const repositoryWithCapabilities = { ...repository, + listMemos: async () => ({ memos: [note], totalCount: 1, nextCursor: null }), + getMemo: async () => ({ memo: note }), + updateMemo: async (_memo, input) => { + updateInput = input; + return { memo: { ...note, contentMarkdown: input.contentMarkdown, contentText: "Updated note" }, queued: true }; + }, createNotebook: async (input) => ({ notebook: { id: "notebook-created", parentId: null, name: input.name, memoCount: 0 } }), moveMemos: async () => ({ ok: true, moved: 1 }), pinMemos: async () => ({ ok: true, updated: 1 }), @@ -323,9 +363,27 @@ describe("EdgeEverPluginHost", () => { }] }), listResources: async () => ({ resources: [{ ...resource, memoTitle: "Note", memoExcerpt: "", memoDeleted: false }], summary: {} }), uploadMemoResource: async () => ({ resource }), + listTemplates: async () => ({ templates: [template] }), + createTemplate: async (input) => ({ template: { ...template, name: input.name } }), + updateTemplate: async (_templateId, input) => ({ template: { ...template, name: input.name ?? template.name } }), + deleteTemplate: async () => ({ ok: true }), + useTemplate: async () => ({ memo: { ...note, id: "templated-note", contentMarkdown: template.contentMarkdown } }), }; const packageStorage = { get: async () => null, put: async () => undefined, remove: async () => undefined }; const host = new EdgeEverPluginHost({ repository: repositoryWithCapabilities, scope: "capabilities", packageStorage }); + const opened = []; + const openedPanels = []; + let liveDocument = "First note"; + let liveDocumentDirty = false; + host.setNavigationAdapter({ openNote: (noteId, notebookId, options) => opened.push({ noteId, notebookId, options }) }); + host.setPanelAdapter({ openPanel: (pluginId, panelId) => openedPanels.push({ pluginId, panelId }) }); + host.setEditorAdapter({ + getSelection: () => ({ noteId: "note-1", from: 0, to: 0, empty: true, text: "", contentMarkdown: "" }), + getDocument: () => ({ noteId: "note-1", contentMarkdown: liveDocument, hasUnsavedChanges: liveDocumentDirty }), + replaceDocument: (contentMarkdown) => { liveDocument = contentMarkdown; liveDocumentDirty = true; }, + replaceSelection: () => undefined, + insertAtCursor: () => undefined, + }); const entry = new URL("./plugin-host-capabilities.fixture.mjs", import.meta.url).href; host.installManifest({ type: "plugin", @@ -334,7 +392,11 @@ describe("EdgeEverPluginHost", () => { version: "1.0.0", apiVersion: "1", entry, - permissions: ["notes:read", "notes:write", "metadata:write", "resources:read", "resources:write", "ui:commands"], + permissions: [ + "notes:read", "notes:write", "metadata:write", "resources:read", "resources:write", + "templates:read", "templates:write", "editor:read", "editor:write", + "ui:commands", "ui:navigation", "ui:panels", + ], settings: { fields: [{ key: "endpoint", type: "text", label: "Endpoint", default: "https://api.example" }] }, }, "https://plugins.example/capabilities/manifest.json"); @@ -349,11 +411,51 @@ describe("EdgeEverPluginHost", () => { resources: [{ id: "resource-1", noteId: "note-1", filename: "hello.txt" }], uploaded: { id: "resource-1", noteId: "note-1" }, endpoint: "https://api.example", + note: { id: "note-1", revision: 3, contentHash: "first-hash" }, + edited: { contentMarkdown: "Updated note" }, + contentQuery: { notes: [{ id: "note-1", contentMarkdown: "First note" }], totalCount: 1 }, + templates: [{ id: "template-1", name: "Existing template" }], + createdTemplate: { id: "template-1", name: "Plugin template" }, + updatedTemplate: { id: "template-1", name: "Updated template" }, + templatedNote: { id: "templated-note", contentMarkdown: "Template" }, + editorDocument: { noteId: "note-1", contentMarkdown: "First note", hasUnsavedChanges: false }, + editedEditorDocument: { noteId: "note-1", contentMarkdown: "Live note", hasUnsavedChanges: true }, }); + expect(updateInput).toMatchObject({ + expectedRevision: 3, + expectedContentHash: "first-hash", + contentMarkdown: "Updated note", + }); + expect(opened).toEqual([{ noteId: "note-1", notebookId: "notebook-1", options: { search: "Updated note" } }]); + expect(openedPanels).toEqual([{ pluginId: "org.edgeever.capabilities", panelId: "capability-panel" }]); + await expect(host.runCommand("org.edgeever.capabilities", "edit-stale-note")) + .rejects.toMatchObject({ code: "NOTE_CONFLICT" }); + host.setEditorAdapter({ + getSelection: () => ({ noteId: "note-1", from: 0, to: 0, empty: true, text: "", contentMarkdown: "" }), + getDocument: () => ({ noteId: "note-1", contentMarkdown: "Draft", hasUnsavedChanges: true }), + replaceDocument: () => undefined, + replaceSelection: () => undefined, + insertAtCursor: () => undefined, + }); + await expect(host.runCommand("org.edgeever.capabilities", "exercise-capabilities")) + .rejects.toMatchObject({ code: "NOTE_CONFLICT" }); delete globalThis.edgeeverPluginCapabilityResult; await host.dispose(); }); + test("applies non-overlapping Markdown edits and rejects invalid ranges", () => { + expect(applyPluginMarkdownEdits("one two three", [ + { from: 8, to: 13, insert: "THREE" }, + { from: 0, to: 3, insert: "ONE" }, + ])).toBe("ONE two THREE"); + expect(() => applyPluginMarkdownEdits("abcdef", [ + { from: 1, to: 4, insert: "x" }, + { from: 3, to: 5, insert: "y" }, + ])).toThrow("cannot overlap"); + expect(() => applyPluginMarkdownEdits("A😀B", [{ from: 2, to: 2, insert: "x" }])) + .toThrow("surrogate pair"); + }); + test("delivers successful user and plugin repository mutations through one workspace event stream", async () => { const updatedMemo = { id: "note-events", @@ -376,9 +478,21 @@ describe("EdgeEverPluginHost", () => { mergeSourceCount: 0, mergedIntoMemoId: null, }; + const createdTemplate = { + id: "template-events", + name: "Event template", + description: null, + title: null, + contentJson: { type: "doc", content: [] }, + contentMarkdown: "Template", + tags: ["events"], + createdAt: "2026-09-01T00:00:00.000Z", + updatedAt: "2026-09-01T00:00:00.000Z", + }; const repositoryWithEvents = withRepositoryMutationEvents({ ...repository, updateMemo: async () => ({ memo: updatedMemo, queued: true }), + createTemplate: async () => ({ template: createdTemplate, queued: true }), }, "event-workspace"); const packageStorage = { get: async () => null, put: async () => undefined, remove: async () => undefined }; const host = new EdgeEverPluginHost({ repository: repositoryWithEvents, scope: "event-workspace", packageStorage }); @@ -389,18 +503,24 @@ describe("EdgeEverPluginHost", () => { version: "1.0.0", apiVersion: "1", entry: new URL("./plugin-host-events.fixture.mjs", import.meta.url).href, - permissions: ["notes:read"], + permissions: ["notes:read", "templates:read"], }, "https://plugins.example/events/manifest.json"); await host.setEnabled("org.edgeever.events", true); await host.activateEnabled(); await repositoryWithEvents.updateMemo(updatedMemo, {}); + await repositoryWithEvents.createTemplate({ name: "Event template" }); expect(globalThis.edgeeverPluginObservedNote).toMatchObject({ id: "note-events", contentMarkdown: "Updated", }); + expect(globalThis.edgeeverPluginObservedTemplate).toMatchObject({ + id: "template-events", + contentMarkdown: "Template", + }); delete globalThis.edgeeverPluginObservedNote; + delete globalThis.edgeeverPluginObservedTemplate; await host.dispose(); }); }); diff --git a/apps/web/src/lib/plugins/plugin-host.ts b/apps/web/src/lib/plugins/plugin-host.ts index 9f37b4e6..d1d69995 100644 --- a/apps/web/src/lib/plugins/plugin-host.ts +++ b/apps/web/src/lib/plugins/plugin-host.ts @@ -6,16 +6,21 @@ import { type PluginCommand, type PluginContext, type PluginEventMap, + type PluginEditorDocument, type PluginManifest, + type PluginMarkdownEdit, type PluginNote, type PluginNoteRevision, type PluginNoteSummary, type PluginPanel, + type PluginOpenNoteOptions, type PluginPermission, + type PluginApiErrorCode, type PluginResource, type PluginSettingField, type PluginSettingValue, type PluginEditorSelection, + type PluginTemplate, type ThemeManifest, type ThemeTokenName, type ThemeTokens, @@ -128,10 +133,20 @@ export interface RegisteredPluginAction { export interface PluginEditorAdapter { getSelection(): PluginEditorSelection | null; + getDocument(): PluginEditorDocument | null; + replaceDocument(contentMarkdown: string): void; replaceSelection(contentMarkdown: string): void; insertAtCursor(contentMarkdown: string): void; } +export interface PluginNavigationAdapter { + openNote(noteId: string, notebookId: string, options?: PluginOpenNoteOptions): void | Promise; +} + +export interface PluginPanelAdapter { + openPanel(pluginId: string, panelId: string): void | Promise; +} + export interface PluginHostSnapshot { extensions: InstalledExtension[]; commands: RegisteredPluginCommand[]; @@ -167,8 +182,10 @@ const toPluginNoteSummary = (note: Awaited>["memo"]): PluginNote => ({ ...toPluginNoteSummary(note), + revision: note.revision, contentMarkdown: note.contentMarkdown, contentText: note.contentText, + contentHash: note.contentHash, }); const toPluginNotebook = (notebook: Awaited>["notebooks"][number]) => ({ @@ -203,8 +220,54 @@ const toPluginResource = (resource: Awaited>["templates"][number]): PluginTemplate => ({ + id: template.id, + name: template.name, + description: template.description, + title: template.title, + contentMarkdown: template.contentMarkdown, + tags: [...template.tags], + createdAt: template.createdAt, + updatedAt: template.updatedAt, +}); + const getErrorMessage = (error: unknown) => error instanceof Error ? error.message : String(error); +const createPluginApiError = (code: PluginApiErrorCode, message: string) => Object.assign(new Error(message), { code }); + +const isUtf16Boundary = (value: string, offset: number) => { + if (offset <= 0 || offset >= value.length) return true; + const previous = value.charCodeAt(offset - 1); + const current = value.charCodeAt(offset); + return !(previous >= 0xd800 && previous <= 0xdbff && current >= 0xdc00 && current <= 0xdfff); +}; + +export const applyPluginMarkdownEdits = (contentMarkdown: string, edits: PluginMarkdownEdit[]) => { + if (!Array.isArray(edits) || edits.length === 0) { + throw createPluginApiError("INVALID_MARKDOWN_EDIT", "At least one Markdown edit is required."); + } + const ordered = edits.map((edit) => ({ ...edit })).sort((left, right) => left.from - right.from || left.to - right.to); + for (let index = 0; index < ordered.length; index += 1) { + const edit = ordered[index]; + if (!Number.isSafeInteger(edit.from) || !Number.isSafeInteger(edit.to) || typeof edit.insert !== "string") { + throw createPluginApiError("INVALID_MARKDOWN_EDIT", "Markdown edit offsets must be safe integers and insert must be a string."); + } + if (edit.from < 0 || edit.to < edit.from || edit.to > contentMarkdown.length) { + throw createPluginApiError("INVALID_MARKDOWN_EDIT", "Markdown edit range is outside the current note."); + } + if (!isUtf16Boundary(contentMarkdown, edit.from) || !isUtf16Boundary(contentMarkdown, edit.to)) { + throw createPluginApiError("INVALID_MARKDOWN_EDIT", "Markdown edit range cannot split a Unicode surrogate pair."); + } + if (index > 0 && edit.from < ordered[index - 1].to) { + throw createPluginApiError("INVALID_MARKDOWN_EDIT", "Markdown edit ranges cannot overlap."); + } + } + return ordered.reduceRight( + (markdown, edit) => `${markdown.slice(0, edit.from)}${edit.insert}${markdown.slice(edit.to)}`, + contentMarkdown, + ); +}; + const normalizeInstallSource = (value: unknown): ExtensionInstallSource => { if (!value || typeof value !== "object") return { kind: "manifest", verified: false }; const candidate = value as Partial; @@ -256,6 +319,9 @@ const EVENT_PERMISSIONS: Partial> "note.updated": "notes:read", "note.deleted": "notes:read", "tag.changed": "metadata:read", + "template.created": "templates:read", + "template.updated": "templates:read", + "template.deleted": "templates:read", }; const isAllowedNetworkHost = (hostname: string, allowedHosts: string[]) => @@ -322,6 +388,8 @@ export class EdgeEverPluginHost { private snapshot: PluginHostSnapshot = { extensions: [], commands: [], panels: [], recentActions: [], activeThemeId: null }; private recentActions: RegisteredPluginAction[]; private editorAdapter: PluginEditorAdapter | null = null; + private navigationAdapter: PluginNavigationAdapter | null = null; + private panelAdapter: PluginPanelAdapter | null = null; private themeObserver: MutationObserver | null = null; private repositoryEventDisposer: (() => void) | null = null; private started = false; @@ -351,6 +419,20 @@ export class EdgeEverPluginHost { }; } + setNavigationAdapter(adapter: PluginNavigationAdapter | null) { + this.navigationAdapter = adapter; + return () => { + if (this.navigationAdapter === adapter) this.navigationAdapter = null; + }; + } + + setPanelAdapter(adapter: PluginPanelAdapter | null) { + this.panelAdapter = adapter; + return () => { + if (this.panelAdapter === adapter) this.panelAdapter = null; + }; + } + async activateEnabled() { this.start(); for (const extension of this.extensions) { @@ -636,6 +718,9 @@ export class EdgeEverPluginHost { ...(event.name ? { name: event.name } : {}), ...(event.deleted ? { deleted: true } : {}), }); + if (event.type === "template.created") return this.emit(event.type, { template: toPluginTemplate(event.template) }); + if (event.type === "template.updated") return this.emit(event.type, { template: toPluginTemplate(event.template) }); + if (event.type === "template.deleted") return this.emit(event.type, { templateId: event.templateId }); this.emit("workspace.synced", { bootstrapped: event.bootstrapped, changed: event.changed }); }; @@ -740,12 +825,64 @@ export class EdgeEverPluginHost { .filter((note) => !input.tags?.length || input.tags.every((tag) => note.tags.includes(tag))) .map(toPluginNoteSummary); const offset = Math.max(input.offset ?? 0, 0); - return { notes, totalCount: result.totalCount, nextOffset: result.nextCursor ? offset + notes.length : null }; + return { notes, totalCount: result.totalCount, nextOffset: result.nextCursor ? offset + result.memos.length : null }; + }, + queryContent: async (input = {}) => { + assertPermission(manifest, "notes:read"); + const result = await this.repository.listMemos({ + notebookId: input.notebookId, + q: input.text, + sort: input.sort, + limit: Math.min(Math.max(input.limit ?? 50, 1), 200), + offset: Math.max(input.offset ?? 0, 0), + }); + const summaries = result.memos.filter( + (note) => !input.tags?.length || input.tags.every((tag) => note.tags.includes(tag)), + ); + const notes = await Promise.all(summaries.map(async (note) => + toPluginNote((await this.repository.getMemo(note.id)).memo))); + const offset = Math.max(input.offset ?? 0, 0); + return { notes, totalCount: result.totalCount, nextOffset: result.nextCursor ? offset + result.memos.length : null }; }, get: async (noteId) => { assertPermission(manifest, "notes:read"); return toPluginNote((await this.repository.getMemo(noteId)).memo); }, + editMarkdown: async (noteId, input) => { + assertPermission(manifest, "notes:read"); + assertPermission(manifest, "notes:write"); + if ( + !input + || !Number.isSafeInteger(input.expectedRevision) + || input.expectedRevision < 0 + || typeof input.expectedContentHash !== "string" + || !input.expectedContentHash.trim() + ) { + throw createPluginApiError("INVALID_MARKDOWN_EDIT", "A valid expected revision and content hash are required."); + } + const current = (await this.repository.getMemo(noteId)).memo; + if (current.revision !== input.expectedRevision || current.contentHash !== input.expectedContentHash) { + throw createPluginApiError("NOTE_CONFLICT", "The note changed after the plugin read it. Reload the note before editing."); + } + const activeDocument = this.editorAdapter?.getDocument(); + if (activeDocument?.noteId === noteId && activeDocument.hasUnsavedChanges) { + throw createPluginApiError("NOTE_CONFLICT", "The note has unsaved editor changes. Save them before editing from a plugin."); + } + const contentMarkdown = applyPluginMarkdownEdits(current.contentMarkdown, input.edits); + if (contentMarkdown === current.contentMarkdown) return toPluginNote(current); + const updated = (await this.repository.updateMemo(current, { + expectedRevision: input.expectedRevision, + expectedContentHash: input.expectedContentHash, + editSessionId: `plugin:${manifest.id}`, + title: current.title ?? "", + contentJson: markdownToDoc(contentMarkdown), + contentMarkdown, + tags: current.tags, + })).memo; + const note = toPluginNote(updated); + await this.onWorkspaceChanged?.(); + return note; + }, create: async (input) => { assertPermission(manifest, "notes:write"); const note = toPluginNote((await this.repository.createMemo(input)).memo); @@ -852,6 +989,45 @@ export class EdgeEverPluginHost { return updated; }, }, + templates: { + list: async () => { + assertPermission(manifest, "templates:read"); + return (await this.repository.listTemplates()).templates.map(toPluginTemplate); + }, + create: async (input) => { + assertPermission(manifest, "templates:write"); + if (input.noteId) assertPermission(manifest, "notes:read"); + const template = toPluginTemplate((await this.repository.createTemplate({ + name: input.name, + description: input.description, + memoId: input.noteId, + title: input.title, + contentMarkdown: input.contentMarkdown, + tags: input.tags, + })).template); + await this.onWorkspaceChanged?.(); + return template; + }, + update: async (templateId, input) => { + assertPermission(manifest, "templates:read"); + assertPermission(manifest, "templates:write"); + const template = toPluginTemplate((await this.repository.updateTemplate(templateId, input)).template); + await this.onWorkspaceChanged?.(); + return template; + }, + delete: async (templateId) => { + assertPermission(manifest, "templates:write"); + await this.repository.deleteTemplate(templateId); + await this.onWorkspaceChanged?.(); + }, + use: async (templateId, notebookId) => { + assertPermission(manifest, "templates:read"); + assertPermission(manifest, "notes:write"); + const note = toPluginNote((await this.repository.useTemplate(templateId, notebookId)).memo); + await this.onWorkspaceChanged?.(); + return note; + }, + }, commands: { register: (command) => { assertPermission(manifest, "ui:commands"); @@ -914,6 +1090,20 @@ export class EdgeEverPluginHost { assertPermission(manifest, "editor:read"); return this.editorAdapter?.getSelection() ?? null; }, + getDocument: async () => { + assertPermission(manifest, "editor:read"); + return this.editorAdapter?.getDocument() ?? null; + }, + editMarkdown: async (edits) => { + assertPermission(manifest, "editor:read"); + assertPermission(manifest, "editor:write"); + if (!this.editorAdapter) throw new Error("No note editor is currently active."); + const current = this.editorAdapter.getDocument(); + if (!current) throw new Error("No note editor is currently active."); + const contentMarkdown = applyPluginMarkdownEdits(current.contentMarkdown, edits); + if (contentMarkdown !== current.contentMarkdown) this.editorAdapter.replaceDocument(contentMarkdown); + return { ...current, contentMarkdown, hasUnsavedChanges: contentMarkdown !== current.contentMarkdown || current.hasUnsavedChanges }; + }, replaceSelection: async (contentMarkdown) => { assertPermission(manifest, "editor:write"); if (!this.editorAdapter) throw new Error("No note editor is currently active."); @@ -968,6 +1158,16 @@ export class EdgeEverPluginHost { assertPermission(manifest, "ui:notices"); this.onNotice?.(message); }, + openNote: async (noteId, options) => { + assertPermission(manifest, "ui:navigation"); + if (!this.navigationAdapter) throw new Error("Note navigation is unavailable in this host."); + if (options?.search !== undefined && (typeof options.search !== "string" || !options.search.trim() || options.search.length > 500)) { + throw new Error("Note navigation search must contain between 1 and 500 characters."); + } + const note = (await this.repository.getMemo(noteId)).memo; + if (note.isDeleted) throw new Error("Deleted notes cannot be opened from a plugin."); + await this.navigationAdapter.openNote(note.id, note.notebookId, options?.search ? { search: options.search } : undefined); + }, panels: { register: (panel) => { assertPermission(manifest, "ui:panels"); @@ -984,6 +1184,12 @@ export class EdgeEverPluginHost { disposers.push(dispose); return dispose; }, + open: async (panelId) => { + assertPermission(manifest, "ui:panels"); + if (!this.panels.has(`${manifest.id}:${panelId}`)) throw new Error("Plugin panel is not registered."); + if (!this.panelAdapter) throw new Error("Plugin panel presentation is unavailable in this host."); + await this.panelAdapter.openPanel(manifest.id, panelId); + }, }, }, }; diff --git a/apps/web/src/lib/repository-events.ts b/apps/web/src/lib/repository-events.ts index 3730f4ef..686f4d52 100644 --- a/apps/web/src/lib/repository-events.ts +++ b/apps/web/src/lib/repository-events.ts @@ -1,4 +1,4 @@ -import type { MemoDetail } from "@edgeever/shared"; +import type { MemoDetail, MemoTemplate } from "@edgeever/shared"; import type { EdgeEverRepository } from "@/lib/repository"; export type RepositoryMutationEvent = @@ -6,6 +6,9 @@ export type RepositoryMutationEvent = | { type: "note.updated"; note: MemoDetail } | { type: "note.deleted"; noteId: string } | { type: "tag.changed"; previousName?: string; name?: string; deleted?: boolean } + | { type: "template.created"; template: MemoTemplate } + | { type: "template.updated"; template: MemoTemplate } + | { type: "template.deleted"; templateId: string } | { type: "workspace.synced"; bootstrapped: boolean; changed: number }; type RepositoryMutationListener = (event: RepositoryMutationEvent) => void; @@ -58,6 +61,21 @@ export const withRepositoryMutationEvents = (repository: EdgeEverRepository, sco notifyRepositoryMutation(scope, { type: "note.created", note: result.memo }); return result; }, + async createTemplate(input) { + const result = await repository.createTemplate(input); + notifyRepositoryMutation(scope, { type: "template.created", template: result.template }); + return result; + }, + async updateTemplate(templateId, input) { + const result = await repository.updateTemplate(templateId, input); + notifyRepositoryMutation(scope, { type: "template.updated", template: result.template }); + return result; + }, + async deleteTemplate(templateId) { + const result = await repository.deleteTemplate(templateId); + notifyRepositoryMutation(scope, { type: "template.deleted", templateId }); + return result; + }, async mergeMemos(input) { const result = await repository.mergeMemos(input); notifyRepositoryMutation(scope, { type: "note.created", note: result.memo }); diff --git a/docs/plugin-development.md b/docs/plugin-development.md index e810ad1f..5e1c7830 100644 --- a/docs/plugin-development.md +++ b/docs/plugin-development.md @@ -93,6 +93,8 @@ Supported permissions: - `notes:read` - `notes:write` - `notes:delete` +- `templates:read` +- `templates:write` - `metadata:read` - `metadata:write` - `resources:read` @@ -103,6 +105,7 @@ Supported permissions: - `editor:read` - `editor:write` - `ui:commands` +- `ui:navigation` - `ui:notices` - `ui:panels` @@ -163,7 +166,9 @@ The package build contains only `dist/index.js`, `dist/index.d.ts`, its README, ```ts context.notes.query({ text, notebookId, tags, sort, limit, offset }); +context.notes.queryContent({ text, notebookId, tags, sort, limit, offset }); context.notes.get(noteId); +context.notes.editMarkdown(noteId, { expectedRevision, expectedContentHash, edits }); context.notes.create({ notebookId, title, contentMarkdown, tags }); context.notes.update(noteId, { title, contentMarkdown, tags }); context.notes.delete(noteId, { permanent: false }); @@ -181,8 +186,25 @@ context.tags.rename("old", "new"); context.tags.delete("unused"); ``` +`notes.query()` returns lightweight summaries. Use `notes.queryContent()` when a plugin must scan Markdown across many notes, such as a Tasks index, Calendar, Kanban board, or Linter. Both APIs accept at most 200 notes per page; follow `nextOffset` until it is `null`. Prefer the summary query whenever full content is unnecessary. + All writes go through EdgeEver's shared repository/business layer, including offline queueing and desktop adapters. Plugins do not access a storage implementation directly. `notes.update()` requires both `notes:write` and `notes:read` because the update flow reads the current revision and returns the complete updated note. This prevents write access from becoming an indirect note-content read capability. +`notes.editMarkdown()` requires the same permissions and performs optimistic concurrency checks with the `revision` and `contentHash` returned by `notes.get()`. It is intended for plugins such as task togglers, linters, and index maintainers that change only specific Markdown ranges: + +```ts +const note = await context.notes.get(noteId); +await context.notes.editMarkdown(noteId, { + expectedRevision: note.revision, + expectedContentHash: note.contentHash, + edits: [ + { from: 2, to: 3, insert: "x" }, + { from: note.contentMarkdown.length, to: note.contentMarkdown.length, insert: "\nAppended text" } + ] +}); +``` + +Edit ranges use JavaScript UTF-16 string offsets and half-open ranges `[from, to)`. Ranges in one call must not overlap, exceed the note, or split a Unicode surrogate pair. The host rejects writes with an error carrying `code: "NOTE_CONFLICT"` when the note baseline changed or the active editor has unsaved changes; plugins should reload and ask the user to retry. Invalid ranges use `code: "INVALID_MARKDOWN_EDIT"`. The SDK exports `PluginApiError` and `PluginApiErrorCode` for TypeScript error narrowing. Notebook and tag reads require `metadata:read`; notebook and tag changes require `metadata:write`. Attachments use their own permissions and still flow through the same Web/Desktop repository adapters: @@ -194,9 +216,26 @@ context.resources.rename(resourceId, filename); context.resources.delete(resourceId); ``` -Subscribing to `note.*` events requires `notes:read`, and subscribing to `tag.changed` requires `metadata:read`. The sync-queue status event carries no note or metadata content and requires no additional read permission. +Subscribing to `note.*` events requires `notes:read`, subscribing to `tag.changed` requires `metadata:read`, and subscribing to `template.*` requires `templates:read`. The sync-queue status event carries no note or metadata content and requires no additional read permission. -Successful note and tag changes made through EdgeEver's normal repository layer—including user actions and plugin actions—feed the same plugin event stream. `workspace.synced` reports completed repository sync passes. Failed mutations do not emit success events. +Successful note, tag, and template changes made through EdgeEver's normal repository layer—including user actions and plugin actions—feed the same plugin event stream. `workspace.synced` reports completed repository sync passes. Failed mutations do not emit success events. + +## Templates API + +Templates are shared workspace data rather than plugin-local settings. Reading them requires `templates:read`; creating and deleting require `templates:write`; updating requires both. Applying a template also requires `notes:write` because it creates a note: + +```ts +const template = await context.templates.create({ + name: "Daily stand-up", + contentMarkdown: "## Done\n\n## Next\n", + tags: ["daily"] +}); +await context.templates.update(template.id, { description: "Team check-in" }); +const note = await context.templates.use(template.id, notebookId); +context.events.on("template.updated", ({ template }) => console.log(template.name)); +``` + +`templates.create({ noteId })` can capture an existing note and additionally requires `notes:read`. Plugins can also call `templates.list()` and `templates.delete(templateId)`. ## Host-rendered settings @@ -258,9 +297,9 @@ await context.secrets.remove("api-token"); The web host namespaces secrets by workspace and plugin ID, encrypts them with AES-GCM using a device-local, non-exportable WebCrypto key, and stores ciphertext in IndexedDB. This prevents plaintext storage, but P0 plugins are trusted same-page code and the mechanism cannot defend against a malicious plugin reading live data. -## Editor selection API +## Editor API -`editor:read` reads the active editor selection. `editor:write` replaces it or inserts Markdown at the cursor: +`editor:read` reads the active editor selection or full live document. `editor:write` replaces the selection, inserts Markdown at the cursor, or applies validated UTF-16 range edits to the live document: ```ts const selection = await context.editor.getSelection(); @@ -268,9 +307,26 @@ if (selection && !selection.empty) { await context.editor.replaceSelection(selection.text.toUpperCase()); } await context.editor.insertAtCursor("**Inserted by plugin**"); + +const document = await context.editor.getDocument(); +if (document) { + await context.editor.editMarkdown([ + { from: 0, to: 0, insert: "\n" } + ]); +} ``` -Reading returns `null` when no editable note is open; writes throw an error. Plugin edits use normal editor transactions and the autosave flow. +Reading returns `null` when no editable note is open; writes throw an error. `editor.editMarkdown()` uses the same range validation as `notes.editMarkdown()`, but operates on the current in-memory document so it can safely include unsaved user changes. Plugin edits use normal editor transactions and the autosave flow. + +## Note navigation + +With `ui:navigation` declared, a plugin can open an existing note from a task, calendar, index, or search panel: + +```ts +await context.ui.openNote(noteId, { search: "- [ ] Ship release" }); +``` + +The host verifies that the note exists and is not deleted, then switches to its notebook and editor. When `search` is supplied, EdgeEver opens in-note search and reveals its first exact match. Plugins do not need and cannot access private routes or React state. ## Custom panels @@ -287,8 +343,12 @@ context.ui.panels.register({ return () => heading.remove(); } }); + +await context.ui.panels.open("dashboard"); ``` +`panels.open()` can only open a panel registered by the calling plugin and is useful for commands, onboarding, and deep links into a plugin workflow. + ## Desktop plugin entry After a plugin is enabled, a unified puzzle button appears in the desktop workspace shortcuts on the left. Its menu groups commands and panels by plugin, keeps recently used actions at the top, and links directly to extension management. Individual plugins do not each consume a toolbar icon. diff --git a/docs/plugin-development.zh-CN.md b/docs/plugin-development.zh-CN.md index fb0eac2c..23dd9db5 100644 --- a/docs/plugin-development.zh-CN.md +++ b/docs/plugin-development.zh-CN.md @@ -93,6 +93,8 @@ Registry 格式: - `notes:read` - `notes:write` - `notes:delete` +- `templates:read` +- `templates:write` - `metadata:read` - `metadata:write` - `resources:read` @@ -103,6 +105,7 @@ Registry 格式: - `editor:read` - `editor:write` - `ui:commands` +- `ui:navigation` - `ui:notices` - `ui:panels` @@ -163,7 +166,9 @@ npm pack --dry-run ```ts context.notes.query({ text, notebookId, tags, sort, limit, offset }); +context.notes.queryContent({ text, notebookId, tags, sort, limit, offset }); context.notes.get(noteId); +context.notes.editMarkdown(noteId, { expectedRevision, expectedContentHash, edits }); context.notes.create({ notebookId, title, contentMarkdown, tags }); context.notes.update(noteId, { title, contentMarkdown, tags }); context.notes.delete(noteId, { permanent: false }); @@ -181,8 +186,25 @@ context.tags.rename("old", "new"); context.tags.delete("unused"); ``` +`notes.query()` 返回轻量摘要。当插件必须扫描多篇笔记的 Markdown 时,例如 Tasks 索引、日历、看板或 Linter,应使用 `notes.queryContent()`。两个接口每页最多返回 200 篇笔记;持续使用 `nextOffset` 翻页,直到它为 `null`。不需要正文时应优先使用摘要查询。 + 所有写入都经过 EdgeEver 的共享 Repository 和业务层,包括离线队列与桌面端适配器。插件不能直接访问具体存储实现。 `notes.update()` 同时需要 `notes:write` 和 `notes:read`,因为更新流程需要读取当前版本并会返回更新后的完整笔记;这可以避免写权限被间接用于读取笔记正文。 +`notes.editMarkdown()` 同样需要这两项权限,并用 `notes.get()` 返回的 `revision` 与 `contentHash` 做乐观并发检查。它适合任务勾选、Linter 和索引维护等只修改 Markdown 局部内容的插件: + +```ts +const note = await context.notes.get(noteId); +await context.notes.editMarkdown(noteId, { + expectedRevision: note.revision, + expectedContentHash: note.contentHash, + edits: [ + { from: 2, to: 3, insert: "x" }, + { from: note.contentMarkdown.length, to: note.contentMarkdown.length, insert: "\n追加内容" } + ] +}); +``` + +编辑区间使用 JavaScript UTF-16 字符串偏移和半开区间 `[from, to)`。一次调用中的区间不得重叠、越界或切开 Unicode 代理对。笔记基线已经变化或当前编辑器存在未保存内容时,宿主拒绝写入并抛出带 `code: "NOTE_CONFLICT"` 的错误;插件应重新读取并要求用户重试。无效区间使用 `code: "INVALID_MARKDOWN_EDIT"`。SDK 导出 `PluginApiError` 和 `PluginApiErrorCode` 供 TypeScript 插件缩小错误类型。 读取笔记本和标签需要 `metadata:read`,修改笔记本和标签需要 `metadata:write`。 附件使用独立权限,并继续通过 Web/桌面端共用的 Repository 适配器执行: @@ -194,9 +216,26 @@ context.resources.rename(resourceId, filename); context.resources.delete(resourceId); ``` -订阅 `note.*` 事件需要 `notes:read`,订阅 `tag.changed` 需要 `metadata:read`。同步队列状态事件不包含笔记或元数据,因此无需额外读取权限。 +订阅 `note.*` 事件需要 `notes:read`,订阅 `tag.changed` 需要 `metadata:read`,订阅 `template.*` 需要 `templates:read`。同步队列状态事件不包含笔记或元数据,因此无需额外读取权限。 -通过 EdgeEver 正常 Repository 层成功完成的笔记和标签变更——无论来自用户操作还是插件操作——都会进入同一条插件事件流。`workspace.synced` 用于报告已完成的 Repository 同步;失败的变更不会发送成功事件。 +通过 EdgeEver 正常 Repository 层成功完成的笔记、标签和模板变更——无论来自用户操作还是插件操作——都会进入同一条插件事件流。`workspace.synced` 用于报告已完成的 Repository 同步;失败的变更不会发送成功事件。 + +## 模板 API + +模板是工作区共享数据,而不是插件私有设置。读取模板需要 `templates:read`;创建和删除需要 `templates:write`;更新需要两者。套用模板会创建笔记,因此还需要 `notes:write`: + +```ts +const template = await context.templates.create({ + name: "每日站会", + contentMarkdown: "## 已完成\n\n## 下一步\n", + tags: ["daily"] +}); +await context.templates.update(template.id, { description: "团队同步" }); +const note = await context.templates.use(template.id, notebookId); +context.events.on("template.updated", ({ template }) => console.log(template.name)); +``` + +`templates.create({ noteId })` 可以从已有笔记生成模板,此时还需要 `notes:read`。插件也可以调用 `templates.list()` 和 `templates.delete(templateId)`。 ## 宿主统一渲染的设置 @@ -258,9 +297,9 @@ await context.secrets.remove("api-token"); Web 端按照工作区和插件 ID 隔离 Secret,并使用设备本地、不可导出的 WebCrypto 密钥进行 AES-GCM 加密,密文保存在 IndexedDB。它可以避免密钥以明文形式落盘,但由于 P0 插件是同页面受信任代码,不能防御恶意插件读取运行中的数据。 -## 编辑器选区 API +## 编辑器 API -`editor:read` 可以读取当前编辑器选区,`editor:write` 可以替换选区或在光标处插入 Markdown: +`editor:read` 可以读取当前编辑器选区或完整实时文档,`editor:write` 可以替换选区、在光标处插入 Markdown,或对实时文档应用经过校验的 UTF-16 区间编辑: ```ts const selection = await context.editor.getSelection(); @@ -268,9 +307,26 @@ if (selection && !selection.empty) { await context.editor.replaceSelection(selection.text.toUpperCase()); } await context.editor.insertAtCursor("**Inserted by plugin**"); + +const document = await context.editor.getDocument(); +if (document) { + await context.editor.editMarkdown([ + { from: 0, to: 0, insert: "\n" } + ]); +} ``` -没有打开可编辑笔记时,读取返回 `null`,写入会抛出错误。插件修改会进入正常的编辑器事务和自动保存流程。 +没有打开可编辑笔记时,读取返回 `null`,写入会抛出错误。`editor.editMarkdown()` 使用与 `notes.editMarkdown()` 相同的区间校验,但操作当前内存中的文档,因此可以安全保留用户尚未保存的修改。插件修改会进入正常的编辑器事务和自动保存流程。 + +## 笔记导航 + +声明 `ui:navigation` 后,插件可以从任务、日历、索引或搜索面板打开一篇现有笔记: + +```ts +await context.ui.openNote(noteId, { search: "- [ ] 发布版本" }); +``` + +宿主会先确认笔记存在且未被删除,再切换到对应笔记本和编辑器。提供 `search` 后,EdgeEver 会打开笔记内搜索并显示第一个精确匹配。插件不需要也不能操作私有路由或 React 状态。 ## 自定义面板 @@ -287,8 +343,12 @@ context.ui.panels.register({ return () => heading.remove(); } }); + +await context.ui.panels.open("dashboard"); ``` +`panels.open()` 只能打开调用插件自己注册的面板,适合命令、首次引导和插件工作流的深度跳转。 + ## 桌面端插件入口 启用插件后,桌面端左侧工作区快捷栏会显示统一的拼图入口。菜单按插件分组展示命令和面板,并在顶部保留最近使用的操作;“管理插件与主题”会直接打开独立插件市场页面。插件不会各自在工具栏占用一个图标。 diff --git a/packages/plugin-api/README.md b/packages/plugin-api/README.md index 4e567a3d..78856fcd 100644 --- a/packages/plugin-api/README.md +++ b/packages/plugin-api/README.md @@ -17,3 +17,17 @@ export default definePlugin({ ``` The package ships ESM JavaScript and TypeScript declarations. Plugin bundles must produce a single `main.js` file without relative imports before distribution. See the EdgeEver plugin development guide for the Manifest format, permissions, settings Schema, release assets, and marketplace verification rules. + +Conflict-safe Markdown edits use the note baseline returned by `context.notes.get()`: + +```ts +const note = await context.notes.get(noteId); +await context.notes.editMarkdown(noteId, { + expectedRevision: note.revision, + expectedContentHash: note.contentHash, + edits: [{ from: 0, to: 0, insert: "- [ ] New task\n" }], +}); +await context.ui.openNote(noteId, { search: "New task" }); // requires ui:navigation +``` + +Bulk-indexing plugins can page through `context.notes.queryContent()`. The API also exposes workspace templates, full live-editor reads and range edits, template mutation events, and programmatic opening of a plugin's own registered panels. diff --git a/packages/plugin-api/dist/index.d.ts b/packages/plugin-api/dist/index.d.ts index 9278983f..a46bec48 100644 --- a/packages/plugin-api/dist/index.d.ts +++ b/packages/plugin-api/dist/index.d.ts @@ -1,6 +1,6 @@ export declare const PLUGIN_API_VERSION: "1"; export declare const THEME_API_VERSION: "1"; -export declare const PLUGIN_PERMISSIONS: readonly ["notes:read", "notes:write", "notes:delete", "metadata:read", "metadata:write", "resources:read", "resources:write", "network", "storage", "secrets", "editor:read", "editor:write", "ui:commands", "ui:notices", "ui:panels"]; +export declare const PLUGIN_PERMISSIONS: readonly ["notes:read", "notes:write", "notes:delete", "metadata:read", "metadata:write", "resources:read", "resources:write", "templates:read", "templates:write", "network", "storage", "secrets", "editor:read", "editor:write", "ui:commands", "ui:navigation", "ui:notices", "ui:panels"]; export type PluginPermission = (typeof PLUGIN_PERMISSIONS)[number]; export type ExtensionPlatform = "web" | "desktop" | "android" | "ios"; export interface PluginManifest { @@ -51,6 +51,11 @@ export interface PluginSettingsSchema { fields: PluginSettingField[]; } export type PluginSettingValue = string | number | boolean; +export declare const PLUGIN_API_ERROR_CODES: readonly ["NOTE_CONFLICT", "INVALID_MARKDOWN_EDIT"]; +export type PluginApiErrorCode = (typeof PLUGIN_API_ERROR_CODES)[number]; +export interface PluginApiError extends Error { + code: PluginApiErrorCode; +} export declare const THEME_TOKEN_NAMES: readonly ["color.background", "color.surface", "color.surfaceMuted", "color.text", "color.textMuted", "color.border", "color.accent", "color.accentForeground", "color.success", "color.warning", "color.danger", "font.body", "font.mono", "font.size", "lineHeight.body", "radius.medium", "density.scale", "editor.contentWidth"]; export type ThemeTokenName = (typeof THEME_TOKEN_NAMES)[number]; export type ThemeTokens = Partial>; @@ -107,8 +112,24 @@ export interface PluginNoteSummary { updatedAt: string; } export interface PluginNote extends PluginNoteSummary { + revision: number; contentMarkdown: string; contentText: string; + contentHash: string; +} +/** + * A replacement range in a note's Markdown source. Offsets use JavaScript + * UTF-16 string indices and ranges are half-open: [from, to). + */ +export interface PluginMarkdownEdit { + from: number; + to: number; + insert: string; +} +export interface PluginMarkdownEditInput { + expectedRevision: number; + expectedContentHash: string; + edits: PluginMarkdownEdit[]; } export interface PluginNoteQuery { notebookId?: string; @@ -134,6 +155,11 @@ export interface PluginNoteQueryResult { totalCount: number; nextOffset: number | null; } +export interface PluginNoteContentQueryResult { + notes: PluginNote[]; + totalCount: number; + nextOffset: number | null; +} export interface PluginNotebook { id: string; parentId: string | null; @@ -167,6 +193,16 @@ export interface PluginTag { name: string; noteCount: number; } +export interface PluginTemplate { + id: string; + name: string; + description: string | null; + title: string | null; + contentMarkdown: string; + tags: string[]; + createdAt: string; + updatedAt: string; +} export type PluginEventMap = { "note.created": { note: PluginNote; @@ -182,6 +218,15 @@ export type PluginEventMap = { name?: string; deleted?: boolean; }; + "template.created": { + template: PluginTemplate; + }; + "template.updated": { + template: PluginTemplate; + }; + "template.deleted": { + templateId: string; + }; "workspace.sync-queue-changed": Record; "workspace.synced": { bootstrapped: boolean; @@ -201,6 +246,15 @@ export interface PluginEditorSelection { text: string; contentMarkdown: string; } +export interface PluginEditorDocument { + noteId: string; + contentMarkdown: string; + hasUnsavedChanges: boolean; +} +export interface PluginOpenNoteOptions { + /** Opens in-note search and reveals the first exact text match. */ + search?: string; +} export interface PluginPanel { id: string; title: string; @@ -210,7 +264,9 @@ export interface PluginContext { pluginId: string; notes: { query(input?: PluginNoteQuery): Promise; + queryContent(input?: PluginNoteQuery): Promise; get(noteId: string): Promise; + editMarkdown(noteId: string, input: PluginMarkdownEditInput): Promise; create(input: PluginNoteCreateInput): Promise; update(noteId: string, input: PluginNoteUpdateInput): Promise; delete(noteId: string, options?: { @@ -242,6 +298,26 @@ export interface PluginContext { rename(name: string, nextName: string): Promise; delete(name: string): Promise; }; + templates: { + list(): Promise; + create(input: { + name: string; + description?: string | null; + noteId?: string; + title?: string | null; + contentMarkdown?: string; + tags?: string[]; + }): Promise; + update(templateId: string, input: { + name?: string; + description?: string | null; + title?: string | null; + contentMarkdown?: string; + tags?: string[]; + }): Promise; + delete(templateId: string): Promise; + use(templateId: string, notebookId: string): Promise; + }; commands: { register(command: PluginCommand): () => void; }; @@ -260,6 +336,8 @@ export interface PluginContext { }; editor: { getSelection(): Promise; + getDocument(): Promise; + editMarkdown(edits: PluginMarkdownEdit[]): Promise; replaceSelection(contentMarkdown: string): Promise; insertAtCursor(contentMarkdown: string): Promise; }; @@ -279,8 +357,10 @@ export interface PluginContext { }; ui: { showNotice(message: string): void; + openNote(noteId: string, options?: PluginOpenNoteOptions): Promise; panels: { register(panel: PluginPanel): () => void; + open(panelId: string): Promise; }; }; } diff --git a/packages/plugin-api/dist/index.js b/packages/plugin-api/dist/index.js index 593de242..8ae71e13 100644 --- a/packages/plugin-api/dist/index.js +++ b/packages/plugin-api/dist/index.js @@ -9,15 +9,19 @@ var PLUGIN_PERMISSIONS = [ "metadata:write", "resources:read", "resources:write", + "templates:read", + "templates:write", "network", "storage", "secrets", "editor:read", "editor:write", "ui:commands", + "ui:navigation", "ui:notices", "ui:panels" ]; +var PLUGIN_API_ERROR_CODES = ["NOTE_CONFLICT", "INVALID_MARKDOWN_EDIT"]; var THEME_TOKEN_NAMES = [ "color.background", "color.surface", @@ -302,5 +306,6 @@ export { THEME_API_VERSION, PLUGIN_PERMISSIONS, PLUGIN_API_VERSION, + PLUGIN_API_ERROR_CODES, MARKETPLACE_REGISTRY_VERSION }; diff --git a/packages/plugin-api/src/index.test.mjs b/packages/plugin-api/src/index.test.mjs index bc9d3030..ca3e7b90 100644 --- a/packages/plugin-api/src/index.test.mjs +++ b/packages/plugin-api/src/index.test.mjs @@ -10,8 +10,8 @@ describe("extension manifests", () => { version: "1.0.0", apiVersion: "1", entry: "./main.js", - permissions: ["notes:read", "notes:read", "ui:commands"], - })).toMatchObject({ permissions: ["notes:read", "ui:commands"] }); + permissions: ["notes:read", "notes:read", "templates:read", "templates:write", "ui:commands", "ui:navigation"], + })).toMatchObject({ permissions: ["notes:read", "templates:read", "templates:write", "ui:commands", "ui:navigation"] }); }); test("rejects undeclared permissions", () => { diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts index 9bfb78ca..0ab73e5d 100644 --- a/packages/plugin-api/src/index.ts +++ b/packages/plugin-api/src/index.ts @@ -9,12 +9,15 @@ export const PLUGIN_PERMISSIONS = [ "metadata:write", "resources:read", "resources:write", + "templates:read", + "templates:write", "network", "storage", "secrets", "editor:read", "editor:write", "ui:commands", + "ui:navigation", "ui:notices", "ui:panels", ] as const; @@ -57,6 +60,12 @@ export interface PluginSettingsSchema { export type PluginSettingValue = string | number | boolean; +export const PLUGIN_API_ERROR_CODES = ["NOTE_CONFLICT", "INVALID_MARKDOWN_EDIT"] as const; +export type PluginApiErrorCode = (typeof PLUGIN_API_ERROR_CODES)[number]; +export interface PluginApiError extends Error { + code: PluginApiErrorCode; +} + export const THEME_TOKEN_NAMES = [ "color.background", "color.surface", @@ -136,8 +145,26 @@ export interface PluginNoteSummary { } export interface PluginNote extends PluginNoteSummary { + revision: number; contentMarkdown: string; contentText: string; + contentHash: string; +} + +/** + * A replacement range in a note's Markdown source. Offsets use JavaScript + * UTF-16 string indices and ranges are half-open: [from, to). + */ +export interface PluginMarkdownEdit { + from: number; + to: number; + insert: string; +} + +export interface PluginMarkdownEditInput { + expectedRevision: number; + expectedContentHash: string; + edits: PluginMarkdownEdit[]; } export interface PluginNoteQuery { @@ -168,6 +195,12 @@ export interface PluginNoteQueryResult { nextOffset: number | null; } +export interface PluginNoteContentQueryResult { + notes: PluginNote[]; + totalCount: number; + nextOffset: number | null; +} + export interface PluginNotebook { id: string; parentId: string | null; @@ -205,11 +238,25 @@ export interface PluginTag { noteCount: number; } +export interface PluginTemplate { + id: string; + name: string; + description: string | null; + title: string | null; + contentMarkdown: string; + tags: string[]; + createdAt: string; + updatedAt: string; +} + export type PluginEventMap = { "note.created": { note: PluginNote }; "note.updated": { note: PluginNote }; "note.deleted": { noteId: string }; "tag.changed": { previousName?: string; name?: string; deleted?: boolean }; + "template.created": { template: PluginTemplate }; + "template.updated": { template: PluginTemplate }; + "template.deleted": { templateId: string }; "workspace.sync-queue-changed": Record; "workspace.synced": { bootstrapped: boolean; changed: number }; }; @@ -229,6 +276,17 @@ export interface PluginEditorSelection { contentMarkdown: string; } +export interface PluginEditorDocument { + noteId: string; + contentMarkdown: string; + hasUnsavedChanges: boolean; +} + +export interface PluginOpenNoteOptions { + /** Opens in-note search and reveals the first exact text match. */ + search?: string; +} + export interface PluginPanel { id: string; title: string; @@ -239,7 +297,9 @@ export interface PluginContext { pluginId: string; notes: { query(input?: PluginNoteQuery): Promise; + queryContent(input?: PluginNoteQuery): Promise; get(noteId: string): Promise; + editMarkdown(noteId: string, input: PluginMarkdownEditInput): Promise; create(input: PluginNoteCreateInput): Promise; update(noteId: string, input: PluginNoteUpdateInput): Promise; delete(noteId: string, options?: { permanent?: boolean }): Promise; @@ -262,6 +322,13 @@ export interface PluginContext { rename(name: string, nextName: string): Promise; delete(name: string): Promise; }; + templates: { + list(): Promise; + create(input: { name: string; description?: string | null; noteId?: string; title?: string | null; contentMarkdown?: string; tags?: string[] }): Promise; + update(templateId: string, input: { name?: string; description?: string | null; title?: string | null; contentMarkdown?: string; tags?: string[] }): Promise; + delete(templateId: string): Promise; + use(templateId: string, notebookId: string): Promise; + }; commands: { register(command: PluginCommand): () => void; }; @@ -280,6 +347,8 @@ export interface PluginContext { }; editor: { getSelection(): Promise; + getDocument(): Promise; + editMarkdown(edits: PluginMarkdownEdit[]): Promise; replaceSelection(contentMarkdown: string): Promise; insertAtCursor(contentMarkdown: string): Promise; }; @@ -299,8 +368,10 @@ export interface PluginContext { }; ui: { showNotice(message: string): void; + openNote(noteId: string, options?: PluginOpenNoteOptions): Promise; panels: { register(panel: PluginPanel): () => void; + open(panelId: string): Promise; }; }; }