@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/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
|
+
}
|