feat: pi-siyuan v0.1.0 SiYuan extension with RWD permission auditing

- 15 siyuan-mcp-compatible tools + siyuan_discover progressive-disclosure loader
- Notebook-level R/W/D/NONE permission audit matrix (SQL box lookup, global rules)
- New-notebook auto-sync to config.json (default R)
- env-first connection config with config.json fallback
- 3 self-check suites (pure-function / integration / extension e2e)
This commit is contained in:
2026-09-02 19:07:04 +08:00
commit 305cdfb3f3
12 changed files with 3496 additions and 0 deletions
+222
View File
@@ -0,0 +1,222 @@
/**
* SiYuan HTTP API 客户端
* 直接封装 /api/*Authorization: Token 头),无外部依赖。
*/
export class SiYuanError extends Error {
code?: number;
constructor(message: string, code?: number) {
super(message);
this.code = code;
}
}
export class SiYuanClient {
private apiUrl: string;
private token: string;
constructor(apiUrl: string, token: string) {
this.apiUrl = apiUrl;
this.token = token;
}
/** 连通性检查(审计豁免) */
async version(): Promise<string> {
const d = await this.call("/api/system/version", {});
return d as string;
}
async lsNotebooks(): Promise<
Array<{ id: string; name: string; closed: boolean }>
> {
const d = await this.call("/api/notebook/lsNotebooks", {});
return d.notebooks ?? [];
}
/** SQL 查询(该部署的 /api/query/query 被反代吞掉,sql 可用) */
async sql(stmt: string): Promise<any[]> {
const d = await this.call("/api/query/sql", { stmt });
return d ?? [];
}
/** docId/blockId → 所属笔记本 idblocks.box)。查不到返回 null */
async boxOf(blockId: string): Promise<string | null> {
try {
const rows = await this.sql(
`select distinct box from blocks where id='${this.esc(blockId)}' or root_id='${this.esc(blockId)}' limit 1`,
);
return rows.length ? rows[0].box : null;
} catch {
return null;
}
}
// ---- 文档 ----
async createDoc(
notebook: string,
path: string,
markdown: string,
): Promise<string> {
return (await this.call("/api/filetree/createDocWithMd", {
notebook,
path,
markdown,
})) as string;
}
async getDocContent(id: string): Promise<string> {
const d = await this.call("/api/export/exportMdContent", { id });
return d.content ?? "";
}
async appendBlock(parentID: string, markdown: string): Promise<any> {
return this.call("/api/block/appendBlock", {
dataType: "markdown",
data: markdown,
parentID,
});
}
async updateBlock(id: string, markdown: string): Promise<any> {
return this.call("/api/block/updateBlock", {
dataType: "markdown",
data: markdown,
id,
});
}
async moveDocs(
fromIDs: string[],
toNotebook: string,
toPath: string,
): Promise<any> {
return this.call("/api/filetree/moveDocs", { fromIDs, toNotebook, toPath });
}
async listDocsByPath(notebook: string, path: string): Promise<any[]> {
const d = await this.call("/api/filetree/listDocsByPath", { notebook, path });
return d?.files ?? [];
}
/** daily note/api/dailynote/* 在该部署返回空 body,退化为 createDocWithMd 幂等创建 /YYYY-MM-DD */
async dailyNote(
notebook: string,
markdown: string,
): Promise<{ docId: string; created: boolean }> {
const today = new Date();
const dateStr = `${today.getFullYear()}-${String(today.getMonth() + 1).padStart(2, "0")}-${String(
today.getDate(),
).padStart(2, "0")}`;
const path = `/${dateStr}`;
// 已存在则追加
try {
const rows = await this.sql(
`select id from blocks where type='d' and box='${this.esc(notebook)}' and content='${dateStr}' limit 1`,
);
if (rows.length) {
await this.appendBlock(rows[0].id, markdown);
return { docId: rows[0].id, created: false };
}
} catch {
// 查询失败走创建
}
const docId = await this.createDoc(
notebook,
path,
`# ${dateStr}\n\n${markdown}`,
);
return { docId, created: true };
}
// ---- 搜索(fulltext 端点被吞,用 SQL----
async searchContent(query: string, limit: number): Promise<any[]> {
return this.sql(
`select id, box, root_id, content, type from blocks where content like '%${this.esc(query)}%' limit ${limit}`,
);
}
async searchDocTitles(query: string, limit: number): Promise<any[]> {
return this.sql(
`select id, box, content from blocks where type='d' and content like '%${this.esc(query)}%' limit ${limit}`,
);
}
// ---- 标签 ----
async searchTags(k: string): Promise<string[]> {
const d = await this.call("/api/search/searchTag", { k });
const tags: string[] = (d?.tags ?? []).map((t: string) =>
t.replace(/<\/?mark>/g, ""),
);
return tags;
}
async renameTag(oldLabel: string, newLabel: string): Promise<any> {
// /api/tag/replaceTag 被吞;renameTag 可用。newLabel 为空 = 删除标签语义由调用方控制
return this.call("/api/tag/renameTag", { oldLabel, newLabel });
}
// ---- 快照 ----
async createSnapshot(memo: string): Promise<any> {
return this.call("/api/repo/createSnapshot", { memo });
}
/** /api/repo/listSnapshots 被吞,用 getRepoSnapshots 分页 */
async listSnapshots(page = 1): Promise<any> {
return this.call("/api/repo/getRepoSnapshots", { page });
}
async rollbackToSnapshot(id: string): Promise<any> {
return this.call("/api/repo/checkoutRepo", { id });
}
// ---- 内部 ----
private esc(s: string): string {
return s.replace(/'/g, "''");
}
private async call(path: string, body: unknown): Promise<any> {
let res: Response;
try {
res = await fetch(this.apiUrl + path, {
method: "POST",
headers: {
Authorization: `Token ${this.token}`,
"Content-Type": "application/json",
},
body: JSON.stringify(body),
signal: AbortSignal.timeout(30000),
});
} catch (e: any) {
throw new SiYuanError(`连接 SiYuan 失败 (${this.apiUrl}): ${e.message}`);
}
if (res.status === 401)
throw new SiYuanError("SiYuan 认证失败:检查 token", 401);
const text = await res.text();
if (!text) {
// 该部署的反代会吞掉部分端点的响应体
throw new SiYuanError(
`SiYuan 端点 ${path} 返回空响应(可能被反向代理吞掉)`,
);
}
let json: any;
try {
json = JSON.parse(text);
} catch {
throw new SiYuanError(
`SiYuan 端点 ${path} 返回非 JSON: ${text.slice(0, 200)}`,
);
}
if (json.code !== 0)
throw new SiYuanError(
json.msg || `SiYuan 错误 code=${json.code}`,
json.code,
);
return json.data;
}
}