dsh-local-board 0.0.0-stage → 1.5.6

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 dsh-kanban contributors
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 CHANGED
@@ -1,3 +1,81 @@
1
- # Temporary Holding Version
1
+ # DSH 本地待办看板 · dsh-local-board
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ DeepSeek Harness 的左侧导航任务看板:**Todo / Doing / Done 三列**,按项目组织。
4
+
5
+ **数据是你自己的**:第一次打开会让你选一个文件夹,看板在里头生成一个 `看板.md`,所有任务都在这个文件里。放哪都行——Obsidian 仓库、iCloud、坚果云、U 盘,随便;换机器把这个文件夹拷走就是完整迁移。
6
+
7
+ ## 特性
8
+
9
+ - 左侧导航栏常驻入口,点开是整页看板
10
+ - 三列看板,卡片**拖拽**流转;也能直接拖到左侧项目名上换项目
11
+ - 每个项目有状态(进行中/暂停/已结束,左侧圆点用颜色区分)、角色(我负责/需跟进/仅知晓)
12
+ - 卡片支持:负责人、优先级、截止日期**精确到分钟**、备注、**颜色标记**、**私密打码**(默认遮罩,点一下才显示)
13
+ - 逾期标红、今天到期标橙
14
+ - 顶部「打开文件夹」一键在访达中定位数据目录
15
+ - 搜索、按截止日排序、新建项目、重命名、删除(删除会归档到文件末尾,可恢复)
16
+ - 面向 Agent 的 `kanban` 工具,可以在对话里直接读写看板
17
+
18
+ ## 数据在哪
19
+
20
+ 第一次打开面板时会让你**选一个文件夹**,选择记在 `~/.dsh/kanban/settings.json`,之后一直用这个目录。
21
+
22
+ 也可以跳过选择界面,直接在 profile 的 `cordis.patch.yml` 里固定:
23
+
24
+ ```yaml
25
+ - id: kanban
26
+ config:
27
+ vaultDir: '/你的/路径/看板'
28
+ ```
29
+
30
+ 文件长这样,用任何编辑器都能直接读改:
31
+
32
+ ```markdown
33
+ ## 童话镇守卫战
34
+
35
+ - 角色: 我负责
36
+ - 状态: 进行中
37
+
38
+ ### Todo
39
+ - [ ] 修好泡泡糖发射器 · @小呆呆 · #P1 · 📅2026-11-12 14:30 ^k-a1
40
+
41
+ ### Doing
42
+ - [ ] 特训菲菲的魔法棒 · 🎨blue · @菲菲 · 📝周三下午在糖果广场集合 ^k-a2
43
+
44
+ ### Done
45
+ - [x] 把怪兽赶出糖果森林 · ✅2026-11-05 ^k-a3
46
+ ```
47
+
48
+ 这是纯 Markdown,用任何编辑器都能打开和手改。
49
+
50
+ - `🎨` 后面是颜色名(red/orange/yellow/green/blue/purple/gray)
51
+ - `🙈` 表示该卡片在界面上默认打码
52
+ - `^k-xxxxx` 是稳定 ID,请勿删除
53
+ - `[ ]` 改成 `[x]` 即完成
54
+
55
+ ## 定时提醒(仅 macOS)
56
+
57
+ 装好后会自动创建三个 LaunchAgent,可在面板「自动化」标签里改时间、停用:
58
+
59
+ | 任务 | 默认 | 内容 |
60
+ | --- | --- | --- |
61
+ | 每日摘要 | 每天 09:00 | 今天到期的卡片(按时间排序)+ 已逾期 |
62
+ | 临近提醒 | 每 30 分钟 | 仅在 2 小时内有卡片到期时提醒,同一条不重复 |
63
+ | 周回顾 | 每周一 09:30 | 上周完成、进行中、待办、逾期 |
64
+
65
+ > macOS 的隐私保护会拦住定时任务读取 `~/Documents` 这类受保护目录;插件每次保存时都会往 `~/.dsh/kanban/board-snapshot.md` 写一份快照,任务读不到原文件时自动用快照。
66
+
67
+ ### 已知边界
68
+
69
+ 一台机器同一时间**只支持一个数据目录的定时提醒**——三个任务在系统里各只有一份,指向最后一次配置的目录:
70
+
71
+ - 换目录:在面板里重新选即可,任务会自动跟着走(旧目录不再提醒,数据不受影响)
72
+ - 同时用两个 profile、各配不同目录:只有最后配置的那个会收到提醒
73
+ - 不需要提醒:在「自动化」里逐个停用即可,看板功能完全不受影响
74
+
75
+ ## 平台
76
+
77
+ 看板本体跨平台;定时提醒依赖 `launchctl`,仅 macOS 生效,其他平台「自动化」标签隐藏。
78
+
79
+ ## 许可
80
+
81
+ MIT
package/automations.js ADDED
@@ -0,0 +1,281 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { promisify } from 'node:util';
3
+ import fs from 'node:fs/promises';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+
7
+ const run = promisify(execFile);
8
+ const LABEL_PREFIX = 'com.dsh.kanban.';
9
+ const WEEKDAYS = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
10
+
11
+ /** launchd is macOS-only; every entry point degrades to "unsupported" elsewhere. */
12
+ export const SUPPORTED = process.platform === 'darwin';
13
+
14
+ /** Where the shipped helper scripts are copied to, so launchd can run them by absolute path. */
15
+ export function notifyDir() {
16
+ return path.join(os.homedir(), '.dsh', 'kanban', 'notify');
17
+ }
18
+
19
+ /** Friendly names for the jobs this plugin ships; anything else keeps its label. */
20
+ const KNOWN = {
21
+ digest: { title: '每日摘要', description: '每天早上把今天到期的卡片按时间排好推一条通知' },
22
+ duesoon: { title: '临近提醒', description: '每隔一段时间检查一次,只在有卡片 2 小时内到期时提醒(同一条不重复)' },
23
+ weekly: { title: '周回顾', description: '每周一统计上周完成情况、进行中与逾期' }
24
+ };
25
+
26
+ export function agentsDir() {
27
+ // Tests and secondary instances point this elsewhere so they never touch the real jobs.
28
+ return process.env.DSH_KANBAN_AGENTS_DIR || path.join(os.homedir(), 'Library', 'LaunchAgents');
29
+ }
30
+
31
+ const DEFAULT_JOBS = {
32
+ digest: { script: 'digest.py', schedule: { Hour: 9, Minute: 0 } },
33
+ duesoon: { script: 'due-soon.py', schedule: { StartInterval: 1800 } },
34
+ weekly: { script: 'weekly.py', schedule: { Weekday: 1, Hour: 9, Minute: 30 } }
35
+ };
36
+
37
+ /** Absolute path of a shipped script inside this package. */
38
+ function packagedScript(name, pluginDir) {
39
+ return path.join(pluginDir, 'scripts', name);
40
+ }
41
+
42
+ /**
43
+ * Copy the shipped helper scripts into ~/.dsh/kanban/notify on install or upgrade, so
44
+ * the launchd jobs always run the code that belongs to this plugin version.
45
+ */
46
+ export async function syncScripts(pluginDir, version) {
47
+ if (!SUPPORTED) return;
48
+ const dir = notifyDir();
49
+ await fs.mkdir(dir, { recursive: true });
50
+ const stampFile = path.join(dir, '.scripts-version');
51
+ let current = '';
52
+ try {
53
+ current = (await fs.readFile(stampFile, 'utf8')).trim();
54
+ } catch {
55
+ current = '';
56
+ }
57
+ if (current === version) return;
58
+ for (const [id, job] of Object.entries(DEFAULT_JOBS)) {
59
+ try {
60
+ await fs.copyFile(packagedScript(job.script, pluginDir), path.join(dir, job.script));
61
+ } catch {
62
+ // A missing packaged script must not stop the plugin from loading.
63
+ }
64
+ }
65
+ await fs.writeFile(stampFile, version, 'utf8');
66
+ }
67
+
68
+ /** Create any shipped job that is not installed yet, pointing at this vault. */
69
+ export async function ensureDefaultAutomations(vaultDir) {
70
+ if (!SUPPORTED) return;
71
+ for (const [id, job] of Object.entries(DEFAULT_JOBS)) {
72
+ const file = plistPathFor(id);
73
+ let exists = true;
74
+ try {
75
+ await fs.access(file);
76
+ } catch {
77
+ exists = false;
78
+ }
79
+ if (exists) {
80
+ // Keep an existing job's schedule, but make sure it can find this vault.
81
+ try {
82
+ const data = await readPlist(file);
83
+ const current = (data.EnvironmentVariables || {}).DSH_KANBAN_VAULT;
84
+ if (current !== vaultDir) {
85
+ data.EnvironmentVariables = { ...(data.EnvironmentVariables || {}), DSH_KANBAN_VAULT: vaultDir };
86
+ await fs.writeFile(file, writePlistXml(data), 'utf8');
87
+ await bootstrap(file);
88
+ }
89
+ } catch {
90
+ // An unreadable plist is left alone; the panel reports it.
91
+ }
92
+ continue;
93
+ }
94
+ const data = {
95
+ Label: `${LABEL_PREFIX}${id}`,
96
+ ProgramArguments: ['/usr/bin/python3', path.join(notifyDir(), job.script)],
97
+ ...(job.schedule.StartInterval ? { StartInterval: job.schedule.StartInterval } : { StartCalendarInterval: job.schedule }),
98
+ RunAtLoad: false,
99
+ StandardOutPath: path.join(notifyDir(), `${id}.out`),
100
+ StandardErrorPath: path.join(notifyDir(), `${id}.err`),
101
+ EnvironmentVariables: { DSH_KANBAN_VAULT: path.dirname(path.join(vaultDir, '看板.md')) }
102
+ };
103
+ await fs.mkdir(agentsDir(), { recursive: true });
104
+ await fs.writeFile(file, writePlistXml(data), 'utf8');
105
+ await bootstrap(file);
106
+ }
107
+ }
108
+
109
+ function plistPathFor(id) {
110
+ return path.join(agentsDir(), `${LABEL_PREFIX}${id}.plist`);
111
+ }
112
+
113
+ function idFromLabel(label) {
114
+ return String(label).startsWith(LABEL_PREFIX) ? String(label).slice(LABEL_PREFIX.length) : '';
115
+ }
116
+
117
+ async function readPlist(file) {
118
+ const { stdout } = await run('/usr/bin/plutil', ['-convert', 'json', '-o', '-', file]);
119
+ return JSON.parse(stdout);
120
+ }
121
+
122
+ /** True when launchd currently has the job registered (it may still be temporarily off). */
123
+ async function isLoaded(id) {
124
+ try {
125
+ await run('/bin/launchctl', ['print', `gui/${process.getuid()}/${LABEL_PREFIX}${id}`]);
126
+ return true;
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+
132
+ function describeInterval(seconds) {
133
+ const minutes = Math.max(1, Math.round(seconds / 60));
134
+ return { kind: 'interval', text: `每 ${minutes} 分钟`, minutes };
135
+ }
136
+
137
+ function describeSchedule(interval) {
138
+ if (interval === undefined || interval === null) return { kind: 'unknown', text: '未知' };
139
+ if (Array.isArray(interval)) return { kind: 'unknown', text: `${interval.length} 个时间点` };
140
+ const { Hour = 0, Minute = 0, Weekday, Day, Month } = interval;
141
+ const clock = `${String(Hour).padStart(2, '0')}:${String(Minute).padStart(2, '0')}`;
142
+ if (Weekday !== undefined) return { kind: 'weekly', text: `每${WEEKDAYS[Weekday % 7] || Weekday} ${clock}`, hour: Hour, minute: Minute, weekday: Weekday };
143
+ if (Day !== undefined) return { kind: 'once', text: `${Month ? `${Month} 月 ` : ''}${Day} 日 ${clock}`, hour: Hour, minute: Minute, day: Day, month: Month };
144
+ return { kind: 'daily', text: `每天 ${clock}`, hour: Hour, minute: Minute };
145
+ }
146
+
147
+ /** Every kanban LaunchAgent on this Mac, with its schedule and live state. */
148
+ export async function listAutomations() {
149
+ if (!SUPPORTED) return [];
150
+ const dir = agentsDir();
151
+ let names = [];
152
+ try {
153
+ names = await fs.readdir(dir);
154
+ } catch {
155
+ return [];
156
+ }
157
+ const out = [];
158
+ for (const name of names) {
159
+ if (!name.startsWith(LABEL_PREFIX) || !name.endsWith('.plist')) continue;
160
+ const id = idFromLabel(name.slice(0, -'.plist'.length));
161
+ if (id === '') continue;
162
+ let data;
163
+ try {
164
+ data = await readPlist(path.join(dir, name));
165
+ } catch {
166
+ continue;
167
+ }
168
+ const schedule = Number.isInteger(data.StartInterval)
169
+ ? describeInterval(data.StartInterval)
170
+ : describeSchedule(data.StartCalendarInterval);
171
+ const known = KNOWN[id] || {};
172
+ out.push({
173
+ id,
174
+ label: data.Label,
175
+ title: known.title || `提醒 ${id}`,
176
+ description: known.description || '自定义定时通知',
177
+ script: (data.ProgramArguments || []).slice(-1)[0] || '',
178
+ enabled: await isLoaded(id),
179
+ kind: schedule.kind,
180
+ text: schedule.text,
181
+ hour: schedule.hour ?? 9,
182
+ minute: schedule.minute ?? 0,
183
+ weekday: schedule.weekday,
184
+ day: schedule.day,
185
+ month: schedule.month,
186
+ minutes: schedule.minutes
187
+ });
188
+ }
189
+ out.sort((a, b) => (a.id === 'digest' ? -1 : b.id === 'weekly' ? 1 : a.id.localeCompare(b.id)));
190
+ return out;
191
+ }
192
+
193
+ function xmlEscape(text) {
194
+ return String(text).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
195
+ }
196
+
197
+ function writePlistXml(data) {
198
+ const interval = data.StartCalendarInterval || {};
199
+ const intervalKeys = Object.entries(interval)
200
+ .map(([key, value]) => `\t\t<key>${key}</key>\n\t\t<integer>${value}</integer>`)
201
+ .join('\n');
202
+ const schedule = Number.isInteger(data.StartInterval)
203
+ ? `\t<key>StartInterval</key>\n\t<integer>${data.StartInterval}</integer>`
204
+ : `\t<key>StartCalendarInterval</key>\n\t<dict>\n${intervalKeys}\n\t</dict>`;
205
+ const args = (data.ProgramArguments || []).map((arg) => `\t\t<string>${xmlEscape(arg)}</string>`).join('\n');
206
+ const optional = ['StandardOutPath', 'StandardErrorPath']
207
+ .filter((key) => typeof data[key] === 'string')
208
+ .map((key) => `\t<key>${key}</key>\n\t<string>${xmlEscape(data[key])}</string>`)
209
+ .join('\n');
210
+ const envEntries = Object.entries(data.EnvironmentVariables || {});
211
+ const env = envEntries.length === 0
212
+ ? ''
213
+ : `\t<key>EnvironmentVariables</key>\n\t<dict>\n${envEntries.map(([key, value]) => `\t\t<key>${xmlEscape(key)}</key>\n\t\t<string>${xmlEscape(value)}</string>`).join('\n')}\n\t</dict>`;
214
+ return `<?xml version="1.0" encoding="UTF-8"?>
215
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
216
+ <plist version="1.0">
217
+ <dict>
218
+ <key>Label</key>
219
+ <string>${xmlEscape(data.Label)}</string>
220
+ <key>ProgramArguments</key>
221
+ <array>
222
+ ${args}
223
+ </array>
224
+ ${schedule}
225
+ <key>RunAtLoad</key>
226
+ <false/>
227
+ ${optional}
228
+ ${env}
229
+ </dict>
230
+ </plist>
231
+ `;
232
+ }
233
+
234
+ async function bootstrap(file) {
235
+ const uid = process.getuid();
236
+ try {
237
+ await run('/bin/launchctl', ['bootout', `gui/${uid}`, file]);
238
+ } catch {
239
+ // Not currently registered: nothing to remove.
240
+ }
241
+ await run('/bin/launchctl', ['bootstrap', `gui/${uid}`, file]);
242
+ }
243
+
244
+ async function bootout(file, id) {
245
+ const uid = process.getuid();
246
+ try {
247
+ await run('/bin/launchctl', ['bootout', `gui/${uid}`, file]);
248
+ } catch {
249
+ try {
250
+ await run('/bin/launchctl', ['bootout', `gui/${uid}/${LABEL_PREFIX}${id}`]);
251
+ } catch {
252
+ // Already gone.
253
+ }
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Change one job's schedule and/or its registration. Only the schedule keys and the
259
+ * loaded/disabled state are touched: the program the job runs stays exactly as it was.
260
+ */
261
+ export async function updateAutomation(id, patch) {
262
+ if (!SUPPORTED) throw new Error('定时通知目前只支持 macOS');
263
+ const file = plistPathFor(id);
264
+ const data = await readPlist(file);
265
+ const interval = { ...(data.StartCalendarInterval || {}) };
266
+ const timed = patch.hour !== undefined || patch.minute !== undefined || patch.weekday !== undefined || patch.intervalMinutes !== undefined;
267
+ if (Number.isInteger(patch.intervalMinutes)) {
268
+ delete data.StartCalendarInterval;
269
+ data.StartInterval = Math.max(1, patch.intervalMinutes) * 60;
270
+ } else {
271
+ if (Number.isInteger(patch.hour)) interval.Hour = patch.hour;
272
+ if (Number.isInteger(patch.minute)) interval.Minute = patch.minute;
273
+ if (Number.isInteger(patch.weekday)) interval.Weekday = patch.weekday;
274
+ if (Object.keys(interval).length > 0) data.StartCalendarInterval = interval;
275
+ }
276
+ if (timed) await fs.writeFile(file, writePlistXml(data), 'utf8');
277
+ if (patch.enabled === true) await bootstrap(file);
278
+ else if (patch.enabled === false) await bootout(file, id);
279
+ else if (timed) await bootstrap(file);
280
+ return listAutomations();
281
+ }