diff --git a/package.json b/package.json index a5e4901..fa0e1e3 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@bauke/eslint-config": "^0.1.2", "@bauke/prettier-config": "^0.1.2", "@bauke/stylelint-config": "^0.1.2", + "@holllo/test": "^0.2.1", "@types/debounce": "^1.2.1", "@types/node": "^20.3.1", "@types/platform": "^1.3.4", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1091cbd..f413e10 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,5 +1,9 @@ lockfileVersion: '6.0' +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + dependencies: '@holllo/migration-helper': specifier: ^0.1.4 @@ -36,6 +40,9 @@ devDependencies: '@bauke/stylelint-config': specifier: ^0.1.2 version: 0.1.2(stylelint-config-standard-scss@6.1.0)(stylelint@15.9.0) + '@holllo/test': + specifier: ^0.2.1 + version: 0.2.1 '@types/debounce': specifier: ^1.2.1 version: 1.2.1 @@ -713,6 +720,10 @@ packages: resolution: {integrity: sha512-4XMYlIOSFzTDWSFizKRSYUHWEfQBNZbPI/Tc5Qhef4lhxZBCAzJt1+RutB5TpCdR6sllJN+Dt5WblCmZXCaoLw==} dev: false + /@holllo/test@0.2.1: + resolution: {integrity: sha512-QlIvEqvuEfu8vapnwai8A+1TmZGkPObgU32VEXHBc3XEKhupHZRFB778oLPYlJVuSsi4TT99890iSR3nlvVwtQ==} + dev: true + /@holllo/webextension-storage@0.2.0(webextension-polyfill@0.10.0): resolution: {integrity: sha512-WiSkkY/Jg3PhlHOH8eGvRBBtvZwHrJ0FD/LF8lNZAc3uaRdonF79o/Xt9CefYUjV6FSbHl/vsccXyAoitvkRIQ==} peerDependencies: diff --git a/source/background/setup.ts b/source/background/setup.ts index 1944fd6..f9e98d9 100644 --- a/source/background/setup.ts +++ b/source/background/setup.ts @@ -1,4 +1,6 @@ import browser from "webextension-polyfill"; +import {migrations} from "../storage/migrations/migrations.js"; +import {log} from "../utilities/logging.js"; if ($browser === "firefox") { browser.browserAction.onClicked.addListener(openOptionsPage); @@ -7,6 +9,14 @@ if ($browser === "firefox") { } browser.runtime.onInstalled.addListener(async () => { + const existingStorage = await browser.storage.sync.get(); + if (existingStorage.version === "1.1.2") { + log("Running 1.1.2 to 2.0.0 data migration.", true); + await browser.storage.local.set({backup: JSON.stringify(existingStorage)}); + await browser.storage.sync.clear(); + await migrations[0].migrate(existingStorage); + } + if ($dev) { await openOptionsPage(); } diff --git a/source/options/setup.tsx b/source/options/setup.tsx index fbd71a0..86582a5 100644 --- a/source/options/setup.tsx +++ b/source/options/setup.tsx @@ -11,7 +11,11 @@ import {type Feature, Data, fromStorage} from "../storage/common.js"; import {AppContext} from "./context.js"; import {features} from "./features.js"; -window.addEventListener("load", async () => { +window.addEventListener("DOMContentLoaded", async () => { + if ($test) { + await import("../storage/migrations/migrations.test.js"); + } + initializeGlobals(); const manifest = browser.runtime.getManifest(); diff --git a/source/storage/common.ts b/source/storage/common.ts index 2b1361e..c84fe63 100644 --- a/source/storage/common.ts +++ b/source/storage/common.ts @@ -1,8 +1,8 @@ -import {type Value, createValue} from "@holllo/webextension-storage"; +import {createValue} from "@holllo/webextension-storage"; import browser from "webextension-polyfill"; export enum Feature { - AnonymizeUsernames = "anonymize-users", + AnonymizeUsernames = "anonymize-usernames", Autocomplete = "autocomplete", BackToTop = "back-to-top", Debug = "debug", @@ -18,6 +18,7 @@ export enum Data { EnabledFeatures = "enabled-features", KnownGroups = "known-groups", LatestActiveFeatureTab = "latest-active-feature-tab", + Version = "data-version", } export type HideVotesData = { @@ -111,6 +112,13 @@ export const storageValues = { value: Feature.Debug, storage: browser.storage.sync, }), + [Data.Version]: createValue({ + deserialize: (input) => JSON.parse(input) as string, + serialize: (input) => JSON.stringify(input), + key: Data.Version, + value: "2.0.0", + storage: browser.storage.sync, + }), [Feature.HideVotes]: createValue({ deserialize: (input) => JSON.parse(input) as HideVotesData, serialize: (input) => JSON.stringify(input), diff --git a/source/storage/migrations/migrations.test.ts b/source/storage/migrations/migrations.test.ts new file mode 100644 index 0000000..27e7fbe --- /dev/null +++ b/source/storage/migrations/migrations.test.ts @@ -0,0 +1,71 @@ +import browser from "webextension-polyfill"; +import {setup} from "@holllo/test"; +import {Data, Feature} from "../common.js"; +import {migrations} from "./migrations.js"; +import {v112Sample} from "./v1-1-2.js"; + +await setup("Migrations", async (group) => { + group.test("2.0.0", async (test) => { + await browser.storage.sync.clear(); + + await migrations[0].migrate(v112Sample); + const storage = await browser.storage.sync.get(); + for (const [key, value] of Object.entries(storage)) { + switch (key) { + case Data.EnabledFeatures: { + test.equals( + value, + '["autocomplete","back-to-top","debug","hide-votes","jump-to-new-comment","markdown-toolbar","themed-logo","user-labels"]', + ); + break; + } + + case Data.KnownGroups: { + test.equals(value, '["~group","~group.subgroup","~test"]'); + break; + } + + case Data.Version: { + test.equals(value, '"2.0.0"'); + break; + } + + case Feature.HideVotes: { + test.equals( + value, + '{"otherComments":true,"otherTopics":true,"ownComments":true,"ownTopics":false}', + ); + break; + } + + case Feature.UsernameColors: { + test.equals( + value, + '[{"color":"red","id":4,"username":"Test"},{"color":"green","id":18,"username":"AnotherTest"}]', + ); + break; + } + + case `${Feature.UserLabels}-1`: { + test.equals( + value, + '{"color":"#ff00ff","id":1,"priority":0,"text":"Test Label","username":"Test"}', + ); + break; + } + + case `${Feature.UserLabels}-15`: { + test.equals( + value, + '{"id":15,"color":"var(--syntax-string-color)","priority":0,"text":"Another Label","username":"AnotherTest"}', + ); + break; + } + + default: { + console.log(key, JSON.stringify(value)); + } + } + } + }); +}); diff --git a/source/storage/migrations/migrations.ts b/source/storage/migrations/migrations.ts new file mode 100644 index 0000000..f312ad7 --- /dev/null +++ b/source/storage/migrations/migrations.ts @@ -0,0 +1,52 @@ +import {setup} from "@holllo/test"; +import {type Migration} from "@holllo/migration-helper"; +import browser from "webextension-polyfill"; +import {Data, Feature, fromStorage, saveUserLabels} from "../common.js"; +import {v112DeserializeData, v112Sample, type V112Settings} from "./v1-1-2.js"; + +export const migrations: Array> = [ + { + version: "2.0.0", + async migrate(data: V112Settings): Promise { + const deserialized = v112DeserializeData(data); + data.data.userLabels = deserialized.userLabels; + data.data.usernameColors = deserialized.usernameColors; + await saveUserLabels(data.data.userLabels); + + const hideVotes = await fromStorage(Feature.HideVotes); + hideVotes.value = { + otherComments: data.data.hideVotes.comments, + otherTopics: data.data.hideVotes.topics, + ownComments: data.data.hideVotes.ownComments, + ownTopics: data.data.hideVotes.ownTopics, + }; + await hideVotes.save(); + + const knownGroups = await fromStorage(Data.KnownGroups); + knownGroups.value = new Set(data.data.knownGroups); + await knownGroups.save(); + + const version = await fromStorage(Data.Version); + version.value = "2.0.0"; + await version.save(); + + const usernameColors = await fromStorage(Feature.UsernameColors); + usernameColors.value = data.data.usernameColors; + await usernameColors.save(); + + const enabledFeatures = await fromStorage(Data.EnabledFeatures); + for (const [key, value] of Object.entries(data.features)) { + if (value) { + const snakeCasedKey = key.replace(/([A-Z])/g, "-$1").toLowerCase(); + if (Object.values(Feature).includes(snakeCasedKey as Feature)) { + enabledFeatures.value.add(snakeCasedKey as Feature); + } else { + throw new Error(`Unknown key: ${key}`); + } + } + } + + await enabledFeatures.save(); + }, + }, +]; diff --git a/source/storage/migrations/v1-1-2.ts b/source/storage/migrations/v1-1-2.ts new file mode 100644 index 0000000..5ad04cb --- /dev/null +++ b/source/storage/migrations/v1-1-2.ts @@ -0,0 +1,114 @@ +export function v112DeserializeData(data: Record): { + userLabels: V112Settings["data"]["userLabels"]; + usernameColors: V112Settings["data"]["usernameColors"]; +} { + const deserialized: ReturnType = { + userLabels: [], + usernameColors: [], + }; + + for (const [key, value] of Object.entries(data)) { + if (key.startsWith("userLabel")) { + deserialized.userLabels.push( + value as (typeof deserialized)["userLabels"][number], + ); + } else if (key.startsWith("usernameColor")) { + deserialized.usernameColors.push( + value as (typeof deserialized)["usernameColors"][number], + ); + } + } + + return deserialized; +} + +export type V112Settings = { + [index: string]: any; + data: { + hideVotes: { + comments: boolean; + topics: boolean; + ownComments: boolean; + ownTopics: boolean; + }; + knownGroups: string[]; + latestActiveFeatureTab: string; + userLabels: Array<{ + color: string; + id: number; + priority: number; + text: string; + username: string; + }>; + usernameColors: Array<{ + color: string; + id: number; + username: string; + }>; + }; + features: { + anonymizeUsernames: boolean; + autocomplete: boolean; + backToTop: boolean; + debug: boolean; + hideVotes: boolean; + jumpToNewComment: boolean; + markdownToolbar: boolean; + themedLogo: boolean; + userLabels: boolean; + usernameColors: boolean; + }; + version: string; +}; + +export const v112Sample: V112Settings = { + data: { + hideVotes: { + comments: true, + ownComments: true, + ownTopics: false, + topics: true, + }, + knownGroups: ["~group", "~group.subgroup", "~test"], + latestActiveFeatureTab: "userLabels", + userLabels: [], + usernameColors: [], + }, + features: { + anonymizeUsernames: false, + autocomplete: true, + backToTop: true, + debug: true, + hideVotes: true, + jumpToNewComment: true, + markdownToolbar: true, + themedLogo: true, + userLabels: true, + usernameColors: false, + }, + version: "1.1.2", + userLabel1: { + color: "#ff00ff", + id: 1, + priority: 0, + text: "Test Label", + username: "Test", + }, + userLabel15: { + id: 15, + color: "var(--syntax-string-color)", + priority: 0, + text: "Another Label", + username: "AnotherTest", + }, + usernameColor4: { + color: "red", + id: 4, + username: "Test", + }, + usernameColor18: { + color: "green", + id: 18, + username: "AnotherTest", + }, +};