@nsnanocat/preference-panes 0.1.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.
- package/LICENSE +201 -0
- package/README.md +109 -0
- package/browser/client.mjs +112 -0
- package/browser/index.mjs +2 -0
- package/browser/panel.css +103 -0
- package/browser/panel.mjs +233 -0
- package/dist/preference-panes.mjs +720 -0
- package/dist/preference-panes.request.js +2129 -0
- package/index.mjs +3 -0
- package/lib/boxjs.mjs +90 -0
- package/lib/settings-handler.mjs +113 -0
- package/lib/settings-path.mjs +20 -0
- package/package.json +69 -0
- package/proxy/request.mjs +36 -0
- package/types/browser.d.ts +32 -0
- package/types/index.d.ts +52 -0
|
@@ -0,0 +1,720 @@
|
|
|
1
|
+
/* https://www.lodashjs.com */
|
|
2
|
+
/**
|
|
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
|
|
19
|
+
*/
|
|
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
|
+
"<": "<",
|
|
34
|
+
">": ">",
|
|
35
|
+
'"': """,
|
|
36
|
+
"'": "'",
|
|
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
|
+
"&": "&",
|
|
238
|
+
"<": "<",
|
|
239
|
+
">": ">",
|
|
240
|
+
""": '"',
|
|
241
|
+
"'": "'",
|
|
242
|
+
};
|
|
243
|
+
return string.replace(/&|<|>|"|'/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
|
+
/**
|
|
270
|
+
* 将 /api/ 后的 URL 路径转换为 util 的路径片段;非 API 路径不处理。
|
|
271
|
+
* Convert URL segments after /api/ to util path segments; ignore non-API paths.
|
|
272
|
+
* @param {string} url 请求完整 URL / Absolute request URL.
|
|
273
|
+
* @returns {string[] | undefined} 键路径片段 / Key path segments.
|
|
274
|
+
* @throws {TypeError} API 路径无效或包含危险片段 / Invalid or unsafe API path.
|
|
275
|
+
*/
|
|
276
|
+
function parseSettingsPath(url) {
|
|
277
|
+
const pathname = new URL(url).pathname;
|
|
278
|
+
if (!pathname.startsWith("/api/")) return;
|
|
279
|
+
let parts;
|
|
280
|
+
try {
|
|
281
|
+
parts = pathname.slice(5).replace(/\/$/, "").split("/").map(decodeURIComponent);
|
|
282
|
+
} catch {
|
|
283
|
+
throw new TypeError("Invalid encoded key path");
|
|
284
|
+
}
|
|
285
|
+
if (!parts.every((part) => /^[a-zA-Z0-9_-]+$/.test(part) && !["__proto__", "prototype", "constructor"].includes(part)))
|
|
286
|
+
throw new TypeError("Invalid key path");
|
|
287
|
+
return parts;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 将 BoxJS 数组、app 或订阅转换为模块字段,保留原文件为唯一字段来源。
|
|
292
|
+
* Normalize a BoxJS array, app or subscription using the source JSON as the field authority.
|
|
293
|
+
* @param {unknown} config BoxJS JSON / BoxJS document.
|
|
294
|
+
* @param {string} module API 第一段模块名 / First API path segment.
|
|
295
|
+
* @returns {import("../types/index.js").ModuleDefinition} 存储根和字段 / Storage root and fields.
|
|
296
|
+
*/
|
|
297
|
+
function normalizeBoxJs(config, module) {
|
|
298
|
+
parseSettingsPath(`https://example.invalid/api/${module}`);
|
|
299
|
+
const entries = Array.isArray(config) ? config : config?.apps ? config.apps.flatMap((app) => app.settings ?? []) : config?.settings;
|
|
300
|
+
if (!Array.isArray(entries)) throw new TypeError("Expected BoxJS settings array, app or subscription");
|
|
301
|
+
let storageKey;
|
|
302
|
+
const fields = [];
|
|
303
|
+
for (const entry of entries) {
|
|
304
|
+
if (typeof entry.id !== "string" || !entry.id.startsWith("@")) throw new TypeError("BoxJS settings require @root.path IDs");
|
|
305
|
+
const [root, ...parts] = entry.id.slice(1).split(".");
|
|
306
|
+
if (parts[0] !== module) continue;
|
|
307
|
+
if (parts.length < 2) throw new TypeError("A BoxJS setting must be below the module root");
|
|
308
|
+
parseSettingsPath(`https://example.invalid/api/${parts.map(encodeURIComponent).join("/")}`);
|
|
309
|
+
if (!root || (storageKey && root !== storageKey)) throw new TypeError("A module must use one storage root");
|
|
310
|
+
storageKey = root;
|
|
311
|
+
const type = { boolean: "boolean", checkboxes: "array", selects: "select", text: "string", textarea: "string", number: "number" }[
|
|
312
|
+
entry.type
|
|
313
|
+
];
|
|
314
|
+
if (!type) throw new TypeError(`Unsupported BoxJS control: ${entry.type}`);
|
|
315
|
+
const field = {
|
|
316
|
+
key: parts.join("."),
|
|
317
|
+
name: entry.name,
|
|
318
|
+
type: type === "select" ? typeof entry.val : type,
|
|
319
|
+
description: entry.desc ?? "",
|
|
320
|
+
};
|
|
321
|
+
if (type === "select" && !["string", "number", "boolean"].includes(field.type))
|
|
322
|
+
throw new TypeError(`Select requires a scalar val: ${entry.id}`);
|
|
323
|
+
if (entry.items) field.options = entry.items.map((item) => ({ key: item.key, label: item.label }));
|
|
324
|
+
if (Object.hasOwn(entry, "val")) field.defaultValue = normalizeStoredValue(field, entry.val);
|
|
325
|
+
if (
|
|
326
|
+
typeof field.name !== "string" ||
|
|
327
|
+
fields.some((other) => other.key === field.key || other.key.startsWith(`${field.key}.`) || field.key.startsWith(`${other.key}.`))
|
|
328
|
+
)
|
|
329
|
+
throw new TypeError(`Invalid or overlapping BoxJS field: ${entry.id}`);
|
|
330
|
+
if (
|
|
331
|
+
field.options &&
|
|
332
|
+
(new Set(field.options.map((item) => item.key)).size !== field.options.length ||
|
|
333
|
+
field.options.some((item) => !scalar(item.key) || typeof item.label !== "string"))
|
|
334
|
+
)
|
|
335
|
+
throw new TypeError(`Invalid options: ${entry.id}`);
|
|
336
|
+
if (Object.hasOwn(field, "defaultValue") && !validValue(field, field.defaultValue))
|
|
337
|
+
throw new TypeError(`Invalid BoxJS val: ${entry.id}`);
|
|
338
|
+
fields.push(field);
|
|
339
|
+
}
|
|
340
|
+
if (!fields.length) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
341
|
+
const common = fields[0].key.split(".").slice(0, -1);
|
|
342
|
+
for (const field of fields) while (!field.key.startsWith(`${common.join(".")}.`)) common.pop();
|
|
343
|
+
return { module, storageKey, fields, settingsPath: common };
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
/**
|
|
347
|
+
* 归一化 BoxJS 的字符串存储值,不改变普通文本内容。
|
|
348
|
+
* Normalize BoxJS string persistence without changing free-text values.
|
|
349
|
+
* @param {import("../types/index.js").SettingsField} field 字段 / Field.
|
|
350
|
+
* @param {unknown} value 存储值 / Stored value.
|
|
351
|
+
* @returns {unknown} 控件值 / Control value.
|
|
352
|
+
*/
|
|
353
|
+
function normalizeStoredValue(field, value) {
|
|
354
|
+
if (field.type === "boolean" && (value === "true" || value === "false")) return value === "true";
|
|
355
|
+
if (field.type === "number" && typeof value === "string" && value.trim() !== "") return Number(value);
|
|
356
|
+
if (field.type === "array" && typeof value === "string") value = value === "" || value === "[]" ? [] : value.split(",");
|
|
357
|
+
if (field.options) {
|
|
358
|
+
const match = (item) => field.options.find((option) => String(option.key) === String(item))?.key ?? item;
|
|
359
|
+
return field.type === "array" && Array.isArray(value) ? value.map(match) : match(value);
|
|
360
|
+
}
|
|
361
|
+
return value;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function scalar(value) {
|
|
365
|
+
return (
|
|
366
|
+
typeof value === "boolean" ||
|
|
367
|
+
(typeof value === "string" && value.length <= 2048) ||
|
|
368
|
+
(typeof value === "number" && Number.isFinite(value))
|
|
369
|
+
);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function validValue(field, value) {
|
|
373
|
+
if (field.type === "array") {
|
|
374
|
+
if (!Array.isArray(value) || value.some((item) => !scalar(item)) || new Set(value).size !== value.length) return false;
|
|
375
|
+
} else if (typeof value !== field.type || !scalar(value)) return false;
|
|
376
|
+
return !field.options || (field.type === "array" ? value : [value]).every((item) => field.options.some((option) => option.key === item));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* 创建页面会话缓存;打开时重读,选项操作仅在 HTTP 200 后更新缓存。
|
|
381
|
+
* Create a page-session cache; reload on open and mutate cache only after HTTP 200.
|
|
382
|
+
* @param {import("../types/browser.js").PreferencesClientOptions} options 请求与通知 / Requests and notifications.
|
|
383
|
+
* @returns {import("../types/browser.js").PreferencesClient} 通用客户端 / Generic client.
|
|
384
|
+
*/
|
|
385
|
+
function createPreferencesClient({ fetch: request = globalThis.fetch.bind(globalThis), notify = () => {}, timeout = 10000 } = {}) {
|
|
386
|
+
const sessions = new Map();
|
|
387
|
+
async function send(path, method, body, signal, resource = false) {
|
|
388
|
+
const controller = new AbortController();
|
|
389
|
+
const abort = () => controller.abort();
|
|
390
|
+
if (signal?.aborted) abort();
|
|
391
|
+
signal?.addEventListener("abort", abort, { once: true });
|
|
392
|
+
const timer = setTimeout(abort, timeout);
|
|
393
|
+
try {
|
|
394
|
+
const response = await request(path, {
|
|
395
|
+
method,
|
|
396
|
+
credentials: "omit",
|
|
397
|
+
cache: "no-store",
|
|
398
|
+
signal: controller.signal,
|
|
399
|
+
headers: resource ? {} : { "X-Settings-Client": "1", ...(method === "POST" ? { "Content-Type": "application/json" } : {}) },
|
|
400
|
+
...(method === "POST" ? { body: JSON.stringify(body) } : {}),
|
|
401
|
+
});
|
|
402
|
+
if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
|
|
403
|
+
return response;
|
|
404
|
+
} finally {
|
|
405
|
+
clearTimeout(timer);
|
|
406
|
+
signal?.removeEventListener("abort", abort);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
const configPath = (module) => {
|
|
410
|
+
if (typeof module !== "string" || !module) throw new TypeError("module is required");
|
|
411
|
+
const parts = parseSettingsPath(`https://example.invalid/api/${encodeURIComponent(module)}/`);
|
|
412
|
+
if (parts.length !== 1) throw new TypeError("Expected a module name");
|
|
413
|
+
return `/configs/${encodeURIComponent(module)}`;
|
|
414
|
+
};
|
|
415
|
+
const snapshot = (module) => {
|
|
416
|
+
const state = sessions.get(module);
|
|
417
|
+
if (!state?.definition) throw new Error("Open the module first");
|
|
418
|
+
return structuredClone({ definition: state.definition, values: state.values });
|
|
419
|
+
};
|
|
420
|
+
async function change(module, key, method, value) {
|
|
421
|
+
const state = sessions.get(module);
|
|
422
|
+
if (!state?.definition) throw new Error("Open the module first");
|
|
423
|
+
if (state.saving) throw new Error("A settings write is already in progress");
|
|
424
|
+
const field = state.definition.fields.find((field) => field.key === key);
|
|
425
|
+
state.saving = true;
|
|
426
|
+
try {
|
|
427
|
+
if (!field || (method === "POST" && !validValue(field, value))) throw new TypeError("Invalid setting value");
|
|
428
|
+
await send(`/api/${key.split(".").map(encodeURIComponent).join("/")}`, method, value);
|
|
429
|
+
if (sessions.get(module) === state) {
|
|
430
|
+
if (method === "DELETE") {
|
|
431
|
+
delete state.values[key];
|
|
432
|
+
if (Object.hasOwn(field, "defaultValue")) state.values[key] = structuredClone(field.defaultValue);
|
|
433
|
+
} else state.values[key] = structuredClone(value);
|
|
434
|
+
}
|
|
435
|
+
notify({ kind: "success", operation: method === "DELETE" ? "delete" : "write", module, key });
|
|
436
|
+
} catch (error) {
|
|
437
|
+
notify({ kind: "error", operation: method === "DELETE" ? "delete" : "write", module, key, message: error.message });
|
|
438
|
+
throw error;
|
|
439
|
+
} finally {
|
|
440
|
+
state.saving = false;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
async probe(module) {
|
|
445
|
+
try {
|
|
446
|
+
await send(configPath(module), "HEAD", undefined, undefined, true);
|
|
447
|
+
return true;
|
|
448
|
+
} catch {
|
|
449
|
+
return false;
|
|
450
|
+
}
|
|
451
|
+
},
|
|
452
|
+
async open(module) {
|
|
453
|
+
const previous = sessions.get(module);
|
|
454
|
+
if (previous?.saving) throw new Error("Cannot refresh while saving");
|
|
455
|
+
previous?.controller.abort();
|
|
456
|
+
const state = { controller: new AbortController(), definition: null, values: {}, saving: false };
|
|
457
|
+
sessions.set(module, state);
|
|
458
|
+
try {
|
|
459
|
+
const resource = configPath(module);
|
|
460
|
+
const definition = normalizeBoxJs(await (await send(resource, "GET", undefined, state.controller.signal, true)).json(), module);
|
|
461
|
+
if (definition.settingsPath.length < 2) throw new TypeError("BoxJS fields must share a settings subtree below the module root");
|
|
462
|
+
const subtree = await (
|
|
463
|
+
await send(`/api/${definition.settingsPath.map(encodeURIComponent).join("/")}/`, "GET", undefined, state.controller.signal)
|
|
464
|
+
).json();
|
|
465
|
+
if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
|
|
466
|
+
if (sessions.get(module) !== state) throw new Error("Module session was replaced");
|
|
467
|
+
state.definition = definition;
|
|
468
|
+
for (const field of definition.fields) {
|
|
469
|
+
const value = Lodash.get(subtree, field.key.split(".").slice(definition.settingsPath.length), field.defaultValue);
|
|
470
|
+
if (value !== undefined) state.values[field.key] = normalizeStoredValue(field, value);
|
|
471
|
+
}
|
|
472
|
+
return snapshot(module);
|
|
473
|
+
} catch (error) {
|
|
474
|
+
if (sessions.get(module) === state) sessions.delete(module);
|
|
475
|
+
throw error;
|
|
476
|
+
}
|
|
477
|
+
},
|
|
478
|
+
snapshot,
|
|
479
|
+
leave(module) {
|
|
480
|
+
sessions.get(module)?.controller.abort();
|
|
481
|
+
sessions.delete(module);
|
|
482
|
+
},
|
|
483
|
+
set: (module, key, value) => change(module, key, "POST", value),
|
|
484
|
+
remove: (module, key) => change(module, key, "DELETE"),
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/**
|
|
489
|
+
* 挂载从 BoxJS 实时生成的设置面板和短暂通知。
|
|
490
|
+
* Mount runtime-generated BoxJS controls and transient notifications.
|
|
491
|
+
* @param {import("../types/browser.js").PreferencesPanelOptions} options 容器与请求;页面路径 /settings/{module} 对应配置 / Container and requests; /settings/{module} selects config.
|
|
492
|
+
* @returns {{destroy(): void}} 清理接口 / Cleanup handle.
|
|
493
|
+
*/
|
|
494
|
+
function mountPreferencePanes({ element: root, fetch, title = "Preferences" }) {
|
|
495
|
+
const document = root.ownerDocument;
|
|
496
|
+
const window = document.defaultView;
|
|
497
|
+
const node = (tag, className, text) => {
|
|
498
|
+
const el = document.createElement(tag);
|
|
499
|
+
el.className = className;
|
|
500
|
+
if (text !== undefined) el.textContent = text;
|
|
501
|
+
return el;
|
|
502
|
+
};
|
|
503
|
+
const shell = node("div", "pp-panel");
|
|
504
|
+
const header = node("header", "pp-header");
|
|
505
|
+
const back = node("button", "pp-back", "返回");
|
|
506
|
+
back.type = "button";
|
|
507
|
+
const heading = node("h1", "pp-title", title);
|
|
508
|
+
const viewport = node("div", "pp-viewport");
|
|
509
|
+
const toast = node("div", "pp-toast");
|
|
510
|
+
toast.setAttribute("role", "status");
|
|
511
|
+
toast.hidden = true;
|
|
512
|
+
header.append(back, heading);
|
|
513
|
+
shell.append(header, viewport, toast);
|
|
514
|
+
root.append(shell);
|
|
515
|
+
let timer,
|
|
516
|
+
routedPath,
|
|
517
|
+
generation = 0,
|
|
518
|
+
active = null,
|
|
519
|
+
saving = false,
|
|
520
|
+
pendingRoute = false,
|
|
521
|
+
destroyed = false;
|
|
522
|
+
const notify = (event) => {
|
|
523
|
+
if (destroyed) return;
|
|
524
|
+
toast.textContent = event.kind === "error" ? `操作失败:${event.message}` : event.operation === "delete" ? "删除成功" : "修改成功";
|
|
525
|
+
toast.dataset.kind = event.kind;
|
|
526
|
+
toast.hidden = false;
|
|
527
|
+
clearTimeout(timer);
|
|
528
|
+
timer = setTimeout(() => {
|
|
529
|
+
toast.hidden = true;
|
|
530
|
+
}, 2400);
|
|
531
|
+
};
|
|
532
|
+
const client = createPreferencesClient({ ...(fetch ? { fetch } : {}), notify });
|
|
533
|
+
function replace(view, direction) {
|
|
534
|
+
const old = viewport.firstElementChild;
|
|
535
|
+
viewport.replaceChildren(view);
|
|
536
|
+
if (old && !document.defaultView.matchMedia("(prefers-reduced-motion: reduce)").matches)
|
|
537
|
+
view.animate(
|
|
538
|
+
[
|
|
539
|
+
{ opacity: 0.4, transform: `translateX(${direction * 24}px)` },
|
|
540
|
+
{ opacity: 1, transform: "translateX(0)" },
|
|
541
|
+
],
|
|
542
|
+
{ duration: 180, easing: "ease-out" },
|
|
543
|
+
);
|
|
544
|
+
}
|
|
545
|
+
async function open(module) {
|
|
546
|
+
const version = ++generation;
|
|
547
|
+
active = module;
|
|
548
|
+
back.disabled = window.history.length <= 1;
|
|
549
|
+
heading.textContent = module;
|
|
550
|
+
replace(node("p", "pp-loading", "读取设置…"), 1);
|
|
551
|
+
try {
|
|
552
|
+
await client.open(module);
|
|
553
|
+
if (version === generation) controls();
|
|
554
|
+
} catch (error) {
|
|
555
|
+
if (version !== generation) return;
|
|
556
|
+
const view = node("section", "pp-error");
|
|
557
|
+
view.append(node("p", "", `加载失败:${error.message}`));
|
|
558
|
+
const retry = node("button", "", "重新读取");
|
|
559
|
+
retry.onclick = () => open(module);
|
|
560
|
+
view.append(retry);
|
|
561
|
+
replace(view, 1);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
function controls() {
|
|
565
|
+
const { definition, values } = client.snapshot(active);
|
|
566
|
+
const view = node("section", "pp-fields");
|
|
567
|
+
for (const field of definition.fields) {
|
|
568
|
+
const row = node("fieldset", "pp-field");
|
|
569
|
+
row.append(node("legend", "", field.name));
|
|
570
|
+
if (field.description) row.append(node("p", "pp-description", field.description));
|
|
571
|
+
const value = values[field.key];
|
|
572
|
+
let read, write;
|
|
573
|
+
if (field.options && field.type !== "array") {
|
|
574
|
+
const select = node("select", "pp-input");
|
|
575
|
+
select.setAttribute("aria-label", field.name);
|
|
576
|
+
field.options.forEach((option, index) => {
|
|
577
|
+
const item = node("option", "", option.label);
|
|
578
|
+
item.value = String(index);
|
|
579
|
+
select.append(item);
|
|
580
|
+
});
|
|
581
|
+
write = (value) => {
|
|
582
|
+
select.selectedIndex = field.options.findIndex((option) => option.key === value);
|
|
583
|
+
};
|
|
584
|
+
row.append(select);
|
|
585
|
+
read = () => field.options[select.selectedIndex]?.key;
|
|
586
|
+
} else if (field.type === "array" && field.options) {
|
|
587
|
+
const inputs = field.options.map((option) => {
|
|
588
|
+
const label = node("label", "pp-choice", option.label);
|
|
589
|
+
const input = node("input", "");
|
|
590
|
+
input.type = "checkbox";
|
|
591
|
+
input.checked = Array.isArray(value) && value.includes(option.key);
|
|
592
|
+
label.prepend(input);
|
|
593
|
+
row.append(label);
|
|
594
|
+
return { input, key: option.key };
|
|
595
|
+
});
|
|
596
|
+
read = () => inputs.filter((option) => option.input.checked).map((option) => option.key);
|
|
597
|
+
write = (value) => {
|
|
598
|
+
for (const option of inputs) option.input.checked = Array.isArray(value) && value.includes(option.key);
|
|
599
|
+
};
|
|
600
|
+
} else {
|
|
601
|
+
const input = node(field.type === "array" ? "textarea" : "input", "pp-input");
|
|
602
|
+
input.setAttribute("aria-label", field.name);
|
|
603
|
+
if (field.type === "boolean") {
|
|
604
|
+
input.type = "checkbox";
|
|
605
|
+
write = (value) => {
|
|
606
|
+
input.checked = value === true;
|
|
607
|
+
};
|
|
608
|
+
read = () => input.checked;
|
|
609
|
+
} else {
|
|
610
|
+
input.type = field.type === "number" ? "number" : "text";
|
|
611
|
+
write = (value) => {
|
|
612
|
+
input.value = field.type === "array" ? JSON.stringify(value ?? []) : (value ?? "");
|
|
613
|
+
};
|
|
614
|
+
read = () =>
|
|
615
|
+
field.type === "array"
|
|
616
|
+
? JSON.parse(input.value)
|
|
617
|
+
: field.type === "number"
|
|
618
|
+
? input.value === ""
|
|
619
|
+
? Number.NaN
|
|
620
|
+
: Number(input.value)
|
|
621
|
+
: input.value;
|
|
622
|
+
}
|
|
623
|
+
row.append(input);
|
|
624
|
+
}
|
|
625
|
+
write(value);
|
|
626
|
+
const actions = node("div", "pp-actions");
|
|
627
|
+
for (const [operation, label] of [
|
|
628
|
+
["write", "保存"],
|
|
629
|
+
["delete", "删除覆盖值"],
|
|
630
|
+
]) {
|
|
631
|
+
const button = node("button", "", label);
|
|
632
|
+
button.type = "button";
|
|
633
|
+
button.onclick = async () => {
|
|
634
|
+
if (saving) return;
|
|
635
|
+
saving = true;
|
|
636
|
+
back.disabled = true;
|
|
637
|
+
view.querySelectorAll("button,input,select,textarea").forEach((input) => {
|
|
638
|
+
input.disabled = true;
|
|
639
|
+
});
|
|
640
|
+
let success = false;
|
|
641
|
+
try {
|
|
642
|
+
if (operation === "delete") await client.remove(active, field.key);
|
|
643
|
+
else {
|
|
644
|
+
let value;
|
|
645
|
+
try {
|
|
646
|
+
value = read();
|
|
647
|
+
} catch (error) {
|
|
648
|
+
notify({ kind: "error", message: error.message });
|
|
649
|
+
throw error;
|
|
650
|
+
}
|
|
651
|
+
await client.set(active, field.key, value);
|
|
652
|
+
}
|
|
653
|
+
success = true;
|
|
654
|
+
} catch {
|
|
655
|
+
/* 客户端已显示错误通知 / Client already displayed an error notification. */
|
|
656
|
+
} finally {
|
|
657
|
+
saving = false;
|
|
658
|
+
back.disabled = window.history.length <= 1;
|
|
659
|
+
view.querySelectorAll("button,input,select,textarea").forEach((input) => {
|
|
660
|
+
input.disabled = false;
|
|
661
|
+
});
|
|
662
|
+
if (success && !destroyed) {
|
|
663
|
+
// 只更新当前控件,保留其它尚未保存的输入。
|
|
664
|
+
// Update this control without discarding other unsaved inputs.
|
|
665
|
+
write(client.snapshot(active).values[field.key]);
|
|
666
|
+
}
|
|
667
|
+
if (!destroyed && pendingRoute) route();
|
|
668
|
+
}
|
|
669
|
+
};
|
|
670
|
+
actions.append(button);
|
|
671
|
+
}
|
|
672
|
+
row.append(actions);
|
|
673
|
+
view.append(row);
|
|
674
|
+
}
|
|
675
|
+
viewport.replaceChildren(view);
|
|
676
|
+
}
|
|
677
|
+
function route() {
|
|
678
|
+
if (saving) {
|
|
679
|
+
pendingRoute = true;
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
pendingRoute = false;
|
|
683
|
+
if (active) client.leave(active);
|
|
684
|
+
routedPath = window.location.pathname;
|
|
685
|
+
const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(routedPath);
|
|
686
|
+
if (!match) {
|
|
687
|
+
generation++;
|
|
688
|
+
active = null;
|
|
689
|
+
heading.textContent = title;
|
|
690
|
+
replace(node("p", "pp-error", "页面地址应为 /settings/模块标识。"), 1);
|
|
691
|
+
return;
|
|
692
|
+
}
|
|
693
|
+
open(match[1]);
|
|
694
|
+
}
|
|
695
|
+
const onPopState = () => {
|
|
696
|
+
if (window.location.pathname !== routedPath) route();
|
|
697
|
+
};
|
|
698
|
+
const onPageShow = (event) => {
|
|
699
|
+
if (event.persisted) route();
|
|
700
|
+
};
|
|
701
|
+
back.onclick = () => {
|
|
702
|
+
if (!saving) window.history.back();
|
|
703
|
+
};
|
|
704
|
+
window.addEventListener("popstate", onPopState);
|
|
705
|
+
window.addEventListener("pageshow", onPageShow);
|
|
706
|
+
route();
|
|
707
|
+
return {
|
|
708
|
+
destroy() {
|
|
709
|
+
destroyed = true;
|
|
710
|
+
window.removeEventListener("popstate", onPopState);
|
|
711
|
+
window.removeEventListener("pageshow", onPageShow);
|
|
712
|
+
generation++;
|
|
713
|
+
if (active) client.leave(active);
|
|
714
|
+
clearTimeout(timer);
|
|
715
|
+
shell.remove();
|
|
716
|
+
},
|
|
717
|
+
};
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
export { createPreferencesClient, mountPreferencePanes };
|