@sunr/dsh-reminder 0.0.0-stage → 0.14.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/README.md +188 -2
- package/bundles/cordis.patch.yml +5 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +52 -3
- package/src/almanac.js +80 -0
- package/src/client.js +984 -0
- package/src/export.js +111 -0
- package/src/index.js +296 -0
- package/src/lunar.js +180 -0
- package/src/remind.js +40 -0
- package/src/repeat.js +171 -0
- package/src/retrograde.js +67 -0
- package/src/service.js +170 -0
- package/src/store.js +116 -0
- package/src/tools.js +512 -0
- package/src/view.js +107 -0
package/src/tools.js
ADDED
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 六个工具的定义。刻意把 defineTool 作为参数传进来:
|
|
3
|
+
* 这样脱离 DSH 宿主也能用假 defineTool 单测 execute(),不用起服务。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { toDate, toISODate, solarToLunar, nextLunarOccurrence, formatLunar } from './lunar.js'
|
|
7
|
+
import {
|
|
8
|
+
matchesItem, occurrencesBetween, nextOccurrence, daysUntil, validateItem, REPEAT_KINDS,
|
|
9
|
+
} from './repeat.js'
|
|
10
|
+
import { newId } from './store.js'
|
|
11
|
+
import { normalizeAdvance } from './service.js'
|
|
12
|
+
import { normalizeTime, effectiveTime } from './remind.js'
|
|
13
|
+
import { toMarkdown, toJSON, toICS } from './export.js'
|
|
14
|
+
import { mkdir, writeFile } from 'node:fs/promises'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
import { homedir } from 'node:os'
|
|
17
|
+
|
|
18
|
+
const KIND = ['todo', 'anniversary', 'memo']
|
|
19
|
+
|
|
20
|
+
function splitTags(value) {
|
|
21
|
+
if (!value) return []
|
|
22
|
+
return String(value).split(/[,,\s]+/).map((tag) => tag.trim()).filter(Boolean)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function shiftISO(iso, days) {
|
|
26
|
+
return toISODate(new Date(toDate(iso).getTime() + days * 86_400_000))
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function inDays(iso, todayISO) {
|
|
30
|
+
return Math.round((toDate(iso).getTime() - toDate(todayISO).getTime()) / 86_400_000)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function describeItem(item) {
|
|
34
|
+
const repeat = item.repeat === 'none' ? '单次' : item.repeat
|
|
35
|
+
const calendar = item.calendar === 'lunar' ? '农历' : '公历'
|
|
36
|
+
return `${item.title}(${calendar} · ${repeat})`
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function lunarNote(item, iso) {
|
|
40
|
+
if (item.calendar !== 'lunar') return ''
|
|
41
|
+
try {
|
|
42
|
+
return formatLunar(solarToLunar(iso))
|
|
43
|
+
} catch {
|
|
44
|
+
return ''
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function renderList(rows) {
|
|
49
|
+
if (!rows.length) return [{ type: 'text', text: '没有匹配的记录。' }]
|
|
50
|
+
const lines = ['| 日期 | 距今天 | 事项 | 农历 | id |', '| --- | --- | --- | --- | --- |']
|
|
51
|
+
for (const row of rows) {
|
|
52
|
+
const when = row.inDays === 0 ? '今天' : row.inDays > 0 ? `+${row.inDays} 天` : `${-row.inDays} 天前`
|
|
53
|
+
lines.push(`| ${row.date} | ${when} | ${row.title} | ${row.lunar || '—'} | \`${row.id}\` |`)
|
|
54
|
+
}
|
|
55
|
+
return [{ type: 'text', text: lines.join('\n') }]
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function buildTools({ defineTool, store, timeZone = 'Asia/Shanghai', reminderTime = () => '09:00', now = () => new Date() }) {
|
|
59
|
+
// timeZone 可以是字符串,也可以是函数——页面改了设置后工具要立刻跟着变
|
|
60
|
+
const zone = () => (typeof timeZone === 'function' ? timeZone() : timeZone)
|
|
61
|
+
const todayISO = () => toISODate(now(), zone())
|
|
62
|
+
|
|
63
|
+
const add = defineTool({
|
|
64
|
+
name: 'jiri_add',
|
|
65
|
+
description: '记录一条纪念日、待办或备忘。农历条目用 lunarMonth/lunarDay 指定,date 填对应的公历锚点(如出生日期)。',
|
|
66
|
+
parameters: {
|
|
67
|
+
title: { type: 'string', required: true, description: '事项名称,如「妈妈的生日」' },
|
|
68
|
+
date: { type: 'string', description: '公历锚点 YYYY-MM-DD;农历条目填出生/起始日,省略时按今天' },
|
|
69
|
+
kind: { type: 'string', description: `类别:${KIND.join(' / ')}` },
|
|
70
|
+
calendar: { type: 'string', description: 'gregorian(公历)或 lunar(农历)' },
|
|
71
|
+
lunarMonth: { type: 'number', description: '农历月 1-12,calendar=lunar 时必填' },
|
|
72
|
+
lunarDay: { type: 'number', description: '农历日 1-30' },
|
|
73
|
+
lunarLeap: { type: 'boolean', description: '是否闰月,默认 false' },
|
|
74
|
+
repeat: { type: 'string', description: `重复规则:${REPEAT_KINDS.join(' / ')}` },
|
|
75
|
+
every: { type: 'number', description: 'customDays 时的间隔天数' },
|
|
76
|
+
weekdayMask: { type: 'number', description: 'weekly 时的周几掩码:周日=1,周一=2,周三=8,周五=32' },
|
|
77
|
+
tags: { type: 'string', description: '标签,逗号分隔,如「家人,生日」' },
|
|
78
|
+
advance: { type: 'string', description: '提前几天提醒,可多个:7,1(逗号分隔,天数 1-30)' },
|
|
79
|
+
remindAt: { type: 'string', description: '这条记录的提醒时刻 HH:mm;省略则用全局设置' },
|
|
80
|
+
note: { type: 'string', description: '备注' },
|
|
81
|
+
},
|
|
82
|
+
output: {
|
|
83
|
+
schema: { type: 'object', additionalProperties: true },
|
|
84
|
+
render: (_args, value) => [{ type: 'text', text: value.message }],
|
|
85
|
+
},
|
|
86
|
+
async execute(args) {
|
|
87
|
+
const calendar = args.calendar === 'lunar' ? 'lunar' : 'gregorian'
|
|
88
|
+
const kind = KIND.includes(args.kind) ? args.kind : 'todo'
|
|
89
|
+
const repeat = args.repeat ?? (kind === 'anniversary' ? 'yearly' : 'none')
|
|
90
|
+
|
|
91
|
+
let date = args.date?.trim() || null
|
|
92
|
+
if (calendar === 'lunar') {
|
|
93
|
+
if (!args.lunarMonth || !args.lunarDay) throw new Error('农历条目需要 lunarMonth 和 lunarDay')
|
|
94
|
+
if (!date) {
|
|
95
|
+
const hit = nextLunarOccurrence(
|
|
96
|
+
{ month: args.lunarMonth, day: args.lunarDay, leap: Boolean(args.lunarLeap) },
|
|
97
|
+
todayISO(),
|
|
98
|
+
{ timeZone: zone() },
|
|
99
|
+
)
|
|
100
|
+
if (!hit) throw new Error('找不到下一次对应的农历日期,请补一个 date 锚点')
|
|
101
|
+
date = hit.date
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
date ??= todayISO()
|
|
105
|
+
|
|
106
|
+
const item = {
|
|
107
|
+
id: newId(),
|
|
108
|
+
kind,
|
|
109
|
+
title: args.title.trim(),
|
|
110
|
+
calendar,
|
|
111
|
+
date,
|
|
112
|
+
repeat,
|
|
113
|
+
every: args.every ?? null,
|
|
114
|
+
weekdayMask: args.weekdayMask ?? 0,
|
|
115
|
+
leapDayFallback: 'feb28',
|
|
116
|
+
tags: splitTags(args.tags),
|
|
117
|
+
advance: normalizeAdvance(args.advance),
|
|
118
|
+
remindAt: normalizeTime(args.remindAt),
|
|
119
|
+
note: args.note ?? '',
|
|
120
|
+
skipped: [],
|
|
121
|
+
done: [],
|
|
122
|
+
createdAt: new Date().toISOString(),
|
|
123
|
+
updatedAt: new Date().toISOString(),
|
|
124
|
+
}
|
|
125
|
+
if (calendar === 'lunar') {
|
|
126
|
+
item.lunar = { month: args.lunarMonth, day: args.lunarDay, leap: Boolean(args.lunarLeap) }
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const errors = validateItem(item)
|
|
130
|
+
if (errors.length) throw new Error(errors.join(';'))
|
|
131
|
+
|
|
132
|
+
await store.insert(item)
|
|
133
|
+
const next = nextOccurrence(item, todayISO()) ?? null
|
|
134
|
+
const days = next ? inDays(next, todayISO()) : null
|
|
135
|
+
const when = next ? `${next}${days === 0 ? '(今天)' : `(${days} 天后)`}` : '暂无下一次'
|
|
136
|
+
return {
|
|
137
|
+
id: item.id,
|
|
138
|
+
next,
|
|
139
|
+
inDays: days,
|
|
140
|
+
message: `已记下:${describeItem(item)},下一次 ${when}。`,
|
|
141
|
+
}
|
|
142
|
+
},
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const list = defineTool({
|
|
146
|
+
name: 'jiri_list',
|
|
147
|
+
description: '查询纪念日 / 待办 / 备忘。scope=upcoming 时返回未来 days 天内即将发生的。',
|
|
148
|
+
parameters: {
|
|
149
|
+
scope: { type: 'string', description: 'today / upcoming / overdue / all,默认 upcoming' },
|
|
150
|
+
days: { type: 'number', description: 'upcoming 的前瞻天数,默认 30' },
|
|
151
|
+
tag: { type: 'string', description: '按标签过滤' },
|
|
152
|
+
query: { type: 'string', description: '按标题模糊搜索' },
|
|
153
|
+
limit: { type: 'number', description: '最多返回条数,默认 50' },
|
|
154
|
+
},
|
|
155
|
+
output: {
|
|
156
|
+
schema: { type: 'object', additionalProperties: true },
|
|
157
|
+
render: (_args, value) => renderList(value.rows),
|
|
158
|
+
},
|
|
159
|
+
async execute(args) {
|
|
160
|
+
const scope = args.scope ?? 'upcoming'
|
|
161
|
+
const days = Number.isFinite(args.days) ? Number(args.days) : 30
|
|
162
|
+
const limit = Number.isFinite(args.limit) ? Number(args.limit) : 50
|
|
163
|
+
const today = todayISO()
|
|
164
|
+
|
|
165
|
+
let items = await store.list()
|
|
166
|
+
if (args.tag) {
|
|
167
|
+
const tag = String(args.tag)
|
|
168
|
+
items = items.filter((item) => item.tags?.includes(tag))
|
|
169
|
+
}
|
|
170
|
+
if (args.query) {
|
|
171
|
+
const query = String(args.query).toLowerCase()
|
|
172
|
+
items = items.filter((item) => item.title.toLowerCase().includes(query))
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const rows = []
|
|
176
|
+
for (const item of items) {
|
|
177
|
+
if (scope === 'all') {
|
|
178
|
+
rows.push({
|
|
179
|
+
id: item.id,
|
|
180
|
+
title: item.title,
|
|
181
|
+
date: item.date,
|
|
182
|
+
inDays: inDays(item.date, today),
|
|
183
|
+
lunar: lunarNote(item, item.date),
|
|
184
|
+
})
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
if (scope === 'today') {
|
|
188
|
+
if (matchesItem(item, today) && !(item.done ?? []).includes(today)) {
|
|
189
|
+
rows.push({ id: item.id, title: item.title, date: today, inDays: 0, lunar: lunarNote(item, today) })
|
|
190
|
+
}
|
|
191
|
+
continue
|
|
192
|
+
}
|
|
193
|
+
if (scope === 'overdue') {
|
|
194
|
+
const overdue = item.repeat === 'none' && item.date < today && !(item.done ?? []).includes(item.date)
|
|
195
|
+
if (overdue) {
|
|
196
|
+
rows.push({
|
|
197
|
+
id: item.id, title: item.title, date: item.date,
|
|
198
|
+
inDays: inDays(item.date, today), lunar: lunarNote(item, item.date),
|
|
199
|
+
})
|
|
200
|
+
}
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
// upcoming
|
|
204
|
+
const end = shiftISO(today, days)
|
|
205
|
+
const hits = occurrencesBetween(item, today, end)
|
|
206
|
+
.filter((hit) => !(item.done ?? []).includes(hit))
|
|
207
|
+
if (hits.length) {
|
|
208
|
+
rows.push({
|
|
209
|
+
id: item.id, title: item.title, date: hits[0],
|
|
210
|
+
inDays: inDays(hits[0], today), lunar: lunarNote(item, hits[0]),
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
rows.sort((a, b) => (a.date === b.date ? a.title.localeCompare(b.title) : a.date < b.date ? -1 : 1))
|
|
216
|
+
const trimmed = rows.slice(0, limit)
|
|
217
|
+
return {
|
|
218
|
+
count: trimmed.length,
|
|
219
|
+
total: rows.length,
|
|
220
|
+
rows: trimmed,
|
|
221
|
+
message: trimmed.length ? `${trimmed.length} 条` : '没有匹配的记录',
|
|
222
|
+
}
|
|
223
|
+
},
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
const done = defineTool({
|
|
227
|
+
name: 'jiri_done',
|
|
228
|
+
description: '把某一次发生标记为完成(或跳过)。occurrence 省略时默认今天,今天不命中则取下一次。',
|
|
229
|
+
parameters: {
|
|
230
|
+
id: { type: 'string', required: true, description: '记录 id,如 jiri_ab12cd34ef' },
|
|
231
|
+
occurrence: { type: 'string', description: '哪一天的那一次,YYYY-MM-DD' },
|
|
232
|
+
},
|
|
233
|
+
output: {
|
|
234
|
+
schema: { type: 'object', additionalProperties: true },
|
|
235
|
+
render: (_args, value) => [{ type: 'text', text: value.message }],
|
|
236
|
+
},
|
|
237
|
+
async execute(args) {
|
|
238
|
+
const item = await store.get(String(args.id))
|
|
239
|
+
if (!item) throw new Error(`找不到记录:${args.id}`)
|
|
240
|
+
|
|
241
|
+
const today = todayISO()
|
|
242
|
+
const occurrence = args.occurrence?.trim()
|
|
243
|
+
|| (matchesItem(item, today) ? today : nextOccurrence(item, today) ?? today)
|
|
244
|
+
|
|
245
|
+
const doneDates = new Set(item.done ?? [])
|
|
246
|
+
doneDates.add(occurrence)
|
|
247
|
+
const updated = await store.patch(item.id, { done: [...doneDates].sort() })
|
|
248
|
+
return {
|
|
249
|
+
id: item.id,
|
|
250
|
+
occurrence,
|
|
251
|
+
message: `已完成:${item.title} · ${occurrence}${updated ? '' : '(写入失败)'}`,
|
|
252
|
+
}
|
|
253
|
+
},
|
|
254
|
+
})
|
|
255
|
+
|
|
256
|
+
const update = defineTool({
|
|
257
|
+
name: 'jiri_update',
|
|
258
|
+
description: '修改一条记录的标题、日期、重复规则、标签或备注。',
|
|
259
|
+
parameters: {
|
|
260
|
+
id: { type: 'string', required: true },
|
|
261
|
+
title: { type: 'string' },
|
|
262
|
+
date: { type: 'string', description: 'YYYY-MM-DD' },
|
|
263
|
+
repeat: { type: 'string', description: REPEAT_KINDS.join(' / ') },
|
|
264
|
+
every: { type: 'number' },
|
|
265
|
+
weekdayMask: { type: 'number' },
|
|
266
|
+
tags: { type: 'string', description: '逗号分隔' },
|
|
267
|
+
note: { type: 'string' },
|
|
268
|
+
},
|
|
269
|
+
output: {
|
|
270
|
+
schema: { type: 'object', additionalProperties: true },
|
|
271
|
+
render: (_args, value) => [{ type: 'text', text: value.message }],
|
|
272
|
+
},
|
|
273
|
+
async execute(args) {
|
|
274
|
+
const item = await store.get(String(args.id))
|
|
275
|
+
if (!item) throw new Error(`找不到记录:${args.id}`)
|
|
276
|
+
|
|
277
|
+
const fields = {}
|
|
278
|
+
if (args.title !== undefined) fields.title = String(args.title)
|
|
279
|
+
if (args.date !== undefined) fields.date = String(args.date)
|
|
280
|
+
if (args.repeat !== undefined) fields.repeat = String(args.repeat)
|
|
281
|
+
if (args.every !== undefined) fields.every = Number(args.every)
|
|
282
|
+
if (args.weekdayMask !== undefined) fields.weekdayMask = Number(args.weekdayMask)
|
|
283
|
+
if (args.tags !== undefined) fields.tags = splitTags(args.tags)
|
|
284
|
+
if (args.note !== undefined) fields.note = String(args.note)
|
|
285
|
+
if (!Object.keys(fields).length) throw new Error('没有要修改的字段')
|
|
286
|
+
|
|
287
|
+
const merged = { ...item, ...fields }
|
|
288
|
+
const errors = validateItem(merged)
|
|
289
|
+
if (errors.length) throw new Error(errors.join(';'))
|
|
290
|
+
|
|
291
|
+
const updated = await store.patch(item.id, fields)
|
|
292
|
+
const next = nextOccurrence(updated, todayISO())
|
|
293
|
+
return {
|
|
294
|
+
id: item.id,
|
|
295
|
+
next,
|
|
296
|
+
message: `已更新:${describeItem(updated)}${next ? `,下一次 ${next}` : ''}。`,
|
|
297
|
+
}
|
|
298
|
+
},
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
const remove = defineTool({
|
|
302
|
+
name: 'jiri_delete',
|
|
303
|
+
description: '删除一条记录。',
|
|
304
|
+
parameters: {
|
|
305
|
+
id: { type: 'string', required: true },
|
|
306
|
+
},
|
|
307
|
+
output: {
|
|
308
|
+
schema: { type: 'object', additionalProperties: true },
|
|
309
|
+
render: (_args, value) => [{ type: 'text', text: value.message }],
|
|
310
|
+
},
|
|
311
|
+
async execute(args) {
|
|
312
|
+
const removed = await store.remove(String(args.id))
|
|
313
|
+
if (!removed) throw new Error(`找不到记录:${args.id}`)
|
|
314
|
+
return { id: removed.id, message: `已删除:${removed.title}` }
|
|
315
|
+
},
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
const lunar = defineTool({
|
|
319
|
+
name: 'jiri_lunar',
|
|
320
|
+
description: '公历转农历,或求某个农历月/日下一次对应的公历日期。',
|
|
321
|
+
parameters: {
|
|
322
|
+
date: { type: 'string', description: '公历 YYYY-MM-DD,省略则用今天' },
|
|
323
|
+
month: { type: 'number', description: '农历月 1-12' },
|
|
324
|
+
day: { type: 'number', description: '农历日 1-30' },
|
|
325
|
+
leap: { type: 'boolean', description: '是否闰月' },
|
|
326
|
+
},
|
|
327
|
+
output: {
|
|
328
|
+
schema: { type: 'object', additionalProperties: true },
|
|
329
|
+
render: (_args, value) => [{ type: 'text', text: value.message }],
|
|
330
|
+
},
|
|
331
|
+
async execute(args) {
|
|
332
|
+
if (args.month && args.day) {
|
|
333
|
+
const hit = nextLunarOccurrence(
|
|
334
|
+
{ month: Number(args.month), day: Number(args.day), leap: Boolean(args.leap) },
|
|
335
|
+
args.date?.trim() || todayISO(),
|
|
336
|
+
{ timeZone: zone() },
|
|
337
|
+
)
|
|
338
|
+
if (!hit) throw new Error('这个农历日期在未来一年内没有出现(闰月?)')
|
|
339
|
+
return {
|
|
340
|
+
date: hit.date,
|
|
341
|
+
lunar: formatLunar(hit.lunar),
|
|
342
|
+
message: `下一次${formatLunar({ ...hit.lunar, leap: Boolean(args.leap) })}是 ${hit.date}。`,
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const iso = args.date?.trim() || todayISO()
|
|
347
|
+
const converted = solarToLunar(iso, zone())
|
|
348
|
+
return {
|
|
349
|
+
date: iso,
|
|
350
|
+
lunar: formatLunar(converted),
|
|
351
|
+
month: converted.month,
|
|
352
|
+
day: converted.day,
|
|
353
|
+
leap: converted.leap,
|
|
354
|
+
message: `${iso} 是 ${converted.year} 年${formatLunar(converted)}。`,
|
|
355
|
+
}
|
|
356
|
+
},
|
|
357
|
+
})
|
|
358
|
+
|
|
359
|
+
|
|
360
|
+
const brief = defineTool({
|
|
361
|
+
name: 'jiri_brief',
|
|
362
|
+
description: '生成记日简报:今天到未来 N 天的纪念日与待办。定时任务用它做主动提醒。',
|
|
363
|
+
parameters: {
|
|
364
|
+
days: { type: 'number', description: '前瞻天数,默认 3' },
|
|
365
|
+
},
|
|
366
|
+
output: {
|
|
367
|
+
schema: { type: 'object', additionalProperties: true },
|
|
368
|
+
render: (_args, value) => [{ type: 'text', text: value.message }],
|
|
369
|
+
},
|
|
370
|
+
async execute(args) {
|
|
371
|
+
const days = Number.isFinite(args.days) ? Number(args.days) : 3
|
|
372
|
+
const today = todayISO()
|
|
373
|
+
const end = shiftISO(today, days)
|
|
374
|
+
const items = await store.list()
|
|
375
|
+
const rows = []
|
|
376
|
+
for (const item of items) {
|
|
377
|
+
for (const hit of occurrencesBetween(item, today, end)) {
|
|
378
|
+
if ((item.done ?? []).includes(hit)) continue
|
|
379
|
+
const distance = inDays(hit, today)
|
|
380
|
+
rows.push({
|
|
381
|
+
date: hit,
|
|
382
|
+
inDays: distance,
|
|
383
|
+
title: item.title,
|
|
384
|
+
lunar: lunarNote(item, hit),
|
|
385
|
+
id: item.id,
|
|
386
|
+
// 提前提醒:今天正好是「发生日 - N 天」中的某一个点
|
|
387
|
+
advanceHit: distance > 0 && normalizeAdvance(item.advance).includes(distance),
|
|
388
|
+
advance: normalizeAdvance(item.advance),
|
|
389
|
+
})
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
rows.sort((a, b) => (a.date === b.date ? a.title.localeCompare(b.title) : a.date < b.date ? -1 : 1))
|
|
393
|
+
|
|
394
|
+
const line = (row) => {
|
|
395
|
+
const when = row.inDays === 0 ? '今天' : row.inDays === 1 ? '明天' : `${row.inDays} 天后`
|
|
396
|
+
return `- ${row.date}(${when})${row.title}${row.lunar ? ` · ${row.lunar}` : ''}`
|
|
397
|
+
}
|
|
398
|
+
const todayRows = rows.filter((row) => row.inDays === 0)
|
|
399
|
+
const reminderRows = rows.filter((row) => row.advanceHit)
|
|
400
|
+
const rest = rows.filter((row) => row.inDays > 0 && !row.advanceHit)
|
|
401
|
+
|
|
402
|
+
const sections = [`记日简报 · ${today}`]
|
|
403
|
+
if (reminderRows.length) sections.push('【提前提醒】', ...reminderRows.map((row) => `- ${row.title} 还有 ${row.inDays} 天(${row.date})`))
|
|
404
|
+
if (todayRows.length) sections.push('【今天】', ...todayRows.map(line))
|
|
405
|
+
if (rest.length) sections.push('【接下来】', ...rest.map(line))
|
|
406
|
+
if (!rows.length) sections.push(`到 ${end} 没有安排。`)
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
today,
|
|
410
|
+
end,
|
|
411
|
+
days,
|
|
412
|
+
count: rows.length,
|
|
413
|
+
reminders: reminderRows.length,
|
|
414
|
+
rows,
|
|
415
|
+
message: sections.join('\n'),
|
|
416
|
+
}
|
|
417
|
+
},
|
|
418
|
+
})
|
|
419
|
+
|
|
420
|
+
const exportTool = defineTool({
|
|
421
|
+
name: 'jiri_export',
|
|
422
|
+
description: '导出全部记录,格式支持 json(备份)/ markdown(阅读)/ ics(导入系统日历)。',
|
|
423
|
+
parameters: {
|
|
424
|
+
format: { type: 'string', description: 'json / markdown / ics,默认 json' },
|
|
425
|
+
path: { type: 'string', description: '输出路径;省略时写到 ~/.dsh/exports/' },
|
|
426
|
+
},
|
|
427
|
+
output: {
|
|
428
|
+
schema: { type: 'object', additionalProperties: true },
|
|
429
|
+
render: (_args, value) => [{ type: 'text', text: value.message }],
|
|
430
|
+
},
|
|
431
|
+
async execute(args) {
|
|
432
|
+
const format = ['json', 'markdown', 'ics'].includes(args.format) ? args.format : 'json'
|
|
433
|
+
const today = todayISO()
|
|
434
|
+
const items = await store.list()
|
|
435
|
+
const text = format === 'markdown'
|
|
436
|
+
? toMarkdown(items, { today, timeZone: zone() })
|
|
437
|
+
: format === 'ics'
|
|
438
|
+
? toICS(items, { today, timeZone: zone() })
|
|
439
|
+
: toJSON(items, { today })
|
|
440
|
+
const extension = format === 'ics' ? 'ics' : format === 'markdown' ? 'md' : 'json'
|
|
441
|
+
const home = process.env.DSH_HOME || join(homedir(), '.dsh')
|
|
442
|
+
const target = args.path?.trim() || join(home, 'exports', `jiri-${today}.${extension}`)
|
|
443
|
+
await mkdir(dirname(target), { recursive: true })
|
|
444
|
+
await writeFile(target, text, 'utf8')
|
|
445
|
+
return { path: target, format, bytes: Buffer.byteLength(text), message: `已导出 ${items.length} 条到 ${target}` }
|
|
446
|
+
},
|
|
447
|
+
})
|
|
448
|
+
|
|
449
|
+
const reminders = defineTool({
|
|
450
|
+
name: 'jiri_reminders',
|
|
451
|
+
description: '只返回今天需要提醒的内容:提前提醒命中 + 今天到期。定时任务用这个工具;没有内容时 hasReminders 为 false。',
|
|
452
|
+
parameters: {
|
|
453
|
+
time: { type: 'string', description: '本次投递对应的时刻 HH:mm,按记录归属的时间过滤' },
|
|
454
|
+
},
|
|
455
|
+
output: {
|
|
456
|
+
schema: { type: 'object', additionalProperties: true },
|
|
457
|
+
render: (_args, value) => [{ type: 'text', text: value.message }],
|
|
458
|
+
},
|
|
459
|
+
async execute(args) {
|
|
460
|
+
const today = todayISO()
|
|
461
|
+
const wanted = normalizeTime(args.time)
|
|
462
|
+
const items = await store.list()
|
|
463
|
+
const ahead = []
|
|
464
|
+
const due = []
|
|
465
|
+
|
|
466
|
+
for (const item of items) {
|
|
467
|
+
if (wanted && effectiveTime(item, reminderTime()) !== wanted) continue
|
|
468
|
+
const points = normalizeAdvance(item.advance)
|
|
469
|
+
const done = item.done ?? []
|
|
470
|
+
|
|
471
|
+
for (const hit of occurrencesBetween(item, today, today)) {
|
|
472
|
+
if (done.includes(hit)) continue
|
|
473
|
+
due.push({ id: item.id, title: item.title, date: hit, lunar: lunarNote(item, hit) })
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (!points.length) continue
|
|
477
|
+
for (const hit of occurrencesBetween(item, today, shiftISO(today, 30))) {
|
|
478
|
+
if (done.includes(hit)) continue
|
|
479
|
+
const distance = inDays(hit, today)
|
|
480
|
+
if (distance > 0 && points.includes(distance)) {
|
|
481
|
+
ahead.push({ id: item.id, title: item.title, date: hit, inDays: distance, lunar: lunarNote(item, hit) })
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
const byDate = (left, right) => (left.date === right.date ? left.title.localeCompare(right.title) : left.date < right.date ? -1 : 1)
|
|
487
|
+
ahead.sort(byDate)
|
|
488
|
+
due.sort(byDate)
|
|
489
|
+
|
|
490
|
+
const lines = []
|
|
491
|
+
if (ahead.length) {
|
|
492
|
+
lines.push('【提前提醒】', ...ahead.map((row) => `- ${row.title} 还有 ${row.inDays} 天(${row.date})${row.lunar ? ` · ${row.lunar}` : ''}`))
|
|
493
|
+
}
|
|
494
|
+
if (due.length) {
|
|
495
|
+
lines.push('【今天】', ...due.map((row) => `- ${row.title}${row.lunar ? ` · ${row.lunar}` : ''}`))
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
const hasReminders = ahead.length + due.length > 0
|
|
499
|
+
return {
|
|
500
|
+
today,
|
|
501
|
+
hasReminders,
|
|
502
|
+
ahead,
|
|
503
|
+
due,
|
|
504
|
+
message: hasReminders
|
|
505
|
+
? `记日提醒 · ${today}\n${lines.join('\n')}`
|
|
506
|
+
: `记日提醒 · ${today}:没有需要提醒的事项。`,
|
|
507
|
+
}
|
|
508
|
+
},
|
|
509
|
+
})
|
|
510
|
+
|
|
511
|
+
return [add, list, done, update, remove, lunar, brief, reminders, exportTool]
|
|
512
|
+
}
|
package/src/view.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 页面数据投影:
|
|
3
|
+
* · buildRows —— 列表视图,每条记录只取「下一次」
|
|
4
|
+
* · buildMonth —— 月历视图,把整月每一天命中的记录展开
|
|
5
|
+
* 两个视图共用同一个 rowOf,保证编辑表单拿到的字段一致。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { nextOccurrence, matchesItem, isoWeekOf } from './repeat.js'
|
|
9
|
+
import { almanacOf } from './almanac.js'
|
|
10
|
+
import { normalizeAdvance } from './service.js'
|
|
11
|
+
import { mercuryRetrogradeRanges, isRetrograde } from './retrograde.js'
|
|
12
|
+
import { solarToLunar, formatLunar, toDate } from './lunar.js'
|
|
13
|
+
|
|
14
|
+
const pad = (value) => String(value).padStart(2, '0')
|
|
15
|
+
|
|
16
|
+
function daysBetween(fromISO, toISO) {
|
|
17
|
+
return Math.round((toDate(toISO).getTime() - toDate(fromISO).getTime()) / 86_400_000)
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function lunarLabel(item, iso) {
|
|
21
|
+
if (item.calendar !== 'lunar') return ''
|
|
22
|
+
try {
|
|
23
|
+
return formatLunar(solarToLunar(iso))
|
|
24
|
+
} catch {
|
|
25
|
+
return ''
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function rowOf(item, iso, todayISO) {
|
|
30
|
+
return {
|
|
31
|
+
id: item.id,
|
|
32
|
+
title: item.title,
|
|
33
|
+
kind: item.kind ?? 'todo',
|
|
34
|
+
calendar: item.calendar ?? 'gregorian',
|
|
35
|
+
repeat: item.repeat ?? 'none',
|
|
36
|
+
every: item.every ?? null,
|
|
37
|
+
weekdayMask: item.weekdayMask ?? 0,
|
|
38
|
+
date: iso,
|
|
39
|
+
anchor: item.date,
|
|
40
|
+
weekNo: isoWeekOf(iso),
|
|
41
|
+
inDays: daysBetween(todayISO, iso),
|
|
42
|
+
lunar: lunarLabel(item, iso),
|
|
43
|
+
lunarMonth: item.lunar?.month ?? null,
|
|
44
|
+
lunarDay: item.lunar?.day ?? null,
|
|
45
|
+
lunarLeap: Boolean(item.lunar?.leap),
|
|
46
|
+
advance: normalizeAdvance(item.advance),
|
|
47
|
+
remindAt: item.remindAt ?? null,
|
|
48
|
+
tags: item.tags ?? [],
|
|
49
|
+
note: item.note ?? '',
|
|
50
|
+
done: item.done ?? [],
|
|
51
|
+
doneNow: (item.done ?? []).includes(iso),
|
|
52
|
+
overdue: item.repeat === 'none' && iso < todayISO && !(item.done ?? []).includes(iso),
|
|
53
|
+
today: iso === todayISO,
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sortRows(left, right) {
|
|
58
|
+
if (left.date !== right.date) return left.date < right.date ? -1 : 1
|
|
59
|
+
return left.title.localeCompare(right.title)
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** 列表视图:每条记录只出现一次,取下一次(或原始日期) */
|
|
63
|
+
export function buildRows(items, todayISO) {
|
|
64
|
+
const rows = items.map((item) => rowOf(item, nextOccurrence(item, todayISO) ?? item.date, todayISO))
|
|
65
|
+
return rows.sort(sortRows)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* 月历视图:某个月每一天命中的记录。
|
|
70
|
+
* @returns {{ year, month, firstWeekday, days: { date, day, items }[], today }}
|
|
71
|
+
* firstWeekday 0=周日,前端按它留空格。
|
|
72
|
+
*/
|
|
73
|
+
export function buildMonth(items, year, month, todayISO) {
|
|
74
|
+
const total = new Date(Date.UTC(year, month, 0)).getUTCDate()
|
|
75
|
+
// 多算前后 40 天,月历格子的“逆”标记和卡片里的“下次水逆”都要用
|
|
76
|
+
const monthStart = `${year}-${pad(month)}-01`
|
|
77
|
+
const monthEnd = `${year}-${pad(month)}-${pad(total)}`
|
|
78
|
+
const windowStart = new Date(Date.parse(`${monthStart}T00:00:00Z`) - 40 * 86_400_000).toISOString().slice(0, 10)
|
|
79
|
+
const windowEnd = new Date(Date.parse(`${monthEnd}T00:00:00Z`) + 40 * 86_400_000).toISOString().slice(0, 10)
|
|
80
|
+
const retrograde = mercuryRetrogradeRanges(windowStart, windowEnd)
|
|
81
|
+
const firstWeekday = new Date(Date.UTC(year, month - 1, 1)).getUTCDay()
|
|
82
|
+
const days = []
|
|
83
|
+
for (let day = 1; day <= total; day += 1) {
|
|
84
|
+
const iso = `${year}-${pad(month)}-${pad(day)}`
|
|
85
|
+
const hits = items
|
|
86
|
+
.filter((item) => matchesItem(item, iso))
|
|
87
|
+
.map((item) => rowOf(item, iso, todayISO))
|
|
88
|
+
days.push({
|
|
89
|
+
date: iso,
|
|
90
|
+
day,
|
|
91
|
+
items: hits,
|
|
92
|
+
almanac: almanacOf(iso),
|
|
93
|
+
weekNo: isoWeekOf(iso),
|
|
94
|
+
mercuryRetrograde: isRetrograde(iso, retrograde),
|
|
95
|
+
})
|
|
96
|
+
}
|
|
97
|
+
const firstWeekdayMonday = (firstWeekday + 6) % 7
|
|
98
|
+
return {
|
|
99
|
+
year,
|
|
100
|
+
month,
|
|
101
|
+
firstWeekday,
|
|
102
|
+
firstWeekdayMonday,
|
|
103
|
+
days,
|
|
104
|
+
retrograde: retrograde.filter((range) => range.end >= monthStart && range.start <= monthEnd),
|
|
105
|
+
today: todayISO,
|
|
106
|
+
}
|
|
107
|
+
}
|