dsh-tiddlywiki 0.23.3 → 0.23.4
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/README.md +5 -0
- package/lib/client.js +2 -2
- package/lib/index.js +372 -27
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/client/settings-page.ts +24 -2
- package/src/host/admin.ts +12 -1
- package/src/host/config.ts +66 -7
- package/src/host/git.ts +175 -3
- package/src/host/routes.ts +30 -3
- package/src/host/seed-wechat-publish.ts +2 -2
- package/src/host/tools.ts +24 -3
- package/src/host/wechat-publish.ts +111 -12
- package/src/index.ts +4 -1
package/lib/index.js
CHANGED
|
@@ -27,6 +27,17 @@ import { lookup } from "node:dns/promises";
|
|
|
27
27
|
* (only reachable by "forgot to pull before writing") → `git rebase --abort`
|
|
28
28
|
* + report the unmerged files. Never auto-merge data.
|
|
29
29
|
*
|
|
30
|
+
* **Commit guard (v0.23.4, 真实事故)**: `git pull --rebase --autostash` can end
|
|
31
|
+
* with the REBASE succeeding but the autostash RE-APPLY conflicting. `rebase
|
|
32
|
+
* --abort` then has nothing to abort, so the conflict markers stay in the
|
|
33
|
+
* working tree while every subsequent `git add -A && commit` (the 60s
|
|
34
|
+
* AutoCommitter included) happily **commits and pushes them** — on 2026-09-17
|
|
35
|
+
* that is exactly how `<<<<<<< Updated upstream` got permanently written into
|
|
36
|
+
* this wiki's config tiddler, which in turn made the plugin unable to parse its
|
|
37
|
+
* own config for a day (wechat stayed off + prompt.extra silently inactive).
|
|
38
|
+
* `commit()` therefore REFUSES a conflicted tree up front (see
|
|
39
|
+
* `conflictState()`), and `pull()` re-checks after its abort.
|
|
40
|
+
*
|
|
30
41
|
* @module dsh-tiddlywiki/host/git
|
|
31
42
|
*/
|
|
32
43
|
const execFileP = promisify(execFile);
|
|
@@ -58,12 +69,45 @@ const defaultExec = async (args, options) => {
|
|
|
58
69
|
};
|
|
59
70
|
}
|
|
60
71
|
};
|
|
72
|
+
/**
|
|
73
|
+
* A conflict block's opening/closing line. Only the LABELLED forms count
|
|
74
|
+
* (`<<<<<<< HEAD`, `>>>>>>> Stashed changes`): a bare `=======` is far too
|
|
75
|
+
* common in real content (Markdown setext headings, ASCII dividers) to treat
|
|
76
|
+
* as evidence of a conflict.
|
|
77
|
+
*/
|
|
78
|
+
const CONFLICT_MARKER_RE = /^(?:<{7}|>{7}) /m;
|
|
79
|
+
/**
|
|
80
|
+
* In-progress git operations that make a plain `git commit` the WRONG move.
|
|
81
|
+
*
|
|
82
|
+
* ⚠️ `MERGE_HEAD` / `CHERRY_PICK_HEAD` / `REVERT_HEAD` are deliberately NOT in
|
|
83
|
+
* this list: with those, `git commit` IS how you conclude the operation once the
|
|
84
|
+
* conflicts are resolved and staged — refusing them would make a resolved merge
|
|
85
|
+
* permanently uncommittable (v0.23.4, caught by the real-git E2E). They are still
|
|
86
|
+
* covered by the two content-based checks below: an *unresolved* merge shows up
|
|
87
|
+
* as unmerged index entries and/or leftover conflict blocks.
|
|
88
|
+
*/
|
|
89
|
+
const CONFLICT_STATE_REFS = [["rebase", "REBASE_HEAD"]];
|
|
90
|
+
/**
|
|
91
|
+
* Thrown by `GitFace.commit()` when the tree must NOT be committed. Carries the
|
|
92
|
+
* offending files so a route/tool can tell the user exactly what to resolve.
|
|
93
|
+
*/
|
|
94
|
+
var GitConflictStateError = class extends Error {
|
|
95
|
+
reason;
|
|
96
|
+
files;
|
|
97
|
+
constructor(message, reason, files) {
|
|
98
|
+
super(message);
|
|
99
|
+
this.reason = reason;
|
|
100
|
+
this.files = files;
|
|
101
|
+
this.name = "GitConflictStateError";
|
|
102
|
+
}
|
|
103
|
+
};
|
|
61
104
|
function parseCount(line, re) {
|
|
62
105
|
const m = line.match(re);
|
|
63
106
|
return m === null ? void 0 : Number(m[1]);
|
|
64
107
|
}
|
|
65
108
|
var GitFace = class {
|
|
66
109
|
exec;
|
|
110
|
+
readTextFile;
|
|
67
111
|
/**
|
|
68
112
|
* FIFO mutex for the index-touching operations (`commit` and `pull
|
|
69
113
|
* --rebase`). The debounced AutoCommitter can fire while an explicit
|
|
@@ -77,8 +121,9 @@ var GitFace = class {
|
|
|
77
121
|
this.lock = run.then(() => void 0, () => void 0);
|
|
78
122
|
return run;
|
|
79
123
|
}
|
|
80
|
-
constructor(exec = defaultExec) {
|
|
124
|
+
constructor(exec = defaultExec, readTextFile = (path) => readFileSync(path, "utf8")) {
|
|
81
125
|
this.exec = exec;
|
|
126
|
+
this.readTextFile = readTextFile;
|
|
82
127
|
}
|
|
83
128
|
async isRepo(dir) {
|
|
84
129
|
const r = await this.exec(["rev-parse", "--is-inside-work-tree"], {
|
|
@@ -123,6 +168,8 @@ var GitFace = class {
|
|
|
123
168
|
return this.withLock(() => this.commitUnlocked(dir, message));
|
|
124
169
|
}
|
|
125
170
|
async commitUnlocked(dir, message) {
|
|
171
|
+
const conflict = await this.conflictState(dir, { contentScan: true });
|
|
172
|
+
if (conflict.conflicted) throw new GitConflictStateError(describeConflict(conflict), conflict.reason, conflict.files);
|
|
126
173
|
await this.exec(["add", "-A"], {
|
|
127
174
|
cwd: dir,
|
|
128
175
|
timeout: HEAVY_TIMEOUT_MS
|
|
@@ -193,6 +240,7 @@ var GitFace = class {
|
|
|
193
240
|
timeout: QUICK_TIMEOUT_MS
|
|
194
241
|
});
|
|
195
242
|
const lastCommit = lastR.ok && lastR.stdout.trim().length > 0 ? lastR.stdout.trim() : void 0;
|
|
243
|
+
const conflict = await this.conflictState(dir, { contentScan: dirty });
|
|
196
244
|
return {
|
|
197
245
|
exists: true,
|
|
198
246
|
branch,
|
|
@@ -201,7 +249,11 @@ var GitFace = class {
|
|
|
201
249
|
remote,
|
|
202
250
|
...lastCommit !== void 0 ? { lastCommit } : {},
|
|
203
251
|
...ahead !== void 0 ? { ahead } : {},
|
|
204
|
-
...behind !== void 0 ? { behind } : {}
|
|
252
|
+
...behind !== void 0 ? { behind } : {},
|
|
253
|
+
...conflict.conflicted ? { conflict: {
|
|
254
|
+
reason: conflict.reason,
|
|
255
|
+
files: conflict.files
|
|
256
|
+
} } : {}
|
|
205
257
|
};
|
|
206
258
|
}
|
|
207
259
|
/** `git pull --rebase --autostash`; on conflict: abort + report files.
|
|
@@ -244,11 +296,14 @@ var GitFace = class {
|
|
|
244
296
|
cwd: dir,
|
|
245
297
|
timeout: HEAVY_TIMEOUT_MS
|
|
246
298
|
});
|
|
299
|
+
const after = await this.conflictState(dir);
|
|
300
|
+
const files = after.conflicted ? [.../* @__PURE__ */ new Set([...conflictFiles, ...after.files])] : conflictFiles;
|
|
301
|
+
const note = after.conflicted ? `(rebase --abort 之后工作树仍有冲突:${after.reason})` : "";
|
|
247
302
|
const reason = (r.stderr.trim() || r.stdout.trim()).slice(0, 500);
|
|
248
303
|
return {
|
|
249
304
|
ok: false,
|
|
250
|
-
message:
|
|
251
|
-
...
|
|
305
|
+
message: files.length > 0 ? `conflict in ${files.join(", ")} (rebase aborted)${note}: ${reason}` : `pull failed${note}: ${reason}`,
|
|
306
|
+
...files.length > 0 ? { conflictFiles: files } : {}
|
|
252
307
|
};
|
|
253
308
|
}
|
|
254
309
|
async push(dir) {
|
|
@@ -374,6 +429,101 @@ var GitFace = class {
|
|
|
374
429
|
message: add.stderr.trim() || "remote add failed"
|
|
375
430
|
};
|
|
376
431
|
}
|
|
432
|
+
/**
|
|
433
|
+
* Is the working tree in an unresolved conflict state? (v0.23.4)
|
|
434
|
+
*
|
|
435
|
+
* Three sources, cheapest first — a pure `git` probe unless `contentScan`
|
|
436
|
+
* is on:
|
|
437
|
+
* 1. an in-progress operation (`MERGE_HEAD` / `REBASE_HEAD` / …);
|
|
438
|
+
* 2. unmerged index entries (`diff --diff-filter=U`);
|
|
439
|
+
* 3. a conflict BLOCK left in a changed file (the autostash-reapply case:
|
|
440
|
+
* git itself is no longer mid-operation, so only the text shows it).
|
|
441
|
+
*/
|
|
442
|
+
async conflictState(dir, options = {}) {
|
|
443
|
+
for (const [label, ref] of CONFLICT_STATE_REFS) {
|
|
444
|
+
const r = await this.exec([
|
|
445
|
+
"rev-parse",
|
|
446
|
+
"-q",
|
|
447
|
+
"--verify",
|
|
448
|
+
ref
|
|
449
|
+
], {
|
|
450
|
+
cwd: dir,
|
|
451
|
+
timeout: QUICK_TIMEOUT_MS
|
|
452
|
+
});
|
|
453
|
+
if (r.ok && r.stdout.trim().length > 0) return {
|
|
454
|
+
conflicted: true,
|
|
455
|
+
reason: `${label} in progress`,
|
|
456
|
+
files: await this.unmergedFiles(dir)
|
|
457
|
+
};
|
|
458
|
+
}
|
|
459
|
+
const unmerged = await this.unmergedFiles(dir);
|
|
460
|
+
if (unmerged.length > 0) return {
|
|
461
|
+
conflicted: true,
|
|
462
|
+
reason: "unmerged paths",
|
|
463
|
+
files: unmerged
|
|
464
|
+
};
|
|
465
|
+
if (options.contentScan !== true) return {
|
|
466
|
+
conflicted: false,
|
|
467
|
+
reason: "",
|
|
468
|
+
files: []
|
|
469
|
+
};
|
|
470
|
+
const marked = [];
|
|
471
|
+
for (const file of await this.changedFiles(dir)) if (this.fileHasConflictMarkers(join(dir, file))) marked.push(file);
|
|
472
|
+
return marked.length > 0 ? {
|
|
473
|
+
conflicted: true,
|
|
474
|
+
reason: "conflict markers in working tree",
|
|
475
|
+
files: marked
|
|
476
|
+
} : {
|
|
477
|
+
conflicted: false,
|
|
478
|
+
reason: "",
|
|
479
|
+
files: []
|
|
480
|
+
};
|
|
481
|
+
}
|
|
482
|
+
/** Working-tree files a commit would touch: modified, staged and untracked. */
|
|
483
|
+
async changedFiles(dir) {
|
|
484
|
+
const out = /* @__PURE__ */ new Set();
|
|
485
|
+
for (const args of [
|
|
486
|
+
[
|
|
487
|
+
"diff",
|
|
488
|
+
"--name-only",
|
|
489
|
+
"HEAD"
|
|
490
|
+
],
|
|
491
|
+
[
|
|
492
|
+
"diff",
|
|
493
|
+
"--cached",
|
|
494
|
+
"--name-only"
|
|
495
|
+
],
|
|
496
|
+
[
|
|
497
|
+
"ls-files",
|
|
498
|
+
"--others",
|
|
499
|
+
"--exclude-standard"
|
|
500
|
+
]
|
|
501
|
+
]) {
|
|
502
|
+
const r = await this.exec(args, {
|
|
503
|
+
cwd: dir,
|
|
504
|
+
timeout: QUICK_TIMEOUT_MS
|
|
505
|
+
});
|
|
506
|
+
if (!r.ok) continue;
|
|
507
|
+
for (const line of r.stdout.split("\n")) {
|
|
508
|
+
const name = line.trim();
|
|
509
|
+
if (name.length > 0) out.add(name);
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
return [...out];
|
|
513
|
+
}
|
|
514
|
+
/**
|
|
515
|
+
* Does this file contain a conflict block? Read failures and binary content
|
|
516
|
+
* are treated as "no" — the guard is a safety net, not a content auditor.
|
|
517
|
+
*/
|
|
518
|
+
fileHasConflictMarkers(absPath) {
|
|
519
|
+
try {
|
|
520
|
+
const text = this.readTextFile(absPath);
|
|
521
|
+
if (text.includes("\0")) return false;
|
|
522
|
+
return CONFLICT_MARKER_RE.test(text);
|
|
523
|
+
} catch {
|
|
524
|
+
return false;
|
|
525
|
+
}
|
|
526
|
+
}
|
|
377
527
|
async unmergedFiles(dir) {
|
|
378
528
|
const r = await this.exec([
|
|
379
529
|
"diff",
|
|
@@ -396,6 +546,16 @@ function identity() {
|
|
|
396
546
|
];
|
|
397
547
|
}
|
|
398
548
|
/**
|
|
549
|
+
* Wording for a refused commit — shared by the error, the AutoCommitter log and
|
|
550
|
+
* the `/sync` 409 body so all three say the same actionable thing.
|
|
551
|
+
*/
|
|
552
|
+
function describeConflict(state) {
|
|
553
|
+
const shown = state.files.slice(0, 5);
|
|
554
|
+
const more = state.files.length > shown.length ? ` 等 ${state.files.length} 个文件` : "";
|
|
555
|
+
const where = shown.length > 0 ? `涉及 ${shown.join("、")}${more}` : "(未列出具体文件)";
|
|
556
|
+
return `拒绝提交:工作树处于未解决的冲突状态(${state.reason}),${where}。带着冲突标记提交会把坏内容写进 git 历史(v0.23.3 的真实事故:wiki 配置 tiddler 被提交了 <<<<<<< 标记,插件随即解析不了自己的配置)。请先解决冲突(tiddlywiki_git_resolve 或手动编辑)再同步。`;
|
|
557
|
+
}
|
|
558
|
+
/**
|
|
399
559
|
* Debounced auto-committer: every wiki write calls `touch()`; the commit
|
|
400
560
|
* fires once writes settle for `debounceMs`. Disable with git.autoCommit.
|
|
401
561
|
*/
|
|
@@ -403,6 +563,13 @@ var AutoCommitter = class {
|
|
|
403
563
|
options;
|
|
404
564
|
timer;
|
|
405
565
|
disposed = false;
|
|
566
|
+
/**
|
|
567
|
+
* Signature of the conflict we already reported. The debounce fires every
|
|
568
|
+
* 60s and a leftover conflict block stays put until a human fixes it, so
|
|
569
|
+
* reporting on every tick would bury the log; report once per distinct state
|
|
570
|
+
* (v0.23.4) and reset the moment a commit succeeds.
|
|
571
|
+
*/
|
|
572
|
+
lastBlocked;
|
|
406
573
|
constructor(options) {
|
|
407
574
|
this.options = options;
|
|
408
575
|
}
|
|
@@ -422,8 +589,14 @@ var AutoCommitter = class {
|
|
|
422
589
|
if (!this.options.enabled || this.disposed) return;
|
|
423
590
|
try {
|
|
424
591
|
const result = await this.options.git.commit(this.options.dir, this.options.message());
|
|
592
|
+
this.lastBlocked = void 0;
|
|
425
593
|
this.options.onCommit?.(result);
|
|
426
594
|
} catch (err) {
|
|
595
|
+
if (err instanceof GitConflictStateError) {
|
|
596
|
+
const signature = `${err.reason}|${err.files.join(",")}`;
|
|
597
|
+
if (signature === this.lastBlocked) return;
|
|
598
|
+
this.lastBlocked = signature;
|
|
599
|
+
}
|
|
427
600
|
this.options.onError?.(err);
|
|
428
601
|
}
|
|
429
602
|
}
|
|
@@ -3756,7 +3929,7 @@ const WECHAT_PUBLISH_PLUGIN_TITLE = "$:/plugins/dsh/wechat-publish";
|
|
|
3756
3929
|
/** One-time marker: presence means "the button was offered once — hands off". */
|
|
3757
3930
|
const WECHAT_PUBLISH_MARKER_TITLE = "$:/plugins/dsh-tiddlywiki/seed-wechat-publish";
|
|
3758
3931
|
/** The bundle's JSON text (`{"tiddlers": {...}}`), exactly as TW stores it. */
|
|
3759
|
-
const WECHAT_PUBLISH_BUNDLE_TEXT = "{\n \"tiddlers\": {\n \"$:/plugins/dsh/wechat-publish/plugin.info\": {\n \"title\": \"$:/plugins/dsh/wechat-publish/plugin.info\",\n \"type\": \"application/json\",\n \"text\": \"{\\\"title\\\":\\\"$:/plugins/dsh/wechat-publish\\\",\\\"name\\\":\\\"WeChat Publish\\\",\\\"description\\\":\\\"把当前笔记一键存到微信公众号草稿箱(TiddlyWiki → DSH → 浏览器自动化)\\\",\\\"author\\\":\\\"dsh-tiddlywiki\\\",\\\"version\\\":\\\"0.1.0\\\",\\\"plugin-type\\\":\\\"plugin\\\"}\"\n },\n \"$:/plugins/dsh/wechat-publish/startup.js\": {\n \"title\": \"$:/plugins/dsh/wechat-publish/startup.js\",\n \"type\": \"application/javascript\",\n \"module-type\": \"startup\",\n \"text\": \"/*\\\\\\ntitle: $:/plugins/dsh/wechat-publish/startup.js\\ntype: application/javascript\\nmodule-type: startup\\n\\n\\\\*/\\n(function(){\\n\\n/*jslint node: true, browser: true */\\n/*global $tw: false */\\n\\\"use strict\\\";\\n\\nexports.name = \\\"dsh-wechat-publish\\\";\\nexports.after = [\\\"story\\\"];\\nexports.platforms = [\\\"browser\\\"];\\n\\n// The one-shot confirm overlay. Kept as a DOM id (not a closure variable)\\n// because the poller and the overlay are wired together long after the\\n// overlay was built — see publishAndPoll().\\nvar OVERLAY_ID = \\\"dsh-wechat-publish-overlay\\\";\\n// Stop polling after 10 minutes. The browser flow uploads every inlined image\\n// one by one, so a long article is slow, but a job that never settles must not\\n// keep a 2s timer alive for the rest of the page's life.\\nvar POLL_INTERVAL_MS = 2000;\\nvar POLL_TIMEOUT_MS = 10 * 60 * 1000;\\n// How much of job.log to show (a full adapter run can log megabytes).\\nvar LOG_TAIL_CHARS = 2000;\\n\\n// Mirrors the host's DEFAULT_ADAPTER. `wechat.enabled` is false by default:\\n// browser automation against the 公众号 backend is an OPT-IN feature that\\n// needs opencli + the Browser Bridge extension installed separately.\\nfunction readConfig() {\\n\\tvar config = { enabled: false, endpoint: \\\"\\\", token: \\\"\\\", adapter: \\\"publish-note\\\" };\\n\\ttry {\\n\\t\\tvar t = $tw.wiki.getTiddler(\\\"$:/plugins/dsh-tiddlywiki/config\\\");\\n\\t\\tif (t && t.fields && typeof t.fields.text === \\\"string\\\") {\\n\\t\\t\\tvar parsed = JSON.parse(t.fields.text);\\n\\t\\t\\tvar wechat = parsed && parsed.wechat;\\n\\t\\t\\tif (wechat) {\\n\\t\\t\\t\\tif (typeof wechat.enabled === \\\"boolean\\\") { config.enabled = wechat.enabled; }\\n\\t\\t\\t\\tif (typeof wechat.endpoint === \\\"string\\\" && wechat.endpoint.length > 0) { config.endpoint = wechat.endpoint; }\\n\\t\\t\\t\\tif (typeof wechat.token === \\\"string\\\" && wechat.token.length > 0) { config.token = wechat.token; }\\n\\t\\t\\t\\tif (wechat.adapter === \\\"publish-note\\\" || wechat.adapter === \\\"publish-note-imgs\\\") { config.adapter = wechat.adapter; }\\n\\t\\t\\t}\\n\\t\\t}\\n\\t} catch (e) {}\\n\\treturn config;\\n}\\n\\nfunction baseEndpoint() {\\n\\tvar config = readConfig();\\n\\tif (config.endpoint.length > 0) { return config.endpoint.replace(/\\\\/+$/, \\\"\\\"); }\\n\\tif (typeof location !== \\\"undefined\\\" && location.origin) { return location.origin + \\\"/dsh-tiddlywiki\\\"; }\\n\\treturn \\\"/dsh-tiddlywiki\\\";\\n}\\n\\nfunction authHeaders() {\\n\\tvar headers = { \\\"Content-Type\\\": \\\"application/json\\\" };\\n\\tvar config = readConfig();\\n\\t// Must match the host's readToken(): the token only travels in this header,\\n\\t// never in the URL, so it cannot leak through history/referer/proxy logs.\\n\\tif (config.token.length > 0) { headers[\\\"x-wechat-publish-token\\\"] = config.token; }\\n\\treturn headers;\\n}\\n\\n/*\\nShow a transient message.\\n\\nTW's notifier renders a TIDDLER by title and does **nothing at all** when that\\ntiddler does not exist (core/modules/utils/dom/notifier.js: \\\"Don't do anything\\nif the tiddler doesn't exist\\\"). Passing free text as the title — the mistake\\nthe 「发送给 Agent」 button shipped with — makes every notice a silent no-op.\\nStore the message in a $:/temp tiddler first (volatile: never synced, never\\nwritten to disk) and display THAT title.\\n*/\\nvar NOTICE_TITLE = \\\"$:/temp/dsh/wechat-publish/notice\\\";\\nfunction notify(msg) {\\n\\ttry {\\n\\t\\tif ($tw.wiki && $tw.notifier && typeof $tw.notifier.display === \\\"function\\\" && typeof $tw.wiki.addTiddler === \\\"function\\\") {\\n\\t\\t\\t$tw.wiki.addTiddler({ title: NOTICE_TITLE, text: String(msg), type: \\\"text/vnd.tiddlywiki\\\" });\\n\\t\\t\\t$tw.notifier.display(NOTICE_TITLE);\\n\\t\\t\\treturn;\\n\\t\\t}\\n\\t} catch (e) {}\\n\\tif (typeof alert === \\\"function\\\") { alert(msg); }\\n}\\n\\n/*\\nParse a response body. The host answers every failure with a JSON body AND a\\nnon-2xx status, and $tw.utils.httpRequest reports those as\\n`callback(\\\"Error/XMLHttpRequest: 403\\\", body, xhr)` — i.e. err is a localized\\nstring and the body is still handed over. So parse first, then decide: the\\nbody's `error` is the readable truth, err is the fallback.\\n*/\\nfunction parseJob(data) {\\n\\ttry { return JSON.parse(data || \\\"\\\"); } catch (e) { return null; }\\n}\\nfunction failText(err, parsed, data, fallback) {\\n\\tif (parsed && parsed.error) { return String(parsed.error); }\\n\\tif (err) { return String(err); }\\n\\tif (data) { return String(data); }\\n\\treturn fallback || \\\"未知错误\\\";\\n}\\n\\nfunction closeOverlay() {\\n\\tvar overlay = document.getElementById(OVERLAY_ID);\\n\\tif (overlay) {\\n\\t\\tif (typeof overlay.__dshStopPolling === \\\"function\\\") { overlay.__dshStopPolling(); }\\n\\t\\tif (overlay.parentNode) { overlay.parentNode.removeChild(overlay); }\\n\\t}\\n}\\n\\nfunction makeBox() {\\n\\tvar box = document.createElement(\\\"div\\\");\\n\\tbox.setAttribute(\\\"style\\\", \\\"background:#fff;color:#333;border-radius:8px;padding:16px;max-width:520px;width:92vw;max-height:82vh;overflow:auto;box-shadow:0 8px 30px rgba(0,0,0,.3);font-family:system-ui,-apple-system,sans-serif;font-size:13px;line-height:1.6;\\\");\\n\\treturn box;\\n}\\n\\nfunction makeRow(parent, weight, text, color) {\\n\\tvar el = document.createElement(weight === \\\"h\\\" ? \\\"h3\\\" : \\\"p\\\");\\n\\tel.setAttribute(\\\"style\\\", (weight === \\\"h\\\" ? \\\"margin:0 0 8px;font-size:15px;\\\" : \\\"margin:0 0 8px;\\\") + (color ? \\\"color:\\\" + color + \\\";\\\" : \\\"\\\") + \\\"word-break:break-all;\\\");\\n\\tel.textContent = text;\\n\\tparent.appendChild(el);\\n\\treturn el;\\n}\\n\\nfunction makeButton(label, primary) {\\n\\tvar b = document.createElement(\\\"button\\\");\\n\\tb.type = \\\"button\\\";\\n\\tb.textContent = label;\\n\\tb.setAttribute(\\\"style\\\", primary\\n\\t\\t? \\\"padding:7px 16px;border:1px solid #4a90d9;border-radius:6px;background:#4a90d9;color:#fff;cursor:pointer;font-size:13px;\\\"\\n\\t\\t: \\\"padding:7px 16px;border:1px solid #ccc;border-radius:6px;background:#fff;cursor:pointer;font-size:13px;color:#444;\\\");\\n\\treturn b;\\n}\\n\\nfunction setDisabled(btn, disabled) {\\n\\tbtn.disabled = !!disabled;\\n\\tbtn.style.opacity = disabled ? \\\"0.5\\\" : \\\"1\\\";\\n\\tbtn.style.cursor = disabled ? \\\"default\\\" : \\\"pointer\\\";\\n}\\n\\n/*\\nBuild the confirm overlay. Returns the box plus the pieces the caller mutates:\\n {overlay, box, status, actions, logs, logPre}\\nOnly ever one overlay at a time (a second click replaces the first), and Esc /\\nbackdrop click / ✕ all funnel through closeOverlay() so the poller is stopped.\\n*/\\nfunction createOverlay(title) {\\n\\tvar previous = document.getElementById(OVERLAY_ID);\\n\\tif (previous) { closeOverlay(); }\\n\\n\\tvar overlay = document.createElement(\\\"div\\\");\\n\\toverlay.id = OVERLAY_ID;\\n\\toverlay.setAttribute(\\\"style\\\", \\\"position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.35);z-index:99999;display:flex;align-items:center;justify-content:center;\\\");\\n\\tvar box = makeBox();\\n\\n\\tvar header = document.createElement(\\\"div\\\");\\n\\theader.setAttribute(\\\"style\\\", \\\"display:flex;align-items:center;justify-content:space-between;gap:8px;\\\");\\n\\tvar h = document.createElement(\\\"h3\\\");\\n\\th.setAttribute(\\\"style\\\", \\\"margin:0;font-size:15px;\\\");\\n\\th.textContent = \\\"发布到公众号 · \\\" + title;\\n\\tvar closeX = document.createElement(\\\"button\\\");\\n\\tcloseX.type = \\\"button\\\";\\n\\tcloseX.textContent = \\\"✕\\\";\\n\\tcloseX.title = \\\"关闭(停止轮询)\\\";\\n\\tcloseX.setAttribute(\\\"style\\\", \\\"border:none;background:transparent;font-size:15px;cursor:pointer;color:#888;padding:2px 8px;border-radius:4px;\\\");\\n\\tcloseX.addEventListener(\\\"click\\\", function() { closeOverlay(); });\\n\\theader.appendChild(h);\\n\\theader.appendChild(closeX);\\n\\tbox.appendChild(header);\\n\\n\\tvar status = document.createElement(\\\"div\\\");\\n\\tstatus.setAttribute(\\\"style\\\", \\\"margin:6px 0 10px;white-space:pre-wrap;\\\");\\n\\tbox.appendChild(status);\\n\\n\\tvar logs = document.createElement(\\\"div\\\");\\n\\tlogs.setAttribute(\\\"style\\\", \\\"margin:8px 0 0;\\\");\\n\\tvar logCaption = document.createElement(\\\"p\\\");\\n\\tlogCaption.setAttribute(\\\"style\\\", \\\"margin:0 0 4px;font-size:12px;color:#888;\\\");\\n\\tlogCaption.textContent = \\\"运行日志(最近 \\\" + LOG_TAIL_CHARS + \\\" 字符):\\\";\\n\\tvar logPre = document.createElement(\\\"pre\\\");\\n\\tlogPre.setAttribute(\\\"style\\\", \\\"margin:0;max-height:32vh;overflow:auto;background:#1e1e1e;color:#ddd;border-radius:6px;padding:8px;font-size:11px;white-space:pre-wrap;word-break:break-all;\\\");\\n\\tlogs.appendChild(logCaption);\\n\\tlogs.appendChild(logPre);\\n\\tlogs.style.display = \\\"none\\\";\\n\\tbox.appendChild(logs);\\n\\n\\tvar actions = document.createElement(\\\"div\\\");\\n\\tactions.setAttribute(\\\"style\\\", \\\"display:flex;justify-content:flex-end;gap:8px;margin-top:14px;flex-wrap:wrap;\\\");\\n\\tbox.appendChild(actions);\\n\\n\\toverlay.appendChild(box);\\n\\tdocument.body.appendChild(overlay);\\n\\n\\toverlay.addEventListener(\\\"click\\\", function(e) { if (e.target === overlay) { closeOverlay(); } });\\n\\tvar escHandler = function(e) { if (e.key === \\\"Escape\\\") { closeOverlay(); } };\\n\\tdocument.addEventListener(\\\"keydown\\\", escHandler);\\n\\t// The Esc listener lived on `document`: it must die with the overlay,\\n\\t// otherwise every open/close cycle leaks one keydown handler.\\n\\toverlay.__dshCleanup = function() { document.removeEventListener(\\\"keydown\\\", escHandler); };\\n\\treturn { overlay: overlay, box: box, status: status, actions: actions, logs: logs, logPre: logPre };\\n}\\n\\n/*\\nThe plain-text confirm body. The「只存草稿」/「需要扫码」/「复用已登录浏览器」\\nsentences are the contract of the whole feature (v0.23.2 起策略就是「到草稿箱为止」):\\nusers must not expect a one-click public post, and must know their session is\\nreused rather than a stored credential.\\n*/\\nfunction confirmationText(title) {\\n\\treturn \\\"笔记:《\\\" + title + \\\"》\\\\n\\\\n\\\"\\n\\t\\t+ \\\"只存到草稿箱,不会自动发表;发表需你在浏览器里扫码。\\\\n\\\"\\n\\t\\t+ \\\"脚本会复用你已登录的浏览器(不保存任何凭据)。\\\\n\\\"\\n\\t\\t+ \\\"首次运行可能需要一两分钟(图片要逐张上传)。\\\";\\n}\\n\\n/*\\nA warning row for notes the 发布元数据规范 marks as off-limits. Read from the\\ntiddler's custom FIELDS (not tags alone): `pub-state: excluded` and the\\n`no-publish` tag are the two mirrors the spec keeps in sync. This only warns —\\nthe host does not block (see docs: 只告警不阻断), so the user stays in control.\\n*/\\nfunction publishWarnings(title) {\\n\\tvar tiddler = $tw.wiki.getTiddler(title);\\n\\tvar fields = (tiddler && tiddler.fields) || {};\\n\\tvar tags = fields.tags || [];\\n\\tvar state = fields[\\\"pub-state\\\"] ? String(fields[\\\"pub-state\\\"]) : \\\"\\\";\\n\\tvar warnings = [];\\n\\tif (state === \\\"published\\\") { warnings.push(\\\"这篇笔记的 pub-state 是 published —— 可能已经发表过,请确认不要重复发布。\\\"); }\\n\\tif (state === \\\"excluded\\\") { warnings.push(\\\"这篇笔记的 pub-state 是 excluded —— 规范里标为「明确不发」。\\\"); }\\n\\tif (tags.indexOf(\\\"no-publish\\\") !== -1) { warnings.push(\\\"这篇笔记带着 no-publish 标签 —— 规范里标为「不可发布」。\\\"); }\\n\\treturn warnings;\\n}\\n\\nfunction showError(title, msg, silent) {\\n\\tvar ui = createOverlay(title);\\n\\tui.overlay.__dshCleanup();\\n\\tmakeRow(ui.status, \\\"p\\\", \\\"⚠️ \\\" + msg, \\\"#c0392b\\\");\\n\\tvar cancel = makeButton(\\\"关闭\\\", false);\\n\\tcancel.addEventListener(\\\"click\\\", function() { closeOverlay(); });\\n\\tui.actions.appendChild(cancel);\\n\\t// `silent` for the callers that already fire their own toast — one failure\\n\\t// must produce exactly one notification, not two.\\n\\tif (!silent) { notify(\\\"发布到公众号失败:\\\" + msg); }\\n}\\n\\n/*\\nGET /wechat/ready. The endpoint answers 200 with `ok:true` whenever the feature\\nis on — an INSTALLATION problem is deliberately not an HTTP error (see the host\\nroute), it is reported inside the body as `opencli.ok=false` / `adapters.missing`.\\nSo `parsed.ok` alone must never be read as \\\"ready\\\": gating on it would skip both\\nchecks and let the user confirm a publish that is guaranteed to fail 503.\\nReturns an actionable message for a CONCRETE problem, or null otherwise.\\n\\nThis was measured, not guessed: with a body of\\n`{ok:true, adapters:{publishNote:false,…}}` an earlier draft still rendered the\\nconfirm dialog, because the readiness check was keyed off `parsed.ok`.\\n*/\\nfunction readinessProblem(parsed, adapter) {\\n\\tif (!parsed) { return null; }\\n\\tif (parsed.enabled === false) {\\n\\t\\treturn \\\"微信发布未启用:请在 DSH 设置 →「TiddlyWiki 知识库」→「可选功能:微信公众号发布」勾选并保存。\\\";\\n\\t}\\n\\tvar ready = parsed.ready || parsed;\\n\\tif (ready.opencli && ready.opencli.ok === false) {\\n\\t\\treturn \\\"未检测到可用的 opencli —— 发布依赖它驱动浏览器。\\\\n请先按 docs/wechat-publish-setup.md 安装 opencli,然后运行:\\\\nnode <插件目录>/tools/wechat/install-wechat-adapters.mjs\\\";\\n\\t}\\n\\tvar adapters = ready.adapters;\\n\\tif (!adapters) { return null; }\\n\\tvar missing = Array.isArray(adapters.missing) ? adapters.missing : [];\\n\\t// Only the selected adapter matters: a wiki can publish plain notes long\\n\\t// before the multi-image adapter is installed.\\n\\tvar needed = adapter === \\\"publish-note-imgs\\\" ? \\\"publishNoteImgs\\\" : \\\"publishNote\\\";\\n\\tif (adapters[needed] === false || missing.length > 0) {\\n\\t\\treturn \\\"缺少发布脚本(adapter):\\\" + (missing.length > 0 ? missing.join(\\\"、\\\") : needed) + \\\"。\\\\n\\\"\\n\\t\\t\\t+ \\\"请运行(路径相对插件目录,npm 安装时是 node_modules/dsh-tiddlywiki):\\\\n\\\"\\n\\t\\t\\t+ \\\"node <插件目录>/tools/wechat/install-wechat-adapters.mjs\\\";\\n\\t}\\n\\treturn null;\\n}\\n\\n/** The concrete problem, or the transport error / host message when there is none. */\\nfunction describeNotReady(parsed, err, data, adapter) {\\n\\tvar problem = readinessProblem(parsed, adapter);\\n\\tif (problem !== null) { return problem; }\\n\\treturn \\\"无法检查发布环境:\\\" + failText(err, parsed, data, \\\"未知错误\\\");\\n}\\n\\n/*\\nPOST /wechat/publish, then poll GET /wechat/publish/status?id=… every 2s until\\nthe job settles. A 409 (another job running) is surfaced verbatim — the host\\nalready names the running jobId, which is what the user needs to go look at it.\\n*/\\nfunction publishAndPoll(title, adapter) {\\n\\tvar ui = createOverlay(title);\\n\\tsetDisabled(ui.actions, true);\\n\\tui.actions.innerHTML = \\\"\\\";\\n\\tmakeRow(ui.status, \\\"p\\\", \\\"正在提交发布任务…\\\", \\\"#555\\\");\\n\\n\\tvar stopped = false;\\n\\tvar timer = null;\\n\\tfunction stopPolling() {\\n\\t\\tstopped = true;\\n\\t\\tif (timer !== null) { clearInterval(timer); timer = null; }\\n\\t}\\n\\t// closeOverlay() calls this through the overlay node, so EVERY close path\\n\\t// (✕ / Esc / backdrop / programmatic) stops the 2s polling loop.\\n\\tui.overlay.__dshStopPolling = function() {\\n\\t\\tstopPolling();\\n\\t\\tui.overlay.__dshCleanup();\\n\\t};\\n\\n\\tfunction addCloseButton(onClose) {\\n\\t\\tvar close = makeButton(\\\"关闭\\\", false);\\n\\t\\tclose.addEventListener(\\\"click\\\", function() { closeOverlay(); if (onClose) { onClose(); } });\\n\\t\\tui.actions.appendChild(close);\\n\\t}\\n\\n\\tfunction renderJob(job) {\\n\\t\\tvar lines = [];\\n\\t\\tif (job.state === \\\"running\\\") { lines.push(\\\"⏳ 正在发布…\\\"); }\\n\\t\\telse if (job.state === \\\"ok\\\") { lines.push(\\\"✅ \\\" + (job.message || \\\"已存到公众号草稿箱\\\")); }\\n\\t\\telse if (job.state === \\\"error\\\") { lines.push(\\\"❌ 发布失败\\\" + (job.exitCode !== undefined ? \\\"(exit \\\" + job.exitCode + \\\")\\\" : \\\"\\\")); }\\n\\t\\telse { lines.push(\\\"状态:\\\" + String(job.state || \\\"未知\\\")); }\\n\\t\\tif (job.message && job.state !== \\\"ok\\\") { lines.push(String(job.message)); }\\n\\t\\tif (job.state === \\\"ok\\\") {\\n\\t\\t\\tlines.push(\\\"\\\");\\n\\t\\t\\tlines.push(\\\"建议回写发布元数据(用 tiddlywiki_put 补 fields,别动 tags):\\\");\\n\\t\\t\\tlines.push(\\\"pub-state: published · pub-platform: wechat · pub-wechat-at: <发表时间>\\\");\\n\\t\\t\\tlines.push(\\\"若实际发表时间/标题与本文不同,再补 pub-wechat-title / pub-wechat-url。\\\");\\n\\t\\t}\\n\\t\\tui.status.textContent = lines.join(\\\"\\\\n\\\");\\n\\t\\tif (job.log) {\\n\\t\\t\\tui.logs.style.display = \\\"\\\";\\n\\t\\t\\tui.logPre.textContent = String(job.log).slice(-LOG_TAIL_CHARS);\\n\\t\\t\\tui.logPre.scrollTop = ui.logPre.scrollHeight;\\n\\t\\t}\\n\\t}\\n\\n\\tfunction poll(jobId) {\\n\\t\\t$tw.utils.httpRequest({\\n\\t\\t\\turl: baseEndpoint() + \\\"/wechat/publish/status?id=\\\" + encodeURIComponent(jobId),\\n\\t\\t\\ttype: \\\"GET\\\",\\n\\t\\t\\theaders: authHeaders(),\\n\\t\\t\\tcallback: function(err, data) {\\n\\t\\t\\t\\tif (stopped) { return; }\\n\\t\\t\\t\\tvar parsed = parseJob(data);\\n\\t\\t\\t\\tif (err || !parsed || !parsed.ok || !parsed.job) {\\n\\t\\t\\t\\t\\t// A transient poll failure must not end the job: show it and\\n\\t\\t\\t\\t\\t// keep polling — the job is still running on the host.\\n\\t\\t\\t\\t\\tmakeRow(ui.status, \\\"p\\\", \\\"⚠️ 查询状态失败:\\\" + failText(err, parsed, data, \\\"未知错误\\\") + \\\"(继续重试…)\\\", \\\"#c0392b\\\");\\n\\t\\t\\t\\t\\treturn;\\n\\t\\t\\t\\t}\\n\\t\\t\\t\\tvar job = parsed.job;\\n\\t\\t\\t\\trenderJob(job);\\n\\t\\t\\t\\tif (job.state === \\\"ok\\\" || job.state === \\\"error\\\") {\\n\\t\\t\\t\\t\\tstopPolling();\\n\\t\\t\\t\\t\\tui.overlay.__dshCleanup();\\n\\t\\t\\t\\t\\tui.actions.innerHTML = \\\"\\\";\\n\\t\\t\\t\\t\\taddCloseButton();\\n\\t\\t\\t\\t\\t// Sync a one-shot toast: the overlay may be closed (or the\\n\\t\\t\\t\\t\\t// TW panel hidden) before the user reads it.\\n\\t\\t\\t\\t\\tnotify(job.state === \\\"ok\\\"\\n\\t\\t\\t\\t\\t\\t? \\\"已存到公众号草稿箱 ✓《\\\" + title + \\\"》\\\"\\n\\t\\t\\t\\t\\t\\t: \\\"发布到公众号失败:《\\\" + title + \\\"》\\\" + (job.message ? \\\"(\\\" + job.message + \\\")\\\" : \\\"\\\"));\\n\\t\\t\\t\\t}\\n\\t\\t\\t}\\n\\t\\t});\\n\\t}\\n\\n\\t$tw.utils.httpRequest({\\n\\t\\turl: baseEndpoint() + \\\"/wechat/publish\\\",\\n\\t\\ttype: \\\"POST\\\",\\n\\t\\theaders: authHeaders(),\\n\\t\\tdata: JSON.stringify({ title: title, adapter: adapter }),\\n\\t\\tcallback: function(err, data) {\\n\\t\\t\\tif (stopped) { return; }\\n\\t\\t\\tvar parsed = parseJob(data);\\n\\t\\t\\tif (err || !parsed || !parsed.ok) {\\n\\t\\t\\t\\t// The 503 body carries a `ready` snapshot (opencli + adapters)\\n\\t\\t\\t\\t// whose install advice is more useful than the raw error; for\\n\\t\\t\\t\\t// every other failure (400/401/403/409) the host's own `error`\\n\\t\\t\\t\\t// is already the actionable text, so it is kept as-is.\\n\\t\\t\\t\\tvar advice = readinessProblem(parsed, adapter);\\n\\t\\t\\t\\tvar detail = advice !== null\\n\\t\\t\\t\\t\\t? advice + \\\"\\\\n\\\" + failText(err, parsed, data, \\\"未知错误\\\")\\n\\t\\t\\t\\t\\t: failText(err, parsed, data, \\\"未知错误\\\");\\n\\t\\t\\t\\tstopPolling();\\n\\t\\t\\t\\tui.overlay.__dshCleanup();\\n\\t\\t\\t\\tui.actions.innerHTML = \\\"\\\";\\n\\t\\t\\t\\tif (parsed && parsed.jobId) { detail += \\\"\\\\n(正在运行的任务:\\\" + parsed.jobId + \\\")\\\"; }\\n\\t\\t\\t\\tmakeRow(ui.status, \\\"p\\\", \\\"⚠️ \\\" + detail, \\\"#c0392b\\\");\\n\\t\\t\\t\\taddCloseButton();\\n\\t\\t\\t\\tnotify(\\\"发布到公众号失败:\\\" + failText(err, parsed, data, \\\"未知错误\\\"));\\n\\t\\t\\t\\treturn;\\n\\t\\t\\t}\\n\\t\\t\\tvar jobId = parsed.jobId;\\n\\t\\t\\tmakeRow(ui.status, \\\"p\\\", \\\"任务已提交(\\\" + jobId + \\\"),每 2 秒查询进度…\\\", \\\"#555\\\");\\n\\t\\t\\taddCloseButton();\\n\\t\\t\\tpoll(jobId);\\n\\t\\t\\ttimer = setInterval(function() { poll(jobId); }, POLL_INTERVAL_MS);\\n\\t\\t\\t// Hard stop: the host has its own timeouts, but the page must not\\n\\t\\t\\t// poll forever if a job somehow never settles.\\n\\t\\t\\tsetTimeout(function() {\\n\\t\\t\\t\\tif (!stopped && timer !== null) {\\n\\t\\t\\t\\t\\tstopPolling();\\n\\t\\t\\t\\t\\tmakeRow(ui.status, \\\"p\\\", \\\"⚠️ 超过 10 分钟仍未结束,已停止查询。请到公众号后台确认草稿是否已保存。\\\", \\\"#c0392b\\\");\\n\\t\\t\\t\\t}\\n\\t\\t\\t}, POLL_TIMEOUT_MS);\\n\\t\\t}\\n\\t});\\n}\\n\\nfunction checkReadyThenConfirm(title, adapter) {\\n\\tvar ui = createOverlay(title);\\n\\tsetDisabled(ui.actions, true);\\n\\tui.actions.innerHTML = \\\"\\\";\\n\\tmakeRow(ui.status, \\\"p\\\", \\\"正在检查发布环境…\\\", \\\"#555\\\");\\n\\n\\t$tw.utils.httpRequest({\\n\\t\\turl: baseEndpoint() + \\\"/wechat/ready\\\",\\n\\t\\ttype: \\\"GET\\\",\\n\\t\\theaders: authHeaders(),\\n\\t\\tcallback: function(err, data) {\\n\\t\\t\\tvar parsed = parseJob(data);\\n\\t\\t\\t// `parsed.ok` is NOT the readiness signal (see readinessProblem):\\n\\t\\t\\t// the endpoint returns ok:true even when opencli/adapters are\\n\\t\\t\\t// missing, and reports those inside the body.\\n\\t\\t\\tvar problem = readinessProblem(parsed, adapter);\\n\\t\\t\\tif (problem !== null) {\\n\\t\\t\\t\\tcloseOverlay();\\n\\t\\t\\t\\t// The overlay shows the fix; the toast mirrors it once.\\n\\t\\t\\t\\tshowError(title, problem);\\n\\t\\t\\t\\treturn;\\n\\t\\t\\t}\\n\\t\\t\\t// Ready → rebuild the overlay as the confirm dialog.\\n\\t\\t\\tcloseOverlay();\\n\\t\\t\\tvar ui2 = createOverlay(title);\\n\\t\\t\\tmakeRow(ui2.status, \\\"p\\\", confirmationText(title), \\\"#444\\\");\\n\\t\\t\\tpublishWarnings(title).forEach(function(w) {\\n\\t\\t\\t\\tmakeRow(ui2.status, \\\"p\\\", \\\"⚠️ \\\" + w, \\\"#c0392b\\\");\\n\\t\\t\\t});\\n\\t\\t\\tvar cancel = makeButton(\\\"取消\\\", false);\\n\\t\\t\\tcancel.addEventListener(\\\"click\\\", function() { closeOverlay(); });\\n\\t\\t\\tvar go = makeButton(\\\"开始存草稿\\\", true);\\n\\t\\t\\tgo.addEventListener(\\\"click\\\", function() { closeOverlay(); publishAndPoll(title, adapter); });\\n\\t\\t\\tui2.actions.appendChild(cancel);\\n\\t\\t\\tui2.actions.appendChild(go);\\n\\t\\t}\\n\\t});\\n}\\n\\nfunction handlePublish(title) {\\n\\t// Whole handler is wrapped by the caller: a throw inside a startup module\\n\\t// aborts the rest of TW's startup (other plugins' modules never run).\\n\\tif (!title) { notify(\\\"无法确定当前笔记标题\\\"); return; }\\n\\tvar tiddler = $tw.wiki.getTiddler(title);\\n\\tif (!tiddler) { notify(\\\"找不到笔记:\\\" + title); return; }\\n\\tvar config = readConfig();\\n\\tif (!config.enabled) {\\n\\t\\tnotify(\\\"「发布到公众号」未启用:请在 DSH 设置 →「TiddlyWiki 知识库」→「微信公众号发布」勾选并保存后重试。\\\");\\n\\t\\treturn;\\n\\t}\\n\\tcheckReadyThenConfirm(title, config.adapter);\\n}\\n\\nexports.startup = function() {\\n\\tconsole.log(\\\"[dsh-wechat-publish] startup ran\\\");\\n\\tif (!$tw.rootWidget || typeof $tw.rootWidget.addEventListener !== \\\"function\\\") { return; }\\n\\t$tw.rootWidget.addEventListener(\\\"dsh-wechat-publish\\\", function(event) {\\n\\t\\ttry {\\n\\t\\t\\thandlePublish(event.param);\\n\\t\\t} catch (e) {\\n\\t\\t\\t// Never let a startup-module exception escape: TW runs every startup\\n\\t\\t\\t// module in one loop, so a throw here breaks unrelated plugins.\\n\\t\\t\\ttry { notify(\\\"发布到公众号失败:\\\" + (e && e.message ? e.message : e)); } catch (e2) {}\\n\\t\\t\\ttry { console.log(\\\"[dsh-wechat-publish] handler error\\\", e); } catch (e3) {}\\n\\t\\t}\\n\\t});\\n};\\n\\n})();\\n\"\n },\n \"$:/plugins/dsh/wechat-publish/ui/icon\": {\n \"title\": \"$:/plugins/dsh/wechat-publish/ui/icon\",\n \"tags\": [\n \"$:/tags/Image\"\n ],\n \"text\": \"\\\\parameters (size:\\\"22pt\\\")\\n<svg width=<<size>> height=<<size>> class=\\\"tc-image-wechat-publish tc-image-button\\\" viewBox=\\\"0 0 24 24\\\"><path d=\\\"M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z\\\"/></svg>\\n\"\n },\n \"$:/plugins/dsh/wechat-publish/ui/ViewToolbar/PublishToWechat\": {\n \"title\": \"$:/plugins/dsh/wechat-publish/ui/ViewToolbar/PublishToWechat\",\n \"type\": \"text/vnd.tiddlywiki\",\n \"tags\": [\n \"$:/tags/ViewToolbar\"\n ],\n \"icon\": \"$:/plugins/dsh/wechat-publish/ui/icon\",\n \"caption\": \"发布到公众号\",\n \"description\": \"把当前笔记一键存到微信公众号草稿箱(TiddlyWiki → DSH → 浏览器自动化)\",\n \"text\": \"\\\\whitespace trim\\n<$button message=\\\"dsh-wechat-publish\\\"\\n\\tparam=<<currentTiddler>>\\n\\ttooltip=\\\"把当前笔记存到微信公众号草稿箱\\\"\\n\\taria-label=\\\"把当前笔记存到微信公众号草稿箱\\\"\\n\\tclass=<<tv-config-toolbar-class>>\\n>\\n\\t<%if [<tv-config-toolbar-icons>match[yes]] %>\\n\\t\\t{{$:/plugins/dsh/wechat-publish/ui/icon}}\\n\\t<%endif%>\\n\\t<%if [<tv-config-toolbar-text>match[yes]] %>\\n\\t\\t<span class=\\\"tc-btn-text\\\">\\n\\t\\t<$text text=\\\"发布到公众号\\\"/>\\n\\t</span>\\n\\t<%endif%>\\n</$button>\\n\"\n },\n \"$:/core/ui/ControlPanel/Toolbars/ItemTemplate\": {\n \"title\": \"$:/core/ui/ControlPanel/Toolbars/ItemTemplate\",\n \"type\": \"text/vnd.tiddlywiki\",\n \"text\": \"\\\\define config-title()\\n$(config-base)$$(currentTiddler)$\\n\\\\end\\n\\\\whitespace trim\\n\\n<$draggable tiddler=<<currentTiddler>>>\\n<$checkbox tiddler=<<config-title>> field=\\\"text\\\" checked=\\\"show\\\" unchecked=\\\"hide\\\" default=\\\"show\\\"/>\\n \\n<span class=\\\"tc-icon-wrapper\\\"><$transclude tiddler={{!!icon}}/></span>\\n \\n<$transclude field=\\\"caption\\\"/>\\n -- \\n<i class=\\\"tc-muted\\\"><$transclude field=\\\"description\\\"/></i>\\n</$draggable>\\n\"\n }\n }\n}";
|
|
3932
|
+
const WECHAT_PUBLISH_BUNDLE_TEXT = "{\n \"tiddlers\": {\n \"$:/plugins/dsh/wechat-publish/plugin.info\": {\n \"title\": \"$:/plugins/dsh/wechat-publish/plugin.info\",\n \"type\": \"application/json\",\n \"text\": \"{\\\"title\\\":\\\"$:/plugins/dsh/wechat-publish\\\",\\\"name\\\":\\\"WeChat Publish\\\",\\\"description\\\":\\\"把当前笔记一键存到微信公众号草稿箱(TiddlyWiki → DSH → 浏览器自动化)\\\",\\\"author\\\":\\\"dsh-tiddlywiki\\\",\\\"version\\\":\\\"0.2.0\\\",\\\"plugin-type\\\":\\\"plugin\\\"}\"\n },\n \"$:/plugins/dsh/wechat-publish/startup.js\": {\n \"title\": \"$:/plugins/dsh/wechat-publish/startup.js\",\n \"type\": \"application/javascript\",\n \"module-type\": \"startup\",\n \"text\": \"/*\\\\\\ntitle: $:/plugins/dsh/wechat-publish/startup.js\\ntype: application/javascript\\nmodule-type: startup\\n\\n\\\\*/\\n(function(){\\n\\n/*jslint node: true, browser: true */\\n/*global $tw: false */\\n\\\"use strict\\\";\\n\\nexports.name = \\\"dsh-wechat-publish\\\";\\nexports.after = [\\\"story\\\"];\\nexports.platforms = [\\\"browser\\\"];\\n\\n// The one-shot confirm overlay. Kept as a DOM id (not a closure variable)\\n// because the poller and the overlay are wired together long after the\\n// overlay was built — see publishAndPoll().\\nvar OVERLAY_ID = \\\"dsh-wechat-publish-overlay\\\";\\n// Stop polling after 10 minutes. The browser flow uploads every inlined image\\n// one by one, so a long article is slow, but a job that never settles must not\\n// keep a 2s timer alive for the rest of the page's life.\\nvar POLL_INTERVAL_MS = 2000;\\nvar POLL_TIMEOUT_MS = 10 * 60 * 1000;\\n// How much of job.log to show (a full adapter run can log megabytes).\\nvar LOG_TAIL_CHARS = 2000;\\n\\n// Mirrors the host's DEFAULT_ADAPTER. `wechat.enabled` is false by default:\\n// browser automation against the 公众号 backend is an OPT-IN feature that\\n// needs opencli + the Browser Bridge extension installed separately.\\nfunction readConfig() {\\n\\tvar config = { enabled: false, endpoint: \\\"\\\", token: \\\"\\\", adapter: \\\"publish-note\\\" };\\n\\ttry {\\n\\t\\tvar t = $tw.wiki.getTiddler(\\\"$:/plugins/dsh-tiddlywiki/config\\\");\\n\\t\\tif (t && t.fields && typeof t.fields.text === \\\"string\\\") {\\n\\t\\t\\tvar parsed = JSON.parse(t.fields.text);\\n\\t\\t\\tvar wechat = parsed && parsed.wechat;\\n\\t\\t\\tif (wechat) {\\n\\t\\t\\t\\tif (typeof wechat.enabled === \\\"boolean\\\") { config.enabled = wechat.enabled; }\\n\\t\\t\\t\\tif (typeof wechat.endpoint === \\\"string\\\" && wechat.endpoint.length > 0) { config.endpoint = wechat.endpoint; }\\n\\t\\t\\t\\tif (typeof wechat.token === \\\"string\\\" && wechat.token.length > 0) { config.token = wechat.token; }\\n\\t\\t\\t\\tif (wechat.adapter === \\\"publish-note\\\" || wechat.adapter === \\\"publish-note-imgs\\\") { config.adapter = wechat.adapter; }\\n\\t\\t\\t}\\n\\t\\t}\\n\\t} catch (e) {}\\n\\treturn config;\\n}\\n\\nfunction baseEndpoint() {\\n\\tvar config = readConfig();\\n\\tif (config.endpoint.length > 0) { return config.endpoint.replace(/\\\\/+$/, \\\"\\\"); }\\n\\tif (typeof location !== \\\"undefined\\\" && location.origin) { return location.origin + \\\"/dsh-tiddlywiki\\\"; }\\n\\treturn \\\"/dsh-tiddlywiki\\\";\\n}\\n\\nfunction authHeaders() {\\n\\tvar headers = { \\\"Content-Type\\\": \\\"application/json\\\" };\\n\\tvar config = readConfig();\\n\\t// Must match the host's readToken(): the token only travels in this header,\\n\\t// never in the URL, so it cannot leak through history/referer/proxy logs.\\n\\tif (config.token.length > 0) { headers[\\\"x-wechat-publish-token\\\"] = config.token; }\\n\\treturn headers;\\n}\\n\\n/*\\nShow a transient message.\\n\\nTW's notifier renders a TIDDLER by title and does **nothing at all** when that\\ntiddler does not exist (core/modules/utils/dom/notifier.js: \\\"Don't do anything\\nif the tiddler doesn't exist\\\"). Passing free text as the title — the mistake\\nthe 「发送给 Agent」 button shipped with — makes every notice a silent no-op.\\nStore the message in a $:/temp tiddler first (volatile: never synced, never\\nwritten to disk) and display THAT title.\\n*/\\nvar NOTICE_TITLE = \\\"$:/temp/dsh/wechat-publish/notice\\\";\\nfunction notify(msg) {\\n\\ttry {\\n\\t\\tif ($tw.wiki && $tw.notifier && typeof $tw.notifier.display === \\\"function\\\" && typeof $tw.wiki.addTiddler === \\\"function\\\") {\\n\\t\\t\\t$tw.wiki.addTiddler({ title: NOTICE_TITLE, text: String(msg), type: \\\"text/vnd.tiddlywiki\\\" });\\n\\t\\t\\t$tw.notifier.display(NOTICE_TITLE);\\n\\t\\t\\treturn;\\n\\t\\t}\\n\\t} catch (e) {}\\n\\tif (typeof alert === \\\"function\\\") { alert(msg); }\\n}\\n\\n/*\\nParse a response body. The host answers every failure with a JSON body AND a\\nnon-2xx status, and $tw.utils.httpRequest reports those as\\n`callback(\\\"Error/XMLHttpRequest: 403\\\", body, xhr)` — i.e. err is a localized\\nstring and the body is still handed over. So parse first, then decide: the\\nbody's `error` is the readable truth, err is the fallback.\\n*/\\nfunction parseJob(data) {\\n\\ttry { return JSON.parse(data || \\\"\\\"); } catch (e) { return null; }\\n}\\nfunction failText(err, parsed, data, fallback) {\\n\\tif (parsed && parsed.error) { return String(parsed.error); }\\n\\tif (err) { return String(err); }\\n\\tif (data) { return String(data); }\\n\\treturn fallback || \\\"未知错误\\\";\\n}\\n\\nfunction closeOverlay() {\\n\\tvar overlay = document.getElementById(OVERLAY_ID);\\n\\tif (overlay) {\\n\\t\\tif (typeof overlay.__dshStopPolling === \\\"function\\\") { overlay.__dshStopPolling(); }\\n\\t\\tif (overlay.parentNode) { overlay.parentNode.removeChild(overlay); }\\n\\t}\\n}\\n\\nfunction makeBox() {\\n\\tvar box = document.createElement(\\\"div\\\");\\n\\tbox.setAttribute(\\\"style\\\", \\\"background:#fff;color:#333;border-radius:8px;padding:16px;max-width:520px;width:92vw;max-height:82vh;overflow:auto;box-shadow:0 8px 30px rgba(0,0,0,.3);font-family:system-ui,-apple-system,sans-serif;font-size:13px;line-height:1.6;\\\");\\n\\treturn box;\\n}\\n\\nfunction makeRow(parent, weight, text, color) {\\n\\tvar el = document.createElement(weight === \\\"h\\\" ? \\\"h3\\\" : \\\"p\\\");\\n\\tel.setAttribute(\\\"style\\\", (weight === \\\"h\\\" ? \\\"margin:0 0 8px;font-size:15px;\\\" : \\\"margin:0 0 8px;\\\") + (color ? \\\"color:\\\" + color + \\\";\\\" : \\\"\\\") + \\\"word-break:break-all;\\\");\\n\\tel.textContent = text;\\n\\tparent.appendChild(el);\\n\\treturn el;\\n}\\n\\nfunction makeButton(label, primary) {\\n\\tvar b = document.createElement(\\\"button\\\");\\n\\tb.type = \\\"button\\\";\\n\\tb.textContent = label;\\n\\tb.setAttribute(\\\"style\\\", primary\\n\\t\\t? \\\"padding:7px 16px;border:1px solid #4a90d9;border-radius:6px;background:#4a90d9;color:#fff;cursor:pointer;font-size:13px;\\\"\\n\\t\\t: \\\"padding:7px 16px;border:1px solid #ccc;border-radius:6px;background:#fff;cursor:pointer;font-size:13px;color:#444;\\\");\\n\\treturn b;\\n}\\n\\nfunction setDisabled(btn, disabled) {\\n\\tbtn.disabled = !!disabled;\\n\\tbtn.style.opacity = disabled ? \\\"0.5\\\" : \\\"1\\\";\\n\\tbtn.style.cursor = disabled ? \\\"default\\\" : \\\"pointer\\\";\\n}\\n\\n/*\\nBuild the confirm overlay. Returns the box plus the pieces the caller mutates:\\n {overlay, box, status, actions, logs, logPre}\\nOnly ever one overlay at a time (a second click replaces the first), and Esc /\\nbackdrop click / ✕ all funnel through closeOverlay() so the poller is stopped.\\n*/\\nfunction createOverlay(title) {\\n\\tvar previous = document.getElementById(OVERLAY_ID);\\n\\tif (previous) { closeOverlay(); }\\n\\n\\tvar overlay = document.createElement(\\\"div\\\");\\n\\toverlay.id = OVERLAY_ID;\\n\\toverlay.setAttribute(\\\"style\\\", \\\"position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(0,0,0,.35);z-index:99999;display:flex;align-items:center;justify-content:center;\\\");\\n\\tvar box = makeBox();\\n\\n\\tvar header = document.createElement(\\\"div\\\");\\n\\theader.setAttribute(\\\"style\\\", \\\"display:flex;align-items:center;justify-content:space-between;gap:8px;\\\");\\n\\tvar h = document.createElement(\\\"h3\\\");\\n\\th.setAttribute(\\\"style\\\", \\\"margin:0;font-size:15px;\\\");\\n\\th.textContent = \\\"发布到公众号 · \\\" + title;\\n\\tvar closeX = document.createElement(\\\"button\\\");\\n\\tcloseX.type = \\\"button\\\";\\n\\tcloseX.textContent = \\\"✕\\\";\\n\\tcloseX.title = \\\"关闭(停止轮询)\\\";\\n\\tcloseX.setAttribute(\\\"style\\\", \\\"border:none;background:transparent;font-size:15px;cursor:pointer;color:#888;padding:2px 8px;border-radius:4px;\\\");\\n\\tcloseX.addEventListener(\\\"click\\\", function() { closeOverlay(); });\\n\\theader.appendChild(h);\\n\\theader.appendChild(closeX);\\n\\tbox.appendChild(header);\\n\\n\\tvar status = document.createElement(\\\"div\\\");\\n\\tstatus.setAttribute(\\\"style\\\", \\\"margin:6px 0 10px;white-space:pre-wrap;\\\");\\n\\tbox.appendChild(status);\\n\\n\\tvar logs = document.createElement(\\\"div\\\");\\n\\tlogs.setAttribute(\\\"style\\\", \\\"margin:8px 0 0;\\\");\\n\\tvar logCaption = document.createElement(\\\"p\\\");\\n\\tlogCaption.setAttribute(\\\"style\\\", \\\"margin:0 0 4px;font-size:12px;color:#888;\\\");\\n\\tlogCaption.textContent = \\\"运行日志(最近 \\\" + LOG_TAIL_CHARS + \\\" 字符):\\\";\\n\\tvar logPre = document.createElement(\\\"pre\\\");\\n\\tlogPre.setAttribute(\\\"style\\\", \\\"margin:0;max-height:32vh;overflow:auto;background:#1e1e1e;color:#ddd;border-radius:6px;padding:8px;font-size:11px;white-space:pre-wrap;word-break:break-all;\\\");\\n\\tlogs.appendChild(logCaption);\\n\\tlogs.appendChild(logPre);\\n\\tlogs.style.display = \\\"none\\\";\\n\\tbox.appendChild(logs);\\n\\n\\tvar actions = document.createElement(\\\"div\\\");\\n\\tactions.setAttribute(\\\"style\\\", \\\"display:flex;justify-content:flex-end;gap:8px;margin-top:14px;flex-wrap:wrap;\\\");\\n\\tbox.appendChild(actions);\\n\\n\\toverlay.appendChild(box);\\n\\tdocument.body.appendChild(overlay);\\n\\n\\toverlay.addEventListener(\\\"click\\\", function(e) { if (e.target === overlay) { closeOverlay(); } });\\n\\tvar escHandler = function(e) { if (e.key === \\\"Escape\\\") { closeOverlay(); } };\\n\\tdocument.addEventListener(\\\"keydown\\\", escHandler);\\n\\t// The Esc listener lived on `document`: it must die with the overlay,\\n\\t// otherwise every open/close cycle leaks one keydown handler.\\n\\toverlay.__dshCleanup = function() { document.removeEventListener(\\\"keydown\\\", escHandler); };\\n\\treturn { overlay: overlay, box: box, status: status, actions: actions, logs: logs, logPre: logPre };\\n}\\n\\n/*\\nThe plain-text confirm body. The「只存草稿」/「需要扫码」/「复用已登录浏览器」\\nsentences are the contract of the whole feature (v0.23.2 起策略就是「到草稿箱为止」):\\nusers must not expect a one-click public post, and must know their session is\\nreused rather than a stored credential.\\n*/\\nfunction confirmationText(title) {\\n\\treturn \\\"笔记:《\\\" + title + \\\"》\\\\n\\\\n\\\"\\n\\t\\t+ \\\"只存到草稿箱,不会自动发表;发表需你在浏览器里扫码。\\\\n\\\"\\n\\t\\t+ \\\"脚本会复用你已登录的浏览器(不保存任何凭据)。\\\\n\\\"\\n\\t\\t+ \\\"首次运行可能需要一两分钟(图片要逐张上传)。\\\";\\n}\\n\\n/*\\nA warning row for notes the 发布元数据规范 marks as off-limits. Read from the\\ntiddler's custom FIELDS (not tags alone): `pub-state: excluded` and the\\n`no-publish` tag are the two mirrors the spec keeps in sync. This only warns —\\nthe host does not block (see docs: 只告警不阻断), so the user stays in control.\\n*/\\nfunction publishWarnings(title) {\\n\\tvar tiddler = $tw.wiki.getTiddler(title);\\n\\tvar fields = (tiddler && tiddler.fields) || {};\\n\\tvar tags = fields.tags || [];\\n\\tvar state = fields[\\\"pub-state\\\"] ? String(fields[\\\"pub-state\\\"]) : \\\"\\\";\\n\\tvar warnings = [];\\n\\tif (state === \\\"published\\\") { warnings.push(\\\"这篇笔记的 pub-state 是 published —— 可能已经发表过,请确认不要重复发布。\\\"); }\\n\\tif (state === \\\"excluded\\\") { warnings.push(\\\"这篇笔记的 pub-state 是 excluded —— 规范里标为「明确不发」。\\\"); }\\n\\tif (tags.indexOf(\\\"no-publish\\\") !== -1) { warnings.push(\\\"这篇笔记带着 no-publish 标签 —— 规范里标为「不可发布」。\\\"); }\\n\\treturn warnings;\\n}\\n\\nfunction showError(title, msg, silent) {\\n\\tvar ui = createOverlay(title);\\n\\tui.overlay.__dshCleanup();\\n\\tmakeRow(ui.status, \\\"p\\\", \\\"⚠️ \\\" + msg, \\\"#c0392b\\\");\\n\\tvar cancel = makeButton(\\\"关闭\\\", false);\\n\\tcancel.addEventListener(\\\"click\\\", function() { closeOverlay(); });\\n\\tui.actions.appendChild(cancel);\\n\\t// `silent` for the callers that already fire their own toast — one failure\\n\\t// must produce exactly one notification, not two.\\n\\tif (!silent) { notify(\\\"发布到公众号失败:\\\" + msg); }\\n}\\n\\n/*\\nGET /wechat/ready. The endpoint answers 200 with `ok:true` whenever the feature\\nis on — an INSTALLATION problem is deliberately not an HTTP error (see the host\\nroute), it is reported inside the body as `opencli.ok=false` / `adapters.missing`.\\nSo `parsed.ok` alone must never be read as \\\"ready\\\": gating on it would skip both\\nchecks and let the user confirm a publish that is guaranteed to fail 503.\\nReturns an actionable message for a CONCRETE problem, or null otherwise.\\n\\nThis was measured, not guessed: with a body of\\n`{ok:true, adapters:{publishNote:false,…}}` an earlier draft still rendered the\\nconfirm dialog, because the readiness check was keyed off `parsed.ok`.\\n*/\\nfunction readinessProblem(parsed, adapter) {\\n\\tif (!parsed) { return null; }\\n\\tif (parsed.enabled === false) {\\n\\t\\treturn \\\"微信发布未启用:请在 DSH 设置 →「TiddlyWiki 知识库」→「可选功能:微信公众号发布」勾选并保存。\\\";\\n\\t}\\n\\tvar ready = parsed.ready || parsed;\\n\\tif (ready.opencli && ready.opencli.ok === false) {\\n\\t\\treturn \\\"未检测到可用的 opencli —— 发布依赖它驱动浏览器。\\\\n请先按 docs/wechat-publish-setup.md 安装 opencli,然后运行:\\\\nnode <插件目录>/tools/wechat/install-wechat-adapters.mjs\\\";\\n\\t}\\n\\tvar adapters = ready.adapters;\\n\\tif (!adapters) { return null; }\\n\\tvar missing = Array.isArray(adapters.missing) ? adapters.missing : [];\\n\\t// v0.23.4:`stale` = 文件都在但**版本过旧**(旧版 adapter 不认宿主的\\n\\t// `--title-file`,点了按钮才会在 opencli 里报「缺少必填参数」)。措辞必须\\n\\t// 与「缺失」分开,否则用户会去重装一遍同样的旧文件。\\n\\tvar stale = Array.isArray(adapters.stale) ? adapters.stale : [];\\n\\tvar installHint = \\\"\\\\n请运行(路径相对插件目录,npm 安装时是 node_modules/dsh-tiddlywiki):\\\\n\\\"\\n\\t\\t+ \\\"node <插件目录>/tools/wechat/install-wechat-adapters.mjs\\\";\\n\\tif (stale.length > 0) {\\n\\t\\treturn \\\"发布脚本(adapter)版本过旧:\\\" + stale.join(\\\"、\\\") + \\\"。\\\\n\\\"\\n\\t\\t\\t+ \\\"旧版不认识宿主的 --title-file(标题走文件、不进命令行),直接发布会在 opencli 里报「缺少必填参数」。\\\"\\n\\t\\t\\t+ installHint;\\n\\t}\\n\\t// Only the selected adapter matters: a wiki can publish plain notes long\\n\\t// before the multi-image adapter is installed.\\n\\tvar needed = adapter === \\\"publish-note-imgs\\\" ? \\\"publishNoteImgs\\\" : \\\"publishNote\\\";\\n\\tif (adapters[needed] === false || missing.length > 0) {\\n\\t\\treturn \\\"缺少发布脚本(adapter):\\\" + (missing.length > 0 ? missing.join(\\\"、\\\") : needed) + \\\"。\\\"\\n\\t\\t\\t+ installHint;\\n\\t}\\n\\treturn null;\\n}\\n\\n/** The concrete problem, or the transport error / host message when there is none. */\\nfunction describeNotReady(parsed, err, data, adapter) {\\n\\tvar problem = readinessProblem(parsed, adapter);\\n\\tif (problem !== null) { return problem; }\\n\\treturn \\\"无法检查发布环境:\\\" + failText(err, parsed, data, \\\"未知错误\\\");\\n}\\n\\n/*\\nPOST /wechat/publish, then poll GET /wechat/publish/status?id=… every 2s until\\nthe job settles. A 409 (another job running) is surfaced verbatim — the host\\nalready names the running jobId, which is what the user needs to go look at it.\\n*/\\nfunction publishAndPoll(title, adapter) {\\n\\tvar ui = createOverlay(title);\\n\\tsetDisabled(ui.actions, true);\\n\\tui.actions.innerHTML = \\\"\\\";\\n\\tmakeRow(ui.status, \\\"p\\\", \\\"正在提交发布任务…\\\", \\\"#555\\\");\\n\\n\\tvar stopped = false;\\n\\tvar timer = null;\\n\\tfunction stopPolling() {\\n\\t\\tstopped = true;\\n\\t\\tif (timer !== null) { clearInterval(timer); timer = null; }\\n\\t}\\n\\t// closeOverlay() calls this through the overlay node, so EVERY close path\\n\\t// (✕ / Esc / backdrop / programmatic) stops the 2s polling loop.\\n\\tui.overlay.__dshStopPolling = function() {\\n\\t\\tstopPolling();\\n\\t\\tui.overlay.__dshCleanup();\\n\\t};\\n\\n\\tfunction addCloseButton(onClose) {\\n\\t\\tvar close = makeButton(\\\"关闭\\\", false);\\n\\t\\tclose.addEventListener(\\\"click\\\", function() { closeOverlay(); if (onClose) { onClose(); } });\\n\\t\\tui.actions.appendChild(close);\\n\\t}\\n\\n\\tfunction renderJob(job) {\\n\\t\\tvar lines = [];\\n\\t\\tif (job.state === \\\"running\\\") { lines.push(\\\"⏳ 正在发布…\\\"); }\\n\\t\\telse if (job.state === \\\"ok\\\") { lines.push(\\\"✅ \\\" + (job.message || \\\"已存到公众号草稿箱\\\")); }\\n\\t\\telse if (job.state === \\\"error\\\") { lines.push(\\\"❌ 发布失败\\\" + (job.exitCode !== undefined ? \\\"(exit \\\" + job.exitCode + \\\")\\\" : \\\"\\\")); }\\n\\t\\telse { lines.push(\\\"状态:\\\" + String(job.state || \\\"未知\\\")); }\\n\\t\\tif (job.message && job.state !== \\\"ok\\\") { lines.push(String(job.message)); }\\n\\t\\tif (job.state === \\\"ok\\\") {\\n\\t\\t\\tlines.push(\\\"\\\");\\n\\t\\t\\tlines.push(\\\"建议回写发布元数据(用 tiddlywiki_put 补 fields,别动 tags):\\\");\\n\\t\\t\\tlines.push(\\\"pub-state: published · pub-platform: wechat · pub-wechat-at: <发表时间>\\\");\\n\\t\\t\\tlines.push(\\\"若实际发表时间/标题与本文不同,再补 pub-wechat-title / pub-wechat-url。\\\");\\n\\t\\t}\\n\\t\\tui.status.textContent = lines.join(\\\"\\\\n\\\");\\n\\t\\tif (job.log) {\\n\\t\\t\\tui.logs.style.display = \\\"\\\";\\n\\t\\t\\tui.logPre.textContent = String(job.log).slice(-LOG_TAIL_CHARS);\\n\\t\\t\\tui.logPre.scrollTop = ui.logPre.scrollHeight;\\n\\t\\t}\\n\\t}\\n\\n\\tfunction poll(jobId) {\\n\\t\\t$tw.utils.httpRequest({\\n\\t\\t\\turl: baseEndpoint() + \\\"/wechat/publish/status?id=\\\" + encodeURIComponent(jobId),\\n\\t\\t\\ttype: \\\"GET\\\",\\n\\t\\t\\theaders: authHeaders(),\\n\\t\\t\\tcallback: function(err, data) {\\n\\t\\t\\t\\tif (stopped) { return; }\\n\\t\\t\\t\\tvar parsed = parseJob(data);\\n\\t\\t\\t\\tif (err || !parsed || !parsed.ok || !parsed.job) {\\n\\t\\t\\t\\t\\t// A transient poll failure must not end the job: show it and\\n\\t\\t\\t\\t\\t// keep polling — the job is still running on the host.\\n\\t\\t\\t\\t\\tmakeRow(ui.status, \\\"p\\\", \\\"⚠️ 查询状态失败:\\\" + failText(err, parsed, data, \\\"未知错误\\\") + \\\"(继续重试…)\\\", \\\"#c0392b\\\");\\n\\t\\t\\t\\t\\treturn;\\n\\t\\t\\t\\t}\\n\\t\\t\\t\\tvar job = parsed.job;\\n\\t\\t\\t\\trenderJob(job);\\n\\t\\t\\t\\tif (job.state === \\\"ok\\\" || job.state === \\\"error\\\") {\\n\\t\\t\\t\\t\\tstopPolling();\\n\\t\\t\\t\\t\\tui.overlay.__dshCleanup();\\n\\t\\t\\t\\t\\tui.actions.innerHTML = \\\"\\\";\\n\\t\\t\\t\\t\\taddCloseButton();\\n\\t\\t\\t\\t\\t// Sync a one-shot toast: the overlay may be closed (or the\\n\\t\\t\\t\\t\\t// TW panel hidden) before the user reads it.\\n\\t\\t\\t\\t\\tnotify(job.state === \\\"ok\\\"\\n\\t\\t\\t\\t\\t\\t? \\\"已存到公众号草稿箱 ✓《\\\" + title + \\\"》\\\"\\n\\t\\t\\t\\t\\t\\t: \\\"发布到公众号失败:《\\\" + title + \\\"》\\\" + (job.message ? \\\"(\\\" + job.message + \\\")\\\" : \\\"\\\"));\\n\\t\\t\\t\\t}\\n\\t\\t\\t}\\n\\t\\t});\\n\\t}\\n\\n\\t$tw.utils.httpRequest({\\n\\t\\turl: baseEndpoint() + \\\"/wechat/publish\\\",\\n\\t\\ttype: \\\"POST\\\",\\n\\t\\theaders: authHeaders(),\\n\\t\\tdata: JSON.stringify({ title: title, adapter: adapter }),\\n\\t\\tcallback: function(err, data) {\\n\\t\\t\\tif (stopped) { return; }\\n\\t\\t\\tvar parsed = parseJob(data);\\n\\t\\t\\tif (err || !parsed || !parsed.ok) {\\n\\t\\t\\t\\t// The 503 body carries a `ready` snapshot (opencli + adapters)\\n\\t\\t\\t\\t// whose install advice is more useful than the raw error; for\\n\\t\\t\\t\\t// every other failure (400/401/403/409) the host's own `error`\\n\\t\\t\\t\\t// is already the actionable text, so it is kept as-is.\\n\\t\\t\\t\\tvar advice = readinessProblem(parsed, adapter);\\n\\t\\t\\t\\tvar detail = advice !== null\\n\\t\\t\\t\\t\\t? advice + \\\"\\\\n\\\" + failText(err, parsed, data, \\\"未知错误\\\")\\n\\t\\t\\t\\t\\t: failText(err, parsed, data, \\\"未知错误\\\");\\n\\t\\t\\t\\tstopPolling();\\n\\t\\t\\t\\tui.overlay.__dshCleanup();\\n\\t\\t\\t\\tui.actions.innerHTML = \\\"\\\";\\n\\t\\t\\t\\tif (parsed && parsed.jobId) { detail += \\\"\\\\n(正在运行的任务:\\\" + parsed.jobId + \\\")\\\"; }\\n\\t\\t\\t\\tmakeRow(ui.status, \\\"p\\\", \\\"⚠️ \\\" + detail, \\\"#c0392b\\\");\\n\\t\\t\\t\\taddCloseButton();\\n\\t\\t\\t\\tnotify(\\\"发布到公众号失败:\\\" + failText(err, parsed, data, \\\"未知错误\\\"));\\n\\t\\t\\t\\treturn;\\n\\t\\t\\t}\\n\\t\\t\\tvar jobId = parsed.jobId;\\n\\t\\t\\tmakeRow(ui.status, \\\"p\\\", \\\"任务已提交(\\\" + jobId + \\\"),每 2 秒查询进度…\\\", \\\"#555\\\");\\n\\t\\t\\taddCloseButton();\\n\\t\\t\\tpoll(jobId);\\n\\t\\t\\ttimer = setInterval(function() { poll(jobId); }, POLL_INTERVAL_MS);\\n\\t\\t\\t// Hard stop: the host has its own timeouts, but the page must not\\n\\t\\t\\t// poll forever if a job somehow never settles.\\n\\t\\t\\tsetTimeout(function() {\\n\\t\\t\\t\\tif (!stopped && timer !== null) {\\n\\t\\t\\t\\t\\tstopPolling();\\n\\t\\t\\t\\t\\tmakeRow(ui.status, \\\"p\\\", \\\"⚠️ 超过 10 分钟仍未结束,已停止查询。请到公众号后台确认草稿是否已保存。\\\", \\\"#c0392b\\\");\\n\\t\\t\\t\\t}\\n\\t\\t\\t}, POLL_TIMEOUT_MS);\\n\\t\\t}\\n\\t});\\n}\\n\\nfunction checkReadyThenConfirm(title, adapter) {\\n\\tvar ui = createOverlay(title);\\n\\tsetDisabled(ui.actions, true);\\n\\tui.actions.innerHTML = \\\"\\\";\\n\\tmakeRow(ui.status, \\\"p\\\", \\\"正在检查发布环境…\\\", \\\"#555\\\");\\n\\n\\t$tw.utils.httpRequest({\\n\\t\\turl: baseEndpoint() + \\\"/wechat/ready\\\",\\n\\t\\ttype: \\\"GET\\\",\\n\\t\\theaders: authHeaders(),\\n\\t\\tcallback: function(err, data) {\\n\\t\\t\\tvar parsed = parseJob(data);\\n\\t\\t\\t// `parsed.ok` is NOT the readiness signal (see readinessProblem):\\n\\t\\t\\t// the endpoint returns ok:true even when opencli/adapters are\\n\\t\\t\\t// missing, and reports those inside the body.\\n\\t\\t\\tvar problem = readinessProblem(parsed, adapter);\\n\\t\\t\\tif (problem !== null) {\\n\\t\\t\\t\\tcloseOverlay();\\n\\t\\t\\t\\t// The overlay shows the fix; the toast mirrors it once.\\n\\t\\t\\t\\tshowError(title, problem);\\n\\t\\t\\t\\treturn;\\n\\t\\t\\t}\\n\\t\\t\\t// Ready → rebuild the overlay as the confirm dialog.\\n\\t\\t\\tcloseOverlay();\\n\\t\\t\\tvar ui2 = createOverlay(title);\\n\\t\\t\\tmakeRow(ui2.status, \\\"p\\\", confirmationText(title), \\\"#444\\\");\\n\\t\\t\\tpublishWarnings(title).forEach(function(w) {\\n\\t\\t\\t\\tmakeRow(ui2.status, \\\"p\\\", \\\"⚠️ \\\" + w, \\\"#c0392b\\\");\\n\\t\\t\\t});\\n\\t\\t\\tvar cancel = makeButton(\\\"取消\\\", false);\\n\\t\\t\\tcancel.addEventListener(\\\"click\\\", function() { closeOverlay(); });\\n\\t\\t\\tvar go = makeButton(\\\"开始存草稿\\\", true);\\n\\t\\t\\tgo.addEventListener(\\\"click\\\", function() { closeOverlay(); publishAndPoll(title, adapter); });\\n\\t\\t\\tui2.actions.appendChild(cancel);\\n\\t\\t\\tui2.actions.appendChild(go);\\n\\t\\t}\\n\\t});\\n}\\n\\nfunction handlePublish(title) {\\n\\t// Whole handler is wrapped by the caller: a throw inside a startup module\\n\\t// aborts the rest of TW's startup (other plugins' modules never run).\\n\\tif (!title) { notify(\\\"无法确定当前笔记标题\\\"); return; }\\n\\tvar tiddler = $tw.wiki.getTiddler(title);\\n\\tif (!tiddler) { notify(\\\"找不到笔记:\\\" + title); return; }\\n\\tvar config = readConfig();\\n\\tif (!config.enabled) {\\n\\t\\tnotify(\\\"「发布到公众号」未启用:请在 DSH 设置 →「TiddlyWiki 知识库」→「微信公众号发布」勾选并保存后重试。\\\");\\n\\t\\treturn;\\n\\t}\\n\\tcheckReadyThenConfirm(title, config.adapter);\\n}\\n\\nexports.startup = function() {\\n\\tconsole.log(\\\"[dsh-wechat-publish] startup ran\\\");\\n\\tif (!$tw.rootWidget || typeof $tw.rootWidget.addEventListener !== \\\"function\\\") { return; }\\n\\t$tw.rootWidget.addEventListener(\\\"dsh-wechat-publish\\\", function(event) {\\n\\t\\ttry {\\n\\t\\t\\thandlePublish(event.param);\\n\\t\\t} catch (e) {\\n\\t\\t\\t// Never let a startup-module exception escape: TW runs every startup\\n\\t\\t\\t// module in one loop, so a throw here breaks unrelated plugins.\\n\\t\\t\\ttry { notify(\\\"发布到公众号失败:\\\" + (e && e.message ? e.message : e)); } catch (e2) {}\\n\\t\\t\\ttry { console.log(\\\"[dsh-wechat-publish] handler error\\\", e); } catch (e3) {}\\n\\t\\t}\\n\\t});\\n};\\n\\n})();\\n\"\n },\n \"$:/plugins/dsh/wechat-publish/ui/icon\": {\n \"title\": \"$:/plugins/dsh/wechat-publish/ui/icon\",\n \"tags\": [\n \"$:/tags/Image\"\n ],\n \"text\": \"\\\\parameters (size:\\\"22pt\\\")\\n<svg width=<<size>> height=<<size>> class=\\\"tc-image-wechat-publish tc-image-button\\\" viewBox=\\\"0 0 24 24\\\"><path d=\\\"M3.478 2.404a.75.75 0 0 0-.926.941l2.432 7.905H13.5a.75.75 0 0 1 0 1.5H4.984l-2.432 7.905a.75.75 0 0 0 .926.94 60.519 60.519 0 0 0 18.445-8.986.75.75 0 0 0 0-1.218A60.517 60.517 0 0 0 3.478 2.404Z\\\"/></svg>\\n\"\n },\n \"$:/plugins/dsh/wechat-publish/ui/ViewToolbar/PublishToWechat\": {\n \"title\": \"$:/plugins/dsh/wechat-publish/ui/ViewToolbar/PublishToWechat\",\n \"type\": \"text/vnd.tiddlywiki\",\n \"tags\": [\n \"$:/tags/ViewToolbar\"\n ],\n \"icon\": \"$:/plugins/dsh/wechat-publish/ui/icon\",\n \"caption\": \"发布到公众号\",\n \"description\": \"把当前笔记一键存到微信公众号草稿箱(TiddlyWiki → DSH → 浏览器自动化)\",\n \"text\": \"\\\\whitespace trim\\n<$button message=\\\"dsh-wechat-publish\\\"\\n\\tparam=<<currentTiddler>>\\n\\ttooltip=\\\"把当前笔记存到微信公众号草稿箱\\\"\\n\\taria-label=\\\"把当前笔记存到微信公众号草稿箱\\\"\\n\\tclass=<<tv-config-toolbar-class>>\\n>\\n\\t<%if [<tv-config-toolbar-icons>match[yes]] %>\\n\\t\\t{{$:/plugins/dsh/wechat-publish/ui/icon}}\\n\\t<%endif%>\\n\\t<%if [<tv-config-toolbar-text>match[yes]] %>\\n\\t\\t<span class=\\\"tc-btn-text\\\">\\n\\t\\t<$text text=\\\"发布到公众号\\\"/>\\n\\t</span>\\n\\t<%endif%>\\n</$button>\\n\"\n },\n \"$:/core/ui/ControlPanel/Toolbars/ItemTemplate\": {\n \"title\": \"$:/core/ui/ControlPanel/Toolbars/ItemTemplate\",\n \"type\": \"text/vnd.tiddlywiki\",\n \"text\": \"\\\\define config-title()\\n$(config-base)$$(currentTiddler)$\\n\\\\end\\n\\\\whitespace trim\\n\\n<$draggable tiddler=<<currentTiddler>>>\\n<$checkbox tiddler=<<config-title>> field=\\\"text\\\" checked=\\\"show\\\" unchecked=\\\"hide\\\" default=\\\"show\\\"/>\\n \\n<span class=\\\"tc-icon-wrapper\\\"><$transclude tiddler={{!!icon}}/></span>\\n \\n<$transclude field=\\\"caption\\\"/>\\n -- \\n<i class=\\\"tc-muted\\\"><$transclude field=\\\"description\\\"/></i>\\n</$draggable>\\n\"\n }\n }\n}";
|
|
3760
3933
|
/**
|
|
3761
3934
|
* Seed the「发布到公众号」TW button exactly once per wiki (mirrors the
|
|
3762
3935
|
* send-to-agent one-shot policy). The marker records the offer; afterwards the
|
|
@@ -3783,7 +3956,7 @@ async function seedWechatPublish(client, opts) {
|
|
|
3783
3956
|
"plugin-type": "plugin",
|
|
3784
3957
|
name: "WeChat Publish",
|
|
3785
3958
|
author: "dsh-tiddlywiki",
|
|
3786
|
-
version: "0.
|
|
3959
|
+
version: "0.2.0",
|
|
3787
3960
|
description: "把当前笔记一键存到微信公众号草稿箱(TiddlyWiki → DSH → 浏览器自动化)"
|
|
3788
3961
|
});
|
|
3789
3962
|
wrote = true;
|
|
@@ -3874,12 +4047,37 @@ function deepMerge(base, over) {
|
|
|
3874
4047
|
return out;
|
|
3875
4048
|
}
|
|
3876
4049
|
/**
|
|
4050
|
+
* The config tiddler EXISTS but cannot be understood (v0.23.4).
|
|
4051
|
+
*
|
|
4052
|
+
* `set()` used to fall back to the in-memory cache when the stored text failed
|
|
4053
|
+
* to parse — and right after a failed `load()` that cache is EMPTY, so a single
|
|
4054
|
+
* settings-page save silently replaced the whole config with just the fields
|
|
4055
|
+
* the form happened to send. That is exactly what happened on 2026-09-18 after
|
|
4056
|
+
* a leftover `<<<<<<<` conflict block (committed by the auto-committer) made
|
|
4057
|
+
* the tiddler unparseable: `prompt.extra` / `git.remote` / `ui.*` were wiped.
|
|
4058
|
+
* Saving is now REFUSED instead, and the reason is surfaced to the user.
|
|
4059
|
+
*/
|
|
4060
|
+
var ConfigUnreadableError = class extends Error {
|
|
4061
|
+
raw;
|
|
4062
|
+
constructor(message, raw) {
|
|
4063
|
+
super(message);
|
|
4064
|
+
this.raw = raw;
|
|
4065
|
+
this.name = "ConfigUnreadableError";
|
|
4066
|
+
}
|
|
4067
|
+
};
|
|
4068
|
+
/** User-facing explanation for a refused save (kept next to the config tiddler name). */
|
|
4069
|
+
function describeUnreadableConfig() {
|
|
4070
|
+
return `配置 tiddler ${CONFIG_TIDDLER} 存在但不是合法 JSON(常见原因:git 冲突标记 <<<<<<< 残留,或手工编辑出错)。为避免抹掉其它设置,本次保存已被拒绝。请先修好该 tiddler(或删除它、回落到 cordis config 块)再保存。`;
|
|
4071
|
+
}
|
|
4072
|
+
/**
|
|
3877
4073
|
* Runtime config store: caches the override tiddler and exposes the effective
|
|
3878
4074
|
* (merged) config. `load` runs at startup and after every write/restart.
|
|
3879
4075
|
*/
|
|
3880
4076
|
var ConfigStore = class {
|
|
3881
4077
|
base;
|
|
3882
4078
|
overrides = {};
|
|
4079
|
+
/** Set when the stored tiddler exists but cannot be parsed (v0.23.4). */
|
|
4080
|
+
lastParseError;
|
|
3883
4081
|
constructor(base) {
|
|
3884
4082
|
this.base = base;
|
|
3885
4083
|
}
|
|
@@ -3888,6 +4086,14 @@ var ConfigStore = class {
|
|
|
3888
4086
|
return deepMerge(this.base, this.overrides);
|
|
3889
4087
|
}
|
|
3890
4088
|
/**
|
|
4089
|
+
* Why the stored config is unusable, or undefined when everything is fine.
|
|
4090
|
+
* `/admin/state` exposes this so the settings page can show a banner instead
|
|
4091
|
+
* of pretending the (ignored) overrides are in effect.
|
|
4092
|
+
*/
|
|
4093
|
+
parseError() {
|
|
4094
|
+
return this.lastParseError;
|
|
4095
|
+
}
|
|
4096
|
+
/**
|
|
3891
4097
|
* Reload the override tiddler (no-op when the wiki is unavailable).
|
|
3892
4098
|
*
|
|
3893
4099
|
* A MISSING tiddler (404) legitimately means "no overrides" and clears the
|
|
@@ -3895,10 +4101,15 @@ var ConfigStore = class {
|
|
|
3895
4101
|
* the cache on error silently reverted every user setting for the rest of the
|
|
3896
4102
|
* session, and the next `set()` then persisted a config tiddler without the
|
|
3897
4103
|
* user's other overrides.
|
|
4104
|
+
*
|
|
4105
|
+
* A tiddler that exists but does not PARSE is a third case (v0.23.4): the
|
|
4106
|
+
* cache is kept (writes are refused, so nothing can be lost) and the failure
|
|
4107
|
+
* is recorded for the UI — see ConfigUnreadableError.
|
|
3898
4108
|
*/
|
|
3899
4109
|
async load(client) {
|
|
3900
4110
|
if (client === void 0) {
|
|
3901
4111
|
this.overrides = {};
|
|
4112
|
+
this.lastParseError = void 0;
|
|
3902
4113
|
return;
|
|
3903
4114
|
}
|
|
3904
4115
|
let tiddler;
|
|
@@ -3910,13 +4121,18 @@ var ConfigStore = class {
|
|
|
3910
4121
|
}
|
|
3911
4122
|
if (tiddler === void 0) {
|
|
3912
4123
|
this.overrides = {};
|
|
4124
|
+
this.lastParseError = void 0;
|
|
3913
4125
|
return;
|
|
3914
4126
|
}
|
|
4127
|
+
const raw = typeof tiddler.text === "string" ? tiddler.text : "";
|
|
3915
4128
|
try {
|
|
3916
|
-
const parsed = JSON.parse(
|
|
3917
|
-
|
|
4129
|
+
const parsed = JSON.parse(raw);
|
|
4130
|
+
if (!isPlainObject(parsed)) throw new Error("config tiddler is not a JSON object");
|
|
4131
|
+
this.overrides = parsed;
|
|
4132
|
+
this.lastParseError = void 0;
|
|
3918
4133
|
} catch {
|
|
3919
|
-
|
|
4134
|
+
this.lastParseError = describeUnreadableConfig();
|
|
4135
|
+
console.warn("[dsh-tiddlywiki] config tiddler is not valid JSON, keeping cached overrides; saving is blocked until it is fixed");
|
|
3920
4136
|
}
|
|
3921
4137
|
}
|
|
3922
4138
|
/**
|
|
@@ -3925,18 +4141,29 @@ var ConfigStore = class {
|
|
|
3925
4141
|
* The patch is merged onto the STORED overrides (re-read here), not just the
|
|
3926
4142
|
* in-memory cache: if the startup `load()` failed transiently, saving one
|
|
3927
4143
|
* setting must not drop every other override the user had stored.
|
|
4144
|
+
*
|
|
4145
|
+
* ⚠️ When the stored tiddler exists but is UNPARSEABLE this THROWS
|
|
4146
|
+
* (v0.23.4) instead of merging onto an empty cache — see ConfigUnreadableError.
|
|
3928
4147
|
*/
|
|
3929
4148
|
async set(client, patch) {
|
|
3930
4149
|
let stored = this.overrides;
|
|
3931
4150
|
let existingCreated;
|
|
4151
|
+
let storedText;
|
|
3932
4152
|
try {
|
|
3933
4153
|
const tiddler = await client.get(CONFIG_TIDDLER);
|
|
3934
4154
|
if (tiddler !== void 0 && typeof tiddler.text === "string") {
|
|
4155
|
+
storedText = tiddler.text;
|
|
3935
4156
|
const parsed = JSON.parse(tiddler.text);
|
|
3936
|
-
if (isPlainObject(parsed))
|
|
4157
|
+
if (!isPlainObject(parsed)) throw new Error("config tiddler is not a JSON object");
|
|
4158
|
+
stored = parsed;
|
|
3937
4159
|
}
|
|
3938
4160
|
if (tiddler !== void 0 && typeof tiddler.created === "string" && tiddler.created.trim().length > 0) existingCreated = tiddler.created;
|
|
3939
|
-
} catch {
|
|
4161
|
+
} catch (err) {
|
|
4162
|
+
if (storedText !== void 0) {
|
|
4163
|
+
this.lastParseError = describeUnreadableConfig();
|
|
4164
|
+
throw new ConfigUnreadableError(describeUnreadableConfig(), storedText);
|
|
4165
|
+
}
|
|
4166
|
+
}
|
|
3940
4167
|
this.overrides = deepMerge(stored, patch);
|
|
3941
4168
|
await client.put({
|
|
3942
4169
|
title: CONFIG_TIDDLER,
|
|
@@ -4996,7 +5223,7 @@ function defineTool(options) {
|
|
|
4996
5223
|
* TW 按钮 → POST /wechat/publish → 这里 spawn opencli → 轮询
|
|
4997
5224
|
* GET /wechat/publish/status → 覆盖层显示进度 → 存草稿成功/失败
|
|
4998
5225
|
*
|
|
4999
|
-
*
|
|
5226
|
+
* 四个必须讲清楚的设计点(都是踩出来的,别改回去):
|
|
5000
5227
|
*
|
|
5001
5228
|
* 1. **标题不进 argv,走 UTF-8 文件**。Windows 上 `opencli` 是 `.cmd` shim,
|
|
5002
5229
|
* Node 的 `spawn('opencli', …)` 直接 ENOENT,必须经 cmd.exe 才跑得起来 ——
|
|
@@ -5008,6 +5235,11 @@ function defineTool(options) {
|
|
|
5008
5235
|
* 3. **`--trace retain-on-failure` 是承重参数**(不带就对 mp.weixin.qq.com 稳定
|
|
5009
5236
|
* 报 `Navigation rejected`,实测 trace 开 5/5 成功、关 8/8 失败),所以它写死在
|
|
5010
5237
|
* 组装逻辑里,不暴露给调用方。
|
|
5238
|
+
* 4. **就绪预检要认版本,不能只认文件**。`~/.opencli/clis/weixin/` 是用户态目录:
|
|
5239
|
+
* 装着旧版 adapter 时文件一个不少,但旧入口把标题当**必填位置参数**、根本不认
|
|
5240
|
+
* `--title-file`(宿主只用 `--title-file`)—— 预检报「就绪」,用户点下按钮才
|
|
5241
|
+
* 报「缺少必填参数」(v0.23.3 的真实事故)。所以 `scanAdapterDir` 除了
|
|
5242
|
+
* `missing` 还返回 `stale`,按**符号存在性**判版本(见 WECHAT_ADAPTER_MARKER)。
|
|
5011
5243
|
*
|
|
5012
5244
|
* 单并发:同一时刻只允许一个发布任务(`start()` 返回 busy)。这与路由的
|
|
5013
5245
|
* `/sync` `/restart` 互斥是同一思路 —— 两个 opencli 同时抢一个浏览器标签页没有
|
|
@@ -5038,6 +5270,43 @@ const WECHAT_ADAPTER_FILES = {
|
|
|
5038
5270
|
"weixin-flow.js"
|
|
5039
5271
|
]
|
|
5040
5272
|
};
|
|
5273
|
+
/** The shared flow module every adapter imports (`resolveNoteTitle` lives here). */
|
|
5274
|
+
const WECHAT_FLOW_FILE = "weixin-flow.js";
|
|
5275
|
+
/** The argv key each entry adapter must declare to accept `--title-file`. */
|
|
5276
|
+
const WECHAT_TITLE_FILE_ARG = "titleFile";
|
|
5277
|
+
/**
|
|
5278
|
+
* The one symbol only a v0.23.3+ install exports.
|
|
5279
|
+
*
|
|
5280
|
+
* Why symbol presence instead of file size or mtime: `~/.opencli/clis/weixin/`
|
|
5281
|
+
* is a plain user-owned folder that `install-wechat-adapters.mjs` copies files
|
|
5282
|
+
* into, so neither size nor timestamp says WHICH version is installed — an old
|
|
5283
|
+
* adapter re-copied yesterday looks brand new, and a re-formatted (but current)
|
|
5284
|
+
* file can shrink. The failure being guarded is behavioural, not cosmetic: a
|
|
5285
|
+
* pre-v0.23.3 `publish-note.js` takes the title as a *required positional*
|
|
5286
|
+
* argument and has never heard of `--title-file`, while the host only ever
|
|
5287
|
+
* sends `--title-file` (the title must not enter argv, see module header) — so
|
|
5288
|
+
* every file "exists", the readiness probe says 就绪, and the run dies with
|
|
5289
|
+
* 「缺少必填参数」 only after the user presses the button (the v0.23.3
|
|
5290
|
+
* accident). Symbol presence is the only cheap, exact answer to "can this file
|
|
5291
|
+
* do what we are about to ask of it?".
|
|
5292
|
+
*/
|
|
5293
|
+
const WECHAT_ADAPTER_MARKER = "resolveNoteTitle";
|
|
5294
|
+
/**
|
|
5295
|
+
* file → a matcher deciding whether that file is CURRENT.
|
|
5296
|
+
*
|
|
5297
|
+
* ⚠️ A plain `includes(WECHAT_ADAPTER_MARKER)` is NOT enough (a guard-script
|
|
5298
|
+
* fixture fooled itself this way): an old file that merely MENTIONS the symbol
|
|
5299
|
+
* in a comment would pass. Match the actual definition instead —
|
|
5300
|
+
* - `weixin-flow.js` must **export** `resolveNoteTitle`;
|
|
5301
|
+
* - the entry adapters must **declare** the `titleFile` argument.
|
|
5302
|
+
* `wechat-html.js` (the shared decorator) carries no version-specific contract,
|
|
5303
|
+
* so it is never reported as stale.
|
|
5304
|
+
*/
|
|
5305
|
+
const WECHAT_FRESHNESS_MATCHERS = {
|
|
5306
|
+
[WECHAT_FLOW_FILE]: new RegExp(`export\\s+function\\s+${WECHAT_ADAPTER_MARKER}\\s*\\(`),
|
|
5307
|
+
"publish-note.js": new RegExp(`name:\\s*['"]${WECHAT_TITLE_FILE_ARG}['"]`),
|
|
5308
|
+
"publish-note-imgs.js": new RegExp(`name:\\s*['"]${WECHAT_TITLE_FILE_ARG}['"]`)
|
|
5309
|
+
};
|
|
5041
5310
|
/** Captured output per stream; the job view only exposes the tail. */
|
|
5042
5311
|
const MAX_JOB_OUTPUT_CHARS = 64e3;
|
|
5043
5312
|
/** How much of the log the TW overlay shows (keeps the JSON response small). */
|
|
@@ -5413,11 +5682,20 @@ function defaultAdaptersDir(home = homedir()) {
|
|
|
5413
5682
|
return join(home, ".opencli", "clis", "weixin");
|
|
5414
5683
|
}
|
|
5415
5684
|
/**
|
|
5416
|
-
* Which adapter files exist in `dir
|
|
5417
|
-
*
|
|
5418
|
-
*
|
|
5685
|
+
* Which adapter files exist in `dir` **and are new enough to be driven by this
|
|
5686
|
+
* host**.
|
|
5687
|
+
*
|
|
5688
|
+
* `missing` and `stale` are deliberately disjoint and each keeps its literal
|
|
5689
|
+
* meaning: a file is either absent or present-and-unusable, never both (an old
|
|
5690
|
+
* file must not be reported as "missing" — the user would go re-install files
|
|
5691
|
+
* that are already on disk and end up exactly where they started).
|
|
5692
|
+
*
|
|
5693
|
+
* A missing/unreadable directory means "nothing installed"; a read failure on a
|
|
5694
|
+
* file we must inspect (EACCES, deleted mid-scan, a directory in its place…)
|
|
5695
|
+
* counts as stale — this feeds a readiness report that must always answer, so it
|
|
5696
|
+
* never throws.
|
|
5419
5697
|
*/
|
|
5420
|
-
function scanAdapterDir(dir, readDir = (path) => readdirSync(path)) {
|
|
5698
|
+
function scanAdapterDir(dir, readDir = (path) => readdirSync(path), readFile = (path) => readFileSync(path, "utf8")) {
|
|
5421
5699
|
let present = [];
|
|
5422
5700
|
try {
|
|
5423
5701
|
present = readDir(dir);
|
|
@@ -5425,11 +5703,24 @@ function scanAdapterDir(dir, readDir = (path) => readdirSync(path)) {
|
|
|
5425
5703
|
present = [];
|
|
5426
5704
|
}
|
|
5427
5705
|
const has = (file) => present.includes(file);
|
|
5428
|
-
const
|
|
5706
|
+
const required = [.../* @__PURE__ */ new Set([...WECHAT_ADAPTER_FILES["publish-note"], ...WECHAT_ADAPTER_FILES["publish-note-imgs"]])];
|
|
5707
|
+
const missing = required.filter((file) => !has(file));
|
|
5708
|
+
const stale = required.filter((file) => {
|
|
5709
|
+
const matcher = WECHAT_FRESHNESS_MATCHERS[file];
|
|
5710
|
+
if (matcher === void 0 || !has(file)) return false;
|
|
5711
|
+
try {
|
|
5712
|
+
return !matcher.test(readFile(join(dir, file)));
|
|
5713
|
+
} catch {
|
|
5714
|
+
return true;
|
|
5715
|
+
}
|
|
5716
|
+
});
|
|
5717
|
+
const staleSet = new Set(stale);
|
|
5718
|
+
const usable = (adapter) => WECHAT_ADAPTER_FILES[adapter].every((file) => has(file) && !staleSet.has(file));
|
|
5429
5719
|
return {
|
|
5430
|
-
publishNote:
|
|
5431
|
-
publishNoteImgs:
|
|
5432
|
-
missing
|
|
5720
|
+
publishNote: usable("publish-note"),
|
|
5721
|
+
publishNoteImgs: usable("publish-note-imgs"),
|
|
5722
|
+
missing,
|
|
5723
|
+
stale
|
|
5433
5724
|
};
|
|
5434
5725
|
}
|
|
5435
5726
|
/** `opencli --version` with a timeout; never throws. */
|
|
@@ -5502,7 +5793,11 @@ async function probeOpencli(opts) {
|
|
|
5502
5793
|
}
|
|
5503
5794
|
/**
|
|
5504
5795
|
* Full readiness report for the TW button (and for the 503 body of a failed
|
|
5505
|
-
* start): is the CLI there, and are the adapter files installed
|
|
5796
|
+
* start): is the CLI there, and are the adapter files installed **and current**?
|
|
5797
|
+
*
|
|
5798
|
+
* `ok` keeps its v0.23.3 meaning (enabled && CLI ok && at least one adapter is
|
|
5799
|
+
* usable) — `stale` is additive data for the caller's message, so existing
|
|
5800
|
+
* callers that only read `ok` are unaffected.
|
|
5506
5801
|
*/
|
|
5507
5802
|
async function checkWechatReady(opts) {
|
|
5508
5803
|
const dir = opts.adaptersDir ?? defaultAdaptersDir();
|
|
@@ -5522,7 +5817,8 @@ async function checkWechatReady(opts) {
|
|
|
5522
5817
|
dir,
|
|
5523
5818
|
publishNote: adapters.publishNote,
|
|
5524
5819
|
publishNoteImgs: adapters.publishNoteImgs,
|
|
5525
|
-
missing: adapters.missing
|
|
5820
|
+
missing: adapters.missing,
|
|
5821
|
+
stale: adapters.stale
|
|
5526
5822
|
}
|
|
5527
5823
|
};
|
|
5528
5824
|
}
|
|
@@ -6565,7 +6861,23 @@ function registerRoutes(ctx, deps) {
|
|
|
6565
6861
|
} catch (err) {
|
|
6566
6862
|
restartError = err instanceof Error ? err.message : String(err);
|
|
6567
6863
|
}
|
|
6568
|
-
|
|
6864
|
+
let committed;
|
|
6865
|
+
try {
|
|
6866
|
+
committed = await deps.git.commit(dir, `sync ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
6867
|
+
} catch (err) {
|
|
6868
|
+
if (err instanceof GitConflictStateError) {
|
|
6869
|
+
json(res, {
|
|
6870
|
+
ok: false,
|
|
6871
|
+
action: "sync",
|
|
6872
|
+
message: err.message,
|
|
6873
|
+
conflictFiles: err.files,
|
|
6874
|
+
pull: "ok",
|
|
6875
|
+
status: await status()
|
|
6876
|
+
}, 409);
|
|
6877
|
+
return;
|
|
6878
|
+
}
|
|
6879
|
+
throw err;
|
|
6880
|
+
}
|
|
6569
6881
|
const pushed = await deps.git.push(dir);
|
|
6570
6882
|
const fresh = await status();
|
|
6571
6883
|
json(res, {
|
|
@@ -7021,9 +7333,11 @@ function registerRoutes(ctx, deps) {
|
|
|
7021
7333
|
const ready = await deps.wechatReady();
|
|
7022
7334
|
const adapterReady = adapter === "publish-note-imgs" ? ready.adapters.publishNoteImgs : ready.adapters.publishNote;
|
|
7023
7335
|
if (!ready.opencli.ok || !adapterReady) {
|
|
7336
|
+
const stale = ready.adapters.stale ?? [];
|
|
7337
|
+
const staleNote = stale.length > 0 ? `adapter 版本过旧(缺 --title-file):${stale.join("、")}` : null;
|
|
7024
7338
|
json(res, {
|
|
7025
7339
|
ok: false,
|
|
7026
|
-
error: !ready.opencli.ok ? `找不到 opencli(wechat.command=${ready.command}):请先 npm install -g @jackwener/opencli` : `opencli 里还没有 weixin adapter(缺 ${ready.adapters.missing.join("、") || adapter}
|
|
7340
|
+
error: !ready.opencli.ok ? `找不到 opencli(wechat.command=${ready.command}):请先 npm install -g @jackwener/opencli` : (staleNote ?? `opencli 里还没有 weixin adapter(缺 ${ready.adapters.missing.join("、") || adapter})`) + ":请运行 node tools/wechat/install-wechat-adapters.mjs",
|
|
7027
7341
|
ready
|
|
7028
7342
|
}, 503);
|
|
7029
7343
|
return;
|
|
@@ -7582,6 +7896,7 @@ function registerAdminRoutes(ctx, deps) {
|
|
|
7582
7896
|
},
|
|
7583
7897
|
catalog,
|
|
7584
7898
|
config: maskConfigSecrets(deps.config.get()),
|
|
7899
|
+
configError: deps.config.parseError() ?? null,
|
|
7585
7900
|
git
|
|
7586
7901
|
});
|
|
7587
7902
|
} catch (err) {
|
|
@@ -7725,6 +8040,13 @@ function registerAdminRoutes(ctx, deps) {
|
|
|
7725
8040
|
config: maskConfigSecrets(deps.config.get())
|
|
7726
8041
|
});
|
|
7727
8042
|
} catch (err) {
|
|
8043
|
+
if (err instanceof ConfigUnreadableError) {
|
|
8044
|
+
json(res, {
|
|
8045
|
+
ok: false,
|
|
8046
|
+
error: err.message
|
|
8047
|
+
}, 409);
|
|
8048
|
+
return;
|
|
8049
|
+
}
|
|
7728
8050
|
json(res, {
|
|
7729
8051
|
ok: false,
|
|
7730
8052
|
error: err instanceof Error ? err.message : String(err)
|
|
@@ -10194,7 +10516,19 @@ function registerTiddlywikiTools(ctx, deps) {
|
|
|
10194
10516
|
...pulled.conflictFiles !== void 0 ? { conflictFiles: pulled.conflictFiles } : {}
|
|
10195
10517
|
};
|
|
10196
10518
|
const restart = await restartIfChanged(pulled);
|
|
10197
|
-
|
|
10519
|
+
let committed;
|
|
10520
|
+
try {
|
|
10521
|
+
committed = await deps.git.commit(dir, args.message ?? `sync ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
10522
|
+
} catch (err) {
|
|
10523
|
+
if (err instanceof GitConflictStateError) return {
|
|
10524
|
+
action: args.action,
|
|
10525
|
+
ok: false,
|
|
10526
|
+
message: err.message,
|
|
10527
|
+
conflictFiles: err.files,
|
|
10528
|
+
...restart
|
|
10529
|
+
};
|
|
10530
|
+
throw err;
|
|
10531
|
+
}
|
|
10198
10532
|
const pushed = await deps.git.push(dir);
|
|
10199
10533
|
const status = await deps.git.status(dir);
|
|
10200
10534
|
return {
|
|
@@ -10280,7 +10614,18 @@ function registerTiddlywikiTools(ctx, deps) {
|
|
|
10280
10614
|
action: "keep-remote",
|
|
10281
10615
|
message: `从远端检出失败:${checked.message}`
|
|
10282
10616
|
};
|
|
10283
|
-
|
|
10617
|
+
let committed;
|
|
10618
|
+
try {
|
|
10619
|
+
committed = await deps.git.commit(dir, `resolve conflict (keep remote) ${(/* @__PURE__ */ new Date()).toISOString()}`);
|
|
10620
|
+
} catch (err) {
|
|
10621
|
+
if (err instanceof GitConflictStateError) return {
|
|
10622
|
+
ok: false,
|
|
10623
|
+
action: "keep-remote",
|
|
10624
|
+
message: err.message,
|
|
10625
|
+
files: err.files
|
|
10626
|
+
};
|
|
10627
|
+
throw err;
|
|
10628
|
+
}
|
|
10284
10629
|
deps.autoCommit();
|
|
10285
10630
|
const status = await deps.git.status(dir);
|
|
10286
10631
|
return {
|
|
@@ -11250,6 +11595,6 @@ function apply(ctx, rawConfig = {}) {
|
|
|
11250
11595
|
}, "dsh-tiddlywiki: host teardown");
|
|
11251
11596
|
}
|
|
11252
11597
|
//#endregion
|
|
11253
|
-
export { AGENT_WRITTEN_TAG, ALL_ARTICLES_MARKER_TITLE, ALL_ARTICLES_TEXT, ALL_ARTICLES_TITLE, ANON_USERNAME, AutoCommitter, CLIP_BRIDGE_BOOKMARKLET, CLIP_BRIDGE_DEFAULT_PORT, CLIP_BRIDGE_DOC_TEXT, CLIP_BRIDGE_DOC_TITLE, CLIP_BRIDGE_DRAG_HREF, CLIP_BRIDGE_MARKER_TITLE, ClipBridge, ConfigStore, DEFAULT_NOTE_TYPE, DEFAULT_PROMPT_MODE, DEFAULT_WECHAT_ADAPTER, DEFAULT_WECHAT_COMMAND, DOC_NOTE_TAG, DOC_NOTE_TEXT, DOC_NOTE_TITLE, DSH_DOCS_TAG, GitFace, HOME_DEFAULT_TIDDLERS, HOME_INDEX_ITEMS, HOME_INDEX_MARKER_TITLE, HUMAN_EDITED_TAG, LOCATION_STATE_VERSION, MARKDOWN_PLUGIN, MASKED_SECRET, MENUBAR_THEME_MARKER_TITLE, MENUBAR_THEME_TEXT, MENUBAR_THEME_TIDDLER, MISSING_TYPE_FILTER, PATH_PREFIX, PROMPT_GOVERNANCE_BLOCKS, PROMPT_MODES, PROMPT_SECTION_NAME, PROMPT_SECTION_ORDER, READY_HARD_FACTOR, READY_POLL_MS, READY_SLOW_POLL_MS, READY_TIMEOUT_DEFAULT_MS, READY_TIMEOUT_MAX_MS, READY_TIMEOUT_MIN_MS, RENDER_BUNDLE_TEXT, RENDER_MARKER_TITLE, RENDER_PLUGIN_FILE, RENDER_PLUGIN_TITLE, SEED_DEFS, SEED_MARKER_VERSION, SEND_TO_AGENT_BUNDLE_TEXT, SEND_TO_AGENT_MARKER_TITLE, SEND_TO_AGENT_PLUGIN_TITLE, SESSION_SUMMARY_PREFIX, STARTER_DOCS_ITEMS, STARTER_DOCS_MARKER_TITLE, TEXT_LIST_FILTER, TRASH_INDEX_TITLE, TRASH_PREFIX, TW_PROXY_PATH, TW_PROXY_PREFIX, TW_WEB_HOST_DEFAULT, TW_WEB_HOST_TIDDLER, TiddlyWebClient, TrashIndexUnavailableError, UI_STYLES_MARKER_TITLE, UI_STYLE_ITEMS, WECHAT_ADAPTERS, WECHAT_ADAPTER_FILES, WECHAT_DOCS_MARKER_TITLE, WECHAT_DOCS_TEXT, WECHAT_DOCS_TITLE, WECHAT_PUBLISH_BUNDLE_TEXT, WECHAT_PUBLISH_MARKER_TITLE, WECHAT_PUBLISH_PLUGIN_TITLE, WechatPublishRunner, WikiServer, WriteConflictError, apply, assertNoConflict, assertPublicImageUrl, awaitReady, buildBinaryTiddler, buildClipTiddler, buildImageNoteTiddler, buildPromptText, buildPublishInvocation, buildVersionInvocation, buildWriteTiddler, bundledCatalog, capOutput, checkAllSeeds, checkWechatReady, cleanTiddler, clearLocationState, deepMerge, defaultAdaptersDir, defaultLocationStateFile, defaultWechatJobDir, defineTool, describePrompt, docNoteText, downloadClipImage, dshHomePath, ensureLanguage, ensurePlugin, ensureTiddlerTimestamps, escapeInline, escapePromptBraces, expandEnvPath, flattenTiddlerFields, flushPendingWrites, formatTiddlerDate, hashText, hostAllowed, imageExtensionForMime, inject, interpretPublishOutcome, isBinaryType, isPrivateAddress, isSafeCliValue, isSafeDsn, isSafeUrl, listWikiCandidates, locationPath, maskConfigSecrets, name, needsRestartAfterSeeds, normalizeLocation, normalizePromptMode, normalizePromptPreview, normalizeReadyTimeoutMs, normalizeThemes, normalizeWechatConfig, openInTwEditor, parseClipPayload, parseSeedMarker, parseTiddlerDate, pickImageMime, readActiveThemeName, readLocationState, readSeedMarker, readWikiInfo, readyHardTimeoutMs, registerAdminRoutes, registerRoutes, registerTiddlywikiTools, removeSeedById, resolveClipTitle, resolveTwRoot, runAllSeeds, runSeedById, sanitizeTwFragment, scanAdapterDir, seedAllArticles, seedClipBridge, seedDocNote, seedHomeIndex, seedMenubarTheme, seedRenderRoute, seedSendToAgent, seedStarterDocs, seedUiStyles, seedWechatDocs, seedWechatPublish, stripMaskedSecrets, switchWiki, tailOf, tiddlywikiToolSummary, toIsoDateString, toolSignatureLines, unseedClipBridge, unseedWechatDocs, unseedWechatPublish, waitForFileWrite, writeLocationState, writeSeedMarker, writeSessionSummary, writeWikiInfo };
|
|
11598
|
+
export { AGENT_WRITTEN_TAG, ALL_ARTICLES_MARKER_TITLE, ALL_ARTICLES_TEXT, ALL_ARTICLES_TITLE, ANON_USERNAME, AutoCommitter, CLIP_BRIDGE_BOOKMARKLET, CLIP_BRIDGE_DEFAULT_PORT, CLIP_BRIDGE_DOC_TEXT, CLIP_BRIDGE_DOC_TITLE, CLIP_BRIDGE_DRAG_HREF, CLIP_BRIDGE_MARKER_TITLE, ClipBridge, ConfigStore, ConfigUnreadableError, DEFAULT_NOTE_TYPE, DEFAULT_PROMPT_MODE, DEFAULT_WECHAT_ADAPTER, DEFAULT_WECHAT_COMMAND, DOC_NOTE_TAG, DOC_NOTE_TEXT, DOC_NOTE_TITLE, DSH_DOCS_TAG, GitConflictStateError, GitFace, HOME_DEFAULT_TIDDLERS, HOME_INDEX_ITEMS, HOME_INDEX_MARKER_TITLE, HUMAN_EDITED_TAG, LOCATION_STATE_VERSION, MARKDOWN_PLUGIN, MASKED_SECRET, MENUBAR_THEME_MARKER_TITLE, MENUBAR_THEME_TEXT, MENUBAR_THEME_TIDDLER, MISSING_TYPE_FILTER, PATH_PREFIX, PROMPT_GOVERNANCE_BLOCKS, PROMPT_MODES, PROMPT_SECTION_NAME, PROMPT_SECTION_ORDER, READY_HARD_FACTOR, READY_POLL_MS, READY_SLOW_POLL_MS, READY_TIMEOUT_DEFAULT_MS, READY_TIMEOUT_MAX_MS, READY_TIMEOUT_MIN_MS, RENDER_BUNDLE_TEXT, RENDER_MARKER_TITLE, RENDER_PLUGIN_FILE, RENDER_PLUGIN_TITLE, SEED_DEFS, SEED_MARKER_VERSION, SEND_TO_AGENT_BUNDLE_TEXT, SEND_TO_AGENT_MARKER_TITLE, SEND_TO_AGENT_PLUGIN_TITLE, SESSION_SUMMARY_PREFIX, STARTER_DOCS_ITEMS, STARTER_DOCS_MARKER_TITLE, TEXT_LIST_FILTER, TRASH_INDEX_TITLE, TRASH_PREFIX, TW_PROXY_PATH, TW_PROXY_PREFIX, TW_WEB_HOST_DEFAULT, TW_WEB_HOST_TIDDLER, TiddlyWebClient, TrashIndexUnavailableError, UI_STYLES_MARKER_TITLE, UI_STYLE_ITEMS, WECHAT_ADAPTERS, WECHAT_ADAPTER_FILES, WECHAT_ADAPTER_MARKER, WECHAT_DOCS_MARKER_TITLE, WECHAT_DOCS_TEXT, WECHAT_DOCS_TITLE, WECHAT_PUBLISH_BUNDLE_TEXT, WECHAT_PUBLISH_MARKER_TITLE, WECHAT_PUBLISH_PLUGIN_TITLE, WechatPublishRunner, WikiServer, WriteConflictError, apply, assertNoConflict, assertPublicImageUrl, awaitReady, buildBinaryTiddler, buildClipTiddler, buildImageNoteTiddler, buildPromptText, buildPublishInvocation, buildVersionInvocation, buildWriteTiddler, bundledCatalog, capOutput, checkAllSeeds, checkWechatReady, cleanTiddler, clearLocationState, deepMerge, defaultAdaptersDir, defaultLocationStateFile, defaultWechatJobDir, defineTool, describeConflict, describePrompt, describeUnreadableConfig, docNoteText, downloadClipImage, dshHomePath, ensureLanguage, ensurePlugin, ensureTiddlerTimestamps, escapeInline, escapePromptBraces, expandEnvPath, flattenTiddlerFields, flushPendingWrites, formatTiddlerDate, hashText, hostAllowed, imageExtensionForMime, inject, interpretPublishOutcome, isBinaryType, isPrivateAddress, isSafeCliValue, isSafeDsn, isSafeUrl, listWikiCandidates, locationPath, maskConfigSecrets, name, needsRestartAfterSeeds, normalizeLocation, normalizePromptMode, normalizePromptPreview, normalizeReadyTimeoutMs, normalizeThemes, normalizeWechatConfig, openInTwEditor, parseClipPayload, parseSeedMarker, parseTiddlerDate, pickImageMime, readActiveThemeName, readLocationState, readSeedMarker, readWikiInfo, readyHardTimeoutMs, registerAdminRoutes, registerRoutes, registerTiddlywikiTools, removeSeedById, resolveClipTitle, resolveTwRoot, runAllSeeds, runSeedById, sanitizeTwFragment, scanAdapterDir, seedAllArticles, seedClipBridge, seedDocNote, seedHomeIndex, seedMenubarTheme, seedRenderRoute, seedSendToAgent, seedStarterDocs, seedUiStyles, seedWechatDocs, seedWechatPublish, stripMaskedSecrets, switchWiki, tailOf, tiddlywikiToolSummary, toIsoDateString, toolSignatureLines, unseedClipBridge, unseedWechatDocs, unseedWechatPublish, waitForFileWrite, writeLocationState, writeSeedMarker, writeSessionSummary, writeWikiInfo };
|
|
11254
11599
|
|
|
11255
11600
|
//# sourceMappingURL=index.js.map
|