@nsnanocat/preference-panes 0.7.2 → 0.8.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/README.md +23 -78
- package/dist/api.js +1622 -0
- package/dist/module/app.mjs +22 -30
- package/dist/module/index.html +1 -1
- package/dist/module/navigation.mjs +1 -1
- package/dist/preference-panes.mjs +21 -29
- package/package.json +1 -1
- package/src/Store.mjs +40 -53
- package/src/browser/client.d.mts +2 -2
- package/src/browser/client.mjs +21 -21
- package/src/build.mjs +3 -13
- package/src/index.d.ts +6 -6
- package/src/lib/page-inputs.mjs +1 -1
- package/src/lib/settings-path.mjs +0 -18
- package/src/proxy/handler.mjs +9 -22
- package/dist/preference-panes.config.js +0 -842
- package/dist/preference-panes.proxy.js +0 -1754
- package/src/proxy/config.mjs +0 -14
package/dist/module/app.mjs
CHANGED
|
@@ -1,11 +1,3 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* 解析已经取得的 pathname,避免重复构造 URL。
|
|
3
|
-
* Parse an existing pathname without constructing another URL.
|
|
4
|
-
* @param {string} pathname 以 / 开头的 URL pathname / URL pathname beginning with /.
|
|
5
|
-
* @returns {string[] | undefined} 解码后的路径,非 API 路径不处理 / Decoded path, or undefined outside /api/.
|
|
6
|
-
* @throws {TypeError} 转义编码或路径片段非法 / Invalid percent encoding or path segments.
|
|
7
|
-
*/
|
|
8
|
-
|
|
9
1
|
/**
|
|
10
2
|
* 校验原始路径片段,不进行 URL 编码转换。
|
|
11
3
|
* Validate raw path segments without URL encoding conversion.
|
|
@@ -116,7 +108,7 @@ function pageInputs(url, headers = {}) {
|
|
|
116
108
|
const module = match[1];
|
|
117
109
|
const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
118
110
|
const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
|
|
119
|
-
const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ??
|
|
111
|
+
const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? "";
|
|
120
112
|
if (!json.trim()) throw new TypeError("JSON resource URL is required");
|
|
121
113
|
return { url: url.href, module, json, css };
|
|
122
114
|
}
|
|
@@ -322,31 +314,31 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
322
314
|
*/
|
|
323
315
|
const sessions = new Map();
|
|
324
316
|
/**
|
|
325
|
-
*
|
|
326
|
-
* Send a
|
|
327
|
-
* @param {string} path
|
|
328
|
-
* @param {"
|
|
329
|
-
* @param {unknown} body
|
|
317
|
+
* 用 form 发送完整存储键;读取 404 交给调用方处理。
|
|
318
|
+
* Send a complete storage key as form data; callers handle missing reads.
|
|
319
|
+
* @param {string} path 完整 @root.path / Complete @root.path.
|
|
320
|
+
* @param {"get" | "set" | "delete"} action 存储操作 / Storage operation.
|
|
321
|
+
* @param {unknown} body set 值,其它操作忽略 / Set value, ignored by other operations.
|
|
330
322
|
* @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
|
|
331
323
|
* @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
|
|
332
324
|
* @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
|
|
333
325
|
*/
|
|
334
|
-
async function send(path,
|
|
326
|
+
async function send(path, action, body, signal) {
|
|
335
327
|
const controller = new AbortController();
|
|
336
328
|
const abort = () => controller.abort();
|
|
337
329
|
if (signal?.aborted) abort();
|
|
338
330
|
signal?.addEventListener("abort", abort, { once: true });
|
|
339
331
|
const timer = setTimeout(abort, timeout);
|
|
340
332
|
try {
|
|
341
|
-
const response = await request(
|
|
342
|
-
method,
|
|
333
|
+
const response = await request(`/api/${action}`, {
|
|
334
|
+
method: "POST",
|
|
343
335
|
credentials: "omit",
|
|
344
336
|
cache: "no-store",
|
|
345
337
|
signal: controller.signal,
|
|
346
|
-
headers: { "
|
|
347
|
-
|
|
338
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
339
|
+
body: new URLSearchParams([[path, action === "set" ? JSON.stringify(body) : ""]]).toString(),
|
|
348
340
|
});
|
|
349
|
-
if (response.status !== 200 && !(
|
|
341
|
+
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
350
342
|
return response;
|
|
351
343
|
} finally {
|
|
352
344
|
clearTimeout(timer);
|
|
@@ -370,21 +362,21 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
370
362
|
* Serialize single-key mutations and update a still-active session only after success.
|
|
371
363
|
* @param {string} module 已打开模块 / Open module.
|
|
372
364
|
* @param {string} key 完整点分字段路径 / Complete dotted field path.
|
|
373
|
-
* @param {"
|
|
365
|
+
* @param {"set" | "delete"} action 写入或删除 / Write or delete.
|
|
374
366
|
* @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
|
|
375
367
|
* @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
|
|
376
368
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
377
369
|
* @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
|
|
378
370
|
*/
|
|
379
|
-
async function change(module, key,
|
|
371
|
+
async function change(module, key, action, value, operation = action === "set" ? "write" : "delete") {
|
|
380
372
|
const state = sessions.get(module);
|
|
381
373
|
if (!state?.definition) throw new Error("Open the module first");
|
|
382
374
|
if (state.saving) throw new Error("A settings write is already in progress");
|
|
383
375
|
const field = state.definition.fields.find(field => field.key === key);
|
|
384
376
|
state.saving = true;
|
|
385
377
|
try {
|
|
386
|
-
if ((operation === "write" || operation === "delete") && (!field || (
|
|
387
|
-
await send(
|
|
378
|
+
if ((operation === "write" || operation === "delete") && (!field || (action === "set" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
|
|
379
|
+
await send(`@${state.definition.storageKey}.${key}`, action, value);
|
|
388
380
|
if (sessions.get(module) === state) {
|
|
389
381
|
switch (operation) {
|
|
390
382
|
case "write":
|
|
@@ -427,7 +419,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
427
419
|
sessions.set(module, state);
|
|
428
420
|
try {
|
|
429
421
|
const definition = normalizeBoxJs(catalog.select(module), module);
|
|
430
|
-
const response = await send(
|
|
422
|
+
const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
|
|
431
423
|
let subtree = response.status === 404 ? {} : await response.json();
|
|
432
424
|
if (typeof subtree === "string") subtree = JSON.parse(subtree);
|
|
433
425
|
if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
|
|
@@ -457,7 +449,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
457
449
|
async readCaches(module) {
|
|
458
450
|
const state = sessions.get(module);
|
|
459
451
|
if (!state?.definition) throw new Error("Open the module first");
|
|
460
|
-
const response = await send(
|
|
452
|
+
const response = await send(`@${state.definition.storageKey}.${module}.Caches`, "get", undefined, state.controller.signal);
|
|
461
453
|
return response.status === 404 ? undefined : response.json();
|
|
462
454
|
},
|
|
463
455
|
/**
|
|
@@ -466,14 +458,14 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
466
458
|
* @param {string} module 已打开模块 / Open module.
|
|
467
459
|
* @returns {Promise<void>} 清理完成 / Cleanup completion.
|
|
468
460
|
*/
|
|
469
|
-
clearCaches: module => change(module, `${module}.Caches`, "
|
|
461
|
+
clearCaches: module => change(module, `${module}.Caches`, "delete", undefined, "clearCaches"),
|
|
470
462
|
/**
|
|
471
463
|
* 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
|
|
472
464
|
* Delete module persistence and reset the page cache using current BoxJS defaults.
|
|
473
465
|
* @param {string} module 已打开模块 / Open module.
|
|
474
466
|
* @returns {Promise<void>} 重置完成 / Reset completion.
|
|
475
467
|
*/
|
|
476
|
-
reset: module => change(module, module, "
|
|
468
|
+
reset: module => change(module, module, "delete", undefined, "reset"),
|
|
477
469
|
/**
|
|
478
470
|
* 取消读取并清除会话,不撤销已发送的写入。
|
|
479
471
|
* Abort reads and clear the session without undoing dispatched writes.
|
|
@@ -492,7 +484,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
492
484
|
* @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
|
|
493
485
|
* @returns {Promise<void>} 写入完成 / Write completion.
|
|
494
486
|
*/
|
|
495
|
-
set: (module, key, value) => change(module, key, "
|
|
487
|
+
set: (module, key, value) => change(module, key, "set", value),
|
|
496
488
|
/**
|
|
497
489
|
* 删除单键覆盖值并显示默认值。
|
|
498
490
|
* Delete one override and display its default value.
|
|
@@ -500,7 +492,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
500
492
|
* @param {string} key 点分字段路径 / Dotted field path.
|
|
501
493
|
* @returns {Promise<void>} 删除完成 / Delete completion.
|
|
502
494
|
*/
|
|
503
|
-
remove: (module, key) => change(module, key, "
|
|
495
|
+
remove: (module, key) => change(module, key, "delete"),
|
|
504
496
|
};
|
|
505
497
|
}
|
|
506
498
|
|
package/dist/module/index.html
CHANGED
|
@@ -11,7 +11,7 @@ function pageInputs(url, headers = {}) {
|
|
|
11
11
|
const module = match[1];
|
|
12
12
|
const values = Object.fromEntries(Object.entries(headers).map(([key, value]) => [key.toLowerCase(), value]));
|
|
13
13
|
const json = values["x-preferencepanes-json"] ?? url.searchParams.get("json") ?? `/configs/${module}`;
|
|
14
|
-
const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ??
|
|
14
|
+
const css = values["x-preferencepanes-css"] ?? url.searchParams.get("css") ?? "";
|
|
15
15
|
if (!json.trim()) throw new TypeError("JSON resource URL is required");
|
|
16
16
|
return { url: url.href, module, json, css };
|
|
17
17
|
}
|
|
@@ -1,13 +1,5 @@
|
|
|
1
1
|
var defaults = "/* 分组列表沿用 Bilibili 设置页的行结构,样式限定在面板内。\n * Grouped rows follow the Bilibili settings layout, scoped to the panel. */\n.pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n --pp-muted: #9499a0;\n --pp-accent: #fb7299;\n font:\n 15px / 1.5 -apple-system,\n BlinkMacSystemFont,\n \"Segoe UI\",\n sans-serif;\n color: var(--pp-text);\n background: var(--pp-background);\n position: relative;\n min-height: 100vh;\n}\n.pp-panel * {\n box-sizing: border-box;\n letter-spacing: 0;\n}\n.pp-header {\n height: calc(52px + env(safe-area-inset-top));\n padding: env(safe-area-inset-top) 12px 0;\n display: flex;\n align-items: center;\n background: var(--pp-surface);\n border-bottom: 1px solid var(--pp-border);\n position: sticky;\n top: 0;\n z-index: 1;\n}\n.pp-title {\n font-size: 17px;\n font-weight: 500;\n margin: 0;\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-brand {\n flex: 1;\n min-width: 0;\n display: flex;\n align-items: center;\n justify-content: center;\n gap: 8px;\n text-align: center;\n}\n.pp-brand-icon {\n display: none;\n flex: none;\n width: 28px;\n height: 28px;\n}\n.pp-brand-icon:not(:empty) {\n display: block;\n}\n.pp-brand-icon img {\n display: block;\n width: 100%;\n height: 100%;\n object-fit: contain;\n}\n.pp-nav-spacer {\n width: 44px;\n flex: none;\n}\n.pp-panel button {\n font: inherit;\n cursor: pointer;\n border: 0;\n background: none;\n color: inherit;\n}\n.pp-panel .pp-back {\n width: 44px;\n height: 44px;\n flex: none;\n font-size: 34px;\n line-height: 32px;\n padding: 0;\n}\n.pp-panel button:disabled {\n opacity: 0.5;\n cursor: wait;\n}\n.pp-viewport {\n position: relative;\n height: calc(100vh - 52px - env(safe-area-inset-top));\n overflow: hidden;\n}\n:root[data-preference-panes-embedded] .pp-header {\n display: none;\n}\n:root[data-preference-panes-embedded] .pp-viewport {\n height: 100vh;\n}\n@supports (height: 100dvh) {\n .pp-viewport {\n height: calc(100dvh - 52px - env(safe-area-inset-top));\n }\n :root[data-preference-panes-embedded] .pp-viewport {\n height: 100dvh;\n }\n}\n.pp-fields,\n.pp-choice-page {\n position: absolute;\n inset: 0;\n overflow: auto;\n padding: 12px max(16px, calc((100% - 688px) / 2)) calc(28px + env(safe-area-inset-bottom));\n background: var(--pp-background);\n}\n.pp-panel .form-group {\n margin: 0 0 12px;\n}\n.pp-panel .form-group__title {\n font-size: 12px;\n line-height: 17px;\n font-weight: 400;\n color: var(--pp-muted);\n padding-left: 12px;\n margin: 12px 0 6px;\n}\n.pp-panel .form-group__row {\n border-radius: 8px;\n overflow: hidden;\n background: var(--pp-surface);\n}\n.pp-panel .form-row {\n position: relative;\n display: flex;\n align-items: center;\n width: 100%;\n min-height: 46px;\n padding: 12px;\n border: 0;\n border-bottom: 1px solid var(--pp-border);\n background: var(--pp-surface);\n gap: 12px;\n}\n.pp-panel .form-row:last-child {\n border-bottom: 0;\n}\n.pp-panel .form-row__text {\n flex: 1;\n min-width: 0;\n margin: 0;\n display: flex;\n flex-direction: column;\n}\n.pp-panel .form-row__title {\n font-size: 15px;\n line-height: 22px;\n color: var(--pp-text);\n text-align: left;\n}\n.pp-panel .form-row__subtitle {\n font-size: 12px;\n line-height: 18px;\n color: var(--pp-muted);\n overflow-wrap: anywhere;\n margin-top: 2px;\n}\n.pp-choice-link {\n display: flex;\n align-items: center;\n justify-content: flex-end;\n gap: 8px;\n max-width: 45%;\n min-width: 44px;\n min-height: 44px;\n padding: 0;\n text-align: right;\n flex: 1;\n}\n.pp-summary {\n color: var(--pp-muted);\n font-size: 13px;\n line-height: 18px;\n display: -webkit-box;\n -webkit-line-clamp: 2;\n -webkit-box-orient: vertical;\n overflow: hidden;\n overflow-wrap: anywhere;\n}\n.pp-chevron {\n color: var(--pp-muted);\n font-size: 22px;\n flex: none;\n}\n.pp-input {\n font: inherit;\n color: var(--pp-text);\n background: var(--pp-surface);\n border: 1px solid var(--pp-border);\n border-radius: 6px;\n padding: 8px;\n min-width: 0;\n max-width: 45%;\n width: 45%;\n}\nselect.pp-input {\n text-overflow: ellipsis;\n font-size: 13px;\n}\n.pp-panel .pp-multiline {\n display: block;\n}\n.pp-multiline .pp-input {\n max-width: 100%;\n width: 100%;\n margin-top: 10px;\n}\n.pp-switch {\n appearance: none;\n -webkit-appearance: none;\n position: relative;\n flex: none;\n width: 32px;\n height: 20px;\n max-width: none;\n border: 0;\n border-radius: 15px;\n padding: 0;\n background: #c9ccd0;\n cursor: pointer;\n transition: background 0.2s;\n}\n.pp-switch::before {\n content: \"\";\n position: absolute;\n top: 3px;\n left: 3px;\n width: 14px;\n height: 14px;\n border-radius: 50%;\n background: white;\n transition: transform 0.2s;\n}\n.pp-switch:checked {\n background: var(--pp-accent);\n}\n.pp-switch:checked::before {\n transform: translateX(12px);\n}\n.pp-choice {\n justify-content: space-between;\n cursor: pointer;\n}\n.pp-choice input {\n width: 20px;\n height: 20px;\n flex: none;\n accent-color: var(--pp-accent);\n margin: 0;\n}\n.pp-description {\n font-size: 12px;\n line-height: 1.6;\n color: var(--pp-muted);\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-module-info {\n display: flex;\n gap: 12px;\n margin: 12px 0;\n}\n.pp-module-icon {\n width: 48px;\n height: 48px;\n object-fit: contain;\n flex: none;\n}\n.pp-module-details {\n min-width: 0;\n overflow-wrap: anywhere;\n}\n.pp-module-source {\n color: inherit;\n text-decoration: underline;\n}\n.pp-maintenance {\n margin-top: 24px;\n}\n.pp-actions {\n display: flex;\n flex-wrap: wrap;\n gap: 8px;\n}\n.pp-actions button,\n.pp-error button {\n min-height: 44px;\n padding: 8px 12px;\n border-radius: 6px;\n background: var(--pp-surface);\n}\n.pp-panel .pp-danger {\n color: #e45656;\n}\n.pp-cache {\n max-height: 320px;\n overflow: auto;\n white-space: pre-wrap;\n overflow-wrap: anywhere;\n}\n.pp-toast {\n pointer-events: none;\n position: fixed;\n bottom: calc(30px + env(safe-area-inset-bottom));\n left: 50%;\n transform: translateX(-50%);\n max-width: 90vw;\n padding: 10px 16px;\n border-radius: 8px;\n background: #333e;\n color: white;\n font-size: 13px;\n z-index: 20;\n}\n.pp-toast[data-kind=\"error\"] {\n background: #8d2424;\n}\n.pp-panel :focus-visible {\n outline: 2px solid var(--pp-accent);\n outline-offset: -2px;\n}\n@media (prefers-color-scheme: dark) {\n .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n }\n}\n:root[data-theme=\"dark\"] .pp-panel {\n --pp-text: #e3e5e7;\n --pp-background: #17181a;\n --pp-surface: #232427;\n --pp-border: #343538;\n}\n:root[data-theme=\"light\"] .pp-panel {\n --pp-text: #18191c;\n --pp-background: #f6f7f8;\n --pp-surface: #fff;\n --pp-border: #e3e5e7;\n}\n@media (prefers-reduced-motion: reduce) {\n .pp-panel .pp-switch,\n .pp-panel .pp-switch::before {\n transition: none;\n }\n}\n";
|
|
2
2
|
|
|
3
|
-
/**
|
|
4
|
-
* 解析已经取得的 pathname,避免重复构造 URL。
|
|
5
|
-
* Parse an existing pathname without constructing another URL.
|
|
6
|
-
* @param {string} pathname 以 / 开头的 URL pathname / URL pathname beginning with /.
|
|
7
|
-
* @returns {string[] | undefined} 解码后的路径,非 API 路径不处理 / Decoded path, or undefined outside /api/.
|
|
8
|
-
* @throws {TypeError} 转义编码或路径片段非法 / Invalid percent encoding or path segments.
|
|
9
|
-
*/
|
|
10
|
-
|
|
11
3
|
/**
|
|
12
4
|
* 校验原始路径片段,不进行 URL 编码转换。
|
|
13
5
|
* Validate raw path segments without URL encoding conversion.
|
|
@@ -304,31 +296,31 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
304
296
|
*/
|
|
305
297
|
const sessions = new Map();
|
|
306
298
|
/**
|
|
307
|
-
*
|
|
308
|
-
* Send a
|
|
309
|
-
* @param {string} path
|
|
310
|
-
* @param {"
|
|
311
|
-
* @param {unknown} body
|
|
299
|
+
* 用 form 发送完整存储键;读取 404 交给调用方处理。
|
|
300
|
+
* Send a complete storage key as form data; callers handle missing reads.
|
|
301
|
+
* @param {string} path 完整 @root.path / Complete @root.path.
|
|
302
|
+
* @param {"get" | "set" | "delete"} action 存储操作 / Storage operation.
|
|
303
|
+
* @param {unknown} body set 值,其它操作忽略 / Set value, ignored by other operations.
|
|
312
304
|
* @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
|
|
313
305
|
* @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
|
|
314
306
|
* @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
|
|
315
307
|
*/
|
|
316
|
-
async function send(path,
|
|
308
|
+
async function send(path, action, body, signal) {
|
|
317
309
|
const controller = new AbortController();
|
|
318
310
|
const abort = () => controller.abort();
|
|
319
311
|
if (signal?.aborted) abort();
|
|
320
312
|
signal?.addEventListener("abort", abort, { once: true });
|
|
321
313
|
const timer = setTimeout(abort, timeout);
|
|
322
314
|
try {
|
|
323
|
-
const response = await request(
|
|
324
|
-
method,
|
|
315
|
+
const response = await request(`/api/${action}`, {
|
|
316
|
+
method: "POST",
|
|
325
317
|
credentials: "omit",
|
|
326
318
|
cache: "no-store",
|
|
327
319
|
signal: controller.signal,
|
|
328
|
-
headers: { "
|
|
329
|
-
|
|
320
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
321
|
+
body: new URLSearchParams([[path, action === "set" ? JSON.stringify(body) : ""]]).toString(),
|
|
330
322
|
});
|
|
331
|
-
if (response.status !== 200 && !(
|
|
323
|
+
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
332
324
|
return response;
|
|
333
325
|
} finally {
|
|
334
326
|
clearTimeout(timer);
|
|
@@ -352,21 +344,21 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
352
344
|
* Serialize single-key mutations and update a still-active session only after success.
|
|
353
345
|
* @param {string} module 已打开模块 / Open module.
|
|
354
346
|
* @param {string} key 完整点分字段路径 / Complete dotted field path.
|
|
355
|
-
* @param {"
|
|
347
|
+
* @param {"set" | "delete"} action 写入或删除 / Write or delete.
|
|
356
348
|
* @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
|
|
357
349
|
* @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
|
|
358
350
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
359
351
|
* @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
|
|
360
352
|
*/
|
|
361
|
-
async function change(module, key,
|
|
353
|
+
async function change(module, key, action, value, operation = action === "set" ? "write" : "delete") {
|
|
362
354
|
const state = sessions.get(module);
|
|
363
355
|
if (!state?.definition) throw new Error("Open the module first");
|
|
364
356
|
if (state.saving) throw new Error("A settings write is already in progress");
|
|
365
357
|
const field = state.definition.fields.find(field => field.key === key);
|
|
366
358
|
state.saving = true;
|
|
367
359
|
try {
|
|
368
|
-
if ((operation === "write" || operation === "delete") && (!field || (
|
|
369
|
-
await send(
|
|
360
|
+
if ((operation === "write" || operation === "delete") && (!field || (action === "set" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
|
|
361
|
+
await send(`@${state.definition.storageKey}.${key}`, action, value);
|
|
370
362
|
if (sessions.get(module) === state) {
|
|
371
363
|
switch (operation) {
|
|
372
364
|
case "write":
|
|
@@ -409,7 +401,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
409
401
|
sessions.set(module, state);
|
|
410
402
|
try {
|
|
411
403
|
const definition = normalizeBoxJs(catalog.select(module), module);
|
|
412
|
-
const response = await send(
|
|
404
|
+
const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
|
|
413
405
|
let subtree = response.status === 404 ? {} : await response.json();
|
|
414
406
|
if (typeof subtree === "string") subtree = JSON.parse(subtree);
|
|
415
407
|
if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
|
|
@@ -439,7 +431,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
439
431
|
async readCaches(module) {
|
|
440
432
|
const state = sessions.get(module);
|
|
441
433
|
if (!state?.definition) throw new Error("Open the module first");
|
|
442
|
-
const response = await send(
|
|
434
|
+
const response = await send(`@${state.definition.storageKey}.${module}.Caches`, "get", undefined, state.controller.signal);
|
|
443
435
|
return response.status === 404 ? undefined : response.json();
|
|
444
436
|
},
|
|
445
437
|
/**
|
|
@@ -448,14 +440,14 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
448
440
|
* @param {string} module 已打开模块 / Open module.
|
|
449
441
|
* @returns {Promise<void>} 清理完成 / Cleanup completion.
|
|
450
442
|
*/
|
|
451
|
-
clearCaches: module => change(module, `${module}.Caches`, "
|
|
443
|
+
clearCaches: module => change(module, `${module}.Caches`, "delete", undefined, "clearCaches"),
|
|
452
444
|
/**
|
|
453
445
|
* 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
|
|
454
446
|
* Delete module persistence and reset the page cache using current BoxJS defaults.
|
|
455
447
|
* @param {string} module 已打开模块 / Open module.
|
|
456
448
|
* @returns {Promise<void>} 重置完成 / Reset completion.
|
|
457
449
|
*/
|
|
458
|
-
reset: module => change(module, module, "
|
|
450
|
+
reset: module => change(module, module, "delete", undefined, "reset"),
|
|
459
451
|
/**
|
|
460
452
|
* 取消读取并清除会话,不撤销已发送的写入。
|
|
461
453
|
* Abort reads and clear the session without undoing dispatched writes.
|
|
@@ -474,7 +466,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
474
466
|
* @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
|
|
475
467
|
* @returns {Promise<void>} 写入完成 / Write completion.
|
|
476
468
|
*/
|
|
477
|
-
set: (module, key, value) => change(module, key, "
|
|
469
|
+
set: (module, key, value) => change(module, key, "set", value),
|
|
478
470
|
/**
|
|
479
471
|
* 删除单键覆盖值并显示默认值。
|
|
480
472
|
* Delete one override and display its default value.
|
|
@@ -482,7 +474,7 @@ function createPreferencesClient({ catalog, fetch: request = globalThis.fetch.bi
|
|
|
482
474
|
* @param {string} key 点分字段路径 / Dotted field path.
|
|
483
475
|
* @returns {Promise<void>} 删除完成 / Delete completion.
|
|
484
476
|
*/
|
|
485
|
-
remove: (module, key) => change(module, key, "
|
|
477
|
+
remove: (module, key) => change(module, key, "delete"),
|
|
486
478
|
};
|
|
487
479
|
}
|
|
488
480
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nsnanocat/preference-panes",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Shared settings API runtime for JavaScript proxy modules",
|
|
5
5
|
"author": "VirgilClyne <Virgil@nanocat.me>",
|
|
6
6
|
"homepage": "https://NSNanoCat.github.io/preference-panes",
|
package/src/Store.mjs
CHANGED
|
@@ -2,83 +2,70 @@ import { URL } from "@nsnanocat/url";
|
|
|
2
2
|
import { Lodash as _ } from "@nsnanocat/util/polyfill/Lodash.mjs";
|
|
3
3
|
import { Storage } from "@nsnanocat/util/polyfill/Storage";
|
|
4
4
|
import { response } from "./lib/response.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { validatePathParts } from "./lib/settings-path.mjs";
|
|
6
6
|
|
|
7
7
|
/**
|
|
8
|
-
*
|
|
9
|
-
*
|
|
8
|
+
* 无配置绑定的本地存储桥接;form 字段名就是完整 @root.path。
|
|
9
|
+
* Unbound local storage bridge; the form field name is the complete @root.path.
|
|
10
10
|
*/
|
|
11
11
|
export class Store {
|
|
12
|
-
#catalog;
|
|
13
|
-
|
|
14
|
-
/**
|
|
15
|
-
* 复用包内已解析的目录,构造时不访问网络或存储。
|
|
16
|
-
* Reuse the parsed internal catalog without network or persistence access during construction.
|
|
17
|
-
* @param {import("./BoxJS.mjs").BoxJS} catalog BoxJS 路径目录 / BoxJS path catalog.
|
|
18
|
-
*/
|
|
19
|
-
constructor(catalog) {
|
|
20
|
-
this.#catalog = catalog;
|
|
21
|
-
}
|
|
22
|
-
|
|
23
12
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
13
|
+
* POST /api/get、set、delete;不下载配置、不解析控件、不鉴权。
|
|
14
|
+
* POST /api/get, set or delete without config downloads, control parsing or authentication.
|
|
26
15
|
* @param {import("./index.js").SettingsRequest} request 代理请求 / Proxy request.
|
|
27
|
-
* @param {URL} [url]
|
|
28
|
-
* @returns {Promise<import("./index.js").SettingsResponse | undefined>}
|
|
16
|
+
* @param {URL} [url] 已解析地址 / Parsed URL.
|
|
17
|
+
* @returns {Promise<import("./index.js").SettingsResponse | undefined>} 操作结果 / Operation result.
|
|
29
18
|
*/
|
|
30
19
|
async handle(request, url = new URL(request.url)) {
|
|
31
20
|
if (!url.pathname.startsWith("/api/")) return;
|
|
32
21
|
const reply = (status, data) => response(request, status, data);
|
|
33
|
-
|
|
22
|
+
const action = url.pathname.slice(5);
|
|
23
|
+
if (!["get", "set", "delete"].includes(action)) return reply(404, { error: "Unknown action" });
|
|
24
|
+
if (request.method !== "POST") return reply(405, { error: "Use POST with a form body" });
|
|
25
|
+
const headers = Object.fromEntries(Object.entries(request.headers ?? {}).map(([key, value]) => [key.toLowerCase(), value]));
|
|
26
|
+
if (headers["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/x-www-form-urlencoded") return reply(415, { error: "Expected application/x-www-form-urlencoded" });
|
|
27
|
+
if (typeof request.body !== "string" || request.body.length > 65536) return reply(400, { error: "Expected a form body up to 65536 characters" });
|
|
28
|
+
let parts, value;
|
|
34
29
|
try {
|
|
35
|
-
|
|
30
|
+
const fields = request.body.split("&");
|
|
31
|
+
if (fields.length !== 1) throw new TypeError("Send exactly one storage key");
|
|
32
|
+
const separator = fields[0].indexOf("=");
|
|
33
|
+
if (separator < 0) throw new TypeError("Expected @root.path=value");
|
|
34
|
+
const key = decodeURIComponent(fields[0].slice(0, separator).replace(/\+/g, " "));
|
|
35
|
+
value = decodeURIComponent(fields[0].slice(separator + 1).replace(/\+/g, " "));
|
|
36
|
+
if (!key.startsWith("@")) throw new TypeError("Storage keys must start with @");
|
|
37
|
+
parts = validatePathParts(key.slice(1).split("."));
|
|
38
|
+
if (parts.length < 2) throw new TypeError("Specify a storage root and child path");
|
|
36
39
|
} catch (error) {
|
|
37
40
|
return reply(400, { error: error.message });
|
|
38
41
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
case "HEAD":
|
|
46
|
-
return reply(200, undefined);
|
|
47
|
-
case "GET":
|
|
48
|
-
case "DELETE":
|
|
49
|
-
break;
|
|
50
|
-
case "POST":
|
|
51
|
-
if (requestHeaders["content-type"]?.split(";")[0].trim().toLowerCase() !== "application/json") return reply(415, { error: "Expected application/json" });
|
|
52
|
-
if (typeof request.body !== "string") return reply(400, { error: "Expected a JSON string body" });
|
|
53
|
-
if (request.body.length > 65536) return reply(413, { error: "Body exceeds 65536 UTF-16 code units" });
|
|
54
|
-
try {
|
|
55
|
-
value = JSON.parse(request.body);
|
|
56
|
-
} catch {
|
|
57
|
-
return reply(400, { error: "Invalid JSON" });
|
|
58
|
-
}
|
|
59
|
-
break;
|
|
60
|
-
default:
|
|
61
|
-
return { ...reply(405, { error: "Method not allowed" }), headers: { ...reply(405).headers, Allow: "HEAD, GET, POST, DELETE" } };
|
|
42
|
+
if (action === "set") {
|
|
43
|
+
try {
|
|
44
|
+
value = JSON.parse(value);
|
|
45
|
+
} catch (error) {
|
|
46
|
+
if (!(error instanceof SyntaxError)) throw error;
|
|
47
|
+
}
|
|
62
48
|
}
|
|
49
|
+
const [storageKey, ...path] = parts;
|
|
63
50
|
try {
|
|
64
|
-
const root = Storage.getItem(
|
|
65
|
-
if (!isRecord(root)) throw new TypeError("
|
|
66
|
-
const parent = storageParent(root,
|
|
67
|
-
const key =
|
|
68
|
-
switch (
|
|
69
|
-
case "
|
|
51
|
+
const root = Storage.getItem(storageKey, {});
|
|
52
|
+
if (!isRecord(root)) throw new TypeError("Stored root must be an object");
|
|
53
|
+
const parent = storageParent(root, path, action === "set");
|
|
54
|
+
const key = path.at(-1);
|
|
55
|
+
switch (action) {
|
|
56
|
+
case "get": {
|
|
70
57
|
const result = parent ? _.get(parent, [key]) : undefined;
|
|
71
58
|
return result === undefined ? reply(404, { error: "Stored path does not exist" }) : reply(200, result);
|
|
72
59
|
}
|
|
73
|
-
case "
|
|
60
|
+
case "set":
|
|
74
61
|
_.set(parent, [key], value);
|
|
75
62
|
break;
|
|
76
|
-
case "
|
|
63
|
+
case "delete":
|
|
77
64
|
if (parent) _.unset(parent, [key]);
|
|
78
65
|
break;
|
|
79
66
|
}
|
|
80
|
-
if (!Storage.setItem(
|
|
81
|
-
return reply(200,
|
|
67
|
+
if (!Storage.setItem(storageKey, root)) throw new Error("Storage write failed");
|
|
68
|
+
return reply(200, action === "set" ? { saved: true } : { deleted: true });
|
|
82
69
|
} catch (error) {
|
|
83
70
|
return reply(500, { error: error.message });
|
|
84
71
|
}
|
package/src/browser/client.d.mts
CHANGED
|
@@ -111,8 +111,8 @@ export interface PreferencesClient {
|
|
|
111
111
|
*/
|
|
112
112
|
set(module: string, key: string, value: SettingsScalar | SettingsScalar[]): Promise<void>;
|
|
113
113
|
/**
|
|
114
|
-
*
|
|
115
|
-
*
|
|
114
|
+
* POST /api/delete 删除覆盖值,200 后显示默认值,不追加读取。
|
|
115
|
+
* POST /api/delete removes an override and displays its default after 200, without rereading.
|
|
116
116
|
* @param module 已打开的模块 / Open module.
|
|
117
117
|
* @param key 完整点分字段路径 / Complete dotted field path.
|
|
118
118
|
* @returns 操作完成 / Completion of the operation.
|
package/src/browser/client.mjs
CHANGED
|
@@ -24,31 +24,31 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
24
24
|
*/
|
|
25
25
|
const sessions = new Map();
|
|
26
26
|
/**
|
|
27
|
-
*
|
|
28
|
-
* Send a
|
|
29
|
-
* @param {string} path
|
|
30
|
-
* @param {"
|
|
31
|
-
* @param {unknown} body
|
|
27
|
+
* 用 form 发送完整存储键;读取 404 交给调用方处理。
|
|
28
|
+
* Send a complete storage key as form data; callers handle missing reads.
|
|
29
|
+
* @param {string} path 完整 @root.path / Complete @root.path.
|
|
30
|
+
* @param {"get" | "set" | "delete"} action 存储操作 / Storage operation.
|
|
31
|
+
* @param {unknown} body set 值,其它操作忽略 / Set value, ignored by other operations.
|
|
32
32
|
* @param {AbortSignal | undefined} signal 会话取消信号 / Session cancellation signal.
|
|
33
33
|
* @returns {Promise<Response>} 未消费正文的响应 / Response with an unread body.
|
|
34
34
|
* @throws {Error} 非 200 且非数据 GET 404、超时、取消或网络错误 / Non-200 status except missing-data GETs, timeout, cancellation or network error.
|
|
35
35
|
*/
|
|
36
|
-
async function send(path,
|
|
36
|
+
async function send(path, action, body, signal) {
|
|
37
37
|
const controller = new AbortController();
|
|
38
38
|
const abort = () => controller.abort();
|
|
39
39
|
if (signal?.aborted) abort();
|
|
40
40
|
signal?.addEventListener("abort", abort, { once: true });
|
|
41
41
|
const timer = setTimeout(abort, timeout);
|
|
42
42
|
try {
|
|
43
|
-
const response = await request(
|
|
44
|
-
method,
|
|
43
|
+
const response = await request(`/api/${action}`, {
|
|
44
|
+
method: "POST",
|
|
45
45
|
credentials: "omit",
|
|
46
46
|
cache: "no-store",
|
|
47
47
|
signal: controller.signal,
|
|
48
|
-
headers: { "
|
|
49
|
-
|
|
48
|
+
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
|
49
|
+
body: new URLSearchParams([[path, action === "set" ? JSON.stringify(body) : ""]]).toString(),
|
|
50
50
|
});
|
|
51
|
-
if (response.status !== 200 && !(
|
|
51
|
+
if (response.status !== 200 && !(action === "get" && response.status === 404)) throw new Error(`HTTP ${response.status}`);
|
|
52
52
|
return response;
|
|
53
53
|
} finally {
|
|
54
54
|
clearTimeout(timer);
|
|
@@ -72,21 +72,21 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
72
72
|
* Serialize single-key mutations and update a still-active session only after success.
|
|
73
73
|
* @param {string} module 已打开模块 / Open module.
|
|
74
74
|
* @param {string} key 完整点分字段路径 / Complete dotted field path.
|
|
75
|
-
* @param {"
|
|
75
|
+
* @param {"set" | "delete"} action 写入或删除 / Write or delete.
|
|
76
76
|
* @param {unknown} value 写入值,删除时忽略 / Write value, ignored for deletion.
|
|
77
77
|
* @param {"write" | "delete" | "clearCaches" | "reset"} [operation] 操作类型 / Operation kind.
|
|
78
78
|
* @returns {Promise<void>} 操作完成 / Operation completion.
|
|
79
79
|
* @throws {Error} 会话、字段、值或请求错误 / Session, field, value or request error.
|
|
80
80
|
*/
|
|
81
|
-
async function change(module, key,
|
|
81
|
+
async function change(module, key, action, value, operation = action === "set" ? "write" : "delete") {
|
|
82
82
|
const state = sessions.get(module);
|
|
83
83
|
if (!state?.definition) throw new Error("Open the module first");
|
|
84
84
|
if (state.saving) throw new Error("A settings write is already in progress");
|
|
85
85
|
const field = state.definition.fields.find(field => field.key === key);
|
|
86
86
|
state.saving = true;
|
|
87
87
|
try {
|
|
88
|
-
if ((operation === "write" || operation === "delete") && (!field || (
|
|
89
|
-
await send(
|
|
88
|
+
if ((operation === "write" || operation === "delete") && (!field || (action === "set" && !validValue(field, value)))) throw new TypeError("Invalid setting value");
|
|
89
|
+
await send(`@${state.definition.storageKey}.${key}`, action, value);
|
|
90
90
|
if (sessions.get(module) === state) {
|
|
91
91
|
switch (operation) {
|
|
92
92
|
case "write":
|
|
@@ -129,7 +129,7 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
129
129
|
sessions.set(module, state);
|
|
130
130
|
try {
|
|
131
131
|
const definition = normalizeBoxJs(catalog.select(module), module);
|
|
132
|
-
const response = await send(
|
|
132
|
+
const response = await send(`@${definition.storageKey}.${definition.settingsPath.join(".")}`, "get", undefined, state.controller.signal);
|
|
133
133
|
let subtree = response.status === 404 ? {} : await response.json();
|
|
134
134
|
if (typeof subtree === "string") subtree = JSON.parse(subtree);
|
|
135
135
|
if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
|
|
@@ -159,7 +159,7 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
159
159
|
async readCaches(module) {
|
|
160
160
|
const state = sessions.get(module);
|
|
161
161
|
if (!state?.definition) throw new Error("Open the module first");
|
|
162
|
-
const response = await send(
|
|
162
|
+
const response = await send(`@${state.definition.storageKey}.${module}.Caches`, "get", undefined, state.controller.signal);
|
|
163
163
|
return response.status === 404 ? undefined : response.json();
|
|
164
164
|
},
|
|
165
165
|
/**
|
|
@@ -168,14 +168,14 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
168
168
|
* @param {string} module 已打开模块 / Open module.
|
|
169
169
|
* @returns {Promise<void>} 清理完成 / Cleanup completion.
|
|
170
170
|
*/
|
|
171
|
-
clearCaches: module => change(module, `${module}.Caches`, "
|
|
171
|
+
clearCaches: module => change(module, `${module}.Caches`, "delete", undefined, "clearCaches"),
|
|
172
172
|
/**
|
|
173
173
|
* 删除整个模块持久化节点,以当前 BoxJS 默认值重置页面缓存。
|
|
174
174
|
* Delete module persistence and reset the page cache using current BoxJS defaults.
|
|
175
175
|
* @param {string} module 已打开模块 / Open module.
|
|
176
176
|
* @returns {Promise<void>} 重置完成 / Reset completion.
|
|
177
177
|
*/
|
|
178
|
-
reset: module => change(module, module, "
|
|
178
|
+
reset: module => change(module, module, "delete", undefined, "reset"),
|
|
179
179
|
/**
|
|
180
180
|
* 取消读取并清除会话,不撤销已发送的写入。
|
|
181
181
|
* Abort reads and clear the session without undoing dispatched writes.
|
|
@@ -194,7 +194,7 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
194
194
|
* @param {import("../index.js").SettingsScalar | import("../index.js").SettingsScalar[]} value 字段值 / Field value.
|
|
195
195
|
* @returns {Promise<void>} 写入完成 / Write completion.
|
|
196
196
|
*/
|
|
197
|
-
set: (module, key, value) => change(module, key, "
|
|
197
|
+
set: (module, key, value) => change(module, key, "set", value),
|
|
198
198
|
/**
|
|
199
199
|
* 删除单键覆盖值并显示默认值。
|
|
200
200
|
* Delete one override and display its default value.
|
|
@@ -202,6 +202,6 @@ export function createPreferencesClient({ catalog, fetch: request = globalThis.f
|
|
|
202
202
|
* @param {string} key 点分字段路径 / Dotted field path.
|
|
203
203
|
* @returns {Promise<void>} 删除完成 / Delete completion.
|
|
204
204
|
*/
|
|
205
|
-
remove: (module, key) => change(module, key, "
|
|
205
|
+
remove: (module, key) => change(module, key, "delete"),
|
|
206
206
|
};
|
|
207
207
|
}
|