diff --git a/global.d.ts b/global.d.ts index 97c20e21b..6d510ba59 100644 --- a/global.d.ts +++ b/global.d.ts @@ -100,7 +100,27 @@ declare namespace timer { countLocalFiles: boolean } - type AllOption = PopupOption & AppearanceOption & StatisticsOption + /** + * The options of backup + * + * @since 1.2.0 + */ + type BackupOption = { + /** + * The type 2 backup + */ + backupType: backup.Type + /** + * The auth of types, maybe ak/sk or static token + */ + backupAuths: { [type in backup.Type]?: string } + /** + * The name of this client + */ + clientName: string + } + + type AllOption = PopupOption & AppearanceOption & StatisticsOption & BackupOption /** * @since 0.8.0 */ @@ -114,6 +134,12 @@ declare namespace timer { popupCounter?: { _total?: number } + /** + * The id of this client + * + * @since 1.2.0 + */ + cid?: string } } @@ -162,10 +188,12 @@ declare namespace timer { date?: string } + type RowBase = RowKey & Result + /** * Row of each statistics result */ - type Row = RowKey & Result & { + type Row = RowBase & { /** * The merged domains * @@ -185,6 +213,15 @@ declare namespace timer { */ alias?: string } + /** + * @since 1.2.0 + */ + type RemoteRow = RowBase & { + /** + * The name of client where the remote data is storaged + */ + clientName?: string + } } namespace limit { @@ -241,6 +278,14 @@ declare namespace timer { num: number total: number } + type PageQuery = { + num?: number + size?: number + } + type PageResult = { + list: T[] + total: number + } } namespace popup { @@ -323,6 +368,38 @@ declare namespace timer { timeFormat: TimeFormat } } + } + + /** + * @since 1.2.0 + */ + namespace backup { + + type Type = + | 'none' + | 'gist' + + /** + * Snapshot of last backup + */ + type Snapshot = { + /** + * Timestamp + */ + ts: number + /** + * The date of the ts + */ + date: string + } + + /** + * Snapshot cache + */ + type SnaptshotCache = Partial<{ + [type in Type]: Snapshot + }> + type MetaCache = Partial> } } \ No newline at end of file diff --git a/src/api/gist.ts b/src/api/gist.ts new file mode 100644 index 000000000..decc6de48 --- /dev/null +++ b/src/api/gist.ts @@ -0,0 +1,195 @@ +/** + * Copyright (c) 2022-present Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { AxiosAdapter, AxiosError, AxiosResponse } from "axios" + +import FIFOCache from "@util/fifo-cache" + +import axios from "axios" + +type BaseFile = { + filename: string +} + +export type FileForm = BaseFile & { + content: string +} + +export type File = BaseFile & { + type: string + language: string + raw_url: string + content?: string +} + +type BaseGist = { + public: boolean + description: string + files: { [filename: string]: FileInfo } +} + +export type Gist = BaseGist & { + id: string +} + +export type GistForm = BaseGist + +const BASE_URL = 'https://api.github.com/gists' + +/** + * Cache of get requests + */ +const GET_CACHE = new FIFOCache(20) + +function createCacheAdaptor(originAdaptor: AxiosAdapter): AxiosAdapter { + return (config) => { + const { url, method } = config; + let useCache = method === 'get' && url.startsWith(BASE_URL) + if (useCache) { + // Use url and token as key + const key = url + config?.headers?.['Authorization'] + return GET_CACHE.getOrSupply(key, () => originAdaptor(config)) + } else { + return originAdaptor(config) + } + } +} + +async function get(token: string, uri: string): Promise { + return new Promise(resolve => axios.get(BASE_URL + uri, { + headers: { + "Accept": "application/vnd.github+json", + "Authorization": `token ${token}` + }, + // Use cache + adapter: createCacheAdaptor(axios.defaults.adapter) + }).then(response => { + if (response.status >= 200 && response.status < 300) { + return resolve(response.data as T) + } else { + return resolve(null) + } + }).catch((error: AxiosError) => { + console.log("AxisError", error) + resolve(null) + })) +} + +async function post(token: string, uri: string, body?: R): Promise { + return new Promise(resolve => axios.post(BASE_URL + uri, body, + { + headers: { + "Accept": "application/vnd.github+json", + "Authorization": `token ${token}` + } + } + ).then(response => { + // Clear cache if success to request + GET_CACHE.clear() + if (response.status >= 200 && response.status < 300) { + return resolve(response.data as T) + } else { + return resolve(null) + } + }).catch((error: AxiosError) => { + console.log("AxisError", error) + resolve(null) + })) +} + +/** + * @param token token + * @param id id + * @returns detail of Gist + */ +export function getGist(token: string, id: string): Promise { + return get(token, `/${id}`) +} + +/** + * Find the first target gist with predicate + * + * @param token gist token + * @param predicate predicate + * @returns + */ +export async function findTarget(token: string, predicate: (gist: Gist) => boolean): Promise { + let pageNum = 1 + while (true) { + const uri = `?per_page=100&page=${pageNum}` + const gists: Gist[] = await get(token, uri) + if (!gists?.length) { + break + } + const satisfied = gists.find(predicate) + if (satisfied) { + return satisfied + } + pageNum += 1 + } + return undefined +} + +/** + * Create one gist + * + * @param token token + * @param gist gist info + * @returns gist info with id + */ +export function createGist(token: string, gist: GistForm): Promise { + return post(token, "", gist) +} + +/** + * Update gist + * + * @param token token + * @param gist gist + * @returns + */ +export async function updateGist(token: string, id: string, gist: GistForm): Promise { + await post(token, `/${id}`, gist) +} + +/** + * Get content of file + */ +export async function getJsonFileContent(file: File): Promise { + const content = file.content + if (content) { + return JSON.parse(content) + } + const rawUrl = file.raw_url + if (!rawUrl) { + return undefined + } + const response = await axios.get(rawUrl) + return response.data +} + +/** + * Test token to process gist + * + * @returns errorMsg or null/undefined + */ +export async function testToken(token: string): Promise { + return new Promise(resolve => { + axios.get(BASE_URL + '?per_page=1&page=1', { + headers: { + "Accept": "application/vnd.github+json", + "Authorization": `token ${token}` + } + }).then(response => { + if (response.status >= 200 && response.status < 300) { + resolve(undefined) + } else { + resolve(response.data?.message || 'Unknown error') + } + }).catch((error: AxiosError) => resolve((error.response?.data as any)?.message || 'Unknown error')) + }) +} diff --git a/src/api/index.d.ts b/src/api/index.d.ts deleted file mode 100644 index c17f79a4e..000000000 --- a/src/api/index.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -/** - * @since 0.1.8 - */ -declare interface FirefoxDetail { - current_version: { - // Like 0.1.5 - version: string - } - // Like '2021-06-11T08:45:32Z' - last_updated: string -} - -/** - * @since 0.1.8 - */ -declare interface EdgeDetail { - // Version like 0.1.5, without 'v' prefix - version: string - // Like '1619432502.5944779' - lastUpdateDate: string -} \ No newline at end of file diff --git a/src/api/version.ts b/src/api/version.ts index 7c53da31d..30ddf8c05 100644 --- a/src/api/version.ts +++ b/src/api/version.ts @@ -5,9 +5,33 @@ * https://opensource.org/licenses/MIT */ -import axios, { AxiosResponse } from "axios" +import type { AxiosResponse } from 'axios' + +import axios from "axios" import { IS_CHROME, IS_EDGE, IS_FIREFOX } from "@util/constant/environment" +/** + * @since 0.1.8 + */ +type FirefoxDetail = { + current_version: { + // Like 0.1.5 + version: string + } + // Like '2021-06-11T08:45:32Z' + last_updated: string +} + +/** + * @since 0.1.8 + */ +type EdgeDetail = { + // Version like 0.1.5, without 'v' prefix + version: string + // Like '1619432502.5944779' + lastUpdateDate: string +} + async function getFirefoxVersion(): Promise { const response: AxiosResponse = await axios.get('https://addons.mozilla.org/api/v3/addons/addon/2690100') if (response.status !== 200) { diff --git a/src/app/components/dashboard/components/top-k-visit.ts b/src/app/components/dashboard/components/top-k-visit.ts index 96daef9d8..e4604bdd1 100644 --- a/src/app/components/dashboard/components/top-k-visit.ts +++ b/src/app/components/dashboard/components/top-k-visit.ts @@ -116,7 +116,7 @@ const _default = defineComponent({ sortOrder: SortDirect.DESC, mergeDate: true, } - const top: timer.stat.Row[] = (await timerService.selectByPage(query, { pageNum: 1, pageSize: TOP_NUM }, { alias: true })).list + const top: timer.stat.Row[] = (await timerService.selectByPage(query, { num: 1, size: TOP_NUM }, { alias: true })).list const data: _Value[] = top.map(({ time, host, alias }) => ({ name: alias || host, host, alias, value: time })) for (let realSize = top.length; realSize < TOP_NUM; realSize++) { data.push({ name: '', host: '', value: 0 }) diff --git a/src/app/components/option/common.ts b/src/app/components/option/common.ts index 57c6faeef..f4fbc3344 100644 --- a/src/app/components/option/common.ts +++ b/src/app/components/option/common.ts @@ -18,12 +18,16 @@ import { OptionMessage } from "@app/locale/components/option" * @param label label * @param defaultValue default value */ -export function renderOptionItem(input: VNode | { [key: string]: VNode }, label: (msg: OptionMessage) => string, defaultValue: string | number) { +export function renderOptionItem(input: VNode | { [key: string]: VNode }, label: (msg: OptionMessage) => string, defaultValue?: string | number) { const param = isVNode(input) ? { input } : input const labelArcher = h('a', { class: 'option-label' }, tN(msg => label(msg.option), param)) - const defaultTag = h(ElTag, { size: 'small' }, () => defaultValue) - const defaultArcher = h('a', { class: 'option-default' }, tN(msg => msg.option.defaultValue, { default: defaultTag })) - return h('div', { class: 'option-line' }, [labelArcher, defaultArcher]) + const content = [labelArcher] + if (!!defaultValue) { + const defaultTag = h(ElTag, { size: 'small' }, () => defaultValue) + const defaultArcher = h('a', { class: 'option-default' }, tN(msg => msg.option.defaultValue, { default: defaultTag })) + content.push(defaultArcher) + } + return h('div', { class: 'option-line' }, content) } /** diff --git a/src/app/components/option/components/backup.ts b/src/app/components/option/components/backup.ts new file mode 100644 index 000000000..c0826a68a --- /dev/null +++ b/src/app/components/option/components/backup.ts @@ -0,0 +1,171 @@ +/** + * Copyright (c) 2022-present Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { Ref } from "vue" + +import { t } from "@app/locale" +import optionService from "@service/option-service" +import processor from "@src/background/backup/processor" +import { defaultBackup } from "@util/constant/option" +import { ElInput, ElOption, ElSelect, ElDivider, ElAlert, ElButton, ElMessage, ElLoading } from "element-plus" +import { defineComponent, ref, h } from "vue" +import { renderOptionItem, tooltip } from "../common" +import { UploadFilled } from "@element-plus/icons-vue" + +const ALL_TYPES: timer.backup.Type[] = [ + 'none', + 'gist', +] + +const typeOptions = () => ALL_TYPES.map(type => h(ElOption, { + value: type, + label: t(msg => msg.option.backup.meta[type].label) +})) + +const typeSelect = (type: Ref, handleChange?: Function) => h(ElSelect, + { + modelValue: type.value, + size: 'small', + style: { width: '120px' }, + async onChange(newType: timer.backup.Type) { + type.value = newType + handleChange?.() + } + }, + () => typeOptions() +) + +const clientNameInput = (clientName: Ref, handleInput?: Function) => h(ElInput, { + modelValue: clientName.value, + size: 'small', + style: { width: '120px' }, + placeholder: DEFAULT.clientName, + onInput: newVal => { + clientName.value = newVal?.trim?.() || '' + handleInput?.() + } +}) + +const authInput = (auth: Ref, handleInput: Function, handleTest: Function) => h(ElInput, { + modelValue: auth.value, + size: 'small', + type: 'password', + showPassword: true, + style: { width: '400px' }, + onInput: newVal => { + auth.value = newVal?.trim?.() || '' + handleInput() + } +}, { + append: () => h(ElButton, { + onClick: () => handleTest() + }, () => t(msg => msg.option.backup.test)) +}) + +const DEFAULT = defaultBackup() + +const _default = defineComponent({ + name: "BackupOptionContainer", + setup(_props, ctx) { + const type: Ref = ref(DEFAULT.backupType) + const auth: Ref = ref('') + const clientName: Ref = ref(DEFAULT.clientName) + + optionService.getAllOption().then(currentVal => { + clientName.value = currentVal.clientName + type.value = currentVal.backupType + if (type.value) { + auth.value = currentVal.backupAuths?.[type.value] + } + }) + + function handleChange() { + const backupAuths = {} + backupAuths[type.value] = auth.value + const newOption: timer.option.BackupOption = { + backupType: type.value, + backupAuths, + clientName: clientName.value || DEFAULT.clientName + } + optionService.setBackupOption(newOption) + } + + async function handleTest() { + const loading = ElLoading.service({ + text: "Please wait...." + }) + const errorMsg = await processor.test(type.value, auth.value) + loading.close() + if (!errorMsg) { + ElMessage.success("Valid!") + } else { + ElMessage.error(errorMsg) + } + } + + async function handleBackup() { + const loading = ElLoading.service({ + text: "Doing backup...." + }) + const result = await processor.syncData() + loading.close() + if (result.success) { + ElMessage.success('Successfully!') + } else { + ElMessage.error(result.errorMsg || 'Unknown error') + } + } + + ctx.expose({ + reset() { + // Only reset type + type.value = DEFAULT.backupType + } + }) + + return () => { + const nodes = [ + h(ElAlert, { + closable: false, + type: "warning", + description: t(msg => msg.option.backup.alert) + }), + h(ElDivider), + renderOptionItem({ + input: typeSelect(type, handleChange) + }, + msg => msg.backup.type, + t(msg => msg.option.backup.meta[DEFAULT.backupType].label) + ) + ] + type.value !== 'none' && nodes.push( + h(ElDivider), + renderOptionItem({ + input: authInput(auth, handleChange, handleTest), + info: tooltip(msg => msg.option.backup.meta[type.value].authInfo) + }, + msg => msg.backup.meta[type.value].auth + ), + h(ElDivider), + renderOptionItem({ + input: clientNameInput(clientName, handleChange) + }, + msg => msg.backup.client + ), + h(ElDivider), + h(ElButton, { + type: 'primary', + icon: UploadFilled, + onClick: handleBackup + }, () => t(msg => msg.option.backup.operation)) + ) + return h('div', nodes) + } + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/option/components/popup.ts b/src/app/components/option/components/popup.ts index 20db72279..d3a4cfa5f 100644 --- a/src/app/components/option/components/popup.ts +++ b/src/app/components/option/components/popup.ts @@ -5,9 +5,11 @@ * https://opensource.org/licenses/MIT */ +import type { Ref } from "vue" + import { ElDivider, ElInputNumber, ElOption, ElSelect, ElSwitch } from "element-plus" import { t } from "@app/locale" -import { defineComponent, h, Ref, ref } from "vue" +import { defineComponent, h, ref } from "vue" import optionService from "@service/option-service" import { renderOptionItem, tagText } from "../common" import { defaultPopup } from "@util/constant/option" diff --git a/src/app/components/option/components/statistics.ts b/src/app/components/option/components/statistics.ts index b5ae3c36b..af3467d5a 100644 --- a/src/app/components/option/components/statistics.ts +++ b/src/app/components/option/components/statistics.ts @@ -5,10 +5,12 @@ * https://opensource.org/licenses/MIT */ +import type { Ref } from "vue" + import { ElDivider, ElSwitch } from "element-plus" import optionService from "@service/option-service" import { defaultStatistics } from "@util/constant/option" -import { defineComponent, h, Ref, ref } from "vue" +import { defineComponent, h, ref } from "vue" import { t } from "@app/locale" import { renderOptionItem, tagText, tooltip } from "../common" diff --git a/src/app/components/option/index.ts b/src/app/components/option/index.ts index 3338cbf8c..a1c279fbf 100644 --- a/src/app/components/option/index.ts +++ b/src/app/components/option/index.ts @@ -5,19 +5,23 @@ * https://opensource.org/licenses/MIT */ +import type { Ref } from "vue" + import ContentContainer from "../common/content-container" -import { defineComponent, h, Ref, ref } from "vue" +import { defineComponent, h, ref } from "vue" import Popup from "./components/popup" import Appearance from "./components/appearance" import Statistics from "./components/statistics" +import Backup from './components/backup' import './style' import { ElIcon, ElMessage, ElTabPane, ElTabs } from "element-plus" import { t } from "@app/locale" import { Refresh } from "@element-plus/icons-vue" import { useRoute, useRouter } from "vue-router" + const resetButtonName = "reset" const initialParamName = "i" -const allCategories = ["appearance", "statistics", "popup"] as const +const allCategories = ["appearance", "statistics", "popup", 'backup'] as const type _Category = typeof allCategories[number] function initWithQuery(tab: Ref<_Category>) { @@ -40,7 +44,8 @@ const _default = defineComponent({ const paneRefMap: { [key in _Category]: Ref } = { appearance: ref(), statistics: ref(), - popup: ref() + popup: ref(), + backup: ref(), } const router = useRouter() return () => h(ContentContainer, () => h(ElTabs, { @@ -87,6 +92,13 @@ const _default = defineComponent({ }, () => h(Popup, { ref: paneRefMap.popup })), + // backup + h(ElTabPane, { + label: t(msg => msg.option.backup.title), + name: "backup" as _Category + }, () => h(Backup, { + ref: paneRefMap.backup + })), // Refresh button h(ElTabPane, { label: t(msg => msg.option.resetButton), name: resetButtonName }, { label: () => [h(ElIcon, () => h(Refresh)), t(msg => msg.option.resetButton)] diff --git a/src/app/components/option/style/index.sass b/src/app/components/option/style/index.sass index 73353b42c..782c49463 100644 --- a/src/app/components/option/style/index.sass +++ b/src/app/components/option/style/index.sass @@ -23,9 +23,6 @@ margin: 22px 10px 10px 10px font-size: 14px .option-line - display: flex - justify-content: space-between - align-items: center height: 30px line-height: 30px .el-input--small @@ -33,13 +30,21 @@ .el-input__wrapper height: 26px .option-label + color: var(--el-text-color-primary) + float: left display: inline-flex align-items: center color: var(--el-text-color-primary) i margin: 0 2px - .el-input-number,.el-select,.el-date-editor--time + .el-input--small + height: 28px + .el-input__wrapper + height: 26px + .el-select,.el-date-editor--time margin: 0 3px + >.el-input-number--small,>.el-input--small + margin: 0 5px .el-select display: inline-flex margin-left: 10px @@ -54,9 +59,8 @@ color: #F56C6C margin: 0 3px .option-default - display: inline-flex - align-items: center color: var(--el-text-color-primary) + float: right .el-tag height: 20px .option-container>span diff --git a/src/app/components/report/filter/index.ts b/src/app/components/report/filter/index.ts index f57ce6bd3..5575bde3f 100644 --- a/src/app/components/report/filter/index.ts +++ b/src/app/components/report/filter/index.ts @@ -10,6 +10,7 @@ import type { ElementDatePickerShortcut } from "@app/element-ui/date" import type { ReportMessage } from "@app/locale/components/report" import DownloadFile from "./download-file" +import RemoteClient from "./remote-client" import { h, defineComponent, ref, } from "vue" import { t } from "@app/locale" import InputFilterItem from '@app/components/common/input-filter-item' @@ -19,6 +20,7 @@ import DateRangeFilterItem from "@app/components/common/date-range-filter-item" import { daysAgo } from "@util/time" import { ElButton } from "element-plus" import { DeleteFilled } from "@element-plus/icons-vue" +import timerService from "@service/timer-service" const hostPlaceholder = t(msg => msg.report.hostPlaceholder) const mergeDateLabel = t(msg => msg.report.mergeDate) @@ -57,7 +59,7 @@ const _default = defineComponent({ mergeHost: Boolean, timeFormat: String as PropType }, - emits: ["change", "download", "batchDelete"], + emits: ["change", "download", "batchDelete", 'remoteChange'], setup(props, ctx) { const host: Ref = ref(props.host) // Don't know why the error occurred, so ignore @@ -66,6 +68,9 @@ const _default = defineComponent({ const mergeDate: Ref = ref(props.mergeDate) const mergeHost: Ref = ref(props.mergeHost) const timeFormat: Ref = ref(props.timeFormat) + const remoteSwitchVisible: Ref = ref(false) + // Whether to read remote backup data + const readRemote: Ref = ref(false) const computeOption = () => ({ host: host.value, dateRange: dateRange.value, @@ -74,6 +79,7 @@ const _default = defineComponent({ timeFormat: timeFormat.value } as timer.app.report.FilterOption) const handleChange = () => ctx.emit("change", computeOption()) + timerService.canReadRemote().then(abled => remoteSwitchVisible.value = abled) return () => [ h(InputFilterItem, { placeholder: hostPlaceholder, @@ -120,6 +126,7 @@ const _default = defineComponent({ // Float right h("div", { class: "filter-item-right-group" }, [ h(ElButton, { + style: readRemote.value ? { display: 'none' } : { display: 'inline-flex' }, class: "batch-delete-button", disabled: mergeHost.value, type: "primary", @@ -127,6 +134,13 @@ const _default = defineComponent({ icon: DeleteFilled, onClick: () => ctx.emit("batchDelete", computeOption()) }, () => batchDeleteButtonText), + h(RemoteClient, { + visible: remoteSwitchVisible.value, + onChange: newVal => { + readRemote.value = newVal + ctx.emit('remoteChange', readRemote.value) + } + }), h(DownloadFile, { onDownload: (format: FileFormat) => ctx.emit("download", format) }) diff --git a/src/app/components/report/filter/remote-client.ts b/src/app/components/report/filter/remote-client.ts new file mode 100644 index 000000000..fccd12f9d --- /dev/null +++ b/src/app/components/report/filter/remote-client.ts @@ -0,0 +1,47 @@ +/** + * Copyright (c) 2022-present Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { Ref } from "vue" + +import { t } from "@app/locale" +import { UploadFilled } from "@element-plus/icons-vue" +import { ElButton, ElIcon, ElTooltip } from "element-plus" +import { defineComponent, ref, h, watch } from "vue" + +const _default = defineComponent({ + name: "ClientSelect", + emits: ["change"], + props: { + visible: Boolean + }, + setup(props, ctx) { + const button = ref() + const style: Ref> = ref({ display: 'none' }) + watch(() => props.visible, visibe => style.value = { display: visibe ? 'inline-flex' : 'none' }) + const readRemote = ref(false) + return () => h(ElTooltip, { + trigger: 'hover', + placement: 'bottom-start', + effect: 'dark', + content: t(msg => msg.report.remoteReading[readRemote.value ? 'on' : 'off']) + }, () => h(ElButton, { + size: 'small', + ref: button.value, + style: style.value, + type: readRemote.value ? 'primary' : '', + class: 'export-dropdown-button', + onClick() { + readRemote.value = !readRemote.value + ctx.emit('change', readRemote.value) + } + }, + () => h(ElIcon, { size: 17, style: { padding: '0 1px' } }, () => h(UploadFilled)) + )) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/report/index.ts b/src/app/components/report/index.ts index 5d98364c2..dba39e8a8 100644 --- a/src/app/components/report/index.ts +++ b/src/app/components/report/index.ts @@ -33,11 +33,17 @@ const timerDatabase = new TimerDatabase(chrome.storage.local) async function queryData( queryParam: Ref, data: Ref, - page: UnwrapRef + page: UnwrapRef, + readRemote: Ref ) { const loading = ElLoadingService({ target: `.container-card>.el-card__body`, text: "LOADING..." }) - const pageInfo = { pageSize: page.size, pageNum: page.num } - const pageResult = await timerService.selectByPage(queryParam.value, pageInfo) + const pageInfo = { size: page.size, num: page.num } + const fillFlag = { alias: true, iconUrl: true } + const param = { + ...queryParam.value, + inclusiveRemote: readRemote.value + } + const pageResult = await timerService.selectByPage(param, pageInfo, fillFlag) const { list, total } = pageResult data.value = list page.total = total @@ -206,6 +212,7 @@ const _default = defineComponent({ const timeFormat: Ref = ref("default") const data: Ref = ref([]) const whitelist: Ref> = ref([]) + const remoteRead: Ref = ref(false) const sort: UnwrapRef = reactive({ prop: sc || 'focus', order: ElSortDirect.DESC @@ -236,7 +243,7 @@ const _default = defineComponent({ const tableEl: Ref = ref() - const query = () => queryData(queryParam, data, page) + const query = () => queryData(queryParam, data, page, remoteRead) // Init first queryWhiteList(whitelist).then(query) @@ -288,7 +295,11 @@ const _default = defineComponent({ }).catch(() => { // Do nothing }) - } + }, + onRemoteChange(newRemoteChange) { + remoteRead.value = newRemoteChange + query() + }, }), content: () => [ h(ReportTable, { diff --git a/src/app/components/report/styles/element.sass b/src/app/components/report/styles/element.sass index 5d3469f68..959c07b69 100644 --- a/src/app/components/report/styles/element.sass +++ b/src/app/components/report/styles/element.sass @@ -9,12 +9,12 @@ margin-left: 5px !important .batch-delete-button - margin-right: 20px + margin-right: 10px padding-top: 8px !important display: inline-flex .export-dropdown - + margin-left: 4px &-menu-icon font-size: 16px diff --git a/src/app/components/site-manage/index.ts b/src/app/components/site-manage/index.ts index f38c11c75..bd9d27716 100644 --- a/src/app/components/site-manage/index.ts +++ b/src/app/components/site-manage/index.ts @@ -34,7 +34,7 @@ const queryParam: ComputedRef = computed(() => { }) async function queryData() { - const page = { pageSize: pageRef.size, pageNum: pageRef.num } + const page = { size: pageRef.size, num: pageRef.num } const pageResult = await hostAliasService.selectByPage(queryParam.value, page) const { list, total } = pageResult dataRef.value = list diff --git a/src/app/locale/components/option.ts b/src/app/locale/components/option.ts index 84475ee30..062a95e12 100644 --- a/src/app/locale/components/option.ts +++ b/src/app/locale/components/option.ts @@ -61,6 +61,21 @@ export type OptionMessage = { siteNameUsage: string siteName: string } + backup: { + title: string + type: string + client: string + meta: { + [type in timer.backup.Type]: { + label: string + auth?: string + authInfo?: string + } + } + alert: string + test: string + operation: string + } resetButton: string resetSuccess: string defaultValue: string @@ -118,6 +133,25 @@ const _default: Messages = { siteName: '网站的名称', siteNameUsage: '数据只存放在本地,将代替域名用于展示,增加辨识度。当然您可以自定义每个网站的名称' }, + backup: { + title: '数据备份', + type: '远端类型 {input}', + client: '客户端标识 {input}', + meta: { + none: { + label: '不开启备份', + auth: '' + }, + gist: { + label: 'Github Gist', + auth: 'Personal Access Token {info} {input}', + authInfo: '需要创建一个至少包含 gist 权限的 token' + } + }, + alert: '这是一项实验性功能,如果有任何问题请联系作者~ (returnzhy1996@outlook.com)', + test: '测试', + operation: '备份数据' + }, resetButton: '恢复默认', resetSuccess: '成功重置为默认值', defaultValue: '默认值: {default}' @@ -149,7 +183,7 @@ const _default: Messages = { }, printInConsole: { label: '{input} 是否在 {console} 裡打印當前網站的 {info}', - console: '瀏覽器的控制台', + console: '瀏覽器控制台', info: '今日拜訪信息' }, darkMode: { @@ -173,6 +207,24 @@ const _default: Messages = { siteName: '網站的名稱', siteNameUsage: '數據隻存放在本地,將代替域名用於展示,增加辨識度。當然您可以自定義每個網站的名稱' }, + backup: { + title: '數據備份', + type: '雲端類型 {input}', + client: '客戶端標識 {input}', + meta: { + none: { + label: '關閉備份' + }, + gist: { + label: 'Github Gist', + auth: 'Personal Access Token {info} {input}', + authInfo: '需要創建一個至少包含 gist 權限的 token', + } + }, + alert: '這是一項實驗性功能,如果有任何問題請聯繫作者 (returnzhy1996@outlook.com) ~', + test: '測試', + operation: '備份數據' + }, resetButton: '恢複默認', resetSuccess: '成功重置爲默認值', defaultValue: '默認值: {default}' @@ -229,6 +281,24 @@ const _default: Messages = { siteNameUsage: 'The data is only stored locally and will be displayed instead of the URL to increase the recognition.' + 'Of course, you can also customize the name of each site.' }, + backup: { + title: 'Data Backup', + type: 'Remote type {input}', + client: 'Client name {input}', + meta: { + none: { + label: 'Always off' + }, + gist: { + label: 'Github Gist', + auth: 'Personal Access Token {info} {input}', + authInfo: 'One token with at least gist permission is required', + } + }, + alert: 'This is an experimental feature, if you have any questions please contact the author via returnzhy1996@outlook.com~', + test: 'Test', + operation: 'Backup', + }, resetButton: 'Reset', resetSuccess: 'Reset to default successfully!', defaultValue: 'Default: {default}' @@ -285,6 +355,23 @@ const _default: Messages = { siteNameUsage: 'データはローカルにのみ存在し、認識を高めるためにホストの代わりに表示に使用されます。' + 'もちろん、各Webサイトの名前をカスタマイズできます。' }, + backup: { + title: 'データバックアップ', + type: 'バックアップ方法 {input}', + client: 'クライアント名 {input}', + meta: { + none: { + label: 'バックアップを有効にしない' + }, + gist: { + label: 'Github Gist', + auth: 'Personal Access Token {input}' + } + }, + alert: 'これは実験的な機能です。質問がある場合は、作成者に連絡してください (returnzhy1996@outlook.com)', + test: 'テスト', + operation: 'バックアップ' + }, resetButton: 'リセット', resetSuccess: 'デフォルトに正常にリセット', defaultValue: 'デフォルト値:{default}' diff --git a/src/app/locale/components/report.ts b/src/app/locale/components/report.ts index a68f9d5b1..24bf01ba0 100644 --- a/src/app/locale/components/report.ts +++ b/src/app/locale/components/report.ts @@ -28,6 +28,10 @@ export type ReportMessage = { confirmMsgRange: string successMsg: string } + remoteReading: { + on: string + off: string + } } const _default: Messages = { @@ -51,6 +55,10 @@ const _default: Messages = { confirmMsgAll: '{example} 等网站的 {count} 条记录将会被删除!', confirmMsgRange: '{example} 等网站在 {start} 至 {end} 之间的 {count} 条记录将会被删除!', successMsg: '成功批量删除', + }, + remoteReading: { + on: '正在查询远端备份数据', + off: '单击以开启远端备份数据查询功能', } }, zh_TW: { @@ -73,6 +81,10 @@ const _default: Messages = { confirmMsgAll: '{example} 等網站的 {count} 條記錄將會被刪除!', confirmMsgRange: '{example} 等網站在 {start} 至 {end} 之間的 {count} 條記錄將會被刪除!', successMsg: '成功批量刪除', + }, + remoteReading: { + on: '正在查詢遠端備份數據', + off: '單擊以開啟遠端備份數據查詢功能' } }, en: { @@ -95,6 +107,10 @@ const _default: Messages = { confirmMsgAll: '{count} records for sites like {example} will be deleted!', confirmMsgRange: '{count} records for sites like {example} between {start} and {end} will be deleted!', successMsg: 'Batch delete successfully', + }, + remoteReading: { + on: 'Reading remote backuped data', + off: 'Click to read remote backuped data' } }, ja: { @@ -117,6 +133,10 @@ const _default: Messages = { confirmMsgAll: '{example} のようなサイトの {count} レコードは削除されます!', confirmMsgRange: '{start} と {end} の間の {example} のようなサイトの {count} レコードが削除されます!', successMsg: 'バッチ削除に成功', + }, + remoteReading: { + on: 'リモート バックアップ データのクエリ', + off: 'クリックして、リモート バックアップ データのクエリ機能を有効にします', } } } diff --git a/src/background/backup/gist/compressor.ts b/src/background/backup/gist/compressor.ts new file mode 100644 index 000000000..e6271b779 --- /dev/null +++ b/src/background/backup/gist/compressor.ts @@ -0,0 +1,62 @@ +import { groupBy } from "@util/array" + +function calcGroupKey(row: timer.stat.RowBase): string { + const date = row.date + if (!date) { + return undefined + } + return date.substring(0, 6) +} + +/** + * Compress row array to gist row + * + * @param rows row array + */ +function compress(rows: timer.stat.RowBase[]): GistData { + const result: GistData = groupBy( + rows, + row => row.date.substring(6), + groupedRows => { + const gistRow: GistRow = {} + groupedRows.forEach(({ host, focus, total, time }) => gistRow[host] = [time, focus, total]) + return gistRow + } + ) + return result +} + +/** + * Divide rows to buckets + * + * @returns [bucket, data][] + */ +export function devide2Buckets(rows: timer.stat.RowBase[]): [string, GistData][] { + const grouped: { [yearAndPart: string]: GistData } = groupBy(rows, calcGroupKey, compress) + return Object.entries(grouped) +} + +/** + * Gist data 2 rows + * + * @param filename filename + * @param gistData gistData + * @returns rows + */ +export function gistData2Rows(filename: string, gistData: GistData): timer.stat.RowBase[] { + const result = [] + const yearMonth = filename.substring(0, 6) + Object.entries(gistData).forEach(([dateOfMonth, gistRow]) => { + const date = yearMonth + dateOfMonth + Object.entries(gistRow).forEach(([host, val]) => { + const [time, focus, total] = val + const row: timer.stat.RowBase = { + date, + host, + time, focus, total + } + result.push(row) + }) + }) + return result +} \ No newline at end of file diff --git a/src/background/backup/gist/coordinator.ts b/src/background/backup/gist/coordinator.ts new file mode 100644 index 000000000..7d54baa19 --- /dev/null +++ b/src/background/backup/gist/coordinator.ts @@ -0,0 +1,168 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +import type { Gist, GistForm, File, FileForm } from "@api/gist" + +import { getJsonFileContent, findTarget, getGist, createGist, updateGist, testToken } from "@api/gist" +import { SOURCE_CODE_PAGE } from "@util/constant/url" +import { devide2Buckets, gistData2Rows } from "./compressor" + +const TIMER_META_GIST_DESC = "Used for timer to save meta info. Don't change this description :)" +const TIMER_DATA_GIST_DESC = "Used for timer to save stat data. Don't change this description :)" + +const READEME_FILE_NAME = "README.md" +const CLIENT_FILE_NAME = "clients.json" + +const INIT_README_MD: FileForm = { + filename: READEME_FILE_NAME, + content: `Created by [TIMER](${SOURCE_CODE_PAGE}) automatically, please DO NOT edit this gist!` +} + +const INIT_CLIENT_JSON: FileForm = { + filename: CLIENT_FILE_NAME, + content: "[]" +} + +/** + * Local cache of gist + */ +type Cache = { + /** + * Gist id with meta info + */ + metaGistId: string + /** + * Gist id with stat info + */ + statGistId: string +} + +function bucket2filename(bucket: string, cid: string) { + return `${bucket}_${cid}.json` +} + +export default class GistCoordinator implements Coordinator { + async updateClients(context: CoordinatorContext, clients: Client[]): Promise { + const gist = await this.getMetaGist(context) + if (!gist) { + return + } + const files: { [filename: string]: FileForm } = {} + files[READEME_FILE_NAME] = INIT_README_MD + files[CLIENT_FILE_NAME] = { + filename: CLIENT_FILE_NAME, + content: JSON.stringify(clients) + } + await updateGist(context.auth, gist.id, { description: gist.description, public: false, files }) + } + + async listAllClients(context: CoordinatorContext): Promise { + const gist = await this.getMetaGist(context) + if (!gist) { + return [] + } + const file = gist?.files[CLIENT_FILE_NAME] + return file ? getJsonFileContent(file) || [] : [] + } + + async download(context: CoordinatorContext, yearMonth: string, targetCid?: string): Promise { + const filename = bucket2filename(yearMonth, targetCid || context.cid) + const gist: Gist = await this.getStatGist(context) + const file: File = gist.files[filename] + if (file) { + const gistData: GistData = await getJsonFileContent(file) + return gistData2Rows(filename, gistData) + } else { + return [] + } + } + + async upload(context: CoordinatorContext, rows: timer.stat.RowBase[]): Promise { + const cid = context.cid + const buckets = devide2Buckets(rows) + const gist = await this.getStatGist(context) + const files2Update: { [filename: string]: FileForm } = {} + for (const [bucket, gistData] of buckets) { + const filename = bucket2filename(bucket, cid) + let content: string = JSON.stringify(gistData) + files2Update[filename] = { content, filename } + } + + const gist2update: GistForm = { + public: false, + files: files2Update, + description: TIMER_DATA_GIST_DESC + } + updateGist(context.auth, gist.id, gist2update) + } + + private isTargetMetaGist(gist: Gist): boolean { + return gist.description === TIMER_META_GIST_DESC + } + + private isTargetStatGist(gist: Gist): boolean { + return gist.description === TIMER_DATA_GIST_DESC + } + + private async getMetaGist(context: CoordinatorContext): Promise { + const gistId = context.cache.metaGistId + const auth = context.auth + // 1. Find by id + if (gistId) { + const gist = await getGist(auth, gistId) + if (gist && this.isTargetMetaGist(gist)) { + return gist + } + } + // 2. Find another + const anotherGist = await findTarget(auth, gist => this.isTargetMetaGist(gist)) + if (anotherGist) { + context.cache.metaGistId = anotherGist.id + context.handleCacheChanged() + return anotherGist + } + // 3. Create new one + const files = {} + files[INIT_README_MD.filename] = INIT_README_MD + files[INIT_CLIENT_JSON.filename] = INIT_CLIENT_JSON + const gist2Create: GistForm = { description: TIMER_META_GIST_DESC, files, public: false } + const created = await createGist(auth, gist2Create) + const newId = created?.id + newId && (context.cache.metaGistId = newId) && context.handleCacheChanged() + return created + } + + private async getStatGist(context: CoordinatorContext): Promise { + const gistId = context.cache.statGistId + const auth = context.auth + // 1. Find by id + if (gistId) { + const gist = await getGist(auth, gistId) + if (gist && this.isTargetStatGist(gist)) { + return gist + } + } + // 2. Find another + const anotherGist = await findTarget(auth, gist => this.isTargetStatGist(gist)) + if (anotherGist) { + context.cache.statGistId = anotherGist.id + context.handleCacheChanged() + return anotherGist + } + // 3. Create new one + const files = {} + files[READEME_FILE_NAME] = INIT_README_MD + const gist2Create: GistForm = { description: TIMER_DATA_GIST_DESC, files, public: false } + const created = await createGist(auth, gist2Create) + const newId = created?.id + newId && (context.cache.statGistId = newId) && context.handleCacheChanged() + return created + } + + testAuth(auth: string): Promise { + return testToken(auth) + } +} diff --git a/src/background/backup/gist/gist.d.ts b/src/background/backup/gist/gist.d.ts new file mode 100644 index 000000000..cb5840ff8 --- /dev/null +++ b/src/background/backup/gist/gist.d.ts @@ -0,0 +1,20 @@ +/** + * Data format in each json file in gist + */ +declare type GistData = { + /** + * Index = month_of_part * 32 + date_of_month + */ + [index: string]: GistRow +} + +/** + * Row stored in the gist + */ +declare type GistRow = { + [host: string]: [ + number, // Visit count + number, // Browsing time + number // Running time + ] +} \ No newline at end of file diff --git a/src/background/backup/processor.ts b/src/background/backup/processor.ts new file mode 100644 index 000000000..f3a37baad --- /dev/null +++ b/src/background/backup/processor.ts @@ -0,0 +1,215 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import BackupDatabase from "@db/backup-database" +import metaService from "@service/meta-service" +import optionService from "@service/option-service" +import timerService from "@service/timer-service" +import MonthIterator from "@util/month-iterator" +import { formatTime, getBirthday } from "@util/time" +import GistCoordinator from "./gist/coordinator" + +const storage = chrome.storage.local +const syncDb = new BackupDatabase(storage) + +class CoordinatorContextWrapper implements CoordinatorContext{ + auth: string + cache: Cache + type: timer.backup.Type + cid: string + + constructor(cid: string, auth: string, type: timer.backup.Type) { + this.cid = cid + this.auth = auth + this.type = type + } + + async init(): Promise> { + this.cache = await syncDb.getCache(this.type) as Cache + return this + } + + handleCacheChanged(): Promise { + return syncDb.updateCache(this.type, this.cache) + } +} + +/** + * Declare type of NavigatorUAData + */ +type NavigatorUAData = { + brands?: { + brand?: string + version?: string + }[] + platform?: string +} + +type Result = { + success: boolean + errorMsg?: string + data?: T +} + +function error(msg: string): Result { + return { success: false, errorMsg: msg, } +} + +function success(data?: T): Result { + return { success: true, data } +} + +function generateCid() { + const uaData = (navigator as any)?.userAgentData as NavigatorUAData + let prefix = 'unknown' + if (uaData) { + const brand: string = uaData.brands + ?.map(e => e.brand) + ?.filter(brand => brand !== "Chromium" && !brand.includes("Not")) + ?.[0] + ?.replace(' ', '-') + || undefined + const platform: string = uaData.platform + brand && platform && (prefix = `${platform.toLowerCase()}-${brand.toLowerCase()}`) + } + return prefix + '-' + new Date().getTime() +} + +/** + * Get client id or generate it lazily + */ +async function lazyGetCid(): Promise { + let cid = await metaService.getCid() + if (!cid) { + cid = generateCid() + await metaService.updateCid(cid) + } + return cid +} + +async function syncFull( + context: CoordinatorContext, + coordinator: Coordinator, + client: Client +): Promise { + // 1. select rows + let start = getBirthday() + let end = new Date() + const rows = await timerService.select({ date: [start, end] }) + const allDates = rows.map(r => r.date).sort((a, b) => a == b ? 0 : a > b ? 1 : -1) + client.maxDate = allDates[allDates.length - 1] + client.minDate = allDates[0] + // 2. upload + try { + await coordinator.upload(context, rows) + } catch (error) { + console.log(error) + } + return { + ts: end.getTime(), + date: formatTime(end, '{y}{m}{d}') + } +} + +function filterClient(c: Client, localClientId: string, start: string, end: string) { + // Excluse local client + if (c.id === localClientId) return false + // Judge range + if (start && c.maxDate && c.maxDate < start) return false + if (end && c.minDate && c.minDate > end) return false + return true +} + +function filterDate(row: timer.stat.RowBase, start: string, end: string) { + const { date } = row + if (!date) return false + if (start && date < start) return false + if (end && date > end) return false + return true +} + +class Processor { + coordinators: { + [type in timer.backup.Type]: Coordinator + } + + constructor() { + this.coordinators = { + none: undefined, + gist: new GistCoordinator() + } + } + + async syncData(): Promise> { + const option = (await optionService.getAllOption()) as timer.option.BackupOption + const auth = option?.backupAuths?.[option.backupType || 'none'] + + const type = option?.backupType + const coordinator: Coordinator = type && this.coordinators[type] + if (!coordinator) { + // no coordinator, do nothing + return error("Invalid type") + } + + const errorMsg = await this.test(type, auth) + if (errorMsg) return error(errorMsg) + + const cid = await lazyGetCid() + const context: CoordinatorContext = await new CoordinatorContextWrapper(cid, auth, type).init() + const client: Client = { + id: cid, + name: option.clientName, + minDate: undefined, + maxDate: undefined + } + let snapshot: timer.backup.Snapshot = await syncFull(context, coordinator, client) + await syncDb.updateSnapshot(type, snapshot) + const clients: Client[] = (await coordinator.listAllClients(context)).filter(a => a.id !== cid) || [] + clients.push(client) + await coordinator.updateClients(context, clients) + return success() + } + + async query(type: timer.backup.Type, auth: string, start: Date, end: Date): Promise { + const coordinator: Coordinator = this.coordinators?.[type] + if (!coordinator) return [] + let cid = await metaService.getCid() + // 1. init context + const context: CoordinatorContext = await new CoordinatorContextWrapper(cid, auth, type).init() + // 2. query all clients, and filter them + let startStr = start ? formatTime(start, '{y}{m}{d}') : undefined + let endStr = end ? formatTime(end, '{y}{m}{d}') : undefined + const allClients = (await coordinator.listAllClients(context)) + .filter(c => filterClient(c, cid, startStr, endStr)) + // 3. iterate month and clients + const result: timer.stat.RemoteRow[] = [] + const allYearMonth = new MonthIterator(start, end || new Date()).toArray() + await Promise.all(allClients.map(async client => { + const { id, name } = client + await Promise.all(allYearMonth.map(async ym => { + (await coordinator.download(context, ym, id)) + .filter(row => filterDate(row, startStr, endStr)) + .forEach(row => result.push({ + ...row, + clientName: name + })) + })) + })) + console.log(`Queried ${result.length} remote items`) + return result + } + + async test(type: timer.backup.Type, auth: string): Promise { + const coordinator: Coordinator = this.coordinators?.[type] + if (!coordinator) { + return 'Invalid type' + } + return coordinator.testAuth(auth) + } +} + +export default new Processor() \ No newline at end of file diff --git a/src/background/backup/synchronizer.d.ts b/src/background/backup/synchronizer.d.ts new file mode 100644 index 000000000..2424cf4cc --- /dev/null +++ b/src/background/backup/synchronizer.d.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2022-present Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +declare interface CoordinatorContext { + cid: string + auth: string + cache: Cache + handleCacheChanged: () => Promise +} + +declare type Client = { + id: string + name: string + minDate?: string + maxDate?: string +} + +/** + * Coordinator of data synchronizer + */ +declare interface Coordinator { + /** + * Register for client + */ + updateClients(context: CoordinatorContext, clients: Client[]): Promise + /** + * List all clients + */ + listAllClients(context: CoordinatorContext): Promise + /** + * Download fragmented data from cloud + * + * @param targetCid The client id, default value is the local one in context + */ + download(context: CoordinatorContext, yearMonth: string, targetCid?: string): Promise + /** + * Upload fragmented data to cloud + * @param rows + */ + upload(context: CoordinatorContext, rows: timer.stat.RowBase[]): Promise + /** + * Test auth + * + * @returns errorMsg or null/undefined + */ + testAuth(auth: string): Promise +} \ No newline at end of file diff --git a/src/database/backup-database.ts b/src/database/backup-database.ts new file mode 100644 index 000000000..9b899e273 --- /dev/null +++ b/src/database/backup-database.ts @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import BaseDatabase from "./common/base-database" +import { REMAIN_WORD_PREFIX } from "./common/constant" + +const PREFIX = REMAIN_WORD_PREFIX + "backup" +const SNAPSHOT_KEY = PREFIX + "_snap" +const CACHE_KEY = PREFIX + "_cache" + +function cackeKeyOf(type: timer.backup.Type) { + return CACHE_KEY + "_" + type +} + +class BackupDatabase extends BaseDatabase { + + constructor(storage: chrome.storage.StorageArea) { + super(storage) + } + + async getSnapshot(type: timer.backup.Type): Promise { + const cache = await this.storage.getOne(SNAPSHOT_KEY) as timer.backup.SnaptshotCache + return cache?.[type] + } + + async updateSnapshot(type: timer.backup.Type, snapshot: timer.backup.Snapshot): Promise { + const cache = (await this.storage.getOne(SNAPSHOT_KEY) as timer.backup.SnaptshotCache) || {} + cache[type] = snapshot + await this.storage.put(SNAPSHOT_KEY, cache) + } + + async getCache(type: timer.backup.Type): Promise { + return (await this.storage.getOne(cackeKeyOf(type))) || {} + } + + async updateCache(type: timer.backup.Type, newVal: unknown): Promise { + return this.storage.put(cackeKeyOf(type), newVal) + } + + importData(_data: any): Promise { + // Do nothing + return + } +} + +export default BackupDatabase \ No newline at end of file diff --git a/src/popup/components/footer/upgrade.ts b/src/popup/components/footer/upgrade.ts index c6d9aa6b7..d63aa52be 100644 --- a/src/popup/components/footer/upgrade.ts +++ b/src/popup/components/footer/upgrade.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { getLatestVersion } from "@src/api/version" +import { getLatestVersion } from "@api/version" import packageInfo from "@src/package" import { t } from "@popup/locale" import { UPDATE_PAGE } from "@util/constant/url" diff --git a/src/service/components/page-info.ts b/src/service/components/page-info.ts index b9d4644b0..aec19a2ac 100644 --- a/src/service/components/page-info.ts +++ b/src/service/components/page-info.ts @@ -5,26 +5,6 @@ * https://opensource.org/licenses/MIT */ -/** - * Reconstruct - * - * @since 0.5.2 - */ -export type PageResult = { - list: T[] - total: number -} - -/** - * Reconstruct - * - * @since 0.5.2 - */ -export type PageParam = { - pageNum?: number - pageSize?: number -} - const DEFAULT_PAGE_NUM = 1 const DEFAULT_PAGE_SIZE = 10 @@ -32,9 +12,9 @@ const DEFAULT_PAGE_SIZE = 10 * Slice the origin list to page * @returns */ -export function slicePageResult(originList: T[], pageParam: PageParam): PageResult { - let pageNum = pageParam.pageNum - let pageSize = pageParam.pageSize +export function slicePageResult(originList: T[], pageQuery: timer.common.PageQuery): timer.common.PageResult { + let pageNum = pageQuery.num + let pageSize = pageQuery.size pageNum === undefined || pageNum < 1 && (pageNum = DEFAULT_PAGE_NUM) pageSize === undefined || pageSize < 1 && (pageSize = DEFAULT_PAGE_SIZE) const startIndex = (pageNum - 1) * pageSize diff --git a/src/service/host-alias-service.ts b/src/service/host-alias-service.ts index 1876e7ae9..8b3bd0561 100644 --- a/src/service/host-alias-service.ts +++ b/src/service/host-alias-service.ts @@ -9,7 +9,7 @@ import HostAlias, { HostAliasSource } from "@entity/dao/host-alias" import HostAliasDatabase, { HostAliasCondition } from "@db/host-alias-database" import IconUrlDatabase from "@db/icon-url-database" import { HostAliasInfo } from "@entity/dto/host-alias-info" -import { PageParam, PageResult, slicePageResult } from "./components/page-info" +import { slicePageResult } from "./components/page-info" const storage = chrome.storage.local const hostAliasDatabase = new HostAliasDatabase(storage) @@ -18,9 +18,9 @@ const iconUrlDatabase = new IconUrlDatabase(storage) export type HostAliasQueryParam = HostAliasCondition class HostAliasService { - async selectByPage(param?: HostAliasQueryParam, page?: PageParam): Promise> { + async selectByPage(param?: HostAliasQueryParam, page?: timer.common.PageQuery): Promise> { const origin: HostAlias[] = await hostAliasDatabase.select(param) - const result: PageResult = slicePageResult(origin, page); + const result: timer.common.PageResult = slicePageResult(origin, page) const list: HostAliasInfo[] = result.list await this.fillIconUrl(list) return result diff --git a/src/service/meta-service.ts b/src/service/meta-service.ts index 5458bd99d..9a617a3d7 100644 --- a/src/service/meta-service.ts +++ b/src/service/meta-service.ts @@ -43,9 +43,31 @@ function increasePopup(): void { }) } +async function getCid(): Promise { + const meta: timer.meta.ExtensionMeta = await db.getMeta() + return meta?.cid +} + +async function updateCid(newCid: string) { + const meta = await db.getMeta() + if (meta.cid) { + return + } + meta.cid = newCid + await db.update(meta) +} + class MetaService { getInstallTime = getInstallTime updateInstallTime = updateInstallTime + /** + * @since 1.2.0 + */ + getCid = getCid + /** + * @since 1.2.0 + */ + updateCid = updateCid increaseApp = increaseApp increasePopup = increasePopup } diff --git a/src/service/option-service.ts b/src/service/option-service.ts index b422b042a..f72de2216 100644 --- a/src/service/option-service.ts +++ b/src/service/option-service.ts @@ -6,13 +6,21 @@ */ import OptionDatabase from "@db/option-database" -import { defaultAppearance, defaultPopup, defaultStatistics } from "@util/constant/option" +import { + defaultAppearance, + defaultPopup, + defaultStatistics, + defaultBackup, +} from "@util/constant/option" const db = new OptionDatabase(chrome.storage.local) -const defaultOption = () => { - return { ...defaultAppearance(), ...defaultPopup(), ...defaultStatistics() } -} +const defaultOption = () => ({ + ...defaultAppearance(), + ...defaultPopup(), + ...defaultStatistics(), + ...defaultBackup(), +}) async function getAllOption(): Promise { const exist: Partial = await db.getOption() @@ -33,6 +41,15 @@ async function setStatisticsOption(option: timer.option.StatisticsOption): Promi await setOption(option) } +async function setBackupOption(option: Partial): Promise { + // Rewrite auths + const existOption = await getAllOption() + const existAuths = existOption.backupAuths || {} + Object.entries(option.backupAuths || {}).forEach(([type, auth]) => existAuths[type] = auth) + option.backupAuths = existAuths + await setOption(option) +} + async function setOption(option: Partial): Promise { const exist: Partial = await db.getOption() const toSet = defaultOption() @@ -73,6 +90,10 @@ class OptionService { setPopupOption = setPopupOption setAppearanceOption = setAppearanceOption setStatisticsOption = setStatisticsOption + /** + * @since 1.2.0 + */ + setBackupOption = setBackupOption addOptionChangeListener = db.addOptionChangeListener /** * @since 1.1.0 diff --git a/src/service/timer-service.ts b/src/service/timer-service.ts index 4a3a3ad19..df8cdbcf0 100644 --- a/src/service/timer-service.ts +++ b/src/service/timer-service.ts @@ -13,9 +13,12 @@ import HostMergeRuleItem from "@entity/dto/host-merge-rule-item" import MergeRuleDatabase from "@db/merge-rule-database" import IconUrlDatabase from "@db/icon-url-database" import HostAliasDatabase from "@db/host-alias-database" -import { PageParam, PageResult, slicePageResult } from "./components/page-info" +import { slicePageResult } from "./components/page-info" import whitelistHolder from './components/whitelist-holder' import { resultOf, rowOf } from "@util/stat" +import OptionDatabase from "@db/option-database" +import processor from "@src/background/backup/processor" +import { getBirthday } from "@util/time" const storage = chrome.storage.local @@ -24,6 +27,7 @@ const archivedDatabase = new ArchivedDatabase(storage) const iconUrlDatabase = new IconUrlDatabase(storage) const hostAliasDatabase = new HostAliasDatabase(storage) const mergeRuleDatabase = new MergeRuleDatabase(storage) +const optionDatabase = new OptionDatabase(storage) export enum SortDirect { ASC = 1, @@ -31,6 +35,14 @@ export enum SortDirect { } export type TimerQueryParam = TimerCondition & { + /** + * Inclusive remote data + * + * If true the date range MUST NOT be unlimited + * + * @since 1.2.0 + */ + inclusiveRemote?: boolean /** * Group by the root host */ @@ -176,6 +188,9 @@ class TimerService { param = param || {} let origin = await timerDatabase.select(param as TimerCondition) + if (param.inclusiveRemote) { + origin = await this.processRemote(param, origin) + } // Process after select // 1st merge if (param.mergeHost) { @@ -197,16 +212,71 @@ class TimerService { return origin } - async selectByPage(param?: TimerQueryParam, page?: PageParam, fillFlag?: FillFlagParam): Promise> { + private async processRemote( + param: TimerCondition, + origin: timer.stat.Row[] + ): Promise { + const { backupType, backupAuths } = await optionDatabase.getOption() + const auth = backupAuths?.[backupType] + const canReadRemote = await this.canReadRemote0(backupType, auth) + if (!canReadRemote) { + return origin + } + // Map to merge + const originMap: Record = {} + origin.forEach(row => originMap[this.keyOf(row)] = row) + // Predicate with host + const { host, fullHost } = param + const predicate: (row: timer.stat.RowBase) => boolean = host + // With host condition + ? fullHost + // Full match + ? r => r.host === host + // Fuzzy match + : r => r.host && r.host.includes(host) + // Without host condition + : _r => true + // 1. query remote + let start: Date = undefined, end: Date = undefined + if (param.date instanceof Array) { + start = param.date?.[0] + end = param.date?.[1] + } else { + start = param.date + } + start = start || getBirthday() + end = end || new Date() + const remote = await processor.query(backupType, auth, start, end) + remote.filter(predicate) + .forEach(row => { + const key = this.keyOf(row) + const exist = originMap[key] + if (exist) { + exist.focus += row.focus || 0 + exist.time += row.time || 0 + exist.total += row.total || 0 + } else { + originMap[key] = { ...row, mergedHosts: [] } + } + }) + return Object.values(originMap) + } + + private keyOf(row: timer.stat.RowKey) { + return `${row.date}${row.host}` + } + + async selectByPage( + param?: TimerQueryParam, + page?: timer.common.PageQuery, + fillFlag?: FillFlagParam + ): Promise> { log("selectByPage:{param},{page}", param, page) const origin: timer.stat.Row[] = await this.select(param, fillFlag) - const result: PageResult = slicePageResult(origin, page) + const result: timer.common.PageResult = slicePageResult(origin, page) const list = result.list - if (param.mergeHost) { + if (param.mergeHost && fillFlag.iconUrl) { for (const beforeMerge of list) await this.fillIconUrl(beforeMerge.mergedHosts) - } else { - await this.fillIconUrl(list) - await this.fillAlias(list) } return result } @@ -216,7 +286,7 @@ class TimerService { return paramHost ? origin.filter(o => o.host.includes(paramHost)) : origin } - private async mergeHost(origin: timer.stat.Row[]): Promise { + private async mergeHost(origin: T[]): Promise { const newRows = [] const map = {} @@ -239,7 +309,7 @@ class TimerService { return newRows } - private mergeDate(origin: timer.stat.Row[]): timer.stat.Row[] { + private mergeDate(origin: T[]): T[] { const newRows = [] const map = {} @@ -265,6 +335,21 @@ class TimerService { ) return exist } + + /** + * Aable to read remote backup data + * + * @since 1.2.0 + * @returns T/F + */ + async canReadRemote(): Promise { + const { backupType, backupAuths } = await optionDatabase.getOption() + return await this.canReadRemote0(backupType, backupAuths?.[backupType]) + } + + private async canReadRemote0(backupType: timer.backup.Type, auth: string): Promise { + return backupType && backupType !== 'none' && !await processor.test(backupType, auth) + } } export default new TimerService() \ No newline at end of file diff --git a/src/util/array.ts b/src/util/array.ts index cf523ddbd..ba0c74f25 100644 --- a/src/util/array.ts +++ b/src/util/array.ts @@ -15,7 +15,7 @@ export function groupBy( arr: T[], keyFunc: (e: T) => string | number, - downstream: (grouped: T[]) => R + downstream: (grouped: T[], key: string) => R ): { [key: string]: R } { const groupedMap: { [key: string]: T[] } = {} arr.forEach(e => { @@ -26,7 +26,7 @@ export function groupBy( }) const result = {} Object.entries(groupedMap) - .forEach(([key, grouped]) => result[key] = downstream(grouped)) + .forEach(([key, grouped]) => result[key] = downstream(grouped, key)) return result } diff --git a/src/util/constant/option.ts b/src/util/constant/option.ts index 547b45cd8..ba6c9db57 100644 --- a/src/util/constant/option.ts +++ b/src/util/constant/option.ts @@ -42,10 +42,19 @@ export function defaultStatistics(): timer.option.StatisticsOption { } } +export function defaultBackup(): timer.option.BackupOption { + return { + backupType: 'none', + clientName: 'unknown', + backupAuths: {} + } +} + export function defaultOption(): timer.option.AllOption { return { ...defaultPopup(), ...defaultAppearance(), ...defaultStatistics(), + ...defaultBackup(), } } \ No newline at end of file diff --git a/src/util/fifo-cache.ts b/src/util/fifo-cache.ts new file mode 100644 index 000000000..8649fa69c --- /dev/null +++ b/src/util/fifo-cache.ts @@ -0,0 +1,64 @@ +/** + * Copyright (c) 2022-present Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +const DEFAULT_THRESHOLD = 10 + +/** + * FIFO cache + */ +class FIFOCache { + keyQueue: string[] = [] + threshold: number + map: Record = {} + + constructor(threshold?: number) { + if (!threshold) { + threshold = DEFAULT_THRESHOLD + } else if (!Number.isInteger(threshold)) { + throw new Error('Threashold MUST BE integer') + } else if (threshold <= 0) { + threshold = DEFAULT_THRESHOLD + } + this.threshold = threshold + } + + async set(key: string, value: T) { + if (!key || !value) return + const hasKey = this.map[key] + this.map[key] = value + if (!hasKey) { + // New key + this.keyQueue.push(key) + const length = this.keyQueue.length + if (length > this.threshold) { + const deleted = this.keyQueue.slice(0, length - this.threshold) + deleted.forEach(key => delete this.map[key]) + } + } + } + + async getOrSupply(key: string, supplier: () => PromiseLike): Promise { + const exist = this.map[key] + if (exist) { + console.log("Hit cache with key: " + key) + return exist + } + const value = await supplier() + this.set(key, value) + return value + } + + clear() { + this.keyQueue = [] + this.map = {} + } + + size() { + return this.keyQueue.length + } +} + +export default FIFOCache \ No newline at end of file diff --git a/src/util/month-iterator.ts b/src/util/month-iterator.ts new file mode 100644 index 000000000..de729c4e4 --- /dev/null +++ b/src/util/month-iterator.ts @@ -0,0 +1,55 @@ +/** + * Copyright (c) 2022-present Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +/** + * Iterate from the {@param start} to the {@param end} + */ +export default class MonthIterator { + cursor: [number, number] + end: [number, number] + + constructor(start: Date, end: Date) { + if (!start || !end) { + throw new Error("Invalid param") + } + this.cursor = [start.getFullYear(), start.getMonth()] + this.end = [end.getFullYear(), end.getMonth()] + } + + hasNext(): boolean { + if (this.cursor[0] === this.end[0]) { + return this.cursor[1] <= this.end[1] + } else { + return this.cursor[0] < this.end[0] + } + } + + next(): string { + if (this.hasNext()) { + const [year, month] = this.cursor + const result = year.toString().padStart(4, '0') + (month + 1).toString().padStart(2, '0') + const nextMonth = month + 1 + this.cursor[0] += nextMonth >= 12 ? 1 : 0 + this.cursor[1] = nextMonth % 12 + return result + } else { + return undefined + } + } + + forEach(callback: (yearMonth: string) => void) { + while (this.hasNext()) { + callback(this.next()) + } + } + + toArray(): string[] { + const result = [] + this.forEach(yearMonth => result.push(yearMonth)) + return result + } +} \ No newline at end of file diff --git a/src/util/time.ts b/src/util/time.ts index c0fce47f4..3627a483c 100644 --- a/src/util/time.ts +++ b/src/util/time.ts @@ -178,4 +178,19 @@ export function getStartOfDay(target: Date) { const currentYear = target.getFullYear() const currentDate = target.getDate() return new Date(currentYear, currentMonth, currentDate) +} + +/** + * The birthday of this extension + * + * @since 1.2.0 + */ +export function getBirthday(): Date { + const date = new Date() + // 2022-03-03 + date.setFullYear(2021) + date.setMonth(2) + date.setDate(3) + date.setHours(0, 0, 0, 0) + return date } \ No newline at end of file diff --git a/test/background/backup/gist/compressor.test.ts b/test/background/backup/gist/compressor.test.ts new file mode 100644 index 000000000..7a04d0352 --- /dev/null +++ b/test/background/backup/gist/compressor.test.ts @@ -0,0 +1,21 @@ +import { devide2Buckets } from "@src/background/backup/gist/compressor" + +test('devide 1', () => { + const rows: timer.stat.Row[] = [{ + host: 'www.baidu.com', + date: '20220801', + focus: 0, + time: 10, + total: 1000, + mergedHosts: [] + }] + const devided = devide2Buckets(rows) + expect(devided.length).toEqual(1) + const [bucket, gistData] = devided[0] + expect(bucket).toEqual('202208') + expect(gistData).toEqual({ + "01": { + "www.baidu.com": [10, 0, 1000] + } + }) +}) diff --git a/tsconfig.json b/tsconfig.json index b7f4cd957..88fff4214 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,6 +9,9 @@ "importHelpers": true, "moduleResolution": "node", "paths": { + "@api/*": [ + "src/api/*" + ], "@app/*": [ "src/app/*" ], diff --git a/webpack/webpack.prod.ts b/webpack/webpack.prod.ts index 7337e761b..2aadd8f3e 100644 --- a/webpack/webpack.prod.ts +++ b/webpack/webpack.prod.ts @@ -15,7 +15,7 @@ const sourceCodeForFireFox = path.resolve(__dirname, '..', 'market_packages', `$ // Temporary directory for source code to archive on Firefox const sourceTempDir = path.resolve(__dirname, '..', 'firefox') -const srcDir = ['public', 'src', 'package.json', 'tsconfig.json', 'webpack', 'global.d.ts', "jest.config.ts"] +const srcDir = ['public', 'src', "test", 'package.json', 'tsconfig.json', 'webpack', 'global.d.ts', "jest.config.ts"] const copyMapper = srcDir.map(p => { return { source: path.resolve(__dirname, '..', p), destination: path.resolve(sourceTempDir, p) } }) const readmeForFirefox = path.join(__dirname, '..', 'doc', 'for-fire-fox.md')