Files
edgeever/docs/plugin-development.zh-CN.md
tianma-if 9f0d18ca54 feat(memos): sort note lists from Z to A
Add a title Z-A option beside title A-Z on web, Android, iOS, the API, and the desktop sidecar. Pinned notes stay first, and same-title tie breaks stay as they were.
2026-10-21 23:09:57 +08:00

719 lines
29 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# EdgeEver 插件开发指南
[English](plugin-development.md)
EdgeEver 提供客户端插件与无代码主题扩展能力。用户可从官方市场、公开 GitHub 仓库或 Manifest 地址安装扩展。已安装扩展随当前工作区在 Web 与桌面端之间同步,在应用打开期间运行;Android 与 iOS 原生端暂不运行插件。
> [!NOTE]
> **安全模型**:客户端插件采用受信任代码模型(类似 Obsidian)。启用插件即代表信任其在客户端 JavaScript 环境中运行并调用插件 API。清单中的能力声明(Permissions)用于向用户披露插件涉及的功能范围,不构成强制安全沙箱。主题包为纯声明式 JSON,不执行任何 JavaScript。 `networkHosts` 同样只是用途声明,不限制插件代码的网络访问。桌面插件还可以通过客户端桥访问登录凭据、本地数据及 Agent 能力。
---
## 目录
- [插件清单(Manifest)](#插件清单manifest)
- [字段说明](#字段说明)
- [可用权限标识](#可用权限标识)
- [插件入口与生命周期](#插件入口与生命周期)
- [API 参考](#api-参考)
- [API 命名空间速查](#api-命名空间速查)
- [1. 笔记与笔记本(Notes & Notebooks)](#1-笔记与笔记本notes--notebooks)
- [2. 模板管理(Templates)](#2-模板管理templates)
- [3. 资源附件(Resources)](#3-资源附件resources)
- [4. 编辑器交互(Editor & Embeds)](#4-编辑器交互editor--embeds)
- [5. 全局命令(Commands)](#5-全局命令commands)
- [6. 宿主界面与通知(UI & Notices)](#6-宿主界面与通知ui--notices)
- [7. 自定义面板(Panels)](#7-自定义面板panels)
- [8. 统一设置(Settings)](#8-统一设置settings)
- [9. 数据存储与安全凭据(Storage & Secrets)](#9-数据存储与安全凭据storage--secrets)
- [10. 网络请求(Network)](#10-网络请求network)
- [11. 定时任务(Schedules,仅桌面端)](#11-定时任务schedules仅桌面端)
- [12. 事件监听(Events)](#12-事件监听events)
- [13. 人工智能(AI)](#13-人工智能ai)
- [主题扩展(Theme Manifest)](#主题扩展theme-manifest)
- [打包、分发与本地开发](#打包分发与本地开发)
- [打包规范](#打包规范)
- [GitHub 分发](#github-分发)
- [本地测试与调试](#本地测试与调试)
---
## 插件清单(Manifest)
每个插件必须在根目录提供 `manifest.json`:
```json
{
"type": "plugin",
"id": "com.example.recent-notes",
"name": "Recent Notes",
"version": "1.0.0",
"apiVersion": "2",
"settingsUi": "host",
"description": "查看最近更新的笔记。",
"locales": {
"zh-CN": {
"name": "最近笔记",
"description": "查看最近更新的笔记。"
}
},
"entry": "./main.js",
"platforms": ["web", "desktop"],
"permissions": ["notes:read", "editor:read", "ui:commands", "ui:notices", "ui:panels"]
}
```
### 字段说明
| 字段 | 类型 | 必填 | 说明 |
| :--- | :--- | :---: | :--- |
| `type` | `"plugin"` | 是 | 固定为 `"plugin"`。 |
| `id` | `string` | 是 | 插件全局唯一标识,推荐反向域名格式(如 `com.example.plugin`)。 |
| `name` | `string` | 是 | 插件默认名称。 |
| `version` | `string` | 是 | 符合 SemVer 的版本号(如 `1.0.0`)。 |
| `apiVersion` | `"2"` | 是 | 插件 API 版本,当前固定为 `"2"`。 |
| `settingsUi` | `"host"` | 是 | 设置界面渲染方式,当前固定为 `"host"`(由宿主统一渲染)。 |
| `entry` | `string` | 是 | 入口脚本路径,GitHub 分发时固定为 `./main.js`。 |
| `description` | `string` | 否 | 插件默认描述。 |
| `locales` | `object` | 否 | 多语言本地化元数据,以 BCP 47 语言标签为键(如 `zh-CN`, `en-US`),覆盖 `name` 与 `description`。 |
| `platforms` | `string[]` | 否 | 支持的平台,可选 `"web"`、`"desktop"`。 |
| `permissions` | `string[]` | 否 | 能力声明列表,用于安装与更新时向用户披露。 |
| `settings` | `object` | 否 | 宿主渲染的配置项 Schema(详见下文)。 |
### 可用权限标识
声明权限用于在安装和更新界面向用户透明披露功能:
- **笔记与内容**:`notes:read`, `notes:write`, `notes:delete`, `templates:read`, `templates:write`, `metadata:read`, `metadata:write`, `resources:read`, `resources:write`
- **编辑器**:`editor:read`, `editor:write`
- **界面交互**:`ui:commands`, `ui:navigation`, `ui:notices`, `ui:panels`, `ui:embeds`
- **存储与系统**:`storage`, `secrets`, `network`, `network:public`, `schedules`
- **AI 能力**:`ai:generate`
---
## 插件入口与生命周期
插件入口文件(`main.js`)需导出一个包含 `activate` 生命周期的默认对象:
```js
export default {
activate(context) {
// 注册命令
const disposeCommand = context.commands.register({
id: "hello-world",
title: "Say Hello",
run() {
context.ui.showNotice("Hello from EdgeEver Plugin!");
}
});
// 返回清理函数(插件停用时执行)
return () => {
disposeCommand();
};
}
};
```
TypeScript 项目可使用 `@edgeever/plugin-api` 提供的辅助工具:
```ts
import { definePlugin } from "@edgeever/plugin-api";
export default definePlugin({
activate(context) {
return context.commands.register({
id: "hello-world",
title: "Say Hello",
run: () => context.ui.showNotice("Hello!")
});
}
});
```
> [!TIP]
> 注册命令、面板、事件等接口均会返回清理函数;插件停用时,宿主也会自动回收其注册的命令与监听器。
---
## API 参考
### API 命名空间速查
插件上下文对象(`context`)提供以下能力接口:
| 命名空间 | 对应权限(披露用) | 核心方法 | 职责说明 |
| :--- | :--- | :--- | :--- |
| **`context.notes`** | `notes:read`<br>`notes:write`<br>`notes:delete` | `query()`, `queryContent()`, `get()`, `create()`, `update()`, `editMarkdown()`, `delete()`, `move()`, `pin()`, `restore()`, `revisions` | 笔记查询、正文读取、乐观并发编辑与版本管理 |
| **`context.diagrams`** | `notes:read`<br>`notes:write` | `get()`, `edit()` | 读取图表语义结构与布局,批量编辑节点和连线 |
| **`context.tables`** | `notes:read`<br>`notes:write` | `get()`, `edit()` | 读取完整表格,批量编辑字段、记录和单元格 |
| **`context.posters`** | `notes:read`<br>`notes:write` | `capabilities()`, `get()`, `edit()` | 海报元素、图层、分组、样式与画布编辑 |
| **`context.infographics`** | `notes:read`<br>`notes:write` | `capabilities()`, `get()`, `edit()` | 信息图嵌套数据项、模板与主题编辑 |
| **`context.notebooks`** | `metadata:read`<br>`metadata:write` | `list()`, `create()`, `update()`, `delete()` | 笔记本树状层级维护与增删改查 |
| **`context.tags`** | `metadata:read`<br>`metadata:write` | `list()`, `rename()`, `delete()` | 标签全局查询、重命名与删除 |
| **`context.templates`** | `templates:read`<br>`templates:write` | `list()`, `create()`, `update()`, `delete()`, `use()` | 模板增删改查及一键套用创建笔记 |
| **`context.resources`** | `resources:read`<br>`resources:write` | `list()`, `read()`, `upload()`, `update()`, `rename()`, `delete()` | 笔记图片及文件附件上传、下载、修改与管理 |
| **`context.editor`** | `editor:read`<br>`editor:write`<br>`ui:embeds` | `getSelection()`, `replaceSelection()`, `insertAtCursor()`, `getDocument()`, `editMarkdown()`, `insertEmbed()`, `embeds.register()` | 活动编辑器交互、实时文档修改与自定义块级 Embed |
| **`context.ui`** | `ui:navigation`<br>`ui:notices`<br>`ui:panels` | `showNotice()`, `openNote()`, `panels.register()`, `panels.open()` | 宿主全局提示、笔记精准跳转与自定义 DOM 面板 |
| **`context.commands`** | `ui:commands` | `register()` | 注册全局或编辑器命令(命令面板、工具栏快捷菜单) |
| **`context.settings`** | *(无)* | `get()`, `set()`, `remove()` | 读取与修改插件自身的宿主统一配置项 |
| **`context.storage`** | `storage` | `get()`, `set()`, `remove()` | 本地轻量键值存储(游标、偏好缓存,按插件与工作区隔离) |
| **`context.secrets`** | `secrets` | `get()`, `set()`, `remove()` | 本地加密的安全凭据存储(API Key、Token 等敏感数据) |
| **`context.network`** | `network`<br>`network:public` | `fetch(url, options)` | 网络请求(标准请求或跨域免凭据公开只读) |
| **`context.schedules`** | `schedules` | `upsert()`, `list()`, `remove()` | 桌面端持久化定时任务调度(应用开启期间运行) |
| **`context.events`** | *(无)* | `on(event, handler)` | 订阅工作区笔记、标签、模板及同步完成事件 |
| **`context.ai`** | `ai:generate` | `status()`, `generate()`, `transcribeResource()`, `transcribeMedia()` | 调用工作区配置的 AI 模型生成文本或转录音视频媒体 |
---
### 1. 笔记与笔记本(Notes & Notebooks)
#### 查询笔记
```ts
// 1. 查询轻量摘要列表(用于列表、侧边栏等,每页最多 200 条)
const result = await context.notes.query({
notebookId: "optional-notebook-id",
text: "关键词",
tags: ["todo"],
sort: "updated-desc", // "updated-desc" | "created-desc" | "title-asc" | "title-desc"
limit: 50,
offset: 0
});
// 返回 { notes: PluginNoteSummary[], totalCount: number, nextOffset: number | null }
// 2. 查询包含完整 Markdown 内容的笔记(用于 Linter、索引扫描等)
const fullNotes = await context.notes.queryContent({
tags: ["task"],
limit: 20
});
```
#### 增删改查
```ts
// 获取单篇笔记详情
const note = await context.notes.get(noteId);
// 创建笔记
const newNote = await context.notes.create({
notebookId: "target-notebook-id",
title: "新笔记",
contentMarkdown: "# 标题\n正文内容",
tags: ["tag1"]
});
// 更新整篇笔记
await context.notes.update(noteId, {
title: "更新后的标题",
contentMarkdown: "新的正文",
tags: ["updated"]
});
// 局部并发安全编辑(基于 revision 与 contentHash 进行乐观锁校验)
await context.notes.editMarkdown(noteId, {
expectedRevision: note.revision,
expectedContentHash: note.contentHash,
edits: [
{ from: 0, to: 0, insert: "> 插入顶部引用\n\n" } // UTF-16 偏移量,半开区间 [from, to)
]
});
// 移动、置顶与删除
await context.notes.move([noteId], targetNotebookId);
await context.notes.pin([noteId], true);
await context.notes.delete(noteId, { permanent: false }); // 移入回收站
await context.notes.restore(noteId); // 从回收站恢复
// 历史版本
const revisions = await context.notes.revisions.list(noteId);
await context.notes.revisions.restore(noteId, revisionId);
```
#### 笔记本与标签
```ts
// 笔记本
const notebooks = await context.notebooks.list();
const nb = await context.notebooks.create({ name: "新建笔记本", parentId: null });
await context.notebooks.update(nb.id, { name: "新名称" });
await context.notebooks.delete(nb.id);
// 标签
const tags = await context.tags.list();
await context.tags.rename("old-tag", "new-tag");
await context.tags.delete("unused-tag");
```
---
#### 特殊笔记的读取与细项编辑
`notes.query()` / `queryContent()` / `get()` 返回 `kind`(`text`、`mind-map`、`flowchart`、`architecture`、`table`、`poster`、`infographic` 或 `video`);完整笔记还包含独立副本 `contentJson`。特殊笔记的完整持久化载荷也保留在 `contentMarkdown` 中,读取这些字段不会修改笔记。
图表、表格、海报和信息图必须先调用专用 `get()`,再以返回的 `note.revision`、`note.contentHash` 和 `editToken` 调用 `edit()`。快照令牌还会检测尚未同步、云端版本号未递增的本地内容变化。批量操作全部验证成功后才写入一次;`dryRun: true` 返回预览结构,不保存,不产生笔记变更事件。预览返回的 `note` 和 `editToken` 仍对应保存前的基线。
```ts
const snapshot = await context.diagrams.get(noteId, { includeLayout: true });
const result = await context.diagrams.edit(noteId, {
expectedRevision: snapshot.note.revision,
expectedContentHash: snapshot.note.contentHash,
expectedEditToken: snapshot.editToken,
reflow: "preserve",
operations: [{ op: "update_node", nodeId: "root", changes: { label: "Updated" } }],
});
const table = await context.tables.get(tableNoteId);
await context.tables.edit(tableNoteId, {
expectedRevision: table.note.revision,
expectedContentHash: table.note.contentHash,
expectedEditToken: table.editToken,
operations: [
{ op: "update_record", recordId: "row-1", cells: { "field-1": "Updated" } },
{ op: "add_record", recordId: "row-2", cells: { "field-1": "New" } },
],
});
```
- 图表操作:`add_node`、`update_node`、`remove_node`、`add_edge`、`update_edge`、`remove_edge`。每批 1–100 项;删除包含子节点的节点须显式设置 `cascade: true`。默认保留已有布局;`reflow: "all"` 重新布局,主题与结构保持不变。
- 表格操作:`add_field`、`update_field`、`remove_field`、`add_record`、`update_record`、`remove_record`,每批 1–100 项。单元格以字段 ID 为键;附件写入使用资源 ID 数组,宿主核验其属于当前笔记并补全元数据。新增记录可以指定唯一 `recordId`,省略则自动生成。
- 字段类型转换、删字段等操作影响已有非空单元格时,先用 `dryRun` 查看 `changedCellCount` / `removedAttachmentCount`,确认后显式传入 `allowDataChanges: true`。记录删除仍会直接执行,须由插件自身提供适当的交互。
- 海报支持 `edits: [{ id, patch }]` 和 `operations`:`add`、`remove`、`duplicate`、`reorder`、`group`、`ungroup`、`align`、`lock`、`apply_template`。先处理元素属性修改,再依序执行操作,最后调整画布/平台尺寸。数组顺序由底层到顶层;可编辑字段、平台和字体见 `posters.capabilities()`。锁定元素不能直接修改或删除,须先显式解锁。`apply_template` 会替换全部元素,只有用户要求替换版式时才调用。图片必须属于当前笔记;修改后清除旧预览引用,但不生成新预览图片。
- 信息图支持 `set_data` / `remove_data` / `insert_data` 操作嵌套数据,路径是字段名与数组下标组成的数组;数组插入使用独立 `index`。每批 1–100 项。`set_template` / `set_theme` 显式修改模板/主题,名称见 `infographics.capabilities()`。普通数据编辑保留模板、主题覆盖、画布配置和会话历史;笔记标题保持不变,若需要同步标题请另用 `notes.update()`。使用现有 AntV 解析器校验;数据段可能重新格式化,但解析后的未指定数据与样式必须保持一致。不能无损回读的值(如不受支持的数组或多行标量)会抛出 `INVALID_STRUCTURED_EDIT`,不会截断或静默丢弃。主题修改只更换主题名,保留显式颜色等覆盖项。
```ts
const poster = await context.posters.get(posterNoteId);
await context.posters.edit(posterNoteId, {
expectedRevision: poster.note.revision,
expectedContentHash: poster.note.contentHash,
expectedEditToken: poster.editToken,
edits: [{ id: "heading", patch: { text: "Updated", fill: "#16A06E" } }],
});
const infographic = await context.infographics.get(infographicNoteId);
await context.infographics.edit(infographicNoteId, {
expectedRevision: infographic.note.revision,
expectedContentHash: infographic.note.contentHash,
expectedEditToken: infographic.editToken,
operations: [{ op: "set_data", path: ["lists", 0, "label"], value: "Updated" }],
});
```
**兼容性与错误**:`notes.update()` 修改标题/标签会保留原始正文;对特殊笔记传入 `contentMarkdown`,或用 `notes.editMarkdown()`、编辑器 Markdown/嵌入写入接口操作特殊正文,会抛出 `STRUCTURED_NOTE_REQUIRED`。通用 Markdown 创建/写入也拒绝特殊笔记标记。已有插件须按 `kind` 选择接口。类型不符为 `NOTE_TYPE_MISMATCH`;非法批量操作为 `INVALID_STRUCTURED_EDIT`;需确认字段数据变化为 `TABLE_DATA_CHANGES_REQUIRED`;快照过期或编辑器有未保存内容为 `NOTE_CONFLICT`,须先保存并重新读取。专用写入沿用宿主仓库的同步、历史版本与刷新机制,离线返回并不表示云端已同步成功。
插件保存还会在本地仓库事务内检查内容快照,防止多个窗口在宿主校验后互相覆盖;未解决的同步冲突会拒绝后续插件写入,并保留冲突队列的草稿与诊断。桌面插件保存使用专用原生接口,将正文、历史快照和同步入队一起提交;不支持该接口的旧宿主会抛出 `HOST_UPGRADE_REQUIRED`,不会回退到无保护写入。原有普通编辑器保存行为保持不变。服务器历史版本沿用现有五分钟快照规则,不保证每次插件写入都产生独立云端版本。
维护者可在构建 sidecar 后运行 `bun run test:plugin-sync`,验证隔离的 Web IndexedDB → 本地 API/SQLite → 原生 sidecar → Web 往返、离线持久化、重启与冲突恢复。`test:desktop:sidecar` 已包含此验证。设置 `EDGE_EVER_LEGACY_SIDECAR_PATH` 和可选的 `EDGE_EVER_LEGACY_MIGRATIONS_PATH` 可额外验证旧原生服务创建的数据升级到当前构建;所有原生进程只访问临时测试目录,不启动已安装的桌面应用,不读取真实账户数据。该验证覆盖当前主机的原生服务,不替代其他平台的实机与应用内验收。
### 2. 模板管理(Templates)
操作工作区内的公共模板或一键套用模板创建新笔记:
```ts
// 查询所有可用模板
const templates = await context.templates.list();
// 创建模板(可直接提供内容,或基于已有 noteId 生成)
const template = await context.templates.create({
name: "会议纪要",
description: "日常项目例会模板",
contentMarkdown: "## 参会人员\n\n## 议题\n\n## 待办行动项\n",
tags: ["meeting"]
});
// 更新模板
await context.templates.update(template.id, {
name: "周会纪要",
description: "每周团队例会"
});
// 套用模板在指定笔记本下创建新笔记
const note = await context.templates.use(template.id, targetNotebookId);
// 删除模板
await context.templates.delete(template.id);
```
---
### 3. 资源附件(Resources)
操作关联到笔记的媒体或文件附件:
```ts
// 获取笔记关联的所有附件
const resources = await context.resources.list(noteId);
// 读取附件 Blob
const blob = await context.resources.read(resourceId);
// 上传新附件
const uploaded = await context.resources.upload(noteId, file);
// 乐观并发更新附件内容(单文件上限 100 MiB)
await context.resources.update(resourceId, {
file: newFile,
expectedContentHash: currentResource.contentHash
});
// 重命名与删除
await context.resources.rename(resourceId, "document.pdf");
await context.resources.delete(resourceId);
```
---
### 4. 编辑器交互(Editor & Embeds)
用于在用户处于编辑状态时操作当前文档:
```ts
// 选区读取与替换
const selection = await context.editor.getSelection();
if (selection && !selection.empty) {
await context.editor.replaceSelection(selection.text.toUpperCase());
}
// 在当前光标处插入内容
await context.editor.insertAtCursor("- [ ] 新建任务\n");
// 实时编辑当前正在编辑的文档(不会丢失未保存的内容)
const doc = await context.editor.getDocument();
if (doc) {
await context.editor.editMarkdown([
{ from: 0, to: 0, insert: "<!-- 插件插入前缀 -->\n" }
]);
}
```
#### 自定义块级 Embed
插件可注册自定义块级渲染器并插入到文档中(保存为通用的 `edgeever-plugin-embed` 块):
```ts
// 1. 注册 Embed 渲染器
context.editor.embeds.register({
type: "my-diagram",
async mount(container, embed) {
const data = embed.data;
container.innerHTML = `<div>图表预览: ${data.title}</div>`;
return () => container.replaceChildren(); // 卸载清理
}
});
// 2. 向文档中插入 Embed
await context.editor.insertEmbed({
type: "my-diagram",
title: "架构图",
data: { mode: "preview", version: 1 } // JSON 兼容对象,上限 64 KiB
});
```
---
### 5. 全局命令(Commands)
```ts
context.commands.register({
id: "quick-action",
title: "快速操作",
listed: true, // 是否在市场卡片中展示,默认为 true;上下文命令建议设为 false
menu: true, // 是否在桌面端快捷菜单中展示,默认为 true
async run() {
context.ui.showNotice("操作执行成功");
}
});
```
---
### 6. 宿主界面与通知(UI & Notices)
```ts
// 宿主全局轻提示
context.ui.showNotice("操作执行成功");
// 打开指定笔记并可选跳转至指定搜索关键词
await context.ui.openNote(noteId, { search: "关键定位词" });
```
---
### 7. 自定义面板(Panels)
插件可以注册全屏或对话框形态的原生 DOM 面板:
```ts
context.ui.panels.register({
id: "task-dashboard",
title: "任务仪表盘",
purpose: "dashboard", // "workflow" | "dashboard" | "preview" | "onboarding"
presentation: "dialog", // "dialog" | "fullscreen"
mount(container, { shell, requestClose, state }) {
// 1. 使用宿主标准 Shell 控件(页头、搜索栏、分段切换等)
shell.set({
header: {
title: "任务管理",
description: "查看待办与进行中的任务",
actions: [{ id: "refresh", label: "刷新" }]
},
toolbar: [
{
type: "tabs",
key: "filter",
value: "all",
options: [{ value: "all", label: "全部" }, { value: "done", label: "已完成" }]
},
{ type: "search", key: "q", placeholder: "搜索任务" }
],
onAction(actionId) { /* 处理按钮点击 */ },
onChange(key, value) { /* 处理筛选变更 */ }
});
// 2. 渲染插件自定义 DOM 内容
const content = document.createElement("div");
content.textContent = "内容区域";
container.append(content);
return () => content.remove(); // 卸载清理
},
beforeClose() {
// 关闭防丢失保护:返回 true 允许关闭,false 阻止,或返回提示配置由宿主弹窗确认
return true;
}
});
// 打开面板
await context.ui.panels.open("task-dashboard", { state: { initialTab: "all" } });
```
---
### 8. 统一设置(Settings)
EdgeEver 由宿主统一渲染插件设置界面。在 `manifest.json` 中声明配置字段,无需手写设置页 UI:
```json
{
"settings": {
"fields": [
{ "key": "endpoint", "type": "text", "label": "API 地址", "required": true },
{ "key": "token", "type": "secret", "label": "API Token", "required": true },
{ "key": "autoSync", "type": "boolean", "label": "自动同步", "default": true },
{
"key": "mode",
"type": "select",
"label": "同步模式",
"default": "fast",
"options": [
{ "value": "fast", "label": "快速" },
{ "value": "full", "label": "完整" }
]
}
]
}
}
```
#### 读取与监听设置
```ts
// 读取设置值
const endpoint = await context.settings.get<string>("endpoint");
const token = await context.settings.get<string>("token");
// 监听当前插件设置变更
context.events.on("settings.changed", async ({ key }) => {
if (key === "autoSync") {
const autoSync = await context.settings.get<boolean>("autoSync");
// 更新逻辑
}
});
```
> [!NOTE]
> `type: "secret"` 字段会经过本地加密保存,不会明文回显在表单中。设置仅保存在当前设备,不跨端同步。
---
### 9. 数据存储与安全凭据(Storage & Secrets)
用于插件自身的数据持久化(按工作区与插件 ID 隔离存储在当前设备):
```ts
// 普通键值存储(适合游标、偏好缓存)
await context.storage.set("last_sync_time", Date.now());
const lastSync = await context.storage.get<number>("last_sync_time");
await context.storage.remove("last_sync_time");
// 安全凭据存储(敏感 Token、密钥等,采用本地加密保存)
await context.secrets.set("api_key", "sk-xxx");
const apiKey = await context.secrets.get("api_key");
await context.secrets.remove("api_key");
```
---
### 10. 网络请求(Network)
```ts
// 1. 标准请求(支持任意 HTTP/HTTPS 请求,受宿主环境 CORS 策略约束)
const response = await context.network.fetch("https://api.example.com/data", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: "example" })
});
const data = await response.json();
// 2. 公开只读请求(无凭据读取跨域公开 RSS/API,仅支持 443 端口 HTTPS GET/HEAD)
const publicRes = await context.network.fetch("https://example.org/feed.xml", {
transport: "public",
headers: { Accept: "application/rss+xml" }
});
const xml = await publicRes.text();
```
---
### 11. 定时任务(Schedules,仅桌面端)
桌面端插件可注册定时计划执行已注册的命令(在 EdgeEver 打开期间运行):
```ts
// 注册命令
context.commands.register({
id: "sync-feed",
title: "同步订阅",
run: async () => { /* 耗时同步任务 */ }
});
// 配置定时计划(以 key 为唯一标识,激活时重复调用自动幂等更新)
await context.schedules.upsert({
key: "hourly-sync",
name: "每小时同步订阅",
commandId: "sync-feed",
cronExpression: "0 * * * *",
missedRunPolicy: "run-once" // 错过的执行补跑一次 ("run-once" | "skip")
});
// 管理计划
const schedules = await context.schedules.list();
await context.schedules.remove("hourly-sync");
```
---
### 12. 事件监听(Events)
订阅工作区的数据变更事件:
```ts
// 监听笔记事件
context.events.on("note.created", ({ note }) => console.log("新建笔记", note.id));
context.events.on("note.updated", ({ note }) => console.log("笔记更新", note.id));
context.events.on("note.deleted", ({ noteId }) => console.log("笔记删除", noteId));
// 监听其他变更
context.events.on("tag.changed", () => { /* 标签更新 */ });
context.events.on("workspace.synced", () => { /* 工作区同步完成 */ });
```
---
### 13. 人工智能(AI)
调用用户当前工作区已配置的 AI 模型生成文本或转录媒体(凭据由宿主安全保管,插件无需知晓用户 API Key):
```ts
// 1. 检查 AI 状态与当前配置模型
const status = await context.ai.status();
// { configured: boolean, modelName?: string }
if (status.configured) {
// 2. 文本生成
const result = await context.ai.generate({
system: "你是一个专业的文本润色助手。",
prompt: "请优化以下段落:...",
maxOutputTokens: 2000,
signal: abortController.signal
});
console.log("生成结果:", result.text);
// 3. 转录笔记内已上传的音视频附件
const transcript = await context.ai.transcribeResource(noteId, resourceId);
// { text: string, resourceId: string, filename: string }
// 4. 转录插件持有的独立媒体 Blob / File(无需先上传为笔记附件)
const ownMedia = new File([audioBytes], "recording.mp3", { type: "audio/mpeg" });
const mediaTranscript = await context.ai.transcribeMedia(ownMedia, {
signal: abortController.signal
});
console.log("音频转录:", mediaTranscript.text);
}
```
---
## 主题扩展(Theme Manifest)
主题为纯无代码扩展,仅需一个 `manifest.json`:
```json
{
"type": "theme",
"id": "com.example.theme",
"name": "Nord Emerald",
"version": "1.0.0",
"themeApiVersion": "1",
"modes": ["light", "dark"],
"light": {
"color.background": "#f8fafc",
"color.surface": "#ffffff",
"color.text": "#0f172a",
"color.accent": "#16a06e"
},
"dark": {
"color.background": "#0f172a",
"color.surface": "#1e293b",
"color.text": "#f8fafc",
"color.accent": "#4ade80"
}
}
```
支持的 Token 包括:
- 颜色:`color.background`, `color.surface`, `color.surfaceMuted`, `color.text`, `color.textMuted`, `color.border`, `color.accent`, `color.accentForeground`, `color.success`, `color.warning`, `color.danger`(格式必须为 `#RRGGBB` 或 `#RRGGBBAA`)
- 排版与尺寸:`font.body`, `font.mono`, `font.size`, `lineHeight.body`, `radius.medium`, `density.scale`, `editor.contentWidth`
---
## 打包、分发与本地开发
### 打包规范
插件分发产物必须为免相对依赖的单个 Bundle 文件:
- `manifest.json`:插件清单
- `main.js`:打包后的单文件脚本(上限 5 MB)
- `styles.css`:可选样式表(上限 1 MB)
若使用 `@edgeever/plugin-api`,请在构建时将其内联打入 `main.js`。
### GitHub 分发
1. 将包含最新 `manifest.json` 的仓库发布在 GitHub(公开仓库)。
2. 创建 GitHub Release,Release Tag 匹配 Manifest 版本(如 `1.0.0` 或 `v1.0.0`)。
3. 在 Release 资产中上传 `manifest.json`、`main.js` 和可选的 `styles.css`。
4. 用户可在 EdgeEver 的「插件市场」页面直接输入仓库地址安装:
```text
https://github.com/owner/edgeever-plugin
```
### 本地测试与调试
开发 EdgeEver 时,可直接在插件市场中输入本地 Manifest 地址进行调试:
- `/extensions/recent-notes/manifest.json`(官方示例插件)
- `/extensions/nord-emerald/manifest.json`(官方示例主题)
提交至官方已验证市场请参阅 [官方插件市场上架政策](plugin-marketplace-policy.zh-CN.md)。