diff --git a/package.json b/package.json index 8e358d1ce..9a1487465 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.2" }, "engines": { "node": ">=22" } -} +} \ No newline at end of file diff --git a/rspack/rspack.common.ts b/rspack/rspack.common.ts index 8fbcda9c7..3f2554c0c 100644 --- a/rspack/rspack.common.ts +++ b/rspack/rspack.common.ts @@ -15,7 +15,6 @@ const generateJsonPlugins: RspackPluginInstance[] = [] const localeJsonFiles = Object.entries(i18nChrome) .map(([locale, message]) => new GenerateJsonPlugin(`_locales/${locale}/messages.json`, message)) - .map(plugin => plugin as unknown as RspackPluginInstance) generateJsonPlugins.push(...localeJsonFiles) type EntryConfig = { diff --git a/src/background/message-dispatcher.ts b/src/background/message-dispatcher.ts index 7ee147690..d6b94f8ea 100644 --- a/src/background/message-dispatcher.ts +++ b/src/background/message-dispatcher.ts @@ -33,7 +33,7 @@ class MessageDispatcher { const result = await handler(message.data, sender) return { code: 'success', data: result } } catch (error) { - const msg = (error as Error)?.message ?? error?.toString?.() + const msg = error instanceof Error ? error.message : error?.toString?.() return { code: 'fail', msg } } } diff --git a/src/background/track-server/group.ts b/src/background/track-server/group.ts index 123d1f16c..5e73906b4 100644 --- a/src/background/track-server/group.ts +++ b/src/background/track-server/group.ts @@ -1,7 +1,7 @@ import itemService from '@service/item-service' function handleTabGroupRemove(group: chrome.tabGroups.TabGroup) { - itemService.batchDeleteGroupById(group.id) + itemService.deleteByGroup(group.id) } export function handleTabGroupEnabled() { diff --git a/src/database/backup-database.ts b/src/database/backup-database.ts index 082cdca46..ebabe390d 100644 --- a/src/database/backup-database.ts +++ b/src/database/backup-database.ts @@ -36,10 +36,6 @@ class BackupDatabase extends BaseDatabase { async updateCache(type: timer.backup.Type, newVal: unknown): Promise { return this.storage.put(cacheKeyOf(type), newVal as Object) } - - async importData(_data: any): Promise { - // Do nothing - } } const backupDatabase = new BackupDatabase() diff --git a/src/database/common/base-database.ts b/src/database/common/base-database.ts index 0137adc43..2c00a4e19 100644 --- a/src/database/common/base-database.ts +++ b/src/database/common/base-database.ts @@ -24,12 +24,4 @@ export default abstract class BaseDatabase { protected setByKey(key: string, val: any): Promise { return this.storage.put(key, val) } - - /** - * Import data - * - * @since 0.2.5 - * @param data backup data - */ - abstract importData(data: any): Promise } \ No newline at end of file diff --git a/src/database/common/indexed-storage.ts b/src/database/common/indexed-storage.ts new file mode 100644 index 000000000..e74a14761 --- /dev/null +++ b/src/database/common/indexed-storage.ts @@ -0,0 +1,185 @@ +const ALL_TABLES = ['stat'] as const + +export type Table = typeof ALL_TABLES[number] + +export type Key> = keyof T & string + +type IndexConfig> = { + key: Key | Key[] + unique?: boolean +} + +export type Index> = Key | Key[] | IndexConfig + +const DB_NAME = `tt4b_${chrome.runtime.id}` + +function normalizeIndex>(index: Index): IndexConfig { + return typeof index === 'string' || Array.isArray(index) ? { key: index } : index +} + +function formatIdxName>(key: IndexConfig['key']): string { + const keyStr = Array.isArray(key) ? key.sort().join('_') : key + return `idx_${keyStr}` +} + +export function req2Promise(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result) + req.onerror = (ev) => { + console.error("Failed to request indexed-db", ev, req.error) + reject(req.error) + } + }) +} + +export async function iterateCursor( + req: IDBRequest +): Promise +export async function iterateCursor( + req: IDBRequest, + processor: (cursor: IDBCursorWithValue) => void | Promise +): Promise + +export async function iterateCursor( + req: IDBRequest, + processor?: (cursor: IDBCursorWithValue) => void | Promise +): Promise { + const collectResults = !processor + const results: T[] = [] + + return new Promise((resolve, reject) => { + req.onsuccess = async () => { + const cursor = req.result + if (!cursor) return resolve(collectResults ? results : undefined) + + try { + collectResults && results.push(cursor.value as T) + await processor?.(cursor) + cursor.continue() + } catch (error) { + reject(error) + } + } + + req.onerror = () => reject(req.error) + }) +} + +export abstract class BaseIDBStorage> { + private db: IDBDatabase | undefined + abstract indexes: Index[] + abstract key: Key | Key[] + abstract table: Table + + protected async initDb(): Promise { + if (this.db) return this.db + + const factory = typeof window === 'undefined' ? self.indexedDB : window.indexedDB + const checkRequest = factory.open(DB_NAME) + + return new Promise((resolve, reject) => { + checkRequest.onsuccess = () => { + const db = checkRequest.result + const storeExisted = db.objectStoreNames.contains(this.table) + const needUpgrade = !storeExisted || this.needUpgradeIndexes(db) + + if (!needUpgrade) { + this.db = db + return resolve(db) + } + + const currentVersion = db.version + db.close() + + const upgradeRequest = factory.open(DB_NAME, currentVersion + 1) + + upgradeRequest.onupgradeneeded = () => { + const upgradeDb = upgradeRequest.result + const transaction = upgradeRequest.transaction + if (!transaction) return reject("Failed to get transaction of upgrading request") + + let store = upgradeDb.objectStoreNames.contains(this.table) + ? transaction.objectStore(this.table) + : upgradeDb.createObjectStore(this.table, { keyPath: this.key }) + this.createIndexes(store) + } + + upgradeRequest.onsuccess = () => { + console.log("IndexedDB upgraded") + this.db = upgradeRequest.result + resolve(upgradeRequest.result) + } + + upgradeRequest.onerror = () => reject(upgradeRequest.error) + } + + checkRequest.onerror = () => reject(checkRequest.error) + }) + } + + private needUpgradeIndexes(db: IDBDatabase): boolean { + try { + const transaction = db.transaction(this.table, 'readonly') + const store = transaction.objectStore(this.table) + const indexNames = store.indexNames + + for (const index of this.indexes) { + const { key } = normalizeIndex(index) + const idxName = formatIdxName(key) + if (!indexNames.contains(idxName)) { + return true + } + } + return false + } catch (e) { + console.error("Failed to check indexes", e) + return true + } + } + + private createIndexes(store: IDBObjectStore) { + const existingIndexes = store.indexNames + + for (const index of this.indexes) { + const { key, unique } = normalizeIndex(index) + const idxName = formatIdxName(key) + if (!existingIndexes.contains(idxName)) { + store.createIndex(idxName, key, { unique }) + } + } + } + + protected async withStore(operation: (store: IDBObjectStore) => T | Promise, mode?: IDBTransactionMode): Promise { + const db = await this.initDb() + const trans = db.transaction(this.table, mode ?? 'readwrite') + try { + const store = trans.objectStore(this.table) + const result = await operation(store) + // Waiting for transaction completed + await new Promise((resolve, reject) => { + trans.oncomplete = () => resolve() + trans.onerror = () => reject(trans.error) + trans.onabort = () => reject(new Error('Transaction aborted')) + }) + return result + } catch (e) { + console.error("Failed to process with transaction", e) + if (!trans.error && trans.mode !== 'readonly') { + try { + trans.abort() + } catch (ignored) { } + } + throw e + } + } + + protected assertIndex(store: IDBObjectStore, key: Key | Key[]): IDBIndex { + const idxName = formatIdxName(key) + try { + return store.index(idxName) + } catch (err) { + console.error(`Failed to query index: table=${this.table}`, err) + throw err + } + } +} diff --git a/src/database/common/migratable.ts b/src/database/common/migratable.ts new file mode 100644 index 000000000..0c429ff01 --- /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 +} \ No newline at end of file diff --git a/src/database/common/storage-holder.ts b/src/database/common/storage-holder.ts new file mode 100644 index 000000000..672044a9e --- /dev/null +++ b/src/database/common/storage-holder.ts @@ -0,0 +1,23 @@ +import optionHolder from '@service/components/option-holder' + +export class StorageHolder { + current: Database + delegates: Record + + constructor(delegates: Record) { + this.delegates = delegates + this.current = delegates['classic'] + + optionHolder.get().then(val => this.handleOption(val)) + optionHolder.addChangeListener(val => this.handleOption(val)) + } + + private handleOption(option: timer.option.TrackingOption) { + const delegate = this.delegates[option.storage] + delegate && (this.current = delegate) + } + + get(type: timer.option.StorageType): Database | null { + return this.delegates[type] ?? null + } +} \ No newline at end of file diff --git a/src/database/common/storage-promise.ts b/src/database/common/storage-promise.ts index 91c8d9c5a..14c47dc97 100644 --- a/src/database/common/storage-promise.ts +++ b/src/database/common/storage-promise.ts @@ -44,7 +44,7 @@ export default class StoragePromise { /** * @since 0.5.0 */ - put(key: string, val: Object): Promise { + put(key: string, val: unknown): Promise { return this.set({ [key]: val }) } diff --git a/src/database/limit-database.ts b/src/database/limit-database.ts index f8327bd3e..601aba2a1 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 { 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,9 @@ 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 +286,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 + 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: Items = 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/meta-database.ts b/src/database/meta-database.ts index 9475bdc50..a4ee18283 100644 --- a/src/database/meta-database.ts +++ b/src/database/meta-database.ts @@ -17,21 +17,6 @@ class MetaDatabase extends BaseDatabase { return meta || {} } - async importData(data: any): Promise { - const meta: timer.ExtensionMeta = data[META_KEY] as timer.ExtensionMeta - if (!meta) return - - const existMeta = await this.getMeta() - const { popupCounter = {}, appCounter = {} } = existMeta - popupCounter._total = (popupCounter._total ?? 0) + (popupCounter._total ?? 0) - if (meta.appCounter) { - Object.entries(meta.appCounter).forEach(([routePath, count]) => { - appCounter[routePath] = (appCounter[routePath] ?? 0) + count - }) - } - await this.update({ ...existMeta, popupCounter, appCounter }) - } - async update(existMeta: timer.ExtensionMeta): Promise { await this.storage.put(META_KEY, existMeta) } diff --git a/src/database/option-database.ts b/src/database/option-database.ts index f7b07f72a..d2ee7c88f 100644 --- a/src/database/option-database.ts +++ b/src/database/option-database.ts @@ -17,14 +17,6 @@ const DB_KEY = REMAIN_WORD_PREFIX + 'OPTION' * @since 0.3.0 */ class OptionDatabase extends BaseDatabase { - async importData(data: any): Promise { - const newVal = data[DB_KEY] - const exist = await this.getOption() - if (exist) { - Object.entries(exist).forEach(([key, value]) => (exist as any)[key] = value) - } - await this.setOption(newVal) - } async getOption(): Promise { const option = await this.storage.getOne(DB_KEY) diff --git a/src/database/period-database.ts b/src/database/period-database.ts index 1ff81dd94..0bfecf1c6 100644 --- a/src/database/period-database.ts +++ b/src/database/period-database.ts @@ -102,17 +102,6 @@ class PeriodDatabase extends BaseDatabase { const keys = dates.map(generateKey) await this.storage.remove(keys) } - - async importData(data: any): Promise { - if (typeof data !== "object") return - const items = await this.storage.get() - const keyReg = new RegExp(`^${KEY_PREFIX}20\\d{2}[01]\\d[0-3]\\d$`) - const toSave: Record = {} - Object.entries(data) - .filter(([key]) => keyReg.test(key)) - .forEach(([key, value]) => toSave[key] = migrate(items[key], value as _Value)) - this.storage.set(toSave) - } } type _Value = { [key: string]: number } diff --git a/src/database/site-cate-database.ts b/src/database/site-cate-database.ts index feb063b00..bcf054f7a 100644 --- a/src/database/site-cate-database.ts +++ b/src/database/site-cate-database.ts @@ -19,26 +19,13 @@ type Item = { type Items = Record -function migrate(exist: Items, toMigrate: any) { - let idBase = Object.keys(exist).map(parseInt).sort().reverse()?.[0] ?? 0 + 1 - const existLabels = new Set(Object.values(exist).map(e => e.n)) - - Object.values(toMigrate).forEach(value => { - const { n } = (value as Item) || {} - if (!n || existLabels.has(n)) return - - const id = idBase - idBase++ - exist[id] = { n } - }) -} - /** * Site tag * * @since 3.0.0 */ class SiteCateDatabase extends BaseDatabase { + private async getItems(): Promise { return await this.storage.getOne(KEY) || {} } @@ -86,14 +73,6 @@ class SiteCateDatabase extends BaseDatabase { await this.saveItems(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() - migrate(exists, toImport) - this.setByKey(KEY, exists) - } async delete(id: number): Promise { const items = await this.getItems() diff --git a/src/database/site-database.ts b/src/database/site-database.ts index 71826de80..45555da81 100644 --- a/src/database/site-database.ts +++ b/src/database/site-database.ts @@ -179,9 +179,6 @@ async function existBatch(this: SiteDatabase, siteKeys: timer.site.SiteKey[]): P return Object.entries(items).map(([key]) => cvt2SiteKey(key)) } -async function importData(this: SiteDatabase, _data: any) { - throw new Error("Method not implemented.") -} //////////////////////////////////////////////////////////////////////////// ///////////////////////// ///////////////////////// @@ -197,7 +194,6 @@ class SiteDatabase extends BaseDatabase { remove = remove exist = exist existBatch = existBatch - importData = importData /** * Add listener to listen changes diff --git a/src/database/stat-database/classic.ts b/src/database/stat-database/classic.ts new file mode 100644 index 000000000..eaa413f39 --- /dev/null +++ b/src/database/stat-database/classic.ts @@ -0,0 +1,263 @@ +import BaseDatabase from '@db/common/base-database' +import { REMAIN_WORD_PREFIX } from '@db/common/constant' +import { log } from '@src/common/logger' +import { isOptionalInt } from '@util/guard' +import { escapeRegExp } from '@util/pattern' +import { isNotZeroResult } from '@util/stat' +import { createObjectGuard } from 'typescript-guard' +import { cvtGroupId2Host, formatDateStr, GROUP_PREFIX, increase, zeroResult } from './common' +import { filterDate, filterHost, filterNumberRange, processCondition, type ProcessedCondition } from './condition' +import type { StatCondition, StatDatabase } from './types' + +/** + * Generate the key in local storage by host and date + * + * @param host host + * @param date date + */ +const generateKey = (host: string, date: Date | string) => formatDateStr(date) + host +const generateHostReg = (host: string): RegExp => RegExp(`^\\d{8}${escapeRegExp(host)}$`) + +const generateGroupKey = (groupId: number, date: Date | string) => formatDateStr(date) + cvtGroupId2Host(groupId) +const generateGroupReg = (groupId: number): RegExp => RegExp(`^\\d{8}${escapeRegExp(cvtGroupId2Host(groupId))}$`) + +const isPartialResult = createObjectGuard>({ + focus: isOptionalInt, + time: isOptionalInt, + run: isOptionalInt, +}) + +function filterRow(row: timer.core.Row, condition: ProcessedCondition): boolean { + const { host, date, focus, time } = row + const { timeStart, timeEnd, focusStart, focusEnd } = condition + + return filterHost(host, condition) + && filterDate(date, condition) + && filterNumberRange(time, [timeStart, timeEnd]) + && filterNumberRange(focus, [focusStart, focusEnd]) +} + +/** + * Default implementation by `chrome.storage.local` + */ +export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { + + async refresh(): Promise<{ [key: string]: unknown }> { + const result = await this.storage.get() + const items: Record = {} + Object.entries(result) + .filter(([key]) => !key.startsWith(REMAIN_WORD_PREFIX)) + .forEach(([key, value]) => items[key] = value) + return items + } + + /** + * @param host host + * @since 0.1.3 + */ + accumulate(host: string, date: Date | string, item: timer.core.Result): Promise { + const key = generateKey(host, date) + return this.accumulateInner(key, item) + } + + /** + * @param host host + * @since 0.1.3 + */ + accumulateGroup(groupId: number, date: Date | string, item: timer.core.Result): Promise { + const key = generateGroupKey(groupId, date) + return this.accumulateInner(key, item) + } + + private async accumulateInner(key: string, item: timer.core.Result): Promise { + const exist = await this.storage.getOne(key) + const value = increase(item, exist) + await this.setByKey(key, value) + return value + } + + /** + * Batch accumulate + * + * @param data data: {host=>waste_per_day} + * @param date date + * @since 0.1.8 + */ + async batchAccumulate(data: Record, date: Date | string): Promise> { + const hosts = Object.keys(data) + if (!hosts.length) return {} + const dateStr = formatDateStr(date) + const keys: { [host: string]: string } = {} + hosts.forEach(host => keys[host] = generateKey(host, dateStr)) + + const items = await this.storage.get(Object.values(keys)) + + const toUpdate: Record = {} + const afterUpdated: Record = {} + Object.entries(keys).forEach(([host, key]) => { + const item = data[host] + const exist: timer.core.Result = increase(item, items[key] as timer.core.Result) + toUpdate[key] = afterUpdated[host] = exist + }) + await this.storage.set(toUpdate) + return afterUpdated + } + + /** + * Filter by query parameters + */ + private async filter(condition?: StatCondition, onlyGroup?: boolean): Promise { + const cond = processCondition(condition ?? {}) + const items = await this.refresh() + const result: timer.core.Row[] = [] + Object.entries(items).forEach(([key, value]) => { + const date = key.substring(0, 8) + let host = key.substring(8) + if (onlyGroup) { + if (host.startsWith(GROUP_PREFIX)) { + host = host.substring(GROUP_PREFIX.length) + } else { + return + } + } else if (host.startsWith(GROUP_PREFIX)) { + return + } + const { focus, time, run } = value as timer.core.Result + const row: timer.core.Row = { host, date, focus, time } + run !== undefined && (row.run = run) + filterRow(row, cond) && result.push(row) + }) + return result + } + + /** + * Select + * + * @param condition condition + */ + async select(condition?: StatCondition): Promise { + log("select:{condition}", condition) + return this.filter(condition) + } + + async selectGroup(condition?: StatCondition): Promise { + return this.filter(condition, true) + } + + /** + * Get by host and date + * + * @since 0.0.5 + */ + async get(host: string, date: Date | string): Promise { + const key = generateKey(host, date) + const exist = await this.storage.getOne(key) + return exist ?? zeroResult() + } + + /** + * Delete by key + * + * @param rows site rows, the host and date mustn't be null + * @since 0.0.9 + */ + async delete(...rows: timer.core.RowKey[]): Promise { + const keys: string[] = rows.map(({ host, date }) => generateKey(host, date)) + return this.storage.remove(keys) + } + + async deleteGroup(...rows: [groupId: number, date: string][]): Promise { + const keys: string[] = rows.map(([groupId, date]) => generateGroupKey(groupId, date)) + return this.storage.remove(keys) + } + + /** + * Force update data + * + * @since 1.4.3 + */ + forceUpdate(...rows: timer.core.Row[]): Promise { + const toSet = Object.fromEntries(rows.map(({ host, date, time, focus, run }) => { + const key = generateKey(host, date) + const result: timer.core.Result = { time, focus } + run && (result.run = run) + return [key, result] + })) + + return this.storage.set(toSet) + } + + forceUpdateGroup(...rows: timer.core.Row[]): Promise { + const toSet = Object.fromEntries(rows.map(({ host, date, time, focus, run }) => { + const key = generateGroupKey(Number(host), date) + const result: timer.core.Result = { time, focus } + run && (result.run = run) + return [key, result] + })) + + return this.storage.set(toSet) + } + + /** + * @param host host + * @param range [start date (inclusive), end date (inclusive)] + * @returns [dates] + * @since 0.0.7 + */ + async deleteByHost(host: string, range?: [start?: Date | string, end?: Date | string]): Promise { + const [start, end] = range ?? [] + const startStr = start && formatDateStr(start) + const endStr = end && formatDateStr(end) + if (startStr && startStr === endStr) { + // Delete one day + const key = generateKey(host, start) + await this.storage.remove(key) + return [startStr] + } + + const dateFilter = (date: string) => (startStr ? startStr <= date : true) && (endStr ? date <= endStr : true) + const items = await this.refresh() + + // Key format: 20201112www.google.com + const keyReg = generateHostReg(host) + const keys: string[] = Object.keys(items) + .filter(key => keyReg.test(key) && dateFilter(key.substring(0, 8))) + + await this.storage.remove(keys) + return keys.map(k => k.substring(0, 8)) + } + + async deleteByGroup(groupId: number, range?: [start?: Date | string, end?: Date | string]): Promise { + const [start, end] = range ?? [] + const startStr = start && formatDateStr(start) + const endStr = end && formatDateStr(end) + const dateFilter = (date: string) => (startStr ? startStr <= date : true) && (endStr ? date <= endStr : true) + const items = await this.refresh() + + const keyReg = generateGroupReg(groupId) + const keys: string[] = Object.keys(items).filter(key => keyReg.test(key) && dateFilter(key.substring(0, 8))) + + await this.storage.remove(keys) + } +} + +/** + * Legacy data extract + * + * @deprecated since 4.0.0, legacy data is not supported for export, this method will be removed in future versions + */ +export function parseImportData(data: unknown): timer.core.Row[] { + if (typeof data !== "object" || data === null) return [] + const rows: timer.core.Row[] = [] + Object.entries(data) + .filter(([key]) => /^20\d{2}[01]\d[0-3]\d.*/.test(key) && !key.substring(8).startsWith(GROUP_PREFIX)) + .forEach(([key, value]) => { + if (typeof value !== "object") return + if (!isPartialResult(value)) return + const date = key.substring(0, 8) + const host = key.substring(8) + const row: timer.core.Row = { host, date, focus: value.focus ?? 0, time: value.time ?? 0 } + isNotZeroResult(row) && rows.push(row) + }) + return rows +} \ No newline at end of file diff --git a/src/database/stat-database/common.ts b/src/database/stat-database/common.ts new file mode 100644 index 000000000..dc0c755bd --- /dev/null +++ b/src/database/stat-database/common.ts @@ -0,0 +1,22 @@ +import { formatTimeYMD } from '@util/time' + +export const GROUP_PREFIX = "_g_" + +export const cvtGroupId2Host = (groupId: number): string => `${GROUP_PREFIX}${groupId}` + +export const formatDateStr = (date: string | Date): string => { + if (typeof date === 'string') return date + return formatTimeYMD(date) +} + +export const zeroResult = (): timer.core.Result => ({ focus: 0, time: 0 }) + +export const increase = (a: timer.core.Result, b: timer.core.Result | undefined) => { + const res: timer.core.Result = { + focus: a.focus + (b?.focus ?? 0), + time: a.time + (b?.time ?? 0), + } + const run = (a.run ?? 0) + (b?.run ?? 0) + run && (res.run = run) + return res +} diff --git a/src/database/stat-database/condition.ts b/src/database/stat-database/condition.ts new file mode 100644 index 000000000..6835fece6 --- /dev/null +++ b/src/database/stat-database/condition.ts @@ -0,0 +1,72 @@ +import { judgeVirtualFast } from "@util/pattern" +import { formatTimeYMD } from "@util/time" +import type { StatCondition } from './types' + +export type ProcessedCondition = StatCondition & { + useExactDate?: boolean + exactDateStr?: string + startDateStr?: string + endDateStr?: string + timeStart?: number + timeEnd?: number + focusStart?: number + focusEnd?: number +} + +export function filterHost(host: string, condition: ProcessedCondition): boolean { + const { keys, virtual } = condition + const keyArr = typeof keys === 'string' ? [keys] : keys + if (!virtual && judgeVirtualFast(host)) return false + if (keyArr?.length && !keyArr.includes(host)) return false + return true +} + +export function filterDate( + date: string, + { useExactDate, exactDateStr, startDateStr, endDateStr }: ProcessedCondition +): boolean { + if (useExactDate) { + if (exactDateStr !== date) return false + } else { + if (startDateStr && startDateStr > date) return false + if (endDateStr && endDateStr < date) return false + } + return true +} + +export function filterNumberRange(val: number, [start, end]: [start?: number, end?: number]): boolean { + if (start !== null && start !== undefined && start > val) return false + if (end !== null && end !== undefined && end < val) return false + return true +} + +export function processCondition(condition?: StatCondition): ProcessedCondition { + const result: ProcessedCondition = { ...condition } + + const paramDate = condition?.date + if (paramDate) { + if (paramDate instanceof Date) { + result.useExactDate = true + result.exactDateStr = formatTimeYMD(paramDate) + } else { + const [startDate, endDate] = paramDate + result.useExactDate = false + startDate && (result.startDateStr = formatTimeYMD(startDate)) + endDate && (result.endDateStr = formatTimeYMD(endDate)) + } + } + + const paramTime = condition?.timeRange + if (paramTime) { + paramTime.length >= 2 && (result.timeEnd = paramTime[1]) + paramTime.length >= 1 && (result.timeStart = paramTime[0]) + } + + const paramFocus = condition?.focusRange + if (paramFocus) { + paramFocus.length >= 2 && (result.focusEnd = paramFocus[1]) + paramFocus.length >= 1 && (result.focusStart = paramFocus[0]) + } + + return result +} diff --git a/src/database/stat-database/constants.ts b/src/database/stat-database/constants.ts deleted file mode 100644 index d1ca9f5e4..000000000 --- a/src/database/stat-database/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const GROUP_PREFIX = "_g_" \ No newline at end of file diff --git a/src/database/stat-database/filter.ts b/src/database/stat-database/filter.ts deleted file mode 100644 index 384f9a6bb..000000000 --- a/src/database/stat-database/filter.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { judgeVirtualFast } from "@util/pattern" -import { formatTimeYMD } from "@util/time" -import { type StatCondition, type StatDatabase } from "." -import { GROUP_PREFIX } from "./constants" - -type _StatCondition = StatCondition & { - // Use exact date condition - useExactDate?: boolean - // date str - exactDateStr?: string - startDateStr?: string - endDateStr?: string - // time range - timeStart?: number - timeEnd?: number - focusStart?: number - focusEnd?: number -} - -type _FilterResult = { - host: string - date: string - value: timer.core.Result -} - -function filterHost(host: string, condition: _StatCondition): boolean { - const { keys, virtual } = condition - const keyArr = typeof keys === 'string' ? [keys] : keys - // 1. virtual - if (!virtual && judgeVirtualFast(host)) return false - // 2. host - if (keyArr?.length && !keyArr.includes(host)) return false - return true -} - -function filterDate( - date: string, - { useExactDate, exactDateStr, startDateStr, endDateStr }: _StatCondition -): boolean { - if (useExactDate) { - if (exactDateStr !== date) return false - } else { - if (startDateStr && startDateStr > date) return false - if (endDateStr && endDateStr < date) return false - } - return true -} - -function filterNumberRange(val: number, [start, end]: [start?: number, end?: number]): boolean { - if (start !== null && start !== undefined && start > val) return false - if (end !== null && end !== undefined && end < val) return false - return true -} - -/** - * Filter by query parameters - * - * @param date date of item - * @param host host of item - * @param val val of item - * @param condition query parameters - * @return true if valid, or false - */ -function filterByCond(result: _FilterResult, condition: _StatCondition): boolean { - const { host, date, value } = result - const { focus, time } = value - const { timeStart, timeEnd, focusStart, focusEnd } = condition - - return filterHost(host, condition) - && filterDate(date, condition) - && filterNumberRange(time, [timeStart, timeEnd]) - && filterNumberRange(focus, [focusStart, focusEnd]) -} - - -function processDateCondition(cond: _StatCondition, paramDate?: Date | [Date?, Date?]) { - if (!paramDate) return - - if (paramDate instanceof Date) { - cond.useExactDate = true - cond.exactDateStr = formatTimeYMD(paramDate as Date) - } else { - const [startDate, endDate] = paramDate - cond.useExactDate = false - startDate && (cond.startDateStr = formatTimeYMD(startDate)) - endDate && (cond.endDateStr = formatTimeYMD(endDate)) - } -} - -function processParamTimeCondition(cond: _StatCondition, paramTime?: [number, number?]) { - if (!paramTime) return - paramTime.length >= 2 && (cond.timeEnd = paramTime[1]) - paramTime.length >= 1 && (cond.timeStart = paramTime[0]) -} - -function processParamFocusCondition(cond: _StatCondition, paramFocus?: Vector<2>) { - if (!paramFocus) return - paramFocus.length >= 2 && (cond.focusEnd = paramFocus[1]) - paramFocus.length >= 1 && (cond.focusStart = paramFocus[0]) -} - -function processCondition(condition: StatCondition): _StatCondition { - const result: _StatCondition = { ...condition } - processDateCondition(result, condition.date) - processParamTimeCondition(result, condition.timeRange) - processParamFocusCondition(result, condition.focusRange) - return result -} - -/** - * Filter by query parameters - */ -export async function filter(this: StatDatabase, condition?: StatCondition, onlyGroup?: boolean): Promise<_FilterResult[]> { - const cond = processCondition(condition ?? {}) - const items = await this.refresh() - const result: _FilterResult[] = [] - Object.entries(items).forEach(([key, value]) => { - const date = key.substring(0, 8) - let host = key.substring(8) - if (onlyGroup) { - if (host.startsWith(GROUP_PREFIX)) { - host = host.substring(GROUP_PREFIX.length) - result.push({ date, host, value: value as timer.core.Result }) - } - } else if (!host.startsWith(GROUP_PREFIX)) { - result.push({ date, host, value: value as timer.core.Result }) - } - }) - return result.filter(item => filterByCond(item, cond)) -} diff --git a/src/database/stat-database/idb.ts b/src/database/stat-database/idb.ts new file mode 100644 index 000000000..a6d17d3ed --- /dev/null +++ b/src/database/stat-database/idb.ts @@ -0,0 +1,382 @@ +import { BaseIDBStorage, iterateCursor, type Key, req2Promise, type Table } from '@db/common/indexed-storage' +import { cvtGroupId2Host, formatDateStr, increase, zeroResult } from './common' +import { filterDate, filterHost, filterNumberRange, processCondition, type ProcessedCondition } from './condition' +import type { StatCondition, StatDatabase } from './types' + +type StoredRow = timer.core.Row & { + // If present, this is a group row + groupId?: number +} + +function fromStoredRow(stored: StoredRow): timer.core.Row { + if (stored.groupId !== undefined) { + const { groupId, ...row } = stored + return { ...row, host: cvtGroupId2Host(groupId) } + } + return stored +} + +const GROUP_HOST_PATTERN = /^_g_(\d+)$/ + +const INDEXES: (Key | Key[])[] = [ + 'date', 'host', 'groupId', + 'focus', 'time', + ['date', 'host'], +] as const + +const isGroup = (row: StoredRow): boolean => row.groupId !== undefined + +type IndexCoverage = { + date?: boolean + host?: boolean + time?: boolean + focus?: boolean +} + +function buildFilter(cond: ProcessedCondition, coverage: IndexCoverage): (row: StoredRow) => boolean { + return (row: StoredRow) => { + if (!coverage.time && !filterNumberRange(row.time, [cond.timeStart, cond.timeEnd])) { + return false + } + + if (!coverage.focus && !filterNumberRange(row.focus, [cond.focusStart, cond.focusEnd])) { + return false + } + + if (!coverage.date && !filterDate(row.date, cond)) { + return false + } + + if (!coverage.host && !filterHost(row.host, cond)) { + return false + } + + return true + } +} + +type StatIndex = typeof INDEXES[number] + +export class IDBStatDatabase extends BaseIDBStorage implements StatDatabase { + table: Table = 'stat' + key: StatIndex = ['date', 'host'] + indexes: StatIndex[] = INDEXES + + get(host: string, date: Date | string): Promise { + return this.withStore(async store => { + const index = super.assertIndex(store, ['date', 'host']) + const dateStr = formatDateStr(date) + const req = index.get([dateStr, host]) + return await req2Promise(req) ?? zeroResult() + }, 'readonly') + } + + private judgeIndex( + store: IDBObjectStore, + cond: ProcessedCondition, + expectGroup: boolean + ): { cursorReq: IDBRequest; coverage: IndexCoverage } { + if (expectGroup) { + const keys = typeof cond.keys === 'string' ? [cond.keys] : cond.keys + const index = super.assertIndex(store, 'groupId') + + if (keys?.length === 1) { + const groupId = parseInt(keys[0]) + if (!isNaN(groupId)) { + return { + cursorReq: index.openCursor(IDBKeyRange.only(groupId)), + coverage: { host: true } + } + } + } + + return { + cursorReq: index.openCursor(IDBKeyRange.lowerBound(0)), + coverage: {} + } + } + + const keys = typeof cond.keys === 'string' ? [cond.keys] : cond.keys + + if (cond.useExactDate && cond.exactDateStr && keys?.length === 1) { + const index = super.assertIndex(store, ['date', 'host']) + return { + cursorReq: index.openCursor(IDBKeyRange.only([cond.exactDateStr, keys[0]])), + coverage: { date: true, host: true } + } + } + + if (cond.useExactDate && cond.exactDateStr) { + const index = super.assertIndex(store, 'date') + return { + cursorReq: index.openCursor(IDBKeyRange.only(cond.exactDateStr)), + coverage: { date: true } + } + } + + if (cond.startDateStr || cond.endDateStr) { + const index = super.assertIndex(store, 'date') + const range = IDBKeyRange.bound( + cond.startDateStr ?? '', + cond.endDateStr ?? '\uffff', + false, false, + ) + return { + cursorReq: index.openCursor(range), + coverage: { date: true } + } + } + + if (cond.timeStart !== undefined || cond.timeEnd !== undefined) { + const index = super.assertIndex(store, 'time') + const range = IDBKeyRange.bound( + cond.timeStart ?? 0, + cond.timeEnd ?? Number.MAX_SAFE_INTEGER, + false, false, + ) + return { + cursorReq: index.openCursor(range), + coverage: { time: true } + } + } + + if (cond.focusStart !== undefined || cond.focusEnd !== undefined) { + const index = super.assertIndex(store, 'focus') + const range = IDBKeyRange.bound( + cond.focusStart ?? 0, + cond.focusEnd ?? Number.MAX_SAFE_INTEGER, + false, false, + ) + return { + cursorReq: index.openCursor(range), + coverage: { focus: true } + } + } + + return { + cursorReq: store.openCursor(), + coverage: {} + } + } + + private async selectInternal(store: IDBObjectStore, cond: ProcessedCondition, expectGroup: boolean): Promise { + const allRows: timer.core.Row[] = [] + const { cursorReq, coverage } = this.judgeIndex(store, cond, expectGroup) + const filter = buildFilter(cond, coverage) + + const rows = await iterateCursor(cursorReq) + for (const row of rows) { + if (expectGroup) { + if (!isGroup(row)) continue + } else { + if (isGroup(row)) continue + } + + if (!filter(row)) continue + + if (expectGroup) { + allRows.push({ + host: row.groupId!.toString(), + date: row.date, + time: row.time, + focus: row.focus, + run: row.run, + }) + } else { + allRows.push(row) + } + } + + return allRows + } + + select(condition?: StatCondition): Promise { + return this.withStore(async store => { + const cond = processCondition(condition) + return this.selectInternal(store, cond, false) + }, 'readonly') + } + + accumulate(host: string, date: Date | string, item: timer.core.Result): Promise { + return this.withStore(async store => { + const index = super.assertIndex(store, ['date', 'host']) + const dateStr = formatDateStr(date) + const req = index.get([dateStr, host]) + const existing = await req2Promise(req) + const newVal = increase(item, existing) + const newData: StoredRow = { host, date: dateStr, ...newVal } + await req2Promise(store.put(newData)) + return newData + }, 'readwrite') + } + + batchAccumulate(data: Record, date: Date | string): Promise> { + return this.withStore(async store => { + const index = super.assertIndex(store, 'date') + const dateStr = formatDateStr(date) + const cursorReq = index.openCursor(IDBKeyRange.only(dateStr)) + const toUpdate: Record = {} + + await iterateCursor(cursorReq, cursor => { + const stored = cursor.value as StoredRow | undefined + if (stored && !isGroup(stored)) { + toUpdate[stored.host] = fromStoredRow(stored) + } + }) + + for (const [host, result] of Object.entries(data)) { + const existing = toUpdate[host] + const newValue: timer.core.Row = { host, date: dateStr, ...increase(result, existing) } + toUpdate[host] = newValue + store.put(newValue) + } + return toUpdate + }, 'readwrite') + } + + delete(...rows: timer.core.RowKey[]): Promise { + return this.withStore(async store => { + const index = super.assertIndex(store, ['date', 'host']) + for (const { host, date } of rows) { + const dateStr = formatDateStr(date) + const req = index.getKey([dateStr, host]) + const key = await req2Promise(req) + if (key) { + await req2Promise(store.delete(key)) + } + } + }, 'readwrite') + } + + deleteByHost(host: string, range?: [start?: Date | string, end?: Date | string]): Promise { + return this.withStore(async store => { + const [start, end] = range ?? [] + const startStr = start ? formatDateStr(start) : undefined + const endStr = end ? formatDateStr(end) : undefined + + if (startStr && startStr === endStr) { + // Delete one day + const index = super.assertIndex(store, ['date', 'host']) + const req = index.getKey([startStr, host]) + const key = await req2Promise(req) + if (key) { + await req2Promise(store.delete(key)) + } + return [startStr] + } + + // Delete by range + const index = super.assertIndex(store, 'host') + const cursorReq = index.openCursor(IDBKeyRange.only(host)) + const deletedDates = new Set() + + await iterateCursor(cursorReq, cursor => { + const r = cursor.value as StoredRow | undefined + if (!r || isGroup(r)) return + + const dateStr = r.date + const inRange = (!startStr || startStr <= dateStr) && (!endStr || dateStr <= endStr) + if (inRange) { + cursor.delete() + deletedDates.add(dateStr) + } + }) + + return Array.from(deletedDates) + }, 'readwrite') + } + + accumulateGroup(groupId: number, date: Date | string, item: timer.core.Result): Promise { + return this.withStore(async store => { + const index = super.assertIndex(store, ['date', 'host']) + const dateStr = formatDateStr(date) + const host = cvtGroupId2Host(groupId) + const req = index.get([dateStr, host]) + const existing = await req2Promise(req) + const newVal = increase(item, existing) + const newData: StoredRow = { host, date: dateStr, groupId, ...newVal } + await req2Promise(store.put(newData)) + return newData + }, 'readwrite') + } + + selectGroup(condition?: StatCondition): Promise { + return this.withStore(async store => { + const cond = processCondition(condition) + return this.selectInternal(store, cond, true) + }, 'readonly') + } + + deleteGroup(...rows: [groupId: number, date: string][]): Promise { + return this.withStore(async store => { + const index = super.assertIndex(store, ['date', 'host']) + for (const [groupId, date] of rows) { + const host = cvtGroupId2Host(groupId) + const dateStr = formatDateStr(date) + const req = index.getKey([dateStr, host]) + const key = await req2Promise(req) + if (key) { + await req2Promise(store.delete(key)) + } + } + }, 'readwrite') + } + + deleteByGroup(groupId: number, range?: [start?: Date | string, end?: Date | string]): Promise { + return this.withStore(async store => { + const [start, end] = range ?? [] + const startStr = start ? formatDateStr(start) : undefined + const endStr = end ? formatDateStr(end) : undefined + const host = cvtGroupId2Host(groupId) + + if (startStr && startStr === endStr) { + // Delete one day + const index = super.assertIndex(store, ['date', 'host']) + const req = index.getKey([startStr, host]) + const key = await req2Promise(req) + if (key) { + await req2Promise(store.delete(key)) + } + return + } + + // Delete by range + const index = super.assertIndex(store, 'host') + const cursorReq = index.openCursor(IDBKeyRange.only(host)) + + await iterateCursor(cursorReq, cursor => { + const r = cursor.value as StoredRow | undefined + if (!r) return + const dateStr = r.date + const inRange = (!startStr || startStr <= dateStr) && (!endStr || dateStr <= endStr) + inRange && cursor.delete() + }) + }, 'readwrite') + } + + forceUpdate(...rows: timer.core.Row[]): Promise { + return this.withStore(store => { + for (const row of rows) { + const { host, date, time, focus, run } = row + const groupMatch = host.match(GROUP_HOST_PATTERN) + const newData: StoredRow = { host, date, time, focus, run } + groupMatch && (newData.groupId = parseInt(groupMatch[1])) + store.put(newData) + } + }, 'readwrite') + } + + forceUpdateGroup(...rows: timer.core.Row[]): Promise { + return this.withStore(store => { + for (const row of rows) { + const { host, date, time, focus, run } = row + const groupId = parseInt(host) + if (isNaN(groupId)) { + throw new Error(`Invalid group host: ${host}`) + } + const newData: StoredRow = { host, date, time, focus, run, groupId } + store.put(newData) + } + }, 'readwrite') + } +} \ No newline at end of file diff --git a/src/database/stat-database/index.ts b/src/database/stat-database/index.ts index 75ac11eaf..7907e32b8 100644 --- a/src/database/stat-database/index.ts +++ b/src/database/stat-database/index.ts @@ -5,312 +5,140 @@ * https://opensource.org/licenses/MIT */ -import { escapeRegExp } from "@util/pattern" -import { isNotZeroResult } from "@util/stat" -import { formatTimeYMD } from "@util/time" -import { log } from "../../common/logger" -import BaseDatabase from "../common/base-database" -import { REMAIN_WORD_PREFIX } from "../common/constant" -import { GROUP_PREFIX } from "./constants" -import { filter } from "./filter" - -export type StatCondition = { - /** - * Date - * {y}{m}{d} - */ - date?: Date | [Date?, Date?] - /** - * Focus range, milliseconds - * - * @since 0.0.9 - */ - focusRange?: Vector<2> - /** - * Time range - * - * @since 0.0.9 - */ - timeRange?: [number, number?] - /** - * Whether to include virtual sites - * - * @since 1.6.1 - */ - virtual?: boolean - /** - * Host or groupId, full match - */ - keys?: string[] | string -} - -function increase(a: timer.core.Result, b: timer.core.Result) { - const res: timer.core.Result = { - focus: (a?.focus ?? 0) + (b?.focus ?? 0), - time: (a?.time ?? 0) + (b?.time ?? 0), +import { extractNamespace, isExportData, isLegacyVersion } from '@db/common/migratable' +import { StorageHolder } from '@db/common/storage-holder' +import type { BrowserMigratable, StorageMigratable } from '@db/types' +import { isOptionalInt } from '@util/guard' +import { isNotZeroResult } from '@util/stat' +import { createArrayGuard, createObjectGuard, isString } from 'typescript-guard' +import { ClassicStatDatabase, parseImportData } from './classic' +import { IDBStatDatabase } from './idb' +import type { StatCondition, StatDatabase } from './types' + +type StateDatabaseComposite = + & StatDatabase + & StorageMigratable<[tabs: timer.core.Row[], groups: timer.core.Row[]]> + & BrowserMigratable<'__stat__'> + +// Only `date` and `host` are required for import, other fields are optional, and will be set to default if not provided +type ValidImportRow = MakeRequired, 'date' | 'host'> + +const isValidImportRow = createObjectGuard({ + focus: isOptionalInt, + time: isOptionalInt, + run: isOptionalInt, + date: isString, + host: isString, +}) + +class StatDatabaseWrapper implements StateDatabaseComposite { + namespace: '__stat__' = '__stat__' + private holder = new StorageHolder({ + classic: new ClassicStatDatabase(), + indexed_db: new IDBStatDatabase(), + }) + private current = () => this.holder.current + + get(host: string, date: Date | string): Promise { + return this.current().get(host, date) + } + + select(condition?: StatCondition): Promise { + return this.current().select(condition) } - const run = (a?.run ?? 0) + (b?.run ?? 0) - run && (res.run = run) - return res -} - -function createZeroResult(): timer.core.Result { - return { focus: 0, time: 0 } -} - -function mergeMigration(exist: timer.core.Result | undefined, another: any) { - exist = exist || createZeroResult() - return increase(exist, { focus: another.focus ?? 0, time: another.time ?? 0, run: another.run ?? 0 }) -} - -/** - * Generate the key in local storage by host and date - * - * @param host host - * @param date date - */ -function generateKey(host: string, date: Date | string) { - const str = typeof date === 'object' ? formatTimeYMD(date as Date) : date - return str + host -} - -const generateHostReg = (host: string): RegExp => RegExp(`^\\d{8}${escapeRegExp(host)}$`) - -function generateGroupKey(groupId: number, date: Date | string) { - const str = typeof date === 'object' ? formatTimeYMD(date as Date) : date - return str + GROUP_PREFIX + groupId -} - -const generateGroupReg = (groupId: number): RegExp => RegExp(`^\\d{8}${escapeRegExp(`${GROUP_PREFIX}${groupId}`)}$`) - -function migrate(exists: { [key: string]: timer.core.Result }, data: any): Record { - const result: Record = {} - Object.entries(data) - .filter(([key]) => /^20\d{2}[01]\d[0-3]\d.*/.test(key) && !key.substring(8).startsWith(GROUP_PREFIX)) - .forEach(([key, value]) => { - if (typeof value !== "object") return - const exist = exists[key] - const merged = mergeMigration(exist, value) - merged && isNotZeroResult(merged) && (result[key] = mergeMigration(exist, value)) - }) - return result -} -export class StatDatabase extends BaseDatabase { - - async refresh(): Promise<{ [key: string]: unknown }> { - const result = await this.storage.get() - const items: Record = {} - Object.entries(result) - .filter(([key]) => !key.startsWith(REMAIN_WORD_PREFIX)) - .forEach(([key, value]) => items[key] = value) - return items + accumulate(host: string, date: Date | string, item: timer.core.Result): Promise { + return this.current().accumulate(host, date, item) } - /** - * @param host host - * @since 0.1.3 - */ - accumulate(host: string, date: Date | string, item: timer.core.Result): Promise { - const key = generateKey(host, date) - return this.accumulateInner(key, item) + batchAccumulate(data: Record, date: Date | string): Promise> { + return this.current().batchAccumulate(data, date) } - /** - * @param host host - * @since 0.1.3 - */ accumulateGroup(groupId: number, date: Date | string, item: timer.core.Result): Promise { - const key = generateGroupKey(groupId, date) - return this.accumulateInner(key, item) + return this.current().accumulateGroup(groupId, date, item) } - private async accumulateInner(key: string, item: timer.core.Result): Promise { - let exist = await this.storage.getOne(key) - exist = increase(exist || createZeroResult(), item) - await this.setByKey(key, exist) - return exist + delete(...rows: timer.core.RowKey[]): Promise { + return this.current().delete(...rows) } - /** - * Batch accumulate - * - * @param data data: {host=>waste_per_day} - * @param date date - * @since 0.1.8 - */ - async accumulateBatch(data: Record, date: Date | string): Promise> { - const hosts = Object.keys(data) - if (!hosts.length) return {} - const dateStr = typeof date === 'string' ? date : formatTimeYMD(date) - const keys: { [host: string]: string } = {} - hosts.forEach(host => keys[host] = generateKey(host, dateStr)) - - const items = await this.storage.get(Object.values(keys)) - - const toUpdate: Record = {} - const afterUpdated: Record = {} - Object.entries(keys).forEach(([host, key]) => { - const item = data[host] - const exist: timer.core.Result = increase(items[key] as timer.core.Result || createZeroResult(), item) - toUpdate[key] = afterUpdated[host] = exist - }) - await this.storage.set(toUpdate) - return afterUpdated + deleteByHost(host: string, range?: [start?: Date | string, end?: Date | string]): Promise { + return this.current().deleteByHost(host, range) } - filter = filter - - /** - * Select - * - * @param condition condition - */ - async select(condition?: StatCondition): Promise { - log("select:{condition}", condition) - const filterResults = await this.filter(condition) - return filterResults.map(({ date, host, value }) => { - const { focus, time, run } = value - return { date, host, focus, time, run } - }) - } - - async selectGroup(condition?: StatCondition): Promise { - const filterResults = await this.filter(condition, true) - return filterResults.map(({ date, host, value }) => { - const { focus, time, run } = value - return { date, host, focus, time, run } - }) + selectGroup(condition?: StatCondition): Promise { + return this.current().selectGroup(condition) } - /** - * Get by host and date - * - * @since 0.0.5 - */ - async get(host: string, date: Date | string): Promise { - const key = generateKey(host, date) - const exist = await this.storage.getOne(key) - return exist || createZeroResult() + deleteGroup(...rows: [groupId: number, date: string][]): Promise { + return this.current().deleteGroup(...rows) } - /** - * Delete the record - * - * @param host host - * @param date date - * @since 0.0.5 - */ - async deleteByUrlAndDate(host: string, date: Date | string): Promise { - const key = generateKey(host, date) - return this.storage.remove(key) + deleteByGroup(groupId: number, range: [start?: Date | string, end?: Date | string]): Promise { + return this.current().deleteByGroup(groupId, range) } - async deleteByGroupAndDate(groupId: number, date: Date | string): Promise { - const key = generateGroupKey(groupId, date) - return this.storage.remove(key) + forceUpdate(...rows: timer.core.Row[]): Promise { + return this.current().forceUpdate(...rows) } - /** - * Delete by key - * - * @param rows site rows, the host and date mustn't be null - * @since 0.0.9 - */ - async delete(rows: timer.core.RowKey[]): Promise { - const keys: string[] = rows.map(({ host, date }) => generateKey(host, date)) - return this.storage.remove(keys) + forceUpdateGroup(...rows: timer.core.Row[]): Promise { + return this.current().forceUpdateGroup(...rows) } - async deleteGroup(rows: [groupId: number, date: string][]): Promise { - const keys: string[] = rows.map(([groupId, date]) => generateGroupKey(groupId, date)) - return this.storage.remove(keys) - } - async batchDeleteGroup(groupId: number): Promise { - const keyReg = generateGroupReg(groupId) - const items = await this.refresh() - const keys: string[] = Object.keys(items).filter(key => keyReg.test(key)) - await this.storage.remove(keys) + async migrateStorage(type: timer.option.StorageType): Promise<[timer.core.Row[], timer.core.Row[]]> { + const target = this.holder.get(type) + if (!target) return [[], []] + const tabs = await this.select({ virtual: true }) + await target.forceUpdate(...tabs) + const groups = await this.selectGroup() + await target.forceUpdateGroup(...groups) + return [tabs, groups] } - /** - * Force update data - * - * @since 1.4.3 - */ - forceUpdate({ host, date, time, focus, run }: timer.core.Row): Promise { - const key = generateKey(host, date) - const result: timer.core.Result = { time, focus } - run && (result.run = run) - return this.storage.put(key, result) + async afterStorageMigrated([tabs, groups]: [timer.core.Row[], timer.core.Row[]]): Promise { + await this.current().delete(...tabs) + const groupKeys = groups.map(({ host, date }) => [parseInt(host), date] satisfies [number, string]) + await this.current().deleteGroup(...groupKeys) } - /** - * @param host host - * @param start start date, inclusive - * @param end end date, inclusive - * @since 0.0.7 - */ - async deleteByUrlBetween(host: string, start?: Date, end?: Date): Promise { - const startStr = start ? formatTimeYMD(start) : undefined - const endStr = end ? formatTimeYMD(end) : undefined - const dateFilter = (date: string) => (startStr ? startStr <= date : true) && (endStr ? date <= endStr : true) - const items = await this.refresh() - - // Key format: 20201112www.google.com - const keyReg = generateHostReg(host) - const keys: string[] = Object.keys(items) - .filter(key => keyReg.test(key) && dateFilter(key.substring(0, 8))) - - await this.storage.remove(keys) - return keys.map(k => k.substring(0, 8)) + async importData(data: unknown): Promise { + const rows = this.parseImportRows(data) + await this.forceUpdate(...rows) } - async deleteByGroupBetween(groupId: number, start?: Date, end?: Date): Promise { - const startStr = start ? formatTimeYMD(start) : undefined - const endStr = end ? formatTimeYMD(end) : undefined - const dateFilter = (date: string) => (startStr ? startStr <= date : true) && (endStr ? date <= endStr : true) - const items = await this.refresh() - - const keyReg = generateGroupReg(groupId) - const keys: string[] = Object.keys(items).filter(key => keyReg.test(key) && dateFilter(key.substring(0, 8))) - - await this.storage.remove(keys) + async exportData(): Promise { + return this.select({ virtual: true }) } - /** - * Delete the record - * - * @param host host - * @since 0.0.5 - */ - async deleteByUrl(host: string): Promise { - const items = await this.refresh() + private parseImportRows(data: unknown): timer.core.Row[] { + if (!isExportData(data)) return [] + if (isLegacyVersion(data)) { + return parseImportData(data) ?? [] + } - // Key format: 20201112www.google.com - const keyReg = generateHostReg(host) - const keys: string[] = Object.keys(items).filter(key => keyReg.test(key)) - await this.storage.remove(keys) + if (!(this.namespace in data)) return [] - return keys.map(k => k.substring(0, 8)) - } - - async deleteByGroup(groupId: number): Promise { - const items = await this.refresh() - const keyReg = generateGroupReg(groupId) - const keys: string[] = Object.keys(items).filter(key => keyReg.test(key)) - await this.storage.remove(keys) - } - - async importData(data: any): Promise { - if (typeof data !== "object") return - const items = await this.storage.get() - const toSave = migrate(items, data) - this.storage.set(toSave) + const nsData = extractNamespace(data, this.namespace, createArrayGuard(isValidImportRow)) ?? [] + const rows: timer.core.Row[] = [] + for (const item of nsData) { + const row: timer.core.Row = { + host: item.host, + date: item.date, + time: item.time ?? 0, + focus: item.focus ?? 0, + run: item.run ?? 0, + } + isNotZeroResult(row) && rows.push(row) + } + return rows } } -const statDatabase = new StatDatabase() +const statDatabase: StateDatabaseComposite = new StatDatabaseWrapper() export default statDatabase + +export * from "./types" diff --git a/src/database/stat-database/types.ts b/src/database/stat-database/types.ts new file mode 100644 index 000000000..ba4c56dc9 --- /dev/null +++ b/src/database/stat-database/types.ts @@ -0,0 +1,73 @@ + +export type StatCondition = { + /** + * Date + * {y}{m}{d} + */ + date?: Date | [Date?, Date?] + /** + * Focus range, milliseconds + * + * @since 0.0.9 + */ + focusRange?: Vector<2> + /** + * Time range + * + * @since 0.0.9 + */ + timeRange?: [number, number?] + /** + * Whether to include virtual sites + * + * @since 1.6.1 + */ + virtual?: boolean + /** + * Host or groupId, full match + */ + keys?: string[] | string +} + +export interface StatDatabase { + get(host: string, date: Date | string): Promise + select(condition?: StatCondition): Promise + /** + * Accumulate data + */ + accumulate(host: string, date: Date | string, item: timer.core.Result): Promise + batchAccumulate(data: Record, date: Date | string): Promise> + delete(...rows: timer.core.RowKey[]): Promise + /** + * Delete by host + * + * @param host host + * @param range date range, inclusive start and end, if null, delete all + */ + deleteByHost(host: string, range?: [start?: Date | string, end?: Date | string]): Promise + + /******* GROUP *******/ + /** + * Accumulate data for tab group + */ + accumulateGroup(groupId: number, date: Date | string, item: timer.core.Result): Promise + selectGroup(condition?: StatCondition): Promise + deleteGroup(...rows: [groupId: number, date: string][]): Promise + /** + * Delete group data + * + * @param groupId the id of group + * @param range date range, inclusive start and end, if null, delete all + */ + deleteByGroup(groupId: number, range?: [start?: Date | string, end?: Date | string]): Promise + + /** + * Force update data with overwriting + */ + forceUpdate(...rows: timer.core.Row[]): Promise + + /** + * Force update group data with overwriting + */ + forceUpdateGroup(...rows: timer.core.Row[]): Promise +} \ No newline at end of file diff --git a/src/database/timeline-database.ts b/src/database/timeline-database/classic.ts similarity index 87% rename from src/database/timeline-database.ts rename to src/database/timeline-database/classic.ts index 98f270f2d..c1f0c0727 100644 --- a/src/database/timeline-database.ts +++ b/src/database/timeline-database/classic.ts @@ -1,6 +1,6 @@ import { formatTimeYMD, MILL_PER_DAY } from '@util/time' -import BaseDatabase from './common/base-database' -import { REMAIN_WORD_PREFIX } from './common/constant' +import BaseDatabase from '../common/base-database' +import { REMAIN_WORD_PREFIX } from '../common/constant' const DB_KEY = REMAIN_WORD_PREFIX + 'TL' @@ -62,7 +62,8 @@ const removeOutdated = (data: TimelineData, currTime: number) => { keys.forEach(key => delete data[key]) } -class TimelineDatabase extends BaseDatabase { +export default class ClassicTimelineDatabase extends BaseDatabase { + private async getData(): Promise { const data = await this.storage.getOne(DB_KEY) return data ?? {} @@ -72,7 +73,7 @@ class TimelineDatabase extends BaseDatabase { return this.setByKey(DB_KEY, data) } - async batchSave(ticks: timer.timeline.Tick[]) { + async batchSave(ticks: timer.timeline.Tick[]): Promise { const data = await this.getData() ticks.forEach(tick => { merge(data, tick) @@ -92,10 +93,4 @@ class TimelineDatabase extends BaseDatabase { return result } - async importData(_: any): Promise { - // do nothing - } } -const timelineDatabase = new TimelineDatabase() - -export default timelineDatabase \ No newline at end of file diff --git a/src/database/timeline-database/idb.ts b/src/database/timeline-database/idb.ts new file mode 100644 index 000000000..6f8518497 --- /dev/null +++ b/src/database/timeline-database/idb.ts @@ -0,0 +1,10 @@ +import type { TimelineDatabase } from './types' + +export default class IDBTimelineDatabase implements TimelineDatabase { + batchSave(ticks: timer.timeline.Tick[]): Promise { + throw new Error('Method not implemented.') + } + getAll(): Promise { + throw new Error('Method not implemented.') + } +} \ No newline at end of file diff --git a/src/database/timeline-database/index.ts b/src/database/timeline-database/index.ts new file mode 100644 index 000000000..611ebe982 --- /dev/null +++ b/src/database/timeline-database/index.ts @@ -0,0 +1,36 @@ +import { StorageHolder } from '@db/common/storage-holder' +import type { StorageMigratable } from '@db/types' +import ClassicTimelineDatabase from './classic' +import IDBTimelineDatabase from './idb' +import type { TimelineDatabase } from './types' + +type Composite = TimelineDatabase & StorageMigratable + +class TimelineDatabaseWrapper implements Composite { + private holder = new StorageHolder({ + classic: new ClassicTimelineDatabase(), + indexed_db: new IDBTimelineDatabase(), + }) + + private current = () => this.holder.current + + batchSave(ticks: timer.timeline.Tick[]): Promise { + return this.current().batchSave(ticks) + } + + getAll(): Promise { + return this.current().getAll() + } + + migrateStorage(type: timer.option.StorageType): Promise { + throw new Error('Method not implemented.') + } + + afterStorageMigrated(allData: timer.timeline.Tick[]): Promise { + throw new Error('Method not implemented.') + } +} + +const timelineDatabase = new TimelineDatabaseWrapper() + +export default timelineDatabase \ No newline at end of file diff --git a/src/database/timeline-database/types.ts b/src/database/timeline-database/types.ts new file mode 100644 index 000000000..302e12d97 --- /dev/null +++ b/src/database/timeline-database/types.ts @@ -0,0 +1,4 @@ +export interface TimelineDatabase { + batchSave(ticks: timer.timeline.Tick[]): Promise + getAll(): Promise +} \ No newline at end of file diff --git a/src/database/types.d.ts b/src/database/types.d.ts new file mode 100644 index 000000000..c0626e468 --- /dev/null +++ b/src/database/types.d.ts @@ -0,0 +1,33 @@ +/** + * Migrate data among storages (chrome.storage.local / IndexedDB) + */ +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 interface BrowserMigratable { + /** + * The name space for migration + */ + namespace: N + exportData(): Promise[N]> + importData(data: unknown): Promise +} \ No newline at end of file diff --git a/src/database/whitelist-database.ts b/src/database/whitelist-database.ts index bf12e7795..d995be105 100644 --- a/src/database/whitelist-database.ts +++ b/src/database/whitelist-database.ts @@ -7,8 +7,10 @@ import BaseDatabase from "./common/base-database" import { WHITELIST_KEY } from "./common/constant" +import { BrowserMigratable } from './types' -class WhitelistDatabase extends BaseDatabase { +class WhitelistDatabase extends BaseDatabase implements BrowserMigratable<'__whitelist__'> { + namespace: '__whitelist__' = '__whitelist__' private async update(toUpdate: string[]): Promise { await this.setByKey(WHITELIST_KEY, toUpdate || []) @@ -54,12 +56,17 @@ class WhitelistDatabase extends BaseDatabase { chrome.storage.onChanged.addListener(storageListener) } - async importData(data: any): Promise { - const toMigrate = data[WHITELIST_KEY] - if (!Array.isArray(toMigrate)) return - const exist = await this.selectAll() - toMigrate.forEach(white => !exist.includes(white) && exist.push(white)) - await this.update(exist) + async importData(data: unknown): Promise { + // const toMigrate = data[WHITELIST_KEY] + // if (!Array.isArray(toMigrate)) return + // const exist = await this.selectAll() + // toMigrate.forEach(white => !exist.includes(white) && exist.push(white)) + // await this.update(exist) + throw new Error('Method not implemented.') + } + + exportData(): Promise { + throw new Error('Method not implemented.') } } diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 94593ded6..10b5b505d 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -301,7 +301,8 @@ "tabGroupsPermGrant": "This feature requires relevant permissions", "fileAccessDisabled": "Access to file URLs is currently not allowed. Please enable it on the manage page first", "weekStart": "The first day for each week {input}", - "weekStartAsNormal": "As Normal" + "weekStartAsNormal": "As Normal", + "storage": "Store the tracking data in {input}" }, "limit": { "prompt": "Prompt displayed when restricted {input}", diff --git a/src/i18n/message/app/option.ts b/src/i18n/message/app/option.ts index b067ac162..466455df1 100644 --- a/src/i18n/message/app/option.ts +++ b/src/i18n/message/app/option.ts @@ -51,6 +51,7 @@ export type OptionMessage = { fileAccessDisabled: string weekStart: string weekStartAsNormal: string + storage: string } limit: { prompt: string diff --git a/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx b/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx index 3008520dc..013c8045e 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx +++ b/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx @@ -7,7 +7,7 @@ import ChartTitle from '@app/components/Dashboard/ChartTitle' import { t } from '@app/locale' -import { TIMELINE_LIFE_CYCLE } from '@db/timeline-database' +import { TIMELINE_LIFE_CYCLE } from '@db/timeline-database/classic' import { Collection, Files, Link } from '@element-plus/icons-vue' import { useShadow } from '@hooks' import { useEcharts } from "@hooks/useEcharts" diff --git a/src/pages/app/components/Dashboard/components/Timeline/Chart/useMerge.ts b/src/pages/app/components/Dashboard/components/Timeline/Chart/useMerge.ts index 838c8bb51..5d998c0a6 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/Chart/useMerge.ts +++ b/src/pages/app/components/Dashboard/components/Timeline/Chart/useMerge.ts @@ -2,7 +2,7 @@ import { useCategory } from '@app/context' import { t } from '@app/locale' import mergeRuleDatabase from '@db/merge-rule-database' import siteDatabase from '@db/site-database' -import { TIMELINE_LIFE_CYCLE } from '@db/timeline-database' +import { TIMELINE_LIFE_CYCLE } from '@db/timeline-database/classic' import { useState } from '@hooks' import CustomizedHostMergeRuler from '@service/components/host-merge-ruler' import { toMap } from '@util/array' diff --git a/src/pages/app/components/DataManage/ClearPanel/index.tsx b/src/pages/app/components/DataManage/ClearPanel/index.tsx index 7e4cf8861..48b14b0a2 100644 --- a/src/pages/app/components/DataManage/ClearPanel/index.tsx +++ b/src/pages/app/components/DataManage/ClearPanel/index.tsx @@ -94,7 +94,7 @@ const _default = defineComponent(() => { cancelButtonText: t(msg => msg.button.cancel), confirmButtonText: t(msg => msg.button.confirm) }).then(async () => { - await db.delete(result) + await db.delete(...result) ElMessage.success(t(msg => msg.operation.successMsg)) refreshMemory?.() }).catch(() => { }) diff --git a/src/pages/app/components/DataManage/Migration/ImportButton.tsx b/src/pages/app/components/DataManage/Migration/ImportButton.tsx index 1e0f09a23..be13563c4 100644 --- a/src/pages/app/components/DataManage/Migration/ImportButton.tsx +++ b/src/pages/app/components/DataManage/Migration/ImportButton.tsx @@ -7,14 +7,12 @@ import { t } from "@app/locale" import { Upload } from "@element-plus/icons-vue" -import Immigration from "@service/components/immigration" +import immigration from '@service/components/immigration' import { deserialize } from "@util/file" import { ElButton, ElLoading, ElMessage } from "element-plus" import { defineComponent, ref } from "vue" import { useDataMemory } from "../context" -const immigration: Immigration = new Immigration() - async function handleFileSelected(fileInput: HTMLInputElement | undefined, callback: () => void) { const files = fileInput?.files if (!files?.length) { diff --git a/src/pages/app/components/DataManage/Migration/index.tsx b/src/pages/app/components/DataManage/Migration/index.tsx index f9ca7c6ad..e47f22ce7 100644 --- a/src/pages/app/components/DataManage/Migration/index.tsx +++ b/src/pages/app/components/DataManage/Migration/index.tsx @@ -8,7 +8,7 @@ import { t } from "@app/locale" import { Download } from "@element-plus/icons-vue" import Flex from "@pages/components/Flex" -import Immigration from "@service/components/immigration" +import immigration from '@service/components/immigration' import { exportJson } from "@util/file" import { formatTime } from "@util/time" import { ElButton, ElCard } from "element-plus" @@ -17,10 +17,8 @@ import DataManageAlert from '../DataManageAlert' import ImportButton from "./ImportButton" import ImportOtherButton from "./ImportOtherButton" -const immigration: Immigration = new Immigration() - async function handleExport() { - const data = await immigration.getExportingData() + const data = await immigration.exportData() const timestamp = formatTime(new Date(), '{y}{m}{d}_{h}{i}{s}') exportJson(data, `timer_backup_${timestamp}`) } diff --git a/src/pages/app/components/Option/components/OptionLines.tsx b/src/pages/app/components/Option/components/OptionLines.tsx index 96dbf4337..9f89e4500 100644 --- a/src/pages/app/components/Option/components/OptionLines.tsx +++ b/src/pages/app/components/Option/components/OptionLines.tsx @@ -50,8 +50,7 @@ const OptionLines: FunctionalComponent<{}> = (_, { slots }) => { children.push(child) beforeIsItem = thisIsItem } - - return h('div', {}, children) + return
{children}
} OptionLines.displayName = 'OptionLines' diff --git a/src/pages/app/components/Option/components/TrackingOption.tsx b/src/pages/app/components/Option/components/TrackingOption.tsx index c8b01d4c4..87bbecc84 100644 --- a/src/pages/app/components/Option/components/TrackingOption.tsx +++ b/src/pages/app/components/Option/components/TrackingOption.tsx @@ -7,8 +7,9 @@ import { hasPerm, requestPerm } from "@api/chrome/permission" import { isAllowedFileSchemeAccess, sendMsg2Runtime } from "@api/chrome/runtime" import { t } from "@app/locale" -import { useRequest } from "@hooks" +import { useManualRequest, useRequest } from "@hooks" import { locale } from "@i18n" +import immigration from '@service/components/immigration' import { rotate } from "@util/array" import { IS_ANDROID, IS_FIREFOX } from "@util/constant/environment" import { defaultTracking } from "@util/constant/option" @@ -22,6 +23,11 @@ import OptionLines from './OptionLines' import OptionTag from './OptionTag' import OptionTooltip from './OptionTooltip' +const ALL_STORAGES: Record = { + classic: 'chrome.storage.local', + indexed_db: 'IndexedDB', +} + const DEFAULT_VALUE = defaultTracking() const weekStartOptionPairs: [[timer.option.WeekStartOption, string]] = [ @@ -39,6 +45,7 @@ function copy(target: timer.option.TrackingOption, source: timer.option.Tracking target.weekStart = source.weekStart target.autoPauseTracking = source.autoPauseTracking target.autoPauseInterval = source.autoPauseInterval + target.storage = source.storage } const _default = defineComponent((_props, ctx) => { @@ -52,6 +59,11 @@ const _default = defineComponent((_props, ctx) => { } } satisfies OptionInstance) + const { refresh: changeStorageType, loading: storageMigrating } = useManualRequest(async (type: timer.option.StorageType) => { + await immigration.migrateStorage(type) + option.storage = type + }, { loadingText: 'Data migrating...' }) + const interval = computed({ get: _oldValue => { const intervalNum = option.autoPauseInterval @@ -152,6 +164,19 @@ const _default = defineComponent((_props, ctx) => { options={weekStartOptionPairs.map(([value, label]) => ({ value, label }))} /> + msg.option.tracking.storage} + defaultValue={ALL_STORAGES[DEFAULT_VALUE.storage]} + > + changeStorageType(val)} + options={Object.entries(ALL_STORAGES).map(([value, label]) => ({ value, label }))} + /> + }) diff --git a/src/pages/app/components/Report/ReportFilter/BatchDelete.tsx b/src/pages/app/components/Report/ReportFilter/BatchDelete.tsx index bff1b31a7..88caaa9b4 100644 --- a/src/pages/app/components/Report/ReportFilter/BatchDelete.tsx +++ b/src/pages/app/components/Report/ReportFilter/BatchDelete.tsx @@ -106,8 +106,8 @@ async function deleteBatch(selected: timer.stat.Row[], mergeDate: boolean, dateR // Delete according to the date range const [start, end] = dateRange ?? [] for (const row of selected) { - isNormalSite(row) && await statDatabase.deleteByUrlBetween(row.siteKey.host, start, end) - isGroup(row) && await statDatabase.deleteByGroupBetween(row.groupKey, start, end) + isNormalSite(row) && await statDatabase.deleteByHost(row.siteKey.host, [start, end]) + isGroup(row) && await statDatabase.deleteByGroup(row.groupKey, [start, end]) } } else { // If not merge date, batch delete diff --git a/src/pages/app/components/Report/ReportTable/index.tsx b/src/pages/app/components/Report/ReportTable/index.tsx index 32972008c..a6a0b69e0 100644 --- a/src/pages/app/components/Report/ReportTable/index.tsx +++ b/src/pages/app/components/Report/ReportTable/index.tsx @@ -62,7 +62,12 @@ const _default = defineComponent((_, ctx) => { const sort = useReportSort() const filter = useReportFilter() const visible = computed(() => computeVisible(filter)) - const { data, refresh, loading } = useRequest(() => queryPage(filter, sort.value, page.value), { + const { data, refresh, loading } = useRequest(async () => { + const start = Date.now() + const data = await queryPage(filter, sort.value, page.value) + console.log(`Used: ${Date.now() - start} for query`) + return data + }, { loadingTarget: () => table.value?.$el as HTMLDivElement, deps: [() => ({ ...filter }), sort, page], defaultValue: { list: [], total: 0 }, diff --git a/src/pages/app/components/Report/common.ts b/src/pages/app/components/Report/common.ts index 958f36485..fe19bb68f 100644 --- a/src/pages/app/components/Report/common.ts +++ b/src/pages/app/components/Report/common.ts @@ -55,22 +55,22 @@ export async function handleDelete(row: timer.stat.Row, filterOption: ReportFilt const { mergeDate, dateRange } = filterOption if (!mergeDate) { // Delete one day - isSite(row) && date && await statDatabase.deleteByUrlAndDate(row.siteKey.host, date) - isGroup(row) && date && await statDatabase.deleteByGroupAndDate(row.groupKey, date) + isSite(row) && date && await statDatabase.delete({ host: row.siteKey.host, date }) + isGroup(row) && date && await statDatabase.deleteGroup([row.groupKey, date]) return } const start = dateRange?.[0] const end = dateRange?.[1] if (!start && !end) { // Delete all - isSite(row) && await statDatabase.deleteByUrl(row.siteKey.host) + isSite(row) && await statDatabase.deleteByHost(row.siteKey.host) isGroup(row) && await statDatabase.deleteByGroup(row.groupKey) return } // Delete by range - isSite(row) && await statDatabase.deleteByUrlBetween(row.siteKey.host, start, end) - isGroup(row) && await statDatabase.deleteByGroupBetween(row.groupKey, start, end) + isSite(row) && await statDatabase.deleteByHost(row.siteKey.host, [start, end]) + isGroup(row) && await statDatabase.deleteByGroup(row.groupKey, [start, end]) } const cvtOrderDir = (order: ReportSort['order']): timer.common.SortDirection | undefined => { diff --git a/src/service/components/immigration.ts b/src/service/components/immigration.ts index cc74ad70e..a6769311c 100644 --- a/src/service/components/immigration.ts +++ b/src/service/components/immigration.ts @@ -5,63 +5,63 @@ * https://opensource.org/licenses/MIT */ -import BaseDatabase from "@db/common/base-database" -import StoragePromise from "@db/common/storage-promise" import limitDatabase from "@db/limit-database" import mergeRuleDatabase from "@db/merge-rule-database" -import periodDatabase from "@db/period-database" -import siteCateDatabase from "@db/site-cate-database" import statDatabase from "@db/stat-database" +import type { BrowserMigratable, StorageMigratable } from '@db/types' import whitelistDatabase from "@db/whitelist-database" import packageInfo from "@src/package" -type MetaInfo = { - version: string - ts: number -} - -export type BackupData = { - __meta__: MetaInfo -} & any - -function initDatabase(): BaseDatabase[] { - const result: BaseDatabase[] = [ - statDatabase, - periodDatabase, - limitDatabase, - mergeRuleDatabase, - whitelistDatabase, - siteCateDatabase, - ] - - return result -} - /** - * Data is citizens + * Data export/import and storage migration * * @since 0.2.5 */ class Immigration { - private storage: StoragePromise - private databaseArray: BaseDatabase[] + private browserMigratables: BrowserMigratable[] + private storageMigratables: StorageMigratable[] constructor() { - const localStorage = chrome.storage.local - this.storage = new StoragePromise(localStorage) - this.databaseArray = initDatabase() + this.browserMigratables = [ + statDatabase, + limitDatabase, + mergeRuleDatabase, + whitelistDatabase, + ] + this.storageMigratables = [ + statDatabase, + ] } - async getExportingData(): Promise { - const data = await this.storage.get() as BackupData - const meta: MetaInfo = { version: packageInfo.version, ts: Date.now() } - data.__meta__ = meta + async exportData(): Promise { + const data: timer.backup.ExportData = { + __meta__: { version: packageInfo.version, ts: Date.now() }, + } + for (const migratable of this.browserMigratables) { + const namespace = migratable.namespace + const exportData = await migratable.exportData() + data[namespace] = exportData + } return data } - async importData(data: any): Promise { - for (const db of this.databaseArray) await db.importData(data) + async importData(data: unknown): Promise { + for (const db of this.browserMigratables) await db.importData(data) + } + + async migrateStorage(type: timer.option.StorageType): Promise { + const dataList: unknown[] = [] + // 1. migrate all the databases firstly + for (const migratable of this.storageMigratables) { + const data = await migratable.migrateStorage(type) + dataList.push(data) + } + // 2. after migration + for (const migratable of this.storageMigratables) { + const [data] = dataList.splice(0, 1) + await migratable.afterStorageMigrated(data) + } } } -export default Immigration \ No newline at end of file +export default new Immigration() \ No newline at end of file diff --git a/src/service/item-service.ts b/src/service/item-service.ts index 80ba7bf7f..cd4da98f0 100644 --- a/src/service/item-service.ts +++ b/src/service/item-service.ts @@ -19,7 +19,7 @@ async function addFocusTime(context: ItemIncContext, focusTime: number): Promise const now = new Date() - await db.accumulateBatch(resultSet, now) + await db.batchAccumulate(resultSet, now) const { countTabGroup } = await optionHolder.get() countTabGroup && isValidGroup(groupId) && db.accumulateGroup(groupId, now, resultOf(focusTime, 0)) @@ -38,7 +38,7 @@ async function increaseVisit(context: ItemIncContext) { const now = new Date() - await db.accumulateBatch(resultSet, now) + await db.batchAccumulate(resultSet, now) const { countTabGroup } = await optionHolder.get() countTabGroup && isValidGroup(groupId) && await db.accumulateGroup(groupId, now, resultOf(0, 1)) @@ -48,8 +48,8 @@ const getResult = (host: string, date: Date | string) => db.get(host, date) const selectItems = (cond: StatCondition) => db.select(cond) -async function batchDeleteGroupById(groupId: number): Promise { - await db.batchDeleteGroup(groupId) +async function deleteByGroup(groupId: number): Promise { + await db.deleteByGroup(groupId) } export default { @@ -58,5 +58,5 @@ export default { increaseVisit, getResult, selectItems, - batchDeleteGroupById, + deleteByGroup, } \ No newline at end of file diff --git a/src/service/stat-service/index.ts b/src/service/stat-service/index.ts index 239b0f468..e26971b0b 100644 --- a/src/service/stat-service/index.ts +++ b/src/service/stat-service/index.ts @@ -294,8 +294,8 @@ export async function batchDelete(targets: timer.stat.Row[]) { isNormalSite(row) && siteKeys.push({ host: row.siteKey.host, date }) isGroup(row) && groupKeys.push([row.groupKey, date]) }) - await statDatabase.delete(siteKeys) - await statDatabase.deleteGroup(groupKeys) + await statDatabase.delete(...siteKeys) + await statDatabase.deleteGroup(...groupKeys) } export async function selectGroupByPage(param?: GroupQuery, page?: timer.common.PageQuery): Promise> { diff --git a/src/util/constant/option.ts b/src/util/constant/option.ts index dc51fe667..718e38171 100644 --- a/src/util/constant/option.ts +++ b/src/util/constant/option.ts @@ -35,6 +35,7 @@ export function defaultTracking(): TrackingRequired { countLocalFiles: false, countTabGroup: false, weekStart: 'default', + storage: 'classic', } } diff --git a/src/util/guard.ts b/src/util/guard.ts new file mode 100644 index 000000000..d1d57cbb2 --- /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) \ No newline at end of file diff --git a/src/util/time.ts b/src/util/time.ts index 2782444c5..633e7fc49 100644 --- a/src/util/time.ts +++ b/src/util/time.ts @@ -18,22 +18,12 @@ import { isRtl } from "./document" * * Parse the time to string */ -export function formatTime(time: Date | string | number, cFormat?: string) { +export function formatTime(time: Date | number, cFormat?: string) { const format = cFormat || '{y}-{m}-{d} {h}:{i}:{s}' let date: Date if (time instanceof Date) { date = time } else { - if ((typeof time === 'string')) { - if ((/^[0-9]+$/.test(time))) { - // support "1548221490638" - time = parseInt(time) - } else { - // support safari - time = time.replace(new RegExp(/-/gm), '/') - } - } - if ((typeof time === 'number') && (time.toString().length === 10)) { time = time * 1000 } @@ -57,7 +47,7 @@ export function formatTime(time: Date | string | number, cFormat?: string) { return timeStr } -export function formatTimeYMD(time: Date | string | number) { +export function formatTimeYMD(time: Date | number) { return formatTime(time, '{y}{m}{d}') } 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..ce2e3692a --- /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 +} \ No newline at end of file diff --git a/test/database/period-database.test.ts b/test/database/period-database.test.ts index 8fcffd308..c0b206f0c 100644 --- a/test/database/period-database.test.ts +++ b/test/database/period-database.test.ts @@ -1,5 +1,5 @@ import db from "@db/period-database" -import { keyOf, MILL_PER_PERIOD } from "@util/period" +import { keyOf } from "@util/period" import { formatTimeYMD } from "@util/time" import { mockStorage } from "../__mock__/storage" @@ -50,62 +50,4 @@ describe('period-database', () => { let all = await db.getAll() expect(all).toEqual(toAdd) }) - - test("importData", async () => { - const date = new Date(2021, 5, 7) - const yesterday = new Date(2021, 5, 6) - const toAdd: timer.period.Result[] = [ - resultOf(date, 0, 56999), - resultOf(date, 1, 2), - resultOf(yesterday, 95, 2) - ] - await db.accumulate(toAdd) - - const data2Import = await db.storage.get() - chrome.storage.local.clear() - expect(await db.getAll()).toEqual([]) - data2Import.foo = "bar" - db.importData(data2Import) - - const imported = await db.getAll() - expect(imported.length).toEqual(3) - }) - - // Invalid data - test("importData2", async () => { - await db.importData(undefined) - expect(await db.getAll()).toEqual([]) - await db.importData({ foo: "bar" }) - expect(await db.getAll()).toEqual([]) - await db.importData([]) - expect(await db.getAll()).toEqual([]) - await db.importData(1) - expect(await db.getAll()).toEqual([]) - await db.importData({ - __timer__PERIOD20210607: { - "-1": 100, - foo: "bar", - 96: 1000, - 85: "???", - 3: undefined, - 4: "", - } - }) - expect(await db.getAll()).toEqual([]) - }) - - test("importData3", async () => { - await db.importData({ - __timer__PERIOD20210607: { - 0: MILL_PER_PERIOD + 1, - 1: 100, - 2: "100", - } - }) - const imported: timer.period.Result[] = await db.getAll() - expect(imported.length).toEqual(3) - const orderMillMap: Record = {} - imported.forEach(({ milliseconds, order }) => orderMillMap[order] = milliseconds) - expect(orderMillMap).toEqual({ 0: MILL_PER_PERIOD, 1: 100, 2: 100 }) - }) }) diff --git a/test/database/stat-database.test.ts b/test/database/stat-database/classic.test.ts similarity index 82% rename from test/database/stat-database.test.ts rename to test/database/stat-database/classic.test.ts index 20006923c..895e8cfcb 100644 --- a/test/database/stat-database.test.ts +++ b/test/database/stat-database/classic.test.ts @@ -1,8 +1,10 @@ -import db, { type StatCondition } from "@db/stat-database" +import type { StatCondition } from '@db/stat-database' +import { ClassicStatDatabase, parseImportData } from "@db/stat-database/classic" import { resultOf } from "@util/stat" import { formatTimeYMD, MILL_PER_DAY } from "@util/time" -import { mockStorage } from "../__mock__/storage" +import { mockStorage } from '../../__mock__/storage' +let db: ClassicStatDatabase const now = new Date() const nowStr = formatTimeYMD(now) const yesterday = new Date(now.getTime() - MILL_PER_DAY) @@ -11,7 +13,10 @@ const baidu = 'www.baidu.com' const google = 'www.google.com.hk' describe('stat-database', () => { - beforeAll(mockStorage) + beforeAll(() => { + mockStorage() + db = new ClassicStatDatabase(chrome.storage.local) + }) beforeEach(async () => chrome.storage.local.clear()) @@ -32,7 +37,7 @@ describe('stat-database', () => { }) test('3', async () => { - await db.accumulateBatch( + await db.batchAccumulate( { [google]: resultOf(11, 0), [baidu]: resultOf(1, 0) @@ -40,7 +45,7 @@ describe('stat-database', () => { ) expect((await db.select()).length).toEqual(2) - await db.accumulateBatch( + await db.batchAccumulate( { [google]: resultOf(12, 1), [baidu]: resultOf(2, 1) @@ -48,7 +53,7 @@ describe('stat-database', () => { ) expect((await db.select()).length).toEqual(4) - await db.accumulateBatch( + await db.batchAccumulate( { [google]: resultOf(13, 2), [baidu]: resultOf(3, 2) @@ -96,17 +101,17 @@ describe('stat-database', () => { await db.accumulate(baidu, formatTimeYMD(yesterday), resultOf(12, 0)) expect((await db.select()).length).toEqual(2) // Delete yesterday's data - await db.deleteByUrlAndDate(baidu, yesterday) + await db.delete({ host: baidu, date: formatTimeYMD(yesterday) }) expect((await db.select()).length).toEqual(1) // Delete yesterday's data again, nothing changed - await db.deleteByUrlAndDate(baidu, yesterday) + await db.delete({ host: baidu, date: formatTimeYMD(yesterday) }) expect((await db.get(baidu, now)).focus).toEqual(10) // Add one again, and another await db.accumulate(baidu, formatTimeYMD(beforeYesterday), resultOf(1, 1)) await db.accumulate(google, nowStr, resultOf(0, 0)) expect((await db.select()).length).toEqual(3) // Delete all the baidu - await db.deleteByUrl(baidu) + await db.deleteByHost(baidu) const cond: StatCondition = { keys: baidu } // Nothing of baidu remained expect((await db.select(cond)).length).toEqual(0) @@ -117,13 +122,13 @@ describe('stat-database', () => { // Add one item of baidu again again await db.accumulate(baidu, nowStr, resultOf(1, 1)) // But delete google - await db.delete(list) + await db.delete(...list) // Then only one item of baidu expect((await db.select()).length).toEqual(1) }) test('6', async () => { - await db.accumulateBatch({}, now) + await db.batchAccumulate({}, now) expect((await db.select()).length).toEqual(0) // Return zero instance const result = await db.get(baidu, now) @@ -135,22 +140,21 @@ describe('stat-database', () => { await db.accumulate(baidu, nowStr, foo) await db.accumulate(baidu, formatTimeYMD(yesterday), foo) await db.accumulate(baidu, formatTimeYMD(beforeYesterday), foo) - await db.deleteByUrlBetween(baidu, now, now) + await db.delete({ host: baidu, date: formatTimeYMD(now) }) expect((await db.select()).length).toEqual(2) - await db.deleteByUrlBetween(baidu, now, beforeYesterday) // Invalid + await db.deleteByHost(baidu, [now, beforeYesterday]) // Invalid expect((await db.select()).length).toEqual(2) }) - test("importData", async () => { + test("parseImportData", async () => { const foo = resultOf(1, 1) await db.accumulate(baidu, nowStr, foo) const data2Import = await db.storage.get() chrome.storage.local.clear() data2Import.foo = "bar" - await db.importData(data2Import) - const data = await db.select({}) + const data = parseImportData(data2Import) expect(data.length).toEqual(1) const item = data[0] expect(item.date).toEqual(nowStr) @@ -159,8 +163,8 @@ describe('stat-database', () => { expect(item.time).toEqual(1) }) - test("importData2", async () => { - await db.importData({ + test("parseImportData2", async () => { + const data = parseImportData({ // Valid "20210910github.com": { focus: 1, @@ -177,16 +181,6 @@ describe('stat-database', () => { // Ignored with zero info "20210914github.com": {} }) - const imported = await db.select() - expect(imported.length).toEqual(2) - }) - - test("importData3", async () => { - await db.importData([]) - expect(await db.select()).toEqual([]) - await db.importData({ foo: "bar" }) - expect(await db.select()).toEqual([]) - await db.importData(false) - expect(await db.select()).toEqual([]) + expect(data.length).toEqual(2) }) }) \ No newline at end of file diff --git a/test/util/time.test.ts b/test/util/time.test.ts index 40785c103..3d80f4de3 100644 --- a/test/util/time.test.ts +++ b/test/util/time.test.ts @@ -14,18 +14,10 @@ test('time', () => { const result = '20200501 000001δΊ”' // default format - expect(formatTime(dateStr)).toEqual('2020-05-01 00:00:01') - - expect(formatTime(dateStr, format)).toEqual(result) - expect(formatTime(date, format)).toEqual(result) // use seconds expect(formatTime(Math.floor(date / 1000), format)).toEqual(result) - // use string - expect(formatTime(date.toString(), format)).toEqual(result) - expect(formatTime(Math.floor(date / 1000).toString(), format)).toEqual(result) - expect(formatTime(new Date(date), format)).toEqual(result) }) diff --git a/tsconfig.json b/tsconfig.json index 0dfc5b408..a7718c02a 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,6 +16,10 @@ "esModuleInterop": true, "sourceMap": true, "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictPropertyInitialization": true, "resolveJsonModule": true, "importHelpers": true, "skipLibCheck": true, @@ -66,7 +70,11 @@ "*": [ "./types/*" ] - } + }, + "types": [ + "jest", + "chrome" + ] }, "exclude": [ "node_modules", 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 diff --git a/types/timer/option.d.ts b/types/timer/option.d.ts index 089f4f639..9b4cf0d71 100644 --- a/types/timer/option.d.ts +++ b/types/timer/option.d.ts @@ -100,6 +100,12 @@ declare namespace timer.option { * @since 2.4.1 */ weekStart?: WeekStartOption + /** + * Where to store the tracking data + * + * @since 4.0.0 + */ + storage: StorageType } type LimitOption = { @@ -188,4 +194,9 @@ declare namespace timer.option { * @since 0.8.0 */ type LocaleOption = Locale | "default" + + /** + * @since 4.0.0 + */ + type StorageType = 'classic' | 'indexed_db' }