From 09fe1dd61894a437ca60a363556b16291ad06a69 Mon Sep 17 00:00:00 2001 From: cyfung1031 <44498510+cyfung1031@users.noreply.github.com> Date: Sun, 5 Jul 2026 15:20:16 +0900 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20JSON=20=E9=85=8D=E7=BD=AE=E4=BB=85?= =?UTF-8?q?=E5=AD=98=E5=82=A8=E7=94=A8=E6=88=B7=E5=B7=AE=E5=BC=82=E5=B9=B6?= =?UTF-8?q?=E4=B8=8E=E6=9C=80=E6=96=B0=E9=BB=98=E8=AE=A4=E9=85=8D=E7=BD=AE?= =?UTF-8?q?=E5=90=88=E5=B9=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit storage 中只保存 eslint_config / editor_config 与内置默认配置的稀疏差异, 读取时将差异合并到最新默认配置(用户值优先),使扩展升级带来的默认配置 更新能自动生效,同时完整保留用户改动。旧版全量存储的配置读取时自动兼容, 下次保存后自动收敛为稀疏差异。 Co-Authored-By: Claude Fable 5 --- src/pkg/config/config.test.ts | 85 +++++++++++++++++++++++++++ src/pkg/config/config.ts | 40 ++++++++++--- src/pkg/config/json_overrides.test.ts | 70 ++++++++++++++++++++++ src/pkg/config/json_overrides.ts | 57 ++++++++++++++++++ 4 files changed, 245 insertions(+), 7 deletions(-) create mode 100644 src/pkg/config/json_overrides.test.ts create mode 100644 src/pkg/config/json_overrides.ts diff --git a/src/pkg/config/config.test.ts b/src/pkg/config/config.test.ts index cfbf78abe..8649b9763 100644 --- a/src/pkg/config/config.test.ts +++ b/src/pkg/config/config.test.ts @@ -1,6 +1,8 @@ import { describe, expect, it, beforeEach, vi } from "vitest"; import { SystemConfig } from "./config"; import { MessageQueue } from "@Packages/message/message_queue"; +import { defaultConfig as eslintDefaultConfig } from "@Packages/eslint/linter-config"; +import { defaultConfig as editorDefaultConfig } from "@App/pkg/utils/monaco-editor/config"; describe("SystemConfig 双 storage 与懒迁移", () => { let mq: MessageQueue; @@ -185,6 +187,89 @@ describe("SystemConfig 双 storage 与懒迁移", () => { }); }); + describe("JSON 配置的稀疏存储与默认值合并", () => { + it("未修改时应返回最新默认配置", async () => { + await expect(config.getEslintConfig()).resolves.toBe(eslintDefaultConfig); + await expect(config.getEditorConfig()).resolves.toBe(editorDefaultConfig); + }); + + it("保存时应只存储与默认配置的差异", async () => { + const modified = JSON.parse(eslintDefaultConfig); + modified.rules["no-debugger"] = ["warn"]; + modified.rules["custom/added-rule"] = ["error"]; + config.setEslintConfig(JSON.stringify(modified)); + + await vi.waitFor(async () => { + const syncData = await chrome.storage.sync.get("system_eslint_config"); + expect(JSON.parse(syncData["system_eslint_config"] as string)).toEqual({ + rules: { "no-debugger": ["warn"], "custom/added-rule": ["error"] }, + }); + }); + }); + + it("读取时应将存储的差异合并到最新默认配置", async () => { + await chrome.storage.sync.set({ + system_eslint_config: JSON.stringify({ rules: { "no-debugger": ["warn"] } }), + }); + + const result = JSON.parse(await new SystemConfig(new MessageQueue()).getEslintConfig()); + const defaults = JSON.parse(eslintDefaultConfig); + expect(result.rules["no-debugger"]).toEqual(["warn"]); + expect(result.rules["no-empty"]).toEqual(defaults.rules["no-empty"]); + expect(result.globals).toEqual(defaults.globals); + }); + + it("旧版全量配置应自动获得新默认字段且保留用户改动", async () => { + // 模拟旧版本存储的全量 JSON:缺少后续新增的默认规则,且用户改过其中一条 + const legacy = JSON.parse(eslintDefaultConfig); + legacy.rules["no-debugger"] = ["off"]; + delete legacy.rules["no-empty"]; + await chrome.storage.sync.set({ system_eslint_config: JSON.stringify(legacy) }); + + const result = JSON.parse(await new SystemConfig(new MessageQueue()).getEslintConfig()); + expect(result.rules["no-debugger"]).toEqual(["off"]); + // 新增的默认规则应自动生效 + expect(result.rules["no-empty"]).toEqual(JSON.parse(eslintDefaultConfig).rules["no-empty"]); + }); + + it("保存与默认配置一致的内容时应清除存储", async () => { + // 使用紧凑格式,验证差异按语义比较而非字符串比较 + config.setEslintConfig(JSON.stringify(JSON.parse(eslintDefaultConfig))); + + await vi.waitFor(async () => { + const syncData = await chrome.storage.sync.get("system_eslint_config"); + expect(syncData["system_eslint_config"]).toBeUndefined(); + }); + // 重新读取(新实例,不走缓存)应返回默认配置 + await expect(new SystemConfig(new MessageQueue()).getEslintConfig()).resolves.toBe(eslintDefaultConfig); + }); + + it("保存空字符串应恢复默认配置", async () => { + config.setEslintConfig(JSON.stringify({ rules: { "no-debugger": ["warn"] } })); + config.setEslintConfig(""); + + await vi.waitFor(async () => { + const syncData = await chrome.storage.sync.get("system_eslint_config"); + expect(syncData["system_eslint_config"]).toBeUndefined(); + }); + await expect(config.getEslintConfig()).resolves.toBe(eslintDefaultConfig); + }); + + it("editor_config 同样只存储差异并合并读取", async () => { + const modified = JSON.parse(editorDefaultConfig); + modified.strict = false; + config.setEditorConfig(JSON.stringify(modified)); + + await vi.waitFor(async () => { + const syncData = await chrome.storage.sync.get("system_editor_config"); + expect(JSON.parse(syncData["system_editor_config"] as string)).toEqual({ strict: false }); + }); + + const result = JSON.parse(await new SystemConfig(new MessageQueue()).getEditorConfig()); + expect(result).toEqual({ ...JSON.parse(editorDefaultConfig), strict: false }); + }); + }); + describe("React 外部存储适配器", () => { it("同一配置键应返回稳定的 store 实例", () => { expect(config.externalStore("favicon_service")).toBe(config.externalStore("favicon_service")); diff --git a/src/pkg/config/config.ts b/src/pkg/config/config.ts index 0c89229f9..55983780b 100644 --- a/src/pkg/config/config.ts +++ b/src/pkg/config/config.ts @@ -9,6 +9,7 @@ import defaultTypeDefinition from "@App/template/scriptcat.d.tpl"; import { toCamelCase } from "../utils/utils"; import EventEmitter from "eventemitter3"; import { STORAGE_LOCAL_KEYS } from "./consts"; +import { mergeJsonConfig, diffJsonConfig } from "./json_overrides"; export const SystemConfigChange = "systemConfigChange"; @@ -164,6 +165,31 @@ export class SystemConfig { // 设备相关的配置项,使用 chrome.storage.local(不跨设备同步) private readonly localStorage = new ChromeStorage("system", false); + // JSON 配置项的默认配置:storage 只保存与默认配置的稀疏差异, + // 缓存与消息广播中始终是合并后的完整配置(#1517) + private readonly jsonConfigDefaults: Partial> = { + eslint_config: defaultConfig, + editor_config: editorDefaultConfig, + }; + + // 读取解码:将存储的用户差异合并到最新默认配置 + private decodeStored(key: SystemConfigKey, stored: unknown): unknown { + const defaultStr = this.jsonConfigDefaults[key]; + if (defaultStr !== undefined && typeof stored === "string") { + return mergeJsonConfig(defaultStr, stored); + } + return stored; + } + + // 写入编码:只保存与默认配置的差异,完全一致时返回 undefined(清除存储) + private encodeForStorage(key: SystemConfigKey, value: unknown): unknown { + const defaultStr = this.jsonConfigDefaults[key]; + if (defaultStr !== undefined && typeof value === "string") { + return diffJsonConfig(defaultStr, value); + } + return value; + } + private isLocalKey(key: string): boolean { return STORAGE_LOCAL_KEYS.has(key); } @@ -271,9 +297,10 @@ export class SystemConfig { return entry.hasValue && entry.value !== undefined ? (entry.value as T) : this.resolveDefault(defaultValue); } if (val !== undefined) { + const decoded = this.decodeStored(key, val); entry.hasValue = true; - entry.value = val; - return val as T; + entry.value = decoded; + return decoded as T; } // 对 local key,回退读取 sync storage(兼容旧版本数据迁移) if (this.isLocalKey(key)) { @@ -310,7 +337,8 @@ export class SystemConfig { } else { entry.hasValue = true; entry.value = value; - asyncOp = storage.set(key, value); + const stored = this.encodeForStorage(key, value); + asyncOp = stored === undefined ? storage.remove(key) : storage.set(key, stored); } asyncOp.then(() => { // 发送消息通知更新 @@ -439,8 +467,7 @@ export class SystemConfig { setEslintConfig(v: string) { if (v === "") { - this._set("eslint_config", defaultConfig); - return; + v = defaultConfig; } JSON.parse(v); return this._set("eslint_config", v); @@ -452,8 +479,7 @@ export class SystemConfig { setEditorConfig(v: string) { if (v === "") { - this._set("editor_config", editorDefaultConfig); - return; + v = editorDefaultConfig; } JSON.parse(v); return this._set("editor_config", v); diff --git a/src/pkg/config/json_overrides.test.ts b/src/pkg/config/json_overrides.test.ts new file mode 100644 index 000000000..0f40cfad1 --- /dev/null +++ b/src/pkg/config/json_overrides.test.ts @@ -0,0 +1,70 @@ +import { describe, expect, it } from "vitest"; +import { deepMerge, deepDiff, mergeJsonConfig, diffJsonConfig } from "./json_overrides"; + +describe("deepMerge 深度合并", () => { + it("应以覆盖值优先合并嵌套对象", () => { + const defaults = { a: 1, nested: { x: 1, y: 2 } }; + const overrides = { nested: { y: 3 } }; + expect(deepMerge(defaults, overrides)).toEqual({ a: 1, nested: { x: 1, y: 3 } }); + }); + + it("数组应整体替换而非合并", () => { + const defaults = { rule: ["error", { allow: true }] }; + const overrides = { rule: ["warn"] }; + expect(deepMerge(defaults, overrides)).toEqual({ rule: ["warn"] }); + }); + + it("覆盖配置独有的键应保留", () => { + expect(deepMerge({ a: 1 }, { b: 2 })).toEqual({ a: 1, b: 2 }); + }); + + it("类型不同时应使用覆盖值", () => { + expect(deepMerge({ a: { x: 1 } }, { a: false })).toEqual({ a: false }); + }); +}); + +describe("deepDiff 稀疏差异", () => { + it("与默认值一致时应返回 undefined", () => { + const defaults = { a: 1, nested: { x: [1, 2], y: { z: true } } }; + expect(deepDiff(structuredClone(defaults), defaults)).toBeUndefined(); + }); + + it("应只保留与默认值不同的部分", () => { + const defaults = { a: 1, nested: { x: 1, y: 2 } }; + const value = { a: 1, nested: { x: 1, y: 3 } }; + expect(deepDiff(value, defaults)).toEqual({ nested: { y: 3 } }); + }); + + it("深度相等的数组应被去除", () => { + const defaults = { rule: ["error", { allow: true }], other: 1 }; + const value = { rule: ["error", { allow: true }], other: 2 }; + expect(deepDiff(value, defaults)).toEqual({ other: 2 }); + }); + + it("用户新增的键应保留", () => { + expect(deepDiff({ a: 1, custom: "x" }, { a: 1 })).toEqual({ custom: "x" }); + }); +}); + +describe("JSON 配置字符串编解码", () => { + const defaultStr = JSON.stringify({ rules: { "no-debugger": ["error"], "no-eval": ["warn"] } }); + + it("diffJsonConfig 应只保留差异,无差异时返回 undefined", () => { + expect(diffJsonConfig(defaultStr, defaultStr)).toBeUndefined(); + const user = JSON.stringify({ rules: { "no-debugger": ["warn"], "no-eval": ["warn"] } }); + expect(JSON.parse(diffJsonConfig(defaultStr, user)!)).toEqual({ rules: { "no-debugger": ["warn"] } }); + }); + + it("mergeJsonConfig 应将用户差异合并到最新默认配置", () => { + const stored = JSON.stringify({ rules: { "no-debugger": ["warn"] } }); + expect(JSON.parse(mergeJsonConfig(defaultStr, stored))).toEqual({ + rules: { "no-debugger": ["warn"], "no-eval": ["warn"] }, + }); + }); + + it("合并 diff 结果应还原用户配置", () => { + const user = JSON.stringify({ rules: { "no-debugger": ["off"], "custom/rule": ["error"], "no-eval": ["warn"] } }); + const diff = diffJsonConfig(defaultStr, user)!; + expect(JSON.parse(mergeJsonConfig(defaultStr, diff))).toEqual(JSON.parse(user)); + }); +}); diff --git a/src/pkg/config/json_overrides.ts b/src/pkg/config/json_overrides.ts new file mode 100644 index 000000000..ef295c819 --- /dev/null +++ b/src/pkg/config/json_overrides.ts @@ -0,0 +1,57 @@ +// JSON 配置项的「默认配置 + 用户差异」存储机制(#1517) +// storage 中只保存用户与默认配置不同的部分,读取时与最新默认配置合并, +// 这样升级扩展带来的默认配置变化能自动生效,同时保留用户改动(用户值优先)。 + +function isPlainObject(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +// 深度合并:defaults 打底,overrides 覆盖;仅递归普通对象,数组与标量整体替换 +export function deepMerge(defaults: unknown, overrides: unknown): unknown { + if (!isPlainObject(defaults) || !isPlainObject(overrides)) return overrides; + const result: Record = { ...defaults }; + for (const [key, value] of Object.entries(overrides)) { + result[key] = key in defaults ? deepMerge(defaults[key], value) : value; + } + return result; +} + +function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + if (Array.isArray(a) && Array.isArray(b)) { + return a.length === b.length && a.every((v, i) => deepEqual(v, b[i])); + } + if (isPlainObject(a) && isPlainObject(b)) { + const keysA = Object.keys(a); + return keysA.length === Object.keys(b).length && keysA.every((k) => k in b && deepEqual(a[k], b[k])); + } + return false; +} + +// 计算稀疏差异:仅保留与 defaults 不同的部分,完全一致时返回 undefined +// 注意:value 中缺失的默认键不会被记录,语义为「恢复默认值」 +export function deepDiff(value: unknown, defaults: unknown): unknown { + if (deepEqual(value, defaults)) return undefined; + if (!isPlainObject(value) || !isPlainObject(defaults)) return value; + const result: Record = {}; + for (const [key, val] of Object.entries(value)) { + if (key in defaults) { + const diff = deepDiff(val, defaults[key]); + if (diff !== undefined) result[key] = diff; + } else { + result[key] = val; + } + } + return Object.keys(result).length === 0 ? undefined : result; +} + +// 读取解码:将存储的用户差异合并到最新默认配置,返回完整配置字符串 +export function mergeJsonConfig(defaultStr: string, storedStr: string): string { + return JSON.stringify(deepMerge(JSON.parse(defaultStr), JSON.parse(storedStr)), null, 2); +} + +// 写入编码:只保留与默认配置的差异;与默认配置完全一致时返回 undefined(清除存储) +export function diffJsonConfig(defaultStr: string, valueStr: string): string | undefined { + const diff = deepDiff(JSON.parse(valueStr), JSON.parse(defaultStr)); + return diff === undefined ? undefined : JSON.stringify(diff); +}