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