@huoweifang/dsh-mindmap 0.0.0-stage → 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 +39 -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 +229 -0
- package/lib/index.js +1190 -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,1190 @@
|
|
|
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 needsEscape(t) {
|
|
10
|
+
return LIST_RE.test(t) || HEADING_RE.test(t) || t.startsWith("\\") || t.startsWith(">") || FENCE_RE.test(t);
|
|
11
|
+
}
|
|
12
|
+
function unescapeFirst(text) {
|
|
13
|
+
const m = /^(?: )+/.exec(text);
|
|
14
|
+
if (m !== null) return " ".repeat(m[0].length / 5) + text.slice(m[0].length);
|
|
15
|
+
return /^\\\[( |x|X)\]/.test(text) ? text.slice(1) : text;
|
|
16
|
+
}
|
|
17
|
+
function unescapeRest(line) {
|
|
18
|
+
const t = line.trimStart();
|
|
19
|
+
if (!t.startsWith("\\")) return line;
|
|
20
|
+
const u = t.slice(1);
|
|
21
|
+
return needsEscape(u) ? line.slice(0, line.length - t.length) + u : line;
|
|
22
|
+
}
|
|
23
|
+
var LIST_RE = /^(\s*)([-*+]|\d{1,9}[.)])(\s+|$)(.*)$/;
|
|
24
|
+
var HEADING_RE = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
|
|
25
|
+
var CHECK_RE = /^\[( |x|X)\](?:\s+|$)([\s\S]*)$/;
|
|
26
|
+
var FENCE_RE = /^(```+|~~~+)/;
|
|
27
|
+
var QUOTE_RE = /^>\s?(.*)$/;
|
|
28
|
+
function indentOf(line) {
|
|
29
|
+
return line.length - line.trimStart().length;
|
|
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
|
+
}
|
|
34
|
+
function parseMarkdown(src, options = {}) {
|
|
35
|
+
const detectTitle = options.detectTitle ?? true;
|
|
36
|
+
const lines = src.replace(/^\uFEFF/, "").replace(/\r\n?/g, "\n").split("\n").map((l) => l.replace(/\t/g, " "));
|
|
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
|
+
}
|
|
53
|
+
const root = [];
|
|
54
|
+
const headings = [];
|
|
55
|
+
let list = [];
|
|
56
|
+
let last = null;
|
|
57
|
+
let freshHeading = null;
|
|
58
|
+
let blanks = 0;
|
|
59
|
+
let title;
|
|
60
|
+
const container = () => headings.length > 0 ? headings[headings.length - 1].item.children : root;
|
|
61
|
+
for (let i = 0; i < lines.length; i++) {
|
|
62
|
+
const line = lines[i];
|
|
63
|
+
if (line.trim().length === 0) {
|
|
64
|
+
blanks++;
|
|
65
|
+
continue;
|
|
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
|
+
}
|
|
88
|
+
const hm = HEADING_RE.exec(line);
|
|
89
|
+
if (hm !== null) {
|
|
90
|
+
const level = hm[1].length;
|
|
91
|
+
if (detectTitle && title === void 0 && level === 1 && h1Count === 1 && root.length === 0) {
|
|
92
|
+
title = hm[2];
|
|
93
|
+
freshHeading = null;
|
|
94
|
+
} else {
|
|
95
|
+
while (headings.length > 0 && headings[headings.length - 1].level >= level) headings.pop();
|
|
96
|
+
const item = { text: hm[2], children: [] };
|
|
97
|
+
container().push(item);
|
|
98
|
+
headings.push({ level, item });
|
|
99
|
+
freshHeading = item;
|
|
100
|
+
}
|
|
101
|
+
list = [];
|
|
102
|
+
last = null;
|
|
103
|
+
blanks = 0;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const lm = LIST_RE.exec(line);
|
|
107
|
+
if (lm !== null) {
|
|
108
|
+
const indent = lm[1].length;
|
|
109
|
+
let text = lm[4] ?? "";
|
|
110
|
+
let checked;
|
|
111
|
+
const cb = CHECK_RE.exec(text);
|
|
112
|
+
if (cb !== null) {
|
|
113
|
+
checked = cb[1] !== " ";
|
|
114
|
+
text = cb[2];
|
|
115
|
+
}
|
|
116
|
+
text = unescapeFirst(text);
|
|
117
|
+
while (list.length > 0 && list[list.length - 1].indent >= indent) list.pop();
|
|
118
|
+
const item = { text, children: [] };
|
|
119
|
+
if (checked !== void 0) item.checked = checked;
|
|
120
|
+
(list.length > 0 ? list[list.length - 1].item.children : container()).push(item);
|
|
121
|
+
list.push({ indent, item });
|
|
122
|
+
last = { marker: indent, content: indent + lm[2].length + Math.max(1, lm[3].length), item };
|
|
123
|
+
freshHeading = null;
|
|
124
|
+
blanks = 0;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
const qm = QUOTE_RE.exec(trimmed);
|
|
128
|
+
if (last !== null && ind > last.marker) {
|
|
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)));
|
|
131
|
+
blanks = 0;
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
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: [] });
|
|
141
|
+
list = [];
|
|
142
|
+
last = null;
|
|
143
|
+
blanks = 0;
|
|
144
|
+
}
|
|
145
|
+
return title === void 0 ? { items: root } : { title, items: root };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// src/shared/opml.ts
|
|
149
|
+
function unesc(text) {
|
|
150
|
+
return text.replace(/&(#x[0-9a-fA-F]+|#\d+|lt|gt|quot|apos|amp);/g, (_m, ent) => {
|
|
151
|
+
if (ent === "lt") return "<";
|
|
152
|
+
if (ent === "gt") return ">";
|
|
153
|
+
if (ent === "quot") return '"';
|
|
154
|
+
if (ent === "apos") return "'";
|
|
155
|
+
if (ent === "amp") return "&";
|
|
156
|
+
const code = ent[1] === "x" ? Number.parseInt(ent.slice(2), 16) : Number.parseInt(ent.slice(1), 10);
|
|
157
|
+
return Number.isFinite(code) && code > 0 && code <= 1114111 ? String.fromCodePoint(code) : "";
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
function parseOpml(xml) {
|
|
161
|
+
if (!/<opml[\s>]/i.test(xml)) throw new Error("\u4E0D\u662F\u6709\u6548\u7684 OPML \u6587\u4EF6");
|
|
162
|
+
const titleMatch = /<title>([\s\S]*?)<\/title>/i.exec(xml);
|
|
163
|
+
const bodyAt = xml.search(/<body[\s>\/]/i);
|
|
164
|
+
const body = bodyAt >= 0 ? xml.slice(bodyAt) : xml;
|
|
165
|
+
const root = [];
|
|
166
|
+
const stack = [];
|
|
167
|
+
const tokens = /<outline\b((?:[^>"']|"[^"]*"|'[^']*')*?)(\/?)>|<\/outline\s*>/gi;
|
|
168
|
+
let m;
|
|
169
|
+
while ((m = tokens.exec(body)) !== null) {
|
|
170
|
+
if (m[0].startsWith("</")) {
|
|
171
|
+
stack.pop();
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
const attrs = {};
|
|
175
|
+
const attrRe = /([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g;
|
|
176
|
+
let a;
|
|
177
|
+
while ((a = attrRe.exec(m[1] ?? "")) !== null) attrs[a[1]] = unesc(a[2] ?? a[3] ?? "");
|
|
178
|
+
const item = { text: (attrs.text ?? attrs.title ?? "").replace(/\r/g, ""), children: [] };
|
|
179
|
+
const flag = attrs._checked ?? attrs._complete;
|
|
180
|
+
if (flag === "true") item.checked = true;
|
|
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;
|
|
184
|
+
(stack.length > 0 ? stack[stack.length - 1].children : root).push(item);
|
|
185
|
+
if (m[2] !== "/") stack.push(item);
|
|
186
|
+
}
|
|
187
|
+
const title = titleMatch !== null ? unesc(titleMatch[1].trim()) : void 0;
|
|
188
|
+
return title !== void 0 && title.length > 0 ? { title, items: root } : { items: root };
|
|
189
|
+
}
|
|
190
|
+
|
|
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
|
+
}
|
|
205
|
+
var ID_RE = /^[a-z0-9-]{6,64}$/;
|
|
206
|
+
var MAX_TITLE = 200;
|
|
207
|
+
var counter = 0;
|
|
208
|
+
function newId() {
|
|
209
|
+
counter = (counter + 1) % 1296;
|
|
210
|
+
const rand = Math.random().toString(36).slice(2, 10).padEnd(8, "0");
|
|
211
|
+
return `${Date.now().toString(36)}-${counter.toString(36).padStart(2, "0")}${rand}`;
|
|
212
|
+
}
|
|
213
|
+
function normalizeTitle(raw, fallback = "\u672A\u547D\u540D\u6587\u6863") {
|
|
214
|
+
const text = typeof raw === "string" ? raw.replace(/[\r\n\t]+/g, " ").trim() : "";
|
|
215
|
+
return (text.length > 0 ? text : fallback).slice(0, MAX_TITLE);
|
|
216
|
+
}
|
|
217
|
+
function createDoc(title, now, items = [], id = newId()) {
|
|
218
|
+
const rootId = newId();
|
|
219
|
+
const nodes = {};
|
|
220
|
+
nodes[rootId] = { id: rootId, text: "", children: [] };
|
|
221
|
+
buildItems(nodes, rootId, items.length > 0 ? items : [{ text: "", children: [] }]);
|
|
222
|
+
return { schema: 1, id, title: normalizeTitle(title), rootId, nodes, revision: 0, createdAt: now, updatedAt: now };
|
|
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
|
+
}
|
|
246
|
+
function validateDoc(value) {
|
|
247
|
+
if (typeof value !== "object" || value === null) return "document is not an object";
|
|
248
|
+
const doc = value;
|
|
249
|
+
if (doc.schema !== 1) return "unsupported schema";
|
|
250
|
+
if (typeof doc.id !== "string" || !ID_RE.test(doc.id)) return "invalid document id";
|
|
251
|
+
if (typeof doc.title !== "string") return "invalid title";
|
|
252
|
+
if (typeof doc.revision !== "number" || !Number.isInteger(doc.revision) || doc.revision < 0) return "invalid revision";
|
|
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";
|
|
255
|
+
const root = doc.nodes[doc.rootId];
|
|
256
|
+
if (root === void 0) return "root node missing";
|
|
257
|
+
const seen = /* @__PURE__ */ new Set();
|
|
258
|
+
const stack = [doc.rootId];
|
|
259
|
+
while (stack.length > 0) {
|
|
260
|
+
const id = stack.pop();
|
|
261
|
+
if (seen.has(id)) return `node ${id} is referenced more than once`;
|
|
262
|
+
seen.add(id);
|
|
263
|
+
const node = doc.nodes[id];
|
|
264
|
+
if (node === void 0) return `dangling child reference ${id}`;
|
|
265
|
+
if (node.id !== id) return `node key mismatch ${id}`;
|
|
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`;
|
|
268
|
+
if (!Array.isArray(node.children)) return `node ${id} has no children array`;
|
|
269
|
+
for (const child of node.children) {
|
|
270
|
+
if (typeof child !== "string") return `node ${id} has a non-string child`;
|
|
271
|
+
stack.push(child);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
const total = Object.keys(doc.nodes).length;
|
|
275
|
+
if (seen.size !== total) return `${total - seen.size} orphan node(s)`;
|
|
276
|
+
return null;
|
|
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";
|
|
298
|
+
|
|
299
|
+
// src/shared/search.ts
|
|
300
|
+
function snippetOf(text, at, len) {
|
|
301
|
+
const flat = text.replace(/\s+/g, " ");
|
|
302
|
+
const start = Math.max(0, at - 24);
|
|
303
|
+
const end = Math.min(flat.length, at + len + 40);
|
|
304
|
+
return (start > 0 ? "\u2026" : "") + flat.slice(start, end) + (end < flat.length ? "\u2026" : "");
|
|
305
|
+
}
|
|
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 [];
|
|
321
|
+
const hits = [];
|
|
322
|
+
const ti = title.toLowerCase().indexOf(q);
|
|
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;
|
|
330
|
+
}
|
|
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
|
+
}
|
|
334
|
+
return hits;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// src/host/storage.ts
|
|
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
|
+
}
|
|
348
|
+
var StorageError = class extends Error {
|
|
349
|
+
code;
|
|
350
|
+
current;
|
|
351
|
+
constructor(code, message, current) {
|
|
352
|
+
super(message);
|
|
353
|
+
this.code = code;
|
|
354
|
+
if (current !== void 0) this.current = current;
|
|
355
|
+
}
|
|
356
|
+
};
|
|
357
|
+
function stamp(ms, withTime) {
|
|
358
|
+
const d = new Date(ms);
|
|
359
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
360
|
+
const day = `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`;
|
|
361
|
+
return withTime ? `${day}-${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}` : day;
|
|
362
|
+
}
|
|
363
|
+
var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
364
|
+
async function atomicWrite(file, contents) {
|
|
365
|
+
await mkdir(dirname(file), { recursive: true });
|
|
366
|
+
const temp = join(dirname(file), `.${Math.random().toString(36).slice(2)}.tmp`);
|
|
367
|
+
const fh = await open(temp, "w");
|
|
368
|
+
try {
|
|
369
|
+
await fh.writeFile(contents, "utf8");
|
|
370
|
+
await fh.sync();
|
|
371
|
+
} finally {
|
|
372
|
+
await fh.close();
|
|
373
|
+
}
|
|
374
|
+
for (let attempt = 0; ; attempt++) {
|
|
375
|
+
try {
|
|
376
|
+
await rename(temp, file);
|
|
377
|
+
return;
|
|
378
|
+
} catch (error) {
|
|
379
|
+
const code = error.code;
|
|
380
|
+
if (attempt >= 6 || code !== "EPERM" && code !== "EBUSY" && code !== "EACCES") {
|
|
381
|
+
await rm(temp, { force: true }).catch(() => {
|
|
382
|
+
});
|
|
383
|
+
throw error;
|
|
384
|
+
}
|
|
385
|
+
await sleep(40 * (attempt + 1));
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
function isEnoent(error) {
|
|
390
|
+
return error?.code === "ENOENT";
|
|
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
|
+
}
|
|
412
|
+
function sanitizeMeta(raw) {
|
|
413
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
414
|
+
const m = raw;
|
|
415
|
+
if (typeof m.id !== "string" || !ID_RE.test(m.id)) return null;
|
|
416
|
+
const meta = {
|
|
417
|
+
id: m.id,
|
|
418
|
+
title: normalizeTitle(m.title),
|
|
419
|
+
folderId: typeof m.folderId === "string" ? m.folderId : null,
|
|
420
|
+
favorite: m.favorite === true,
|
|
421
|
+
createdAt: typeof m.createdAt === "number" ? m.createdAt : 0,
|
|
422
|
+
updatedAt: typeof m.updatedAt === "number" ? m.updatedAt : 0
|
|
423
|
+
};
|
|
424
|
+
if (typeof m.trashedAt === "number") meta.trashedAt = m.trashedAt;
|
|
425
|
+
return meta;
|
|
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
|
+
}
|
|
436
|
+
function sanitizeLibrary(raw) {
|
|
437
|
+
if (typeof raw !== "object" || raw === null) return null;
|
|
438
|
+
const l = raw;
|
|
439
|
+
if (!Array.isArray(l.docs) || !Array.isArray(l.folders)) return null;
|
|
440
|
+
const folders = [];
|
|
441
|
+
const seenFolders = /* @__PURE__ */ new Set();
|
|
442
|
+
for (const f of l.folders) {
|
|
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 });
|
|
448
|
+
}
|
|
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;
|
|
452
|
+
const docs = [];
|
|
453
|
+
const seen = /* @__PURE__ */ new Set();
|
|
454
|
+
for (const d of l.docs) {
|
|
455
|
+
const meta = sanitizeMeta(d);
|
|
456
|
+
if (meta === null || seen.has(meta.id)) continue;
|
|
457
|
+
if (meta.folderId !== null && !ids.has(meta.folderId)) meta.folderId = null;
|
|
458
|
+
seen.add(meta.id);
|
|
459
|
+
docs.push(meta);
|
|
460
|
+
}
|
|
461
|
+
return { schema: 1, folders, docs };
|
|
462
|
+
}
|
|
463
|
+
var MindmapStorage = class {
|
|
464
|
+
dir;
|
|
465
|
+
now;
|
|
466
|
+
library = { schema: 1, folders: [], docs: [] };
|
|
467
|
+
/** LRU cache of whole documents (Map keeps insertion order). */
|
|
468
|
+
docs = /* @__PURE__ */ new Map();
|
|
469
|
+
/** Compact text index of every readable document, for search. */
|
|
470
|
+
index = /* @__PURE__ */ new Map();
|
|
471
|
+
queue = Promise.resolve();
|
|
472
|
+
loading;
|
|
473
|
+
pendingWarnings = [];
|
|
474
|
+
lastPurgeBackup = null;
|
|
475
|
+
constructor(dir, now = () => Date.now()) {
|
|
476
|
+
this.dir = dir;
|
|
477
|
+
this.now = now;
|
|
478
|
+
}
|
|
479
|
+
get libraryFile() {
|
|
480
|
+
return join(this.dir, "library.json");
|
|
481
|
+
}
|
|
482
|
+
get docsDir() {
|
|
483
|
+
return join(this.dir, "docs");
|
|
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
|
+
}
|
|
494
|
+
get backupsDir() {
|
|
495
|
+
return join(this.dir, "backups");
|
|
496
|
+
}
|
|
497
|
+
docFile(id) {
|
|
498
|
+
return join(this.docsDir, `${id}.json`);
|
|
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
|
+
}
|
|
511
|
+
/** Serialize every mutation (and the initial load) through one queue. */
|
|
512
|
+
serial(fn) {
|
|
513
|
+
const run = this.queue.then(() => this.ready()).then(fn);
|
|
514
|
+
this.queue = run.catch(() => void 0);
|
|
515
|
+
return run;
|
|
516
|
+
}
|
|
517
|
+
/** Load once; later calls share the same promise. */
|
|
518
|
+
ready() {
|
|
519
|
+
this.loading ??= this.load();
|
|
520
|
+
return this.loading;
|
|
521
|
+
}
|
|
522
|
+
async quarantine(file, why) {
|
|
523
|
+
const target = file.replace(/\.json$/, `.corrupt-${stamp(this.now(), true)}.json`);
|
|
524
|
+
await rename(file, target).catch(() => {
|
|
525
|
+
});
|
|
526
|
+
this.pendingWarnings.push(`${why}\uFF1A\u5DF2\u9694\u79BB\u4E3A ${target}`);
|
|
527
|
+
console.warn(`[dsh-mindmap] ${why}; quarantined as ${target}`);
|
|
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
|
+
}
|
|
552
|
+
async load() {
|
|
553
|
+
await mkdir(this.dir, { recursive: true });
|
|
554
|
+
await this.recoverRestore();
|
|
555
|
+
await mkdir(this.docsDir, { recursive: true });
|
|
556
|
+
await mkdir(this.backupsDir, { recursive: true });
|
|
557
|
+
let library = null;
|
|
558
|
+
try {
|
|
559
|
+
library = sanitizeLibrary(JSON.parse(await readFile(this.libraryFile, "utf8")));
|
|
560
|
+
if (library === null) await this.quarantine(this.libraryFile, "library.json \u7ED3\u6784\u65E0\u6548");
|
|
561
|
+
} catch (error) {
|
|
562
|
+
if (!isEnoent(error)) await this.quarantine(this.libraryFile, "library.json \u65E0\u6CD5\u89E3\u6790");
|
|
563
|
+
}
|
|
564
|
+
const files = (await readdir(this.docsDir)).filter((f) => /^[a-z0-9-]{6,64}\.json$/.test(f));
|
|
565
|
+
const lib = library ?? { schema: 1, folders: [], docs: [] };
|
|
566
|
+
const known = new Set(lib.docs.map((d) => d.id));
|
|
567
|
+
let adopted = false;
|
|
568
|
+
for (const file of files) {
|
|
569
|
+
const id = file.slice(0, -5);
|
|
570
|
+
if (known.has(id)) continue;
|
|
571
|
+
const doc = await this.readDocFile(id).catch(() => null);
|
|
572
|
+
if (doc === null) continue;
|
|
573
|
+
lib.docs.push({ id, title: doc.title, folderId: null, favorite: false, createdAt: doc.createdAt, updatedAt: doc.updatedAt });
|
|
574
|
+
adopted = true;
|
|
575
|
+
}
|
|
576
|
+
const present = new Set(files.map((f) => f.slice(0, -5)));
|
|
577
|
+
const before = lib.docs.length;
|
|
578
|
+
lib.docs = lib.docs.filter((d) => present.has(d.id));
|
|
579
|
+
this.library = lib;
|
|
580
|
+
if (adopted || library === null || lib.docs.length !== before) {
|
|
581
|
+
if (lib.docs.length > 0 || library !== null) await this.writeLibrary();
|
|
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
|
+
}
|
|
592
|
+
}
|
|
593
|
+
async readDocFile(id) {
|
|
594
|
+
const file = this.docFile(id);
|
|
595
|
+
let parsed;
|
|
596
|
+
try {
|
|
597
|
+
parsed = JSON.parse(await readFile(file, "utf8"));
|
|
598
|
+
} catch (error) {
|
|
599
|
+
if (isEnoent(error)) throw new StorageError("not_found", "\u6587\u6863\u4E0D\u5B58\u5728");
|
|
600
|
+
await this.quarantine(file, `\u6587\u6863 ${id} \u65E0\u6CD5\u89E3\u6790`);
|
|
601
|
+
throw new StorageError("internal", "\u6587\u6863\u6587\u4EF6\u5DF2\u635F\u574F\u5E76\u5DF2\u9694\u79BB");
|
|
602
|
+
}
|
|
603
|
+
const problem = validateDoc(parsed);
|
|
604
|
+
if (problem !== null || parsed.id !== id) {
|
|
605
|
+
await this.quarantine(file, `\u6587\u6863 ${id} \u7ED3\u6784\u65E0\u6548\uFF08${problem ?? "id \u4E0D\u5339\u914D"}\uFF09`);
|
|
606
|
+
throw new StorageError("internal", "\u6587\u6863\u6587\u4EF6\u7ED3\u6784\u65E0\u6548\u5E76\u5DF2\u9694\u79BB");
|
|
607
|
+
}
|
|
608
|
+
return parsed;
|
|
609
|
+
}
|
|
610
|
+
async writeLibrary() {
|
|
611
|
+
await atomicWrite(this.libraryFile, JSON.stringify(this.library, null, 1));
|
|
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
|
+
}
|
|
619
|
+
async writeDoc(doc) {
|
|
620
|
+
const problem = validateDoc(doc);
|
|
621
|
+
if (problem !== null) throw new StorageError("invalid_input", `\u6587\u6863\u7ED3\u6784\u65E0\u6548\uFF1A${problem}`);
|
|
622
|
+
await atomicWrite(this.docFile(doc.id), JSON.stringify(doc));
|
|
623
|
+
this.remember(doc);
|
|
624
|
+
}
|
|
625
|
+
meta(id) {
|
|
626
|
+
if (!ID_RE.test(id)) throw new StorageError("invalid_input", "\u65E0\u6548\u7684\u6587\u6863 id");
|
|
627
|
+
const meta = this.library.docs.find((d) => d.id === id);
|
|
628
|
+
if (meta === void 0) throw new StorageError("not_found", "\u6587\u6863\u4E0D\u5B58\u5728");
|
|
629
|
+
return meta;
|
|
630
|
+
}
|
|
631
|
+
async loadDoc(id) {
|
|
632
|
+
this.meta(id);
|
|
633
|
+
const cached = this.docs.get(id);
|
|
634
|
+
if (cached !== void 0) {
|
|
635
|
+
this.docs.delete(id);
|
|
636
|
+
this.docs.set(id, cached);
|
|
637
|
+
return cached;
|
|
638
|
+
}
|
|
639
|
+
const doc = await this.readDocFile(id);
|
|
640
|
+
this.remember(doc);
|
|
641
|
+
return doc;
|
|
642
|
+
}
|
|
643
|
+
checkFolder(folderId) {
|
|
644
|
+
if (folderId === null || folderId === void 0) return null;
|
|
645
|
+
if (typeof folderId !== "string" || !this.library.folders.some((f) => f.id === folderId)) throw new StorageError("invalid_input", "\u6587\u4EF6\u5939\u4E0D\u5B58\u5728");
|
|
646
|
+
return folderId;
|
|
647
|
+
}
|
|
648
|
+
/** A copy of the library index. */
|
|
649
|
+
async getLibrary() {
|
|
650
|
+
await this.ready();
|
|
651
|
+
return structuredClone(this.library);
|
|
652
|
+
}
|
|
653
|
+
async getDoc(id) {
|
|
654
|
+
await this.ready();
|
|
655
|
+
return this.serial(() => this.loadDoc(id));
|
|
656
|
+
}
|
|
657
|
+
createDoc(input) {
|
|
658
|
+
return this.serial(async () => {
|
|
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();
|
|
663
|
+
const now = this.now();
|
|
664
|
+
const doc = createDoc(normalizeTitle(input.title), now, items);
|
|
665
|
+
await this.writeDoc(doc);
|
|
666
|
+
const meta = { id: doc.id, title: doc.title, folderId, favorite: false, createdAt: now, updatedAt: now };
|
|
667
|
+
this.library.docs.push(meta);
|
|
668
|
+
await this.writeLibrary();
|
|
669
|
+
return { meta: { ...meta }, doc };
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
/** Optimistic-concurrency save: `baseRevision` must match the stored revision. */
|
|
673
|
+
saveDoc(id, incoming, baseRevision) {
|
|
674
|
+
return this.serial(async () => {
|
|
675
|
+
const meta = this.meta(id);
|
|
676
|
+
const current = await this.loadDoc(id);
|
|
677
|
+
if (typeof baseRevision !== "number" || baseRevision !== current.revision) {
|
|
678
|
+
throw new StorageError("conflict", "\u6587\u6863\u5DF2\u5728\u5176\u4ED6\u7A97\u53E3\u88AB\u4FEE\u6539", current);
|
|
679
|
+
}
|
|
680
|
+
const problem = validateDoc(incoming);
|
|
681
|
+
if (problem !== null) throw new StorageError("invalid_input", `\u6587\u6863\u7ED3\u6784\u65E0\u6548\uFF1A${problem}`);
|
|
682
|
+
const doc = incoming;
|
|
683
|
+
if (doc.id !== id) throw new StorageError("invalid_input", "\u6587\u6863 id \u4E0D\u5339\u914D");
|
|
684
|
+
await this.dailyBackup();
|
|
685
|
+
const now = this.now();
|
|
686
|
+
const next = { ...doc, title: normalizeTitle(doc.title), revision: current.revision + 1, createdAt: current.createdAt, updatedAt: now };
|
|
687
|
+
await this.writeDoc(next);
|
|
688
|
+
meta.title = next.title;
|
|
689
|
+
meta.updatedAt = now;
|
|
690
|
+
await this.writeLibrary();
|
|
691
|
+
return next;
|
|
692
|
+
});
|
|
693
|
+
}
|
|
694
|
+
/** Rename / move to folder / (un)favorite. A rename also rewrites the document title. */
|
|
695
|
+
patchMeta(id, body) {
|
|
696
|
+
return this.serial(async () => {
|
|
697
|
+
const meta = this.meta(id);
|
|
698
|
+
let doc;
|
|
699
|
+
if (body.folderId !== void 0) meta.folderId = this.checkFolder(body.folderId);
|
|
700
|
+
if (body.favorite !== void 0) meta.favorite = body.favorite === true;
|
|
701
|
+
if (body.title !== void 0) {
|
|
702
|
+
const title = normalizeTitle(body.title);
|
|
703
|
+
const current = await this.loadDoc(id);
|
|
704
|
+
if (current.title !== title) {
|
|
705
|
+
await this.dailyBackup();
|
|
706
|
+
const now = this.now();
|
|
707
|
+
doc = { ...current, title, revision: current.revision + 1, updatedAt: now };
|
|
708
|
+
await this.writeDoc(doc);
|
|
709
|
+
meta.updatedAt = now;
|
|
710
|
+
}
|
|
711
|
+
meta.title = title;
|
|
712
|
+
}
|
|
713
|
+
await this.writeLibrary();
|
|
714
|
+
return doc === void 0 ? { meta: { ...meta } } : { meta: { ...meta }, doc };
|
|
715
|
+
});
|
|
716
|
+
}
|
|
717
|
+
/** Soft delete into the trash. */
|
|
718
|
+
trashDoc(id) {
|
|
719
|
+
return this.serial(async () => {
|
|
720
|
+
const meta = this.meta(id);
|
|
721
|
+
meta.trashedAt = this.now();
|
|
722
|
+
await this.writeLibrary();
|
|
723
|
+
return { ...meta };
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
restoreDoc(id) {
|
|
727
|
+
return this.serial(async () => {
|
|
728
|
+
const meta = this.meta(id);
|
|
729
|
+
delete meta.trashedAt;
|
|
730
|
+
await this.writeLibrary();
|
|
731
|
+
return { ...meta };
|
|
732
|
+
});
|
|
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
|
+
}
|
|
750
|
+
/** Permanent delete; only trashed documents. */
|
|
751
|
+
purgeDoc(id) {
|
|
752
|
+
return this.serial(async () => {
|
|
753
|
+
const meta = this.meta(id);
|
|
754
|
+
if (meta.trashedAt === void 0) throw new StorageError("invalid_input", "\u53EA\u80FD\u6C38\u4E45\u5220\u9664\u56DE\u6536\u7AD9\u4E2D\u7684\u6587\u6863");
|
|
755
|
+
await this.purgeIds([id]);
|
|
756
|
+
});
|
|
757
|
+
}
|
|
758
|
+
/** Permanently delete everything in the trash; returns how many documents went. */
|
|
759
|
+
emptyTrash() {
|
|
760
|
+
return this.serial(async () => {
|
|
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 };
|
|
770
|
+
this.library.folders.push(folder);
|
|
771
|
+
await this.writeLibrary();
|
|
772
|
+
return { ...folder };
|
|
773
|
+
});
|
|
774
|
+
}
|
|
775
|
+
renameFolder(id, name2) {
|
|
776
|
+
return this.serial(async () => {
|
|
777
|
+
const folder = this.library.folders.find((f) => f.id === id);
|
|
778
|
+
if (folder === void 0) throw new StorageError("not_found", "\u6587\u4EF6\u5939\u4E0D\u5B58\u5728");
|
|
779
|
+
folder.name = normalizeTitle(name2, folder.name);
|
|
780
|
+
await this.writeLibrary();
|
|
781
|
+
return { ...folder };
|
|
782
|
+
});
|
|
783
|
+
}
|
|
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). */
|
|
797
|
+
deleteFolder(id) {
|
|
798
|
+
return this.serial(async () => {
|
|
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;
|
|
802
|
+
this.library.folders = this.library.folders.filter((f) => f.id !== id);
|
|
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;
|
|
805
|
+
await this.writeLibrary();
|
|
806
|
+
});
|
|
807
|
+
}
|
|
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() {
|
|
832
|
+
await this.ready();
|
|
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 });
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
return out.sort((a, b) => b.createdAt - a.createdAt);
|
|
842
|
+
}
|
|
843
|
+
// -------------------------------------------------------------- backups
|
|
844
|
+
async snapshot() {
|
|
845
|
+
const docs = [];
|
|
846
|
+
for (const meta of this.library.docs) {
|
|
847
|
+
try {
|
|
848
|
+
docs.push(this.docs.get(meta.id) ?? await this.readDocFile(meta.id));
|
|
849
|
+
} catch {
|
|
850
|
+
}
|
|
851
|
+
}
|
|
852
|
+
return { schema: 1, kind: "dsh-mindmap-backup", createdAt: this.now(), library: structuredClone(this.library), docs };
|
|
853
|
+
}
|
|
854
|
+
async backupInternal(kind) {
|
|
855
|
+
const now = this.now();
|
|
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
|
+
}
|
|
861
|
+
const contents = JSON.stringify(await this.snapshot());
|
|
862
|
+
await atomicWrite(join(this.backupsDir, name2), contents);
|
|
863
|
+
await this.prune();
|
|
864
|
+
return { name: name2, size: Buffer.byteLength(contents), createdAt: now, kind };
|
|
865
|
+
}
|
|
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
|
+
*/
|
|
871
|
+
async dailyBackup() {
|
|
872
|
+
if (this.library.docs.length === 0) return;
|
|
873
|
+
const name2 = `mindmap-${stamp(this.now(), false)}.json`;
|
|
874
|
+
if (await exists(join(this.backupsDir, name2))) return;
|
|
875
|
+
await this.backupInternal("daily").catch((e) => console.warn("[dsh-mindmap] daily backup failed:", e));
|
|
876
|
+
}
|
|
877
|
+
/** Keep the newest BACKUP_KEEP[kind] files of each kind. */
|
|
878
|
+
async prune() {
|
|
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
|
+
}
|
|
885
|
+
}
|
|
886
|
+
async listBackupsRaw() {
|
|
887
|
+
const out = [];
|
|
888
|
+
for (const name2 of await readdir(this.backupsDir).catch(() => [])) {
|
|
889
|
+
if (!BACKUP_RE.test(name2)) continue;
|
|
890
|
+
const s = await stat(join(this.backupsDir, name2)).catch(() => null);
|
|
891
|
+
if (s !== null) out.push({ name: name2, size: s.size, createdAt: s.mtimeMs, kind: backupKind(name2) });
|
|
892
|
+
}
|
|
893
|
+
return out.sort((a, b) => b.createdAt - a.createdAt || (a.name < b.name ? 1 : -1));
|
|
894
|
+
}
|
|
895
|
+
async listBackups() {
|
|
896
|
+
await this.ready();
|
|
897
|
+
return this.listBackupsRaw();
|
|
898
|
+
}
|
|
899
|
+
backupNow() {
|
|
900
|
+
return this.serial(() => this.backupInternal("manual"));
|
|
901
|
+
}
|
|
902
|
+
async readBackup(name2) {
|
|
903
|
+
await this.ready();
|
|
904
|
+
if (!BACKUP_RE.test(name2)) throw new StorageError("invalid_input", "\u65E0\u6548\u7684\u5907\u4EFD\u6587\u4EF6\u540D");
|
|
905
|
+
try {
|
|
906
|
+
return await readFile(join(this.backupsDir, name2), "utf8");
|
|
907
|
+
} catch (error) {
|
|
908
|
+
if (isEnoent(error)) throw new StorageError("not_found", "\u5907\u4EFD\u4E0D\u5B58\u5728");
|
|
909
|
+
throw error;
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
/**
|
|
913
|
+
* Replace everything with a backup. The current state is first saved as a
|
|
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).
|
|
917
|
+
*/
|
|
918
|
+
restore(source) {
|
|
919
|
+
return this.serial(async () => {
|
|
920
|
+
let raw;
|
|
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;
|
|
929
|
+
const file = raw;
|
|
930
|
+
if (typeof file !== "object" || file === null || file.kind !== "dsh-mindmap-backup" || !Array.isArray(file.docs)) {
|
|
931
|
+
throw new StorageError("invalid_input", "\u4E0D\u662F\u6709\u6548\u7684\u601D\u7EF4\u5BFC\u56FE\u5907\u4EFD\u6587\u4EF6");
|
|
932
|
+
}
|
|
933
|
+
const library = sanitizeLibrary(file.library);
|
|
934
|
+
if (library === null) throw new StorageError("invalid_input", "\u5907\u4EFD\u4E2D\u7684\u6587\u6863\u5E93\u65E0\u6548");
|
|
935
|
+
for (const doc of file.docs) {
|
|
936
|
+
const problem = validateDoc(doc);
|
|
937
|
+
if (problem !== null) throw new StorageError("invalid_input", `\u5907\u4EFD\u4E2D\u7684\u6587\u6863\u65E0\u6548\uFF1A${problem}`);
|
|
938
|
+
}
|
|
939
|
+
const byId = new Map(file.docs.map((d) => [d.id, d]));
|
|
940
|
+
library.docs = library.docs.filter((d) => byId.has(d.id));
|
|
941
|
+
const pre = await this.backupInternal("pre-restore");
|
|
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 });
|
|
953
|
+
this.docs.clear();
|
|
954
|
+
this.index.clear();
|
|
955
|
+
this.library = library;
|
|
956
|
+
return { docs: library.docs.length, preRestore: pre.name };
|
|
957
|
+
});
|
|
958
|
+
}
|
|
959
|
+
};
|
|
960
|
+
|
|
961
|
+
// src/host/routes.ts
|
|
962
|
+
var BODY_LIMITS = { save: 32 * 1024 * 1024, restore: 64 * 1024 * 1024, other: 1024 * 1024 };
|
|
963
|
+
var STATUS = { invalid_input: 400, not_found: 404, conflict: 409, too_large: 413, internal: 500 };
|
|
964
|
+
var ID = "([a-z0-9-]{6,64})";
|
|
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]"]);
|
|
970
|
+
function send(res, status, body) {
|
|
971
|
+
const text = JSON.stringify(body);
|
|
972
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
973
|
+
res.end(text);
|
|
974
|
+
}
|
|
975
|
+
function ok(res, value) {
|
|
976
|
+
send(res, 200, { ok: true, value });
|
|
977
|
+
}
|
|
978
|
+
function fail(res, code, message, extra = {}) {
|
|
979
|
+
send(res, STATUS[code], { ok: false, error: { code, message, ...extra } });
|
|
980
|
+
}
|
|
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) {
|
|
999
|
+
const type = String(req.headers["content-type"] ?? "");
|
|
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");
|
|
1003
|
+
const chunks = [];
|
|
1004
|
+
let size = 0;
|
|
1005
|
+
for await (const chunk of req) {
|
|
1006
|
+
size += chunk.length;
|
|
1007
|
+
if (size > limit) throw new StorageError("too_large", "\u8BF7\u6C42\u4F53\u8FC7\u5927");
|
|
1008
|
+
chunks.push(chunk);
|
|
1009
|
+
}
|
|
1010
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
1011
|
+
if (raw.trim().length === 0) return {};
|
|
1012
|
+
try {
|
|
1013
|
+
return JSON.parse(raw);
|
|
1014
|
+
} catch {
|
|
1015
|
+
throw new StorageError("invalid_input", "\u8BF7\u6C42\u4F53\u4E0D\u662F\u6709\u6548 JSON");
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
function obj(value) {
|
|
1019
|
+
return typeof value === "object" && value !== null && !Array.isArray(value) ? value : {};
|
|
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
|
+
}
|
|
1026
|
+
function createHandler(storage) {
|
|
1027
|
+
return async (req, res) => {
|
|
1028
|
+
try {
|
|
1029
|
+
if (!checkOrigin(req)) {
|
|
1030
|
+
send(res, 403, { ok: false, error: { code: "invalid_input", message: "\u62D2\u7EDD\u8BBF\u95EE" } });
|
|
1031
|
+
return;
|
|
1032
|
+
}
|
|
1033
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1034
|
+
if (!url.pathname.startsWith(ROUTE_PREFIX)) {
|
|
1035
|
+
fail(res, "not_found", "\u672A\u77E5\u8DEF\u5F84");
|
|
1036
|
+
return;
|
|
1037
|
+
}
|
|
1038
|
+
const path = url.pathname.slice(ROUTE_PREFIX.length) || "/";
|
|
1039
|
+
const method = req.method ?? "GET";
|
|
1040
|
+
if (method === "GET") {
|
|
1041
|
+
if (path === "/library") {
|
|
1042
|
+
const library = await storage.getLibrary();
|
|
1043
|
+
ok(res, { library, warnings: await storage.takeWarnings() });
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
const doc = DOC_RE.exec(path);
|
|
1047
|
+
if (doc !== null) {
|
|
1048
|
+
ok(res, await storage.getDoc(doc[1]));
|
|
1049
|
+
return;
|
|
1050
|
+
}
|
|
1051
|
+
if (path === "/search") {
|
|
1052
|
+
ok(res, await storage.search(String(url.searchParams.get("q") ?? "").slice(0, 200)));
|
|
1053
|
+
return;
|
|
1054
|
+
}
|
|
1055
|
+
if (path === "/backups") {
|
|
1056
|
+
ok(res, await storage.listBackups());
|
|
1057
|
+
return;
|
|
1058
|
+
}
|
|
1059
|
+
if (path === "/health/quarantine") {
|
|
1060
|
+
ok(res, await storage.listQuarantine());
|
|
1061
|
+
return;
|
|
1062
|
+
}
|
|
1063
|
+
const backup2 = BACKUP_RE2.exec(path);
|
|
1064
|
+
if (backup2 !== null && backup2[2] === void 0) {
|
|
1065
|
+
const text = await storage.readBackup(backup2[1]);
|
|
1066
|
+
res.writeHead(200, { "content-type": "application/json; charset=utf-8", "content-disposition": 'attachment; filename="' + backup2[1] + '"', "cache-control": "no-store" });
|
|
1067
|
+
res.end(text);
|
|
1068
|
+
return;
|
|
1069
|
+
}
|
|
1070
|
+
fail(res, "not_found", "\u672A\u77E5\u8DEF\u5F84");
|
|
1071
|
+
return;
|
|
1072
|
+
}
|
|
1073
|
+
if (method !== "POST") {
|
|
1074
|
+
fail(res, "not_found", "\u4E0D\u652F\u6301\u7684\u65B9\u6CD5");
|
|
1075
|
+
return;
|
|
1076
|
+
}
|
|
1077
|
+
const body = obj(await readBody(req, limitFor(path)));
|
|
1078
|
+
if (path === "/docs") {
|
|
1079
|
+
let items = [];
|
|
1080
|
+
let title = body.title;
|
|
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") {
|
|
1088
|
+
const parsed = parseMarkdown(body.markdown);
|
|
1089
|
+
items = parsed.items;
|
|
1090
|
+
if (title === void 0 || title === "") title = parsed.title;
|
|
1091
|
+
} else if (typeof body.opml === "string") {
|
|
1092
|
+
let parsed;
|
|
1093
|
+
try {
|
|
1094
|
+
parsed = parseOpml(body.opml);
|
|
1095
|
+
} catch (error) {
|
|
1096
|
+
throw new StorageError("invalid_input", error.message);
|
|
1097
|
+
}
|
|
1098
|
+
items = parsed.items;
|
|
1099
|
+
if (title === void 0 || title === "") title = parsed.title;
|
|
1100
|
+
}
|
|
1101
|
+
ok(res, await storage.createDoc({ title, folderId: body.folderId, items }));
|
|
1102
|
+
return;
|
|
1103
|
+
}
|
|
1104
|
+
const action = DOC_ACTION_RE.exec(path);
|
|
1105
|
+
if (action !== null) {
|
|
1106
|
+
const id = action[1];
|
|
1107
|
+
switch (action[2]) {
|
|
1108
|
+
case "save":
|
|
1109
|
+
ok(res, await storage.saveDoc(id, body.doc, body.baseRevision));
|
|
1110
|
+
return;
|
|
1111
|
+
case "meta":
|
|
1112
|
+
ok(res, await storage.patchMeta(id, body));
|
|
1113
|
+
return;
|
|
1114
|
+
case "trash":
|
|
1115
|
+
ok(res, await storage.trashDoc(id));
|
|
1116
|
+
return;
|
|
1117
|
+
case "restore":
|
|
1118
|
+
ok(res, await storage.restoreDoc(id));
|
|
1119
|
+
return;
|
|
1120
|
+
case "purge":
|
|
1121
|
+
await storage.purgeDoc(id);
|
|
1122
|
+
ok(res, null);
|
|
1123
|
+
return;
|
|
1124
|
+
}
|
|
1125
|
+
}
|
|
1126
|
+
if (path === "/trash/empty") {
|
|
1127
|
+
ok(res, await storage.emptyTrash());
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
if (path === "/folders") {
|
|
1131
|
+
ok(res, await storage.createFolder(body.name, body.parentId ?? null));
|
|
1132
|
+
return;
|
|
1133
|
+
}
|
|
1134
|
+
const folder = FOLDER_ACTION_RE.exec(path);
|
|
1135
|
+
if (folder !== null) {
|
|
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));
|
|
1138
|
+
else {
|
|
1139
|
+
await storage.deleteFolder(folder[1]);
|
|
1140
|
+
ok(res, null);
|
|
1141
|
+
}
|
|
1142
|
+
return;
|
|
1143
|
+
}
|
|
1144
|
+
if (path === "/backups/now") {
|
|
1145
|
+
ok(res, await storage.backupNow());
|
|
1146
|
+
return;
|
|
1147
|
+
}
|
|
1148
|
+
const backup = BACKUP_RE2.exec(path);
|
|
1149
|
+
if (backup !== null && backup[2] === "/restore") {
|
|
1150
|
+
ok(res, await storage.restore({ name: backup[1] }));
|
|
1151
|
+
return;
|
|
1152
|
+
}
|
|
1153
|
+
if (path === "/restore") {
|
|
1154
|
+
ok(res, await storage.restore({ data: body }));
|
|
1155
|
+
return;
|
|
1156
|
+
}
|
|
1157
|
+
fail(res, "not_found", "\u672A\u77E5\u8DEF\u5F84");
|
|
1158
|
+
} catch (error) {
|
|
1159
|
+
if (error instanceof StorageError) {
|
|
1160
|
+
fail(res, error.code, error.message, error.current !== void 0 ? { current: error.current } : {});
|
|
1161
|
+
return;
|
|
1162
|
+
}
|
|
1163
|
+
console.error("[dsh-mindmap] route failed:", error);
|
|
1164
|
+
fail(res, "internal", "\u670D\u52A1\u5668\u5185\u90E8\u9519\u8BEF");
|
|
1165
|
+
}
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// src/index.ts
|
|
1170
|
+
var name = "dsh-mindmap";
|
|
1171
|
+
var inject = [];
|
|
1172
|
+
function dataDirectory(env = process.env) {
|
|
1173
|
+
if (env.DSH_MINDMAP_DIR !== void 0 && env.DSH_MINDMAP_DIR.length > 0) return resolve(env.DSH_MINDMAP_DIR);
|
|
1174
|
+
const home = env.DSH_HOME !== void 0 && env.DSH_HOME.length > 0 ? env.DSH_HOME : join2(homedir(), ".dsh");
|
|
1175
|
+
return join2(resolve(home), "dsh-mindmap");
|
|
1176
|
+
}
|
|
1177
|
+
function apply(ctx) {
|
|
1178
|
+
const storage = new MindmapStorage(dataDirectory());
|
|
1179
|
+
void storage.ready().catch((error) => console.warn("[dsh-mindmap] storage load failed:", error));
|
|
1180
|
+
const handler = createHandler(storage);
|
|
1181
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
1182
|
+
webCtx.effect(() => webCtx.webServer.register({ kind: "prefix", path: ROUTE_PREFIX, handler }), "dsh-mindmap: routes");
|
|
1183
|
+
});
|
|
1184
|
+
}
|
|
1185
|
+
export {
|
|
1186
|
+
apply,
|
|
1187
|
+
dataDirectory,
|
|
1188
|
+
inject,
|
|
1189
|
+
name
|
|
1190
|
+
};
|