dsh-scheduled-message 0.1.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/LICENSE +21 -0
- package/README.md +113 -0
- package/client.js +1739 -0
- package/cordis.patch.yml +3 -0
- package/docs/DETAIL.md +440 -0
- package/docs/preview.png +0 -0
- package/icon.svg +13 -0
- package/index.js +694 -0
- package/lib/core.js +466 -0
- package/locale/en.json +91 -0
- package/locale/zh.json +91 -0
- package/package.json +65 -0
- package/scripts/build-client.mjs +99 -0
- package/scripts/check.mjs +101 -0
- package/scripts/host-smoke.mjs +433 -0
package/lib/core.js
ADDED
|
@@ -0,0 +1,466 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure shared logic for dsh-scheduled-message.
|
|
3
|
+
*
|
|
4
|
+
* Everything here is dependency-free and deterministic so the Host half, the
|
|
5
|
+
* browser half and the unit tests agree on one implementation: 12-hour clock
|
|
6
|
+
* conversion, wall-clock to absolute-instant resolution in an explicit IANA
|
|
7
|
+
* zone, validation of a create request, the RRULE text handed to
|
|
8
|
+
* `ctx.schedule.create`, timer chunking and retention pruning.
|
|
9
|
+
*
|
|
10
|
+
* @module dsh-scheduled-message/lib/core
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** Plugin id, locale namespace and browser entry name. */
|
|
14
|
+
export const NS = 'dsh-scheduled-message'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Durable storage domain name.
|
|
18
|
+
*
|
|
19
|
+
* Storage unit names are narrower than package names: `@deepseek-ai/dsh-storage`
|
|
20
|
+
* matches them against `/^[a-z][a-z0-9_]*$/`, so the package name (which has
|
|
21
|
+
* hyphens) would be refused when the domain is defined.
|
|
22
|
+
*/
|
|
23
|
+
export const DOMAIN_NAME = 'dsh_scheduled_message'
|
|
24
|
+
|
|
25
|
+
/** A scheduled instant must be at least this far ahead of the accepted request. */
|
|
26
|
+
export const MIN_LEAD_MS = 30_000
|
|
27
|
+
|
|
28
|
+
/** Longest accepted message text. */
|
|
29
|
+
export const MAX_TEXT_CHARS = 200_000
|
|
30
|
+
|
|
31
|
+
/** Most images accepted in one scheduled message. */
|
|
32
|
+
export const MAX_IMAGES = 10
|
|
33
|
+
|
|
34
|
+
/** Most `pending` records the Host keeps. */
|
|
35
|
+
export const MAX_PENDING = 500
|
|
36
|
+
|
|
37
|
+
/** Longest title accepted by `ctx.schedule.create`. */
|
|
38
|
+
export const MAX_TITLE_CHARS = 120
|
|
39
|
+
|
|
40
|
+
/** `setTimeout` stores its delay in a signed 32-bit integer. */
|
|
41
|
+
export const MAX_TIMER_DELAY_MS = 2_147_483_647
|
|
42
|
+
|
|
43
|
+
/** A delivery later than this after its due instant is reported as sent late. */
|
|
44
|
+
export const LATE_THRESHOLD_MS = 60_000
|
|
45
|
+
|
|
46
|
+
/** Finished records older than this are pruned. */
|
|
47
|
+
export const RETENTION_MS = 7 * 24 * 60 * 60 * 1000
|
|
48
|
+
|
|
49
|
+
/** Repeat frequencies the composer offers. */
|
|
50
|
+
export const REPEAT_FREQUENCIES = Object.freeze(['daily', 'weekdays', 'weekly', 'monthly'])
|
|
51
|
+
|
|
52
|
+
const ISO_WEEKDAY_CODES = Object.freeze(['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'])
|
|
53
|
+
|
|
54
|
+
const ISO_DATE = /^(\d{4})-(\d{2})-(\d{2})$/
|
|
55
|
+
|
|
56
|
+
const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}(?::\d{2}(?:\.\d{1,9})?)?(?:Z|[+-]\d{2}:\d{2})$/
|
|
57
|
+
|
|
58
|
+
/** Clamp and zero-pad one clock field. */
|
|
59
|
+
export function pad2(value) {
|
|
60
|
+
return String(Math.trunc(value)).padStart(2, '0')
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Convert a 24-hour clock hour to the 12-hour face shown in the picker.
|
|
65
|
+
* @param hour24 - hour in 0..23.
|
|
66
|
+
* @returns the 1..12 hour and its meridiem; an out-of-range hour throws.
|
|
67
|
+
*/
|
|
68
|
+
export function toMeridiem(hour24) {
|
|
69
|
+
if (!Number.isInteger(hour24) || hour24 < 0 || hour24 > 23) throw new RangeError(`hour out of range: ${hour24}`)
|
|
70
|
+
if (hour24 === 0) return { hour12: 12, meridiem: 'AM' }
|
|
71
|
+
if (hour24 < 12) return { hour12: hour24, meridiem: 'AM' }
|
|
72
|
+
if (hour24 === 12) return { hour12: 12, meridiem: 'PM' }
|
|
73
|
+
return { hour12: hour24 - 12, meridiem: 'PM' }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Convert the picker's 12-hour face back to a 24-hour clock hour.
|
|
78
|
+
* @param hour12 - hour in 1..12.
|
|
79
|
+
* @param meridiem - 'AM' or 'PM'.
|
|
80
|
+
* @returns the 0..23 hour; a malformed input throws.
|
|
81
|
+
*/
|
|
82
|
+
export function to24Hour(hour12, meridiem) {
|
|
83
|
+
if (!Number.isInteger(hour12) || hour12 < 1 || hour12 > 12) throw new RangeError(`hour out of range: ${hour12}`)
|
|
84
|
+
if (meridiem !== 'AM' && meridiem !== 'PM') throw new RangeError(`meridiem out of range: ${String(meridiem)}`)
|
|
85
|
+
const base = hour12 % 12
|
|
86
|
+
return meridiem === 'PM' ? base + 12 : base
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Render the picker's clock face for a copy line.
|
|
91
|
+
* @param hour12 - hour in 1..12.
|
|
92
|
+
* @param minute - minute in 0..59.
|
|
93
|
+
* @param meridiem - 'AM' or 'PM'.
|
|
94
|
+
* @returns text such as `9:00 AM`.
|
|
95
|
+
*/
|
|
96
|
+
export function formatClock(hour12, minute, meridiem) {
|
|
97
|
+
return `${hour12}:${pad2(minute)} ${meridiem}`
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Read a strict `YYYY-MM-DD` calendar date without consulting a process zone.
|
|
102
|
+
* @param text - candidate date.
|
|
103
|
+
* @returns the three fields, or null when the text is not a real calendar date.
|
|
104
|
+
*/
|
|
105
|
+
export function parseLocalDate(text) {
|
|
106
|
+
if (typeof text !== 'string') return null
|
|
107
|
+
const match = ISO_DATE.exec(text)
|
|
108
|
+
if (match === null) return null
|
|
109
|
+
const year = Number(match[1])
|
|
110
|
+
const month = Number(match[2])
|
|
111
|
+
const day = Number(match[3])
|
|
112
|
+
if (month < 1 || month > 12 || day < 1 || day > 31) return null
|
|
113
|
+
const probe = new Date(Date.UTC(year, month - 1, day))
|
|
114
|
+
if (probe.getUTCFullYear() !== year || probe.getUTCMonth() !== month - 1 || probe.getUTCDate() !== day) return null
|
|
115
|
+
return { year, month, day }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Whether a name is usable as an IANA zone for `Intl`.
|
|
120
|
+
* @param timeZone - candidate zone name.
|
|
121
|
+
* @returns whether `Intl.DateTimeFormat` accepts it; `UTC` is accepted.
|
|
122
|
+
*/
|
|
123
|
+
export function isValidTimeZone(timeZone) {
|
|
124
|
+
if (typeof timeZone !== 'string' || timeZone.length === 0 || timeZone.length > 64) return false
|
|
125
|
+
try {
|
|
126
|
+
new Intl.DateTimeFormat('en-US', { timeZone })
|
|
127
|
+
return true
|
|
128
|
+
} catch {
|
|
129
|
+
return false
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const offsetFormatters = new Map()
|
|
134
|
+
|
|
135
|
+
function offsetFormatter(timeZone) {
|
|
136
|
+
let formatter = offsetFormatters.get(timeZone)
|
|
137
|
+
if (formatter === undefined) {
|
|
138
|
+
formatter = new Intl.DateTimeFormat('en-US', {
|
|
139
|
+
timeZone,
|
|
140
|
+
hourCycle: 'h23',
|
|
141
|
+
year: 'numeric',
|
|
142
|
+
month: '2-digit',
|
|
143
|
+
day: '2-digit',
|
|
144
|
+
hour: '2-digit',
|
|
145
|
+
minute: '2-digit',
|
|
146
|
+
second: '2-digit',
|
|
147
|
+
})
|
|
148
|
+
offsetFormatters.set(timeZone, formatter)
|
|
149
|
+
}
|
|
150
|
+
return formatter
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Read the wall clock of one instant in one zone.
|
|
155
|
+
* @param instantMs - epoch milliseconds.
|
|
156
|
+
* @param timeZone - explicit IANA zone.
|
|
157
|
+
* @returns local calendar fields, including the ISO weekday 1..7 (Monday first).
|
|
158
|
+
*/
|
|
159
|
+
export function localParts(instantMs, timeZone) {
|
|
160
|
+
const parts = offsetFormatter(timeZone).formatToParts(new Date(instantMs))
|
|
161
|
+
const field = (type) => Number(parts.find(part => part.type === type)?.value)
|
|
162
|
+
const year = field('year')
|
|
163
|
+
const month = field('month')
|
|
164
|
+
const day = field('day')
|
|
165
|
+
const hour24 = field('hour')
|
|
166
|
+
const minute = field('minute')
|
|
167
|
+
const second = field('second')
|
|
168
|
+
const weekday = new Date(Date.UTC(year, month - 1, day)).getUTCDay()
|
|
169
|
+
return {
|
|
170
|
+
year,
|
|
171
|
+
month,
|
|
172
|
+
day,
|
|
173
|
+
date: `${year}-${pad2(month)}-${pad2(day)}`,
|
|
174
|
+
hour24,
|
|
175
|
+
minute,
|
|
176
|
+
second,
|
|
177
|
+
isoWeekday: weekday === 0 ? 7 : weekday,
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Offset of one zone at one instant, in milliseconds (local minus UTC).
|
|
183
|
+
* @param instantMs - epoch milliseconds.
|
|
184
|
+
* @param timeZone - explicit IANA zone.
|
|
185
|
+
* @returns the offset, positive east of Greenwich.
|
|
186
|
+
*/
|
|
187
|
+
export function zoneOffsetMs(instantMs, timeZone) {
|
|
188
|
+
const local = localParts(instantMs, timeZone)
|
|
189
|
+
const asUtc = Date.UTC(local.year, local.month - 1, local.day, local.hour24, local.minute, local.second)
|
|
190
|
+
return asUtc - Math.floor(instantMs / 1000) * 1000
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Resolve a wall-clock selection in an explicit zone to an absolute instant.
|
|
195
|
+
*
|
|
196
|
+
* The selection is the one the picker shows, so it is interpreted in the
|
|
197
|
+
* browser's zone; the Host only ever sees the resulting absolute instant.
|
|
198
|
+
* A wall clock that a daylight-saving gap removed is refused instead of being
|
|
199
|
+
* shifted silently.
|
|
200
|
+
* @param selection - `date` (`YYYY-MM-DD`), `hour24` (0..23) and `minute` (0..59).
|
|
201
|
+
* @param timeZone - explicit IANA zone.
|
|
202
|
+
* @returns the resolved instant, or a rejection naming the reason.
|
|
203
|
+
*/
|
|
204
|
+
export function wallClockToInstant(selection, timeZone) {
|
|
205
|
+
if (!isValidTimeZone(timeZone)) return { ok: false, reason: 'invalid-time-zone' }
|
|
206
|
+
const date = parseLocalDate(selection?.date)
|
|
207
|
+
const hour24 = selection?.hour24
|
|
208
|
+
const minute = selection?.minute
|
|
209
|
+
if (date === null) return { ok: false, reason: 'invalid-date' }
|
|
210
|
+
if (!Number.isInteger(hour24) || hour24 < 0 || hour24 > 23) return { ok: false, reason: 'invalid-time' }
|
|
211
|
+
if (!Number.isInteger(minute) || minute < 0 || minute > 59) return { ok: false, reason: 'invalid-time' }
|
|
212
|
+
const target = Date.UTC(date.year, date.month - 1, date.day, hour24, minute, 0, 0)
|
|
213
|
+
// Two passes settle zones whose offset at UTC midnight differs from the
|
|
214
|
+
// offset at the resolved instant; a third pass changes nothing.
|
|
215
|
+
let instantMs = target - zoneOffsetMs(target, timeZone)
|
|
216
|
+
instantMs = target - zoneOffsetMs(instantMs, timeZone)
|
|
217
|
+
const check = localParts(instantMs, timeZone)
|
|
218
|
+
if (check.date !== `${date.year}-${pad2(date.month)}-${pad2(date.day)}`
|
|
219
|
+
|| check.hour24 !== hour24 || check.minute !== minute) {
|
|
220
|
+
return { ok: false, reason: 'nonexistent-local-time' }
|
|
221
|
+
}
|
|
222
|
+
return { ok: true, instantMs, iso: new Date(instantMs).toISOString() }
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Build the two-line `DTSTART` + `RRULE` text `ctx.schedule.create` accepts.
|
|
227
|
+
*
|
|
228
|
+
* `DTSTART` carries the zone, so the wall clock the user chose is kept across
|
|
229
|
+
* daylight-saving changes and Host zone changes; `BYDAY`/`BYMONTHDAY` project
|
|
230
|
+
* that same wall clock onto the requested cadence.
|
|
231
|
+
* @param options - repeat frequency, first occurrence instant and explicit zone.
|
|
232
|
+
* @returns normalized recurrence text; an unsupported frequency throws.
|
|
233
|
+
*/
|
|
234
|
+
export function buildRrule({ repeat, instantMs, timeZone }) {
|
|
235
|
+
if (!REPEAT_FREQUENCIES.includes(repeat)) throw new RangeError(`unsupported repeat: ${String(repeat)}`)
|
|
236
|
+
if (!isValidTimeZone(timeZone)) throw new RangeError(`invalid time zone: ${String(timeZone)}`)
|
|
237
|
+
const parts = localParts(instantMs, timeZone)
|
|
238
|
+
const stamp = `${parts.date.replaceAll('-', '')}T${pad2(parts.hour24)}${pad2(parts.minute)}00`
|
|
239
|
+
const start = `DTSTART;TZID=${timeZone}:${stamp}`
|
|
240
|
+
const day = ISO_WEEKDAY_CODES[parts.isoWeekday - 1]
|
|
241
|
+
switch (repeat) {
|
|
242
|
+
case 'daily':
|
|
243
|
+
return `${start}\nRRULE:FREQ=DAILY`
|
|
244
|
+
case 'weekdays':
|
|
245
|
+
return `${start}\nRRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR;WKST=MO`
|
|
246
|
+
case 'weekly':
|
|
247
|
+
return `${start}\nRRULE:FREQ=WEEKLY;BYDAY=${day};WKST=MO`
|
|
248
|
+
default:
|
|
249
|
+
return `${start}\nRRULE:FREQ=MONTHLY;BYMONTHDAY=${parts.day}`
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* Whether a monthly rule on this day of month skips shorter months.
|
|
255
|
+
* @param dayOfMonth - day 1..31.
|
|
256
|
+
* @returns whether the rule cannot fire in every month.
|
|
257
|
+
*/
|
|
258
|
+
export function skipsShortMonths(dayOfMonth) {
|
|
259
|
+
return Number.isInteger(dayOfMonth) && dayOfMonth >= 29 && dayOfMonth <= 31
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Compress a message into the one-line title `ctx.schedule.create` requires.
|
|
264
|
+
* @param text - message text.
|
|
265
|
+
* @param max - longest accepted title; 120 by default.
|
|
266
|
+
* @returns a trimmed single line no longer than `max` characters.
|
|
267
|
+
*/
|
|
268
|
+
export function summariseTitle(text, max = MAX_TITLE_CHARS) {
|
|
269
|
+
const collapsed = String(text ?? '').replace(/\s+/gu, ' ').trim()
|
|
270
|
+
if (collapsed.length === 0) return ''
|
|
271
|
+
if (collapsed.length <= max) return collapsed
|
|
272
|
+
if (max <= 1) return collapsed.slice(0, Math.max(max, 0))
|
|
273
|
+
return `${collapsed.slice(0, max - 1).trimEnd()}…`
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Validate one create request before any durable work happens.
|
|
278
|
+
*
|
|
279
|
+
* Pure: the caller supplies the clock, so the same request validates
|
|
280
|
+
* identically in the browser and in the Host.
|
|
281
|
+
* @param payload - untrusted request body.
|
|
282
|
+
* @param nowMs - the accepting clock.
|
|
283
|
+
* @returns normalized content, or a stable rejection code and message.
|
|
284
|
+
*/
|
|
285
|
+
export function validateCreateRequest(payload, nowMs) {
|
|
286
|
+
const fail = (code, message) => ({ ok: false, code, message })
|
|
287
|
+
if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) {
|
|
288
|
+
return fail('invalid-request', 'The request body must be an object.')
|
|
289
|
+
}
|
|
290
|
+
const { sessionId, dueAt, timeZone } = payload
|
|
291
|
+
if (typeof sessionId !== 'string' || sessionId.length === 0) return fail('invalid-session', 'A session id is required.')
|
|
292
|
+
if (typeof dueAt !== 'string' || !ISO_INSTANT.test(dueAt)) {
|
|
293
|
+
return fail('invalid-due-at', 'dueAt must be an absolute ISO instant with an offset or Z.')
|
|
294
|
+
}
|
|
295
|
+
const dueMs = Date.parse(dueAt)
|
|
296
|
+
if (!Number.isFinite(dueMs)) return fail('invalid-due-at', 'dueAt is not a real instant.')
|
|
297
|
+
if (dueMs < nowMs + MIN_LEAD_MS) {
|
|
298
|
+
return fail('too-soon', `Choose a time at least ${Math.round(MIN_LEAD_MS / 1000)} seconds from now.`)
|
|
299
|
+
}
|
|
300
|
+
if (!isValidTimeZone(timeZone)) return fail('invalid-time-zone', 'timeZone must be an IANA zone name or UTC.')
|
|
301
|
+
|
|
302
|
+
const text = typeof payload.text === 'string' ? payload.text : ''
|
|
303
|
+
if (text.length > MAX_TEXT_CHARS) return fail('text-too-long', `A message may carry at most ${MAX_TEXT_CHARS} characters.`)
|
|
304
|
+
const images = Array.isArray(payload.images) ? payload.images : payload.images === undefined ? [] : null
|
|
305
|
+
if (images === null) return fail('invalid-images', 'images must be an array.')
|
|
306
|
+
if (images.length > MAX_IMAGES) return fail('too-many-images', `A message may carry at most ${MAX_IMAGES} images.`)
|
|
307
|
+
for (const image of images) {
|
|
308
|
+
if (image === null || typeof image !== 'object'
|
|
309
|
+
|| typeof image.mediaType !== 'string' || image.mediaType.length === 0
|
|
310
|
+
|| typeof image.data !== 'string' || image.data.length === 0) {
|
|
311
|
+
return fail('invalid-images', 'Every image needs a mediaType and base64 data.')
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
const rawReceipts = payload.fileReceipts ?? []
|
|
315
|
+
if (!Array.isArray(rawReceipts) || rawReceipts.some(id => typeof id !== 'string' || id.length === 0)) {
|
|
316
|
+
return fail('invalid-files', 'fileReceipts must be a list of receipt ids.')
|
|
317
|
+
}
|
|
318
|
+
const fileReceipts = [...new Set(rawReceipts)]
|
|
319
|
+
|
|
320
|
+
const trimmed = text.trim()
|
|
321
|
+
if (trimmed.length === 0 && images.length === 0 && fileReceipts.length === 0) {
|
|
322
|
+
return fail('empty', 'Type a message or attach something first.')
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const repeat = payload.repeat ?? null
|
|
326
|
+
if (repeat !== null && !REPEAT_FREQUENCIES.includes(repeat)) {
|
|
327
|
+
return fail('invalid-repeat', 'repeat must be daily, weekdays, weekly or monthly.')
|
|
328
|
+
}
|
|
329
|
+
if (repeat !== null && (images.length > 0 || fileReceipts.length > 0)) {
|
|
330
|
+
return fail('repeat-with-attachments', "Repeating messages can't include images or files.")
|
|
331
|
+
}
|
|
332
|
+
if (repeat !== null && trimmed.length === 0) {
|
|
333
|
+
return fail('empty', 'A repeating message needs text.')
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
return {
|
|
337
|
+
ok: true,
|
|
338
|
+
value: {
|
|
339
|
+
sessionId,
|
|
340
|
+
dueAt: new Date(dueMs).toISOString(),
|
|
341
|
+
dueMs,
|
|
342
|
+
timeZone,
|
|
343
|
+
text: trimmed,
|
|
344
|
+
images: images.map(image => ({
|
|
345
|
+
mediaType: image.mediaType,
|
|
346
|
+
data: image.data,
|
|
347
|
+
...(typeof image.name === 'string' && image.name.length > 0 ? { name: image.name } : {}),
|
|
348
|
+
})),
|
|
349
|
+
fileReceipts,
|
|
350
|
+
repeat,
|
|
351
|
+
},
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Pick the record that must be delivered first.
|
|
357
|
+
* @param records - every stored record.
|
|
358
|
+
* @returns the earliest `pending` (or crash-left `sending`) record, if any.
|
|
359
|
+
*/
|
|
360
|
+
export function earliestPending(records) {
|
|
361
|
+
let best
|
|
362
|
+
let bestMs = Number.POSITIVE_INFINITY
|
|
363
|
+
for (const record of records) {
|
|
364
|
+
if (record.status !== 'pending' && record.status !== 'sending') continue
|
|
365
|
+
const dueMs = Date.parse(record.dueAt)
|
|
366
|
+
if (!Number.isFinite(dueMs)) continue
|
|
367
|
+
if (dueMs < bestMs) {
|
|
368
|
+
best = record
|
|
369
|
+
bestMs = dueMs
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
return best
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Clamp the wait for one due instant to what `setTimeout` can hold.
|
|
377
|
+
* @param dueMs - target instant.
|
|
378
|
+
* @param nowMs - current instant.
|
|
379
|
+
* @returns a non-negative delay no larger than {@link MAX_TIMER_DELAY_MS}.
|
|
380
|
+
*/
|
|
381
|
+
export function timerDelay(dueMs, nowMs) {
|
|
382
|
+
const remaining = dueMs - nowMs
|
|
383
|
+
if (!Number.isFinite(remaining)) return MAX_TIMER_DELAY_MS
|
|
384
|
+
return Math.min(Math.max(Math.round(remaining), 0), MAX_TIMER_DELAY_MS)
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/**
|
|
388
|
+
* Whether a delivery happened long enough after its due instant to be reported late.
|
|
389
|
+
* @param dueMs - the instant the message was due.
|
|
390
|
+
* @param deliveredMs - the instant delivery settled.
|
|
391
|
+
* @returns whether the delivery is late by more than {@link LATE_THRESHOLD_MS}.
|
|
392
|
+
*/
|
|
393
|
+
export function isLate(dueMs, deliveredMs) {
|
|
394
|
+
return Number.isFinite(dueMs) && deliveredMs - dueMs > LATE_THRESHOLD_MS
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Choose the finished records that retention should drop.
|
|
399
|
+
* @param records - every stored record.
|
|
400
|
+
* @param nowMs - current instant.
|
|
401
|
+
* @returns ids of finished records older than {@link RETENTION_MS}.
|
|
402
|
+
*/
|
|
403
|
+
export function pruneTargets(records, nowMs) {
|
|
404
|
+
const ids = []
|
|
405
|
+
for (const record of records) {
|
|
406
|
+
if (record.status !== 'sent' && record.status !== 'cancelled') continue
|
|
407
|
+
const settledMs = Date.parse(record.sentAt ?? record.updatedAt ?? record.createdAt)
|
|
408
|
+
if (!Number.isFinite(settledMs)) continue
|
|
409
|
+
if (nowMs - settledMs > RETENTION_MS) ids.push(record.id)
|
|
410
|
+
}
|
|
411
|
+
return ids
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
/**
|
|
415
|
+
* Count the records a session's composer strip should show.
|
|
416
|
+
* @param records - records for one session.
|
|
417
|
+
* @param nowMs - current instant.
|
|
418
|
+
* @returns how many are still scheduled and when the next one fires.
|
|
419
|
+
*/
|
|
420
|
+
export function pendingSummary(records, nowMs) {
|
|
421
|
+
let pending = 0
|
|
422
|
+
let nextMs = Number.POSITIVE_INFINITY
|
|
423
|
+
for (const record of records) {
|
|
424
|
+
if (record.status !== 'pending') continue
|
|
425
|
+
pending += 1
|
|
426
|
+
const dueMs = Date.parse(record.dueAt)
|
|
427
|
+
if (Number.isFinite(dueMs) && dueMs < nextMs) nextMs = dueMs
|
|
428
|
+
}
|
|
429
|
+
return { pending, nextDueMs: Number.isFinite(nextMs) ? nextMs : null, nowMs }
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/**
|
|
433
|
+
* Format one due instant for the summary line and the strip.
|
|
434
|
+
* @param instantMs - the instant to render.
|
|
435
|
+
* @param locale - BCP 47 locale tag.
|
|
436
|
+
* @param timeZone - explicit zone used for display.
|
|
437
|
+
* @returns text such as `Fri, 14 Nov, 9:00 am`; a formatter failure falls back to ISO.
|
|
438
|
+
*/
|
|
439
|
+
export function describeDue(instantMs, locale, timeZone) {
|
|
440
|
+
try {
|
|
441
|
+
return new Intl.DateTimeFormat(locale, {
|
|
442
|
+
dateStyle: 'medium',
|
|
443
|
+
timeStyle: 'short',
|
|
444
|
+
timeZone,
|
|
445
|
+
}).format(new Date(instantMs))
|
|
446
|
+
} catch {
|
|
447
|
+
return new Date(instantMs).toISOString()
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Format one zone's label for a copy line.
|
|
453
|
+
* @param instantMs - the instant whose offset should be labelled.
|
|
454
|
+
* @param locale - BCP 47 locale tag.
|
|
455
|
+
* @param timeZone - explicit zone.
|
|
456
|
+
* @returns the zone's long name when available, else its IANA name.
|
|
457
|
+
*/
|
|
458
|
+
export function describeZone(instantMs, locale, timeZone) {
|
|
459
|
+
try {
|
|
460
|
+
const parts = new Intl.DateTimeFormat(locale, { timeZone, timeZoneName: 'long' }).formatToParts(new Date(instantMs))
|
|
461
|
+
const name = parts.find(part => part.type === 'timeZoneName')?.value
|
|
462
|
+
return typeof name === 'string' && name.length > 0 ? name : timeZone
|
|
463
|
+
} catch {
|
|
464
|
+
return timeZone
|
|
465
|
+
}
|
|
466
|
+
}
|
package/locale/en.json
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
{
|
|
2
|
+
"button.label": "Schedule",
|
|
3
|
+
"button.shortLabel": "Later",
|
|
4
|
+
"button.emptyHint": "Type a message first",
|
|
5
|
+
"button.busyHint": "Wait for the message to finish sending",
|
|
6
|
+
|
|
7
|
+
"shortcut.label": "Schedule message",
|
|
8
|
+
|
|
9
|
+
"attachments.image": "{count} image",
|
|
10
|
+
"attachments.images": "{count} images",
|
|
11
|
+
"attachments.file": "{count} file",
|
|
12
|
+
"attachments.files": "{count} files",
|
|
13
|
+
"attachments.any": "{count} attachment",
|
|
14
|
+
"attachments.anyMany": "{count} attachments",
|
|
15
|
+
|
|
16
|
+
"dialog.title": "Schedule message",
|
|
17
|
+
"dialog.rescheduleTitle": "Change send time",
|
|
18
|
+
"dialog.close": "Close",
|
|
19
|
+
"dialog.date": "Date",
|
|
20
|
+
"dialog.hour": "Hour",
|
|
21
|
+
"dialog.minute": "Minute",
|
|
22
|
+
"dialog.meridiem": "AM or PM",
|
|
23
|
+
"dialog.summary": "Sends {time} ({zone})",
|
|
24
|
+
"dialog.pastWarning": "That time has already passed, or is less than 30 seconds away.",
|
|
25
|
+
"dialog.repeat": "Repeat",
|
|
26
|
+
"dialog.repeatHint": "Create an automation task that sends this text again on a rule.",
|
|
27
|
+
"dialog.repeatLocked": "Repeating messages can't include images or files.",
|
|
28
|
+
"dialog.repeatShortMonths": "Shorter months are skipped.",
|
|
29
|
+
"dialog.submit": "Schedule",
|
|
30
|
+
"dialog.createAutomation": "Create automation",
|
|
31
|
+
"dialog.saveTime": "Save time",
|
|
32
|
+
"dialog.cancel": "Cancel",
|
|
33
|
+
"dialog.noText": "Only attachments",
|
|
34
|
+
|
|
35
|
+
"repeat.daily": "Daily",
|
|
36
|
+
"repeat.weekdays": "Weekdays",
|
|
37
|
+
"repeat.weekly": "Weekly",
|
|
38
|
+
"repeat.monthly": "Monthly",
|
|
39
|
+
"repeat.detail.daily": "Every day at this time.",
|
|
40
|
+
"repeat.detail.weekdays": "Monday to Friday at this time.",
|
|
41
|
+
"repeat.detail.weekly": "Every {weekday} at this time.",
|
|
42
|
+
"repeat.detail.monthly": "Every month on this date at this time.",
|
|
43
|
+
|
|
44
|
+
"weekday.mon": "Monday",
|
|
45
|
+
"weekday.tue": "Tuesday",
|
|
46
|
+
"weekday.wed": "Wednesday",
|
|
47
|
+
"weekday.thu": "Thursday",
|
|
48
|
+
"weekday.fri": "Friday",
|
|
49
|
+
"weekday.sat": "Saturday",
|
|
50
|
+
"weekday.sun": "Sunday",
|
|
51
|
+
|
|
52
|
+
"quick.inHour": "In 1 hour",
|
|
53
|
+
"quick.thisEvening": "This evening, 8:00 PM",
|
|
54
|
+
"quick.tomorrow": "Tomorrow, 9:00 AM",
|
|
55
|
+
"quick.nextMonday": "Next Monday, 9:00 AM",
|
|
56
|
+
|
|
57
|
+
"dock.collapsed": "{count} scheduled · next {next}",
|
|
58
|
+
"dock.collapsedEmpty": "Scheduled messages",
|
|
59
|
+
"dock.noText": "Only attachments",
|
|
60
|
+
"dock.sent": "sent {time}",
|
|
61
|
+
"dock.sentLate": "sent late, {time}",
|
|
62
|
+
"dock.failed": "failed, was due {time}",
|
|
63
|
+
"dock.sending": "sending…",
|
|
64
|
+
"dock.sendNow": "Send now",
|
|
65
|
+
"dock.changeTime": "Change time",
|
|
66
|
+
"dock.cancel": "Cancel",
|
|
67
|
+
"dock.confirmCancel": "Cancel it",
|
|
68
|
+
"dock.keep": "Keep",
|
|
69
|
+
"dock.retry": "Retry",
|
|
70
|
+
|
|
71
|
+
"notice.scheduled": "Scheduled for {time}",
|
|
72
|
+
"notice.automationCreated": "Automation created, first send {time}",
|
|
73
|
+
"notice.rescheduled": "Send time changed to {time}",
|
|
74
|
+
"notice.noComposer": "Open a conversation to schedule a message",
|
|
75
|
+
"notice.busy": "Wait for the message to finish sending",
|
|
76
|
+
"notice.typeFirst": "Type a message first",
|
|
77
|
+
|
|
78
|
+
"error.generic": "That did not work. Try again.",
|
|
79
|
+
"error.tooSoon": "Choose a time at least 30 seconds from now.",
|
|
80
|
+
"error.empty": "Type a message or attach something first.",
|
|
81
|
+
"error.repeatWithAttachments": "Repeating messages can't include images or files.",
|
|
82
|
+
"error.repeatUnavailable": "Repeating messages need the Automation service in this profile.",
|
|
83
|
+
"error.unknownReceipt": "One of the attached files is no longer available; attach it again.",
|
|
84
|
+
"error.unknownSession": "That conversation is no longer available; reopen it and try again.",
|
|
85
|
+
"error.notPending": "That message is no longer waiting to be sent.",
|
|
86
|
+
"error.notFound": "That scheduled message no longer exists.",
|
|
87
|
+
"error.tooManyPending": "At most 500 messages may wait to be sent.",
|
|
88
|
+
"error.invalidImages": "One of the images could not be stored. Try attaching it again.",
|
|
89
|
+
"error.attachmentsUnavailable": "This conversation cannot hand over its attachments.",
|
|
90
|
+
"error.uploading": "Wait for uploads to finish, then try again."
|
|
91
|
+
}
|
package/locale/zh.json
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
{
|
|
2
|
+
"button.label": "定时发送",
|
|
3
|
+
"button.shortLabel": "稍后",
|
|
4
|
+
"button.emptyHint": "先输入内容",
|
|
5
|
+
"button.busyHint": "请等这条消息发送完成",
|
|
6
|
+
|
|
7
|
+
"shortcut.label": "定时发送消息",
|
|
8
|
+
|
|
9
|
+
"attachments.image": "{count} 张图片",
|
|
10
|
+
"attachments.images": "{count} 张图片",
|
|
11
|
+
"attachments.file": "{count} 个文件",
|
|
12
|
+
"attachments.files": "{count} 个文件",
|
|
13
|
+
"attachments.any": "{count} 个附件",
|
|
14
|
+
"attachments.anyMany": "{count} 个附件",
|
|
15
|
+
|
|
16
|
+
"dialog.title": "定时发送消息",
|
|
17
|
+
"dialog.rescheduleTitle": "修改发送时间",
|
|
18
|
+
"dialog.close": "关闭",
|
|
19
|
+
"dialog.date": "日期",
|
|
20
|
+
"dialog.hour": "小时",
|
|
21
|
+
"dialog.minute": "分钟",
|
|
22
|
+
"dialog.meridiem": "上午或下午",
|
|
23
|
+
"dialog.summary": "将于 {time}({zone})发送",
|
|
24
|
+
"dialog.pastWarning": "这个时间已经过去,或距现在不足 30 秒。",
|
|
25
|
+
"dialog.repeat": "重复",
|
|
26
|
+
"dialog.repeatHint": "创建自动化任务,按规则重复发送这段文字。",
|
|
27
|
+
"dialog.repeatLocked": "重复消息不能包含图片或文件。",
|
|
28
|
+
"dialog.repeatShortMonths": "没有这一天的小月会被跳过。",
|
|
29
|
+
"dialog.submit": "定时发送",
|
|
30
|
+
"dialog.createAutomation": "创建自动化",
|
|
31
|
+
"dialog.saveTime": "保存时间",
|
|
32
|
+
"dialog.cancel": "取消",
|
|
33
|
+
"dialog.noText": "仅附件",
|
|
34
|
+
|
|
35
|
+
"repeat.daily": "每天",
|
|
36
|
+
"repeat.weekdays": "工作日",
|
|
37
|
+
"repeat.weekly": "每周",
|
|
38
|
+
"repeat.monthly": "每月",
|
|
39
|
+
"repeat.detail.daily": "每天这个时间发送。",
|
|
40
|
+
"repeat.detail.weekdays": "周一至周五这个时间发送。",
|
|
41
|
+
"repeat.detail.weekly": "每周{weekday}这个时间发送。",
|
|
42
|
+
"repeat.detail.monthly": "每月这一天这个时间发送。",
|
|
43
|
+
|
|
44
|
+
"weekday.mon": "一",
|
|
45
|
+
"weekday.tue": "二",
|
|
46
|
+
"weekday.wed": "三",
|
|
47
|
+
"weekday.thu": "四",
|
|
48
|
+
"weekday.fri": "五",
|
|
49
|
+
"weekday.sat": "六",
|
|
50
|
+
"weekday.sun": "日",
|
|
51
|
+
|
|
52
|
+
"quick.inHour": "1 小时后",
|
|
53
|
+
"quick.thisEvening": "今晚 20:00",
|
|
54
|
+
"quick.tomorrow": "明天 09:00",
|
|
55
|
+
"quick.nextMonday": "下周一 09:00",
|
|
56
|
+
|
|
57
|
+
"dock.collapsed": "{count} 条待发送 · 最近 {next}",
|
|
58
|
+
"dock.collapsedEmpty": "定时消息",
|
|
59
|
+
"dock.noText": "仅附件",
|
|
60
|
+
"dock.sent": "已发送 {time}",
|
|
61
|
+
"dock.sentLate": "已补发 {time}",
|
|
62
|
+
"dock.failed": "发送失败,原定 {time}",
|
|
63
|
+
"dock.sending": "正在发送…",
|
|
64
|
+
"dock.sendNow": "立即发送",
|
|
65
|
+
"dock.changeTime": "改时间",
|
|
66
|
+
"dock.cancel": "取消",
|
|
67
|
+
"dock.confirmCancel": "确认取消",
|
|
68
|
+
"dock.keep": "保留",
|
|
69
|
+
"dock.retry": "重试",
|
|
70
|
+
|
|
71
|
+
"notice.scheduled": "已定时,将于 {time} 发送",
|
|
72
|
+
"notice.automationCreated": "已创建自动化,首次发送 {time}",
|
|
73
|
+
"notice.rescheduled": "发送时间改为 {time}",
|
|
74
|
+
"notice.noComposer": "请先打开一个会话再定时",
|
|
75
|
+
"notice.busy": "请等这条消息发送完成",
|
|
76
|
+
"notice.typeFirst": "先输入内容",
|
|
77
|
+
|
|
78
|
+
"error.generic": "没有成功,请再试一次。",
|
|
79
|
+
"error.tooSoon": "请选择至少 30 秒之后的时间。",
|
|
80
|
+
"error.empty": "请先输入内容或添加附件。",
|
|
81
|
+
"error.repeatWithAttachments": "重复消息不能包含图片或文件。",
|
|
82
|
+
"error.repeatUnavailable": "重复消息需要此配置里的自动化服务。",
|
|
83
|
+
"error.unknownReceipt": "其中一个附件已经不可用,请重新添加。",
|
|
84
|
+
"error.unknownSession": "该会话已不可用,请重新打开后重试。",
|
|
85
|
+
"error.notPending": "这条消息已经不在待发送状态。",
|
|
86
|
+
"error.notFound": "这条定时消息已经不存在。",
|
|
87
|
+
"error.tooManyPending": "最多只能有 500 条待发送消息。",
|
|
88
|
+
"error.invalidImages": "其中一张图片保存失败,请重新添加。",
|
|
89
|
+
"error.attachmentsUnavailable": "此会话无法交出草稿附件。",
|
|
90
|
+
"error.uploading": "请等附件上传完成后再试。"
|
|
91
|
+
}
|