@huoweifang/dsh-mindmap 0.0.0-stage → 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/CHANGELOG.md +9 -0
- package/LICENSE +21 -0
- package/README.md +89 -2
- package/README.zh-CN.md +90 -0
- package/cordis.patch.yml +7 -0
- package/icon.svg +12 -0
- package/lib/client.js +160 -0
- package/lib/index.js +840 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +81 -4
package/lib/index.js
ADDED
|
@@ -0,0 +1,840 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join as join2, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
// src/shared/api.ts
|
|
6
|
+
var ROUTE_PREFIX = "/dsh-mindmap/api";
|
|
7
|
+
|
|
8
|
+
// src/shared/markdown.ts
|
|
9
|
+
function unescapeFirst(text) {
|
|
10
|
+
const m = /^(?: )+/.exec(text);
|
|
11
|
+
if (m !== null) return " ".repeat(m[0].length / 5) + text.slice(m[0].length);
|
|
12
|
+
return /^\\\[( |x|X)\]/.test(text) ? text.slice(1) : text;
|
|
13
|
+
}
|
|
14
|
+
function unescapeRest(line) {
|
|
15
|
+
const t = line.trimStart();
|
|
16
|
+
if (!t.startsWith("\\")) return line;
|
|
17
|
+
const u = t.slice(1);
|
|
18
|
+
return LIST_RE.test(u) || HEADING_RE.test(u) || u.startsWith("\\") ? line.slice(0, line.length - t.length) + u : line;
|
|
19
|
+
}
|
|
20
|
+
var LIST_RE = /^(\s*)([-*+]|\d{1,9}[.)])(\s+|$)(.*)$/;
|
|
21
|
+
var HEADING_RE = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
|
|
22
|
+
var CHECK_RE = /^\[( |x|X)\](?:\s+|$)([\s\S]*)$/;
|
|
23
|
+
function indentOf(line) {
|
|
24
|
+
return line.length - line.trimStart().length;
|
|
25
|
+
}
|
|
26
|
+
function parseMarkdown(src, options = {}) {
|
|
27
|
+
const detectTitle = options.detectTitle ?? true;
|
|
28
|
+
const lines = src.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").split("\n").map((l) => l.replace(/\t/g, " "));
|
|
29
|
+
const h1Count = lines.filter((l) => /^#\s+\S/.test(l)).length;
|
|
30
|
+
const root = [];
|
|
31
|
+
const headings = [];
|
|
32
|
+
let list = [];
|
|
33
|
+
let last = null;
|
|
34
|
+
let blanks = 0;
|
|
35
|
+
let title;
|
|
36
|
+
const container = () => headings.length > 0 ? headings[headings.length - 1].item.children : root;
|
|
37
|
+
for (const line of lines) {
|
|
38
|
+
if (line.trim().length === 0) {
|
|
39
|
+
blanks++;
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
const hm = HEADING_RE.exec(line);
|
|
43
|
+
if (hm !== null) {
|
|
44
|
+
const level = hm[1].length;
|
|
45
|
+
if (detectTitle && title === void 0 && level === 1 && h1Count === 1 && root.length === 0) {
|
|
46
|
+
title = hm[2];
|
|
47
|
+
} else {
|
|
48
|
+
while (headings.length > 0 && headings[headings.length - 1].level >= level) headings.pop();
|
|
49
|
+
const item = { text: hm[2], children: [] };
|
|
50
|
+
container().push(item);
|
|
51
|
+
headings.push({ level, item });
|
|
52
|
+
}
|
|
53
|
+
list = [];
|
|
54
|
+
last = null;
|
|
55
|
+
blanks = 0;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
const lm = LIST_RE.exec(line);
|
|
59
|
+
if (lm !== null) {
|
|
60
|
+
const indent = lm[1].length;
|
|
61
|
+
let text = lm[4] ?? "";
|
|
62
|
+
let checked;
|
|
63
|
+
const cb = CHECK_RE.exec(text);
|
|
64
|
+
if (cb !== null) {
|
|
65
|
+
checked = cb[1] !== " ";
|
|
66
|
+
text = cb[2];
|
|
67
|
+
}
|
|
68
|
+
text = unescapeFirst(text);
|
|
69
|
+
while (list.length > 0 && list[list.length - 1].indent >= indent) list.pop();
|
|
70
|
+
const item = { text, children: [] };
|
|
71
|
+
if (checked !== void 0) item.checked = checked;
|
|
72
|
+
(list.length > 0 ? list[list.length - 1].item.children : container()).push(item);
|
|
73
|
+
list.push({ indent, item });
|
|
74
|
+
last = { marker: indent, content: indent + lm[2].length + Math.max(1, lm[3].length), item };
|
|
75
|
+
blanks = 0;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const ind = indentOf(line);
|
|
79
|
+
if (last !== null && ind > last.marker) {
|
|
80
|
+
last.item.text += "\n".repeat(blanks + 1) + unescapeRest(line.slice(Math.min(ind, last.content)));
|
|
81
|
+
blanks = 0;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
container().push({ text: line.trim(), children: [] });
|
|
85
|
+
list = [];
|
|
86
|
+
last = null;
|
|
87
|
+
blanks = 0;
|
|
88
|
+
}
|
|
89
|
+
return title === void 0 ? { items: root } : { title, items: root };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// src/shared/opml.ts
|
|
93
|
+
function unesc(text) {
|
|
94
|
+
return text.replace(/&(#x[0-9a-fA-F]+|#\d+|lt|gt|quot|apos|amp);/g, (_m, ent) => {
|
|
95
|
+
if (ent === "lt") return "<";
|
|
96
|
+
if (ent === "gt") return ">";
|
|
97
|
+
if (ent === "quot") return '"';
|
|
98
|
+
if (ent === "apos") return "'";
|
|
99
|
+
if (ent === "amp") return "&";
|
|
100
|
+
const code = ent[1] === "x" ? Number.parseInt(ent.slice(2), 16) : Number.parseInt(ent.slice(1), 10);
|
|
101
|
+
return Number.isFinite(code) && code > 0 && code <= 1114111 ? String.fromCodePoint(code) : "";
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
function parseOpml(xml) {
|
|
105
|
+
if (!/<opml[\s>]/i.test(xml)) throw new Error("\u4E0D\u662F\u6709\u6548\u7684 OPML \u6587\u4EF6");
|
|
106
|
+
const titleMatch = /<title>([\s\S]*?)<\/title>/i.exec(xml);
|
|
107
|
+
const bodyAt = xml.search(/<body[\s>\/]/i);
|
|
108
|
+
const body = bodyAt >= 0 ? xml.slice(bodyAt) : xml;
|
|
109
|
+
const root = [];
|
|
110
|
+
const stack = [];
|
|
111
|
+
const tokens = /<outline\b([^>]*?)(\/?)>|<\/outline\s*>/gi;
|
|
112
|
+
let m;
|
|
113
|
+
while ((m = tokens.exec(body)) !== null) {
|
|
114
|
+
if (m[0].startsWith("</")) {
|
|
115
|
+
stack.pop();
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const attrs = {};
|
|
119
|
+
const attrRe = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
120
|
+
let a;
|
|
121
|
+
while ((a = attrRe.exec(m[1] ?? "")) !== null) attrs[a[1]] = unesc(a[2] ?? a[3] ?? "");
|
|
122
|
+
const item = { text: (attrs.text ?? attrs.title ?? "").replace(/\r/g, ""), children: [] };
|
|
123
|
+
const flag = attrs._checked ?? attrs._complete;
|
|
124
|
+
if (flag === "true") item.checked = true;
|
|
125
|
+
else if (attrs._checked === "false") item.checked = false;
|
|
126
|
+
(stack.length > 0 ? stack[stack.length - 1].children : root).push(item);
|
|
127
|
+
if (m[2] !== "/") stack.push(item);
|
|
128
|
+
}
|
|
129
|
+
const title = titleMatch !== null ? unesc(titleMatch[1].trim()) : void 0;
|
|
130
|
+
return title !== void 0 && title.length > 0 ? { title, items: root } : { items: root };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// src/host/storage.ts
|
|
134
|
+
import { mkdir, open, readFile, readdir, rename, rm, stat } from "node:fs/promises";
|
|
135
|
+
import { dirname, join } from "node:path";
|
|
136
|
+
|
|
137
|
+
// src/shared/model.ts
|
|
138
|
+
var ID_RE = /^[a-z0-9-]{6,64}$/;
|
|
139
|
+
var MAX_TITLE = 200;
|
|
140
|
+
var counter = 0;
|
|
141
|
+
function newId() {
|
|
142
|
+
counter = (counter + 1) % 1296;
|
|
143
|
+
const rand = Math.random().toString(36).slice(2, 10).padEnd(8, "0");
|
|
144
|
+
return `${Date.now().toString(36)}-${counter.toString(36).padStart(2, "0")}${rand}`;
|
|
145
|
+
}
|
|
146
|
+
function normalizeTitle(raw, fallback = "\u672A\u547D\u540D\u6587\u6863") {
|
|
147
|
+
const text = typeof raw === "string" ? raw.replace(/[\r\n\t]+/g, " ").trim() : "";
|
|
148
|
+
return (text.length > 0 ? text : fallback).slice(0, MAX_TITLE);
|
|
149
|
+
}
|
|
150
|
+
function createDoc(title, now, items = [], id = newId()) {
|
|
151
|
+
const rootId = newId();
|
|
152
|
+
const nodes = {};
|
|
153
|
+
const build = (item) => {
|
|
154
|
+
const nid = newId();
|
|
155
|
+
const node = { id: nid, text: item.text, children: [] };
|
|
156
|
+
if (typeof item.checked === "boolean") node.checked = item.checked;
|
|
157
|
+
nodes[nid] = node;
|
|
158
|
+
node.children = item.children.map(build);
|
|
159
|
+
return nid;
|
|
160
|
+
};
|
|
161
|
+
const top = (items.length > 0 ? items : [{ text: "", children: [] }]).map(build);
|
|
162
|
+
nodes[rootId] = { id: rootId, text: "", children: top };
|
|
163
|
+
return { schema: 1, id, title: normalizeTitle(title), rootId, nodes, revision: 0, createdAt: now, updatedAt: now };
|
|
164
|
+
}
|
|
165
|
+
function validateDoc(value) {
|
|
166
|
+
if (typeof value !== "object" || value === null) return "document is not an object";
|
|
167
|
+
const doc = value;
|
|
168
|
+
if (doc.schema !== 1) return "unsupported schema";
|
|
169
|
+
if (typeof doc.id !== "string" || !ID_RE.test(doc.id)) return "invalid document id";
|
|
170
|
+
if (typeof doc.title !== "string") return "invalid title";
|
|
171
|
+
if (typeof doc.revision !== "number" || !Number.isInteger(doc.revision) || doc.revision < 0) return "invalid revision";
|
|
172
|
+
if (typeof doc.nodes !== "object" || doc.nodes === null) return "invalid nodes";
|
|
173
|
+
const root = doc.nodes[doc.rootId];
|
|
174
|
+
if (root === void 0) return "root node missing";
|
|
175
|
+
const seen = /* @__PURE__ */ new Set();
|
|
176
|
+
const stack = [doc.rootId];
|
|
177
|
+
while (stack.length > 0) {
|
|
178
|
+
const id = stack.pop();
|
|
179
|
+
if (seen.has(id)) return `node ${id} is referenced more than once`;
|
|
180
|
+
seen.add(id);
|
|
181
|
+
const node = doc.nodes[id];
|
|
182
|
+
if (node === void 0) return `dangling child reference ${id}`;
|
|
183
|
+
if (node.id !== id) return `node key mismatch ${id}`;
|
|
184
|
+
if (typeof node.text !== "string") return `node ${id} has no text`;
|
|
185
|
+
if (!Array.isArray(node.children)) return `node ${id} has no children array`;
|
|
186
|
+
for (const child of node.children) {
|
|
187
|
+
if (typeof child !== "string") return `node ${id} has a non-string child`;
|
|
188
|
+
stack.push(child);
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
const total = Object.keys(doc.nodes).length;
|
|
192
|
+
if (seen.size !== total) return `${total - seen.size} orphan node(s)`;
|
|
193
|
+
return null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// src/shared/search.ts
|
|
197
|
+
function snippetOf(text, at, len) {
|
|
198
|
+
const flat = text.replace(/\s+/g, " ");
|
|
199
|
+
const start = Math.max(0, at - 24);
|
|
200
|
+
const end = Math.min(flat.length, at + len + 40);
|
|
201
|
+
return (start > 0 ? "\u2026" : "") + flat.slice(start, end) + (end < flat.length ? "\u2026" : "");
|
|
202
|
+
}
|
|
203
|
+
function searchDoc(doc, title, query, limit = 20) {
|
|
204
|
+
const q = query.trim().toLowerCase();
|
|
205
|
+
if (q.length === 0) return [];
|
|
206
|
+
const hits = [];
|
|
207
|
+
const ti = title.toLowerCase().indexOf(q);
|
|
208
|
+
if (ti >= 0) hits.push({ docId: doc.id, title, nodeId: null, snippet: snippetOf(title, ti, q.length) });
|
|
209
|
+
const walk = (ids) => {
|
|
210
|
+
for (const id of ids) {
|
|
211
|
+
if (hits.length >= limit) return;
|
|
212
|
+
const n = doc.nodes[id];
|
|
213
|
+
if (n === void 0) continue;
|
|
214
|
+
const at = n.text.toLowerCase().replace(/\s/g, " ").indexOf(q);
|
|
215
|
+
if (at >= 0) hits.push({ docId: doc.id, title, nodeId: id, snippet: snippetOf(n.text, at, q.length) });
|
|
216
|
+
walk(n.children);
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
walk(doc.nodes[doc.rootId]?.children ?? []);
|
|
220
|
+
return hits;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// src/host/storage.ts
|
|
224
|
+
var MAX_BACKUPS = 30;
|
|
225
|
+
var BACKUP_RE = /^mindmap-\d{8}(?:-\d{6})?(?:-pre-restore)?\.json$/;
|
|
226
|
+
var StorageError = class extends Error {
|
|
227
|
+
code;
|
|
228
|
+
current;
|
|
229
|
+
constructor(code, message, current) {
|
|
230
|
+
super(message);
|
|
231
|
+
this.code = code;
|
|
232
|
+
if (current !== void 0) this.current = current;
|
|
233
|
+
}
|
|
234
|
+
};
|
|
235
|
+
function stamp(ms, withTime) {
|
|
236
|
+
const d = new Date(ms);
|
|
237
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
238
|
+
const day = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`;
|
|
239
|
+
return withTime ? `${day}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}` : day;
|
|
240
|
+
}
|
|
241
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
242
|
+
async function atomicWrite(file, contents) {
|
|
243
|
+
await mkdir(dirname(file), { recursive: true });
|
|
244
|
+
const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`);
|
|
245
|
+
const fh = await open(temp, "w");
|
|
246
|
+
try {
|
|
247
|
+
await fh.writeFile(contents, "utf8");
|
|
248
|
+
await fh.sync();
|
|
249
|
+
} finally {
|
|
250
|
+
await fh.close();
|
|
251
|
+
}
|
|
252
|
+
for (let attempt = 0; ; attempt++) {
|
|
253
|
+
try {
|
|
254
|
+
await rename(temp, file);
|
|
255
|
+
return;
|
|
256
|
+
} catch (error) {
|
|
257
|
+
const code = error.code;
|
|
258
|
+
if (attempt >= 6 || code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") {
|
|
259
|
+
await rm(temp, { force: true }).catch(() => {
|
|
260
|
+
});
|
|
261
|
+
throw error;
|
|
262
|
+
}
|
|
263
|
+
await sleep(40 * (attempt + 1));
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
function isEnoent(error) {
|
|
268
|
+
return error?.code === "ENOENT";
|
|
269
|
+
}
|
|
270
|
+
function sanitizeMeta(raw) {
|
|
271
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
272
|
+
const m = raw;
|
|
273
|
+
if (typeof m.id !== "string" || !ID_RE.test(m.id)) return null;
|
|
274
|
+
const meta = {
|
|
275
|
+
id: m.id,
|
|
276
|
+
title: normalizeTitle(m.title),
|
|
277
|
+
folderId: typeof m.folderId === "string" ? m.folderId : null,
|
|
278
|
+
favorite: m.favorite === true,
|
|
279
|
+
createdAt: typeof m.createdAt === "number" ? m.createdAt : 0,
|
|
280
|
+
updatedAt: typeof m.updatedAt === "number" ? m.updatedAt : 0
|
|
281
|
+
};
|
|
282
|
+
if (typeof m.trashedAt === "number") meta.trashedAt = m.trashedAt;
|
|
283
|
+
return meta;
|
|
284
|
+
}
|
|
285
|
+
function sanitizeLibrary(raw) {
|
|
286
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
287
|
+
const l = raw;
|
|
288
|
+
if (!Array.isArray(l.docs) || !Array.isArray(l.folders)) return null;
|
|
289
|
+
const folders = [];
|
|
290
|
+
for (const f of l.folders) {
|
|
291
|
+
if (typeof f === "object" && f !== null && typeof f.id === "string" && ID_RE.test(f.id) && typeof f.name === "string") {
|
|
292
|
+
folders.push({ id: f.id, name: normalizeTitle(f.name, "\u65B0\u5EFA\u6587\u4EF6\u5939"), createdAt: Number(f.createdAt) || 0 });
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
const ids = new Set(folders.map((f) => f.id));
|
|
296
|
+
const docs = [];
|
|
297
|
+
const seen = /* @__PURE__ */ new Set();
|
|
298
|
+
for (const d of l.docs) {
|
|
299
|
+
const meta = sanitizeMeta(d);
|
|
300
|
+
if (meta === null || seen.has(meta.id)) continue;
|
|
301
|
+
if (meta.folderId !== null && !ids.has(meta.folderId)) meta.folderId = null;
|
|
302
|
+
seen.add(meta.id);
|
|
303
|
+
docs.push(meta);
|
|
304
|
+
}
|
|
305
|
+
return { schema: 1, folders, docs };
|
|
306
|
+
}
|
|
307
|
+
var MindmapStorage = class {
|
|
308
|
+
dir;
|
|
309
|
+
now;
|
|
310
|
+
library = { schema: 1, folders: [], docs: [] };
|
|
311
|
+
docs = /* @__PURE__ */ new Map();
|
|
312
|
+
queue = Promise.resolve();
|
|
313
|
+
loading;
|
|
314
|
+
/** Problems found while loading (quarantined files etc.). */
|
|
315
|
+
warnings = [];
|
|
316
|
+
constructor(dir, now = () => Date.now()) {
|
|
317
|
+
this.dir = dir;
|
|
318
|
+
this.now = now;
|
|
319
|
+
}
|
|
320
|
+
get libraryFile() {
|
|
321
|
+
return join(this.dir, "library.json");
|
|
322
|
+
}
|
|
323
|
+
get docsDir() {
|
|
324
|
+
return join(this.dir, "docs");
|
|
325
|
+
}
|
|
326
|
+
get backupsDir() {
|
|
327
|
+
return join(this.dir, "backups");
|
|
328
|
+
}
|
|
329
|
+
docFile(id) {
|
|
330
|
+
return join(this.docsDir, `${id}.json`);
|
|
331
|
+
}
|
|
332
|
+
/** Serialize every mutation (and the initial load) through one queue. */
|
|
333
|
+
serial(fn) {
|
|
334
|
+
const run = this.queue.then(() => this.ready()).then(fn);
|
|
335
|
+
this.queue = run.catch(() => void 0);
|
|
336
|
+
return run;
|
|
337
|
+
}
|
|
338
|
+
/** Load once; later calls share the same promise. */
|
|
339
|
+
ready() {
|
|
340
|
+
this.loading ??= this.load();
|
|
341
|
+
return this.loading;
|
|
342
|
+
}
|
|
343
|
+
async quarantine(file, why) {
|
|
344
|
+
const target = file.replace(/\.json$/, `.corrupt-${stamp(this.now(), true)}.json`);
|
|
345
|
+
await rename(file, target).catch(() => {
|
|
346
|
+
});
|
|
347
|
+
this.warnings.push(`${why}: \u5DF2\u9694\u79BB\u4E3A ${target}`);
|
|
348
|
+
console.warn(`[dsh-mindmap] ${why}; quarantined as ${target}`);
|
|
349
|
+
}
|
|
350
|
+
async load() {
|
|
351
|
+
await mkdir(this.docsDir, { recursive: true });
|
|
352
|
+
await mkdir(this.backupsDir, { recursive: true });
|
|
353
|
+
let library = null;
|
|
354
|
+
try {
|
|
355
|
+
library = sanitizeLibrary(JSON.parse(await readFile(this.libraryFile, "utf8")));
|
|
356
|
+
if (library === null) await this.quarantine(this.libraryFile, "library.json \u7ED3\u6784\u65E0\u6548");
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (!isEnoent(error)) await this.quarantine(this.libraryFile, "library.json \u65E0\u6CD5\u89E3\u6790");
|
|
359
|
+
}
|
|
360
|
+
const files = (await readdir(this.docsDir)).filter((f) => /^[a-z0-9-]{6,64}\.json$/.test(f));
|
|
361
|
+
const lib = library ?? { schema: 1, folders: [], docs: [] };
|
|
362
|
+
const known = new Set(lib.docs.map((d) => d.id));
|
|
363
|
+
let adopted = false;
|
|
364
|
+
for (const file of files) {
|
|
365
|
+
const id = file.slice(0, -5);
|
|
366
|
+
if (known.has(id)) continue;
|
|
367
|
+
const doc = await this.readDocFile(id).catch(() => null);
|
|
368
|
+
if (doc === null) continue;
|
|
369
|
+
lib.docs.push({ id, title: doc.title, folderId: null, favorite: false, createdAt: doc.createdAt, updatedAt: doc.updatedAt });
|
|
370
|
+
adopted = true;
|
|
371
|
+
}
|
|
372
|
+
const present = new Set(files.map((f) => f.slice(0, -5)));
|
|
373
|
+
const before = lib.docs.length;
|
|
374
|
+
lib.docs = lib.docs.filter((d) => present.has(d.id));
|
|
375
|
+
this.library = lib;
|
|
376
|
+
if (adopted || library === null || lib.docs.length !== before) {
|
|
377
|
+
if (lib.docs.length > 0 || library !== null) await this.writeLibrary();
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
async readDocFile(id) {
|
|
381
|
+
const file = this.docFile(id);
|
|
382
|
+
let parsed;
|
|
383
|
+
try {
|
|
384
|
+
parsed = JSON.parse(await readFile(file, "utf8"));
|
|
385
|
+
} catch (error) {
|
|
386
|
+
if (isEnoent(error)) throw new StorageError("not_found", "\u6587\u6863\u4E0D\u5B58\u5728");
|
|
387
|
+
await this.quarantine(file, `\u6587\u6863 ${id} \u65E0\u6CD5\u89E3\u6790`);
|
|
388
|
+
throw new StorageError("internal", "\u6587\u6863\u6587\u4EF6\u5DF2\u635F\u574F\u5E76\u5DF2\u9694\u79BB");
|
|
389
|
+
}
|
|
390
|
+
const problem = validateDoc(parsed);
|
|
391
|
+
if (problem !== null || parsed.id !== id) {
|
|
392
|
+
await this.quarantine(file, `\u6587\u6863 ${id} \u7ED3\u6784\u65E0\u6548\uFF08${problem ?? "id \u4E0D\u5339\u914D"}\uFF09`);
|
|
393
|
+
throw new StorageError("internal", "\u6587\u6863\u6587\u4EF6\u7ED3\u6784\u65E0\u6548\u5E76\u5DF2\u9694\u79BB");
|
|
394
|
+
}
|
|
395
|
+
return parsed;
|
|
396
|
+
}
|
|
397
|
+
async writeLibrary() {
|
|
398
|
+
await atomicWrite(this.libraryFile, JSON.stringify(this.library, null, 1));
|
|
399
|
+
}
|
|
400
|
+
async writeDoc(doc) {
|
|
401
|
+
const problem = validateDoc(doc);
|
|
402
|
+
if (problem !== null) throw new StorageError("invalid_input", `\u6587\u6863\u7ED3\u6784\u65E0\u6548\uFF1A${problem}`);
|
|
403
|
+
await atomicWrite(this.docFile(doc.id), JSON.stringify(doc));
|
|
404
|
+
this.docs.set(doc.id, doc);
|
|
405
|
+
}
|
|
406
|
+
meta(id) {
|
|
407
|
+
if (!ID_RE.test(id)) throw new StorageError("invalid_input", "\u65E0\u6548\u7684\u6587\u6863 id");
|
|
408
|
+
const meta = this.library.docs.find((d) => d.id === id);
|
|
409
|
+
if (meta === void 0) throw new StorageError("not_found", "\u6587\u6863\u4E0D\u5B58\u5728");
|
|
410
|
+
return meta;
|
|
411
|
+
}
|
|
412
|
+
async loadDoc(id) {
|
|
413
|
+
this.meta(id);
|
|
414
|
+
const cached = this.docs.get(id);
|
|
415
|
+
if (cached !== void 0) return cached;
|
|
416
|
+
const doc = await this.readDocFile(id);
|
|
417
|
+
this.docs.set(id, doc);
|
|
418
|
+
return doc;
|
|
419
|
+
}
|
|
420
|
+
checkFolder(folderId) {
|
|
421
|
+
if (folderId === null || folderId === void 0) return null;
|
|
422
|
+
if (typeof folderId !== "string" || !this.library.folders.some((f) => f.id === folderId)) throw new StorageError("invalid_input", "\u6587\u4EF6\u5939\u4E0D\u5B58\u5728");
|
|
423
|
+
return folderId;
|
|
424
|
+
}
|
|
425
|
+
/** A copy of the library index. */
|
|
426
|
+
async getLibrary() {
|
|
427
|
+
await this.ready();
|
|
428
|
+
return structuredClone(this.library);
|
|
429
|
+
}
|
|
430
|
+
async getDoc(id) {
|
|
431
|
+
await this.ready();
|
|
432
|
+
return this.serial(() => this.loadDoc(id));
|
|
433
|
+
}
|
|
434
|
+
createDoc(input) {
|
|
435
|
+
return this.serial(async () => {
|
|
436
|
+
const folderId = this.checkFolder(input.folderId);
|
|
437
|
+
const now = this.now();
|
|
438
|
+
const doc = createDoc(normalizeTitle(input.title), now, input.items ?? []);
|
|
439
|
+
await this.writeDoc(doc);
|
|
440
|
+
const meta = { id: doc.id, title: doc.title, folderId, favorite: false, createdAt: now, updatedAt: now };
|
|
441
|
+
this.library.docs.push(meta);
|
|
442
|
+
await this.writeLibrary();
|
|
443
|
+
await this.dailyBackup();
|
|
444
|
+
return { meta: { ...meta }, doc };
|
|
445
|
+
});
|
|
446
|
+
}
|
|
447
|
+
/** Optimistic-concurrency save: `baseRevision` must match the stored revision. */
|
|
448
|
+
saveDoc(id, incoming, baseRevision) {
|
|
449
|
+
return this.serial(async () => {
|
|
450
|
+
const meta = this.meta(id);
|
|
451
|
+
const current = await this.loadDoc(id);
|
|
452
|
+
if (typeof baseRevision !== "number" || baseRevision !== current.revision) {
|
|
453
|
+
throw new StorageError("conflict", "\u6587\u6863\u5DF2\u5728\u5176\u4ED6\u7A97\u53E3\u88AB\u4FEE\u6539", current);
|
|
454
|
+
}
|
|
455
|
+
const problem = validateDoc(incoming);
|
|
456
|
+
if (problem !== null) throw new StorageError("invalid_input", `\u6587\u6863\u7ED3\u6784\u65E0\u6548\uFF1A${problem}`);
|
|
457
|
+
const doc = incoming;
|
|
458
|
+
if (doc.id !== id) throw new StorageError("invalid_input", "\u6587\u6863 id \u4E0D\u5339\u914D");
|
|
459
|
+
const now = this.now();
|
|
460
|
+
const next = { ...doc, title: normalizeTitle(doc.title), revision: current.revision + 1, createdAt: current.createdAt, updatedAt: now };
|
|
461
|
+
await this.writeDoc(next);
|
|
462
|
+
meta.title = next.title;
|
|
463
|
+
meta.updatedAt = now;
|
|
464
|
+
await this.writeLibrary();
|
|
465
|
+
await this.dailyBackup();
|
|
466
|
+
return next;
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
/** Rename / move to folder / (un)favorite. A rename also rewrites the document title. */
|
|
470
|
+
patchMeta(id, body) {
|
|
471
|
+
return this.serial(async () => {
|
|
472
|
+
const meta = this.meta(id);
|
|
473
|
+
let doc;
|
|
474
|
+
if (body.folderId !== void 0) meta.folderId = this.checkFolder(body.folderId);
|
|
475
|
+
if (body.favorite !== void 0) meta.favorite = body.favorite === true;
|
|
476
|
+
if (body.title !== void 0) {
|
|
477
|
+
const title = normalizeTitle(body.title);
|
|
478
|
+
const current = await this.loadDoc(id);
|
|
479
|
+
if (current.title !== title) {
|
|
480
|
+
const now = this.now();
|
|
481
|
+
doc = { ...current, title, revision: current.revision + 1, updatedAt: now };
|
|
482
|
+
await this.writeDoc(doc);
|
|
483
|
+
meta.updatedAt = now;
|
|
484
|
+
}
|
|
485
|
+
meta.title = title;
|
|
486
|
+
}
|
|
487
|
+
await this.writeLibrary();
|
|
488
|
+
return doc === void 0 ? { meta: { ...meta } } : { meta: { ...meta }, doc };
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
/** Soft delete into the trash. */
|
|
492
|
+
trashDoc(id) {
|
|
493
|
+
return this.serial(async () => {
|
|
494
|
+
const meta = this.meta(id);
|
|
495
|
+
meta.trashedAt = this.now();
|
|
496
|
+
await this.writeLibrary();
|
|
497
|
+
return { ...meta };
|
|
498
|
+
});
|
|
499
|
+
}
|
|
500
|
+
restoreDoc(id) {
|
|
501
|
+
return this.serial(async () => {
|
|
502
|
+
const meta = this.meta(id);
|
|
503
|
+
delete meta.trashedAt;
|
|
504
|
+
await this.writeLibrary();
|
|
505
|
+
return { ...meta };
|
|
506
|
+
});
|
|
507
|
+
}
|
|
508
|
+
/** Permanent delete; only trashed documents. */
|
|
509
|
+
purgeDoc(id) {
|
|
510
|
+
return this.serial(async () => {
|
|
511
|
+
const meta = this.meta(id);
|
|
512
|
+
if (meta.trashedAt === void 0) throw new StorageError("invalid_input", "\u53EA\u80FD\u6C38\u4E45\u5220\u9664\u56DE\u6536\u7AD9\u4E2D\u7684\u6587\u6863");
|
|
513
|
+
await this.backupInternal("manual");
|
|
514
|
+
this.library.docs = this.library.docs.filter((d) => d.id !== id);
|
|
515
|
+
await this.writeLibrary();
|
|
516
|
+
this.docs.delete(id);
|
|
517
|
+
await rm(this.docFile(id), { force: true });
|
|
518
|
+
});
|
|
519
|
+
}
|
|
520
|
+
createFolder(name2) {
|
|
521
|
+
return this.serial(async () => {
|
|
522
|
+
const folder = { id: newId(), name: normalizeTitle(name2, "\u65B0\u5EFA\u6587\u4EF6\u5939"), createdAt: this.now() };
|
|
523
|
+
this.library.folders.push(folder);
|
|
524
|
+
await this.writeLibrary();
|
|
525
|
+
return { ...folder };
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
renameFolder(id, name2) {
|
|
529
|
+
return this.serial(async () => {
|
|
530
|
+
const folder = this.library.folders.find((f) => f.id === id);
|
|
531
|
+
if (folder === void 0) throw new StorageError("not_found", "\u6587\u4EF6\u5939\u4E0D\u5B58\u5728");
|
|
532
|
+
folder.name = normalizeTitle(name2, folder.name);
|
|
533
|
+
await this.writeLibrary();
|
|
534
|
+
return { ...folder };
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
/** Delete a folder; its documents move back to the top level (never deleted). */
|
|
538
|
+
deleteFolder(id) {
|
|
539
|
+
return this.serial(async () => {
|
|
540
|
+
if (!this.library.folders.some((f) => f.id === id)) throw new StorageError("not_found", "\u6587\u4EF6\u5939\u4E0D\u5B58\u5728");
|
|
541
|
+
this.library.folders = this.library.folders.filter((f) => f.id !== id);
|
|
542
|
+
for (const d of this.library.docs) if (d.folderId === id) d.folderId = null;
|
|
543
|
+
await this.writeLibrary();
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
/** Title + node text search over non-trashed documents. */
|
|
547
|
+
async search(query, limit = 100) {
|
|
548
|
+
await this.ready();
|
|
549
|
+
const hits = [];
|
|
550
|
+
for (const meta of this.library.docs) {
|
|
551
|
+
if (meta.trashedAt !== void 0) continue;
|
|
552
|
+
if (hits.length >= limit) break;
|
|
553
|
+
try {
|
|
554
|
+
const doc = await this.serial(() => this.loadDoc(meta.id));
|
|
555
|
+
hits.push(...searchDoc(doc, meta.title, query, limit - hits.length));
|
|
556
|
+
} catch {
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
return hits;
|
|
560
|
+
}
|
|
561
|
+
// -------------------------------------------------------------- backups
|
|
562
|
+
async snapshot() {
|
|
563
|
+
const docs = [];
|
|
564
|
+
for (const meta of this.library.docs) {
|
|
565
|
+
try {
|
|
566
|
+
docs.push(await this.loadDoc(meta.id));
|
|
567
|
+
} catch {
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
return { schema: 1, kind: "dsh-mindmap-backup", createdAt: this.now(), library: structuredClone(this.library), docs };
|
|
571
|
+
}
|
|
572
|
+
async backupInternal(kind) {
|
|
573
|
+
const now = this.now();
|
|
574
|
+
const name2 = kind === "daily" ? `mindmap-${stamp(now, false)}.json` : `mindmap-${stamp(now, true)}${kind === "pre-restore" ? "-pre-restore" : ""}.json`;
|
|
575
|
+
const contents = JSON.stringify(await this.snapshot());
|
|
576
|
+
await atomicWrite(join(this.backupsDir, name2), contents);
|
|
577
|
+
await this.prune();
|
|
578
|
+
return { name: name2, size: Buffer.byteLength(contents), createdAt: now };
|
|
579
|
+
}
|
|
580
|
+
/** First write of a local day snapshots the whole library. */
|
|
581
|
+
async dailyBackup() {
|
|
582
|
+
const name2 = `mindmap-${stamp(this.now(), false)}.json`;
|
|
583
|
+
try {
|
|
584
|
+
await stat(join(this.backupsDir, name2));
|
|
585
|
+
} catch (error) {
|
|
586
|
+
if (!isEnoent(error)) return;
|
|
587
|
+
await this.backupInternal("daily").catch((e) => console.warn("[dsh-mindmap] daily backup failed:", e));
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
async prune() {
|
|
591
|
+
const list = await this.listBackupsRaw();
|
|
592
|
+
for (const old of list.slice(MAX_BACKUPS)) await rm(join(this.backupsDir, old.name), { force: true });
|
|
593
|
+
}
|
|
594
|
+
async listBackupsRaw() {
|
|
595
|
+
const out = [];
|
|
596
|
+
for (const name2 of await readdir(this.backupsDir).catch(() => [])) {
|
|
597
|
+
if (!BACKUP_RE.test(name2)) continue;
|
|
598
|
+
const s = await stat(join(this.backupsDir, name2)).catch(() => null);
|
|
599
|
+
if (s !== null) out.push({ name: name2, size: s.size, createdAt: s.mtimeMs });
|
|
600
|
+
}
|
|
601
|
+
return out.sort((a, b) => b.createdAt - a.createdAt);
|
|
602
|
+
}
|
|
603
|
+
async listBackups() {
|
|
604
|
+
await this.ready();
|
|
605
|
+
return this.listBackupsRaw();
|
|
606
|
+
}
|
|
607
|
+
backupNow() {
|
|
608
|
+
return this.serial(() => this.backupInternal("manual"));
|
|
609
|
+
}
|
|
610
|
+
async readBackup(name2) {
|
|
611
|
+
await this.ready();
|
|
612
|
+
if (!BACKUP_RE.test(name2)) throw new StorageError("invalid_input", "\u65E0\u6548\u7684\u5907\u4EFD\u6587\u4EF6\u540D");
|
|
613
|
+
try {
|
|
614
|
+
return await readFile(join(this.backupsDir, name2), "utf8");
|
|
615
|
+
} catch (error) {
|
|
616
|
+
if (isEnoent(error)) throw new StorageError("not_found", "\u5907\u4EFD\u4E0D\u5B58\u5728");
|
|
617
|
+
throw error;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Replace everything with a backup. The current state is first saved as a
|
|
622
|
+
* "-pre-restore" backup, so a restore is itself reversible.
|
|
623
|
+
*/
|
|
624
|
+
restore(source) {
|
|
625
|
+
return this.serial(async () => {
|
|
626
|
+
let raw;
|
|
627
|
+
if ("name" in source) raw = JSON.parse(await this.readBackup(source.name));
|
|
628
|
+
else raw = source.data;
|
|
629
|
+
const file = raw;
|
|
630
|
+
if (typeof file !== "object" || file === null || file.kind !== "dsh-mindmap-backup" || !Array.isArray(file.docs)) {
|
|
631
|
+
throw new StorageError("invalid_input", "\u4E0D\u662F\u6709\u6548\u7684\u601D\u7EF4\u5BFC\u56FE\u5907\u4EFD\u6587\u4EF6");
|
|
632
|
+
}
|
|
633
|
+
const library = sanitizeLibrary(file.library);
|
|
634
|
+
if (library === null) throw new StorageError("invalid_input", "\u5907\u4EFD\u4E2D\u7684\u6587\u6863\u5E93\u65E0\u6548");
|
|
635
|
+
for (const doc of file.docs) {
|
|
636
|
+
const problem = validateDoc(doc);
|
|
637
|
+
if (problem !== null) throw new StorageError("invalid_input", `\u5907\u4EFD\u4E2D\u7684\u6587\u6863\u65E0\u6548\uFF1A${problem}`);
|
|
638
|
+
}
|
|
639
|
+
const byId = new Map(file.docs.map((d) => [d.id, d]));
|
|
640
|
+
library.docs = library.docs.filter((d) => byId.has(d.id));
|
|
641
|
+
const pre = await this.backupInternal("pre-restore");
|
|
642
|
+
for (const meta of library.docs) await this.writeDoc(byId.get(meta.id));
|
|
643
|
+
const keep = new Set(library.docs.map((d) => d.id));
|
|
644
|
+
for (const f of await readdir(this.docsDir)) {
|
|
645
|
+
const m = /^([a-z0-9-]{6,64})\.json$/.exec(f);
|
|
646
|
+
if (m !== null && !keep.has(m[1])) await rm(join(this.docsDir, f), { force: true });
|
|
647
|
+
}
|
|
648
|
+
this.docs.clear();
|
|
649
|
+
this.library = library;
|
|
650
|
+
await this.writeLibrary();
|
|
651
|
+
return { docs: library.docs.length, preRestore: pre.name };
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
};
|
|
655
|
+
|
|
656
|
+
// src/host/routes.ts
|
|
657
|
+
var MAX_BODY_BYTES = 8 * 1024 * 1024;
|
|
658
|
+
var STATUS = { invalid_input: 400, not_found: 404, conflict: 409, too_large: 413, internal: 500 };
|
|
659
|
+
var ID = "([a-z0-9-]{6,64})";
|
|
660
|
+
var DOC_RE = new RegExp(`^/docs/${ID}$`);
|
|
661
|
+
var DOC_ACTION_RE = new RegExp(`^/docs/${ID}/(save|meta|trash|restore|purge)$`);
|
|
662
|
+
var FOLDER_ACTION_RE = new RegExp(`^/folders/${ID}/(rename|delete)$`);
|
|
663
|
+
var BACKUP_RE2 = /^\/backups\/(mindmap-\d{8}(?:-\d{6})?(?:-pre-restore)?\.json)(\/restore)?$/;
|
|
664
|
+
function send(res, status, body) {
|
|
665
|
+
const text = JSON.stringify(body);
|
|
666
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
667
|
+
res.end(text);
|
|
668
|
+
}
|
|
669
|
+
function ok(res, value) {
|
|
670
|
+
send(res, 200, { ok: true, value });
|
|
671
|
+
}
|
|
672
|
+
function fail(res, code, message, extra = {}) {
|
|
673
|
+
send(res, STATUS[code], { ok: false, error: { code, message, ...extra } });
|
|
674
|
+
}
|
|
675
|
+
async function readBody(req) {
|
|
676
|
+
const type = String(req.headers["content-type"] ?? "");
|
|
677
|
+
if (!type.toLowerCase().startsWith("application/json")) throw new StorageError("invalid_input", "\u8BF7\u6C42\u5FC5\u987B\u662F application/json");
|
|
678
|
+
const chunks = [];
|
|
679
|
+
let size = 0;
|
|
680
|
+
for await (const chunk of req) {
|
|
681
|
+
size += chunk.length;
|
|
682
|
+
if (size > MAX_BODY_BYTES) throw new StorageError("too_large", "\u8BF7\u6C42\u4F53\u8FC7\u5927");
|
|
683
|
+
chunks.push(chunk);
|
|
684
|
+
}
|
|
685
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
686
|
+
if (raw.trim().length === 0) return {};
|
|
687
|
+
try {
|
|
688
|
+
return JSON.parse(raw);
|
|
689
|
+
} catch {
|
|
690
|
+
throw new StorageError("invalid_input", "\u8BF7\u6C42\u4F53\u4E0D\u662F\u6709\u6548 JSON");
|
|
691
|
+
}
|
|
692
|
+
}
|
|
693
|
+
function obj(value) {
|
|
694
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
695
|
+
}
|
|
696
|
+
function createHandler(storage) {
|
|
697
|
+
return async (req, res) => {
|
|
698
|
+
try {
|
|
699
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
700
|
+
if (!url.pathname.startsWith(ROUTE_PREFIX)) {
|
|
701
|
+
fail(res, "not_found", "\u672A\u77E5\u8DEF\u5F84");
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
const path = url.pathname.slice(ROUTE_PREFIX.length) || "/";
|
|
705
|
+
const method = req.method ?? "GET";
|
|
706
|
+
if (method === "GET") {
|
|
707
|
+
if (path === "/library") {
|
|
708
|
+
ok(res, { library: await storage.getLibrary(), warnings: storage.warnings });
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
const doc = DOC_RE.exec(path);
|
|
712
|
+
if (doc !== null) {
|
|
713
|
+
ok(res, await storage.getDoc(doc[1]));
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
if (path === "/search") {
|
|
717
|
+
ok(res, await storage.search(String(url.searchParams.get("q") ?? "").slice(0, 200)));
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
if (path === "/backups") {
|
|
721
|
+
ok(res, await storage.listBackups());
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
const backup2 = BACKUP_RE2.exec(path);
|
|
725
|
+
if (backup2 !== null && backup2[2] === void 0) {
|
|
726
|
+
const text = await storage.readBackup(backup2[1]);
|
|
727
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8", "content-disposition": `attachment; filename="${backup2[1]}"`, "cache-control": "no-store" });
|
|
728
|
+
res.end(text);
|
|
729
|
+
return;
|
|
730
|
+
}
|
|
731
|
+
fail(res, "not_found", "\u672A\u77E5\u8DEF\u5F84");
|
|
732
|
+
return;
|
|
733
|
+
}
|
|
734
|
+
if (method !== "POST") {
|
|
735
|
+
fail(res, "not_found", "\u4E0D\u652F\u6301\u7684\u65B9\u6CD5");
|
|
736
|
+
return;
|
|
737
|
+
}
|
|
738
|
+
const body = obj(await readBody(req));
|
|
739
|
+
if (path === "/docs") {
|
|
740
|
+
let items = [];
|
|
741
|
+
let title = body.title;
|
|
742
|
+
if (typeof body.markdown === "string") {
|
|
743
|
+
const parsed = parseMarkdown(body.markdown);
|
|
744
|
+
items = parsed.items;
|
|
745
|
+
if (title === void 0 || title === "") title = parsed.title;
|
|
746
|
+
} else if (typeof body.opml === "string") {
|
|
747
|
+
let parsed;
|
|
748
|
+
try {
|
|
749
|
+
parsed = parseOpml(body.opml);
|
|
750
|
+
} catch (error) {
|
|
751
|
+
throw new StorageError("invalid_input", error.message);
|
|
752
|
+
}
|
|
753
|
+
items = parsed.items;
|
|
754
|
+
if (title === void 0 || title === "") title = parsed.title;
|
|
755
|
+
}
|
|
756
|
+
ok(res, await storage.createDoc({ title, folderId: body.folderId, items }));
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const action = DOC_ACTION_RE.exec(path);
|
|
760
|
+
if (action !== null) {
|
|
761
|
+
const id = action[1];
|
|
762
|
+
switch (action[2]) {
|
|
763
|
+
case "save":
|
|
764
|
+
ok(res, await storage.saveDoc(id, body.doc, body.baseRevision));
|
|
765
|
+
return;
|
|
766
|
+
case "meta":
|
|
767
|
+
ok(res, await storage.patchMeta(id, body));
|
|
768
|
+
return;
|
|
769
|
+
case "trash":
|
|
770
|
+
ok(res, await storage.trashDoc(id));
|
|
771
|
+
return;
|
|
772
|
+
case "restore":
|
|
773
|
+
ok(res, await storage.restoreDoc(id));
|
|
774
|
+
return;
|
|
775
|
+
case "purge":
|
|
776
|
+
await storage.purgeDoc(id);
|
|
777
|
+
ok(res, null);
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
if (path === "/folders") {
|
|
782
|
+
ok(res, await storage.createFolder(body.name));
|
|
783
|
+
return;
|
|
784
|
+
}
|
|
785
|
+
const folder = FOLDER_ACTION_RE.exec(path);
|
|
786
|
+
if (folder !== null) {
|
|
787
|
+
if (folder[2] === "rename") ok(res, await storage.renameFolder(folder[1], body.name));
|
|
788
|
+
else {
|
|
789
|
+
await storage.deleteFolder(folder[1]);
|
|
790
|
+
ok(res, null);
|
|
791
|
+
}
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
if (path === "/backups/now") {
|
|
795
|
+
ok(res, await storage.backupNow());
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
const backup = BACKUP_RE2.exec(path);
|
|
799
|
+
if (backup !== null && backup[2] === "/restore") {
|
|
800
|
+
ok(res, await storage.restore({ name: backup[1] }));
|
|
801
|
+
return;
|
|
802
|
+
}
|
|
803
|
+
if (path === "/restore") {
|
|
804
|
+
ok(res, await storage.restore({ data: body }));
|
|
805
|
+
return;
|
|
806
|
+
}
|
|
807
|
+
fail(res, "not_found", "\u672A\u77E5\u8DEF\u5F84");
|
|
808
|
+
} catch (error) {
|
|
809
|
+
if (error instanceof StorageError) {
|
|
810
|
+
fail(res, error.code, error.message, error.current !== void 0 ? { current: error.current } : {});
|
|
811
|
+
return;
|
|
812
|
+
}
|
|
813
|
+
console.error("[dsh-mindmap] route failed:", error);
|
|
814
|
+
fail(res, "internal", error instanceof Error ? error.message : String(error));
|
|
815
|
+
}
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
// src/index.ts
|
|
820
|
+
var name = "dsh-mindmap";
|
|
821
|
+
var inject = [];
|
|
822
|
+
function dataDirectory(env = process.env) {
|
|
823
|
+
if (env.DSH_MINDMAP_DIR !== void 0 && env.DSH_MINDMAP_DIR.length > 0) return resolve(env.DSH_MINDMAP_DIR);
|
|
824
|
+
const home = env.DSH_HOME !== void 0 && env.DSH_HOME.length > 0 ? env.DSH_HOME : join2(homedir(), ".dsh");
|
|
825
|
+
return join2(resolve(home), "dsh-mindmap");
|
|
826
|
+
}
|
|
827
|
+
function apply(ctx) {
|
|
828
|
+
const storage = new MindmapStorage(dataDirectory());
|
|
829
|
+
void storage.ready().catch((error) => console.warn("[dsh-mindmap] storage load failed:", error));
|
|
830
|
+
const handler = createHandler(storage);
|
|
831
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
832
|
+
webCtx.effect(() => webCtx.webServer.register({ kind: "prefix", path: ROUTE_PREFIX, handler }), "dsh-mindmap: routes");
|
|
833
|
+
});
|
|
834
|
+
}
|
|
835
|
+
export {
|
|
836
|
+
apply,
|
|
837
|
+
dataDirectory,
|
|
838
|
+
inject,
|
|
839
|
+
name
|
|
840
|
+
};
|