dsh-tiddlywiki 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +172 -0
- package/cordis.patch.yml +13 -0
- package/lib/client.bundle.js +1417 -0
- package/lib/client.js +1427 -0
- package/lib/index.js +2153 -0
- package/lib/index.js.map +1 -0
- package/package.json +63 -0
- package/src/client/editor-popup.ts +121 -0
- package/src/client/index.ts +76 -0
- package/src/client/note-widget.ts +210 -0
- package/src/client/panel.ts +304 -0
- package/src/client/settings-page.ts +397 -0
- package/src/client/sidebar-entry.ts +148 -0
- package/src/client/state.ts +39 -0
- package/src/client/styles.ts +235 -0
- package/src/client/toast.ts +22 -0
- package/src/host/admin.ts +408 -0
- package/src/host/config.ts +86 -0
- package/src/host/git.ts +218 -0
- package/src/host/routes.ts +233 -0
- package/src/host/seed-notes.ts +62 -0
- package/src/host/tools.ts +254 -0
- package/src/host/tw-api.ts +157 -0
- package/src/host/wiki.ts +287 -0
- package/src/index.ts +331 -0
- package/src/sdk.ts +198 -0
package/lib/index.js
ADDED
|
@@ -0,0 +1,2153 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
2
|
+
import { existsSync, watch } from "node:fs";
|
|
3
|
+
import { mkdir, readFile, readdir, writeFile } from "node:fs/promises";
|
|
4
|
+
import { dirname, join, resolve } from "node:path";
|
|
5
|
+
import { execFile, spawn } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { createServer } from "node:net";
|
|
8
|
+
import { homedir } from "node:os";
|
|
9
|
+
//#region \0rolldown/runtime.js
|
|
10
|
+
var __defProp = Object.defineProperty;
|
|
11
|
+
var __exportAll = (all, no_symbols) => {
|
|
12
|
+
let target = {};
|
|
13
|
+
for (var name in all) __defProp(target, name, {
|
|
14
|
+
get: all[name],
|
|
15
|
+
enumerable: true
|
|
16
|
+
});
|
|
17
|
+
if (!no_symbols) __defProp(target, Symbol.toStringTag, { value: "Module" });
|
|
18
|
+
return target;
|
|
19
|
+
};
|
|
20
|
+
//#endregion
|
|
21
|
+
//#region src/host/git.ts
|
|
22
|
+
/**
|
|
23
|
+
* Git face (design doc §7, D11) — the ONLY place dsh-tiddlywiki shells out to
|
|
24
|
+
* git. The wiki folder itself is the repository; the folder is pure text
|
|
25
|
+
* (FileSystemAdaptor writes one file per tiddler), so git is a natural sync /
|
|
26
|
+
* backup channel.
|
|
27
|
+
*
|
|
28
|
+
* Sync model is the single-thread alternating one:
|
|
29
|
+
* 1. start of work: `git pull --rebase --autostash`
|
|
30
|
+
* 2. end of work: `git add -A && git commit && git push`
|
|
31
|
+
* 3. auto-commit: debounced 60s commit after wiki writes (AutoCommitter)
|
|
32
|
+
*
|
|
33
|
+
* Conflict policy (user-confirmed, no complex handling): a rebase conflict
|
|
34
|
+
* (only reachable by "forgot to pull before writing") → `git rebase --abort`
|
|
35
|
+
* + report the unmerged files. Never auto-merge data.
|
|
36
|
+
*
|
|
37
|
+
* @module dsh-tiddlywiki/host/git
|
|
38
|
+
*/
|
|
39
|
+
var git_exports = /* @__PURE__ */ __exportAll({
|
|
40
|
+
AutoCommitter: () => AutoCommitter,
|
|
41
|
+
GitFace: () => GitFace
|
|
42
|
+
});
|
|
43
|
+
const execFileP = promisify(execFile);
|
|
44
|
+
/** Timeout for quick read-only queries. */
|
|
45
|
+
const QUICK_TIMEOUT_MS = 5e3;
|
|
46
|
+
/** Timeout for structural/network operations. */
|
|
47
|
+
const HEAVY_TIMEOUT_MS = 6e4;
|
|
48
|
+
/** Default exec layer: run `git <args>` under a cwd with a timeout. */
|
|
49
|
+
const defaultExec = async (args, options) => {
|
|
50
|
+
try {
|
|
51
|
+
const { stdout, stderr } = await execFileP("git", args, {
|
|
52
|
+
cwd: options.cwd,
|
|
53
|
+
timeout: options.timeout ?? QUICK_TIMEOUT_MS,
|
|
54
|
+
windowsHide: true,
|
|
55
|
+
encoding: "utf8",
|
|
56
|
+
maxBuffer: 32 * 1024 * 1024
|
|
57
|
+
});
|
|
58
|
+
return {
|
|
59
|
+
ok: true,
|
|
60
|
+
stdout,
|
|
61
|
+
stderr
|
|
62
|
+
};
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const e = err;
|
|
65
|
+
return {
|
|
66
|
+
ok: false,
|
|
67
|
+
stdout: e.stdout ?? "",
|
|
68
|
+
stderr: e.stderr ?? String(e.message ?? err)
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
};
|
|
72
|
+
function parseCount(line, re) {
|
|
73
|
+
const m = line.match(re);
|
|
74
|
+
return m === null ? void 0 : Number(m[1]);
|
|
75
|
+
}
|
|
76
|
+
var GitFace = class {
|
|
77
|
+
exec;
|
|
78
|
+
constructor(exec = defaultExec) {
|
|
79
|
+
this.exec = exec;
|
|
80
|
+
}
|
|
81
|
+
async isRepo(dir) {
|
|
82
|
+
const r = await this.exec(["rev-parse", "--is-inside-work-tree"], {
|
|
83
|
+
cwd: dir,
|
|
84
|
+
timeout: 2e3
|
|
85
|
+
});
|
|
86
|
+
return r.ok && r.stdout.trim() === "true";
|
|
87
|
+
}
|
|
88
|
+
async init(dir, branch = "main") {
|
|
89
|
+
return (await this.exec([
|
|
90
|
+
"init",
|
|
91
|
+
"-b",
|
|
92
|
+
branch
|
|
93
|
+
], {
|
|
94
|
+
cwd: dir,
|
|
95
|
+
timeout: HEAVY_TIMEOUT_MS
|
|
96
|
+
})).ok;
|
|
97
|
+
}
|
|
98
|
+
/** Initial commit for a fresh repo (tolerates an empty index). */
|
|
99
|
+
async initialCommit(dir) {
|
|
100
|
+
await this.exec(["add", "-A"], {
|
|
101
|
+
cwd: dir,
|
|
102
|
+
timeout: HEAVY_TIMEOUT_MS
|
|
103
|
+
});
|
|
104
|
+
const r = await this.exec([
|
|
105
|
+
...identity(),
|
|
106
|
+
"commit",
|
|
107
|
+
"-m",
|
|
108
|
+
"chore(dsh-tiddlywiki): initial commit"
|
|
109
|
+
], {
|
|
110
|
+
cwd: dir,
|
|
111
|
+
timeout: HEAVY_TIMEOUT_MS
|
|
112
|
+
});
|
|
113
|
+
return r.ok || /nothing to commit/.test(r.stderr + r.stdout);
|
|
114
|
+
}
|
|
115
|
+
/**
|
|
116
|
+
* Stage everything and commit; a local identity is always provided so the
|
|
117
|
+
* plugin never depends on the machine's global git config. Returns whether
|
|
118
|
+
* a commit actually happened.
|
|
119
|
+
*/
|
|
120
|
+
async commit(dir, message) {
|
|
121
|
+
await this.exec(["add", "-A"], {
|
|
122
|
+
cwd: dir,
|
|
123
|
+
timeout: HEAVY_TIMEOUT_MS
|
|
124
|
+
});
|
|
125
|
+
if ((await this.exec([
|
|
126
|
+
"diff",
|
|
127
|
+
"--cached",
|
|
128
|
+
"--quiet"
|
|
129
|
+
], {
|
|
130
|
+
cwd: dir,
|
|
131
|
+
timeout: QUICK_TIMEOUT_MS
|
|
132
|
+
})).ok) return {
|
|
133
|
+
committed: false,
|
|
134
|
+
message: "nothing to commit"
|
|
135
|
+
};
|
|
136
|
+
const r = await this.exec([
|
|
137
|
+
...identity(),
|
|
138
|
+
"commit",
|
|
139
|
+
"-m",
|
|
140
|
+
message
|
|
141
|
+
], {
|
|
142
|
+
cwd: dir,
|
|
143
|
+
timeout: HEAVY_TIMEOUT_MS
|
|
144
|
+
});
|
|
145
|
+
return r.ok ? {
|
|
146
|
+
committed: true,
|
|
147
|
+
message
|
|
148
|
+
} : {
|
|
149
|
+
committed: false,
|
|
150
|
+
message: `commit failed: ${(r.stderr.trim() || r.stdout.trim()).slice(0, 500)}`
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
async status(dir) {
|
|
154
|
+
const empty = {
|
|
155
|
+
exists: false,
|
|
156
|
+
branch: "",
|
|
157
|
+
dirty: false,
|
|
158
|
+
dirtyFiles: [],
|
|
159
|
+
remote: ""
|
|
160
|
+
};
|
|
161
|
+
const r = await this.exec([
|
|
162
|
+
"status",
|
|
163
|
+
"--porcelain",
|
|
164
|
+
"-b"
|
|
165
|
+
], {
|
|
166
|
+
cwd: dir,
|
|
167
|
+
timeout: QUICK_TIMEOUT_MS
|
|
168
|
+
});
|
|
169
|
+
if (!r.ok) return empty;
|
|
170
|
+
const lines = r.stdout.split("\n").filter((l) => l.length > 0);
|
|
171
|
+
const branchLine = lines.find((l) => l.startsWith("## "));
|
|
172
|
+
const branch = branchLine === void 0 ? "" : branchLine.slice(3).split("...")[0] ?? "";
|
|
173
|
+
const ahead = branchLine === void 0 ? void 0 : parseCount(branchLine, /ahead (\d+)/);
|
|
174
|
+
const behind = branchLine === void 0 ? void 0 : parseCount(branchLine, /behind (\d+)/);
|
|
175
|
+
const dirty = lines.some((l) => !l.startsWith("## "));
|
|
176
|
+
const dirtyFiles = lines.filter((l) => !l.startsWith("## ")).map((l) => l.slice(3).trim()).filter(Boolean);
|
|
177
|
+
const remoteR = await this.exec(["remote", "-v"], {
|
|
178
|
+
cwd: dir,
|
|
179
|
+
timeout: QUICK_TIMEOUT_MS
|
|
180
|
+
});
|
|
181
|
+
const remote = remoteR.ok ? remoteR.stdout.split("\n").map((l) => l.trim()).find(Boolean) ?? "" : "";
|
|
182
|
+
const lastR = await this.exec([
|
|
183
|
+
"log",
|
|
184
|
+
"-1",
|
|
185
|
+
"--format=%h %s"
|
|
186
|
+
], {
|
|
187
|
+
cwd: dir,
|
|
188
|
+
timeout: QUICK_TIMEOUT_MS
|
|
189
|
+
});
|
|
190
|
+
const lastCommit = lastR.ok && lastR.stdout.trim().length > 0 ? lastR.stdout.trim() : void 0;
|
|
191
|
+
return {
|
|
192
|
+
exists: true,
|
|
193
|
+
branch,
|
|
194
|
+
dirty,
|
|
195
|
+
dirtyFiles,
|
|
196
|
+
remote,
|
|
197
|
+
...lastCommit !== void 0 ? { lastCommit } : {},
|
|
198
|
+
...ahead !== void 0 ? { ahead } : {},
|
|
199
|
+
...behind !== void 0 ? { behind } : {}
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
/** `git pull --rebase --autostash`; on conflict: abort + report files. */
|
|
203
|
+
async pull(dir) {
|
|
204
|
+
const r = await this.exec([
|
|
205
|
+
"pull",
|
|
206
|
+
"--rebase",
|
|
207
|
+
"--autostash"
|
|
208
|
+
], {
|
|
209
|
+
cwd: dir,
|
|
210
|
+
timeout: HEAVY_TIMEOUT_MS
|
|
211
|
+
});
|
|
212
|
+
if (r.ok) return {
|
|
213
|
+
ok: true,
|
|
214
|
+
message: r.stdout.trim() || "pull ok"
|
|
215
|
+
};
|
|
216
|
+
const conflictFiles = await this.unmergedFiles(dir);
|
|
217
|
+
await this.exec(["rebase", "--abort"], {
|
|
218
|
+
cwd: dir,
|
|
219
|
+
timeout: HEAVY_TIMEOUT_MS
|
|
220
|
+
});
|
|
221
|
+
const reason = (r.stderr.trim() || r.stdout.trim()).slice(0, 500);
|
|
222
|
+
return {
|
|
223
|
+
ok: false,
|
|
224
|
+
message: conflictFiles.length > 0 ? `conflict in ${conflictFiles.join(", ")} (rebase aborted): ${reason}` : `pull failed: ${reason}`,
|
|
225
|
+
...conflictFiles.length > 0 ? { conflictFiles } : {}
|
|
226
|
+
};
|
|
227
|
+
}
|
|
228
|
+
async push(dir) {
|
|
229
|
+
const r = await this.exec(["push"], {
|
|
230
|
+
cwd: dir,
|
|
231
|
+
timeout: HEAVY_TIMEOUT_MS
|
|
232
|
+
});
|
|
233
|
+
return r.ok ? {
|
|
234
|
+
ok: true,
|
|
235
|
+
message: r.stdout.trim() || "push ok"
|
|
236
|
+
} : {
|
|
237
|
+
ok: false,
|
|
238
|
+
message: (r.stderr.trim() || r.stdout.trim()).slice(0, 500)
|
|
239
|
+
};
|
|
240
|
+
}
|
|
241
|
+
/** First push with upstream tracking (called once after a remote is set). */
|
|
242
|
+
async firstPush(dir) {
|
|
243
|
+
const branch = (await this.status(dir)).branch || "main";
|
|
244
|
+
const r = await this.exec([
|
|
245
|
+
"push",
|
|
246
|
+
"-u",
|
|
247
|
+
"origin",
|
|
248
|
+
branch
|
|
249
|
+
], {
|
|
250
|
+
cwd: dir,
|
|
251
|
+
timeout: HEAVY_TIMEOUT_MS
|
|
252
|
+
});
|
|
253
|
+
return r.ok ? {
|
|
254
|
+
ok: true,
|
|
255
|
+
message: `pushed ${branch} to origin`
|
|
256
|
+
} : {
|
|
257
|
+
ok: false,
|
|
258
|
+
message: (r.stderr.trim() || r.stdout.trim()).slice(0, 500)
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
/** Ensure `origin` points at `url` (add or set-url). */
|
|
262
|
+
async ensureRemote(dir, url) {
|
|
263
|
+
const cur = await this.exec([
|
|
264
|
+
"remote",
|
|
265
|
+
"get-url",
|
|
266
|
+
"origin"
|
|
267
|
+
], {
|
|
268
|
+
cwd: dir,
|
|
269
|
+
timeout: QUICK_TIMEOUT_MS
|
|
270
|
+
});
|
|
271
|
+
if (cur.ok) {
|
|
272
|
+
if (cur.stdout.trim() === url) return {
|
|
273
|
+
ok: true,
|
|
274
|
+
message: "remote origin already set"
|
|
275
|
+
};
|
|
276
|
+
const set = await this.exec([
|
|
277
|
+
"remote",
|
|
278
|
+
"set-url",
|
|
279
|
+
"origin",
|
|
280
|
+
url
|
|
281
|
+
], {
|
|
282
|
+
cwd: dir,
|
|
283
|
+
timeout: HEAVY_TIMEOUT_MS
|
|
284
|
+
});
|
|
285
|
+
return set.ok ? {
|
|
286
|
+
ok: true,
|
|
287
|
+
message: `remote origin → ${url}`
|
|
288
|
+
} : {
|
|
289
|
+
ok: false,
|
|
290
|
+
message: set.stderr.trim() || "remote set-url failed"
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
const add = await this.exec([
|
|
294
|
+
"remote",
|
|
295
|
+
"add",
|
|
296
|
+
"origin",
|
|
297
|
+
url
|
|
298
|
+
], {
|
|
299
|
+
cwd: dir,
|
|
300
|
+
timeout: HEAVY_TIMEOUT_MS
|
|
301
|
+
});
|
|
302
|
+
return add.ok ? {
|
|
303
|
+
ok: true,
|
|
304
|
+
message: `remote origin → ${url}`
|
|
305
|
+
} : {
|
|
306
|
+
ok: false,
|
|
307
|
+
message: add.stderr.trim() || "remote add failed"
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
async unmergedFiles(dir) {
|
|
311
|
+
const r = await this.exec([
|
|
312
|
+
"diff",
|
|
313
|
+
"--name-only",
|
|
314
|
+
"--diff-filter=U"
|
|
315
|
+
], {
|
|
316
|
+
cwd: dir,
|
|
317
|
+
timeout: QUICK_TIMEOUT_MS
|
|
318
|
+
});
|
|
319
|
+
return r.ok ? r.stdout.split("\n").map((l) => l.trim()).filter(Boolean) : [];
|
|
320
|
+
}
|
|
321
|
+
};
|
|
322
|
+
/** Always-on local identity so commits never depend on global git config. */
|
|
323
|
+
function identity() {
|
|
324
|
+
return [
|
|
325
|
+
"-c",
|
|
326
|
+
"user.name=dsh-tiddlywiki",
|
|
327
|
+
"-c",
|
|
328
|
+
"user.email=dsh-tiddlywiki@local"
|
|
329
|
+
];
|
|
330
|
+
}
|
|
331
|
+
/**
|
|
332
|
+
* Debounced auto-committer: every wiki write calls `touch()`; the commit
|
|
333
|
+
* fires once writes settle for `debounceMs`. Disable with git.autoCommit.
|
|
334
|
+
*/
|
|
335
|
+
var AutoCommitter = class {
|
|
336
|
+
options;
|
|
337
|
+
timer;
|
|
338
|
+
disposed = false;
|
|
339
|
+
constructor(options) {
|
|
340
|
+
this.options = options;
|
|
341
|
+
}
|
|
342
|
+
touch() {
|
|
343
|
+
if (!this.options.enabled || this.disposed) return;
|
|
344
|
+
if (this.timer !== void 0) clearTimeout(this.timer);
|
|
345
|
+
this.timer = setTimeout(() => {
|
|
346
|
+
this.flush();
|
|
347
|
+
}, this.options.debounceMs);
|
|
348
|
+
}
|
|
349
|
+
/** Run a commit now (also cancels the pending debounce). */
|
|
350
|
+
async flush() {
|
|
351
|
+
if (this.timer !== void 0) {
|
|
352
|
+
clearTimeout(this.timer);
|
|
353
|
+
this.timer = void 0;
|
|
354
|
+
}
|
|
355
|
+
if (!this.options.enabled || this.disposed) return;
|
|
356
|
+
try {
|
|
357
|
+
const result = await this.options.git.commit(this.options.dir, this.options.message());
|
|
358
|
+
this.options.onCommit?.(result);
|
|
359
|
+
} catch (err) {
|
|
360
|
+
this.options.onError?.(err);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
dispose() {
|
|
364
|
+
this.disposed = true;
|
|
365
|
+
if (this.timer !== void 0) {
|
|
366
|
+
clearTimeout(this.timer);
|
|
367
|
+
this.timer = void 0;
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
};
|
|
371
|
+
//#endregion
|
|
372
|
+
//#region src/host/wiki.ts
|
|
373
|
+
/**
|
|
374
|
+
* WikiServer — the TiddlyWiki 5 child-process lifecycle (design doc §9, D3).
|
|
375
|
+
*
|
|
376
|
+
* Zero-friction rules:
|
|
377
|
+
* - ensure the wiki folder exists (scaffold with `--init server` once)
|
|
378
|
+
* - git bootstrap is NOT this class's job (index.ts owns the GitFace)
|
|
379
|
+
* - auto-detect a free loopback port unless one is pinned in config
|
|
380
|
+
* - spawn `node <tw>/tiddlywiki.js <wiki> --listen host=127.0.0.1 ...`
|
|
381
|
+
* and poll /status until it answers 200
|
|
382
|
+
* - the TW child serves at the ROOT of its own dedicated loopback port (no
|
|
383
|
+
* `path-prefix`): TW's browser frontend builds its API URLs from
|
|
384
|
+
* `$protocol$//$host$/` only, so any path-prefix makes every frontend call
|
|
385
|
+
* ../../status → 404 (verified against tiddlywiki 5.4.1). Namespacing lives
|
|
386
|
+
* on the DSH webserver side (/dsh-tiddlywiki/* routes), never in TW itself.
|
|
387
|
+
* - crash → restart with exponential backoff (1s,2s,4s… cap 30s), reset on
|
|
388
|
+
* a successful readiness
|
|
389
|
+
* - stop() is deterministic: SIGTERM, escalate to SIGKILL after a grace
|
|
390
|
+
* period, and never leave a timer that would respawn during teardown
|
|
391
|
+
*
|
|
392
|
+
* @module dsh-tiddlywiki/host/wiki
|
|
393
|
+
*/
|
|
394
|
+
/** The DSH webserver route prefix (NOT a TW path-prefix; see module header). */
|
|
395
|
+
const PATH_PREFIX = "/dsh-tiddlywiki";
|
|
396
|
+
/** How long to wait for the wiki to answer /status. */
|
|
397
|
+
const READY_TIMEOUT_MS = 2e4;
|
|
398
|
+
/** Poll cadence while waiting for readiness. */
|
|
399
|
+
const READY_POLL_MS = 500;
|
|
400
|
+
/** Backoff ceiling for crash restarts. */
|
|
401
|
+
const MAX_RESTART_BACKOFF_MS = 3e4;
|
|
402
|
+
/** SIGTERM → SIGKILL escalation grace. */
|
|
403
|
+
const KILL_GRACE_MS = 3e3;
|
|
404
|
+
/** Ring-buffer cap for the stdout/stderr log. */
|
|
405
|
+
const LOG_BUFFER_LIMIT = 200;
|
|
406
|
+
/** One-shot scaffold timeout for `--init server`. */
|
|
407
|
+
const INIT_TIMEOUT_MS = 3e4;
|
|
408
|
+
/** Resolve the absolute entry of the installed `tiddlywiki` package. */
|
|
409
|
+
function resolveTwEntry() {
|
|
410
|
+
return createRequire(import.meta.url).resolve("tiddlywiki/tiddlywiki.js");
|
|
411
|
+
}
|
|
412
|
+
var WikiServer = class {
|
|
413
|
+
options;
|
|
414
|
+
child;
|
|
415
|
+
wikiPath;
|
|
416
|
+
logs = [];
|
|
417
|
+
logLimit;
|
|
418
|
+
health = "stopped";
|
|
419
|
+
port;
|
|
420
|
+
stopping = false;
|
|
421
|
+
restartTimer;
|
|
422
|
+
restartDelay = 1e3;
|
|
423
|
+
lastStartedAt;
|
|
424
|
+
error;
|
|
425
|
+
constructor(options) {
|
|
426
|
+
this.options = options;
|
|
427
|
+
this.wikiPath = resolve(options.wikiRoot, options.wiki);
|
|
428
|
+
this.logLimit = options.logBufferLimit ?? LOG_BUFFER_LIMIT;
|
|
429
|
+
}
|
|
430
|
+
/** Base URL of the TW service, once a port is bound (root, no path prefix). */
|
|
431
|
+
get url() {
|
|
432
|
+
return this.port === void 0 ? void 0 : `http://127.0.0.1:${this.port}`;
|
|
433
|
+
}
|
|
434
|
+
/** The currently bound port (undefined until first spawn). */
|
|
435
|
+
get currentPort() {
|
|
436
|
+
return this.port;
|
|
437
|
+
}
|
|
438
|
+
log(line) {
|
|
439
|
+
const ts = (/* @__PURE__ */ new Date()).toISOString();
|
|
440
|
+
this.logs.push(`[${ts}] ${line}`);
|
|
441
|
+
if (this.logs.length > this.logLimit) this.logs.splice(0, this.logs.length - this.logLimit);
|
|
442
|
+
}
|
|
443
|
+
/** Scaffold the wiki folder with `--init server` when it is absent. */
|
|
444
|
+
async ensureWiki() {
|
|
445
|
+
await mkdir(this.wikiPath, { recursive: true });
|
|
446
|
+
if (existsSync(join(this.wikiPath, "tiddlywiki.info"))) return;
|
|
447
|
+
const tw = resolveTwEntry();
|
|
448
|
+
this.log(`init: ${process.execPath} ${tw} ${this.wikiPath} --init server`);
|
|
449
|
+
await new Promise((resolveP, rejectP) => {
|
|
450
|
+
execFile(process.execPath, [
|
|
451
|
+
tw,
|
|
452
|
+
this.wikiPath,
|
|
453
|
+
"--init",
|
|
454
|
+
"server"
|
|
455
|
+
], {
|
|
456
|
+
timeout: INIT_TIMEOUT_MS,
|
|
457
|
+
windowsHide: true
|
|
458
|
+
}, (err) => {
|
|
459
|
+
if (err) rejectP(err);
|
|
460
|
+
else resolveP();
|
|
461
|
+
});
|
|
462
|
+
});
|
|
463
|
+
}
|
|
464
|
+
/** Probe a free loopback port. */
|
|
465
|
+
async findFreePort() {
|
|
466
|
+
return new Promise((resolveP, rejectP) => {
|
|
467
|
+
const server = createServer();
|
|
468
|
+
server.unref();
|
|
469
|
+
server.once("error", rejectP);
|
|
470
|
+
server.listen(0, "127.0.0.1", () => {
|
|
471
|
+
const address = server.address();
|
|
472
|
+
if (address === null || typeof address === "string") {
|
|
473
|
+
server.close();
|
|
474
|
+
rejectP(/* @__PURE__ */ new Error("cannot resolve a free port"));
|
|
475
|
+
return;
|
|
476
|
+
}
|
|
477
|
+
const port = address.port;
|
|
478
|
+
server.close(() => resolveP(port));
|
|
479
|
+
});
|
|
480
|
+
});
|
|
481
|
+
}
|
|
482
|
+
/**
|
|
483
|
+
* Start (or restart) the TW child. Resolves once `/status` answers 200 or
|
|
484
|
+
* the readiness deadline passes. Never throws on a crash — the exit handler
|
|
485
|
+
* schedules a self-healing restart unless we are stopping.
|
|
486
|
+
*/
|
|
487
|
+
async start() {
|
|
488
|
+
this.stopping = false;
|
|
489
|
+
this.restartDelay = 1e3;
|
|
490
|
+
await this.ensureWiki();
|
|
491
|
+
if (this.child !== void 0) return this.status();
|
|
492
|
+
this.health = "starting";
|
|
493
|
+
const port = this.options.port > 0 ? this.options.port : (this.port ?? 0) > 0 ? this.port : await this.findFreePort();
|
|
494
|
+
this.port = port;
|
|
495
|
+
const args = [
|
|
496
|
+
resolveTwEntry(),
|
|
497
|
+
this.wikiPath,
|
|
498
|
+
"--listen",
|
|
499
|
+
"host=127.0.0.1",
|
|
500
|
+
`port=${port}`
|
|
501
|
+
];
|
|
502
|
+
if (this.options.username) {
|
|
503
|
+
args.push(`username=${this.options.username}`);
|
|
504
|
+
args.push(`password=${this.options.password ?? ""}`);
|
|
505
|
+
args.push(`readers=${this.options.username}`);
|
|
506
|
+
args.push(`writers=${this.options.username}`);
|
|
507
|
+
}
|
|
508
|
+
this.log(`spawn: ${process.execPath} ${args.join(" ")}`);
|
|
509
|
+
const child = spawn(process.execPath, args, {
|
|
510
|
+
cwd: this.wikiPath,
|
|
511
|
+
stdio: [
|
|
512
|
+
"ignore",
|
|
513
|
+
"pipe",
|
|
514
|
+
"pipe"
|
|
515
|
+
],
|
|
516
|
+
windowsHide: true
|
|
517
|
+
});
|
|
518
|
+
this.child = child;
|
|
519
|
+
child.stdout.on("data", (chunk) => this.log(`[out] ${String(chunk).trimEnd()}`));
|
|
520
|
+
child.stderr.on("data", (chunk) => this.log(`[err] ${String(chunk).trimEnd()}`));
|
|
521
|
+
child.once("exit", (code, signal) => {
|
|
522
|
+
this.log(`exit code=${code} signal=${signal ?? ""} stopping=${this.stopping}`);
|
|
523
|
+
this.child = void 0;
|
|
524
|
+
this.health = "stopped";
|
|
525
|
+
if (!this.stopping) this.scheduleRestart();
|
|
526
|
+
});
|
|
527
|
+
child.once("error", (err) => {
|
|
528
|
+
this.log(`spawn error: ${err.message}`);
|
|
529
|
+
this.error = err.message;
|
|
530
|
+
this.child = void 0;
|
|
531
|
+
this.health = "failed";
|
|
532
|
+
if (!this.stopping) this.scheduleRestart();
|
|
533
|
+
});
|
|
534
|
+
this.lastStartedAt = Date.now();
|
|
535
|
+
await this.waitReady();
|
|
536
|
+
return this.status();
|
|
537
|
+
}
|
|
538
|
+
/** Poll /status until 200 or the deadline; throws only on deadline/crash. */
|
|
539
|
+
async waitReady() {
|
|
540
|
+
const deadline = Date.now() + READY_TIMEOUT_MS;
|
|
541
|
+
for (;;) {
|
|
542
|
+
if (this.child === void 0) throw new Error("wiki process exited before ready");
|
|
543
|
+
try {
|
|
544
|
+
if ((await fetch(`${this.url}/status`, { signal: AbortSignal.timeout(2e3) })).ok) {
|
|
545
|
+
this.health = "running";
|
|
546
|
+
this.log("ready: /status 200");
|
|
547
|
+
return;
|
|
548
|
+
}
|
|
549
|
+
} catch {}
|
|
550
|
+
if (Date.now() > deadline) {
|
|
551
|
+
this.health = "failed";
|
|
552
|
+
this.error = "wiki server did not become ready in time";
|
|
553
|
+
this.log(this.error);
|
|
554
|
+
throw new Error(this.error);
|
|
555
|
+
}
|
|
556
|
+
await new Promise((r) => setTimeout(r, READY_POLL_MS));
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
scheduleRestart() {
|
|
560
|
+
if (this.stopping || this.restartTimer !== void 0) return;
|
|
561
|
+
const delay = this.restartDelay;
|
|
562
|
+
this.restartDelay = Math.min(this.restartDelay * 2, MAX_RESTART_BACKOFF_MS);
|
|
563
|
+
this.log(`restart scheduled in ${delay}ms`);
|
|
564
|
+
this.health = "starting";
|
|
565
|
+
this.restartTimer = setTimeout(() => {
|
|
566
|
+
this.restartTimer = void 0;
|
|
567
|
+
this.start().catch((err) => {
|
|
568
|
+
this.health = "failed";
|
|
569
|
+
this.error = err instanceof Error ? err.message : String(err);
|
|
570
|
+
this.log(`restart failed: ${this.error}`);
|
|
571
|
+
});
|
|
572
|
+
}, delay);
|
|
573
|
+
}
|
|
574
|
+
/** One-click restart (route /dsh-tiddlywiki/restart, panel retry button). */
|
|
575
|
+
async restart() {
|
|
576
|
+
await this.stop();
|
|
577
|
+
return this.start();
|
|
578
|
+
}
|
|
579
|
+
/** Deterministic teardown: cancel timers, SIGTERM, escalate to SIGKILL. */
|
|
580
|
+
async stop() {
|
|
581
|
+
this.stopping = true;
|
|
582
|
+
if (this.restartTimer !== void 0) {
|
|
583
|
+
clearTimeout(this.restartTimer);
|
|
584
|
+
this.restartTimer = void 0;
|
|
585
|
+
}
|
|
586
|
+
const child = this.child;
|
|
587
|
+
this.child = void 0;
|
|
588
|
+
if (child !== void 0 && child.exitCode === null && child.signalCode === null) {
|
|
589
|
+
try {
|
|
590
|
+
child.kill("SIGTERM");
|
|
591
|
+
} catch {}
|
|
592
|
+
await Promise.race([new Promise((r) => child.once("exit", () => r())), new Promise((r) => {
|
|
593
|
+
setTimeout(() => {
|
|
594
|
+
try {
|
|
595
|
+
child.kill("SIGKILL");
|
|
596
|
+
} catch {}
|
|
597
|
+
r();
|
|
598
|
+
}, KILL_GRACE_MS).unref?.();
|
|
599
|
+
})]);
|
|
600
|
+
}
|
|
601
|
+
this.health = "stopped";
|
|
602
|
+
}
|
|
603
|
+
/** Live status view (health, url, git-independent, recent logs). */
|
|
604
|
+
status() {
|
|
605
|
+
return {
|
|
606
|
+
status: this.health,
|
|
607
|
+
url: this.url,
|
|
608
|
+
port: this.port,
|
|
609
|
+
wikiPath: this.wikiPath,
|
|
610
|
+
pid: this.child?.pid,
|
|
611
|
+
lastStartedAt: this.lastStartedAt,
|
|
612
|
+
...this.error !== void 0 ? { error: this.error } : {},
|
|
613
|
+
logs: [...this.logs]
|
|
614
|
+
};
|
|
615
|
+
}
|
|
616
|
+
};
|
|
617
|
+
//#endregion
|
|
618
|
+
//#region src/host/routes.ts
|
|
619
|
+
const ROUTE_PREFIX = PATH_PREFIX;
|
|
620
|
+
/** Max JSON body for note/restart. */
|
|
621
|
+
const MAX_BODY_BYTES = 2 * 1024 * 1024;
|
|
622
|
+
/** Max passthrough body (tiddler content can be large). */
|
|
623
|
+
const MAX_PROXY_BODY_BYTES = 16 * 1024 * 1024;
|
|
624
|
+
function readBody$1(req, limit = MAX_BODY_BYTES) {
|
|
625
|
+
return new Promise((resolveP, rejectP) => {
|
|
626
|
+
let size = 0;
|
|
627
|
+
const chunks = [];
|
|
628
|
+
req.on("data", (chunk) => {
|
|
629
|
+
size += chunk.length;
|
|
630
|
+
if (size > limit) {
|
|
631
|
+
rejectP(/* @__PURE__ */ new Error("body too large"));
|
|
632
|
+
req.destroy();
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
635
|
+
chunks.push(chunk);
|
|
636
|
+
});
|
|
637
|
+
req.on("end", () => resolveP(Buffer.concat(chunks).toString("utf8")));
|
|
638
|
+
req.on("error", rejectP);
|
|
639
|
+
});
|
|
640
|
+
}
|
|
641
|
+
function json$1(res, payload, status = 200) {
|
|
642
|
+
const body = JSON.stringify(payload);
|
|
643
|
+
res.writeHead(status, {
|
|
644
|
+
"content-type": "application/json; charset=utf-8",
|
|
645
|
+
"cache-control": "no-store"
|
|
646
|
+
});
|
|
647
|
+
res.end(body);
|
|
648
|
+
}
|
|
649
|
+
function pad(n) {
|
|
650
|
+
return n < 10 ? `0${n}` : String(n);
|
|
651
|
+
}
|
|
652
|
+
/** Default note title: `YYYY-MM-DD HH:mm` (design doc D6). */
|
|
653
|
+
function timestampTitle(date = /* @__PURE__ */ new Date()) {
|
|
654
|
+
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}`;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Open a tiddler in TW's NATIVE editor: save the tiddler (when text is
|
|
658
|
+
* non-empty), reuse or create a DRAFT tiddler carrying `draft.of`/`draft.title`
|
|
659
|
+
* (TW's story view renders drafts with the EditTemplate — list.js:
|
|
660
|
+
* `isDraft && editTemplate`), and return the draft title so the client can
|
|
661
|
+
* navigate the panel iframe to `#<draftTitle>`.
|
|
662
|
+
*/
|
|
663
|
+
async function openInTwEditor(client, title, text, tag) {
|
|
664
|
+
if (text.trim().length > 0) await client.put({
|
|
665
|
+
title,
|
|
666
|
+
text,
|
|
667
|
+
tags: [tag]
|
|
668
|
+
});
|
|
669
|
+
let draftText = text;
|
|
670
|
+
if (draftText.trim().length === 0) draftText = (await client.get(title))?.text ?? "";
|
|
671
|
+
let draftTitle;
|
|
672
|
+
try {
|
|
673
|
+
const items = await client.list(void 0, true);
|
|
674
|
+
for (const item of items) if (item["draft.of"] === title && typeof item.title === "string") {
|
|
675
|
+
draftTitle = item.title;
|
|
676
|
+
break;
|
|
677
|
+
}
|
|
678
|
+
} catch {}
|
|
679
|
+
if (draftTitle === void 0) draftTitle = `Draft of "${title}" ${Date.now()}`;
|
|
680
|
+
await client.put({
|
|
681
|
+
title: draftTitle,
|
|
682
|
+
text: draftText,
|
|
683
|
+
"draft.of": title,
|
|
684
|
+
"draft.title": title,
|
|
685
|
+
type: "text/vnd.tiddlywiki"
|
|
686
|
+
});
|
|
687
|
+
return {
|
|
688
|
+
title,
|
|
689
|
+
draftTitle
|
|
690
|
+
};
|
|
691
|
+
}
|
|
692
|
+
function registerRoutes(ctx, deps) {
|
|
693
|
+
const handleStatus = async (_req, res) => {
|
|
694
|
+
const view = deps.server.status();
|
|
695
|
+
let gitSummary = null;
|
|
696
|
+
try {
|
|
697
|
+
gitSummary = await deps.git.status(deps.getWikiPath());
|
|
698
|
+
} catch {
|
|
699
|
+
gitSummary = null;
|
|
700
|
+
}
|
|
701
|
+
json$1(res, {
|
|
702
|
+
ok: true,
|
|
703
|
+
...view,
|
|
704
|
+
git: gitSummary,
|
|
705
|
+
note: { tag: deps.noteDefaults().tag }
|
|
706
|
+
});
|
|
707
|
+
};
|
|
708
|
+
const handleNote = async (req, res) => {
|
|
709
|
+
try {
|
|
710
|
+
const body = JSON.parse(await readBody$1(req));
|
|
711
|
+
const text = typeof body.text === "string" && body.text.trim().length > 0 ? body.text.trim() : null;
|
|
712
|
+
if (text === null) {
|
|
713
|
+
json$1(res, {
|
|
714
|
+
ok: false,
|
|
715
|
+
error: "text is required"
|
|
716
|
+
}, 400);
|
|
717
|
+
return;
|
|
718
|
+
}
|
|
719
|
+
const client = deps.getClient();
|
|
720
|
+
if (client === void 0) {
|
|
721
|
+
json$1(res, {
|
|
722
|
+
ok: false,
|
|
723
|
+
error: "wiki service is not running"
|
|
724
|
+
}, 503);
|
|
725
|
+
return;
|
|
726
|
+
}
|
|
727
|
+
const title = typeof body.title === "string" && body.title.trim().length > 0 ? body.title.trim() : timestampTitle();
|
|
728
|
+
const tag = typeof body.tag === "string" && body.tag.trim().length > 0 ? body.tag.trim() : deps.noteDefaults().tag;
|
|
729
|
+
await client.put({
|
|
730
|
+
title,
|
|
731
|
+
text,
|
|
732
|
+
tags: [tag]
|
|
733
|
+
});
|
|
734
|
+
deps.autoCommit();
|
|
735
|
+
json$1(res, {
|
|
736
|
+
ok: true,
|
|
737
|
+
title,
|
|
738
|
+
tag,
|
|
739
|
+
text
|
|
740
|
+
});
|
|
741
|
+
} catch (err) {
|
|
742
|
+
json$1(res, {
|
|
743
|
+
ok: false,
|
|
744
|
+
error: err instanceof Error ? err.message : String(err)
|
|
745
|
+
}, 500);
|
|
746
|
+
}
|
|
747
|
+
};
|
|
748
|
+
const handleEdit = async (req, res) => {
|
|
749
|
+
try {
|
|
750
|
+
const body = JSON.parse(await readBody$1(req));
|
|
751
|
+
const client = deps.getClient();
|
|
752
|
+
if (client === void 0) {
|
|
753
|
+
json$1(res, {
|
|
754
|
+
ok: false,
|
|
755
|
+
error: "wiki service is not running"
|
|
756
|
+
}, 503);
|
|
757
|
+
return;
|
|
758
|
+
}
|
|
759
|
+
const title = typeof body.title === "string" && body.title.trim().length > 0 ? body.title.trim() : timestampTitle();
|
|
760
|
+
const tag = typeof body.tag === "string" && body.tag.trim().length > 0 ? body.tag.trim() : deps.noteDefaults().tag;
|
|
761
|
+
const result = await openInTwEditor(client, title, typeof body.text === "string" ? body.text : "", tag);
|
|
762
|
+
deps.autoCommit();
|
|
763
|
+
json$1(res, {
|
|
764
|
+
ok: true,
|
|
765
|
+
...result,
|
|
766
|
+
twUrl: deps.server.url
|
|
767
|
+
});
|
|
768
|
+
} catch (err) {
|
|
769
|
+
json$1(res, {
|
|
770
|
+
ok: false,
|
|
771
|
+
error: err instanceof Error ? err.message : String(err)
|
|
772
|
+
}, 500);
|
|
773
|
+
}
|
|
774
|
+
};
|
|
775
|
+
const handleRestart = async (_req, res) => {
|
|
776
|
+
try {
|
|
777
|
+
await deps.server.restart();
|
|
778
|
+
json$1(res, {
|
|
779
|
+
ok: true,
|
|
780
|
+
status: deps.server.status().status
|
|
781
|
+
});
|
|
782
|
+
} catch (err) {
|
|
783
|
+
json$1(res, {
|
|
784
|
+
ok: false,
|
|
785
|
+
error: err instanceof Error ? err.message : String(err)
|
|
786
|
+
}, 500);
|
|
787
|
+
}
|
|
788
|
+
};
|
|
789
|
+
/** Passthrough /dsh-tiddlywiki/api/<rest> → TW root /<rest>. */
|
|
790
|
+
const handleApiProxy = async (req, res) => {
|
|
791
|
+
if (deps.getClient() === void 0) {
|
|
792
|
+
json$1(res, {
|
|
793
|
+
ok: false,
|
|
794
|
+
error: "wiki service is not running"
|
|
795
|
+
}, 503);
|
|
796
|
+
return;
|
|
797
|
+
}
|
|
798
|
+
const url = new URL(req.url ?? "/", "http://127.0.0.1");
|
|
799
|
+
const rest = url.pathname.replace(/^\/dsh-tiddlywiki\/api/, "") || "/";
|
|
800
|
+
try {
|
|
801
|
+
const headers = {};
|
|
802
|
+
const ct = req.headers["content-type"];
|
|
803
|
+
if (typeof ct === "string") headers["content-type"] = ct;
|
|
804
|
+
const method = (req.method ?? "GET").toUpperCase();
|
|
805
|
+
if (method === "PUT" || method === "DELETE" || method === "POST") headers["x-requested-with"] = "TiddlyWiki";
|
|
806
|
+
const init = {
|
|
807
|
+
method,
|
|
808
|
+
headers,
|
|
809
|
+
signal: AbortSignal.timeout(15e3)
|
|
810
|
+
};
|
|
811
|
+
if (method === "PUT" || method === "POST") init.body = await readBody$1(req, MAX_PROXY_BODY_BYTES);
|
|
812
|
+
const upstream = await fetch(`${deps.server.url}${rest}${url.search}`, init);
|
|
813
|
+
const data = await upstream.text();
|
|
814
|
+
res.writeHead(upstream.status, {
|
|
815
|
+
"content-type": upstream.headers.get("content-type") ?? "application/json; charset=utf-8",
|
|
816
|
+
"cache-control": "no-store"
|
|
817
|
+
});
|
|
818
|
+
res.end(data);
|
|
819
|
+
} catch (err) {
|
|
820
|
+
json$1(res, {
|
|
821
|
+
ok: false,
|
|
822
|
+
error: err instanceof Error ? err.message : String(err)
|
|
823
|
+
}, 502);
|
|
824
|
+
}
|
|
825
|
+
};
|
|
826
|
+
const disposers = [
|
|
827
|
+
ctx.webServer.register({
|
|
828
|
+
kind: "exact",
|
|
829
|
+
path: `${ROUTE_PREFIX}/status`,
|
|
830
|
+
handler: (req, res) => {
|
|
831
|
+
handleStatus(req, res);
|
|
832
|
+
}
|
|
833
|
+
}),
|
|
834
|
+
ctx.webServer.register({
|
|
835
|
+
kind: "exact",
|
|
836
|
+
path: `${ROUTE_PREFIX}/note`,
|
|
837
|
+
handler: (req, res) => {
|
|
838
|
+
handleNote(req, res);
|
|
839
|
+
}
|
|
840
|
+
}),
|
|
841
|
+
ctx.webServer.register({
|
|
842
|
+
kind: "exact",
|
|
843
|
+
path: `${ROUTE_PREFIX}/edit`,
|
|
844
|
+
handler: (req, res) => {
|
|
845
|
+
handleEdit(req, res);
|
|
846
|
+
}
|
|
847
|
+
}),
|
|
848
|
+
ctx.webServer.register({
|
|
849
|
+
kind: "exact",
|
|
850
|
+
path: `${ROUTE_PREFIX}/restart`,
|
|
851
|
+
handler: (req, res) => {
|
|
852
|
+
handleRestart(req, res);
|
|
853
|
+
}
|
|
854
|
+
}),
|
|
855
|
+
ctx.webServer.register({
|
|
856
|
+
kind: "prefix",
|
|
857
|
+
path: `${ROUTE_PREFIX}/api`,
|
|
858
|
+
handler: (req, res) => {
|
|
859
|
+
handleApiProxy(req, res);
|
|
860
|
+
}
|
|
861
|
+
})
|
|
862
|
+
];
|
|
863
|
+
return () => {
|
|
864
|
+
for (const dispose of disposers) dispose();
|
|
865
|
+
};
|
|
866
|
+
}
|
|
867
|
+
//#endregion
|
|
868
|
+
//#region src/host/config.ts
|
|
869
|
+
/** Config tiddler (JSON string) where the settings page stores overrides. */
|
|
870
|
+
const CONFIG_TIDDLER = "$:/plugins/dsh-tiddlywiki/config";
|
|
871
|
+
function isPlainObject(value) {
|
|
872
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
873
|
+
}
|
|
874
|
+
/** Deep-merge: `over` wins; nested plain objects merge recursively. */
|
|
875
|
+
function deepMerge(base, over) {
|
|
876
|
+
const out = { ...base };
|
|
877
|
+
for (const [key, value] of Object.entries(over)) {
|
|
878
|
+
if (value === void 0) continue;
|
|
879
|
+
if (isPlainObject(value) && isPlainObject(out[key])) out[key] = deepMerge(out[key], value);
|
|
880
|
+
else out[key] = value;
|
|
881
|
+
}
|
|
882
|
+
return out;
|
|
883
|
+
}
|
|
884
|
+
/**
|
|
885
|
+
* Runtime config store: caches the override tiddler and exposes the effective
|
|
886
|
+
* (merged) config. `load` runs at startup and after every write/restart.
|
|
887
|
+
*/
|
|
888
|
+
var ConfigStore = class {
|
|
889
|
+
base;
|
|
890
|
+
overrides = {};
|
|
891
|
+
constructor(base) {
|
|
892
|
+
this.base = base;
|
|
893
|
+
}
|
|
894
|
+
/** Effective config = cordis base overlaid with the user override tiddler. */
|
|
895
|
+
get() {
|
|
896
|
+
return deepMerge(this.base, this.overrides);
|
|
897
|
+
}
|
|
898
|
+
/** Reload the override tiddler (no-op when the wiki is unavailable). */
|
|
899
|
+
async load(client) {
|
|
900
|
+
this.overrides = {};
|
|
901
|
+
if (client === void 0) return;
|
|
902
|
+
try {
|
|
903
|
+
const tiddler = await client.get(CONFIG_TIDDLER);
|
|
904
|
+
if (tiddler !== void 0 && typeof tiddler.text === "string") {
|
|
905
|
+
const parsed = JSON.parse(tiddler.text);
|
|
906
|
+
if (isPlainObject(parsed)) this.overrides = parsed;
|
|
907
|
+
}
|
|
908
|
+
} catch {
|
|
909
|
+
this.overrides = {};
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
/** Merge a patch into the overrides and persist the tiddler. */
|
|
913
|
+
async set(client, patch) {
|
|
914
|
+
this.overrides = deepMerge(this.overrides, patch);
|
|
915
|
+
await client.put({
|
|
916
|
+
title: CONFIG_TIDDLER,
|
|
917
|
+
text: JSON.stringify(this.overrides, null, 2),
|
|
918
|
+
type: "application/json",
|
|
919
|
+
tags: []
|
|
920
|
+
});
|
|
921
|
+
return this.get();
|
|
922
|
+
}
|
|
923
|
+
};
|
|
924
|
+
//#endregion
|
|
925
|
+
//#region src/host/admin.ts
|
|
926
|
+
/**
|
|
927
|
+
* Admin surface for the plugin settings page (design doc §13, config panel).
|
|
928
|
+
*
|
|
929
|
+
* - dynamic plugin/theme management: enumerate the bundled catalog from the
|
|
930
|
+
* installed tiddlywiki package, read/write the wiki's `tiddlywiki.info`
|
|
931
|
+
* plugins/themes arrays, then restart the TW child so the change applies;
|
|
932
|
+
* - extensible config: the settings page reads/writes a config tiddler
|
|
933
|
+
* ($:/plugins/dsh-tiddlywiki/config, a JSON string) that overlays the
|
|
934
|
+
* cordis `config:` block — future config fields just extend the shape.
|
|
935
|
+
*
|
|
936
|
+
* Routes (all under ROUTE_PREFIX/admin, JSON):
|
|
937
|
+
* GET /admin/state current info + catalog + effective config + status
|
|
938
|
+
* POST /admin/info { plugins?, themes? } → write info → restart TW
|
|
939
|
+
* POST /admin/config { ...patch } → write config tiddler
|
|
940
|
+
* POST /admin/restart restart the TW child
|
|
941
|
+
*
|
|
942
|
+
* @module dsh-tiddlywiki/host/admin
|
|
943
|
+
*/
|
|
944
|
+
/** Resolve the installed tiddlywiki package root (for the catalog). */
|
|
945
|
+
function resolveTwRoot() {
|
|
946
|
+
return dirname(createRequire(import.meta.url).resolve("tiddlywiki/package.json"));
|
|
947
|
+
}
|
|
948
|
+
/** Read the wiki's tiddlywiki.info. */
|
|
949
|
+
async function readWikiInfo(wikiPath) {
|
|
950
|
+
let raw;
|
|
951
|
+
try {
|
|
952
|
+
raw = await readFile(join(wikiPath, "tiddlywiki.info"), "utf8");
|
|
953
|
+
} catch {
|
|
954
|
+
return {
|
|
955
|
+
plugins: [],
|
|
956
|
+
themes: [],
|
|
957
|
+
languages: []
|
|
958
|
+
};
|
|
959
|
+
}
|
|
960
|
+
const parsed = JSON.parse(raw);
|
|
961
|
+
return {
|
|
962
|
+
description: parsed.description,
|
|
963
|
+
plugins: parsed.plugins ?? [],
|
|
964
|
+
themes: parsed.themes ?? [],
|
|
965
|
+
languages: parsed.languages ?? [],
|
|
966
|
+
...parsed
|
|
967
|
+
};
|
|
968
|
+
}
|
|
969
|
+
/** Write the wiki's tiddlywiki.info (pretty-printed, ordering preserved). */
|
|
970
|
+
async function writeWikiInfo(wikiPath, info) {
|
|
971
|
+
await writeFile(join(wikiPath, "tiddlywiki.info"), `${JSON.stringify(info, null, 4)}\n`, "utf8");
|
|
972
|
+
}
|
|
973
|
+
/** Enumerate bundled official plugins + themes + languages of tiddlywiki. */
|
|
974
|
+
async function bundledCatalog(twRoot) {
|
|
975
|
+
const themeHasCss = async (dir) => {
|
|
976
|
+
for (const name of ["base.tid", "styles.tid"]) try {
|
|
977
|
+
if ((await readFile(join(twRoot, "themes", "tiddlywiki", dir, name), "utf8")).replace(/^[\s\S]*?\r?\n\r?\n/, "").split("\n").filter((line) => !/^\\rules\b/.test(line.trim())).join("\n").trim().length > 0) return true;
|
|
978
|
+
} catch {}
|
|
979
|
+
return false;
|
|
980
|
+
};
|
|
981
|
+
const scan = async (sub) => {
|
|
982
|
+
const root = join(twRoot, sub, "tiddlywiki");
|
|
983
|
+
let dirs;
|
|
984
|
+
try {
|
|
985
|
+
dirs = await readdir(root);
|
|
986
|
+
} catch {
|
|
987
|
+
return [];
|
|
988
|
+
}
|
|
989
|
+
const out = [];
|
|
990
|
+
for (const dir of dirs) {
|
|
991
|
+
let info = {};
|
|
992
|
+
try {
|
|
993
|
+
info = JSON.parse(await readFile(join(root, dir, "plugin.info"), "utf8"));
|
|
994
|
+
} catch {
|
|
995
|
+
info = {};
|
|
996
|
+
}
|
|
997
|
+
if (sub === "themes" && dir !== "vanilla" && !await themeHasCss(dir)) continue;
|
|
998
|
+
out.push({
|
|
999
|
+
name: `tiddlywiki/${dir}`,
|
|
1000
|
+
title: sub === "plugins" ? `$:/plugins/tiddlywiki/${dir}` : `$:/themes/tiddlywiki/${dir}`,
|
|
1001
|
+
label: info.name ?? dir,
|
|
1002
|
+
description: info.description ?? "",
|
|
1003
|
+
dependents: Array.isArray(info.dependents) ? info.dependents.map((dep) => dep.replace(/^\$:\/themes\/tiddlywiki\//, "tiddlywiki/")) : void 0
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
1007
|
+
return out;
|
|
1008
|
+
};
|
|
1009
|
+
const scanLanguages = async () => {
|
|
1010
|
+
const root = join(twRoot, "languages");
|
|
1011
|
+
let dirs;
|
|
1012
|
+
try {
|
|
1013
|
+
dirs = await readdir(root);
|
|
1014
|
+
} catch {
|
|
1015
|
+
return [];
|
|
1016
|
+
}
|
|
1017
|
+
const out = [];
|
|
1018
|
+
for (const dir of dirs) {
|
|
1019
|
+
let info = {};
|
|
1020
|
+
try {
|
|
1021
|
+
info = JSON.parse(await readFile(join(root, dir, "plugin.info"), "utf8"));
|
|
1022
|
+
} catch {
|
|
1023
|
+
info = {};
|
|
1024
|
+
}
|
|
1025
|
+
out.push({
|
|
1026
|
+
name: dir,
|
|
1027
|
+
title: `$:/languages/${dir}`,
|
|
1028
|
+
label: info.name ?? dir,
|
|
1029
|
+
description: info.description ?? ""
|
|
1030
|
+
});
|
|
1031
|
+
}
|
|
1032
|
+
out.sort((a, b) => a.name.localeCompare(b.name));
|
|
1033
|
+
return out;
|
|
1034
|
+
};
|
|
1035
|
+
const [plugins, themes, languages] = await Promise.all([
|
|
1036
|
+
scan("plugins"),
|
|
1037
|
+
scan("themes"),
|
|
1038
|
+
scanLanguages()
|
|
1039
|
+
]);
|
|
1040
|
+
return {
|
|
1041
|
+
plugins,
|
|
1042
|
+
themes,
|
|
1043
|
+
languages
|
|
1044
|
+
};
|
|
1045
|
+
}
|
|
1046
|
+
/**
|
|
1047
|
+
* Normalize a theme selection into the tiddlywiki.info `themes` array.
|
|
1048
|
+
*
|
|
1049
|
+
* TW themes are SKINS with a dependency chain (plugin.info `dependents`):
|
|
1050
|
+
* vanilla ← snowwhite ← heavier / centralised / readonly / starlight
|
|
1051
|
+
* vanilla ← tight / seamless
|
|
1052
|
+
* The ACTIVE theme is `$:/theme`, and switching to it registers the theme PLUS
|
|
1053
|
+
* its transitive dependents (boot.js accumulatePlugin) — if a dependent isn't
|
|
1054
|
+
* loaded, the vanilla base stylesheet is lost and the UI breaks. So we always
|
|
1055
|
+
* emit the transitive closure, dependency-first (base first, active overlay
|
|
1056
|
+
* last), and force vanilla in as the base. Empty selection → vanilla.
|
|
1057
|
+
*/
|
|
1058
|
+
function normalizeThemes(selected, deps = {}) {
|
|
1059
|
+
const sel = selected.filter((name) => typeof name === "string" && name.length > 0);
|
|
1060
|
+
if (sel.length === 0) sel.push("tiddlywiki/vanilla");
|
|
1061
|
+
const out = [];
|
|
1062
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1063
|
+
const visit = (name) => {
|
|
1064
|
+
if (seen.has(name)) return;
|
|
1065
|
+
seen.add(name);
|
|
1066
|
+
for (const dep of deps[name] ?? []) if (dep !== name) visit(dep);
|
|
1067
|
+
out.push(name);
|
|
1068
|
+
};
|
|
1069
|
+
for (const name of sel) visit(name);
|
|
1070
|
+
if (!out.includes("tiddlywiki/vanilla")) out.unshift("tiddlywiki/vanilla");
|
|
1071
|
+
return out;
|
|
1072
|
+
}
|
|
1073
|
+
/**
|
|
1074
|
+
* Ensure a language code (e.g. "zh-Hans") is in tiddlywiki.info `languages`.
|
|
1075
|
+
* Returns whether tiddlywiki.info changed (caller decides whether to restart).
|
|
1076
|
+
*/
|
|
1077
|
+
async function ensureLanguage(wikiPath, twRoot, lang) {
|
|
1078
|
+
if (typeof lang !== "string" || lang.trim().length === 0) return false;
|
|
1079
|
+
const code = lang.trim();
|
|
1080
|
+
if (!(await bundledCatalog(twRoot)).languages.some((l) => l.name === code)) throw new Error(`unknown language plugin: ${code}`);
|
|
1081
|
+
const info = await readWikiInfo(wikiPath);
|
|
1082
|
+
const current = info.languages ?? [];
|
|
1083
|
+
if (current.includes(code)) return false;
|
|
1084
|
+
info.languages = [...current, code];
|
|
1085
|
+
await writeWikiInfo(wikiPath, info);
|
|
1086
|
+
return true;
|
|
1087
|
+
}
|
|
1088
|
+
function json(res, payload, status = 200) {
|
|
1089
|
+
res.writeHead(status, {
|
|
1090
|
+
"content-type": "application/json; charset=utf-8",
|
|
1091
|
+
"cache-control": "no-store"
|
|
1092
|
+
});
|
|
1093
|
+
res.end(JSON.stringify(payload));
|
|
1094
|
+
}
|
|
1095
|
+
async function readBody(req, limit = 1024 * 1024) {
|
|
1096
|
+
return new Promise((resolveP, rejectP) => {
|
|
1097
|
+
let size = 0;
|
|
1098
|
+
const chunks = [];
|
|
1099
|
+
req.on("data", (chunk) => {
|
|
1100
|
+
size += chunk.length;
|
|
1101
|
+
if (size > limit) {
|
|
1102
|
+
rejectP(/* @__PURE__ */ new Error("body too large"));
|
|
1103
|
+
req.destroy();
|
|
1104
|
+
return;
|
|
1105
|
+
}
|
|
1106
|
+
chunks.push(chunk);
|
|
1107
|
+
});
|
|
1108
|
+
req.on("end", () => resolveP(Buffer.concat(chunks).toString("utf8")));
|
|
1109
|
+
req.on("error", rejectP);
|
|
1110
|
+
});
|
|
1111
|
+
}
|
|
1112
|
+
function registerAdminRoutes(ctx, deps) {
|
|
1113
|
+
const handleState = async (_req, res) => {
|
|
1114
|
+
try {
|
|
1115
|
+
const wikiPath = deps.getWikiPath();
|
|
1116
|
+
const [info, catalog] = await Promise.all([readWikiInfo(wikiPath), bundledCatalog(deps.twRoot())]);
|
|
1117
|
+
let git = null;
|
|
1118
|
+
try {
|
|
1119
|
+
const { GitFace } = await Promise.resolve().then(() => git_exports);
|
|
1120
|
+
git = await new GitFace().status(wikiPath);
|
|
1121
|
+
} catch {
|
|
1122
|
+
git = null;
|
|
1123
|
+
}
|
|
1124
|
+
json(res, {
|
|
1125
|
+
ok: true,
|
|
1126
|
+
server: deps.server.status(),
|
|
1127
|
+
info: {
|
|
1128
|
+
plugins: info.plugins,
|
|
1129
|
+
themes: info.themes,
|
|
1130
|
+
languages: info.languages ?? []
|
|
1131
|
+
},
|
|
1132
|
+
catalog,
|
|
1133
|
+
config: deps.config.get(),
|
|
1134
|
+
git
|
|
1135
|
+
});
|
|
1136
|
+
} catch (err) {
|
|
1137
|
+
json(res, {
|
|
1138
|
+
ok: false,
|
|
1139
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1140
|
+
}, 500);
|
|
1141
|
+
}
|
|
1142
|
+
};
|
|
1143
|
+
const handleInfo = async (req, res) => {
|
|
1144
|
+
try {
|
|
1145
|
+
const body = JSON.parse(await readBody(req));
|
|
1146
|
+
const wikiPath = deps.getWikiPath();
|
|
1147
|
+
const info = await readWikiInfo(wikiPath);
|
|
1148
|
+
const catalog = await bundledCatalog(deps.twRoot());
|
|
1149
|
+
const known = new Set([...catalog.plugins, ...catalog.themes].map((c) => c.name));
|
|
1150
|
+
const knownLangs = new Set(catalog.languages.map((c) => c.name));
|
|
1151
|
+
const applyList = (field, raw) => {
|
|
1152
|
+
if (!Array.isArray(raw)) return info[field];
|
|
1153
|
+
const next = [];
|
|
1154
|
+
for (const name of raw) {
|
|
1155
|
+
if (typeof name !== "string") continue;
|
|
1156
|
+
if (!known.has(name) && !info[field].includes(name)) throw new Error(`unknown plugin/theme: ${name}`);
|
|
1157
|
+
if (!next.includes(name)) next.push(name);
|
|
1158
|
+
}
|
|
1159
|
+
return next;
|
|
1160
|
+
};
|
|
1161
|
+
const applyLanguages = (raw) => {
|
|
1162
|
+
if (!Array.isArray(raw)) return info.languages ?? [];
|
|
1163
|
+
const next = [];
|
|
1164
|
+
for (const code of raw) {
|
|
1165
|
+
if (typeof code !== "string") continue;
|
|
1166
|
+
if (!knownLangs.has(code) && !(info.languages ?? []).includes(code)) throw new Error(`unknown language plugin: ${code}`);
|
|
1167
|
+
if (!next.includes(code)) next.push(code);
|
|
1168
|
+
}
|
|
1169
|
+
return next;
|
|
1170
|
+
};
|
|
1171
|
+
info.plugins = applyList("plugins", body.plugins);
|
|
1172
|
+
let activatedTheme;
|
|
1173
|
+
if (Array.isArray(body.themes)) {
|
|
1174
|
+
const themeDeps = {};
|
|
1175
|
+
for (const theme of catalog.themes) if (theme.dependents && theme.dependents.length > 0) themeDeps[theme.name] = theme.dependents;
|
|
1176
|
+
let selected = applyList("themes", body.themes);
|
|
1177
|
+
if (typeof body.themeActive === "string" && body.themeActive.length > 0) {
|
|
1178
|
+
const activeName = body.themeActive;
|
|
1179
|
+
if (known.has(activeName) || info.themes.includes(activeName)) {
|
|
1180
|
+
if (!selected.includes(activeName)) selected.push(activeName);
|
|
1181
|
+
activatedTheme = activeName;
|
|
1182
|
+
}
|
|
1183
|
+
}
|
|
1184
|
+
info.themes = normalizeThemes(selected, themeDeps);
|
|
1185
|
+
if (activatedTheme === void 0 && info.themes.length > 0) activatedTheme = info.themes[info.themes.length - 1];
|
|
1186
|
+
} else info.themes = applyList("themes", body.themes);
|
|
1187
|
+
if (Array.isArray(body.languages)) info.languages = applyLanguages(body.languages);
|
|
1188
|
+
await writeWikiInfo(wikiPath, info);
|
|
1189
|
+
await deps.server.restart();
|
|
1190
|
+
if (activatedTheme !== void 0) {
|
|
1191
|
+
const client = deps.getClient();
|
|
1192
|
+
if (client !== void 0) await client.put({
|
|
1193
|
+
title: "$:/theme",
|
|
1194
|
+
text: `$:/themes/${activatedTheme}`,
|
|
1195
|
+
type: "text/vnd.tiddlywiki",
|
|
1196
|
+
tags: []
|
|
1197
|
+
}).catch(() => void 0);
|
|
1198
|
+
}
|
|
1199
|
+
if (Array.isArray(body.languages)) {
|
|
1200
|
+
const client = deps.getClient();
|
|
1201
|
+
if (client !== void 0) {
|
|
1202
|
+
const langs = info.languages ?? [];
|
|
1203
|
+
const active = langs.length > 0 ? `$:/languages/${langs[0]}` : "$:/languages/en-GB";
|
|
1204
|
+
await client.put({
|
|
1205
|
+
title: "$:/language",
|
|
1206
|
+
text: active,
|
|
1207
|
+
type: "text/plain",
|
|
1208
|
+
tags: []
|
|
1209
|
+
}).catch(() => void 0);
|
|
1210
|
+
const hint = langs.length > 0 ? langs[0] : "";
|
|
1211
|
+
if ((deps.config.get().uiLanguage ?? "") !== hint) await deps.config.set(client, { uiLanguage: hint }).catch(() => void 0);
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
json(res, {
|
|
1215
|
+
ok: true,
|
|
1216
|
+
info: {
|
|
1217
|
+
plugins: info.plugins,
|
|
1218
|
+
themes: info.themes,
|
|
1219
|
+
languages: info.languages ?? []
|
|
1220
|
+
}
|
|
1221
|
+
});
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
json(res, {
|
|
1224
|
+
ok: false,
|
|
1225
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1226
|
+
}, 400);
|
|
1227
|
+
}
|
|
1228
|
+
};
|
|
1229
|
+
const handleConfig = async (req, res) => {
|
|
1230
|
+
try {
|
|
1231
|
+
const body = JSON.parse(await readBody(req));
|
|
1232
|
+
const client = deps.getClient();
|
|
1233
|
+
if (client === void 0) {
|
|
1234
|
+
json(res, {
|
|
1235
|
+
ok: false,
|
|
1236
|
+
error: "wiki service is not running"
|
|
1237
|
+
}, 503);
|
|
1238
|
+
return;
|
|
1239
|
+
}
|
|
1240
|
+
await deps.config.set(client, body);
|
|
1241
|
+
json(res, {
|
|
1242
|
+
ok: true,
|
|
1243
|
+
config: deps.config.get()
|
|
1244
|
+
});
|
|
1245
|
+
} catch (err) {
|
|
1246
|
+
json(res, {
|
|
1247
|
+
ok: false,
|
|
1248
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1249
|
+
}, 400);
|
|
1250
|
+
}
|
|
1251
|
+
};
|
|
1252
|
+
const handleRestart = async (_req, res) => {
|
|
1253
|
+
try {
|
|
1254
|
+
await deps.server.restart();
|
|
1255
|
+
json(res, {
|
|
1256
|
+
ok: true,
|
|
1257
|
+
status: deps.server.status().status
|
|
1258
|
+
});
|
|
1259
|
+
} catch (err) {
|
|
1260
|
+
json(res, {
|
|
1261
|
+
ok: false,
|
|
1262
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1263
|
+
}, 500);
|
|
1264
|
+
}
|
|
1265
|
+
};
|
|
1266
|
+
const disposers = [
|
|
1267
|
+
ctx.webServer.register({
|
|
1268
|
+
kind: "exact",
|
|
1269
|
+
path: `${ROUTE_PREFIX}/admin/state`,
|
|
1270
|
+
handler: (req, res) => {
|
|
1271
|
+
handleState(req, res);
|
|
1272
|
+
}
|
|
1273
|
+
}),
|
|
1274
|
+
ctx.webServer.register({
|
|
1275
|
+
kind: "exact",
|
|
1276
|
+
path: `${ROUTE_PREFIX}/admin/info`,
|
|
1277
|
+
handler: (req, res) => {
|
|
1278
|
+
handleInfo(req, res);
|
|
1279
|
+
}
|
|
1280
|
+
}),
|
|
1281
|
+
ctx.webServer.register({
|
|
1282
|
+
kind: "exact",
|
|
1283
|
+
path: `${ROUTE_PREFIX}/admin/config`,
|
|
1284
|
+
handler: (req, res) => {
|
|
1285
|
+
handleConfig(req, res);
|
|
1286
|
+
}
|
|
1287
|
+
}),
|
|
1288
|
+
ctx.webServer.register({
|
|
1289
|
+
kind: "exact",
|
|
1290
|
+
path: `${ROUTE_PREFIX}/admin/restart`,
|
|
1291
|
+
handler: (req, res) => {
|
|
1292
|
+
handleRestart(req, res);
|
|
1293
|
+
}
|
|
1294
|
+
})
|
|
1295
|
+
];
|
|
1296
|
+
return () => {
|
|
1297
|
+
for (const dispose of disposers) dispose();
|
|
1298
|
+
};
|
|
1299
|
+
}
|
|
1300
|
+
//#endregion
|
|
1301
|
+
//#region src/host/seed-notes.ts
|
|
1302
|
+
/** Note tiddler title (a normal, searchable note — not a system tiddler). */
|
|
1303
|
+
const DOC_NOTE_TITLE = "dsh-tiddlywiki 插件说明";
|
|
1304
|
+
/** Tag that makes the note easy to find via `tiddlywiki_search tag=docs`. */
|
|
1305
|
+
const DOC_NOTE_TAG = "docs";
|
|
1306
|
+
/** The note body, TiddlyWiki wiki-text. */
|
|
1307
|
+
const DOC_NOTE_TEXT = `! dsh-tiddlywiki 插件说明
|
|
1308
|
+
|
|
1309
|
+
本插件把 **TiddlyWiki 5** 作为 DSH 的持久知识库(wiki 文件夹本身就是一个 git 仓库,随内容自动提交/同步)。
|
|
1310
|
+
|
|
1311
|
+
!! 它能做什么
|
|
1312
|
+
|
|
1313
|
+
* **5 个 agent 工具**:\`tiddlywiki_search\`(检索)/ \`tiddlywiki_get\`(读)/ \`tiddlywiki_put\`(写)/ \`tiddlywiki_delete\`(删)/ \`tiddlywiki_git_sync\`(git 同步)。
|
|
1314
|
+
* **TW 编辑器面板**:侧边栏「TiddlyWiki」按钮 → 在界面中央打开完整版 TW 编辑器。
|
|
1315
|
+
* **快速笔记**:右下角悬浮「📝 快速笔记」写随手记,\`Ctrl+Enter\` 保存;点「✏️ 在 TW 中编辑」会弹出独立小窗用 TW 原生编辑器编辑。
|
|
1316
|
+
* **git 同步**:写入自动防抖 commit(默认 60 秒);手动 \`tiddlywiki_git_sync action=sync\` 做 pull → commit → push。
|
|
1317
|
+
* **设置页**:DSH 设置 → 「TiddlyWiki 知识库」管理插件/主题/语言与运行配置。
|
|
1318
|
+
|
|
1319
|
+
!! 知识库纪律(三条)
|
|
1320
|
+
|
|
1321
|
+
1. 开工先 \`tiddlywiki_git_sync action=pull\`(rebase + autostash,真冲突会自动 abort 并报文件)。
|
|
1322
|
+
2. 收工 \`tiddlywiki_git_sync action=sync\`。
|
|
1323
|
+
3. 插件自动 commit 兜底,手动 sync 用于需要主动推送的场合。
|
|
1324
|
+
|
|
1325
|
+
!! 主题与语言
|
|
1326
|
+
|
|
1327
|
+
* **主题**分两层:每行一个「☑ 加载」(多选 = TW 里可用的主题,依赖链自动带上)和「◉ 活动」(单选 = 当前视觉主题)。应用后自动重启 TW。
|
|
1328
|
+
* **语言**:设置页勾选 \`zh-Hans\`(简体)并应用,TW 界面即切换为中文。
|
|
1329
|
+
|
|
1330
|
+
!! 说明
|
|
1331
|
+
|
|
1332
|
+
* 本笔记由插件在首次启动时自动写入 wiki(幂等:不存在才写)。删除后重启 dsh web 会重建;手动编辑过的内容不会被覆盖。
|
|
1333
|
+
* 更多细节见插件仓库 README。`;
|
|
1334
|
+
/**
|
|
1335
|
+
* Seed the doc note when it is absent. Returns whether a note was written.
|
|
1336
|
+
* Never throws (missing wiki or note already present → no-op / false).
|
|
1337
|
+
*/
|
|
1338
|
+
async function seedDocNote(client) {
|
|
1339
|
+
if (await client.get("dsh-tiddlywiki 插件说明").catch(() => void 0) !== void 0) return false;
|
|
1340
|
+
await client.put({
|
|
1341
|
+
title: DOC_NOTE_TITLE,
|
|
1342
|
+
text: DOC_NOTE_TEXT,
|
|
1343
|
+
type: "text/vnd.tiddlywiki",
|
|
1344
|
+
tags: [DOC_NOTE_TAG]
|
|
1345
|
+
});
|
|
1346
|
+
return true;
|
|
1347
|
+
}
|
|
1348
|
+
//#endregion
|
|
1349
|
+
//#region src/host/tw-api.ts
|
|
1350
|
+
const REQUEST_TIMEOUT_MS = 1e4;
|
|
1351
|
+
/** TW's CSRF gate: writes must carry this header (TW's own UI always does). */
|
|
1352
|
+
const CSRF_HEADER = { "x-requested-with": "TiddlyWiki" };
|
|
1353
|
+
/** Sentinel `exclude` value: excludes nothing, so `text` stays in the list. */
|
|
1354
|
+
const LIST_WITH_TEXT_EXCLUDE = "__dsh_tw_none__";
|
|
1355
|
+
/** Split TW's whitespace-joined tags string into an array. */
|
|
1356
|
+
function normalizeTags(tags) {
|
|
1357
|
+
if (tags === void 0) return void 0;
|
|
1358
|
+
if (Array.isArray(tags)) return tags.map(String);
|
|
1359
|
+
if (typeof tags === "string") {
|
|
1360
|
+
const parts = tags.trim().split(/\s+/).filter(Boolean);
|
|
1361
|
+
return parts.length > 0 ? parts : [];
|
|
1362
|
+
}
|
|
1363
|
+
return [];
|
|
1364
|
+
}
|
|
1365
|
+
/** Normalize a raw server tiddler (tags string → array, unknown fields nested). */
|
|
1366
|
+
function normalizeTiddler(raw) {
|
|
1367
|
+
const out = { ...raw };
|
|
1368
|
+
const tags = normalizeTags(raw.tags);
|
|
1369
|
+
if (tags !== void 0) out.tags = tags;
|
|
1370
|
+
return out;
|
|
1371
|
+
}
|
|
1372
|
+
var TiddlyWebClient = class {
|
|
1373
|
+
baseUrl;
|
|
1374
|
+
constructor(baseUrl) {
|
|
1375
|
+
this.baseUrl = baseUrl;
|
|
1376
|
+
}
|
|
1377
|
+
async request(path, init) {
|
|
1378
|
+
return fetch(`${this.baseUrl}${path}`, {
|
|
1379
|
+
...init,
|
|
1380
|
+
signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS)
|
|
1381
|
+
});
|
|
1382
|
+
}
|
|
1383
|
+
/** GET /status → { username, anonymous, space, tiddlywiki_version, ... }. */
|
|
1384
|
+
async status() {
|
|
1385
|
+
const res = await this.request("/status");
|
|
1386
|
+
if (!res.ok) throw new Error(`TiddlyWeb /status HTTP ${res.status}`);
|
|
1387
|
+
return res.json();
|
|
1388
|
+
}
|
|
1389
|
+
/** Read one tiddler; undefined when it does not exist (404). */
|
|
1390
|
+
async get(title) {
|
|
1391
|
+
const res = await this.request(`/recipes/default/tiddlers/${encodeURIComponent(title)}`);
|
|
1392
|
+
if (res.status === 404) return void 0;
|
|
1393
|
+
if (!res.ok) throw new Error(`TiddlyWeb GET /recipes/default/tiddlers/${title} HTTP ${res.status}`);
|
|
1394
|
+
return normalizeTiddler(await res.json());
|
|
1395
|
+
}
|
|
1396
|
+
/** Write (create or overwrite) one tiddler via PUT (204 on success). */
|
|
1397
|
+
async put(tiddler) {
|
|
1398
|
+
const title = tiddler.title;
|
|
1399
|
+
const res = await this.request(`/recipes/default/tiddlers/${encodeURIComponent(title)}`, {
|
|
1400
|
+
method: "PUT",
|
|
1401
|
+
headers: {
|
|
1402
|
+
"content-type": "application/json",
|
|
1403
|
+
...CSRF_HEADER
|
|
1404
|
+
},
|
|
1405
|
+
body: JSON.stringify(tiddler)
|
|
1406
|
+
});
|
|
1407
|
+
if (!res.ok) {
|
|
1408
|
+
const detail = await res.text().catch(() => "");
|
|
1409
|
+
throw new Error(`TiddlyWeb PUT /recipes/default/tiddlers/${title} HTTP ${res.status}: ${detail.slice(0, 300)}`);
|
|
1410
|
+
}
|
|
1411
|
+
return tiddler;
|
|
1412
|
+
}
|
|
1413
|
+
/** Delete one tiddler via the bags route (204); a missing one is a no-op. */
|
|
1414
|
+
async delete(title) {
|
|
1415
|
+
const res = await this.request(`/bags/default/tiddlers/${encodeURIComponent(title)}`, {
|
|
1416
|
+
method: "DELETE",
|
|
1417
|
+
headers: CSRF_HEADER
|
|
1418
|
+
});
|
|
1419
|
+
if (res.status === 404) return;
|
|
1420
|
+
if (!res.ok) throw new Error(`TiddlyWeb DELETE /bags/default/tiddlers/${title} HTTP ${res.status}`);
|
|
1421
|
+
}
|
|
1422
|
+
/**
|
|
1423
|
+
* List tiddlers via the default server filter. Arbitrary `filter=` queries
|
|
1424
|
+
* are blocked by the server (403) unless whitelisted, so callers needing a
|
|
1425
|
+
* subset should use search(); a supplied filter that is 403-blocked falls
|
|
1426
|
+
* back to the default listing.
|
|
1427
|
+
*/
|
|
1428
|
+
async list(filter, includeText = false) {
|
|
1429
|
+
const params = new URLSearchParams();
|
|
1430
|
+
if (includeText) params.set("exclude", LIST_WITH_TEXT_EXCLUDE);
|
|
1431
|
+
if (filter !== void 0 && filter.length > 0) params.set("filter", filter);
|
|
1432
|
+
const query = params.toString();
|
|
1433
|
+
let res = await this.request(`/recipes/default/tiddlers.json${query.length > 0 ? `?${query}` : ""}`);
|
|
1434
|
+
if (!res.ok && res.status === 403 && filter !== void 0 && filter.length > 0) {
|
|
1435
|
+
const retry = new URLSearchParams();
|
|
1436
|
+
if (includeText) retry.set("exclude", LIST_WITH_TEXT_EXCLUDE);
|
|
1437
|
+
const retryQuery = retry.toString();
|
|
1438
|
+
res = await this.request(`/recipes/default/tiddlers.json${retryQuery.length > 0 ? `?${retryQuery}` : ""}`);
|
|
1439
|
+
}
|
|
1440
|
+
if (!res.ok) throw new Error(`TiddlyWeb recipe list HTTP ${res.status}`);
|
|
1441
|
+
const data = await res.json();
|
|
1442
|
+
return (Array.isArray(data) ? data : data.tiddlers ?? []).map(normalizeTiddler);
|
|
1443
|
+
}
|
|
1444
|
+
/**
|
|
1445
|
+
* Search non-system tiddlers: one request (default listing with text) plus
|
|
1446
|
+
* local case-insensitive substring matching on title + text, optional exact
|
|
1447
|
+
* tag, capped at `limit`. Robust against the server's external-filter 403.
|
|
1448
|
+
*/
|
|
1449
|
+
async search(query, tag, limit = 30) {
|
|
1450
|
+
const items = await this.list(void 0, true);
|
|
1451
|
+
const needle = query.toLowerCase();
|
|
1452
|
+
return items.filter((t) => {
|
|
1453
|
+
if (!t.title.toLowerCase().includes(needle) && !(t.text ?? "").toLowerCase().includes(needle)) return false;
|
|
1454
|
+
if (tag !== void 0 && tag.length > 0) {
|
|
1455
|
+
if (!(t.tags ?? []).some((t2) => t2.toLowerCase() === tag.toLowerCase())) return false;
|
|
1456
|
+
}
|
|
1457
|
+
return true;
|
|
1458
|
+
}).slice(0, limit);
|
|
1459
|
+
}
|
|
1460
|
+
};
|
|
1461
|
+
//#endregion
|
|
1462
|
+
//#region src/sdk.ts
|
|
1463
|
+
/**
|
|
1464
|
+
* Self-contained replacements for the @deepseek-ai runtime imports the host
|
|
1465
|
+
* half must NEVER take from npm-mirror SDK packages (dsh-home-paths,
|
|
1466
|
+
* dsh-tools' defineTool).
|
|
1467
|
+
*
|
|
1468
|
+
* Why (design doc §4.4, taskboard lesson): a published copy must not resolve
|
|
1469
|
+
* `@deepseek-ai/dsh-tools` from the profile's node_modules — an npm-mirror
|
|
1470
|
+
* dsh-tools there shadows the CLI-internal build for the WHOLE base layer and
|
|
1471
|
+
* breaks the agent loop. Everything here is a pure, structure-compatible
|
|
1472
|
+
* reimplementation of the exact behavior the registry relies on:
|
|
1473
|
+
*
|
|
1474
|
+
* - `dshHomePath` mirrors `join(resolve(env.DSH_HOME ?? ~/.dsh), ...segments)`;
|
|
1475
|
+
* - `defineTool` compiles author-facing parameter specs into the same raw
|
|
1476
|
+
* JSON-Schema subset the registry expects and pre-validates model arguments.
|
|
1477
|
+
*
|
|
1478
|
+
* @module dsh-tiddlywiki/sdk
|
|
1479
|
+
*/
|
|
1480
|
+
/** The DSH user home (DSH_HOME overrides). */
|
|
1481
|
+
function dshHomePath(...segments) {
|
|
1482
|
+
const override = process.env.DSH_HOME;
|
|
1483
|
+
return join(resolve(override !== void 0 && override.length > 0 ? override : join(homedir(), ".dsh")), ...segments);
|
|
1484
|
+
}
|
|
1485
|
+
/** Compile one value spec to the raw subset (json → annotation-only). */
|
|
1486
|
+
function compileValue(spec) {
|
|
1487
|
+
const node = {};
|
|
1488
|
+
const description = spec.description;
|
|
1489
|
+
if (typeof description === "string" && description.length > 0) node.description = description;
|
|
1490
|
+
const type = spec.type;
|
|
1491
|
+
if (type === void 0 || type === "json") return node;
|
|
1492
|
+
if (type === "object") {
|
|
1493
|
+
const objectSpec = spec;
|
|
1494
|
+
node.type = "object";
|
|
1495
|
+
node.additionalProperties = objectSpec.additionalProperties;
|
|
1496
|
+
if (objectSpec.properties !== void 0) node.properties = compilePropertyMap(objectSpec.properties).properties;
|
|
1497
|
+
return node;
|
|
1498
|
+
}
|
|
1499
|
+
if (type === "array") {
|
|
1500
|
+
node.type = "array";
|
|
1501
|
+
const items = spec.items;
|
|
1502
|
+
if (items !== void 0) node.items = compileValue(items);
|
|
1503
|
+
return node;
|
|
1504
|
+
}
|
|
1505
|
+
node.type = type;
|
|
1506
|
+
const enumValues = spec.enum;
|
|
1507
|
+
if (enumValues !== void 0) node.enum = [...enumValues];
|
|
1508
|
+
const constValue = spec.const;
|
|
1509
|
+
if (constValue !== void 0) node.const = constValue;
|
|
1510
|
+
return node;
|
|
1511
|
+
}
|
|
1512
|
+
/** Compile a property map: properties + collected required list. */
|
|
1513
|
+
function compilePropertyMap(spec) {
|
|
1514
|
+
const properties = {};
|
|
1515
|
+
const required = [];
|
|
1516
|
+
for (const [name, entry] of Object.entries(spec)) {
|
|
1517
|
+
const { required: isRequired, ...valueSpec } = entry;
|
|
1518
|
+
properties[name] = compileValue(valueSpec);
|
|
1519
|
+
if (isRequired === true) required.push(name);
|
|
1520
|
+
}
|
|
1521
|
+
return required.length > 0 ? {
|
|
1522
|
+
properties,
|
|
1523
|
+
required
|
|
1524
|
+
} : { properties };
|
|
1525
|
+
}
|
|
1526
|
+
/** Does a JS value match a raw-subset scalar type? */
|
|
1527
|
+
function matchesScalarType(value, type) {
|
|
1528
|
+
switch (type) {
|
|
1529
|
+
case "string": return typeof value === "string";
|
|
1530
|
+
case "number": return typeof value === "number";
|
|
1531
|
+
case "integer": return typeof value === "number" && Number.isInteger(value);
|
|
1532
|
+
case "boolean": return typeof value === "boolean";
|
|
1533
|
+
case "null": return value === null;
|
|
1534
|
+
default: return true;
|
|
1535
|
+
}
|
|
1536
|
+
}
|
|
1537
|
+
/** Validate a value against the compiled subset; returns path-qualified violations. */
|
|
1538
|
+
function validateValue(schema, value, path) {
|
|
1539
|
+
if (typeof schema.type !== "string" || schema.type.length === 0) return [];
|
|
1540
|
+
if (schema.type === "object") {
|
|
1541
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return [`${path} must be an object`];
|
|
1542
|
+
const violations = [];
|
|
1543
|
+
const present = value;
|
|
1544
|
+
for (const key of schema.required ?? []) if (!(key in present)) violations.push(`${path}.${key} is required`);
|
|
1545
|
+
if (schema.additionalProperties === false) {
|
|
1546
|
+
const known = new Set(Object.keys(schema.properties ?? {}));
|
|
1547
|
+
for (const key of Object.keys(present)) if (!known.has(key)) violations.push(`${path}.${key} is not a declared property`);
|
|
1548
|
+
}
|
|
1549
|
+
for (const [key, child] of Object.entries(schema.properties ?? {})) if (key in present) violations.push(...validateValue(child, present[key], `${path}.${key}`));
|
|
1550
|
+
return violations;
|
|
1551
|
+
}
|
|
1552
|
+
if (schema.type === "array") {
|
|
1553
|
+
if (!Array.isArray(value)) return [`${path} must be an array`];
|
|
1554
|
+
const violations = [];
|
|
1555
|
+
const items = schema.items;
|
|
1556
|
+
if (items !== void 0) value.forEach((item, index) => {
|
|
1557
|
+
violations.push(...validateValue(items, item, `${path}[${index}]`));
|
|
1558
|
+
});
|
|
1559
|
+
return violations;
|
|
1560
|
+
}
|
|
1561
|
+
if (!matchesScalarType(value, schema.type)) return [`${path} must be ${schema.type}`];
|
|
1562
|
+
const enumValues = schema.enum;
|
|
1563
|
+
if (enumValues !== void 0 && !enumValues.some((v) => v === value)) return [`${path} must be one of ${enumValues.map(String).join(", ")}`];
|
|
1564
|
+
const constValue = schema.const;
|
|
1565
|
+
if (constValue !== void 0 && constValue !== value) return [`${path} must be ${String(constValue)}`];
|
|
1566
|
+
return [];
|
|
1567
|
+
}
|
|
1568
|
+
/**
|
|
1569
|
+
* Define a first-party tool: compile the parameter spec, pre-validate
|
|
1570
|
+
* arguments, and pass through the execution.
|
|
1571
|
+
*/
|
|
1572
|
+
function defineTool(options) {
|
|
1573
|
+
const compiled = compilePropertyMap(options.parameters);
|
|
1574
|
+
const parameters = {
|
|
1575
|
+
type: "object",
|
|
1576
|
+
properties: compiled.properties
|
|
1577
|
+
};
|
|
1578
|
+
if (compiled.required !== void 0) parameters.required = compiled.required;
|
|
1579
|
+
const userExecute = options.execute;
|
|
1580
|
+
return {
|
|
1581
|
+
name: options.name,
|
|
1582
|
+
description: options.description,
|
|
1583
|
+
parameters,
|
|
1584
|
+
output: {
|
|
1585
|
+
schema: {},
|
|
1586
|
+
render(args, value) {
|
|
1587
|
+
return options.output.render(args, value);
|
|
1588
|
+
}
|
|
1589
|
+
},
|
|
1590
|
+
async execute(args, exec) {
|
|
1591
|
+
const violations = validateValue(parameters, args, "arguments");
|
|
1592
|
+
if (violations.length > 0) throw new Error(`Error: invalid arguments: ${violations.join("; ")}`);
|
|
1593
|
+
return userExecute(args, exec);
|
|
1594
|
+
}
|
|
1595
|
+
};
|
|
1596
|
+
}
|
|
1597
|
+
//#endregion
|
|
1598
|
+
//#region src/host/tools.ts
|
|
1599
|
+
/**
|
|
1600
|
+
* The five `tiddlywiki_*` agent tools (design doc §11, D8) plus the extension
|
|
1601
|
+
* point: `registerTiddlywikiTools(ctx, deps)` registers tools list-style, so a
|
|
1602
|
+
* new tool is just one more `defineTool` in the array — index.ts never changes.
|
|
1603
|
+
*
|
|
1604
|
+
* RENDER CONTRACT (design doc §4.3): the registry feeds `output.render(args,
|
|
1605
|
+
* value)` into the loop — the model sees ONLY the rendered text, never the raw
|
|
1606
|
+
* JSON `value`. Every render must carry the complete facts an agent needs to
|
|
1607
|
+
* act (titles, tags, snippets, git state); a terse UI summary starves it.
|
|
1608
|
+
*
|
|
1609
|
+
* @module dsh-tiddlywiki/host/tools
|
|
1610
|
+
*/
|
|
1611
|
+
function snippetOf(text, max = 160) {
|
|
1612
|
+
const flat = text.replace(/\s+/g, " ").trim();
|
|
1613
|
+
return flat.length <= max ? flat : `${flat.slice(0, max)}…`;
|
|
1614
|
+
}
|
|
1615
|
+
/** Strip dsh-tiddlywiki internal fields from a tiddler for the model. */
|
|
1616
|
+
function pickFields(t) {
|
|
1617
|
+
const out = {};
|
|
1618
|
+
for (const [k, v] of Object.entries(t)) {
|
|
1619
|
+
if (k === "title" || k === "text" || k === "tags") continue;
|
|
1620
|
+
out[k] = v;
|
|
1621
|
+
}
|
|
1622
|
+
return out;
|
|
1623
|
+
}
|
|
1624
|
+
function registerTiddlywikiTools(ctx, deps) {
|
|
1625
|
+
const disposers = [];
|
|
1626
|
+
const register = (tool) => {
|
|
1627
|
+
disposers.push(ctx.tools.register(tool));
|
|
1628
|
+
};
|
|
1629
|
+
register(defineTool({
|
|
1630
|
+
name: "tiddlywiki_search",
|
|
1631
|
+
description: "检索 TiddlyWiki 持久知识库:按关键词(可选 tag 精确匹配)搜索非系统 tiddler,返回标题、标签与摘要片段。",
|
|
1632
|
+
parameters: {
|
|
1633
|
+
query: {
|
|
1634
|
+
type: "string",
|
|
1635
|
+
description: "搜索关键词(大小写不敏感,子串匹配)",
|
|
1636
|
+
required: true
|
|
1637
|
+
},
|
|
1638
|
+
tag: {
|
|
1639
|
+
type: "string",
|
|
1640
|
+
description: "可选:只返回带该 tag 的 tiddler"
|
|
1641
|
+
}
|
|
1642
|
+
},
|
|
1643
|
+
output: {
|
|
1644
|
+
schema: { type: "json" },
|
|
1645
|
+
render: (_args, value) => {
|
|
1646
|
+
const lines = [`TiddlyWiki 搜索「${value.query}」${value.tag !== null ? ` (tag=${value.tag})` : ""}:命中 ${value.count} 条。`];
|
|
1647
|
+
if (value.results.length === 0) lines.push("没有匹配的 tiddler。");
|
|
1648
|
+
for (const r of value.results) {
|
|
1649
|
+
const tags = r.tags.length > 0 ? ` [${r.tags.join(", ")}]` : "";
|
|
1650
|
+
lines.push(`- ${r.title}${tags}`);
|
|
1651
|
+
if (r.snippet.length > 0) lines.push(` ${r.snippet}`);
|
|
1652
|
+
}
|
|
1653
|
+
if (value.count > value.results.length) lines.push(`(另有 ${value.count - value.results.length} 条未展开,可用 tiddlywiki_get 读取具体标题)`);
|
|
1654
|
+
return [{
|
|
1655
|
+
type: "text",
|
|
1656
|
+
text: lines.join("\n")
|
|
1657
|
+
}];
|
|
1658
|
+
}
|
|
1659
|
+
},
|
|
1660
|
+
execute: async (args) => {
|
|
1661
|
+
const wiki = deps.wiki();
|
|
1662
|
+
if (wiki === void 0) throw new Error("TiddlyWiki 服务未运行(tiddlywiki_status 可查)");
|
|
1663
|
+
const results = await wiki.search(args.query, args.tag);
|
|
1664
|
+
return {
|
|
1665
|
+
query: args.query,
|
|
1666
|
+
tag: args.tag ?? null,
|
|
1667
|
+
count: results.length,
|
|
1668
|
+
results: results.map((t) => ({
|
|
1669
|
+
title: t.title,
|
|
1670
|
+
tags: t.tags ?? [],
|
|
1671
|
+
snippet: snippetOf(t.text ?? "")
|
|
1672
|
+
}))
|
|
1673
|
+
};
|
|
1674
|
+
}
|
|
1675
|
+
}));
|
|
1676
|
+
register(defineTool({
|
|
1677
|
+
name: "tiddlywiki_get",
|
|
1678
|
+
description: "读取一个 TiddlyWiki tiddler 的完整内容(标题、全文、标签、自定义字段)。",
|
|
1679
|
+
parameters: { title: {
|
|
1680
|
+
type: "string",
|
|
1681
|
+
description: "tiddler 标题(精确匹配)",
|
|
1682
|
+
required: true
|
|
1683
|
+
} },
|
|
1684
|
+
output: {
|
|
1685
|
+
schema: { type: "json" },
|
|
1686
|
+
render: (_args, value) => {
|
|
1687
|
+
if (value.notFound) return [{
|
|
1688
|
+
type: "text",
|
|
1689
|
+
text: `tiddler「${value.title}」不存在。可用 tiddlywiki_search 检索,或用 tiddlywiki_put 新建。`
|
|
1690
|
+
}];
|
|
1691
|
+
const lines = [`tiddler「${value.title}」`];
|
|
1692
|
+
if (value.tags.length > 0) lines.push(`标签: ${value.tags.join(", ")}`);
|
|
1693
|
+
const fields = Object.entries(value.fields);
|
|
1694
|
+
if (fields.length > 0) lines.push(`字段: ${fields.map(([k, v]) => `${k}=${String(v)}`).join(", ")}`);
|
|
1695
|
+
lines.push("--- 全文 ---");
|
|
1696
|
+
lines.push(value.text.length > 0 ? value.text : "(空)");
|
|
1697
|
+
return [{
|
|
1698
|
+
type: "text",
|
|
1699
|
+
text: lines.join("\n")
|
|
1700
|
+
}];
|
|
1701
|
+
}
|
|
1702
|
+
},
|
|
1703
|
+
execute: async (args) => {
|
|
1704
|
+
const wiki = deps.wiki();
|
|
1705
|
+
if (wiki === void 0) throw new Error("TiddlyWiki 服务未运行(tiddlywiki_status 可查)");
|
|
1706
|
+
const t = await wiki.get(args.title);
|
|
1707
|
+
if (t === void 0) return {
|
|
1708
|
+
notFound: true,
|
|
1709
|
+
title: args.title,
|
|
1710
|
+
text: "",
|
|
1711
|
+
tags: [],
|
|
1712
|
+
fields: {}
|
|
1713
|
+
};
|
|
1714
|
+
return {
|
|
1715
|
+
notFound: false,
|
|
1716
|
+
title: t.title,
|
|
1717
|
+
text: t.text ?? "",
|
|
1718
|
+
tags: t.tags ?? [],
|
|
1719
|
+
fields: pickFields(t)
|
|
1720
|
+
};
|
|
1721
|
+
}
|
|
1722
|
+
}));
|
|
1723
|
+
register(defineTool({
|
|
1724
|
+
name: "tiddlywiki_put",
|
|
1725
|
+
description: "写入(新建或覆盖)一个 TiddlyWiki tiddler。同名覆盖;tags 为标签数组,fields 为附加自定义字段(json 对象,会写入 tiddler 字段)。写入后触发自动 commit。",
|
|
1726
|
+
parameters: {
|
|
1727
|
+
title: {
|
|
1728
|
+
type: "string",
|
|
1729
|
+
description: "tiddler 标题(精确匹配,覆盖同名)",
|
|
1730
|
+
required: true
|
|
1731
|
+
},
|
|
1732
|
+
text: {
|
|
1733
|
+
type: "string",
|
|
1734
|
+
description: "tiddler 全文(wiki 文本)",
|
|
1735
|
+
required: true
|
|
1736
|
+
},
|
|
1737
|
+
tags: {
|
|
1738
|
+
type: "array",
|
|
1739
|
+
items: { type: "string" },
|
|
1740
|
+
description: "标签数组(可选)"
|
|
1741
|
+
},
|
|
1742
|
+
fields: {
|
|
1743
|
+
type: "json",
|
|
1744
|
+
description: "附加自定义字段,如 {\"type\":\"meeting\",\"date\":\"2026-09-02\"}(可选)"
|
|
1745
|
+
}
|
|
1746
|
+
},
|
|
1747
|
+
output: {
|
|
1748
|
+
schema: { type: "json" },
|
|
1749
|
+
render: (_args, value) => {
|
|
1750
|
+
const lines = [`已写入 tiddler「${value.title}」`];
|
|
1751
|
+
if (value.tags.length > 0) lines.push(`标签: ${value.tags.join(", ")}`);
|
|
1752
|
+
if (value.fields !== null) {
|
|
1753
|
+
const entries = Object.entries(value.fields);
|
|
1754
|
+
if (entries.length > 0) lines.push(`字段: ${entries.map(([k, v]) => `${k}=${String(v)}`).join(", ")}`);
|
|
1755
|
+
}
|
|
1756
|
+
return [{
|
|
1757
|
+
type: "text",
|
|
1758
|
+
text: lines.join("\n")
|
|
1759
|
+
}];
|
|
1760
|
+
}
|
|
1761
|
+
},
|
|
1762
|
+
execute: async (args) => {
|
|
1763
|
+
const wiki = deps.wiki();
|
|
1764
|
+
if (wiki === void 0) throw new Error("TiddlyWiki 服务未运行(tiddlywiki_status 可查)");
|
|
1765
|
+
const tiddler = {
|
|
1766
|
+
title: args.title,
|
|
1767
|
+
text: args.text
|
|
1768
|
+
};
|
|
1769
|
+
if (Array.isArray(args.tags) && args.tags.length > 0) tiddler.tags = args.tags;
|
|
1770
|
+
if (args.fields !== void 0 && typeof args.fields === "object" && args.fields !== null) Object.assign(tiddler, args.fields);
|
|
1771
|
+
await wiki.put(tiddler);
|
|
1772
|
+
deps.autoCommit();
|
|
1773
|
+
return {
|
|
1774
|
+
ok: true,
|
|
1775
|
+
title: args.title,
|
|
1776
|
+
tags: args.tags ?? [],
|
|
1777
|
+
fields: args.fields ?? null
|
|
1778
|
+
};
|
|
1779
|
+
}
|
|
1780
|
+
}));
|
|
1781
|
+
register(defineTool({
|
|
1782
|
+
name: "tiddlywiki_delete",
|
|
1783
|
+
description: "删除一个 TiddlyWiki tiddler(不存在时是幂等空操作)。删除后触发自动 commit。",
|
|
1784
|
+
parameters: { title: {
|
|
1785
|
+
type: "string",
|
|
1786
|
+
description: "tiddler 标题(精确匹配)",
|
|
1787
|
+
required: true
|
|
1788
|
+
} },
|
|
1789
|
+
output: {
|
|
1790
|
+
schema: { type: "json" },
|
|
1791
|
+
render: (_args, value) => [{
|
|
1792
|
+
type: "text",
|
|
1793
|
+
text: `已删除 tiddler「${value.title}」。`
|
|
1794
|
+
}]
|
|
1795
|
+
},
|
|
1796
|
+
execute: async (args) => {
|
|
1797
|
+
const wiki = deps.wiki();
|
|
1798
|
+
if (wiki === void 0) throw new Error("TiddlyWiki 服务未运行(tiddlywiki_status 可查)");
|
|
1799
|
+
await wiki.delete(args.title);
|
|
1800
|
+
deps.autoCommit();
|
|
1801
|
+
return {
|
|
1802
|
+
ok: true,
|
|
1803
|
+
title: args.title
|
|
1804
|
+
};
|
|
1805
|
+
}
|
|
1806
|
+
}));
|
|
1807
|
+
register(defineTool({
|
|
1808
|
+
name: "tiddlywiki_git_sync",
|
|
1809
|
+
description: "对 TiddlyWiki 知识库的 git 仓库做同步:pull(拉取远端并 rebase 本地,冲突则 abort 并报文件)、push(推送本地提交到远端)、sync(pull → commit 本地改动 → push)。未配置 git.remote 时 push 会失败并提示。",
|
|
1810
|
+
parameters: {
|
|
1811
|
+
action: {
|
|
1812
|
+
type: "string",
|
|
1813
|
+
enum: [
|
|
1814
|
+
"pull",
|
|
1815
|
+
"push",
|
|
1816
|
+
"sync"
|
|
1817
|
+
],
|
|
1818
|
+
description: "要执行的 git 操作",
|
|
1819
|
+
required: true
|
|
1820
|
+
},
|
|
1821
|
+
message: {
|
|
1822
|
+
type: "string",
|
|
1823
|
+
description: "commit 信息(可选,仅 sync 的本地 commit 使用)"
|
|
1824
|
+
}
|
|
1825
|
+
},
|
|
1826
|
+
output: {
|
|
1827
|
+
schema: { type: "json" },
|
|
1828
|
+
render: (_args, value) => renderSync(value)
|
|
1829
|
+
},
|
|
1830
|
+
execute: async (args) => {
|
|
1831
|
+
const dir = deps.wikiPath();
|
|
1832
|
+
switch (args.action) {
|
|
1833
|
+
case "pull": {
|
|
1834
|
+
const r = await deps.git.pull(dir);
|
|
1835
|
+
return {
|
|
1836
|
+
action: args.action,
|
|
1837
|
+
ok: r.ok,
|
|
1838
|
+
message: r.message,
|
|
1839
|
+
...r.conflictFiles !== void 0 ? { conflictFiles: r.conflictFiles } : {}
|
|
1840
|
+
};
|
|
1841
|
+
}
|
|
1842
|
+
case "push": {
|
|
1843
|
+
const r = await deps.git.push(dir);
|
|
1844
|
+
return {
|
|
1845
|
+
action: args.action,
|
|
1846
|
+
ok: r.ok,
|
|
1847
|
+
message: r.message
|
|
1848
|
+
};
|
|
1849
|
+
}
|
|
1850
|
+
case "sync": {
|
|
1851
|
+
const pulled = await deps.git.pull(dir);
|
|
1852
|
+
if (!pulled.ok) return {
|
|
1853
|
+
action: args.action,
|
|
1854
|
+
ok: false,
|
|
1855
|
+
message: pulled.message,
|
|
1856
|
+
...pulled.conflictFiles !== void 0 ? { conflictFiles: pulled.conflictFiles } : {}
|
|
1857
|
+
};
|
|
1858
|
+
const committed = await deps.git.commit(dir, args.message ?? `sync ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
1859
|
+
const pushed = await deps.git.push(dir);
|
|
1860
|
+
const status = await deps.git.status(dir);
|
|
1861
|
+
return {
|
|
1862
|
+
action: args.action,
|
|
1863
|
+
ok: pushed.ok,
|
|
1864
|
+
message: pushed.ok ? "同步完成" : pushed.message,
|
|
1865
|
+
pull: "ok",
|
|
1866
|
+
commit: committed.message,
|
|
1867
|
+
push: pushed.message,
|
|
1868
|
+
status
|
|
1869
|
+
};
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
}));
|
|
1874
|
+
return disposers;
|
|
1875
|
+
}
|
|
1876
|
+
function renderSync(value) {
|
|
1877
|
+
const lines = [`git ${value.action}: ${value.ok ? "成功" : "失败"}`];
|
|
1878
|
+
lines.push(` ${value.message}`);
|
|
1879
|
+
if (value.conflictFiles !== void 0 && value.conflictFiles.length > 0) {
|
|
1880
|
+
lines.push(`冲突文件(rebase 已 abort,勿自动覆盖):`);
|
|
1881
|
+
for (const f of value.conflictFiles) lines.push(` - ${f}`);
|
|
1882
|
+
lines.push("处理方式:git checkout --ours <file> 保留本地,或人工编辑后 git add + git rebase --continue;也可以直接让用户处理。");
|
|
1883
|
+
}
|
|
1884
|
+
if (value.commit !== void 0) lines.push(`本地 commit: ${value.commit}`);
|
|
1885
|
+
if (value.push !== void 0) lines.push(`远端 push: ${value.push}`);
|
|
1886
|
+
if (value.status !== void 0) {
|
|
1887
|
+
const s = value.status;
|
|
1888
|
+
const bits = [`分支 ${s.branch}`];
|
|
1889
|
+
if (s.ahead !== void 0) bits.push(`领先 ${s.ahead}`);
|
|
1890
|
+
if (s.behind !== void 0) bits.push(`落后 ${s.behind}`);
|
|
1891
|
+
if (s.dirty) bits.push(`工作区有 ${s.dirtyFiles.length} 个未提交改动`);
|
|
1892
|
+
if (s.lastCommit !== void 0) bits.push(`最近提交 ${s.lastCommit}`);
|
|
1893
|
+
lines.push(`状态: ${bits.join(" · ")}`);
|
|
1894
|
+
if (s.dirty && s.dirtyFiles.length > 0) lines.push(` 未提交: ${s.dirtyFiles.join(", ")}`);
|
|
1895
|
+
}
|
|
1896
|
+
return [{
|
|
1897
|
+
type: "text",
|
|
1898
|
+
text: lines.join("\n")
|
|
1899
|
+
}];
|
|
1900
|
+
}
|
|
1901
|
+
//#endregion
|
|
1902
|
+
//#region src/index.ts
|
|
1903
|
+
/**
|
|
1904
|
+
* dsh-tiddlywiki — host half.
|
|
1905
|
+
*
|
|
1906
|
+
* TiddlyWiki 5 as the DSH persistent knowledge base. Wiring:
|
|
1907
|
+
* - WikiServer spawns/kills/self-heals the TW 5 child process (loopback, auto
|
|
1908
|
+
* port) and scaffolds the wiki folder on first run;
|
|
1909
|
+
* - the git face bootstraps the wiki folder as a repository and wires the
|
|
1910
|
+
* debounced auto-committer;
|
|
1911
|
+
* - `tiddlywiki_*` agent tools + a system-prompt section;
|
|
1912
|
+
* - /dsh-tiddlywiki routes when a webServer is present.
|
|
1913
|
+
*
|
|
1914
|
+
* Export shape follows dsh-taskboard: a function/namespace plugin —
|
|
1915
|
+
* `name` / `inject` / `apply`, NO default export. Config arrives as the
|
|
1916
|
+
* second apply() argument (Cordis `runtime.callback(ctx, config)`).
|
|
1917
|
+
*
|
|
1918
|
+
* Extra exports (WikiServer / TiddlyWebClient / GitFace / ...) exist for the
|
|
1919
|
+
* headless selftest and future reuse; the loader only reads name/inject/apply.
|
|
1920
|
+
*
|
|
1921
|
+
* @module dsh-tiddlywiki
|
|
1922
|
+
*/
|
|
1923
|
+
/** Cordis plugin name (also the client loader id / profile row id). */
|
|
1924
|
+
const name = "dsh-tiddlywiki";
|
|
1925
|
+
/** Required host services (tool registry + prompt assembly). */
|
|
1926
|
+
const inject = ["tools", "systemPrompt"];
|
|
1927
|
+
const DEFAULTS = {
|
|
1928
|
+
wikiRoot: "",
|
|
1929
|
+
wiki: "main",
|
|
1930
|
+
port: 0,
|
|
1931
|
+
git: {
|
|
1932
|
+
autoCommit: true,
|
|
1933
|
+
debounceMs: 6e4,
|
|
1934
|
+
remote: "",
|
|
1935
|
+
branch: "main"
|
|
1936
|
+
},
|
|
1937
|
+
note: { tag: "inbox" },
|
|
1938
|
+
auth: {
|
|
1939
|
+
username: "",
|
|
1940
|
+
password: ""
|
|
1941
|
+
}
|
|
1942
|
+
};
|
|
1943
|
+
/** Expand $VAR / ${VAR} / %VAR% from process.env (config uses $DSH_HOME). */
|
|
1944
|
+
function expandEnvPath(input) {
|
|
1945
|
+
return input.replace(/\$\{([A-Za-z_][A-Za-z0-9_]*)\}/g, (_, k) => process.env[k] ?? "").replace(/\$([A-Za-z_][A-Za-z0-9_]*)/g, (_, k) => process.env[k] ?? "").replace(/%([A-Za-z_][A-Za-z0-9_]*%)/g, (_, k) => process.env[k.slice(0, -1)] ?? "");
|
|
1946
|
+
}
|
|
1947
|
+
/** Resolve wikiRoot: explicit config (env-expanded) else $DSH_HOME/tiddlywiki. */
|
|
1948
|
+
function resolveWikiRoot(config) {
|
|
1949
|
+
if (config.wikiRoot !== void 0 && config.wikiRoot.trim().length > 0) return expandEnvPath(config.wikiRoot.trim());
|
|
1950
|
+
return dshHomePath("tiddlywiki");
|
|
1951
|
+
}
|
|
1952
|
+
/** Write the .gitignore for TW transient artifacts (idempotent). */
|
|
1953
|
+
async function writeGitignore(wikiPath) {
|
|
1954
|
+
await writeFile(join(wikiPath, ".gitignore"), [
|
|
1955
|
+
"# TiddlyWiki transient artifacts (auto-managed by dsh-tiddlywiki)",
|
|
1956
|
+
"tiddlers/$__temp_*",
|
|
1957
|
+
"tiddlers/$__StoryList*",
|
|
1958
|
+
"tiddlers/$__HistoryList*",
|
|
1959
|
+
"*.meta.tmp",
|
|
1960
|
+
""
|
|
1961
|
+
].join("\n"), "utf8");
|
|
1962
|
+
}
|
|
1963
|
+
/** Watch the wiki folders and touch the auto-committer on changes. */
|
|
1964
|
+
function watchWiki(wikiPath, onChange) {
|
|
1965
|
+
const watchers = [];
|
|
1966
|
+
for (const dir of [join(wikiPath, "tiddlers"), wikiPath]) try {
|
|
1967
|
+
const watcher = watch(dir, { persistent: false }, () => onChange());
|
|
1968
|
+
watchers.push(watcher);
|
|
1969
|
+
} catch {}
|
|
1970
|
+
return () => {
|
|
1971
|
+
for (const watcher of watchers) try {
|
|
1972
|
+
watcher.close();
|
|
1973
|
+
} catch {}
|
|
1974
|
+
};
|
|
1975
|
+
}
|
|
1976
|
+
/** System-prompt section text (design doc §11 D8). */
|
|
1977
|
+
const PROMPT_SECTION_NAME = "dsh-tiddlywiki";
|
|
1978
|
+
const PROMPT_SECTION_ORDER = 100;
|
|
1979
|
+
const PROMPT_TEXT = `## TiddlyWiki 持久知识库
|
|
1980
|
+
|
|
1981
|
+
本机有一个 TiddlyWiki 5 持久知识库(wiki 文件夹即 git 仓库)。你可以用工具读写 tiddler:
|
|
1982
|
+
|
|
1983
|
+
- \`tiddlywiki_search\`(query, tag?)检索;\`tiddlywiki_get\`(title)读全文;\`tiddlywiki_put\`(title, text, tags?, fields?)写/覆盖;\`tiddlywiki_delete\`(title)删除。
|
|
1984
|
+
- \`tiddlywiki_git_sync\`(pull|push|sync)做 git 同步。
|
|
1985
|
+
|
|
1986
|
+
知识库同步纪律(三条):
|
|
1987
|
+
1. 开工先 pull:\`tiddlywiki_git_sync action=pull\`(rebase + autostash;真冲突会自动 abort 并报冲突文件)。
|
|
1988
|
+
2. 收工 commit + push:\`tiddlywiki_git_sync action=sync\`(pull → commit → push)。
|
|
1989
|
+
3. 插件会自动防抖 commit(默认 60s),手动同步用上面的工具。
|
|
1990
|
+
|
|
1991
|
+
把 wiki 当作长期记忆与知识沉淀的地方:会议纪要、决策记录、调研笔记、随手的想法都可存成独立 tiddler(tag 建议用 inbox/meeting/decision 等便于检索)。`;
|
|
1992
|
+
/**
|
|
1993
|
+
* Mount the host half.
|
|
1994
|
+
* @param ctx - the plugin context (tools + systemPrompt injected).
|
|
1995
|
+
* @param rawConfig - the plugin row's `config:` block (Cordis second arg).
|
|
1996
|
+
*/
|
|
1997
|
+
function apply(ctx, rawConfig = {}) {
|
|
1998
|
+
const config = {
|
|
1999
|
+
wikiRoot: resolveWikiRoot(rawConfig),
|
|
2000
|
+
wiki: rawConfig.wiki ?? DEFAULTS.wiki,
|
|
2001
|
+
port: rawConfig.port ?? DEFAULTS.port,
|
|
2002
|
+
git: {
|
|
2003
|
+
...DEFAULTS.git,
|
|
2004
|
+
...rawConfig.git ?? {}
|
|
2005
|
+
},
|
|
2006
|
+
note: {
|
|
2007
|
+
...DEFAULTS.note,
|
|
2008
|
+
...rawConfig.note ?? {}
|
|
2009
|
+
},
|
|
2010
|
+
auth: {
|
|
2011
|
+
...DEFAULTS.auth,
|
|
2012
|
+
...rawConfig.auth ?? {}
|
|
2013
|
+
}
|
|
2014
|
+
};
|
|
2015
|
+
const wikiPath = join(config.wikiRoot, config.wiki);
|
|
2016
|
+
const git = new GitFace();
|
|
2017
|
+
const configStore = new ConfigStore({
|
|
2018
|
+
note: config.note,
|
|
2019
|
+
git: config.git
|
|
2020
|
+
});
|
|
2021
|
+
const eff = () => configStore.get();
|
|
2022
|
+
const effectiveNoteTag = () => {
|
|
2023
|
+
const tag = eff().note?.tag;
|
|
2024
|
+
return typeof tag === "string" && tag.trim().length > 0 ? tag : config.note.tag;
|
|
2025
|
+
};
|
|
2026
|
+
const disposers = [];
|
|
2027
|
+
const disposeAll = () => {
|
|
2028
|
+
for (const dispose of disposers.splice(0)) dispose();
|
|
2029
|
+
};
|
|
2030
|
+
const disposeSection = ctx.systemPrompt.section({
|
|
2031
|
+
name: PROMPT_SECTION_NAME,
|
|
2032
|
+
order: PROMPT_SECTION_ORDER,
|
|
2033
|
+
text: PROMPT_TEXT
|
|
2034
|
+
});
|
|
2035
|
+
ctx.effect(() => disposeSection, "dsh-tiddlywiki: prompt section");
|
|
2036
|
+
const server = new WikiServer({
|
|
2037
|
+
wikiRoot: config.wikiRoot,
|
|
2038
|
+
wiki: config.wiki,
|
|
2039
|
+
port: config.port,
|
|
2040
|
+
username: config.auth.username,
|
|
2041
|
+
password: config.auth.password
|
|
2042
|
+
});
|
|
2043
|
+
let clientCache;
|
|
2044
|
+
const client = () => {
|
|
2045
|
+
const port = server.currentPort;
|
|
2046
|
+
if (port === void 0) return void 0;
|
|
2047
|
+
clientCache ??= new TiddlyWebClient(`http://127.0.0.1:${port}`);
|
|
2048
|
+
return clientCache;
|
|
2049
|
+
};
|
|
2050
|
+
let committer;
|
|
2051
|
+
let unwatch;
|
|
2052
|
+
const setupCommitter = () => {
|
|
2053
|
+
const g = eff().git ?? {};
|
|
2054
|
+
committer = new AutoCommitter({
|
|
2055
|
+
git,
|
|
2056
|
+
dir: wikiPath,
|
|
2057
|
+
enabled: g.autoCommit ?? config.git.autoCommit,
|
|
2058
|
+
debounceMs: g.debounceMs ?? config.git.debounceMs,
|
|
2059
|
+
message: () => `wiki autocommit ${(/* @__PURE__ */ new Date()).toISOString()}`,
|
|
2060
|
+
onError: (err) => console.warn("[dsh-tiddlywiki] autocommit:", err)
|
|
2061
|
+
});
|
|
2062
|
+
unwatch = watchWiki(wikiPath, () => committer?.touch());
|
|
2063
|
+
disposers.push(() => {
|
|
2064
|
+
committer?.dispose();
|
|
2065
|
+
unwatch?.();
|
|
2066
|
+
});
|
|
2067
|
+
};
|
|
2068
|
+
const bootstrapGit = async () => {
|
|
2069
|
+
const g = eff().git ?? {};
|
|
2070
|
+
const branch = g.branch ?? config.git.branch;
|
|
2071
|
+
const remote = g.remote ?? config.git.remote;
|
|
2072
|
+
if (!await git.isRepo(wikiPath)) {
|
|
2073
|
+
await git.init(wikiPath, branch);
|
|
2074
|
+
await writeGitignore(wikiPath);
|
|
2075
|
+
await git.initialCommit(wikiPath);
|
|
2076
|
+
} else await writeGitignore(wikiPath);
|
|
2077
|
+
if (remote.trim().length > 0) {
|
|
2078
|
+
const ensured = await git.ensureRemote(wikiPath, remote.trim());
|
|
2079
|
+
if (ensured.ok) {
|
|
2080
|
+
const first = await git.firstPush(wikiPath);
|
|
2081
|
+
if (!first.ok) console.warn("[dsh-tiddlywiki] first push failed (retry with tiddlywiki_git_sync):", first.message);
|
|
2082
|
+
} else console.warn("[dsh-tiddlywiki] git remote setup:", ensured.message);
|
|
2083
|
+
}
|
|
2084
|
+
};
|
|
2085
|
+
const toolsDeps = {
|
|
2086
|
+
wiki: client,
|
|
2087
|
+
git,
|
|
2088
|
+
wikiPath: () => wikiPath,
|
|
2089
|
+
noteTag: effectiveNoteTag,
|
|
2090
|
+
autoCommit: () => committer?.touch()
|
|
2091
|
+
};
|
|
2092
|
+
disposers.push(...registerTiddlywikiTools(ctx, toolsDeps));
|
|
2093
|
+
(async () => {
|
|
2094
|
+
try {
|
|
2095
|
+
await server.start();
|
|
2096
|
+
await configStore.load(client());
|
|
2097
|
+
try {
|
|
2098
|
+
const seedClient = client();
|
|
2099
|
+
if (seedClient !== void 0) await seedDocNote(seedClient);
|
|
2100
|
+
} catch (err) {
|
|
2101
|
+
console.warn("[dsh-tiddlywiki] seeding doc note:", err);
|
|
2102
|
+
}
|
|
2103
|
+
const uiLang = eff().uiLanguage;
|
|
2104
|
+
if (typeof uiLang === "string" && uiLang.trim().length > 0) try {
|
|
2105
|
+
const code = uiLang.trim();
|
|
2106
|
+
if (await ensureLanguage(wikiPath, resolveTwRoot(), code)) await server.restart();
|
|
2107
|
+
const langClient = client();
|
|
2108
|
+
if (langClient !== void 0) await langClient.put({
|
|
2109
|
+
title: "$:/language",
|
|
2110
|
+
text: `$:/languages/${code}`,
|
|
2111
|
+
type: "text/plain",
|
|
2112
|
+
tags: []
|
|
2113
|
+
}).catch(() => void 0);
|
|
2114
|
+
} catch (err) {
|
|
2115
|
+
console.warn("[dsh-tiddlywiki] applying uiLanguage:", err);
|
|
2116
|
+
}
|
|
2117
|
+
await bootstrapGit();
|
|
2118
|
+
setupCommitter();
|
|
2119
|
+
} catch (err) {
|
|
2120
|
+
console.warn("[dsh-tiddlywiki] startup issue (self-healing is armed):", err);
|
|
2121
|
+
}
|
|
2122
|
+
})();
|
|
2123
|
+
ctx.inject(["webServer"], (webCtx) => {
|
|
2124
|
+
const ws = webCtx.webServer;
|
|
2125
|
+
const disposeRoutes = registerRoutes({ webServer: ws }, {
|
|
2126
|
+
server,
|
|
2127
|
+
getClient: client,
|
|
2128
|
+
git,
|
|
2129
|
+
autoCommit: () => committer?.touch(),
|
|
2130
|
+
noteDefaults: () => ({ tag: effectiveNoteTag() }),
|
|
2131
|
+
getWikiPath: () => wikiPath
|
|
2132
|
+
});
|
|
2133
|
+
const disposeAdmin = registerAdminRoutes({ webServer: ws }, {
|
|
2134
|
+
server,
|
|
2135
|
+
getClient: client,
|
|
2136
|
+
getWikiPath: () => wikiPath,
|
|
2137
|
+
twRoot: resolveTwRoot,
|
|
2138
|
+
config: configStore
|
|
2139
|
+
});
|
|
2140
|
+
return () => {
|
|
2141
|
+
disposeRoutes();
|
|
2142
|
+
disposeAdmin();
|
|
2143
|
+
};
|
|
2144
|
+
});
|
|
2145
|
+
ctx.effect(() => () => {
|
|
2146
|
+
disposeAll();
|
|
2147
|
+
server.stop();
|
|
2148
|
+
}, "dsh-tiddlywiki: host teardown");
|
|
2149
|
+
}
|
|
2150
|
+
//#endregion
|
|
2151
|
+
export { AutoCommitter, ConfigStore, DOC_NOTE_TAG, DOC_NOTE_TEXT, DOC_NOTE_TITLE, GitFace, PATH_PREFIX, TiddlyWebClient, WikiServer, apply, bundledCatalog, deepMerge, defineTool, dshHomePath, ensureLanguage, inject, name, normalizeThemes, openInTwEditor, readWikiInfo, registerAdminRoutes, resolveTwRoot, seedDocNote, writeWikiInfo };
|
|
2152
|
+
|
|
2153
|
+
//# sourceMappingURL=index.js.map
|