@huoweifang/dsh-mindmap 0.1.0 → 0.2.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 +30 -0
- package/README.md +6 -6
- package/README.zh-CN.md +6 -6
- package/lib/client.js +90 -21
- package/lib/index.js +457 -107
- package/package.json +1 -1
package/lib/index.js
CHANGED
|
@@ -6,6 +6,9 @@ import { join as join2, resolve } from "node:path";
|
|
|
6
6
|
var ROUTE_PREFIX = "/dsh-mindmap/api";
|
|
7
7
|
|
|
8
8
|
// src/shared/markdown.ts
|
|
9
|
+
function needsEscape(t) {
|
|
10
|
+
return LIST_RE.test(t) || HEADING_RE.test(t) || t.startsWith("\\") || t.startsWith(">") || FENCE_RE.test(t);
|
|
11
|
+
}
|
|
9
12
|
function unescapeFirst(text) {
|
|
10
13
|
const m = /^(?: )+/.exec(text);
|
|
11
14
|
if (m !== null) return " ".repeat(m[0].length / 5) + text.slice(m[0].length);
|
|
@@ -15,40 +18,85 @@ function unescapeRest(line) {
|
|
|
15
18
|
const t = line.trimStart();
|
|
16
19
|
if (!t.startsWith("\\")) return line;
|
|
17
20
|
const u = t.slice(1);
|
|
18
|
-
return
|
|
21
|
+
return needsEscape(u) ? line.slice(0, line.length - t.length) + u : line;
|
|
19
22
|
}
|
|
20
23
|
var LIST_RE = /^(\s*)([-*+]|\d{1,9}[.)])(\s+|$)(.*)$/;
|
|
21
24
|
var HEADING_RE = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
|
|
22
25
|
var CHECK_RE = /^\[( |x|X)\](?:\s+|$)([\s\S]*)$/;
|
|
26
|
+
var FENCE_RE = /^(```+|~~~+)/;
|
|
27
|
+
var QUOTE_RE = /^>\s?(.*)$/;
|
|
23
28
|
function indentOf(line) {
|
|
24
29
|
return line.length - line.trimStart().length;
|
|
25
30
|
}
|
|
31
|
+
function addNote(item, text, blanks) {
|
|
32
|
+
item.note = item.note === void 0 ? text : item.note + "\n".repeat(blanks > 0 ? 2 : 1) + text;
|
|
33
|
+
}
|
|
26
34
|
function parseMarkdown(src, options = {}) {
|
|
27
35
|
const detectTitle = options.detectTitle ?? true;
|
|
28
36
|
const lines = src.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").split("\n").map((l) => l.replace(/\t/g, " "));
|
|
29
|
-
|
|
37
|
+
let h1Count = 0;
|
|
38
|
+
{
|
|
39
|
+
let fence = null;
|
|
40
|
+
for (const l of lines) {
|
|
41
|
+
const fm = FENCE_RE.exec(l.trimStart());
|
|
42
|
+
if (fence !== null) {
|
|
43
|
+
if (fm !== null && fm[1].startsWith(fence)) fence = null;
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
if (fm !== null) {
|
|
47
|
+
fence = fm[1];
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
if (/^#\s+\S/.test(l)) h1Count++;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
30
53
|
const root = [];
|
|
31
54
|
const headings = [];
|
|
32
55
|
let list = [];
|
|
33
56
|
let last = null;
|
|
57
|
+
let freshHeading = null;
|
|
34
58
|
let blanks = 0;
|
|
35
59
|
let title;
|
|
36
60
|
const container = () => headings.length > 0 ? headings[headings.length - 1].item.children : root;
|
|
37
|
-
for (
|
|
61
|
+
for (let i = 0; i < lines.length; i++) {
|
|
62
|
+
const line = lines[i];
|
|
38
63
|
if (line.trim().length === 0) {
|
|
39
64
|
blanks++;
|
|
40
65
|
continue;
|
|
41
66
|
}
|
|
67
|
+
const ind = indentOf(line);
|
|
68
|
+
const trimmed = line.slice(ind);
|
|
69
|
+
const fm = FENCE_RE.exec(trimmed);
|
|
70
|
+
if (fm !== null) {
|
|
71
|
+
const block = [trimmed];
|
|
72
|
+
let j = i + 1;
|
|
73
|
+
for (; j < lines.length; j++) {
|
|
74
|
+
const l = lines[j];
|
|
75
|
+
const body = l.slice(Math.min(ind, indentOf(l)));
|
|
76
|
+
block.push(body);
|
|
77
|
+
const close = FENCE_RE.exec(l.trimStart());
|
|
78
|
+
if (close !== null && close[1].startsWith(fm[1]) && l.trimStart().slice(close[1].length).trim().length === 0) break;
|
|
79
|
+
}
|
|
80
|
+
i = j;
|
|
81
|
+
const text = block.join("\n");
|
|
82
|
+
const owner = last !== null && ind > last.marker ? last.item : freshHeading ?? last?.item ?? null;
|
|
83
|
+
if (owner !== null) addNote(owner, text, blanks);
|
|
84
|
+
else container().push({ text, children: [] });
|
|
85
|
+
blanks = 0;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
42
88
|
const hm = HEADING_RE.exec(line);
|
|
43
89
|
if (hm !== null) {
|
|
44
90
|
const level = hm[1].length;
|
|
45
91
|
if (detectTitle && title === void 0 && level === 1 && h1Count === 1 && root.length === 0) {
|
|
46
92
|
title = hm[2];
|
|
93
|
+
freshHeading = null;
|
|
47
94
|
} else {
|
|
48
95
|
while (headings.length > 0 && headings[headings.length - 1].level >= level) headings.pop();
|
|
49
96
|
const item = { text: hm[2], children: [] };
|
|
50
97
|
container().push(item);
|
|
51
98
|
headings.push({ level, item });
|
|
99
|
+
freshHeading = item;
|
|
52
100
|
}
|
|
53
101
|
list = [];
|
|
54
102
|
last = null;
|
|
@@ -72,16 +120,24 @@ function parseMarkdown(src, options = {}) {
|
|
|
72
120
|
(list.length > 0 ? list[list.length - 1].item.children : container()).push(item);
|
|
73
121
|
list.push({ indent, item });
|
|
74
122
|
last = { marker: indent, content: indent + lm[2].length + Math.max(1, lm[3].length), item };
|
|
123
|
+
freshHeading = null;
|
|
75
124
|
blanks = 0;
|
|
76
125
|
continue;
|
|
77
126
|
}
|
|
78
|
-
const
|
|
127
|
+
const qm = QUOTE_RE.exec(trimmed);
|
|
79
128
|
if (last !== null && ind > last.marker) {
|
|
80
|
-
|
|
129
|
+
if (qm !== null) addNote(last.item, qm[1], 0);
|
|
130
|
+
else last.item.text += "\n".repeat(blanks + 1) + unescapeRest(line.slice(Math.min(ind, last.content)));
|
|
81
131
|
blanks = 0;
|
|
82
132
|
continue;
|
|
83
133
|
}
|
|
84
|
-
|
|
134
|
+
const para = qm !== null ? qm[1] : trimmed;
|
|
135
|
+
if (freshHeading !== null) {
|
|
136
|
+
addNote(freshHeading, para, blanks);
|
|
137
|
+
blanks = 0;
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
container().push({ text: para, children: [] });
|
|
85
141
|
list = [];
|
|
86
142
|
last = null;
|
|
87
143
|
blanks = 0;
|
|
@@ -108,7 +164,7 @@ function parseOpml(xml) {
|
|
|
108
164
|
const body = bodyAt >= 0 ? xml.slice(bodyAt) : xml;
|
|
109
165
|
const root = [];
|
|
110
166
|
const stack = [];
|
|
111
|
-
const tokens = /<outline\b([^>]*?)(\/?)>|<\/outline\s*>/gi;
|
|
167
|
+
const tokens = /<outline\b((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>|<\/outline\s*>/gi;
|
|
112
168
|
let m;
|
|
113
169
|
while ((m = tokens.exec(body)) !== null) {
|
|
114
170
|
if (m[0].startsWith("</")) {
|
|
@@ -123,6 +179,8 @@ function parseOpml(xml) {
|
|
|
123
179
|
const flag = attrs._checked ?? attrs._complete;
|
|
124
180
|
if (flag === "true") item.checked = true;
|
|
125
181
|
else if (attrs._checked === "false") item.checked = false;
|
|
182
|
+
const note = (attrs._note ?? "").replace(/\r/g, "");
|
|
183
|
+
if (note.trim().length > 0) item.note = note;
|
|
126
184
|
(stack.length > 0 ? stack[stack.length - 1].children : root).push(item);
|
|
127
185
|
if (m[2] !== "/") stack.push(item);
|
|
128
186
|
}
|
|
@@ -130,11 +188,20 @@ function parseOpml(xml) {
|
|
|
130
188
|
return title !== void 0 && title.length > 0 ? { title, items: root } : { items: root };
|
|
131
189
|
}
|
|
132
190
|
|
|
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
191
|
// src/shared/model.ts
|
|
192
|
+
var MAX_IMPORT_DEPTH = 200;
|
|
193
|
+
function itemsDepth(items) {
|
|
194
|
+
let max = 0;
|
|
195
|
+
const stack = [[items, 1]];
|
|
196
|
+
while (stack.length > 0) {
|
|
197
|
+
const [list, depth] = stack.pop();
|
|
198
|
+
for (const it of list) {
|
|
199
|
+
if (depth > max) max = depth;
|
|
200
|
+
if (it.children.length > 0) stack.push([it.children, depth + 1]);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return max;
|
|
204
|
+
}
|
|
138
205
|
var ID_RE = /^[a-z0-9-]{6,64}$/;
|
|
139
206
|
var MAX_TITLE = 200;
|
|
140
207
|
var counter = 0;
|
|
@@ -150,18 +217,32 @@ function normalizeTitle(raw, fallback = "\u672A\u547D\u540D\u6587\u6863") {
|
|
|
150
217
|
function createDoc(title, now, items = [], id = newId()) {
|
|
151
218
|
const rootId = newId();
|
|
152
219
|
const nodes = {};
|
|
153
|
-
|
|
154
|
-
|
|
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 };
|
|
220
|
+
nodes[rootId] = { id: rootId, text: "", children: [] };
|
|
221
|
+
buildItems(nodes, rootId, items.length > 0 ? items : [{ text: "", children: [] }]);
|
|
163
222
|
return { schema: 1, id, title: normalizeTitle(title), rootId, nodes, revision: 0, createdAt: now, updatedAt: now };
|
|
164
223
|
}
|
|
224
|
+
function nodeFromItem(item) {
|
|
225
|
+
const node = { id: newId(), text: item.text, children: [] };
|
|
226
|
+
if (typeof item.checked === "boolean") node.checked = item.checked;
|
|
227
|
+
if (typeof item.note === "string" && item.note.length > 0) node.note = item.note;
|
|
228
|
+
return node;
|
|
229
|
+
}
|
|
230
|
+
function buildItems(nodes, parentId, items) {
|
|
231
|
+
const top = [];
|
|
232
|
+
const stack = [];
|
|
233
|
+
for (let i = items.length - 1; i >= 0; i--) stack.push([items[i], top]);
|
|
234
|
+
const order = [];
|
|
235
|
+
while (stack.length > 0) {
|
|
236
|
+
const [item, into] = stack.pop();
|
|
237
|
+
const node = nodeFromItem(item);
|
|
238
|
+
nodes[node.id] = node;
|
|
239
|
+
order.push([node.id, into]);
|
|
240
|
+
for (let i = item.children.length - 1; i >= 0; i--) stack.push([item.children[i], node.children]);
|
|
241
|
+
}
|
|
242
|
+
for (const [id, into] of order) into.push(id);
|
|
243
|
+
if (parentId !== null) nodes[parentId].children.push(...top);
|
|
244
|
+
return top;
|
|
245
|
+
}
|
|
165
246
|
function validateDoc(value) {
|
|
166
247
|
if (typeof value !== "object" || value === null) return "document is not an object";
|
|
167
248
|
const doc = value;
|
|
@@ -170,6 +251,7 @@ function validateDoc(value) {
|
|
|
170
251
|
if (typeof doc.title !== "string") return "invalid title";
|
|
171
252
|
if (typeof doc.revision !== "number" || !Number.isInteger(doc.revision) || doc.revision < 0) return "invalid revision";
|
|
172
253
|
if (typeof doc.nodes !== "object" || doc.nodes === null) return "invalid nodes";
|
|
254
|
+
if (doc.view !== void 0 && (typeof doc.view !== "object" || doc.view === null)) return "invalid view";
|
|
173
255
|
const root = doc.nodes[doc.rootId];
|
|
174
256
|
if (root === void 0) return "root node missing";
|
|
175
257
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -182,6 +264,7 @@ function validateDoc(value) {
|
|
|
182
264
|
if (node === void 0) return `dangling child reference ${id}`;
|
|
183
265
|
if (node.id !== id) return `node key mismatch ${id}`;
|
|
184
266
|
if (typeof node.text !== "string") return `node ${id} has no text`;
|
|
267
|
+
if (node.note !== void 0 && typeof node.note !== "string") return `node ${id} has an invalid note`;
|
|
185
268
|
if (!Array.isArray(node.children)) return `node ${id} has no children array`;
|
|
186
269
|
for (const child of node.children) {
|
|
187
270
|
if (typeof child !== "string") return `node ${id} has a non-string child`;
|
|
@@ -192,6 +275,26 @@ function validateDoc(value) {
|
|
|
192
275
|
if (seen.size !== total) return `${total - seen.size} orphan node(s)`;
|
|
193
276
|
return null;
|
|
194
277
|
}
|
|
278
|
+
function toItems(doc, ids) {
|
|
279
|
+
const out = [];
|
|
280
|
+
const stack = [];
|
|
281
|
+
for (let i = ids.length - 1; i >= 0; i--) stack.push([ids[i], out]);
|
|
282
|
+
while (stack.length > 0) {
|
|
283
|
+
const [id, into] = stack.pop();
|
|
284
|
+
const node = doc.nodes[id];
|
|
285
|
+
if (node === void 0) continue;
|
|
286
|
+
const item = { text: node.text, children: [] };
|
|
287
|
+
if (typeof node.checked === "boolean") item.checked = node.checked;
|
|
288
|
+
if (typeof node.note === "string" && node.note.length > 0) item.note = node.note;
|
|
289
|
+
into.push(item);
|
|
290
|
+
for (let i = node.children.length - 1; i >= 0; i--) stack.push([node.children[i], item.children]);
|
|
291
|
+
}
|
|
292
|
+
return out;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// src/host/storage.ts
|
|
296
|
+
import { mkdir, open, readFile, readdir, rename, rm, stat } from "node:fs/promises";
|
|
297
|
+
import { dirname, join } from "node:path";
|
|
195
298
|
|
|
196
299
|
// src/shared/search.ts
|
|
197
300
|
function snippetOf(text, at, len) {
|
|
@@ -200,29 +303,48 @@ function snippetOf(text, at, len) {
|
|
|
200
303
|
const end = Math.min(flat.length, at + len + 40);
|
|
201
304
|
return (start > 0 ? "\u2026" : "") + flat.slice(start, end) + (end < flat.length ? "\u2026" : "");
|
|
202
305
|
}
|
|
203
|
-
function
|
|
204
|
-
const
|
|
205
|
-
|
|
306
|
+
function indexDoc(doc) {
|
|
307
|
+
const entries = [];
|
|
308
|
+
const stack = (doc.nodes[doc.rootId]?.children ?? []).slice().reverse();
|
|
309
|
+
while (stack.length > 0) {
|
|
310
|
+
const id = stack.pop();
|
|
311
|
+
const n = doc.nodes[id];
|
|
312
|
+
if (n === void 0) continue;
|
|
313
|
+
entries.push([id, n.text, n.note ?? ""]);
|
|
314
|
+
for (let i = n.children.length - 1; i >= 0; i--) stack.push(n.children[i]);
|
|
315
|
+
}
|
|
316
|
+
return { docId: doc.id, revision: doc.revision, entries };
|
|
317
|
+
}
|
|
318
|
+
function searchIndex(index, title, query, limit = 20) {
|
|
319
|
+
const q = query.trim().toLowerCase().replace(/\s+/g, " ");
|
|
320
|
+
if (q.length === 0 || limit <= 0) return [];
|
|
206
321
|
const hits = [];
|
|
207
322
|
const ti = title.toLowerCase().indexOf(q);
|
|
208
|
-
if (ti >= 0) hits.push({ docId:
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
if (at >= 0) hits.push({ docId: doc.id, title, nodeId: id, snippet: snippetOf(n.text, at, q.length) });
|
|
216
|
-
walk(n.children);
|
|
323
|
+
if (ti >= 0) hits.push({ docId: index.docId, title, nodeId: null, snippet: snippetOf(title, ti, q.length) });
|
|
324
|
+
for (const [id, text, note] of index.entries) {
|
|
325
|
+
if (hits.length >= limit) break;
|
|
326
|
+
const at = text.toLowerCase().replace(/\s/g, " ").indexOf(q);
|
|
327
|
+
if (at >= 0) {
|
|
328
|
+
hits.push({ docId: index.docId, title, nodeId: id, snippet: snippetOf(text, at, q.length) });
|
|
329
|
+
continue;
|
|
217
330
|
}
|
|
218
|
-
|
|
219
|
-
|
|
331
|
+
const nt = note.length > 0 ? note.toLowerCase().replace(/\s/g, " ").indexOf(q) : -1;
|
|
332
|
+
if (nt >= 0) hits.push({ docId: index.docId, title, nodeId: id, snippet: snippetOf(note, nt, q.length) });
|
|
333
|
+
}
|
|
220
334
|
return hits;
|
|
221
335
|
}
|
|
222
336
|
|
|
223
337
|
// src/host/storage.ts
|
|
224
|
-
var
|
|
225
|
-
var BACKUP_RE = /^mindmap-\d{8}(?:-\d{6})?(?:-pre-restore)?\.json$/;
|
|
338
|
+
var BACKUP_KEEP = { daily: 14, manual: 10, "pre-restore": 5, purge: 5 };
|
|
339
|
+
var BACKUP_RE = /^mindmap-\d{8}(?:-\d{6})?(?:-(?:pre-restore|purge))?\.json$/;
|
|
340
|
+
var TRASH_RETENTION_MS = 30 * 24 * 3600 * 1e3;
|
|
341
|
+
var DOC_CACHE_LIMIT = 50;
|
|
342
|
+
var PURGE_BACKUP_DEDUPE_MS = 60 * 1e3;
|
|
343
|
+
function backupKind(name2) {
|
|
344
|
+
if (name2.endsWith("-pre-restore.json")) return "pre-restore";
|
|
345
|
+
if (name2.endsWith("-purge.json")) return "purge";
|
|
346
|
+
return /^mindmap-\d{8}\.json$/.test(name2) ? "daily" : "manual";
|
|
347
|
+
}
|
|
226
348
|
var StorageError = class extends Error {
|
|
227
349
|
code;
|
|
228
350
|
current;
|
|
@@ -267,6 +389,26 @@ async function atomicWrite(file, contents) {
|
|
|
267
389
|
function isEnoent(error) {
|
|
268
390
|
return error?.code === "ENOENT";
|
|
269
391
|
}
|
|
392
|
+
async function renameRetry(from, to) {
|
|
393
|
+
for (let attempt = 0; ; attempt++) {
|
|
394
|
+
try {
|
|
395
|
+
await rename(from, to);
|
|
396
|
+
return;
|
|
397
|
+
} catch (error) {
|
|
398
|
+
const code = error.code;
|
|
399
|
+
if (attempt >= 8 || code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") throw error;
|
|
400
|
+
await sleep(50 * (attempt + 1));
|
|
401
|
+
}
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
async function exists(path) {
|
|
405
|
+
try {
|
|
406
|
+
await stat(path);
|
|
407
|
+
return true;
|
|
408
|
+
} catch {
|
|
409
|
+
return false;
|
|
410
|
+
}
|
|
411
|
+
}
|
|
270
412
|
function sanitizeMeta(raw) {
|
|
271
413
|
if (typeof raw !== "object" || raw === null) return null;
|
|
272
414
|
const m = raw;
|
|
@@ -282,17 +424,31 @@ function sanitizeMeta(raw) {
|
|
|
282
424
|
if (typeof m.trashedAt === "number") meta.trashedAt = m.trashedAt;
|
|
283
425
|
return meta;
|
|
284
426
|
}
|
|
427
|
+
function wouldCycle(folders, id, parentId) {
|
|
428
|
+
const byId = new Map(folders.map((f) => [f.id, f]));
|
|
429
|
+
let cur = parentId;
|
|
430
|
+
for (let guard = 0; cur !== null && guard <= folders.length; guard++) {
|
|
431
|
+
if (cur === id) return true;
|
|
432
|
+
cur = byId.get(cur)?.parentId ?? null;
|
|
433
|
+
}
|
|
434
|
+
return cur !== null;
|
|
435
|
+
}
|
|
285
436
|
function sanitizeLibrary(raw) {
|
|
286
437
|
if (typeof raw !== "object" || raw === null) return null;
|
|
287
438
|
const l = raw;
|
|
288
439
|
if (!Array.isArray(l.docs) || !Array.isArray(l.folders)) return null;
|
|
289
440
|
const folders = [];
|
|
441
|
+
const seenFolders = /* @__PURE__ */ new Set();
|
|
290
442
|
for (const f of l.folders) {
|
|
291
|
-
if (typeof f
|
|
292
|
-
|
|
293
|
-
|
|
443
|
+
if (typeof f !== "object" || f === null) continue;
|
|
444
|
+
const r = f;
|
|
445
|
+
if (typeof r.id !== "string" || !ID_RE.test(r.id) || typeof r.name !== "string" || seenFolders.has(r.id)) continue;
|
|
446
|
+
seenFolders.add(r.id);
|
|
447
|
+
folders.push({ id: r.id, name: normalizeTitle(r.name, "\u65B0\u5EFA\u6587\u4EF6\u5939"), createdAt: Number(r.createdAt) || 0, parentId: typeof r.parentId === "string" ? r.parentId : null });
|
|
294
448
|
}
|
|
295
449
|
const ids = new Set(folders.map((f) => f.id));
|
|
450
|
+
for (const f of folders) if (f.parentId !== null && f.parentId !== void 0 && !ids.has(f.parentId)) f.parentId = null;
|
|
451
|
+
for (const f of folders) if (f.parentId !== null && f.parentId !== void 0 && wouldCycle(folders, f.id, f.parentId)) f.parentId = null;
|
|
296
452
|
const docs = [];
|
|
297
453
|
const seen = /* @__PURE__ */ new Set();
|
|
298
454
|
for (const d of l.docs) {
|
|
@@ -308,11 +464,14 @@ var MindmapStorage = class {
|
|
|
308
464
|
dir;
|
|
309
465
|
now;
|
|
310
466
|
library = { schema: 1, folders: [], docs: [] };
|
|
467
|
+
/** LRU cache of whole documents (Map keeps insertion order). */
|
|
311
468
|
docs = /* @__PURE__ */ new Map();
|
|
469
|
+
/** Compact text index of every readable document, for search. */
|
|
470
|
+
index = /* @__PURE__ */ new Map();
|
|
312
471
|
queue = Promise.resolve();
|
|
313
472
|
loading;
|
|
314
|
-
|
|
315
|
-
|
|
473
|
+
pendingWarnings = [];
|
|
474
|
+
lastPurgeBackup = null;
|
|
316
475
|
constructor(dir, now = () => Date.now()) {
|
|
317
476
|
this.dir = dir;
|
|
318
477
|
this.now = now;
|
|
@@ -323,12 +482,32 @@ var MindmapStorage = class {
|
|
|
323
482
|
get docsDir() {
|
|
324
483
|
return join(this.dir, "docs");
|
|
325
484
|
}
|
|
485
|
+
get stagingDir() {
|
|
486
|
+
return join(this.dir, "docs.new");
|
|
487
|
+
}
|
|
488
|
+
get retiredDir() {
|
|
489
|
+
return join(this.dir, "docs.old");
|
|
490
|
+
}
|
|
491
|
+
get restoreMarker() {
|
|
492
|
+
return join(this.dir, "restore.pending.json");
|
|
493
|
+
}
|
|
326
494
|
get backupsDir() {
|
|
327
495
|
return join(this.dir, "backups");
|
|
328
496
|
}
|
|
329
497
|
docFile(id) {
|
|
330
498
|
return join(this.docsDir, `${id}.json`);
|
|
331
499
|
}
|
|
500
|
+
/** Problems found while loading that have not been reported yet. */
|
|
501
|
+
get warnings() {
|
|
502
|
+
return this.pendingWarnings;
|
|
503
|
+
}
|
|
504
|
+
/** Return the unreported warnings and forget them (each is reported once). */
|
|
505
|
+
async takeWarnings() {
|
|
506
|
+
await this.ready();
|
|
507
|
+
const out = this.pendingWarnings;
|
|
508
|
+
this.pendingWarnings = [];
|
|
509
|
+
return out;
|
|
510
|
+
}
|
|
332
511
|
/** Serialize every mutation (and the initial load) through one queue. */
|
|
333
512
|
serial(fn) {
|
|
334
513
|
const run = this.queue.then(() => this.ready()).then(fn);
|
|
@@ -344,10 +523,35 @@ var MindmapStorage = class {
|
|
|
344
523
|
const target = file.replace(/\.json$/, `.corrupt-${stamp(this.now(), true)}.json`);
|
|
345
524
|
await rename(file, target).catch(() => {
|
|
346
525
|
});
|
|
347
|
-
this.
|
|
526
|
+
this.pendingWarnings.push(`${why}\uFF1A\u5DF2\u9694\u79BB\u4E3A ${target}`);
|
|
348
527
|
console.warn(`[dsh-mindmap] ${why}; quarantined as ${target}`);
|
|
349
528
|
}
|
|
529
|
+
/**
|
|
530
|
+
* Finish or roll back a restore interrupted by a crash. The marker holds the
|
|
531
|
+
* library to install; docs.new present means the directory swap never
|
|
532
|
+
* completed, so the old documents are put back.
|
|
533
|
+
*/
|
|
534
|
+
async recoverRestore() {
|
|
535
|
+
const marker = await readFile(this.restoreMarker, "utf8").catch(() => null);
|
|
536
|
+
if (marker === null) {
|
|
537
|
+
await rm(this.stagingDir, { recursive: true, force: true });
|
|
538
|
+
await rm(this.retiredDir, { recursive: true, force: true });
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
if (await exists(this.stagingDir)) {
|
|
542
|
+
if (!await exists(this.docsDir) && await exists(this.retiredDir)) await renameRetry(this.retiredDir, this.docsDir);
|
|
543
|
+
await rm(this.stagingDir, { recursive: true, force: true });
|
|
544
|
+
this.pendingWarnings.push("\u4E0A\u6B21\u6062\u590D\u5907\u4EFD\u672A\u5B8C\u6210\uFF0C\u5DF2\u56DE\u6EDA\u5230\u6062\u590D\u524D\u7684\u72B6\u6001");
|
|
545
|
+
} else {
|
|
546
|
+
await atomicWrite(this.libraryFile, marker);
|
|
547
|
+
await rm(this.retiredDir, { recursive: true, force: true });
|
|
548
|
+
this.pendingWarnings.push("\u4E0A\u6B21\u6062\u590D\u5907\u4EFD\u5728\u4E2D\u9014\u4E2D\u65AD\uFF0C\u5DF2\u5B8C\u6210\u6062\u590D");
|
|
549
|
+
}
|
|
550
|
+
await rm(this.restoreMarker, { force: true });
|
|
551
|
+
}
|
|
350
552
|
async load() {
|
|
553
|
+
await mkdir(this.dir, { recursive: true });
|
|
554
|
+
await this.recoverRestore();
|
|
351
555
|
await mkdir(this.docsDir, { recursive: true });
|
|
352
556
|
await mkdir(this.backupsDir, { recursive: true });
|
|
353
557
|
let library = null;
|
|
@@ -376,6 +580,15 @@ var MindmapStorage = class {
|
|
|
376
580
|
if (adopted || library === null || lib.docs.length !== before) {
|
|
377
581
|
if (lib.docs.length > 0 || library !== null) await this.writeLibrary();
|
|
378
582
|
}
|
|
583
|
+
const cutoff = this.now() - TRASH_RETENTION_MS;
|
|
584
|
+
const expired = lib.docs.filter((d) => d.trashedAt !== void 0 && d.trashedAt < cutoff).map((d) => d.id);
|
|
585
|
+
if (expired.length > 0) {
|
|
586
|
+
try {
|
|
587
|
+
await this.purgeIds(expired);
|
|
588
|
+
} catch (error) {
|
|
589
|
+
console.warn("[dsh-mindmap] trash auto-purge failed:", error);
|
|
590
|
+
}
|
|
591
|
+
}
|
|
379
592
|
}
|
|
380
593
|
async readDocFile(id) {
|
|
381
594
|
const file = this.docFile(id);
|
|
@@ -397,11 +610,17 @@ var MindmapStorage = class {
|
|
|
397
610
|
async writeLibrary() {
|
|
398
611
|
await atomicWrite(this.libraryFile, JSON.stringify(this.library, null, 1));
|
|
399
612
|
}
|
|
613
|
+
remember(doc) {
|
|
614
|
+
this.docs.delete(doc.id);
|
|
615
|
+
this.docs.set(doc.id, doc);
|
|
616
|
+
while (this.docs.size > DOC_CACHE_LIMIT) this.docs.delete(this.docs.keys().next().value);
|
|
617
|
+
this.index.set(doc.id, indexDoc(doc));
|
|
618
|
+
}
|
|
400
619
|
async writeDoc(doc) {
|
|
401
620
|
const problem = validateDoc(doc);
|
|
402
621
|
if (problem !== null) throw new StorageError("invalid_input", `\u6587\u6863\u7ED3\u6784\u65E0\u6548\uFF1A${problem}`);
|
|
403
622
|
await atomicWrite(this.docFile(doc.id), JSON.stringify(doc));
|
|
404
|
-
this.
|
|
623
|
+
this.remember(doc);
|
|
405
624
|
}
|
|
406
625
|
meta(id) {
|
|
407
626
|
if (!ID_RE.test(id)) throw new StorageError("invalid_input", "\u65E0\u6548\u7684\u6587\u6863 id");
|
|
@@ -412,9 +631,13 @@ var MindmapStorage = class {
|
|
|
412
631
|
async loadDoc(id) {
|
|
413
632
|
this.meta(id);
|
|
414
633
|
const cached = this.docs.get(id);
|
|
415
|
-
if (cached !== void 0)
|
|
634
|
+
if (cached !== void 0) {
|
|
635
|
+
this.docs.delete(id);
|
|
636
|
+
this.docs.set(id, cached);
|
|
637
|
+
return cached;
|
|
638
|
+
}
|
|
416
639
|
const doc = await this.readDocFile(id);
|
|
417
|
-
this.
|
|
640
|
+
this.remember(doc);
|
|
418
641
|
return doc;
|
|
419
642
|
}
|
|
420
643
|
checkFolder(folderId) {
|
|
@@ -434,13 +657,15 @@ var MindmapStorage = class {
|
|
|
434
657
|
createDoc(input) {
|
|
435
658
|
return this.serial(async () => {
|
|
436
659
|
const folderId = this.checkFolder(input.folderId);
|
|
660
|
+
const items = input.items ?? [];
|
|
661
|
+
if (itemsDepth(items) > MAX_IMPORT_DEPTH) throw new StorageError("invalid_input", `\u5C42\u7EA7\u8D85\u8FC7 ${MAX_IMPORT_DEPTH} \u7EA7\uFF0C\u65E0\u6CD5\u5BFC\u5165`);
|
|
662
|
+
await this.dailyBackup();
|
|
437
663
|
const now = this.now();
|
|
438
|
-
const doc = createDoc(normalizeTitle(input.title), now,
|
|
664
|
+
const doc = createDoc(normalizeTitle(input.title), now, items);
|
|
439
665
|
await this.writeDoc(doc);
|
|
440
666
|
const meta = { id: doc.id, title: doc.title, folderId, favorite: false, createdAt: now, updatedAt: now };
|
|
441
667
|
this.library.docs.push(meta);
|
|
442
668
|
await this.writeLibrary();
|
|
443
|
-
await this.dailyBackup();
|
|
444
669
|
return { meta: { ...meta }, doc };
|
|
445
670
|
});
|
|
446
671
|
}
|
|
@@ -456,13 +681,13 @@ var MindmapStorage = class {
|
|
|
456
681
|
if (problem !== null) throw new StorageError("invalid_input", `\u6587\u6863\u7ED3\u6784\u65E0\u6548\uFF1A${problem}`);
|
|
457
682
|
const doc = incoming;
|
|
458
683
|
if (doc.id !== id) throw new StorageError("invalid_input", "\u6587\u6863 id \u4E0D\u5339\u914D");
|
|
684
|
+
await this.dailyBackup();
|
|
459
685
|
const now = this.now();
|
|
460
686
|
const next = { ...doc, title: normalizeTitle(doc.title), revision: current.revision + 1, createdAt: current.createdAt, updatedAt: now };
|
|
461
687
|
await this.writeDoc(next);
|
|
462
688
|
meta.title = next.title;
|
|
463
689
|
meta.updatedAt = now;
|
|
464
690
|
await this.writeLibrary();
|
|
465
|
-
await this.dailyBackup();
|
|
466
691
|
return next;
|
|
467
692
|
});
|
|
468
693
|
}
|
|
@@ -477,6 +702,7 @@ var MindmapStorage = class {
|
|
|
477
702
|
const title = normalizeTitle(body.title);
|
|
478
703
|
const current = await this.loadDoc(id);
|
|
479
704
|
if (current.title !== title) {
|
|
705
|
+
await this.dailyBackup();
|
|
480
706
|
const now = this.now();
|
|
481
707
|
doc = { ...current, title, revision: current.revision + 1, updatedAt: now };
|
|
482
708
|
await this.writeDoc(doc);
|
|
@@ -505,21 +731,42 @@ var MindmapStorage = class {
|
|
|
505
731
|
return { ...meta };
|
|
506
732
|
});
|
|
507
733
|
}
|
|
734
|
+
/** Remove documents for good, after one (coalesced) purge backup. Caller holds the queue. */
|
|
735
|
+
async purgeIds(ids) {
|
|
736
|
+
if (ids.length === 0) return;
|
|
737
|
+
const last = this.lastPurgeBackup;
|
|
738
|
+
if (last === null || this.now() - last.at > PURGE_BACKUP_DEDUPE_MS) {
|
|
739
|
+
this.lastPurgeBackup = { at: this.now(), info: await this.backupInternal("purge") };
|
|
740
|
+
}
|
|
741
|
+
const gone = new Set(ids);
|
|
742
|
+
this.library.docs = this.library.docs.filter((d) => !gone.has(d.id));
|
|
743
|
+
await this.writeLibrary();
|
|
744
|
+
for (const id of gone) {
|
|
745
|
+
this.docs.delete(id);
|
|
746
|
+
this.index.delete(id);
|
|
747
|
+
await rm(this.docFile(id), { force: true });
|
|
748
|
+
}
|
|
749
|
+
}
|
|
508
750
|
/** Permanent delete; only trashed documents. */
|
|
509
751
|
purgeDoc(id) {
|
|
510
752
|
return this.serial(async () => {
|
|
511
753
|
const meta = this.meta(id);
|
|
512
754
|
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.
|
|
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 });
|
|
755
|
+
await this.purgeIds([id]);
|
|
518
756
|
});
|
|
519
757
|
}
|
|
520
|
-
|
|
758
|
+
/** Permanently delete everything in the trash; returns how many documents went. */
|
|
759
|
+
emptyTrash() {
|
|
521
760
|
return this.serial(async () => {
|
|
522
|
-
const
|
|
761
|
+
const ids = this.library.docs.filter((d) => d.trashedAt !== void 0).map((d) => d.id);
|
|
762
|
+
await this.purgeIds(ids);
|
|
763
|
+
return { purged: ids.length };
|
|
764
|
+
});
|
|
765
|
+
}
|
|
766
|
+
createFolder(name2, parentId = null) {
|
|
767
|
+
return this.serial(async () => {
|
|
768
|
+
const parent = this.checkFolder(parentId);
|
|
769
|
+
const folder = { id: newId(), name: normalizeTitle(name2, "\u65B0\u5EFA\u6587\u4EF6\u5939"), createdAt: this.now(), parentId: parent };
|
|
523
770
|
this.library.folders.push(folder);
|
|
524
771
|
await this.writeLibrary();
|
|
525
772
|
return { ...folder };
|
|
@@ -534,36 +781,71 @@ var MindmapStorage = class {
|
|
|
534
781
|
return { ...folder };
|
|
535
782
|
});
|
|
536
783
|
}
|
|
537
|
-
/**
|
|
784
|
+
/** Move a folder under another folder (null = top level); cycles are refused. */
|
|
785
|
+
moveFolder(id, parentId) {
|
|
786
|
+
return this.serial(async () => {
|
|
787
|
+
const folder = this.library.folders.find((f) => f.id === id);
|
|
788
|
+
if (folder === void 0) throw new StorageError("not_found", "\u6587\u4EF6\u5939\u4E0D\u5B58\u5728");
|
|
789
|
+
const parent = this.checkFolder(parentId);
|
|
790
|
+
if (parent !== null && wouldCycle(this.library.folders, id, parent)) throw new StorageError("invalid_input", "\u4E0D\u80FD\u628A\u6587\u4EF6\u5939\u79FB\u5230\u5B83\u81EA\u5DF1\u7684\u5B50\u6587\u4EF6\u5939\u91CC");
|
|
791
|
+
folder.parentId = parent;
|
|
792
|
+
await this.writeLibrary();
|
|
793
|
+
return { ...folder };
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
/** Delete a folder; its documents and sub-folders move up one level (never deleted). */
|
|
538
797
|
deleteFolder(id) {
|
|
539
798
|
return this.serial(async () => {
|
|
540
|
-
|
|
799
|
+
const folder = this.library.folders.find((f) => f.id === id);
|
|
800
|
+
if (folder === void 0) throw new StorageError("not_found", "\u6587\u4EF6\u5939\u4E0D\u5B58\u5728");
|
|
801
|
+
const up = folder.parentId ?? null;
|
|
541
802
|
this.library.folders = this.library.folders.filter((f) => f.id !== id);
|
|
542
|
-
for (const
|
|
803
|
+
for (const f of this.library.folders) if (f.parentId === id) f.parentId = up;
|
|
804
|
+
for (const d of this.library.docs) if (d.folderId === id) d.folderId = up;
|
|
543
805
|
await this.writeLibrary();
|
|
544
806
|
});
|
|
545
807
|
}
|
|
546
|
-
/** Title + node text search over non-trashed documents. */
|
|
547
|
-
|
|
808
|
+
/** Title + node text + note search over non-trashed documents (uses the text index). */
|
|
809
|
+
search(query, limit = 100) {
|
|
810
|
+
return this.serial(async () => {
|
|
811
|
+
const hits = [];
|
|
812
|
+
if (query.trim().length === 0) return hits;
|
|
813
|
+
for (const meta of this.library.docs) {
|
|
814
|
+
if (meta.trashedAt !== void 0) continue;
|
|
815
|
+
if (hits.length >= limit) break;
|
|
816
|
+
let idx = this.index.get(meta.id);
|
|
817
|
+
if (idx === void 0) {
|
|
818
|
+
try {
|
|
819
|
+
idx = indexDoc(await this.readDocFile(meta.id));
|
|
820
|
+
this.index.set(meta.id, idx);
|
|
821
|
+
} catch {
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
hits.push(...searchIndex(idx, meta.title, query, limit - hits.length));
|
|
826
|
+
}
|
|
827
|
+
return hits;
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
/** Quarantined (corrupt) files, newest first; paths relative to the data directory. */
|
|
831
|
+
async listQuarantine() {
|
|
548
832
|
await this.ready();
|
|
549
|
-
const
|
|
550
|
-
for (const
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
hits.push(...searchDoc(doc, meta.title, query, limit - hits.length));
|
|
556
|
-
} catch {
|
|
833
|
+
const out = [];
|
|
834
|
+
for (const [sub, dir] of [["", this.dir], ["docs/", this.docsDir]]) {
|
|
835
|
+
for (const name2 of await readdir(dir).catch(() => [])) {
|
|
836
|
+
if (!/\.corrupt-\d{8}-\d{6}\.json$/.test(name2)) continue;
|
|
837
|
+
const s = await stat(join(dir, name2)).catch(() => null);
|
|
838
|
+
if (s !== null && s.isFile()) out.push({ name: sub + name2, size: s.size, createdAt: s.mtimeMs });
|
|
557
839
|
}
|
|
558
840
|
}
|
|
559
|
-
return
|
|
841
|
+
return out.sort((a, b) => b.createdAt - a.createdAt);
|
|
560
842
|
}
|
|
561
843
|
// -------------------------------------------------------------- backups
|
|
562
844
|
async snapshot() {
|
|
563
845
|
const docs = [];
|
|
564
846
|
for (const meta of this.library.docs) {
|
|
565
847
|
try {
|
|
566
|
-
docs.push(await this.
|
|
848
|
+
docs.push(this.docs.get(meta.id) ?? await this.readDocFile(meta.id));
|
|
567
849
|
} catch {
|
|
568
850
|
}
|
|
569
851
|
}
|
|
@@ -571,34 +853,44 @@ var MindmapStorage = class {
|
|
|
571
853
|
}
|
|
572
854
|
async backupInternal(kind) {
|
|
573
855
|
const now = this.now();
|
|
574
|
-
const
|
|
856
|
+
const suffix = kind === "pre-restore" ? "-pre-restore" : kind === "purge" ? "-purge" : "";
|
|
857
|
+
let name2 = kind === "daily" ? `mindmap-${stamp(now, false)}.json` : `mindmap-${stamp(now, true)}${suffix}.json`;
|
|
858
|
+
for (let bump = 1; kind !== "daily" && await exists(join(this.backupsDir, name2)) && bump < 60; bump++) {
|
|
859
|
+
name2 = `mindmap-${stamp(now + bump * 1e3, true)}${suffix}.json`;
|
|
860
|
+
}
|
|
575
861
|
const contents = JSON.stringify(await this.snapshot());
|
|
576
862
|
await atomicWrite(join(this.backupsDir, name2), contents);
|
|
577
863
|
await this.prune();
|
|
578
|
-
return { name: name2, size: Buffer.byteLength(contents), createdAt: now };
|
|
864
|
+
return { name: name2, size: Buffer.byteLength(contents), createdAt: now, kind };
|
|
579
865
|
}
|
|
580
|
-
/**
|
|
866
|
+
/**
|
|
867
|
+
* The first write of a local day snapshots the library as it was BEFORE that
|
|
868
|
+
* write, so the day's starting state is always recoverable. Empty libraries
|
|
869
|
+
* are not backed up.
|
|
870
|
+
*/
|
|
581
871
|
async dailyBackup() {
|
|
872
|
+
if (this.library.docs.length === 0) return;
|
|
582
873
|
const name2 = `mindmap-${stamp(this.now(), false)}.json`;
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
} catch (error) {
|
|
586
|
-
if (!isEnoent(error)) return;
|
|
587
|
-
await this.backupInternal("daily").catch((e) => console.warn("[dsh-mindmap] daily backup failed:", e));
|
|
588
|
-
}
|
|
874
|
+
if (await exists(join(this.backupsDir, name2))) return;
|
|
875
|
+
await this.backupInternal("daily").catch((e) => console.warn("[dsh-mindmap] daily backup failed:", e));
|
|
589
876
|
}
|
|
877
|
+
/** Keep the newest BACKUP_KEEP[kind] files of each kind. */
|
|
590
878
|
async prune() {
|
|
591
|
-
const
|
|
592
|
-
for (const
|
|
879
|
+
const seen = { daily: 0, manual: 0, "pre-restore": 0, purge: 0 };
|
|
880
|
+
for (const b of await this.listBackupsRaw()) {
|
|
881
|
+
const kind = b.kind ?? "manual";
|
|
882
|
+
seen[kind]++;
|
|
883
|
+
if (seen[kind] > BACKUP_KEEP[kind]) await rm(join(this.backupsDir, b.name), { force: true });
|
|
884
|
+
}
|
|
593
885
|
}
|
|
594
886
|
async listBackupsRaw() {
|
|
595
887
|
const out = [];
|
|
596
888
|
for (const name2 of await readdir(this.backupsDir).catch(() => [])) {
|
|
597
889
|
if (!BACKUP_RE.test(name2)) continue;
|
|
598
890
|
const s = await stat(join(this.backupsDir, name2)).catch(() => null);
|
|
599
|
-
if (s !== null) out.push({ name: name2, size: s.size, createdAt: s.mtimeMs });
|
|
891
|
+
if (s !== null) out.push({ name: name2, size: s.size, createdAt: s.mtimeMs, kind: backupKind(name2) });
|
|
600
892
|
}
|
|
601
|
-
return out.sort((a, b) => b.createdAt - a.createdAt);
|
|
893
|
+
return out.sort((a, b) => b.createdAt - a.createdAt || (a.name < b.name ? 1 : -1));
|
|
602
894
|
}
|
|
603
895
|
async listBackups() {
|
|
604
896
|
await this.ready();
|
|
@@ -619,13 +911,21 @@ var MindmapStorage = class {
|
|
|
619
911
|
}
|
|
620
912
|
/**
|
|
621
913
|
* Replace everything with a backup. The current state is first saved as a
|
|
622
|
-
* "-pre-restore" backup, so a restore is itself reversible.
|
|
914
|
+
* "-pre-restore" backup, so a restore is itself reversible. The new documents
|
|
915
|
+
* are staged in docs.new/ and swapped in with directory renames; a crash at
|
|
916
|
+
* any point is finished or rolled back on the next start (recoverRestore).
|
|
623
917
|
*/
|
|
624
918
|
restore(source) {
|
|
625
919
|
return this.serial(async () => {
|
|
626
920
|
let raw;
|
|
627
|
-
if ("name" in source)
|
|
628
|
-
|
|
921
|
+
if ("name" in source) {
|
|
922
|
+
const text = await this.readBackup(source.name);
|
|
923
|
+
try {
|
|
924
|
+
raw = JSON.parse(text);
|
|
925
|
+
} catch {
|
|
926
|
+
throw new StorageError("invalid_input", "\u5907\u4EFD\u6587\u4EF6\u4E0D\u662F\u6709\u6548 JSON");
|
|
927
|
+
}
|
|
928
|
+
} else raw = source.data;
|
|
629
929
|
const file = raw;
|
|
630
930
|
if (typeof file !== "object" || file === null || file.kind !== "dsh-mindmap-backup" || !Array.isArray(file.docs)) {
|
|
631
931
|
throw new StorageError("invalid_input", "\u4E0D\u662F\u6709\u6548\u7684\u601D\u7EF4\u5BFC\u56FE\u5907\u4EFD\u6587\u4EF6");
|
|
@@ -639,28 +939,34 @@ var MindmapStorage = class {
|
|
|
639
939
|
const byId = new Map(file.docs.map((d) => [d.id, d]));
|
|
640
940
|
library.docs = library.docs.filter((d) => byId.has(d.id));
|
|
641
941
|
const pre = await this.backupInternal("pre-restore");
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
942
|
+
await rm(this.stagingDir, { recursive: true, force: true });
|
|
943
|
+
await rm(this.retiredDir, { recursive: true, force: true });
|
|
944
|
+
await mkdir(this.stagingDir, { recursive: true });
|
|
945
|
+
for (const meta of library.docs) await atomicWrite(join(this.stagingDir, `${meta.id}.json`), JSON.stringify(byId.get(meta.id)));
|
|
946
|
+
const libText = JSON.stringify(library, null, 1);
|
|
947
|
+
await atomicWrite(this.restoreMarker, libText);
|
|
948
|
+
await renameRetry(this.docsDir, this.retiredDir);
|
|
949
|
+
await renameRetry(this.stagingDir, this.docsDir);
|
|
950
|
+
await atomicWrite(this.libraryFile, libText);
|
|
951
|
+
await rm(this.restoreMarker, { force: true });
|
|
952
|
+
await rm(this.retiredDir, { recursive: true, force: true });
|
|
648
953
|
this.docs.clear();
|
|
954
|
+
this.index.clear();
|
|
649
955
|
this.library = library;
|
|
650
|
-
await this.writeLibrary();
|
|
651
956
|
return { docs: library.docs.length, preRestore: pre.name };
|
|
652
957
|
});
|
|
653
958
|
}
|
|
654
959
|
};
|
|
655
960
|
|
|
656
961
|
// src/host/routes.ts
|
|
657
|
-
var
|
|
962
|
+
var BODY_LIMITS = { save: 32 * 1024 * 1024, restore: 64 * 1024 * 1024, other: 1024 * 1024 };
|
|
658
963
|
var STATUS = { invalid_input: 400, not_found: 404, conflict: 409, too_large: 413, internal: 500 };
|
|
659
964
|
var ID = "([a-z0-9-]{6,64})";
|
|
660
|
-
var DOC_RE = new RegExp(
|
|
661
|
-
var DOC_ACTION_RE = new RegExp(
|
|
662
|
-
var FOLDER_ACTION_RE = new RegExp(
|
|
663
|
-
var BACKUP_RE2 = /^\/backups\/(mindmap-\d{8}(?:-\d{6})?(?:-pre-restore)?\.json)(\/restore)?$/;
|
|
965
|
+
var DOC_RE = new RegExp("^/docs/" + ID + "$");
|
|
966
|
+
var DOC_ACTION_RE = new RegExp("^/docs/" + ID + "/(save|meta|trash|restore|purge)$");
|
|
967
|
+
var FOLDER_ACTION_RE = new RegExp("^/folders/" + ID + "/(rename|delete|move)$");
|
|
968
|
+
var BACKUP_RE2 = /^\/backups\/(mindmap-\d{8}(?:-\d{6})?(?:-(?:pre-restore|purge))?\.json)(\/restore)?$/;
|
|
969
|
+
var LOOPBACK = /* @__PURE__ */ new Set(["127.0.0.1", "localhost", "[::1]"]);
|
|
664
970
|
function send(res, status, body) {
|
|
665
971
|
const text = JSON.stringify(body);
|
|
666
972
|
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
@@ -672,14 +978,33 @@ function ok(res, value) {
|
|
|
672
978
|
function fail(res, code, message, extra = {}) {
|
|
673
979
|
send(res, STATUS[code], { ok: false, error: { code, message, ...extra } });
|
|
674
980
|
}
|
|
675
|
-
|
|
981
|
+
function hostName(value) {
|
|
982
|
+
const v = value.trim().toLowerCase();
|
|
983
|
+
if (v.startsWith("[")) return v.slice(0, v.indexOf("]") + 1);
|
|
984
|
+
const i = v.lastIndexOf(":");
|
|
985
|
+
return i >= 0 ? v.slice(0, i) : v;
|
|
986
|
+
}
|
|
987
|
+
function checkOrigin(req) {
|
|
988
|
+
const host = String(req.headers.host ?? "");
|
|
989
|
+
if (host === "" || !LOOPBACK.has(hostName(host))) return false;
|
|
990
|
+
const origin = req.headers.origin;
|
|
991
|
+
if (origin === void 0) return true;
|
|
992
|
+
try {
|
|
993
|
+
return new URL(String(origin)).host.toLowerCase() === host.trim().toLowerCase();
|
|
994
|
+
} catch {
|
|
995
|
+
return false;
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
async function readBody(req, limit) {
|
|
676
999
|
const type = String(req.headers["content-type"] ?? "");
|
|
677
1000
|
if (!type.toLowerCase().startsWith("application/json")) throw new StorageError("invalid_input", "\u8BF7\u6C42\u5FC5\u987B\u662F application/json");
|
|
1001
|
+
const declared = Number(req.headers["content-length"]);
|
|
1002
|
+
if (Number.isFinite(declared) && declared > limit) throw new StorageError("too_large", "\u8BF7\u6C42\u4F53\u8FC7\u5927");
|
|
678
1003
|
const chunks = [];
|
|
679
1004
|
let size = 0;
|
|
680
1005
|
for await (const chunk of req) {
|
|
681
1006
|
size += chunk.length;
|
|
682
|
-
if (size >
|
|
1007
|
+
if (size > limit) throw new StorageError("too_large", "\u8BF7\u6C42\u4F53\u8FC7\u5927");
|
|
683
1008
|
chunks.push(chunk);
|
|
684
1009
|
}
|
|
685
1010
|
const raw = Buffer.concat(chunks).toString("utf8");
|
|
@@ -693,9 +1018,18 @@ async function readBody(req) {
|
|
|
693
1018
|
function obj(value) {
|
|
694
1019
|
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
695
1020
|
}
|
|
1021
|
+
function limitFor(path) {
|
|
1022
|
+
if (path === "/restore") return BODY_LIMITS.restore;
|
|
1023
|
+
if (path === "/docs" || path.endsWith("/save")) return BODY_LIMITS.save;
|
|
1024
|
+
return BODY_LIMITS.other;
|
|
1025
|
+
}
|
|
696
1026
|
function createHandler(storage) {
|
|
697
1027
|
return async (req, res) => {
|
|
698
1028
|
try {
|
|
1029
|
+
if (!checkOrigin(req)) {
|
|
1030
|
+
send(res, 403, { ok: false, error: { code: "invalid_input", message: "\u62D2\u7EDD\u8BBF\u95EE" } });
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
699
1033
|
const url = new URL(req.url ?? "/", "http://localhost");
|
|
700
1034
|
if (!url.pathname.startsWith(ROUTE_PREFIX)) {
|
|
701
1035
|
fail(res, "not_found", "\u672A\u77E5\u8DEF\u5F84");
|
|
@@ -705,7 +1039,8 @@ function createHandler(storage) {
|
|
|
705
1039
|
const method = req.method ?? "GET";
|
|
706
1040
|
if (method === "GET") {
|
|
707
1041
|
if (path === "/library") {
|
|
708
|
-
|
|
1042
|
+
const library = await storage.getLibrary();
|
|
1043
|
+
ok(res, { library, warnings: await storage.takeWarnings() });
|
|
709
1044
|
return;
|
|
710
1045
|
}
|
|
711
1046
|
const doc = DOC_RE.exec(path);
|
|
@@ -721,10 +1056,14 @@ function createHandler(storage) {
|
|
|
721
1056
|
ok(res, await storage.listBackups());
|
|
722
1057
|
return;
|
|
723
1058
|
}
|
|
1059
|
+
if (path === "/health/quarantine") {
|
|
1060
|
+
ok(res, await storage.listQuarantine());
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
724
1063
|
const backup2 = BACKUP_RE2.exec(path);
|
|
725
1064
|
if (backup2 !== null && backup2[2] === void 0) {
|
|
726
1065
|
const text = await storage.readBackup(backup2[1]);
|
|
727
|
-
res.writeHead(200, { "content-type": "application/json; charset=utf-8", "content-disposition":
|
|
1066
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8", "content-disposition": 'attachment; filename="' + backup2[1] + '"', "cache-control": "no-store" });
|
|
728
1067
|
res.end(text);
|
|
729
1068
|
return;
|
|
730
1069
|
}
|
|
@@ -735,11 +1074,17 @@ function createHandler(storage) {
|
|
|
735
1074
|
fail(res, "not_found", "\u4E0D\u652F\u6301\u7684\u65B9\u6CD5");
|
|
736
1075
|
return;
|
|
737
1076
|
}
|
|
738
|
-
const body = obj(await readBody(req));
|
|
1077
|
+
const body = obj(await readBody(req, limitFor(path)));
|
|
739
1078
|
if (path === "/docs") {
|
|
740
1079
|
let items = [];
|
|
741
1080
|
let title = body.title;
|
|
742
|
-
if (
|
|
1081
|
+
if (body.copy !== void 0) {
|
|
1082
|
+
const problem = validateDoc(body.copy);
|
|
1083
|
+
if (problem !== null) throw new StorageError("invalid_input", "\u6587\u6863\u7ED3\u6784\u65E0\u6548\uFF1A" + problem);
|
|
1084
|
+
const copy = body.copy;
|
|
1085
|
+
items = toItems(copy, copy.nodes[copy.rootId].children);
|
|
1086
|
+
if (title === void 0 || title === "") title = copy.title;
|
|
1087
|
+
} else if (typeof body.markdown === "string") {
|
|
743
1088
|
const parsed = parseMarkdown(body.markdown);
|
|
744
1089
|
items = parsed.items;
|
|
745
1090
|
if (title === void 0 || title === "") title = parsed.title;
|
|
@@ -778,13 +1123,18 @@ function createHandler(storage) {
|
|
|
778
1123
|
return;
|
|
779
1124
|
}
|
|
780
1125
|
}
|
|
1126
|
+
if (path === "/trash/empty") {
|
|
1127
|
+
ok(res, await storage.emptyTrash());
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
781
1130
|
if (path === "/folders") {
|
|
782
|
-
ok(res, await storage.createFolder(body.name));
|
|
1131
|
+
ok(res, await storage.createFolder(body.name, body.parentId ?? null));
|
|
783
1132
|
return;
|
|
784
1133
|
}
|
|
785
1134
|
const folder = FOLDER_ACTION_RE.exec(path);
|
|
786
1135
|
if (folder !== null) {
|
|
787
1136
|
if (folder[2] === "rename") ok(res, await storage.renameFolder(folder[1], body.name));
|
|
1137
|
+
else if (folder[2] === "move") ok(res, await storage.moveFolder(folder[1], body.parentId ?? null));
|
|
788
1138
|
else {
|
|
789
1139
|
await storage.deleteFolder(folder[1]);
|
|
790
1140
|
ok(res, null);
|
|
@@ -811,7 +1161,7 @@ function createHandler(storage) {
|
|
|
811
1161
|
return;
|
|
812
1162
|
}
|
|
813
1163
|
console.error("[dsh-mindmap] route failed:", error);
|
|
814
|
-
fail(res, "internal",
|
|
1164
|
+
fail(res, "internal", "\u670D\u52A1\u5668\u5185\u90E8\u9519\u8BEF");
|
|
815
1165
|
}
|
|
816
1166
|
};
|
|
817
1167
|
}
|