@nsnanocat/preference-panes 0.2.0 → 0.3.0

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.
@@ -1,630 +1,13 @@
1
- /* https://www.lodashjs.com */
2
1
  /**
3
- * 轻量 Lodash 工具集。
4
- * Lightweight Lodash-like utilities.
5
- *
6
- * 说明:
7
- * Notes:
8
- * - 这是 Lodash 的“部分方法”简化实现,不等价于完整 Lodash
9
- * - This is a simplified subset, not a full Lodash implementation
10
- * - 各方法语义可参考 Lodash 官方文档
11
- * - Method semantics can be referenced from official Lodash docs
12
- * - 导入时建议使用 `Lodash as _`,遵循 lodash 官方示例惯例
13
- * - Use `Lodash as _` when importing, following official lodash example convention
14
- *
15
- * 参考:
16
- * Reference:
17
- * - https://www.lodashjs.com
18
- * - https://lodash.com
2
+ * 校验原始路径片段,不进行 URL 编码转换。
3
+ * Validate raw path segments without URL encoding conversion.
4
+ * @param {string[]} parts 原始路径片段 / Raw path segments.
5
+ * @returns {string[]} 同一数组,不复制或修改 / The same array without copying or mutation.
6
+ * @throws {TypeError} 空片段、非法字符或原型属性名 / Empty segments, invalid characters or prototype property names.
19
7
  */
20
- class Lodash {
21
- /**
22
- * HTML 特殊字符转义。
23
- * Escape HTML special characters.
24
- *
25
- * @param {string} string 输入文本 / Input text.
26
- * @returns {string}
27
- * @see {@link https://lodash.com/docs/#escape lodash.escape}
28
- * @see {@link https://www.lodashjs.com/docs/lodash.escape lodash.escape (中文)}
29
- */
30
- static escape(string) {
31
- const map = {
32
- "&": "&",
33
- "<": "&lt;",
34
- ">": "&gt;",
35
- '"': "&quot;",
36
- "'": "&#39;",
37
- };
38
- return string.replace(/[&<>"']/g, m => map[m]);
39
- }
40
-
41
- /**
42
- * 按路径读取对象值。
43
- * Get object value by path.
44
- *
45
- * @param {object} [object={}] 目标对象 / Target object.
46
- * @param {string|string[]} [path=""] 路径 / Path.
47
- * @param {*} [defaultValue=undefined] 默认值 / Default value.
48
- * @returns {*}
49
- * @see {@link https://lodash.com/docs/#get lodash.get}
50
- * @see {@link https://www.lodashjs.com/docs/lodash.get lodash.get (中文)}
51
- */
52
- static get(object = {}, path = "", defaultValue = undefined) {
53
- // translate array case to dot case, then split with .
54
- // a[0].b -> a.0.b -> ['a', '0', 'b']
55
- if (!Array.isArray(path)) path = Lodash.toPath(path);
56
-
57
- const result = path.reduce((previousValue, currentValue) => {
58
- return Object(previousValue)[currentValue]; // null undefined get attribute will throwError, Object() can return a object
59
- }, object);
60
- return result === undefined ? defaultValue : result;
61
- }
62
-
63
- /**
64
- * 递归合并源对象的自身可枚举属性到目标对象
65
- * Recursively merge source enumerable properties into target object.
66
- * @description 简化版 lodash.merge,用于合并配置对象
67
- * @description A simplified lodash.merge for config merging.
68
- *
69
- * 适用情况:
70
- * - 合并嵌套的配置/设置对象
71
- * - 需要深度合并而非浅层覆盖的场景
72
- * - 多个源对象依次合并到目标对象
73
- *
74
- * 限制:
75
- * - 仅处理普通对象 (Plain Object),不处理 Date/RegExp 等特殊对象
76
- * - Map/Set 仅支持同类型合并,不递归内部值
77
- * - 数组会被直接覆盖,不会合并数组元素
78
- * - 不处理循环引用,可能导致栈溢出
79
- * - 不复制 Symbol 属性和不可枚举属性
80
- * - 不保留原型链,仅处理自身属性
81
- * - 会修改原始目标对象 (mutates target)
82
- *
83
- * @param {object} object - 目标对象
84
- * @param {object} object - Target object.
85
- * @param {...object} sources - 源对象(可多个)
86
- * @param {...object} sources - Source objects.
87
- * @returns {object} 返回合并后的目标对象
88
- * @returns {object} Merged target object.
89
- * @see {@link https://lodash.com/docs/#merge lodash.merge}
90
- * @see {@link https://www.lodashjs.com/docs/lodash.merge lodash.merge (中文)}
91
- * @example
92
- * const target = { a: { b: 1 }, c: 2 };
93
- * const source = { a: { d: 3 }, e: 4 };
94
- * Lodash.merge(target, source);
95
- * // => { a: { b: 1, d: 3 }, c: 2, e: 4 }
96
- */
97
- static merge(object, ...sources) {
98
- if (object === null || object === undefined) return object;
99
-
100
- for (const source of sources) {
101
- if (source === null || source === undefined) continue;
102
-
103
- for (const key of Object.keys(source)) {
104
- const sourceValue = source[key];
105
- const targetValue = object[key];
106
-
107
- switch (true) {
108
- case Lodash.#isPlainObject(sourceValue) && Lodash.#isPlainObject(targetValue):
109
- // 递归合并对象
110
- object[key] = Lodash.merge(targetValue, sourceValue);
111
- break;
112
- case sourceValue instanceof Map && targetValue instanceof Map:
113
- // 合并 Map(空 Map 跳过)
114
- if (sourceValue.size > 0) {
115
- for (const [k, v] of sourceValue) {
116
- targetValue.set(k, v);
117
- }
118
- }
119
- break;
120
- case sourceValue instanceof Set && targetValue instanceof Set:
121
- // 合并 Set(空 Set 跳过)
122
- if (sourceValue.size > 0) {
123
- for (const v of sourceValue) {
124
- targetValue.add(v);
125
- }
126
- }
127
- break;
128
- case Array.isArray(sourceValue) && sourceValue.length === 0 && targetValue !== undefined:
129
- // 空数组不覆盖已有值
130
- break;
131
- case (sourceValue instanceof Map && sourceValue.size === 0 && targetValue !== undefined):
132
- case (sourceValue instanceof Set && sourceValue.size === 0 && targetValue !== undefined):
133
- // 空 Map/Set 不覆盖已有值
134
- break;
135
- case sourceValue !== undefined:
136
- object[key] = sourceValue;
137
- break;
138
- }
139
- }
140
- }
141
-
142
- return object;
143
- }
144
-
145
- /**
146
- * 判断值是否为普通对象 (Plain Object)
147
- * Check whether a value is a plain object.
148
- * @param {*} value - 要检查的值
149
- * @param {*} value - Value to check.
150
- * @returns {boolean} 如果是普通对象返回 true
151
- * @returns {boolean} Returns true when value is a plain object.
152
- * @see {@link https://lodash.com/docs/#isPlainObject lodash.isPlainObject}
153
- * @see {@link https://www.lodashjs.com/docs/lodash.isPlainObject lodash.isPlainObject (中文)}
154
- */
155
- static #isPlainObject(value) {
156
- if (value === null || typeof value !== "object") return false;
157
- const proto = Object.getPrototypeOf(value);
158
- return proto === null || proto === Object.prototype;
159
- }
160
-
161
- /**
162
- * 删除对象指定路径并返回对象。
163
- * Omit paths from object and return the same object.
164
- *
165
- * @param {object} [object={}] 目标对象 / Target object.
166
- * @param {string|string[]} [paths=[]] 要删除的路径 / Paths to remove.
167
- * @returns {object}
168
- * @see {@link https://lodash.com/docs/#omit lodash.omit}
169
- * @see {@link https://www.lodashjs.com/docs/lodash.omit lodash.omit (中文)}
170
- */
171
- static omit(object = {}, paths = []) {
172
- if (!Array.isArray(paths)) paths = [paths.toString()];
173
- paths.forEach(path => Lodash.unset(object, path));
174
- return object;
175
- }
176
-
177
- /**
178
- * 仅保留对象指定键(第一层)。
179
- * Pick selected keys from object (top level only).
180
- *
181
- * @param {object} [object={}] 目标对象 / Target object.
182
- * @param {string|string[]} [paths=[]] 需要保留的键 / Keys to keep.
183
- * @returns {object}
184
- * @see {@link https://lodash.com/docs/#pick lodash.pick}
185
- * @see {@link https://www.lodashjs.com/docs/lodash.pick lodash.pick (中文)}
186
- */
187
- static pick(object = {}, paths = []) {
188
- if (!Array.isArray(paths)) paths = [paths.toString()];
189
- const filteredEntries = Object.entries(object).filter(([key, value]) => paths.includes(key));
190
- return Object.fromEntries(filteredEntries);
191
- }
192
-
193
- /**
194
- * 按路径写入对象值。
195
- * Set object value by path.
196
- *
197
- * @param {object} object 目标对象 / Target object.
198
- * @param {string|string[]} path 路径 / Path.
199
- * @param {*} value 写入值 / Value.
200
- * @returns {object}
201
- * @see {@link https://lodash.com/docs/#set lodash.set}
202
- * @see {@link https://www.lodashjs.com/docs/lodash.set lodash.set (中文)}
203
- */
204
- static set(object, path, value) {
205
- if (!Array.isArray(path)) path = Lodash.toPath(path);
206
- path.slice(0, -1).reduce((previousValue, currentValue, currentIndex) => (Object(previousValue[currentValue]) === previousValue[currentValue] ? previousValue[currentValue] : (previousValue[currentValue] = /^\d+$/.test(path[currentIndex + 1]) ? [] : {})), object)[path[path.length - 1]] = value;
207
- return object;
208
- }
209
-
210
- /**
211
- * 将点路径或数组下标路径转换为数组。
212
- * Convert dot/array-index path string into path segments.
213
- *
214
- * @param {string} value 路径字符串 / Path string.
215
- * @returns {string[]}
216
- * @see {@link https://lodash.com/docs/#toPath lodash.toPath}
217
- * @see {@link https://www.lodashjs.com/docs/lodash.toPath lodash.toPath (中文)}
218
- */
219
- static toPath(value) {
220
- return value
221
- .replace(/\[(\d+)\]/g, ".$1")
222
- .split(".")
223
- .filter(Boolean);
224
- }
225
-
226
- /**
227
- * HTML 实体反转义。
228
- * Unescape HTML entities.
229
- *
230
- * @param {string} string 输入文本 / Input text.
231
- * @returns {string}
232
- * @see {@link https://lodash.com/docs/#unescape lodash.unescape}
233
- * @see {@link https://www.lodashjs.com/docs/lodash.unescape lodash.unescape (中文)}
234
- */
235
- static unescape(string) {
236
- const map = {
237
- "&amp;": "&",
238
- "&lt;": "<",
239
- "&gt;": ">",
240
- "&quot;": '"',
241
- "&#39;": "'",
242
- };
243
- return string.replace(/&amp;|&lt;|&gt;|&quot;|&#39;/g, m => map[m]);
244
- }
245
-
246
- /**
247
- * 删除对象路径对应的值。
248
- * Remove value by object path.
249
- *
250
- * @param {object} [object={}] 目标对象 / Target object.
251
- * @param {string|string[]} [path=""] 路径 / Path.
252
- * @returns {boolean}
253
- * @see {@link https://lodash.com/docs/#unset lodash.unset}
254
- * @see {@link https://www.lodashjs.com/docs/lodash.unset lodash.unset (中文)}
255
- */
256
- static unset(object = {}, path = "") {
257
- if (!Array.isArray(path)) path = Lodash.toPath(path);
258
- const result = path.reduce((previousValue, currentValue, currentIndex) => {
259
- if (currentIndex === path.length - 1) {
260
- delete previousValue[currentValue];
261
- return true;
262
- }
263
- return Object(previousValue)[currentValue];
264
- }, object);
265
- return result;
266
- }
267
- }
268
-
269
- class URLSearchParams {
270
- constructor(params, onUpdate) {
271
- switch (typeof params) {
272
- case "string": {
273
- if (params.length === 0)
274
- break;
275
- if (params.startsWith("?"))
276
- params = params.slice(1);
277
- const pairs = params.split("&").map(pair => {
278
- const separator = pair.indexOf("=");
279
- return separator < 0 ? [pair, ""] : [pair.slice(0, separator), pair.slice(separator + 1)];
280
- });
281
- pairs.forEach(([key, value]) => {
282
- this.#params.push(key ? this.#decodeQueryComponent(key) : key);
283
- this.#values.push(this.#decodeQueryComponent(value));
284
- });
285
- break;
286
- }
287
- case "object":
288
- if (Array.isArray(params)) {
289
- Object.entries(params).forEach(([key, value]) => {
290
- this.#params.push(key);
291
- this.#values.push(value);
292
- });
293
- }
294
- else if (Symbol.iterator in Object(params)) {
295
- for (const [key, value] of params) {
296
- this.#params.push(key);
297
- this.#values.push(value);
298
- }
299
- }
300
- break;
301
- }
302
- this.#updateSearchString(this.#params, this.#values);
303
- this.#onUpdate = onUpdate;
304
- }
305
- // Create 2 seperate arrays for the params and values to make management and lookup easier.
306
- #param = "";
307
- #params = [];
308
- #values = [];
309
- #onUpdate;
310
- #decodeQueryComponent(str) {
311
- return decodeURIComponent(str.replace(/\+/g, " "));
312
- }
313
- #encodeQueryComponent(str) {
314
- return encodeURIComponent(str)
315
- .replace(/%20/g, "+")
316
- .replace(/[!'()~]/g, character => `%${character.charCodeAt(0).toString(16).toUpperCase()}`);
317
- }
318
- // Update the search property of the URL instance with the new params and values.
319
- #updateSearchString(params, values) {
320
- if (params.length === 0)
321
- this.#param = "";
322
- else
323
- this.#param = params
324
- .map((param, index) => {
325
- switch (typeof values[index]) {
326
- case "object":
327
- return `${this.#encodeQueryComponent(param)}=${this.#encodeQueryComponent(JSON.stringify(values[index]))}`;
328
- case "boolean":
329
- case "number":
330
- case "string":
331
- return `${this.#encodeQueryComponent(param)}=${this.#encodeQueryComponent(values[index])}`;
332
- case "undefined":
333
- default:
334
- return this.#encodeQueryComponent(param);
335
- }
336
- })
337
- .join("&");
338
- this.#onUpdate?.(this.#param);
339
- }
340
- // Add a given param with a given value to the end.
341
- append(name, value) {
342
- this.#params.push(name);
343
- this.#values.push(value);
344
- this.#updateSearchString(this.#params, this.#values);
345
- }
346
- // Remove all occurances of a given param
347
- delete(name, value) {
348
- while (this.#params.indexOf(name) > -1) {
349
- this.#values.splice(this.#params.indexOf(name), 1);
350
- this.#params.splice(this.#params.indexOf(name), 1);
351
- }
352
- this.#updateSearchString(this.#params, this.#values);
353
- }
354
- // Return an array to be structured in this way: [[param1, value1], [param2, value2]] to mimic the native method's ES6 iterator.
355
- entries() {
356
- return this.#params.map((param, index) => [param, this.#values[index]]);
357
- }
358
- // Return the value matched to the first occurance of a given param.
359
- get(name) {
360
- return this.#values[this.#params.indexOf(name)];
361
- }
362
- // Return all values matched to all occurances of a given param.
363
- getAll(name) {
364
- return this.#values.filter((value, index) => this.#params[index] === name);
365
- }
366
- // Return a boolean to indicate whether a given param exists.
367
- has(name, value) {
368
- return this.#params.indexOf(name) > -1;
369
- }
370
- // Return an array of the param names to mimic the native method's ES6 iterator.
371
- keys() {
372
- return this.#params;
373
- }
374
- // Set a given param to a given value.
375
- set(name, value) {
376
- if (this.#params.indexOf(name) === -1) {
377
- this.append(name, value); // If the given param doesn't already exist, append it.
378
- }
379
- else {
380
- let first = true;
381
- const newValues = [];
382
- // If the param already exists, change the value of the first occurance and remove any remaining occurances.
383
- this.#params = this.#params.filter((currentParam, index) => {
384
- if (currentParam !== name) {
385
- newValues.push(this.#values[index]);
386
- return true;
387
- // If the currentParam matches the one being changed and it's the first one, keep the param and change its value to the given one.
388
- }
389
- else if (first) {
390
- first = false;
391
- newValues.push(value);
392
- return true;
393
- }
394
- // If the currentParam matches the one being changed, but it's not the first, remove it.
395
- return false;
396
- });
397
- this.#values = newValues;
398
- this.#updateSearchString(this.#params, this.#values);
399
- }
400
- }
401
- // Sort all key/value pairs, if any, by their keys then by their values.
402
- sort() {
403
- // Call entries to make sorting easier, then rewrite the params and values in the new order.
404
- const sortedPairs = this.entries().sort();
405
- this.#params = [];
406
- this.#values = [];
407
- sortedPairs.forEach(pair => {
408
- this.#params.push(pair[0]);
409
- this.#values.push(pair[1]);
410
- });
411
- this.#updateSearchString(this.#params, this.#values);
412
- }
413
- // Return the search string without the '?'.
414
- toString = () => this.#param;
415
- // Return and array of the param values to mimic the native method's ES6 iterator..
416
- values = () => this.#values.values();
417
- }
418
-
419
- class URL {
420
- constructor(url, base) {
421
- switch (typeof url) {
422
- case "string": {
423
- const urlIsValid = /^(blob:|file:)?[a-zA-z]+:\/\/.*/.test(url);
424
- const baseIsValid = base ? /^(blob:|file:)?[a-zA-z]+:\/\/.*/.test(base) : false;
425
- // If a string is passed for url instead of location or link, then set the properties of the URL instance.
426
- if (urlIsValid)
427
- this.href = url;
428
- // If the url isn't valid, but the base is, then prepend the base to the url.
429
- else if (baseIsValid)
430
- this.href = base + url;
431
- // If no valid url or base is given, then throw a type error.
432
- else
433
- throw new TypeError('URL string is not valid. If using a relative url, a second argument needs to be passed representing the base URL. Example: new URL("relative/path", "http://www.example.com");');
434
- break;
435
- }
436
- case "object":
437
- break;
438
- default:
439
- throw new TypeError("Invalid argument type.");
440
- }
441
- }
442
- #url = {
443
- hash: "",
444
- host: "",
445
- hostname: "",
446
- href: "",
447
- password: "",
448
- pathname: "",
449
- port: Number.NaN,
450
- protocol: "",
451
- search: "",
452
- searchParams: new URLSearchParams(""),
453
- username: "",
454
- };
455
- // refer: http://www.ietf.org/rfc/rfc3986.txt
456
- static #URLRegExp = /^(?<scheme>([^:\/?#]+):)?(?:\/\/(?<authority>[^\/?#]*))?(?<path>[^?#]*)(?<query>\?([^#]*))?(?<hash>#(.*))?$/;
457
- static #AuthorityRegExp = /^(?<authentication>(?<username>[^:]*)(:(?<password>[^@]*))?@)?(?<hostname>[^:]+)(:(?<port>\d+))?$/;
458
- get hash() {
459
- return this.#url.hash;
460
- }
461
- set hash(value) {
462
- if (value.length !== 0) {
463
- if (value.startsWith("#"))
464
- value = value.slice(1);
465
- this.#url.hash = `#${encodeURIComponent(value)}`;
466
- }
467
- }
468
- get host() {
469
- return this.port.length > 0 ? `${this.hostname}:${this.port}` : this.hostname;
470
- }
471
- set host(value) {
472
- [this.hostname, this.port] = value.split(":", 2);
473
- }
474
- get hostname() {
475
- return encodeURIComponent(this.#url.hostname);
476
- }
477
- set hostname(value) {
478
- this.#url.hostname = value ?? "";
479
- }
480
- get href() {
481
- let authority = "";
482
- if (this.username.length > 0) {
483
- authority += this.username;
484
- if (this.password.length > 0)
485
- authority += `:${this.password}`;
486
- authority += "@";
487
- }
488
- return `${this.protocol}//${authority}${this.host}${this.pathname}${this.search}${this.hash}`;
489
- }
490
- set href(value) {
491
- if (value.startsWith("blob:") || value.startsWith("file:"))
492
- value = value.slice(5);
493
- const urlMatch = value.match(URL.#URLRegExp);
494
- if (!urlMatch)
495
- throw new TypeError("Invalid URL format.");
496
- this.protocol = urlMatch.groups.scheme ?? "";
497
- const authorityMatch = urlMatch.groups.authority.match(URL.#AuthorityRegExp);
498
- this.username = authorityMatch.groups.username ?? "";
499
- this.password = authorityMatch.groups.password ?? "";
500
- this.hostname = authorityMatch.groups.hostname ?? "";
501
- this.port = authorityMatch.groups.port ?? "";
502
- this.pathname = urlMatch.groups.path ?? "";
503
- this.search = urlMatch.groups.query ?? "";
504
- this.hash = urlMatch.groups.hash ?? "";
505
- }
506
- get origin() {
507
- return `${this.protocol}//${this.host}`;
508
- }
509
- get password() {
510
- return encodeURIComponent(this.#url.password);
511
- }
512
- set password(value) {
513
- if (this.username.length > 0)
514
- this.#url.password = value ?? "";
515
- }
516
- get pathname() {
517
- return `/${this.#url.pathname}`;
518
- }
519
- set pathname(value) {
520
- value = `${value}`;
521
- if (value.startsWith("/"))
522
- value = value.slice(1);
523
- this.#url.pathname = value;
524
- }
525
- get port() {
526
- if (Number.isNaN(this.#url.port))
527
- return "";
528
- const port = this.#url.port.toString();
529
- if (this.protocol === "ftp:" && port === "21")
530
- return "";
531
- if (this.protocol === "http:" && port === "80")
532
- return "";
533
- if (this.protocol === "https:" && port === "443")
534
- return "";
535
- return port;
536
- }
537
- set port(value) {
538
- switch (value) {
539
- case "":
540
- this.#url.port = Number.NaN;
541
- break;
542
- default: {
543
- const port = Number.parseInt(value, 10);
544
- if (port >= 0 && port < 65535)
545
- this.#url.port = port;
546
- }
547
- }
548
- }
549
- get protocol() {
550
- return `${this.#url.protocol}:`;
551
- }
552
- set protocol(value) {
553
- if (value.endsWith(":"))
554
- value = value.slice(0, -1);
555
- this.#url.protocol = value;
556
- }
557
- get search() {
558
- if (this.#url.search.length > 0)
559
- return `?${this.#url.search}`;
560
- else
561
- return "";
562
- }
563
- set search(value) {
564
- value = `${value}`;
565
- if (value.startsWith("?"))
566
- value = value.slice(1);
567
- this.#url.search = value;
568
- this.#url.searchParams = new URLSearchParams(this.#url.search, search => {
569
- this.#url.search = search;
570
- });
571
- }
572
- get searchParams() {
573
- return this.#url.searchParams;
574
- }
575
- get username() {
576
- return encodeURIComponent(this.#url.username);
577
- }
578
- set username(value) {
579
- this.#url.username = value ?? "";
580
- }
581
- static parse = (url, base) => new URL(url, base);
582
- /**
583
- * Returns the string representation of the URL.
584
- *
585
- * @returns {string} The href of the URL.
586
- */
587
- toString = () => this.href;
588
- /**
589
- * Converts the URL object properties to a JSON string.
590
- *
591
- * @returns {string} A JSON string representation of the URL object.
592
- */
593
- toJSON = () => JSON.stringify({
594
- hash: this.hash,
595
- host: this.host,
596
- hostname: this.hostname,
597
- href: this.href,
598
- origin: this.origin,
599
- password: this.password,
600
- pathname: this.pathname,
601
- port: this.port,
602
- protocol: this.protocol,
603
- search: this.search,
604
- searchParams: this.searchParams,
605
- username: this.username,
606
- });
607
- }
608
-
609
- /**
610
- * 将 /api/ 后的 URL 路径转换为 util 的路径片段;非 API 路径不处理。
611
- * Convert URL segments after /api/ to util path segments; ignore non-API paths.
612
- * @param {string} url 请求完整 URL / Absolute request URL.
613
- * @returns {string[] | undefined} 键路径片段 / Key path segments.
614
- * @throws {TypeError} API 路径无效或包含危险片段 / Invalid or unsafe API path.
615
- */
616
- function parseSettingsPath(url) {
617
- const pathname = new URL(url).pathname;
618
- if (!pathname.startsWith("/api/")) return;
619
- let parts;
620
- try {
621
- parts = pathname.slice(5).replace(/\/$/, "").split("/").map(decodeURIComponent);
622
- } catch {
623
- throw new TypeError("Invalid encoded key path");
624
- }
625
- if (!parts.every((part) => /^[a-zA-Z0-9_-]+$/.test(part) && !["__proto__", "prototype", "constructor"].includes(part)))
626
- throw new TypeError("Invalid key path");
627
- return parts;
8
+ function validatePathParts(parts) {
9
+ if (!parts.every(part => typeof part === "string" && /^[a-zA-Z0-9_-]+$/.test(part) && !["__proto__", "prototype", "constructor"].includes(part))) throw new TypeError("Invalid key path");
10
+ return parts;
628
11
  }
629
12
 
630
13
  /**
@@ -633,127 +16,148 @@ function parseSettingsPath(url) {
633
16
  * @param {unknown} config BoxJS JSON / BoxJS document.
634
17
  * @param {string} module API 第一段模块名 / First API path segment.
635
18
  * @returns {import("../index.js").ModuleDefinition} 存储根和字段 / Storage root and fields.
19
+ * @throws {TypeError} 配置结构、字段路径、默认值或展示属性无效 / Invalid configuration, field path, default or presentation attribute.
636
20
  */
637
21
  function normalizeBoxJs(config, module) {
638
- parseSettingsPath(`https://example.invalid/api/${module}`);
639
- const apps = Array.isArray(config) ? [] : (config?.apps ?? [config]);
640
- if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
641
- for (const candidate of apps) {
642
- if (!candidate || typeof candidate !== "object") throw new TypeError("Expected BoxJS app object");
643
- if (candidate.settings !== undefined && !Array.isArray(candidate.settings)) throw new TypeError("Expected BoxJS settings array");
644
- }
645
- const owners = apps.filter((candidate) =>
646
- candidate.settings?.some(
647
- (entry) => typeof entry.id === "string" && entry.id.startsWith("@") && entry.id.slice(1).split(".")[1] === module,
648
- ),
649
- );
650
- const entries = Array.isArray(config) ? config : owners.flatMap((candidate) => candidate.settings);
651
- const app = owners.length === 1 ? owners[0] : undefined;
652
- if (!Array.isArray(entries)) throw new TypeError("Expected BoxJS settings array, app or subscription");
653
- let storageKey;
654
- const fields = [];
655
- for (const entry of entries) {
656
- if (typeof entry.id !== "string" || !entry.id.startsWith("@")) throw new TypeError("BoxJS settings require @root.path IDs");
657
- const [root, ...parts] = entry.id.slice(1).split(".");
658
- if (parts[0] !== module) continue;
659
- if (parts.length < 2) throw new TypeError("A BoxJS setting must be below the module root");
660
- parseSettingsPath(`https://example.invalid/api/${parts.map(encodeURIComponent).join("/")}`);
661
- if (!root || (storageKey && root !== storageKey)) throw new TypeError("A module must use one storage root");
662
- storageKey = root;
663
- const type = { boolean: "boolean", checkboxes: "array", selects: "select", text: "string", textarea: "string", number: "number" }[
664
- entry.type
665
- ];
666
- if (!type) throw new TypeError(`Unsupported BoxJS control: ${entry.type}`);
667
- const field = {
668
- key: parts.join("."),
669
- name: entry.name,
670
- type: type === "select" ? typeof entry.val : type,
671
- description: entry.desc ?? "",
672
- control: entry.type,
673
- ...(entry.placeholder === undefined ? {} : { placeholder: entry.placeholder }),
674
- ...(entry.rows === undefined ? {} : { rows: entry.rows }),
675
- ...(entry.autoGrow === undefined ? {} : { autoGrow: entry.autoGrow }),
676
- };
677
- if (type === "select" && !["string", "number", "boolean"].includes(field.type))
678
- throw new TypeError(`Select requires a scalar val: ${entry.id}`);
679
- if (entry.items) field.options = entry.items.map((item) => ({ key: item.key, label: item.label }));
680
- if (Object.hasOwn(entry, "val")) field.defaultValue = normalizeStoredValue(field, entry.val);
681
- if (
682
- typeof field.name !== "string" ||
683
- (field.placeholder !== undefined && typeof field.placeholder !== "string") ||
684
- (field.rows !== undefined && (!Number.isInteger(field.rows) || field.rows < 1)) ||
685
- (field.autoGrow !== undefined && typeof field.autoGrow !== "boolean") ||
686
- fields.some((other) => other.key === field.key || other.key.startsWith(`${field.key}.`) || field.key.startsWith(`${other.key}.`))
687
- )
688
- throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);
689
- if (
690
- field.options &&
691
- (new Set(field.options.map((item) => item.key)).size !== field.options.length ||
692
- field.options.some((item) => !scalar(item.key) || typeof item.label !== "string"))
693
- )
694
- throw new TypeError(`Invalid options: ${entry.id}`);
695
- if (Object.hasOwn(field, "defaultValue") && !validValue(field, field.defaultValue))
696
- throw new TypeError(`Invalid BoxJS val: ${entry.id}`);
697
- fields.push(field);
698
- }
699
- if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
700
- const common = fields[0].key.split(".").slice(0, -1);
701
- for (const field of fields) while (!field.key.startsWith(`${common.join(".")}.`)) common.pop();
702
- const metadata = {};
703
- if (app) {
704
- for (const key of ["id", "name", "author", "repo", "script", "icon", "description", "desc"]) {
705
- if (app[key] === undefined) continue;
706
- if (typeof app[key] !== "string") throw new TypeError(`Invalid BoxJS app ${key}`);
707
- metadata[key] = app[key];
708
- }
709
- for (const key of ["icons", "descs"]) {
710
- if (app[key] === undefined) continue;
711
- if (!Array.isArray(app[key]) || app[key].some((item) => typeof item !== "string")) throw new TypeError(`Invalid BoxJS app ${key}`);
712
- metadata[key] = [...app[key]];
713
- }
714
- }
715
- return {
716
- module,
717
- storageKey,
718
- fields,
719
- settingsPath: common,
720
- ...(Object.keys(metadata).length ? { metadata } : {}),
721
- };
22
+ validatePathParts([module]);
23
+ const apps = Array.isArray(config) ? [] : (config?.apps ?? [config]);
24
+ if (!Array.isArray(apps)) throw new TypeError("Expected BoxJS apps array");
25
+ for (const candidate of apps) {
26
+ if (!candidate || typeof candidate !== "object") throw new TypeError("Expected BoxJS app object");
27
+ if (candidate.settings !== undefined && !Array.isArray(candidate.settings)) throw new TypeError("Expected BoxJS settings array");
28
+ }
29
+ const owners = apps.filter(candidate => candidate.settings?.some(entry => typeof entry.id === "string" && entry.id.startsWith("@") && entry.id.slice(1).split(".")[1] === module));
30
+ const entries = Array.isArray(config) ? config : owners.flatMap(candidate => candidate.settings);
31
+ const app = owners.length === 1 ? owners[0] : undefined;
32
+ let storageKey;
33
+ const fields = [];
34
+ for (const entry of entries) {
35
+ if (typeof entry.id !== "string" || !entry.id.startsWith("@")) throw new TypeError("BoxJS settings require @root.path IDs");
36
+ const [root, ...parts] = entry.id.slice(1).split(".");
37
+ if (parts[0] !== module) continue;
38
+ if (parts.length < 2) throw new TypeError("A BoxJS setting must be below the module root");
39
+ validatePathParts(parts);
40
+ if (!root || (storageKey && root !== storageKey)) throw new TypeError("A module must use one storage root");
41
+ storageKey = root;
42
+ const type = { boolean: "boolean", checkboxes: "array", selects: "select", text: "string", textarea: "string", number: "number" }[entry.type];
43
+ if (!type) throw new TypeError(`Unsupported BoxJS control: ${entry.type}`);
44
+ const field = {
45
+ key: parts.join("."),
46
+ type: type === "select" ? typeof entry.val : type,
47
+
48
+ name: entry.name,
49
+ description: entry.desc ?? "",
50
+ control: entry.type,
51
+ ...(entry.placeholder === undefined ? {} : { placeholder: entry.placeholder }),
52
+ ...(entry.rows === undefined ? {} : { rows: entry.rows }),
53
+ ...(entry.autoGrow === undefined ? {} : { autoGrow: entry.autoGrow }),
54
+ };
55
+ if (type === "select" && !["string", "number", "boolean"].includes(field.type)) throw new TypeError(`Select requires a scalar val: ${entry.id}`);
56
+ if (entry.items) field.options = entry.items.map(item => ({ key: item.key, label: item.label }));
57
+ if (Object.hasOwn(entry, "val")) field.defaultValue = normalizeStoredValue(field, entry.val);
58
+ if (
59
+ typeof field.name !== "string" ||
60
+ (field.placeholder !== undefined && typeof field.placeholder !== "string") ||
61
+ (field.rows !== undefined && (!Number.isInteger(field.rows) || field.rows < 1)) ||
62
+ (field.autoGrow !== undefined && typeof field.autoGrow !== "boolean") ||
63
+ fields.some(other => other.key === field.key || other.key.startsWith(`${field.key}.`) || field.key.startsWith(`${other.key}.`))
64
+ )
65
+ throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);
66
+ if (field.options && (new Set(field.options.map(item => item.key)).size !== field.options.length || field.options.some(item => !scalar(item.key) || typeof item.label !== "string"))) throw new TypeError(`Invalid options: ${entry.id}`);
67
+ if (Object.hasOwn(field, "defaultValue") && !validValue(field, field.defaultValue)) throw new TypeError(`Invalid BoxJS val: ${entry.id}`);
68
+ fields.push(field);
69
+ }
70
+ if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
71
+ const common = fields[0].key.split(".").slice(0, -1);
72
+ for (const field of fields) while (!field.key.startsWith(`${common.join(".")}.`)) common.pop();
73
+ const metadata = {};
74
+ if (app) {
75
+ for (const key of ["id", "name", "author", "repo", "script", "icon", "description", "desc", "icons", "descs"]) {
76
+ if (app[key] === undefined) continue;
77
+ const multiple = key === "icons" || key === "descs";
78
+ const values = multiple ? app[key] : [app[key]];
79
+ if (!Array.isArray(values) || values.some(item => typeof item !== "string")) throw new TypeError(`Invalid BoxJS app ${key}`);
80
+ metadata[key] = multiple ? [...values] : app[key];
81
+ }
82
+ }
83
+ return {
84
+ module,
85
+ storageKey,
86
+ fields,
87
+ settingsPath: common,
88
+ ...(Object.keys(metadata).length ? { metadata } : {}),
89
+ };
722
90
  }
723
91
 
724
92
  /**
725
93
  * 归一化 BoxJS 的字符串存储值,不改变普通文本内容。
726
94
  * Normalize BoxJS string persistence without changing free-text values.
727
- * @param {import("../index.js").SettingsField} field 字段 / Field.
95
+ * @param {import("../index.js").SettingsField} field 前端字段约束 / Frontend field constraints.
728
96
  * @param {unknown} value 存储值 / Stored value.
729
- * @returns {unknown} 控件值 / Control value.
97
+ * @returns {unknown} 转换后的控件值;是否允许写入由 validValue 单独校验 / Converted control value; write eligibility is checked separately by validValue.
730
98
  */
731
99
  function normalizeStoredValue(field, value) {
732
- if (field.type === "boolean" && (value === "true" || value === "false")) return value === "true";
733
- if (field.type === "number" && typeof value === "string" && value.trim() !== "") return Number(value);
734
- if (field.type === "array" && typeof value === "string") value = value === "" || value === "[]" ? [] : value.split(",");
735
- if (field.options) {
736
- const match = (item) => field.options.find((option) => String(option.key) === String(item))?.key ?? item;
737
- return field.type === "array" && Array.isArray(value) ? value.map(match) : match(value);
738
- }
739
- return value;
100
+ switch (field.type) {
101
+ case "boolean":
102
+ if (value === "true" || value === "false") return value === "true";
103
+ break;
104
+ case "number":
105
+ if (typeof value === "string" && value.trim() !== "") return Number(value);
106
+ break;
107
+ case "array":
108
+ if (typeof value === "string") value = value === "" || value === "[]" ? [] : value.split(",");
109
+ break;
110
+ }
111
+ if (field.options) {
112
+ const match = item => field.options.find(option => String(option.key) === String(item))?.key ?? item;
113
+ return field.type === "array" && Array.isArray(value) ? value.map(match) : match(value);
114
+ }
115
+ return value;
740
116
  }
741
117
 
118
+ /**
119
+ * 校验支持的标量范围,包括文本长度与数值有限性。
120
+ * Validate supported scalar bounds, including text length and numeric finiteness.
121
+ * @param {unknown} value 待检查值 / Value to inspect.
122
+ * @returns {boolean} 是否为有效标量 / Whether the scalar is valid.
123
+ */
742
124
  function scalar(value) {
743
- return (
744
- typeof value === "boolean" ||
745
- (typeof value === "string" && value.length <= 2048) ||
746
- (typeof value === "number" && Number.isFinite(value))
747
- );
125
+ switch (typeof value) {
126
+ case "boolean":
127
+ return true;
128
+ case "string":
129
+ return value.length <= 2048;
130
+ case "number":
131
+ return Number.isFinite(value);
132
+ default:
133
+ return false;
134
+ }
748
135
  }
749
136
 
137
+ /**
138
+ * 检查值类型、数组唯一性及声明的选项,不进行转换。
139
+ * Check value type, array uniqueness and declared choices without coercion.
140
+ * @param {import("../index.js").SettingsField} field 前端归一化字段 / Normalized frontend field.
141
+ * @param {unknown} value 待写入的 JSON 值 / JSON value to write.
142
+ * @returns {boolean} 是否符合字段约束 / Whether the value satisfies field constraints.
143
+ */
750
144
  function validValue(field, value) {
751
- if (field.type === "array") {
752
- if (!Array.isArray(value) || value.some((item) => !scalar(item)) || new Set(value).size !== value.length) return false;
753
- } else if (typeof value !== field.type || !scalar(value)) return false;
754
- return !field.options || (field.type === "array" ? value : [value]).every((item) => field.options.some((option) => option.key === item));
145
+ if (field.type === "array") {
146
+ if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;
147
+ } else if (typeof value !== field.type || !scalar(value)) return false;
148
+ return !field.options || (field.type === "array" ? value : [value]).every(item => field.options.some(option => option.key === item));
755
149
  }
756
150
 
151
+ /**
152
+ * 单个模块的临时会话;离开页面后丢弃。
153
+ * Transient module session discarded when leaving the page.
154
+ * @typedef {object} ModuleSession
155
+ * @property {AbortController} controller 读取请求的取消控制器 / Abort controller for reads.
156
+ * @property {import("../index.js").ModuleDefinition | null} definition 加载完成的配置,加载中为 null / Loaded configuration, or null while loading.
157
+ * @property {import("./index.js").ModuleSnapshot["values"]} values 当前显示值 / Current display values.
158
+ * @property {boolean} saving 是否正在写入 / Whether a mutation is in progress.
159
+ */
160
+
757
161
  /**
758
162
  * 创建页面会话缓存;打开时重读,选项操作仅在 HTTP 200 后更新缓存。
759
163
  * Create a page-session cache; reload on open and mutate cache only after HTTP 200.
@@ -761,386 +165,644 @@ function validValue(field, value) {
761
165
  * @returns {import("./index.js").PreferencesClient} 通用客户端 / Generic client.
762
166
  */
763
167
  function createPreferencesClient({ fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 } = {}) {
764
- const sessions = new Map();
765
- async function send(path, method, body, signal, resource = false) {
766
- const controller = new AbortController();
767
- const abort = () => controller.abort();
768
- if (signal?.aborted) abort();
769
- signal?.addEventListener("abort", abort, { once: true });
770
- const timer = setTimeout(abort, timeout);
771
- try {
772
- const response = await request(path, {
773
- method,
774
- credentials: "omit",
775
- cache: "no-store",
776
- signal: controller.signal,
777
- headers: resource ? {} : { "X-Settings-Client": "1", ...(method === "POST" ? { "Content-Type": "application/json" } : {}) },
778
- ...(method === "POST" ? { body: JSON.stringify(body) } : {}),
779
- });
780
- if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
781
- return response;
782
- } finally {
783
- clearTimeout(timer);
784
- signal?.removeEventListener("abort", abort);
785
- }
786
- }
787
- const configPath = (module) => {
788
- if (typeof module !== "string" || !module) throw new TypeError("module is required");
789
- const parts = parseSettingsPath(`https://example.invalid/api/${encodeURIComponent(module)}/`);
790
- if (parts.length !== 1) throw new TypeError("Expected a module name");
791
- return `/configs/${encodeURIComponent(module)}`;
792
- };
793
- const snapshot = (module) => {
794
- const state = sessions.get(module);
795
- if (!state?.definition) throw new Error("Open the module first");
796
- return structuredClone({ definition: state.definition, values: state.values });
797
- };
798
- async function change(module, key, method, value) {
799
- const state = sessions.get(module);
800
- if (!state?.definition) throw new Error("Open the module first");
801
- if (state.saving) throw new Error("A settings write is already in progress");
802
- const field = state.definition.fields.find((field) => field.key === key);
803
- state.saving = true;
804
- try {
805
- if (!field || (method === "POST" && !validValue(field, value))) throw new TypeError("Invalid setting value");
806
- await send(`/api/${key.split(".").map(encodeURIComponent).join("/")}`, method, value);
807
- if (sessions.get(module) === state) {
808
- if (method === "DELETE") {
809
- delete state.values[key];
810
- if (Object.hasOwn(field, "defaultValue")) state.values[key] = structuredClone(field.defaultValue);
811
- } else state.values[key] = structuredClone(value);
812
- }
813
- notify({ kind: "success", operation: method === "DELETE" ? "delete" : "write", module, key });
814
- } catch (error) {
815
- notify({ kind: "error", operation: method === "DELETE" ? "delete" : "write", module, key, message: error.message });
816
- throw error;
817
- } finally {
818
- state.saving = false;
819
- }
820
- }
821
- return {
822
- async probe(module) {
823
- try {
824
- await send(configPath(module), "HEAD", undefined, undefined, true);
825
- return true;
826
- } catch {
827
- return false;
828
- }
829
- },
830
- async open(module) {
831
- const previous = sessions.get(module);
832
- if (previous?.saving) throw new Error("Cannot refresh while saving");
833
- previous?.controller.abort();
834
- const state = { controller: new AbortController(), definition: null, values: {}, saving: false };
835
- sessions.set(module, state);
836
- try {
837
- const resource = configPath(module);
838
- const definition = normalizeBoxJs(await (await send(resource, "GET", undefined, state.controller.signal, true)).json(), module);
839
- if (definition.settingsPath.length < 2) throw new TypeError("BoxJS fields must share a settings subtree below the module root");
840
- const subtree = await (
841
- await send(`/api/${definition.settingsPath.map(encodeURIComponent).join("/")}/`, "GET", undefined, state.controller.signal)
842
- ).json();
843
- if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
844
- if (sessions.get(module) !== state) throw new Error("Module session was replaced");
845
- state.definition = definition;
846
- for (const field of definition.fields) {
847
- const value = Lodash.get(subtree, field.key.split(".").slice(definition.settingsPath.length), field.defaultValue);
848
- if (value !== undefined) state.values[field.key] = normalizeStoredValue(field, value);
849
- }
850
- return snapshot(module);
851
- } catch (error) {
852
- if (sessions.get(module) === state) sessions.delete(module);
853
- throw error;
854
- }
855
- },
856
- snapshot,
857
- leave(module) {
858
- sessions.get(module)?.controller.abort();
859
- sessions.delete(module);
860
- },
861
- set: (module, key, value) => change(module, key, "POST", value),
862
- remove: (module, key) => change(module, key, "DELETE"),
863
- };
168
+ /** @type {Map<string, ModuleSession>} 模块会话表 / Module session map. */
169
+ const sessions = new Map();
170
+ /**
171
+ * 发送同源请求,处理超时与取消;数据 GET 404 交给调用方处理。
172
+ * Send a same-origin request with timeout and cancellation; callers handle missing-data GET responses.
173
+ * @param {string} path 相对请求路径 / Relative request path.
174
+ * @param {"HEAD" | "GET" | "POST" | "DELETE"} method HTTP 方法 / HTTP method.
175
+ * @param {unknown} body POST 值,其它方法忽略 / POST value, ignored by other methods.
176
+ * @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
177
+ * @param {boolean} [resource=false] 是否为无标记头的配置资源 / Whether this is a config resource without the marker header.
178
+ * @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
179
+ * @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
180
+ */
181
+ async function send(path, method, body, signal, resource = false) {
182
+ const controller = new AbortController();
183
+ const abort = () => controller.abort();
184
+ if (signal?.aborted) abort();
185
+ signal?.addEventListener("abort", abort, { once: true });
186
+ const timer = setTimeout(abort, timeout);
187
+ try {
188
+ const response = await request(path, {
189
+ method,
190
+ credentials: "omit",
191
+ cache: "no-store",
192
+ signal: controller.signal,
193
+ headers: resource ? {} : { "X-Settings-Client": "1", ...(method === "POST" ? { "Content-Type": "application/json" } : {}) },
194
+ ...(method === "POST" ? { body: JSON.stringify(body) } : {}),
195
+ });
196
+ if (response.status !== 200 && !(!resource && method === "GET" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
197
+ return response;
198
+ } finally {
199
+ clearTimeout(timer);
200
+ signal?.removeEventListener("abort", abort);
201
+ }
202
+ }
203
+ /**
204
+ * 由合法模块名生成配置 Mock 路径。
205
+ * Build the config Mock path from a valid module name.
206
+ * @param {string} module 模块标识 / Module identifier.
207
+ * @returns {string} 配置路径 / Config path.
208
+ */
209
+ const configPath = module => {
210
+ validatePathParts([module]);
211
+ return `/configs/${encodeURIComponent(module)}`;
212
+ };
213
+ /**
214
+ * 获取独立快照,避免调用方修改内部缓存。
215
+ * Return an independent snapshot so callers cannot mutate the cache.
216
+ * @param {string} module 已打开模块 / Open module.
217
+ * @returns {import("./index.js").ModuleSnapshot} 会话快照 / Session snapshot.
218
+ * @throws {Error} 模块未完成加载 / Module has not finished loading.
219
+ */
220
+ const snapshot = module => {
221
+ const state = sessions.get(module);
222
+ if (!state?.definition) throw new Error("Open the module first");
223
+ return structuredClone({ definition: state.definition, values: state.values });
224
+ };
225
+ /**
226
+ * 串行修改单键,仅成功后更新仍存活的会话。
227
+ * Serialize single-key mutations and update a still-active session only after success.
228
+ * @param {string} module 已打开模块 / Open module.
229
+ * @param {string} key 完整点分字段路径 / Complete dotted field path.
230
+ * @param {"POST" | "DELETE"} method 写入或删除 / Write or delete.
231
+ * @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
232
+ * @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
233
+ * @returns {Promise<void>} 操作完成 / Operation completion.
234
+ * @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
235
+ */
236
+ async function change(module, key, method, value, operation = method === "POST" ? "write" : "delete") {
237
+ const state = sessions.get(module);
238
+ if (!state?.definition) throw new Error("Open the module first");
239
+ if (state.saving) throw new Error("A settings write is already in progress");
240
+ const field = state.definition.fields.find(field => field.key === key);
241
+ state.saving = true;
242
+ try {
243
+ if ((operation === "write" || operation === "delete") && (!field || (method === "POST" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
244
+ await send(`/api/${key.split(".").map(encodeURIComponent).join("/")}`, method, value);
245
+ if (sessions.get(module) === state) {
246
+ switch (operation) {
247
+ case "write":
248
+ state.values[key] = structuredClone(value);
249
+ break;
250
+ case "delete":
251
+ case "clearCaches":
252
+ case "reset":
253
+ for (const candidate of state.definition.fields) {
254
+ if (candidate.key !== key && !candidate.key.startsWith(`${key}.`)) continue;
255
+ delete state.values[candidate.key];
256
+ if (Object.hasOwn(candidate, "defaultValue")) state.values[candidate.key] = structuredClone(candidate.defaultValue);
257
+ }
258
+ break;
259
+ }
260
+ }
261
+ notify({ kind: "success", operation, module, key });
262
+ } catch (error) {
263
+ notify({ kind: "error", operation, module, key, message: error.message });
264
+ throw error;
265
+ } finally {
266
+ state.saving = false;
267
+ }
268
+ }
269
+ return {
270
+ /**
271
+ * 探测配置 Mock,不读写存储。
272
+ * Probe the config Mock without accessing storage.
273
+ * @param {string} module 模块标识 / Module identifier.
274
+ * @returns {Promise<boolean>} 是否返回 HTTP 200 / Whether HTTP 200 was returned.
275
+ */
276
+ async probe(module) {
277
+ try {
278
+ await send(configPath(module), "HEAD", undefined, undefined, true);
279
+ return true;
280
+ } catch {
281
+ return false;
282
+ }
283
+ },
284
+ /**
285
+ * 替换旧会话,读取一次配置与一次设置子树。
286
+ * Replace the previous session and read config and settings subtree once each.
287
+ * @param {string} module 模块标识 / Module identifier.
288
+ * @returns {Promise<import("./index.js").ModuleSnapshot>} 新快照 / New snapshot.
289
+ * @throws {Error} 读取失败、会话被替换或写入尚未完成 / Read failure, replaced session or unfinished write.
290
+ */
291
+ async open(module) {
292
+ const previous = sessions.get(module);
293
+ if (previous?.saving) throw new Error("Cannot refresh while saving");
294
+ previous?.controller.abort();
295
+ const state = { controller: new AbortController(), definition: null, values: {}, saving: false };
296
+ sessions.set(module, state);
297
+ try {
298
+ const definition = normalizeBoxJs(await (await send(configPath(module), "GET", undefined, state.controller.signal, true)).json(), module);
299
+ if (definition.settingsPath.length < 2) throw new TypeError("BoxJS fields must share a settings subtree below the module root");
300
+ const response = await send(`/api/${definition.settingsPath.map(encodeURIComponent).join("/")}/`, "GET", undefined, state.controller.signal);
301
+ let subtree = response.status === 404 ? {} : await response.json();
302
+ if (typeof subtree === "string") subtree = JSON.parse(subtree);
303
+ if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
304
+ if (sessions.get(module) !== state) throw new Error("Module session was replaced");
305
+ state.definition = definition;
306
+ for (const field of definition.fields) {
307
+ const stored = field.key
308
+ .split(".")
309
+ .slice(definition.settingsPath.length)
310
+ .reduce((parent, part) => Object(parent)[part], subtree);
311
+ const value = stored === undefined ? field.defaultValue : stored;
312
+ if (value !== undefined) state.values[field.key] = normalizeStoredValue(field, value);
313
+ }
314
+ return snapshot(module);
315
+ } catch (error) {
316
+ if (sessions.get(module) === state) sessions.delete(module);
317
+ throw error;
318
+ }
319
+ },
320
+ snapshot,
321
+ /**
322
+ * 按需读取模块 Caches,不自动读取其它设置。
323
+ * Read module Caches on demand without refreshing other settings.
324
+ * @param {string} module 已打开的模块 / Open module.
325
+ * @returns {Promise<unknown>} 缓存值,缺失为 undefined / Cache value, or undefined when absent.
326
+ */
327
+ async readCaches(module) {
328
+ const state = sessions.get(module);
329
+ if (!state?.definition) throw new Error("Open the module first");
330
+ const response = await send(`/api/${encodeURIComponent(module)}/Caches`, "GET", undefined, state.controller.signal);
331
+ return response.status === 404 ? undefined : response.json();
332
+ },
333
+ /**
334
+ * 删除整个 Caches 节点,成功后不追加 GET。
335
+ * Delete the entire Caches node without a follow-up GET.
336
+ * @param {string} module 已打开模块 / Open module.
337
+ * @returns {Promise<void>} 清理完成 / Cleanup completion.
338
+ */
339
+ clearCaches: module => change(module, `${module}.Caches`, "DELETE", undefined, "clearCaches"),
340
+ /**
341
+ * 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
342
+ * Delete module persistence and reset the page cache using current BoxJS defaults.
343
+ * @param {string} module 已打开模块 / Open module.
344
+ * @returns {Promise<void>} 重置完成 / Reset completion.
345
+ */
346
+ reset: module => change(module, module, "DELETE", undefined, "reset"),
347
+ /**
348
+ * 取消读取并清除会话,不撤销已发送的写入。
349
+ * Abort reads and clear the session without undoing dispatched writes.
350
+ * @param {string} module 模块标识 / Module identifier.
351
+ * @returns {void} 无返回值 / No return value.
352
+ */
353
+ leave(module) {
354
+ sessions.get(module)?.controller.abort();
355
+ sessions.delete(module);
356
+ },
357
+ /**
358
+ * 写入单键并更新当前会话。
359
+ * Write one key and update the current session.
360
+ * @param {string} module 已打开模块 / Open module.
361
+ * @param {string} key 点分字段路径 / Dotted field path.
362
+ * @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
363
+ * @returns {Promise<void>} 写入完成 / Write completion.
364
+ */
365
+ set: (module, key, value) => change(module, key, "POST", value),
366
+ /**
367
+ * 删除单键覆盖值并显示默认值。
368
+ * Delete one override and display its default value.
369
+ * @param {string} module 已打开模块 / Open module.
370
+ * @param {string} key 点分字段路径 / Dotted field path.
371
+ * @returns {Promise<void>} 删除完成 / Delete completion.
372
+ */
373
+ remove: (module, key) => change(module, key, "DELETE"),
374
+ };
864
375
  }
865
376
 
866
377
  /**
867
378
  * 挂载从 BoxJS 实时生成的设置面板和短暂通知。
868
379
  * Mount runtime-generated BoxJS controls and transient notifications.
869
380
  * @param {import("./index.js").PreferencesPanelOptions} options 容器与请求;页面路径 /settings/{module} 对应配置 / Container and requests; /settings/{module} selects config.
870
- * @returns {{destroy(): void}} 清理接口 / Cleanup handle.
381
+ * @returns {import("./index.js").PreferencesPanel} 面板生命周期句柄 / Panel lifecycle handle.
871
382
  */
872
383
  function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
873
- const document = root.ownerDocument;
874
- const window = document.defaultView;
875
- const node = (tag, className, text) => {
876
- const el = document.createElement(tag);
877
- el.className = className;
878
- if (text !== undefined) el.textContent = text;
879
- return el;
880
- };
881
- const shell = node("div", "pp-panel");
882
- const header = node("header", "pp-header");
883
- const back = node("button", "pp-back", "返回");
884
- back.type = "button";
885
- const heading = node("h1", "pp-title", title);
886
- const viewport = node("div", "pp-viewport");
887
- const toast = node("div", "pp-toast");
888
- toast.setAttribute("role", "status");
889
- toast.hidden = true;
890
- header.append(back, heading);
891
- shell.append(header, viewport, toast);
892
- root.append(shell);
893
- let timer,
894
- routedPath,
895
- generation = 0,
896
- active = null,
897
- saving = false,
898
- pendingRoute = false,
899
- destroyed = false;
900
- const notify = (event) => {
901
- if (destroyed) return;
902
- toast.textContent = event.kind === "error" ? `操作失败:${event.message}` : event.operation === "delete" ? "删除成功" : "修改成功";
903
- toast.dataset.kind = event.kind;
904
- toast.hidden = false;
905
- clearTimeout(timer);
906
- timer = setTimeout(() => {
907
- toast.hidden = true;
908
- }, 2400);
909
- };
910
- const client = createPreferencesClient({ ...(fetch ? { fetch } : {}), notify });
911
- function replace(view, direction) {
912
- const old = viewport.firstElementChild;
913
- viewport.replaceChildren(view);
914
- if (old && !document.defaultView.matchMedia("(prefers-reduced-motion: reduce)").matches)
915
- view.animate(
916
- [
917
- { opacity: 0.4, transform: `translateX(${direction * 24}px)` },
918
- { opacity: 1, transform: "translateX(0)" },
919
- ],
920
- { duration: 180, easing: "ease-out" },
921
- );
922
- }
923
- async function open(module) {
924
- const version = ++generation;
925
- active = module;
926
- back.disabled = window.history.length <= 1;
927
- heading.textContent = module;
928
- replace(node("p", "pp-loading", "读取设置…"), 1);
929
- try {
930
- await client.open(module);
931
- if (version === generation) controls();
932
- } catch (error) {
933
- if (version !== generation) return;
934
- const view = node("section", "pp-error");
935
- view.append(node("p", "", `加载失败:${error.message}`));
936
- const retry = node("button", "", "重新读取");
937
- retry.onclick = () => open(module);
938
- view.append(retry);
939
- replace(view, 1);
940
- }
941
- }
942
- function controls() {
943
- const { definition, values } = client.snapshot(active);
944
- heading.textContent = definition.metadata?.name || active;
945
- const view = node("section", "pp-fields");
946
- const growingInputs = [];
947
- const metadata = definition.metadata;
948
- if (metadata) {
949
- const info = node("div", "pp-module-info");
950
- const iconURL = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
951
- const resourceURL = (value) => {
952
- const url = new window.URL(value, window.location.href);
953
- if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Module metadata URLs must use HTTP or HTTPS");
954
- return url.href;
955
- };
956
- if (iconURL) {
957
- const image = node("img", "pp-module-icon");
958
- image.src = resourceURL(iconURL);
959
- image.alt = "";
960
- info.append(image);
961
- }
962
- const details = node("div", "pp-module-details");
963
- if (metadata.author) details.append(node("p", "pp-description", metadata.author));
964
- for (const description of [metadata.desc ?? metadata.description, ...(metadata.descs ?? [])])
965
- if (description) details.append(node("p", "pp-description", description));
966
- if (metadata.repo) {
967
- const link = node("a", "pp-module-source", "项目主页");
968
- link.href = resourceURL(metadata.repo);
969
- link.target = "_blank";
970
- link.rel = "noopener noreferrer";
971
- details.append(link);
972
- }
973
- info.append(details);
974
- view.append(info);
975
- }
976
- for (const field of definition.fields) {
977
- const row = node("fieldset", "pp-field");
978
- row.append(node("legend", "", field.name));
979
- if (field.description) row.append(node("p", "pp-description", field.description));
980
- const value = values[field.key];
981
- let read, write;
982
- if (field.options && field.type !== "array") {
983
- const select = node("select", "pp-input");
984
- select.setAttribute("aria-label", field.name);
985
- field.options.forEach((option, index) => {
986
- const item = node("option", "", option.label);
987
- item.value = String(index);
988
- select.append(item);
989
- });
990
- write = (value) => {
991
- select.selectedIndex = field.options.findIndex((option) => option.key === value);
992
- };
993
- row.append(select);
994
- read = () => field.options[select.selectedIndex]?.key;
995
- } else if (field.type === "array" && field.options) {
996
- const inputs = field.options.map((option) => {
997
- const label = node("label", "pp-choice", option.label);
998
- const input = node("input", "");
999
- input.type = "checkbox";
1000
- input.checked = Array.isArray(value) && value.includes(option.key);
1001
- label.prepend(input);
1002
- row.append(label);
1003
- return { input, key: option.key };
1004
- });
1005
- read = () => inputs.filter((option) => option.input.checked).map((option) => option.key);
1006
- write = (value) => {
1007
- for (const option of inputs) option.input.checked = Array.isArray(value) && value.includes(option.key);
1008
- };
1009
- } else {
1010
- const multiline = field.control === "textarea" || field.type === "array";
1011
- const input = node(multiline ? "textarea" : "input", "pp-input");
1012
- input.setAttribute("aria-label", field.name);
1013
- if (field.placeholder) input.placeholder = field.placeholder;
1014
- if (multiline && field.rows) input.rows = field.rows;
1015
- const grow = () => {
1016
- if (!multiline || !field.autoGrow || !input.isConnected) return;
1017
- input.style.height = "auto";
1018
- const baseline = input.getBoundingClientRect().height;
1019
- const style = window.getComputedStyle(input);
1020
- const borders = parseFloat(style.borderTopWidth) + parseFloat(style.borderBottomWidth);
1021
- input.style.height = `${Math.max(baseline, input.scrollHeight + borders)}px`;
1022
- };
1023
- if (multiline && field.autoGrow) {
1024
- input.addEventListener("input", grow);
1025
- growingInputs.push(grow);
1026
- }
1027
- if (field.type === "boolean") {
1028
- input.type = "checkbox";
1029
- write = (value) => {
1030
- input.checked = value === true;
1031
- };
1032
- read = () => input.checked;
1033
- } else {
1034
- if (!multiline) input.type = field.type === "number" ? "number" : "text";
1035
- write = (value) => {
1036
- input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
1037
- grow();
1038
- };
1039
- read = () =>
1040
- field.type === "array"
1041
- ? JSON.parse(input.value)
1042
- : field.type === "number"
1043
- ? input.value === ""
1044
- ? Number.NaN
1045
- : Number(input.value)
1046
- : input.value;
1047
- }
1048
- row.append(input);
1049
- }
1050
- write(value);
1051
- const actions = node("div", "pp-actions");
1052
- for (const [operation, label] of [
1053
- ["write", "保存"],
1054
- ["delete", "删除覆盖值"],
1055
- ]) {
1056
- const button = node("button", "", label);
1057
- button.type = "button";
1058
- button.onclick = async () => {
1059
- if (saving) return;
1060
- saving = true;
1061
- back.disabled = true;
1062
- view.querySelectorAll("button,input,select,textarea").forEach((input) => {
1063
- input.disabled = true;
1064
- });
1065
- let success = false;
1066
- try {
1067
- if (operation === "delete") await client.remove(active, field.key);
1068
- else {
1069
- let value;
1070
- try {
1071
- value = read();
1072
- } catch (error) {
1073
- notify({ kind: "error", message: error.message });
1074
- throw error;
1075
- }
1076
- await client.set(active, field.key, value);
1077
- }
1078
- success = true;
1079
- } catch {
1080
- /* 客户端已显示错误通知 / Client already displayed an error notification. */
1081
- } finally {
1082
- saving = false;
1083
- back.disabled = window.history.length <= 1;
1084
- view.querySelectorAll("button,input,select,textarea").forEach((input) => {
1085
- input.disabled = false;
1086
- });
1087
- if (success && !destroyed) {
1088
- // 只更新当前控件,保留其它尚未保存的输入。
1089
- // Update this control without discarding other unsaved inputs.
1090
- write(client.snapshot(active).values[field.key]);
1091
- }
1092
- if (!destroyed && pendingRoute) route();
1093
- }
1094
- };
1095
- actions.append(button);
1096
- }
1097
- row.append(actions);
1098
- view.append(row);
1099
- }
1100
- viewport.replaceChildren(view);
1101
- for (const grow of growingInputs) grow();
1102
- }
1103
- function route() {
1104
- if (saving) {
1105
- pendingRoute = true;
1106
- return;
1107
- }
1108
- pendingRoute = false;
1109
- if (active) client.leave(active);
1110
- routedPath = window.location.pathname;
1111
- const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(routedPath);
1112
- if (!match) {
1113
- generation++;
1114
- active = null;
1115
- heading.textContent = title;
1116
- replace(node("p", "pp-error", "页面地址应为 /settings/模块标识。"), 1);
1117
- return;
1118
- }
1119
- open(match[1]);
1120
- }
1121
- const onPopState = () => {
1122
- if (window.location.pathname !== routedPath) route();
1123
- };
1124
- const onPageShow = (event) => {
1125
- if (event.persisted) route();
1126
- };
1127
- back.onclick = () => {
1128
- if (!saving) window.history.back();
1129
- };
1130
- window.addEventListener("popstate", onPopState);
1131
- window.addEventListener("pageshow", onPageShow);
1132
- route();
1133
- return {
1134
- destroy() {
1135
- destroyed = true;
1136
- window.removeEventListener("popstate", onPopState);
1137
- window.removeEventListener("pageshow", onPageShow);
1138
- generation++;
1139
- if (active) client.leave(active);
1140
- clearTimeout(timer);
1141
- shell.remove();
1142
- },
1143
- };
384
+ const document = root.ownerDocument;
385
+ const window = document.defaultView;
386
+ /**
387
+ * 创建元素,文本统一通过 textContent 写入。
388
+ * Create an element and assign text only through textContent.
389
+ * @template {keyof HTMLElementTagNameMap} T
390
+ * @param {T} tag HTML 标签 / HTML tag.
391
+ * @param {string} className 样式类名 / CSS class name.
392
+ * @param {string} [text] 纯文本内容 / Plain-text content.
393
+ * @returns {HTMLElementTagNameMap[T]} 对应类型的元素 / Element of the corresponding type.
394
+ */
395
+ const node = (tag, className, text) => {
396
+ const el = document.createElement(tag);
397
+ el.className = className;
398
+ if (text !== undefined) el.textContent = text;
399
+ return el;
400
+ };
401
+ const shell = node("div", "pp-panel");
402
+ const header = node("header", "pp-header");
403
+ const back = node("button", "pp-back", "返回");
404
+ back.type = "button";
405
+ const heading = node("h1", "pp-title", title);
406
+ const viewport = node("div", "pp-viewport");
407
+ const toast = node("div", "pp-toast");
408
+ toast.setAttribute("role", "status");
409
+ toast.hidden = true;
410
+ header.append(back, heading);
411
+ shell.append(header, viewport, toast);
412
+ root.append(shell);
413
+ let timer,
414
+ routedPath,
415
+ generation = 0,
416
+ active = null,
417
+ saving = false,
418
+ pendingRoute = false,
419
+ destroyed = false;
420
+ /**
421
+ * 展示短暂通知,不刷新设置数据。
422
+ * Display a transient notification without refreshing settings.
423
+ * @param {{kind: "success" | "error", operation?: "write" | "delete" | "clearCaches" | "reset", message?: string}} event 操作结果 / Operation result.
424
+ * @returns {void} 无返回值 / No return value.
425
+ */
426
+ const notify = event => {
427
+ if (destroyed) return;
428
+ switch (true) {
429
+ case event.kind === "error":
430
+ toast.textContent = `操作失败:${event.message}`;
431
+ break;
432
+ case event.operation === "delete":
433
+ toast.textContent = "删除成功";
434
+ break;
435
+ case event.operation === "clearCaches":
436
+ toast.textContent = "Caches 已清空";
437
+ break;
438
+ case event.operation === "reset":
439
+ toast.textContent = "模块已重置";
440
+ break;
441
+ default:
442
+ toast.textContent = "修改成功";
443
+ break;
444
+ }
445
+ toast.dataset.kind = event.kind;
446
+ toast.hidden = false;
447
+ clearTimeout(timer);
448
+ timer = setTimeout(() => {
449
+ toast.hidden = true;
450
+ }, 2400);
451
+ };
452
+ const client = createPreferencesClient({ fetch, notify });
453
+ /**
454
+ * 切换加载或错误视图,按用户的动态效果偏好播放过渡。
455
+ * Replace a loading or error view, respecting reduced-motion preferences.
456
+ * @param {HTMLElement} view 新视图 / New view.
457
+ * @param {number} direction 过渡方向,正数从右侧进入 / Transition direction; positive enters from the right.
458
+ * @returns {void} 无返回值 / No return value.
459
+ */
460
+ function replace(view, direction) {
461
+ const old = viewport.firstElementChild;
462
+ viewport.replaceChildren(view);
463
+ if (old && !window.matchMedia("(prefers-reduced-motion: reduce)").matches)
464
+ view.animate(
465
+ [
466
+ { opacity: 0.4, transform: `translateX(${direction * 24}px)` },
467
+ { opacity: 1, transform: "translateX(0)" },
468
+ ],
469
+ { duration: 180, easing: "ease-out" },
470
+ );
471
+ }
472
+ /**
473
+ * 打开模块并忽略已过期的异步结果。
474
+ * Open a module and ignore stale asynchronous results.
475
+ * @param {string} module 模块标识 / Module identifier.
476
+ * @returns {Promise<void>} 视图加载完成,失败显示错误视图 / View load completion; failures display an error view.
477
+ */
478
+ async function open(module) {
479
+ const version = ++generation;
480
+ active = module;
481
+ back.disabled = window.history.length <= 1;
482
+ heading.textContent = module;
483
+ replace(node("p", "pp-loading", "读取设置…"), 1);
484
+ try {
485
+ await client.open(module);
486
+ if (version === generation) controls();
487
+ } catch (error) {
488
+ if (version !== generation) return;
489
+ const view = node("section", "pp-error");
490
+ view.append(node("p", "", `加载失败:${error.message}`));
491
+ const retry = node("button", "", "重新读取");
492
+ retry.onclick = () => open(module);
493
+ view.append(retry);
494
+ replace(view, 1);
495
+ }
496
+ }
497
+ /**
498
+ * 从会话快照创建控件与操作按钮,不重新读取网络配置。
499
+ * Build controls and actions from the session snapshot without fetching config again.
500
+ * @returns {void} 无返回值 / No return value.
501
+ */
502
+ function controls() {
503
+ const { definition, values } = client.snapshot(active);
504
+ heading.textContent = definition.metadata?.name || active;
505
+ const view = node("section", "pp-fields");
506
+ /** @type {Array<() => void>} 挂载后执行的多行高度更新 / Textarea sizing callbacks run after mounting. */
507
+ const growingInputs = [];
508
+ /**
509
+ * 写入期间统一切换控件禁用状态。
510
+ * Toggle all control disabled states during mutations.
511
+ * @param {boolean} disabled 是否禁用 / Whether controls are disabled.
512
+ * @returns {void} 无返回值 / No return value.
513
+ */
514
+ const disableControls = disabled => {
515
+ view.querySelectorAll("button,input,select,textarea").forEach(input => {
516
+ input.disabled = disabled;
517
+ });
518
+ };
519
+ /**
520
+ * 执行页面操作,期间锁定控件,完成后处理延后的导航。
521
+ * Run a page action with controls locked, then process deferred navigation.
522
+ * @param {() => Promise<void>} action 请求或写入 / Request or mutation.
523
+ * @param {() => void} success 成功后的局部更新 / Local update after success.
524
+ * @returns {Promise<void>} 操作完成 / Operation completion.
525
+ */
526
+ async function perform(action, success) {
527
+ if (saving) return;
528
+ saving = true;
529
+ back.disabled = true;
530
+ disableControls(true);
531
+ try {
532
+ await action();
533
+ if (!destroyed) success();
534
+ } catch {
535
+ /* 请求层已通知错误 / The request layer has already reported the error. */
536
+ } finally {
537
+ saving = false;
538
+ back.disabled = window.history.length <= 1;
539
+ disableControls(false);
540
+ if (!destroyed && pendingRoute) route();
541
+ }
542
+ }
543
+ const metadata = definition.metadata;
544
+ if (metadata) {
545
+ const info = node("div", "pp-module-info");
546
+ const iconURL = metadata.icon || metadata.icons?.[1] || metadata.icons?.[0];
547
+ /**
548
+ * 将元数据地址解析为可显示的 HTTP(S) URL。
549
+ * Resolve a metadata address into an HTTP(S) URL suitable for display.
550
+ * @param {string} value 绝对或相对地址 / Absolute or relative address.
551
+ * @returns {string} 完整地址 / Absolute URL.
552
+ * @throws {TypeError} 非 HTTP(S) 协议 / Non-HTTP(S) protocol.
553
+ */
554
+ const resourceURL = value => {
555
+ const url = new window.URL(value, window.location.href);
556
+ if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Module metadata URLs must use HTTP or HTTPS");
557
+ return url.href;
558
+ };
559
+ if (iconURL) {
560
+ const image = node("img", "pp-module-icon");
561
+ image.src = resourceURL(iconURL);
562
+ image.alt = "";
563
+ info.append(image);
564
+ }
565
+ const details = node("div", "pp-module-details");
566
+ for (const description of [metadata.author, metadata.desc ?? metadata.description, ...(metadata.descs ?? [])]) if (description) details.append(node("p", "pp-description", description));
567
+ if (metadata.repo) {
568
+ const link = node("a", "pp-module-source", "项目主页");
569
+ link.href = resourceURL(metadata.repo);
570
+ link.target = "_blank";
571
+ link.rel = "noopener noreferrer";
572
+ details.append(link);
573
+ }
574
+ info.append(details);
575
+ view.append(info);
576
+ }
577
+ for (const field of definition.fields) {
578
+ const row = node("fieldset", "pp-field");
579
+ row.append(node("legend", "", field.name));
580
+ if (field.description) row.append(node("p", "pp-description", field.description));
581
+ const value = values[field.key];
582
+ /** @type {() => unknown} 读取尚未保存的输入 / Read the unsaved input. */
583
+ let read;
584
+ /** @type {(value: unknown) => void} 更新当前控件 / Update the current control. */
585
+ let write;
586
+ switch (true) {
587
+ case Boolean(field.options) && field.type !== "array": {
588
+ const select = node("select", "pp-input");
589
+ select.setAttribute("aria-label", field.name);
590
+ field.options.forEach((option, index) => {
591
+ const item = node("option", "", option.label);
592
+ item.value = String(index);
593
+ select.append(item);
594
+ });
595
+ write = value => {
596
+ select.selectedIndex = field.options.findIndex(option => option.key === value);
597
+ };
598
+ row.append(select);
599
+ read = () => field.options[select.selectedIndex]?.key;
600
+ break;
601
+ }
602
+ case field.type === "array" && Boolean(field.options): {
603
+ const inputs = field.options.map(option => {
604
+ const label = node("label", "pp-choice", option.label);
605
+ const input = node("input", "");
606
+ input.type = "checkbox";
607
+ label.prepend(input);
608
+ row.append(label);
609
+ return { input, key: option.key };
610
+ });
611
+ read = () => inputs.filter(option => option.input.checked).map(option => option.key);
612
+ write = value => {
613
+ for (const option of inputs) option.input.checked = Array.isArray(value) && value.includes(option.key);
614
+ };
615
+ break;
616
+ }
617
+ default: {
618
+ const multiline = field.control === "textarea" || field.type === "array";
619
+ const input = node(multiline ? "textarea" : "input", "pp-input");
620
+ input.setAttribute("aria-label", field.name);
621
+ if (field.placeholder) input.placeholder = field.placeholder;
622
+ if (multiline && field.rows) input.rows = field.rows;
623
+ /**
624
+ * 在挂载后根据内容调整高度,同时保留基础行数。
625
+ * Size mounted textareas to their contents while retaining baseline rows.
626
+ * @returns {void} 无返回值 / No return value.
627
+ */
628
+ const grow = () => {
629
+ if (!multiline || !field.autoGrow || !input.isConnected) return;
630
+ input.style.height = "auto";
631
+ const baseline = input.getBoundingClientRect().height;
632
+ const style = window.getComputedStyle(input);
633
+ const borders = Number.parseFloat(style.borderTopWidth) + Number.parseFloat(style.borderBottomWidth);
634
+ input.style.height = `${Math.max(baseline, input.scrollHeight + borders)}px`;
635
+ };
636
+ if (multiline && field.autoGrow) {
637
+ input.addEventListener("input", grow);
638
+ growingInputs.push(grow);
639
+ }
640
+ if (field.type === "boolean") {
641
+ input.type = "checkbox";
642
+ write = value => {
643
+ input.checked = value === true;
644
+ };
645
+ read = () => input.checked;
646
+ } else {
647
+ if (!multiline) input.type = field.type === "number" ? "number" : "text";
648
+ write = value => {
649
+ input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
650
+ grow();
651
+ };
652
+ read = () => {
653
+ switch (field.type) {
654
+ case "array":
655
+ return JSON.parse(input.value);
656
+ case "number":
657
+ return input.value === "" ? Number.NaN : Number(input.value);
658
+ default:
659
+ return input.value;
660
+ }
661
+ };
662
+ }
663
+ row.append(input);
664
+ break;
665
+ }
666
+ }
667
+ write(value);
668
+ const actions = node("div", "pp-actions");
669
+ for (const [operation, label] of [
670
+ ["write", "保存"],
671
+ ["delete", "删除覆盖值"],
672
+ ]) {
673
+ const button = node("button", "", label);
674
+ button.type = "button";
675
+ button.onclick = () =>
676
+ perform(
677
+ async () => {
678
+ if (operation === "delete") await client.remove(active, field.key);
679
+ else {
680
+ let value;
681
+ try {
682
+ value = read();
683
+ } catch (error) {
684
+ notify({ kind: "error", message: error.message });
685
+ throw error;
686
+ }
687
+ await client.set(active, field.key, value);
688
+ }
689
+ },
690
+ () => write(client.snapshot(active).values[field.key]),
691
+ );
692
+ actions.append(button);
693
+ }
694
+ row.append(actions);
695
+ view.append(row);
696
+ }
697
+ const maintenance = node("section", "pp-maintenance");
698
+ maintenance.append(node("h2", "pp-title", "模块数据"));
699
+ const actions = node("div", "pp-actions");
700
+ const cacheView = node("button", "", "查看 Caches");
701
+ const cacheClear = node("button", "", "清空 Caches");
702
+ const reset = node("button", "pp-danger", "重置模块");
703
+ const output = node("pre", "pp-cache");
704
+ output.hidden = true;
705
+ output.setAttribute("aria-label", "Caches 内容");
706
+ for (const button of [cacheView, cacheClear, reset]) button.type = "button";
707
+ cacheView.onclick = () => {
708
+ let value;
709
+ return perform(
710
+ async () => {
711
+ try {
712
+ value = await client.readCaches(active);
713
+ } catch (error) {
714
+ notify({ kind: "error", message: error.message });
715
+ throw error;
716
+ }
717
+ },
718
+ () => {
719
+ output.textContent = value === undefined ? "暂无缓存" : JSON.stringify(value, null, 2);
720
+ output.hidden = false;
721
+ cacheView.textContent = "刷新 Caches";
722
+ },
723
+ );
724
+ };
725
+ cacheClear.onclick = () => {
726
+ if (!window.confirm(`清空 ${active} 的全部 Caches?`)) return;
727
+ return perform(
728
+ () => client.clearCaches(active),
729
+ () => {
730
+ output.textContent = "暂无缓存";
731
+ },
732
+ );
733
+ };
734
+ reset.onclick = () => {
735
+ if (!window.confirm(`重置 ${active}?这将删除该模块的 Settings、Caches 和其它持久化数据。`)) return;
736
+ return perform(() => client.reset(active), controls);
737
+ };
738
+ actions.append(cacheView, cacheClear, reset);
739
+ maintenance.append(actions, output);
740
+ view.append(maintenance);
741
+ viewport.replaceChildren(view);
742
+ for (const grow of growingInputs) grow();
743
+ }
744
+ /**
745
+ * 按页面 pathname 切换模块,写入尚未完成时延后导航。
746
+ * Route by the page pathname, deferring navigation while a mutation is pending.
747
+ * @returns {void} 无返回值 / No return value.
748
+ */
749
+ function route() {
750
+ if (saving) {
751
+ pendingRoute = true;
752
+ return;
753
+ }
754
+ pendingRoute = false;
755
+ if (active) client.leave(active);
756
+ routedPath = window.location.pathname;
757
+ const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(routedPath);
758
+ if (!match) {
759
+ generation++;
760
+ active = null;
761
+ heading.textContent = title;
762
+ replace(node("p", "pp-error", "页面地址应为 /settings/模块标识。"), 1);
763
+ return;
764
+ }
765
+ open(match[1]);
766
+ }
767
+ /**
768
+ * 仅在 pathname 改变时处理历史导航。
769
+ * Handle history navigation only when the pathname changes.
770
+ * @returns {void} 无返回值 / No return value.
771
+ */
772
+ const onPopState = () => {
773
+ if (window.location.pathname !== routedPath) route();
774
+ };
775
+ /**
776
+ * 从浏览器往返缓存恢复时重新读取当前模块。
777
+ * Reload the current module when restored from the browser back-forward cache.
778
+ * @param {PageTransitionEvent} event 页面恢复事件 / Page restoration event.
779
+ * @returns {void} 无返回值 / No return value.
780
+ */
781
+ const onPageShow = event => {
782
+ if (event.persisted) route();
783
+ };
784
+ back.onclick = () => {
785
+ if (!saving) window.history.back();
786
+ };
787
+ window.addEventListener("popstate", onPopState);
788
+ window.addEventListener("pageshow", onPageShow);
789
+ route();
790
+ return {
791
+ /**
792
+ * 移除监听器、定时器、会话和挂载内容。
793
+ * Remove listeners, timers, session and mounted content.
794
+ * @returns {void} 无返回值 / No return value.
795
+ */
796
+ destroy() {
797
+ destroyed = true;
798
+ window.removeEventListener("popstate", onPopState);
799
+ window.removeEventListener("pageshow", onPageShow);
800
+ generation++;
801
+ if (active) client.leave(active);
802
+ clearTimeout(timer);
803
+ shell.remove();
804
+ },
805
+ };
1144
806
  }
1145
807
 
1146
808
  export { createPreferencesClient, mountPreferencePanes };