@nsnanocat/preference-panes 1.1.1 → 1.1.3
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/README.md +16 -15
- package/dist/api.js +92 -606
- package/dist/module/index.html +1 -1
- package/dist/module/index.mjs +125 -121
- package/dist/module/navigation.mjs +1 -1
- package/dist/preference-panes.mjs +26 -24
- package/dist/web.js +1 -1
- package/package.json +1 -1
- package/src/api.mjs +95 -114
- package/src/browser/ModuleStatus.mjs +1 -1
- package/src/browser/boxjs.mjs +1 -0
- package/src/browser/client.mjs +25 -24
- package/src/browser/index.mjs +7 -4
- package/src/index.d.ts +2 -2
package/dist/module/index.html
CHANGED
package/dist/module/index.mjs
CHANGED
|
@@ -1,94 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 创建元素,所有展示文本通过 textContent 写入。
|
|
3
|
-
* Create elements and assign display text through textContent only.
|
|
4
|
-
* @template {keyof HTMLElementTagNameMap} T
|
|
5
|
-
* @param {T} tag 元素标签 / Element tag.
|
|
6
|
-
* @param {string} className 样式类名 / CSS class.
|
|
7
|
-
* @param {string} [text] 纯文本 / Plain text.
|
|
8
|
-
* @returns {HTMLElementTagNameMap[T]} 创建的元素 / Created element.
|
|
9
|
-
*/
|
|
10
|
-
function element(tag, className, text) {
|
|
11
|
-
const node = document.createElement(tag);
|
|
12
|
-
node.className = className;
|
|
13
|
-
if (text !== undefined) node.textContent = text;
|
|
14
|
-
return node;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* 创建通用设置行;外部 CSS 可通过 pp 类名覆盖视觉样式。
|
|
19
|
-
* Create a generic settings row whose appearance can be overridden through pp classes.
|
|
20
|
-
* @template {"div" | "label"} T
|
|
21
|
-
* @param {T} tag 行元素 / Row element.
|
|
22
|
-
* @returns {HTMLElementTagNameMap[T]} 设置行 / Settings row.
|
|
23
|
-
*/
|
|
24
|
-
function settingRow(tag) {
|
|
25
|
-
return element(tag, "pp-row");
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* 为标准 HTML 输入控件添加通用面板类名。
|
|
30
|
-
* Add the generic panel class to a standard HTML input control.
|
|
31
|
-
* @param {HTMLElement} control 已创建的原生控件 / Existing native control.
|
|
32
|
-
* @returns {HTMLElement} 输入控件 / Input control.
|
|
33
|
-
*/
|
|
34
|
-
function fieldControl(control) {
|
|
35
|
-
control.classList.add("pp-editor");
|
|
36
|
-
return control;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* 元数据地址只允许 HTTP(S) 和相对地址。
|
|
41
|
-
* Allow only HTTP(S) and relative metadata addresses.
|
|
42
|
-
* @param {string} value 元数据地址 / Metadata address.
|
|
43
|
-
* @returns {string} 完整地址 / Absolute address.
|
|
44
|
-
*/
|
|
45
|
-
function resourceURL(value) {
|
|
46
|
-
const url = new URL(value, document.baseURI);
|
|
47
|
-
if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Metadata URLs must use HTTP(S)");
|
|
48
|
-
return url.href;
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* 创建覆盖可用内容区的通用读取状态,失败时可附加重试动作。
|
|
53
|
-
* Create a shared status view that fills the available content area and may include retry.
|
|
54
|
-
* @param {string} message 状态文本 / Status message.
|
|
55
|
-
* @param {(() => unknown) | undefined} [retry] 重试动作 / Retry action.
|
|
56
|
-
* @returns {HTMLElement} 居中状态视图 / Centered status view.
|
|
57
|
-
*/
|
|
58
|
-
function statusView(message, retry) {
|
|
59
|
-
const view = element("section", "pp-status");
|
|
60
|
-
view.setAttribute("role", "status");
|
|
61
|
-
view.setAttribute("aria-live", "polite");
|
|
62
|
-
const spinner = element("span", "pp-status-spinner");
|
|
63
|
-
spinner.setAttribute("aria-hidden", "true");
|
|
64
|
-
view.append(spinner, element("p", "pp-status-message", message));
|
|
65
|
-
if (retry) {
|
|
66
|
-
const button = element("button", "pp-status-action", "重新读取");
|
|
67
|
-
button.type = "button";
|
|
68
|
-
button.onclick = retry;
|
|
69
|
-
view.append(button);
|
|
70
|
-
}
|
|
71
|
-
return view;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* 请求宿主确认;独立网页使用浏览器对话框。
|
|
76
|
-
* Request confirmation from the host, using the browser dialog for standalone pages.
|
|
77
|
-
* @param {Window} host 模块窗口 / Module window.
|
|
78
|
-
* @param {string} message 确认内容 / Confirmation message.
|
|
79
|
-
* @returns {Promise<boolean>} 用户是否确认 / Whether the user confirmed.
|
|
80
|
-
*/
|
|
81
|
-
function requestConfirmation(host, message) {
|
|
82
|
-
return new Promise((resolve, reject) => {
|
|
83
|
-
const frame = host.frameElement;
|
|
84
|
-
if (frame) {
|
|
85
|
-
const event = new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:confirm", { cancelable: true, detail: { message, resolve, reject } });
|
|
86
|
-
if (!frame.dispatchEvent(event)) return;
|
|
87
|
-
}
|
|
88
|
-
resolve(host.confirm(message));
|
|
89
|
-
});
|
|
90
|
-
}
|
|
91
|
-
|
|
92
1
|
/**
|
|
93
2
|
* 校验原始路径片段,不进行 URL 编码转换。
|
|
94
3
|
* Validate raw path segments without URL encoding conversion.
|
|
@@ -126,6 +35,7 @@ function normalizeBoxJs(config, module) {
|
|
|
126
35
|
if (!storageKey || storageKey.startsWith("@") || parts.length < 2) throw new TypeError("A BoxJS setting must be below a literal storage root and module");
|
|
127
36
|
validatePathParts(parts);
|
|
128
37
|
const name = parts[0];
|
|
38
|
+
if (["get", "set", "delete"].includes(name)) throw new TypeError(`Reserved API module name: ${name}`);
|
|
129
39
|
let target = modules.get(name);
|
|
130
40
|
if (!target) {
|
|
131
41
|
target = { module: name, storageKey, entries: [], owners: new Set() };
|
|
@@ -136,8 +46,8 @@ function normalizeBoxJs(config, module) {
|
|
|
136
46
|
target.owners.add(app);
|
|
137
47
|
}
|
|
138
48
|
}
|
|
139
|
-
if (modules.size !== 1) throw new TypeError("Import BoxJS JSON for exactly one module");
|
|
140
|
-
const target = modules.values().next().value ;
|
|
49
|
+
if (module === undefined && modules.size !== 1) throw new TypeError("Import BoxJS JSON for exactly one module");
|
|
50
|
+
const target = module === undefined ? modules.values().next().value : modules.get(module);
|
|
141
51
|
if (!target) throw new TypeError(`No BoxJS settings for module: ${module}`);
|
|
142
52
|
const metadata = normalizeMetadata(target.owners.size === 1 ? presentation([...target.owners][0]) : {});
|
|
143
53
|
const fields = [];
|
|
@@ -274,6 +184,97 @@ function validValue(field, value) {
|
|
|
274
184
|
return !field.options || (field.type === "array" ? value : [value]).every(item => field.options.some(option => option.key === item));
|
|
275
185
|
}
|
|
276
186
|
|
|
187
|
+
/**
|
|
188
|
+
* 创建元素,所有展示文本通过 textContent 写入。
|
|
189
|
+
* Create elements and assign display text through textContent only.
|
|
190
|
+
* @template {keyof HTMLElementTagNameMap} T
|
|
191
|
+
* @param {T} tag 元素标签 / Element tag.
|
|
192
|
+
* @param {string} className 样式类名 / CSS class.
|
|
193
|
+
* @param {string} [text] 纯文本 / Plain text.
|
|
194
|
+
* @returns {HTMLElementTagNameMap[T]} 创建的元素 / Created element.
|
|
195
|
+
*/
|
|
196
|
+
function element(tag, className, text) {
|
|
197
|
+
const node = document.createElement(tag);
|
|
198
|
+
node.className = className;
|
|
199
|
+
if (text !== undefined) node.textContent = text;
|
|
200
|
+
return node;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* 创建通用设置行;外部 CSS 可通过 pp 类名覆盖视觉样式。
|
|
205
|
+
* Create a generic settings row whose appearance can be overridden through pp classes.
|
|
206
|
+
* @template {"div" | "label"} T
|
|
207
|
+
* @param {T} tag 行元素 / Row element.
|
|
208
|
+
* @returns {HTMLElementTagNameMap[T]} 设置行 / Settings row.
|
|
209
|
+
*/
|
|
210
|
+
function settingRow(tag) {
|
|
211
|
+
return element(tag, "pp-row");
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* 为标准 HTML 输入控件添加通用面板类名。
|
|
216
|
+
* Add the generic panel class to a standard HTML input control.
|
|
217
|
+
* @param {HTMLElement} control 已创建的原生控件 / Existing native control.
|
|
218
|
+
* @returns {HTMLElement} 输入控件 / Input control.
|
|
219
|
+
*/
|
|
220
|
+
function fieldControl(control) {
|
|
221
|
+
control.classList.add("pp-editor");
|
|
222
|
+
return control;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* 元数据地址只允许 HTTP(S) 和相对地址。
|
|
227
|
+
* Allow only HTTP(S) and relative metadata addresses.
|
|
228
|
+
* @param {string} value 元数据地址 / Metadata address.
|
|
229
|
+
* @returns {string} 完整地址 / Absolute address.
|
|
230
|
+
*/
|
|
231
|
+
function resourceURL(value) {
|
|
232
|
+
const url = new URL(value, document.baseURI);
|
|
233
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new TypeError("Metadata URLs must use HTTP(S)");
|
|
234
|
+
return url.href;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* 创建覆盖可用内容区的通用读取状态,失败时可附加重试动作。
|
|
239
|
+
* Create a shared status view that fills the available content area and may include retry.
|
|
240
|
+
* @param {string} message 状态文本 / Status message.
|
|
241
|
+
* @param {(() => unknown) | undefined} [retry] 重试动作 / Retry action.
|
|
242
|
+
* @returns {HTMLElement} 居中状态视图 / Centered status view.
|
|
243
|
+
*/
|
|
244
|
+
function statusView(message, retry) {
|
|
245
|
+
const view = element("section", "pp-status");
|
|
246
|
+
view.setAttribute("role", "status");
|
|
247
|
+
view.setAttribute("aria-live", "polite");
|
|
248
|
+
const spinner = element("span", "pp-status-spinner");
|
|
249
|
+
spinner.setAttribute("aria-hidden", "true");
|
|
250
|
+
view.append(spinner, element("p", "pp-status-message", message));
|
|
251
|
+
if (retry) {
|
|
252
|
+
const button = element("button", "pp-status-action", "重新读取");
|
|
253
|
+
button.type = "button";
|
|
254
|
+
button.onclick = retry;
|
|
255
|
+
view.append(button);
|
|
256
|
+
}
|
|
257
|
+
return view;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* 请求宿主确认;独立网页使用浏览器对话框。
|
|
262
|
+
* Request confirmation from the host, using the browser dialog for standalone pages.
|
|
263
|
+
* @param {Window} host 模块窗口 / Module window.
|
|
264
|
+
* @param {string} message 确认内容 / Confirmation message.
|
|
265
|
+
* @returns {Promise<boolean>} 用户是否确认 / Whether the user confirmed.
|
|
266
|
+
*/
|
|
267
|
+
function requestConfirmation(host, message) {
|
|
268
|
+
return new Promise((resolve, reject) => {
|
|
269
|
+
const frame = host.frameElement;
|
|
270
|
+
if (frame) {
|
|
271
|
+
const event = new frame.ownerDocument.defaultView.CustomEvent("preferencepanes:confirm", { cancelable: true, detail: { message, resolve, reject } });
|
|
272
|
+
if (!frame.dispatchEvent(event)) return;
|
|
273
|
+
}
|
|
274
|
+
resolve(host.confirm(message));
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
277
278
|
/**
|
|
278
279
|
* 共用三点按钮和底部操作菜单;弹层挂载到文档根部,不受标题栏显示状态影响。
|
|
279
280
|
* Shared overflow trigger and bottom action sheet; the layer is mounted at document level and remains independent of header visibility.
|
|
@@ -489,7 +490,7 @@ class PreferencesClient {
|
|
|
489
490
|
* @returns {Promise<unknown>} Settings 内容或 undefined / Settings content or undefined.
|
|
490
491
|
*/
|
|
491
492
|
async readSettings() {
|
|
492
|
-
const response = await this.#send("get", {
|
|
493
|
+
const response = await this.#send("get", `@${this.#definition.storageKey}.${this.#definition.settingsPath.join(".")}`);
|
|
493
494
|
return response.status === 404 ? undefined : response.json();
|
|
494
495
|
}
|
|
495
496
|
|
|
@@ -499,7 +500,7 @@ class PreferencesClient {
|
|
|
499
500
|
* @returns {Promise<unknown>} Caches 内容或 undefined / Caches content or undefined.
|
|
500
501
|
*/
|
|
501
502
|
async readCaches() {
|
|
502
|
-
const response = await this.#send("get", {
|
|
503
|
+
const response = await this.#send("get", `@${this.#definition.storageKey}.${this.#module}.Caches`);
|
|
503
504
|
return response.status === 404 ? undefined : response.json();
|
|
504
505
|
}
|
|
505
506
|
|
|
@@ -509,7 +510,7 @@ class PreferencesClient {
|
|
|
509
510
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
510
511
|
*/
|
|
511
512
|
clearCaches() {
|
|
512
|
-
return this.#change("delete", {
|
|
513
|
+
return this.#change("delete", `${this.#module}.Caches`, undefined, "clearCaches");
|
|
513
514
|
}
|
|
514
515
|
|
|
515
516
|
/**
|
|
@@ -518,7 +519,7 @@ class PreferencesClient {
|
|
|
518
519
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
519
520
|
*/
|
|
520
521
|
reset() {
|
|
521
|
-
return this.#change("delete",
|
|
522
|
+
return this.#change("delete", this.#module, undefined, "reset");
|
|
522
523
|
}
|
|
523
524
|
|
|
524
525
|
/**
|
|
@@ -538,7 +539,7 @@ class PreferencesClient {
|
|
|
538
539
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
539
540
|
*/
|
|
540
541
|
set(key, value) {
|
|
541
|
-
return this.#change("set",
|
|
542
|
+
return this.#change("set", key, value, "write");
|
|
542
543
|
}
|
|
543
544
|
|
|
544
545
|
/**
|
|
@@ -548,30 +549,31 @@ class PreferencesClient {
|
|
|
548
549
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
549
550
|
*/
|
|
550
551
|
remove(key) {
|
|
551
|
-
return this.#change("delete",
|
|
552
|
+
return this.#change("delete", key, undefined, "delete");
|
|
552
553
|
}
|
|
553
554
|
|
|
554
555
|
/**
|
|
555
|
-
*
|
|
556
|
-
* Send a
|
|
557
|
-
* @param {"get" | "set" | "delete"} action
|
|
558
|
-
* @param {
|
|
556
|
+
* 向固定存储 API 发送完整路径的 form 动作。
|
|
557
|
+
* Send a complete-path form action to the fixed storage API.
|
|
558
|
+
* @param {"get" | "set" | "delete"} action 存储动作 / Storage action.
|
|
559
|
+
* @param {string} path 完整 @root.path / Complete @root.path.
|
|
560
|
+
* @param {unknown} [value] set 写入值 / Value written by set.
|
|
559
561
|
* @returns {Promise<Response>} 原始响应 / Raw response.
|
|
560
562
|
*/
|
|
561
|
-
async #send(action,
|
|
563
|
+
async #send(action, path, value) {
|
|
562
564
|
const controller = new AbortController();
|
|
563
565
|
const abort = () => controller.abort();
|
|
564
566
|
if (this.#session.signal.aborted) abort();
|
|
565
567
|
this.#session.signal.addEventListener("abort", abort, { once: true });
|
|
566
568
|
const timer = setTimeout(abort, this.#timeout);
|
|
567
569
|
try {
|
|
568
|
-
const response = await this.#request(`/api/${
|
|
570
|
+
const response = await this.#request(`/api/${action}`, {
|
|
569
571
|
method: "POST",
|
|
570
572
|
credentials: "omit",
|
|
571
573
|
cache: "no-store",
|
|
572
574
|
signal: controller.signal,
|
|
573
|
-
headers: { "Content-Type": "application/
|
|
574
|
-
body: JSON.stringify(
|
|
575
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
576
|
+
body: new URLSearchParams([[path, action === "set" ? JSON.stringify(value) : ""]]).toString(),
|
|
575
577
|
});
|
|
576
578
|
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
577
579
|
return response;
|
|
@@ -585,28 +587,28 @@ class PreferencesClient {
|
|
|
585
587
|
* 执行写入动作;成功后只更新当前页面值。
|
|
586
588
|
* Execute a mutation and update only the current page values after success.
|
|
587
589
|
* @param {"set" | "delete"} action API 动作 / API action.
|
|
588
|
-
* @param {
|
|
590
|
+
* @param {string} key 不含存储根的路径 / Path without the storage root.
|
|
591
|
+
* @param {unknown} value set 写入值 / Value written by set.
|
|
589
592
|
* @param {"write" | "delete" | "clearCaches" | "reset"} operation 通知操作 / Notification operation.
|
|
590
|
-
* @param {string} [key] 字段路径 / Field path.
|
|
591
593
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
592
594
|
*/
|
|
593
|
-
async #change(action,
|
|
595
|
+
async #change(action, key, value, operation) {
|
|
594
596
|
if (this.#saving) throw new Error("A settings write is already in progress");
|
|
595
597
|
this.#saving = true;
|
|
596
598
|
try {
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
599
|
+
let field;
|
|
600
|
+
if (operation === "write" || operation === "delete") {
|
|
601
|
+
field = this.#definition.fields.find(candidate => candidate.key === key);
|
|
602
|
+
if (!field || (operation === "write" && !validValue(field, value))) throw new TypeError("Invalid setting value");
|
|
600
603
|
}
|
|
601
|
-
await this.#send(action,
|
|
604
|
+
await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
|
|
602
605
|
switch (operation) {
|
|
603
606
|
case "write":
|
|
604
|
-
this.#values[key] = structuredClone(
|
|
607
|
+
this.#values[key] = structuredClone(value);
|
|
605
608
|
break;
|
|
606
609
|
case "delete": {
|
|
607
|
-
const field = this.#definition.fields.find(candidate => candidate.key === key);
|
|
608
610
|
delete this.#values[key];
|
|
609
|
-
if (
|
|
611
|
+
if (Object.hasOwn(field, "defaultValue")) this.#values[key] = structuredClone(field.defaultValue);
|
|
610
612
|
break;
|
|
611
613
|
}
|
|
612
614
|
case "clearCaches":
|
|
@@ -1440,8 +1442,8 @@ class ModulePage {
|
|
|
1440
1442
|
}
|
|
1441
1443
|
|
|
1442
1444
|
/**
|
|
1443
|
-
*
|
|
1444
|
-
* Read BoxJS JSON
|
|
1445
|
+
* 通过模块 API 读取 BoxJS JSON 并挂载通用前端。
|
|
1446
|
+
* Read BoxJS JSON through the module API and mount the generic frontend.
|
|
1445
1447
|
* @returns {Promise<void>} 启动完成 / Startup completion.
|
|
1446
1448
|
*/
|
|
1447
1449
|
async start() {
|
|
@@ -1453,9 +1455,11 @@ class ModulePage {
|
|
|
1453
1455
|
const match = /^\/settings\/([a-zA-Z0-9_-]+)\/?$/.exec(this.#window.location.pathname);
|
|
1454
1456
|
const module = embedded ?? match?.[1];
|
|
1455
1457
|
if (!module) throw new TypeError("Open a concrete module URL");
|
|
1456
|
-
const response = await fetch(`/
|
|
1458
|
+
const response = await fetch(`/api/${encodeURIComponent(module)}`, { cache: "no-store", credentials: "omit", headers: { Accept: "application/json" } });
|
|
1457
1459
|
if (response.status !== 200) throw new Error(`HTTP ${response.status}`);
|
|
1458
|
-
|
|
1460
|
+
const boxjs = await response.json();
|
|
1461
|
+
normalizeBoxJs(boxjs, module);
|
|
1462
|
+
this.#view = mount(boxjs);
|
|
1459
1463
|
} catch (error) {
|
|
1460
1464
|
this.#root.replaceChildren(statusView(`加载失败:${error.message}`, () => this.start()));
|
|
1461
1465
|
}
|
|
@@ -331,7 +331,7 @@ class ModuleStatus extends EventTarget {
|
|
|
331
331
|
const response = await probeModule(url, { ...options, signal: controller.signal });
|
|
332
332
|
if (controller !== this.#controller) return response;
|
|
333
333
|
const version = response.status === 200 ? response.headers.get("X-PreferencePanes-Version")?.trim() || null : null;
|
|
334
|
-
this.#render(
|
|
334
|
+
this.#render(version ? "installed" : "missing", version);
|
|
335
335
|
return response;
|
|
336
336
|
} catch (error) {
|
|
337
337
|
if (controller !== this.#controller) return;
|
|
@@ -35,6 +35,7 @@ function normalizeBoxJs(config, module) {
|
|
|
35
35
|
if (!storageKey || storageKey.startsWith("@") || parts.length < 2) throw new TypeError("A BoxJS setting must be below a literal storage root and module");
|
|
36
36
|
validatePathParts(parts);
|
|
37
37
|
const name = parts[0];
|
|
38
|
+
if (["get", "set", "delete"].includes(name)) throw new TypeError(`Reserved API module name: ${name}`);
|
|
38
39
|
let target = modules.get(name);
|
|
39
40
|
if (!target) {
|
|
40
41
|
target = { module: name, storageKey, entries: [], owners: new Set() };
|
|
@@ -489,7 +490,7 @@ class PreferencesClient {
|
|
|
489
490
|
* @returns {Promise<unknown>} Settings 内容或 undefined / Settings content or undefined.
|
|
490
491
|
*/
|
|
491
492
|
async readSettings() {
|
|
492
|
-
const response = await this.#send("get", {
|
|
493
|
+
const response = await this.#send("get", `@${this.#definition.storageKey}.${this.#definition.settingsPath.join(".")}`);
|
|
493
494
|
return response.status === 404 ? undefined : response.json();
|
|
494
495
|
}
|
|
495
496
|
|
|
@@ -499,7 +500,7 @@ class PreferencesClient {
|
|
|
499
500
|
* @returns {Promise<unknown>} Caches 内容或 undefined / Caches content or undefined.
|
|
500
501
|
*/
|
|
501
502
|
async readCaches() {
|
|
502
|
-
const response = await this.#send("get", {
|
|
503
|
+
const response = await this.#send("get", `@${this.#definition.storageKey}.${this.#module}.Caches`);
|
|
503
504
|
return response.status === 404 ? undefined : response.json();
|
|
504
505
|
}
|
|
505
506
|
|
|
@@ -509,7 +510,7 @@ class PreferencesClient {
|
|
|
509
510
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
510
511
|
*/
|
|
511
512
|
clearCaches() {
|
|
512
|
-
return this.#change("delete", {
|
|
513
|
+
return this.#change("delete", `${this.#module}.Caches`, undefined, "clearCaches");
|
|
513
514
|
}
|
|
514
515
|
|
|
515
516
|
/**
|
|
@@ -518,7 +519,7 @@ class PreferencesClient {
|
|
|
518
519
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
519
520
|
*/
|
|
520
521
|
reset() {
|
|
521
|
-
return this.#change("delete",
|
|
522
|
+
return this.#change("delete", this.#module, undefined, "reset");
|
|
522
523
|
}
|
|
523
524
|
|
|
524
525
|
/**
|
|
@@ -538,7 +539,7 @@ class PreferencesClient {
|
|
|
538
539
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
539
540
|
*/
|
|
540
541
|
set(key, value) {
|
|
541
|
-
return this.#change("set",
|
|
542
|
+
return this.#change("set", key, value, "write");
|
|
542
543
|
}
|
|
543
544
|
|
|
544
545
|
/**
|
|
@@ -548,30 +549,31 @@ class PreferencesClient {
|
|
|
548
549
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
549
550
|
*/
|
|
550
551
|
remove(key) {
|
|
551
|
-
return this.#change("delete",
|
|
552
|
+
return this.#change("delete", key, undefined, "delete");
|
|
552
553
|
}
|
|
553
554
|
|
|
554
555
|
/**
|
|
555
|
-
*
|
|
556
|
-
* Send a
|
|
557
|
-
* @param {"get" | "set" | "delete"} action
|
|
558
|
-
* @param {
|
|
556
|
+
* 向固定存储 API 发送完整路径的 form 动作。
|
|
557
|
+
* Send a complete-path form action to the fixed storage API.
|
|
558
|
+
* @param {"get" | "set" | "delete"} action 存储动作 / Storage action.
|
|
559
|
+
* @param {string} path 完整 @root.path / Complete @root.path.
|
|
560
|
+
* @param {unknown} [value] set 写入值 / Value written by set.
|
|
559
561
|
* @returns {Promise<Response>} 原始响应 / Raw response.
|
|
560
562
|
*/
|
|
561
|
-
async #send(action,
|
|
563
|
+
async #send(action, path, value) {
|
|
562
564
|
const controller = new AbortController();
|
|
563
565
|
const abort = () => controller.abort();
|
|
564
566
|
if (this.#session.signal.aborted) abort();
|
|
565
567
|
this.#session.signal.addEventListener("abort", abort, { once: true });
|
|
566
568
|
const timer = setTimeout(abort, this.#timeout);
|
|
567
569
|
try {
|
|
568
|
-
const response = await this.#request(`/api/${
|
|
570
|
+
const response = await this.#request(`/api/${action}`, {
|
|
569
571
|
method: "POST",
|
|
570
572
|
credentials: "omit",
|
|
571
573
|
cache: "no-store",
|
|
572
574
|
signal: controller.signal,
|
|
573
|
-
headers: { "Content-Type": "application/
|
|
574
|
-
body: JSON.stringify(
|
|
575
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
576
|
+
body: new URLSearchParams([[path, action === "set" ? JSON.stringify(value) : ""]]).toString(),
|
|
575
577
|
});
|
|
576
578
|
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
577
579
|
return response;
|
|
@@ -585,28 +587,28 @@ class PreferencesClient {
|
|
|
585
587
|
* 执行写入动作;成功后只更新当前页面值。
|
|
586
588
|
* Execute a mutation and update only the current page values after success.
|
|
587
589
|
* @param {"set" | "delete"} action API 动作 / API action.
|
|
588
|
-
* @param {
|
|
590
|
+
* @param {string} key 不含存储根的路径 / Path without the storage root.
|
|
591
|
+
* @param {unknown} value set 写入值 / Value written by set.
|
|
589
592
|
* @param {"write" | "delete" | "clearCaches" | "reset"} operation 通知操作 / Notification operation.
|
|
590
|
-
* @param {string} [key] 字段路径 / Field path.
|
|
591
593
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
592
594
|
*/
|
|
593
|
-
async #change(action,
|
|
595
|
+
async #change(action, key, value, operation) {
|
|
594
596
|
if (this.#saving) throw new Error("A settings write is already in progress");
|
|
595
597
|
this.#saving = true;
|
|
596
598
|
try {
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
599
|
+
let field;
|
|
600
|
+
if (operation === "write" || operation === "delete") {
|
|
601
|
+
field = this.#definition.fields.find(candidate => candidate.key === key);
|
|
602
|
+
if (!field || (operation === "write" && !validValue(field, value))) throw new TypeError("Invalid setting value");
|
|
600
603
|
}
|
|
601
|
-
await this.#send(action,
|
|
604
|
+
await this.#send(action, `@${this.#definition.storageKey}.${key}`, value);
|
|
602
605
|
switch (operation) {
|
|
603
606
|
case "write":
|
|
604
|
-
this.#values[key] = structuredClone(
|
|
607
|
+
this.#values[key] = structuredClone(value);
|
|
605
608
|
break;
|
|
606
609
|
case "delete": {
|
|
607
|
-
const field = this.#definition.fields.find(candidate => candidate.key === key);
|
|
608
610
|
delete this.#values[key];
|
|
609
|
-
if (
|
|
611
|
+
if (Object.hasOwn(field, "defaultValue")) this.#values[key] = structuredClone(field.defaultValue);
|
|
610
612
|
break;
|
|
611
613
|
}
|
|
612
614
|
case "clearCaches":
|