dsh-worktodo 0.1.0 → 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 +141 -2
- package/README.en.md +13 -3
- package/README.md +18 -3
- package/lib/client.js +597 -73
- package/lib/client.js.map +4 -4
- package/lib/index.js +107 -1
- package/package.json +15 -25
- package/src/client/index.js +462 -71
- package/src/parse-due.mjs +170 -0
- package/src/records.mjs +131 -16
- package/src/schema.mjs +9 -1
- package/src/service.mjs +58 -3
- package/src/store.mjs +4 -1
- package/src/tools.mjs +137 -4
|
@@ -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,14 +240,18 @@ 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),
|
|
254
|
+
order: null,
|
|
231
255
|
source,
|
|
232
256
|
createdAt: now,
|
|
233
257
|
updatedAt: now,
|
|
@@ -253,7 +277,16 @@ export function patchRecord(record, patch = {}, now = Date.now()) {
|
|
|
253
277
|
if (take('notes')) { next.notes = normalizeNotes(patch.notes); touched = true; }
|
|
254
278
|
if (take('project')) { next.project = normalizeProject(patch.project); touched = true; }
|
|
255
279
|
if (take('priority')) { next.priority = normalizePriority(patch.priority) ?? next.priority; touched = true; }
|
|
256
|
-
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
|
+
}
|
|
257
290
|
|
|
258
291
|
const status = normalizeStatus(patch.status);
|
|
259
292
|
if (status && status !== next.status) {
|
|
@@ -305,31 +338,69 @@ export function resolveRef(records, ref) {
|
|
|
305
338
|
* ------------------------------------------------------------------ */
|
|
306
339
|
|
|
307
340
|
/** 优先级权重,用于排序(高的在前)。 */
|
|
308
|
-
|
|
341
|
+
// 「无」排在最后(未定优先级的不该挡在已定优先级的前面),但仍排在「已完成」之前
|
|
342
|
+
const PRIORITY_WEIGHT = { high: 0, normal: 1, low: 2, none: 3 };
|
|
309
343
|
|
|
310
344
|
/**
|
|
311
345
|
* 视图排序:未完成在前;同组内 优先级 → 截止日期(无日期排最后)→ 创建时间。
|
|
312
346
|
* @param {object[]} records
|
|
313
347
|
* @returns {object[]} 新数组
|
|
314
348
|
*/
|
|
315
|
-
export
|
|
349
|
+
export const SORT_MODES = ['manual', 'priority', 'created'];
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* 视图排序。三种模式共用同一套「已完成永远沉底」的规则:
|
|
353
|
+
* - `priority`(默认):优先级 → 截止日期 → 创建时间(早的在前)
|
|
354
|
+
* - `created`:创建时间**倒序**(刚加的在前)
|
|
355
|
+
* - `manual`:拖过顺序的(`order` 是有限数)按 order 升序在前;没拖过的按 priority 规则排在后面
|
|
356
|
+
* @param {object[]} records
|
|
357
|
+
* @param {'manual'|'priority'|'created'} [mode]
|
|
358
|
+
*/
|
|
359
|
+
export function sortForView(records, mode = 'priority') {
|
|
316
360
|
const rank = (r) => (r.status === 'completed' ? 1 : 0);
|
|
317
|
-
|
|
318
|
-
if (rank(a) !== rank(b)) return rank(a) - rank(b);
|
|
319
|
-
if (a.status === 'completed' && b.status === 'completed') {
|
|
320
|
-
// 已完成按完成时间倒序:最近做完的先看到
|
|
321
|
-
return (b.completedAt ?? b.updatedAt) - (a.completedAt ?? a.updatedAt);
|
|
322
|
-
}
|
|
361
|
+
const byFallback = (a, b) => {
|
|
323
362
|
const pa = PRIORITY_WEIGHT[a.priority] ?? 1;
|
|
324
363
|
const pb = PRIORITY_WEIGHT[b.priority] ?? 1;
|
|
325
364
|
if (pa !== pb) return pa - pb;
|
|
326
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
|
+
}
|
|
327
371
|
if (a.due && !b.due) return -1;
|
|
328
372
|
if (!a.due && b.due) return 1;
|
|
329
373
|
return a.createdAt - b.createdAt;
|
|
374
|
+
};
|
|
375
|
+
const manualRank = (r) => (Number.isFinite(r.order) ? 0 : 1);
|
|
376
|
+
return [...records].sort((a, b) => {
|
|
377
|
+
if (rank(a) !== rank(b)) return rank(a) - rank(b);
|
|
378
|
+
if (a.status === 'completed' && b.status === 'completed') {
|
|
379
|
+
// 已完成按完成时间倒序:最近做完的先看到
|
|
380
|
+
return (b.completedAt ?? b.updatedAt) - (a.completedAt ?? a.updatedAt);
|
|
381
|
+
}
|
|
382
|
+
if (mode === 'created') return b.createdAt - a.createdAt;
|
|
383
|
+
if (mode === 'manual') {
|
|
384
|
+
if (manualRank(a) !== manualRank(b)) return manualRank(a) - manualRank(b);
|
|
385
|
+
if (manualRank(a) === 0 && a.order !== b.order) return a.order - b.order;
|
|
386
|
+
return byFallback(a, b);
|
|
387
|
+
}
|
|
388
|
+
return byFallback(a, b);
|
|
330
389
|
});
|
|
331
390
|
}
|
|
332
391
|
|
|
392
|
+
/**
|
|
393
|
+
* 把「新的完整顺序」落到记录上:ids[i] 的记录得到 order=i。
|
|
394
|
+
* 不在 ids 里的记录保持原样(order 为 null 的继续按自动规则排)。
|
|
395
|
+
* 纯函数:返回新数组,不改入参。
|
|
396
|
+
* @param {object[]} records
|
|
397
|
+
* @param {string[]} ids
|
|
398
|
+
*/
|
|
399
|
+
export function applyManualOrder(records, ids) {
|
|
400
|
+
const position = new Map(ids.map((id, index) => [id, index]));
|
|
401
|
+
return records.map((record) => (position.has(record.id) ? { ...record, order: position.get(record.id) } : record));
|
|
402
|
+
}
|
|
403
|
+
|
|
333
404
|
/**
|
|
334
405
|
* 分组成面板的五个区。
|
|
335
406
|
* 「本周」= 今天之后到本周日;无截止日期的进「以后」。
|
|
@@ -337,7 +408,7 @@ export function sortForView(records) {
|
|
|
337
408
|
* @param {number} [now]
|
|
338
409
|
* @returns {{overdue: object[], today: object[], week: object[], later: object[], completed: object[]}}
|
|
339
410
|
*/
|
|
340
|
-
export function groupRecords(records, now = Date.now()) {
|
|
411
|
+
export function groupRecords(records, now = Date.now(), mode = 'priority') {
|
|
341
412
|
const today = dayKey(now);
|
|
342
413
|
const weekEnd = dayKey(dayStart(weekStartKey(now)) + 6 * DAY_MS);
|
|
343
414
|
const groups = { overdue: [], today: [], week: [], later: [], completed: [] };
|
|
@@ -350,7 +421,7 @@ export function groupRecords(records, now = Date.now()) {
|
|
|
350
421
|
if (due <= weekEnd) { groups.week.push(record); continue; }
|
|
351
422
|
groups.later.push(record);
|
|
352
423
|
}
|
|
353
|
-
for (const key of Object.keys(groups)) groups[key] = sortForView(groups[key]);
|
|
424
|
+
for (const key of Object.keys(groups)) groups[key] = sortForView(groups[key], mode);
|
|
354
425
|
return groups;
|
|
355
426
|
}
|
|
356
427
|
|
|
@@ -360,6 +431,49 @@ export function groupRecords(records, now = Date.now()) {
|
|
|
360
431
|
* @param {number} [now]
|
|
361
432
|
* @returns {{total: number, open: number, overdue: number, today: number, doing: number, completed: number, completedToday: number}}
|
|
362
433
|
*/
|
|
434
|
+
/**
|
|
435
|
+
* 按项目分组:面板的「按项目」视图。
|
|
436
|
+
*
|
|
437
|
+
* 规则:
|
|
438
|
+
* - 没写 project 的记录归入「未归类」桶(project 为 null,永远排在最后);
|
|
439
|
+
* - 桶的排序:未完成条数多的在前 → 未完成里最早的截止日期更早的在前 → 名字字典序;
|
|
440
|
+
* - 桶内沿用 sortForView(未完成在前、高优先级在前、临近截止在前);
|
|
441
|
+
* - 每个桶附 total / open / overdue,面板可直接显示「项目名 · 未完成/总数」。
|
|
442
|
+
*
|
|
443
|
+
* @param {object[]} records
|
|
444
|
+
* @param {number} [now]
|
|
445
|
+
* @returns {{project: string|null, key: string, todos: object[], total: number, open: number, overdue: number}[]}
|
|
446
|
+
*/
|
|
447
|
+
export function groupByProject(records, now = Date.now(), mode = 'priority') {
|
|
448
|
+
const today = dayKey(now);
|
|
449
|
+
const buckets = new Map();
|
|
450
|
+
for (const record of records) {
|
|
451
|
+
const project = record.project ?? null;
|
|
452
|
+
const key = project ?? '';
|
|
453
|
+
if (!buckets.has(key)) buckets.set(key, { project, key, todos: [], total: 0, open: 0, overdue: 0 });
|
|
454
|
+
const bucket = buckets.get(key);
|
|
455
|
+
bucket.todos.push(record);
|
|
456
|
+
bucket.total += 1;
|
|
457
|
+
if (record.status !== 'completed') {
|
|
458
|
+
bucket.open += 1;
|
|
459
|
+
if (record.due && record.due < today) bucket.overdue += 1;
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
const list = [...buckets.values()];
|
|
463
|
+
for (const bucket of list) bucket.todos = sortForView(bucket.todos, mode);
|
|
464
|
+
const earliestOpenDue = (bucket) =>
|
|
465
|
+
bucket.todos.filter((t) => t.status !== 'completed' && t.due).map((t) => t.due).sort()[0] ?? '9999-99-99';
|
|
466
|
+
return list.sort((a, b) => {
|
|
467
|
+
// 未归类永远最后:它不是一个真正的项目
|
|
468
|
+
if ((a.project === null) !== (b.project === null)) return a.project === null ? 1 : -1;
|
|
469
|
+
if (a.open !== b.open) return b.open - a.open;
|
|
470
|
+
const da = earliestOpenDue(a);
|
|
471
|
+
const db = earliestOpenDue(b);
|
|
472
|
+
if (da !== db) return da < db ? -1 : 1;
|
|
473
|
+
return (a.project ?? '').localeCompare(b.project ?? '');
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
363
477
|
export function statsOf(records, now = Date.now()) {
|
|
364
478
|
const today = dayKey(now);
|
|
365
479
|
const stats = { total: records.length, open: 0, overdue: 0, today: 0, doing: 0, completed: 0, completedToday: 0 };
|
|
@@ -413,7 +527,7 @@ export function filterRecords(records, filter = 'open', now = Date.now()) {
|
|
|
413
527
|
* ------------------------------------------------------------------ */
|
|
414
528
|
|
|
415
529
|
/** 优先级在文本里的记号:让模型一眼看出轻重,且不占多少 token。 */
|
|
416
|
-
const PRIORITY_MARK = { high: '!', normal: '', low: '·' };
|
|
530
|
+
const PRIORITY_MARK = { high: '!', normal: '', low: '·', none: '' };
|
|
417
531
|
|
|
418
532
|
/**
|
|
419
533
|
* 把一条记录渲染成单行文本,例如:
|
|
@@ -427,10 +541,11 @@ export function formatLine(record, now = Date.now()) {
|
|
|
427
541
|
const box = record.status === 'completed' ? '[x]' : record.status === 'in_progress' ? '[~]' : '[ ]';
|
|
428
542
|
const bits = [];
|
|
429
543
|
if (record.priority === 'high') bits.push('高');
|
|
544
|
+
else if (record.priority === 'normal') bits.push('中');
|
|
430
545
|
else if (record.priority === 'low') bits.push('低');
|
|
431
546
|
if (record.due) {
|
|
432
547
|
const overdue = record.status !== 'completed' && record.due < today;
|
|
433
|
-
bits.push(`${overdue ? '已逾期 ' : '截止 '}${record.due}`);
|
|
548
|
+
bits.push(`${overdue ? '已逾期 ' : '截止 '}${record.due}${record.dueTime ? ` ${record.dueTime}` : ''}`);
|
|
434
549
|
}
|
|
435
550
|
if (record.project) bits.push(`#${record.project}`);
|
|
436
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,10 +64,12 @@ 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;
|
|
71
|
+
// 手动排序位置:只有拖过的记录才有;null 表示「按优先级/截止/创建时间自动排」
|
|
72
|
+
const order = Number.isFinite(raw.order) ? Number(raw.order) : null;
|
|
70
73
|
const completedAt = status === 'completed'
|
|
71
74
|
? (Number.isFinite(raw.completedAt) ? Number(raw.completedAt) : updatedAt)
|
|
72
75
|
: null;
|
|
@@ -79,7 +82,12 @@ export function repairRecord(raw) {
|
|
|
79
82
|
priority,
|
|
80
83
|
// 相对日期(today/+3d)按「读到的这一刻」折算:手改文件的人想的当然是今天
|
|
81
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,
|
|
82
89
|
project: typeof raw.project === 'string' && raw.project.trim() ? raw.project.trim() : null,
|
|
90
|
+
order,
|
|
83
91
|
source,
|
|
84
92
|
createdAt,
|
|
85
93
|
updatedAt,
|
package/src/service.mjs
CHANGED
|
@@ -16,7 +16,10 @@ import {
|
|
|
16
16
|
adoptSessionTodos,
|
|
17
17
|
createRecord,
|
|
18
18
|
dayKey,
|
|
19
|
+
SORT_MODES,
|
|
20
|
+
applyManualOrder,
|
|
19
21
|
filterRecords,
|
|
22
|
+
groupByProject,
|
|
20
23
|
groupRecords,
|
|
21
24
|
patchRecord,
|
|
22
25
|
resolveRef,
|
|
@@ -79,7 +82,9 @@ export function createWorkTodoService({ store, clock = () => Date.now(), logger
|
|
|
79
82
|
* @param {string} [options.query] 标题/备注里的关键词
|
|
80
83
|
* @returns {Promise<object>} 显式状态
|
|
81
84
|
*/
|
|
82
|
-
async function list({ filter = 'open', project, query } = {}) {
|
|
85
|
+
async function list({ filter = 'open', project, query, sort } = {}) {
|
|
86
|
+
// 默认 manual:拖过的顺序优先,没拖过的自动退回优先级/截止/创建时间(所以初看依然合理)
|
|
87
|
+
const mode = SORT_MODES.includes(sort) ? sort : 'manual';
|
|
83
88
|
const now = clock();
|
|
84
89
|
let records;
|
|
85
90
|
try {
|
|
@@ -101,12 +106,28 @@ export function createWorkTodoService({ store, clock = () => Date.now(), logger
|
|
|
101
106
|
});
|
|
102
107
|
if (!picked.ok) return picked;
|
|
103
108
|
|
|
109
|
+
// 项目视图用的分组:**不套 project 收窄**,否则面板上只剩当前项目、没法切到别处
|
|
110
|
+
const forProjects = attempt(() => {
|
|
111
|
+
let subset = records;
|
|
112
|
+
if (query) {
|
|
113
|
+
const needle = String(query).trim().toLowerCase();
|
|
114
|
+
subset = subset.filter((r) => r.title.toLowerCase().includes(needle) || (r.notes ?? '').toLowerCase().includes(needle));
|
|
115
|
+
}
|
|
116
|
+
return filterRecords(subset, filter, now);
|
|
117
|
+
});
|
|
118
|
+
if (!forProjects.ok) return forProjects;
|
|
119
|
+
|
|
104
120
|
return {
|
|
105
121
|
ok: true,
|
|
106
|
-
|
|
107
|
-
|
|
122
|
+
// filterRecords 内部按优先级排过序;这里按当前模式再排一次,
|
|
123
|
+
// 否则「平铺列表」会和「分组」用两套顺序(sort=created/manual 时最明显)
|
|
124
|
+
todos: sortForView(picked.value, mode),
|
|
125
|
+
groups: groupRecords(picked.value, now, mode),
|
|
126
|
+
// 「按项目」视图:每个项目一个桶,附 total/open/overdue
|
|
127
|
+
projectGroups: groupByProject(forProjects.value, now, mode),
|
|
108
128
|
// 统计基于**全部**记录,而不是当前筛选结果:面板顶部的「逾期 3 条」不该因为
|
|
109
129
|
// 用户切到「已完成」标签就变成 0
|
|
130
|
+
sort: mode,
|
|
110
131
|
stats: statsOf(records, now),
|
|
111
132
|
today: dayKey(now),
|
|
112
133
|
store: { kind: store.kind, location: store.location },
|
|
@@ -134,6 +155,39 @@ export function createWorkTodoService({ store, clock = () => Date.now(), logger
|
|
|
134
155
|
return { ok: true, todo: built.value };
|
|
135
156
|
}
|
|
136
157
|
|
|
158
|
+
/**
|
|
159
|
+
* 手动排序:ids 是「新的完整顺序」,ids[i] 的记录得到 order=i。
|
|
160
|
+
* 只写真正变了的记录;不在 ids 里的记录保持原样(order 仍为 null,继续按自动规则排)。
|
|
161
|
+
* @param {{ids?: string[]}} [input]
|
|
162
|
+
*/
|
|
163
|
+
async function reorder({ ids } = {}) {
|
|
164
|
+
const order = Array.isArray(ids) ? ids.filter((id) => typeof id === 'string' && id.trim()) : [];
|
|
165
|
+
if (!order.length) return { ok: false, reason: 'empty-order', message: '需要给出待办 id 的顺序数组' };
|
|
166
|
+
let records;
|
|
167
|
+
try {
|
|
168
|
+
records = await all();
|
|
169
|
+
} catch (error) {
|
|
170
|
+
return { ok: false, reason: 'store-unreadable', message: `读取待办失败:${error?.message ?? error}` };
|
|
171
|
+
}
|
|
172
|
+
const known = new Set(records.map((record) => record.id));
|
|
173
|
+
const unknownIds = order.filter((id) => !known.has(id));
|
|
174
|
+
if (unknownIds.length) return { ok: false, reason: 'not-found', message: `这些 id 不存在:${unknownIds.join('、')}` };
|
|
175
|
+
|
|
176
|
+
const next = applyManualOrder(records, order);
|
|
177
|
+
let updated = 0;
|
|
178
|
+
for (let index = 0; index < next.length; index += 1) {
|
|
179
|
+
if (next[index] === records[index]) continue; // 顺序没变的记录不写盘
|
|
180
|
+
try {
|
|
181
|
+
await store.put(next[index]);
|
|
182
|
+
updated += 1;
|
|
183
|
+
} catch (error) {
|
|
184
|
+
return { ok: false, reason: 'store-write-failed', message: `保存顺序失败:${error?.message ?? error}` };
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
emit({ operation: 'reorder', updated });
|
|
188
|
+
return { ok: true, updated, order };
|
|
189
|
+
}
|
|
190
|
+
|
|
137
191
|
/**
|
|
138
192
|
* 改一条:按引用定位,只覆盖显式给出的字段。
|
|
139
193
|
* @param {string} ref id 或标题
|
|
@@ -243,6 +297,7 @@ export function createWorkTodoService({ store, clock = () => Date.now(), logger
|
|
|
243
297
|
|
|
244
298
|
return {
|
|
245
299
|
list,
|
|
300
|
+
reorder,
|
|
246
301
|
create,
|
|
247
302
|
update,
|
|
248
303
|
setStatus,
|
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 {
|