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/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: conflictFiles.length > 0 ? `conflict in ${conflictFiles.join(", ")} (rebase aborted): ${reason}` : `pull failed: ${reason}`,
251
- ...conflictFiles.length > 0 ? { conflictFiles } : {}
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&#32;\\n<span class=\\\"tc-icon-wrapper\\\"><$transclude tiddler={{!!icon}}/></span>\\n&#32;\\n<$transclude field=\\\"caption\\\"/>\\n&#32;--&#32;\\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&#32;\\n<span class=\\\"tc-icon-wrapper\\\"><$transclude tiddler={{!!icon}}/></span>\\n&#32;\\n<$transclude field=\\\"caption\\\"/>\\n&#32;--&#32;\\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.1.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(typeof tiddler.text === "string" ? tiddler.text : "");
3917
- this.overrides = isPlainObject(parsed) ? parsed : {};
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
- console.warn("[dsh-tiddlywiki] config tiddler is not valid JSON, keeping cached overrides");
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)) stored = 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`. A missing/unreadable directory means
5417
- * "nothing installed" — never a thrown error, because this feeds a readiness
5418
- * report that must always answer.
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 missing = [.../* @__PURE__ */ new Set([...WECHAT_ADAPTER_FILES["publish-note"], ...WECHAT_ADAPTER_FILES["publish-note-imgs"]])].filter((file) => !has(file));
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: WECHAT_ADAPTER_FILES["publish-note"].every(has),
5431
- publishNoteImgs: WECHAT_ADAPTER_FILES["publish-note-imgs"].every(has),
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
- const committed = await deps.git.commit(dir, `sync ${(/* @__PURE__ */ new Date()).toISOString()}`);
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}):请运行 node tools/wechat/install-wechat-adapters.mjs`,
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
- const committed = await deps.git.commit(dir, args.message ?? `sync ${(/* @__PURE__ */ new Date()).toISOString()}`);
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
- const committed = await deps.git.commit(dir, `resolve conflict (keep remote) ${(/* @__PURE__ */ new Date()).toISOString()}`);
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