dsh-plugin-library 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +154 -0
- package/cordis.patch.yml +9 -0
- package/dist/client.js +2730 -0
- package/dist/host.js +1626 -0
- package/locale/en.json +4 -0
- package/locale/zh.json +4 -0
- package/package.json +45 -0
package/dist/host.js
ADDED
|
@@ -0,0 +1,1626 @@
|
|
|
1
|
+
// ../../shared/host/route.mjs
|
|
2
|
+
function json(body, status) {
|
|
3
|
+
return Response.json(body, { status, headers: { "cache-control": "no-store" } });
|
|
4
|
+
}
|
|
5
|
+
function ok(value) {
|
|
6
|
+
return json({ ok: true, value: value ?? null }, 200);
|
|
7
|
+
}
|
|
8
|
+
function fail(code, message) {
|
|
9
|
+
return json({ ok: false, error: { code, message } }, 200);
|
|
10
|
+
}
|
|
11
|
+
var FeatureError2 = class extends Error {
|
|
12
|
+
/**
|
|
13
|
+
* @param {string} code - stable machine-readable code, for example `connector/not-found`.
|
|
14
|
+
* @param {string} message - human-readable reason shown to the user.
|
|
15
|
+
*/
|
|
16
|
+
constructor(code, message) {
|
|
17
|
+
super(message);
|
|
18
|
+
this.name = "FeatureError";
|
|
19
|
+
this.code = code;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
function registerFeatureRoute(ctx, { feature, handlers, methods = ["POST"] }) {
|
|
23
|
+
const path = `/api/dsh-skills/${feature}`;
|
|
24
|
+
return ctx.effect(
|
|
25
|
+
() => ctx.connection.fetch.register({
|
|
26
|
+
path,
|
|
27
|
+
methods: [...methods],
|
|
28
|
+
requestBody: "buffered",
|
|
29
|
+
fetch: async (request) => {
|
|
30
|
+
try {
|
|
31
|
+
if (request.method !== "POST") return fail("method-not-allowed", "\u6B64\u8DEF\u7531\u53EA\u63A5\u53D7 POST");
|
|
32
|
+
const body = await request.json().catch(() => null);
|
|
33
|
+
if (body === null || typeof body !== "object" || Array.isArray(body)) {
|
|
34
|
+
return fail("malformed-request", "\u8BF7\u6C42\u4F53\u5FC5\u987B\u662F JSON \u5BF9\u8C61");
|
|
35
|
+
}
|
|
36
|
+
const { endpoint, payload } = body;
|
|
37
|
+
if (typeof endpoint !== "string") return fail("malformed-request", "endpoint \u5FC5\u987B\u662F\u5B57\u7B26\u4E32");
|
|
38
|
+
const handler = Object.hasOwn(handlers, endpoint) ? handlers[endpoint] : void 0;
|
|
39
|
+
if (typeof handler !== "function") return fail("unknown-endpoint", `\u672A\u77E5\u7AEF\u70B9 ${JSON.stringify(endpoint)}`);
|
|
40
|
+
const result = await handler(payload ?? {}, request);
|
|
41
|
+
return result instanceof Response ? result : ok(result);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
const code = typeof error?.code === "string" && error.code.length > 0 ? error.code : "handler-failed";
|
|
44
|
+
return fail(code, String(error?.message ?? error));
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}),
|
|
48
|
+
`dsh-skills: ${feature} route`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ../../shared/dsh-package.mjs
|
|
53
|
+
import { createRequire } from "node:module";
|
|
54
|
+
import { isAbsolute, join } from "node:path";
|
|
55
|
+
import { pathToFileURL } from "node:url";
|
|
56
|
+
function dshResolutionAnchors({ argv = process.argv, cwd = process.cwd() } = {}) {
|
|
57
|
+
const anchors = [];
|
|
58
|
+
if (typeof argv[1] === "string" && isAbsolute(argv[1])) anchors.push(argv[1]);
|
|
59
|
+
if (typeof argv[2] === "string" && isAbsolute(argv[2])) anchors.push(join(argv[2], "package.json"));
|
|
60
|
+
anchors.push(join(cwd, "package.json"));
|
|
61
|
+
return [...new Set(anchors)];
|
|
62
|
+
}
|
|
63
|
+
var stubs = /* @__PURE__ */ new Map();
|
|
64
|
+
function resolveDshPackage(specifier, options = {}) {
|
|
65
|
+
if (stubs.has(specifier)) return `stub:${specifier}`;
|
|
66
|
+
const tried = [];
|
|
67
|
+
for (const anchor of dshResolutionAnchors(options)) {
|
|
68
|
+
try {
|
|
69
|
+
return createRequire(anchor).resolve(specifier);
|
|
70
|
+
} catch (error) {
|
|
71
|
+
tried.push(`${anchor} (${error?.code ?? error?.name ?? "error"})`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
throw new Error(
|
|
75
|
+
`dsh-skills: cannot resolve ${JSON.stringify(specifier)} from the dsh installation or the profile.
|
|
76
|
+
Tried: ${tried.join(", ")}
|
|
77
|
+
A Host half must not import an official package by bare specifier: Node resolves that from
|
|
78
|
+
this plugin's realpath, which is outside the installation. Use importDshPackage() instead.`
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
async function importDshPackage(specifier, options) {
|
|
82
|
+
const stub = stubs.get(specifier);
|
|
83
|
+
if (stub !== void 0) return stub;
|
|
84
|
+
return import(pathToFileURL(resolveDshPackage(specifier, options)).href);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// ../../shared/host/storage.mjs
|
|
88
|
+
var cached;
|
|
89
|
+
async function storageKit() {
|
|
90
|
+
if (cached !== void 0) return cached;
|
|
91
|
+
const [domain, zod] = await Promise.all([
|
|
92
|
+
importDshPackage("@deepseek-ai/dsh-storage-domain"),
|
|
93
|
+
importDshPackage("zod")
|
|
94
|
+
]);
|
|
95
|
+
const z = zod.z ?? zod.default ?? zod;
|
|
96
|
+
if (typeof domain?.defineDomain !== "function" || typeof domain?.domainTable !== "function") {
|
|
97
|
+
throw new Error("dsh-skills: @deepseek-ai/dsh-storage-domain no longer exports defineDomain/domainTable");
|
|
98
|
+
}
|
|
99
|
+
if (typeof z?.object !== "function") {
|
|
100
|
+
throw new Error("dsh-skills: the installation's zod does not expose z.object");
|
|
101
|
+
}
|
|
102
|
+
cached = { defineDomain: domain.defineDomain, domainTable: domain.domainTable, descriptorOf: domain.descriptorOf, z };
|
|
103
|
+
return cached;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// src/attribution.js
|
|
107
|
+
import { resolve } from "node:path";
|
|
108
|
+
function absolutePath(path, cwd) {
|
|
109
|
+
if (cwd === void 0 || cwd.length === 0) return path;
|
|
110
|
+
return resolve(cwd, path);
|
|
111
|
+
}
|
|
112
|
+
function registerDeliverableImport(ctx, manager) {
|
|
113
|
+
const warn = (message) => ctx.logger?.warn?.(`dsh-plugin-library: ${message}`);
|
|
114
|
+
const inflight = /* @__PURE__ */ new Set();
|
|
115
|
+
const dispose = ctx.on("session/event", (session, event) => {
|
|
116
|
+
if (event?.type !== "deliverables/presented") return;
|
|
117
|
+
const sessionId = String(session?.id ?? "");
|
|
118
|
+
if (sessionId.length === 0) return;
|
|
119
|
+
const cwd = session?.header?.cwd;
|
|
120
|
+
const files = Array.isArray(event?.data?.files) ? event.data.files : [];
|
|
121
|
+
const seen = /* @__PURE__ */ new Set();
|
|
122
|
+
for (const file of files) {
|
|
123
|
+
const path = absolutePath(file?.path ?? "", cwd);
|
|
124
|
+
if (path.length === 0) continue;
|
|
125
|
+
if (seen.has(path)) continue;
|
|
126
|
+
seen.add(path);
|
|
127
|
+
const run = (async () => {
|
|
128
|
+
const entry = await manager.importTaskFile({
|
|
129
|
+
path,
|
|
130
|
+
sourceTaskId: sessionId
|
|
131
|
+
});
|
|
132
|
+
ctx.emit("library/deliverable-imported", { sessionId, path, entry });
|
|
133
|
+
})().catch((cause) => {
|
|
134
|
+
warn(`\u4EA4\u4ED8\u5165\u5E93\u5931\u8D25\uFF1A${path}\uFF08${cause instanceof Error ? cause.message : String(cause)}\uFF09`);
|
|
135
|
+
});
|
|
136
|
+
inflight.add(run);
|
|
137
|
+
run.finally(() => {
|
|
138
|
+
inflight.delete(run);
|
|
139
|
+
});
|
|
140
|
+
}
|
|
141
|
+
});
|
|
142
|
+
return {
|
|
143
|
+
dispose,
|
|
144
|
+
flush: async () => {
|
|
145
|
+
while (inflight.size > 0) await Promise.allSettled([...inflight]);
|
|
146
|
+
}
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// src/domains.js
|
|
151
|
+
var LOCAL_PRINCIPAL = "local";
|
|
152
|
+
function buildLibraryDomains({ z, defineDomain, domainTable }) {
|
|
153
|
+
const loose = z.record(z.string(), z.unknown());
|
|
154
|
+
const stateSchema = z.object({
|
|
155
|
+
schemaVersion: z.literal(1),
|
|
156
|
+
/** Tree nodes: folders and asset nodes. `id` is the node id (equal to `assetId` for assets). */
|
|
157
|
+
nodes: loose,
|
|
158
|
+
/** Asset records keyed by asset id. */
|
|
159
|
+
assets: loose,
|
|
160
|
+
/** Immutable revision records keyed `${assetId}:${sha256}`. */
|
|
161
|
+
revisions: loose,
|
|
162
|
+
/**
|
|
163
|
+
* Editable drafts keyed by draft id, added after the first release.
|
|
164
|
+
*
|
|
165
|
+
* `.default({})`, never a plain required key: the domain facility validates
|
|
166
|
+
* **every stored record on `open()`**, before any consumer sees it, and an
|
|
167
|
+
* unparsable record is a hard `DomainError`. A plain key would therefore make
|
|
168
|
+
* every library record written before drafts existed fail to parse — taking
|
|
169
|
+
* the route, the tools and the whole panel down on the next launch. This is
|
|
170
|
+
* exactly the failure docs/PORTING-NOTES.md §15 records for experts.
|
|
171
|
+
*/
|
|
172
|
+
drafts: loose.default({})
|
|
173
|
+
});
|
|
174
|
+
return {
|
|
175
|
+
stateSchema,
|
|
176
|
+
libraryDomain: defineDomain({
|
|
177
|
+
name: "dsh_skills_library",
|
|
178
|
+
version: 1,
|
|
179
|
+
layout: "per-record",
|
|
180
|
+
tables: { states: domainTable(stateSchema) }
|
|
181
|
+
})
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// src/manager.js
|
|
186
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
187
|
+
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
188
|
+
import { homedir } from "node:os";
|
|
189
|
+
import { basename, dirname, extname as extname2, join as join2, resolve as resolve2, sep } from "node:path";
|
|
190
|
+
|
|
191
|
+
// src/convert.js
|
|
192
|
+
import { extname } from "node:path";
|
|
193
|
+
var KINDS_BY_EXTENSION = {
|
|
194
|
+
".md": "markdown",
|
|
195
|
+
".markdown": "markdown",
|
|
196
|
+
".txt": "text",
|
|
197
|
+
".html": "html",
|
|
198
|
+
".htm": "html",
|
|
199
|
+
".json": "json",
|
|
200
|
+
".csv": "csv",
|
|
201
|
+
".log": "log"
|
|
202
|
+
};
|
|
203
|
+
var MEDIA_TYPES_BY_KIND = {
|
|
204
|
+
markdown: "text/markdown",
|
|
205
|
+
text: "text/plain",
|
|
206
|
+
html: "text/html",
|
|
207
|
+
json: "application/json",
|
|
208
|
+
csv: "text/csv",
|
|
209
|
+
log: "text/plain",
|
|
210
|
+
binary: "application/octet-stream"
|
|
211
|
+
};
|
|
212
|
+
var ENTITIES = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
|
|
213
|
+
function kindForName(name2) {
|
|
214
|
+
return KINDS_BY_EXTENSION[extname(String(name2)).toLowerCase()] ?? "binary";
|
|
215
|
+
}
|
|
216
|
+
function mediaTypeForKind(kind) {
|
|
217
|
+
return MEDIA_TYPES_BY_KIND[kind] ?? MEDIA_TYPES_BY_KIND.binary;
|
|
218
|
+
}
|
|
219
|
+
function decodeEntities(value) {
|
|
220
|
+
return value.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (whole, code) => {
|
|
221
|
+
if (code[0] !== "#") return ENTITIES[code.toLowerCase()] ?? whole;
|
|
222
|
+
const point = code[1]?.toLowerCase() === "x" ? Number.parseInt(code.slice(2), 16) : Number.parseInt(code.slice(1), 10);
|
|
223
|
+
return Number.isFinite(point) ? String.fromCodePoint(point) : whole;
|
|
224
|
+
});
|
|
225
|
+
}
|
|
226
|
+
function stripHtml(source) {
|
|
227
|
+
const withoutComments = source.replace(/<!--[\s\S]*?-->/g, " ");
|
|
228
|
+
const withoutHidden = withoutComments.replace(
|
|
229
|
+
/<(script|style|noscript|template|svg|canvas)(?:\s[^>]*)?>[\s\S]*?<\/\1\s*>/gi,
|
|
230
|
+
" "
|
|
231
|
+
);
|
|
232
|
+
const broken = withoutHidden.replace(/<(br|hr)\s*\/?>/gi, "\n").replace(/<\/(p|div|section|article|main|header|footer|li|tr|h[1-6]|ul|ol|table|blockquote|pre)\s*>/gi, "\n");
|
|
233
|
+
const text = decodeEntities(broken.replace(/<[^>]*>/g, " "));
|
|
234
|
+
return `${text.split("\n").map((line) => line.replace(/[\t ]+/g, " ").trim()).filter(Boolean).join("\n\n")}
|
|
235
|
+
`;
|
|
236
|
+
}
|
|
237
|
+
function deriveText(kind, bytes) {
|
|
238
|
+
if (kind === "binary") {
|
|
239
|
+
return {
|
|
240
|
+
text: "",
|
|
241
|
+
status: "unavailable",
|
|
242
|
+
warnings: ["\u8BE5\u683C\u5F0F\u6CA1\u6709\u6587\u672C\u8F6C\u6362\uFF0C\u4EC5\u4FDD\u5B58\u539F\u4EF6\uFF1B\u4E0D\u53C2\u4E0E\u6B63\u6587\u641C\u7D22\u3002"]
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
let source;
|
|
246
|
+
try {
|
|
247
|
+
source = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
248
|
+
} catch {
|
|
249
|
+
return {
|
|
250
|
+
text: "",
|
|
251
|
+
status: "failed",
|
|
252
|
+
warnings: ["\u6587\u4EF6\u4E0D\u662F\u6709\u6548\u7684 UTF-8 \u6587\u672C\uFF0C\u65E0\u6CD5\u751F\u6210\u68C0\u7D22\u6587\u672C\uFF1B\u539F\u4EF6\u5DF2\u5B8C\u6574\u4FDD\u5B58\u3002"]
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const normalized = source.replace(/\r\n?/g, "\n");
|
|
256
|
+
if (kind === "html") {
|
|
257
|
+
return {
|
|
258
|
+
text: stripHtml(normalized),
|
|
259
|
+
status: "ready",
|
|
260
|
+
warnings: ["\u811A\u672C\u3001\u6837\u5F0F\u3001SVG \u4E0E\u753B\u5E03\u5185\u5BB9\u4E0D\u8FDB\u5165\u68C0\u7D22\u6587\u672C\uFF1B\u539F\u59CB HTML \u4ECD\u6309\u5B57\u8282\u4FDD\u5B58\u3002"]
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
return { text: normalized, status: "ready", warnings: [] };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// src/native-open.js
|
|
267
|
+
var cached2;
|
|
268
|
+
async function loadNativeCommand() {
|
|
269
|
+
if (cached2 !== void 0) return cached2;
|
|
270
|
+
try {
|
|
271
|
+
const mod = await importDshPackage("@deepseek-ai/dsh-native-command");
|
|
272
|
+
if (typeof mod?.openNativeAssociatedPath === "function") {
|
|
273
|
+
cached2 = mod;
|
|
274
|
+
return cached2;
|
|
275
|
+
}
|
|
276
|
+
} catch {
|
|
277
|
+
}
|
|
278
|
+
cached2 = {
|
|
279
|
+
openNativeAssociatedPath: async () => {
|
|
280
|
+
throw new FeatureError2("library/native-unavailable", "\u5F53\u524D\u73AF\u5883\u65E0\u6CD5\u8C03\u7528\u7CFB\u7EDF\u7A0B\u5E8F\u6253\u5F00\u6587\u4EF6\uFF08\u539F\u751F\u547D\u4EE4\u5C42\u4E0D\u53EF\u7528\uFF09\u3002");
|
|
281
|
+
},
|
|
282
|
+
nativeFileApplications: async () => [],
|
|
283
|
+
openNativeFileApplication: async () => {
|
|
284
|
+
throw new FeatureError2("library/native-unavailable", "\u5F53\u524D\u73AF\u5883\u65E0\u6CD5\u8C03\u7528\u7CFB\u7EDF\u7A0B\u5E8F\u6253\u5F00\u6587\u4EF6\uFF08\u539F\u751F\u547D\u4EE4\u5C42\u4E0D\u53EF\u7528\uFF09\u3002");
|
|
285
|
+
}
|
|
286
|
+
};
|
|
287
|
+
return cached2;
|
|
288
|
+
}
|
|
289
|
+
async function openInSystemApp(absolutePath2, appId, ctx) {
|
|
290
|
+
if (typeof absolutePath2 !== "string" || !absolutePath2.startsWith("/")) {
|
|
291
|
+
throw new FeatureError2("library/invalid-request", "\u9700\u8981\u4E00\u4E2A\u672C\u673A\u7EDD\u5BF9\u8DEF\u5F84\u3002");
|
|
292
|
+
}
|
|
293
|
+
const signal = new AbortController().signal;
|
|
294
|
+
const native = await loadNativeCommand();
|
|
295
|
+
try {
|
|
296
|
+
if (typeof appId === "string" && appId.length > 0) {
|
|
297
|
+
if (typeof native.openNativeFileApplication !== "function") {
|
|
298
|
+
throw new FeatureError2("library/native-unavailable", "\u5F53\u524D\u539F\u751F\u547D\u4EE4\u5C42\u4E0D\u652F\u6301\u6307\u5B9A\u7A0B\u5E8F\u6253\u5F00\u3002");
|
|
299
|
+
}
|
|
300
|
+
await native.openNativeFileApplication(absolutePath2, appId, signal);
|
|
301
|
+
return { opened: true, appId };
|
|
302
|
+
}
|
|
303
|
+
await native.openNativeAssociatedPath(absolutePath2, signal);
|
|
304
|
+
return { opened: true, appId: void 0 };
|
|
305
|
+
} catch (error) {
|
|
306
|
+
if (error instanceof FeatureError2) throw error;
|
|
307
|
+
ctx?.logger?.warn?.(`dsh-plugin-library: \u7CFB\u7EDF\u6253\u5F00\u5931\u8D25 ${absolutePath2}\uFF08${appId ?? "\u9ED8\u8BA4"}\uFF09\uFF1A${String(error?.message ?? error)}`);
|
|
308
|
+
throw new FeatureError2("library/open-failed", `\u6CA1\u6709\u6253\u5F00\u6210\u529F\uFF1A${String(error?.message ?? error)}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
async function fileApplications(absolutePath2) {
|
|
312
|
+
if (typeof absolutePath2 !== "string" || !absolutePath2.startsWith("/")) return [];
|
|
313
|
+
const signal = new AbortController().signal;
|
|
314
|
+
const native = await loadNativeCommand();
|
|
315
|
+
if (typeof native.nativeFileApplications !== "function") return [];
|
|
316
|
+
const rows = await native.nativeFileApplications(absolutePath2, signal);
|
|
317
|
+
return Array.isArray(rows) ? rows : [];
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// src/manager.js
|
|
321
|
+
var MAX_BYTES = 50 * 1024 * 1024;
|
|
322
|
+
var MAX_TOTAL_BYTES = 5 * 1024 * 1024 * 1024;
|
|
323
|
+
var MAX_HITS = 50;
|
|
324
|
+
var NAME_ATTEMPTS = 5;
|
|
325
|
+
var DRAFT_TTL_MS = 7 * 24 * 60 * 60 * 1e3;
|
|
326
|
+
var EDITABLE_KINDS = /* @__PURE__ */ new Set(["markdown", "text"]);
|
|
327
|
+
var FS_CODES = {
|
|
328
|
+
FS_NOT_FOUND: "library/not-found",
|
|
329
|
+
FS_NOT_REGULAR_FILE: "library/not-a-file",
|
|
330
|
+
FS_TOO_LARGE: "library/file-size",
|
|
331
|
+
FS_SANDBOX_DENIED: "library/read-denied",
|
|
332
|
+
FS_ABORTED: "library/aborted",
|
|
333
|
+
FS_IO_ERROR: "library/read-failed"
|
|
334
|
+
};
|
|
335
|
+
function sha256Hex(bytes) {
|
|
336
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
337
|
+
}
|
|
338
|
+
function requireString(value, field) {
|
|
339
|
+
if (typeof value !== "string" || value.length === 0) {
|
|
340
|
+
throw new FeatureError2("library/invalid-request", `\u7F3A\u5C11\u5B57\u6BB5 ${field}\u3002`);
|
|
341
|
+
}
|
|
342
|
+
return value;
|
|
343
|
+
}
|
|
344
|
+
function optionalId(value) {
|
|
345
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
346
|
+
}
|
|
347
|
+
function cleanName(value) {
|
|
348
|
+
const name2 = typeof value === "string" ? value.trim() : "";
|
|
349
|
+
if (name2.length === 0 || name2.length > 256 || name2 === "." || name2 === ".." || /[/\\\u0000-\u001f]/.test(name2)) {
|
|
350
|
+
throw new FeatureError2("library/invalid-name", "\u540D\u79F0\u9700\u8981 1\u2013256 \u4E2A\u5B57\u7B26\uFF0C\u4E14\u4E0D\u80FD\u5305\u542B\u8DEF\u5F84\u5206\u9694\u7B26\u3002");
|
|
351
|
+
}
|
|
352
|
+
return name2;
|
|
353
|
+
}
|
|
354
|
+
function cleanCreateName(value) {
|
|
355
|
+
const name2 = cleanName(value);
|
|
356
|
+
if (name2.includes("..")) {
|
|
357
|
+
throw new FeatureError2("library/invalid-name", '\u540D\u79F0\u4E0D\u80FD\u5305\u542B\u8DEF\u5F84\u5206\u9694\u7B26\u3001\u63A7\u5236\u5B57\u7B26\u6216 ".."\u3002');
|
|
358
|
+
}
|
|
359
|
+
return name2;
|
|
360
|
+
}
|
|
361
|
+
function requireContent(value) {
|
|
362
|
+
if (typeof value !== "string") {
|
|
363
|
+
throw new FeatureError2("library/invalid-request", "\u7F3A\u5C11\u5B57\u6BB5 content\u3002");
|
|
364
|
+
}
|
|
365
|
+
return value;
|
|
366
|
+
}
|
|
367
|
+
function emptyState() {
|
|
368
|
+
return { schemaVersion: 1, nodes: {}, assets: {}, revisions: {}, drafts: {} };
|
|
369
|
+
}
|
|
370
|
+
function draftsOf(state) {
|
|
371
|
+
const drafts = state?.drafts;
|
|
372
|
+
return drafts !== null && typeof drafts === "object" ? drafts : {};
|
|
373
|
+
}
|
|
374
|
+
function sweepDrafts(state) {
|
|
375
|
+
const drafts = draftsOf(state);
|
|
376
|
+
const keys = Object.keys(drafts);
|
|
377
|
+
if (keys.length === 0) return state;
|
|
378
|
+
const cutoff = Date.now() - DRAFT_TTL_MS;
|
|
379
|
+
const kept = {};
|
|
380
|
+
for (const key of keys) {
|
|
381
|
+
const stamp = Date.parse(drafts[key]?.updatedAt ?? "");
|
|
382
|
+
if (Number.isFinite(stamp) && stamp >= cutoff) kept[key] = drafts[key];
|
|
383
|
+
}
|
|
384
|
+
if (Object.keys(kept).length === keys.length) return state;
|
|
385
|
+
return { ...state, drafts: kept };
|
|
386
|
+
}
|
|
387
|
+
var LibraryManager = class {
|
|
388
|
+
/**
|
|
389
|
+
* @param {import('@deepseek-ai/cordis').Context} ctx - Host context.
|
|
390
|
+
* @param {{ libraryDomain: object }} domains - built spec.
|
|
391
|
+
* @param {{ root?: string, maxBytes?: number, maxTotalBytes?: number }} [options] - overrides, used by tests.
|
|
392
|
+
*/
|
|
393
|
+
constructor(ctx, domains, options = {}) {
|
|
394
|
+
this.ctx = ctx;
|
|
395
|
+
this.domains = domains;
|
|
396
|
+
const home = options.home ?? process.env.DSH_HOME ?? join2(homedir(), ".dsh");
|
|
397
|
+
this.root = resolve2(options.root ?? join2(home, "dsh-skills-library"));
|
|
398
|
+
this.maxBytes = options.maxBytes ?? MAX_BYTES;
|
|
399
|
+
this.maxTotalBytes = options.maxTotalBytes ?? MAX_TOTAL_BYTES;
|
|
400
|
+
this.table = void 0;
|
|
401
|
+
this.serial = Promise.resolve();
|
|
402
|
+
}
|
|
403
|
+
/** Open the domain. Creates nothing on disk. */
|
|
404
|
+
async init() {
|
|
405
|
+
const domain = await this.ctx.storageDomain.open(this.domains.libraryDomain);
|
|
406
|
+
this.ctx.effect(() => () => domain.close(), "dsh-plugin-library: domain");
|
|
407
|
+
this.table = domain.table("states");
|
|
408
|
+
}
|
|
409
|
+
// ---- read surface --------------------------------------------------------
|
|
410
|
+
/** Root, limits and live usage; the panel's header data. */
|
|
411
|
+
overview() {
|
|
412
|
+
const state = this.load();
|
|
413
|
+
const revisions = Object.values(state.revisions);
|
|
414
|
+
return {
|
|
415
|
+
root: this.root,
|
|
416
|
+
limits: { maxBytes: this.maxBytes, maxTotalBytes: this.maxTotalBytes },
|
|
417
|
+
usage: {
|
|
418
|
+
bytes: revisions.reduce((total, row) => total + (row.originalByteLength ?? 0), 0),
|
|
419
|
+
revisions: revisions.length,
|
|
420
|
+
assets: Object.values(state.assets).filter((row) => row.removedAt === void 0).length,
|
|
421
|
+
folders: Object.values(state.nodes).filter((row) => row.kind === "folder" && row.removedAt === void 0).length
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
}
|
|
425
|
+
/** Every live node, folders first within each parent, for the client to nest. */
|
|
426
|
+
tree() {
|
|
427
|
+
const state = this.load();
|
|
428
|
+
return Object.values(state.nodes).filter((node) => node.removedAt === void 0).map((node) => this.entry(state, node)).sort((a, b) => {
|
|
429
|
+
if (a.kind !== b.kind) return a.kind === "folder" ? -1 : 1;
|
|
430
|
+
return a.name.localeCompare(b.name, "zh-CN");
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
/** One asset with its full immutable revision history, newest first. */
|
|
434
|
+
assetDetail(assetId) {
|
|
435
|
+
const state = this.load();
|
|
436
|
+
const node = this.requireLiveNode(state, assetId);
|
|
437
|
+
this.requireAsset(state, assetId);
|
|
438
|
+
const view = this.entry(state, node);
|
|
439
|
+
const revisions = Object.values(state.revisions).filter((row) => row.assetId === assetId).sort((a, b) => b.number - a.number);
|
|
440
|
+
return { node: view, asset: view.asset, revisions, folderPath: this.folderPath(state, node.parentId) };
|
|
441
|
+
}
|
|
442
|
+
/**
|
|
443
|
+
* Search derived text.
|
|
444
|
+
*
|
|
445
|
+
* Non-`ready` revisions are listed by name (so an unconvertible file is visible)
|
|
446
|
+
* but are never text-matched, and a disabled or removed asset never appears.
|
|
447
|
+
* @param {string} query - keyword; empty returns everything active.
|
|
448
|
+
* @param {string} [kind] - optional asset kind filter.
|
|
449
|
+
* @returns {Promise<object[]>} up to {@link MAX_HITS} hits, best first.
|
|
450
|
+
*/
|
|
451
|
+
async search(query, kind) {
|
|
452
|
+
const needle = String(query ?? "").trim().toLocaleLowerCase();
|
|
453
|
+
const state = this.load();
|
|
454
|
+
const hits = [];
|
|
455
|
+
for (const asset of Object.values(state.assets)) {
|
|
456
|
+
if (asset.status !== "active" || asset.removedAt !== void 0) continue;
|
|
457
|
+
const node = state.nodes[asset.id];
|
|
458
|
+
if (node === void 0 || node.removedAt !== void 0) continue;
|
|
459
|
+
if (kind !== void 0 && asset.kind !== kind) continue;
|
|
460
|
+
const revision = state.revisions[`${asset.id}:${asset.currentRevisionId}`];
|
|
461
|
+
if (revision === void 0) continue;
|
|
462
|
+
const titleMatch = needle.length > 0 && node.name.toLocaleLowerCase().includes(needle);
|
|
463
|
+
let excerpt = "";
|
|
464
|
+
let score = 0;
|
|
465
|
+
if (revision.conversionStatus === "ready" && needle.length > 0) {
|
|
466
|
+
const text = await this.readContent(revision);
|
|
467
|
+
const offset = text.toLocaleLowerCase().indexOf(needle);
|
|
468
|
+
if (!titleMatch && offset < 0) continue;
|
|
469
|
+
const start = Math.max(0, offset < 0 ? 0 : offset - 80);
|
|
470
|
+
excerpt = text.slice(start, start + 240).replace(/\s+/g, " ").trim();
|
|
471
|
+
score = titleMatch ? 2 : 1;
|
|
472
|
+
} else if (revision.conversionStatus !== "ready" && needle.length > 0) {
|
|
473
|
+
if (!titleMatch) continue;
|
|
474
|
+
score = 1;
|
|
475
|
+
}
|
|
476
|
+
hits.push({
|
|
477
|
+
assetId: asset.id,
|
|
478
|
+
nodeId: node.id,
|
|
479
|
+
revisionId: revision.id,
|
|
480
|
+
revisionNumber: revision.number,
|
|
481
|
+
name: node.name,
|
|
482
|
+
kind: asset.kind,
|
|
483
|
+
folderPath: this.folderPath(state, node.parentId),
|
|
484
|
+
conversionStatus: revision.conversionStatus,
|
|
485
|
+
byteLength: asset.byteLength,
|
|
486
|
+
updatedAt: asset.updatedAt,
|
|
487
|
+
excerpt,
|
|
488
|
+
score
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
return hits.sort((a, b) => b.score - a.score || b.updatedAt.localeCompare(a.updatedAt) || a.name.localeCompare(b.name, "zh-CN")).slice(0, MAX_HITS);
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* Read the derived searchable text of one revision.
|
|
495
|
+
* @param {string} assetId - the asset.
|
|
496
|
+
* @param {string} [revisionId] - a pinned revision; defaults to the current one.
|
|
497
|
+
* @returns {Promise<string>} the derived text.
|
|
498
|
+
*/
|
|
499
|
+
async readText(assetId, revisionId) {
|
|
500
|
+
const { revision } = this.resolveReadable(assetId, revisionId);
|
|
501
|
+
return this.readContent(revision);
|
|
502
|
+
}
|
|
503
|
+
/**
|
|
504
|
+
* Read the original bytes of one revision.
|
|
505
|
+
*
|
|
506
|
+
* This is the one read that does not need a derived text view: an asset with
|
|
507
|
+
* `unavailable` or `failed` conversion can still hand back its exact bytes. A
|
|
508
|
+
* *disabled* asset is still refused, because disable is meant to stop history
|
|
509
|
+
* references from resolving at all.
|
|
510
|
+
* @param {string} assetId - the asset.
|
|
511
|
+
* @param {string} [revisionId] - a pinned revision; defaults to the current one.
|
|
512
|
+
* @returns {Promise<Uint8Array>} the exact stored bytes.
|
|
513
|
+
*/
|
|
514
|
+
async readOriginal(assetId, revisionId) {
|
|
515
|
+
const { revision } = this.resolveStored(assetId, revisionId);
|
|
516
|
+
return new Uint8Array(await readFile(this.safePath(revision.originalRelativePath)));
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* The on-disk facts about one revision's original: where it lives and whether
|
|
520
|
+
* the bytes there still hash to the stored revision id.
|
|
521
|
+
*
|
|
522
|
+
* The client uses this to offer 「用系统程序打开」 a file the OS can edit, and
|
|
523
|
+
* then to notice — by the digest changing — that the user has saved a new
|
|
524
|
+
* version outside, so 「导入新版本」 can be offered against real evidence
|
|
525
|
+
* rather than a guess.
|
|
526
|
+
* @param {string} assetId - the asset.
|
|
527
|
+
* @param {string} [revisionId] - a pinned revision; defaults to the current one.
|
|
528
|
+
* @returns {Promise<{ absolutePath: string, exists: boolean, matchesRevision: boolean }>} the disk facts.
|
|
529
|
+
*/
|
|
530
|
+
async originalPath(assetId, revisionId) {
|
|
531
|
+
const { revision } = this.resolveStored(assetId, revisionId);
|
|
532
|
+
const absolutePath2 = this.safePath(revision.originalRelativePath);
|
|
533
|
+
let bytes;
|
|
534
|
+
try {
|
|
535
|
+
bytes = await readFile(absolutePath2);
|
|
536
|
+
} catch {
|
|
537
|
+
return { absolutePath: absolutePath2, exists: false, matchesRevision: false };
|
|
538
|
+
}
|
|
539
|
+
return { absolutePath: absolutePath2, exists: true, matchesRevision: sha256Hex(bytes) === revision.id };
|
|
540
|
+
}
|
|
541
|
+
/**
|
|
542
|
+
* Hand one revision's original to the OS-associated application (WPS,
|
|
543
|
+
* PowerPoint, Preview …). The save afterwards writes the disk file directly;
|
|
544
|
+
* the library learns about the edit only when 「导入新版本」 is invoked.
|
|
545
|
+
* @param {string} assetId - the asset.
|
|
546
|
+
* @param {string} [revisionId] - a pinned revision; defaults to the current one.
|
|
547
|
+
* @returns {Promise<{ opened: true, absolutePath: string }>} the receipt with the opened path.
|
|
548
|
+
*/
|
|
549
|
+
async openInSystemApp(assetId, revisionId, open, appId = void 0) {
|
|
550
|
+
const { revision } = this.resolveStored(assetId, revisionId);
|
|
551
|
+
const absolutePath2 = this.safePath(revision.originalRelativePath);
|
|
552
|
+
await open(absolutePath2, appId);
|
|
553
|
+
return { opened: true, absolutePath: absolutePath2, ...appId !== void 0 ? { appId } : {} };
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Re-read the on-disk original and file it as a new revision when its bytes
|
|
557
|
+
* differ from the stored ones. Content addressing decides: identical bytes
|
|
558
|
+
* reuse the existing revision (`reused: true`), changed bytes append a new
|
|
559
|
+
* one, and the previous revisions stay readable.
|
|
560
|
+
* @param {string} assetId - the asset whose original was edited outside.
|
|
561
|
+
* @returns {Promise<object>} the entry from `storeBytes`, with `reused` set.
|
|
562
|
+
*/
|
|
563
|
+
async importExternalChanges(assetId) {
|
|
564
|
+
return this.enqueue(async () => {
|
|
565
|
+
const state = this.load();
|
|
566
|
+
const node = this.requireLiveNode(state, assetId);
|
|
567
|
+
if (node.kind !== "asset") {
|
|
568
|
+
throw new FeatureError2("library/not-an-asset", "\u8BE5\u8282\u70B9\u4E0D\u662F\u8D44\u6599\u3002");
|
|
569
|
+
}
|
|
570
|
+
const asset = state.assets[assetId];
|
|
571
|
+
const revision = state.revisions[`${assetId}:${asset.currentRevisionId}`];
|
|
572
|
+
const absolutePath2 = this.safePath(revision.originalRelativePath);
|
|
573
|
+
let bytes;
|
|
574
|
+
try {
|
|
575
|
+
bytes = await readFile(absolutePath2);
|
|
576
|
+
} catch {
|
|
577
|
+
throw new FeatureError2("library/not-found", `\u539F\u4EF6\u4E0D\u5728\u78C1\u76D8\u4E0A\uFF08${absolutePath2}\uFF09\uFF0C\u65E0\u6CD5\u5BFC\u5165\u5916\u90E8\u4FEE\u6539\u3002`);
|
|
578
|
+
}
|
|
579
|
+
this.assertFolder(state, node.parentId);
|
|
580
|
+
const name2 = node.name;
|
|
581
|
+
return this.storeBytes({ name: name2, parentId: node.parentId, bytes, source: asset.source ?? "import" });
|
|
582
|
+
});
|
|
583
|
+
}
|
|
584
|
+
// ---- write surface -------------------------------------------------------
|
|
585
|
+
/** Create one folder. */
|
|
586
|
+
createFolder(input) {
|
|
587
|
+
return this.enqueue(async () => {
|
|
588
|
+
const name2 = cleanName(input?.name);
|
|
589
|
+
const parentId = optionalId(input?.parentId);
|
|
590
|
+
const state = this.load();
|
|
591
|
+
this.assertFolder(state, parentId);
|
|
592
|
+
this.assertUniqueName(state, parentId, name2);
|
|
593
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
594
|
+
const node = {
|
|
595
|
+
id: randomUUID(),
|
|
596
|
+
kind: "folder",
|
|
597
|
+
name: name2,
|
|
598
|
+
...parentId !== void 0 ? { parentId } : {},
|
|
599
|
+
createdAt: now,
|
|
600
|
+
updatedAt: now
|
|
601
|
+
};
|
|
602
|
+
await this.put({ ...state, nodes: { ...state.nodes, [node.id]: node } });
|
|
603
|
+
return node;
|
|
604
|
+
});
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Import a file from an absolute path, reading it through `ctx.fs`.
|
|
608
|
+
*
|
|
609
|
+
* The asset is identified by `(parentId, name)`: importing a changed file under
|
|
610
|
+
* the same name appends a revision to that asset rather than creating a second
|
|
611
|
+
* node, which is what makes "re-import" mean "new revision".
|
|
612
|
+
* @param {{ path: string, parentId?: string, name?: string }} input - the import request.
|
|
613
|
+
* @returns {Promise<object>} the resulting tree entry.
|
|
614
|
+
*/
|
|
615
|
+
async importFromPath(input) {
|
|
616
|
+
const sourcePath = requireString(input?.path, "path");
|
|
617
|
+
const fs = this.fs();
|
|
618
|
+
let target;
|
|
619
|
+
try {
|
|
620
|
+
target = await fs.resolve(sourcePath);
|
|
621
|
+
} catch (error) {
|
|
622
|
+
throw this.fsFailure(error);
|
|
623
|
+
}
|
|
624
|
+
const info = await Promise.resolve(fs.stat(target)).catch((error) => {
|
|
625
|
+
throw this.fsFailure(error);
|
|
626
|
+
});
|
|
627
|
+
if (info === void 0) throw new FeatureError2("library/not-found", "\u627E\u4E0D\u5230\u8981\u5BFC\u5165\u7684\u6587\u4EF6\u3002");
|
|
628
|
+
if (info.type !== "file") throw new FeatureError2("library/not-a-file", "\u53EA\u80FD\u5BFC\u5165\u666E\u901A\u6587\u4EF6\uFF0C\u4E0D\u80FD\u5BFC\u5165\u76EE\u5F55\u3002");
|
|
629
|
+
const name2 = cleanName(input?.name ?? basename(target.displayPath ?? sourcePath));
|
|
630
|
+
if (typeof info.size === "number" && info.size > this.maxBytes) {
|
|
631
|
+
throw new FeatureError2("library/file-size", `\u6587\u4EF6\u8D85\u8FC7\u5355\u4E2A\u6587\u4EF6\u4E0A\u9650 ${this.maxBytes} \u5B57\u8282\u3002`);
|
|
632
|
+
}
|
|
633
|
+
let bytes;
|
|
634
|
+
try {
|
|
635
|
+
bytes = await fs.readBytes(target, void 0, this.maxBytes);
|
|
636
|
+
} catch (error) {
|
|
637
|
+
throw this.fsFailure(error);
|
|
638
|
+
}
|
|
639
|
+
return this.importBytes({ name: name2, parentId: optionalId(input?.parentId), bytes, source: "import" });
|
|
640
|
+
}
|
|
641
|
+
/**
|
|
642
|
+
* Store one buffer as an immutable revision of `(parentId, name)`.
|
|
643
|
+
*
|
|
644
|
+
* Identical bytes reuse the existing revision — same id, same number, no
|
|
645
|
+
* second directory — which is the content-addressing contract. A disabled asset
|
|
646
|
+
* accepts a re-import and becomes the current revision again, but stays disabled.
|
|
647
|
+
* @param {{ name: string, parentId?: string, bytes: Uint8Array, source?: string }} input - the payload.
|
|
648
|
+
* @returns {Promise<object>} the resulting tree entry, with `reused` set.
|
|
649
|
+
*/
|
|
650
|
+
importBytes(input) {
|
|
651
|
+
return this.enqueue(() => this.storeBytes(input));
|
|
652
|
+
}
|
|
653
|
+
/**
|
|
654
|
+
* Store one delivered file as `source: 'task'` — a 本地产物.
|
|
655
|
+
*
|
|
656
|
+
* The ingest path behind automatic deliverable attribution. It is **not** a
|
|
657
|
+
* second implementation: the bytes go through the same {@link storeBytes} the
|
|
658
|
+
* `import` endpoint uses, so hashing, the `objects/<assetId>/<revisionId>/`
|
|
659
|
+
* layout, `revision.json`, the derived `content.md` and every read behave
|
|
660
|
+
* identically.
|
|
661
|
+
*
|
|
662
|
+
* Two things make it a distinct entry point rather than a call to
|
|
663
|
+
* {@link importFromPath}:
|
|
664
|
+
*
|
|
665
|
+
* 1. `source` is `'task'` and the producing session is recorded as
|
|
666
|
+
* `sourceTaskId`, which is what the panel's 本地产物 filter matches on.
|
|
667
|
+
* `import` deliberately keeps `source: 'import'`.
|
|
668
|
+
* 2. A name collision does **not** mean "same asset, new revision". Two
|
|
669
|
+
* different deliverables called `report.md` are two different files;
|
|
670
|
+
* folding them into one asset would make one overwrite the other's body.
|
|
671
|
+
* So the name is advanced to `report (2).md`, `report (3).md` … up to
|
|
672
|
+
* {@link NAME_ATTEMPTS}, and a refusal is the honest outcome after that.
|
|
673
|
+
*
|
|
674
|
+
* Re-delivering *the same bytes* for the *same task* is idempotent: the bytes
|
|
675
|
+
* hash to the same revision id, so the existing `source: 'task'` asset is
|
|
676
|
+
* reused and `reused` comes back `true` instead of minting `report (2).md`.
|
|
677
|
+
*
|
|
678
|
+
* @param {{ path: string, name?: string, parentId?: string, sourceTaskId?: string }} input - the request.
|
|
679
|
+
* @returns {Promise<object>} the resulting tree entry, with `reused` set.
|
|
680
|
+
*/
|
|
681
|
+
async importTaskFile(input) {
|
|
682
|
+
const sourcePath = requireString(input?.path, "path");
|
|
683
|
+
const fs = this.fs();
|
|
684
|
+
let target;
|
|
685
|
+
try {
|
|
686
|
+
target = await fs.resolve(sourcePath);
|
|
687
|
+
} catch (error) {
|
|
688
|
+
throw this.fsFailure(error);
|
|
689
|
+
}
|
|
690
|
+
const info = await Promise.resolve(fs.stat(target)).catch((error) => {
|
|
691
|
+
throw this.fsFailure(error);
|
|
692
|
+
});
|
|
693
|
+
if (info === void 0) throw new FeatureError2("library/not-found", "\u627E\u4E0D\u5230\u8981\u5BFC\u5165\u7684\u6587\u4EF6\u3002");
|
|
694
|
+
if (info.type !== "file") throw new FeatureError2("library/not-a-file", "\u53EA\u80FD\u5BFC\u5165\u666E\u901A\u6587\u4EF6\uFF0C\u4E0D\u80FD\u5BFC\u5165\u76EE\u5F55\u3002");
|
|
695
|
+
if (typeof info.size === "number" && info.size > this.maxBytes) {
|
|
696
|
+
throw new FeatureError2("library/file-size", `\u6587\u4EF6\u8D85\u8FC7\u5355\u4E2A\u6587\u4EF6\u4E0A\u9650 ${this.maxBytes} \u5B57\u8282\u3002`);
|
|
697
|
+
}
|
|
698
|
+
let bytes;
|
|
699
|
+
try {
|
|
700
|
+
bytes = await fs.readBytes(target, void 0, this.maxBytes);
|
|
701
|
+
} catch (error) {
|
|
702
|
+
throw this.fsFailure(error);
|
|
703
|
+
}
|
|
704
|
+
const baseName = cleanName(input?.name ?? basename(target.displayPath ?? sourcePath));
|
|
705
|
+
const parentId = optionalId(input?.parentId);
|
|
706
|
+
const sourceTaskId = optionalId(input?.sourceTaskId);
|
|
707
|
+
return this.enqueue(async () => {
|
|
708
|
+
const state = this.load();
|
|
709
|
+
this.assertFolder(state, parentId);
|
|
710
|
+
const revisionId = sha256Hex(bytes);
|
|
711
|
+
if (sourceTaskId !== void 0) {
|
|
712
|
+
const reused = Object.values(state.assets).find((asset) => asset.removedAt === void 0 && asset.source === "task" && asset.sourceTaskId === sourceTaskId && asset.currentRevisionId === revisionId);
|
|
713
|
+
if (reused !== void 0 && state.nodes[reused.id]?.removedAt === void 0) {
|
|
714
|
+
return { ...this.entry(state, state.nodes[reused.id]), reused: true };
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
const name2 = this.freeName(state, parentId, baseName);
|
|
718
|
+
return this.storeBytes({ name: name2, parentId, bytes, source: "task", sourceTaskId });
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
/**
|
|
722
|
+
* A sibling-free name for one import, or a refusal after {@link NAME_ATTEMPTS}.
|
|
723
|
+
*
|
|
724
|
+
* Runs inside a serialized task, so the name it returns is still free when
|
|
725
|
+
* {@link storeBytes} writes it.
|
|
726
|
+
* @param {object} state - the current state record.
|
|
727
|
+
* @param {string|undefined} parentId - the destination folder.
|
|
728
|
+
* @param {string} baseName - the name to start from.
|
|
729
|
+
* @returns {string} `baseName`, or `name (2)` … up to the attempt ceiling.
|
|
730
|
+
*/
|
|
731
|
+
freeName(state, parentId, baseName) {
|
|
732
|
+
const taken = new Set(Object.values(state.nodes).filter((node) => node.removedAt === void 0 && node.parentId === parentId).map((node) => node.name.toLocaleLowerCase()));
|
|
733
|
+
if (!taken.has(baseName.toLocaleLowerCase())) return baseName;
|
|
734
|
+
const extension = extname2(baseName);
|
|
735
|
+
const stem = extension.length > 0 ? baseName.slice(0, -extension.length) : baseName;
|
|
736
|
+
for (let attempt = 2; attempt <= NAME_ATTEMPTS; attempt++) {
|
|
737
|
+
const candidate = `${stem} (${attempt})${extension}`;
|
|
738
|
+
if (!taken.has(candidate.toLocaleLowerCase())) return candidate;
|
|
739
|
+
}
|
|
740
|
+
throw new FeatureError2("library/name-conflict", `\u540C\u540D\u8D44\u6599\u5DF2\u6709 ${NAME_ATTEMPTS} \u4EFD\uFF0C\u65E0\u6CD5\u518D\u81EA\u52A8\u5165\u5E93\u3002`);
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* This is the panel's "new document" path and the only write that originates
|
|
744
|
+
* in the browser, but it is **not** a second ingest implementation: the
|
|
745
|
+
* content becomes the asset's first revision through the same
|
|
746
|
+
* {@link storeBytes} the `import` endpoint uses, so hashing, the
|
|
747
|
+
* `objects/<assetId>/<revisionId>/` layout, the derived `content.md`, the
|
|
748
|
+
* conversion status and every read (`tree` / `search` / `read-text` /
|
|
749
|
+
* `read-original`) behave identically.
|
|
750
|
+
*
|
|
751
|
+
* What deliberately differs from `import`: identity. An import addressed by
|
|
752
|
+
* `(parentId, name)` appends a revision to an existing asset, while a create
|
|
753
|
+
* refuses an existing name with the same `library/name-conflict` rule
|
|
754
|
+
* `create-folder` uses — "new document" must never silently replace the body
|
|
755
|
+
* of a document that already exists.
|
|
756
|
+
*
|
|
757
|
+
* The asset is recorded with `source: 'created'`, the panel's own label for
|
|
758
|
+
* it; the kind still comes from the name's extension exactly as an import's
|
|
759
|
+
* does (`kindForName`), so a `.md` / `.markdown` name is markdown and `.txt`
|
|
760
|
+
* is text.
|
|
761
|
+
*
|
|
762
|
+
* @param {{ name: string, content: string, parentId?: string }} input - the request.
|
|
763
|
+
* @returns {Promise<object>} the resulting tree entry, with `reused: false`.
|
|
764
|
+
*/
|
|
765
|
+
createText(input) {
|
|
766
|
+
return this.enqueue(async () => {
|
|
767
|
+
const name2 = cleanCreateName(input?.name);
|
|
768
|
+
const parentId = optionalId(input?.parentId);
|
|
769
|
+
const content = requireContent(input?.content);
|
|
770
|
+
const state = this.load();
|
|
771
|
+
this.assertFolder(state, parentId);
|
|
772
|
+
this.assertUniqueName(state, parentId, name2);
|
|
773
|
+
const bytes = new TextEncoder().encode(content);
|
|
774
|
+
if (bytes.byteLength > this.maxBytes) {
|
|
775
|
+
throw new FeatureError2("library/file-size", `\u5185\u5BB9\u8D85\u8FC7\u5355\u4E2A\u6587\u4EF6\u4E0A\u9650 ${this.maxBytes} \u5B57\u8282\u3002`);
|
|
776
|
+
}
|
|
777
|
+
return this.storeBytes({ name: name2, parentId, bytes, source: "created" });
|
|
778
|
+
});
|
|
779
|
+
}
|
|
780
|
+
/**
|
|
781
|
+
* The shared ingest body behind `import`, `importFromPath` and `createText`.
|
|
782
|
+
*
|
|
783
|
+
* Runs inside {@link enqueue}: callers are already serialized, and it must not
|
|
784
|
+
* be called from inside another queued task (that would deadlock the chain).
|
|
785
|
+
* @param {{ name: string, parentId?: string, bytes: Uint8Array, source?: string }} input - the payload.
|
|
786
|
+
* @returns {Promise<object>} the resulting tree entry, with `reused` set.
|
|
787
|
+
*/
|
|
788
|
+
async storeBytes(input) {
|
|
789
|
+
const name2 = cleanName(input?.name);
|
|
790
|
+
const parentId = optionalId(input?.parentId);
|
|
791
|
+
const bytes = input?.bytes;
|
|
792
|
+
if (!(bytes instanceof Uint8Array)) throw new FeatureError2("library/invalid-request", "\u7F3A\u5C11\u6587\u4EF6\u5185\u5BB9\u3002");
|
|
793
|
+
const state = this.load();
|
|
794
|
+
this.assertFolder(state, parentId);
|
|
795
|
+
const existing = Object.values(state.nodes).find((node2) => node2.kind === "asset" && node2.removedAt === void 0 && node2.parentId === parentId && node2.name.toLocaleLowerCase() === name2.toLocaleLowerCase());
|
|
796
|
+
const assetId = existing?.id ?? randomUUID();
|
|
797
|
+
const revisionId = sha256Hex(bytes);
|
|
798
|
+
const key = `${assetId}:${revisionId}`;
|
|
799
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
800
|
+
const found = state.revisions[key];
|
|
801
|
+
if (found !== void 0) {
|
|
802
|
+
const asset2 = state.assets[assetId];
|
|
803
|
+
const nextAsset = { ...asset2, currentRevisionId: revisionId, updatedAt: now };
|
|
804
|
+
const nextNode2 = { ...existing, updatedAt: now };
|
|
805
|
+
await this.put({
|
|
806
|
+
...state,
|
|
807
|
+
assets: { ...state.assets, [assetId]: nextAsset },
|
|
808
|
+
nodes: { ...state.nodes, [assetId]: nextNode2 }
|
|
809
|
+
});
|
|
810
|
+
return { ...this.entry({ ...state, assets: { ...state.assets, [assetId]: nextAsset } }, nextNode2), reused: true };
|
|
811
|
+
}
|
|
812
|
+
const used = Object.values(state.revisions).reduce((total, row) => total + (row.originalByteLength ?? 0), 0);
|
|
813
|
+
if (used + bytes.byteLength > this.maxTotalBytes) {
|
|
814
|
+
throw new FeatureError2("library/quota-exceeded", `\u8D44\u6599\u5E93\u539F\u4EF6\u603B\u91CF\u5C06\u8D85\u8FC7 ${this.maxTotalBytes} \u5B57\u8282\u4E0A\u9650\u3002`);
|
|
815
|
+
}
|
|
816
|
+
const kind = kindForName(name2);
|
|
817
|
+
const derived = deriveText(kind, bytes);
|
|
818
|
+
const extension = extname2(name2).toLowerCase() || ".bin";
|
|
819
|
+
const relativeBase = join2("objects", assetId, revisionId);
|
|
820
|
+
const originalRelativePath = join2(relativeBase, `original${extension}`);
|
|
821
|
+
const contentRelativePath = join2(relativeBase, "content.md");
|
|
822
|
+
const metadataRelativePath = join2(relativeBase, "revision.json");
|
|
823
|
+
const number = Object.values(state.revisions).filter((row) => row.assetId === assetId).length + 1;
|
|
824
|
+
const revision = {
|
|
825
|
+
id: revisionId,
|
|
826
|
+
assetId,
|
|
827
|
+
number,
|
|
828
|
+
kind,
|
|
829
|
+
originalSha256: revisionId,
|
|
830
|
+
contentSha256: sha256Hex(new TextEncoder().encode(derived.text)),
|
|
831
|
+
originalByteLength: bytes.byteLength,
|
|
832
|
+
originalRelativePath,
|
|
833
|
+
contentRelativePath,
|
|
834
|
+
metadataRelativePath,
|
|
835
|
+
conversionStatus: derived.status,
|
|
836
|
+
conversionWarnings: [...derived.warnings],
|
|
837
|
+
createdAt: now
|
|
838
|
+
};
|
|
839
|
+
await this.writeObject({
|
|
840
|
+
directory: this.safePath(relativeBase),
|
|
841
|
+
entries: [
|
|
842
|
+
{ relativePath: originalRelativePath, data: bytes },
|
|
843
|
+
{ relativePath: contentRelativePath, data: derived.text },
|
|
844
|
+
{
|
|
845
|
+
relativePath: metadataRelativePath,
|
|
846
|
+
data: `${JSON.stringify({
|
|
847
|
+
schemaVersion: 1,
|
|
848
|
+
assetId,
|
|
849
|
+
revisionId,
|
|
850
|
+
kind,
|
|
851
|
+
originalSha256: revisionId,
|
|
852
|
+
originalByteLength: bytes.byteLength,
|
|
853
|
+
conversionStatus: derived.status,
|
|
854
|
+
conversionWarnings: derived.warnings,
|
|
855
|
+
createdAt: now
|
|
856
|
+
}, null, 2)}
|
|
857
|
+
`
|
|
858
|
+
}
|
|
859
|
+
]
|
|
860
|
+
});
|
|
861
|
+
const node = existing ?? {
|
|
862
|
+
id: assetId,
|
|
863
|
+
kind: "asset",
|
|
864
|
+
name: name2,
|
|
865
|
+
...parentId !== void 0 ? { parentId } : {},
|
|
866
|
+
createdAt: now,
|
|
867
|
+
updatedAt: now
|
|
868
|
+
};
|
|
869
|
+
const asset = {
|
|
870
|
+
id: assetId,
|
|
871
|
+
kind,
|
|
872
|
+
mediaType: mediaTypeForKind(kind),
|
|
873
|
+
byteLength: bytes.byteLength,
|
|
874
|
+
status: existing !== void 0 ? state.assets[assetId]?.status ?? "active" : "active",
|
|
875
|
+
currentRevisionId: revisionId,
|
|
876
|
+
source: typeof input?.source === "string" ? input.source : "import",
|
|
877
|
+
...typeof input?.sourceTaskId === "string" && input.sourceTaskId.length > 0 ? { sourceTaskId: input.sourceTaskId } : {},
|
|
878
|
+
createdAt: existing !== void 0 ? state.assets[assetId]?.createdAt ?? now : now,
|
|
879
|
+
updatedAt: now
|
|
880
|
+
};
|
|
881
|
+
const nextNode = { ...node, updatedAt: now };
|
|
882
|
+
const nextState = {
|
|
883
|
+
...state,
|
|
884
|
+
nodes: { ...state.nodes, [assetId]: nextNode },
|
|
885
|
+
assets: { ...state.assets, [assetId]: asset },
|
|
886
|
+
revisions: { ...state.revisions, [key]: revision }
|
|
887
|
+
};
|
|
888
|
+
await this.put(nextState);
|
|
889
|
+
return { ...this.entry(nextState, nextNode), reused: false };
|
|
890
|
+
}
|
|
891
|
+
/** Rename one folder or asset node. */
|
|
892
|
+
rename(nodeId, name2) {
|
|
893
|
+
return this.enqueue(async () => {
|
|
894
|
+
const state = this.load();
|
|
895
|
+
const node = this.requireLiveNode(state, nodeId);
|
|
896
|
+
const next = cleanName(name2);
|
|
897
|
+
this.assertUniqueName(state, node.parentId, next, node.id);
|
|
898
|
+
const updated = { ...node, name: next, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
899
|
+
await this.put({ ...state, nodes: { ...state.nodes, [nodeId]: updated } });
|
|
900
|
+
return this.entry({ ...state, nodes: { ...state.nodes, [nodeId]: updated } }, updated);
|
|
901
|
+
});
|
|
902
|
+
}
|
|
903
|
+
/** Move one folder or asset node to another folder (or the root). */
|
|
904
|
+
move(nodeId, parentIdInput) {
|
|
905
|
+
return this.enqueue(async () => {
|
|
906
|
+
const parentId = optionalId(parentIdInput);
|
|
907
|
+
const state = this.load();
|
|
908
|
+
const node = this.requireLiveNode(state, nodeId);
|
|
909
|
+
this.assertFolder(state, parentId);
|
|
910
|
+
if (parentId === nodeId || parentId !== void 0 && this.descendants(state, nodeId).has(parentId)) {
|
|
911
|
+
throw new FeatureError2("library/cycle", "\u6587\u4EF6\u5939\u4E0D\u80FD\u79FB\u52A8\u5230\u5B83\u81EA\u5DF1\u7684\u5B50\u76EE\u5F55\u91CC\u3002");
|
|
912
|
+
}
|
|
913
|
+
this.assertUniqueName(state, parentId, node.name, node.id);
|
|
914
|
+
const moved = { ...node, parentId, updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
915
|
+
if (moved.parentId === void 0) delete moved.parentId;
|
|
916
|
+
await this.put({ ...state, nodes: { ...state.nodes, [nodeId]: moved } });
|
|
917
|
+
return this.entry({ ...state, nodes: { ...state.nodes, [nodeId]: moved } }, moved);
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Soft-remove a node and its subtree.
|
|
922
|
+
*
|
|
923
|
+
* Bytes and revision records are kept: "remove" hides the entry from the tree
|
|
924
|
+
* and from every read, it never deletes the immutable originals. This is what
|
|
925
|
+
* makes the operation safe to offer without a second confirmation step.
|
|
926
|
+
*/
|
|
927
|
+
remove(nodeId) {
|
|
928
|
+
return this.enqueue(async () => {
|
|
929
|
+
const state = this.load();
|
|
930
|
+
this.requireLiveNode(state, nodeId);
|
|
931
|
+
const ids = /* @__PURE__ */ new Set([nodeId, ...this.descendants(state, nodeId)]);
|
|
932
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
933
|
+
const nodes = { ...state.nodes };
|
|
934
|
+
const assets = { ...state.assets };
|
|
935
|
+
for (const id of ids) {
|
|
936
|
+
nodes[id] = { ...nodes[id], removedAt: now, updatedAt: now };
|
|
937
|
+
if (assets[id] !== void 0) assets[id] = { ...assets[id], status: "removed", removedAt: now, updatedAt: now };
|
|
938
|
+
}
|
|
939
|
+
await this.put({ ...state, nodes, assets });
|
|
940
|
+
return { removed: true, nodes: ids.size };
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
/** Enable or disable one asset. A disabled asset stops resolving immediately. */
|
|
944
|
+
setEnabled(assetId, enabled) {
|
|
945
|
+
return this.enqueue(async () => {
|
|
946
|
+
if (typeof enabled !== "boolean") throw new FeatureError2("library/invalid-request", "enabled \u5FC5\u987B\u662F\u5E03\u5C14\u503C\u3002");
|
|
947
|
+
const state = this.load();
|
|
948
|
+
const node = this.requireLiveNode(state, assetId);
|
|
949
|
+
if (node.kind !== "asset") throw new FeatureError2("library/not-an-asset", "\u53EA\u6709\u8D44\u6599\u53EF\u4EE5\u542F\u505C\u3002");
|
|
950
|
+
const asset = this.requireAsset(state, assetId);
|
|
951
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
952
|
+
const nextAsset = { ...asset, status: enabled ? "active" : "disabled", updatedAt: now };
|
|
953
|
+
const nextNode = { ...node, updatedAt: now };
|
|
954
|
+
const nextState = {
|
|
955
|
+
...state,
|
|
956
|
+
assets: { ...state.assets, [assetId]: nextAsset },
|
|
957
|
+
nodes: { ...state.nodes, [assetId]: nextNode }
|
|
958
|
+
};
|
|
959
|
+
await this.put(nextState);
|
|
960
|
+
return this.entry(nextState, nextNode);
|
|
961
|
+
});
|
|
962
|
+
}
|
|
963
|
+
// ---- draft → publish -----------------------------------------------------
|
|
964
|
+
/**
|
|
965
|
+
* Open an editable draft over one revision's derived text.
|
|
966
|
+
*
|
|
967
|
+
* The port of upstream's `createDraft`. A draft is **working scratch, not a
|
|
968
|
+
* revision**: it lives in the state record, never in `objects/`, so the
|
|
969
|
+
* immutable store is untouched until 发布新版本. Cancelling a draft is
|
|
970
|
+
* therefore a client-side `setDraft(undefined)` — there is nothing to undo,
|
|
971
|
+
* and an abandoned draft is swept by {@link DRAFT_TTL_MS}.
|
|
972
|
+
*
|
|
973
|
+
* A draft is identified by `(assetId, baseRevisionId)` and re-opening the same
|
|
974
|
+
* base returns the *same* draft with its edits intact, which is what makes
|
|
975
|
+
* 取消 → 编辑 behave. Opening against a different revision starts a fresh
|
|
976
|
+
* draft, because the text being edited is different text.
|
|
977
|
+
*
|
|
978
|
+
* @param {string} assetId - the asset to edit.
|
|
979
|
+
* @param {string} [revisionId] - the revision to base the draft on; defaults to the current one.
|
|
980
|
+
* @returns {Promise<{id: string, assetId: string, baseRevisionId: string, number: number, content: string, revision: string, createdAt: string, updatedAt: string}>} the draft.
|
|
981
|
+
*/
|
|
982
|
+
createDraft(assetId, revisionId) {
|
|
983
|
+
return this.enqueue(async () => {
|
|
984
|
+
const state = this.load();
|
|
985
|
+
const { node, asset, revision } = this.resolveEditable(state, assetId, revisionId);
|
|
986
|
+
const drafts = draftsOf(state);
|
|
987
|
+
const existing = Object.values(drafts).find((row) => row.assetId === asset.id && row.baseRevisionId === revision.id);
|
|
988
|
+
if (existing !== void 0) return { ...existing };
|
|
989
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
990
|
+
const draft = {
|
|
991
|
+
id: randomUUID(),
|
|
992
|
+
assetId: asset.id,
|
|
993
|
+
baseRevisionId: revision.id,
|
|
994
|
+
// The revision the panel was showing, so a publish can prove the view
|
|
995
|
+
// the user edited against is still the current one.
|
|
996
|
+
number: revision.number,
|
|
997
|
+
content: await this.readContent(revision),
|
|
998
|
+
revision: randomUUID(),
|
|
999
|
+
createdAt: now,
|
|
1000
|
+
updatedAt: now
|
|
1001
|
+
};
|
|
1002
|
+
void node;
|
|
1003
|
+
await this.put({ ...state, drafts: { ...drafts, [draft.id]: draft } });
|
|
1004
|
+
return { ...draft };
|
|
1005
|
+
});
|
|
1006
|
+
}
|
|
1007
|
+
/**
|
|
1008
|
+
* Replace a draft's content under an optimistic revision check.
|
|
1009
|
+
*
|
|
1010
|
+
* `expectedRevision` is the draft token the client last read. A mismatch means
|
|
1011
|
+
* another editor (or another tab) saved in between, and the write is refused
|
|
1012
|
+
* rather than applied, so neither editor's text is silently overwritten.
|
|
1013
|
+
* @param {string} draftId - the draft.
|
|
1014
|
+
* @param {string} content - the complete replacement text.
|
|
1015
|
+
* @param {string} expectedRevision - the draft token the client holds.
|
|
1016
|
+
* @returns {Promise<object>} the updated draft.
|
|
1017
|
+
*/
|
|
1018
|
+
updateDraft(draftId, content, expectedRevision) {
|
|
1019
|
+
return this.enqueue(async () => {
|
|
1020
|
+
const id = requireString(draftId, "draftId");
|
|
1021
|
+
const expected = requireString(expectedRevision, "expectedRevision");
|
|
1022
|
+
const text = requireContent(content);
|
|
1023
|
+
const state = this.load();
|
|
1024
|
+
const draft = this.requireDraft(state, id);
|
|
1025
|
+
if (draft.revision !== expected) throw this.staleDraft();
|
|
1026
|
+
const bytes = new TextEncoder().encode(text);
|
|
1027
|
+
if (bytes.byteLength > this.maxBytes) {
|
|
1028
|
+
throw new FeatureError2("library/file-size", `\u5185\u5BB9\u8D85\u8FC7\u5355\u4E2A\u6587\u4EF6\u4E0A\u9650 ${this.maxBytes} \u5B57\u8282\u3002`);
|
|
1029
|
+
}
|
|
1030
|
+
const next = { ...draft, content: text, revision: randomUUID(), updatedAt: (/* @__PURE__ */ new Date()).toISOString() };
|
|
1031
|
+
await this.put({ ...state, drafts: { ...draftsOf(state), [id]: next } });
|
|
1032
|
+
return { ...next };
|
|
1033
|
+
});
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Publish a draft as a **new immutable revision** of its asset.
|
|
1037
|
+
*
|
|
1038
|
+
* The commit step, and deliberately not a second ingest implementation: the
|
|
1039
|
+
* draft text becomes UTF-8 bytes and goes through {@link storeBytes} — the same
|
|
1040
|
+
* body `import` and `create-text` use — so hashing, the
|
|
1041
|
+
* `objects/<assetId>/<revisionId>/` layout, `revision.json`, the derived
|
|
1042
|
+
* `content.md` and every existing read behave identically. Nothing here
|
|
1043
|
+
* rewrites `objects/`: publishing *appends*, and the base revision stays
|
|
1044
|
+
* readable byte for byte.
|
|
1045
|
+
*
|
|
1046
|
+
* Two guards run before the write:
|
|
1047
|
+
*
|
|
1048
|
+
* 1. `expectedRevision` must match the draft token, or the *text* being
|
|
1049
|
+
* committed is not the text the user saw — refused, not applied.
|
|
1050
|
+
* 2. the asset's current revision must still be the draft's base. If someone
|
|
1051
|
+
* re-imported or published in between, the draft was written against a
|
|
1052
|
+
* body that is no longer current, and committing it would drop that change.
|
|
1053
|
+
*
|
|
1054
|
+
* A publish that stores bytes identical to the base revision reuses that
|
|
1055
|
+
* revision (content addressing) and the asset simply points at it again, so
|
|
1056
|
+
* 发布新版本 with no edits is harmless.
|
|
1057
|
+
*
|
|
1058
|
+
* @param {string} draftId - the draft.
|
|
1059
|
+
* @param {string} expectedRevision - the draft token the client holds.
|
|
1060
|
+
* @returns {Promise<object>} the resulting tree entry, `reused` included.
|
|
1061
|
+
*/
|
|
1062
|
+
publishDraft(draftId, expectedRevision) {
|
|
1063
|
+
return this.enqueue(async () => {
|
|
1064
|
+
const id = requireString(draftId, "draftId");
|
|
1065
|
+
const expected = requireString(expectedRevision, "expectedRevision");
|
|
1066
|
+
const state = this.load();
|
|
1067
|
+
const draft = this.requireDraft(state, id);
|
|
1068
|
+
if (draft.revision !== expected) throw this.staleDraft();
|
|
1069
|
+
const node = this.requireLiveNode(state, draft.assetId);
|
|
1070
|
+
if (node.kind !== "asset") throw new FeatureError2("library/not-an-asset", "\u8349\u7A3F\u5BF9\u5E94\u7684\u8282\u70B9\u4E0D\u662F\u8D44\u6599\u3002");
|
|
1071
|
+
const asset = this.requireAsset(state, draft.assetId);
|
|
1072
|
+
if (asset.status === "disabled") {
|
|
1073
|
+
throw new FeatureError2("library/disabled", "\u8D44\u6599\u5DF2\u505C\u7528\uFF0C\u65E0\u6CD5\u53D1\u5E03\u8349\u7A3F\uFF1B\u91CD\u65B0\u542F\u7528\u540E\u518D\u8BD5\u3002");
|
|
1074
|
+
}
|
|
1075
|
+
if (!EDITABLE_KINDS.has(asset.kind)) {
|
|
1076
|
+
throw new FeatureError2("library/not-editable", "\u53EA\u6709 Markdown \u548C\u7EAF\u6587\u672C\u8D44\u6599\u53EF\u4EE5\u7F16\u8F91\u5E76\u53D1\u5E03\u65B0\u7248\u672C\u3002");
|
|
1077
|
+
}
|
|
1078
|
+
if (asset.currentRevisionId !== draft.baseRevisionId) {
|
|
1079
|
+
throw new FeatureError2("library/draft-conflict", "\u8FD9\u4EFD\u8D44\u6599\u5DF2\u7ECF\u6709\u4E86\u66F4\u65B0\u7684\u4FEE\u8BA2\uFF0C\u8349\u7A3F\u57FA\u4E8E\u7684\u5185\u5BB9\u4E0D\u662F\u5F53\u524D\u7248\u672C\uFF1B\u8BF7\u91CD\u65B0\u6253\u5F00\u8349\u7A3F\u540E\u518D\u53D1\u5E03\u3002");
|
|
1080
|
+
}
|
|
1081
|
+
const bytes = new TextEncoder().encode(draft.content);
|
|
1082
|
+
if (bytes.byteLength > this.maxBytes) {
|
|
1083
|
+
throw new FeatureError2("library/file-size", `\u5185\u5BB9\u8D85\u8FC7\u5355\u4E2A\u6587\u4EF6\u4E0A\u9650 ${this.maxBytes} \u5B57\u8282\u3002`);
|
|
1084
|
+
}
|
|
1085
|
+
const entry = await this.storeBytes({
|
|
1086
|
+
name: node.name,
|
|
1087
|
+
parentId: node.parentId,
|
|
1088
|
+
bytes,
|
|
1089
|
+
source: asset.source
|
|
1090
|
+
});
|
|
1091
|
+
const drafts = { ...draftsOf(this.load()) };
|
|
1092
|
+
delete drafts[id];
|
|
1093
|
+
await this.put({ ...this.load(), drafts });
|
|
1094
|
+
return entry;
|
|
1095
|
+
});
|
|
1096
|
+
}
|
|
1097
|
+
/** The route dispatch table. */
|
|
1098
|
+
handlers() {
|
|
1099
|
+
return {
|
|
1100
|
+
overview: () => this.overview(),
|
|
1101
|
+
tree: () => this.tree(),
|
|
1102
|
+
asset: (payload) => this.assetDetail(requireString(payload.assetId, "assetId")),
|
|
1103
|
+
"create-folder": (payload) => this.createFolder({ name: payload.name, parentId: payload.parentId }),
|
|
1104
|
+
"create-text": (payload) => this.createText({ name: payload.name, content: payload.content, parentId: payload.parentId }),
|
|
1105
|
+
"import-task": (payload) => this.importTaskFile({
|
|
1106
|
+
path: requireString(payload.path, "path"),
|
|
1107
|
+
name: payload.name,
|
|
1108
|
+
parentId: payload.parentId,
|
|
1109
|
+
sourceTaskId: payload.sourceTaskId
|
|
1110
|
+
}),
|
|
1111
|
+
rename: (payload) => this.rename(requireString(payload.nodeId, "nodeId"), requireString(payload.name, "name")),
|
|
1112
|
+
move: (payload) => this.move(requireString(payload.nodeId, "nodeId"), payload.parentId),
|
|
1113
|
+
remove: (payload) => this.remove(requireString(payload.nodeId, "nodeId")),
|
|
1114
|
+
"set-enabled": (payload) => this.setEnabled(requireString(payload.assetId, "assetId"), payload.enabled),
|
|
1115
|
+
"create-draft": (payload) => this.createDraft(
|
|
1116
|
+
requireString(payload.assetId, "assetId"),
|
|
1117
|
+
optionalId(payload.revisionId)
|
|
1118
|
+
),
|
|
1119
|
+
"update-draft": (payload) => this.updateDraft(
|
|
1120
|
+
requireString(payload.draftId, "draftId"),
|
|
1121
|
+
requireContent(payload.content),
|
|
1122
|
+
requireString(payload.expectedRevision, "expectedRevision")
|
|
1123
|
+
),
|
|
1124
|
+
"publish-draft": (payload) => this.publishDraft(
|
|
1125
|
+
requireString(payload.draftId, "draftId"),
|
|
1126
|
+
requireString(payload.expectedRevision, "expectedRevision")
|
|
1127
|
+
),
|
|
1128
|
+
import: (payload) => this.importFromPath(payload),
|
|
1129
|
+
"original-path": (payload) => this.originalPath(requireString(payload.assetId, "assetId"), optionalId(payload.revisionId)),
|
|
1130
|
+
"open-in-system": async (payload) => this.openInSystemApp(
|
|
1131
|
+
requireString(payload.assetId, "assetId"),
|
|
1132
|
+
optionalId(payload.revisionId),
|
|
1133
|
+
(absolutePath2, appId) => openInSystemApp(absolutePath2, appId, this.ctx),
|
|
1134
|
+
typeof payload.appId === "string" && payload.appId.length > 0 ? payload.appId : void 0
|
|
1135
|
+
),
|
|
1136
|
+
"file-applications": async (payload) => {
|
|
1137
|
+
const { absolutePath: absolutePath2 } = await this.originalPath(requireString(payload.assetId, "assetId"), optionalId(payload.revisionId));
|
|
1138
|
+
return { applications: await fileApplications(absolutePath2) };
|
|
1139
|
+
},
|
|
1140
|
+
"import-external": (payload) => this.importExternalChanges(requireString(payload.assetId, "assetId")),
|
|
1141
|
+
search: (payload) => this.search(
|
|
1142
|
+
typeof payload.query === "string" ? payload.query : "",
|
|
1143
|
+
typeof payload.kind === "string" && payload.kind.length > 0 ? payload.kind : void 0
|
|
1144
|
+
),
|
|
1145
|
+
"read-text": (payload) => this.readText(requireString(payload.assetId, "assetId"), optionalId(payload.revisionId)),
|
|
1146
|
+
"read-original": async (payload) => {
|
|
1147
|
+
const assetId = requireString(payload.assetId, "assetId");
|
|
1148
|
+
const revisionId = optionalId(payload.revisionId);
|
|
1149
|
+
const node = this.requireLiveNode(this.load(), assetId);
|
|
1150
|
+
const bytes = await this.readOriginal(assetId, revisionId);
|
|
1151
|
+
const asset = this.requireAsset(this.load(), assetId);
|
|
1152
|
+
return {
|
|
1153
|
+
name: node.name,
|
|
1154
|
+
mediaType: asset.mediaType,
|
|
1155
|
+
byteLength: bytes.byteLength,
|
|
1156
|
+
base64: Buffer.from(bytes).toString("base64")
|
|
1157
|
+
};
|
|
1158
|
+
}
|
|
1159
|
+
};
|
|
1160
|
+
}
|
|
1161
|
+
// ---- internals -----------------------------------------------------------
|
|
1162
|
+
/** The current state record, or the empty state. */
|
|
1163
|
+
load() {
|
|
1164
|
+
if (this.table === void 0) throw new FeatureError2("library/not-ready", "\u8D44\u6599\u5E93\u5B58\u50A8\u5C1A\u672A\u5C31\u7EEA\u3002");
|
|
1165
|
+
return this.table.get(LOCAL_PRINCIPAL) ?? emptyState();
|
|
1166
|
+
}
|
|
1167
|
+
/** Persist one state record, dropping drafts that have gone stale. */
|
|
1168
|
+
async put(next) {
|
|
1169
|
+
if (this.table === void 0) throw new FeatureError2("library/not-ready", "\u8D44\u6599\u5E93\u5B58\u50A8\u5C1A\u672A\u5C31\u7EEA\u3002");
|
|
1170
|
+
await this.table.put(LOCAL_PRINCIPAL, sweepDrafts(next));
|
|
1171
|
+
}
|
|
1172
|
+
/** Serialize one mutation. */
|
|
1173
|
+
enqueue(run) {
|
|
1174
|
+
const queued = this.serial.then(run, run);
|
|
1175
|
+
this.serial = queued.then(() => void 0, () => void 0);
|
|
1176
|
+
return queued;
|
|
1177
|
+
}
|
|
1178
|
+
/** `ctx.fs` or a clear refusal; the real composition always provides it. */
|
|
1179
|
+
fs() {
|
|
1180
|
+
const fs = this.ctx.get?.("fs") ?? this.ctx.fs;
|
|
1181
|
+
if (fs === void 0 || typeof fs.resolve !== "function") {
|
|
1182
|
+
throw new FeatureError2("library/fs-unavailable", "\u6587\u4EF6\u7CFB\u7EDF\u670D\u52A1\u4E0D\u53EF\u7528\uFF0C\u65E0\u6CD5\u8BFB\u53D6\u5BFC\u5165\u6765\u6E90\u3002");
|
|
1183
|
+
}
|
|
1184
|
+
return fs;
|
|
1185
|
+
}
|
|
1186
|
+
/** Map an `fs` refusal onto the library's own vocabulary. */
|
|
1187
|
+
fsFailure(error) {
|
|
1188
|
+
if (error instanceof FeatureError2) return error;
|
|
1189
|
+
const code = typeof error?.code === "string" ? error.code : void 0;
|
|
1190
|
+
const mapped = code !== void 0 ? FS_CODES[code] : void 0;
|
|
1191
|
+
if (mapped !== void 0) return new FeatureError2(mapped, String(error?.message ?? "\u8BFB\u53D6\u5931\u8D25\u3002"));
|
|
1192
|
+
if (error?.name === "AbortError") return new FeatureError2("library/aborted", "\u5BFC\u5165\u5DF2\u53D6\u6D88\u3002");
|
|
1193
|
+
return new FeatureError2("library/read-failed", `\u8BFB\u53D6\u6765\u6E90\u6587\u4EF6\u5931\u8D25\uFF1A${String(error?.message ?? error)}`);
|
|
1194
|
+
}
|
|
1195
|
+
/** The panel/tool view of one node, with its asset and current revision attached. */
|
|
1196
|
+
entry(state, node) {
|
|
1197
|
+
const asset = node.kind === "asset" ? state.assets[node.id] : void 0;
|
|
1198
|
+
const revision = asset !== void 0 ? state.revisions[`${asset.id}:${asset.currentRevisionId}`] : void 0;
|
|
1199
|
+
const draft = asset !== void 0 ? Object.values(draftsOf(state)).find((row) => row.assetId === asset.id) : void 0;
|
|
1200
|
+
return {
|
|
1201
|
+
id: node.id,
|
|
1202
|
+
kind: node.kind,
|
|
1203
|
+
name: node.name,
|
|
1204
|
+
...node.parentId !== void 0 ? { parentId: node.parentId } : {},
|
|
1205
|
+
...node.removedAt !== void 0 ? { removedAt: node.removedAt } : {},
|
|
1206
|
+
createdAt: node.createdAt,
|
|
1207
|
+
updatedAt: node.updatedAt,
|
|
1208
|
+
...asset !== void 0 ? {
|
|
1209
|
+
asset: {
|
|
1210
|
+
id: asset.id,
|
|
1211
|
+
kind: asset.kind,
|
|
1212
|
+
mediaType: asset.mediaType,
|
|
1213
|
+
byteLength: asset.byteLength,
|
|
1214
|
+
status: asset.status,
|
|
1215
|
+
currentRevisionId: asset.currentRevisionId,
|
|
1216
|
+
source: asset.source,
|
|
1217
|
+
revisionCount: Object.values(state.revisions).filter((row) => row.assetId === asset.id).length,
|
|
1218
|
+
createdAt: asset.createdAt,
|
|
1219
|
+
updatedAt: asset.updatedAt
|
|
1220
|
+
},
|
|
1221
|
+
/**
|
|
1222
|
+
* Whether this asset may be edited at all. The rule lives here, next to
|
|
1223
|
+
* the refusals that enforce it, and the panel renders 编辑 from this
|
|
1224
|
+
* rather than re-deriving it from `kind` and `status` — one source of
|
|
1225
|
+
* truth for a condition two halves have to agree on.
|
|
1226
|
+
*/
|
|
1227
|
+
editable: asset.status === "active" && EDITABLE_KINDS.has(asset.kind) && revision?.conversionStatus === "ready",
|
|
1228
|
+
...draft !== void 0 ? { draft: { id: draft.id, baseRevisionId: draft.baseRevisionId, updatedAt: draft.updatedAt } } : {}
|
|
1229
|
+
} : {},
|
|
1230
|
+
...revision !== void 0 ? { revision } : {}
|
|
1231
|
+
};
|
|
1232
|
+
}
|
|
1233
|
+
/** Resolve `(assetId, revisionId)` to a revision an edit may start from. */
|
|
1234
|
+
resolveEditable(state, assetId, revisionId) {
|
|
1235
|
+
const node = this.requireLiveNode(state, assetId);
|
|
1236
|
+
if (node.kind !== "asset") throw new FeatureError2("library/not-an-asset", "\u53EA\u6709\u8D44\u6599\u53EF\u4EE5\u7F16\u8F91\u3002");
|
|
1237
|
+
const asset = this.requireReadableAsset(state, assetId);
|
|
1238
|
+
if (!EDITABLE_KINDS.has(asset.kind)) {
|
|
1239
|
+
throw new FeatureError2("library/not-editable", "\u53EA\u6709 Markdown \u548C\u7EAF\u6587\u672C\u8D44\u6599\u53EF\u4EE5\u7F16\u8F91\uFF1B\u5176\u4ED6\u683C\u5F0F\u8BF7\u4E0B\u8F7D\u540E\u5728\u672C\u673A\u4FEE\u6539\u518D\u91CD\u65B0\u5BFC\u5165\u3002");
|
|
1240
|
+
}
|
|
1241
|
+
const revision = state.revisions[`${assetId}:${revisionId ?? asset.currentRevisionId}`];
|
|
1242
|
+
if (revision === void 0) throw new FeatureError2("library/not-found", "\u627E\u4E0D\u5230\u8BE5\u4FEE\u8BA2\u3002");
|
|
1243
|
+
if (revision.conversionStatus !== "ready") {
|
|
1244
|
+
throw new FeatureError2("library/conversion-unavailable", "\u8BE5\u4FEE\u8BA2\u6CA1\u6709\u53EF\u7F16\u8F91\u7684\u6D3E\u751F\u6B63\u6587\u3002");
|
|
1245
|
+
}
|
|
1246
|
+
return { node, asset, revision };
|
|
1247
|
+
}
|
|
1248
|
+
/** One live draft, or a refusal that names the reason. */
|
|
1249
|
+
requireDraft(state, draftId) {
|
|
1250
|
+
const draft = draftsOf(state)[draftId];
|
|
1251
|
+
if (draft === void 0) throw new FeatureError2("library/draft-not-found", "\u8349\u7A3F\u4E0D\u5B58\u5728\u6216\u5DF2\u8FC7\u671F\u3002");
|
|
1252
|
+
return draft;
|
|
1253
|
+
}
|
|
1254
|
+
/**
|
|
1255
|
+
* The refusal a stale draft token earns.
|
|
1256
|
+
*
|
|
1257
|
+
* One code and one sentence for every caller, so 取消/发布/再次编辑 all say the
|
|
1258
|
+
* same thing and the panel can surface it verbatim.
|
|
1259
|
+
*/
|
|
1260
|
+
staleDraft() {
|
|
1261
|
+
return new FeatureError2("library/draft-conflict", "\u8349\u7A3F\u5DF2\u88AB\u66F4\u65B0\uFF0C\u8BF7\u5237\u65B0\u540E\u91CD\u8BD5\u3002");
|
|
1262
|
+
}
|
|
1263
|
+
/** Resolve `(assetId, revisionId)` to a readable revision, refusing anything inactive. */
|
|
1264
|
+
resolveReadable(assetId, revisionId) {
|
|
1265
|
+
const resolved = this.resolveStored(assetId, revisionId);
|
|
1266
|
+
const { revision } = resolved;
|
|
1267
|
+
if (revision.conversionStatus !== "ready") {
|
|
1268
|
+
throw new FeatureError2(
|
|
1269
|
+
"library/conversion-unavailable",
|
|
1270
|
+
revision.conversionStatus === "failed" ? "\u8BE5\u4FEE\u8BA2\u7684\u6587\u672C\u8F6C\u6362\u5931\u8D25\uFF0C\u65E0\u6CD5\u8BFB\u53D6\u6B63\u6587\uFF1B\u539F\u4EF6\u4ECD\u53EF\u4E0B\u8F7D\u3002" : "\u8BE5\u683C\u5F0F\u6CA1\u6709\u6587\u672C\u8F6C\u6362\uFF0C\u53EA\u80FD\u8BFB\u53D6\u539F\u4EF6\u3002"
|
|
1271
|
+
);
|
|
1272
|
+
}
|
|
1273
|
+
return resolved;
|
|
1274
|
+
}
|
|
1275
|
+
/** Resolve `(assetId, revisionId)` to an enabled revision, without a conversion requirement. */
|
|
1276
|
+
resolveStored(assetId, revisionId) {
|
|
1277
|
+
const state = this.load();
|
|
1278
|
+
const node = this.requireLiveNode(state, assetId);
|
|
1279
|
+
if (node.kind !== "asset") throw new FeatureError2("library/not-an-asset", "\u8BE5\u8282\u70B9\u4E0D\u662F\u8D44\u6599\u3002");
|
|
1280
|
+
const asset = this.requireReadableAsset(state, assetId);
|
|
1281
|
+
const revision = state.revisions[`${assetId}:${revisionId ?? asset.currentRevisionId}`];
|
|
1282
|
+
if (revision === void 0) throw new FeatureError2("library/not-found", "\u627E\u4E0D\u5230\u8BE5\u4FEE\u8BA2\u3002");
|
|
1283
|
+
return { state, node, asset, revision };
|
|
1284
|
+
}
|
|
1285
|
+
/** Read a derived-text file, guarded by `safePath`. */
|
|
1286
|
+
readContent(revision) {
|
|
1287
|
+
return readFile(this.safePath(revision.contentRelativePath), "utf8");
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Write one revision directory atomically.
|
|
1291
|
+
*
|
|
1292
|
+
* Staged under `.tmp/<uuid>` and renamed into place, so a crash can never leave
|
|
1293
|
+
* a half-written revision that a later read would treat as authoritative.
|
|
1294
|
+
*/
|
|
1295
|
+
async writeObject({ directory, entries }) {
|
|
1296
|
+
const staging = this.safePath(join2(".tmp", randomUUID()));
|
|
1297
|
+
await mkdir(staging, { recursive: true, mode: 448 });
|
|
1298
|
+
try {
|
|
1299
|
+
for (const entry of entries) {
|
|
1300
|
+
const target = this.safePath(entry.relativePath);
|
|
1301
|
+
await writeFile(join2(staging, basename(target)), entry.data, { flag: "wx", mode: 384 });
|
|
1302
|
+
}
|
|
1303
|
+
await mkdir(dirname(directory), { recursive: true, mode: 448 });
|
|
1304
|
+
await rename(staging, directory);
|
|
1305
|
+
} catch (error) {
|
|
1306
|
+
await rm(staging, { recursive: true, force: true });
|
|
1307
|
+
throw error;
|
|
1308
|
+
}
|
|
1309
|
+
}
|
|
1310
|
+
/**
|
|
1311
|
+
* Resolve a library-relative path, refusing anything outside the root.
|
|
1312
|
+
*
|
|
1313
|
+
* Every disk access in this module goes through here. A corrupted or hostile
|
|
1314
|
+
* state record therefore cannot make the library read or write outside its own
|
|
1315
|
+
* directory, which is the whole reason the check exists.
|
|
1316
|
+
* @param {string} relative - a path relative to the library root.
|
|
1317
|
+
* @returns {string} the absolute path.
|
|
1318
|
+
* @throws {FeatureError} `library/path-escape` when the target is outside the root.
|
|
1319
|
+
*/
|
|
1320
|
+
safePath(relative) {
|
|
1321
|
+
const target = resolve2(this.root, String(relative ?? ""));
|
|
1322
|
+
if (target !== this.root && !target.startsWith(`${this.root}${sep}`)) {
|
|
1323
|
+
throw new FeatureError2("library/path-escape", "\u5BF9\u8C61\u8DEF\u5F84\u8D85\u51FA\u8D44\u6599\u5E93\u6839\u76EE\u5F55\uFF0C\u5DF2\u62D2\u7EDD\u3002");
|
|
1324
|
+
}
|
|
1325
|
+
return target;
|
|
1326
|
+
}
|
|
1327
|
+
/** A node that is neither removed nor missing. */
|
|
1328
|
+
requireLiveNode(state, id) {
|
|
1329
|
+
const node = state.nodes[id];
|
|
1330
|
+
if (node === void 0 || node.removedAt !== void 0) {
|
|
1331
|
+
throw new FeatureError2("library/not-found", "\u8D44\u6599\u4E0D\u5B58\u5728\u6216\u5DF2\u79FB\u9664\u3002");
|
|
1332
|
+
}
|
|
1333
|
+
return node;
|
|
1334
|
+
}
|
|
1335
|
+
/** An asset record that has not been removed (disabled is allowed; reads check separately). */
|
|
1336
|
+
requireAsset(state, assetId) {
|
|
1337
|
+
const asset = state.assets[assetId];
|
|
1338
|
+
if (asset === void 0) throw new FeatureError2("library/not-found", "\u8D44\u6599\u4E0D\u5B58\u5728\u3002");
|
|
1339
|
+
if (asset.status === "removed") throw new FeatureError2("library/not-found", "\u8D44\u6599\u5DF2\u79FB\u9664\u3002");
|
|
1340
|
+
return asset;
|
|
1341
|
+
}
|
|
1342
|
+
/** An asset record a read is allowed to resolve: present and enabled. */
|
|
1343
|
+
requireReadableAsset(state, assetId) {
|
|
1344
|
+
const asset = this.requireAsset(state, assetId);
|
|
1345
|
+
if (asset.status === "disabled") {
|
|
1346
|
+
throw new FeatureError2("library/disabled", "\u8D44\u6599\u5DF2\u505C\u7528\uFF0C\u8BFB\u53D6\u4E0E\u68C0\u7D22\u88AB\u963B\u65AD\uFF1B\u91CD\u65B0\u542F\u7528\u540E\u53EF\u7EE7\u7EED\u4F7F\u7528\u3002");
|
|
1347
|
+
}
|
|
1348
|
+
return asset;
|
|
1349
|
+
}
|
|
1350
|
+
/** A parent id must be a live folder when present. */
|
|
1351
|
+
assertFolder(state, parentId) {
|
|
1352
|
+
if (parentId === void 0) return;
|
|
1353
|
+
const node = state.nodes[parentId];
|
|
1354
|
+
if (node === void 0 || node.removedAt !== void 0) throw new FeatureError2("library/not-found", "\u76EE\u6807\u6587\u4EF6\u5939\u4E0D\u5B58\u5728\u3002");
|
|
1355
|
+
if (node.kind !== "folder") throw new FeatureError2("library/not-folder", "\u76EE\u6807\u4E0D\u662F\u6587\u4EF6\u5939\u3002");
|
|
1356
|
+
}
|
|
1357
|
+
/** Two live siblings may not share a name (case-insensitively). */
|
|
1358
|
+
assertUniqueName(state, parentId, name2, exceptId) {
|
|
1359
|
+
const conflict = Object.values(state.nodes).some((node) => node.removedAt === void 0 && node.id !== exceptId && node.parentId === parentId && node.name.toLocaleLowerCase() === name2.toLocaleLowerCase());
|
|
1360
|
+
if (conflict) throw new FeatureError2("library/name-conflict", "\u540C\u4E00\u76EE\u5F55\u4E0B\u5DF2\u6709\u540C\u540D\u9879\u76EE\u3002");
|
|
1361
|
+
}
|
|
1362
|
+
/** Every descendant id of one node. */
|
|
1363
|
+
descendants(state, id) {
|
|
1364
|
+
const result = /* @__PURE__ */ new Set();
|
|
1365
|
+
const queue = [id];
|
|
1366
|
+
while (queue.length > 0) {
|
|
1367
|
+
const parent = queue.shift();
|
|
1368
|
+
for (const node of Object.values(state.nodes)) {
|
|
1369
|
+
if (node.parentId === parent && !result.has(node.id)) {
|
|
1370
|
+
result.add(node.id);
|
|
1371
|
+
queue.push(node.id);
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
return result;
|
|
1376
|
+
}
|
|
1377
|
+
/** Human breadcrumb for a folder id. */
|
|
1378
|
+
folderPath(state, parentId) {
|
|
1379
|
+
const parts = [];
|
|
1380
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1381
|
+
let current = parentId;
|
|
1382
|
+
while (current !== void 0 && !seen.has(current)) {
|
|
1383
|
+
seen.add(current);
|
|
1384
|
+
const node = state.nodes[current];
|
|
1385
|
+
if (node === void 0) break;
|
|
1386
|
+
parts.unshift(node.name);
|
|
1387
|
+
current = node.parentId;
|
|
1388
|
+
}
|
|
1389
|
+
return parts.length > 0 ? `\u8D44\u6599\u5E93 / ${parts.join(" / ")}` : "\u8D44\u6599\u5E93";
|
|
1390
|
+
}
|
|
1391
|
+
};
|
|
1392
|
+
|
|
1393
|
+
// src/tools.js
|
|
1394
|
+
function compileValue(node) {
|
|
1395
|
+
if (node === null || typeof node !== "object") throw new Error("dsh-plugin-library: schema node must be an object");
|
|
1396
|
+
if (node.type === "object") {
|
|
1397
|
+
const properties = {};
|
|
1398
|
+
const required = [];
|
|
1399
|
+
for (const [key, value] of Object.entries(node.properties ?? {})) {
|
|
1400
|
+
if (value?.required === true) required.push(key);
|
|
1401
|
+
properties[key] = compileValue(value);
|
|
1402
|
+
}
|
|
1403
|
+
return {
|
|
1404
|
+
type: "object",
|
|
1405
|
+
additionalProperties: node.additionalProperties === true,
|
|
1406
|
+
properties,
|
|
1407
|
+
...required.length > 0 ? { required } : {},
|
|
1408
|
+
...node.description !== void 0 ? { description: node.description } : {}
|
|
1409
|
+
};
|
|
1410
|
+
}
|
|
1411
|
+
if (node.type === "array") {
|
|
1412
|
+
return {
|
|
1413
|
+
type: "array",
|
|
1414
|
+
...node.items !== void 0 ? { items: compileValue(node.items) } : {},
|
|
1415
|
+
...node.description !== void 0 ? { description: node.description } : {}
|
|
1416
|
+
};
|
|
1417
|
+
}
|
|
1418
|
+
return {
|
|
1419
|
+
type: node.type,
|
|
1420
|
+
...node.description !== void 0 ? { description: node.description } : {},
|
|
1421
|
+
...node.enum !== void 0 ? { enum: [...node.enum] } : {}
|
|
1422
|
+
};
|
|
1423
|
+
}
|
|
1424
|
+
function compileParameters(spec) {
|
|
1425
|
+
const properties = {};
|
|
1426
|
+
const required = [];
|
|
1427
|
+
for (const [key, value] of Object.entries(spec ?? {})) {
|
|
1428
|
+
if (value?.required === true) required.push(key);
|
|
1429
|
+
properties[key] = compileValue(value);
|
|
1430
|
+
}
|
|
1431
|
+
return { type: "object", properties, ...required.length > 0 ? { required } : {} };
|
|
1432
|
+
}
|
|
1433
|
+
function fallbackDefineTool(options) {
|
|
1434
|
+
return {
|
|
1435
|
+
name: options.name,
|
|
1436
|
+
description: options.description,
|
|
1437
|
+
parameters: compileParameters(options.parameters),
|
|
1438
|
+
output: { schema: compileValue(options.output.schema), render: options.output.render },
|
|
1439
|
+
execute: options.execute
|
|
1440
|
+
};
|
|
1441
|
+
}
|
|
1442
|
+
var cached3;
|
|
1443
|
+
async function loadDefineTool() {
|
|
1444
|
+
if (cached3 !== void 0) return cached3;
|
|
1445
|
+
try {
|
|
1446
|
+
const mod = await importDshPackage("@deepseek-ai/dsh-tools");
|
|
1447
|
+
if (typeof mod?.defineTool === "function") {
|
|
1448
|
+
cached3 = mod.defineTool;
|
|
1449
|
+
return cached3;
|
|
1450
|
+
}
|
|
1451
|
+
} catch {
|
|
1452
|
+
}
|
|
1453
|
+
cached3 = fallbackDefineTool;
|
|
1454
|
+
return cached3;
|
|
1455
|
+
}
|
|
1456
|
+
function hitForModel(hit) {
|
|
1457
|
+
return {
|
|
1458
|
+
asset_id: hit.assetId,
|
|
1459
|
+
revision_id: hit.revisionId,
|
|
1460
|
+
revision_number: hit.revisionNumber,
|
|
1461
|
+
name: hit.name,
|
|
1462
|
+
kind: hit.kind,
|
|
1463
|
+
folder_path: hit.folderPath,
|
|
1464
|
+
conversion_status: hit.conversionStatus,
|
|
1465
|
+
byte_length: hit.byteLength,
|
|
1466
|
+
updated_at: hit.updatedAt,
|
|
1467
|
+
excerpt: hit.excerpt
|
|
1468
|
+
};
|
|
1469
|
+
}
|
|
1470
|
+
async function registerLibraryTools(ctx, manager) {
|
|
1471
|
+
const defineTool = await loadDefineTool();
|
|
1472
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
1473
|
+
name: "library_search",
|
|
1474
|
+
description: "\u641C\u7D22\u672C\u5730\u8D44\u6599\u5E93\u7684\u6D3E\u751F\u6B63\u6587\uFF0C\u8FD4\u56DE\u4E0D\u53EF\u53D8\u7684 asset_id + revision_id \u5F15\u7528\u3002\u8D44\u6599\u4E0D\u5728\u5DE5\u4F5C\u533A\u6587\u4EF6\u7CFB\u7EDF\u91CC\uFF0C\u4E0D\u9700\u8981\u4E5F\u4E0D\u5E94\u8BE5\u5148\u7528\u6587\u4EF6\u5DE5\u5177\u53BB\u627E\u3002",
|
|
1475
|
+
parameters: {
|
|
1476
|
+
query: { type: "string", required: true, description: "\u6807\u9898\u6216\u6B63\u6587\u5173\u952E\u8BCD\u3002" },
|
|
1477
|
+
kind: {
|
|
1478
|
+
type: "string",
|
|
1479
|
+
description: "\u53EA\u770B\u67D0\u4E00\u7C7B\u8D44\u6599\u3002",
|
|
1480
|
+
enum: ["markdown", "text", "html", "json", "csv", "log", "binary"]
|
|
1481
|
+
}
|
|
1482
|
+
},
|
|
1483
|
+
output: {
|
|
1484
|
+
schema: {
|
|
1485
|
+
type: "object",
|
|
1486
|
+
additionalProperties: false,
|
|
1487
|
+
properties: {
|
|
1488
|
+
hits: {
|
|
1489
|
+
type: "array",
|
|
1490
|
+
required: true,
|
|
1491
|
+
items: {
|
|
1492
|
+
type: "object",
|
|
1493
|
+
additionalProperties: false,
|
|
1494
|
+
properties: {
|
|
1495
|
+
asset_id: { type: "string", required: true },
|
|
1496
|
+
revision_id: { type: "string", required: true },
|
|
1497
|
+
revision_number: { type: "integer", required: true },
|
|
1498
|
+
name: { type: "string", required: true },
|
|
1499
|
+
kind: { type: "string", required: true },
|
|
1500
|
+
folder_path: { type: "string", required: true },
|
|
1501
|
+
conversion_status: { type: "string", required: true },
|
|
1502
|
+
byte_length: { type: "integer", required: true },
|
|
1503
|
+
updated_at: { type: "string", required: true },
|
|
1504
|
+
excerpt: { type: "string", required: true }
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
},
|
|
1510
|
+
render: (_args, value) => [{
|
|
1511
|
+
type: "text",
|
|
1512
|
+
text: value.hits.length > 0 ? value.hits.map((hit) => `${hit.name} (${hit.folder_path} \xB7 ${hit.kind} \xB7 \u4FEE\u8BA2 ${hit.revision_number} \xB7 ${hit.conversion_status})
|
|
1513
|
+
${hit.excerpt}`).join("\n\n") : "\u8D44\u6599\u5E93\u91CC\u6CA1\u6709\u5339\u914D\u7684\u8D44\u6599\u3002"
|
|
1514
|
+
}]
|
|
1515
|
+
},
|
|
1516
|
+
async execute(args) {
|
|
1517
|
+
const hits = await manager.search(args.query, args.kind);
|
|
1518
|
+
return { hits: hits.map(hitForModel) };
|
|
1519
|
+
}
|
|
1520
|
+
})), "dsh-plugin-library: tool library_search");
|
|
1521
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
1522
|
+
name: "library_read",
|
|
1523
|
+
description: "\u6309 asset_id \u8BFB\u53D6\u672C\u5730\u8D44\u6599\u5E93\u67D0\u4E2A\u4FEE\u8BA2\u7684\u6D3E\u751F\u6B63\u6587\uFF1B\u53EF\u6307\u5B9A revision_id \u8BFB\u53D6\u5386\u53F2\u4FEE\u8BA2\u3002\u53EA\u8BFB\uFF0C\u4E0D\u4FEE\u6539\u4EFB\u4F55\u5185\u5BB9\u3002",
|
|
1524
|
+
parameters: {
|
|
1525
|
+
asset_id: { type: "string", required: true, description: "library_search \u8FD4\u56DE\u7684 asset_id\u3002" },
|
|
1526
|
+
revision_id: { type: "string", description: "\u5386\u53F2\u4FEE\u8BA2 id\uFF1B\u7701\u7565\u5219\u8BFB\u5F53\u524D\u4FEE\u8BA2\u3002" },
|
|
1527
|
+
offset: { type: "integer", description: "\u4ECE 0 \u5F00\u59CB\u7684\u5B57\u7B26\u504F\u79FB\u3002" },
|
|
1528
|
+
limit: { type: "integer", description: "\u672C\u6B21\u6700\u591A\u8FD4\u56DE\u7684\u5B57\u7B26\u6570\uFF0C1\u201320000\u3002" }
|
|
1529
|
+
},
|
|
1530
|
+
output: {
|
|
1531
|
+
schema: {
|
|
1532
|
+
type: "object",
|
|
1533
|
+
additionalProperties: false,
|
|
1534
|
+
properties: {
|
|
1535
|
+
asset_id: { type: "string", required: true },
|
|
1536
|
+
revision_id: { type: "string", required: true },
|
|
1537
|
+
conversion_status: { type: "string", required: true },
|
|
1538
|
+
content: { type: "string", required: true },
|
|
1539
|
+
offset: { type: "integer", required: true },
|
|
1540
|
+
truncated: { type: "boolean", required: true },
|
|
1541
|
+
next_offset: { type: "integer" }
|
|
1542
|
+
}
|
|
1543
|
+
},
|
|
1544
|
+
render: (_args, value) => [{ type: "text", text: value.content }]
|
|
1545
|
+
},
|
|
1546
|
+
async execute(args, exec) {
|
|
1547
|
+
const offset = args.offset ?? 0;
|
|
1548
|
+
const limit = args.limit ?? 12e3;
|
|
1549
|
+
if (!Number.isInteger(offset) || offset < 0 || !Number.isInteger(limit) || limit < 1 || limit > 2e4) {
|
|
1550
|
+
throw new Error("library/invalid-page");
|
|
1551
|
+
}
|
|
1552
|
+
const detail = manager.assetDetail(args.asset_id);
|
|
1553
|
+
const revisionId = args.revision_id ?? detail.asset.currentRevisionId;
|
|
1554
|
+
const revision = detail.revisions.find((row) => row.id === revisionId);
|
|
1555
|
+
const complete = await manager.readText(args.asset_id, revisionId);
|
|
1556
|
+
const content = complete.slice(offset, offset + limit);
|
|
1557
|
+
const next = offset + content.length;
|
|
1558
|
+
const truncated = next < complete.length;
|
|
1559
|
+
return {
|
|
1560
|
+
asset_id: args.asset_id,
|
|
1561
|
+
revision_id: revisionId,
|
|
1562
|
+
conversion_status: revision?.conversionStatus ?? "ready",
|
|
1563
|
+
content,
|
|
1564
|
+
offset,
|
|
1565
|
+
truncated,
|
|
1566
|
+
...truncated ? { next_offset: next } : {}
|
|
1567
|
+
};
|
|
1568
|
+
}
|
|
1569
|
+
})), "dsh-plugin-library: tool library_read");
|
|
1570
|
+
ctx.effect(() => ctx.tools.register(defineTool({
|
|
1571
|
+
name: "library_import",
|
|
1572
|
+
description: "\u628A\u5185\u5BB9\u6216\u6587\u4EF6\u5BFC\u5165\u672C\u5730\u8D44\u6599\u5E93\uFF0C\u6210\u4E3A\u4E00\u4E2A\u65B0\u7684\u8D44\u6599\uFF08Markdown / TXT \u53EF\u7F16\u8F91\uFF1BCSV \u7B49\u6309\u6269\u5C55\u540D\u5165\u5E93\uFF09\u3002\u4E8C\u9009\u4E00\uFF1A\u7ED9 content\uFF08\u6587\u672C\u5185\u5BB9\uFF0CMarkdown \u6587\u672C\u63A8\u8350\uFF09\u6216\u7ED9 path\uFF08\u672C\u673A\u5DF2\u6709\u6587\u4EF6\u7684\u7EDD\u5BF9\u8DEF\u5F84\uFF09\u3002\u540C\u540D\u8D44\u6599\u5DF2\u5B58\u5728\u4F1A\u88AB\u62D2\u7EDD\uFF08library/name-conflict\uFF09\uFF0C\u4E0D\u4F1A\u9759\u9ED8\u8986\u76D6\u3002\u5BFC\u5165\u540E\u53EF\u7528 library_search / library_read \u627E\u5230\u5B83\u3002",
|
|
1573
|
+
parameters: {
|
|
1574
|
+
name: { type: "string", required: true, description: "\u8D44\u6599\u540D\u79F0\uFF08\u542B\u6269\u5C55\u540D\uFF0C\u5982 \u9700\u6C42.md \u6216 \u6570\u636E.csv\uFF09\u3002.md / .markdown / .txt \u6309\u6269\u5C55\u540D\u5B9A\u7C7B\u578B\u4E14\u53EF\u7F16\u8F91\u3002" },
|
|
1575
|
+
content: { type: "string", description: "\u6587\u672C\u5185\u5BB9\uFF08\u4E0E path \u4E8C\u9009\u4E00\uFF09\u3002Markdown / \u7EAF\u6587\u672C\u7528\u8FD9\u4E2A\uFF1A\u5185\u5BB9\u4F1A\u6210\u4E3A\u7B2C\u4E00\u4E2A\u4FEE\u8BA2\uFF0C\u4E4B\u540E\u53EF\u5728\u9762\u677F\u91CC\u7F16\u8F91\u5E76\u53D1\u5E03\u65B0\u7248\u672C\u3002" },
|
|
1576
|
+
path: { type: "string", description: "\u672C\u673A\u5DF2\u6709\u6587\u4EF6\u7684\u7EDD\u5BF9\u8DEF\u5F84\uFF08\u4E0E content \u4E8C\u9009\u4E00\uFF09\u3002\u7531 Host \u8BFB\u6587\u4EF6\u5B57\u8282\u5165\u5E93\uFF1B\u9002\u5408 CSV / PDF \u7B49\u5DF2\u6709\u6587\u4EF6\u3002" },
|
|
1577
|
+
parent_id: { type: "string", description: "\u53EF\u9009\uFF1A\u76EE\u6807\u6587\u4EF6\u5939\u7684 node id\uFF1B\u7701\u7565\u5219\u653E\u5230\u6839\u76EE\u5F55\u3002" }
|
|
1578
|
+
},
|
|
1579
|
+
output: {
|
|
1580
|
+
schema: { type: "object", additionalProperties: true, description: "\u5165\u5E93\u540E\u7684\u8D44\u6599\u6761\u76EE\uFF08\u542B asset_id \u4E0E reused \u6807\u8BB0\uFF09\u3002" },
|
|
1581
|
+
render: (_args, value) => [{
|
|
1582
|
+
type: "text",
|
|
1583
|
+
text: [
|
|
1584
|
+
`\u5DF2\u5BFC\u5165\u8D44\u6599\u300C${String(value?.name ?? "")}\u300D`,
|
|
1585
|
+
value?.reused === true ? "\uFF08\u4E0E\u5DF2\u6709\u4FEE\u8BA2\u5185\u5BB9\u4E00\u81F4\uFF0C\u76F4\u63A5\u590D\u7528\uFF09" : "",
|
|
1586
|
+
`asset_id=${String(value?.asset?.id ?? value?.id ?? "")}`,
|
|
1587
|
+
value?.source === "task" ? "\uFF08\u672C\u5730\u4EA7\u7269\uFF09" : ""
|
|
1588
|
+
].filter(Boolean).join(" ")
|
|
1589
|
+
}]
|
|
1590
|
+
},
|
|
1591
|
+
async execute(args) {
|
|
1592
|
+
const hasContent = typeof args.content === "string" && args.content.length > 0;
|
|
1593
|
+
const hasPath = typeof args.path === "string" && args.path.length > 0;
|
|
1594
|
+
if (hasContent && hasPath) {
|
|
1595
|
+
throw new FeatureError("library/invalid-request", "content \u4E0E path \u53EA\u80FD\u4E8C\u9009\u4E00\u3002");
|
|
1596
|
+
}
|
|
1597
|
+
if (!hasContent && !hasPath) {
|
|
1598
|
+
throw new FeatureError("library/invalid-request", "content \u4E0E path \u5FC5\u987B\u4E8C\u9009\u4E00\uFF1A\u7ED9\u6587\u672C\u5185\u5BB9\uFF0C\u6216\u7ED9\u4E00\u4E2A\u672C\u673A\u6587\u4EF6\u7684\u7EDD\u5BF9\u8DEF\u5F84\u3002");
|
|
1599
|
+
}
|
|
1600
|
+
if (hasPath) {
|
|
1601
|
+
return manager.importFromPath({ path: args.path, name: args.name, parentId: args.parent_id });
|
|
1602
|
+
}
|
|
1603
|
+
return manager.createText({ name: args.name, content: args.content, parentId: args.parent_id });
|
|
1604
|
+
}
|
|
1605
|
+
})), "dsh-plugin-library: tool library_import");
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
// index.js
|
|
1609
|
+
var name = "dsh-plugin-library";
|
|
1610
|
+
var inject = ["connection", "storageDomain", "tools", "fs"];
|
|
1611
|
+
async function apply(ctx, options = {}) {
|
|
1612
|
+
const kit = await storageKit();
|
|
1613
|
+
const manager = new LibraryManager(ctx, buildLibraryDomains(kit), options);
|
|
1614
|
+
await manager.init();
|
|
1615
|
+
registerFeatureRoute(ctx, { feature: "library", handlers: manager.handlers() });
|
|
1616
|
+
await registerLibraryTools(ctx, manager);
|
|
1617
|
+
const attribution = registerDeliverableImport(ctx, manager);
|
|
1618
|
+
if (typeof options.attribution === "object" && options.attribution !== null) {
|
|
1619
|
+
options.attribution.flush = attribution.flush;
|
|
1620
|
+
}
|
|
1621
|
+
}
|
|
1622
|
+
export {
|
|
1623
|
+
apply,
|
|
1624
|
+
inject,
|
|
1625
|
+
name
|
|
1626
|
+
};
|