From 70ef69c6ccb0d9772dcef79b988a252442f04ef0 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 21 Jul 2023 18:12:14 +0800 Subject: [PATCH] Support import data of Web Activity Time Tracker (#216) --- package.json | 60 +++--- src/app/components/common/editable.ts | 2 +- src/app/components/data-manage/index.ts | 27 ++- src/app/components/data-manage/migration.ts | 81 -------- .../data-manage/migration/import-button.ts | 59 ++++++ .../migration/import-other-button/index.ts | 36 ++++ .../import-other-button/processor.ts | 81 ++++++++ .../migration/import-other-button/sop.ts | 46 +++++ .../migration/import-other-button/step1.ts | 91 +++++++++ .../migration/import-other-button/step2.ts | 187 ++++++++++++++++++ .../migration/import-other-button/style.sass | 70 +++++++ .../components/data-manage/migration/index.ts | 51 +++++ src/app/components/data-manage/style.sass | 4 +- .../components/data-manage/style/index.sass | 43 ---- src/database/stat-database/index.ts | 4 +- .../message/app/data-manage-resource.json | 30 +++ src/i18n/message/app/data-manage.ts | 15 ++ src/i18n/message/app/menu-resource.json | 2 +- src/i18n/message/common/button-resource.json | 4 + src/i18n/message/common/button.ts | 2 + src/i18n/message/common/item-resource.json | 6 +- src/i18n/message/common/item.ts | 1 + 22 files changed, 726 insertions(+), 176 deletions(-) delete mode 100644 src/app/components/data-manage/migration.ts create mode 100644 src/app/components/data-manage/migration/import-button.ts create mode 100644 src/app/components/data-manage/migration/import-other-button/index.ts create mode 100644 src/app/components/data-manage/migration/import-other-button/processor.ts create mode 100644 src/app/components/data-manage/migration/import-other-button/sop.ts create mode 100644 src/app/components/data-manage/migration/import-other-button/step1.ts create mode 100644 src/app/components/data-manage/migration/import-other-button/step2.ts create mode 100644 src/app/components/data-manage/migration/import-other-button/style.sass create mode 100644 src/app/components/data-manage/migration/index.ts delete mode 100644 src/app/components/data-manage/style/index.sass diff --git a/package.json b/package.json index b71edcc8..814cf56f 100644 --- a/package.json +++ b/package.json @@ -19,51 +19,51 @@ }, "license": "MIT", "devDependencies": { - "@crowdin/crowdin-api-client": "^1.22.1", - "@types/chrome": "0.0.224", + "@crowdin/crowdin-api-client": "^1.23.3", + "@types/chrome": "0.0.241", "@types/copy-webpack-plugin": "^8.0.1", - "@types/echarts": "^4.9.16", + "@types/echarts": "^4.9.18", "@types/generate-json-webpack-plugin": "^0.3.4", - "@types/jest": "^29.5.0", - "@types/node": "^18.15.3", + "@types/jest": "^29.5.3", + "@types/node": "^20.4.2", "@types/psl": "^1.1.0", - "@types/webpack": "^5.28.0", + "@types/webpack": "^5.28.1", "@types/webpack-bundle-analyzer": "^4.6.0", - "babel-loader": "^9.1.2", + "babel-loader": "^9.1.3", "copy-webpack-plugin": "^11.0.0", - "css-loader": "^6.7.3", - "eslint": "^8.36.0", + "css-loader": "^6.8.1", + "eslint": "^8.44.0", "filemanager-webpack-plugin": "^8.0.0", "generate-json-webpack-plugin": "^2.0.0", - "html-webpack-plugin": "^5.5.1", - "jest": "^29.5.0", - "jest-environment-jsdom": "^29.5.0", - "mini-css-extract-plugin": "^2.7.5", - "node-sass": "^8.0.0", - "sass-loader": "^13.2.1", - "style-loader": "^3.3.2", - "ts-jest": "^29.1.0", - "ts-loader": "^9.4.2", + "html-webpack-plugin": "^5.5.3", + "jest": "^29.6.1", + "jest-environment-jsdom": "^29.6.1", + "mini-css-extract-plugin": "^2.7.6", + "node-sass": "^9.0.0", + "sass-loader": "^13.3.2", + "style-loader": "^3.3.3", + "ts-jest": "^29.1.1", + "ts-loader": "^9.4.4", "ts-node": "^10.9.1", - "tsconfig-paths": "^4.1.2", - "tslib": "^2.5.0", - "typescript": "5.0.2", + "tsconfig-paths": "^4.2.0", + "tslib": "^2.6.0", + "typescript": "5.1.6", "url-loader": "^4.1.1", - "webpack": "^5.76.2", - "webpack-bundle-analyzer": "^4.8.0", - "webpack-cli": "^5.0.1" + "webpack": "^5.88.1", + "webpack-bundle-analyzer": "^4.9.0", + "webpack-cli": "^5.1.4" }, "dependencies": { "@element-plus/icons-vue": "^2.1.0", - "axios": "^1.3.4", + "axios": "^1.4.0", "clipboardy": "^3.0.0", - "countup.js": "^2.6.0", - "echarts": "^5.4.1", - "element-plus": "2.3.1", + "countup.js": "^2.7.0", + "echarts": "^5.4.2", + "element-plus": "2.3.7", "psl": "^1.9.0", "stream-browserify": "^3.0.0", - "vue": "^3.2.47", - "vue-router": "^4.1.6" + "vue": "^3.3.4", + "vue-router": "^4.2.4" }, "engines": { "node": ">=16" diff --git a/src/app/components/common/editable.ts b/src/app/components/common/editable.ts index ac7c55fd..5d260b93 100644 --- a/src/app/components/common/editable.ts +++ b/src/app/components/common/editable.ts @@ -6,7 +6,7 @@ */ import { Check, Close, Edit } from "@element-plus/icons-vue" -import { defineComponent, h, nextTick, watch } from "@vue/runtime-core" +import { defineComponent, h, nextTick, watch } from "vue" import { ElButton, ElIcon, ElInput } from "element-plus" import { Ref, ref, SetupContext } from "vue" diff --git a/src/app/components/data-manage/index.ts b/src/app/components/data-manage/index.ts index 2b87a527..ade5595d 100644 --- a/src/app/components/data-manage/index.ts +++ b/src/app/components/data-manage/index.ts @@ -13,19 +13,16 @@ import MemeryInfo from "./memory-info" import ClearPanel from "./clear" import './style' -export default defineComponent({ - name: "DataManage", - setup() { - const memeryInfoRef: Ref = ref() - const queryData = () => memeryInfoRef?.value?.queryData() - return () => h(ContentContainer, { - class: 'data-manage-container' - }, () => h(ElRow, { gutter: 20 }, - () => [ - h(ElCol, { span: 8 }, () => h(MemeryInfo, { ref: memeryInfoRef })), - h(ElCol, { span: 11 }, () => h(ClearPanel, { onDataDelete: queryData })), - h(ElCol, { span: 5 }, () => h(Migration, { onImport: queryData })), - ] - )) - } +export default defineComponent(() => { + const memeryInfoRef: Ref = ref() + const queryData = () => memeryInfoRef?.value?.queryData() + return () => h(ContentContainer, { + class: 'data-manage-container' + }, () => h(ElRow, { gutter: 20 }, + () => [ + h(ElCol, { span: 8 }, () => h(MemeryInfo, { ref: memeryInfoRef })), + h(ElCol, { span: 11 }, () => h(ClearPanel, { onDataDelete: queryData })), + h(ElCol, { span: 5 }, () => h(Migration, { onImport: queryData })), + ] + )) }) \ No newline at end of file diff --git a/src/app/components/data-manage/migration.ts b/src/app/components/data-manage/migration.ts deleted file mode 100644 index 06198e84..00000000 --- a/src/app/components/data-manage/migration.ts +++ /dev/null @@ -1,81 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { defineComponent, Ref } from "vue" - -import { h, ref } from "vue" -import { ElAlert, ElButton, ElCard, ElLoading, ElMain, ElMessage } from "element-plus" -import { t } from "@app/locale" -import { alertProps } from "./common" -import { deserialize, exportJson } from "@util/file" -import { formatTime } from "@util/time" -import Immigration from "@service/components/immigration" -import { Download, Upload } from "@element-plus/icons-vue" - -const immigration: Immigration = new Immigration() - -async function handleExport() { - const data = await immigration.getExportingData() - const timestamp = formatTime(new Date(), '{y}{m}{d}_{h}{i}{s}') - exportJson(data, `timer_backup_${timestamp}`) -} - -async function handleFileSelected(fileInputRef: Ref, callback: () => void) { - const files: FileList | null = fileInputRef?.value?.files - if (!files || !files.length) { - return - } - const loading = ElLoading.service({ fullscreen: true }) - const file: File = files[0] - const fileText = await file.text() - const data = deserialize(fileText) - if (!data) { - ElMessage.error(t(msg => msg.dataManage.importError)) - } - await immigration.importData(data) - loading.close() - callback?.() - ElMessage.success(t(msg => msg.dataManage.migrated)) -} - -const exportButtonText = t(msg => msg.item.operation.exportWholeData) - -const _default = defineComponent({ - name: "Migration", - emits: { - import: () => true - }, - setup(_, ctx) { - const fileInputRef: Ref = ref() - return () => h(ElCard, { class: 'migration-container' }, () => h(ElMain, {}, () => [ - h(ElAlert, alertProps, () => t(msg => msg.dataManage.migrationAlert)), - h(ElButton, { - size: 'large', - type: 'success', - icon: Download, - onClick: handleExport - }, () => exportButtonText), - h(ElButton, { - size: 'large', - type: 'primary', - icon: Upload, - onClick: () => fileInputRef.value.click() - }, () => [ - t(msg => msg.item.operation.importWholeData), - h('input', { - ref: fileInputRef, - type: 'file', - accept: '.json', - style: { display: 'none' }, - onChange: () => handleFileSelected(fileInputRef, () => ctx.emit('import')) - }) - ]) - ])) - } -}) - -export default _default \ No newline at end of file diff --git a/src/app/components/data-manage/migration/import-button.ts b/src/app/components/data-manage/migration/import-button.ts new file mode 100644 index 00000000..06c2ba33 --- /dev/null +++ b/src/app/components/data-manage/migration/import-button.ts @@ -0,0 +1,59 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { t } from "@app/locale" +import { Upload } from "@element-plus/icons-vue" +import Immigration from "@service/components/immigration" +import { deserialize } from "@util/file" +import { ElButton, ElLoading, ElMessage } from "element-plus" +import { Ref, defineComponent, h, ref } from "vue" + +const immigration: Immigration = new Immigration() + +async function handleFileSelected(fileInputRef: Ref, callback: () => void) { + const files: FileList | null = fileInputRef?.value?.files + if (!files || !files.length) { + return + } + const loading = ElLoading.service({ fullscreen: true }) + const file: File = files[0] + const fileText = await file.text() + const data = deserialize(fileText) + if (!data) { + ElMessage.error(t(msg => msg.dataManage.importError)) + } + await immigration.importData(data) + loading.close() + callback?.() + ElMessage.success(t(msg => msg.dataManage.migrated)) +} + +const _default = defineComponent({ + emits: { + import: () => true + }, + setup(_, ctx) { + const fileInputRef = ref() + return () => h(ElButton, { + size: 'large', + type: 'primary', + icon: Upload, + onClick: () => fileInputRef.value.click() + }, () => [ + t(msg => msg.item.operation.importWholeData), + h('input', { + ref: fileInputRef, + type: 'file', + accept: '.json', + style: { display: 'none' }, + onChange: () => handleFileSelected(fileInputRef, () => ctx.emit('import')) + }) + ]) + } +}) + +export default _default diff --git a/src/app/components/data-manage/migration/import-other-button/index.ts b/src/app/components/data-manage/migration/import-other-button/index.ts new file mode 100644 index 00000000..88a78661 --- /dev/null +++ b/src/app/components/data-manage/migration/import-other-button/index.ts @@ -0,0 +1,36 @@ +import { t } from "@app/locale" +import { Upload } from "@element-plus/icons-vue" +import { ElButton, ElDialog } from "element-plus" +import { Ref, defineComponent, h, ref } from "vue" +import Sop from "./sop" +import "./style" + +const _default = defineComponent({ + emits: { + import: () => true + }, + setup(_) { + const dialogVisible: Ref = ref(false) + const close = () => dialogVisible.value = false + return () => [ + h(ElButton, { + size: 'large', + type: 'warning', + icon: Upload, + onClick: () => dialogVisible.value = true + }, () => t(msg => msg.item.operation.importOtherData)), + h(ElDialog, { + top: '10vh', + modelValue: dialogVisible.value, + title: t(msg => msg.item.operation.importOtherData), + width: '80%', + closeOnClickModal: false, + }, () => h(Sop, { + onCancel: close, + onImport: close, + })) + ] + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/data-manage/migration/import-other-button/processor.ts b/src/app/components/data-manage/migration/import-other-button/processor.ts new file mode 100644 index 00000000..00e1fd16 --- /dev/null +++ b/src/app/components/data-manage/migration/import-other-button/processor.ts @@ -0,0 +1,81 @@ +import StatDatabase from "@db/stat-database" +import { isNotZeroResult } from "@util/stat" + +const statDatabase: StatDatabase = new StatDatabase(chrome.storage.local) + +export type OtherExtension = "web_activity_time_tracker" + +export type ActionType = 'overwrite' | 'accumulate' + +export type ImportedRow = Required & Partial & { + exist?: timer.stat.Result +} + +export type ImportedData = { + // Whether there is data for this dimension + [dimension in timer.stat.Dimension]?: boolean +} & { + rows: ImportedRow[] +} + +/** + * Parse the content to rows + * + * @param type extension type + * @param file selected file + * @returns row data + */ +export async function parseFile(ext: OtherExtension, file: File): Promise { + const text = await file.text() + if (ext === 'web_activity_time_tracker') { + const lines = text.split('\n').map(line => line.trim()).filter(line => !!line).splice(1) + const rows: ImportedRow[] = lines.map(line => { + const [host, date, seconds] = line.split(',').map(cell => cell.trim()) + const [year, month, day] = date.split('/') + const realDate = `${year}${month.length == 2 ? month : '0' + month}${day.length == 2 ? day : '0' + day}` + return { host, date: realDate, focus: parseInt(seconds) * 1000 } + }) + await doIfExist(rows, (row, exist) => row.exist = exist) + return { rows, focus: true } + } else { + return { rows: [] } + } +} + +async function doIfExist(items: T[], processor: (item: T, existVal: timer.stat.Result) => any): Promise { + await Promise.all(items.map(async item => { + const { host, date } = item + const exist = await statDatabase.get(host, date) + isNotZeroResult(exist) && processor(item, exist) + })) +} + +/** + * Import data + */ +export async function processImport(data: ImportedData, action: ActionType): Promise { + if (action === 'overwrite') { + return processOverwrite(data) + } else { + return processAcc(data) + } +} + +function processOverwrite(data: ImportedData): Promise { + const { rows, focus, time } = data + return Promise.all(rows.map(async row => { + const { host, date } = row + const exist = await statDatabase.get(host, date) + focus && (exist.focus = row.focus || 0) + time && (exist.time = row.time || 0) + await statDatabase.forceUpdate({ host, date, ...exist }) + })) +} + +function processAcc(data: ImportedData): Promise { + const { rows } = data + return Promise.all(rows.map(async row => { + const { host, date, focus = 0, time = 0 } = row + await statDatabase.accumulate(host, date, { focus, time }) + })) +} \ No newline at end of file diff --git a/src/app/components/data-manage/migration/import-other-button/sop.ts b/src/app/components/data-manage/migration/import-other-button/sop.ts new file mode 100644 index 00000000..15b0c929 --- /dev/null +++ b/src/app/components/data-manage/migration/import-other-button/sop.ts @@ -0,0 +1,46 @@ +import { t } from "@app/locale" +import { ElStep, ElSteps } from "element-plus" +import { Ref, defineComponent, h, nextTick, ref } from "vue" +import Step1 from "./step1" +import Step2 from "./step2" +import { ImportedData } from "./processor" + +const _default = defineComponent({ + emits: { + cancel: () => true, + import: () => true, + }, + setup(_, ctx) { + const step: Ref<0 | 1> = ref(0) + const data: Ref = ref() + return () => h('div', { class: 'sop-dialog-container' }, [ + h('div', { class: 'step-container' }, h(ElSteps, { + space: 200, + finishStatus: 'success', + active: step.value, + }, () => [ + h(ElStep, { title: t(msg => msg.dataManage.importOther.step1) }), + h(ElStep, { title: t(msg => msg.dataManage.importOther.step2) }), + ])), + h('div', { class: 'operation-container' }, step.value === 0 + ? h(Step1, { + onCancel: () => ctx.emit('cancel'), + onNext: newData => { + data.value = newData + step.value = 1 + }, + }) + : h(Step2, { + data: data.value, + onBack: () => step.value = 0, + onImport: () => { + step.value = 0 + nextTick(() => ctx.emit('import')) + }, + }) + ), + ]) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/data-manage/migration/import-other-button/step1.ts b/src/app/components/data-manage/migration/import-other-button/step1.ts new file mode 100644 index 00000000..10bdad03 --- /dev/null +++ b/src/app/components/data-manage/migration/import-other-button/step1.ts @@ -0,0 +1,91 @@ +import { t } from "@app/locale" +import { ElButton, ElForm, ElFormItem, ElMessage, ElOption, ElSelect } from "element-plus" +import { Ref, defineComponent, h, ref } from "vue" +import { Document, Close, Right } from "@element-plus/icons-vue" +import { ImportedData, OtherExtension, parseFile } from "./processor" + +const OTHER_NAMES: { [ext in OtherExtension]: string } = { + web_activity_time_tracker: "Web Activity Time Tracker" +} + +const OTHER_FILE_FORMAT: { [ext in OtherExtension]: string } = { + web_activity_time_tracker: '.csv' +} + +const ALL_TYPES: OtherExtension[] = Object.keys(OTHER_NAMES) as OtherExtension[] + +const _default = defineComponent({ + emits: { + cancel: () => true, + next: (_rows: ImportedData) => true, + }, + setup(_, ctx) { + const type: Ref = ref('web_activity_time_tracker') + const selectedFile: Ref = ref() + const fileInput: Ref = ref() + const fileParsing: Ref = ref(false) + + const handleNext = () => { + const file = selectedFile.value + if (!file) { + ElMessage.warning(t(msg => msg.dataManage.importOther.fileNotSelected)) + return + } + fileParsing.value = true + parseFile(type.value, selectedFile.value) + .then(data => data?.rows?.length ? ctx.emit('next', data) : ElMessage.error("No rows parsed")) + .catch((e: Error) => ElMessage.error(e.message)) + .finally(() => fileParsing.value = false) + } + + return () => [ + h(ElForm, { + labelWidth: 100, + class: "import-other-form", + labelPosition: 'left', + }, () => [ + h(ElFormItem, { + label: t(msg => msg.dataManage.importOther.dataSource), + required: true, + }, () => h(ElSelect, { + modelValue: type.value, + onChange: (val: OtherExtension) => type.value = val, + }, () => ALL_TYPES.map(type => h(ElOption, { value: type, label: OTHER_NAMES[type] })))), + h(ElFormItem, + { label: t(msg => msg.dataManage.importOther.file), required: true }, + () => [ + h(ElButton, { + icon: Document, + onClick: () => fileInput.value?.click() + }, () => [ + t(msg => msg.dataManage.importOther.selectFileBtn), + h('input', { + ref: fileInput, + type: 'file', + accept: OTHER_FILE_FORMAT[type.value], + style: { display: 'none' }, + onChange: () => selectedFile.value = fileInput.value?.files?.[0], + }) + ]), + selectedFile.value?.name && h('span', { class: 'select-import-file-name' }, selectedFile.value?.name), + ] + ) + ]), + h('div', { class: 'sop-footer' }, [ + h(ElButton, { + type: 'info', + icon: Close, + onClick: () => ctx.emit('cancel'), + }, () => t(msg => msg.button.cancel)), + h(ElButton, { + type: 'primary', + icon: Right, + loading: fileParsing.value, + onClick: handleNext + }, () => t(msg => msg.button.next)), + ]), + ] + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/data-manage/migration/import-other-button/step2.ts b/src/app/components/data-manage/migration/import-other-button/step2.ts new file mode 100644 index 00000000..0c5bf41c --- /dev/null +++ b/src/app/components/data-manage/migration/import-other-button/step2.ts @@ -0,0 +1,187 @@ +import { t } from "@app/locale" +import { ElButton, ElForm, ElFormItem, ElIcon, ElMessage, ElRadio, ElRadioGroup, ElTable, ElTableColumn, ElTooltip, Sort } from "element-plus" +import { PropType, Ref, defineComponent, h, ref, computed, watch, VNode } from "vue" +import { ActionType, ImportedData, ImportedRow, processImport } from "./processor" +import { Back, Check, InfoFilled } from "@element-plus/icons-vue" +import HostAlert from "@app/components/common/host-alert" +import { isRemainHost } from "@util/constant/remain-host" +import { cvt2LocaleTime, periodFormatter } from "@app/util/time" + +type SortInfo = Sort & { + prop: keyof ImportedRow +} + +const ACTION_LABELS: { [action in ActionType]: string } = { + overwrite: t(msg => msg.dataManage.importOther.overwrite), + accumulate: t(msg => msg.dataManage.importOther.accumulate), +} + +function computeList(sort: SortInfo, originRows: ImportedRow[]): ImportedRow[] { + const { prop, order } = sort || {} + originRows = originRows || [] + if (!prop) { + return originRows + } + const comparator = (a: ImportedRow, b: ImportedRow): number => { + const av = a[prop], bv = b[prop] + if (av == bv) return 0 + if (order === 'descending') { + return av > bv ? -1 : 1 + } else { + return av > bv ? 1 : -1 + } + } + return originRows.sort(comparator) +} + +const renderFocus = (data: ImportedData): VNode | undefined => data?.focus && h(ElTableColumn, { + label: t(msg => msg.item.focus), + align: 'center', +}, { + default: () => [ + h(ElTableColumn, { + prop: 'focus', + sortable: true, + label: t(msg => msg.dataManage.importOther.imported), + align: 'center', + minWidth: 220, + formatter: (row: ImportedRow) => periodFormatter(row.focus, "default"), + }), + h(ElTableColumn, { + label: t(msg => msg.dataManage.importOther.local), + align: 'center', + minWidth: 220, + formatter: (row: ImportedRow) => { + const localVal = row?.exist?.focus + return localVal ? periodFormatter(localVal, "default") : '-' + } + }), + ] +}) + +const renderTime = (data: ImportedData): VNode | undefined => data?.time && h(ElTableColumn, { + label: t(msg => msg.item.time), + align: 'center', +}, { + default: () => [ + h(ElTableColumn, { + prop: 'time', + align: 'center', + sortable: true, + minWidth: 150, + label: t(msg => msg.dataManage.importOther.imported), + formatter: (row: ImportedRow) => (row?.time || 0).toString() + }), + h(ElTableColumn, { + label: t(msg => msg.dataManage.importOther.local), + align: 'center', + minWidth: 150, + formatter: (row: ImportedRow) => { + const { exist } = row || {} + return exist ? (exist.time || 0).toString() : '-' + } + }) + ] +}) + +const renderActionRadio = (action: Ref): VNode => h(ElForm, {}, + () => h(ElFormItem, { required: true }, { + label: () => [ + t(msg => msg.dataManage.importOther.conflictType), + h(ElTooltip, { + content: t(msg => msg.dataManage.importOther.conflictTip), + }, () => h(ElIcon, () => h(InfoFilled))) + ], + default: () => h(ElRadioGroup, { + modelValue: action.value, + onChange: (val: ActionType) => action.value = val, + }, () => Object.entries(ACTION_LABELS) + .map(([label, text]) => h(ElRadio, { label }, () => text)) + ) + }) +) + +const _default = defineComponent({ + props: { + data: Object as PropType + }, + emits: { + back: () => true, + import: () => true, + }, + setup(props, ctx) { + const data: Ref = ref(props.data) + watch(() => props.data, () => data.value = props.data) + + const sort: Ref = ref({ order: 'ascending', prop: 'date' }) + const action: Ref = ref() + const importing: Ref = ref(false) + + const list: Ref = computed(() => computeList(sort.value, data.value?.rows)) + + const handleImport = () => { + const actionVal = action.value + if (!actionVal) { + ElMessage.warning(t(msg => msg.dataManage.importOther.conflictNotSelected)) + return + } + importing.value = true + processImport(data.value, actionVal) + .then(() => { + ElMessage.success(t(msg => msg.dataManage.migrated)) + ctx.emit('import') + }) + .catch(e => ElMessage.error(e)) + .finally(() => importing.value = false) + } + + return () => [ + h(ElTable, { + size: 'small', + maxHeight: '45vh', + border: true, + highlightCurrentRow: true, + fit: true, + defaultSort: sort.value, + "onSort-change": newSort => sort.value = newSort, + data: list.value || [], + }, () => [ + h(ElTableColumn, { type: 'index', align: 'center' }), + h(ElTableColumn, { + prop: 'date', + label: t(msg => msg.item.date), + sortable: true, + align: 'center', + minWidth: 120, + formatter: (row: ImportedRow) => cvt2LocaleTime(row.date) + }), + h(ElTableColumn, { + prop: 'host', + label: t(msg => msg.item.host), + sortable: true, + minWidth: 325, + align: 'center', + formatter: (row: ImportedRow) => h(HostAlert, { host: row.host, clickable: !isRemainHost(row.host) }) + }), + renderFocus(data.value), + renderTime(data.value), + ]), + h('div', { class: 'action-container' }, renderActionRadio(action)), + h('div', { class: 'sop-footer' }, [ + h(ElButton, { + type: 'info', + icon: Back, + disabled: importing.value, + onClick: () => ctx.emit('back'), + }, () => t(msg => msg.button.previous)), + h(ElButton, { + type: 'success', + icon: Check, + loading: importing.value, + onClick: handleImport + }, () => t(msg => msg.button.confirm)), + ]),] + } +}) + +export default _default diff --git a/src/app/components/data-manage/migration/import-other-button/style.sass b/src/app/components/data-manage/migration/import-other-button/style.sass new file mode 100644 index 00000000..1e3d99e5 --- /dev/null +++ b/src/app/components/data-manage/migration/import-other-button/style.sass @@ -0,0 +1,70 @@ + +.sop-dialog-container + margin-right: 16px +.step-container + width: 400px + margin: auto + .el-step__head + .el-step__line + margin-right: -50% !important + left: 50% !important + .el-step__title + text-align: center + +.operation-container + margin: 40px 20px 0 20px + +.import-other-form + width: 400px + margin: auto + .select-import-file-name + margin-left: 10px + display: inline-flex + flex: 1 + overflow: hidden + text-overflow: ellipsis + .el-form-item + .el-select + width: 100% + .el-input__wrapper + height: 30px + .el-input__inner + user-select: none + .el-select-dropdown__item + height: 28px + line-height: 28px + font-size: 13px + .el-icon + font-size: 14px + line-height: 32px + height: 32px + padding-left: 2px + .el-button + height: 32px !important + width: initial !important +.action-container + margin-top: 20px + text-align: center + line-height: 32px + .el-form + display: flex + justify-content: center + margin-bottom: -5px + .el-form-item + .el-form-item__content + margin-left: 16px + .el-icon + height: inherit + margin-left: 3px + .el-radio + margin-right: 16px +.sop-footer + display: block + width: 100% + margin: auto + margin-top: 40px + .el-button + width: initial !important + height: initial !important + .el-button:not(:last-child) + margin-right: 10px diff --git a/src/app/components/data-manage/migration/index.ts b/src/app/components/data-manage/migration/index.ts new file mode 100644 index 00000000..b901c268 --- /dev/null +++ b/src/app/components/data-manage/migration/index.ts @@ -0,0 +1,51 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { defineComponent } from "vue" + +import { h } from "vue" +import { ElAlert, ElButton, ElCard, ElMain } from "element-plus" +import { t } from "@app/locale" +import { alertProps } from "../common" +import { exportJson } from "@util/file" +import { formatTime } from "@util/time" +import Immigration from "@service/components/immigration" +import { Download } from "@element-plus/icons-vue" +import ImportButton from "./import-button" +import ImportOtherButton from "./import-other-button" + +const immigration: Immigration = new Immigration() + +async function handleExport() { + const data = await immigration.getExportingData() + const timestamp = formatTime(new Date(), '{y}{m}{d}_{h}{i}{s}') + exportJson(data, `timer_backup_${timestamp}`) +} + +const exportButtonText = t(msg => msg.item.operation.exportWholeData) + +const _default = defineComponent({ + name: "Migration", + emits: { + import: () => true + }, + setup(_, ctx) { + return () => h(ElCard, { class: 'migration-container' }, () => h(ElMain, {}, () => [ + h(ElAlert, alertProps, () => t(msg => msg.dataManage.migrationAlert)), + h(ElButton, { + size: 'large', + type: 'success', + icon: Download, + onClick: handleExport + }, () => exportButtonText), + h(ImportButton, { onImport: () => ctx.emit('import') }), + h(ImportOtherButton, { onImport: () => ctx.emit('import') }), + ])) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/data-manage/style.sass b/src/app/components/data-manage/style.sass index c7e3a032..da7ab717 100644 --- a/src/app/components/data-manage/style.sass +++ b/src/app/components/data-manage/style.sass @@ -35,8 +35,10 @@ justify-content: space-between .el-button width: 100% - height: 30% + height: 22% margin-left: 0px + text-wrap: wrap + line-height: 1.4em .el-progress-circle width: 250px !important height: 250px !important diff --git a/src/app/components/data-manage/style/index.sass b/src/app/components/data-manage/style/index.sass deleted file mode 100644 index c7e3a032..00000000 --- a/src/app/components/data-manage/style/index.sass +++ /dev/null @@ -1,43 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -.data-manage-container - .el-card__body - height: 450px - text-align: center - .clear-container - .clear-panel - text-align: left - padding-left: 30px - padding-top: 20px - .filter-input - width: 60px - .el-input__wrapper - height: 22px - - .el-input__suffix - right: 0 !important - - .step-no - margin-right: 10px - .filter-container - padding-top: 40px - - .migration-container - .el-main - height: 100% - flex-direction: column - display: flex - justify-content: space-between - .el-button - width: 100% - height: 30% - margin-left: 0px - .el-progress-circle - width: 250px !important - height: 250px !important - margin: auto diff --git a/src/database/stat-database/index.ts b/src/database/stat-database/index.ts index 5b57c486..17b5df76 100644 --- a/src/database/stat-database/index.ts +++ b/src/database/stat-database/index.ts @@ -164,7 +164,7 @@ class StatDatabase extends BaseDatabase { * * @since 0.0.5 */ - async get(host: string, date: Date): Promise { + async get(host: string, date: Date | string): Promise { const key = generateKey(host, date) const items = await this.storage.get(key) return Promise.resolve(items[key] || createZeroResult()) @@ -198,7 +198,7 @@ class StatDatabase extends BaseDatabase { * * @since 1.4.3 */ - forceUpdate(row: timer.stat.Row): Promise { + forceUpdate(row: timer.stat.RowBase): Promise { const key = generateKey(row.host, row.date) const result: timer.stat.Result = { time: row.time, focus: row.focus } return this.storage.put(key, result) diff --git a/src/i18n/message/app/data-manage-resource.json b/src/i18n/message/app/data-manage-resource.json index 0de06be0..bea7ab50 100644 --- a/src/i18n/message/app/data-manage-resource.json +++ b/src/i18n/message/app/data-manage-resource.json @@ -9,6 +9,21 @@ "filterTime": "当日打开次数在 {start} 次至 {end} 次之间。", "filterDate": "{picker} 产生的数据。", "unlimited": "无限", + "importOther": { + "step1": "选择数据", + "step2": "确认数据", + "dataSource": "数据来源", + "file": "数据文件", + "selectFileBtn": "选择", + "conflictType": "数据处理方式", + "conflictTip": "导入的数据与本地数据存在冲突时需要执行的操作", + "overwrite": "覆盖", + "accumulate": "累加", + "imported": "导入", + "local": "本地", + "fileNotSelected": "未选择文件", + "conflictNotSelected": "未选择数据处理方式" + }, "dateShortcut": { "tillYesterday": "直到昨天", "till7DaysAgo": "直到7天前", @@ -53,6 +68,21 @@ "filterTime": "The number of visits for the day is between {start} and {end}", "filterDate": "Recorded between {picker}", "unlimited": "∞", + "importOther": { + "step1": "Select data", + "step2": "Confirm data", + "dataSource": "Data source", + "file": "Data file", + "selectFileBtn": "Select", + "conflictType": "Conflict resolution", + "conflictTip": "What to do if imported data conflicts with local data", + "overwrite": "Overwrite", + "accumulate": "Accumulate", + "imported": "Imported", + "local": "Local", + "fileNotSelected": "File not selected", + "conflictNotSelected": "Conflict resolution not selected" + }, "dateShortcut": { "tillYesterday": "Until yesterday", "till7DaysAgo": "Until 7 days ago", diff --git a/src/i18n/message/app/data-manage.ts b/src/i18n/message/app/data-manage.ts index 5c429d15..9f9fb9c0 100644 --- a/src/i18n/message/app/data-manage.ts +++ b/src/i18n/message/app/data-manage.ts @@ -23,6 +23,21 @@ export type DataManageMessage = { migrationAlert: string importError: string migrated: string + importOther: { + step1: string + step2: string + dataSource: string + file: string + conflictType: string + conflictTip: string + selectFileBtn: string + overwrite: string + accumulate: string + imported: string + local: string + fileNotSelected: string + conflictNotSelected: string + } dateShortcut: { tillYesterday: string till7DaysAgo: string diff --git a/src/i18n/message/app/menu-resource.json b/src/i18n/message/app/menu-resource.json index b27b9bf3..09f7afd9 100644 --- a/src/i18n/message/app/menu-resource.json +++ b/src/i18n/message/app/menu-resource.json @@ -4,7 +4,7 @@ "data": "我的数据", "dataReport": "报表明细", "siteAnalysis": "站点分析", - "dataClear": "内存管理", + "dataClear": "数据管理", "additional": "附加功能", "siteManage": "网站管理", "whitelist": "白名单管理", diff --git a/src/i18n/message/common/button-resource.json b/src/i18n/message/common/button-resource.json index d8610a90..41907882 100644 --- a/src/i18n/message/common/button-resource.json +++ b/src/i18n/message/common/button-resource.json @@ -8,6 +8,8 @@ "paste": "Paste", "confirm": "Confirm", "cancel": "Cancel", + "previous": "Previous", + "next": "Next", "okey": "OK", "dont": "NO!" }, @@ -20,6 +22,8 @@ "paste": "粘贴", "confirm": "确认", "cancel": "取消", + "previous": "上一步", + "next": "下一步", "okey": "好的", "dont": "不用了" }, diff --git a/src/i18n/message/common/button.ts b/src/i18n/message/common/button.ts index 0ff343de..a4dca240 100644 --- a/src/i18n/message/common/button.ts +++ b/src/i18n/message/common/button.ts @@ -16,6 +16,8 @@ export type ButtonMessage = { paste: string confirm: string cancel: string + previous: string + next: string okey: string dont: string } diff --git a/src/i18n/message/common/item-resource.json b/src/i18n/message/common/item-resource.json index dbfad7db..35fe981c 100644 --- a/src/i18n/message/common/item-resource.json +++ b/src/i18n/message/common/item-resource.json @@ -14,7 +14,8 @@ "deleteConfirmMsgRange": "{url} 在 {start} 到 {end} 的访问记录将被删除", "deleteConfirmMsg": "{url} 在 {date} 的访问记录将被删除", "exportWholeData": "导出数据", - "importWholeData": "导入数据" + "importWholeData": "导入数据", + "importOtherData": "导入其他插件的数据" } }, "zh_TW": { @@ -50,7 +51,8 @@ "deleteConfirmMsgRange": "All records of {url} between {start} and {end} will be deleted!", "deleteConfirmMsg": "The record of {url} on {date} will be deleted!", "exportWholeData": "Export Data", - "importWholeData": "Import Data" + "importWholeData": "Import Data", + "importOtherData": "Import from Other Extensions" } }, "ja": { diff --git a/src/i18n/message/common/item.ts b/src/i18n/message/common/item.ts index 7190f16a..449f3a16 100644 --- a/src/i18n/message/common/item.ts +++ b/src/i18n/message/common/item.ts @@ -23,6 +23,7 @@ export type ItemMessage = { analysis: string exportWholeData: string importWholeData: string + importOtherData: string } }