@sunr/dsh-reminder 0.0.0-stage → 0.14.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/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 +53 -4
- 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/export.js
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 导出:JSON(备份)、Markdown(给人看)、ICS(喂给系统日历)。
|
|
3
|
+
* 导入只接受本插件导出的 JSON 结构,逐条校验后合并。
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { validateItem, nextOccurrence } from './repeat.js'
|
|
7
|
+
import { solarToLunar, formatLunar } from './lunar.js'
|
|
8
|
+
|
|
9
|
+
const pad = (value) => String(value).padStart(2, '0')
|
|
10
|
+
|
|
11
|
+
function compactDate(iso) {
|
|
12
|
+
return iso.replaceAll('-', '')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function icsEscape(text) {
|
|
16
|
+
return String(text).replaceAll('\\', '\\\\').replaceAll(';', '\\;').replaceAll(',', '\\,').replaceAll('\n', '\\n')
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** RRULE 只覆盖本插件支持的规则;单次事件不写 RRULE */
|
|
20
|
+
function rruleOf(item) {
|
|
21
|
+
switch (item.repeat) {
|
|
22
|
+
case 'daily': return 'FREQ=DAILY'
|
|
23
|
+
case 'weekly': return 'FREQ=WEEKLY'
|
|
24
|
+
case 'monthly': return 'FREQ=MONTHLY'
|
|
25
|
+
case 'yearly': return 'FREQ=YEARLY'
|
|
26
|
+
case 'biweeklyOdd':
|
|
27
|
+
case 'biweeklyEven': return 'FREQ=WEEKLY;INTERVAL=2'
|
|
28
|
+
case 'customDays': return `FREQ=DAILY;INTERVAL=${Math.max(2, Number(item.every) || 2)}`
|
|
29
|
+
default: return null
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function toJSON(items, { today } = {}) {
|
|
34
|
+
return JSON.stringify({ version: 1, exportedAt: new Date().toISOString(), today, items }, null, 2)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function toMarkdown(items, { today, timeZone = 'Asia/Shanghai' } = {}) {
|
|
38
|
+
const lines = ['# 记日 · 导出', '', `导出日期:${today}`, '', '| 日期 | 事项 | 农历 | 类型 | 重复 | 标签 |', '| --- | --- | --- | --- | --- | --- |']
|
|
39
|
+
const rows = items
|
|
40
|
+
.map((item) => ({ item, next: nextOccurrence(item, today) ?? item.date }))
|
|
41
|
+
.sort((left, right) => (left.next === right.next ? 0 : left.next < right.next ? -1 : 1))
|
|
42
|
+
|
|
43
|
+
for (const { item, next } of rows) {
|
|
44
|
+
let lunar = ''
|
|
45
|
+
if (item.calendar === 'lunar') {
|
|
46
|
+
try { lunar = formatLunar(solarToLunar(next, timeZone)) } catch { lunar = '' }
|
|
47
|
+
}
|
|
48
|
+
lines.push(`| ${next} | ${item.title} | ${lunar || '—'} | ${item.kind ?? 'todo'} | ${item.repeat ?? 'none'} | ${(item.tags ?? []).join('、') || '—'} |`)
|
|
49
|
+
}
|
|
50
|
+
return `${lines.join('\n')}\n`
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function toICS(items, { today, timeZone = 'Asia/Shanghai' } = {}) {
|
|
54
|
+
const stamp = new Date().toISOString().replaceAll('-', '').replaceAll(':', '').slice(0, 15) + 'Z'
|
|
55
|
+
const lines = [
|
|
56
|
+
'BEGIN:VCALENDAR',
|
|
57
|
+
'VERSION:2.0',
|
|
58
|
+
'PRODID:-//dsh-reminder//记日//CN',
|
|
59
|
+
'CALSCALE:GREGORIAN',
|
|
60
|
+
]
|
|
61
|
+
|
|
62
|
+
for (const item of items) {
|
|
63
|
+
const date = nextOccurrence(item, today) ?? item.date
|
|
64
|
+
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) continue
|
|
65
|
+
const next = new Date(Date.parse(date + 'T00:00:00Z') + 86_400_000).toISOString().slice(0, 10)
|
|
66
|
+
const rule = rruleOf(item)
|
|
67
|
+
lines.push('BEGIN:VEVENT')
|
|
68
|
+
lines.push(`UID:${item.id}@dsh-reminder`)
|
|
69
|
+
lines.push(`DTSTAMP:${stamp}`)
|
|
70
|
+
lines.push(`DTSTART;VALUE=DATE:${compactDate(date)}`)
|
|
71
|
+
lines.push(`DTEND;VALUE=DATE:${compactDate(next)}`)
|
|
72
|
+
lines.push(`SUMMARY:${icsEscape(item.title)}`)
|
|
73
|
+
if (item.note) lines.push(`DESCRIPTION:${icsEscape(item.note)}`)
|
|
74
|
+
if (rule) lines.push(`RRULE:${rule}`)
|
|
75
|
+
lines.push('END:VEVENT')
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
lines.push('END:VCALENDAR')
|
|
79
|
+
return `${lines.join('\r\n')}\r\n`
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** 导入:接受 { items } 或裸数组;返回 { added, skipped, errors } */
|
|
83
|
+
export function parseImport(text) {
|
|
84
|
+
let parsed
|
|
85
|
+
try {
|
|
86
|
+
parsed = JSON.parse(text)
|
|
87
|
+
} catch {
|
|
88
|
+
throw new Error('不是合法 JSON')
|
|
89
|
+
}
|
|
90
|
+
const items = Array.isArray(parsed) ? parsed : parsed?.items
|
|
91
|
+
if (!Array.isArray(items)) throw new Error('找不到 items 数组')
|
|
92
|
+
|
|
93
|
+
const accepted = []
|
|
94
|
+
const errors = []
|
|
95
|
+
items.forEach((raw, index) => {
|
|
96
|
+
const item = {
|
|
97
|
+
...raw,
|
|
98
|
+
id: typeof raw?.id === 'string' ? raw.id : undefined,
|
|
99
|
+
tags: Array.isArray(raw?.tags) ? raw.tags : [],
|
|
100
|
+
done: Array.isArray(raw?.done) ? raw.done : [],
|
|
101
|
+
skipped: Array.isArray(raw?.skipped) ? raw.skipped : [],
|
|
102
|
+
}
|
|
103
|
+
const problems = validateItem(item)
|
|
104
|
+
if (problems.length) errors.push(`第 ${index + 1} 条:${problems.join(';')}`)
|
|
105
|
+
else accepted.push(item)
|
|
106
|
+
})
|
|
107
|
+
return { items: accepted, errors }
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export const EXPORT_FORMATS = { json: 'application/json', markdown: 'text/markdown', ics: 'text/calendar' }
|
|
111
|
+
export { pad }
|
package/src/index.js
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 插件入口。
|
|
3
|
+
*
|
|
4
|
+
* Node 面:
|
|
5
|
+
* · 8 个工具(含 jiri_brief 简报与 jiri_export 导出)
|
|
6
|
+
* · 页面读写路由(list / month / add / update / delete / done / export / import / remind)
|
|
7
|
+
* · 每日提醒(借助宿主的 schedule 服务,投递到新会话)
|
|
8
|
+
* 浏览器面:src/client.js
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { defineTool } from '@deepseek-ai/dsh-tools'
|
|
12
|
+
import { mkdir, readFile, unlink, writeFile } from 'node:fs/promises'
|
|
13
|
+
import { existsSync, readFileSync, renameSync } from 'node:fs'
|
|
14
|
+
import { homedir } from 'node:os'
|
|
15
|
+
import { dirname, join } from 'node:path'
|
|
16
|
+
|
|
17
|
+
import { buildTools } from './tools.js'
|
|
18
|
+
import { createStore, defaultStorePath, newId } from './store.js'
|
|
19
|
+
import { buildRows, buildMonth } from './view.js'
|
|
20
|
+
import { addItem, updateItem, removeItem, markDone, undoDone } from './service.js'
|
|
21
|
+
import { toJSON, toMarkdown, toICS, parseImport, EXPORT_FORMATS } from './export.js'
|
|
22
|
+
import { LUNAR_DEFAULT_TIME_ZONE, toISODate } from './lunar.js'
|
|
23
|
+
import { normalizeTime, collectTimes } from './remind.js'
|
|
24
|
+
|
|
25
|
+
export const name = 'dsh-reminder'
|
|
26
|
+
export const inject = ['tools']
|
|
27
|
+
export const ITEMS_ROUTE = '/api/dsh-reminder/items'
|
|
28
|
+
|
|
29
|
+
const REMINDER_TITLE = '记日 · 每日简报'
|
|
30
|
+
const reminderPrompt = (time) => `调用 jiri_reminders,time 传 "${time}"。若 hasReminders 为 false,只回复「今天没有提醒」,不要展开;否则按返回的清单原样汇报,不要额外补充建议。`
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
export function apply(ctx, config = {}) {
|
|
34
|
+
const file = config.file ?? defaultStorePath()
|
|
35
|
+
|
|
36
|
+
// 包名从 dsh-jiri 改成 @sunr/dsh-reminder:数据目录跟着改,首次加载自动迁移
|
|
37
|
+
const currentDir = dirname(file)
|
|
38
|
+
const legacyDir = join(process.env.DSH_HOME || join(homedir(), '.dsh'), 'storages', 'dsh-jiri')
|
|
39
|
+
if (!existsSync(currentDir) && existsSync(legacyDir)) {
|
|
40
|
+
try { renameSync(legacyDir, currentDir) } catch { /* 迁移失败就沿用空目录 */ }
|
|
41
|
+
}
|
|
42
|
+
const reminderFile = join(dirname(file), 'reminder.json')
|
|
43
|
+
const settingsFile = join(dirname(file), 'settings.json')
|
|
44
|
+
const store = createStore(file)
|
|
45
|
+
|
|
46
|
+
const settings = { timeZone: config.timeZone ?? LUNAR_DEFAULT_TIME_ZONE, reminderTime: '09:00' }
|
|
47
|
+
try {
|
|
48
|
+
const saved = JSON.parse(readFileSync(settingsFile, 'utf8'))
|
|
49
|
+
if (typeof saved?.timeZone === 'string') settings.timeZone = saved.timeZone
|
|
50
|
+
if (/^([01]\d|2[0-3]):[0-5]\d$/.test(saved?.reminderTime ?? '')) settings.reminderTime = saved.reminderTime
|
|
51
|
+
} catch { /* 没有设置文件就用默认 */ }
|
|
52
|
+
const timeZone = () => settings.timeZone
|
|
53
|
+
|
|
54
|
+
for (const tool of buildTools({ defineTool, store, timeZone, reminderTime: () => settings.reminderTime })) ctx.tools.register(tool)
|
|
55
|
+
|
|
56
|
+
const today = () => toISODate(new Date(), timeZone())
|
|
57
|
+
|
|
58
|
+
async function readReminder() {
|
|
59
|
+
try { return JSON.parse(await readFile(reminderFile, 'utf8')) } catch { return null }
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function writeReminder(value) {
|
|
63
|
+
await mkdir(dirname(reminderFile), { recursive: true })
|
|
64
|
+
await writeFile(reminderFile, `${JSON.stringify(value, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
async function clearReminder() {
|
|
68
|
+
try { await unlink(reminderFile) } catch { /* 没有就算了 */ }
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// 宿主可能没装 schedule(headless 等),拿不到就降级
|
|
72
|
+
let schedule = null
|
|
73
|
+
ctx.inject(['schedule'], (scope) => { schedule = scope.get('schedule') })
|
|
74
|
+
|
|
75
|
+
/** 某条任务用的 RRULE:每天在该时刻触发 */
|
|
76
|
+
function ruleFor(time) {
|
|
77
|
+
const [hour, minute] = time.split(':')
|
|
78
|
+
return `DTSTART;TZID=${settings.timeZone}:${today().replaceAll('-', '')}T${hour}${minute}00\nRRULE:FREQ=DAILY`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
async function createSchedule(time) {
|
|
82
|
+
const created = await schedule.create({
|
|
83
|
+
title: REMINDER_TITLE,
|
|
84
|
+
prompt: reminderPrompt(time),
|
|
85
|
+
timing: { kind: 'rrule', rrule: ruleFor(time) },
|
|
86
|
+
target: { kind: 'new_once' },
|
|
87
|
+
})
|
|
88
|
+
const id = created?.id ?? created?.task?.id ?? created?.value?.id ?? await findReminderId(time)
|
|
89
|
+
if (!id) throw new Error(`任务创建成功但拿不到 id(${time})`)
|
|
90
|
+
return id
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function findReminderId(time) {
|
|
94
|
+
try {
|
|
95
|
+
const listed = await schedule.list({ active: true })
|
|
96
|
+
const rows = Array.isArray(listed) ? listed : (listed?.items ?? listed?.tasks ?? [])
|
|
97
|
+
const hit = rows.find((row) => row?.title === REMINDER_TITLE && (time === undefined || String(row?.prompt ?? '').includes(`"${time}"`)))
|
|
98
|
+
return hit?.id ?? null
|
|
99
|
+
} catch { return null }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** 旧版只存 { id },迁移成 { times: { 'HH:mm': id } } */
|
|
103
|
+
function reminderRecord(raw) {
|
|
104
|
+
if (!raw?.id && !raw?.times) return null
|
|
105
|
+
if (raw.times) return raw
|
|
106
|
+
return { times: { [normalizeTime(raw.time) ?? settings.reminderTime]: raw.id }, createdAt: raw.createdAt }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* 让「已启用的时间点」与实际任务对齐:缺的建、多的删。
|
|
111
|
+
* 记录增删改、全局时间变更后都要调一次。
|
|
112
|
+
*/
|
|
113
|
+
async function syncSchedules() {
|
|
114
|
+
const record = reminderRecord(await readReminder())
|
|
115
|
+
if (!record || !schedule) return record
|
|
116
|
+
|
|
117
|
+
const items = await store.list()
|
|
118
|
+
const desired = collectTimes(items, settings.reminderTime)
|
|
119
|
+
const times = { ...record.times }
|
|
120
|
+
|
|
121
|
+
for (const time of Object.keys(times)) {
|
|
122
|
+
if (desired.includes(time)) continue
|
|
123
|
+
try { await schedule.edit({ id: times[time], action: 'delete' }) } catch { /* 可能已被手工删除 */ }
|
|
124
|
+
delete times[time]
|
|
125
|
+
}
|
|
126
|
+
for (const time of desired) {
|
|
127
|
+
if (times[time]) continue
|
|
128
|
+
times[time] = await createSchedule(time)
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const next = { times, createdAt: record.createdAt ?? new Date().toISOString(), timeZone: settings.timeZone }
|
|
132
|
+
await writeReminder(next)
|
|
133
|
+
return next
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
ctx.inject(['connection'], (scope) => {
|
|
137
|
+
const register = scope.get('connection')?.fetch?.register
|
|
138
|
+
if (typeof register !== 'function') return
|
|
139
|
+
|
|
140
|
+
const json = (value, status = 200) => Response.json(value, {
|
|
141
|
+
status,
|
|
142
|
+
headers: { 'cache-control': 'no-store' },
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
scope.effect(() => register({
|
|
146
|
+
path: ITEMS_ROUTE,
|
|
147
|
+
methods: ['POST'],
|
|
148
|
+
requestBody: 'buffered',
|
|
149
|
+
fetch: async (request) => {
|
|
150
|
+
let body = {}
|
|
151
|
+
try {
|
|
152
|
+
body = await request.json()
|
|
153
|
+
} catch {
|
|
154
|
+
return json({ ok: false, error: { code: 'bad-request', message: 'body 不是 JSON' } }, 400)
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const action = typeof body?.action === 'string' ? body.action : 'list'
|
|
158
|
+
// 必须声明在 try 外:最终 return 在 catch 之后
|
|
159
|
+
let extra = {}
|
|
160
|
+
try {
|
|
161
|
+
if (action === 'month') {
|
|
162
|
+
const year = Number(body.year)
|
|
163
|
+
const month = Number(body.month)
|
|
164
|
+
if (!(year >= 1900 && year <= 2200) || !(month >= 1 && month <= 12)) {
|
|
165
|
+
return json({ ok: false, error: { code: 'bad-month', message: 'year/month 不合法' } }, 400)
|
|
166
|
+
}
|
|
167
|
+
const items = await store.list()
|
|
168
|
+
return json({ ok: true, today: today(), month: buildMonth(items, year, month, today()) })
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
switch (action) {
|
|
172
|
+
case 'list':
|
|
173
|
+
break
|
|
174
|
+
case 'add':
|
|
175
|
+
await addItem(store, body.item ?? {}, { timeZone: timeZone(), today: today() })
|
|
176
|
+
break
|
|
177
|
+
case 'update':
|
|
178
|
+
await updateItem(store, String(body.id ?? ''), body.fields ?? {})
|
|
179
|
+
break
|
|
180
|
+
case 'delete':
|
|
181
|
+
extra = { removed: await removeItem(store, String(body.id ?? '')) }
|
|
182
|
+
break
|
|
183
|
+
case 'undone':
|
|
184
|
+
await undoDone(store, String(body.id ?? ''), String(body.occurrence ?? ''))
|
|
185
|
+
break
|
|
186
|
+
case 'restore': {
|
|
187
|
+
const item = body.item
|
|
188
|
+
if (!item || typeof item.id !== 'string') {
|
|
189
|
+
return json({ ok: false, error: { code: 'bad-item', message: '缺少要恢复的记录' } }, 400)
|
|
190
|
+
}
|
|
191
|
+
if (!(await store.get(item.id))) await store.insert(item)
|
|
192
|
+
break
|
|
193
|
+
}
|
|
194
|
+
case 'done':
|
|
195
|
+
await markDone(store, String(body.id ?? ''), body.occurrence, today())
|
|
196
|
+
break
|
|
197
|
+
case 'export': {
|
|
198
|
+
const format = ['json', 'markdown', 'ics'].includes(body.format) ? body.format : 'json'
|
|
199
|
+
const items = await store.list()
|
|
200
|
+
const text = format === 'markdown'
|
|
201
|
+
? toMarkdown(items, { today: today(), timeZone: timeZone() })
|
|
202
|
+
: format === 'ics'
|
|
203
|
+
? toICS(items, { today: today(), timeZone: timeZone() })
|
|
204
|
+
: toJSON(items, { today: today() })
|
|
205
|
+
const extension = format === 'ics' ? 'ics' : format === 'markdown' ? 'md' : 'json'
|
|
206
|
+
return json({
|
|
207
|
+
ok: true,
|
|
208
|
+
filename: `jiri-${today()}.${extension}`,
|
|
209
|
+
mime: EXPORT_FORMATS[format] ?? 'text/plain',
|
|
210
|
+
data: text,
|
|
211
|
+
})
|
|
212
|
+
}
|
|
213
|
+
case 'import': {
|
|
214
|
+
const parsed = parseImport(String(body.data ?? ''))
|
|
215
|
+
const existing = new Set((await store.list()).map((item) => item.id))
|
|
216
|
+
let added = 0
|
|
217
|
+
let skipped = 0
|
|
218
|
+
for (const item of parsed.items) {
|
|
219
|
+
if (item.id && existing.has(item.id)) { skipped += 1; continue }
|
|
220
|
+
await store.insert({
|
|
221
|
+
...item,
|
|
222
|
+
id: item.id ?? newId(),
|
|
223
|
+
createdAt: item.createdAt ?? new Date().toISOString(),
|
|
224
|
+
updatedAt: new Date().toISOString(),
|
|
225
|
+
})
|
|
226
|
+
added += 1
|
|
227
|
+
}
|
|
228
|
+
await syncSchedules()
|
|
229
|
+
return json({ ok: true, added, skipped, errors: parsed.errors, rows: buildRows(await store.list(), today()) })
|
|
230
|
+
}
|
|
231
|
+
case 'settings.get':
|
|
232
|
+
return json({ ok: true, ...settings, today: today() })
|
|
233
|
+
case 'settings.set': {
|
|
234
|
+
const nextZone = body.timeZone === undefined ? settings.timeZone : String(body.timeZone)
|
|
235
|
+
const nextTime = body.reminderTime === undefined ? settings.reminderTime : String(body.reminderTime)
|
|
236
|
+
try {
|
|
237
|
+
new Intl.DateTimeFormat('en-US', { timeZone: nextZone })
|
|
238
|
+
} catch {
|
|
239
|
+
return json({ ok: false, error: { code: 'bad-zone', message: `无效时区:${nextZone}` } }, 400)
|
|
240
|
+
}
|
|
241
|
+
if (!/^([01]\d|2[0-3]):[0-5]\d$/.test(nextTime)) {
|
|
242
|
+
return json({ ok: false, error: { code: 'bad-time', message: `时间格式应为 HH:mm:${nextTime}` } }, 400)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const changedTime = nextTime !== settings.reminderTime || nextZone !== settings.timeZone
|
|
246
|
+
settings.timeZone = nextZone
|
|
247
|
+
settings.reminderTime = nextTime
|
|
248
|
+
await mkdir(dirname(settingsFile), { recursive: true })
|
|
249
|
+
await writeFile(settingsFile, `${JSON.stringify({ ...settings }, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
|
|
250
|
+
|
|
251
|
+
// 时间或时区变了:让任务跟着对齐(会重建 RRULE)
|
|
252
|
+
let reminder = await readReminder()
|
|
253
|
+
if (changedTime) reminder = await syncSchedules()
|
|
254
|
+
|
|
255
|
+
return json({ ok: true, ...settings, today: today(), reminder })
|
|
256
|
+
}
|
|
257
|
+
case 'remind.status': {
|
|
258
|
+
const reminder = reminderRecord(await readReminder())
|
|
259
|
+
return json({ ok: true, reminder, supported: Boolean(schedule) })
|
|
260
|
+
}
|
|
261
|
+
case 'remind.on': {
|
|
262
|
+
if (!schedule) {
|
|
263
|
+
return json({ ok: false, error: { code: 'no-schedule', message: '当前 profile 没有 schedule 服务' } }, 400)
|
|
264
|
+
}
|
|
265
|
+
const existing = reminderRecord(await readReminder())
|
|
266
|
+
if (existing) return json({ ok: true, reminder: existing })
|
|
267
|
+
|
|
268
|
+
await writeReminder({ times: {}, createdAt: new Date().toISOString() })
|
|
269
|
+
const record = await syncSchedules()
|
|
270
|
+
return json({ ok: true, reminder: record })
|
|
271
|
+
}
|
|
272
|
+
case 'remind.off': {
|
|
273
|
+
const existing = reminderRecord(await readReminder())
|
|
274
|
+
if (existing && schedule) {
|
|
275
|
+
for (const id of Object.values(existing.times)) {
|
|
276
|
+
try { await schedule.edit({ id, action: 'delete' }) } catch { /* 可能已被手工删除 */ }
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
await clearReminder()
|
|
280
|
+
return json({ ok: true, reminder: null })
|
|
281
|
+
}
|
|
282
|
+
default:
|
|
283
|
+
return json({ ok: false, error: { code: 'bad-action', message: `未知动作:${action}` } }, 400)
|
|
284
|
+
}
|
|
285
|
+
} catch (error) {
|
|
286
|
+
return json({ ok: false, error: { code: 'dsh-reminder', message: String(error?.message ?? error) } }, 400)
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (['add', 'update', 'delete', 'done', 'undone', 'restore'].includes(action)) {
|
|
290
|
+
await syncSchedules()
|
|
291
|
+
}
|
|
292
|
+
return json({ ok: true, today: today(), rows: buildRows(await store.list(), today()), ...extra })
|
|
293
|
+
},
|
|
294
|
+
}, 'dsh-reminder: items'), 'dsh-reminder: items route')
|
|
295
|
+
})
|
|
296
|
+
}
|
package/src/lunar.js
ADDED
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 农历换算:基于 Node 内置 Intl 的中国历,零依赖。
|
|
3
|
+
*
|
|
4
|
+
* 只做记日真正需要的事:
|
|
5
|
+
* 1. 公历 → 农历(月 / 日 / 是否闰月)
|
|
6
|
+
* 2. 农历月日 → 未来最近一次对应的公历日期(Intl 没有反向 API,只能逐日扫描)
|
|
7
|
+
* 3. 人类可读的「农历九月廿九」格式化
|
|
8
|
+
*
|
|
9
|
+
* 三个已知边界,与 iOS 端 Shared/Core/LunarCalendar.swift 的结论一致:
|
|
10
|
+
* · Intl 只给「公历 → 农历」,反查必须扫描;
|
|
11
|
+
* · 闰月以「闰」前缀出现(en 下是 "bis"),必须单独建模;
|
|
12
|
+
* · 农历腊月可能只有廿九,没有三十 —— 扫描不到时由调用方决定降级策略。
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const DEFAULT_TIME_ZONE = 'Asia/Shanghai'
|
|
16
|
+
|
|
17
|
+
/** 一月最多 30 天,覆盖一个农历年的最长扫描范围 */
|
|
18
|
+
const MAX_SCAN_DAYS = 400
|
|
19
|
+
|
|
20
|
+
const CN_DIGITS = { 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 七: 7, 八: 8, 九: 9, 十: 10 }
|
|
21
|
+
|
|
22
|
+
const MONTH_NAMES = new Map([
|
|
23
|
+
['正月', 1], ['一月', 1],
|
|
24
|
+
['二月', 2], ['三月', 3], ['四月', 4], ['五月', 5], ['六月', 6],
|
|
25
|
+
['七月', 7], ['八月', 8], ['九月', 9], ['十月', 10],
|
|
26
|
+
['冬月', 11], ['十一月', 11],
|
|
27
|
+
['腊月', 12], ['十二月', 12],
|
|
28
|
+
])
|
|
29
|
+
|
|
30
|
+
const DAY_NAMES = ['初一', '初二', '初三', '初四', '初五', '初六', '初七', '初八', '初九', '初十',
|
|
31
|
+
'十一', '十二', '十三', '十四', '十五', '十六', '十七', '十八', '十九', '二十',
|
|
32
|
+
'廿一', '廿二', '廿三', '廿四', '廿五', '廿六', '廿七', '廿八', '廿九', '三十']
|
|
33
|
+
|
|
34
|
+
const formatters = new Map()
|
|
35
|
+
|
|
36
|
+
function formatterFor(timeZone) {
|
|
37
|
+
let formatter = formatters.get(timeZone)
|
|
38
|
+
if (!formatter) {
|
|
39
|
+
formatter = new Intl.DateTimeFormat('zh-CN-u-ca-chinese', {
|
|
40
|
+
year: 'numeric',
|
|
41
|
+
month: 'long',
|
|
42
|
+
day: 'numeric',
|
|
43
|
+
timeZone,
|
|
44
|
+
})
|
|
45
|
+
formatters.set(timeZone, formatter)
|
|
46
|
+
}
|
|
47
|
+
return formatter
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** 解析 YYYY-MM-DD;也接受 Date。统一取当天 12:00 UTC,避开时区把日期推前/推后一天。 */
|
|
51
|
+
export function toDate(value) {
|
|
52
|
+
if (value instanceof Date) return value
|
|
53
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(String(value ?? '').trim())
|
|
54
|
+
if (!match) throw new Error(`日期格式应为 YYYY-MM-DD,收到:${value}`)
|
|
55
|
+
const [, y, m, d] = match
|
|
56
|
+
const date = new Date(Date.UTC(Number(y), Number(m) - 1, Number(d), 12))
|
|
57
|
+
if (Number.isNaN(date.getTime())) throw new Error(`无效日期:${value}`)
|
|
58
|
+
return date
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Date → YYYY-MM-DD(按指定时区取「那一天」) */
|
|
62
|
+
export function toISODate(value, timeZone = DEFAULT_TIME_ZONE) {
|
|
63
|
+
const date = value instanceof Date ? value : toDate(value)
|
|
64
|
+
const parts = new Intl.DateTimeFormat('en-CA', {
|
|
65
|
+
year: 'numeric',
|
|
66
|
+
month: '2-digit',
|
|
67
|
+
day: '2-digit',
|
|
68
|
+
timeZone,
|
|
69
|
+
}).formatToParts(date)
|
|
70
|
+
const pick = (type) => parts.find((part) => part.type === type)?.value
|
|
71
|
+
return `${pick('year')}-${pick('month')}-${pick('day')}`
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 今天(指定时区)的 YYYY-MM-DD */
|
|
75
|
+
export function today(timeZone = DEFAULT_TIME_ZONE) {
|
|
76
|
+
return toISODate(new Date(), timeZone)
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function parseDay(raw) {
|
|
80
|
+
if (/^\d+$/.test(raw)) return Number(raw)
|
|
81
|
+
if (raw === '二十') return 20
|
|
82
|
+
if (raw === '三十') return 30
|
|
83
|
+
if (raw.startsWith('廿')) return 20 + (CN_DIGITS[raw[1]] ?? NaN)
|
|
84
|
+
if (raw.startsWith('初')) return CN_DIGITS[raw[1]] ?? NaN
|
|
85
|
+
if (raw === '十') return 10
|
|
86
|
+
if (raw.startsWith('十')) return 10 + (CN_DIGITS[raw[1]] ?? NaN)
|
|
87
|
+
return NaN
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* 公历 → 农历。
|
|
92
|
+
* @returns {{ year: number, month: number, day: number, leap: boolean,
|
|
93
|
+
* monthName: string, ganzhi: string }}
|
|
94
|
+
* year 是农历年所属的公历年(Intl 的 relatedYear),不是 60 年一轮的序号。
|
|
95
|
+
*/
|
|
96
|
+
export function solarToLunar(value, timeZone = DEFAULT_TIME_ZONE) {
|
|
97
|
+
const parts = formatterFor(timeZone).formatToParts(toDate(value))
|
|
98
|
+
const pick = (type) => parts.find((part) => part.type === type)?.value
|
|
99
|
+
|
|
100
|
+
const monthRaw = pick('month') ?? ''
|
|
101
|
+
const leap = monthRaw.startsWith('闰')
|
|
102
|
+
const monthName = leap ? monthRaw.slice(1) : monthRaw
|
|
103
|
+
const month = MONTH_NAMES.get(monthName)
|
|
104
|
+
const day = parseDay(pick('day') ?? '')
|
|
105
|
+
|
|
106
|
+
if (!month || !Number.isFinite(day)) {
|
|
107
|
+
throw new Error(`无法解析农历:${JSON.stringify(parts.map((p) => [p.type, p.value]))}`)
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
year: Number(pick('relatedYear')),
|
|
112
|
+
month,
|
|
113
|
+
day,
|
|
114
|
+
leap,
|
|
115
|
+
monthName,
|
|
116
|
+
ganzhi: pick('yearName') ?? '',
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 1 → 初一,21 → 廿一……30 → 三十 */
|
|
121
|
+
export function dayName(day) {
|
|
122
|
+
return DAY_NAMES[day - 1] ?? String(day)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** { month: 9, day: 29, leap: false } → "农历九月廿九" */
|
|
126
|
+
export function formatLunar(lunar, { withYear = false } = {}) {
|
|
127
|
+
const prefix = lunar.leap ? '闰' : ''
|
|
128
|
+
const month = lunar.monthName ?? `${lunar.month}月`
|
|
129
|
+
const body = `${prefix}${month}${dayName(lunar.day)}`
|
|
130
|
+
return withYear && lunar.year ? `${lunar.year}年${body}` : `农历${body}`
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 从 from 起(含当天)向后扫描,找最近一次指定农历月/日。
|
|
135
|
+
* 找不到(例如要求「腊月三十」而那年只有廿九)返回 null,由调用方降级。
|
|
136
|
+
*/
|
|
137
|
+
export function nextLunarOccurrence(
|
|
138
|
+
{ month, day, leap = false },
|
|
139
|
+
from,
|
|
140
|
+
{ timeZone = DEFAULT_TIME_ZONE, maxDays = MAX_SCAN_DAYS } = {},
|
|
141
|
+
) {
|
|
142
|
+
const start = toDate(from)
|
|
143
|
+
for (let offset = 0; offset <= maxDays; offset += 1) {
|
|
144
|
+
const cursor = new Date(start.getTime() + offset * 86_400_000)
|
|
145
|
+
const lunar = solarToLunar(cursor, timeZone)
|
|
146
|
+
if (lunar.month === month && lunar.day === day && lunar.leap === leap) {
|
|
147
|
+
return { date: toISODate(cursor, timeZone), lunar }
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return null
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** 某个农历月在该农历年里的天数(30 / 29),用于「腊月三十」这类边界降级 */
|
|
154
|
+
export function lunarMonthLength({ year, month, leap = false }, timeZone = DEFAULT_TIME_ZONE) {
|
|
155
|
+
// 春节落在 1/21–2/20 之间:先从 1/1 找到该农历年的正月初一,避免把上一年的腊月算进来
|
|
156
|
+
const scanStart = toDate(`${year}-01-01`)
|
|
157
|
+
let newYear = null
|
|
158
|
+
for (let offset = 0; offset <= 60; offset += 1) {
|
|
159
|
+
const date = new Date(scanStart.getTime() + offset * 86_400_000)
|
|
160
|
+
const lunar = solarToLunar(date, timeZone)
|
|
161
|
+
if (lunar.year === year && lunar.month === 1 && lunar.day === 1 && !lunar.leap) {
|
|
162
|
+
newYear = date
|
|
163
|
+
break
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
if (!newYear) return null
|
|
167
|
+
|
|
168
|
+
let best = 0
|
|
169
|
+
// 农历平年 354 天、闰年 384 天,必须扫满 400 天才能覆盖到腊月
|
|
170
|
+
for (let offset = 0; offset <= MAX_SCAN_DAYS; offset += 1) {
|
|
171
|
+
const date = new Date(newYear.getTime() + offset * 86_400_000)
|
|
172
|
+
const lunar = solarToLunar(date, timeZone)
|
|
173
|
+
if (lunar.year === year && lunar.month === month && lunar.leap === leap) {
|
|
174
|
+
best = Math.max(best, lunar.day)
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return best || null
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export const LUNAR_DEFAULT_TIME_ZONE = DEFAULT_TIME_ZONE
|
package/src/remind.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 提醒时间的纯逻辑:每条记录可以有自己的 HH:mm,缺省跟随全局设置。
|
|
3
|
+
*
|
|
4
|
+
* 投递机制:每个不同的时间点对应一条 schedule 任务,任务里把 time 传给
|
|
5
|
+
* jiri_reminders,工具再按「这条记录实际归属的时间」过滤。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const TIME_PATTERN = /^([01]\d|2[0-3]):[0-5]\d$/
|
|
9
|
+
|
|
10
|
+
/** '9:00' / '09:00' / 空 → 'HH:mm' 或 null */
|
|
11
|
+
export function normalizeTime(value) {
|
|
12
|
+
if (value === undefined || value === null) return null
|
|
13
|
+
const text = String(value).trim()
|
|
14
|
+
if (!text) return null
|
|
15
|
+
const match = /^(\d{1,2}):(\d{2})$/.exec(text)
|
|
16
|
+
if (!match) return null
|
|
17
|
+
const hour = Number(match[1])
|
|
18
|
+
const minute = Number(match[2])
|
|
19
|
+
if (hour > 23 || minute > 59) return null
|
|
20
|
+
return `${String(hour).padStart(2, '0')}:${String(minute).padStart(2, '0')}`
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** 这条记录实际用哪个时间:自己的优先,否则全局 */
|
|
24
|
+
export function effectiveTime(item, globalTime = '09:00') {
|
|
25
|
+
return normalizeTime(item?.remindAt) ?? normalizeTime(globalTime) ?? '09:00'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* 需要哪些时间点的定时任务:只算「设了提前提醒」的记录。
|
|
30
|
+
* @returns {string[]} 升序去重的 HH:mm
|
|
31
|
+
*/
|
|
32
|
+
export function collectTimes(items, globalTime = '09:00') {
|
|
33
|
+
const times = new Set([normalizeTime(globalTime) ?? '09:00'])
|
|
34
|
+
for (const item of items) {
|
|
35
|
+
const points = Array.isArray(item?.advance) ? item.advance : []
|
|
36
|
+
if (!points.length) continue
|
|
37
|
+
times.add(effectiveTime(item, globalTime))
|
|
38
|
+
}
|
|
39
|
+
return [...times].sort()
|
|
40
|
+
}
|