@chance722/dsh-inbox 0.2.5 → 0.2.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/README.md +39 -55
- package/README.zh.md +36 -53
- package/cordis.patch.yml +21 -0
- package/lib/cli.js +1 -0
- package/lib/client.js +4 -24
- package/lib/index.js +201 -43
- package/lib/types/client/i18n.d.ts +0 -11
- package/lib/types/client/messages.d.ts +2 -12
- package/lib/types/host/link-title.d.ts +25 -1
- package/lib/types/host/remote/merge.d.ts +10 -0
- package/lib/types/host/vault/spec.d.ts +38 -0
- package/lib/types/host/vault/vault.d.ts +20 -2
- package/lib/types/shared/panel-wire.d.ts +13 -2
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -6,45 +6,41 @@ A **local inbox plugin for [DeepSeek Harness](https://github.com/deepseek-ai/dee
|
|
|
6
6
|
|
|
7
7
|
English | [中文](README.zh.md)
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## What it is
|
|
10
10
|
|
|
11
|
-
The things you copy
|
|
11
|
+
The things you copy in a day — a link to read later, a screenshot, a config snippet, an account and password — end up scattered across clipboard history, bookmarks and temp files. dsh-inbox keeps them in one local vault: paste to file, automatic classification, browse and search from the sidebar — and instead of digging through it yourself, ask your assistant "what was that article about caching I saved last month?".
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
It works for you alone: everything lands on your machine, and the model only sees a record when you ask it to look.
|
|
13
|
+
Everything lands on your machine, and the model only sees a record when you ask it to look.
|
|
16
14
|
|
|
17
15
|
## Features
|
|
18
16
|
|
|
19
17
|
| Feature | What it does |
|
|
20
18
|
|---|---|
|
|
21
19
|
| **Two ways in** | `/inbox <text or link>` in the composer (attach images to carry them along), or paste / drop / pick a file in the panel |
|
|
22
|
-
| **Automatic classification** | Links
|
|
23
|
-
| **Panel** | Filter by watch-later / category / tag, search
|
|
24
|
-
| **
|
|
25
|
-
| **Who judged the category** | A coloured badge
|
|
26
|
-
| **Ask in conversation** | Ask for 收件箱 / 仓库 / inbox and the assistant searches by words, category, tag, watch-later flag or kind (ten at a time plus a count
|
|
27
|
-
| **Looking at a picture** | Image bytes stay out of the conversation
|
|
28
|
-
| **Credentials are safe** |
|
|
29
|
-
| **Two-way sync** | Point it at a WebDAV folder or an S3 bucket: changes
|
|
30
|
-
| **Conversation cards** | Tool results render as dsh-inbox cards — links
|
|
31
|
-
| **Gateway quirks** | Some object-storage gateways bind each AccessKey to an "application" and identify clients by a header
|
|
20
|
+
| **Automatic classification** | Links by platform and media type (60-odd sites: Bilibili, YouTube, WeChat, 掘金, Zhihu…), text by credential shape, images get a 疑似证件 tag at card proportions; the rest goes to the model, with a daily cap |
|
|
21
|
+
| **Panel** | Filter by watch-later / category / tag, search title, text, link and note, two list densities; the detail pane edits name, category, note and tags, flags watch-later, deletes and restores — and it follows dsh's theme **and language** |
|
|
22
|
+
| **Every record has a name** | A link takes the page's own headline (no model tokens), a photo or file its file name, and the name you type always wins. A page that will not be read — an anti-bot page, a dead link — leaves the address as the name rather than a guess |
|
|
23
|
+
| **Who judged the category** | A coloured badge: 规则判定 (local rules) / 模型判定 (the capped model pass) / 手动判定 (yours — nothing overwrites it later) |
|
|
24
|
+
| **Ask in conversation** | Ask for 收件箱 / 仓库 / inbox and the assistant searches by words, category, tag, watch-later flag or kind (ten at a time plus a count, thumbnails inline) and opens one by id (text up to 1000 characters, link, note, tags, attachments). 「打开 ↗」 under an answer jumps to that record in the 仓库 tab |
|
|
25
|
+
| **Looking at a picture** | Image bytes stay out of the conversation; ask the assistant to look at one and that single image is sent — explicitly, per call |
|
|
26
|
+
| **Credentials are safe** | The body is encrypted at rest, with a key derived from your master password — neither is ever written down. The list shows only the name you gave it, and plain text never reaches a conversation or a model |
|
|
27
|
+
| **Two-way sync** | Point it at a WebDAV folder or an S3 bucket: changes push a few seconds later, 刷新 runs a full push-then-pull merged per record by `id` + timestamp, and emptying the recycle bin deletes the cloud copies too. Two machines see each other when they share one 「目录」 |
|
|
28
|
+
| **Conversation cards** | Tool results render as dsh-inbox cards — links clickable, image markers drawn as thumbnails on your machine |
|
|
29
|
+
| **Gateway quirks** | Some object-storage gateways bind each AccessKey to an "application" and identify clients by a header — so the plugin keeps **one client identity per protocol** |
|
|
32
30
|
|
|
33
31
|
## Screenshots
|
|
34
32
|
|
|
35
|
-

|
|
36
|
-
|
|
37
|
-
It all lives inside dsh: the left rail gains an **Inbox** entry that opens a full-page vault, and the panel's top right has 设置 (settings) and 使用手册 (a short manual).
|
|
33
|
+

|
|
38
34
|
|
|
39
35
|
## Two ways to use it
|
|
40
36
|
|
|
41
37
|
**① File something from the conversation**
|
|
42
38
|
|
|
43
|
-
|
|
39
|
+
`/inbox` in the composer, followed by text or a link, with images attached right there — **this never reaches the model**, it only goes into the vault. That is the way to file credentials and throwaway links.
|
|
44
40
|
|
|
45
41
|
**② Ask for it later**
|
|
46
42
|
|
|
47
|
-
No command
|
|
43
|
+
No command, just talk:
|
|
48
44
|
|
|
49
45
|
> which of my saved images is the mini-program code?
|
|
50
46
|
>
|
|
@@ -54,46 +50,33 @@ No command needed, just talk:
|
|
|
54
50
|
|
|
55
51
|
Two questions in the same conversation, on a real machine:
|
|
56
52
|
|
|
57
|
-

|
|
58
54
|
|
|
59
|
-

|
|
60
56
|
|
|
61
57
|
## Install
|
|
62
58
|
|
|
63
|
-
Needs Node ≥ 22 and a working `dsh
|
|
64
|
-
|
|
65
|
-
**Platform**: fully accepted on **Windows** only so far; macOS and Linux are **not verified yet** (no platform-specific dependency in the code — try it and tell me how it goes).
|
|
66
|
-
|
|
67
|
-
`dsh web` is just `dsh --profile web`, so install into the profile you already start:
|
|
59
|
+
Needs Node ≥ 22 and a working `dsh`; the installer brings pnpm along when your machine does not have it. **Windows only so far** — macOS and Linux are unverified.
|
|
68
60
|
|
|
69
61
|
```powershell
|
|
62
|
+
# install (`dsh web` is `dsh --profile web`, so this is the profile you already start)
|
|
70
63
|
npx @chance722/dsh-inbox init --profile web --install-pnpm
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
Then start dsh the way you always do — `dsh web`. The **Inbox** entry is in the left rail, and a new session's assistant can look things up for you ("what links in my inbox haven't I read yet?").
|
|
74
|
-
|
|
75
|
-
On a machine that has never run dsh there is no `web` profile yet; `--create-profile` makes it first:
|
|
76
64
|
|
|
77
|
-
|
|
65
|
+
# never run dsh on this machine? create the profile in the same command
|
|
78
66
|
npx @chance722/dsh-inbox init --profile web --create-profile --install-pnpm
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
`init` does three things, and running it twice is safe:
|
|
82
67
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
68
|
+
# update (init only installs and wires things up — re-running it never upgrades;
|
|
69
|
+
# minutes after a release, write the exact version instead: @0.2.7)
|
|
70
|
+
dsh plugin --profile web add @chance722/dsh-inbox@latest
|
|
71
|
+
```
|
|
86
72
|
|
|
87
|
-
|
|
73
|
+
**Restart dsh** afterwards, then start it as usual. **Inbox** is in the left rail, and a new session's assistant can look things up ("what links in my inbox haven't I read yet?").
|
|
88
74
|
|
|
89
|
-
|
|
75
|
+
`init` also copies dsh's shipped `standard` preset to `~/.dsh/.agent-presets/inbox/` with this plugin added, and points your default preset at it — that is what lets the assistant see the inbox tools. It becomes a snapshot that will not follow later dsh upgrades; both it and the profile are reversible (see Uninstall).
|
|
90
76
|
|
|
91
|
-
|
|
92
|
-
npx @chance722/dsh-inbox init --create-profile # an isolated `inbox` profile
|
|
93
|
-
dsh --profile inbox --no-open --port 3102 # start it there
|
|
94
|
-
```
|
|
77
|
+
To keep your daily dsh untouched, give the plugin its own profile and port: `npx @chance722/dsh-inbox init --create-profile`, then `dsh --profile inbox --no-open --port 3102`.
|
|
95
78
|
|
|
96
|
-
Other flags: `--profile <name
|
|
79
|
+
Other flags: `--profile <name>`, `--install-pnpm`, `--no-default`, `--help`.
|
|
97
80
|
|
|
98
81
|
### From a local checkout
|
|
99
82
|
|
|
@@ -123,7 +106,7 @@ rm -r ~/.dsh/profiles/<that profile>
|
|
|
123
106
|
|
|
124
107
|
## Where things live
|
|
125
108
|
|
|
126
|
-
|
|
109
|
+
Everything under `%DSH_HOME%` (`C:\Users\<you>\.dsh` on Windows):
|
|
127
110
|
|
|
128
111
|
| What | Where |
|
|
129
112
|
|---|---|
|
|
@@ -148,19 +131,20 @@ With a remote configured and `/inbox` as the directory:
|
|
|
148
131
|
|
|
149
132
|
### How syncing works
|
|
150
133
|
|
|
151
|
-
- **Automatic**: a push
|
|
152
|
-
- **Merging**:
|
|
153
|
-
- **Two machines**:
|
|
154
|
-
- **Deleting**: 「删除」 only moves a record to the bin
|
|
134
|
+
- **Automatic**: a push seconds after a capture or edit (debounced — several quick saves are one push); 「刷新」 is a full sync: push → pull → re-read the list.
|
|
135
|
+
- **Merging**: per `id` + timestamp, the newer write wins, no conflict copies; needed attachment bytes come down with the record.
|
|
136
|
+
- **Two machines**: one shared 「目录」 (blank, `/` and `inbox` are the same). Records a machine left under an older directory come back if you tick **Merge other sync directories too** in the settings.
|
|
137
|
+
- **Deleting**: 「删除」 only moves a record to the bin and the other devices are told it is gone; emptying the bin deletes the cloud copy as well, and it stays deleted — a copy sitting in another sync directory cannot file it back in.
|
|
155
138
|
|
|
156
139
|
## Privacy and security
|
|
157
140
|
|
|
158
|
-
- **
|
|
159
|
-
- **
|
|
141
|
+
- **Credential bodies are encrypted at rest** (AES-256-GCM, key derived from your master password). Neither the password nor the key is written down: the vault locks on every restart and you unlock it under 设置 → 账密加密, and a forgotten password means unrecoverable ciphertext. With no master password set, a credential is **refused rather than stored in the clear**.
|
|
142
|
+
- **That covers the body only**: notes, categories, tags, timestamps and attachment **bytes** are not encrypted — a key inside a pasted file is still a key inside a file.
|
|
160
143
|
- **Masked where it matters**: a credential is listed by the name you gave it, and its plain text never reaches a conversation or a model.
|
|
161
|
-
- **Pictures**: classification does send an image to the model
|
|
144
|
+
- **Pictures**: classification does send an image to the model, and the conversation gets an `[attachment:id]` marker — that one image is sent only when you explicitly ask the assistant to look at it.
|
|
162
145
|
- **The model cannot see your vault** unless you ask it to look, and classification requests are redacted first.
|
|
163
146
|
- **Your remote needs access control**: only credential bodies are ciphertext up there — text, links, notes and attachment bytes are in the clear, and the master password and key never sync.
|
|
147
|
+
- **One thing the install changes outside the panel**: reading a pasted link's headline is the plugin's only outbound request (one GET, only for links captured on this machine, never for links that came through sync). It goes out with a **browser-shaped** identity, an override of `web-fetch-http.userAgent` shipped in this package's `cordis.patch.yml`. That identity is the whole profile's, the model's own web tools included; **your own `cordis.patch.yml` overrides it**. Details: [docs/help/link-title-fetch.md](docs/help/link-title-fetch.md).
|
|
164
148
|
|
|
165
149
|
## Development
|
|
166
150
|
|
|
@@ -171,7 +155,7 @@ pnpm typecheck
|
|
|
171
155
|
pnpm test # vitest
|
|
172
156
|
```
|
|
173
157
|
|
|
174
|
-
Client-side changes need `pnpm build`
|
|
158
|
+
Client-side changes need `pnpm build` (dsh's client-hmr reloads the page); host-side changes need a restart. Details in [docs/help/dev-setup.md](docs/help/dev-setup.md), milestones in the [development bus](docs/feature/dev-bus.md).
|
|
175
159
|
|
|
176
160
|
To switch the profile you actually run between the **published package** and **this checkout** (default profile `web`; set `DSH_PROFILE` for another):
|
|
177
161
|
|
package/README.zh.md
CHANGED
|
@@ -6,41 +6,37 @@
|
|
|
6
6
|
|
|
7
7
|
[English](README.md) | 中文
|
|
8
8
|
|
|
9
|
-
##
|
|
9
|
+
## 它是什么
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
一天里复制过的东西——一条待读的链接、一张截图、一段配置、一个账号密码——散落在剪贴板历史、收藏夹和临时文件里。dsh-inbox 把它们收进一个本地仓库:粘贴即存、自动分类、侧栏里能翻能搜;要找回什么,直接问助手「我上个月存的那篇讲缓存的文章呢」。
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
它只服务于你自己:数据全部落在本机,只有你让助手查的时候,模型才看得到内容。
|
|
13
|
+
数据全部落在本机;只有你让助手查的时候,模型才看得到内容。
|
|
16
14
|
|
|
17
15
|
## 主要功能
|
|
18
16
|
|
|
19
17
|
| 功能 | 说明 |
|
|
20
18
|
|---|---|
|
|
21
19
|
| **两个入口** | 输入框里 `/inbox <文字或链接>`(图片附在输入框上一起收),或面板里粘贴 / 拖拽 / 选择文件 |
|
|
22
|
-
| **自动分类** | 链接按平台与类型分(B
|
|
23
|
-
| **面板** | 待看 / 类目 / 标签筛选 +
|
|
24
|
-
| **每条都有名字** |
|
|
25
|
-
| **类目是谁判的** |
|
|
26
|
-
| **对话里取回** |
|
|
27
|
-
| **看图** |
|
|
28
|
-
| **密钥安全** |
|
|
29
|
-
| **双向同步** |
|
|
30
|
-
| **对话卡片** |
|
|
31
|
-
| **网关兼容** |
|
|
20
|
+
| **自动分类** | 链接按平台与类型分(60 多个站点:B 站、YouTube、公众号、掘金、知乎…)、文本按密钥形状分、图片按比例加「疑似证件」标签;规则判不出的交给模型兜底,有每日上限 |
|
|
21
|
+
| **面板** | 待看 / 类目 / 标签筛选 + 跨标题正文链接备注的搜索;两种列表密度;详情里能改名称、类目、备注、标签,能标待看、删除与恢复;**跟着 dsh 的主题和语言走** |
|
|
22
|
+
| **每条都有名字** | 链接抓页面自己的标题(不花模型 token)、图片文件用原文件名,你自己起的名字永远优先。读不到的页面——反爬页、死链——名字位留链接本身,不猜一个 |
|
|
23
|
+
| **类目是谁判的** | 类目旁的颜色标签:规则判定 / 模型判定 / 手动判定(你改过的,后续不会覆盖) |
|
|
24
|
+
| **对话里取回** | 问「我的收件箱 / 仓库 / inbox 里有哪些还没看的链接」即可,助手按关键词/类目/标签/待看/类型查(最多 10 条 + 还剩几条,带图的直接显示缩略图),按 id 打开一条(正文最多 1000 字、链接、备注、标签、附件信息);回答上方的「打开 ↗」会跳到右侧「仓库」里那一条 |
|
|
25
|
+
| **看图** | 默认图片字节不进对话;你说「帮我看这张图是什么」时,才会把那一张发给自己看(一次性、显式要) |
|
|
26
|
+
| **密钥安全** | 账密正文**加密落盘**(主密码派生密钥,两者都不落盘);列表只显示你起的名字;明文永不进对话、永不发给模型 |
|
|
27
|
+
| **双向同步** | 配 WebDAV 目录或 S3 桶:改动后几秒自动推送,「刷新」= 先推后拉完整同步、按 `id` + 时间合并;清空回收站连云端一起删;两台机器用同一个「目录」就互通 |
|
|
28
|
+
| **对话卡片** | 工具结果渲染成卡片:链接可点,图片标记在本机渲染成缩略图 |
|
|
29
|
+
| **网关兼容** | 有些对象存储网关把 AccessKey 绑在「应用」上、按客户端标识认人——插件**按协议各存一份客户端标识** |
|
|
32
30
|
|
|
33
31
|
## 界面
|
|
34
32
|
|
|
35
|
-

|
|
36
|
-
|
|
37
|
-
界面都在 dsh 里:左侧栏多一个 **Inbox** 入口,点开是整页仓库管理;面板右上角有「设置」和「使用手册」(手册把常见场景讲了一遍)。
|
|
33
|
+

|
|
38
34
|
|
|
39
35
|
## 两种用法
|
|
40
36
|
|
|
41
37
|
**① 存:在对话里转存**
|
|
42
38
|
|
|
43
|
-
输入框里写 `/inbox`
|
|
39
|
+
输入框里写 `/inbox` 再跟文字或链接,图片直接附在输入框上——**这条命令不会发给模型**,只进仓库。适合存账号密码、临时链接这类东西。
|
|
44
40
|
|
|
45
41
|
**② 取:在对话里问**
|
|
46
42
|
|
|
@@ -54,46 +50,32 @@
|
|
|
54
50
|
|
|
55
51
|
同一段对话里的两次提问(真机截图):
|
|
56
52
|
|
|
57
|
-

|
|
58
54
|
|
|
59
55
|

|
|
60
56
|
|
|
61
57
|
## 安装
|
|
62
58
|
|
|
63
|
-
前置:Node ≥ 22
|
|
64
|
-
|
|
65
|
-
**平台**:目前只在 **Windows** 上做过完整验收;macOS / Linux **尚未验证**(代码里没有平台特定依赖,欢迎试用后反馈)。
|
|
66
|
-
|
|
67
|
-
`dsh web` 就是 `dsh --profile web`,所以直接装进你日常启动的那个 profile:
|
|
59
|
+
前置:Node ≥ 22 和一个能用的 `dsh`;没装 pnpm 时安装命令会顺手装好。**目前只在 Windows 上验收过**,macOS / Linux 未验证。
|
|
68
60
|
|
|
69
61
|
```powershell
|
|
62
|
+
# 安装(`dsh web` 就是 `dsh --profile web`,所以装进你日常启动的那个 profile)
|
|
70
63
|
npx @chance722/dsh-inbox init --profile web --install-pnpm
|
|
71
|
-
```
|
|
72
|
-
|
|
73
|
-
然后照旧启动——`dsh web`。左栏出现 **Inbox**;新会话里问「我的收件箱里有哪些还没看的链接」,助手就会去查。
|
|
74
|
-
|
|
75
|
-
全新机器(从没跑过 dsh,还没有 `web` 这个 profile)加 `--create-profile` 让它先建:
|
|
76
64
|
|
|
77
|
-
|
|
65
|
+
# 全新机器(还没跑过 dsh、没有这个 profile)多带一个 --create-profile
|
|
78
66
|
npx @chance722/dsh-inbox init --profile web --create-profile --install-pnpm
|
|
79
|
-
```
|
|
80
|
-
|
|
81
|
-
`init` 做三件事,重复运行是安全的:
|
|
82
67
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
68
|
+
# 更新(init 只管装和接线,重复跑不会升级;刚发布的几分钟内请写确切版本:@0.2.7)
|
|
69
|
+
dsh plugin --profile web add @chance722/dsh-inbox@latest
|
|
70
|
+
```
|
|
86
71
|
|
|
87
|
-
|
|
72
|
+
装完**重启 dsh**,然后照旧启动。左栏出现 **Inbox**;新会话里问「我的收件箱里有哪些还没看的链接」,助手就会去查。
|
|
88
73
|
|
|
89
|
-
|
|
74
|
+
`init` 还会把 dsh 自带的 `standard` preset 复制到 `~/.dsh/.agent-presets/inbox/` 并加上本插件、把默认 preset 指向它——助手能看见收件箱工具就是靠这一步。那份快照不会跟着 dsh 以后升级 `standard` 一起变;它和 profile 都能退掉(见「卸载」)。
|
|
90
75
|
|
|
91
|
-
|
|
92
|
-
npx @chance722/dsh-inbox init --create-profile # 建一个隔离的 inbox profile
|
|
93
|
-
dsh --profile inbox --no-open --port 3102 # 在那个 profile 里起
|
|
94
|
-
```
|
|
76
|
+
想让日常 dsh 保持干净:`npx @chance722/dsh-inbox init --create-profile`,然后 `dsh --profile inbox --no-open --port 3102`。
|
|
95
77
|
|
|
96
|
-
其它选项:`--profile
|
|
78
|
+
其它选项:`--profile <名字>`、`--install-pnpm`、`--no-default`、`--help`。
|
|
97
79
|
|
|
98
80
|
### 从本地仓库装
|
|
99
81
|
|
|
@@ -122,7 +104,7 @@ rm -r ~/.dsh/profiles/<你装的 profile>
|
|
|
122
104
|
|
|
123
105
|
## 东西存在哪
|
|
124
106
|
|
|
125
|
-
|
|
107
|
+
全部在 `%DSH_HOME%` 下(Windows 上就是 `C:\Users\<你>\.dsh`):
|
|
126
108
|
|
|
127
109
|
| 什么 | 在哪 |
|
|
128
110
|
|---|---|
|
|
@@ -147,19 +129,20 @@ rm -r ~/.dsh/profiles/<你装的 profile>
|
|
|
147
129
|
|
|
148
130
|
### 同步怎么走
|
|
149
131
|
|
|
150
|
-
-
|
|
151
|
-
-
|
|
152
|
-
- **两台机器**:用同一个「目录」就互通(留空 / `/` / `inbox`
|
|
153
|
-
-
|
|
132
|
+
- **自动**:入库或改动后几秒推一次(防抖);「刷新」= 先推 → 再拉 → 重读列表。
|
|
133
|
+
- **合并**:按 `id` + 更新时间逐条判——谁新谁赢,不留冲突副本;需要的附件字节一起下来。
|
|
134
|
+
- **两台机器**:用同一个「目录」就互通(留空 / `/` / `inbox` 同义)。换过目录的机器留在别处的记录,可以在设置里打开「**同时合并别的同步目录**」一起拉回来。
|
|
135
|
+
- **删除**:「删除」只进回收站,别的设备会知道它被删了;「清空回收站」才连云端那份一起删,而且**清掉就是清掉**——另一套同步目录里那份副本不会再把它塞回来。
|
|
154
136
|
|
|
155
137
|
## 隐私与安全
|
|
156
138
|
|
|
157
|
-
- **账密加密落盘**:账密正文以密文保存(AES-256-GCM
|
|
158
|
-
-
|
|
139
|
+
- **账密加密落盘**:账密正文以密文保存(AES-256-GCM,密钥由主密码派生)。主密码和密钥都不落盘——每次重启都要在「设置 → 账密加密」解锁一次;密码忘了就解不开,没有找回。没设主密码时,账密**不会被存进去**。
|
|
140
|
+
- **加密的边界**:只加密账密的**正文**。备注、类目、标签、时间与附件的**字节**不在内——附件里装着密钥,它就是明文。
|
|
159
141
|
- **列表与对话脱敏**:密钥类记录在列表里只显示你起的名字;明文永不进对话、永不发给模型。
|
|
160
|
-
-
|
|
142
|
+
- **图片**:分类时会把图片发给模型判断;对话里默认只回 `[attachment:id]` 标记。只有你明确让助手「看这张图」时,它才会把那一张发给自己看。
|
|
161
143
|
- **模型看不到你的仓库**,除非你让它查(它调工具时才读得到),且分类请求先过脱敏。
|
|
162
144
|
- **云端要有访问控制**:同步上去的内容里只有账密正文是密文,其余(文本、链接、备注、附件字节)是明文;主密码与密钥从不同步。
|
|
145
|
+
- **装它会改到面板之外的一处**:抓链接标题是本插件唯一的对外请求(一次 GET,只在本机捕获的链接上发,同步拉进来的不抓)。它用的是**浏览器形状**的身份,由本包的 `cordis.patch.yml` 覆盖 `web-fetch-http.userAgent` 写成。这层身份是整个 profile 的,模型的 web 工具也一起用;**你自己的 `cordis.patch.yml` 覆盖得掉**。细节见 [docs/help/link-title-fetch.md](docs/help/link-title-fetch.md)。
|
|
163
146
|
|
|
164
147
|
## 开发
|
|
165
148
|
|
|
@@ -170,7 +153,7 @@ pnpm typecheck
|
|
|
170
153
|
pnpm test # vitest
|
|
171
154
|
```
|
|
172
155
|
|
|
173
|
-
改客户端代码:`pnpm build`
|
|
156
|
+
改客户端代码:`pnpm build` 后页面会自己重载(dsh 的 client-hmr);改宿主代码要重启服务。细节见 [docs/help/dev-setup.md](docs/help/dev-setup.md),阶段记录在[开发总线](docs/feature/dev-bus.md)。
|
|
174
157
|
|
|
175
158
|
日常想在**线上发布版**和**本地改动**之间切换(默认 profile 是 `web`,换 profile 加 `DSH_PROFILE`):
|
|
176
159
|
|
package/cordis.patch.yml
CHANGED
|
@@ -9,3 +9,24 @@
|
|
|
9
9
|
- insert:
|
|
10
10
|
- id: dsh-inbox
|
|
11
11
|
name: '@chance722/dsh-inbox'
|
|
12
|
+
|
|
13
|
+
# The identity this profile fetches pages with, restated here because our own
|
|
14
|
+
# link-title fetch needs a browser-shaped one: sites gate on the *shape* of this
|
|
15
|
+
# string, and bilibili answers the `Mozilla/5.0 (compatible; ...)` form with an
|
|
16
|
+
# anti-bot page that carries a real-looking `<title>` (measured 2026-09-21:
|
|
17
|
+
# 20/29 refused for the compatible form, 0/15 for the one below). Details,
|
|
18
|
+
# measurements and the refusal heuristics live in docs/help/link-title-fetch.md.
|
|
19
|
+
#
|
|
20
|
+
# It still says who is asking — `dsh-inbox` sits where a browser puts
|
|
21
|
+
# `Chrome/140.0.0.0` — with no version token, because one that goes stale on every
|
|
22
|
+
# release is worse than none. A parenthetical `(+url)` or a suffix after a browser
|
|
23
|
+
# token lands in the refused bucket exactly like `(compatible; ...)` does, so
|
|
24
|
+
# there is none of either.
|
|
25
|
+
#
|
|
26
|
+
# Scope: this is the profile's fetch identity, so the model's own web tools use it
|
|
27
|
+
# too. The user's own cordis.patch.yml is applied after this layer and wins,
|
|
28
|
+
# which is how anyone picks a different identity without touching the package.
|
|
29
|
+
- id: web-fetch-http
|
|
30
|
+
name: '@deepseek-ai/dsh-web-fetch-http'
|
|
31
|
+
config:
|
|
32
|
+
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) dsh-inbox Safari/537.36'
|
package/lib/cli.js
CHANGED
|
@@ -327,6 +327,7 @@ ${hint}`);
|
|
|
327
327
|
\xB7 \u7528\u4F60\u5E73\u65F6\u90A3\u6761\u547D\u4EE4\u542F\u52A8\u5B83\uFF1A${profile === "web" ? "dsh web" : `dsh --profile ${profile}`}
|
|
328
328
|
\xB7 \u65B0\u5EFA\u4E00\u4E2A\u4F1A\u8BDD\uFF0C\u5B83\u5C31\u4F1A\u5E26\u4E0A\u6536\u4EF6\u7BB1\u5DE5\u5177\uFF1B\u60F3\u8BA9\u52A9\u624B\u67E5\u4ED3\u5E93\uFF0C\u76F4\u63A5\u95EE\u300C\u6211\u7684\u6536\u4EF6\u7BB1\u91CC\u6709\u54EA\u4E9B\u8FD8\u6CA1\u770B\u7684\u94FE\u63A5\u300D
|
|
329
329
|
\xB7 \u9762\u677F\uFF08\u4FA7\u680F Inbox\uFF09\u4E0D\u9700\u8981 preset\uFF0C\u88C5\u5B8C\u5C31\u5728
|
|
330
|
+
\xB7 \u4EE5\u540E\u8981\u66F4\u65B0\u5230\u7EBF\u4E0A\u6700\u65B0\u7248\uFF1Adsh plugin --profile ${profile} add ${PACKAGE_NAME}@latest\uFF08\u91CD\u590D\u8DD1 init \u53EA\u4F1A\u91CD\u590D\u68C0\u67E5\uFF0Cpnpm \u56DE\u4E00\u53E5 "Already up to date"\uFF0C\u4E0D\u4F1A\u5347\u7EA7\uFF09
|
|
330
331
|
`
|
|
331
332
|
);
|
|
332
333
|
return 0;
|
package/lib/client.js
CHANGED
|
@@ -715,12 +715,6 @@ window.__ModuleLoader__.load({
|
|
|
715
715
|
"source.rule.hint": "\u672C\u5730\u89C4\u5219\u6309\u94FE\u63A5\u3001\u6587\u672C\u3001\u56FE\u7247\u7684\u5F62\u72B6\u5224\u7684\uFF0C\u6CA1\u6709\u8054\u7F51",
|
|
716
716
|
"source.model.hint": "\u89C4\u5219\u5224\u4E0D\u51FA\u6765\u624D\u4EA4\u7ED9\u6A21\u578B\u5224\u7684\uFF1B\u4F60\u8BF4\u7684\u8BDD\u6C38\u8FDC\u4F18\u5148\uFF0C\u968F\u65F6\u53EF\u4EE5\u6539",
|
|
717
717
|
"source.user.hint": "\u4F60\u81EA\u5DF1\u9009\u7684\u7C7B\u76EE\uFF0C\u89C4\u5219\u548C\u6A21\u578B\u90FD\u4E0D\u4F1A\u8986\u76D6\u5B83",
|
|
718
|
-
// Why a link has no fetched title (these reach the panel as `linkTitleError`).
|
|
719
|
-
"link.noTitle": "\u90A3\u4E2A\u9875\u9762\u91CC\u6CA1\u6709\u6807\u9898\uFF08\u6709\u4E9B\u7AD9\u70B9\u5BF9\u975E\u6D4F\u89C8\u5668\u7684\u8BF7\u6C42\u53EA\u56DE\u7A7A\u58F3\u9875\uFF0C\u6BD4\u5982\u5FAE\u4FE1\uFF09",
|
|
720
|
-
"link.http": "\u5BF9\u65B9\u8FD4\u56DE HTTP {status}",
|
|
721
|
-
"link.notHtml": "\u90A3\u4E2A\u5730\u5740\u4E0D\u662F\u7F51\u9875",
|
|
722
|
-
"link.unreachable": "\u8BF7\u6C42\u6CA1\u6210\u529F\uFF08\u7F51\u7EDC\u4E0D\u901A\u6216\u5BF9\u65B9\u62D2\u7EDD\uFF09",
|
|
723
|
-
"link.unknown": "\u539F\u56E0\u4E0D\u660E",
|
|
724
718
|
// Chrome above the list.
|
|
725
719
|
"app.settings": "\u8BBE\u7F6E",
|
|
726
720
|
"app.manual": "\u4F7F\u7528\u624B\u518C",
|
|
@@ -777,7 +771,6 @@ window.__ModuleLoader__.load({
|
|
|
777
771
|
"detail.removeTag": "\u4ECE\u8FD9\u6761\u8BB0\u5F55\u4E0A\u79FB\u9664\u300C{tag}\u300D",
|
|
778
772
|
"detail.updated": " \xB7 \u66F4\u65B0 {when}",
|
|
779
773
|
"detail.sealedNote": "\u8FD9\u6761\u8D26\u5BC6\u7684\u6B63\u6587\u662F\u5BC6\u6587\uFF0C\u73B0\u5728\u89E3\u4E0D\u5F00\u3002\u5230\u300C\u8BBE\u7F6E \u2192 \u8D26\u5BC6\u52A0\u5BC6\u300D\u89E3\u9501\uFF08\u6216\u5148\u8BBE\u4E00\u4E2A\u4E3B\u5BC6\u7801\uFF09\u5C31\u80FD\u770B\u5230\u3002",
|
|
780
|
-
"detail.titleMissed": "\u6CA1\u6293\u5230\u9875\u9762\u6807\u9898\uFF1A{reason}\u3002\u53EF\u4EE5\u81EA\u5DF1\u8D77\u4E2A\u540D\u5B57\u3002",
|
|
781
774
|
"detail.play": "\u64AD\u653E",
|
|
782
775
|
"detail.zoom": "\u653E\u5927\u67E5\u770B",
|
|
783
776
|
"detail.playInBrowser": "\u5728\u6D4F\u89C8\u5668\u91CC\u64AD\u653E",
|
|
@@ -876,6 +869,7 @@ window.__ModuleLoader__.load({
|
|
|
876
869
|
"sync.detailPulled": "\u62C9\u53D6 {count} \u6761 \xB7 \u4E91\u7AEF {records} \u6761\u8BB0\u5F55 / {files} \u4E2A\u9644\u4EF6",
|
|
877
870
|
"sync.detailAdded": "\uFF08\u5176\u4E2D {count} \u6761\u662F\u65B0\u8BB0\u5F55\uFF09",
|
|
878
871
|
"sync.detailDeleted": "\uFF08\u5176\u4E2D {count} \u6761\u662F\u5220\u9664\uFF0C\u4F1A\u8FDB\u56DE\u6536\u7AD9\uFF09",
|
|
872
|
+
"sync.detailPurged": "\uFF08\u6E05\u7A7A\u8FC7 {count} \u6761\uFF1A\u4E91\u7AEF\u7684\u65E7\u526F\u672C\u6CA1\u6709\u62C9\u56DE\u6765\uFF09",
|
|
879
873
|
"sync.pullForeignSync": " \xB7 \u26A0\uFE0F \u53E6\u6709 {records} \u6761\u8BB0\u5F55\u5728\u522B\u7684\u540C\u6B65\u76EE\u5F55\uFF1A{roots}",
|
|
880
874
|
"sync.detailForeign": " \xB7 \u26A0\uFE0F \u522B\u7684\u540C\u6B65\u76EE\u5F55\uFF1A{roots}\uFF08{records} \u6761\u8BB0\u5F55\uFF1B\u672C\u673A {ours}\uFF09",
|
|
881
875
|
// The toast: two numbers, nothing else. Everything else about a sync is
|
|
@@ -999,11 +993,6 @@ window.__ModuleLoader__.load({
|
|
|
999
993
|
"source.rule.hint": "the local rules judged this from the shape of the link, text or image \u2014 no network was used",
|
|
1000
994
|
"source.model.hint": "the rules could not decide, so the model did; what you say always wins and can be changed any time",
|
|
1001
995
|
"source.user.hint": "the category you chose yourself \u2014 neither the rules nor the model will overwrite it",
|
|
1002
|
-
"link.noTitle": "that page has no title (some sites answer non-browser requests with an empty shell \u2014 WeChat does)",
|
|
1003
|
-
"link.http": "the other side answered HTTP {status}",
|
|
1004
|
-
"link.notHtml": "that address is not a web page",
|
|
1005
|
-
"link.unreachable": "the request did not go through (no network, or refused)",
|
|
1006
|
-
"link.unknown": "no clear reason",
|
|
1007
996
|
"app.settings": "Settings",
|
|
1008
997
|
"app.manual": "Manual",
|
|
1009
998
|
"app.counts": " \xB7 {total} total \xB7 {watch} to read \xB7 {deleted} in the bin",
|
|
@@ -1056,7 +1045,6 @@ window.__ModuleLoader__.load({
|
|
|
1056
1045
|
"detail.removeTag": "Remove \u201C{tag}\u201D from this record",
|
|
1057
1046
|
"detail.updated": " \xB7 updated {when}",
|
|
1058
1047
|
"detail.sealedNote": "This credential\u2019s body is ciphertext and cannot be read right now. Unlock it under Settings \u2192 Credential encryption (or set a master password first).",
|
|
1059
|
-
"detail.titleMissed": "No page title was fetched: {reason}. You can give it a name yourself.",
|
|
1060
1048
|
"detail.play": "Play",
|
|
1061
1049
|
"detail.zoom": "Zoom in",
|
|
1062
1050
|
"detail.playInBrowser": "Play in the browser",
|
|
@@ -1145,6 +1133,7 @@ window.__ModuleLoader__.load({
|
|
|
1145
1133
|
"sync.detailPulled": "pulled {count} \xB7 the cloud holds {records} records / {files} attachments",
|
|
1146
1134
|
"sync.detailAdded": " ({count} of them new)",
|
|
1147
1135
|
"sync.detailDeleted": " ({count} of them deletions \u2014 they land in the recycle bin)",
|
|
1136
|
+
"sync.detailPurged": " ({count} emptied out of the bin: their older cloud copies stayed out)",
|
|
1148
1137
|
"sync.pullForeignSync": " \xB7 \u26A0\uFE0F {records} records live in another sync directory: {roots}",
|
|
1149
1138
|
"sync.detailForeign": " \xB7 \u26A0\uFE0F another sync directory: {roots} ({records} records; ours is {ours})",
|
|
1150
1139
|
"sync.shortPushed": "pushed {count}",
|
|
@@ -2025,15 +2014,6 @@ window.__ModuleLoader__.load({
|
|
|
2025
2014
|
var ACCENT_COLOR = "#6e9ef7";
|
|
2026
2015
|
var MODEL_COLOR = "#a78bfa";
|
|
2027
2016
|
var CONTROL_HEIGHT = "calc(1.6em + 12px)";
|
|
2028
|
-
function titleFailureText(code) {
|
|
2029
|
-
if (code === "no-title") {
|
|
2030
|
-
return t("link.noTitle");
|
|
2031
|
-
}
|
|
2032
|
-
if (code.startsWith("http:")) return t("link.http", { status: code.slice("http:".length) });
|
|
2033
|
-
if (code.startsWith("not-html:")) return t("link.notHtml");
|
|
2034
|
-
if (code.startsWith("network:")) return t("link.unreachable");
|
|
2035
|
-
return t("link.unknown");
|
|
2036
|
-
}
|
|
2037
2017
|
var paneRowStyle = { flex: "none" };
|
|
2038
2018
|
var selectStyle = {
|
|
2039
2019
|
...actionStyle,
|
|
@@ -3320,7 +3300,8 @@ window.__ModuleLoader__.load({
|
|
|
3320
3300
|
});
|
|
3321
3301
|
const gained = (result.added ?? 0) === 0 ? "" : t("sync.detailAdded", { count: result.added ?? 0 });
|
|
3322
3302
|
const deleted = (result.deletions ?? 0) === 0 ? "" : t("sync.detailDeleted", { count: result.deletions ?? 0 });
|
|
3323
|
-
|
|
3303
|
+
const purged = (result.purged ?? 0) === 0 ? "" : t("sync.detailPurged", { count: result.purged ?? 0 });
|
|
3304
|
+
return `${head}${gained}${deleted}${purged}${warningsOf(result, true)}`;
|
|
3324
3305
|
}
|
|
3325
3306
|
function EncryptionSettings({ call }) {
|
|
3326
3307
|
const [status, setStatus] = import_react8.default.useState();
|
|
@@ -4323,7 +4304,6 @@ window.__ModuleLoader__.load({
|
|
|
4323
4304
|
style: { ...paneRowStyle, ...inputStyle, width: "100%", boxSizing: "border-box" }
|
|
4324
4305
|
}
|
|
4325
4306
|
),
|
|
4326
|
-
detail.title === void 0 && detail.linkTitle === void 0 && detail.linkTitleError !== void 0 && detail.kind === "link" && /* @__PURE__ */ (0, import_jsx_runtime4.jsx)("p", { style: { ...paneRowStyle, margin: 0, fontSize: 12, opacity: 0.6 }, children: t("detail.titleMissed", { reason: titleFailureText(detail.linkTitleError) }) }),
|
|
4327
4307
|
/* @__PURE__ */ (0, import_jsx_runtime4.jsx)(
|
|
4328
4308
|
SelectBox,
|
|
4329
4309
|
{
|
package/lib/index.js
CHANGED
|
@@ -3,7 +3,7 @@ import { defineTool as defineTool2 } from "@deepseek-ai/dsh-tools";
|
|
|
3
3
|
|
|
4
4
|
// src/shared/constants.ts
|
|
5
5
|
var PACKAGE_NAME = "@chance722/dsh-inbox";
|
|
6
|
-
var VERSION = true ? "0.2.
|
|
6
|
+
var VERSION = true ? "0.2.7" : "0.0.0-dev";
|
|
7
7
|
var DEFAULT_USER_AGENT = "dsh-inbox";
|
|
8
8
|
|
|
9
9
|
// src/shared/vocabulary.ts
|
|
@@ -208,6 +208,16 @@ var ENTITIES = {
|
|
|
208
208
|
apos: "'",
|
|
209
209
|
nbsp: " "
|
|
210
210
|
};
|
|
211
|
+
var REFUSAL_MARKER = /(risk-captcha|_BiliGreyResult|cf-chl|challenge-platform|geetest)/i;
|
|
212
|
+
var REFUSAL_TITLE = /(验证码|人机验证|安全验证|环境异常|访问异常|请完成验证|正在验证|captcha|just a moment|attention required|access denied|forbidden)/i;
|
|
213
|
+
var REFUSAL_TEXT_FLOOR = 200;
|
|
214
|
+
function visibleTextLength(html) {
|
|
215
|
+
return html.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<!--[\s\S]*?-->/g, "").replace(/<[^>]+>/g, "").replace(/\s+/g, "").length;
|
|
216
|
+
}
|
|
217
|
+
function looksLikeRefusal(html, title) {
|
|
218
|
+
if (visibleTextLength(html) >= REFUSAL_TEXT_FLOOR) return false;
|
|
219
|
+
return REFUSAL_MARKER.test(html) || REFUSAL_TITLE.test(title);
|
|
220
|
+
}
|
|
211
221
|
function decodeEntities(text) {
|
|
212
222
|
return text.replace(/&(#[xX]?[0-9a-fA-F]+|[a-zA-Z]+);/g, (whole, body) => {
|
|
213
223
|
if (body.startsWith("#")) {
|
|
@@ -277,6 +287,11 @@ async function fetchLinkTitle(vault, id, web, log = () => {
|
|
|
277
287
|
log(`${host}\uFF1A\u9875\u9762\u91CC\u6CA1\u6709 <title>\uFF08${String(result.body.content.length)} \u5B57\u7B26\uFF09`);
|
|
278
288
|
return void 0;
|
|
279
289
|
}
|
|
290
|
+
if (looksLikeRefusal(result.body.content, title)) {
|
|
291
|
+
await miss(vault, id, "refused-page");
|
|
292
|
+
log(`${host}\uFF1A\u6293\u5230\u7684\u662F\u62D2\u7EDD\u9875\uFF0C\u4E0D\u5F53\u540D\u5B57`);
|
|
293
|
+
return void 0;
|
|
294
|
+
}
|
|
280
295
|
const current = vault.get(id);
|
|
281
296
|
if (current === void 0 || current.title !== void 0 || current.linkTitle !== void 0) {
|
|
282
297
|
log(`${host}\uFF1A\u6293\u5230\u4E86\u6807\u9898\uFF0C\u4F46\u8BB0\u5F55\u5DF2\u7ECF\u6709\u540D\u5B57\u4E86`);
|
|
@@ -295,19 +310,71 @@ async function miss(vault, id, reason) {
|
|
|
295
310
|
|
|
296
311
|
// src/host/classify/rules.ts
|
|
297
312
|
var PLATFORMS = [
|
|
298
|
-
|
|
299
|
-
["
|
|
300
|
-
["
|
|
301
|
-
["
|
|
302
|
-
["
|
|
303
|
-
["
|
|
304
|
-
["
|
|
305
|
-
["
|
|
313
|
+
// 视频 / 音频
|
|
314
|
+
["bilibili.com", "bilibili", "media"],
|
|
315
|
+
["b23.tv", "bilibili", "media"],
|
|
316
|
+
["youtube.com", "youtube", "media"],
|
|
317
|
+
["youtu.be", "youtube", "media"],
|
|
318
|
+
["vimeo.com", "vimeo", "media"],
|
|
319
|
+
["youku.com", "youku", "media"],
|
|
320
|
+
["v.qq.com", "tencentvideo", "media"],
|
|
321
|
+
["iqiyi.com", "iqiyi", "media"],
|
|
322
|
+
["mgtv.com", "mgtv", "media"],
|
|
323
|
+
["douyin.com", "douyin", "media"],
|
|
324
|
+
["iesdouyin.com", "douyin", "media"],
|
|
325
|
+
["kuaishou.com", "kuaishou", "media"],
|
|
326
|
+
["ixigua.com", "xigua", "media"],
|
|
327
|
+
["tiktok.com", "tiktok", "media"],
|
|
328
|
+
["twitch.tv", "twitch", "media"],
|
|
329
|
+
["dailymotion.com", "dailymotion", "media"],
|
|
330
|
+
["music.163.com", "netease-music", "media"],
|
|
331
|
+
["y.qq.com", "qq-music", "media"],
|
|
332
|
+
["spotify.com", "spotify", "media"],
|
|
333
|
+
["soundcloud.com", "soundcloud", "media"],
|
|
334
|
+
["ximalaya.com", "ximalaya", "media"],
|
|
335
|
+
// 文章 / 帖子
|
|
336
|
+
["mp.weixin.qq.com", "wechat", "article"],
|
|
337
|
+
["weixin.qq.com", "wechat", "article"],
|
|
338
|
+
["zhihu.com", "zhihu", "article"],
|
|
339
|
+
["juejin.cn", "juejin", "article"],
|
|
340
|
+
["csdn.net", "csdn", "article"],
|
|
341
|
+
["cnblogs.com", "cnblogs", "article"],
|
|
342
|
+
["jianshu.com", "jianshu", "article"],
|
|
343
|
+
["segmentfault.com", "segmentfault", "article"],
|
|
344
|
+
["v2ex.com", "v2ex", "article"],
|
|
345
|
+
["sspai.com", "sspai", "article"],
|
|
346
|
+
["36kr.com", "36kr", "article"],
|
|
347
|
+
["infoq.cn", "infoq", "article"],
|
|
348
|
+
["toutiao.com", "toutiao", "article"],
|
|
349
|
+
["weibo.com", "weibo", "article"],
|
|
350
|
+
["weibo.cn", "weibo", "article"],
|
|
351
|
+
["douban.com", "douban", "article"],
|
|
352
|
+
["xiaohongshu.com", "xiaohongshu", "article"],
|
|
353
|
+
["xhslink.com", "xiaohongshu", "article"],
|
|
354
|
+
["maimai.cn", "maimai", "article"],
|
|
355
|
+
["yuque.com", "yuque", "article"],
|
|
356
|
+
["medium.com", "medium", "article"],
|
|
357
|
+
["substack.com", "substack", "article"],
|
|
358
|
+
["dev.to", "devto", "article"],
|
|
359
|
+
["news.ycombinator.com", "hackernews", "article"],
|
|
360
|
+
["reddit.com", "reddit", "article"],
|
|
361
|
+
["stackoverflow.com", "stackoverflow", "article"],
|
|
362
|
+
["arxiv.org", "arxiv", "article"],
|
|
363
|
+
["developer.mozilla.org", "mdn", "article"],
|
|
364
|
+
["x.com", "twitter", "article"],
|
|
365
|
+
["twitter.com", "twitter", "article"],
|
|
366
|
+
["instagram.com", "instagram", "article"],
|
|
367
|
+
["threads.net", "threads", "article"],
|
|
368
|
+
["bsky.app", "bluesky", "article"],
|
|
369
|
+
["t.me", "telegram", "article"],
|
|
370
|
+
["linkedin.com", "linkedin", "article"],
|
|
371
|
+
// 代码 / 包:认得出站点,说不准"文章还是视频",交给模型
|
|
306
372
|
["github.com", "github"],
|
|
307
|
-
["
|
|
308
|
-
["
|
|
309
|
-
["
|
|
310
|
-
["
|
|
373
|
+
["gitlab.com", "gitlab"],
|
|
374
|
+
["gitee.com", "gitee"],
|
|
375
|
+
["npmjs.com", "npm"],
|
|
376
|
+
["pypi.org", "pypi"],
|
|
377
|
+
["huggingface.co", "huggingface"]
|
|
311
378
|
];
|
|
312
379
|
var SECRET_PATTERNS = [
|
|
313
380
|
[/\b(secret|access)[-_]?(id|key)\b\s*[:=]/i, "\u51FA\u73B0 secretId / secretKey \u8D4B\u503C"],
|
|
@@ -333,28 +400,28 @@ function hostOf(url) {
|
|
|
333
400
|
return void 0;
|
|
334
401
|
}
|
|
335
402
|
}
|
|
336
|
-
function
|
|
403
|
+
function ruleFor(url) {
|
|
337
404
|
const host = hostOf(url);
|
|
338
405
|
if (host === void 0) return void 0;
|
|
339
|
-
for (const
|
|
340
|
-
|
|
406
|
+
for (const rule of PLATFORMS) {
|
|
407
|
+
const [suffix] = rule;
|
|
408
|
+
if (host === suffix || host.endsWith(`.${suffix}`)) return rule;
|
|
341
409
|
}
|
|
342
410
|
return void 0;
|
|
343
411
|
}
|
|
344
|
-
function
|
|
345
|
-
|
|
346
|
-
return !path.startsWith("/read/");
|
|
347
|
-
}
|
|
348
|
-
return /\/video\/|\/watch\b|\/audio\/|\/podcast/.test(path);
|
|
412
|
+
function platformOf(url) {
|
|
413
|
+
return ruleFor(url)?.[1];
|
|
349
414
|
}
|
|
350
|
-
function
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
return /\/article\/|\/post\/|\/blog\/|\/read
|
|
415
|
+
function pathSaysMedia(path) {
|
|
416
|
+
return /\/video\/|\/watch\b|\/audio\/|\/podcast|\/shorts\/|\/v_show\/|\/playlist\b/.test(path);
|
|
417
|
+
}
|
|
418
|
+
function pathSaysArticle(path) {
|
|
419
|
+
return /\/article\/|\/articles\/|\/post\/|\/posts\/|\/blog\/|\/read\/|\/story\/|\/item\b/.test(path);
|
|
355
420
|
}
|
|
356
421
|
function classifyLink(url) {
|
|
357
|
-
const
|
|
422
|
+
const rule = ruleFor(url);
|
|
423
|
+
const platform = rule?.[1];
|
|
424
|
+
const habit = rule?.[2];
|
|
358
425
|
const parsed = (() => {
|
|
359
426
|
try {
|
|
360
427
|
return new URL(url);
|
|
@@ -365,15 +432,22 @@ function classifyLink(url) {
|
|
|
365
432
|
if (parsed === void 0) {
|
|
366
433
|
return { category: "other", confidence: "unsure", reason: "\u94FE\u63A5\u89E3\u6790\u4E0D\u4E86" };
|
|
367
434
|
}
|
|
368
|
-
const host = parsed.hostname.toLowerCase().replace(/^www\./, "");
|
|
369
435
|
const path = parsed.pathname;
|
|
370
436
|
const base = platform === void 0 ? {} : { platform };
|
|
371
|
-
if (
|
|
437
|
+
if (pathSaysMedia(path)) {
|
|
372
438
|
return { ...base, category: "media", confidence: "decided", reason: "\u94FE\u63A5\u6307\u5411\u89C6\u9891/\u97F3\u9891\u9875" };
|
|
373
439
|
}
|
|
374
|
-
if (
|
|
440
|
+
if (pathSaysArticle(path)) {
|
|
375
441
|
return { ...base, category: "article", confidence: "decided", reason: "\u94FE\u63A5\u6307\u5411\u6587\u7AE0\u9875" };
|
|
376
442
|
}
|
|
443
|
+
if (habit !== void 0) {
|
|
444
|
+
return {
|
|
445
|
+
...base,
|
|
446
|
+
category: habit,
|
|
447
|
+
confidence: "decided",
|
|
448
|
+
reason: habit === "media" ? `${String(platform)} \u4E0A\u7684\u89C6\u9891/\u97F3\u9891\u9875` : `${String(platform)} \u4E0A\u7684\u6587\u7AE0\u9875`
|
|
449
|
+
};
|
|
450
|
+
}
|
|
377
451
|
if (platform !== void 0) {
|
|
378
452
|
return { ...base, category: "other", confidence: "unsure", reason: `\u8BA4\u5F97\u51FA\u5E73\u53F0\u662F ${platform}\uFF0C\u4F46\u8BF4\u4E0D\u51C6\u662F\u6587\u7AE0\u8FD8\u662F\u89C6\u9891` };
|
|
379
453
|
}
|
|
@@ -459,7 +533,17 @@ function mergedNote(current, incoming) {
|
|
|
459
533
|
async function absorb(vault, existing, incoming) {
|
|
460
534
|
const patched = await vault.patch(existing.id, {
|
|
461
535
|
note: mergedNote(existing, incoming.note),
|
|
462
|
-
...existing.title === void 0 && incoming.title !== void 0 ? { title: incoming.title } : {}
|
|
536
|
+
...existing.title === void 0 && incoming.title !== void 0 ? { title: incoming.title } : {},
|
|
537
|
+
/*
|
|
538
|
+
A repeat is the cheap chance to fill in what the rules learned since.
|
|
539
|
+
|
|
540
|
+
`platform` is derived from the host, and the table grows (掘金 was added
|
|
541
|
+
after a record for it already existed), so an old record can sit there
|
|
542
|
+
with no platform while a fresh paste of the same URL knows it. This only
|
|
543
|
+
ever *fills*: a record that already names a platform keeps it, because
|
|
544
|
+
that value may be what the user corrected by hand.
|
|
545
|
+
*/
|
|
546
|
+
...existing.platform === void 0 && incoming.platform !== void 0 ? { platform: incoming.platform } : {}
|
|
463
547
|
});
|
|
464
548
|
const restored = existing.deletedAt !== void 0;
|
|
465
549
|
const item = restored ? await vault.restore(existing.id) : patched;
|
|
@@ -493,7 +577,12 @@ async function captureText(vault, raw, source, note) {
|
|
|
493
577
|
const existing = vault.list({ includeDeleted: true, kinds: [sniffed.kind] }).find(
|
|
494
578
|
(item) => sniffed.kind === "link" ? item.url !== void 0 && normalizeLink(item.url) === normalizeLink(raw) : sealed === void 0 ? item.text === raw : item.secretDigest === sealed.secretDigest
|
|
495
579
|
);
|
|
496
|
-
if (existing !== void 0)
|
|
580
|
+
if (existing !== void 0) {
|
|
581
|
+
return absorb(vault, existing, {
|
|
582
|
+
note,
|
|
583
|
+
...platform === void 0 ? {} : { platform }
|
|
584
|
+
});
|
|
585
|
+
}
|
|
497
586
|
return { item: await vault.create(candidate), merged: false, restored: false, verdict };
|
|
498
587
|
}
|
|
499
588
|
async function captureImage(vault, attachment, source, note) {
|
|
@@ -1400,18 +1489,32 @@ function wins(remote, local) {
|
|
|
1400
1489
|
if (Number.isNaN(incoming) || Number.isNaN(current)) return false;
|
|
1401
1490
|
return incoming > current;
|
|
1402
1491
|
}
|
|
1492
|
+
function newerThanPurge(vault, remote) {
|
|
1493
|
+
const purged = vault.purgedAt(remote.id);
|
|
1494
|
+
if (purged === void 0) return true;
|
|
1495
|
+
return Date.parse(remote.updatedAt) > Date.parse(purged);
|
|
1496
|
+
}
|
|
1403
1497
|
async function mergeOnce(vault, tree, prefix, admit) {
|
|
1404
1498
|
const failures = [];
|
|
1405
1499
|
let merged = 0;
|
|
1406
1500
|
let added = 0;
|
|
1407
1501
|
let deletions = 0;
|
|
1502
|
+
let purged = 0;
|
|
1408
1503
|
let kept = 0;
|
|
1409
1504
|
let attachments = 0;
|
|
1410
1505
|
let objects;
|
|
1411
1506
|
try {
|
|
1412
1507
|
objects = await tree.list(`${prefix}/items/`);
|
|
1413
1508
|
} catch (error) {
|
|
1414
|
-
return {
|
|
1509
|
+
return {
|
|
1510
|
+
merged: 0,
|
|
1511
|
+
added: 0,
|
|
1512
|
+
deletions: 0,
|
|
1513
|
+
purged: 0,
|
|
1514
|
+
kept: 0,
|
|
1515
|
+
attachments: 0,
|
|
1516
|
+
failures: [`\u5217\u8FDC\u7AEF\u540C\u6B65\u76EE\u5F55\u5931\u8D25\uFF1A${reasonOf(error)}`]
|
|
1517
|
+
};
|
|
1415
1518
|
}
|
|
1416
1519
|
const wanted = /* @__PURE__ */ new Map();
|
|
1417
1520
|
const bytesAt = /* @__PURE__ */ new Map();
|
|
@@ -1435,6 +1538,10 @@ async function mergeOnce(vault, tree, prefix, admit) {
|
|
|
1435
1538
|
continue;
|
|
1436
1539
|
}
|
|
1437
1540
|
const local = vault.get(remote.id);
|
|
1541
|
+
if (local === void 0 && !newerThanPurge(vault, remote)) {
|
|
1542
|
+
purged += 1;
|
|
1543
|
+
continue;
|
|
1544
|
+
}
|
|
1438
1545
|
if (!wins(remote, local)) {
|
|
1439
1546
|
kept += 1;
|
|
1440
1547
|
continue;
|
|
@@ -1477,7 +1584,7 @@ async function mergeOnce(vault, tree, prefix, admit) {
|
|
|
1477
1584
|
failures.push(`\u9644\u4EF6 ${attachmentId}\uFF1A${reasonOf(error)}`);
|
|
1478
1585
|
}
|
|
1479
1586
|
}
|
|
1480
|
-
return { merged, added, deletions, kept, attachments, failures };
|
|
1587
|
+
return { merged, added, deletions, purged, kept, attachments, failures };
|
|
1481
1588
|
}
|
|
1482
1589
|
function extensionOfName(path) {
|
|
1483
1590
|
return /\.([A-Za-z0-9]{1,8})$/.exec(nameOf2(path))?.[1]?.toLowerCase() ?? "bin";
|
|
@@ -1490,7 +1597,9 @@ async function mergeRemote(ctx, vault, attachments, extraRoots = []) {
|
|
|
1490
1597
|
const prefix = syncRoot(settings);
|
|
1491
1598
|
try {
|
|
1492
1599
|
const tree = settings.protocol === "s3" ? await s3Tree(ctx, settings) : await webdavTree(ctx, settings);
|
|
1493
|
-
if (tree === void 0)
|
|
1600
|
+
if (tree === void 0) {
|
|
1601
|
+
return { merged: 0, added: 0, deletions: 0, purged: 0, kept: 0, attachments: 0, failures: [] };
|
|
1602
|
+
}
|
|
1494
1603
|
const admit = admitWith(attachments);
|
|
1495
1604
|
const outcome = await mergeOnce(vault, tree, prefix, admit);
|
|
1496
1605
|
for (const root of extraRoots) {
|
|
@@ -1499,13 +1608,22 @@ async function mergeRemote(ctx, vault, attachments, extraRoots = []) {
|
|
|
1499
1608
|
outcome.merged += extra.merged;
|
|
1500
1609
|
outcome.added += extra.added;
|
|
1501
1610
|
outcome.deletions += extra.deletions;
|
|
1611
|
+
outcome.purged += extra.purged;
|
|
1502
1612
|
outcome.kept += extra.kept;
|
|
1503
1613
|
outcome.attachments += extra.attachments;
|
|
1504
1614
|
outcome.failures.push(...extra.failures);
|
|
1505
1615
|
}
|
|
1506
1616
|
return outcome;
|
|
1507
1617
|
} catch (error) {
|
|
1508
|
-
return {
|
|
1618
|
+
return {
|
|
1619
|
+
merged: 0,
|
|
1620
|
+
added: 0,
|
|
1621
|
+
deletions: 0,
|
|
1622
|
+
purged: 0,
|
|
1623
|
+
kept: 0,
|
|
1624
|
+
attachments: 0,
|
|
1625
|
+
failures: [reasonOf(error)]
|
|
1626
|
+
};
|
|
1509
1627
|
}
|
|
1510
1628
|
}
|
|
1511
1629
|
function admitWith(store) {
|
|
@@ -1613,6 +1731,7 @@ async function withMerge(ctx, vault, attachments, pulled) {
|
|
|
1613
1731
|
merged: outcome.merged,
|
|
1614
1732
|
added: outcome.added,
|
|
1615
1733
|
deletions: outcome.deletions,
|
|
1734
|
+
...outcome.purged === 0 ? {} : { purged: outcome.purged },
|
|
1616
1735
|
kept: outcome.kept,
|
|
1617
1736
|
attachments: outcome.attachments,
|
|
1618
1737
|
failed: pulled.failed + outcome.failures.length,
|
|
@@ -2377,6 +2496,12 @@ var attachmentSchema = z3.object({
|
|
|
2377
2496
|
/** Present when the medium identifies the bytes by digest. */
|
|
2378
2497
|
sha256: z3.string().optional()
|
|
2379
2498
|
});
|
|
2499
|
+
var graveSchema = z3.object({
|
|
2500
|
+
/** The record's own key, repeated inside the document (see `attachments`). */
|
|
2501
|
+
id: z3.string().min(1),
|
|
2502
|
+
/** When the user emptied it out of the bin. */
|
|
2503
|
+
purgedAt: timestamp
|
|
2504
|
+
});
|
|
2380
2505
|
var vaultGlobalSchema = z3.object({
|
|
2381
2506
|
sync: z3.object({
|
|
2382
2507
|
lastPullAt: z3.string().optional(),
|
|
@@ -2429,9 +2554,17 @@ var vaultSpec = defineDomain({
|
|
|
2429
2554
|
* Version 7 adds `sync.lastPushAt`: the cursor that keeps a push to "what
|
|
2430
2555
|
* changed since last time" instead of re-uploading the vault on every pass.
|
|
2431
2556
|
* A `global` field, so no record shape changes at all.
|
|
2557
|
+
*
|
|
2558
|
+
* Version 8 adds the `graves` table: one row per record the user emptied out
|
|
2559
|
+
* of the bin. A purge leaves no local row, so the merge had nothing to
|
|
2560
|
+
* outrank the cloud's copy with — with 「同时合并别的同步目录」 on, the older
|
|
2561
|
+
* tree in the same bucket filed all thirteen of them back into the bin on
|
|
2562
|
+
* every restart (measured 2026-09-21). A new table, so no record shape
|
|
2563
|
+
* changes; an older vault simply has no graves, and there is nothing to
|
|
2564
|
+
* protect until the next purge.
|
|
2432
2565
|
*/
|
|
2433
|
-
version:
|
|
2434
|
-
compatibleVersions: [1, 2, 3, 4, 5, 6],
|
|
2566
|
+
version: 8,
|
|
2567
|
+
compatibleVersions: [1, 2, 3, 4, 5, 6, 7],
|
|
2435
2568
|
layout: "per-record",
|
|
2436
2569
|
global: {
|
|
2437
2570
|
schema: vaultGlobalSchema,
|
|
@@ -2439,7 +2572,8 @@ var vaultSpec = defineDomain({
|
|
|
2439
2572
|
},
|
|
2440
2573
|
tables: {
|
|
2441
2574
|
items: domainTable(itemSchema),
|
|
2442
|
-
attachments: domainTable(attachmentSchema)
|
|
2575
|
+
attachments: domainTable(attachmentSchema),
|
|
2576
|
+
graves: domainTable(graveSchema)
|
|
2443
2577
|
}
|
|
2444
2578
|
});
|
|
2445
2579
|
|
|
@@ -2510,6 +2644,9 @@ var Vault = class _Vault {
|
|
|
2510
2644
|
get attachments() {
|
|
2511
2645
|
return this.domain.table("attachments");
|
|
2512
2646
|
}
|
|
2647
|
+
get graves() {
|
|
2648
|
+
return this.domain.table("graves");
|
|
2649
|
+
}
|
|
2513
2650
|
/** Where the key state stands; what the panel shows and the tools consult. */
|
|
2514
2651
|
get lockState() {
|
|
2515
2652
|
return { configured: this.global.master !== void 0, unlocked: this.key !== void 0 };
|
|
@@ -2764,16 +2901,26 @@ var Vault = class _Vault {
|
|
|
2764
2901
|
});
|
|
2765
2902
|
}
|
|
2766
2903
|
/**
|
|
2767
|
-
* Delete one record for good, together with its attachment rows
|
|
2904
|
+
* Delete one record for good, together with its attachment rows, and leave a
|
|
2905
|
+
* grave behind so nothing that is still in the cloud can file it again.
|
|
2768
2906
|
*
|
|
2769
2907
|
* The bytes behind an attachment live in dsh's own store, which never deletes
|
|
2770
2908
|
* automatically — emptying the recycle bin drops our references, not their
|
|
2771
2909
|
* objects. A row another record still references is left alone.
|
|
2772
2910
|
*
|
|
2911
|
+
* The grave is the other half of that sentence, and it is why this is not
|
|
2912
|
+
* called `remove` any more: deleting the row really does take this machine's
|
|
2913
|
+
* copy away, but a copy under a *different* `sync/` tree in the same bucket
|
|
2914
|
+
* survives the purge of our own tree (`../remote/remove.ts` only ever deletes
|
|
2915
|
+
* under this vault's root), and the merge has no local row to outrank it with.
|
|
2916
|
+
* Measured 2026-09-21: thirteen emptied tombstones came back into the bin on
|
|
2917
|
+
* every `dsh` restart. The grave is what makes "gone" stick; it holds the id
|
|
2918
|
+
* and the moment, no content.
|
|
2919
|
+
*
|
|
2773
2920
|
* @param id - record key.
|
|
2774
2921
|
* @returns whether the record existed.
|
|
2775
2922
|
*/
|
|
2776
|
-
async
|
|
2923
|
+
async purge(id) {
|
|
2777
2924
|
const item = this.items.get(id);
|
|
2778
2925
|
if (item === void 0) return false;
|
|
2779
2926
|
const stillReferenced = /* @__PURE__ */ new Set();
|
|
@@ -2784,7 +2931,18 @@ var Vault = class _Vault {
|
|
|
2784
2931
|
for (const attachmentId of item.attachmentIds) {
|
|
2785
2932
|
if (!stillReferenced.has(attachmentId)) await this.attachments.delete(attachmentId);
|
|
2786
2933
|
}
|
|
2787
|
-
|
|
2934
|
+
await this.items.delete(id);
|
|
2935
|
+
await this.graves.put(id, { id, purgedAt: (/* @__PURE__ */ new Date()).toISOString() });
|
|
2936
|
+
return true;
|
|
2937
|
+
}
|
|
2938
|
+
/**
|
|
2939
|
+
* When this id was emptied out of the bin, if it ever was.
|
|
2940
|
+
*
|
|
2941
|
+
* The merge asks this before taking a copy of a record this vault does not
|
|
2942
|
+
* have (see `newerThanPurge` in `../remote/merge.ts`).
|
|
2943
|
+
*/
|
|
2944
|
+
purgedAt(id) {
|
|
2945
|
+
return this.graves.get(id)?.purgedAt;
|
|
2788
2946
|
}
|
|
2789
2947
|
/**
|
|
2790
2948
|
* Record an attachment's metadata. The bytes stay in the store named by
|
|
@@ -3164,7 +3322,7 @@ async function handlePurge(ctx, vault) {
|
|
|
3164
3322
|
}
|
|
3165
3323
|
let removed = 0;
|
|
3166
3324
|
for (const item of bin) {
|
|
3167
|
-
if (await vault.
|
|
3325
|
+
if (await vault.purge(item.id)) removed += 1;
|
|
3168
3326
|
}
|
|
3169
3327
|
const stillReferenced = new Set(
|
|
3170
3328
|
vault.list({ includeDeleted: true }).flatMap((item) => [...item.attachmentIds])
|
|
@@ -86,17 +86,6 @@ export declare function categoryLabel(category: Category): string;
|
|
|
86
86
|
export declare function sourceLabel(source: CategorySource): string;
|
|
87
87
|
/** One sentence explaining what that badge implies. */
|
|
88
88
|
export declare function sourceHint(source: CategorySource): string;
|
|
89
|
-
/**
|
|
90
|
-
* Why a link never got a name.
|
|
91
|
-
*
|
|
92
|
-
* The host stores a machine-readable code (`http:403`, `no-title`, …) — never a
|
|
93
|
-
* sentence, and deliberately not in the host's language — so the panel can say
|
|
94
|
-
* it in the reader's.
|
|
95
|
-
*
|
|
96
|
-
* @param code - the stored `linkTitleError`, or undefined when there is none.
|
|
97
|
-
* @returns a sentence, or undefined when there is nothing to explain.
|
|
98
|
-
*/
|
|
99
|
-
export declare function titleMissReason(code: string | undefined): string | undefined;
|
|
100
89
|
/**
|
|
101
90
|
* Hook the panel's copy up to dsh's language.
|
|
102
91
|
*
|
|
@@ -41,11 +41,6 @@ export declare const zh: {
|
|
|
41
41
|
'source.rule.hint': string;
|
|
42
42
|
'source.model.hint': string;
|
|
43
43
|
'source.user.hint': string;
|
|
44
|
-
'link.noTitle': string;
|
|
45
|
-
'link.http': string;
|
|
46
|
-
'link.notHtml': string;
|
|
47
|
-
'link.unreachable': string;
|
|
48
|
-
'link.unknown': string;
|
|
49
44
|
'app.settings': string;
|
|
50
45
|
'app.manual': string;
|
|
51
46
|
'app.counts': string;
|
|
@@ -98,7 +93,6 @@ export declare const zh: {
|
|
|
98
93
|
'detail.removeTag': string;
|
|
99
94
|
'detail.updated': string;
|
|
100
95
|
'detail.sealedNote': string;
|
|
101
|
-
'detail.titleMissed': string;
|
|
102
96
|
'detail.play': string;
|
|
103
97
|
'detail.zoom': string;
|
|
104
98
|
'detail.playInBrowser': string;
|
|
@@ -187,6 +181,7 @@ export declare const zh: {
|
|
|
187
181
|
'sync.detailPulled': string;
|
|
188
182
|
'sync.detailAdded': string;
|
|
189
183
|
'sync.detailDeleted': string;
|
|
184
|
+
'sync.detailPurged': string;
|
|
190
185
|
'sync.pullForeignSync': string;
|
|
191
186
|
'sync.detailForeign': string;
|
|
192
187
|
'sync.shortPushed': string;
|
|
@@ -310,11 +305,6 @@ export declare const MESSAGES: {
|
|
|
310
305
|
'source.rule.hint': string;
|
|
311
306
|
'source.model.hint': string;
|
|
312
307
|
'source.user.hint': string;
|
|
313
|
-
'link.noTitle': string;
|
|
314
|
-
'link.http': string;
|
|
315
|
-
'link.notHtml': string;
|
|
316
|
-
'link.unreachable': string;
|
|
317
|
-
'link.unknown': string;
|
|
318
308
|
'app.settings': string;
|
|
319
309
|
'app.manual': string;
|
|
320
310
|
'app.counts': string;
|
|
@@ -367,7 +357,6 @@ export declare const MESSAGES: {
|
|
|
367
357
|
'detail.removeTag': string;
|
|
368
358
|
'detail.updated': string;
|
|
369
359
|
'detail.sealedNote': string;
|
|
370
|
-
'detail.titleMissed': string;
|
|
371
360
|
'detail.play': string;
|
|
372
361
|
'detail.zoom': string;
|
|
373
362
|
'detail.playInBrowser': string;
|
|
@@ -456,6 +445,7 @@ export declare const MESSAGES: {
|
|
|
456
445
|
'sync.detailPulled': string;
|
|
457
446
|
'sync.detailAdded': string;
|
|
458
447
|
'sync.detailDeleted': string;
|
|
448
|
+
'sync.detailPurged': string;
|
|
459
449
|
'sync.pullForeignSync': string;
|
|
460
450
|
'sync.detailForeign': string;
|
|
461
451
|
'sync.shortPushed': string;
|
|
@@ -8,12 +8,15 @@
|
|
|
8
8
|
* private ones, follows only same-origin redirects, and caps both time and size.
|
|
9
9
|
* It runs after the record is stored, never in front of the paste.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
11
|
+
* Four rules keep it honest:
|
|
12
12
|
* - it never writes `title`. That field is the user's own word, and the
|
|
13
13
|
* credentials rule depends on it staying that way (`AGENTS.md` 3); the fetched
|
|
14
14
|
* headline lives in `linkTitle` instead;
|
|
15
15
|
* - it writes nothing when the record already has a name, and nothing when the
|
|
16
16
|
* user renamed it while the fetch was in flight;
|
|
17
|
+
* - it does not take a name from a page that is not the page: a site can answer
|
|
18
|
+
* with an anti-bot page that carries a *real-looking* `<title>` (bilibili's is
|
|
19
|
+
* 「验证码_哔哩哔哩」), and that string names the refusal, not the link;
|
|
17
20
|
* - it never turns a failure into a problem: a dead link, a slow host or a page
|
|
18
21
|
* that is not HTML simply leaves the URL as the name, which is where this
|
|
19
22
|
* started.
|
|
@@ -34,6 +37,27 @@ export interface WebFetchSeam {
|
|
|
34
37
|
readonly url: string;
|
|
35
38
|
}, signal?: AbortSignal): Promise<WebFetchResult>;
|
|
36
39
|
}
|
|
40
|
+
/**
|
|
41
|
+
* True when this document is a refusal wearing a page's clothes.
|
|
42
|
+
*
|
|
43
|
+
* The checks before this one — a 2xx status, an HTML body, a non-empty
|
|
44
|
+
* `<title>` — all pass on bilibili's anti-bot page, which is what made a record
|
|
45
|
+
* show 「验证码」 as its name (2026-09-21). What separates that page from a real
|
|
46
|
+
* one is that it carries **no text at all**: the challenge shell is 1360 bytes
|
|
47
|
+
* of scripts and empty containers, while the page it stands in for has
|
|
48
|
+
* thousands of characters. Only then do the markers and the shape of the title
|
|
49
|
+
* decide, because the markers alone are not evidence — real bilibili pages
|
|
50
|
+
* carry the same strings.
|
|
51
|
+
*
|
|
52
|
+
* Both halves are deliberately biased towards refusing: a link whose name we
|
|
53
|
+
* skip shows its own address, which is honest and one keystroke away from being
|
|
54
|
+
* named by hand, while a refusal kept as a name is a wrong name that stays.
|
|
55
|
+
*
|
|
56
|
+
* @param html - the decoded document.
|
|
57
|
+
* @param title - the headline pulled out of it.
|
|
58
|
+
* @returns true when the page refuses to be read.
|
|
59
|
+
*/
|
|
60
|
+
export declare function looksLikeRefusal(html: string, title: string): boolean;
|
|
37
61
|
/**
|
|
38
62
|
* Pull the `<title>` out of an HTML document.
|
|
39
63
|
*
|
|
@@ -59,6 +59,16 @@ export interface MergeOutcome {
|
|
|
59
59
|
* 2026-09-21, after merging an abandoned tree whose copies mostly overlapped).
|
|
60
60
|
*/
|
|
61
61
|
added: number;
|
|
62
|
+
/**
|
|
63
|
+
* Copies of records this vault **emptied out of the bin** — left alone, on
|
|
64
|
+
* purpose, because a purge is not a deletion to be argued with.
|
|
65
|
+
*
|
|
66
|
+
* Counted apart from {@link kept}: "the cloud has a copy you already have" and
|
|
67
|
+
* "the cloud has a copy of something you threw away" are different sentences,
|
|
68
|
+
* and only the second one answers "so why did the emptied records stay empty
|
|
69
|
+
* this time?" (2026-09-21).
|
|
70
|
+
*/
|
|
71
|
+
purged: number;
|
|
62
72
|
/** Records the remote had and this machine already had, newer or equal. */
|
|
63
73
|
kept: number;
|
|
64
74
|
/** Attachment objects pulled down (bytes plus their row). */
|
|
@@ -67,6 +67,31 @@ export declare const attachmentSchema: z.ZodObject<{
|
|
|
67
67
|
height: z.ZodOptional<z.ZodNumber>;
|
|
68
68
|
sha256: z.ZodOptional<z.ZodString>;
|
|
69
69
|
}, z.core.$strip>;
|
|
70
|
+
/**
|
|
71
|
+
* One grave: the id the user emptied out of the recycle bin, and when.
|
|
72
|
+
*
|
|
73
|
+
* Emptying the bin is not "deleted" — it is *gone*, and a copy of the record
|
|
74
|
+
* living somewhere else (another `sync/` tree in the same bucket, a device that
|
|
75
|
+
* has not pulled the tombstone yet) must not be able to file it back in.
|
|
76
|
+
* `updatedAt` cannot express that on its own: the merge compares the incoming
|
|
77
|
+
* copy against the local one, and after a purge there is no local row at all,
|
|
78
|
+
* so any copy won. That is measured, not theoretical — thirteen tombstones came
|
|
79
|
+
* back into the bin on every restart (2026-09-21).
|
|
80
|
+
*
|
|
81
|
+
* It carries an id and a time and nothing else: no text, no note, no
|
|
82
|
+
* attachment. What the row buys is the one comparison the merge needs — a copy
|
|
83
|
+
* **at or before** this moment is dead, a copy strictly newer still wins, the
|
|
84
|
+
* same way it does for a record that was never deleted.
|
|
85
|
+
*
|
|
86
|
+
* Nothing ever collects these, and that is deliberate rather than an oversight:
|
|
87
|
+
* a grave that has been pruned is a hole reopened — the copy it was holding out
|
|
88
|
+
* is exactly as old as it was, and the next pull takes it. One row per emptied
|
|
89
|
+
* record, six fields of JSON, for as long as the vault lives.
|
|
90
|
+
*/
|
|
91
|
+
export declare const graveSchema: z.ZodObject<{
|
|
92
|
+
id: z.ZodString;
|
|
93
|
+
purgedAt: z.ZodString;
|
|
94
|
+
}, z.core.$strip>;
|
|
70
95
|
/**
|
|
71
96
|
* One global slot per domain. Sync state lives here because it belongs to the
|
|
72
97
|
* vault as a whole, not to any item; M6 fills it in.
|
|
@@ -96,6 +121,7 @@ export declare const vaultGlobalSchema: z.ZodObject<{
|
|
|
96
121
|
}, z.core.$strip>;
|
|
97
122
|
export type Item = z.infer<typeof itemSchema>;
|
|
98
123
|
export type Attachment = z.infer<typeof attachmentSchema>;
|
|
124
|
+
export type Grave = z.infer<typeof graveSchema>;
|
|
99
125
|
export type VaultGlobal = z.infer<typeof vaultGlobalSchema>;
|
|
100
126
|
/**
|
|
101
127
|
* Domain name doubles as the backend unit name: `<DSH_HOME>/storages/dsh_inbox/…`.
|
|
@@ -122,6 +148,14 @@ export declare const vaultSpec: {
|
|
|
122
148
|
* Version 7 adds `sync.lastPushAt`: the cursor that keeps a push to "what
|
|
123
149
|
* changed since last time" instead of re-uploading the vault on every pass.
|
|
124
150
|
* A `global` field, so no record shape changes at all.
|
|
151
|
+
*
|
|
152
|
+
* Version 8 adds the `graves` table: one row per record the user emptied out
|
|
153
|
+
* of the bin. A purge leaves no local row, so the merge had nothing to
|
|
154
|
+
* outrank the cloud's copy with — with 「同时合并别的同步目录」 on, the older
|
|
155
|
+
* tree in the same bucket filed all thirteen of them back into the bin on
|
|
156
|
+
* every restart (measured 2026-09-21). A new table, so no record shape
|
|
157
|
+
* changes; an older vault simply has no graves, and there is nothing to
|
|
158
|
+
* protect until the next purge.
|
|
125
159
|
*/
|
|
126
160
|
version: number;
|
|
127
161
|
compatibleVersions: number[];
|
|
@@ -188,6 +222,10 @@ export declare const vaultSpec: {
|
|
|
188
222
|
height?: number | undefined;
|
|
189
223
|
sha256?: string | undefined;
|
|
190
224
|
}>;
|
|
225
|
+
graves: import("@deepseek-ai/dsh-storage-domain").DomainTableSpec<string, {
|
|
226
|
+
id: string;
|
|
227
|
+
purgedAt: string;
|
|
228
|
+
}>;
|
|
191
229
|
};
|
|
192
230
|
};
|
|
193
231
|
export type VaultSpec = typeof vaultSpec;
|
|
@@ -96,6 +96,7 @@ export declare class Vault {
|
|
|
96
96
|
private migrateOnce;
|
|
97
97
|
private get items();
|
|
98
98
|
private get attachments();
|
|
99
|
+
private get graves();
|
|
99
100
|
/** Where the key state stands; what the panel shows and the tools consult. */
|
|
100
101
|
get lockState(): VaultLockState;
|
|
101
102
|
/**
|
|
@@ -211,16 +212,33 @@ export declare class Vault {
|
|
|
211
212
|
/** Undo a soft delete. */
|
|
212
213
|
restore(id: string): Promise<Item>;
|
|
213
214
|
/**
|
|
214
|
-
* Delete one record for good, together with its attachment rows
|
|
215
|
+
* Delete one record for good, together with its attachment rows, and leave a
|
|
216
|
+
* grave behind so nothing that is still in the cloud can file it again.
|
|
215
217
|
*
|
|
216
218
|
* The bytes behind an attachment live in dsh's own store, which never deletes
|
|
217
219
|
* automatically — emptying the recycle bin drops our references, not their
|
|
218
220
|
* objects. A row another record still references is left alone.
|
|
219
221
|
*
|
|
222
|
+
* The grave is the other half of that sentence, and it is why this is not
|
|
223
|
+
* called `remove` any more: deleting the row really does take this machine's
|
|
224
|
+
* copy away, but a copy under a *different* `sync/` tree in the same bucket
|
|
225
|
+
* survives the purge of our own tree (`../remote/remove.ts` only ever deletes
|
|
226
|
+
* under this vault's root), and the merge has no local row to outrank it with.
|
|
227
|
+
* Measured 2026-09-21: thirteen emptied tombstones came back into the bin on
|
|
228
|
+
* every `dsh` restart. The grave is what makes "gone" stick; it holds the id
|
|
229
|
+
* and the moment, no content.
|
|
230
|
+
*
|
|
220
231
|
* @param id - record key.
|
|
221
232
|
* @returns whether the record existed.
|
|
222
233
|
*/
|
|
223
|
-
|
|
234
|
+
purge(id: string): Promise<boolean>;
|
|
235
|
+
/**
|
|
236
|
+
* When this id was emptied out of the bin, if it ever was.
|
|
237
|
+
*
|
|
238
|
+
* The merge asks this before taking a copy of a record this vault does not
|
|
239
|
+
* have (see `newerThanPurge` in `../remote/merge.ts`).
|
|
240
|
+
*/
|
|
241
|
+
purgedAt(id: string): string | undefined;
|
|
224
242
|
/**
|
|
225
243
|
* Record an attachment's metadata. The bytes stay in the store named by
|
|
226
244
|
* `storeId`; this row is our own index over them, keyed by a generated id
|
|
@@ -92,8 +92,10 @@ export interface WebdavSettings {
|
|
|
92
92
|
* cloud drive": a machine that used to sync somewhere else leaves its records
|
|
93
93
|
* behind, and they come back (asked 2026-09-21, 19 records). Merging settles
|
|
94
94
|
* per record by `id` + `updatedAt`, so an older tree cannot overwrite a newer
|
|
95
|
-
* copy —
|
|
96
|
-
* purge leaves
|
|
95
|
+
* copy — and a record the user **emptied out of the bin** stays gone: the
|
|
96
|
+
* grave that `purge` leaves is the thing such a copy cannot outrank (it used
|
|
97
|
+
* to, and thirteen emptied tombstones came back into the bin on every restart
|
|
98
|
+
* — measured 2026-09-21).
|
|
97
99
|
*/
|
|
98
100
|
adoptForeignRoots: boolean;
|
|
99
101
|
username: string;
|
|
@@ -175,6 +177,15 @@ export interface PullResult {
|
|
|
175
177
|
merged?: number;
|
|
176
178
|
/** How many of {@link merged} were ids this vault did not have at all. */
|
|
177
179
|
added?: number;
|
|
180
|
+
/**
|
|
181
|
+
* Copies the cloud still holds of records this vault **emptied out of the bin**.
|
|
182
|
+
*
|
|
183
|
+
* Not taken in, on purpose: `purge` leaves a grave, and a copy that is not
|
|
184
|
+
* *newer* than it does not come back (see `graves` in `src/host/vault/spec.ts`).
|
|
185
|
+
* Reported because a reader who just emptied the bin is owed the sight of the
|
|
186
|
+
* older tree trying and being refused.
|
|
187
|
+
*/
|
|
188
|
+
purged?: number;
|
|
178
189
|
/** How many of {@link merged} were deletions (they land in the recycle bin). */
|
|
179
190
|
deletions?: number;
|
|
180
191
|
/** Attachment objects the merge had to fetch and admit locally. */
|