dsh-intertidal 0.0.0-stage → 0.1.4

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.
@@ -0,0 +1,322 @@
1
+ /**
2
+ * 潮间 · 设置默认值、合并、范围钳制
3
+ *
4
+ * 零依赖、零 IO。宿主侧不信任任何客户端输入:
5
+ * 类型错、越界、时区非法一律退回该字段的默认值。
6
+ */
7
+
8
+ import { findCity, guessCity } from './cities.js';
9
+
10
+ const HHMM_RE = /^([01]\d|2[0-3]):([0-5]\d)$/;
11
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
12
+
13
+ /** 是不是一个真实存在的日历日('2026-13-01' 不算) */
14
+ function isDateString(value) {
15
+ if (typeof value !== 'string' || !DATE_RE.test(value)) return false;
16
+ const [y, m, d] = value.split('-').map(Number);
17
+ if (m < 1 || m > 12 || d < 1 || d > 31) return false;
18
+ const dt = new Date(Date.UTC(y, m - 1, d));
19
+ return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
20
+ }
21
+
22
+ /** 契约 §3 的完整默认值 */
23
+ export const DEFAULTS = Object.freeze({
24
+ auto: true,
25
+ intervalMinutes: 120,
26
+ durationSeconds: 180,
27
+ firstDelayMinutes: 20,
28
+ quietHours: Object.freeze({ enabled: true, start: '23:00', end: '07:00' }),
29
+ deferOnTyping: true,
30
+ manualExtends: true,
31
+ form: 'full',
32
+ rememberForm: true,
33
+ caption: true,
34
+ location: Object.freeze({
35
+ city: 'shanghai',
36
+ lat: 31.23,
37
+ lon: 121.47,
38
+ tz: 'Asia/Shanghai',
39
+ }),
40
+ sound: Object.freeze({ enabled: false, volume: 0.35 }),
41
+ /** v2:真实素材(图片/视频)替代程序化海;'off' 时仍用程序化海。 */
42
+ media: Object.freeze({
43
+ mode: 'off',
44
+ dir: '',
45
+ urls: Object.freeze([]),
46
+ grade: 0.55,
47
+ motion: 0.5,
48
+ shuffle: true,
49
+ }),
50
+ reducedMotion: 'auto',
51
+ });
52
+
53
+ /** 契约 §3 的范围 */
54
+ export const LIMITS = Object.freeze({
55
+ intervalMinutes: Object.freeze([15, 480]),
56
+ durationSeconds: Object.freeze([30, 1800]),
57
+ firstDelayMinutes: Object.freeze([0, 120]),
58
+ volume: Object.freeze([0, 1]),
59
+ grade: Object.freeze([0, 1]),
60
+ motion: Object.freeze([0, 1]),
61
+ });
62
+
63
+ const FORMS = Object.freeze(['full', 'pip']);
64
+ const REDUCED_MOTION = Object.freeze(['auto', 'on', 'off']);
65
+ const MEDIA_MODES = Object.freeze(['off', 'dir', 'url']);
66
+ const MAX_MEDIA_URLS = 8;
67
+ const MEDIA_URL_RE = /^https?:\/\/[^\s]+$/i;
68
+ const ABS_PATH_RE = /^(\/|[A-Za-z]:[\\/])/;
69
+
70
+ /* ---------------------------- 小工具 ---------------------------- */
71
+
72
+ /** 深拷贝一份普通对象(DEFAULTS 里有冻结对象,不能直接外泄引用) */
73
+ function clone(value) {
74
+ if (Array.isArray(value)) return value.map(clone);
75
+ if (value && typeof value === 'object') {
76
+ const out = {};
77
+ for (const [k, v] of Object.entries(value)) out[k] = clone(v);
78
+ return out;
79
+ }
80
+ return value;
81
+ }
82
+
83
+ const isPlainObject = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
84
+
85
+ /** 取对象里的值;非对象当空对象 */
86
+ const field = (obj, key) => (isPlainObject(obj) ? obj[key] : undefined);
87
+
88
+ /** 布尔:只接受真正的 boolean,其余回默认 */
89
+ function boolOr(value, fallback) {
90
+ return typeof value === 'boolean' ? value : fallback;
91
+ }
92
+
93
+ /** 数字:有限数才认,然后钳制到 [lo, hi] */
94
+ function numberIn(value, lo, hi, fallback) {
95
+ const n = typeof value === 'number' ? value : Number.NaN;
96
+ if (!Number.isFinite(n)) return fallback;
97
+ return n < lo ? lo : n > hi ? hi : n;
98
+ }
99
+
100
+ /** 整数:有限数才认,四舍五入后钳制 */
101
+ function intIn(value, lo, hi, fallback) {
102
+ const n = typeof value === 'number' ? value : Number.NaN;
103
+ if (!Number.isFinite(n)) return fallback;
104
+ const rounded = Math.round(n);
105
+ return rounded < lo ? lo : rounded > hi ? hi : rounded;
106
+ }
107
+
108
+ /** 'HH:MM' 且是真实存在的时刻 */
109
+ function timeOr(value, fallback) {
110
+ if (typeof value !== 'string') return fallback;
111
+ return HHMM_RE.test(value.trim()) ? value.trim() : fallback;
112
+ }
113
+
114
+ /** IANA 时区名是否可用 */
115
+ export function isValidTimeZone(tz) {
116
+ if (typeof tz !== 'string' || tz.trim() === '') return false;
117
+ try {
118
+ new Intl.DateTimeFormat('en-US', { timeZone: tz.trim() });
119
+ return true;
120
+ } catch {
121
+ return false;
122
+ }
123
+ }
124
+
125
+ /** 枚举:命中才认 */
126
+ function oneOf(value, allowed, fallback) {
127
+ return typeof value === 'string' && allowed.includes(value) ? value : fallback;
128
+ }
129
+
130
+ /** 某个 location 字段的默认值:优先用城市表里的坐标,否则用 DEFAULTS */
131
+ function locationDefaults(inputCity) {
132
+ const known = findCity(inputCity) ?? findCity(DEFAULTS.location.city);
133
+ return known
134
+ ? { city: known.id, lat: known.lat, lon: known.lon, tz: known.tz }
135
+ : clone(DEFAULTS.location);
136
+ }
137
+
138
+ /* ------------------------- 设置校验(核心) ------------------------- */
139
+
140
+ /** 任意输入 → 合法 settings。非法/越界/类型错一律退回默认该字段。 */
141
+ export function sanitizeSettings(input) {
142
+ const src = isPlainObject(input) ? input : {};
143
+ const rawLocation = field(src, 'location');
144
+ const locDefaults = locationDefaults(field(rawLocation, 'city'));
145
+
146
+ const rawCity = field(rawLocation, 'city');
147
+ // 认得出来就用城市表里的规范 id + 用它补全缺失的经纬度/时区;
148
+ // 认不出来就把用户写的 id 原样保留(自定义地点),缺失项退回默认。
149
+ const matchedCity = typeof rawCity === 'string' ? findCity(rawCity) : undefined;
150
+ const city =
151
+ matchedCity?.id ??
152
+ (typeof rawCity === 'string' && rawCity.trim() ? rawCity.trim() : DEFAULTS.location.city);
153
+
154
+ const quiet = field(src, 'quietHours');
155
+ const sound = field(src, 'sound');
156
+ const media = field(src, 'media');
157
+
158
+ return {
159
+ auto: boolOr(field(src, 'auto'), DEFAULTS.auto),
160
+ intervalMinutes: intIn(
161
+ field(src, 'intervalMinutes'),
162
+ LIMITS.intervalMinutes[0],
163
+ LIMITS.intervalMinutes[1],
164
+ DEFAULTS.intervalMinutes,
165
+ ),
166
+ durationSeconds: intIn(
167
+ field(src, 'durationSeconds'),
168
+ LIMITS.durationSeconds[0],
169
+ LIMITS.durationSeconds[1],
170
+ DEFAULTS.durationSeconds,
171
+ ),
172
+ firstDelayMinutes: intIn(
173
+ field(src, 'firstDelayMinutes'),
174
+ LIMITS.firstDelayMinutes[0],
175
+ LIMITS.firstDelayMinutes[1],
176
+ DEFAULTS.firstDelayMinutes,
177
+ ),
178
+ quietHours: {
179
+ enabled: boolOr(field(quiet, 'enabled'), DEFAULTS.quietHours.enabled),
180
+ start: timeOr(field(quiet, 'start'), DEFAULTS.quietHours.start),
181
+ end: timeOr(field(quiet, 'end'), DEFAULTS.quietHours.end),
182
+ },
183
+ deferOnTyping: boolOr(field(src, 'deferOnTyping'), DEFAULTS.deferOnTyping),
184
+ manualExtends: boolOr(field(src, 'manualExtends'), DEFAULTS.manualExtends),
185
+ form: oneOf(field(src, 'form'), FORMS, DEFAULTS.form),
186
+ rememberForm: boolOr(field(src, 'rememberForm'), DEFAULTS.rememberForm),
187
+ caption: boolOr(field(src, 'caption'), DEFAULTS.caption),
188
+ location: {
189
+ city,
190
+ lat: numberIn(field(rawLocation, 'lat'), -90, 90, locDefaults.lat),
191
+ lon: numberIn(field(rawLocation, 'lon'), -180, 180, locDefaults.lon),
192
+ tz: isValidTimeZone(field(rawLocation, 'tz'))
193
+ ? field(rawLocation, 'tz').trim()
194
+ : locDefaults.tz,
195
+ },
196
+ sound: {
197
+ enabled: boolOr(field(sound, 'enabled'), DEFAULTS.sound.enabled),
198
+ volume: numberIn(
199
+ field(sound, 'volume'),
200
+ LIMITS.volume[0],
201
+ LIMITS.volume[1],
202
+ DEFAULTS.sound.volume,
203
+ ),
204
+ },
205
+ media: {
206
+ mode: oneOf(field(media, 'mode'), MEDIA_MODES, DEFAULTS.media.mode),
207
+ // 目录必须是绝对路径;写相对路径就没法在宿主侧做围栏,直接退回空
208
+ dir: absPathOr(field(media, 'dir'), DEFAULTS.media.dir),
209
+ urls: urlListOr(field(media, 'urls'), DEFAULTS.media.urls),
210
+ grade: numberIn(field(media, 'grade'), LIMITS.grade[0], LIMITS.grade[1], DEFAULTS.media.grade),
211
+ motion: numberIn(field(media, 'motion'), LIMITS.motion[0], LIMITS.motion[1], DEFAULTS.media.motion),
212
+ shuffle: boolOr(field(media, 'shuffle'), DEFAULTS.media.shuffle),
213
+ },
214
+ reducedMotion: oneOf(field(src, 'reducedMotion'), REDUCED_MOTION, DEFAULTS.reducedMotion),
215
+ };
216
+ }
217
+
218
+ /** 绝对路径字符串:不是字符串/不是绝对路径 → 默认值(空串)。 */
219
+ function absPathOr(value, fallback) {
220
+ if (typeof value !== 'string') return fallback;
221
+ const text = value.trim();
222
+ if (!text) return fallback;
223
+ return ABS_PATH_RE.test(text) ? text : fallback;
224
+ }
225
+
226
+ /** 链接列表:只留 http/https、去重、最多 MAX_MEDIA_URLS 条。 */
227
+ function urlListOr(value, fallback) {
228
+ const list = Array.isArray(value) ? value : typeof value === 'string' ? value.split(/[\n,]+/) : [];
229
+ const out = [];
230
+ for (const item of list) {
231
+ if (typeof item !== 'string') continue;
232
+ const text = item.trim();
233
+ if (!text || !MEDIA_URL_RE.test(text)) continue;
234
+ if (out.includes(text)) continue;
235
+ out.push(text);
236
+ if (out.length >= MAX_MEDIA_URLS) break;
237
+ }
238
+ return out.length ? out : [...fallback];
239
+ }
240
+
241
+ /** 深合并(普通对象递归,数组与标量整体覆盖,undefined 跳过) */
242
+ function deepMerge(base, patch) {
243
+ if (!isPlainObject(patch)) return patch === undefined ? clone(base) : clone(patch);
244
+ const out = isPlainObject(base) ? clone(base) : {};
245
+ for (const [key, value] of Object.entries(patch)) {
246
+ if (value === undefined) continue;
247
+ out[key] = isPlainObject(value) && isPlainObject(out[key]) ? deepMerge(out[key], value) : clone(value);
248
+ }
249
+ return out;
250
+ }
251
+
252
+ /** 默认值 + 已存盘 + 本次 patch 的深合并,再做钳制。 */
253
+ export function mergeSettings(base, patch) {
254
+ const merged = deepMerge(deepMerge(DEFAULTS, base), patch);
255
+ return sanitizeSettings(merged);
256
+ }
257
+
258
+ /* --------------------------- rhythm 归一化 --------------------------- */
259
+
260
+ const RHYTHM_EVENTS = Object.freeze(['shown', 'hidden', 'skip-today', 'manual-open']);
261
+ /** skipDates 只保留最近 7 天 */
262
+ const SKIP_KEEP = 7;
263
+
264
+ /** 毫秒 → Unix 秒;非法返回 null */
265
+ function toUnixSeconds(value) {
266
+ if (value === null || value === undefined) return null;
267
+ const ms = value instanceof Date ? value.getTime() : Number(value);
268
+ if (!Number.isFinite(ms)) return null;
269
+ const sec = ms > 1e11 ? Math.floor(ms / 1000) : Math.floor(ms);
270
+ return sec >= 0 ? sec : null;
271
+ }
272
+
273
+ /** 系统本地日期 YYYY-MM-DD */
274
+ export function localDateString(nowMs) {
275
+ const ms = nowMs instanceof Date ? nowMs.getTime() : Number(nowMs);
276
+ const d = new Date(Number.isFinite(ms) ? ms : 0);
277
+ const y = d.getFullYear();
278
+ const m = String(d.getMonth() + 1).padStart(2, '0');
279
+ const day = String(d.getDate()).padStart(2, '0');
280
+ return `${y}-${m}-${day}`;
281
+ }
282
+
283
+ /**
284
+ * 归一化 rhythm:三个时间戳为 Unix 秒(可 null),skipDates 为本地日期串、
285
+ * 去重排序后只保留最近 7 天。
286
+ */
287
+ export function sanitizeRhythm(input, nowMs) {
288
+ const src = isPlainObject(input) ? input : {};
289
+ const nowSec = toUnixSeconds(nowMs) ?? 0;
290
+
291
+ const rawSkip = Array.isArray(src.skipDates) ? src.skipDates : [];
292
+ const skipDates = [...new Set(rawSkip.filter(isDateString))].sort().slice(-SKIP_KEEP);
293
+
294
+ return {
295
+ lastShownAt: toUnixSeconds(src.lastShownAt),
296
+ lastHideAt: toUnixSeconds(src.lastHideAt),
297
+ installedAt: toUnixSeconds(src.installedAt) ?? nowSec,
298
+ skipDates,
299
+ };
300
+ }
301
+
302
+ /* ------------------------------ 对外状态 ------------------------------ */
303
+
304
+ /** 给 GET /intertidal/state 的完整结构。now 接受 Date 或 ms,输出 Unix 秒。 */
305
+ export function publicState(settings, rhythm, now) {
306
+ return {
307
+ settings: sanitizeSettings(settings),
308
+ rhythm: sanitizeRhythm(rhythm, now),
309
+ now: toUnixSeconds(now) ?? 0,
310
+ };
311
+ }
312
+
313
+ /** 时区非法时给 store/settings 的兜底时区(不抛) */
314
+ export function safeTimeZoneOr(tz, fallback) {
315
+ return isValidTimeZone(tz) ? tz.trim() : fallback;
316
+ }
317
+
318
+ /** rhythm 事件白名单(宿主路由用它校验入参) */
319
+ export { RHYTHM_EVENTS, SKIP_KEEP };
320
+
321
+ /** 猜城市 / 查城市(转发 cities,方便宿主一处引入) */
322
+ export { guessCity, findCity };