@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/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 LIST_RE.test(u) || HEADING_RE.test(u) || u.startsWith("\\") ? line.slice(0, line.length - t.length) + u : line;
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
- const h1Count = lines.filter((l) => /^#\s+\S/.test(l)).length;
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 (const line of lines) {
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 ind = indentOf(line);
127
+ const qm = QUOTE_RE.exec(trimmed);
79
128
  if (last !== null && ind > last.marker) {
80
- last.item.text += "\n".repeat(blanks + 1) + unescapeRest(line.slice(Math.min(ind, last.content)));
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
- container().push({ text: line.trim(), children: [] });
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
- 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 };
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 searchDoc(doc, title, query, limit = 20) {
204
- const q = query.trim().toLowerCase();
205
- if (q.length === 0) return [];
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: 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);
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
- walk(doc.nodes[doc.rootId]?.children ?? []);
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 MAX_BACKUPS = 30;
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 === "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
- }
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
- /** Problems found while loading (quarantined files etc.). */
315
- warnings = [];
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.warnings.push(`${why}: \u5DF2\u9694\u79BB\u4E3A ${target}`);
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.docs.set(doc.id, doc);
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) return cached;
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.docs.set(id, doc);
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, input.items ?? []);
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.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 });
755
+ await this.purgeIds([id]);
518
756
  });
519
757
  }
520
- createFolder(name2) {
758
+ /** Permanently delete everything in the trash; returns how many documents went. */
759
+ emptyTrash() {
521
760
  return this.serial(async () => {
522
- const folder = { id: newId(), name: normalizeTitle(name2, "\u65B0\u5EFA\u6587\u4EF6\u5939"), createdAt: this.now() };
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
- /** Delete a folder; its documents move back to the top level (never deleted). */
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
- if (!this.library.folders.some((f) => f.id === id)) throw new StorageError("not_found", "\u6587\u4EF6\u5939\u4E0D\u5B58\u5728");
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 d of this.library.docs) if (d.folderId === id) d.folderId = null;
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
- async search(query, limit = 100) {
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 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 {
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 hits;
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.loadDoc(meta.id));
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 name2 = kind === "daily" ? `mindmap-${stamp(now, false)}.json` : `mindmap-${stamp(now, true)}${kind === "pre-restore" ? "-pre-restore" : ""}.json`;
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
- /** First write of a local day snapshots the whole library. */
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
- 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
- }
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 list = await this.listBackupsRaw();
592
- for (const old of list.slice(MAX_BACKUPS)) await rm(join(this.backupsDir, old.name), { force: true });
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) raw = JSON.parse(await this.readBackup(source.name));
628
- else raw = source.data;
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
- 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
- }
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 MAX_BODY_BYTES = 8 * 1024 * 1024;
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(`^/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)?$/;
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
- async function readBody(req) {
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 > MAX_BODY_BYTES) throw new StorageError("too_large", "\u8BF7\u6C42\u4F53\u8FC7\u5927");
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
- ok(res, { library: await storage.getLibrary(), warnings: storage.warnings });
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": `attachment; filename="${backup2[1]}"`, "cache-control": "no-store" });
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 (typeof body.markdown === "string") {
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", error instanceof Error ? error.message : String(error));
1164
+ fail(res, "internal", "\u670D\u52A1\u5668\u5185\u90E8\u9519\u8BEF");
815
1165
  }
816
1166
  };
817
1167
  }