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/LICENSE +21 -21
- package/README.md +3 -1
- package/cordis.patch.yml +13 -13
- package/lib/client.js +27 -27
- package/lib/index.js +89 -17
- package/lib/index.js.map +1 -1
- package/package.json +2 -2
- package/src/client/endpoints.ts +27 -0
- package/src/client/markdown-editor.ts +128 -128
- package/src/client/note-widget.ts +8 -5
- package/src/client/state.ts +39 -39
- package/src/client/status-cache.ts +7 -0
- package/src/client/toast.ts +22 -22
- package/src/client/tw-frame.ts +20 -4
- package/src/host/http.ts +34 -1
- package/src/host/routes.ts +16 -3
- package/src/host/tools.ts +56 -15
- package/docs/code-review-2026-09-17.md +0 -151
package/lib/index.js
CHANGED
|
@@ -2574,10 +2574,40 @@ function rejectCrossSiteWrite(req, res, allowedMethods) {
|
|
|
2574
2574
|
}, 403);
|
|
2575
2575
|
return true;
|
|
2576
2576
|
}
|
|
2577
|
-
/**
|
|
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`
|
|
10320
|
-
*
|
|
10321
|
-
*
|
|
10322
|
-
*
|
|
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
|
|
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(
|
|
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
|
-
|
|
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}
|
|
10343
|
-
return
|
|
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
|
|
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
|
|
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
|
-
|
|
11188
|
-
|
|
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,
|