Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
}
}
29 changes: 29 additions & 0 deletions src/database/common/migratable.ts
Original file line number Diff line number Diff line change
@@ -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<Pick<timer.backup.ExportData, '__meta__'>>({
__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 = <T>(data: unknown, namespace: BrowserMigratableNamespace, guard: TypeGuard<T>): T | undefined => {
if (!isRecord(data)) return undefined
if (!(namespace in data)) return undefined
const nsData = data[namespace]
return guard(nsData) ? nsData : undefined
}
72 changes: 65 additions & 7 deletions src/database/limit-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -23,6 +27,26 @@ type LimitRecord = timer.limit.Rule & {
records: DateRecords
}

type PartialRule = MakeRequired<Partial<timer.limit.Rule>, 'name' | 'cond'>

const isValidRow = createObjectGuard<PartialRule>({
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
Expand Down Expand Up @@ -121,7 +145,8 @@ const cvtItem2Rec = (item: ItemValue): LimitRecord => {

type Items = Record<number, ItemValue>

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
Expand All @@ -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<Items> {
let items = await this.storage.getOne<Items>(KEY) || {}
return items
Expand Down Expand Up @@ -259,14 +285,46 @@ class LimitDatabase extends BaseDatabase {
await this.update(items)
}

async importData(data: any): Promise<void> {
let toImport = data[KEY] as Items
// Not import
if (typeof toImport !== 'object') return
const exists: Items = await this.getItems()
async importData(data: unknown): Promise<void> {
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<timer.limit.Rule, 'id'> = {
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<void> {
if (!isRecord(data)) return
let toImport = data[KEY]
const exists = await this.getItems()
migrate(exists, toImport)
this.setByKey(KEY, exists)
}

exportData(): Promise<timer.limit.Rule[]> {
return this.all()
}
}

const limitDatabase = new LimitDatabase()
Expand Down
44 changes: 36 additions & 8 deletions src/database/merge-rule-database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<timer.merge.Rule>({
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<MergeRuleSet> {
const result = await this.storage.getOne<MergeRuleSet>(DB_KEY)
Expand Down Expand Up @@ -50,17 +64,31 @@ class MergeRuleDatabase extends BaseDatabase {
await this.update(set)
}

async importData(data: any): Promise<void> {
const toMigrate = data?.[DB_KEY]
if (!toMigrate) return
async importData(data: unknown): Promise<void> {
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<void> {
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<timer.merge.Rule[]> {
return this.selectAll()
}
}

Expand Down
35 changes: 35 additions & 0 deletions src/database/types.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
/**
* Migrate data among storages (chrome.storage.local / IndexedDB)
*
* @since 4.0.0
*/
export interface StorageMigratable<AllData> {
/**
* 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<AllData>
/**
* Handler after migration finished. Clean the old data here
*
* @param allData
*/
afterStorageMigrated(allData: AllData): Promise<void>
}

export type BrowserMigratableNamespace = keyof Omit<timer.backup.ExportData, '__meta__'>

/**
* Migrate data among browsers (export / import)
*/
export interface BrowserMigratable<N = BrowserMigratableNamespace> {
/**
* The name space for migration
*/
namespace: N
exportData(): Promise<Required<timer.backup.ExportData>[N]>
importData(data: unknown): Promise<void>
}
7 changes: 7 additions & 0 deletions src/util/guard.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
import { createOptionalGuard, isInt } from 'typescript-guard'

export const isOptionalInt = createOptionalGuard(isInt)

export const isRecord = (unk: unknown): unk is Record<string, unknown> => typeof unk === 'object' && unk !== null

export const isVector2 = (unk: unknown): unk is Vector<2> => Array.isArray(unk) && unk.length === 2 && unk.every(isInt)
7 changes: 4 additions & 3 deletions test/database/limit-database.test.ts
Original file line number Diff line number Diff line change
@@ -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())
Expand Down Expand Up @@ -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"))
Expand All @@ -115,10 +116,10 @@ describe('limit-database', () => {
test("import data2", async () => {
const importData: Record<string, any> = {}
// 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([])
})

Expand Down
3 changes: 2 additions & 1 deletion test/database/merge-rule-database.test.ts
Original file line number Diff line number Diff line change
@@ -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 || '' }
Expand Down Expand Up @@ -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 },
Expand Down
10 changes: 10 additions & 0 deletions test/database/migratable.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export function mockLegacyData(data: Record<string, unknown>): timer.backup.ExportData {
const withMeta: timer.backup.ExportData = {
...data,
__meta__: {
version: "3.8.15",
ts: Date.now(),
},
}
return withMeta
}
19 changes: 19 additions & 0 deletions types/timer/backup.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}