dsh-worktodo 0.1.3 → 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/CHANGELOG.md +75 -1
- package/README.en.md +1 -1
- package/lib/client.js +460 -60
- package/lib/client.js.map +4 -4
- package/lib/index.js +57 -1
- package/package.json +1 -1
- package/src/client/index.js +298 -60
- package/src/parse-due.mjs +170 -0
- package/src/records.mjs +46 -7
- package/src/schema.mjs +6 -1
- package/src/store.mjs +4 -1
- package/src/tools.mjs +33 -5
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 自然语言日期解析:从一句人话里认出「截止日期 / 时间」,并把认出来的词从标题里摘掉。
|
|
3
|
+
*
|
|
4
|
+
* 设计原则(保守优先):
|
|
5
|
+
* 1. 只认**明确的日期词**,宁可漏也不误吞("买3个苹果" 里的 3 不能变成"3 天后");
|
|
6
|
+
* 2. 出现多个星期几时取**最后**一个("周一例会改到周三" → 周三);
|
|
7
|
+
* 3. 结果的每一处都能追溯到原文片段(matched),人类侧据此显示"从标题识别"并可撤销;
|
|
8
|
+
* 4. 与 `normalizeDue` 的既有说法保持兼容(今天/明天/昨天/+3d/本周)。
|
|
9
|
+
*
|
|
10
|
+
* 人类面板与 worktodo_* 工具共用这一份实现(本文件零依赖,可直接被打进客户端 bundle),
|
|
11
|
+
* 避免两边理解不一致。
|
|
12
|
+
*/
|
|
13
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
14
|
+
|
|
15
|
+
/** 本地时区的 YYYY-MM-DD(与 records.mjs 的 dayKey 同语义)。 */
|
|
16
|
+
function dayKey(stamp) {
|
|
17
|
+
const d = new Date(stamp);
|
|
18
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
19
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 所在周的周一(本地时区,与 records.mjs 的 weekStartKey 同语义)。 */
|
|
23
|
+
function weekStartKey(stamp) {
|
|
24
|
+
const d = new Date(stamp);
|
|
25
|
+
const day = (d.getDay() + 6) % 7; // 周一=0
|
|
26
|
+
const monday = new Date(d.getFullYear(), d.getMonth(), d.getDate() - day);
|
|
27
|
+
return dayKey(monday.getTime());
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
const CN_NUM = { 零: 0, 一: 1, 两: 2, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10 };
|
|
32
|
+
const WEEKDAY = { 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 日: 7, 天: 7 };
|
|
33
|
+
const PERIOD_HOUR = { 今早: 9, 早上: 9, 早晨: 9, 上午: 9, 中午: 12, 下午: 15, 傍晚: 18, 今晚: 20, 明晚: 20, 晚上: 20, 晚: 20, 夜里: 21 };
|
|
34
|
+
|
|
35
|
+
const toCnNumber = (raw) => {
|
|
36
|
+
if (/^\d+$/.test(raw)) return Number(raw);
|
|
37
|
+
if (raw === '十') return 10;
|
|
38
|
+
if (raw.startsWith('十')) return 10 + (CN_NUM[raw[1]] ?? 0);
|
|
39
|
+
if (raw.includes('十')) {
|
|
40
|
+
const [a, b] = raw.split('十');
|
|
41
|
+
return (CN_NUM[a] ?? 1) * 10 + (CN_NUM[b] ?? 0);
|
|
42
|
+
}
|
|
43
|
+
return CN_NUM[raw] ?? null;
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* @param {string} text 用户输入
|
|
48
|
+
* @param {number} [now] 基准时刻
|
|
49
|
+
* @returns {{title:string, due:string|null, dueTime:string|null, matched:string[]}}
|
|
50
|
+
*/
|
|
51
|
+
export function parseNaturalDue(text, now = Date.now()) {
|
|
52
|
+
const source = typeof text === 'string' ? text : '';
|
|
53
|
+
const spans = []; // {start, end, kind:'date'|'time'}
|
|
54
|
+
let due = null;
|
|
55
|
+
let dueTime = null;
|
|
56
|
+
let hourFromPeriod = null; // 「今晚」这类只给时段,兜底时间
|
|
57
|
+
|
|
58
|
+
const today = dayKey(now);
|
|
59
|
+
const todayStart = new Date(`${today}T00:00:00`).getTime();
|
|
60
|
+
|
|
61
|
+
// ---------- 1) 绝对日期:2026-10-20 / 10月20日 / 10/20 ----------
|
|
62
|
+
let m = /(\d{4})[-/年](\d{1,2})[-/月](\d{1,2})日?/.exec(source);
|
|
63
|
+
if (m) {
|
|
64
|
+
due = dayKey(new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3])).getTime());
|
|
65
|
+
spans.push({ start: m.index, end: m.index + m[0].length, kind: 'date' });
|
|
66
|
+
} else {
|
|
67
|
+
m = /(\d{1,2})月(\d{1,2})日?/.exec(source);
|
|
68
|
+
if (m) {
|
|
69
|
+
// 省略年份:取「最近一次」(已过去就顺延到明年)
|
|
70
|
+
let year = new Date(now).getFullYear();
|
|
71
|
+
let candidate = dayKey(new Date(year, Number(m[1]) - 1, Number(m[2])).getTime());
|
|
72
|
+
if (candidate < today) candidate = dayKey(new Date(year + 1, Number(m[1]) - 1, Number(m[2])).getTime());
|
|
73
|
+
due = candidate;
|
|
74
|
+
spans.push({ start: m.index, end: m.index + m[0].length, kind: 'date' });
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------- 2) 星期:周X / 星期X / 礼拜X / 下周一(多个取最后一个) ----------
|
|
79
|
+
const weekRe = /(下下|下个|下|本|这)?(?:周|星期|礼拜)([一二三四五六日天1-7])/g;
|
|
80
|
+
for (let w = weekRe.exec(source); w; w = weekRe.exec(source)) {
|
|
81
|
+
const offsetWeeks = w[1] === '下' || w[1] === '下个' ? 1 : w[1] === '下下' ? 2 : 0;
|
|
82
|
+
const target = WEEKDAY[w[2]] ?? Number(w[2]);
|
|
83
|
+
const thisWeekStart = new Date(`${weekStartKey(now)}T00:00:00`).getTime();
|
|
84
|
+
let stamp = thisWeekStart + (target - 1) * DAY_MS + offsetWeeks * 7 * DAY_MS;
|
|
85
|
+
if (offsetWeeks === 0 && stamp < todayStart) stamp += 7 * DAY_MS; // 已过则算下周
|
|
86
|
+
const candidate = dayKey(stamp);
|
|
87
|
+
// 后面的星期几覆盖前面的("改到周三"的语义)
|
|
88
|
+
if (due === null || spans.some((s) => s.kind === 'date' && s.weekday)) {
|
|
89
|
+
spans.filter((s) => s.weekday).forEach((s) => { s.dropped = true; });
|
|
90
|
+
}
|
|
91
|
+
due = candidate;
|
|
92
|
+
spans.push({ start: w.index, end: w.index + w[0].length, kind: 'date', weekday: true });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// ---------- 3) 相对天数:今天/明天/后天/大后天/3天后/+3d ----------
|
|
96
|
+
if (due === null) {
|
|
97
|
+
const relWords = { 今天: 0, 今日: 0, 明天: 1, 明日: 1, 后天: 2, 大后天: 3, 昨天: -1, 昨: -1 };
|
|
98
|
+
for (const [word, offset] of Object.entries(relWords)) {
|
|
99
|
+
const at = source.indexOf(word);
|
|
100
|
+
if (at !== -1) {
|
|
101
|
+
due = dayKey(now + offset * DAY_MS);
|
|
102
|
+
spans.push({ start: at, end: at + word.length, kind: 'date' });
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (due === null) {
|
|
108
|
+
const plus = /\+(\d{1,3})\s*d(?:ay)?s?/.exec(source);
|
|
109
|
+
const cn = /([0-9零一两二三四五六七八九十]+)\s*天\s*(?:后|以后|之后)/.exec(source);
|
|
110
|
+
if (plus) {
|
|
111
|
+
due = dayKey(now + Number(plus[1]) * DAY_MS);
|
|
112
|
+
spans.push({ start: plus.index, end: plus.index + plus[0].length, kind: 'date' });
|
|
113
|
+
} else if (cn) {
|
|
114
|
+
const n = toCnNumber(cn[1]);
|
|
115
|
+
if (n !== null) {
|
|
116
|
+
due = dayKey(now + n * DAY_MS);
|
|
117
|
+
spans.push({ start: cn.index, end: cn.index + cn[0].length, kind: 'date' });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ---------- 4) 时段词:今晚 / 明早 / 下午 / 早上 ----------
|
|
123
|
+
const periodRe = /(今晚|明晚|明早|早上|早晨|上午|中午|下午|傍晚|晚上|夜里)/;
|
|
124
|
+
const period = periodRe.exec(source);
|
|
125
|
+
if (period) {
|
|
126
|
+
hourFromPeriod = PERIOD_HOUR[period[1]] ?? null;
|
|
127
|
+
if (due === null) {
|
|
128
|
+
if (period[1] === '今晚' || period[1] === '明晚') due = dayKey(now + (period[1] === '明晚' ? 1 : 0) * DAY_MS);
|
|
129
|
+
if (period[1] === '明早') due = dayKey(now + DAY_MS);
|
|
130
|
+
}
|
|
131
|
+
spans.push({ start: period.index, end: period.index + period[1].length, kind: 'time' });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------- 5) 具体时间:下午3点 / 3点半 / 15:00 / 上午10点 ----------
|
|
135
|
+
const hm = /(\d{1,2})[::](\d{2})/.exec(source);
|
|
136
|
+
const cnHour = /([0-9零一两二三四五六七八九十]+)\s*[点时](半|[0-9零一两二三四五六七八九十]{1,3}分)?/.exec(source);
|
|
137
|
+
if (hm) {
|
|
138
|
+
const h = Number(hm[1]);
|
|
139
|
+
if (h <= 23 && Number(hm[2]) <= 59) dueTime = `${String(h).padStart(2, '0')}:${hm[2]}`;
|
|
140
|
+
spans.push({ start: hm.index, end: hm.index + hm[0].length, kind: 'time' });
|
|
141
|
+
} else if (cnHour) {
|
|
142
|
+
let h = toCnNumber(cnHour[1]);
|
|
143
|
+
if (h !== null && h <= 23) {
|
|
144
|
+
if (h <= 12 && hourFromPeriod !== null && hourFromPeriod >= 12) h = h === 12 ? 12 : h + 12; // 下午3点 → 15
|
|
145
|
+
const minute = cnHour[2] === '半' ? 30 : cnHour[2] ? (toCnNumber(cnHour[2].replace('分', '')) ?? 0) : 0;
|
|
146
|
+
if (minute <= 59) dueTime = `${String(h).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
|
147
|
+
spans.push({ start: cnHour.index, end: cnHour.index + cnHour[0].length, kind: 'time' });
|
|
148
|
+
}
|
|
149
|
+
} else if (hourFromPeriod !== null) {
|
|
150
|
+
dueTime = `${String(hourFromPeriod).padStart(2, '0')}:00`; // 只有时段词 → 用该时段默认钟点
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// 只认到时间、没认到日期 → 今天(用户决定④)
|
|
154
|
+
if (dueTime && !due) due = today;
|
|
155
|
+
|
|
156
|
+
// ---------- 6) 摘除:把认出来的片段从标题里去掉 ----------
|
|
157
|
+
const kept = spans.filter((s) => !s.dropped).sort((a, b) => a.start - b.start);
|
|
158
|
+
let title = source;
|
|
159
|
+
for (const span of [...kept].reverse()) {
|
|
160
|
+
title = title.slice(0, span.start) + ' ' + title.slice(span.end);
|
|
161
|
+
}
|
|
162
|
+
title = title.replace(/\s+/g, ' ').trim();
|
|
163
|
+
|
|
164
|
+
return {
|
|
165
|
+
title: title || source.trim(),
|
|
166
|
+
due,
|
|
167
|
+
dueTime,
|
|
168
|
+
matched: kept.map((s) => source.slice(s.start, s.end)),
|
|
169
|
+
};
|
|
170
|
+
}
|
package/src/records.mjs
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
/** 状态:与官方 todo_write 同名同义,便于互相提升。 */
|
|
17
17
|
export const STATUSES = ['pending', 'in_progress', 'completed'];
|
|
18
18
|
/** 优先级:只留三档,避免用户在选择上犹豫。 */
|
|
19
|
-
export const PRIORITIES = ['high', 'normal', 'low'];
|
|
19
|
+
export const PRIORITIES = ['high', 'normal', 'low', 'none'];
|
|
20
20
|
/** 来源:人加的 / agent 加的 / 从会话 todos 提升的。 */
|
|
21
21
|
export const SOURCES = ['human', 'agent', 'session'];
|
|
22
22
|
|
|
@@ -147,6 +147,7 @@ export function normalizePriority(value) {
|
|
|
147
147
|
p0: 'high', urgent: 'high', high: 'high', 高: 'high', 紧急: 'high', 重要: 'high',
|
|
148
148
|
p1: 'normal', normal: 'normal', medium: 'normal', mid: 'normal', 中: 'normal', 普通: 'normal',
|
|
149
149
|
p2: 'low', low: 'low', 低: 'low', 次要: 'low',
|
|
150
|
+
none: 'none', no: 'none', 无: 'none', 不设: 'none', 无优先级: 'none',
|
|
150
151
|
};
|
|
151
152
|
const resolved = alias[raw];
|
|
152
153
|
if (!resolved) throw new WorkTodoError('bad-priority', `优先级只能是 ${PRIORITIES.join(' / ')},收到 ${JSON.stringify(value)}`);
|
|
@@ -202,6 +203,25 @@ export function normalizeDue(value, now = Date.now()) {
|
|
|
202
203
|
* @param {number} now
|
|
203
204
|
* @returns {string}
|
|
204
205
|
*/
|
|
206
|
+
/**
|
|
207
|
+
* 截止时间(可选,只有配了截止日期才有意义):接受 "14:30" / "9:05" / "1430" / "14"。
|
|
208
|
+
* 空值(undefined/null/''/none)返回 null;写坏了抛错,别悄悄丢掉用户填的时间。
|
|
209
|
+
* @param {unknown} value
|
|
210
|
+
* @returns {string|null} HH:MM
|
|
211
|
+
*/
|
|
212
|
+
export function normalizeDueTime(value) {
|
|
213
|
+
if (value === undefined || value === null || value === '') return null;
|
|
214
|
+
if (typeof value !== 'string') throw new WorkTodoError('bad-due-time', '截止时间必须是文本');
|
|
215
|
+
const raw = value.trim().toLowerCase();
|
|
216
|
+
if (!raw || raw === 'none' || raw === 'null' || raw === 'clear') return null;
|
|
217
|
+
const m = /^(\d{1,2})(?::?(\d{2}))?$/.exec(raw);
|
|
218
|
+
if (!m) throw new WorkTodoError('bad-due-time', '截止时间要写成 HH:MM(例如 14:30)');
|
|
219
|
+
const hour = Number(m[1]);
|
|
220
|
+
const minute = m[2] === undefined ? 0 : Number(m[2]);
|
|
221
|
+
if (hour > 23 || minute > 59) throw new WorkTodoError('bad-due-time', '截止时间超出范围(00:00–23:59)');
|
|
222
|
+
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`;
|
|
223
|
+
}
|
|
224
|
+
|
|
205
225
|
export function newId(now = Date.now(), rand = Math.random) {
|
|
206
226
|
const t = Math.floor(now).toString(36);
|
|
207
227
|
const r = Math.floor(rand() * 36 ** 4).toString(36).padStart(4, '0');
|
|
@@ -220,13 +240,16 @@ export function newId(now = Date.now(), rand = Math.random) {
|
|
|
220
240
|
export function createRecord(input = {}, { now = Date.now(), source = 'human', id } = {}) {
|
|
221
241
|
if (!SOURCES.includes(source)) throw new WorkTodoError('bad-source', `来源只能是 ${SOURCES.join(' / ')}`);
|
|
222
242
|
const status = normalizeStatus(input.status) ?? 'pending';
|
|
243
|
+
const due = normalizeDue(input.due, now);
|
|
223
244
|
const record = {
|
|
224
245
|
id: id ?? newId(now),
|
|
225
246
|
title: normalizeTitle(input.title),
|
|
226
247
|
notes: normalizeNotes(input.notes),
|
|
227
248
|
status,
|
|
228
|
-
priority: normalizePriority(input.priority) ?? '
|
|
229
|
-
due
|
|
249
|
+
priority: normalizePriority(input.priority) ?? 'none',
|
|
250
|
+
due,
|
|
251
|
+
// 没有截止日期就没有截止时间(避免出现「有时间没日期」的怪状态)
|
|
252
|
+
dueTime: due ? normalizeDueTime(input.dueTime) : null,
|
|
230
253
|
project: normalizeProject(input.project),
|
|
231
254
|
order: null,
|
|
232
255
|
source,
|
|
@@ -254,7 +277,16 @@ export function patchRecord(record, patch = {}, now = Date.now()) {
|
|
|
254
277
|
if (take('notes')) { next.notes = normalizeNotes(patch.notes); touched = true; }
|
|
255
278
|
if (take('project')) { next.project = normalizeProject(patch.project); touched = true; }
|
|
256
279
|
if (take('priority')) { next.priority = normalizePriority(patch.priority) ?? next.priority; touched = true; }
|
|
257
|
-
if (take('due')) {
|
|
280
|
+
if (take('due')) {
|
|
281
|
+
next.due = normalizeDue(patch.due, now);
|
|
282
|
+
if (!next.due) next.dueTime = null; // 清掉日期时时间一并清掉
|
|
283
|
+
touched = true;
|
|
284
|
+
}
|
|
285
|
+
if (take('dueTime')) {
|
|
286
|
+
// 只给时间不给日期是无效的:没有日期就存 null
|
|
287
|
+
next.dueTime = next.due ? normalizeDueTime(patch.dueTime) : null;
|
|
288
|
+
touched = true;
|
|
289
|
+
}
|
|
258
290
|
|
|
259
291
|
const status = normalizeStatus(patch.status);
|
|
260
292
|
if (status && status !== next.status) {
|
|
@@ -306,7 +338,8 @@ export function resolveRef(records, ref) {
|
|
|
306
338
|
* ------------------------------------------------------------------ */
|
|
307
339
|
|
|
308
340
|
/** 优先级权重,用于排序(高的在前)。 */
|
|
309
|
-
|
|
341
|
+
// 「无」排在最后(未定优先级的不该挡在已定优先级的前面),但仍排在「已完成」之前
|
|
342
|
+
const PRIORITY_WEIGHT = { high: 0, normal: 1, low: 2, none: 3 };
|
|
310
343
|
|
|
311
344
|
/**
|
|
312
345
|
* 视图排序:未完成在前;同组内 优先级 → 截止日期(无日期排最后)→ 创建时间。
|
|
@@ -330,6 +363,11 @@ export function sortForView(records, mode = 'priority') {
|
|
|
330
363
|
const pb = PRIORITY_WEIGHT[b.priority] ?? 1;
|
|
331
364
|
if (pa !== pb) return pa - pb;
|
|
332
365
|
if (a.due && b.due && a.due !== b.due) return a.due < b.due ? -1 : 1;
|
|
366
|
+
if (a.due === b.due) {
|
|
367
|
+
const ta = a.dueTime ?? '99:99'; // 同一天:有时间的在前,没时间的垫后
|
|
368
|
+
const tb = b.dueTime ?? '99:99';
|
|
369
|
+
if (ta !== tb) return ta < tb ? -1 : 1;
|
|
370
|
+
}
|
|
333
371
|
if (a.due && !b.due) return -1;
|
|
334
372
|
if (!a.due && b.due) return 1;
|
|
335
373
|
return a.createdAt - b.createdAt;
|
|
@@ -489,7 +527,7 @@ export function filterRecords(records, filter = 'open', now = Date.now()) {
|
|
|
489
527
|
* ------------------------------------------------------------------ */
|
|
490
528
|
|
|
491
529
|
/** 优先级在文本里的记号:让模型一眼看出轻重,且不占多少 token。 */
|
|
492
|
-
const PRIORITY_MARK = { high: '!', normal: '', low: '·' };
|
|
530
|
+
const PRIORITY_MARK = { high: '!', normal: '', low: '·', none: '' };
|
|
493
531
|
|
|
494
532
|
/**
|
|
495
533
|
* 把一条记录渲染成单行文本,例如:
|
|
@@ -503,10 +541,11 @@ export function formatLine(record, now = Date.now()) {
|
|
|
503
541
|
const box = record.status === 'completed' ? '[x]' : record.status === 'in_progress' ? '[~]' : '[ ]';
|
|
504
542
|
const bits = [];
|
|
505
543
|
if (record.priority === 'high') bits.push('高');
|
|
544
|
+
else if (record.priority === 'normal') bits.push('中');
|
|
506
545
|
else if (record.priority === 'low') bits.push('低');
|
|
507
546
|
if (record.due) {
|
|
508
547
|
const overdue = record.status !== 'completed' && record.due < today;
|
|
509
|
-
bits.push(`${overdue ? '已逾期 ' : '截止 '}${record.due}`);
|
|
548
|
+
bits.push(`${overdue ? '已逾期 ' : '截止 '}${record.due}${record.dueTime ? ` ${record.dueTime}` : ''}`);
|
|
510
549
|
}
|
|
511
550
|
if (record.project) bits.push(`#${record.project}`);
|
|
512
551
|
const suffix = bits.length ? ` (${bits.join(', ')})` : '';
|
package/src/schema.mjs
CHANGED
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
dayKey,
|
|
20
20
|
newId,
|
|
21
21
|
normalizeDue,
|
|
22
|
+
normalizeDueTime,
|
|
22
23
|
normalizePriority,
|
|
23
24
|
normalizeStatus,
|
|
24
25
|
} from './records.mjs';
|
|
@@ -63,7 +64,7 @@ export function repairRecord(raw) {
|
|
|
63
64
|
const id = typeof raw.id === 'string' && raw.id.trim() ? raw.id.trim() : newId();
|
|
64
65
|
// 状态/优先级/日期都过一遍同义词归一:手改成 done / 高 / 2026/10/20 也要能懂
|
|
65
66
|
const status = STATUSES.includes(raw.status) ? raw.status : coerce(() => normalizeStatus(raw.status), 'pending');
|
|
66
|
-
const priority = PRIORITIES.includes(raw.priority) ? raw.priority : coerce(() => normalizePriority(raw.priority), '
|
|
67
|
+
const priority = PRIORITIES.includes(raw.priority) ? raw.priority : coerce(() => normalizePriority(raw.priority), 'none');
|
|
67
68
|
const source = SOURCES.includes(raw.source) ? raw.source : 'human';
|
|
68
69
|
const createdAt = Number.isFinite(raw.createdAt) ? Number(raw.createdAt) : Date.now();
|
|
69
70
|
const updatedAt = Number.isFinite(raw.updatedAt) ? Number(raw.updatedAt) : createdAt;
|
|
@@ -81,6 +82,10 @@ export function repairRecord(raw) {
|
|
|
81
82
|
priority,
|
|
82
83
|
// 相对日期(today/+3d)按「读到的这一刻」折算:手改文件的人想的当然是今天
|
|
83
84
|
due: typeof raw.due === 'string' ? coerce(() => normalizeDue(raw.due, Date.now()), null) : null,
|
|
85
|
+
// 时间只在有日期时保留(同 createRecord/patchRecord 的约定)
|
|
86
|
+
dueTime: typeof raw.dueTime === 'string' && typeof raw.due === 'string'
|
|
87
|
+
? coerce(() => normalizeDueTime(raw.dueTime), null)
|
|
88
|
+
: null,
|
|
84
89
|
project: typeof raw.project === 'string' && raw.project.trim() ? raw.project.trim() : null,
|
|
85
90
|
order,
|
|
86
91
|
source,
|
package/src/store.mjs
CHANGED
|
@@ -209,7 +209,10 @@ export async function openWorkTodoStore({ ctx, config = {}, logger } = {}) {
|
|
|
209
209
|
|
|
210
210
|
// ctx.get 对未提供的服务返回 undefined,不抛错;storage 服务可能晚于本插件注册,
|
|
211
211
|
// 但 dsh-base 的层序在 profile 组合之前,所以这里探测到不存在时才回退。
|
|
212
|
-
|
|
212
|
+
// ⚠️ 只用 ctx.get 探测,**绝不**退回 `ctx.storageDomain`:直接访问未在 inject 里声明的服务
|
|
213
|
+
// 会被 cordis 守卫抛错("cannot get property … without inject"),而那个异常会让整个插件挂载失败——
|
|
214
|
+
// 实测插件「时好时坏」就是这里(服务尚未注册时 ctx.get 返回 undefined,兜底访问随即抛错)
|
|
215
|
+
const storageDomain = explicitPath ? undefined : ctx?.get?.('storageDomain');
|
|
213
216
|
|
|
214
217
|
if (storageDomain && typeof storageDomain.open === 'function') {
|
|
215
218
|
try {
|
package/src/tools.mjs
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { parseNaturalDue } from './parse-due.mjs';
|
|
2
|
+
|
|
1
3
|
/**
|
|
2
4
|
* 面向模型的 worktodo_* 工具。
|
|
3
5
|
*
|
|
@@ -90,6 +92,7 @@ export async function registerWorkTodoTools({ ctx, service, logger }) {
|
|
|
90
92
|
parameters: {
|
|
91
93
|
title: { type: 'string', required: true, description: '待办内容,一句简短的祈使句,例如「把 v0.1.0 发到 npm」。' },
|
|
92
94
|
due: { type: 'string', description: '截止日期:YYYY-MM-DD / today / tomorrow / +3d。省略表示没有截止日期。' },
|
|
95
|
+
dueTime: { type: 'string', description: '截止时间 HH:MM(可选,只有给了 due 才有意义;例如 14:30)。' },
|
|
93
96
|
priority: { type: 'string', enum: ['high', 'normal', 'low'], description: '优先级,默认 normal。真正卡住别人的事才给 high。' },
|
|
94
97
|
project: { type: 'string', description: '项目或领域标签,例如 dsh-worktodo。省略表示不归类。' },
|
|
95
98
|
notes: { type: 'string', description: '补充说明:背景、链接、验收标准。默认空。' },
|
|
@@ -103,6 +106,7 @@ export async function registerWorkTodoTools({ ctx, service, logger }) {
|
|
|
103
106
|
id: { type: 'string', required: true },
|
|
104
107
|
title: { type: 'string', required: true },
|
|
105
108
|
due: { type: 'string' },
|
|
109
|
+
dueTime: { type: 'string' },
|
|
106
110
|
priority: { type: 'string', required: true },
|
|
107
111
|
project: { type: 'string' },
|
|
108
112
|
status: { type: 'string', required: true },
|
|
@@ -111,15 +115,16 @@ export async function registerWorkTodoTools({ ctx, service, logger }) {
|
|
|
111
115
|
},
|
|
112
116
|
render: (_args, value) => [{
|
|
113
117
|
type: 'text',
|
|
114
|
-
text: `已记入工作待办:${value.title}(${value.id}${value.due ? `,截止 ${value.due}` : ''})`
|
|
118
|
+
text: `已记入工作待办:${value.title}(${value.id}${value.due ? `,截止 ${value.due}${value.dueTime ? ` ${value.dueTime}` : ''}` : ''})`
|
|
115
119
|
+ (value.stats ? `|当前未完成 ${value.stats.open} 条,逾期 ${value.stats.overdue} 条` : ''),
|
|
116
120
|
}],
|
|
117
121
|
},
|
|
118
122
|
execute(args) {
|
|
119
|
-
return service.create(args, { source: 'agent' }).then(unwrap).then((result) => withStats(compact({
|
|
123
|
+
return service.create(withParsedDue(args), { source: 'agent' }).then(unwrap).then((result) => withStats(compact({
|
|
120
124
|
id: result.todo.id,
|
|
121
125
|
title: result.todo.title,
|
|
122
126
|
due: result.todo.due ?? undefined,
|
|
127
|
+
dueTime: result.todo.dueTime ?? undefined,
|
|
123
128
|
priority: result.todo.priority,
|
|
124
129
|
project: result.todo.project ?? undefined,
|
|
125
130
|
status: result.todo.status,
|
|
@@ -145,6 +150,7 @@ export async function registerWorkTodoTools({ ctx, service, logger }) {
|
|
|
145
150
|
properties: {
|
|
146
151
|
title: { type: 'string', required: true, description: '待办内容(一句简短的祈使句)。' },
|
|
147
152
|
due: { type: 'string', description: '截止日期:YYYY-MM-DD / today / tomorrow / +3d。' },
|
|
153
|
+
dueTime: { type: 'string', description: '截止时间 HH:MM(可选)。' },
|
|
148
154
|
priority: { type: 'string', enum: ['high', 'normal', 'low'], description: '默认 normal。' },
|
|
149
155
|
project: { type: 'string', description: '项目或领域标签。' },
|
|
150
156
|
notes: { type: 'string', description: '背景、链接、验收标准。' },
|
|
@@ -169,6 +175,7 @@ export async function registerWorkTodoTools({ ctx, service, logger }) {
|
|
|
169
175
|
id: { type: 'string', required: true },
|
|
170
176
|
title: { type: 'string', required: true },
|
|
171
177
|
due: { type: 'string' },
|
|
178
|
+
dueTime: { type: 'string' },
|
|
172
179
|
priority: { type: 'string', required: true },
|
|
173
180
|
project: { type: 'string' },
|
|
174
181
|
status: { type: 'string', required: true },
|
|
@@ -204,11 +211,12 @@ export async function registerWorkTodoTools({ ctx, service, logger }) {
|
|
|
204
211
|
const items = Array.isArray(args.items) ? args.items : [];
|
|
205
212
|
for (const [index, item] of items.entries()) {
|
|
206
213
|
try {
|
|
207
|
-
const result = unwrap(await service.create(item ?? {}, { source: 'agent' }));
|
|
214
|
+
const result = unwrap(await service.create(withParsedDue(item ?? {}), { source: 'agent' }));
|
|
208
215
|
created.push(compact({
|
|
209
216
|
id: result.todo.id,
|
|
210
217
|
title: result.todo.title,
|
|
211
218
|
due: result.todo.due ?? undefined,
|
|
219
|
+
dueTime: result.todo.dueTime ?? undefined,
|
|
212
220
|
priority: result.todo.priority,
|
|
213
221
|
project: result.todo.project ?? undefined,
|
|
214
222
|
status: result.todo.status,
|
|
@@ -261,7 +269,7 @@ export async function registerWorkTodoTools({ ctx, service, logger }) {
|
|
|
261
269
|
lines: result.todos.length
|
|
262
270
|
? result.todos.map((t) => {
|
|
263
271
|
const box = t.status === 'completed' ? '[x]' : t.status === 'in_progress' ? '[~]' : '[ ]';
|
|
264
|
-
const bits = [t.priority === 'high' ? '高' : t.priority === 'low' ? '低' : null, t.due ? `${t.due}${t.due < result.today && t.status !== 'completed' ? '(逾期)' : ''}` : null, t.project ? `#${t.project}` : null].filter(Boolean);
|
|
272
|
+
const bits = [t.priority === 'high' ? '高' : t.priority === 'low' ? '低' : null, t.due ? `${t.due}${t.dueTime ? ` ${t.dueTime}` : ''}${t.due < result.today && t.status !== 'completed' ? '(逾期)' : ''}` : null, t.project ? `#${t.project}` : null].filter(Boolean);
|
|
265
273
|
return `${box} ${t.id} ${t.title}${bits.length ? ` (${bits.join(', ')})` : ''}`;
|
|
266
274
|
}).join('\n')
|
|
267
275
|
: '(没有符合条件的待办)',
|
|
@@ -282,7 +290,8 @@ export async function registerWorkTodoTools({ ctx, service, logger }) {
|
|
|
282
290
|
parameters: {
|
|
283
291
|
ref: { type: 'string', required: true, description: '待办的 id(推荐)或标题。' },
|
|
284
292
|
title: { type: 'string', description: '新标题。' },
|
|
285
|
-
due: { type: 'string', description: '新截止日期;传 none
|
|
293
|
+
due: { type: 'string', description: '新截止日期;传 none 清空(清空日期会一并清掉时间)。' },
|
|
294
|
+
dueTime: { type: 'string', description: '新截止时间 HH:MM;传 none 清空。' },
|
|
286
295
|
priority: { type: 'string', enum: ['high', 'normal', 'low'] },
|
|
287
296
|
project: { type: 'string', description: '新项目标签;传 none 清空。' },
|
|
288
297
|
notes: { type: 'string', description: '新备注。' },
|
|
@@ -447,4 +456,23 @@ export async function registerWorkTodoTools({ ctx, service, logger }) {
|
|
|
447
456
|
}
|
|
448
457
|
},
|
|
449
458
|
};
|
|
459
|
+
}/**
|
|
460
|
+
* 让工具也能"说人话":标题里写了「明天下午三点」这类说法时,交给与面板**同一份**解析器
|
|
461
|
+
* (`src/parse-due.mjs`)。显式传了 `due` 就以显式为准,绝不被标题覆盖。
|
|
462
|
+
* @param {object} input
|
|
463
|
+
* @returns {object} 落库用的参数(标题里的日期词会被摘掉)
|
|
464
|
+
*/
|
|
465
|
+
export function withParsedDue(input = {}) {
|
|
466
|
+
const explicit = input.due !== undefined && input.due !== null && input.due !== '';
|
|
467
|
+
if (explicit) return input;
|
|
468
|
+
const parsed = parseNaturalDue(String(input.title ?? ''), Date.now());
|
|
469
|
+
if (!parsed.matched.length) return input;
|
|
470
|
+
return {
|
|
471
|
+
...input,
|
|
472
|
+
title: parsed.title,
|
|
473
|
+
due: parsed.due ?? undefined,
|
|
474
|
+
dueTime: input.dueTime ?? parsed.dueTime ?? undefined,
|
|
475
|
+
};
|
|
450
476
|
}
|
|
477
|
+
|
|
478
|
+
|