@nsnanocat/preference-panes 1.2.2 → 1.2.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.
@@ -164,16 +164,60 @@ function scalar(value) {
164
164
  }
165
165
 
166
166
  /**
167
- * 检查值类型、数组唯一性及声明的选项,不进行转换。
168
- * Check value type, array uniqueness and declared choices without coercion.
167
+ * 检查值类型与数组结构,不检查选项是否仍在当前配置中。
168
+ * Check value type and array structure without requiring current option membership.
169
169
  * @param {import("../index.js").SettingsField} field 前端归一化字段 / Normalized frontend field.
170
- * @param {unknown} value 待写入的 JSON 值 / JSON value to write.
171
- * @returns {boolean} 是否符合字段约束 / Whether the value satisfies field constraints.
170
+ * @param {unknown} value 待检查值 / Value to inspect.
171
+ * @returns {boolean} 是否可由控件表示 / Whether the control can represent the value.
172
172
  */
173
- export function validValue(field, value) {
173
+ function validValueShape(field, value) {
174
174
  if (field.type === "array") {
175
175
  if (!Array.isArray(value) || value.some(item => !scalar(item)) || new Set(value).size !== value.length) return false;
176
176
  } else if (typeof value !== field.type || !scalar(value)) return false;
177
177
  if (field.control === "url" && (!/^[a-z][a-z\d+.-]*:/i.test(value) || /^(?:javascript|data|vbscript):/i.test(value))) return false;
178
- return !field.options || (field.type === "array" ? value : [value]).every(item => field.options.some(option => option.key === item));
178
+ return true;
179
+ }
180
+
181
+ /**
182
+ * 返回当前配置未声明的选项值。
183
+ * Return option values absent from the current definition.
184
+ * @param {import("../index.js").SettingsField} field 前端归一化字段 / Normalized frontend field.
185
+ * @param {unknown} value 已确认结构有效的值 / Value with a valid shape.
186
+ * @returns {unknown[]} 未定义值 / Undefined values.
187
+ */
188
+ function undefinedOptions(field, value) {
189
+ if (!field.options) return [];
190
+ return (field.type === "array" ? value : [value]).filter(item => !field.options.some(option => option.key === item));
191
+ }
192
+
193
+ /**
194
+ * 将外部存储值解析为可渲染值,并把兼容性问题作为字段级警告返回。
195
+ * Resolve external storage into a renderable value and return compatibility issues as field warnings.
196
+ * @param {import("../index.js").SettingsField} field 前端归一化字段 / Normalized frontend field.
197
+ * @param {unknown} stored 外部存储值 / External stored value.
198
+ * @returns {{value?: unknown, warning?: {kind: "invalid-value" | "undefined-options", values: unknown[]}}} 解析结果 / Resolution result.
199
+ */
200
+ export function resolveStoredValue(field, stored) {
201
+ if (field.control === "url") return { value: field.defaultValue };
202
+ const value = normalizeStoredValue(field, stored === undefined ? field.defaultValue : stored);
203
+ if (value === undefined) return {};
204
+ if (!validValueShape(field, value)) {
205
+ return {
206
+ ...(Object.hasOwn(field, "defaultValue") ? { value: field.defaultValue } : {}),
207
+ warning: { kind: "invalid-value", values: [stored] },
208
+ };
209
+ }
210
+ const undefinedValues = undefinedOptions(field, value);
211
+ return { value, ...(undefinedValues.length ? { warning: { kind: "undefined-options", values: undefinedValues } } : {}) };
212
+ }
213
+
214
+ /**
215
+ * 严格检查写入值的类型、数组唯一性及声明选项。
216
+ * Strictly check a write value's type, array uniqueness, and declared options.
217
+ * @param {import("../index.js").SettingsField} field 前端归一化字段 / Normalized frontend field.
218
+ * @param {unknown} value 待写入的 JSON 值 / JSON value to write.
219
+ * @returns {boolean} 是否允许写入 / Whether the value may be written.
220
+ */
221
+ export function validValue(field, value) {
222
+ return validValueShape(field, value) && undefinedOptions(field, value).length === 0;
179
223
  }
@@ -29,6 +29,16 @@ export interface PreferencesClientOptions {
29
29
  /** 请求超时毫秒数 / Request timeout in milliseconds. */
30
30
  timeout?: number;
31
31
  }
32
+ /**
33
+ * 外部存储值与当前字段定义不完全兼容时的非阻断诊断。
34
+ * Non-blocking diagnostic for external storage that no longer fully matches a field definition.
35
+ */
36
+ export interface StoredValueWarning {
37
+ /** 警告类别 / Warning kind. */
38
+ kind: "invalid-value" | "undefined-options";
39
+ /** 触发警告的原始值或未定义选项 / Raw value or undefined options that triggered the warning. */
40
+ values: unknown[];
41
+ }
32
42
  /**
33
43
  * 会话的深拷贝快照。
34
44
  * Deep-cloned session snapshot.
@@ -38,6 +48,8 @@ export interface ModuleSnapshot {
38
48
  definition: ModuleDefinition;
39
49
  /** 当前页面值 / Current page values. */
40
50
  values: Record<string, SettingsScalar | SettingsScalar[] | null>;
51
+ /** 按字段路径索引的非阻断存储警告 / Non-blocking storage warnings keyed by field path. */
52
+ warnings: Record<string, StoredValueWarning>;
41
53
  }
42
54
  /**
43
55
  * 管理单模块页面的 API 请求、值快照和会话终止。
@@ -1,4 +1,4 @@
1
- import { normalizeStoredValue, validValue } from "./boxjs.mjs";
1
+ import { resolveStoredValue, validValue } from "./boxjs.mjs";
2
2
 
3
3
  /**
4
4
  * 管理单模块页面的 API 请求、值快照和会话终止。
@@ -12,6 +12,7 @@ export class PreferencesClient {
12
12
  #timeout;
13
13
  #session = new AbortController();
14
14
  #values = {};
15
+ #warnings = {};
15
16
  #saving = false;
16
17
 
17
18
  /**
@@ -38,17 +39,18 @@ export class PreferencesClient {
38
39
  if (typeof subtree === "string") subtree = JSON.parse(subtree);
39
40
  if (!subtree || typeof subtree !== "object" || Array.isArray(subtree)) throw new TypeError("Expected a settings subtree object");
40
41
  const values = {};
42
+ const warnings = {};
41
43
  for (const field of this.#definition.fields) {
42
44
  const stored = field.key
43
45
  .split(".")
44
46
  .slice(this.#definition.settingsPath.length)
45
47
  .reduce((parent, part) => Object(parent)[part], subtree);
46
- const value = normalizeStoredValue(field, stored === undefined ? field.defaultValue : stored);
47
- if (value === undefined) continue;
48
- if (!validValue(field, value)) throw new TypeError(`Invalid stored value: ${field.key}`);
49
- values[field.key] = value;
48
+ const resolved = resolveStoredValue(field, stored);
49
+ if (Object.hasOwn(resolved, "value")) values[field.key] = resolved.value;
50
+ if (resolved.warning) warnings[field.key] = resolved.warning;
50
51
  }
51
52
  this.#values = values;
53
+ this.#warnings = warnings;
52
54
  return this.snapshot();
53
55
  }
54
56
 
@@ -58,7 +60,7 @@ export class PreferencesClient {
58
60
  * @returns {import("./client.mjs").ModuleSnapshot} 会话快照 / Session snapshot.
59
61
  */
60
62
  snapshot() {
61
- return structuredClone({ definition: this.#definition, values: this.#values });
63
+ return structuredClone({ definition: this.#definition, values: this.#values, warnings: this.#warnings });
62
64
  }
63
65
 
64
66
  /**
@@ -182,15 +184,18 @@ export class PreferencesClient {
182
184
  switch (operation) {
183
185
  case "write":
184
186
  this.#values[key] = structuredClone(value);
187
+ delete this.#warnings[key];
185
188
  break;
186
189
  case "delete": {
187
190
  delete this.#values[key];
191
+ delete this.#warnings[key];
188
192
  if (Object.hasOwn(field, "defaultValue")) this.#values[key] = structuredClone(field.defaultValue);
189
193
  break;
190
194
  }
191
195
  case "clearCaches":
192
196
  break;
193
197
  case "reset":
198
+ this.#warnings = {};
194
199
  for (const field of this.#definition.fields) {
195
200
  delete this.#values[field.key];
196
201
  if (Object.hasOwn(field, "defaultValue")) this.#values[field.key] = structuredClone(field.defaultValue);
@@ -8,6 +8,7 @@
8
8
  --pp-border: #e3e5e7;
9
9
  --pp-muted: #797f87;
10
10
  --pp-accent: #1677ff;
11
+ --pp-warning: #9a6700;
11
12
  font:
12
13
  15px / 1.5 -apple-system,
13
14
  BlinkMacSystemFont,
@@ -32,6 +33,7 @@
32
33
  --pp-field: #2f3238;
33
34
  --pp-border: #2f3238;
34
35
  --pp-muted: #9499a0;
36
+ --pp-warning: #f0b849;
35
37
  }
36
38
  .pp-panel * {
37
39
  box-sizing: border-box;
@@ -175,6 +177,12 @@
175
177
  color: var(--pp-muted);
176
178
  font-size: 12px;
177
179
  }
180
+ .pp-field-warning {
181
+ margin-top: 4px;
182
+ color: var(--pp-warning);
183
+ font-size: 12px;
184
+ overflow-wrap: anywhere;
185
+ }
178
186
  .pp-group {
179
187
  margin-top: 16px;
180
188
  }
@@ -4,6 +4,22 @@ import { PreferencesClient } from "./client.mjs";
4
4
  import { fieldControl, element as node, requestConfirmation, requestURLNavigation, resourceURL, settingRow, statusView } from "./components.mjs";
5
5
  import { Navigation } from "./Navigation.mjs";
6
6
 
7
+ /**
8
+ * 将字段级存储诊断转换为简短提示。
9
+ * Convert a field-level storage diagnostic into concise display text.
10
+ * @param {import("./client.mjs").StoredValueWarning} warning 存储警告 / Storage warning.
11
+ * @returns {string} 用户可见提示 / User-visible warning.
12
+ */
13
+ function warningMessage(warning) {
14
+ const values = warning.values
15
+ .map(value => {
16
+ if (typeof value === "string") return value;
17
+ return JSON.stringify(value) ?? String(value);
18
+ })
19
+ .join("、");
20
+ return warning.kind === "undefined-options" ? `当前配置未定义值:${values}` : `当前存储值格式不受支持:${values}`;
21
+ }
22
+
7
23
  /**
8
24
  * 管理模块表单、导航、操作队列和短暂通知。
9
25
  * Manage the module form, navigation, operation queue, and transient notifications.
@@ -182,6 +198,7 @@ export class PreferencesPanel {
182
198
  const growingInputs = [];
183
199
  const editors = new Map();
184
200
  const summaries = [];
201
+ const warningRefreshers = [];
185
202
  const groups = new Map();
186
203
  let queue = Promise.resolve(),
187
204
  pendingWrites = 0;
@@ -258,6 +275,15 @@ export class PreferencesPanel {
258
275
  const label = node("div", "pp-label");
259
276
  label.append(node("span", "pp-field-name", match?.[2] ?? field.name));
260
277
  if (field.description) label.append(node("span", "pp-field-description", field.description));
278
+ const warning = node("span", "pp-field-warning");
279
+ const refreshWarning = () => {
280
+ const issue = client.snapshot().warnings[field.key];
281
+ warning.hidden = !issue;
282
+ warning.textContent = issue ? warningMessage(issue) : "";
283
+ };
284
+ warningRefreshers.push(refreshWarning);
285
+ refreshWarning();
286
+ label.append(warning);
261
287
  row.append(label);
262
288
  const value = values[field.key];
263
289
  /**
@@ -434,6 +460,7 @@ export class PreferencesPanel {
434
460
  },
435
461
  () => {
436
462
  for (const refresh of summaries) refresh();
463
+ for (const refresh of warningRefreshers) refresh();
437
464
  },
438
465
  restore,
439
466
  );