dsh-global-todo 0.4.0 → 0.4.1
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/CHANGELOG.md +21 -0
- package/README.md +2 -1
- package/README.zh.md +2 -1
- package/docs/DESIGN.md +1 -1
- package/docs/ROADMAP.md +5 -0
- package/docs/screenshots/panel-detail.png +0 -0
- package/docs/screenshots/panel-select.png +0 -0
- package/docs/screenshots/panel.png +0 -0
- package/lib/client.js +100 -2
- package/lib/index.js +1 -1
- package/lib/model.js +4 -4
- package/lib/routes.js +9 -0
- package/lib/service.js +50 -1
- package/lib/tools.js +5 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,6 +7,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
7
7
|
|
|
8
8
|
## [Unreleased]
|
|
9
9
|
|
|
10
|
+
## [0.4.1] — 2026-10-19
|
|
11
|
+
|
|
12
|
+
The half-finished half: the selection could only be assigned, and the only way to get a record out
|
|
13
|
+
of the list was to delete it. Both are now actions of their own.
|
|
14
|
+
|
|
15
|
+
- **A selection can be completed, archived and tagged.** `POST /bulk` applies one change across the
|
|
16
|
+
picked rows, and the selection bar gained 完成 / 归档 / a tag field. Each record still goes through
|
|
17
|
+
the same validation and activity trail as a single edit; an id that no longer exists is reported
|
|
18
|
+
in `unmatched` rather than failing the whole batch, because a selection is a human gesture and one
|
|
19
|
+
stale row must not discard the other nine.
|
|
20
|
+
|
|
21
|
+
- **Archive instead of delete.** `archived` joined the statuses, so a finished-or-abandoned record can
|
|
22
|
+
leave the active view without losing its history. The panel offers 归档 / 取消归档 in the row menu,
|
|
23
|
+
the filter chip appears only once something is archived, and `global_todo_update` now accepts
|
|
24
|
+
`status` at all — before this, marking something in progress required the tool named
|
|
25
|
+
`global_todo_done`, which is exactly the kind of name/capability mismatch that makes a model misuse
|
|
26
|
+
a tool.
|
|
27
|
+
|
|
28
|
+
Still deliberately out of scope for this release: the wide two-column detail layout, and the
|
|
29
|
+
cross-machine conflict check — both deserve their own pass rather than riding along with a data-model
|
|
30
|
+
change.
|
|
10
31
|
## [0.4.0] — 2026-10-19
|
|
11
32
|
|
|
12
33
|
The first release after the plugin was listed in a community catalog. It is a reliability
|
package/README.md
CHANGED
|
@@ -194,7 +194,7 @@ Your list is not touched by install or uninstall: it lives outside the plugin, a
|
|
|
194
194
|
| `global_todo_capture` | **Record a batch**: for 「抓一下待办」 or a turn that produced several follow-ups; each entry is validated on its own |
|
|
195
195
|
| `global_todo_list` | Query by state, tag or text; every row carries its creation time |
|
|
196
196
|
| `global_todo_get` | Read one item in full, including attachments and the last hand-off |
|
|
197
|
-
| `global_todo_update` | Change any field of one item; `""` clears, `[]` empties |
|
|
197
|
+
| `global_todo_update` | Change any field of one item, including `status` (`archived` archives without deleting); `""` clears, `[]` empties |
|
|
198
198
|
| `global_todo_link` | Attach or detach documents and addresses |
|
|
199
199
|
| `global_todo_done` | Mark done, back to open, or in progress |
|
|
200
200
|
| `global_todo_reopen` | Put a **wrongly finished** item back (`done` / `cancelled` → `open`); refuses anything else, and the trail records it as the agent |
|
|
@@ -240,6 +240,7 @@ fence when that service is present, and by a strict loopback check otherwise.
|
|
|
240
240
|
| GET | `/api/global-todo/export` | — (the store's own JSON document, for backup or migration) |
|
|
241
241
|
| POST | `/api/global-todo/import` | `{ doc, mode?: replace\|merge }` — `replace` is an exact round trip, `merge` updates by id and appends (what the panel sends) |
|
|
242
242
|
| POST | `/api/global-todo/capture` | `{ sessionId? }` — ask the current conversation to collect its follow-ups |
|
|
243
|
+
| POST | `/api/global-todo/bulk` | `{ ids, status?, addTags?, removeTags? }` — one change across a selection; a stale id lands in `unmatched` instead of failing the batch |
|
|
243
244
|
| GET | `/api/global-todo/health` | — (store location, counts, dispatch capabilities, plus **tool registration** and **whether the completion watcher attached** — both fail quietly, so both are reported — and `metrics`: dispatch success rate, agent tool calls, list growth; `autoCollect`: why each waiting conversation was or was not asked, counters, and how the settings file loaded) |
|
|
244
245
|
| POST | `/api/global-todo/create` | `{ title, description?, links?, tags?, priority?, dueAt? }` |
|
|
245
246
|
| POST | `/api/global-todo/update` | `{ id, ...fields, by?: human\|agent }` |
|
package/README.zh.md
CHANGED
|
@@ -120,7 +120,7 @@ dsh plugin --profile web remove dsh-global-todo
|
|
|
120
120
|
| `global_todo_capture` | **一次登记多条**(批量抓取):用户说「抓一下待办」,或你一轮里发现好几件要跟进的;逐条校验,坏的那条只跳过它自己 |
|
|
121
121
|
| `global_todo_list` | 按状态/标签/关键词查询,每条都带上加入时间 |
|
|
122
122
|
| `global_todo_get` | 读一条的全文,含附件与最近一次派发 |
|
|
123
|
-
| `global_todo_update` |
|
|
123
|
+
| `global_todo_update` | 改任意字段,包括 `status`(`archived` 归档而不删除);`""` 清空,`[]` 清空列表 |
|
|
124
124
|
| `global_todo_link` | 挂上或摘掉文档与地址 |
|
|
125
125
|
| `global_todo_done` | 标完成 / 标回未完成 / 标进行中 |
|
|
126
126
|
| `global_todo_reopen` | 把**被误标完成**的一条标回未完成(只做 `done` / `cancelled` → `open`);本来就未完成的会被拒绝,动作按 agent 记进活动轨迹 |
|
|
@@ -143,6 +143,7 @@ dsh plugin --profile web remove dsh-global-todo
|
|
|
143
143
|
| POST | `/api/global-todo/import` | `{ doc, mode?: replace\|merge }`——`replace` 是无损往返,`merge` 同 id 覆盖、新 id 追加(面板用的是这个) |
|
|
144
144
|
| POST | `/api/global-todo/note` | `{ id, text }`(追加进展记录) |
|
|
145
145
|
| POST | `/api/global-todo/capture` | `{ sessionId? }`(请当前对话把要跟进的事抓进清单) |
|
|
146
|
+
| POST | `/api/global-todo/bulk` | `{ ids, status?, addTags?, removeTags? }`——一次改一整批;失效的 id 进 `unmatched`,不会让整批失败 |
|
|
146
147
|
| GET | `/api/global-todo/health` | —(数据位置、统计、派发能力,以及**工具注册状态**与**自动收尾观察者是否挂上**——这两种失败是静默的,所以必须能查;还有 `metrics`:派发成功率、工具调用次数、清单增长;`autoCollect`:每个候选对话**为什么被问/没被问**、计次,以及设置文件是怎么加载进来的) |
|
|
147
148
|
| POST | `/api/global-todo/create` | `{ title, description?, links?, tags?, priority?, dueAt? }` |
|
|
148
149
|
| POST | `/api/global-todo/update` | `{ id, ...字段, by?: human\|agent }` |
|
package/docs/DESIGN.md
CHANGED
|
@@ -14,7 +14,7 @@ so the file stays readable, hand-editable and diffable.
|
|
|
14
14
|
| `id` | string | `t-` + 8 hex; unique within the file |
|
|
15
15
|
| `title` | string | 1–200 characters, trimmed |
|
|
16
16
|
| `description` | string | up to 10 000 characters, Markdown-ish plain text |
|
|
17
|
-
| `status` | `open` \| `doing` \| `done` \| `cancelled` | `doing` means someone is on it |
|
|
17
|
+
| `status` | `open` \| `doing` \| `done` \| `cancelled` \| `archived` | `doing` means someone is on it; `archived` keeps the record out of the active view without deleting it |
|
|
18
18
|
| `priority` | `low` \| `normal` \| `high` \| `urgent` | `urgent` was added in front of the first three; `normal` stays the default |
|
|
19
19
|
| `dueAt` | ISO-8601 string \| `null` | canonicalised to UTC on write |
|
|
20
20
|
| `tags` | string[] | ≤ 20, each ≤ 32, de-duplicated case-insensitively |
|
package/docs/ROADMAP.md
CHANGED
|
@@ -97,6 +97,11 @@ asked / succeeded / failed / dropped (`explainAutoCollect` in `lib/settings.js`,
|
|
|
97
97
|
in `lib/index.js`). A failed question releases its booking and retries after 1 / 5 / 25 minutes
|
|
98
98
|
(`retryDelayMs`), and an ask that never settles is bounded by a ten-second timeout. An unknown
|
|
99
99
|
working directory is never asked, because the exemption cannot be checked against it.
|
|
100
|
+
**Batch completion and tagging, and archive** — `/bulk` applies a status or tag change across a
|
|
101
|
+
selection in one request; a stale id is reported in `unmatched` instead of discarding the batch
|
|
102
|
+
(`bulk()` in `lib/service.js`, `runBulk` in `lib/client.js`). `archived` joined the statuses, so a
|
|
103
|
+
record can leave the active view without being deleted; the filter chip only appears once something
|
|
104
|
+
is archived, and `global_todo_update` finally accepts `status`.
|
|
100
105
|
### Partly landed
|
|
101
106
|
|
|
102
107
|
- **Batch and multi-select** — the selection mode, its per-row markers and the batch
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/lib/client.js
CHANGED
|
@@ -50,7 +50,7 @@ window.__ModuleLoader__.load({
|
|
|
50
50
|
* different mechanisms (restart vs. hot reload), so "which one am I looking
|
|
51
51
|
* at" needs an answer that the bundle itself carries.
|
|
52
52
|
*/
|
|
53
|
-
const UI_REV = '
|
|
53
|
+
const UI_REV = 'ui25'
|
|
54
54
|
/** Last language resolved for the shell; module-level so a fetch failure can be worded too. */
|
|
55
55
|
let activeLang = 'zh'
|
|
56
56
|
|
|
@@ -75,6 +75,8 @@ window.__ModuleLoader__.load({
|
|
|
75
75
|
'action.done': '完成',
|
|
76
76
|
'action.reopen': '标回未完成',
|
|
77
77
|
'action.doing': '标为进行中',
|
|
78
|
+
'action.archive': '归档',
|
|
79
|
+
'action.unarchive': '取消归档',
|
|
78
80
|
'action.remove': '删除',
|
|
79
81
|
'action.removeConfirm': '确认删除',
|
|
80
82
|
'action.unlink': '摘掉',
|
|
@@ -102,6 +104,12 @@ window.__ModuleLoader__.load({
|
|
|
102
104
|
'select.all': '全选',
|
|
103
105
|
'select.allOn': '取消全选',
|
|
104
106
|
'select.assign': '一键分配',
|
|
107
|
+
'select.complete': '完成',
|
|
108
|
+
'select.archive': '归档',
|
|
109
|
+
'field.bulkTag': '标签…',
|
|
110
|
+
'select.addTag': '加标签',
|
|
111
|
+
'notice.bulk': '已更新 {n} 条',
|
|
112
|
+
'notice.bulkPartial': '已更新 {n} 条,跳过 {m} 条',
|
|
105
113
|
'select.clear': '清除',
|
|
106
114
|
'select.done': '完成',
|
|
107
115
|
'select.hint': '先勾几条待办,再点「一键分配」:优先回到写下它的那个对话,其次同工作区的对话,都找不到才让你选。',
|
|
@@ -173,6 +181,7 @@ window.__ModuleLoader__.load({
|
|
|
173
181
|
'filter.doing': '进行中',
|
|
174
182
|
'filter.done': '已完成',
|
|
175
183
|
'filter.overdue': '逾期',
|
|
184
|
+
'filter.archived': '已归档',
|
|
176
185
|
'filter.label': '筛选',
|
|
177
186
|
'field.title': '要做什么?回车即存',
|
|
178
187
|
'field.description': '描述:验收标准、已知的坑…',
|
|
@@ -276,6 +285,8 @@ window.__ModuleLoader__.load({
|
|
|
276
285
|
'action.done': 'Done',
|
|
277
286
|
'action.reopen': 'Reopen',
|
|
278
287
|
'action.doing': 'Start',
|
|
288
|
+
'action.archive': 'Archive',
|
|
289
|
+
'action.unarchive': 'Unarchive',
|
|
279
290
|
'action.remove': 'Delete',
|
|
280
291
|
'action.removeConfirm': 'Confirm delete',
|
|
281
292
|
'action.unlink': 'Remove',
|
|
@@ -303,6 +314,12 @@ window.__ModuleLoader__.load({
|
|
|
303
314
|
'select.all': 'Select all',
|
|
304
315
|
'select.allOn': 'Clear selection',
|
|
305
316
|
'select.assign': 'Assign',
|
|
317
|
+
'select.complete': 'Complete',
|
|
318
|
+
'select.archive': 'Archive',
|
|
319
|
+
'field.bulkTag': 'Tag…',
|
|
320
|
+
'select.addTag': 'Add tag',
|
|
321
|
+
'notice.bulk': 'Updated {n}',
|
|
322
|
+
'notice.bulkPartial': 'Updated {n}, skipped {m}',
|
|
306
323
|
'select.clear': 'Clear',
|
|
307
324
|
'select.done': 'Done',
|
|
308
325
|
'select.hint': 'Pick a few todos, then Assign: back to the conversation that wrote each one, else a conversation in the same workspace, else you choose.',
|
|
@@ -376,6 +393,7 @@ window.__ModuleLoader__.load({
|
|
|
376
393
|
'filter.doing': 'Doing',
|
|
377
394
|
'filter.done': 'Done',
|
|
378
395
|
'filter.overdue': 'Overdue',
|
|
396
|
+
'filter.archived': 'Archived',
|
|
379
397
|
'filter.label': 'Filters',
|
|
380
398
|
'field.title': 'What needs doing? Enter to save',
|
|
381
399
|
'field.description': 'Description: acceptance, known traps…',
|
|
@@ -1598,6 +1616,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1598
1616
|
done
|
|
1599
1617
|
? { key: 'reopen', label: t('action.reopen'), icon: 'check', onSelect: () => onAction('reopen', todo) }
|
|
1600
1618
|
: { key: 'doing', label: t('action.doing'), icon: 'clock', onSelect: () => onAction('doing', todo) },
|
|
1619
|
+
todo.status === 'archived'
|
|
1620
|
+
? { key: 'unarchive', label: t('action.unarchive'), icon: 'check', onSelect: () => onAction('unarchive', todo) }
|
|
1621
|
+
: { key: 'archive', label: t('action.archive'), icon: 'doc', onSelect: () => onAction('archive', todo) },
|
|
1601
1622
|
{
|
|
1602
1623
|
key: 'remove',
|
|
1603
1624
|
label: confirming ? t('action.removeConfirm') : t('action.remove'),
|
|
@@ -1939,6 +1960,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
1939
1960
|
// The hidden file input behind 导入: a page cannot open a file dialog by itself.
|
|
1940
1961
|
const importRef = useRef(null)
|
|
1941
1962
|
const [selected, setSelected] = useState({})
|
|
1963
|
+
// One tag field for the whole selection: typing a tag then pressing add applies it to
|
|
1964
|
+
// every picked row in one request, which is the batch half that was missing.
|
|
1965
|
+
const [bulkTag, setBulkTag] = useState('')
|
|
1942
1966
|
const [assignBusy, setAssignBusy] = useState(false)
|
|
1943
1967
|
const [assignAsk, setAssignAsk] = useState(null)
|
|
1944
1968
|
const [groupMode, setGroupMode] = useState(() => readStoredGroupMode())
|
|
@@ -2619,6 +2643,10 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2619
2643
|
if (kind === 'done') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'done' } }))
|
|
2620
2644
|
if (kind === 'reopen') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'open' } }))
|
|
2621
2645
|
if (kind === 'doing') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'doing' } }))
|
|
2646
|
+
// Archive keeps the history without cluttering the list; delete stays a separate,
|
|
2647
|
+
// deliberate action reached only through the two-step menu item.
|
|
2648
|
+
if (kind === 'archive') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'archived' } }))
|
|
2649
|
+
if (kind === 'unarchive') return run(todo.id, () => call('status', { method: 'POST', body: { id: todo.id, status: 'open' } }))
|
|
2622
2650
|
if (kind === 'save') return run(todo.id, () => call('update', { method: 'POST', body: { id: todo.id, ...payload } }))
|
|
2623
2651
|
if (kind === 'note') return run(todo.id, () => call('note', { method: 'POST', body: { id: todo.id, ...payload } }))
|
|
2624
2652
|
if (kind === 'link') return run(todo.id, () => call('link', { method: 'POST', body: { id: todo.id, ...payload } }))
|
|
@@ -2664,7 +2692,32 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2664
2692
|
return undefined
|
|
2665
2693
|
}
|
|
2666
2694
|
|
|
2667
|
-
|
|
2695
|
+
/** Apply one change to the whole selection; a stale id is reported, not fatal. */
|
|
2696
|
+
const runBulk = async (change) => {
|
|
2697
|
+
const ids = Object.keys(selected)
|
|
2698
|
+
if (ids.length === 0) return
|
|
2699
|
+
try {
|
|
2700
|
+
const data = await call('bulk', { method: 'POST', body: { ids, ...change } })
|
|
2701
|
+
const skipped = (data?.unmatched?.length ?? 0) + (data?.errors?.length ?? 0)
|
|
2702
|
+
setNotice({
|
|
2703
|
+
text:
|
|
2704
|
+
skipped > 0
|
|
2705
|
+
? t('notice.bulkPartial', { n: data?.updated ?? 0, m: skipped })
|
|
2706
|
+
: t('notice.bulk', { n: data?.updated ?? 0 }),
|
|
2707
|
+
sessionId: '',
|
|
2708
|
+
})
|
|
2709
|
+
setTimeout(() => setNotice(null), 6000)
|
|
2710
|
+
setSelected({})
|
|
2711
|
+
setError(null)
|
|
2712
|
+
void reload()
|
|
2713
|
+
void refreshHealth()
|
|
2714
|
+
} catch (failure) {
|
|
2715
|
+
reportFailure('bulk', { message: failure?.message })
|
|
2716
|
+
setError(failure)
|
|
2717
|
+
}
|
|
2718
|
+
}
|
|
2719
|
+
|
|
2720
|
+
const counts = state.counts ?? { total: 0, open: 0, doing: 0, done: 0, cancelled: 0, archived: 0, overdue: 0 }
|
|
2668
2721
|
const groups = useMemo(
|
|
2669
2722
|
() => groupTodos(state.todos, Date.now(), groupMode, sessionTitles, t),
|
|
2670
2723
|
[state.todos, groupMode, sessionTitles, lang],
|
|
@@ -2875,6 +2928,9 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
2875
2928
|
['doing', counts.doing],
|
|
2876
2929
|
['done', counts.done],
|
|
2877
2930
|
['overdue', counts.overdue],
|
|
2931
|
+
// Only shown once something is archived: an empty filter that exists from
|
|
2932
|
+
// day one is furniture, not a control.
|
|
2933
|
+
...(counts.archived > 0 ? [['archived', counts.archived]] : []),
|
|
2878
2934
|
].map(([key, count]) => filterChip(key, key === 'all' ? undefined : count)),
|
|
2879
2935
|
),
|
|
2880
2936
|
h('span', { className: 'gt-divider' }),
|
|
@@ -3097,6 +3153,48 @@ button.gt-chip[data-on="1"] { color: var(--dsw-alias-label-primary); border-colo
|
|
|
3097
3153
|
},
|
|
3098
3154
|
assignBusy ? t('assign.working') : t('select.assign'),
|
|
3099
3155
|
),
|
|
3156
|
+
h(
|
|
3157
|
+
'button',
|
|
3158
|
+
{
|
|
3159
|
+
type: 'button',
|
|
3160
|
+
className: 'gt-btn gt-btn-quiet',
|
|
3161
|
+
disabled: Object.keys(selected).length === 0,
|
|
3162
|
+
onClick: () => void runBulk({ status: 'done' }),
|
|
3163
|
+
},
|
|
3164
|
+
t('select.complete'),
|
|
3165
|
+
),
|
|
3166
|
+
h(
|
|
3167
|
+
'button',
|
|
3168
|
+
{
|
|
3169
|
+
type: 'button',
|
|
3170
|
+
className: 'gt-btn gt-btn-quiet',
|
|
3171
|
+
disabled: Object.keys(selected).length === 0,
|
|
3172
|
+
onClick: () => void runBulk({ status: 'archived' }),
|
|
3173
|
+
},
|
|
3174
|
+
t('select.archive'),
|
|
3175
|
+
),
|
|
3176
|
+
h('input', {
|
|
3177
|
+
className: 'gt-input gt-bulk-tag',
|
|
3178
|
+
value: bulkTag,
|
|
3179
|
+
placeholder: t('field.bulkTag'),
|
|
3180
|
+
'aria-label': t('field.bulkTag'),
|
|
3181
|
+
onChange: (event) => setBulkTag(event.target.value),
|
|
3182
|
+
}),
|
|
3183
|
+
h(
|
|
3184
|
+
'button',
|
|
3185
|
+
{
|
|
3186
|
+
type: 'button',
|
|
3187
|
+
className: 'gt-btn gt-btn-quiet',
|
|
3188
|
+
disabled: Object.keys(selected).length === 0 || bulkTag.trim() === '',
|
|
3189
|
+
onClick: () => {
|
|
3190
|
+
const tag = bulkTag.trim()
|
|
3191
|
+
if (tag === '') return
|
|
3192
|
+
void runBulk({ addTags: [tag] })
|
|
3193
|
+
setBulkTag('')
|
|
3194
|
+
},
|
|
3195
|
+
},
|
|
3196
|
+
t('select.addTag'),
|
|
3197
|
+
),
|
|
3100
3198
|
h(
|
|
3101
3199
|
'button',
|
|
3102
3200
|
{ type: 'button', className: 'gt-btn gt-btn-quiet', onClick: () => setSelected({}) },
|
package/lib/index.js
CHANGED
|
@@ -35,7 +35,7 @@ import { createStore, resolveStorePath } from './store.js'
|
|
|
35
35
|
import { createTools } from './tools.js'
|
|
36
36
|
|
|
37
37
|
/** Must equal `package.json`'s version; `npm run check` enforces that. */
|
|
38
|
-
export const VERSION = '0.4.
|
|
38
|
+
export const VERSION = '0.4.1'
|
|
39
39
|
|
|
40
40
|
/** Plugin id. Also the Loader row id in `cordis.patch.yml`. */
|
|
41
41
|
export const name = 'dsh-global-todo'
|
package/lib/model.js
CHANGED
|
@@ -20,7 +20,7 @@ import { LINK_KINDS, linkKey, normalizeLink } from './links.js'
|
|
|
20
20
|
export const SCHEMA_VERSION = 1
|
|
21
21
|
|
|
22
22
|
/** Lifecycle states. `doing` is the explicit "an agent (or I) am on it" state. */
|
|
23
|
-
export const STATUSES = Object.freeze(['open', 'doing', 'done', 'cancelled'])
|
|
23
|
+
export const STATUSES = Object.freeze(['open', 'doing', 'done', 'cancelled', 'archived'])
|
|
24
24
|
|
|
25
25
|
/** Priorities are deliberately coarse: three levels people actually use. */
|
|
26
26
|
/**
|
|
@@ -653,17 +653,17 @@ export function recordDispatch(todo, info, options = {}) {
|
|
|
653
653
|
* @returns {boolean}
|
|
654
654
|
*/
|
|
655
655
|
export function isOverdue(todo, now = Date.now()) {
|
|
656
|
-
if (todo.dueAt === null || todo.status === 'done' || todo.status === 'cancelled') return false
|
|
656
|
+
if (todo.dueAt === null || todo.status === 'done' || todo.status === 'cancelled' || todo.status === 'archived') return false
|
|
657
657
|
const due = Date.parse(todo.dueAt)
|
|
658
658
|
return Number.isFinite(due) && due < now
|
|
659
659
|
}
|
|
660
660
|
|
|
661
661
|
/**
|
|
662
662
|
* @param {object[]} todos
|
|
663
|
-
* @returns {{ total: number, open: number, doing: number, done: number, cancelled: number, overdue: number }}
|
|
663
|
+
* @returns {{ total: number, open: number, doing: number, done: number, cancelled: number, archived: number, overdue: number }}
|
|
664
664
|
*/
|
|
665
665
|
export function counts(todos, now = Date.now()) {
|
|
666
|
-
const out = { total: todos.length, open: 0, doing: 0, done: 0, cancelled: 0, overdue: 0 }
|
|
666
|
+
const out = { total: todos.length, open: 0, doing: 0, done: 0, cancelled: 0, archived: 0, overdue: 0 }
|
|
667
667
|
for (const todo of todos) {
|
|
668
668
|
if (todo.status in out) out[todo.status] += 1
|
|
669
669
|
if (isOverdue(todo, now)) out.overdue += 1
|
package/lib/routes.js
CHANGED
|
@@ -468,6 +468,15 @@ export function createRoutes(deps) {
|
|
|
468
468
|
return dispatcher.assignMany(body ?? {})
|
|
469
469
|
}),
|
|
470
470
|
|
|
471
|
+
exact('POST', `${ROUTE_BASE}/bulk`, async (request) => {
|
|
472
|
+
const body = await readJsonBody(request)
|
|
473
|
+
return service.bulk(
|
|
474
|
+
body.ids,
|
|
475
|
+
{ status: body.status, addTags: body.addTags, removeTags: body.removeTags },
|
|
476
|
+
{ by: actorOf(body) },
|
|
477
|
+
)
|
|
478
|
+
}),
|
|
479
|
+
|
|
471
480
|
exact('POST', `${ROUTE_BASE}/capture`, async (request) => {
|
|
472
481
|
const body = await readJsonBody(request)
|
|
473
482
|
return dispatcher.requestCapture(body ?? {})
|
package/lib/service.js
CHANGED
|
@@ -33,7 +33,7 @@ import {
|
|
|
33
33
|
} from './model.js'
|
|
34
34
|
|
|
35
35
|
/** Filters the panel and the tools may ask for. */
|
|
36
|
-
export const FILTERS = Object.freeze(['all', 'active', 'open', 'doing', 'done', 'cancelled', 'overdue'])
|
|
36
|
+
export const FILTERS = Object.freeze(['all', 'active', 'open', 'doing', 'done', 'cancelled', 'archived', 'overdue'])
|
|
37
37
|
|
|
38
38
|
/** Sort keys the panel and the tools may ask for. */
|
|
39
39
|
export const SORTS = Object.freeze(['created', 'updated', 'due', 'priority'])
|
|
@@ -618,6 +618,54 @@ export function createService(options) {
|
|
|
618
618
|
})
|
|
619
619
|
}
|
|
620
620
|
|
|
621
|
+
/**
|
|
622
|
+
* Apply one change to several todos.
|
|
623
|
+
*
|
|
624
|
+
* Each record goes through the same validation and trail as a single edit. A stale id is
|
|
625
|
+
* reported in `unmatched` instead of failing the batch: a selection is a human gesture, and
|
|
626
|
+
* one row that moved must not discard the other nine.
|
|
627
|
+
*
|
|
628
|
+
* @param {string[]} ids
|
|
629
|
+
* @param {{ status?: string, addTags?: string[], removeTags?: string[] }} change
|
|
630
|
+
* @param {{ by?: 'human' | 'agent' }} [context]
|
|
631
|
+
* @returns {Promise<{ updated: number, ids: string[], unmatched: string[], errors: { id: string, error: string }[] }>}
|
|
632
|
+
*/
|
|
633
|
+
async function bulk(ids, change = {}, context = {}) {
|
|
634
|
+
const wanted = Array.isArray(ids) ? ids.filter((id) => typeof id === 'string' && id.trim() !== '') : []
|
|
635
|
+
if (wanted.length === 0) throw new TodoError('invalid-body', '要改哪些待办?给一个非空的 ids')
|
|
636
|
+
const hasStatus = typeof change.status === 'string' && change.status !== ''
|
|
637
|
+
const addTags = Array.isArray(change.addTags) ? change.addTags : []
|
|
638
|
+
const removeTags = Array.isArray(change.removeTags) ? change.removeTags : []
|
|
639
|
+
if (!hasStatus && addTags.length === 0 && removeTags.length === 0) {
|
|
640
|
+
throw new TodoError('invalid-body', '批量修改需要 status / addTags / removeTags 之一')
|
|
641
|
+
}
|
|
642
|
+
const updated = []
|
|
643
|
+
const unmatched = []
|
|
644
|
+
const errors = []
|
|
645
|
+
for (const id of wanted) {
|
|
646
|
+
try {
|
|
647
|
+
const todo = await store.update((doc) => {
|
|
648
|
+
const target = doc.todos.find((candidate) => candidate.id === id)
|
|
649
|
+
if (target === undefined) return { result: null }
|
|
650
|
+
const patch = {}
|
|
651
|
+
if (hasStatus) patch.status = change.status
|
|
652
|
+
if (addTags.length > 0 || removeTags.length > 0) {
|
|
653
|
+
const merged = new Map(target.tags.map((tag) => [tag.toLowerCase(), tag]))
|
|
654
|
+
for (const tag of addTags) merged.set(String(tag).toLowerCase(), String(tag))
|
|
655
|
+
for (const tag of removeTags) merged.delete(String(tag).toLowerCase())
|
|
656
|
+
patch.tags = [...merged.values()]
|
|
657
|
+
}
|
|
658
|
+
const changed = applyPatch(target, patch, { ...modelOptions(context.by), ...linkOptions() })
|
|
659
|
+
return changed.length === 0 ? { result: target } : { next: doc.todos, result: target }
|
|
660
|
+
})
|
|
661
|
+
if (todo === null) unmatched.push(id)
|
|
662
|
+
else updated.push(id)
|
|
663
|
+
} catch (error) {
|
|
664
|
+
errors.push({ id, error: error instanceof TodoError ? error.message : String(error?.message ?? error) })
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
return { updated: updated.length, ids: updated, unmatched, errors }
|
|
668
|
+
}
|
|
621
669
|
/** @returns {Promise<object>} counts for the panel header and tool summaries */
|
|
622
670
|
async function stats() {
|
|
623
671
|
const todos = await store.all()
|
|
@@ -637,6 +685,7 @@ export function createService(options) {
|
|
|
637
685
|
findBySession,
|
|
638
686
|
remove,
|
|
639
687
|
restore,
|
|
688
|
+
bulk,
|
|
640
689
|
addLink,
|
|
641
690
|
removeLink,
|
|
642
691
|
recordDispatch,
|
package/lib/tools.js
CHANGED
|
@@ -423,7 +423,7 @@ export function createTools(service, options = {}) {
|
|
|
423
423
|
properties: {
|
|
424
424
|
status: {
|
|
425
425
|
type: 'string',
|
|
426
|
-
enum: ['all', 'active', 'open', 'doing', 'done', 'cancelled', 'overdue'],
|
|
426
|
+
enum: ['all', 'active', 'open', 'doing', 'done', 'cancelled', 'archived', 'overdue'],
|
|
427
427
|
description: '缺省 active',
|
|
428
428
|
},
|
|
429
429
|
query: { type: 'string', description: '在标题、描述、标签、附件里模糊匹配' },
|
|
@@ -518,7 +518,7 @@ export function createTools(service, options = {}) {
|
|
|
518
518
|
{
|
|
519
519
|
name: 'global_todo_update',
|
|
520
520
|
description:
|
|
521
|
-
'改一条全局待办。只传要改的字段;`dueAt: ""` 清空截止时间,`tags: []` 清空标签,`links` 是整体替换(要单独加附件请用 global_todo_link
|
|
521
|
+
'改一条全局待办。只传要改的字段;`dueAt: ""` 清空截止时间,`tags: []` 清空标签,`links` 是整体替换(要单独加附件请用 global_todo_link),`status` 可以设为 archived(归档,不删除)。',
|
|
522
522
|
parameters: {
|
|
523
523
|
type: 'object',
|
|
524
524
|
properties: {
|
|
@@ -526,6 +526,9 @@ export function createTools(service, options = {}) {
|
|
|
526
526
|
title: { type: 'string' },
|
|
527
527
|
description: { type: 'string' },
|
|
528
528
|
priority: { type: 'string', enum: [...PRIORITIES] },
|
|
529
|
+
// `status` belongs here: marking something in progress used to require the tool
|
|
530
|
+
// named `global_todo_done`, and archiving had no entry point at all.
|
|
531
|
+
status: { type: 'string', enum: [...STATUSES], description: 'open / doing / done / cancelled / archived' },
|
|
529
532
|
dueAt: { oneOf: [{ type: 'string' }, { type: 'null' }], description: '空串或 null 表示清空' },
|
|
530
533
|
tags: { type: 'array', items: { type: 'string' } },
|
|
531
534
|
links: LINKS_INPUT,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-global-todo",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.1",
|
|
4
4
|
"icon": "./docs/icon.svg",
|
|
5
5
|
"description": "Global, cross-session todos for DeepSeek Harness: one list owned by the human and the agent together, with creation time, attached documents and links, and one-click hand-off to an agent session. 全局待办:跨会话一份清单,记录加入时间,可挂文档与地址,可一键交给 Agent 继续干。",
|
|
6
6
|
"type": "module",
|