dsh-screenshot-capture 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 wangzhanchao883
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,170 @@
1
+ # dsh-screenshot-capture · 指哪拍哪 (Point-and-Shoot Capture)
2
+
3
+ DeepSeek Harness 插件:截屏即存 —— 监听剪贴板 → 鼠标位置弹出系统级悬浮窗
4
+ 【注释输入框 +「重点」复选框 + 复制截图 / 存文档 / 存图片】→ 图片 + 即时 OCR(通义千问多模态)写入 Obsidian 按天合并笔记
5
+ → 晚间 AI 整理(点选保留 / 保存全部 → 归类 → 双链 → 当日总结 → 归档)。
6
+
7
+ A [DeepSeek Harness](https://github.com/deepseek-ai/deepseek-harness)(DSH) plugin that turns a screenshot (or any copied image) into
8
+ an Obsidian note: a system-level floating window appears next to your mouse, lets you add a comment / mark it as a
9
+ key point, then saves the image (with instant OCR via the
10
+ [Tongyi Qianwen](https://dashscope.aliyuncs.com) multimodal API) into a per-day note, and an optional
11
+ evening AI pass organizes the day's entries with categories, backlinks, a summary, and archival.
12
+ `v0.2.0` adds a comment box and a "重点 / key point" checkbox to each capture.
13
+
14
+ [![Awesome DSH Plugin](https://awesome-dsh-plugin.com/badge.svg)](https://awesome-dsh-plugin.com)
15
+
16
+ ## 功能 / Features
17
+
18
+ - **Windows 系统级悬浮窗**:检测到新截图(或复制图片)后,在鼠标旁弹出置顶小窗,三键选择;
19
+ - **注释 / 重点标记(v0.2.0)**:弹窗内置注释输入框 +「重点」复选框;勾重点后该条注释以标题一 `**重点**` 写入笔记,提示这是重点;
20
+ - **存文档**:图片入库 + 当天笔记立即生成,OCR 文字 1~5 秒后自动回填;
21
+ - **存图片**:仅图片入库(`#图片` 标签);
22
+ - **即时 OCR**:通义千问多模态,无需本地模型;未配 key 时存文档会标注识别失败,不影响入库;
23
+ - **晚间 AI 整理**:对当天收件箱条目,点选保留/保存全部 → 归类 → 生成分类笔记 + 双链 → 当日总结 → 归档;
24
+ - **Web 图形配置界面**:DSH 设置 →「截图入库」,改动实时生效。
25
+
26
+ ## 截图 / Screenshots
27
+
28
+ DSH 设置 →「截图入库」图形配置界面:
29
+
30
+ ![Settings](assets/screenshots/settings.png)
31
+
32
+ 截图后鼠标旁弹出的系统级悬浮窗(注释 + 重点 + 复制截图 / 存文档 / 存图片):
33
+
34
+ ![Floating window](assets/screenshots/floating-window.png)
35
+
36
+ 入库后的 Obsidian 按天合并笔记(含 OCR 文字):
37
+
38
+ ![Obsidian note](assets/screenshots/obsidian-note.png)
39
+
40
+ ## 环境要求 / Requirements
41
+
42
+ - **Windows**(依赖系统自带 PowerShell 5.1 + WinForms)
43
+ - 通义千问 API key(通过 `DASHSCOPE_API_KEY` 环境变量,或插件配置文件)
44
+ - Obsidian 库(或任意本地 markdown 目录)
45
+
46
+ ## 安装 / Install (DSH plugin)
47
+
48
+ ```sh
49
+ # 从 npm 安装(若已发布)
50
+ dsh plugin --profile desktop add dsh-screenshot-capture
51
+
52
+ # 从 GitHub 源安装
53
+ dsh plugin --profile desktop add github:wangzhanchao883/dsh-screenshot-capture
54
+
55
+ # 或用本地目录(开发时)
56
+ dsh plugin --profile desktop add ./dsh-screenshot-capture
57
+ ```
58
+
59
+ 重启 DSH 后生效。剪贴板监听与悬浮窗由插件 host 端自动拉起(需要 DSH 运行中)。
60
+
61
+ > 首次使用请先在 DSH 设置 →「截图入库」里配置你的 Obsidian 库路径(以及可选的通义千问 API key)。
62
+ > 未配置 `vaultPath` 时采集功能会自动停用并告警。
63
+
64
+ ## 配置 / Configuration
65
+
66
+ ### 方式一:Web 图形界面 / Web settings (推荐)
67
+
68
+ DSH Web 界面 → 侧栏 **设置** → **截图入库** 分区,直接改:
69
+ - 通用:启用监听、Obsidian 库路径、轮询间隔、冷却时间
70
+ - OCR:识别模式(通义千问 / 关闭)、模型、API Key(留空则用环境变量)
71
+ - 悬浮窗:横向/纵向偏移、预览最大宽度
72
+
73
+ 改动**实时生效**(监听器会自动重启)。配置存储在 DSH 的 `settings.yaml` 用户层。
74
+
75
+ ### 方式二:配置文件 / config file (base 层)
76
+
77
+ `%USERPROFILE%\.dsh-screenshot-capture\config.json`
78
+
79
+ ```jsonc
80
+ {
81
+ "enabled": true,
82
+ "vaultPath": "D:\\path\\to\\obsidian-vault",
83
+ "ocr": { "mode": "qwen", "model": "qwen-vl-plus", "apiKey": "" },
84
+ "pollIntervalMs": 200,
85
+ "cooldownMs": 2000
86
+ }
87
+ ```
88
+
89
+ - OCR key 也可放环境变量 `DASHSCOPE_API_KEY`(推荐,避免明文落盘);
90
+ - `ocr.mode` 为 `"off"` 时存文档不识别文字。
91
+
92
+ > 注意:插件**默认不带库路径**(为空)。未配置 `vaultPath` 时,采集功能会自动停用并告警,不会创建任何目录。首次使用请务必在 Web 设置 / `config.json` 里配置你自己的 Obsidian 库路径。
93
+
94
+ ## 使用 / Usage
95
+
96
+ 1. 开着 DSH;
97
+ 2. `Win+Shift+S` 框选截图(或 `Ctrl+C` 复制任意图片);
98
+ 3. 悬浮窗出现在鼠标旁:先在注释框里写注释/评论(可选),需要的话勾选「重点」,再选【复制截图 / 存文档 / 存图片】;
99
+ 4. 晚上对 DSH 说"整理今天的截图",按提示选择保留条目。
100
+
101
+ ### 注释与「重点」标记 / Comments & key points
102
+
103
+ - 截图时填写的注释会写入当天收件箱该条目下方;
104
+ - 勾选「重点」后,注释上方加一行标题一 `# **重点**` 作为醒目提示;
105
+ - 晚间整理时,注释与重点标记随条目进入「知识库/<分类>/」笔记的 `## 重点` 小节(未勾重点的纯注释也保留);
106
+ - 点「复制截图」不写笔记,忽略输入框内容。
107
+
108
+ ## Obsidian 结构 / Output layout
109
+
110
+ ```
111
+ vault/
112
+ ├── 收件箱/ # 白天截图进(按天合并)
113
+ │ ├── 2026-08-21.md
114
+ │ └── attachments/
115
+ ├── 知识库/<分类>/ # 晚间整理后,按分类
116
+ │ ├── INDEX.md
117
+ │ └── 20260821_1430_文档.md
118
+ ├── 总结/ # 当日总结(含双链)
119
+ └── 归档/ # 已整理的收件箱笔记
120
+ ```
121
+
122
+ ## 目录结构 / Repository layout
123
+
124
+ ```
125
+ dsh-screenshot-capture/
126
+ ├── index.mjs # host 端入口:注册工具 + settings 命名空间 + 剪贴板监听(watcher 单例、配置热重启)
127
+ ├── core.mjs # 悬浮窗三键选择的分发处理
128
+ ├── config.mjs # 默认配置 / config.json 合并 / 运行时入参覆盖
129
+ ├── clipboard.mjs # 拉起 PowerShell 常驻助手并读事件日志
130
+ ├── storage.mjs # Obsidian 入库(按天合并、目录结构)
131
+ ├── ocr.mjs # 通义千问多模态识别
132
+ ├── organize.mjs # 晚间 AI 整理(归类 / 双链 / 总结 / 归档)
133
+ ├── dev-run.mjs # 独立开发入口(不需要 DSH)
134
+ ├── client.js # 浏览器端:注册 Web 设置页,经 settingsScope 读写配置
135
+ ├── scripts/
136
+ │ └── clip-dialog.ps1 # PowerShell 轮询剪贴板 + 弹出系统级悬浮窗
137
+ ├── cordis.patch.yml # DSH bundle patch(声明本插件为可安装 host 端 bundle)
138
+ ├── test/ # 独立测试脚本(注入 vault 路径即可跑,无需 DSH)
139
+ ├── package.json # dsh.bundle + dsh.client manifest
140
+ ├── LICENSE # MIT
141
+ └── README.md
142
+ ```
143
+
144
+ ## 架构速览 / Architecture
145
+
146
+ - **host 端**(Node):`index.mjs` 入口,注册工具 + settings 命名空间 + 剪贴板监听(watcher 单例、配置热重启);`clipboard.mjs` 拉起 PowerShell 常驻助手并读事件日志;`storage.mjs`/`ocr.mjs`/`organize.mjs` 负责入库/识别/整理。
147
+ - **client 端**(浏览器):`client.js` 注册 Web 设置页,经 `settingsScope` 读写配置。
148
+ - **PowerShell**:`scripts/clip-dialog.ps1` 轮询剪贴板 + 弹出悬浮窗,通过 `%TEMP%\dsh-capture\events.log` 与 Node 通信。
149
+
150
+ > 注意:悬浮窗脚本是 Windows PowerShell(`Add-Type` + WinForms)。请在可控环境下使用,并只安装你信任的代码(本插件无后门,不联网外传剪贴板数据)。
151
+
152
+ ## 安全说明 / Security
153
+
154
+ - 本插件**不含任何内置密钥**;OCR key 只从环境变量或配置文件读取;
155
+ - 剪贴板图片仅本地处理,OCR 时才向通义千问上传该张图片用于识别;
156
+ - 仓库代码不含个人路径之外的敏感信息;配置文件里的 `vaultPath` 等路径是使用者的本地路径,不会写入仓库(仓库默认 `vaultPath` 为空,首次使用需自行配置,未配置时自动停用采集)。
157
+
158
+ ## 开发与独立测试 / Development (no DSH needed)
159
+
160
+ ```sh
161
+ npm run dev # 交互模式(真实弹窗)
162
+ npm run dev:auto # 自动模式:检测到截图自动存文档
163
+ npm test # 运行存储/整理单测(无需 DSH、无需 API key)
164
+ ```
165
+
166
+ 可用环境变量 `DSC_VAULT_PATH` 覆盖 vault 路径,避免污染真实库。测试说明见 [`test/README.md`](./test/README.md)。
167
+
168
+ ## License
169
+
170
+ [MIT](./LICENSE) © 2026 wangzhanchao883
package/client.js ADDED
@@ -0,0 +1,261 @@
1
+ /* dsh-screenshot-capture client bundle — 浏览器半端(Web 设置页)。
2
+ * 以经典脚本形式注册到 window.__ModuleLoader__;工厂内用 require 取 React,
3
+ * 不能用 JSX(无构建步骤),一律 React.createElement。 */
4
+ window.__ModuleLoader__.load({
5
+ id: "dsh-screenshot-capture",
6
+ factory: (require) => {
7
+ var module = { exports: {} };
8
+ var exports = module.exports;
9
+
10
+ const React = require("react");
11
+ const h = React.createElement;
12
+ const { useEffect, useRef, useState } = React;
13
+
14
+ const NS = "settings.screenshotCapture";
15
+ const SETTINGS_NAMESPACE = "dsh-screenshot-capture";
16
+
17
+ const zh = {
18
+ nav: "截图入库",
19
+ loading: "正在读取配置…",
20
+ unavailable: "配置面板不可用(设置服务未挂载)。请直接编辑配置 JSON 文件。",
21
+ saved: "已保存",
22
+ error: "保存失败:",
23
+ general: "通用",
24
+ enabled: "启用剪贴板监听",
25
+ enabledHint: "关闭后不再监听剪贴板,悬浮窗也不会弹出",
26
+ vaultPath: "Obsidian 库路径",
27
+ vaultPathHint: "截图笔记会写入这个库",
28
+ pollIntervalMs: "剪贴板轮询间隔 (ms)",
29
+ cooldownMs: "截图冷却时间 (ms)",
30
+ ocr: "OCR 文字识别",
31
+ ocrMode: "识别模式",
32
+ ocrModeQwen: "通义千问 (qwen)",
33
+ ocrModeOff: "关闭(只存图不识别)",
34
+ ocrModel: "模型",
35
+ ocrApiKey: "API Key",
36
+ ocrApiKeyHint: "留空则使用环境变量 DASHSCOPE_API_KEY",
37
+ dialog: "悬浮窗",
38
+ dialogOffsetX: "横向偏移 (px)",
39
+ dialogOffsetY: "纵向偏移 (px)",
40
+ dialogPreviewMaxWidth: "预览最大宽度 (px)",
41
+ };
42
+
43
+ const en = {
44
+ nav: "Screenshot Capture",
45
+ loading: "Reading configuration…",
46
+ unavailable: "Configuration panel unavailable (settings service not mounted). Edit the config JSON file directly instead.",
47
+ saved: "Saved",
48
+ error: "Save failed:",
49
+ general: "General",
50
+ enabled: "Enable clipboard watching",
51
+ enabledHint: "When off, clipboard watching and the floating window are disabled",
52
+ vaultPath: "Obsidian vault path",
53
+ vaultPathHint: "Screenshots are stored into this vault",
54
+ pollIntervalMs: "Clipboard poll interval (ms)",
55
+ cooldownMs: "Screenshot cooldown (ms)",
56
+ ocr: "OCR",
57
+ ocrMode: "Recognition mode",
58
+ ocrModeQwen: "Qwen (qwen)",
59
+ ocrModeOff: "Off (store without OCR)",
60
+ ocrModel: "Model",
61
+ ocrApiKey: "API Key",
62
+ ocrApiKeyHint: "Leave empty to use the DASHSCOPE_API_KEY environment variable",
63
+ dialog: "Floating window",
64
+ dialogOffsetX: "Horizontal offset (px)",
65
+ dialogOffsetY: "Vertical offset (px)",
66
+ dialogPreviewMaxWidth: "Preview max width (px)",
67
+ };
68
+
69
+ const STYLES = [
70
+ ".scc-config{max-width:640px;display:flex;flex-direction:column;gap:16px;color:var(--dsw-alias-label-primary)}",
71
+ ".scc-group{border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:14px;background:var(--dsw-alias-bg-layer-2)}",
72
+ ".scc-group h3{margin:0 0 12px;font-size:13px;font-weight:600}",
73
+ ".scc-field{display:flex;flex-direction:column;gap:4px;margin-bottom:10px}",
74
+ ".scc-field label{font-size:12px;font-weight:500}",
75
+ ".scc-field input[type=text],.scc-field input[type=number],.scc-field input[type=password],.scc-field select{height:30px;border:1px solid var(--dsw-alias-border-l2);background:var(--dsw-alias-bg-layer-1);color:var(--dsw-alias-label-primary);border-radius:6px;padding:0 8px;font:inherit;font-size:13px;box-sizing:border-box}",
76
+ ".scc-hint{font-size:11px;color:var(--dsw-alias-label-tertiary)}",
77
+ ".scc-switch{display:flex;align-items:center;gap:8px;margin-bottom:6px}",
78
+ ".scc-switch input{accent-color:var(--dsw-alias-state-business-primary)}",
79
+ ".scc-status{font-size:12px;color:var(--dsw-alias-label-tertiary);min-height:16px}",
80
+ ".scc-row{display:flex;gap:12px}",
81
+ ".scc-row .scc-field{flex:1}",
82
+ ].join("");
83
+
84
+ /** 一行标签 + 控件 + 提示 */
85
+ function Field({ label, hint, children }) {
86
+ return h("div", { className: "scc-field" },
87
+ h("label", null, label),
88
+ children,
89
+ hint ? h("div", { className: "scc-hint" }, hint) : null,
90
+ );
91
+ }
92
+
93
+ /** 文本输入:本地草稿,失焦/回车时才提交 */
94
+ function TextInput({ value, onCommit, password }) {
95
+ const [draft, setDraft] = useState(value);
96
+ useEffect(() => setDraft(value), [value]);
97
+ return h("input", {
98
+ type: password ? "password" : "text",
99
+ value: draft,
100
+ onChange: (e) => setDraft(e.target.value),
101
+ onBlur: () => {
102
+ if (draft !== value) onCommit(draft);
103
+ },
104
+ onKeyDown: (e) => {
105
+ if (e.key === "Enter") e.target.blur();
106
+ },
107
+ });
108
+ }
109
+
110
+ /** 数字输入:解析失败则回退当前值 */
111
+ function NumberInput({ value, onCommit, min, max }) {
112
+ const [draft, setDraft] = useState(String(value ?? ""));
113
+ useEffect(() => setDraft(String(value ?? "")), [value]);
114
+ const commit = () => {
115
+ const n = Number(draft);
116
+ if (Number.isFinite(n) && n !== value) onCommit(n);
117
+ else setDraft(String(value ?? ""));
118
+ };
119
+ return h("input", {
120
+ type: "number",
121
+ min,
122
+ max,
123
+ value: draft,
124
+ onChange: (e) => setDraft(e.target.value),
125
+ onBlur: commit,
126
+ onKeyDown: (e) => {
127
+ if (e.key === "Enter") e.target.blur();
128
+ },
129
+ });
130
+ }
131
+
132
+ /** 设置页:绑定 settings 命名空间,展示并保存配置 */
133
+ function ConfigSection({ scope, t }) {
134
+ const [snap, setSnap] = useState(() => scope.getSnapshot());
135
+ const [status, setStatus] = useState("");
136
+ const statusTimer = useRef(null);
137
+ useEffect(() => scope.subscribe(() => setSnap(scope.getSnapshot())), [scope]);
138
+ useEffect(() => () => {
139
+ if (statusTimer.current) clearTimeout(statusTimer.current);
140
+ }, []);
141
+ const flash = (msg) => {
142
+ setStatus(msg);
143
+ if (statusTimer.current) clearTimeout(statusTimer.current);
144
+ statusTimer.current = setTimeout(() => setStatus(""), 2500);
145
+ };
146
+ const save = (field, v) => {
147
+ Promise.resolve(scope.set(field, v))
148
+ .then(() => flash(t("saved")))
149
+ .catch((err) => flash(`${t("error")} ${err && err.message ? err.message : String(err)}`));
150
+ };
151
+
152
+ if (snap.status === "loading") return h("p", { className: "scc-status" }, t("loading"));
153
+ if (snap.status === "unavailable") return h("p", { className: "scc-status" }, t("unavailable"));
154
+ const v = snap.value || {};
155
+
156
+ return h("div", { className: "scc-config" }, [
157
+ h("div", { className: "scc-group" }, [
158
+ h("h3", null, t("general")),
159
+ h("div", { className: "scc-switch" }, [
160
+ h("input", {
161
+ type: "checkbox",
162
+ id: "scc-enabled",
163
+ checked: !!v.enabled,
164
+ onChange: (e) => save("enabled", e.target.checked),
165
+ }),
166
+ h("label", { htmlFor: "scc-enabled" }, t("enabled")),
167
+ ]),
168
+ h("div", { className: "scc-hint" }, t("enabledHint")),
169
+ Field({
170
+ label: t("vaultPath"),
171
+ hint: t("vaultPathHint"),
172
+ children: h(TextInput, { value: v.vaultPath || "", onCommit: (val) => save("vaultPath", val) }),
173
+ }),
174
+ h("div", { className: "scc-row" }, [
175
+ Field({
176
+ label: t("pollIntervalMs"),
177
+ children: h(NumberInput, { value: v.pollIntervalMs, min: 50, max: 60000, onCommit: (n) => save("pollIntervalMs", n) }),
178
+ }),
179
+ Field({
180
+ label: t("cooldownMs"),
181
+ children: h(NumberInput, { value: v.cooldownMs, min: 0, max: 120000, onCommit: (n) => save("cooldownMs", n) }),
182
+ }),
183
+ ]),
184
+ ]),
185
+ h("div", { className: "scc-group" }, [
186
+ h("h3", null, t("ocr")),
187
+ Field({
188
+ label: t("ocrMode"),
189
+ children: h("select", { value: v.ocrMode || "qwen", onChange: (e) => save("ocrMode", e.target.value) }, [
190
+ h("option", { value: "qwen" }, t("ocrModeQwen")),
191
+ h("option", { value: "off" }, t("ocrModeOff")),
192
+ ]),
193
+ }),
194
+ Field({
195
+ label: t("ocrModel"),
196
+ children: h(TextInput, { value: v.ocrModel || "", onCommit: (val) => save("ocrModel", val) }),
197
+ }),
198
+ Field({
199
+ label: t("ocrApiKey"),
200
+ hint: t("ocrApiKeyHint"),
201
+ children: h(TextInput, { value: v.ocrApiKey || "", password: true, onCommit: (val) => save("ocrApiKey", val) }),
202
+ }),
203
+ ]),
204
+ h("div", { className: "scc-group" }, [
205
+ h("h3", null, t("dialog")),
206
+ h("div", { className: "scc-row" }, [
207
+ Field({
208
+ label: t("dialogOffsetX"),
209
+ children: h(NumberInput, { value: v.dialogOffsetX, min: -1000, max: 1000, onCommit: (n) => save("dialogOffsetX", n) }),
210
+ }),
211
+ Field({
212
+ label: t("dialogOffsetY"),
213
+ children: h(NumberInput, { value: v.dialogOffsetY, min: -1000, max: 1000, onCommit: (n) => save("dialogOffsetY", n) }),
214
+ }),
215
+ ]),
216
+ Field({
217
+ label: t("dialogPreviewMaxWidth"),
218
+ children: h(NumberInput, { value: v.dialogPreviewMaxWidth, min: 100, max: 2000, onCommit: (n) => save("dialogPreviewMaxWidth", n) }),
219
+ }),
220
+ ]),
221
+ h("div", { className: "scc-status" }, status),
222
+ ]);
223
+ }
224
+
225
+ function apply(ctx) {
226
+ ctx.effect(() => {
227
+ const tag = document.createElement("style");
228
+ tag.setAttribute("data-plugin", "dsh-screenshot-capture");
229
+ tag.textContent = STYLES;
230
+ document.head.appendChild(tag);
231
+ return () => {
232
+ if (tag.parentNode) tag.parentNode.removeChild(tag);
233
+ };
234
+ }, "dsh-screenshot-capture: styles");
235
+
236
+ ctx.effect(() => {
237
+ return ctx.locale.register(NS, { zh, en });
238
+ }, "dsh-screenshot-capture: dictionaries");
239
+
240
+ const t = ctx.locale.bind(NS);
241
+ const scope = ctx.settingsScope.bind({ namespace: SETTINGS_NAMESPACE });
242
+ const injected = () => ({ scope });
243
+
244
+ ctx.slots.inject("settings.section", () => ctx.slots.register({
245
+ name: "settings.section",
246
+ id: "screenshot-capture",
247
+ order: 200,
248
+ label: () => t("nav"),
249
+ locale: NS,
250
+ inject: injected,
251
+ }, ConfigSection));
252
+ }
253
+
254
+ module.exports = {
255
+ name: "dsh-screenshot-capture",
256
+ inject: ["slots", "locale", "settingsScope"],
257
+ apply,
258
+ };
259
+ return module.exports;
260
+ },
261
+ });
package/clipboard.mjs ADDED
@@ -0,0 +1,146 @@
1
+ import { spawn } from "node:child_process";
2
+ import { EventEmitter } from "node:events";
3
+ import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
4
+ import { join } from "node:path";
5
+ import { tmpdir } from "node:os";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ const __dirname = fileURLToPath(new URL(".", import.meta.url));
9
+ const PS_SCRIPT = join(__dirname, "scripts", "clip-dialog.ps1");
10
+ const POWERSHELL = join(
11
+ process.env.SystemRoot || "C:\\Windows",
12
+ "System32",
13
+ "WindowsPowerShell",
14
+ "v1.0",
15
+ "powershell.exe",
16
+ );
17
+
18
+ /**
19
+ * 剪贴板监听器:拉起常驻 PowerShell 助手(轮询剪贴板 + 系统级悬浮窗),
20
+ * 通过事件日志文件(events.log, JSON 每行)通信,避免管道依赖。
21
+ * 事件: "ready" | "img" | "choice" | "err" | "exit"
22
+ */
23
+ export class ClipboardWatcher extends EventEmitter {
24
+ constructor(config = {}, options = {}) {
25
+ super();
26
+ this.config = config;
27
+ this.autoAction = options.autoAction || "";
28
+ this.workDir = join(tmpdir(), "dsh-capture");
29
+ this.logPath = join(this.workDir, "events.log");
30
+ this.offset = 0;
31
+ this.child = null;
32
+ this.timer = null;
33
+ this.started = false;
34
+ }
35
+
36
+ start() {
37
+ if (this.started) return;
38
+ this.started = true;
39
+ mkdirSync(this.workDir, { recursive: true });
40
+ if (existsSync(this.logPath)) this.offset = statSync(this.logPath).size;
41
+ else this.offset = 0;
42
+
43
+ const cfgFile = join(this.workDir, "watcher-config.json");
44
+ writeFileSync(cfgFile, JSON.stringify({
45
+ pollIntervalMs: this.config.pollIntervalMs ?? 200,
46
+ cooldownMs: this.config.cooldownMs ?? 2000,
47
+ offsetX: this.config.dialog?.offsetX ?? 16,
48
+ offsetY: this.config.dialog?.offsetY ?? 16,
49
+ previewMaxWidth: this.config.dialog?.previewMaxWidth ?? 320,
50
+ }), "utf8");
51
+
52
+ const args = [
53
+ "-NoProfile",
54
+ "-ExecutionPolicy", "Bypass",
55
+ "-File", PS_SCRIPT,
56
+ "-ConfigPath", cfgFile,
57
+ ];
58
+ if (this.autoAction) args.push("-AutoAction", this.autoAction);
59
+
60
+ try {
61
+ this.child = spawn(POWERSHELL, args, {
62
+ stdio: "ignore",
63
+ windowsHide: true,
64
+ });
65
+ } catch (err) {
66
+ this.emit("err", { msg: `spawn powershell: ${err.message}` });
67
+ return;
68
+ }
69
+
70
+ this.child.on("exit", (code) => {
71
+ this.emit("exit", { code });
72
+ this.started = false;
73
+ this.child = null;
74
+ });
75
+
76
+ this.timer = setInterval(() => this.#tick(), 300);
77
+ this.timer.unref?.();
78
+ }
79
+
80
+ /** 同步停(用于插件卸载,不等待退出)。 */
81
+ stop() {
82
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
83
+ if (this.child) {
84
+ try { this.child.kill(); } catch { /* ignore */ }
85
+ this.child = null;
86
+ }
87
+ this.started = false;
88
+ }
89
+
90
+ /**
91
+ * 异步停:等 PowerShell 子进程真正退出后再 resolve,避免旧监听残留。
92
+ * kill() 之外再用 taskkill /T /F 强杀进程树,并带超时兜底。
93
+ * @param timeoutMs - 等待退出的上限(默认 3s)。
94
+ */
95
+ stopAsync(timeoutMs = 3000) {
96
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
97
+ this.started = false;
98
+ const child = this.child;
99
+ this.child = null;
100
+ if (!child || child.exitCode !== null) return Promise.resolve();
101
+ return new Promise((resolve) => {
102
+ let settled = false;
103
+ const done = () => {
104
+ if (settled) return;
105
+ settled = true;
106
+ resolve();
107
+ };
108
+ const guard = setTimeout(done, timeoutMs);
109
+ child.once("exit", () => {
110
+ clearTimeout(guard);
111
+ done();
112
+ });
113
+ try {
114
+ child.kill();
115
+ } catch { /* 继续走 taskkill 兜底 */ }
116
+ // Windows 兜底:强制终止进程树(子进程可能处于 Add-Type 初始化等状态)
117
+ try {
118
+ const killer = spawn("taskkill", ["/PID", String(child.pid), "/T", "/F"], {
119
+ stdio: "ignore",
120
+ windowsHide: true,
121
+ });
122
+ killer.unref?.();
123
+ } catch { /* ignore */ }
124
+ });
125
+ }
126
+
127
+ #tick() {
128
+ if (!existsSync(this.logPath)) return;
129
+ const size = statSync(this.logPath).size;
130
+ if (size <= this.offset) return;
131
+ let buf;
132
+ try {
133
+ const fd = readFileSync(this.logPath);
134
+ buf = fd.subarray(this.offset, size);
135
+ } catch {
136
+ return;
137
+ }
138
+ this.offset = size;
139
+ const lines = buf.toString("utf8").split(/\r?\n/).filter(Boolean);
140
+ for (const line of lines) {
141
+ let ev;
142
+ try { ev = JSON.parse(line); } catch { continue; }
143
+ this.emit(ev.t, ev);
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,21 @@
1
+ {
2
+ "//": "dsh-screenshot-capture config example. Copy to %USERPROFILE%\\.dsh-screenshot-capture\\config.json and fill in your own values. The OCR key is also read from the DASHSCOPE_API_KEY environment variable (recommended).",
3
+ "enabled": true,
4
+ "vaultPath": "D:\\path\\to\\obsidian-vault",
5
+ "inboxFolder": "收件箱",
6
+ "attachmentsFolder": "attachments",
7
+ "knowledgeFolder": "知识库",
8
+ "summaryFolder": "总结",
9
+ "archiveFolder": "归档",
10
+ "recycleFolder": "回收站",
11
+ "ocr": {
12
+ "mode": "qwen",
13
+ "model": "qwen-vl-plus",
14
+ "apiKey": "",
15
+ "endpoint": "https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions"
16
+ },
17
+ "pollIntervalMs": 200,
18
+ "cooldownMs": 2000,
19
+ "dialog": { "offsetX": 16, "offsetY": 16, "previewMaxWidth": 320 },
20
+ "organize": { "addBacklinks": true }
21
+ }