dsh-tiddlywiki 0.16.24 → 0.16.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -2
- package/lib/index.js +204 -24
- package/lib/index.js.map +1 -1
- package/package.json +1 -1
- package/src/host/clip-bridge.ts +197 -18
- package/src/host/seed-clip-bridge.ts +37 -11
- package/src/index.ts +20 -2
package/README.md
CHANGED
|
@@ -33,7 +33,7 @@
|
|
|
33
33
|
| 🧩 **Better Sidebar Tab** | dsh-better-sidebar 侧边栏:+ 菜单注册 TiddlyWiki tab,与聊天并排;链接点击可直达;未安装该插件自动跳过(v0.16.23) |
|
|
34
34
|
| 📚 **会话知识库 Tab** | 每个会话顶部汇总本会话读写过的 wiki 笔记,TW 原生渲染(`/tw/render` 片段管线,v0.16.19) |
|
|
35
35
|
| 📝 **快速笔记** | 输入框上方快捷按钮或右下角「知识库」FAB;原生编辑页或 Markdown 卡片两种模式;首页内置「快速记笔记(完整编辑器)」 |
|
|
36
|
-
| 📌 **本地剪藏桥** | 可选「剪藏桥 + 书签小工具」:DSH 监听 127.0.0.1 端口接收剪藏请求(Host 校验防 rebinding
|
|
36
|
+
| 📌 **本地剪藏桥** | 可选「剪藏桥 + 书签小工具」:DSH 监听 127.0.0.1 端口接收剪藏请求(Host 校验防 rebinding),点书签弹出浮层——可改标题/编辑选中文字/**勾选图片**,一键写入知识库;文字成笔记(默认 `clip` 标签),图片由桥下载存为**二进制附件**(`type: image/*` + base64,笔记内 `[img[标题]]` 内嵌),随 wiki 自动进 git |
|
|
37
37
|
| ✅ **待办四象限** | 首页看板:任务打 `todo` 标签即收录,拖动即可分类/完成(正文附 `q` 字段),逾期/今日到期自动统计 |
|
|
38
38
|
| 🌗 **跟随主题** | 内嵌 TW 自适应 DSH 深浅主题(纯内存切换,不进 git) |
|
|
39
39
|
| 🔄 **一键同步** | FAB「同步」一键 pull→commit→push,状态点实时反映 git 状态 |
|
|
@@ -103,7 +103,7 @@ dsh plugin --profile web add link:/path/to/dsh-tiddlywiki
|
|
|
103
103
|
- **🗂️ 右侧边栏 Tab**(v0.16.21):DSH 新右侧栏展开后,首页会出现「**TiddlyWiki 知识库**」入口盒,点击即在右侧栏以 tab 形式打开完整 TW 编辑器——**与聊天并排**,适合边聊边查/边记。由 `ui.showRightbarTab` 控制(默认开);老版本 DSH(无右侧栏)自动跳过。
|
|
104
104
|
- **🧩 Better Sidebar Tab**(v0.16.23):安装了 [dsh-better-sidebar](https://github.com/omdsh-dev/DSH-better-sidebar) 插件时,其侧边栏 **+ 菜单**会出现「TiddlyWiki 知识库」tab,点击即打开完整 TW 编辑器(同一套同源代理 iframe / 主题跟随 / 互斥协议,与右侧栏完全相同),**与聊天并排**。由 `ui.showBetterSidebarTab` 控制(默认开);未安装 Better Sidebar 自动跳过;Better Sidebar 自己的设置页也会为该 tab 提供独立的启用开关。**更新后刷新页面**即可看到入口。
|
|
105
105
|
- **📝 快速笔记**:输入框上方快捷按钮(`ui.showQuickNoteDock`)或 FAB;`ui.quickNoteMode` 选打开方式——**native**(默认,直达 TW 原生编辑页,草稿自动续写)或 **card**(CodeMirror 6 Markdown 高亮、文件上传、多选 tag、草稿自动保存、「🕘 最近」载入、Ctrl+Enter 保存)。
|
|
106
|
-
- **📌 本地剪藏桥**(可选,v0.16.24
|
|
106
|
+
- **📌 本地剪藏桥**(可选,v0.16.24+/v0.16.25 支持图片):DSH 设置 → 常规配置 → 勾选「**启用本地剪藏桥**」(保存后立即生效)——DSH 即监听 `127.0.0.1:8618` 接收剪藏请求。浏览器书签栏新建书签,把知识库文档里的 JS 代码粘贴为地址,点一下弹出**浮层**:确认/修改标题与划线文字、勾选页面图片(封面自动标出)→「剪藏」即写入 wiki。文字成笔记(默认 `clip` 标签、正文含来源与选中文字、重名自动 `(2)` 去重);**图片由 DSH 本机下载字节存为二进制附件**(`clip-url`/`clip-note` 溯源字段,笔记内 `[img[标题]]` 内嵌展示;某张下载失败自动降级为链接),全部随 wiki 自动 git commit。「📚 插件文档」栏的 `本地剪藏桥(书签小工具)` seed 文档含完整步骤、书签代码、安全说明与 curl 用法。安全:桥只监听 127.0.0.1 + Host 白名单防 DNS rebinding;**强烈建议设 `bridge.token`**(非空时校验书签的 `x-clip-token` 头,防止任意网页往 wiki 里塞内容)。图片附件按设计不参与 `search`/`recent`(避免刷屏),`get` 只回元数据。
|
|
107
107
|
- **🏠 首页(初始化 home-index 后)**:待办四象限(`todo` 标签 + `q` 字段拖放分类)+ 快速记笔记(完整编辑器,勾选「同时加入待办」即建任务)+「所有标签 / 所有文章」入口 +「📚 插件文档」栏(自动收录所有带 `dsh-docs` 标签的 seed 文档)。
|
|
108
108
|
- **📚 会话知识库 Tab**:会话顶部 Tab(`ui.tabLabel` 改名、`ui.showSessionTab` 关闭),自动汇总本会话读写过的 wiki 笔记(写入 volatile `$:/temp`,不落盘不进 git),**TW 原生渲染**(v0.16.19 起 `/tw/render` 片段管线,与回复流工具卡同链路),链接点击直达中央 TW 面板,不可编辑。
|
|
109
109
|
- **🌗 跟随 DSH 主题**:内嵌 TW 随 DSH 深浅切换 palette,纯内存不写回 wiki(`ui.followDshTheme`/`ui.darkPalette`)。
|
|
@@ -278,6 +278,9 @@ lib/ # 预构建产物(发布含 lib/**,提交入库;
|
|
|
278
278
|
|
|
279
279
|
> 最近几个主要版本的一句话记录(完整变更见 [Releases](https://github.com/bbqisbbq/dsh-tiddlywiki/releases) / git log)。
|
|
280
280
|
|
|
281
|
+
- **v0.16.26**(2026-09-10):**修:剪藏书签在真实页面上崩溃**(`Cannot set properties of null (setting 'value')`)。根因:浮层字段在 `p` 尚未挂到 document 前就用 `document.getElementById` 取值(脱离文档树查不到 → null);同时 `cb_*` id 可能与页面自身元素冲突。修复:所有浮层字段改为 `p.querySelector` 作用域内查找 + 字段赋值挪到 `appendChild` 之后;并把书签放进 **jsdom 真实 DOM 端到端跑通**(浮层/填充/选图/提交载荷/结果态 12 项全过)作为验证,`verify-clip-bridge.mjs` 新增静态回归断言(禁未限定 getElementById + 挂载时序)。行为无变化,仅书签代码(seed 文档)修复。
|
|
282
|
+
- **v0.16.25**(2026-09-10):**剪藏桥支持图片剪藏(浮层选图)**。书签升级为**选择式浮层**:可改标题/编辑选中文字,浮层列出页面图片缩略图(自动跳过 <80px 小图标与 data:/blob: 占位、去重,`og:image` 标「封面」并默认勾选)勾选图片后一键剪藏。图片由桥(DSH 进程侧)**下载字节存为二进制附件 tiddler**(`type: image/*` + base64 正文,即 TW 原生附件形态;`clip-url`/`clip-note` 溯源),笔记改用 wikitext 用 `[img[标题]]` 内嵌展示;某张下载失败自动降级为链接、不阻塞整次剪藏;纯文字剪藏保持原 markdown 路径不变。安全不变(127.0.0.1 + Host 校验 + 可选 token;下载带 referer/UA 应对常见防盗链)。验收新增 image-flow 测试 + 书签语法检查(`verify-clip-bridge.mjs` 24 项全过)。
|
|
283
|
+
|
|
281
284
|
- **v0.16.24**(2026-09-09):**新:本地剪藏桥 + 书签小工具**。DSH 进程内新增只监听 **127.0.0.1** 的 HTTP 桥(配置组 `bridge.*`:`enabled` 默认关、`port` 默认 8618、`token` 可选共享口令、`tag` 默认 `clip`)——浏览器书签(JS 小工具)把当前页标题/URL/选中文字 POST 给它,经唯一写入通道落进 wiki(markdown、重名自动 `(2)` 去重、默认 `clip` 标签、随 wiki 自动 git commit)。安全:Host 头白名单防 DNS rebinding、CORS/PNA 预检放行(https 页面书签可用)、`bridge.token` 强烈建议设置。附**可选 seed** `clip-bridge`:「本地剪藏桥(书签小工具)」说明文档(含书签代码/启用步骤/安全说明/curl 用法,打 `dsh-docs` 标签进「📚 插件文档」栏,可反初始化);设置页新增 4 个配置字段。`enabled`/`token`/`tag` 设置页保存即生效,改端口需重启 dsh web。
|
|
282
285
|
|
|
283
286
|
- **v0.16.23**(2026-09-09):**新:DSH Better Sidebar(dsh-better-sidebar)集成**。插件通过 `ctx.betterSidebar` 服务注册 TiddlyWiki 为 Better Sidebar 侧边栏的 tab 类型——+ 菜单点击即以 tab 打开完整 TW 编辑器(同源代理 iframe,与聊天并排);TW iframe 机制抽成共享模块(rightbar/better-sidebar 复用:lazy-load / status 轮询 / 主题同步 / hash 导航 / 互斥协议),回复流 wiki 链接在同一处统一路由到任一可见的侧边 TW tab。配置 `ui.showBetterSidebarTab`(默认 true)可开关;未安装 Better Sidebar 自动跳过。**更新后刷新页面**即可看到入口。
|
package/lib/index.js
CHANGED
|
@@ -3379,17 +3379,21 @@ const CLIP_BRIDGE_DOC_TITLE = "本地剪藏桥(书签小工具)";
|
|
|
3379
3379
|
/** One-time marker: presence = "the doc was offered once — hands off". */
|
|
3380
3380
|
const CLIP_BRIDGE_MARKER_TITLE = "$:/plugins/dsh-tiddlywiki/seed-clip-bridge";
|
|
3381
3381
|
/**
|
|
3382
|
-
* The instruction body, Markdown. The bookmarklet
|
|
3383
|
-
* defaults (port 8618, empty token) — users
|
|
3384
|
-
*
|
|
3382
|
+
* The instruction body, Markdown. The bookmarklet mirrors the bridge's
|
|
3383
|
+
* defaults (port 8618, empty token) — users adjust the two placeholders when
|
|
3384
|
+
* they changed `bridge.port` / `bridge.token`.
|
|
3385
3385
|
*/
|
|
3386
3386
|
const CLIP_BRIDGE_DOC_TEXT = `# 本地剪藏桥(书签小工具)
|
|
3387
3387
|
|
|
3388
|
-
把任意网页**一键剪藏进 TiddlyWiki
|
|
3388
|
+
把任意网页**一键剪藏进 TiddlyWiki**:浏览器里点一下书签,会弹出一个小浮层——确认(或修改)标题与选中文字、**勾选想要的图片**,点「剪藏」即写入本插件的知识库(默认打 \`clip\` 标签,随 wiki 自动 git commit),之后随时可在 TW 里整理、让 Agent 检索。
|
|
3389
3389
|
|
|
3390
3390
|
## 原理
|
|
3391
3391
|
|
|
3392
|
-
DSH 进程里运行着一个只监听 **127.0.0.1**(本机回环)的 HTTP 小桥——端口默认 \`8618
|
|
3392
|
+
DSH 进程里运行着一个只监听 **127.0.0.1**(本机回环)的 HTTP 小桥——端口默认 \`8618\`,可在设置页改。书签浮层把 {标题, URL, 选中文字, 图片 URL 列表} POST 给它,小桥经插件唯一的写入通道写进 TiddlyWiki:
|
|
3393
|
+
|
|
3394
|
+
- **文字** → 一篇笔记(Markdown;选了图则自动改用 wikitext,图片用 \`[img[标题]]\` 内嵌展示);
|
|
3395
|
+
- **图片** → 由小桥(DSH 进程侧,无浏览器跨域限制)**下载字节**,存成**二进制附件 tiddler**(\`type: image/…\` + base64 正文,即 TW 原生二进制附件形态),同一篇笔记里内嵌显示并保留 \`clip-url\` / \`clip-note\` 溯源字段;
|
|
3396
|
+
- 某张图下载失败(防盗链/断链)→ **自动降级为链接**,不阻塞整次剪藏。
|
|
3393
3397
|
|
|
3394
3398
|
## 启用(三步)
|
|
3395
3399
|
|
|
@@ -3398,20 +3402,27 @@ DSH 进程里运行着一个只监听 **127.0.0.1**(本机回环)的 HTTP
|
|
|
3398
3402
|
3. **装书签**:浏览器书签栏新建书签,名称随意(如「剪藏」),**地址(URL)粘贴下面整段代码**。若设置了 token,把代码里 \`'x-clip-token': ''\` 的空字符串换成你的口令;若改了端口,把 \`127.0.0.1:8618\` 一起改掉:
|
|
3399
3403
|
|
|
3400
3404
|
\`\`\`javascript
|
|
3401
|
-
javascript:(()
|
|
3405
|
+
javascript:(function(){var t=(document.title||location.hostname).trim();var sel=(window.getSelection()?window.getSelection().toString():'').trim();var imgs=[],seen={};function add(u,a,c){u=String(u||'').trim();if(!u||u.indexOf('data:')===0||u.indexOf('blob:')===0||seen[u])return;seen[u]=1;imgs.push({u:u,a:(a||'').slice(0,60),c:!!c})}var mm=document.querySelector('meta[property="og:image"],meta[name="twitter:image"],link[rel="image_src"]');if(mm){var mu=mm.content||mm.href;if(mu)add(mu,'封面',true)}for(var ii=0;ii<document.images.length;ii++){var im=document.images[ii],iu=im.currentSrc||im.src;if(!iu)continue;if(im.naturalWidth&&im.naturalHeight&&(im.naturalWidth<80||im.naturalHeight<80))continue;add(iu,im.alt||im.title,false)}if(imgs.length>60)imgs=imgs.slice(0,60);var ov=document.createElement('div');ov.style.cssText='position:fixed;top:0;left:0;right:0;bottom:0;background:rgba(10,14,20,.62);z-index:2147483647;display:flex;align-items:flex-start;justify-content:center;padding:4vh 12px';var p=document.createElement('div');p.id='cb_p';p.style.cssText='background:#1c2128;border:1px solid #3a414c;border-radius:12px;max-width:680px;width:100%;max-height:86vh;overflow:auto;padding:16px 18px;color:#e6e6e6;font-family:system-ui,-apple-system,"Segoe UI",sans-serif;box-sizing:border-box';p.innerHTML='<div style="font-size:16px;font-weight:700">剪藏到 TiddlyWiki</div><label style="display:block;font-size:12px;opacity:.7;margin:10px 0 2px">标题</label><input id="cb_t" style="width:100%;box-sizing:border-box;background:#0f131a;border:1px solid #3a414c;border-radius:6px;color:#eee;padding:7px 9px;font-size:13px" value=""><label style="display:block;font-size:12px;opacity:.7;margin:10px 0 2px">正文 / 选中文字(可选)</label><textarea id="cb_s" rows="5" style="width:100%;box-sizing:border-box;background:#0f131a;border:1px solid #3a414c;border-radius:6px;color:#eee;padding:7px 9px;font-size:13px;resize:vertical"></textarea>';var Q=function(id){return p.querySelector('#'+id)};var grid=document.createElement('div');grid.style.cssText='display:grid;grid-template-columns:repeat(auto-fill,minmax(120px,1fr));gap:8px;margin:10px 0';for(var j=0;j<imgs.length;j++){(function(idx){var it=imgs[idx],lab=document.createElement('label');lab.style.cssText='display:flex;flex-direction:column;gap:4px;cursor:pointer;background:#0f131a;border:1px solid #3a414c;border-radius:8px;padding:6px;overflow:hidden';var top=document.createElement('div');top.style.cssText='position:relative';var imgE=document.createElement('img');imgE.style.cssText='display:block;width:100%;height:76px;object-fit:cover;border-radius:4px;background:#000';imgE.src=it.u;imgE.loading='lazy';imgE.referrerPolicy='no-referrer';top.appendChild(imgE);if(it.c){var bd=document.createElement('span');bd.style.cssText='position:absolute;top:2px;left:2px;background:#f5a623;color:#000;font-size:10px;padding:0 4px;border-radius:3px';bd.textContent='封面';top.appendChild(bd)}var row=document.createElement('div');row.style.cssText='display:flex;align-items:center;gap:6px';var ch=document.createElement('input');ch.type='checkbox';ch.id='cb_i_'+idx;ch.checked=it.c;var nm=document.createElement('span');nm.style.cssText='font-size:11px;opacity:.85;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1';nm.textContent=it.a||(it.u.split('/').pop()||'');nm.title=it.u;row.appendChild(ch);row.appendChild(nm);lab.appendChild(top);lab.appendChild(row);grid.appendChild(lab)})(j)};var foot=document.createElement('div');foot.style.cssText='display:flex;align-items:center;justify-content:space-between;margin-top:12px';var cnt=document.createElement('span');cnt.id='cb_cnt';cnt.style.cssText='font-size:12px;opacity:.75';foot.appendChild(cnt);var btns=document.createElement('div');btns.style.cssText='display:flex;gap:8px';var cancel=document.createElement('button');cancel.textContent='取消';cancel.style.cssText='cursor:pointer;background:#333a45;border:0;color:#eee;border-radius:6px;padding:7px 14px;font-size:13px';var go=document.createElement('button');go.id='cb_btn';go.textContent='剪藏';go.style.cssText='cursor:pointer;background:#2f6feb;border:0;color:#fff;border-radius:6px;padding:7px 14px;font-size:13px';btns.appendChild(cancel);btns.appendChild(go);foot.appendChild(btns);p.appendChild(grid);p.appendChild(foot);ov.appendChild(p);document.body.appendChild(ov);Q('cb_t').value=t;Q('cb_s').value=sel;function upd(){var n=0;for(var q=0;q<imgs.length;q++){var c=Q('cb_i_'+q);if(c&&c.checked)n++}cnt.textContent=(imgs.length?('共 '+imgs.length+' 张,已选 '+n+' 张'):'页面没有可选图片(仍可剪藏文字)')}upd();grid.onchange=function(){upd()};function close(){document.removeEventListener('keydown',onKey,true);if(ov.parentNode){document.body.removeChild(ov)}}var onKey=function(ev){if(ev.key==='Escape')close()};cancel.onclick=close;ov.onclick=function(ev){if(ev.target===ov)close()};p.onclick=function(ev){ev.stopPropagation()};document.addEventListener('keydown',onKey,true);function doClip(){var tt=(Q('cb_t').value||'').trim()||t;var urls=[];for(var k=0;k<imgs.length;k++){var ck=Q('cb_i_'+k);if(ck&&ck.checked)urls.push(imgs[k].u)}var btn=Q('cb_btn');btn.disabled=true;btn.textContent='剪藏中…';fetch('http://127.0.0.1:8618/clip',{method:'POST',headers:{'content-type':'application/json','x-clip-token':''},body:JSON.stringify({title:tt,url:location.href,text:(Q('cb_s').value||''),images:urls})}).then(function(r){return r.json().then(function(j){return {ok:r.ok&&!!j.ok,error:(j.error||('HTTP '+r.status)),title:j.title,images:j.images||[]}})}).then(function(res){var okN=0;for(var v=0;v<res.images.length;v++)if(res.images[v].ok)okN++;var line=res.ok?('已剪藏:'+(res.title||tt)+(res.images.length?(' 图片 '+okN+'/'+res.images.length+' 成功'):'')):('剪藏失败:'+res.error);p.innerHTML='<div style="font-size:15px;font-weight:700;margin:8px 0">'+line+'</div><div style="font-size:12px;opacity:.8;margin-bottom:12px">图片以附件形式存进知识库(失败项已降级为链接)。</div><button id="cb_d" style="cursor:pointer;background:#2f6feb;border:0;color:#fff;border-radius:6px;padding:7px 14px;font-size:13px">完成</button>';Q('cb_d').onclick=close}).catch(function(e){p.innerHTML='<div style="font-size:14px;color:#ff8080;font-weight:700;margin:8px 0">剪藏桥不可达</div><div style="font-size:12px;opacity:.85;margin-bottom:12px">'+(e&&e.message?e.message:'')+' 请确认:① dsh web 在运行;② 设置页已启用「本地剪藏桥」;③ 书签里的端口与设置一致;④ 设置了 token 的话书签已带上。</div><button id="cb_d" style="cursor:pointer;background:#333a45;border:0;color:#fff;border-radius:6px;padding:7px 14px;font-size:13px">关闭</button>';Q('cb_d').onclick=close})}go.onclick=doClip})()
|
|
3402
3406
|
\`\`\`
|
|
3403
3407
|
|
|
3404
3408
|
> 部分浏览器禁止页面「拖链接到书签栏」,复制粘贴到书签的地址栏是最稳的方式。
|
|
3405
3409
|
|
|
3406
3410
|
## 使用
|
|
3407
3411
|
|
|
3408
|
-
打开任意网页 → 选中想保留的文字(可选)→ 点书签 →
|
|
3412
|
+
打开任意网页 → 选中想保留的文字(可选)→ 点书签 → 浮层里:
|
|
3413
|
+
|
|
3414
|
+
- **标题**可改;**正文**自动填入选中的文字(可编辑);
|
|
3415
|
+
- **图片区**列出页面图片的缩略图(自动跳过 <80px 的小图标与 data:/blob: 占位,去重;页面声明了 \`og:image\` 的会标「封面」并默认勾选)——勾选想剪的(最多 10 张);
|
|
3416
|
+
- 点「**剪藏**」→ 提示「已剪藏:〈标题〉(图片 n/m 成功)」。
|
|
3417
|
+
|
|
3418
|
+
笔记以**页面标题**命名(重名自动追加 \`(2)\`、\`(3)\`…,绝不覆盖已有笔记)。**图片是二进制附件**(打开笔记直接看到图;每张带 \`clip-url\`、\`clip-note\` 字段溯源);某张图没存成(网站防盗链等)会在笔记里留一行链接。可用「发送给 Agent」一键把整篇丢给会话整理。
|
|
3409
3419
|
|
|
3410
3420
|
## 安全说明
|
|
3411
3421
|
|
|
3412
3422
|
- 桥**只监听 127.0.0.1**,并校验请求的 \`Host\` 头(只放行 127.0.0.1 / localhost / ::1)——恶意网页即使把自己的域名解析到本机回环(DNS rebinding)也调不动它。
|
|
3413
3423
|
- **强烈建议设置 \`bridge.token\`**:不设 token 时,你在浏览器里访问的**任何网页**都能向本机桥提交内容(它们读不到回应,但能往 wiki 里写——只是污染笔记,不是执行代码)。token 只有你知道,网页拿不到。
|
|
3414
|
-
-
|
|
3424
|
+
- 图片**只下载你勾选的那几个 URL**,由 DSH 本机发起(无跨域限制),存进的是字节副本;不做任何代码执行。
|
|
3425
|
+
- 图片附件**不参与 \`search\` / \`recent\`**(按设计排除二进制,避免刷屏);\`get\` 对附件只返回元数据。想删某张图,直接在 TW 里删除对应 tiddler 即可。
|
|
3415
3426
|
|
|
3416
3427
|
## 配置项
|
|
3417
3428
|
|
|
@@ -3424,20 +3435,21 @@ javascript:(()=>{var t=(document.title||location.hostname).trim();var s=(window.
|
|
|
3424
3435
|
|
|
3425
3436
|
## 命令行/脚本方式(可选)
|
|
3426
3437
|
|
|
3427
|
-
不想用书签时,直接 POST 也行(适合脚本、API
|
|
3438
|
+
不想用书签时,直接 POST 也行(适合脚本、API 调用;\`images\` 字段可选,传了就存附件):
|
|
3428
3439
|
|
|
3429
3440
|
\`\`\`bash
|
|
3430
3441
|
curl -X POST http://127.0.0.1:8618/clip ^
|
|
3431
3442
|
-H "content-type: application/json" ^
|
|
3432
3443
|
-H "x-clip-token: 你的token(没设置就删掉这行)" ^
|
|
3433
|
-
-d "{\\"title\\":\\"测试\\",\\"url\\":\\"https://example.com\\",\\"text\\":\\"测试选中文字\\"}"
|
|
3444
|
+
-d "{\\"title\\":\\"测试\\",\\"url\\":\\"https://example.com\\",\\"text\\":\\"测试选中文字\\",\\"images\\":[\\"https://example.com/a.png\\"]}"
|
|
3434
3445
|
\`\`\`
|
|
3435
3446
|
|
|
3436
|
-
成功返回 \`{"ok":true,"title":"测试"
|
|
3447
|
+
成功返回 \`{"ok":true,"title":"测试",...,"images":[{"url":"...","ok":true,"title":"测试 图片 1.png"}]}\`;未启用时返回 503。
|
|
3437
3448
|
|
|
3438
3449
|
## 常见问题
|
|
3439
3450
|
|
|
3440
3451
|
- 点书签提示「剪藏桥不可达」:① dsh web 没在运行;② 设置页没勾选启用;③ 书签里的端口和设置不一致;④ 设置了 token 但书签没带上。
|
|
3452
|
+
- 图片显示「× 失败」:多为网站防盗链/禁止外链下载,笔记里会留链接兜底;也可先在浏览器里把图片另存,再手动拖进 TW。
|
|
3441
3453
|
- 端口被占用:换一个 \`bridge.port\` 后**重启 dsh web**。
|
|
3442
3454
|
- 想彻底关掉:设置页取消勾选保存即可(端口虽在监听但拒绝任何写入),或直接改插件行 config 的 \`bridge.enabled: false\`。
|
|
3443
3455
|
`;
|
|
@@ -4145,10 +4157,20 @@ function parseSince(since) {
|
|
|
4145
4157
|
* 本地剪藏桥(bookmarklet 剪藏小工具的后端)。
|
|
4146
4158
|
*
|
|
4147
4159
|
* A tiny loopback-only HTTP bridge that turns a browser bookmarklet's
|
|
4148
|
-
* "剪藏" request into
|
|
4149
|
-
* browser's bookmarks bar) POSTs { title, url, text } to the bridge;
|
|
4150
|
-
* bridge writes
|
|
4151
|
-
*
|
|
4160
|
+
* "剪藏" request into TiddlyWiki tiddlers. The bookmarklet (JS in the
|
|
4161
|
+
* browser's bookmarks bar) POSTs { title, url, text, images } to the bridge;
|
|
4162
|
+
* the bridge writes ONE note tiddler (markdown, or wikitext when images are
|
|
4163
|
+
* stored) plus optional IMAGE ATTACHMENTS through the SAME TiddlyWebClient
|
|
4164
|
+
* every other writer uses (D1 — there is never a second write path).
|
|
4165
|
+
*
|
|
4166
|
+
* Images (v0.16.25): TW 5.4.1's REST facade is JSON-only (put-tiddler.js
|
|
4167
|
+
* JSON.parses the body — there is no binary upload route), but its binary
|
|
4168
|
+
* tiddlers ARE representable as `{ type: image/*, text: <base64> }` — verified
|
|
4169
|
+
* empirically: type + base64 roundtrip byte-exact, tags survive, custom fields
|
|
4170
|
+
* nest under `fields`. So the bridge downloads each chosen image (server-side,
|
|
4171
|
+
* no CORS; referer + UA sent for hotlink-protected CDNs), stores it as a
|
|
4172
|
+
* binary ATTACHMENT tiddler, and embeds it in the note with `[img[Title]]`.
|
|
4173
|
+
* A failed download degrades gracefully to a plain source-URL line.
|
|
4152
4174
|
*
|
|
4153
4175
|
* Security posture (deliberate):
|
|
4154
4176
|
* - binds `127.0.0.1` ONLY — never reachable from the network;
|
|
@@ -4199,7 +4221,7 @@ async function resolveClipTitle(exists, base) {
|
|
|
4199
4221
|
}
|
|
4200
4222
|
return `${base}(${(/* @__PURE__ */ new Date()).toISOString().slice(0, 19).replace(/[T:]/g, "-")})`;
|
|
4201
4223
|
}
|
|
4202
|
-
/** Build the markdown tiddler for
|
|
4224
|
+
/** Build the markdown tiddler for ONE TEXT-ONLY clip (no images). */
|
|
4203
4225
|
function buildClipTiddler(opts) {
|
|
4204
4226
|
const at = opts.at ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4205
4227
|
const selection = typeof opts.text === "string" ? opts.text.trim() : "";
|
|
@@ -4217,10 +4239,90 @@ function buildClipTiddler(opts) {
|
|
|
4217
4239
|
"clip-at": at
|
|
4218
4240
|
};
|
|
4219
4241
|
}
|
|
4242
|
+
/**
|
|
4243
|
+
* Build the note tiddler for a clip WITH stored images: wikitext so the
|
|
4244
|
+
* attachments render inline via `[img[Title]]` (works in the embedded TW,
|
|
4245
|
+
* story view and /tw/render). Failed downloads degrade to plain URL lines.
|
|
4246
|
+
*/
|
|
4247
|
+
function buildImageNoteTiddler(opts) {
|
|
4248
|
+
const at = opts.at ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4249
|
+
const selection = typeof opts.text === "string" ? opts.text.trim() : "";
|
|
4250
|
+
const lines = [`> 来源:${opts.url}`, ""];
|
|
4251
|
+
if (selection.length > 0) lines.push(selection, "");
|
|
4252
|
+
lines.push("---", "!! 图片");
|
|
4253
|
+
for (const title of opts.stored) lines.push(`[img[${title}]]`);
|
|
4254
|
+
for (const f of opts.failed) lines.push(`* ${f.url}(${f.error})`);
|
|
4255
|
+
lines.push("", "---", `剪藏时间:${at}`);
|
|
4256
|
+
const tags = [opts.tag, ...opts.extraTags ?? []].filter((t) => t.trim().length > 0);
|
|
4257
|
+
return {
|
|
4258
|
+
title: opts.title,
|
|
4259
|
+
text: lines.join("\n"),
|
|
4260
|
+
type: "text/vnd.tiddlywiki",
|
|
4261
|
+
...tags.length > 0 ? { tags } : {},
|
|
4262
|
+
"clip-url": opts.url,
|
|
4263
|
+
"clip-source": opts.source ?? "bookmarklet",
|
|
4264
|
+
"clip-at": at
|
|
4265
|
+
};
|
|
4266
|
+
}
|
|
4267
|
+
/** Build a binary attachment tiddler: `type: <mime>`, `text: <base64>`. */
|
|
4268
|
+
function buildBinaryTiddler(opts) {
|
|
4269
|
+
const at = opts.at ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
4270
|
+
const tags = [opts.tag].filter((t) => t.trim().length > 0);
|
|
4271
|
+
return {
|
|
4272
|
+
title: opts.title,
|
|
4273
|
+
type: opts.mime,
|
|
4274
|
+
text: Buffer.from(opts.buffer).toString("base64"),
|
|
4275
|
+
...tags.length > 0 ? { tags } : {},
|
|
4276
|
+
"clip-url": opts.srcUrl,
|
|
4277
|
+
"clip-source": opts.source ?? "bookmarklet",
|
|
4278
|
+
"clip-at": at,
|
|
4279
|
+
"clip-note": opts.noteTitle
|
|
4280
|
+
};
|
|
4281
|
+
}
|
|
4282
|
+
/** Known image extensions → mime (used when a CDN mislabels as octet-stream). */
|
|
4283
|
+
const IMAGE_EXT_MIME = {
|
|
4284
|
+
png: "image/png",
|
|
4285
|
+
jpg: "image/jpeg",
|
|
4286
|
+
jpeg: "image/jpeg",
|
|
4287
|
+
gif: "image/gif",
|
|
4288
|
+
webp: "image/webp",
|
|
4289
|
+
svg: "image/svg+xml",
|
|
4290
|
+
avif: "image/avif",
|
|
4291
|
+
bmp: "image/bmp",
|
|
4292
|
+
ico: "image/x-icon"
|
|
4293
|
+
};
|
|
4294
|
+
/**
|
|
4295
|
+
* Decide the stored mime for a downloaded image: an `image/*` content-type
|
|
4296
|
+
* wins; otherwise a known image extension in the URL rescues CDNs that serve
|
|
4297
|
+
* `application/octet-stream`. null = not (recognizably) an image.
|
|
4298
|
+
*/
|
|
4299
|
+
function pickImageMime(contentType, url) {
|
|
4300
|
+
const ct = ((contentType ?? "").split(";")[0] ?? "").trim().toLowerCase();
|
|
4301
|
+
if (ct.startsWith("image/")) return ct;
|
|
4302
|
+
const ext = ((url.split("?")[0] ?? "").split(".").pop() ?? "").toLowerCase();
|
|
4303
|
+
if (ct === "application/octet-stream" && IMAGE_EXT_MIME[ext] !== void 0) return IMAGE_EXT_MIME[ext];
|
|
4304
|
+
return null;
|
|
4305
|
+
}
|
|
4306
|
+
/** File extension for a stored image mime. */
|
|
4307
|
+
function imageExtensionForMime(mime) {
|
|
4308
|
+
const map = {
|
|
4309
|
+
"image/png": "png",
|
|
4310
|
+
"image/jpeg": "jpg",
|
|
4311
|
+
"image/gif": "gif",
|
|
4312
|
+
"image/webp": "webp",
|
|
4313
|
+
"image/svg+xml": "svg",
|
|
4314
|
+
"image/avif": "avif",
|
|
4315
|
+
"image/bmp": "bmp",
|
|
4316
|
+
"image/x-icon": "ico"
|
|
4317
|
+
};
|
|
4318
|
+
if (map[mime] !== void 0) return map[mime];
|
|
4319
|
+
const subtype = (mime.split("/")[1] ?? "img").replace(/[^a-z0-9]/gi, "");
|
|
4320
|
+
return subtype.length > 0 ? subtype.slice(0, 8) : "img";
|
|
4321
|
+
}
|
|
4220
4322
|
function cleanStr(value) {
|
|
4221
4323
|
return typeof value === "string" ? value : "";
|
|
4222
4324
|
}
|
|
4223
|
-
/** Validate a clip payload →
|
|
4325
|
+
/** Validate a clip payload → normalized value or an error. */
|
|
4224
4326
|
function parseClipPayload(body) {
|
|
4225
4327
|
if (typeof body !== "object" || body === null || Array.isArray(body)) return {
|
|
4226
4328
|
ok: false,
|
|
@@ -4233,6 +4335,8 @@ function parseClipPayload(body) {
|
|
|
4233
4335
|
const source = cleanStr(raw.source).trim().slice(0, 40);
|
|
4234
4336
|
let extraTags = [];
|
|
4235
4337
|
if (Array.isArray(raw.tags)) extraTags = raw.tags.filter((t) => typeof t === "string").map((t) => t.trim()).filter(Boolean).slice(0, 10);
|
|
4338
|
+
let images = [];
|
|
4339
|
+
if (Array.isArray(raw.images)) images = raw.images.filter((u) => typeof u === "string").map((u) => u.trim()).filter((u) => u.length > 0 && u.length <= 2e3).slice(0, 10);
|
|
4236
4340
|
if (title.length === 0) return {
|
|
4237
4341
|
ok: false,
|
|
4238
4342
|
error: "缺少 title(页面标题)"
|
|
@@ -4248,7 +4352,8 @@ function parseClipPayload(body) {
|
|
|
4248
4352
|
url,
|
|
4249
4353
|
text,
|
|
4250
4354
|
tags: extraTags,
|
|
4251
|
-
source
|
|
4355
|
+
source,
|
|
4356
|
+
images
|
|
4252
4357
|
}
|
|
4253
4358
|
};
|
|
4254
4359
|
}
|
|
@@ -4398,7 +4503,7 @@ var ClipBridge = class {
|
|
|
4398
4503
|
}, 400);
|
|
4399
4504
|
return;
|
|
4400
4505
|
}
|
|
4401
|
-
const { title, url, text, tags, source } = parsed.value;
|
|
4506
|
+
const { title, url, text, tags, source, images } = parsed.value;
|
|
4402
4507
|
let resolvedTitle;
|
|
4403
4508
|
try {
|
|
4404
4509
|
resolvedTitle = await resolveClipTitle((t) => this.deps.exists(t), title);
|
|
@@ -4409,16 +4514,71 @@ var ClipBridge = class {
|
|
|
4409
4514
|
}, 503);
|
|
4410
4515
|
return;
|
|
4411
4516
|
}
|
|
4412
|
-
const
|
|
4517
|
+
const at = (/* @__PURE__ */ new Date()).toISOString();
|
|
4518
|
+
const sourceName = source.length > 0 ? source : "bookmarklet";
|
|
4519
|
+
const stored = [];
|
|
4520
|
+
const failed = [];
|
|
4521
|
+
const imageResults = [];
|
|
4522
|
+
for (let i = 0; i < images.length; i++) {
|
|
4523
|
+
const imageUrl = images[i];
|
|
4524
|
+
if (imageUrl === void 0) continue;
|
|
4525
|
+
try {
|
|
4526
|
+
const { buffer, type } = await this.deps.download(imageUrl, url);
|
|
4527
|
+
if (buffer.length > 15728640) throw new Error("图片超过 15MB 上限");
|
|
4528
|
+
const mime = pickImageMime(type, imageUrl);
|
|
4529
|
+
if (mime === null) throw new Error("内容不是可识别的图片");
|
|
4530
|
+
const ext = imageExtensionForMime(mime);
|
|
4531
|
+
const imageTitle = await resolveClipTitle((t) => this.deps.exists(t), `${resolvedTitle} 图片 ${i + 1}.${ext}`);
|
|
4532
|
+
await this.deps.write(buildBinaryTiddler({
|
|
4533
|
+
title: imageTitle,
|
|
4534
|
+
mime,
|
|
4535
|
+
buffer,
|
|
4536
|
+
srcUrl: imageUrl,
|
|
4537
|
+
noteTitle: resolvedTitle,
|
|
4538
|
+
tag: cfg.tag,
|
|
4539
|
+
source: sourceName,
|
|
4540
|
+
at
|
|
4541
|
+
}));
|
|
4542
|
+
stored.push(imageTitle);
|
|
4543
|
+
imageResults.push({
|
|
4544
|
+
url: imageUrl,
|
|
4545
|
+
ok: true,
|
|
4546
|
+
title: imageTitle
|
|
4547
|
+
});
|
|
4548
|
+
} catch (err) {
|
|
4549
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
4550
|
+
failed.push({
|
|
4551
|
+
url: imageUrl,
|
|
4552
|
+
error: message
|
|
4553
|
+
});
|
|
4554
|
+
imageResults.push({
|
|
4555
|
+
url: imageUrl,
|
|
4556
|
+
ok: false,
|
|
4557
|
+
error: message
|
|
4558
|
+
});
|
|
4559
|
+
}
|
|
4560
|
+
}
|
|
4561
|
+
const noteTiddler = stored.length > 0 || images.length > 0 ? buildImageNoteTiddler({
|
|
4413
4562
|
title: resolvedTitle,
|
|
4414
4563
|
url,
|
|
4415
4564
|
...text.length > 0 ? { text } : {},
|
|
4416
4565
|
tag: cfg.tag,
|
|
4417
|
-
source:
|
|
4566
|
+
source: sourceName,
|
|
4567
|
+
at,
|
|
4568
|
+
stored,
|
|
4569
|
+
failed,
|
|
4570
|
+
extraTags: tags
|
|
4571
|
+
}) : buildClipTiddler({
|
|
4572
|
+
title: resolvedTitle,
|
|
4573
|
+
url,
|
|
4574
|
+
...text.length > 0 ? { text } : {},
|
|
4575
|
+
tag: cfg.tag,
|
|
4576
|
+
source: sourceName,
|
|
4577
|
+
at,
|
|
4418
4578
|
extraTags: tags
|
|
4419
4579
|
});
|
|
4420
4580
|
try {
|
|
4421
|
-
await this.deps.write(
|
|
4581
|
+
await this.deps.write(noteTiddler);
|
|
4422
4582
|
} catch {
|
|
4423
4583
|
this.respond(res, {
|
|
4424
4584
|
ok: false,
|
|
@@ -4431,7 +4591,8 @@ var ClipBridge = class {
|
|
|
4431
4591
|
title: resolvedTitle,
|
|
4432
4592
|
url,
|
|
4433
4593
|
tag: cfg.tag,
|
|
4434
|
-
source:
|
|
4594
|
+
source: sourceName,
|
|
4595
|
+
images: imageResults
|
|
4435
4596
|
});
|
|
4436
4597
|
}
|
|
4437
4598
|
};
|
|
@@ -5674,6 +5835,25 @@ function apply(ctx, rawConfig = {}) {
|
|
|
5674
5835
|
if (c === void 0) throw new Error("wiki not ready");
|
|
5675
5836
|
return await c.get(title) !== void 0;
|
|
5676
5837
|
},
|
|
5838
|
+
download: async (imageUrl, referer) => {
|
|
5839
|
+
const res = await fetch(imageUrl, {
|
|
5840
|
+
headers: {
|
|
5841
|
+
"user-agent": "Mozilla/5.0 (compatible; dsh-tiddlywiki clip bridge)",
|
|
5842
|
+
referer,
|
|
5843
|
+
accept: "image/*,*/*;q=0.8"
|
|
5844
|
+
},
|
|
5845
|
+
redirect: "follow",
|
|
5846
|
+
signal: AbortSignal.timeout(2e4)
|
|
5847
|
+
});
|
|
5848
|
+
if (!res.ok) throw new Error(`下载失败 HTTP ${res.status}`);
|
|
5849
|
+
const buffer = Buffer.from(await res.arrayBuffer());
|
|
5850
|
+
if (buffer.length === 0) throw new Error("下载内容为空");
|
|
5851
|
+
if (buffer.length > 15728640) throw new Error("图片超过 15MB 上限");
|
|
5852
|
+
return {
|
|
5853
|
+
buffer,
|
|
5854
|
+
type: res.headers.get("content-type")
|
|
5855
|
+
};
|
|
5856
|
+
},
|
|
5677
5857
|
log: (m) => console.info("[dsh-tiddlywiki] clip bridge:", m)
|
|
5678
5858
|
});
|
|
5679
5859
|
ctx.effect(() => () => clipBridge.stop(), "dsh-tiddlywiki: clip bridge");
|
|
@@ -5824,6 +6004,6 @@ function apply(ctx, rawConfig = {}) {
|
|
|
5824
6004
|
}, "dsh-tiddlywiki: host teardown");
|
|
5825
6005
|
}
|
|
5826
6006
|
//#endregion
|
|
5827
|
-
export { ALL_ARTICLES_MARKER_TITLE, ALL_ARTICLES_TEXT, ALL_ARTICLES_TITLE, AutoCommitter, CLIP_BRIDGE_DEFAULT_PORT, CLIP_BRIDGE_DOC_TEXT, CLIP_BRIDGE_DOC_TITLE, CLIP_BRIDGE_MARKER_TITLE, ClipBridge, ConfigStore, DOC_NOTE_TAG, DOC_NOTE_TEXT, DOC_NOTE_TITLE, DSH_DOCS_TAG, GitFace, HOME_DEFAULT_TIDDLERS, HOME_INDEX_ITEMS, HOME_INDEX_MARKER_TITLE, MENUBAR_THEME_MARKER_TITLE, MENUBAR_THEME_TEXT, MENUBAR_THEME_TIDDLER, PATH_PREFIX, RENDER_BUNDLE_TEXT, RENDER_MARKER_TITLE, RENDER_PLUGIN_TITLE, SEED_DEFS, 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, TW_PROXY_PATH, TW_PROXY_PREFIX, TW_WEB_HOST_DEFAULT, TW_WEB_HOST_TIDDLER, TiddlyWebClient, UI_STYLES_MARKER_TITLE, UI_STYLE_ITEMS, WikiServer, apply, buildClipTiddler, bundledCatalog, checkAllSeeds, deepMerge, defineTool, dshHomePath, ensureLanguage, ensureTwWebHost, hostAllowed, inject, isBinaryType, name, normalizeThemes, openInTwEditor, parseClipPayload, readWikiInfo, registerAdminRoutes, registerRoutes, registerTiddlywikiTools, removeSeedById, resolveClipTitle, resolveTwRoot, runAllSeeds, runSeedById, seedAllArticles, seedClipBridge, seedDocNote, seedHomeIndex, seedMenubarTheme, seedRenderRoute, seedSendToAgent, seedStarterDocs, seedUiStyles, unseedClipBridge, writeSessionSummary, writeWikiInfo };
|
|
6007
|
+
export { ALL_ARTICLES_MARKER_TITLE, ALL_ARTICLES_TEXT, ALL_ARTICLES_TITLE, AutoCommitter, CLIP_BRIDGE_DEFAULT_PORT, CLIP_BRIDGE_DOC_TEXT, CLIP_BRIDGE_DOC_TITLE, CLIP_BRIDGE_MARKER_TITLE, ClipBridge, ConfigStore, DOC_NOTE_TAG, DOC_NOTE_TEXT, DOC_NOTE_TITLE, DSH_DOCS_TAG, GitFace, HOME_DEFAULT_TIDDLERS, HOME_INDEX_ITEMS, HOME_INDEX_MARKER_TITLE, MENUBAR_THEME_MARKER_TITLE, MENUBAR_THEME_TEXT, MENUBAR_THEME_TIDDLER, PATH_PREFIX, RENDER_BUNDLE_TEXT, RENDER_MARKER_TITLE, RENDER_PLUGIN_TITLE, SEED_DEFS, 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, TW_PROXY_PATH, TW_PROXY_PREFIX, TW_WEB_HOST_DEFAULT, TW_WEB_HOST_TIDDLER, TiddlyWebClient, UI_STYLES_MARKER_TITLE, UI_STYLE_ITEMS, WikiServer, apply, buildBinaryTiddler, buildClipTiddler, buildImageNoteTiddler, bundledCatalog, checkAllSeeds, deepMerge, defineTool, dshHomePath, ensureLanguage, ensureTwWebHost, hostAllowed, imageExtensionForMime, inject, isBinaryType, name, normalizeThemes, openInTwEditor, parseClipPayload, pickImageMime, readWikiInfo, registerAdminRoutes, registerRoutes, registerTiddlywikiTools, removeSeedById, resolveClipTitle, resolveTwRoot, runAllSeeds, runSeedById, seedAllArticles, seedClipBridge, seedDocNote, seedHomeIndex, seedMenubarTheme, seedRenderRoute, seedSendToAgent, seedStarterDocs, seedUiStyles, unseedClipBridge, writeSessionSummary, writeWikiInfo };
|
|
5828
6008
|
|
|
5829
6009
|
//# sourceMappingURL=index.js.map
|