@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/src/repeat.js ADDED
@@ -0,0 +1,171 @@
1
+ /**
2
+ * 重复规则展开。与 iOS 端 Shared/Models/TodoItem.swift 的 RepeatKind 一一对应:
3
+ * none / daily / weekly / monthly / yearly / biweeklyOdd / biweeklyEven / customDays
4
+ *
5
+ * 设计取舍:不预生成日程表,而是提供「某一天是否命中」的纯谓词 matchesItem(),
6
+ * 再按区间逐日扫描。区间通常是「今天起 30 天」,代价可忽略,换来的是
7
+ * 农历年、闰月、大小月、2/29 这些边界全部退化成同一条判断路径。
8
+ */
9
+
10
+ import { solarToLunar, toDate, toISODate } from './lunar.js'
11
+
12
+ export const REPEAT_KINDS = [
13
+ 'none', 'daily', 'weekly', 'monthly', 'yearly',
14
+ 'biweeklyOdd', 'biweeklyEven', 'customDays',
15
+ ]
16
+
17
+ const DAY_MS = 86_400_000
18
+
19
+ /** 周几,0=周日(和 Date#getUTCDay 一致) */
20
+ export function weekdayOf(iso) {
21
+ return toDate(iso).getUTCDay()
22
+ }
23
+
24
+ /** ISO 8601 周序号,用于单/双周 */
25
+ export function isoWeekOf(iso) {
26
+ const date = toDate(iso)
27
+ const d = new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate()))
28
+ const dayNumber = d.getUTCDay() || 7
29
+ d.setUTCDate(d.getUTCDate() + 4 - dayNumber)
30
+ const yearStart = new Date(Date.UTC(d.getUTCFullYear(), 0, 1))
31
+ return Math.ceil(((d.getTime() - yearStart.getTime()) / DAY_MS + 1) / 7)
32
+ }
33
+
34
+ function daysInMonth(year, month) {
35
+ return new Date(Date.UTC(year, month, 0)).getUTCDate()
36
+ }
37
+
38
+ function isLeapYear(year) {
39
+ return (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0
40
+ }
41
+
42
+ function shift(iso, days) {
43
+ return toISODate(new Date(toDate(iso).getTime() + days * DAY_MS))
44
+ }
45
+
46
+ function split(iso) {
47
+ const [year, month, day] = iso.split('-').map(Number)
48
+ return { year, month, day }
49
+ }
50
+
51
+ /** 农历某次命中的最后一天:用来处理「腊月三十」落到只有廿九的年份 */
52
+ function isLastDayOfLunarMonth(iso, lunar) {
53
+ const next = solarToLunar(shift(iso, 1))
54
+ return next.month !== lunar.month || next.leap !== lunar.leap
55
+ }
56
+
57
+ function matchGregorianYearly(item, iso) {
58
+ const anchor = split(item.date)
59
+ const target = split(iso)
60
+
61
+ // 2/29 出生:平年按 feb28 或 mar1 降级(对应 iOS 端的 LeapDayFallback)
62
+ if (anchor.month === 2 && anchor.day === 29 && !isLeapYear(target.year)) {
63
+ const fallback = item.leapDayFallback ?? 'feb28'
64
+ return fallback === 'mar1'
65
+ ? target.month === 3 && target.day === 1
66
+ : target.month === 2 && target.day === 28
67
+ }
68
+ return target.month === anchor.month && target.day === anchor.day
69
+ }
70
+
71
+ function matchLunarYearly(item, iso) {
72
+ const target = solarToLunar(iso)
73
+ const wanted = item.lunar
74
+ if (target.month !== wanted.month) return false
75
+ if (target.leap !== Boolean(wanted.leap)) return false
76
+ if (target.day === wanted.day) return true
77
+
78
+ // 该月只有廿九:要求三十的那年落到月末那一天
79
+ if (wanted.day === 30 && target.day === 29 && isLastDayOfLunarMonth(iso, target)) return true
80
+ return false
81
+ }
82
+
83
+ function matchMonthly(item, iso) {
84
+ const anchorDay = split(item.date).day
85
+ const target = split(iso)
86
+ const lastDay = daysInMonth(target.year, target.month)
87
+ return target.day === Math.min(anchorDay, lastDay)
88
+ }
89
+
90
+ function matchBiweekly(item, iso) {
91
+ if (weekdayOf(iso) !== weekdayOf(item.date)) return false
92
+ const parity = isoWeekOf(iso) % 2
93
+ return item.repeat === 'biweeklyOdd' ? parity === 1 : parity === 0
94
+ }
95
+
96
+ function matchCustomDays(item, iso) {
97
+ const every = Number(item.every)
98
+ if (!Number.isFinite(every) || every < 2) return false
99
+ const diff = Math.round((toDate(iso).getTime() - toDate(item.date).getTime()) / DAY_MS)
100
+ return diff >= 0 && diff % every === 0
101
+ }
102
+
103
+ /**
104
+ * 某一天是否命中这条记录。
105
+ * @param {object} item 见 README 的数据模型
106
+ * @param {string} iso YYYY-MM-DD
107
+ */
108
+ export function matchesItem(item, iso) {
109
+ if (!item?.date || iso < item.date) return false
110
+ if (item.skipped?.includes(iso)) return false
111
+
112
+ switch (item.repeat ?? 'none') {
113
+ case 'none': return iso === item.date
114
+ case 'daily': return true
115
+ case 'weekly': return ((item.weekdayMask ?? 0) >> weekdayOf(iso)) & 1 ? true : false
116
+ case 'monthly': return matchMonthly(item, iso)
117
+ case 'yearly':
118
+ return item.calendar === 'lunar'
119
+ ? matchLunarYearly(item, iso)
120
+ : matchGregorianYearly(item, iso)
121
+ case 'biweeklyOdd':
122
+ case 'biweeklyEven': return matchBiweekly(item, iso)
123
+ case 'customDays': return matchCustomDays(item, iso)
124
+ default: return false
125
+ }
126
+ }
127
+
128
+ /** [from, to] 闭区间内所有命中日期 */
129
+ export function occurrencesBetween(item, from, to) {
130
+ if (to < from) return []
131
+ const out = []
132
+ for (let cursor = from; cursor <= to; cursor = shift(cursor, 1)) {
133
+ if (matchesItem(item, cursor)) out.push(cursor)
134
+ }
135
+ return out
136
+ }
137
+
138
+ /** 从 from 起(含当天)往后找最近一次 */
139
+ export function nextOccurrence(item, from, horizonDays = 400) {
140
+ let cursor = from
141
+ const end = shift(from, horizonDays)
142
+ while (cursor <= end) {
143
+ if (matchesItem(item, cursor)) return cursor
144
+ cursor = shift(cursor, 1)
145
+ }
146
+ return null
147
+ }
148
+
149
+ /** 距离下一次还有几天(今天 → 0) */
150
+ export function daysUntil(item, from, horizonDays = 400) {
151
+ const next = nextOccurrence(item, from, horizonDays)
152
+ if (!next) return null
153
+ return Math.round((toDate(next).getTime() - toDate(from).getTime()) / DAY_MS)
154
+ }
155
+
156
+ /** 写入前的基本校验,返回错误信息数组(空数组表示通过) */
157
+ export function validateItem(item) {
158
+ const errors = []
159
+ if (!item?.title || typeof item.title !== 'string') errors.push('title 必填')
160
+ if (!/^\d{4}-\d{2}-\d{2}$/.test(item?.date ?? '')) errors.push('date 必须是 YYYY-MM-DD')
161
+ if (!REPEAT_KINDS.includes(item?.repeat ?? 'none')) errors.push(`repeat 不支持:${item?.repeat}`)
162
+ if ((item?.repeat ?? 'none') === 'customDays' && !(Number(item?.every) >= 2)) {
163
+ errors.push('customDays 需要 every ≥ 2')
164
+ }
165
+ if (item?.calendar === 'lunar') {
166
+ const lunar = item.lunar ?? {}
167
+ if (!(lunar.month >= 1 && lunar.month <= 12)) errors.push('lunar.month 必须在 1-12')
168
+ if (!(lunar.day >= 1 && lunar.day <= 30)) errors.push('lunar.day 必须在 1-30')
169
+ }
170
+ return errors
171
+ }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * 水星逆行区间:用星历算水星的地心视黄经,黄经在减小就是逆行。
3
+ *
4
+ * 采样 12 小时一格,区间端点精确到天(±12h)。结果按「起止日期」缓存,
5
+ * 月历切页不会重复计算。
6
+ */
7
+
8
+ import * as A from 'astronomy-engine'
9
+
10
+ const DAY = 86_400_000
11
+ const STEP = 12 * 3600 * 1000
12
+ const cache = new Map()
13
+
14
+ function longitude(date) {
15
+ const vector = A.GeoVector(A.Body.Mercury, date, true)
16
+ return A.Ecliptic(vector).elon
17
+ }
18
+
19
+ function unwrap(delta) {
20
+ if (delta > 180) return delta - 360
21
+ if (delta < -180) return delta + 360
22
+ return delta
23
+ }
24
+
25
+ const isoOf = (ms) => new Date(ms).toISOString().slice(0, 10)
26
+
27
+ /**
28
+ * @param {string} startISO YYYY-MM-DD
29
+ * @param {string} endISO YYYY-MM-DD
30
+ * @returns {{ start: string, end: string }[]} 起止都含当天
31
+ */
32
+ export function mercuryRetrogradeRanges(startISO, endISO) {
33
+ const key = `${startISO}..${endISO}`
34
+ if (cache.has(key)) return cache.get(key)
35
+
36
+ const from = Date.parse(`${startISO}T00:00:00Z`) - STEP
37
+ const to = Date.parse(`${endISO}T00:00:00Z`) + STEP
38
+ const ranges = []
39
+ let current = null
40
+ let previous = null
41
+
42
+ for (let time = from; time <= to; time += STEP) {
43
+ const lon = longitude(new Date(time))
44
+ if (previous !== null) {
45
+ const retrograde = unwrap(lon - previous) < 0
46
+ const day = isoOf(time)
47
+ if (retrograde) {
48
+ if (current === null) current = { start: isoOf(time - STEP), end: day }
49
+ else current.end = day
50
+ } else if (current !== null) {
51
+ ranges.push(current)
52
+ current = null
53
+ }
54
+ }
55
+ previous = lon
56
+ }
57
+ if (current !== null) ranges.push(current)
58
+
59
+ if (cache.size > 24) cache.clear()
60
+ cache.set(key, ranges)
61
+ return ranges
62
+ }
63
+
64
+ /** 某天是否在水逆区间内 */
65
+ export function isRetrograde(iso, ranges) {
66
+ return ranges.some((range) => iso >= range.start && iso <= range.end)
67
+ }
package/src/service.js ADDED
@@ -0,0 +1,170 @@
1
+ /**
2
+ * 写入服务:工具(模型调用)和页面(人工操作)共用同一套校验与落库逻辑,
3
+ * 避免两边口径不一致。
4
+ */
5
+
6
+ import { nextOccurrence, validateItem } from './repeat.js'
7
+ import { nextLunarOccurrence } from './lunar.js'
8
+ import { newId } from './store.js'
9
+ import { normalizeTime } from './remind.js'
10
+
11
+ export const KINDS = ['todo', 'anniversary', 'memo']
12
+
13
+ /**
14
+ * 提前提醒天数:支持 0 / 数字 / 数组 / "7,1" 字符串,统一成去重升序的数组。
15
+ * 兼容旧数据(advance 是单个数字)。
16
+ */
17
+ export function normalizeAdvance(value) {
18
+ const raw = Array.isArray(value)
19
+ ? value
20
+ : typeof value === 'string'
21
+ ? value.split(/[,,\s]+/).filter(Boolean)
22
+ : value === undefined || value === null || value === ''
23
+ ? []
24
+ : [value]
25
+
26
+ const points = new Set()
27
+ for (const entry of raw) {
28
+ const days = Math.trunc(Number(entry))
29
+ if (!Number.isFinite(days)) continue
30
+ if (days <= 0) continue
31
+ points.add(Math.min(30, days))
32
+ }
33
+ return [...points].sort((left, right) => left - right)
34
+ }
35
+
36
+ export const REPEATS = [
37
+ 'none', 'daily', 'weekly', 'monthly', 'yearly',
38
+ 'biweeklyOdd', 'biweeklyEven', 'customDays',
39
+ ]
40
+
41
+ function splitTags(value) {
42
+ if (Array.isArray(value)) return value.map((tag) => String(tag).trim()).filter(Boolean)
43
+ if (!value) return []
44
+ return String(value).split(/[,,\s]+/).map((tag) => tag.trim()).filter(Boolean)
45
+ }
46
+
47
+ /** 把「页面表单 / 工具参数」归一成一条完整记录(不含 id),失败时抛错 */
48
+ export function draftFrom(input, { timeZone = 'Asia/Shanghai', today } = {}) {
49
+ const calendar = input.calendar === 'lunar' ? 'lunar' : 'gregorian'
50
+ const kind = KINDS.includes(input.kind) ? input.kind : 'todo'
51
+ const repeat = REPEATS.includes(input.repeat)
52
+ ? input.repeat
53
+ : (kind === 'anniversary' ? 'yearly' : 'none')
54
+
55
+ let date = input.date?.trim() || null
56
+ if (calendar === 'lunar') {
57
+ const month = Number(input.lunarMonth)
58
+ const day = Number(input.lunarDay)
59
+ if (!(month >= 1 && month <= 12) || !(day >= 1 && day <= 30)) {
60
+ throw new Error('农历日期需要月份 1-12、日 1-30')
61
+ }
62
+ if (!date) {
63
+ const hit = nextLunarOccurrence({ month, day, leap: Boolean(input.lunarLeap) }, today, { timeZone })
64
+ if (!hit) throw new Error('这个农历日期在未来一年内没有出现,请补一个公历锚点')
65
+ date = hit.date
66
+ }
67
+ }
68
+ date ??= today
69
+
70
+ const item = {
71
+ id: newId(),
72
+ kind,
73
+ title: String(input.title ?? '').trim(),
74
+ calendar,
75
+ date,
76
+ repeat,
77
+ every: input.every ?? null,
78
+ weekdayMask: Number(input.weekdayMask ?? 0),
79
+ leapDayFallback: input.leapDayFallback ?? 'feb28',
80
+ advance: normalizeAdvance(input.advance),
81
+ remindAt: normalizeTime(input.remindAt),
82
+ tags: splitTags(input.tags),
83
+ note: input.note ?? '',
84
+ skipped: [],
85
+ done: [],
86
+ createdAt: new Date().toISOString(),
87
+ updatedAt: new Date().toISOString(),
88
+ }
89
+ if (calendar === 'lunar') {
90
+ item.lunar = {
91
+ month: Number(input.lunarMonth),
92
+ day: Number(input.lunarDay),
93
+ leap: Boolean(input.lunarLeap),
94
+ }
95
+ }
96
+
97
+ const errors = validateItem(item)
98
+ if (errors.length) throw new Error(errors.join(';'))
99
+ return item
100
+ }
101
+
102
+ export async function addItem(store, input, context) {
103
+ const item = draftFrom(input, context)
104
+ await store.insert(item)
105
+ return item
106
+ }
107
+
108
+ const EDITABLE = ['title', 'date', 'repeat', 'every', 'weekdayMask', 'tags', 'note', 'kind', 'advance', 'remindAt']
109
+
110
+ export async function updateItem(store, id, fields) {
111
+ const current = await store.get(id)
112
+ if (!current) throw new Error(`找不到记录:${id}`)
113
+
114
+ const patch = {}
115
+ for (const key of EDITABLE) {
116
+ if (fields[key] === undefined) continue
117
+ if (key === 'tags') patch[key] = splitTags(fields[key])
118
+ else if (key === 'advance') patch[key] = normalizeAdvance(fields[key])
119
+ else if (key === 'remindAt') patch[key] = normalizeTime(fields[key])
120
+ else patch[key] = fields[key]
121
+ }
122
+
123
+ // 页面可以改历法与农历分量,工具不必改,所以单独处理
124
+ if (fields.calendar !== undefined) {
125
+ patch.calendar = fields.calendar === 'lunar' ? 'lunar' : 'gregorian'
126
+ }
127
+ const calendar = patch.calendar ?? current.calendar
128
+ if (calendar === 'lunar') {
129
+ if (fields.lunarMonth !== undefined || fields.lunarDay !== undefined || fields.lunarLeap !== undefined) {
130
+ patch.lunar = {
131
+ month: Number(fields.lunarMonth ?? current.lunar?.month ?? 1),
132
+ day: Number(fields.lunarDay ?? current.lunar?.day ?? 1),
133
+ leap: Boolean(fields.lunarLeap ?? current.lunar?.leap),
134
+ }
135
+ }
136
+ } else if (patch.calendar === 'gregorian') {
137
+ patch.lunar = undefined
138
+ }
139
+
140
+ if (!Object.keys(patch).length) throw new Error('没有要修改的字段')
141
+
142
+ const merged = { ...current, ...patch }
143
+ const errors = validateItem(merged)
144
+ if (errors.length) throw new Error(errors.join(';'))
145
+
146
+ return store.patch(id, patch)
147
+ }
148
+
149
+ export async function removeItem(store, id) {
150
+ const removed = await store.remove(id)
151
+ if (!removed) throw new Error(`找不到记录:${id}`)
152
+ return removed
153
+ }
154
+
155
+ export async function markDone(store, id, occurrence, today) {
156
+ const item = await store.get(id)
157
+ if (!item) throw new Error(`找不到记录:${id}`)
158
+ const target = occurrence || (item.repeat === 'none' ? item.date : nextOccurrence(item, today) || today)
159
+ const done = new Set(item.done ?? [])
160
+ done.add(target)
161
+ const updated = await store.patch(id, { done: [...done].sort() })
162
+ return { item: updated, occurrence: target }
163
+ }
164
+
165
+ export async function undoDone(store, id, occurrence) {
166
+ const item = await store.get(id)
167
+ if (!item) throw new Error(`找不到记录:${id}`)
168
+ const done = (item.done ?? []).filter((entry) => entry !== occurrence)
169
+ return store.patch(id, { done })
170
+ }
package/src/store.js ADDED
@@ -0,0 +1,116 @@
1
+ /**
2
+ * 存储层:一个 JSON 文件,原子写(临时文件 + rename),进程内串行化。
3
+ *
4
+ * 为什么不用 DSH 的 storage-domain:第一版要能脱离宿主单测,
5
+ * 也要能让人直接打开文件看数据。等形态稳定了再迁到 ctx.storage。
6
+ */
7
+
8
+ import { chmod, mkdir, readFile, rename, writeFile } from 'node:fs/promises'
9
+ import { randomBytes } from 'node:crypto'
10
+ import { dirname, join } from 'node:path'
11
+ import { homedir } from 'node:os'
12
+
13
+ export const SCHEMA_VERSION = 1
14
+
15
+ export function defaultStorePath() {
16
+ const home = process.env.DSH_HOME || join(homedir(), '.dsh')
17
+ return join(home, 'storages', 'dsh-reminder', 'items.json')
18
+ }
19
+
20
+ export function emptyState() {
21
+ return { version: SCHEMA_VERSION, items: [] }
22
+ }
23
+
24
+ export function newId() {
25
+ return `jiri_${randomBytes(5).toString('hex')}`
26
+ }
27
+
28
+ /** 串行化读-改-写,避免并发工具调用互相覆盖 */
29
+ function createQueue() {
30
+ let tail = Promise.resolve()
31
+ return (task) => {
32
+ const run = tail.then(task, task)
33
+ tail = run.then(() => undefined, () => undefined)
34
+ return run
35
+ }
36
+ }
37
+
38
+ export async function readState(file) {
39
+ try {
40
+ const raw = await readFile(file, 'utf8')
41
+ const parsed = JSON.parse(raw)
42
+ if (!Array.isArray(parsed?.items)) throw new Error(`存储格式不对:${file}`)
43
+ return { version: parsed.version ?? SCHEMA_VERSION, items: parsed.items }
44
+ } catch (error) {
45
+ if (error.code === 'ENOENT') return emptyState()
46
+ throw error
47
+ }
48
+ }
49
+
50
+ export async function writeState(file, state) {
51
+ await mkdir(dirname(file), { recursive: true })
52
+ const temp = `${file}.${process.pid}.${Date.now()}.tmp`
53
+ // 记录里有生日、家庭关系,只给当前用户读
54
+ await writeFile(temp, `${JSON.stringify(state, null, 2)}\n`, { encoding: 'utf8', mode: 0o600 })
55
+ await rename(temp, file)
56
+ await chmod(file, 0o600).catch(() => {})
57
+ }
58
+
59
+ export function createStore(file = defaultStorePath()) {
60
+ const enqueue = createQueue()
61
+
62
+ return {
63
+ file,
64
+
65
+ async list() {
66
+ return (await readState(file)).items
67
+ },
68
+
69
+ async get(id) {
70
+ const items = await readState(file)
71
+ return items.items.find((item) => item.id === id) ?? null
72
+ },
73
+
74
+ async insert(item) {
75
+ return enqueue(async () => {
76
+ const state = await readState(file)
77
+ state.items.push(item)
78
+ await writeState(file, state)
79
+ return item
80
+ })
81
+ },
82
+
83
+ async patch(id, fields) {
84
+ return enqueue(async () => {
85
+ const state = await readState(file)
86
+ const index = state.items.findIndex((item) => item.id === id)
87
+ if (index === -1) return null
88
+ const merged = { ...state.items[index], ...fields, id, updatedAt: new Date().toISOString() }
89
+ state.items[index] = merged
90
+ await writeState(file, state)
91
+ return merged
92
+ })
93
+ },
94
+
95
+ async remove(id) {
96
+ return enqueue(async () => {
97
+ const state = await readState(file)
98
+ const index = state.items.findIndex((item) => item.id === id)
99
+ if (index === -1) return null
100
+ const [removed] = state.items.splice(index, 1)
101
+ await writeState(file, state)
102
+ return removed
103
+ })
104
+ },
105
+
106
+ /** 直接改整个 state 的逃生门:导入、迁移用 */
107
+ async mutate(mutator) {
108
+ return enqueue(async () => {
109
+ const state = await readState(file)
110
+ const result = await mutator(state)
111
+ await writeState(file, state)
112
+ return result
113
+ })
114
+ },
115
+ }
116
+ }