diff --git a/package.json b/package.json index 2a8469952..07dbeb425 100644 --- a/package.json +++ b/package.json @@ -65,10 +65,11 @@ "echarts": "^6.0.0", "element-plus": "2.13.2", "punycode": "^2.3.1", + "typescript-guard": "^0.2.1", "vue": "^3.5.28", "vue-router": "^5.0.3" }, "engines": { "node": ">=22" } -} \ No newline at end of file +} diff --git a/src/database/common/migratable.ts b/src/database/common/migratable.ts new file mode 100644 index 000000000..a6ea7770d --- /dev/null +++ b/src/database/common/migratable.ts @@ -0,0 +1,29 @@ +import type { BrowserMigratableNamespace } from '@db/types' +import { isRecord } from '@util/guard' +import { createObjectGuard, isInt, isString, TypeGuard } from 'typescript-guard' + +export const isExportData = createObjectGuard>({ + __meta__: createObjectGuard({ + version: isString, + ts: isInt, + }), +}) + +export const isLegacyVersion = (data: unknown): data is timer.backup.ExportData => { + if (!isExportData(data)) return false + + const version = data.__meta__.version + const match = version.match(/^(\d+)\.(\d+)\.(\d+)/) + if (!match) return true + + const major = parseInt(match[1]) + + return major < 4 +} + +export const extractNamespace = (data: unknown, namespace: BrowserMigratableNamespace, guard: TypeGuard): T | undefined => { + if (!isRecord(data)) return undefined + if (!(namespace in data)) return undefined + const nsData = data[namespace] + return guard(nsData) ? nsData : undefined +} diff --git a/src/database/limit-database.ts b/src/database/limit-database.ts index f8327bd3e..db9f5ed21 100644 --- a/src/database/limit-database.ts +++ b/src/database/limit-database.ts @@ -5,9 +5,13 @@ * https://opensource.org/licenses/MIT */ +import { isOptionalInt, isRecord, isVector2 } from '@util/guard' import { formatTimeYMD, MILL_PER_DAY } from "@util/time" +import { createArrayGuard, createGuard, createObjectGuard, createOptionalGuard, isBoolean, isInt, isString } from 'typescript-guard' import BaseDatabase from "./common/base-database" import { REMAIN_WORD_PREFIX } from "./common/constant" +import { extractNamespace, isExportData, isLegacyVersion } from './common/migratable' +import type { BrowserMigratable } from './types' const KEY = REMAIN_WORD_PREFIX + 'LIMIT' @@ -23,6 +27,26 @@ type LimitRecord = timer.limit.Rule & { records: DateRecords } +type PartialRule = MakeRequired, 'name' | 'cond'> + +const isValidRow = createObjectGuard({ + id: isOptionalInt, + name: isString, + cond: createArrayGuard(isString), + time: isOptionalInt, + count: isOptionalInt, + weekly: isOptionalInt, + weeklyCount: isOptionalInt, + visitTime: isOptionalInt, + enabled: createOptionalGuard(isBoolean), + locked: createOptionalGuard(isBoolean), + weekdays: createOptionalGuard(createArrayGuard(createGuard(val => isInt(val) && val >= 0 && val <= 6))), + allowDelay: createOptionalGuard(isBoolean), + periods: createOptionalGuard(createArrayGuard(isVector2)), +}) + +const isValidImportRows = createArrayGuard(isValidRow) + type ItemValue = { /** * ID @@ -121,7 +145,8 @@ const cvtItem2Rec = (item: ItemValue): LimitRecord => { type Items = Record -function migrate(exist: Items, toMigrate: any) { +function migrate(exist: Items, toMigrate: unknown) { + if (!isRecord(toMigrate)) return const idBase = Object.keys(exist).map(parseInt).sort().reverse()?.[0] ?? 0 + 1 Object.values(toMigrate).forEach((value, idx) => { const id = idBase + idx @@ -139,7 +164,8 @@ function migrate(exist: Items, toMigrate: any) { * * @since 0.2.2 */ -class LimitDatabase extends BaseDatabase { +class LimitDatabase extends BaseDatabase implements BrowserMigratable<'__limit__'> { + namespace: '__limit__' = '__limit__' private async getItems(): Promise { let items = await this.storage.getOne(KEY) || {} return items @@ -259,14 +285,46 @@ class LimitDatabase extends BaseDatabase { await this.update(items) } - async importData(data: any): Promise { - let toImport = data[KEY] as Items - // Not import - if (typeof toImport !== 'object') return - const exists: Items = await this.getItems() + async importData(data: unknown): Promise { + if (!isExportData(data)) return + if (isLegacyVersion(data)) { + return this.importLegacyData(data) + } + + const rows = extractNamespace(data, this.namespace, isValidImportRows) ?? [] + for (const row of rows) { + const toImport: Omit = { + name: row.name, + cond: row.cond, + time: row.time, + count: row.count, + weekly: row.weekly, + weeklyCount: row.weeklyCount, + visitTime: row.visitTime, + periods: row.periods, + enabled: row.enabled ?? true, + locked: row.locked ?? false, + allowDelay: row.allowDelay ?? false, + weekdays: row.weekdays ?? [], + } + await this.save(toImport) + } + } + + /** + * @deprecated Only for legacy data, will be removed in future version + */ + private async importLegacyData(data: unknown): Promise { + if (!isRecord(data)) return + let toImport = data[KEY] + const exists = await this.getItems() migrate(exists, toImport) this.setByKey(KEY, exists) } + + exportData(): Promise { + return this.all() + } } const limitDatabase = new LimitDatabase() diff --git a/src/database/merge-rule-database.ts b/src/database/merge-rule-database.ts index 82fd23307..aa4f1a502 100644 --- a/src/database/merge-rule-database.ts +++ b/src/database/merge-rule-database.ts @@ -5,19 +5,33 @@ * https://opensource.org/licenses/MIT */ +import { isRecord } from '@util/guard' +import { createArrayGuard, createObjectGuard, createRecordGuard, createUnionGuard, isInt, isString } from 'typescript-guard' import BaseDatabase from "./common/base-database" import { REMAIN_WORD_PREFIX } from "./common/constant" +import { extractNamespace, isLegacyVersion } from './common/migratable' +import type { BrowserMigratable } from './types' const DB_KEY = REMAIN_WORD_PREFIX + 'MERGE_RULES' type MergeRuleSet = { [key: string]: string | number } +const isMergeValue = createUnionGuard(isString, isInt) + +const isMergeRuleSet = createRecordGuard(isMergeValue) + +const isMergeRule = createObjectGuard({ + origin: isString, + merged: isMergeValue, +}) + /** * Rules to merge host * * @since 0.1.2 */ -class MergeRuleDatabase extends BaseDatabase { +class MergeRuleDatabase extends BaseDatabase implements BrowserMigratable<'__merge__'> { + namespace: '__merge__' = '__merge__' async refresh(): Promise { const result = await this.storage.getOne(DB_KEY) @@ -50,17 +64,31 @@ class MergeRuleDatabase extends BaseDatabase { await this.update(set) } - async importData(data: any): Promise { - const toMigrate = data?.[DB_KEY] - if (!toMigrate) return + async importData(data: unknown): Promise { + if (isLegacyVersion(data)) { + return this.importLegacyData(data) + } + const rules = extractNamespace(data, this.namespace, createArrayGuard(isMergeRule)) ?? [] + await this.add(...rules) + } + + /** + * @deprecated Only for legacy version + */ + private async importLegacyData(data: unknown): Promise { + if (!isRecord(data)) return + const toMigrate = data[DB_KEY] + if (!isMergeRuleSet(toMigrate)) return const exist = await this.refresh() - const valueTypes = ['string', 'number'] - Object.entries(toMigrate as MergeRuleSet) - .filter(([_key, value]) => valueTypes.includes(typeof value)) + Object.entries(toMigrate satisfies MergeRuleSet) // Not rewrite .filter(([key]) => !exist[key]) .forEach(([key, value]) => exist[key] = value) - this.update(exist) + await this.update(exist) + } + + exportData(): Promise { + return this.selectAll() } } diff --git a/src/database/types.d.ts b/src/database/types.d.ts new file mode 100644 index 000000000..e32978089 --- /dev/null +++ b/src/database/types.d.ts @@ -0,0 +1,35 @@ +/** + * Migrate data among storages (chrome.storage.local / IndexedDB) + * + * @since 4.0.0 + */ +export interface StorageMigratable { + /** + * Migrate data to target storage + * + * NOTE: MUST NOT change the inner storage type + * + * @param type the type of target storage + */ + migrateStorage(type: timer.option.StorageType): Promise + /** + * Handler after migration finished. Clean the old data here + * + * @param allData + */ + afterStorageMigrated(allData: AllData): Promise +} + +export type BrowserMigratableNamespace = keyof Omit + +/** + * Migrate data among browsers (export / import) + */ +export interface BrowserMigratable { + /** + * The name space for migration + */ + namespace: N + exportData(): Promise[N]> + importData(data: unknown): Promise +} diff --git a/src/util/guard.ts b/src/util/guard.ts new file mode 100644 index 000000000..24ab07a37 --- /dev/null +++ b/src/util/guard.ts @@ -0,0 +1,7 @@ +import { createOptionalGuard, isInt } from 'typescript-guard' + +export const isOptionalInt = createOptionalGuard(isInt) + +export const isRecord = (unk: unknown): unk is Record => typeof unk === 'object' && unk !== null + +export const isVector2 = (unk: unknown): unk is Vector<2> => Array.isArray(unk) && unk.length === 2 && unk.every(isInt) diff --git a/test/database/limit-database.test.ts b/test/database/limit-database.test.ts index 49d3244c3..e37263cc8 100644 --- a/test/database/limit-database.test.ts +++ b/test/database/limit-database.test.ts @@ -1,6 +1,7 @@ import db from "@db/limit-database" import { formatTimeYMD } from "@util/time" import { mockStorage } from "../__mock__/storage" +import { mockLegacyData } from './migratable' describe('limit-database', () => { beforeAll(() => mockStorage()) @@ -103,7 +104,7 @@ describe('limit-database', () => { chrome.storage.local.clear() expect(await db.all()).toEqual([]) - await db.importData(data2Import) + await db.importData(mockLegacyData(data2Import)) const imported = await db.all() const cond2After = imported.find(a => a.cond?.includes("cond2")) @@ -115,10 +116,10 @@ describe('limit-database', () => { test("import data2", async () => { const importData: Record = {} // Invalid data, no error throws - await db.importData(importData) + await db.importData(mockLegacyData(importData)) // Valid data importData["__timer__LIMIT"] = {} - await db.importData(importData) + await db.importData(mockLegacyData(importData)) expect(await db.all()).toEqual([]) }) diff --git a/test/database/merge-rule-database.test.ts b/test/database/merge-rule-database.test.ts index 318e7a8f1..ed31f6208 100644 --- a/test/database/merge-rule-database.test.ts +++ b/test/database/merge-rule-database.test.ts @@ -1,5 +1,6 @@ import db from "@db/merge-rule-database" import { mockStorage } from "../__mock__/storage" +import { mockLegacyData } from './migratable' function of(origin: string, merged?: string | number): timer.merge.Rule { return { origin, merged: merged || '' } @@ -40,7 +41,7 @@ describe('merge-rule-database.test', () => { await chrome.storage.local.clear() expect(await db.selectAll()).toEqual([]) - await db.importData(data2Import) + await db.importData(mockLegacyData(data2Import)) const imported: timer.merge.Rule[] = await db.selectAll() expect(imported).toEqual([ { origin: "www.baidu.com", merged: 2 }, diff --git a/test/database/migratable.ts b/test/database/migratable.ts new file mode 100644 index 000000000..950b19331 --- /dev/null +++ b/test/database/migratable.ts @@ -0,0 +1,10 @@ +export function mockLegacyData(data: Record): timer.backup.ExportData { + const withMeta: timer.backup.ExportData = { + ...data, + __meta__: { + version: "3.8.15", + ts: Date.now(), + }, + } + return withMeta +} diff --git a/types/timer/backup.d.ts b/types/timer/backup.d.ts index 28ea41ce7..172e351ce 100644 --- a/types/timer/backup.d.ts +++ b/types/timer/backup.d.ts @@ -123,4 +123,23 @@ declare namespace timer.backup { } type Row = core.Row & RowExtend + + /** + * The data format for export and import + */ + type ExportMeta = { + version: string + ts: number + } + + type ExportData = { + __meta__: ExportMeta + __stat__?: timer.core.Row[] + __limit__?: timer.limit.Rule[] + __merge__?: timer.merge.Rule[] + __whitelist__?: string[] + __cate__?: timer.site.Cate[] + // Legacy data before v4.0.0 + [key: string]: unknown + } } \ No newline at end of file