dsh-tiddlywiki 0.26.5 → 0.26.7

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
@@ -2574,10 +2574,40 @@ function rejectCrossSiteWrite(req, res, allowedMethods) {
2574
2574
  }, 403);
2575
2575
  return true;
2576
2576
  }
2577
- /** Read-only route guard: GET/HEAD only, no side effects on any other method. */
2577
+ /**
2578
+ * Read-only route guard: GET/HEAD only, no side effects on any other method. */
2578
2579
  function rejectNonRead(req, res) {
2579
2580
  return rejectCrossSiteWrite(req, res, ["GET", "HEAD"]);
2580
2581
  }
2582
+ /**
2583
+ * Absolute loopback base for a same-origin proxy path, from a request's `Host`
2584
+ * header (v0.26.7). Returns undefined when the header is unusable, in which
2585
+ * case the caller simply omits the absolute twin and clients keep using the
2586
+ * relative path.
2587
+ *
2588
+ * WHY THIS EXISTS: the DSH **desktop** app serves its renderer from the custom
2589
+ * `dsh-app:` scheme, and TiddlyWiki's own TiddlyWeb sync adaptor refuses to load
2590
+ * on a document whose protocol does not start with `http`
2591
+ * (`plugins/tiddlywiki/tiddlyweb/tiddlywebadaptor.js`:
2592
+ * `if($tw.browser && document.location.protocol.substr(0,4) === "http")`).
2593
+ * Embedded from `dsh-app://app/…` the wiki therefore had NO sync adaptor:
2594
+ * `$:/status/IsReadOnly` was never written, TW's read-only stylesheet (which
2595
+ * treats a MISSING status as read-only) hid 添加条目 / 日志 / 导入 / 管理器 /
2596
+ * 编辑 …, and the saver fell back to local-only dirty tracking — an edit made
2597
+ * there could not be saved either. The client falls back to this absolute URL
2598
+ * only while its own document is not on http(s) (see `resolveTwUrl` in
2599
+ * `src/client/endpoints.ts`), so http(s) embedders — loopback, LAN, Tailscale,
2600
+ * domain, HTTPS — are never moved off their own origin.
2601
+ *
2602
+ * `http` is deliberate rather than `x-forwarded-proto`: the desktop app's host
2603
+ * always listens on plain HTTP loopback, and only a renderer able to reach it
2604
+ * ever reads this value.
2605
+ */
2606
+ function absoluteHostBase(hostHeader, proxyPath) {
2607
+ const host = typeof hostHeader === "string" ? hostHeader.trim() : Array.isArray(hostHeader) ? String(hostHeader[0] ?? "").trim() : "";
2608
+ if (!/^[A-Za-z0-9.\-[\]:]+$/.test(host)) return void 0;
2609
+ return `http://${host}${proxyPath}`;
2610
+ }
2581
2611
  //#endregion
2582
2612
  //#region src/host/sanitize.ts
2583
2613
  /**
@@ -6679,6 +6709,14 @@ function registerRoutes(ctx, deps) {
6679
6709
  * tags, it cannot know the text is a secret.
6680
6710
  */
6681
6711
  const referencesBlockedTitle = (value) => value !== void 0 && BLOCKED_PROXY_TITLE_PREFIXES.some((prefix) => value.includes(prefix));
6712
+ /**
6713
+ * Absolute twin of `twProxy` for embedders whose own document is not on
6714
+ * http(s) — the DSH desktop app's `dsh-app:` renderer (v0.26.7). See
6715
+ * `absoluteHostBase()` in http.ts for the full why; clients only consult it
6716
+ * while their own page is not http(s), so nothing changes for http(s)
6717
+ * deployments.
6718
+ */
6719
+ const twProxyAbsoluteBase = (req) => absoluteHostBase(req.headers.host, TW_PROXY_PATH);
6682
6720
  const handleStatus = async (req, res) => {
6683
6721
  if (rejectNonRead(req, res)) return;
6684
6722
  const view = deps.server.status();
@@ -6688,6 +6726,7 @@ function registerRoutes(ctx, deps) {
6688
6726
  ...view,
6689
6727
  logs: redactLogLines(view.logs),
6690
6728
  twProxy: TW_PROXY_PATH,
6729
+ twProxyAbsolute: twProxyAbsoluteBase(req),
6691
6730
  git: gitSummary,
6692
6731
  note: { tag: deps.noteDefaults().tag },
6693
6732
  ui: deps.uiDefaults()
@@ -6765,7 +6804,8 @@ function registerRoutes(ctx, deps) {
6765
6804
  json(res, {
6766
6805
  ok: true,
6767
6806
  ...await pendingSummary,
6768
- twUrl: TW_PROXY_PATH
6807
+ twUrl: TW_PROXY_PATH,
6808
+ twUrlAbsolute: twProxyAbsoluteBase(req)
6769
6809
  });
6770
6810
  } catch (err) {
6771
6811
  json(res, {
@@ -7186,7 +7226,8 @@ function registerRoutes(ctx, deps) {
7186
7226
  json(res, {
7187
7227
  ok: true,
7188
7228
  ...result,
7189
- twUrl: TW_PROXY_PATH
7229
+ twUrl: TW_PROXY_PATH,
7230
+ twUrlAbsolute: twProxyAbsoluteBase(req)
7190
7231
  });
7191
7232
  } catch (err) {
7192
7233
  if (err instanceof WriteConflictError) {
@@ -10316,22 +10357,44 @@ function rewriteRefs(text, oldTitle, newTitle) {
10316
10357
  };
10317
10358
  }
10318
10359
  /**
10319
- * Insert `addition` at the end of the section introduced by `heading` (Markdown
10320
- * `#`/`##`… or wikitext `!`/`!!`…). Falls back to appending at the end of the
10321
- * document when the heading is not found. Used by tiddlywiki_append so an agent
10322
- * can add to one section of a long note without rewriting the whole file.
10360
+ * Insert `addition` into the section introduced by `heading` (Markdown `#`/`##`…
10361
+ * or wikitext `!`/`!!`…). Used by tiddlywiki_append so an agent can add to one
10362
+ * section of a long note without rewriting the whole file.
10363
+ *
10364
+ * **落点语义**:插在「该标题之后、**下一个任意级别的标题**之前」。所以当标题后面
10365
+ * 紧跟子标题时(`## 4. 定时任务` + `### 槽位表`),内容落在两者**之间**,也就是
10366
+ * 「章节开头」而不是「整节末尾」——这一点以前只在 schema 里写成「段落的末尾」,
10367
+ * 容易让调用方误会(v0.26.6 起描述与回执都写清楚了)。
10368
+ *
10369
+ * **返回 `matched`**:`false` = 没找到该标题,文本被追加到**文末**。调用方**必须**
10370
+ * 把它透出去(v0.26.6 之前这个回退是静默的,回执照样打「段落「X」」,
10371
+ * 于是一次定位失败看起来像成功——Agent 没有任何办法自检)。
10372
+ *
10373
+ * **CRLF(v0.26.6 修复)**:tiddler 正文可能是 CRLF(Windows 上写的/同步来的笔记),
10374
+ * 而 `split('\n')` 会让每行结尾都留着 `\r`。JS 里 `(.*)` 不匹配 `\r`、`$` 也不匹配
10375
+ * `\r` 之前的位置,于是 `/^#{1,6}\s+(.*)$/` 对**每一个**标题都匹配失败 ⇒ 所有带
10376
+ * `heading` 的 append 都静默落到了文末。**只在 CRLF 笔记上复现**(纯 LF 笔记一切正常),
10377
+ * 这就是它像「偶发」的原因。现在只在**匹配时**剥掉行尾 CR,原文行尾一个字节都不动;
10378
+ * 并且新块用**文档自己的行尾**写入,避免 CRLF 笔记被越写越乱(混入 LF 块)。
10323
10379
  */
10324
10380
  function insertIntoSection(base, heading, addition) {
10325
10381
  const lines = base.split("\n");
10326
10382
  const headingText = (line) => {
10327
- const md = /^#{1,6}\s+(.*)$/.exec(line);
10383
+ const s = line.replace(/\r$/, "");
10384
+ const md = /^#{1,6}\s+(.*)$/.exec(s);
10328
10385
  if (md !== null) return (md[1] ?? "").trim();
10329
- const tw = /^!{1,6}\s*(.*)$/.exec(line);
10386
+ const tw = /^!{1,6}\s*(.*)$/.exec(s);
10330
10387
  if (tw !== null) return (tw[1] ?? "").trim();
10331
10388
  return null;
10332
10389
  };
10333
10390
  const start = lines.findIndex((line) => headingText(line) === heading);
10334
- if (start < 0) return base.trim().length === 0 ? addition : `${base.replace(/\s+$/, "")}\n\n${addition}`;
10391
+ const eol = base.includes("\r\n") ? "\r\n" : "\n";
10392
+ const adapt = (s) => eol === "\n" ? s.replace(/\r\n/g, "\n") : s.replace(/\r\n/g, "\n").split("\n").join("\r\n");
10393
+ const block = adapt(addition);
10394
+ if (start < 0) return {
10395
+ text: base.trim().length === 0 ? block : `${base.replace(/\s+$/, "")}${eol}${eol}${block}`,
10396
+ matched: false
10397
+ };
10335
10398
  let end = lines.length;
10336
10399
  for (let i = start + 1; i < lines.length; i++) if (headingText(lines[i] ?? "") !== null) {
10337
10400
  end = i;
@@ -10339,8 +10402,11 @@ function insertIntoSection(base, heading, addition) {
10339
10402
  }
10340
10403
  const before = lines.slice(0, end).join("\n").replace(/\s+$/, "");
10341
10404
  const after = lines.slice(end).join("\n");
10342
- const head = `${before}\n\n${addition}`;
10343
- return after.trim().length === 0 ? head : `${head}\n\n${after.replace(/^\s+/, "")}`;
10405
+ const head = `${before}${eol}${eol}${block}`;
10406
+ return {
10407
+ text: after.trim().length === 0 ? head : `${head}${eol}${eol}${after.replace(/^\s+/, "")}`,
10408
+ matched: true
10409
+ };
10344
10410
  }
10345
10411
  function registerTiddlywikiTools(ctx, deps) {
10346
10412
  const disposers = [];
@@ -11110,7 +11176,7 @@ function registerTiddlywikiTools(ctx, deps) {
11110
11176
  }));
11111
11177
  register(defineTool({
11112
11178
  name: "tiddlywiki_append",
11113
- description: "向已有 tiddler 追加/前插文本,或写入指定标题段落的末尾(无需先读全文、不会整篇覆盖)——适合日志、批注、清单的增量写入。条目不存在时默认新建(createIfMissing=false 则报错)。**写入既有条目走与 tiddlywiki_put 同一套写策略**:原有 tags、自定义字段与**内容类型**全部保留(不会把 Markdown 笔记改成 wikitext、也不会把 CSS 改成 Markdown);可用 fields 显式覆盖字段/类型。新建条目未指定内容类型时才默认 text/markdown(并像 tiddlywiki_put 一样自动补 agent-written 与 `ws/<项目名>` 工作区标记)。⚠️ 本工具内部是「读全文 → 整篇回写」,属于覆盖写入:改前请先 `tiddlywiki_get`,并把读到的 `modified` 作为 `expectedModified` 传回(v0.25.0 起支持),否则人类在 TW 编辑器里的并发修改会被静默回滚。",
11179
+ description: "向已有 tiddler 追加/前插文本,或写入指定标题段落的末尾(无需先读全文、不会整篇覆盖)——适合日志、批注、清单的增量写入。条目不存在时默认新建(createIfMissing=false 则报错)。**写入既有条目走与 tiddlywiki_put 同一套写策略**:原有 tags、自定义字段与**内容类型**全部保留(不会把 Markdown 笔记改成 wikitext、也不会把 CSS 改成 Markdown);可用 fields 显式覆盖字段/类型。新建条目未指定内容类型时才默认 text/markdown(并像 tiddlywiki_put 一样自动补 agent-written 与 `ws/<项目名>` 工作区标记)。⚠️ 本工具内部是「读全文 → 整篇回写」,属于覆盖写入:改前请先 `tiddlywiki_get`,并把读到的 `modified` 作为 `expectedModified` 传回(v0.25.0 起支持),否则人类在 TW 编辑器里的并发修改会被静默回滚。带 `heading` 时返回 `headingMatched`:`false` = 没找到该标题、文本已追加到**文末**(CRLF 行尾的笔记也能正确定位)。",
11114
11180
  parameters: {
11115
11181
  title: {
11116
11182
  type: "string",
@@ -11129,7 +11195,7 @@ function registerTiddlywikiTools(ctx, deps) {
11129
11195
  },
11130
11196
  heading: {
11131
11197
  type: "string",
11132
- description: "可选:append 时改为插入到该标题(Markdown # 或 wikitext ! 标题,按标题文本匹配)对应段落的末尾"
11198
+ description: "可选:append 时改为写入指定标题的位置(Markdown # 或 wikitext ! 标题,按标题文本匹配、忽略标题级别)。**落点 = 该标题之后、下一个任意级别标题之前**:若该标题后紧跟子标题,内容插在两者之间(即章节开头),**不是整节末尾**。标题不存在时不报错,改为追加到文末,并在回执与返回的 headingMatched=false 里明确说明(别把「没命中」当「写进去了」)"
11133
11199
  },
11134
11200
  createIfMissing: {
11135
11201
  type: "boolean",
@@ -11160,7 +11226,8 @@ function registerTiddlywikiTools(ctx, deps) {
11160
11226
  },
11161
11227
  normalizeArgs: normalizeFieldsInArgs,
11162
11228
  output: { render: (_args, value) => {
11163
- const lines = [`${value.created ? "已新建并写入" : "已增量写入"} tiddler「${value.title}」(${value.mode}${value.heading !== null ? ` · 段落「${value.heading}」` : ""}):新增 ${value.added} 字符,现共 ${value.total} 字符。`];
11229
+ const headingNote = value.heading === null ? "" : value.headingMatched === false ? ` · ⚠️ 未找到标题「${value.heading}」,已改为追加到文末` : ` · 段落「${value.heading}」`;
11230
+ const lines = [`${value.created ? "已新建并写入" : "已增量写入"} tiddler「${value.title}」(${value.mode}${headingNote}):新增 ${value.added} 字符,现共 ${value.total} 字符。`];
11164
11231
  if (value.type !== null) lines.push(`类型: ${value.type}${value.typeDefaulted === true ? "(新建且未指定,已默认 markdown)" : ""}`);
11165
11232
  if (value.typeChanged !== void 0) lines.push(`⚠️ 内容类型已从 ${value.typeChanged.from} 改为 ${value.typeChanged.to}`);
11166
11233
  if (value.workspace !== void 0) lines.push(`已自动标记工作区: ws/${value.workspace}(字段 ${WORKSPACE_FIELD})`);
@@ -11184,8 +11251,12 @@ function registerTiddlywikiTools(ctx, deps) {
11184
11251
  const base = existing?.text ?? "";
11185
11252
  const addition = args.text;
11186
11253
  let next;
11187
- if (mode === "append" && typeof args.heading === "string" && args.heading.trim().length > 0) next = insertIntoSection(base, args.heading.trim(), addition);
11188
- else if (mode === "prepend") next = base.trim().length === 0 ? addition : `${addition}\n\n${base}`;
11254
+ let headingMatched;
11255
+ if (mode === "append" && typeof args.heading === "string" && args.heading.trim().length > 0) {
11256
+ const placed = insertIntoSection(base, args.heading.trim(), addition);
11257
+ next = placed.text;
11258
+ headingMatched = placed.matched;
11259
+ } else if (mode === "prepend") next = base.trim().length === 0 ? addition : `${addition}\n\n${base}`;
11189
11260
  else next = base.trim().length === 0 ? addition : `${base.replace(/\s+$/, "")}\n\n${addition}`;
11190
11261
  const appendMarked = existing === void 0 && !title.startsWith("$:/") ? withWorkspaceMark(workspaceMarkFor(deps, exec), normalizeTagArg(args.tags), args.fields) : {
11191
11262
  tags: normalizeTagArg(args.tags),
@@ -11203,6 +11274,7 @@ function registerTiddlywikiTools(ctx, deps) {
11203
11274
  title,
11204
11275
  mode,
11205
11276
  heading: mode === "append" && typeof args.heading === "string" && args.heading.trim().length > 0 ? args.heading.trim() : null,
11277
+ ...headingMatched !== void 0 ? { headingMatched } : {},
11206
11278
  created: existing === void 0,
11207
11279
  added: addition.length,
11208
11280
  total: next.length,