@game25011/dsh-notify-toast 0.0.0-stage → 0.4.2
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/AI-DISCLOSURE.md +29 -0
- package/CHANGELOG.md +87 -0
- package/LICENSE +22 -0
- package/README.en.md +290 -0
- package/README.md +308 -3
- package/THIRD_PARTY_NOTICES.md +22 -0
- package/assets/sounds/bubble.ogg +0 -0
- package/cordis.patch.yml +5 -0
- package/docs/INTERFACE.md +180 -0
- package/docs/RELEASING.md +282 -0
- package/docs/TEST-REPORT.md +183 -0
- package/docs/assets/native-toast.png +0 -0
- package/dsh-plugin.json +14 -0
- package/icon.svg +17 -0
- package/lib/all-done.js +126 -0
- package/lib/client.js +3486 -0
- package/lib/config.js +420 -0
- package/lib/goal-summary.js +438 -0
- package/lib/index.js +1411 -0
- package/lib/native-focus.js +350 -0
- package/lib/native-toast-helper.cs +206 -0
- package/lib/native-toast.js +372 -0
- package/lib/native-toast.ps1 +72 -0
- package/lib/taskbar-badge-helper.cs +126 -0
- package/lib/taskbar-badge-main.cs +43 -0
- package/lib/taskbar-badge.js +347 -0
- package/lib/taskbar-badge.ps1 +59 -0
- package/lib/text.js +174 -0
- package/locale/en.json +17 -0
- package/locale/zh.json +17 -0
- package/package.json +76 -6
package/lib/config.js
ADDED
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-notify-toast — Host configuration: defaults, patch validation, read
|
|
3
|
+
* helpers, the volatile schemastery Config builder, and the pure completion
|
|
4
|
+
* merge scheduler.
|
|
5
|
+
*
|
|
6
|
+
* Contract: docs/INTERFACE.md §2.4. Everything here is side-effect free and
|
|
7
|
+
* unit-testable; the only platform-dependent step (resolving the profile copy
|
|
8
|
+
* of `@deepseek-ai/schemastery`) lives in lib/index.js.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** package.json name / client module id / loader row id / settings namespace. */
|
|
12
|
+
export const PKG_ID = 'dsh-notify-toast'
|
|
13
|
+
/** Plugin version reported by every RPC endpoint. */
|
|
14
|
+
export const VERSION = '0.4.2'
|
|
15
|
+
/** Official Connection RPC channel (registered by the Host half). */
|
|
16
|
+
export const RPC_CHANNEL = '/dsh-notify-toast'
|
|
17
|
+
/** Settings namespace: the profile patch row id, which is also the package id. */
|
|
18
|
+
export const SETTINGS_NS = PKG_ID
|
|
19
|
+
/** Client locale namespace (client-owned; repeated here for completeness). */
|
|
20
|
+
export const LOCALE_NS = PKG_ID
|
|
21
|
+
|
|
22
|
+
/** Shipped defaults; also the fallback when neither settings nor Config exist. */
|
|
23
|
+
export const DEFAULTS = Object.freeze({
|
|
24
|
+
enabled: true,
|
|
25
|
+
questionEnabled: true,
|
|
26
|
+
approvalEnabled: true,
|
|
27
|
+
completionEnabled: true,
|
|
28
|
+
taskbarBadgeEnabled: true,
|
|
29
|
+
titleOnly: false,
|
|
30
|
+
bodyLength: 40,
|
|
31
|
+
sound: 'ding',
|
|
32
|
+
volume: 60,
|
|
33
|
+
mergeIntervalMs: 2500,
|
|
34
|
+
allDoneTitleEnabled: false,
|
|
35
|
+
allDoneTitle: '所有任务均已完成',
|
|
36
|
+
goalSummaryEnabled: true,
|
|
37
|
+
notificationMode: 'native-progress',
|
|
38
|
+
nativeDurationMs: 8000,
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Whitelist of editable fields with the validation rules from INTERFACE §2.4.
|
|
43
|
+
* Only these keys may be read from a settings patch or written through RPC.
|
|
44
|
+
*/
|
|
45
|
+
export const CONFIG_FIELDS = Object.freeze({
|
|
46
|
+
enabled: { type: 'boolean' },
|
|
47
|
+
questionEnabled: { type: 'boolean' },
|
|
48
|
+
approvalEnabled: { type: 'boolean' },
|
|
49
|
+
completionEnabled: { type: 'boolean' },
|
|
50
|
+
taskbarBadgeEnabled: { type: 'boolean' },
|
|
51
|
+
titleOnly: { type: 'boolean' },
|
|
52
|
+
bodyLength: { type: 'number', min: 20, max: 60, integer: true },
|
|
53
|
+
sound: { type: 'enum', values: Object.freeze(['ding', 'chime', 'bell', 'soft', 'bubble', 'none']) },
|
|
54
|
+
volume: { type: 'number', min: 0, max: 100, integer: true },
|
|
55
|
+
mergeIntervalMs: { type: 'number', min: 0, max: 10000, integer: true },
|
|
56
|
+
allDoneTitleEnabled: { type: 'boolean' },
|
|
57
|
+
allDoneTitle: { type: 'string', minBytes: 1, maxBytes: 96 },
|
|
58
|
+
goalSummaryEnabled: { type: 'boolean' },
|
|
59
|
+
notificationMode: { type: 'enum', values: Object.freeze(['native', 'native-progress', 'native-and-inapp']) },
|
|
60
|
+
nativeDurationMs: { type: 'number', min: 3000, max: 30000, integer: true },
|
|
61
|
+
})
|
|
62
|
+
|
|
63
|
+
/** Field order used when building the schemastery object. */
|
|
64
|
+
export const CONFIG_KEYS = Object.freeze(Object.keys(CONFIG_FIELDS))
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Unwrap one schemastery `.volatile()` reference (`{ get(): value }`) when a
|
|
68
|
+
* Loader-validated Config object is read outside the form projection.
|
|
69
|
+
*
|
|
70
|
+
* @param {unknown} value candidate value.
|
|
71
|
+
* @returns {unknown} the snapshot value, or `undefined` for a broken reference.
|
|
72
|
+
*/
|
|
73
|
+
export function unwrapVolatileValue(value) {
|
|
74
|
+
if (value !== null && typeof value === 'object' && typeof value.get === 'function') {
|
|
75
|
+
try {
|
|
76
|
+
return value.get()
|
|
77
|
+
} catch {
|
|
78
|
+
return undefined
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return value
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Read a Loader-validated config object (volatile refs) into plain values.
|
|
86
|
+
*
|
|
87
|
+
* @param {unknown} config `(ctx, config)` config object or a settings value.
|
|
88
|
+
* @returns {Record<string, unknown>} only known keys that were present.
|
|
89
|
+
*/
|
|
90
|
+
export function unwrapConfigObject(config) {
|
|
91
|
+
const result = {}
|
|
92
|
+
if (config === null || typeof config !== 'object' || Array.isArray(config)) return result
|
|
93
|
+
for (const key of CONFIG_KEYS) {
|
|
94
|
+
if (!Object.hasOwn(config, key)) continue
|
|
95
|
+
const value = unwrapVolatileValue(config[key])
|
|
96
|
+
if (value !== undefined) result[key] = value
|
|
97
|
+
}
|
|
98
|
+
return result
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* UTF-8 byte length without depending on TextEncoder/Buffer.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} text candidate text.
|
|
105
|
+
* @returns {number} byte length.
|
|
106
|
+
*/
|
|
107
|
+
function utf8ByteLength(text) {
|
|
108
|
+
let bytes = 0
|
|
109
|
+
for (const ch of text) {
|
|
110
|
+
const code = ch.codePointAt(0)
|
|
111
|
+
if (code <= 0x7f) bytes += 1
|
|
112
|
+
else if (code <= 0x7ff) bytes += 2
|
|
113
|
+
else if (code <= 0xffff) bytes += 3
|
|
114
|
+
else bytes += 4
|
|
115
|
+
}
|
|
116
|
+
return bytes
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Validate one value against its field spec.
|
|
121
|
+
*
|
|
122
|
+
* @param {{type: string, min?: number, max?: number, integer?: boolean, values?: readonly string[]}} spec field spec.
|
|
123
|
+
* @param {unknown} value candidate.
|
|
124
|
+
* @returns {{ok: true} | {ok: false, message: string}} result.
|
|
125
|
+
*/
|
|
126
|
+
export function checkConfigValue(spec, value) {
|
|
127
|
+
if (spec.type === 'boolean') {
|
|
128
|
+
if (typeof value !== 'boolean') return { ok: false, message: 'expected a boolean' }
|
|
129
|
+
return { ok: true }
|
|
130
|
+
}
|
|
131
|
+
if (spec.type === 'string') {
|
|
132
|
+
if (typeof value !== 'string') return { ok: false, message: 'expected a string' }
|
|
133
|
+
const trimmed = value.trim()
|
|
134
|
+
if (trimmed.length === 0) {
|
|
135
|
+
if (spec.allowEmpty === true) return { ok: true }
|
|
136
|
+
return { ok: false, message: 'must not be empty' }
|
|
137
|
+
}
|
|
138
|
+
if (typeof spec.minBytes === 'number' || typeof spec.maxBytes === 'number') {
|
|
139
|
+
const bytes = utf8ByteLength(trimmed)
|
|
140
|
+
if (typeof spec.minBytes === 'number' && bytes < spec.minBytes) return { ok: false, message: `must be at least ${spec.minBytes} bytes` }
|
|
141
|
+
if (typeof spec.maxBytes === 'number' && bytes > spec.maxBytes) return { ok: false, message: `must be at most ${spec.maxBytes} bytes` }
|
|
142
|
+
}
|
|
143
|
+
return { ok: true }
|
|
144
|
+
}
|
|
145
|
+
if (spec.type === 'enum') {
|
|
146
|
+
if (typeof value !== 'string' || !spec.values.includes(value)) {
|
|
147
|
+
return { ok: false, message: `expected one of ${spec.values.map((v) => `"${v}"`).join(', ')}` }
|
|
148
|
+
}
|
|
149
|
+
return { ok: true }
|
|
150
|
+
}
|
|
151
|
+
// number
|
|
152
|
+
if (typeof value !== 'number' || !Number.isFinite(value)) return { ok: false, message: 'expected a finite number' }
|
|
153
|
+
if (spec.integer && !Number.isInteger(value)) return { ok: false, message: 'expected an integer' }
|
|
154
|
+
if (typeof spec.min === 'number' && value < spec.min) return { ok: false, message: `must be >= ${spec.min}` }
|
|
155
|
+
if (typeof spec.max === 'number' && value > spec.max) return { ok: false, message: `must be <= ${spec.max}` }
|
|
156
|
+
return { ok: true }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Validate an RPC `save` patch: whitelist + type + range/enum checks only.
|
|
161
|
+
*
|
|
162
|
+
* Unknown keys are rejected (no silent drops) so a stale client cannot write a
|
|
163
|
+
* field this Host version does not understand.
|
|
164
|
+
*
|
|
165
|
+
* @param {unknown} patch candidate patch object.
|
|
166
|
+
* @returns {{ok: true, patch: object} | {ok: false, error: {code: string, message: string}}} result.
|
|
167
|
+
*/
|
|
168
|
+
export function validatePatch(patch) {
|
|
169
|
+
if (patch === null || typeof patch !== 'object' || Array.isArray(patch)) {
|
|
170
|
+
return { ok: false, error: { code: 'invalid-patch', message: 'patch must be a plain object' } }
|
|
171
|
+
}
|
|
172
|
+
const clean = {}
|
|
173
|
+
for (const key of Object.keys(patch)) {
|
|
174
|
+
const spec = CONFIG_FIELDS[key]
|
|
175
|
+
if (!spec) {
|
|
176
|
+
return { ok: false, error: { code: 'unknown-field', message: `unknown config field "${key}"` } }
|
|
177
|
+
}
|
|
178
|
+
const value = patch[key]
|
|
179
|
+
const checked = checkConfigValue(spec, value)
|
|
180
|
+
if (!checked.ok) {
|
|
181
|
+
return { ok: false, error: { code: 'invalid-value', message: `config field "${key}" ${checked.message}` } }
|
|
182
|
+
}
|
|
183
|
+
clean[key] = value
|
|
184
|
+
}
|
|
185
|
+
return { ok: true, patch: clean }
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Normalize any config-shaped value (defaults + only valid known fields).
|
|
190
|
+
*
|
|
191
|
+
* Invalid or unknown values are ignored rather than thrown: this is the read
|
|
192
|
+
* path, which must keep the plugin usable even when the settings document was
|
|
193
|
+
* edited by hand.
|
|
194
|
+
*
|
|
195
|
+
* @param {unknown} raw defaults base, Loader config object or settings value.
|
|
196
|
+
* @returns {typeof DEFAULTS} a fresh plain config object.
|
|
197
|
+
*/
|
|
198
|
+
export function normalizeConfig(raw) {
|
|
199
|
+
const result = { ...DEFAULTS }
|
|
200
|
+
const plain = unwrapConfigObject(raw)
|
|
201
|
+
for (const key of CONFIG_KEYS) {
|
|
202
|
+
if (!Object.hasOwn(plain, key)) continue
|
|
203
|
+
const value = plain[key]
|
|
204
|
+
if (checkConfigValue(CONFIG_FIELDS[key], value).ok) result[key] = value
|
|
205
|
+
}
|
|
206
|
+
return result
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Merge a validated patch over a base config.
|
|
211
|
+
*
|
|
212
|
+
* @param {unknown} base current config (normalized first).
|
|
213
|
+
* @param {unknown} patch patch object; invalid fields are ignored.
|
|
214
|
+
* @returns {typeof DEFAULTS} merged plain config.
|
|
215
|
+
*/
|
|
216
|
+
export function mergeConfig(base, patch) {
|
|
217
|
+
const merged = normalizeConfig(base)
|
|
218
|
+
const plain = unwrapConfigObject(patch)
|
|
219
|
+
for (const key of CONFIG_KEYS) {
|
|
220
|
+
if (!Object.hasOwn(plain, key)) continue
|
|
221
|
+
const value = plain[key]
|
|
222
|
+
if (checkConfigValue(CONFIG_FIELDS[key], value).ok) merged[key] = value
|
|
223
|
+
}
|
|
224
|
+
return merged
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Build the all-volatile schemastery Config object.
|
|
229
|
+
*
|
|
230
|
+
* Every field is `.volatile()`: the official settings layer only persists and
|
|
231
|
+
* hot-applies volatile fields (docs/INTERFACE.md), and the Loader then updates
|
|
232
|
+
* the stable config references in place without remounting the plugin.
|
|
233
|
+
*
|
|
234
|
+
* @param {{object: Function, boolean: Function, number: Function, union: Function}} Schema
|
|
235
|
+
* the schemastery default export (resolved from the profile in lib/index.js).
|
|
236
|
+
* @returns {unknown} a schemastery schema object.
|
|
237
|
+
* @throws {TypeError} when the supplied module does not look like schemastery.
|
|
238
|
+
*/
|
|
239
|
+
export function buildConfigSchema(Schema) {
|
|
240
|
+
if (!Schema || typeof Schema.object !== 'function' || typeof Schema.boolean !== 'function') {
|
|
241
|
+
throw new TypeError('schemastery module is missing Schema.object/boolean')
|
|
242
|
+
}
|
|
243
|
+
return Schema.object({
|
|
244
|
+
enabled: Schema.boolean().default(DEFAULTS.enabled).volatile(),
|
|
245
|
+
questionEnabled: Schema.boolean().default(DEFAULTS.questionEnabled).volatile(),
|
|
246
|
+
approvalEnabled: Schema.boolean().default(DEFAULTS.approvalEnabled).volatile(),
|
|
247
|
+
completionEnabled: Schema.boolean().default(DEFAULTS.completionEnabled).volatile(),
|
|
248
|
+
taskbarBadgeEnabled: Schema.boolean().default(DEFAULTS.taskbarBadgeEnabled).volatile(),
|
|
249
|
+
titleOnly: Schema.boolean().default(DEFAULTS.titleOnly).volatile(),
|
|
250
|
+
bodyLength: Schema.number().min(20).max(60).step(1).default(DEFAULTS.bodyLength).volatile(),
|
|
251
|
+
sound: Schema.union(['ding', 'chime', 'bell', 'soft', 'bubble', 'none']).default(DEFAULTS.sound).volatile(),
|
|
252
|
+
volume: Schema.number().min(0).max(100).step(1).default(DEFAULTS.volume).volatile(),
|
|
253
|
+
mergeIntervalMs: Schema.number().min(0).max(10000).step(100).default(DEFAULTS.mergeIntervalMs).volatile(),
|
|
254
|
+
allDoneTitleEnabled: Schema.boolean().default(DEFAULTS.allDoneTitleEnabled).volatile(),
|
|
255
|
+
allDoneTitle: Schema.string().default(DEFAULTS.allDoneTitle).volatile(),
|
|
256
|
+
goalSummaryEnabled: Schema.boolean().default(DEFAULTS.goalSummaryEnabled).volatile(),
|
|
257
|
+
notificationMode: Schema.union(['native', 'native-progress', 'native-and-inapp']).default(DEFAULTS.notificationMode).volatile(),
|
|
258
|
+
nativeDurationMs: Schema.number().min(3000).max(30000).step(100).default(DEFAULTS.nativeDurationMs).volatile(),
|
|
259
|
+
})
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/**
|
|
263
|
+
* Create the completion merge scheduler (INTERFACE §2.5 item 5).
|
|
264
|
+
*
|
|
265
|
+
* The scheduler batches completion events for `mergeIntervalMs`: the first
|
|
266
|
+
* event opens a batch and schedules one timer; later events inside the window
|
|
267
|
+
* update the batch (latest text/session, count++) instead of publishing. The
|
|
268
|
+
* timer is the only stateful dependency, so tests inject fake timer functions.
|
|
269
|
+
*
|
|
270
|
+
* Semantics:
|
|
271
|
+
* - a duplicate (sessionId+turn) is rejected before entering the batch;
|
|
272
|
+
* - `count` is published only when it is greater than 1 (default is 1);
|
|
273
|
+
* - a later completion without text keeps the last non-empty text;
|
|
274
|
+
* - `dispose()` drops a pending batch without publishing (the plugin is
|
|
275
|
+
* unloading, so the event queue is going away too).
|
|
276
|
+
*
|
|
277
|
+
* @param {object} [options]
|
|
278
|
+
* @param {(payload: object) => unknown} [options.publish] receives the merged
|
|
279
|
+
* `kind:'complete'` payload (without id/at; the queue adds those).
|
|
280
|
+
* @param {() => number} [options.now] clock (unused placeholders for callers).
|
|
281
|
+
* @param {(callback: () => void, ms: number) => unknown} [options.setTimer] timer factory.
|
|
282
|
+
* @param {(timer: unknown) => void} [options.clearTimer] timer canceller.
|
|
283
|
+
* @param {() => number} [options.getIntervalMs] reads the current merge window.
|
|
284
|
+
* @param {(sessionId: string|undefined, turn: number|undefined) => boolean} [options.isDuplicate]
|
|
285
|
+
* dedupe predicate consulted for every queued completion.
|
|
286
|
+
* @returns {{queue: Function, flushNow: Function, pending: Function, stats: Function, dispose: Function}}
|
|
287
|
+
*/
|
|
288
|
+
export function createMergeScheduler(options = {}) {
|
|
289
|
+
const {
|
|
290
|
+
publish = () => {},
|
|
291
|
+
now = () => Date.now(),
|
|
292
|
+
setTimer = (callback, ms) => setTimeout(callback, ms),
|
|
293
|
+
clearTimer = (timer) => clearTimeout(timer),
|
|
294
|
+
getIntervalMs = () => DEFAULTS.mergeIntervalMs,
|
|
295
|
+
isDuplicate = () => false,
|
|
296
|
+
} = options
|
|
297
|
+
|
|
298
|
+
let batch = null
|
|
299
|
+
let batchSessionIds = new Set()
|
|
300
|
+
let timer = null
|
|
301
|
+
let disposed = false
|
|
302
|
+
let merged = 0
|
|
303
|
+
|
|
304
|
+
const clearPendingTimer = () => {
|
|
305
|
+
if (timer === null) return
|
|
306
|
+
try {
|
|
307
|
+
clearTimer(timer)
|
|
308
|
+
} catch {
|
|
309
|
+
/* timer already gone */
|
|
310
|
+
}
|
|
311
|
+
timer = null
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
const flush = () => {
|
|
315
|
+
if (batch === null) return null
|
|
316
|
+
const current = batch
|
|
317
|
+
const sessionIds = batchSessionIds
|
|
318
|
+
batch = null
|
|
319
|
+
batchSessionIds = new Set()
|
|
320
|
+
clearPendingTimer()
|
|
321
|
+
const payload = {
|
|
322
|
+
kind: 'complete',
|
|
323
|
+
text: current.text === null ? null : current.text,
|
|
324
|
+
}
|
|
325
|
+
// INTERFACE §2.5: `count` is only carried when a batch actually merged.
|
|
326
|
+
if (current.count > 1) payload.count = current.count
|
|
327
|
+
if (sessionIds.size > 1) payload.sessionIds = Array.from(sessionIds)
|
|
328
|
+
if (current.allDone === true) payload.allDone = true
|
|
329
|
+
if (current.sessionId !== undefined) payload.sessionId = current.sessionId
|
|
330
|
+
if (current.sessionTitle !== undefined) payload.sessionTitle = current.sessionTitle
|
|
331
|
+
try {
|
|
332
|
+
publish(payload)
|
|
333
|
+
} catch {
|
|
334
|
+
/* publishing is the caller's problem; never break the timer chain */
|
|
335
|
+
}
|
|
336
|
+
return payload
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
const schedule = () => {
|
|
340
|
+
let interval
|
|
341
|
+
try {
|
|
342
|
+
interval = Number(getIntervalMs())
|
|
343
|
+
} catch {
|
|
344
|
+
interval = DEFAULTS.mergeIntervalMs
|
|
345
|
+
}
|
|
346
|
+
if (!Number.isFinite(interval) || interval <= 0) {
|
|
347
|
+
flush()
|
|
348
|
+
return
|
|
349
|
+
}
|
|
350
|
+
try {
|
|
351
|
+
timer = setTimer(() => {
|
|
352
|
+
timer = null
|
|
353
|
+
flush()
|
|
354
|
+
}, interval)
|
|
355
|
+
} catch {
|
|
356
|
+
timer = null
|
|
357
|
+
flush()
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
return {
|
|
362
|
+
/**
|
|
363
|
+
* Queue one completed root turn.
|
|
364
|
+
* @param {{sessionId?: string, sessionTitle?: string, text?: string|null, turn?: number, allDone?: boolean}} entry
|
|
365
|
+
* @returns {boolean} whether the entry was accepted.
|
|
366
|
+
*/
|
|
367
|
+
queue(entry) {
|
|
368
|
+
if (disposed) return false
|
|
369
|
+
const current = entry && typeof entry === 'object' ? entry : {}
|
|
370
|
+
try {
|
|
371
|
+
if (isDuplicate(current.sessionId, current.turn)) return false
|
|
372
|
+
} catch {
|
|
373
|
+
/* a broken predicate must not swallow the completion */
|
|
374
|
+
}
|
|
375
|
+
const text = typeof current.text === 'string' && current.text !== '' ? current.text : null
|
|
376
|
+
if (batch !== null) {
|
|
377
|
+
batch.count += 1
|
|
378
|
+
merged += 1
|
|
379
|
+
if (current.allDone === true) batch.allDone = true
|
|
380
|
+
if (current.sessionId !== undefined) {
|
|
381
|
+
batch.sessionId = current.sessionId
|
|
382
|
+
batchSessionIds.add(current.sessionId)
|
|
383
|
+
}
|
|
384
|
+
if (current.sessionTitle !== undefined) batch.sessionTitle = current.sessionTitle
|
|
385
|
+
if (text !== null) batch.text = text
|
|
386
|
+
return true
|
|
387
|
+
}
|
|
388
|
+
batchSessionIds = new Set(current.sessionId !== undefined ? [current.sessionId] : [])
|
|
389
|
+
batch = {
|
|
390
|
+
sessionId: current.sessionId,
|
|
391
|
+
sessionTitle: current.sessionTitle,
|
|
392
|
+
text,
|
|
393
|
+
count: 1,
|
|
394
|
+
openedAt: now(),
|
|
395
|
+
}
|
|
396
|
+
if (current.allDone === true) batch.allDone = true
|
|
397
|
+
schedule()
|
|
398
|
+
return true
|
|
399
|
+
},
|
|
400
|
+
/** Publish the pending batch immediately (used by tests and flush hooks). */
|
|
401
|
+
flushNow() {
|
|
402
|
+
return flush()
|
|
403
|
+
},
|
|
404
|
+
/** Snapshot of the pending batch, or null. */
|
|
405
|
+
pending() {
|
|
406
|
+
return batch === null ? null : { ...batch }
|
|
407
|
+
},
|
|
408
|
+
/** Cumulative merge counters for the settings page. */
|
|
409
|
+
stats() {
|
|
410
|
+
return { merged }
|
|
411
|
+
},
|
|
412
|
+
/** Drop the pending batch and timer; safe to call twice. */
|
|
413
|
+
dispose() {
|
|
414
|
+
disposed = true
|
|
415
|
+
clearPendingTimer()
|
|
416
|
+
batch = null
|
|
417
|
+
batchSessionIds = new Set()
|
|
418
|
+
},
|
|
419
|
+
}
|
|
420
|
+
}
|