diff --git a/.github/ISSUE_TEMPLATE/translation-------.md b/.github/ISSUE_TEMPLATE/translation-------.md deleted file mode 100644 index 1cc7fd8ff..000000000 --- a/.github/ISSUE_TEMPLATE/translation-------.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -name: Translation Issue / 翻译改善建议 -about: Improve the translation of this extension -title: 'Report translation mistakes' -labels: 'locale' -assignees: '' ---- - -## Any Snapshot? - - diff --git a/.gitignore b/.gitignore index 25488f8eb..ceeb38226 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ node_modules dist dist_dev +dist_dev_mv3 dist_dev_safari dist_prod dist_prod_safari @@ -10,6 +11,7 @@ Timer_Safari_DEV Timer firefox_dev +firefox_dev_mv3 market_packages diff --git a/README-zh.md b/README-zh.md index c69d8e463..eafb64902 100644 --- a/README-zh.md +++ b/README-zh.md @@ -10,7 +10,6 @@ 网费很贵是一款用于上网时间统计的浏览器插件,使用 webpack,TypeScript 和 Element-plus 进行开发。你可以在 Firefox,Chrome 和 Edge 中安装并使用它。 -- 统计网站的运行时间 - 统计用户在不同网站上的浏览时间,和访问次数 - 统计用户阅读本地文件的时间 - 网站白名单,过滤不需要统计的网站 @@ -51,10 +50,6 @@ ---- - -详细展示图文:[douban.com](https://www.douban.com/group/topic/213888429/) - ## 贡献指南 如果你想参与到该项目的开源建设,可以考虑以下几种方式 @@ -73,7 +68,7 @@ #### 完善翻译 -除了简体中文外,该扩展另外的本地化语言都依赖机翻。所以也非常欢迎您在 [issue](https://github.com/sheepzh/timer/issues/new?assignees=&labels=locale&template=translation-------.md&title=Report+translation+mistakes) 里提交翻译建议。 +除了简体中文外,该扩展另外的本地化语言都依赖机翻。所以也非常欢迎您在 [Crowdin](https://crowdin.com/project/timer-chrome-edge-firefox) 里提交翻译建议。 #### 好评鼓励 diff --git a/README.md b/README.md index e24722cf3..947320ca8 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,6 @@ Timer is a browser extension to track the time you spent on all websites. It's b Timer can -- help you count the time each website running in your browser. - help you count the time you browsing and the visit count on each website. - help you count the time you read local files with browsers. - help you analyze your browsing habit by time period and display it as a histogram. @@ -55,7 +54,7 @@ If you know how to develop browser extensions and are familiar with the project' #### 3. Perfect translation -In addition to Simplified Chinese, the other localized languages of this software all rely on machine translation. You can also submit translation suggestions with [issues](https://github.com/sheepzh/timer/issues/new?assignees=&labels=locale&template=translation-------.md&title=Report+translation+mistakes). +In addition to Simplified Chinese, the other localized languages of this software all rely on machine translation. You can also submit translation suggestions on [Crowdin](https://crowdin.com/project/timer-chrome-edge-firefox). #### 4. Rate 5 stars diff --git a/global.d.ts b/global.d.ts index 9a1fcdcab..665963661 100644 --- a/global.d.ts +++ b/global.d.ts @@ -6,7 +6,9 @@ */ declare namespace timer { namespace option { - + type PopupDuration = + | "today" | "thisWeek" | "thisMonth" + | "last30Days" /** * Options used for the popup page */ @@ -23,7 +25,7 @@ declare namespace timer { * The default duration to search * @since 0.6.0 */ - defaultDuration: popup.Duration + defaultDuration: PopupDuration /** * Replace the host name with site name which is detected automatically from the title of site homepages, * or modified manually by the user @@ -143,6 +145,14 @@ declare namespace timer { * The name of this client */ clientName: string + /** + * Whether to auto-backup data + */ + autoBackUp: boolean + /** + * Interval to auto-backup data, minutes + */ + autoBackUpInterval: number } type AllOption = PopupOption & AppearanceOption & StatisticsOption & BackupOption @@ -165,6 +175,12 @@ declare namespace timer { * @since 1.2.0 */ cid?: string + backup?: { + [key in timer.backup.Type]?: { + ts: number + msg?: string + } + } } } @@ -201,15 +217,23 @@ declare namespace timer { | 'fr' | 'it' | 'sv' + | 'fi' + | 'da' + | 'hr' + | 'id' + | 'tr' + | 'cs' + | 'ro' + | 'nl' + | 'vi' + | 'sk' + | 'mn' namespace stat { /** * The dimension to statistics */ type Dimension = - // Running time - // @deprecated v1.3.4 - | 'total' // Focus time | 'focus' // Visit count @@ -252,6 +276,10 @@ declare namespace timer { * @since 0.1.5 */ mergedHosts: Row[] + /** + * The composition of data when querying remote + */ + composition?: RemoteComposition /** * Icon url * @@ -262,34 +290,46 @@ declare namespace timer { * The alias name of this Site, always is the title of its homepage by detected */ alias?: string - } - /** - * @since 1.2.0 - */ - type RemoteRow = RowBase & { + /** + * The id of client where the remote data is storaged + */ + cid?: string /** * The name of client where the remote data is storaged */ - clientName?: string + cname?: string + } + + type RemoteCompositionVal = + // Means local data + number | { + /** + * Client's id + */ + cid: string + /** + * Client's name + */ + cname?: string + value: number + } + + /** + * @since 1.4.7 + */ + type RemoteComposition = { + [item in timer.stat.Dimension]: RemoteCompositionVal[] } } namespace limit { /** + * Limit rule in runtime + * * @since 0.8.4 */ - type Item = { - /** - * Condition, can be regular expression with star signs - */ - cond: string + type Item = Rule & { regular: RegExp - /** - * Time limit, seconds - */ - time: number - enabled: boolean - allowDelay: boolean /** * Waste today, milliseconds */ @@ -398,24 +438,6 @@ declare namespace timer { } } - namespace popup { - type Duration = "today" | "thisWeek" | "thisMonth" - type Row = timer.stat.Row & { isOther?: boolean } - type QueryResult = { - type: timer.stat.Dimension - mergeHost: boolean - data: Row[] - // Filter items - chartTitle: string - date: Date | Date[] - dateLength: number - } - type QueryResultHandler = (result: QueryResult) => void - type ChartProps = QueryResult & { - displaySiteName: boolean - } - } - namespace app { /** * @since 1.1.7 @@ -425,60 +447,6 @@ declare namespace timer { | "second" | "minute" | "hour" - - namespace trend { - type HostInfo = { - host: string - merged: boolean - } - - type FilterOption = { - host: HostInfo, - dateRange: Date[], - timeFormat: TimeFormat - } - - type RenderOption = FilterOption & { - /** - * Whether render firstly - */ - isFirst: boolean - } - } - - namespace report { - /** - * The query param of report page - */ - type QueryParam = { - /** - * Merge host - */ - mh?: string - /** - * Date start - */ - ds?: string - /** - * Date end - */ - de?: string - /** - * Sorted column - */ - sc?: timer.stat.Dimension - } - type FilterOption = { - host: string - dateRange: Date[] - mergeDate: boolean - mergeHost: boolean - /** - * @since 1.1.7 - */ - timeFormat: TimeFormat - } - } } /** @@ -553,7 +521,7 @@ declare namespace timer { // @since 0.9.0 | 'limitWaking' // @since 1.2.3 - | 'limitRemoved' + | 'limitChanged' // Request by content script // @since 1.3.0 | "cs.isInWhitelist" diff --git a/package.json b/package.json index 912720fd9..cd20d9305 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "1.4.0", + "version": "1.5.0", "description": "Web timer", "homepage": "https://github.com/sheepzh/timer", "scripts": { @@ -16,48 +16,48 @@ "license": "MIT", "devDependencies": { "@crowdin/crowdin-api-client": "^1.19.2", - "@types/chrome": "0.0.199", + "@types/chrome": "0.0.205", "@types/copy-webpack-plugin": "^8.0.1", "@types/echarts": "^4.9.16", "@types/generate-json-webpack-plugin": "^0.3.4", - "@types/jest": "^29.2.0", - "@types/node": "^18.11.3", + "@types/jest": "^29.2.4", + "@types/node": "^18.11.17", "@types/psl": "^1.1.0", "@types/webpack": "^5.28.0", "@types/webpack-bundle-analyzer": "^4.6.0", - "babel-loader": "^8.2.5", + "babel-loader": "^9.1.0", "copy-webpack-plugin": "^11.0.0", - "css-loader": "^6.7.1", - "eslint": "^8.26.0", - "filemanager-webpack-plugin": "^7.0.0", + "css-loader": "^6.7.3", + "eslint": "^8.30.0", + "filemanager-webpack-plugin": "^8.0.0", "generate-json-webpack-plugin": "^2.0.0", - "jest": "^29.2.1", - "jest-environment-jsdom": "^29.2.1", - "mini-css-extract-plugin": "^2.6.1", - "node-sass": "^7.0.3", - "sass-loader": "^13.1.0", + "jest": "^29.3.1", + "jest-environment-jsdom": "^29.3.1", + "mini-css-extract-plugin": "^2.7.2", + "node-sass": "^8.0.0", + "sass-loader": "^13.2.0", "style-loader": "^3.3.1", "ts-jest": "^29.0.3", - "ts-loader": "^9.4.1", + "ts-loader": "^9.4.2", "ts-node": "^10.9.1", "tsconfig-paths": "^4.1.1", - "tslib": "^2.4.0", - "typescript": "4.8.4", + "tslib": "^2.4.1", + "typescript": "4.9.4", "url-loader": "^4.1.1", - "webpack": "^5.74.0", - "webpack-bundle-analyzer": "^4.6.1", - "webpack-cli": "^4.10.0" + "webpack": "^5.75.0", + "webpack-bundle-analyzer": "^4.7.0", + "webpack-cli": "^5.0.1" }, "dependencies": { "@element-plus/icons-vue": "^2.0.10", - "axios": "^1.1.3", + "axios": "^1.2.1", "clipboardy": "^3.0.0", "countup.js": "^2.3.2", - "echarts": "^5.4.0", - "element-plus": "2.2.19", + "echarts": "^5.4.1", + "element-plus": "2.2.27", "psl": "^1.9.0", "stream-browserify": "^3.0.0", - "vue": "^3.2.41", - "vue-router": "^4.1.5" + "vue": "^3.2.45", + "vue-router": "^4.1.6" } } \ No newline at end of file diff --git a/script/user-chart/add.ts b/script/user-chart/add.ts index 6e2611538..bdac7699f 100644 --- a/script/user-chart/add.ts +++ b/script/user-chart/add.ts @@ -5,12 +5,36 @@ import { GistForm, updateGist as updateGistApi } from "@src/api/gist" -import { AddArgv, Argv, Browser } from "./argv" import fs from "fs" -import { descriptionOf, filenameOf, getExistGist } from "./common" +import { descriptionOf, filenameOf, getExistGist, validateTokenFromEnv } from "./common" import { exitWith } from "../util/process" -export type UserCount = Record +type AddArgv = { + browser: Browser + fileName: string +} + +function parseArgv(): AddArgv { + const argv = process.argv.slice(2) + const browserArgv = argv[0] + const fileName = argv[1] + if (!browserArgv || !fileName) { + exitWith("add.ts [c/e/f] [file_name]") + } + const browserArgvMap: Record = { + c: 'chrome', + e: 'edge', + f: 'firefox', + } + const browser: Browser = browserArgvMap[browserArgv] + if (!browser) { + exitWith("add.ts [c/e/f] [file_name]") + } + return { + browser, + fileName + } +} async function createGist(token: string, browser: Browser, data: UserCount) { const description = descriptionOf(browser) @@ -117,10 +141,11 @@ function rjust(str: string, num: number, padding: string): string { return Array.from(new Array(num - str.length).keys()).map(_ => padding).join('') + str } -export async function add(argv: Argv) { - const token = argv.gistToken - const browser = (argv as AddArgv).browser - const fileName = (argv as AddArgv).fileName +async function main() { + const token = validateTokenFromEnv() + const argv: AddArgv = parseArgv() + const browser = argv.browser + const fileName = argv.fileName const content = fs.readFileSync(fileName, { encoding: 'utf-8' }) let newData: UserCount = {} if (browser === 'chrome') { @@ -138,4 +163,6 @@ export async function add(argv: Argv) { } else { await updateGist(token, browser, newData, gist) } -} \ No newline at end of file +} + +main() \ No newline at end of file diff --git a/script/user-chart/argv.ts b/script/user-chart/argv.ts deleted file mode 100644 index 7fbaf8aee..000000000 --- a/script/user-chart/argv.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { exitWith } from "../util/process" - -type Cmd = - | 'add' - | 'render' - -type ArgvBase = { - cmd: Cmd - gistToken: string -} - -export type RenderArgv = ArgvBase & { - cmd: 'render' -} - -export type Browser = - | 'chrome' - | 'firefox' - | 'edge' - -export type AddArgv = ArgvBase & { - cmd: 'add' - browser: Browser - fileName: string -} - -export type Argv = - | RenderArgv - | AddArgv - -export function parseArgv(): Argv { - const argv = process.argv.slice(2) - const cmd: Cmd = argv[0] as Cmd - - const token = process.env.TIMER_USER_COUNT_GIST_TOKEN - if (!token) { - exitWith("Can't find token from env variable [TIMER_USER_COUNT_GIST_TOKEN]") - } - if (cmd === 'add') { - return parseAddArgv(argv, token) - } else if (cmd === 'render') { - return { gistToken: token, cmd: 'render' } as RenderArgv - } else { - console.error("Supported command: render, add") - process.exit() - } -} - -function parseAddArgv(argv: string[], token: string): AddArgv { - const browserArgv = argv[1] - const fileName = argv[2] - if (!browserArgv || !fileName) { - exitWith("add [c/e/f] [file_name]") - } - const browserArgvMap: Record = { - c: 'chrome', - e: 'edge', - f: 'firefox', - } - const browser: Browser = browserArgvMap[browserArgv] - if (!browser) { - exitWith("add [c/e/f] [file_name]") - } - return { - cmd: 'add', - gistToken: token, - browser, - fileName - } -} \ No newline at end of file diff --git a/script/user-chart/common.ts b/script/user-chart/common.ts index ca079da9f..9d8c46c7e 100644 --- a/script/user-chart/common.ts +++ b/script/user-chart/common.ts @@ -1,5 +1,16 @@ import { findTarget, Gist } from "@api/gist" -import { Browser } from "./argv" +import { exitWith } from "../util/process" + +/** + * Validate the token from environment variables + */ +export function validateTokenFromEnv(): string { + const token = process.env.TIMER_USER_COUNT_GIST_TOKEN + if (!token) { + exitWith("Can't find token from env variable [TIMER_USER_COUNT_GIST_TOKEN]") + } + return token +} /** * Calculate the gist description of target browser diff --git a/script/user-chart/index.ts b/script/user-chart/index.ts deleted file mode 100644 index 1cad7a01d..000000000 --- a/script/user-chart/index.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { add } from "./add" -import { parseArgv } from "./argv" -import { exitWith } from "../util/process" -import { render } from "./render" - -function main() { - const argv = parseArgv() - switch (argv.cmd) { - case 'add': - add(argv) - break - case 'render': - render(argv) - break - default: - exitWith('Unsupported cmd: ' + JSON.stringify(argv)) - } -} - -main() \ No newline at end of file diff --git a/script/user-chart/render.ts b/script/user-chart/render.ts index 34ebe1ca0..7b8f3c446 100644 --- a/script/user-chart/render.ts +++ b/script/user-chart/render.ts @@ -7,13 +7,13 @@ import { GistForm, updateGist } from "@api/gist" -import { UserCount } from "./add" -import { Argv, Browser } from "./argv" -import { filenameOf, getExistGist } from "./common" +import { filenameOf, getExistGist, validateTokenFromEnv } from "./common" import { EChartsType, init } from "echarts" const ALL_BROWSERS: Browser[] = ['firefox', 'edge', 'chrome'] +const POINT_COUNT = 200 + type OriginData = { [browser in Browser]: UserCount } @@ -25,19 +25,6 @@ type ChartData = { } } -export async function render(argv: Argv): Promise { - const token = argv.gistToken - // 1. get all data - const originData: OriginData = await getOriginData(token) - // 2. pre-process data - const chartData = preProcess(originData) - // 3. render csv - const svg = render2Svg(chartData) - // 4. upload - await upload2Gist(token, svg) - process.exit() -} - function preProcess(originData: OriginData): ChartData { // 1. sort datess const dateSet = new Set() @@ -64,7 +51,7 @@ function preProcess(originData: OriginData): ChartData { } // 3. zoom - const reduction = Math.floor(Object.keys(allDates).length / 150) + const reduction = Math.floor(Object.keys(allDates).length / POINT_COUNT) result.xAixs = zoom(result.xAixs, reduction) ALL_BROWSERS.forEach(b => result.yAixses[b] = zoom(result.yAixses[b], reduction)) return result @@ -136,10 +123,13 @@ function render2Svg(chartData: ChartData): string { width: 960, height: 640 }) + const totalUserCount = Object.values(yAixses) + .map(v => v[v.length - 1] || 0) + .reduce((a, b) => a + b) chart.setOption({ title: { text: 'Total Active User Count', - subtext: `${xAixs[0]} to ${xAixs[xAixs.length - 1]}` + subtext: `${xAixs[0]} to ${xAixs[xAixs.length - 1]} | currently ${totalUserCount} ` }, legend: { data: ALL_BROWSERS }, grid: { @@ -210,3 +200,17 @@ async function upload2Gist(token: string, svg: string) { console.log('Created new gist') } } + +async function main(): Promise { + const token = validateTokenFromEnv() + // 1. get all data + const originData: OriginData = await getOriginData(token) + // 2. pre-process data + const chartData = preProcess(originData) + // 3. render csv + const svg = render2Svg(chartData) + // 4. upload + await upload2Gist(token, svg) +} + +main() diff --git a/script/user-chart/user-chart.d.ts b/script/user-chart/user-chart.d.ts new file mode 100644 index 000000000..56d0300c8 --- /dev/null +++ b/script/user-chart/user-chart.d.ts @@ -0,0 +1,6 @@ +type Browser = + | 'chrome' + | 'firefox' + | 'edge' + +type UserCount = Record \ No newline at end of file diff --git a/src/api/gist.ts b/src/api/gist.ts index decc6de48..06b1b43c7 100644 --- a/src/api/gist.ts +++ b/src/api/gist.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import type { AxiosAdapter, AxiosError, AxiosResponse } from "axios" +import type { AxiosError, AxiosResponse } from "axios" import FIFOCache from "@util/fifo-cache" @@ -45,38 +45,23 @@ const BASE_URL = 'https://api.github.com/gists' */ 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) - })) + const headers = { + "Accept": "application/vnd.github+json", + "Authorization": `token ${token}` + } + const cacheKey = uri + token + return GET_CACHE.getOrSupply(cacheKey, () => axios.get(BASE_URL + uri, { headers })) + .then(response => { + if (response.status >= 200 && response.status < 300) { + return response.data as T + } else { + return null + } + }).catch((error: AxiosError) => { + console.log("AxisError", error) + return null + }) } async function post(token: string, uri: string, body?: R): Promise { diff --git a/src/app/components/common/button-filter-item.ts b/src/app/components/common/button-filter-item.ts index eb501eea0..4d00438ef 100644 --- a/src/app/components/common/button-filter-item.ts +++ b/src/app/components/common/button-filter-item.ts @@ -10,6 +10,7 @@ import ElementIcon from "@src/element-ui/icon" import { ElButton } from "element-plus" import { defineComponent, PropType, h, Ref, computed } from "vue" + const _default = defineComponent({ name: "ButtonFilterItem", props: { @@ -21,7 +22,9 @@ const _default = defineComponent({ default: true } }, - emits: ["click"], + emits: { + click: () => true + }, setup(props, ctx) { const clz: Ref = computed(() => `filter-item${props.right ? " filter-item-right" : ""}`) return () => h(ElButton, { diff --git a/src/app/components/common/date-range-filter-item.ts b/src/app/components/common/date-range-filter-item.ts index 151e0c8b9..1b9b2cc95 100644 --- a/src/app/components/common/date-range-filter-item.ts +++ b/src/app/components/common/date-range-filter-item.ts @@ -23,7 +23,9 @@ const _default = defineComponent({ default: true } }, - emits: ["change"], + emits: { + change: (_value: Date[]) => true + }, setup(props, ctx) { // @ts-ignore const dateRange: Ref = ref(props.defaultRange || [undefined, undefined]) diff --git a/src/app/components/common/input-filter-item.ts b/src/app/components/common/input-filter-item.ts index bbfb46542..738eb6551 100644 --- a/src/app/components/common/input-filter-item.ts +++ b/src/app/components/common/input-filter-item.ts @@ -13,7 +13,9 @@ const _default = defineComponent({ props: { placeholder: String }, - emits: ["search"], + emits: { + search: (_text: string) => true + }, setup(props, ctx) { const modelValue: Ref = ref("") return () => h(ElInput, { diff --git a/src/app/components/common/number-grow.ts b/src/app/components/common/number-grow.ts index d5c0e41c7..2ff384c05 100644 --- a/src/app/components/common/number-grow.ts +++ b/src/app/components/common/number-grow.ts @@ -17,7 +17,6 @@ const _default = defineComponent({ duration: Number, fontSize: Number, }, - emits: ['stop'], setup(props) { const elRef: Ref = ref() const countUp: Ref = ref() diff --git a/src/app/components/common/pagination.ts b/src/app/components/common/pagination.ts index 7a31b277c..45da29fb2 100644 --- a/src/app/components/common/pagination.ts +++ b/src/app/components/common/pagination.ts @@ -15,7 +15,10 @@ const _default = defineComponent({ size: Number, total: Number }, - emits: ["sizeChange", "numChange"], + emits: { + sizeChange: (_val: number) => true, + numChange: (_val: number) => true, + }, setup(props, ctx) { return () => h('div', { class: 'pagination-container' }, h(ElPagination, { diff --git a/src/app/components/common/popup-confirm-button.ts b/src/app/components/common/popup-confirm-button.ts index f11ce34f6..8446637a4 100644 --- a/src/app/components/common/popup-confirm-button.ts +++ b/src/app/components/common/popup-confirm-button.ts @@ -26,7 +26,10 @@ const _default = defineComponent({ default: true } }, - emits: ["confirm", "referenceClick"], + emits: { + confirm: () => true, + referenceClick: () => true, + }, setup(props, ctx) { const display = computed(() => props.visible ? "inline-block" : "none") return () => h(ElPopconfirm, { diff --git a/src/app/components/common/select-filter-item.ts b/src/app/components/common/select-filter-item.ts index cd40fd527..b4c68fc2d 100644 --- a/src/app/components/common/select-filter-item.ts +++ b/src/app/components/common/select-filter-item.ts @@ -1,6 +1,5 @@ import type { PropType, Ref } from "vue" -import type { RouteLocation } from "vue-router" import { watch } from "vue" import { ElOption, ElSelect } from "element-plus" @@ -8,16 +7,6 @@ import { defineComponent, h, ref, nextTick } from "vue" import { useRoute } from "vue-router" import FilterItemHistoryWrapper from "./filter-item-history-wrapper" -const PREFIX = "__filter_select_history_value_" - -function calcHistoryKey(route: RouteLocation, historyName: string): string { - if (!historyName) { - return undefined - } else { - return PREFIX + route.path + '_' + historyName - } -} - const _default = defineComponent({ name: "SelectFilterItem", props: { @@ -31,7 +20,9 @@ const _default = defineComponent({ }, options: Object as PropType> }, - emits: ['select'], + emits: { + select: (_val: string) => true + }, setup(props, ctx) { const modelValue: Ref = ref(props.defaultValue) const historyWrapper = new FilterItemHistoryWrapper(useRoute().path, props.historyName) diff --git a/src/app/components/common/switch-filter-item.ts b/src/app/components/common/switch-filter-item.ts index 97c7344ac..6f17c6741 100644 --- a/src/app/components/common/switch-filter-item.ts +++ b/src/app/components/common/switch-filter-item.ts @@ -5,43 +5,37 @@ * https://opensource.org/licenses/MIT */ -import { nextTick, Ref } from "vue" -import type { RouteLocation } from "vue-router" +import type { Ref } from "vue" import { ElSwitch } from "element-plus" -import { defineComponent, h, ref } from "vue" +import { defineComponent, h, nextTick, ref } from "vue" import { useRoute } from "vue-router" import FilterItemHistoryWrapper from "./filter-item-history-wrapper" -const PREFIX = "__filter_select_history_value_" - -function calcHistoryKey(route: RouteLocation, historyName: string): string { - if (!historyName) { - return undefined - } else { - return PREFIX + route.path + '_' + historyName - } +type _Props = { + defaultValue?: boolean + historyName?: string + label: string } const _default = defineComponent({ name: "SwitchFilterItem", + emits: { + change: (_val: boolean) => true + }, props: { + label: String, defaultValue: { type: Boolean, - default: false + required: false, }, - /** - * Whether to save the value in the localstorage with {@param historyName} - */ historyName: { type: String, - required: false - }, - label: String + required: false, + } }, - emits: ["change"], setup(props, ctx) { - const modelValue: Ref = ref(props.defaultValue) + const modelValue: Ref = ref(props.defaultValue || false) const historyWrapper = new FilterItemHistoryWrapper(useRoute().path, props.historyName) // Initiliaze value historyWrapper.ifPresent( diff --git a/src/app/components/dashboard/components/calendar-heat-map.ts b/src/app/components/dashboard/components/calendar-heat-map.ts index 00a2ca8f3..1bdc5e277 100644 --- a/src/app/components/dashboard/components/calendar-heat-map.ts +++ b/src/app/components/dashboard/components/calendar-heat-map.ts @@ -15,11 +15,11 @@ import GridComponent from "@echarts/component/grid" import VisualMapComponent from "@echarts/component/visual-map" import HeatmapChart from "@echarts/chart/heatmap" import { init, use } from "@echarts/core" -import CanvasRenderer from "@echarts/canvas-renderer" +import SVGRenderer from "@echarts/svg-renderer" // Register echarts use([ - CanvasRenderer, + SVGRenderer, HeatmapChart, TooltipComponent, GridComponent, @@ -65,9 +65,9 @@ type EcOption = ComposeOption< function formatTooltip(minutes: number, date: string): string { const hour = Math.floor(minutes / 60) const minute = minutes % 60 - const year = date.substr(0, 4) - const month = date.substr(4, 2) - const day = date.substr(6, 2) + const year = date.substring(0, 4) + const month = date.substring(4, 6) + const day = date.substring(6, 8) const placeholders = { hour, minute, year, month, day } @@ -90,7 +90,7 @@ function getXAxisLabelMap(data: _Value[]): { [x: string]: string } { const allMonthLabel = t(msg => msg.calendar.months).split('|') const result = {} // {[ x:string ]: Set } - const xAndMonthMap = groupBy(data, e => e[0], grouped => new Set(grouped.map(a => a[3].substr(4, 2)))) + const xAndMonthMap = groupBy(data, e => e[0], grouped => new Set(grouped.map(a => a[3].substring(4, 6)))) let lastMonth = undefined Object.entries(xAndMonthMap).forEach(([x, monthSet]) => { if (monthSet.size != 1) { @@ -206,11 +206,11 @@ function handleClick(value: _Value): void { return } - const currentYear = parseInt(currentDate.substr(0, 4)) - const currentMonth = parseInt(currentDate.substr(4, 2)) - 1 - const currentDay = parseInt(currentDate.substr(6, 2)) + const currentYear = parseInt(currentDate.substring(0, 4)) + const currentMonth = parseInt(currentDate.substring(4, 6)) - 1 + const currentDay = parseInt(currentDate.substring(6, 8)) const currentTs = (new Date(currentYear, currentMonth, currentDay).getTime() + 1000).toString() - const query: timer.app.report.QueryParam = { ds: currentTs, de: currentTs } + const query: ReportQueryParam = { ds: currentTs, de: currentTs } const url = getAppPageUrl(false, REPORT_ROUTE, query) chrome.tabs.create({ url }) diff --git a/src/app/components/dashboard/components/indicator/index.ts b/src/app/components/dashboard/components/indicator/index.ts index 0ba9e2782..fc031f92a 100644 --- a/src/app/components/dashboard/components/indicator/index.ts +++ b/src/app/components/dashboard/components/indicator/index.ts @@ -80,9 +80,9 @@ async function query(): Promise<_Value> { // 2. if not exist, calculate from all data items const firstDate = allData.map(a => a.date).filter(d => d?.length === 8).sort()[0] if (firstDate) { - const year = parseInt(firstDate.substr(0, 4)) - const month = parseInt(firstDate.substr(4, 2)) - 1 - const date = parseInt(firstDate.substr(6, 2)) + const year = parseInt(firstDate.substring(0, 4)) + const month = parseInt(firstDate.substring(4, 6)) - 1 + const date = parseInt(firstDate.substring(6, 8)) installTime = new Date(year, month, date) } diff --git a/src/app/components/dashboard/components/top-k-visit.ts b/src/app/components/dashboard/components/top-k-visit.ts index e4604bdd1..c8dffdb76 100644 --- a/src/app/components/dashboard/components/top-k-visit.ts +++ b/src/app/components/dashboard/components/top-k-visit.ts @@ -18,7 +18,7 @@ import TooltipComponent from "@echarts/component/tooltip" use([PieChart, TitleComponent, TooltipComponent]) -import timerService, { SortDirect } from "@service/timer-service" +import timerService from "@service/timer-service" import { MILL_PER_DAY } from "@util/time" import { ElLoading } from "element-plus" import { defineComponent, h, onMounted, ref } from "vue" @@ -113,7 +113,7 @@ const _default = defineComponent({ const query: TimerQueryParam = { date: [startTime, now], sort: "time", - sortOrder: SortDirect.DESC, + sortOrder: 'DESC', mergeDate: true, } const top: timer.stat.Row[] = (await timerService.selectByPage(query, { num: 1, size: TOP_NUM }, { alias: true })).list diff --git a/src/app/components/dashboard/index.ts b/src/app/components/dashboard/index.ts index cb7484268..47e7cb70b 100644 --- a/src/app/components/dashboard/index.ts +++ b/src/app/components/dashboard/index.ts @@ -9,14 +9,21 @@ import { defineComponent, h } from "vue" import ContentContainer from "@app/components/common/content-container" import DashboardRow1 from './row1' import DashboardRow2 from './row2' -import "./style/index" +import DashboardRow3 from './row3' +import "./style" +import { isTranslatingLocale, locale } from "@i18n" const _default = defineComponent({ name: 'Dashboard', - render: () => h(ContentContainer, {}, () => [ - h(DashboardRow1), - h(DashboardRow2) - ]) + render: () => h(ContentContainer, {}, () => { + const nodes = [ + h(DashboardRow1), + h(DashboardRow2), + ] + // Only shows for translating languages' speakers in English + locale === 'en' && isTranslatingLocale() && nodes.push(h(DashboardRow3)) + return nodes + }) }) export default _default \ No newline at end of file diff --git a/src/app/components/dashboard/row1.ts b/src/app/components/dashboard/row1.ts index 7d573ebd0..697f5362e 100644 --- a/src/app/components/dashboard/row1.ts +++ b/src/app/components/dashboard/row1.ts @@ -14,22 +14,20 @@ import TopKVisit from './components/top-k-visit' const _default = defineComponent({ name: "DashboardRow1", - render() { - return h(ElRow, { - gutter: 40, - style: { height: '290px' } - }, () => [ - h(DashboardCard, { - span: 4 - }, () => h(Indicator)), - h(DashboardCard, { - span: 12 - }, () => h(WeekOnWeek)), - h(DashboardCard, { - span: 8 - }, () => h(TopKVisit)), - ]) - } + render: () => h(ElRow, { + gutter: 40, + style: { height: '290px' } + }, () => [ + h(DashboardCard, { + span: 4 + }, () => h(Indicator)), + h(DashboardCard, { + span: 12 + }, () => h(WeekOnWeek)), + h(DashboardCard, { + span: 8 + }, () => h(TopKVisit)), + ]) }) export default _default \ No newline at end of file diff --git a/src/app/components/dashboard/row2.ts b/src/app/components/dashboard/row2.ts index b1fec1e52..91e9648e0 100644 --- a/src/app/components/dashboard/row2.ts +++ b/src/app/components/dashboard/row2.ts @@ -13,16 +13,12 @@ import CalendarHeatMap from './components/calendar-heat-map' const _default = defineComponent({ name: "DashboardRow1", - render() { - return h(ElRow, { - gutter: 40, - style: { height: '280px' } - }, () => [ - h(DashboardCard, { - span: 24 - }, () => h(CalendarHeatMap)) - ]) - } + render: () => h(ElRow, { + gutter: 40, + style: { height: '280px' } + }, () => h(DashboardCard, { + span: 24 + }, () => h(CalendarHeatMap))) }) export default _default \ No newline at end of file diff --git a/src/app/components/dashboard/row3.ts b/src/app/components/dashboard/row3.ts new file mode 100644 index 000000000..8402b4ff3 --- /dev/null +++ b/src/app/components/dashboard/row3.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { ElRow } from "element-plus" +import { defineComponent, h } from "vue" +import { useRouter } from "vue-router" + +// Only shows in English +const ALERT_TEXT = '💡 Help us translate this extension/addon into your native language!' + +const _default = defineComponent({ + name: "DashboardRow3", + setup() { + const router = useRouter() + return () => h(ElRow, { + gutter: 40, + }, () => h('span', { + onClick: () => router.push({ path: '/other/help' }), + class: 'help-us-link' + }, ALERT_TEXT)) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/dashboard/style/index.sass b/src/app/components/dashboard/style/index.sass index b44bf7732..d7d3340a7 100644 --- a/src/app/components/dashboard/style/index.sass +++ b/src/app/components/dashboard/style/index.sass @@ -19,6 +19,12 @@ .chart-container height: 100% +.help-us-link + color: var(--el-text-color-primary) + text-align: center + font-size: 14px + width: 100% + cursor: pointer \:root --timer-dashboard-heatmap-color-a: #9be9a8 diff --git a/src/app/components/data-manage/clear/clear.d.ts b/src/app/components/data-manage/clear/clear.d.ts new file mode 100644 index 000000000..89d99ac73 --- /dev/null +++ b/src/app/components/data-manage/clear/clear.d.ts @@ -0,0 +1,7 @@ +declare type DataManageClearFilterOption = { + dateRange: Date[], + focusStart: string, + focusEnd: string, + timeStart: string, + timeEnd: string, +} \ No newline at end of file diff --git a/src/app/components/data-manage/clear/filter/date-filter.ts b/src/app/components/data-manage/clear/filter/date-filter.ts index 3b0c16946..eaea46cb8 100644 --- a/src/app/components/data-manage/clear/filter/date-filter.ts +++ b/src/app/components/data-manage/clear/filter/date-filter.ts @@ -6,16 +6,12 @@ */ import { ElDatePicker } from "element-plus" -import { Ref, h } from "vue" +import { Ref, h, defineComponent, PropType } from "vue" import { formatTime, MILL_PER_DAY } from "@util/time" import { t, tN } from "@app/locale" import { DataManageMessage } from "@i18n/message/app/data-manage" import { stepNoClz } from "./constants" -export type DateFilterProps = { - dateRangeRef: Ref -} - const yesterday = new Date().getTime() - MILL_PER_DAY const daysBefore = (days: number) => new Date().getTime() - days * MILL_PER_DAY @@ -36,31 +32,40 @@ const pickerShortcuts = [ datePickerShortcut('till30DaysAgo', 30) ] +const dateFormat = t(msg => msg.calendar.dateFormat, { y: 'YYYY', m: 'MM', d: 'DD' }) +// The birthday of browser +const startPlaceholder = t(msg => msg.calendar.dateFormat, { y: '1994', m: '12', d: '15' }) +const endPlaceholder = formatTime(yesterday, t(msg => msg.calendar.dateFormat)) -function picker({ dateRangeRef }: DateFilterProps) { - const dateFormat = t(msg => msg.calendar.dateFormat, { y: 'YYYY', m: 'MM', d: 'DD' }) - // The birthday of browser - const startPlaceholder = t(msg => msg.calendar.dateFormat, { y: '1994', m: '12', d: '15' }) - const endPlaceholder = formatTime(yesterday, t(msg => msg.calendar.dateFormat)) - // @ts-ignore - return h(ElDatePicker, { - modelValue: dateRangeRef.value, - "onUpdate:modelValue": (date: Array) => dateRangeRef.value = date, - size: 'small', - style: 'width: 250px;', - startPlaceholder, - format: dateFormat, - endPlaceholder, - type: 'daterange', - disabledDate(date: Date) { return date.getTime() > yesterday }, - shortcuts: pickerShortcuts, - rangeSeparator: '-' - }) -} - -const dateFilter = (props: DateFilterProps) => h('p', [ - h('a', { class: stepNoClz }, '1.'), - tN(msg => msg.dataManage.filterDate, { picker: picker(props) }) -]) +const _default = defineComponent({ + name: "DateFilter", + emits: { + change: (_date: Date[]) => true + }, + props: { + dateRange: Array as PropType + }, + setup(props, ctx) { + return () => h('p', [ + h('a', { class: stepNoClz }, '1.'), + tN(msg => msg.dataManage.filterDate, { + // @ts-ignore + picker: h(ElDatePicker, { + modelValue: props.dateRange, + "onUpdate:modelValue": (date: Array) => ctx.emit('change', date), + size: 'small', + style: 'width: 250px;', + startPlaceholder, + format: dateFormat, + endPlaceholder, + type: 'daterange', + disabledDate(date: Date) { return date.getTime() > yesterday }, + shortcuts: pickerShortcuts, + rangeSeparator: '-' + }) + }) + ]) + } +}) -export default dateFilter +export default _default diff --git a/src/app/components/data-manage/clear/filter/delete-button.ts b/src/app/components/data-manage/clear/filter/delete-button.ts new file mode 100644 index 000000000..1f931b3e1 --- /dev/null +++ b/src/app/components/data-manage/clear/filter/delete-button.ts @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { ElButton } from "element-plus" +import { h, defineComponent } from "vue" +import { t } from "@src/app/locale" +import { Delete } from "@element-plus/icons-vue" + +const _default = defineComponent({ + name: "DeleteButton", + emits: { + click: () => true + }, + setup(_, ctx) { + return () => h('div', { class: 'footer-container filter-container' }, h(ElButton, { + icon: Delete, + type: 'danger', + size: 'small', + onClick: () => ctx.emit('click') + }, () => t(msg => msg.item.operation.delete))) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/data-manage/clear/filter/index.ts b/src/app/components/data-manage/clear/filter/index.ts index 086257f2c..8218d6988 100644 --- a/src/app/components/data-manage/clear/filter/index.ts +++ b/src/app/components/data-manage/clear/filter/index.ts @@ -5,67 +5,60 @@ * https://opensource.org/licenses/MIT */ -import type { Ref, SetupContext } from "vue" +import type { Ref } from "vue" -import { Delete } from "@element-plus/icons-vue" import { defineComponent, h, ref } from "vue" -import TimerDatabase from "@db/timer-database" import { t } from "@app/locale" -import dateFilter from "./date-filter" -import numberFilter from "./number-filter" -import operationButton, { BaseFilterProps } from "./operation-button" +import DateFilter from "./date-filter" +import NumberFilter from "./number-filter" +import DeleteButton from "./delete-button" -const timerDatabase = new TimerDatabase(chrome.storage.local) - -const dateRangeRef: Ref> = ref([]) -const focusStartRef: Ref = ref('0') -const focusEndRef: Ref = ref('2') -const totalStartRef: Ref = ref('0') -const totalEndRef: Ref = ref('') -const timeStartRef: Ref = ref('0') -const timeEndRef: Ref = ref('') - -const title = h('h3', t(msg => msg.dataManage.filterItems)) - -const filterRefs: BaseFilterProps = { - dateRangeRef, - focusStartRef, focusEndRef, - totalStartRef, totalEndRef, - timeStartRef, timeEndRef, -} - -const deleteButton = (onDateChanged: () => void) => operationButton({ - ...filterRefs, - onDateChanged, - - confirm: { - message: 'deleteConfirm', - operation: result => timerDatabase.delete(result), - resultMessage: 'deleteSuccess' +const _default = defineComponent({ + emits: { + delete: () => true }, + setup(_, ctx) { + const dateRangeRef: Ref> = ref([]) + const focusStartRef: Ref = ref('0') + const focusEndRef: Ref = ref('2') + const timeStartRef: Ref = ref('0') + const timeEndRef: Ref = ref('') + const computeFilterOption = () => ({ + dateRange: dateRangeRef.value, + focusStart: focusStartRef.value, + focusEnd: focusEndRef.value, + timeStart: timeStartRef.value, + timeEnd: timeEndRef.value, + } as DataManageClearFilterOption) - button: { - message: 'delete', - icon: Delete, - type: 'danger' - } -}) - -const _default = defineComponent((_props, ctx: SetupContext) => { - const onDateChanged = ctx.attrs.onDateChanged as () => void + ctx.expose({ + getFilterOption: computeFilterOption + }) - const footer = () => h('div', { class: 'footer-container filter-container' }, deleteButton(onDateChanged)) - - return () => h('div', { class: 'clear-panel' }, - [ - title, - dateFilter({ dateRangeRef }), - numberFilter('filterFocus', focusStartRef, focusEndRef, 2), - numberFilter('filterTotal', totalStartRef, totalEndRef, 3), - numberFilter('filterTime', timeStartRef, timeEndRef, 4), - footer() - ] - ) + return () => h('div', { class: 'clear-panel' }, [ + h('h3', t(msg => msg.dataManage.filterItems)), + h(DateFilter, { dateRange: dateRangeRef.value, onChange: (newVal: Date[]) => dateRangeRef.value = newVal }), + h(NumberFilter, { + translateKey: 'filterFocus', + start: focusStartRef.value, + end: focusEndRef.value, + lineNo: 2, + onStartChange: v => focusStartRef.value = v, + onEndChange: v => focusEndRef.value = v, + }), + h(NumberFilter, { + translateKey: 'filterTime', + start: timeStartRef.value, + end: timeEndRef.value, + lineNo: 3, + onStartChange: v => timeStartRef.value = v, + onEndChange: v => timeEndRef.value = v, + }), + h(DeleteButton, { + onClick: () => ctx.emit('delete') + }), + ]) + } }) export default _default \ No newline at end of file diff --git a/src/app/components/data-manage/clear/filter/number-filter.ts b/src/app/components/data-manage/clear/filter/number-filter.ts index 6d8be5f98..236b22549 100644 --- a/src/app/components/data-manage/clear/filter/number-filter.ts +++ b/src/app/components/data-manage/clear/filter/number-filter.ts @@ -6,29 +6,45 @@ */ import { ElInput } from "element-plus" -import { Ref, h } from "vue" +import { h, defineComponent, PropType } from "vue" import { t, tN } from "@app/locale" import { DataManageMessage } from "@i18n/message/app/data-manage" import { stepNoClz } from "./constants" -const elInput = (valRef: Ref, placeholder: string, min?: Ref) => +const elInput = (val: string, setter: (val: string) => void, placeholder: string, min?: string) => h(ElInput, { class: 'filter-input', placeholder: placeholder, - min: min !== undefined ? min.value || '0' : undefined, + min: min !== undefined ? min || '0' : undefined, clearable: true, size: 'small', - modelValue: valRef.value, - onInput: (val: string) => valRef.value = val.trim(), - onClear: () => valRef.value = '' + modelValue: val, + onInput: (val: string) => setter?.(val.trim()), + onClear: () => setter?.('') }) -const numberFilter = (translateKey: keyof DataManageMessage, startRef: Ref, endRef: Ref, lineNo: number) => h('p', [ - h('a', { class: stepNoClz }, `${lineNo}.`), - tN(msg => msg.dataManage[translateKey], { - start: elInput(startRef, '0'), - end: elInput(endRef, t(msg => msg.dataManage.unlimited), startRef) - }) -]) -export default numberFilter \ No newline at end of file +const _default = defineComponent({ + name: "NumberFilter", + props: { + translateKey: String as PropType, + start: String, + end: String, + lineNo: Number, + }, + emits: { + startChange: (_val: string) => true, + endChange: (_val: string) => true, + }, + setup(props, ctx) { + return () => h('p', [ + h('a', { class: stepNoClz }, `${props.lineNo}.`), + tN(msg => msg.dataManage[props.translateKey], { + start: elInput(props.start, v => ctx.emit('startChange', v), '0'), + end: elInput(props.end, v => ctx.emit('endChange', v), t(msg => msg.dataManage.unlimited), props.start) + }) + ]) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/data-manage/clear/filter/operation-button.ts b/src/app/components/data-manage/clear/filter/operation-button.ts index 16a697df3..9027a225e 100644 --- a/src/app/components/data-manage/clear/filter/operation-button.ts +++ b/src/app/components/data-manage/clear/filter/operation-button.ts @@ -18,8 +18,6 @@ import { ElementButtonType } from "@src/element-ui/button" const timerDatabase = new TimerDatabase(chrome.storage.local) export type BaseFilterProps = { - totalStartRef: Ref - totalEndRef: Ref focusStartRef: Ref focusEndRef: Ref timeStartRef: Ref @@ -80,10 +78,8 @@ const str2Range = (startAndEnd: Ref[], numAmplifier?: (origin: number) = const seconds2Milliseconds = (a: number) => a * 1000 function checkParam(props: _Props): TimerCondition | undefined { - const { totalStartRef, totalEndRef, focusStartRef, focusEndRef, timeStartRef, timeEndRef, dateRangeRef } = props + const { focusStartRef, focusEndRef, timeStartRef, timeEndRef } = props let hasError = false - const totalRange = str2Range([totalStartRef, totalEndRef], seconds2Milliseconds) - hasError = hasError || assertQueryParam(totalRange) const focusRange = str2Range([focusStartRef, focusEndRef], seconds2Milliseconds) hasError = hasError || assertQueryParam(focusRange) const timeRange = str2Range([timeStartRef, timeEndRef]) @@ -92,7 +88,6 @@ function checkParam(props: _Props): TimerCondition | undefined { return undefined } const condition: TimerCondition = {} - condition.totalRange = totalRange condition.focusRange = focusRange condition.timeRange = timeRange return condition diff --git a/src/app/components/data-manage/clear/index.ts b/src/app/components/data-manage/clear/index.ts index 1faacc054..ef188ca28 100644 --- a/src/app/components/data-manage/clear/index.ts +++ b/src/app/components/data-manage/clear/index.ts @@ -5,26 +5,124 @@ * https://opensource.org/licenses/MIT */ -import { ElAlert, ElCard } from "element-plus" -import { h } from "vue" +import { ElAlert, ElCard, ElMessage, ElMessageBox } from "element-plus" +import { defineComponent, h, Ref, ref, SetupContext } from "vue" import { t } from "@app/locale" import { alertProps } from "../common" import Filter from "./filter" +import TimerDatabase, { TimerCondition } from "@db/timer-database" +import { MILL_PER_DAY } from "@util/time" -type _Props = { - queryData: () => Promise | void +type _Emits = { + dataDelete: () => true } -const clearAlert = () => h(ElAlert, - { - ...alertProps, - title: t(msg => msg.dataManage.operationAlert) - }) -const clearFilter = (queryData: () => Promise | void) => h(Filter, { onDateChanged: queryData }) -const clearPanel = (props: _Props) => h(ElCard, { - class: 'clear-container' -}, - () => [clearAlert(), clearFilter(props.queryData)] -) - -export default clearPanel \ No newline at end of file +const timerDatabase = new TimerDatabase(chrome.storage.local) + +const operationCancelMsg = t(msg => msg.dataManage.operationCancel) +const operationConfirmMsg = t(msg => msg.dataManage.operationConfirm) + +async function handleClick(filterRef: Ref, ctx: SetupContext<_Emits>) { + const filterOption: DataManageClearFilterOption = filterRef?.value?.getFilterOption() + const result: timer.stat.Row[] = await generateParamAndSelect(filterOption) + + const count = result.length + const confirmMsg = t(msg => msg.dataManage.deleteConfirm, { count }) + ElMessageBox.confirm(confirmMsg, { + cancelButtonText: operationCancelMsg, + confirmButtonText: operationConfirmMsg + }).then(async () => { + await timerDatabase.delete(result) + ElMessage(t(msg => msg.dataManage.deleteSuccess)) + ctx.emit('dataDelete') + }).catch(() => { }) +} + +function generateParamAndSelect(props: DataManageClearFilterOption): Promise | undefined { + const condition = checkParam(props) + if (!condition) { + ElMessage({ message: t(msg => msg.dataManage.paramError), type: 'warning' }) + return + } + + const { dateRange } = props + let [dateStart, dateEnd] = dateRange || [] + if (dateEnd == null) { + // default end time is the yesterday + dateEnd = new Date(new Date().getTime() - MILL_PER_DAY) + } + condition.date = [dateStart, dateEnd] + + return timerDatabase.select(condition) +} + +/** + * Assert query param with numeric range + * + * @param range numeric range, 2-length array + * @param mustInteger must be integer? + * @returns true when has error, or false + */ +function assertQueryParam(range: number[], mustInteger?: boolean): boolean { + const reg = mustInteger ? /^[0-9]+$/ : /^[0-9]+.?[0-9]*$/ + const start = range[0] + const end = range[1] + const noStart = start !== undefined && start !== null + const noEnd = end !== undefined && end !== null + return (noStart && !reg.test(start.toString())) + || (noEnd && !reg.test(end.toString())) + || (noStart && noEnd && start > end) +} + +const str2Num = (str: string, defaultVal?: number) => (str && str !== '') ? parseInt(str) : defaultVal +const seconds2Milliseconds = (a: number) => a * 1000 + +function checkParam(filterOption: DataManageClearFilterOption): TimerCondition | undefined { + const { focusStart, focusEnd, timeStart, timeEnd } = filterOption + let hasError = false + const focusRange = str2Range([focusStart, focusEnd], seconds2Milliseconds) + hasError = hasError || assertQueryParam(focusRange) + const timeRange = str2Range([timeStart, timeEnd]) + hasError = hasError || assertQueryParam(timeRange, true) + if (hasError) { + return undefined + } + const condition: TimerCondition = {} + condition.focusRange = focusRange + condition.timeRange = timeRange + return condition +} + +function str2Range(startAndEnd: [string, string], numAmplifier?: (origin: number) => number): [number, number] { + const startStr = startAndEnd[0] + const endStr = startAndEnd[1] + let start = str2Num(startStr, 0) + numAmplifier && (start = numAmplifier(start)) + let end = str2Num(endStr) + end && numAmplifier && (end = numAmplifier(end)) + return [start, end] +} + + +const _default = defineComponent({ + emits: { + dataDelete: () => true + }, + setup(_, ctx) { + const filterRef: Ref = ref() + return () => h(ElCard, { + class: 'clear-container' + }, () => [ + h(ElAlert, { + ...alertProps, + title: t(msg => msg.dataManage.operationAlert) + }), + h(Filter, { + ref: filterRef, + onDelete: () => handleClick(filterRef, ctx), + }), + ]) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/data-manage/index.ts b/src/app/components/data-manage/index.ts index d4f6794b5..2b87a5270 100644 --- a/src/app/components/data-manage/index.ts +++ b/src/app/components/data-manage/index.ts @@ -6,36 +6,26 @@ */ import { ElRow, ElCol } from "element-plus" -import { defineComponent, h, ref, Ref } from "vue" -import { getUsedStorage } from "@db/memory-detector" -import './style' +import { defineComponent, h, Ref, ref } from "vue" import ContentContainer from "../common/content-container" -import migration from "./migration" -import memoryInfo from "./memory-info" -import clearPanel from "./clear" - -// Total memory with byte -const usedRef: Ref = ref(0) -const totalRef: Ref = ref(1) // As the denominator of percentage, cannot be 0, so be 1 - -const queryData = async () => { - const { used, total } = await getUsedStorage() - usedRef.value = used || 0 - totalRef.value = total -} - -queryData() - -const firstRow = () => h(ElRow, { gutter: 20 }, - () => [ - h(ElCol, { span: 8 }, () => memoryInfo({ usedRef, totalRef })), - h(ElCol, { span: 11 }, () => clearPanel({ queryData })), - h(ElCol, { span: 5 }, () => migration({ queryData })) - ] -) +import Migration from "./migration" +import MemeryInfo from "./memory-info" +import ClearPanel from "./clear" +import './style' -export default defineComponent(() => { - return () => h(ContentContainer, { - class: 'data-manage-container' - }, () => firstRow()) +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 })), + ] + )) + } }) \ No newline at end of file diff --git a/src/app/components/data-manage/memory-info.ts b/src/app/components/data-manage/memory-info.ts index 9568ba317..f91b6cf7b 100644 --- a/src/app/components/data-manage/memory-info.ts +++ b/src/app/components/data-manage/memory-info.ts @@ -6,14 +6,10 @@ */ import { ElAlert, ElCard, ElProgress } from "element-plus" -import { h, Ref } from "vue" +import { computed, ComputedRef, defineComponent, h, ref, Ref } from "vue" import { t } from "@app/locale" import { alertProps } from "./common" - -type _Props = { - usedRef: Ref - totalRef: Ref -} +import { getUsedStorage } from "@db/memory-detector" const memoryAlert = (totalMb: number) => { const title = totalMb @@ -43,13 +39,8 @@ const usedAlert = (usedMb: number, typeColor: string) => h('div', { style: usedA ) const byte2Mb = (size: number) => Math.round((size || 0) / 1024.0 / 1024.0 * 1000) / 1000 -const memoryInfo = (props: _Props) => { - const { usedRef, totalRef } = props - const used = usedRef.value - const total = totalRef.value - const usedMb = byte2Mb(used) - const totalMb = byte2Mb(total) - const percentage: number = total ? Math.round(used * 10000.0 / total) / 100 : 0 + +function computeColor(percentage: number, total: number): string { // Danger color let typeColor = '#F56C6C' // Primary color @@ -58,9 +49,33 @@ const memoryInfo = (props: _Props) => { else if (percentage < 75) typeColor = '#E6A23C' // Specially, show warning color if not detect the max memory if (!total) typeColor = '#E6A23C' - return h(ElCard, {}, - () => [memoryAlert(totalMb), memoryProgress(percentage, typeColor), usedAlert(usedMb, typeColor)] - ) + return typeColor } -export default memoryInfo \ No newline at end of file +const _default = defineComponent({ + name: "MemoryInfo", + setup(_, ctx) { + // Total memory with byte + const usedRef: Ref = ref(0) + // As the denominator of percentage, cannot be 0, so be 1 + const totalRef: Ref = ref(1) + const queryData = async () => { + const { used, total } = await getUsedStorage() + usedRef.value = used || 0 + totalRef.value = total + } + queryData() + ctx.expose({ queryData }) + + const usedMbRef: ComputedRef = computed(() => byte2Mb(usedRef.value)) + const totalMbRef: ComputedRef = computed(() => byte2Mb(totalRef.value)) + const percentageRef: ComputedRef = computed(() => totalRef.value ? Math.round(usedRef.value * 10000.0 / totalRef.value) / 100 : 0) + const colorRef: ComputedRef = computed(() => computeColor(percentageRef.value, totalRef.value)) + + return () => h(ElCard, {}, + () => [memoryAlert(totalMbRef.value), memoryProgress(percentageRef.value, colorRef.value), usedAlert(usedMbRef.value, colorRef.value)] + ) + }, +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/data-manage/migration.ts b/src/app/components/data-manage/migration.ts index 17200937a..06198e84c 100644 --- a/src/app/components/data-manage/migration.ts +++ b/src/app/components/data-manage/migration.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import type { Ref } from "vue" +import { defineComponent, Ref } from "vue" import { h, ref } from "vue" import { ElAlert, ElButton, ElCard, ElLoading, ElMain, ElMessage } from "element-plus" @@ -16,21 +16,16 @@ import { formatTime } from "@util/time" import Immigration from "@service/components/immigration" import { Download, Upload } from "@element-plus/icons-vue" -type _Props = { - queryData: () => void | Promise -} - const immigration: Immigration = new Immigration() -const handleExport = async () => { +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 fileInputRef: Ref = ref() -const handleFileSelected = async (queryData: () => void) => { - const files: FileList | null = fileInputRef.value.files +async function handleFileSelected(fileInputRef: Ref, callback: () => void) { + const files: FileList | null = fileInputRef?.value?.files if (!files || !files.length) { return } @@ -43,52 +38,44 @@ const handleFileSelected = async (queryData: () => void) => { } await immigration.importData(data) loading.close() - queryData() + callback?.() ElMessage.success(t(msg => msg.dataManage.migrated)) } -const alert = () => h(ElAlert, alertProps, () => t(msg => msg.dataManage.migrationAlert)) - const exportButtonText = t(msg => msg.item.operation.exportWholeData) -const exportButton = () => h(ElButton, - { - size: 'large', - type: 'success', - icon: Download, - onClick: handleExport - }, - () => exportButtonText -) -const fileInputProps = { - ref: fileInputRef, - type: 'file', - accept: '.json', - style: { display: 'none' } -} -const fileInput = (queryData: any) => h('input', { - ...fileInputProps, - onChange: () => handleFileSelected(queryData) -}) -const importButtonText = (queryData: any) => [t(msg => msg.item.operation.importWholeData), fileInput(queryData)] -const importButton = (queryData: any) => h(ElButton, - { - size: 'large', - type: 'primary', - icon: Upload, - onClick: () => fileInputRef.value.click() +const _default = defineComponent({ + name: "Migration", + emits: { + import: () => true }, - () => importButtonText(queryData) -) - -const buttonContainer = (queryData: () => void | Promise) => h(ElMain, {}, - () => [alert(), exportButton(), importButton(queryData)] -) + 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 (props: _Props) => { - return h(ElCard, { - class: 'migration-container' - }, - () => buttonContainer(props.queryData) - ) -} \ No newline at end of file +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 new file mode 100644 index 000000000..c7e3a0325 --- /dev/null +++ b/src/app/components/data-manage/style.sass @@ -0,0 +1,43 @@ +/** + * 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/app/components/habit/component/chart/wrapper.ts b/src/app/components/habit/component/chart/wrapper.ts index 7486fe823..13b212a93 100644 --- a/src/app/components/habit/component/chart/wrapper.ts +++ b/src/app/components/habit/component/chart/wrapper.ts @@ -12,13 +12,13 @@ import type { GridComponentOption, TitleComponentOption, ToolboxComponentOption, import { use, init } from "@echarts/core" import BarChart from "@echarts/chart/bar" -import CanvasRenderer from "@echarts/canvas-renderer" +import SVGRenderer from "@echarts/svg-renderer" import TitleComponent from "@echarts/component/title" import TooltipComponent from "@echarts/component/tooltip" import ToolboxComponent from "@echarts/component/toolbox" import GridComponent from "@echarts/component/grid" -use([BarChart, CanvasRenderer, TitleComponent, TooltipComponent, ToolboxComponent, GridComponent]) +use([BarChart, SVGRenderer, TitleComponent, TooltipComponent, ToolboxComponent, GridComponent]) import { formatPeriodCommon, formatTime, MILL_PER_DAY } from "@util/time" import { t } from "@app/locale" diff --git a/src/app/components/habit/component/filter.ts b/src/app/components/habit/component/filter.ts index ceb4c11f2..c55df34a1 100644 --- a/src/app/components/habit/component/filter.ts +++ b/src/app/components/habit/component/filter.ts @@ -16,12 +16,6 @@ import { ElementDatePickerShortcut } from "@src/element-ui/date" import DateRangeFilterItem from "@app/components/common/date-range-filter-item" import SelectFilterItem from "@app/components/common/select-filter-item" -export type HabitFilterOption = { - periodSize: number - dateRange: Date[] - averageByDate: boolean -} - type ShortCutProp = [label: keyof HabitMessage['dateRange'], dayAgo: number] const shortcutProps: ShortCutProp[] = [ @@ -60,6 +54,10 @@ function allOptions(): Record { return allOptions } +const emits = { + change: (_option: HabitFilterOption) => true +} + const _default = defineComponent({ name: "HabitFilter", props: { @@ -67,7 +65,7 @@ const _default = defineComponent({ dateRange: Array as PropType, averageByDate: Boolean }, - emits: ["change"], + emits, setup(props, ctx) { const periodSize: Ref = ref(props.periodSize || 1) // @ts-ignore @@ -78,7 +76,7 @@ const _default = defineComponent({ periodSize: periodSize.value, dateRange: dateRange.value, averageByDate: averageByDate.value - } as HabitFilterOption) + }) } return () => [ // Size select diff --git a/src/app/components/habit/habit.d.ts b/src/app/components/habit/habit.d.ts new file mode 100644 index 000000000..e933606cd --- /dev/null +++ b/src/app/components/habit/habit.d.ts @@ -0,0 +1,5 @@ +declare type HabitFilterOption = { + periodSize: number + dateRange: Date[] + averageByDate: boolean +} \ No newline at end of file diff --git a/src/app/components/habit/index.ts b/src/app/components/habit/index.ts index 8139546ed..c0c98e26c 100644 --- a/src/app/components/habit/index.ts +++ b/src/app/components/habit/index.ts @@ -6,7 +6,6 @@ */ import type { Ref } from "vue" -import type { HabitFilterOption } from "./component/filter" import { defineComponent, h, ref, onMounted } from "vue" import periodService from "@service/period-service" diff --git a/src/app/components/help-us/progress-list.ts b/src/app/components/help-us/progress-list.ts index 0e820c9b7..ac1637355 100644 --- a/src/app/components/help-us/progress-list.ts +++ b/src/app/components/help-us/progress-list.ts @@ -29,6 +29,17 @@ const localeCrowdMap: { [locale in SupportedLocale]: string } = { fr: "fr", it: "it", sv: "sv-SE", + fi: "fi", + da: "da", + hr: "hr", + id: "id", + tr: "tr", + cs: "cs", + ro: "ro", + nl: "nl", + vi: "vi", + sk: "sk", + mn: "mn", } const crowdLocaleMap: { [locale: string]: SupportedLocale } = {} @@ -62,7 +73,7 @@ function renderProgressItem(progressInfo: ProgressInfo): VNode { const { locale, progress } = progressInfo return h(ElProgress, { percentage: progress, strokeWidth: 22, status: computeType(progress) }, () => [ h('span', { class: 'progress-text' }, `${progress}%`), - h('span', { class: 'language-name' }, localeMessages[locale] || locale), + h('span', { class: 'language-name' }, localeMessages[locale]?.name || locale), ]) } diff --git a/src/app/components/help-us/style.sass b/src/app/components/help-us/style.sass index 1acd46db9..29e070dc7 100644 --- a/src/app/components/help-us/style.sass +++ b/src/app/components/help-us/style.sass @@ -18,6 +18,7 @@ top: 35% color: var(--el-text-color-regular) padding-left: 15px + text-align: right .language-name,.progress-text display: block font-size: 12px diff --git a/src/app/components/limit/filter.ts b/src/app/components/limit/filter.ts index 7b149e841..dd8ef2385 100644 --- a/src/app/components/limit/filter.ts +++ b/src/app/components/limit/filter.ts @@ -5,21 +5,23 @@ * https://opensource.org/licenses/MIT */ -import { Plus } from "@element-plus/icons-vue" +import { Operation, Plus } from "@element-plus/icons-vue" import { Ref, h, defineComponent, ref } from "vue" import InputFilterItem from "@app/components/common/input-filter-item" import SwitchFilterItem from "@app/components/common/switch-filter-item" import ButtonFilterItem from "@app/components/common/button-filter-item" import { t } from "@app/locale" -export type LimitFilterOption = { - url: string - onlyEnabled: boolean -} - const urlPlaceholder = t(msg => msg.limit.conditionFilter) const onlyEnabledLabel = t(msg => msg.limit.filterDisabled) const addButtonText = t(msg => msg.limit.button.add) +const testButtonText = t(msg => msg.limit.button.test) + +const emits = { + create: () => true, + change: (_option: LimitFilterOption) => true, + test: () => true, +} const _default = defineComponent({ name: "LimitFilter", @@ -27,14 +29,14 @@ const _default = defineComponent({ url: String, onlyEnabled: Boolean }, - emits: ["create", "change"], + emits, setup(props, ctx) { const url: Ref = ref(props.url) const onlyEnabled: Ref = ref(props.onlyEnabled) const handleChange = () => ctx.emit("change", { url: url.value, onlyEnabled: onlyEnabled.value - } as LimitFilterOption) + }) return () => [ h(InputFilterItem, { placeholder: urlPlaceholder, @@ -52,6 +54,12 @@ const _default = defineComponent({ handleChange() } }), + h(ButtonFilterItem, { + text: testButtonText, + type: 'primary', + icon: Operation, + onClick: () => ctx.emit('test') + }), h(ButtonFilterItem, { text: addButtonText, type: "success", diff --git a/src/app/components/limit/index.ts b/src/app/components/limit/index.ts index 52074f917..bb7f7dc9b 100644 --- a/src/app/components/limit/index.ts +++ b/src/app/components/limit/index.ts @@ -7,13 +7,15 @@ import { defineComponent, h, ref, Ref } from "vue" import ContentContainer from "../common/content-container" -import LimitFilter, { LimitFilterOption } from "./filter" +import LimitFilter from "./filter" import LimitTable from "./table" -import Modify from "./modify" +import LimitModify from "./modify" +import LimitTest from "./test" import limitService from "@service/limit-service" import { useRoute, useRouter } from "vue-router" import { t } from "@app/locale" import { ElMessage } from "element-plus" +import { handleWindowVisibleChange } from "@util/window" const _default = defineComponent({ name: "Limit", @@ -27,12 +29,15 @@ const _default = defineComponent({ data.value = list } queryData() + // Query data if the window become visible + handleWindowVisibleChange(queryData) // Init with url parameter const urlParam = useRoute().query['url'] as string useRouter().replace({ query: {} }) urlParam && (url.value = decodeURIComponent(urlParam)) const modify: Ref = ref() + const test: Ref = ref() return () => h(ContentContainer, {}, { filter: () => h(LimitFilter, { @@ -43,23 +48,30 @@ const _default = defineComponent({ onlyEnabled.value = option.onlyEnabled queryData() }, - onCreate: () => modify.value?.show?.() + onCreate: () => modify.value?.create?.(), + onTest: () => test.value?.show?.(), }), content: () => [ h(LimitTable, { data: data.value, onDelayChange: (row: timer.limit.Item) => limitService.updateDelay(row), - onEnabledChange: (row: timer.limit.Item) => limitService.update(row), + onEnabledChange: (row: timer.limit.Item) => limitService.updateEnabled(row), async onDelete(row: timer.limit.Item) { await limitService.remove(row) ElMessage.success(t(msg => msg.limit.message.deleted)) queryData() + }, + async onModify(row: timer.limit.Item) { + modify.value?.modify?.(row) } }), - h(Modify, { + h(LimitModify, { ref: modify, onSave: queryData - }) + }), + h(LimitTest, { + ref: test + }), ] }) } diff --git a/src/app/components/limit/limit.d.ts b/src/app/components/limit/limit.d.ts new file mode 100644 index 000000000..895a22bb7 --- /dev/null +++ b/src/app/components/limit/limit.d.ts @@ -0,0 +1,4 @@ +declare type LimitFilterOption = { + url: string + onlyEnabled: boolean +} diff --git a/src/app/components/limit/modify/footer.ts b/src/app/components/limit/modify/footer.ts index e3a5d9387..8e1f3a67e 100644 --- a/src/app/components/limit/modify/footer.ts +++ b/src/app/components/limit/modify/footer.ts @@ -13,10 +13,11 @@ import { t } from "@app/locale" const buttonText = t(msg => msg.limit.button.save) const _default = defineComponent({ name: "SaveButton", - emits: ["save"], + emits: { + save: () => true + }, setup(_, ctx) { - return () => h('span', - {}, + return () => h('span', {}, h(ElButton, { onClick: () => ctx.emit("save"), type: 'primary', diff --git a/src/app/components/limit/modify/form/common.ts b/src/app/components/limit/modify/form/common.ts new file mode 100644 index 000000000..ab5233f5e --- /dev/null +++ b/src/app/components/limit/modify/form/common.ts @@ -0,0 +1,16 @@ +export function parseUrl(url: string): UrlInfo { + let protocol: Protocol = '*://' + + url = decodeURI(url)?.trim() + if (url.startsWith('http://')) { + protocol = 'http://' + url = url.substring(protocol.length) + } else if (url.startsWith('https://')) { + protocol = 'https://' + url = url.substring(protocol.length) + } else if (url.startsWith('*://')) { + protocol = '*://' + url = url.substring(protocol.length) + } + return { protocol, url } +} \ No newline at end of file diff --git a/src/app/components/limit/modify/form/form.d.ts b/src/app/components/limit/modify/form/form.d.ts new file mode 100644 index 000000000..24f864764 --- /dev/null +++ b/src/app/components/limit/modify/form/form.d.ts @@ -0,0 +1,41 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +declare type FormInfo = { + /** + * Time / seconds + */ + timeLimit: number + /** + * Protocol + path + */ + condition: string +} + +declare type UrlPart = { + /** + * The origin part text + */ + origin: string + /** + * Whether to replace with wildcard + */ + ignored: boolean +} + +declare type UrlInfo = { + protocol: Protocol + url: string +} + +/** + * The protocol of rule host + */ +declare type Protocol = + | 'http://' + | 'https://' + | '*://' diff --git a/src/app/components/limit/modify/form/index.ts b/src/app/components/limit/modify/form/index.ts index 49bf633c0..0296d1a04 100644 --- a/src/app/components/limit/modify/form/index.ts +++ b/src/app/components/limit/modify/form/index.ts @@ -6,74 +6,100 @@ */ import { ElForm } from "element-plus" -import { computed, ComputedRef, defineComponent, h, ref, Ref, SetupContext } from "vue" +import { computed, ComputedRef, defineComponent, h, reactive, ref, Ref, UnwrapRef } from "vue" import '../style/el-input' -import pathEdit from "./path-edit" -import timeFormItem from "./time-limit" -import urlFormItem, { FormUrlProps, Protocol } from "./url" -import UrlPathItem from "./url-path-item" +import LimitPathEdit from "./path-edit" +import LimitUrlFormItem from "./url" +import LimitTimeFormItem from "./time-limit" +import { parseUrl } from "./common" -// Limited time -const hourRef: Ref = ref() -const minuteRef: Ref = ref() -const secondRef: Ref = ref() - -// Limited url -const protocolRef: Ref = ref() -const pathItemsRef: Ref = ref([]) -const urlRef: ComputedRef = computed(() => pathItemsRef.value.map(i => i.toString()).join('/')) +function computeFormInfo(urlInfo: UrlInfo, timeRef: Ref): FormInfo { + const { url, protocol } = urlInfo + const result: FormInfo = { + timeLimit: timeRef.value || 0, + condition: url ? protocol + url : '' + } + return result +} -const timeProps = { hourRef, minuteRef, secondRef } -const urlProps: FormUrlProps = { protocolRef, pathItemsRef, urlRef } -const pathEditProps = { pathItemsRef } -const render = () => h(ElForm, - { labelWidth: '100px' }, - () => [timeFormItem(timeProps), urlFormItem(urlProps), pathEdit(pathEditProps)] -) +function init(timeRef: Ref, urlInfo: UrlInfo) { + // 1 hour + timeRef.value = 3600 + urlInfo.protocol = '*://' + urlInfo.url = '' +} -function init() { - hourRef.value = 1 - minuteRef.value = undefined - secondRef.value = undefined - protocolRef.value = Protocol.ALL - pathItemsRef.value = [] +function parseRow(row: timer.limit.Item, timeRef: Ref, urlInfo: UrlInfo) { + const { cond, time } = row + timeRef.value = time || 0 + const { protocol, url } = parseUrl(cond) + urlInfo.url = url + urlInfo.protocol = protocol } -init() +const _default = defineComponent({ + name: "LimitForm", + setup(_, ctx) { + // Limited time + const timeRef: Ref = ref() + // Limited url + const urlInfo: UnwrapRef = reactive({ + protocol: undefined, + url: undefined + }) + const editMode: Ref = ref('create') + init(timeRef, urlInfo) -export type FormData = { - /** - * Time / seconds - */ - timeLimit: number - /** - * Protocol + url - */ - url: string -} + const formInfo: ComputedRef = computed(() => computeFormInfo(urlInfo, timeRef)) + const canEditUrl: ComputedRef = computed(() => editMode.value === 'create') + const pathEditRef: Ref = ref() -const handleGetData = () => { - let timeLimit = 0 - timeLimit += (hourRef.value || 0) * 3600 - timeLimit += (minuteRef.value || 0) * 60 - timeLimit += (secondRef.value || 0) - const url = urlRef.value || '' - const protocol = protocolRef.value - const result: FormData = { - timeLimit, - url: url ? protocol + url : '' - } - return result -} + function setUrl(newUrl: string) { + urlInfo.url = newUrl + pathEditRef?.value?.forceUpdateUrl?.(newUrl) + } -const exposeOption = { - getData: () => handleGetData(), - clean: () => init() -} + ctx.expose({ + getData: () => formInfo.value, + clean: () => { + editMode.value = 'create' + init(timeRef, urlInfo) + }, + modify: (row: timer.limit.Item) => { + editMode.value = 'modify' + parseRow(row, timeRef, urlInfo) + setUrl(urlInfo.url) + }, + }) -const _default = defineComponent({ - setup: (_, context: SetupContext) => context.expose(exposeOption), - render + return () => h(ElForm, + { labelWidth: 120 }, + () => { + const items = [ + h(LimitTimeFormItem, { + modelValue: timeRef.value, + onChange: (newVal: number) => timeRef.value = newVal + }), + h(LimitUrlFormItem, { + url: urlInfo.url, + protocol: urlInfo.protocol, + disabled: !canEditUrl.value, + onUrlChange: (newUrl: string) => setUrl(newUrl), + onProtocolChange: (newProtocol: Protocol) => urlInfo.protocol = newProtocol + }), + ] + canEditUrl.value && items.push( + h(LimitPathEdit, { + ref: pathEditRef, + disabled: editMode.value === 'modify', + url: urlInfo.url, + onUrlChange: (newVal: string) => urlInfo.url = newVal + }) + ) + return items + } + ) + } }) export default _default \ No newline at end of file diff --git a/src/app/components/limit/modify/form/path-edit.ts b/src/app/components/limit/modify/form/path-edit.ts index b71f3e182..8ca5cdb51 100644 --- a/src/app/components/limit/modify/form/path-edit.ts +++ b/src/app/components/limit/modify/form/path-edit.ts @@ -6,20 +6,19 @@ */ import { ElSwitch, ElTag, ElTooltip } from "element-plus" -import { h, Ref, VNode } from "vue" +import { defineComponent, h, ref, Ref, VNode } from "vue" import { t } from "@app/locale" -import UrlPathItem from "./url-path-item" -type _Props = { - pathItemsRef: Ref -} +const switchStyle: Partial = { marginRight: '2px' } -const switchStyle = { marginRight: '2px' } -const tagContent = (item: UrlPathItem, index: number, arr: UrlPathItem[]) => { +const tagContent = (item: UrlPart, index: number, callback: Function) => { const result: VNode[] = [] if (!!index) { const modelValue = item.ignored - const onChange = (val: boolean) => item.ignored = val + const onChange = (val: boolean) => { + item.ignored = val + callback?.() + } const switchNode = h(ElSwitch, { style: switchStyle, modelValue, onChange }) const tooltipNode = h(ElTooltip, { content: t(msg => msg.limit.useWildcard) }, { default: () => switchNode }) @@ -29,20 +28,22 @@ const tagContent = (item: UrlPathItem, index: number, arr: UrlPathItem[]) => { return result } -const tabStyle = { marginBottom: '5px' } -const item2Tag = (item: UrlPathItem, index: number, arr: UrlPathItem[]) => { +const tabStyle: Partial = { + marginBottom: '5px', + marginRight: '0', +} + +const item2Tag = (item: UrlPart, index: number, arr: UrlPart[], onChange: Function) => { const isNotHost: boolean = !!index - return h(ElTag, - { - type: isNotHost ? '' : 'info', - closable: isNotHost, - onClose: () => arr.splice(index), - style: tabStyle + return h(ElTag, { + type: isNotHost ? '' : 'info', + closable: isNotHost, + onClose: () => { + arr.splice(index) + onChange?.() }, - { - default: () => tagContent(item, index, arr) - } - ) + style: tabStyle + }, () => tagContent(item, index, onChange)) } const combineStyle = { fontSize: '14px', @@ -55,6 +56,40 @@ const combineTags = (arr: VNode[], current: VNode) => { return arr } -const _default = (props: _Props) => h('div', {}, props.pathItemsRef.value.map(item2Tag).reduce(combineTags, [])) +function url2PathItems(url: string): UrlPart[] { + return url.split('/').filter(path => path).map(path => ({ origin: path, ignored: path === '*' })) +} + +function pathItems2Url(pathItems: UrlPart[]): string { + return pathItems.map(i => i.ignored ? '*' : i.origin || '').join('/') +} + +const _default = defineComponent({ + name: 'LimitPathEdit', + props: { + url: { + type: String, + required: false, + defaultValue: '' + }, + }, + emits: { + urlChange: (_url: string) => true + }, + setup(props, ctx) { + const url = props.url + const items: Ref = ref(url2PathItems(url)) + ctx.expose({ + forceUpdateUrl(url: string) { + items.value = url2PathItems(url) + } + }) + const handleUrlChange = () => ctx.emit('urlChange', pathItems2Url(items.value)) + return () => h('div', {}, items.value + .map((item, index, arr) => item2Tag(item, index, arr, handleUrlChange)) + .reduce(combineTags, []) + ) + } +}) export default _default \ No newline at end of file diff --git a/src/app/components/limit/modify/form/time-limit.ts b/src/app/components/limit/modify/form/time-limit.ts index 5e4511bb3..14a5042a9 100644 --- a/src/app/components/limit/modify/form/time-limit.ts +++ b/src/app/components/limit/modify/form/time-limit.ts @@ -5,51 +5,86 @@ * https://opensource.org/licenses/MIT */ +import type { ComputedRef, Ref } from "vue" + import { ElCol, ElFormItem, ElInput, ElRow } from "element-plus" -import { Ref, h } from "vue" +import { defineComponent, h, computed, ref, watch } from "vue" import { t } from "@app/locale" -type _Props = { - hourRef: Ref - minuteRef: Ref - secondRef: Ref -} - -const handleInput = (val: string, ref: Ref, maxVal: number) => { - val = val.trim() - if (!val) { +const handleInput = (inputVal: string, ref: Ref, maxVal: number) => { + inputVal = inputVal?.trim?.() + if (!inputVal) { ref.value = undefined return } - let num = Number.parseInt(val) + let num = Number.parseInt(inputVal) if (isNaN(num)) return if (num < 0) num = 0 if (num > maxVal) num = maxVal ref.value = num } -const timeInput = (valRef: Ref, unit: string, maxVal: number) => h(ElCol, { span: 8 }, - () => h(ElInput, - { - modelValue: valRef.value === undefined ? '' : valRef.value.toString(), - clearable: true, - onInput: (val: string) => handleInput(val, valRef, maxVal), - onClear: () => valRef.value = undefined, - placeholder: '0', - class: 'limit-modify-time-limit-input' - }, - { - append: () => unit - } - ) +const timeInput = (ref: Ref, unit: string, maxVal: number) => h(ElCol, { span: 8 }, + () => h(ElInput, { + modelValue: ref.value, + clearable: true, + onInput: (val: string) => handleInput(val, ref, maxVal), + onClear: () => ref.value = undefined, + placeholder: '0', + class: 'limit-modify-time-limit-input' + }, { + append: () => unit + }) ) -const timeInputCols = (props: _Props) => h(ElRow, { gutter: 10 }, () => [ - timeInput(props.hourRef, 'H', 23), - timeInput(props.minuteRef, 'M', 59), - timeInput(props.secondRef, 'S', 59) -]) +function computeSecond2LimitInfo(time: number): [number, number, number] { + time = time || 0 + const second = time % 60 + const totalMinutes = (time - second) / 60 + const minute = totalMinutes % 60 + const hour = (totalMinutes - minute) / 60 + return [hour, minute, second] +} -const timeFormItem = (props: _Props) => h(ElFormItem, { label: t(msg => msg.limit.item.time) }, () => timeInputCols(props)) +function computeLimitInfo2Second(hourRef: Ref, minuteRef: Ref, secondRef: Ref): number { + let time = 0 + time += (hourRef.value || 0) * 3600 + time += (minuteRef.value || 0) * 60 + time += (secondRef.value || 0) + return time +} + +const _default = defineComponent({ + name: "LimitTimeLimit", + props: { + modelValue: { + type: Number + } + }, + emits: { + change: (_val: number) => true + }, + setup(props, ctx) { + const [hour, minute, second] = computeSecond2LimitInfo(props.modelValue) + const hourRef: Ref = ref(hour) + const minuteRef: Ref = ref(minute) + const secondRef: Ref = ref(second) + watch(() => props.modelValue, (newVal: number) => { + const [hour, minute, second] = computeSecond2LimitInfo(newVal) + hourRef.value = hour + minuteRef.value = minute + secondRef.value = second + }) + const limitTime: ComputedRef = computed(() => computeLimitInfo2Second(hourRef, minuteRef, secondRef)) + watch([hourRef, minuteRef, secondRef], () => ctx.emit('change', limitTime.value)) + return () => h(ElFormItem, { + label: t(msg => msg.limit.item.time) + }, () => h(ElRow, { gutter: 10 }, () => [ + timeInput(hourRef, 'H', 23), + timeInput(minuteRef, 'M', 59), + timeInput(secondRef, 'S', 59) + ])) + } +}) -export default timeFormItem \ No newline at end of file +export default _default \ No newline at end of file diff --git a/src/app/components/limit/modify/form/url-path-item.ts b/src/app/components/limit/modify/form/url-path-item.ts deleted file mode 100644 index 011e83cd7..000000000 --- a/src/app/components/limit/modify/form/url-path-item.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -export default class UrlPathItem { - origin: string - ignored: boolean - - static of(origin: string, ignored?: boolean) { - const item: UrlPathItem = new UrlPathItem() - item.origin = origin - item.ignored = !!ignored - return item - } - - toString(): string { - return this.ignored ? "*" : (this.origin || '') - } -} \ No newline at end of file diff --git a/src/app/components/limit/modify/form/url.ts b/src/app/components/limit/modify/form/url.ts index fc7eb59d6..b595c63d6 100644 --- a/src/app/components/limit/modify/form/url.ts +++ b/src/app/components/limit/modify/form/url.ts @@ -5,49 +5,57 @@ * https://opensource.org/licenses/MIT */ -import { h, Ref } from "vue" +import { defineComponent, h, PropType, ref, Ref, VNode, watch } from "vue" import clipboardy from "clipboardy" import { t } from "@app/locale" import { ElButton, ElFormItem, ElInput, ElOption, ElSelect } from "element-plus" -import UrlPathItem from "./url-path-item" import { checkPermission, requestPermission } from "@src/permissions" import { IS_FIREFOX } from "@util/constant/environment" +import { parseUrl } from "./common" -export enum Protocol { - HTTP = 'http://', - HTTPS = 'https://', - ALL = '*://' -} +const ALL_PROTOCOLS: Protocol[] = ['http://', 'https://', '*://'] -type _Props = { - protocolRef: Ref - pathItemsRef: Ref - urlRef: Ref +export function computeUrl(pathItems: UrlPart[]): string { + return pathItems.map(i => i.ignored ? '*' : i.origin || '').join('/') } -export type FormUrlProps = _Props - -const protocolOptions = () => Object.entries(Protocol) - .map(([_name, value]) => value) - .map(prefix => h(ElOption, { value: prefix, label: prefix })) +const protocolOptions = () => ALL_PROTOCOLS.map(prefix => h(ElOption, { value: prefix, label: prefix })) -const protocolSelect = (protocolRef: Ref) => h(ElSelect, { - modelValue: protocolRef.value, - onChange: (val: string) => protocolRef.value = val -}, protocolOptions) - -const url2PathItems = (url: string) => { +function cleanUrl(url: string): string { + if (!url) { + return '' + } const querySign = url.indexOf('?') querySign > -1 && (url = url.substring(0, querySign)) const hashSign = url.indexOf('#') hashSign > -1 && (url = url.substring(0, hashSign)) - return url.split('/').filter(path => path).map(path => UrlPathItem.of(path)) + return url } const PERMISSION = 'clipboardRead' const FIREFOX_NO_PERMISSION_MSG = t(msg => msg.limit.message.noPermissionFirefox) -const handlePaste = async (protocolRef: Ref, pathItemsRef: Ref) => { +type _Slot = () => VNode +type _Slots = { prefix: _Slot, append?: _Slot } + +function slots(protocolRef: Ref, urlRef: Ref, disabled: boolean): _Slots { + const slots: _Slots = { + prefix: () => h(ElSelect, { + modelValue: protocolRef.value, + onChange: (val: string) => protocolRef.value = val as Protocol, + disabled: disabled, + }, protocolOptions), + } + !disabled && (slots.append = () => h(ElButton, { + onClick: () => handlePaste( + url => urlRef.value = url, + prot => protocolRef.value = prot + ) + }, () => pasteButtonText)) + return slots +} + +async function handlePaste(urlHandler: (newUrl: string) => void, protocolHandler: (newProtocol: Protocol) => void) { let granted = await checkPermission(PERMISSION) if (!granted) { @@ -67,46 +75,60 @@ const handlePaste = async (protocolRef: Ref, pathItemsRef: Ref msg.limit.button.paste) -const urlPaste = (protocolRef: Ref, pathItemsRef: Ref) => h(ElButton, - { - onClick: () => handlePaste(protocolRef, pathItemsRef) - }, - () => pasteButtonText -) +const pasteButtonText = t(msg => msg.limit.button.paste) const placeholder = t(msg => msg.limit.urlPlaceholder) -const urlInput = ({ protocolRef, urlRef, pathItemsRef }: _Props) => h(ElInput, - { - modelValue: urlRef.value, - clearable: true, - onClear: () => pathItemsRef.value = [], - // Disabled this input in the css to customized the styles - // @see ../style/el-input.sass - // @see this.onInput - // disabled: true, - onInput: (_val: string) => { /** Do nothing */ }, - placeholder +const _default = defineComponent({ + name: 'LimitUrlFormItem', + emits: { + urlChange: (_val: string) => true, + protocolChange: (_val: Protocol) => true, + }, + props: { + url: String, + protocol: String as PropType, + disabled: { + type: Boolean, + defaultValue: false + } }, - { - prefix: () => protocolSelect(protocolRef), - append: () => urlPaste(protocolRef, pathItemsRef) + setup(props, ctx) { + // protocol + const protocolRef: Ref = ref(props.protocol) + watch(() => props.protocol, () => protocolRef.value = props.protocol) + watch(protocolRef, () => ctx.emit('protocolChange', protocolRef.value)) + // url + const urlRef: Ref = ref(props.url) + watch(urlRef, () => ctx.emit('urlChange', urlRef.value)) + watch(() => props.url, () => urlRef.value = props.url) + + return () => h(ElFormItem, { label: t(msg => msg.limit.item.condition) }, + () => { + const slots_: _Slots = slots(protocolRef, urlRef, props.disabled) + return h(ElInput, { + modelValue: urlRef.value, + clearable: !props.disabled, + disabled: props.disabled, + onClear() { + urlRef.value = '' + ctx.emit('urlChange', '') + }, + // Disabled this input in the css to customized the styles + // @see ../style/el-input.sass + // @see this.onInput + // disabled: true, + onInput: (_val: string) => { /** Do nothing */ }, + placeholder + }, slots_) + } + ) } -) -const urlFormItem = (props: _Props) => h(ElFormItem, { label: t(msg => msg.limit.item.condition) }, () => urlInput(props)) +}) -export default urlFormItem \ No newline at end of file +export default _default \ No newline at end of file diff --git a/src/app/components/limit/modify/index.ts b/src/app/components/limit/modify/index.ts index f2c8ac6e3..78994723b 100644 --- a/src/app/components/limit/modify/index.ts +++ b/src/app/components/limit/modify/index.ts @@ -6,8 +6,8 @@ */ import { ElDialog, ElMessage } from "element-plus" -import { defineComponent, h, ref, Ref } from "vue" -import Form, { FormData } from "./form" +import { defineComponent, h, nextTick, ref, Ref } from "vue" +import Form from "./form" import Footer from "./footer" import LimitDatabase from "@db/limit-database" import { t } from "@app/locale" @@ -17,13 +17,29 @@ const db = new LimitDatabase(chrome.storage.local) const noUrlError = t(msg => msg.limit.message.noUrl) const noTimeError = t(msg => msg.limit.message.noTime) const _default = defineComponent({ - emits: ['save'], + emits: { + save: (_saved: timer.limit.Rule) => true + }, setup: (_, ctx) => { const visible: Ref = ref(false) const form: Ref = ref() + const mode: Ref = ref() + // Cache + let modifyingItem: timer.limit.Item = undefined ctx.expose({ - show: () => visible.value = true, + create() { + visible.value = true + mode.value = 'create' + modifyingItem = undefined + nextTick(() => form.value?.clean?.()) + }, + modify(row: timer.limit.Item) { + visible.value = true + mode.value = 'modify' + modifyingItem = { ...row } + nextTick(() => form.value?.modify?.(row)) + }, hide: () => visible.value = false }) @@ -36,8 +52,8 @@ const _default = defineComponent({ default: () => h(Form, { ref: form }), footer: () => h(Footer, { async onSave() { - const { url, timeLimit }: FormData = form.value?.getData?.() - if (!url) { + const { condition, timeLimit }: FormInfo = form.value?.getData?.() + if (!condition) { ElMessage.warning(noUrlError) return } @@ -45,12 +61,16 @@ const _default = defineComponent({ ElMessage.warning(noTimeError) return } - const toInsert: timer.limit.Rule = { cond: url, time: timeLimit, enabled: true, allowDelay: true } - await db.save(toInsert) + const toSave: timer.limit.Rule = { cond: condition, time: timeLimit, enabled: true, allowDelay: true } + if (mode.value === 'modify' && modifyingItem) { + toSave.enabled = modifyingItem.enabled + toSave.allowDelay = modifyingItem.allowDelay + } + await db.save(toSave, mode.value === 'modify') visible.value = false ElMessage.success(t(msg => msg.limit.message.saved)) form.value?.clean?.() - ctx.emit("save", toInsert) + ctx.emit("save", toSave) } }) }) diff --git a/src/app/components/limit/modify/modify.d.ts b/src/app/components/limit/modify/modify.d.ts new file mode 100644 index 000000000..a5ece4c0b --- /dev/null +++ b/src/app/components/limit/modify/modify.d.ts @@ -0,0 +1 @@ +declare type Mode = 'modify' | 'create' \ No newline at end of file diff --git a/src/app/components/limit/table/column/delay.ts b/src/app/components/limit/table/column/delay.ts index 66710768e..616aa280c 100644 --- a/src/app/components/limit/table/column/delay.ts +++ b/src/app/components/limit/table/column/delay.ts @@ -15,7 +15,9 @@ const tooltip = t(msg => msg.limit.item.delayAllowedInfo) const _default = defineComponent({ name: "LimitDelayColumn", - emits: ["rowChange"], + emits: { + rowChange: (_row: timer.limit.Rule, _val: boolean) => true, + }, setup(_, ctx) { return () => h(ElTableColumn, { prop: 'delayClosed', diff --git a/src/app/components/limit/table/column/enabled.ts b/src/app/components/limit/table/column/enabled.ts index 00fbde150..60b1d0ac9 100644 --- a/src/app/components/limit/table/column/enabled.ts +++ b/src/app/components/limit/table/column/enabled.ts @@ -13,7 +13,9 @@ const label = t(msg => msg.limit.item.enabled) const _default = defineComponent({ name: "LimitEnabledColumn", - emits: ["rowChange"], + emits: { + rowChange: (_row: timer.limit.Item, _val: boolean) => true + }, setup(_, ctx) { return () => h(ElTableColumn, { prop: 'enabled', diff --git a/src/app/components/limit/table/column/operation.ts b/src/app/components/limit/table/column/operation.ts index efbade6aa..a42e68094 100644 --- a/src/app/components/limit/table/column/operation.ts +++ b/src/app/components/limit/table/column/operation.ts @@ -5,21 +5,25 @@ * https://opensource.org/licenses/MIT */ -import { Delete } from "@element-plus/icons-vue" +import { Delete, Edit } from "@element-plus/icons-vue" import { ElButton, ElMessageBox, ElTableColumn } from "element-plus" import { defineComponent, h } from "vue" import { t } from "@app/locale" const label = t(msg => msg.limit.item.operation) const deleteButtonText = t(msg => msg.limit.button.delete) +const modifyButtonText = t(msg => msg.limit.button.modify) const _default = defineComponent({ name: "LimitOperationColumn", - emits: ["rowDelete"], + emits: { + rowDelete: (_row: timer.limit.Item, _cond: string) => true, + rowModify: (_row: timer.limit.Item) => true, + }, setup(_props, ctx) { return () => h(ElTableColumn, { prop: 'operations', label, - minWidth: 80, + minWidth: 200, align: 'center', }, { default: ({ row }: { row: timer.limit.Item }) => [ @@ -34,7 +38,13 @@ const _default = defineComponent({ .then(() => ctx.emit("rowDelete", row, cond)) .catch(() => { /** Do nothing */ }) } - }, () => deleteButtonText) + }, () => deleteButtonText), + h(ElButton, { + type: 'primary', + size: 'small', + icon: Edit, + onClick: () => ctx.emit('rowModify', row), + }, () => modifyButtonText) ] }) } diff --git a/src/app/components/limit/table/index.ts b/src/app/components/limit/table/index.ts index 8b3a88346..07f4bd181 100644 --- a/src/app/components/limit/table/index.ts +++ b/src/app/components/limit/table/index.ts @@ -19,7 +19,12 @@ const _default = defineComponent({ props: { data: Array as PropType }, - emits: ["delayChange", "enabledChange", "delete"], + emits: { + delayChange: (_row: timer.limit.Item) => true, + enabledChange: (_row: timer.limit.Item) => true, + delete: (_row: timer.limit.Item) => true, + modify: (_row: timer.limit.Item) => true, + }, setup(props, ctx) { return () => h(ElTable, { border: true, @@ -33,13 +38,14 @@ const _default = defineComponent({ h(LimitTimeColumn), h(LimitWasteColumn), h(LimitDelayColumn, { - onRowChange: (row: timer.limit.Item, _allowDelay: boolean) => ctx.emit("delayChange", row) + onRowChange: (row: timer.limit.Item) => ctx.emit("delayChange", row) }), h(LimitEnabledColumn, { - onRowChange: (row: timer.limit.Item, _enabled: boolean) => ctx.emit("enabledChange", row) + onRowChange: (row: timer.limit.Item) => ctx.emit("enabledChange", row) }), h(LimitOperationColumn, { - onRowDelete: (row: timer.limit.Item, _cond: string) => ctx.emit("delete", row) + onRowDelete: (row: timer.limit.Item) => ctx.emit("delete", row), + onRowModify: (row: timer.limit.Item) => ctx.emit("modify", row), }) ]) } diff --git a/src/app/components/limit/test.ts b/src/app/components/limit/test.ts new file mode 100644 index 000000000..5b5f0295e --- /dev/null +++ b/src/app/components/limit/test.ts @@ -0,0 +1,102 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { t } from "@app/locale" +import limitService from "@service/limit-service" +import { ElAlert, ElButton, ElDialog, ElFormItem, ElInput } from "element-plus" +import { defineComponent, Ref, ref, h, ComputedRef, computed } from "vue" + +async function handleTest(url: string): Promise { + const items = await limitService.select({ url, filterDisabled: true }) + return items.map(v => v.cond) +} + +function computeResultTitle(url: string, inputting: boolean, matchedCondition: string[]): string { + if (!url) { + return t(msg => msg.limit.message.inputTestUrl) + } + if (inputting) { + return t(msg => msg.limit.message.clickTestButton, { buttonText: t(msg => msg.limit.button.testSimple) }) + } + if (!matchedCondition?.length) { + return t(msg => msg.limit.message.noRuleMatched) + } else { + return t(msg => msg.limit.message.rulesMatched) + } +} + +function computeResultDesc(url: string, inputting: boolean, matchedCondition: string[]): string[] { + if (!url || inputting || !matchedCondition?.length) { + return [] + } + return matchedCondition +} + +type _ResultType = 'info' | 'success' | 'warning' + +function computeResultType(url: string, inputting: boolean, matchedCondition: string[]): _ResultType { + if (!url || inputting) { + return 'info' + } + return matchedCondition?.length ? 'success' : 'warning' +} + +const _default = defineComponent({ + name: "LimitTest", + setup(_props, ctx) { + const urlRef: Ref = ref() + const matchedConditionRef: Ref = ref([]) + const visible: Ref = ref(false) + const urlInputtingRef: Ref = ref(true) + const resultTitleRef: ComputedRef = computed(() => computeResultTitle(urlRef.value, urlInputtingRef.value, matchedConditionRef.value)) + const resultTypeRef: ComputedRef<_ResultType> = computed(() => computeResultType(urlRef.value, urlInputtingRef.value, matchedConditionRef.value)) + const resultDescRef: ComputedRef = computed(() => computeResultDesc(urlRef.value, urlInputtingRef.value, matchedConditionRef.value)) + + const changeInput = (newVal: string) => (urlInputtingRef.value = true) && (urlRef.value = newVal?.trim()) + const test = () => { + urlInputtingRef.value = false + handleTest(urlRef.value).then(matched => matchedConditionRef.value = matched) + } + + ctx.expose({ + show() { + urlRef.value = '' + visible.value = true + urlInputtingRef.value = true + matchedConditionRef.value = [] + } + }) + return () => h(ElDialog, { + title: t(msg => msg.limit.button.testSimple), + modelValue: visible.value, + closeOnClickModal: false, + onClose: () => visible.value = false + }, () => [ + h(ElFormItem, { + label: t(msg => msg.limit.testUrlLabel), + labelWidth: 120 + }, () => h(ElInput, { + modelValue: urlRef.value, + clearable: true, + onClear: () => changeInput(''), + onKeyup: (event: KeyboardEvent) => event.key === 'Enter' && test(), + onInput: (newVal: string) => changeInput(newVal) + }, { + append: () => h(ElButton, { + onClick: () => test() + }, () => t(msg => msg.limit.button.testSimple)), + })), + h(ElAlert, { + closable: false, + type: resultTypeRef.value, + title: resultTitleRef.value, + }, () => resultDescRef.value.map(desc => h('li', desc))) + ]) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/option/components/appearance/dark-mode-input.ts b/src/app/components/option/components/appearance/dark-mode-input.ts index 35e8ad236..0cfa67610 100644 --- a/src/app/components/option/components/appearance/dark-mode-input.ts +++ b/src/app/components/option/components/appearance/dark-mode-input.ts @@ -37,7 +37,9 @@ const _default = defineComponent({ startSecond: Number, endSecond: Number }, - emits: ["change"], + emits: { + change: (_darkMode: timer.option.DarkMode, [_startSecond, _endSecond]: [number, number]) => true + }, setup(props, ctx) { const darkMode: Ref = ref(props.modelValue) // @ts-ignore diff --git a/src/app/components/option/components/appearance/index.ts b/src/app/components/option/components/appearance/index.ts index 8c2dc4e55..b97b9d672 100644 --- a/src/app/components/option/components/appearance/index.ts +++ b/src/app/components/option/components/appearance/index.ts @@ -75,7 +75,7 @@ const locale = (option: UnwrapRef) => h(ElSelect, locale => h(ElOption, { value: locale, label: locale === "default" ? t(msg => msg.option.appearance.locale.default) - : localeMessages[locale] + : localeMessages[locale].name }) ) }) diff --git a/src/app/components/option/components/backup/auto-input.ts b/src/app/components/option/components/backup/auto-input.ts new file mode 100644 index 000000000..8a92177ca --- /dev/null +++ b/src/app/components/option/components/backup/auto-input.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { t, tN } from "@app/locale" +import { ElInputNumber, ElSwitch } from "element-plus" +import { defineComponent, ref, watch, h } from "vue" +import localeMessages from "@i18n/message/common/locale" +import { locale } from "@i18n" + +const _default = defineComponent({ + name: 'BackUpAutoInput', + props: { + autoBackup: Boolean, + interval: Number + }, + emits: { + change: (_autoBackUp: boolean, _interval: number) => true + }, + setup(props, ctx) { + const autoBackUp = ref(props.autoBackup) + const interval = ref(props.interval) + watch(() => props.autoBackup, newVal => autoBackUp.value = newVal) + watch(() => props.interval, newVal => interval.value = newVal) + + const handleChange = () => ctx.emit('change', autoBackUp.value, interval.value) + + return () => { + const result = [ + h(ElSwitch, { + modelValue: autoBackUp.value, + onChange: (newVal: boolean) => { + autoBackUp.value = newVal + handleChange() + } + }), + ' ' + t(msg => msg.option.backup.auto.label), + ] + autoBackUp.value && result.push( + localeMessages[locale].comma || ' ', + ...tN(msg => msg.option.backup.auto.interval, { + input: h(ElInputNumber, { + min: 10, + size: 'small', + modelValue: interval.value, + onChange(newVal) { + interval.value = newVal + handleChange() + }, + }) + }) + ) + return result + } + }, +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/option/components/backup.ts b/src/app/components/option/components/backup/index.ts similarity index 80% rename from src/app/components/option/components/backup.ts rename to src/app/components/option/components/backup/index.ts index cc585a84d..0cbd73441 100644 --- a/src/app/components/option/components/backup.ts +++ b/src/app/components/option/components/backup/index.ts @@ -9,12 +9,13 @@ import { Ref } from "vue" import { t } from "@app/locale" import optionService from "@service/option-service" -import processor from "@src/background/backup/processor" +import processor from "@src/common/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 { renderOptionItem, tooltip } from "../../common" import { UploadFilled } from "@element-plus/icons-vue" +import BackUpAutoInput from "./auto-input" const ALL_TYPES: timer.backup.Type[] = [ 'none', @@ -74,6 +75,8 @@ const _default = defineComponent({ const type: Ref = ref(DEFAULT.backupType) const auth: Ref = ref('') const clientName: Ref = ref(DEFAULT.clientName) + const autoBackUp: Ref = ref(DEFAULT.autoBackUp) + const autoBackUpInterval: Ref = ref(DEFAULT.autoBackUpInterval) optionService.getAllOption().then(currentVal => { clientName.value = currentVal.clientName @@ -81,6 +84,8 @@ const _default = defineComponent({ if (type.value) { auth.value = currentVal.backupAuths?.[type.value] } + autoBackUp.value = currentVal.autoBackUp + autoBackUpInterval.value = currentVal.autoBackUpInterval }) function handleChange() { @@ -89,7 +94,9 @@ const _default = defineComponent({ const newOption: timer.option.BackupOption = { backupType: type.value, backupAuths, - clientName: clientName.value || DEFAULT.clientName + clientName: clientName.value || DEFAULT.clientName, + autoBackUp: autoBackUp.value, + autoBackUpInterval: autoBackUpInterval.value, } optionService.setBackupOption(newOption) } @@ -122,8 +129,9 @@ const _default = defineComponent({ ctx.expose({ async reset() { - // Only reset type + // Only reset type and auto flag type.value = DEFAULT.backupType + autoBackUp.value = DEFAULT.autoBackUp handleChange() } }) @@ -144,6 +152,18 @@ const _default = defineComponent({ ) ] type.value !== 'none' && nodes.push( + h(ElDivider), + renderOptionItem({ + input: h(BackUpAutoInput, { + autoBackup: autoBackUp.value, + interval: autoBackUpInterval.value, + onChange(newAutoBackUp, newInterval) { + autoBackUp.value = newAutoBackUp + autoBackUpInterval.value = newInterval + handleChange() + } + }) + }, _msg => '{input}', t(msg => msg.option.no)), h(ElDivider), renderOptionItem({ input: authInput(auth, handleChange, handleTest), @@ -162,7 +182,7 @@ const _default = defineComponent({ type: 'primary', icon: UploadFilled, onClick: handleBackup - }, () => t(msg => msg.option.backup.operation)) + }, () => t(msg => msg.option.backup.operation)), ) return h('div', nodes) } diff --git a/src/app/components/option/components/popup.ts b/src/app/components/option/components/popup.ts index 036e5b7af..3887978ed 100644 --- a/src/app/components/option/components/popup.ts +++ b/src/app/components/option/components/popup.ts @@ -53,7 +53,7 @@ const durationSelect = (option: UnwrapRef) => h(ElSele modelValue: option.defaultDuration, size: 'small', style: { width: t(msg => msg.option.popup.durationWidth) }, - onChange: (val: timer.popup.Duration) => { + onChange: (val: PopupDuration) => { option.defaultDuration = val optionService.setPopupOption(unref(option)) } @@ -103,11 +103,7 @@ const _default = defineComponent({ name: "PopupOptionContainer", setup(_props, ctx) { const option: UnwrapRef = reactive(defaultPopup()) - optionService.getAllOption().then(currentVal => { - // Remove total @since v1.3.4 - currentVal.defaultType === 'total' && (currentVal.defaultType = 'focus') - copy(option, currentVal) - }) + optionService.getAllOption().then(currentVal => copy(option, currentVal)) ctx.expose({ async reset() { copy(option, defaultPopup()) diff --git a/src/app/components/option/components/statistics.ts b/src/app/components/option/components/statistics.ts index f9c901dce..741f59dc0 100644 --- a/src/app/components/option/components/statistics.ts +++ b/src/app/components/option/components/statistics.ts @@ -50,7 +50,7 @@ function renderOptionItems(option: timer.option.StatisticsOption) { input: countWhenIdle(option), idleTime: tagText(msg => msg.option.statistics.idleTime), info: tooltip(msg => msg.option.statistics.idleTimeInfo) - }, msg => msg.statistics.countWhenIdle, t(msg => msg.option.no)), + }, msg => msg.statistics.countWhenIdle, t(msg => msg.option.yes)), h(ElDivider) ) } diff --git a/src/app/components/report/file-export.ts b/src/app/components/report/file-export.ts index 9ac26cd80..89dd325aa 100644 --- a/src/app/components/report/file-export.ts +++ b/src/app/components/report/file-export.ts @@ -17,7 +17,6 @@ type _ExportInfo = { host: string alias?: string date?: string - total?: string focus?: string time?: number } @@ -25,7 +24,7 @@ type _ExportInfo = { /** * Compute the name of downloaded file */ -function computeFileName(filterParam: timer.app.report.FilterOption): string { +function computeFileName(filterParam: ReportFilterOption): string { let baseName = t(msg => msg.report.exportFileName) const { dateRange, mergeDate, mergeHost, timeFormat } = filterParam if (dateRange && dateRange.length === 2) { @@ -45,7 +44,6 @@ const generateJsonData = (rows: timer.stat.Row[]) => rows.map(row => { data.date = row.date data.alias = row.alias // Always display by seconds - data.total = periodFormatter(row.total, "second", true) data.focus = periodFormatter(row.focus, "second", true) data.time = row.time return data @@ -57,13 +55,13 @@ const generateJsonData = (rows: timer.stat.Row[]) => rows.map(row => { * @param filterParam filter params * @param rows row data */ -export function exportJson(filterParam: timer.app.report.FilterOption, rows: timer.stat.Row[]): void { +export function exportJson(filterParam: ReportFilterOption, rows: timer.stat.Row[]): void { const fileName = computeFileName(filterParam) const jsonData = generateJsonData(rows) exportJson_(jsonData, fileName) } -function generateCsvData(rows: timer.stat.Row[], filterParam: timer.app.report.FilterOption): string[][] { +function generateCsvData(rows: timer.stat.Row[], filterParam: ReportFilterOption): string[][] { const { mergeDate, mergeHost } = filterParam const columnName: string[] = [] if (!mergeDate) { @@ -73,7 +71,6 @@ function generateCsvData(rows: timer.stat.Row[], filterParam: timer.app.report.F if (!mergeHost) { columnName.push(t(msg => msg.siteManage.column.alias)) } - columnName.push(t(msg => msg.item.total)) columnName.push(t(msg => msg.item.focus)) columnName.push(t(msg => msg.item.time)) const data = [columnName] @@ -86,7 +83,6 @@ function generateCsvData(rows: timer.stat.Row[], filterParam: timer.app.report.F if (!mergeHost) { line.push(row.alias || '') } - line.push(periodFormatter(row.total, "second", true)) line.push(periodFormatter(row.focus, "second", true)) line.push(row.time) data.push(line) @@ -100,7 +96,7 @@ function generateCsvData(rows: timer.stat.Row[], filterParam: timer.app.report.F * @param filterParam filter params * @param rows row data */ -export function exportCsv(filterParam: timer.app.report.FilterOption, rows: timer.stat.Row[]): void { +export function exportCsv(filterParam: ReportFilterOption, rows: timer.stat.Row[]): void { const fileName = computeFileName(filterParam) const csvData = generateCsvData(rows, filterParam) exportCsv_(csvData, fileName) diff --git a/src/app/components/report/filter/download-file.ts b/src/app/components/report/filter/download-file.ts index 5d1521e87..c06912bc3 100644 --- a/src/app/components/report/filter/download-file.ts +++ b/src/app/components/report/filter/download-file.ts @@ -9,13 +9,13 @@ import { Download } from "@element-plus/icons-vue" import { ElButton, ElDropdown, ElDropdownItem, ElDropdownMenu, ElIcon } from "element-plus" import { h, defineComponent } from "vue" -export type FileFormat = 'json' | 'csv' - const ALL_FILE_FORMATS: FileFormat[] = ["json", "csv"] const _default = defineComponent({ name: "FileDownload", - emits: ["download"], + emits: { + download: (_format: FileFormat) => true, + }, setup(_, ctx) { return () => h(ElDropdown, { class: 'export-dropdown', showTimeout: 100 }, { default: () => h(ElButton, diff --git a/src/app/components/report/filter/index.ts b/src/app/components/report/filter/index.ts index 4d78d9d34..084e8b985 100644 --- a/src/app/components/report/filter/index.ts +++ b/src/app/components/report/filter/index.ts @@ -5,7 +5,6 @@ * https://opensource.org/licenses/MIT */ -import type { FileFormat } from "./download-file" import type { Ref, PropType } from "vue" import type { ElementDatePickerShortcut } from "@src/element-ui/date" import type { ReportMessage } from "@i18n/message/app/report" @@ -60,7 +59,12 @@ const _default = defineComponent({ mergeHost: Boolean, timeFormat: String as PropType }, - emits: ["change", "download", "batchDelete", 'remoteChange'], + emits: { + change: (_filterOption: ReportFilterOption) => true, + download: (_format: FileFormat) => true, + batchDelete: (_filterOption: ReportFilterOption) => true, + remoteChange: (_readRemote: boolean) => true, + }, setup(props, ctx) { const host: Ref = ref(props.host) // Don't know why the error occurred, so ignore @@ -78,7 +82,7 @@ const _default = defineComponent({ mergeDate: mergeDate.value, mergeHost: mergeHost.value, timeFormat: timeFormat.value - } as timer.app.report.FilterOption) + } as ReportFilterOption) const handleChange = () => ctx.emit("change", computeOption()) timerService.canReadRemote().then(abled => remoteSwitchVisible.value = abled) return () => [ diff --git a/src/app/components/report/filter/remote-client.ts b/src/app/components/report/filter/remote-client.ts index fccd12f9d..aaafaadef 100644 --- a/src/app/components/report/filter/remote-client.ts +++ b/src/app/components/report/filter/remote-client.ts @@ -14,7 +14,9 @@ import { defineComponent, ref, h, watch } from "vue" const _default = defineComponent({ name: "ClientSelect", - emits: ["change"], + emits: { + change: (_readRemote: boolean) => true + }, props: { visible: Boolean }, diff --git a/src/app/components/report/formatter.ts b/src/app/components/report/formatter.ts index 85223b6ec..c45cb48f2 100644 --- a/src/app/components/report/formatter.ts +++ b/src/app/components/report/formatter.ts @@ -27,7 +27,7 @@ export function dateFormatter(date: string): string { * @param timeFormat * @param hideUnit */ -export const periodFormatter = (milliseconds: number, timeFormat?: timer.app.TimeFormat, hideUnit?: boolean) => { +export function periodFormatter(milliseconds: number, timeFormat?: timer.app.TimeFormat, hideUnit?: boolean): string { const format = timeFormat || "default" if (milliseconds === undefined) { if (format === 'default') { diff --git a/src/app/components/report/index.ts b/src/app/components/report/index.ts index d5d7b3d74..308b4eb55 100644 --- a/src/app/components/report/index.ts +++ b/src/app/components/report/index.ts @@ -7,16 +7,14 @@ import type { Ref, UnwrapRef, ComputedRef } from "vue" import type { TimerQueryParam } from "@service/timer-service" -import type { SortInfo } from "./table" -import type { FileFormat } from "./filter/download-file" import type { Router, RouteLocation } from "vue-router" import { computed, defineComponent, h, reactive, ref } from "vue" import { I18nKey, t } from "@app/locale" -import timerService, { SortDirect } from "@service/timer-service" +import timerService from "@service/timer-service" import whitelistService from "@service/whitelist-service" import './styles/element' -import ReportTable, { ElSortDirect } from "./table" +import ReportTable from "./table" import ReportFilter from "./filter" import Pagination from "../common/pagination" import ContentContainer from "../common/content-container" @@ -28,6 +26,7 @@ import { groupBy, sum } from "@util/array" import { formatTime } from "@util/time" import TimerDatabase from "@db/timer-database" import { IS_SAFARI } from "@util/constant/environment" +import { handleWindowVisibleChange } from "@util/window" const timerDatabase = new TimerDatabase(chrome.storage.local) @@ -122,7 +121,7 @@ async function computeBatchDeleteMsg(selected: timer.stat.Row[], mergeDate: bool return t(key, i18nParam) } -async function handleBatchDelete(tableEl: Ref, filterOption: timer.app.report.FilterOption, afterDelete: Function) { +async function handleBatchDelete(tableEl: Ref, filterOption: ReportFilterOption, afterDelete: Function) { const selected: timer.stat.Row[] = tableEl?.value?.getSelected?.() || [] if (!selected?.length) { ElMessage({ type: "info", message: t(msg => msg.report.batchDelete.noSelectedMsg) }) @@ -167,8 +166,8 @@ async function deleteBatch(selected: timer.stat.Row[], mergeDate: boolean, dateR /** * Init the query parameters */ -function initQueryParam(route: RouteLocation, router: Router): [timer.app.report.FilterOption, SortInfo] { - const routeQuery: timer.app.report.QueryParam = route.query as unknown as timer.app.report.QueryParam +function initQueryParam(route: RouteLocation, router: Router): [ReportFilterOption, SortInfo] { + const routeQuery: ReportQueryParam = route.query as unknown as ReportQueryParam const { mh, ds, de, sc } = routeQuery const dateStart = ds ? new Date(Number.parseInt(ds)) : undefined const dateEnd = ds ? new Date(Number.parseInt(de)) : undefined @@ -176,7 +175,7 @@ function initQueryParam(route: RouteLocation, router: Router): [timer.app.report router.replace({ query: {} }) const now = new Date() - const filterOption: timer.app.report.FilterOption = { + const filterOption: ReportFilterOption = { host: '', dateRange: [dateStart || now, dateEnd || now], mergeDate: false, @@ -185,23 +184,23 @@ function initQueryParam(route: RouteLocation, router: Router): [timer.app.report } const sortInfo: SortInfo = { prop: sc || 'focus', - order: ElSortDirect.DESC + order: 'descending' } return [filterOption, sortInfo] } -function computeTimerQueryParam(filterOption: timer.app.report.FilterOption, sort: SortInfo): TimerQueryParam { +function computeTimerQueryParam(filterOption: ReportFilterOption, sort: SortInfo): TimerQueryParam { return { host: filterOption.host, date: filterOption.dateRange, mergeHost: filterOption.mergeHost, mergeDate: filterOption.mergeDate, sort: sort.prop, - sortOrder: sort.order === ElSortDirect.ASC ? SortDirect.ASC : SortDirect.DESC + sortOrder: sort.order === 'ascending' ? 'ASC' : 'DESC' } } -function copyFilterParam(newVal: timer.app.report.FilterOption, oldVal: timer.app.report.FilterOption) { +function copyFilterParam(newVal: ReportFilterOption, oldVal: ReportFilterOption) { oldVal.host = newVal.host oldVal.dateRange = newVal.dateRange oldVal.mergeDate = newVal.mergeDate @@ -215,7 +214,7 @@ const _default = defineComponent({ const route = useRoute() const router = useRouter() const [initialFilterParam, initialSort] = initQueryParam(route, router) - const filterOption: UnwrapRef = reactive(initialFilterParam) + const filterOption: UnwrapRef = reactive(initialFilterParam) const sort: UnwrapRef = reactive(initialSort) const data: Ref = ref([]) @@ -227,6 +226,8 @@ const _default = defineComponent({ const tableEl: Ref = ref() const query = () => queryData(queryParam, data, page, remoteRead) + // Query data if window become visible + handleWindowVisibleChange(query) // Init first queryWhiteList(whitelist).then(query) @@ -237,7 +238,7 @@ const _default = defineComponent({ mergeDate: filterOption.mergeDate, mergeHost: filterOption.mergeHost, timeFormat: filterOption.timeFormat, - onChange: (newFilterOption: timer.app.report.FilterOption) => { + onChange: (newFilterOption: ReportFilterOption) => { copyFilterParam(newFilterOption, filterOption) query() }, @@ -246,7 +247,7 @@ const _default = defineComponent({ format === 'json' && exportJson(filterOption, rows) format === 'csv' && exportCsv(filterOption, rows) }, - onBatchDelete: (filterOption: timer.app.report.FilterOption) => handleBatchDelete(tableEl, filterOption, query), + onBatchDelete: (filterOption: ReportFilterOption) => handleBatchDelete(tableEl, filterOption, query), onRemoteChange(newRemoteChange) { remoteRead.value = newRemoteChange query() @@ -259,6 +260,7 @@ const _default = defineComponent({ mergeHost: filterOption.mergeHost, timeFormat: filterOption.timeFormat, dateRange: filterOption.dateRange, + readRemote: remoteRead.value, data: data.value, defaultSort: sort, ref: tableEl, diff --git a/src/app/components/report/report.d.ts b/src/app/components/report/report.d.ts new file mode 100644 index 000000000..3bc8ebd76 --- /dev/null +++ b/src/app/components/report/report.d.ts @@ -0,0 +1,42 @@ + +declare type FileFormat = 'json' | 'csv' + +declare type SortDirect = 'ascending' | 'descending' + +declare type SortInfo = { + prop: timer.stat.Dimension | 'host' + order: SortDirect +} + +declare type ReportFilterOption = { + host: string + dateRange: Date[] + mergeDate: boolean + mergeHost: boolean + /** + * @since 1.1.7 + */ + timeFormat: timer.app.TimeFormat +} + +/** +* The query param of report page +*/ +declare type ReportQueryParam = { + /** + * Merge host + */ + mh?: string + /** + * Date start + */ + ds?: string + /** + * Date end + */ + de?: string + /** + * Sorted column + */ + sc?: timer.stat.Dimension +} \ No newline at end of file diff --git a/src/app/components/report/table/columns/alias-info.ts b/src/app/components/report/table/columns/alias-info.ts index e612970f2..c54f7639a 100644 --- a/src/app/components/report/table/columns/alias-info.ts +++ b/src/app/components/report/table/columns/alias-info.ts @@ -17,9 +17,11 @@ type _Data = { input: Ref } -type _Emits = "change" +type _Emits = { + change: (_newVal: string) => true +} -function renderEditing(data: _Data, ctx: SetupContext<_Emits[]>) { +function renderEditing(data: _Data, ctx: SetupContext<_Emits>) { return h(ElInput, { size: 'small', ref: data.input, @@ -71,7 +73,7 @@ function renderText(data: _Data) { return result } -function render(data: _Data, ctx) { +function render(data: _Data, ctx: SetupContext<_Emits>) { const isEditing = data.editing.value if (isEditing) { return renderEditing(data, ctx) @@ -89,7 +91,9 @@ const _default = defineComponent({ type: String } }, - emits: ['change'], + emits: { + change: (_newAlias: string) => true + }, setup(props, ctx) { const editing = ref(false) const originVal = ref(props.modelValue) diff --git a/src/app/components/report/table/columns/alias.ts b/src/app/components/report/table/columns/alias.ts index f3c148adc..a7718f1ba 100644 --- a/src/app/components/report/table/columns/alias.ts +++ b/src/app/components/report/table/columns/alias.ts @@ -17,7 +17,9 @@ const columnLabel = t(msg => msg.siteManage.column.alias) const _default = defineComponent({ name: "AliasColumn", - emits: ["aliasChange"], + emits: { + aliasChange: (_host: string, _newAlias: string) => true, + }, setup(_, ctx) { return () => h(ElTableColumn, { label: columnLabel, diff --git a/src/app/components/report/table/columns/composition-table.ts b/src/app/components/report/table/columns/composition-table.ts new file mode 100644 index 000000000..db137092e --- /dev/null +++ b/src/app/components/report/table/columns/composition-table.ts @@ -0,0 +1,74 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { computed, ComputedRef, defineComponent, h, PropType } from "vue" +import { ElTable, ElTableColumn } from "element-plus" +import { sum } from "@util/array" +import { t } from "@app/locale" + +type Row = { + name: string + value: number + percent?: string +} + +type ValueFormatter = (val: number) => string + +const CLIENT_NAME = t(msg => msg.report.remoteReading.table.client) +const VALUE = t(msg => msg.report.remoteReading.table.value) +const LOCAL_DATA = t(msg => msg.report.remoteReading.table.localData) +const PERCENTAGE = t(msg => msg.report.remoteReading.table.percentage) + +function computeRows(data: timer.stat.RemoteCompositionVal[]): Row[] { + const rows: Row[] = data.map(e => typeof e === 'number' + ? { name: LOCAL_DATA, value: e || 0 } + : { name: e.cname || e.cid, value: e.value } + ) + const total = sum(rows.map(row => row.value)) + total && rows.forEach(row => row.percent = (row.value / total * 100).toFixed(2) + ' %') + rows.sort((a, b) => b.value - a.value) + return rows +} + +const _default = defineComponent({ + name: 'CompositionChart', + props: { + data: Array as PropType, + valueFormatter: Function as PropType, + }, + setup(props) { + const rows: ComputedRef = computed(() => computeRows(props.data)) + return () => h('div', { style: { width: '400px' } }, + h(ElTable, { + data: rows.value, + size: 'small', + border: true, + }, () => [ + h(ElTableColumn, { + label: CLIENT_NAME, + formatter: (row: Row) => row.name, + align: 'center', + width: 150, + }), + h(ElTableColumn, { + label: VALUE, + formatter: (row: Row) => props.valueFormatter(row.value), + align: 'center', + width: 150, + }), + h(ElTableColumn, { + label: PERCENTAGE, + align: 'center', + formatter: (row: Row) => row.percent, + width: 100, + }), + ]) + ) + } +}) + +export default _default \ No newline at end of file diff --git a/src/app/components/report/table/columns/focus.ts b/src/app/components/report/table/columns/focus.ts index 1bd81334c..4389c637f 100644 --- a/src/app/components/report/table/columns/focus.ts +++ b/src/app/components/report/table/columns/focus.ts @@ -8,18 +8,18 @@ import type { PropType } from "vue" import { t } from "@app/locale" -import { ElTableColumn } from "element-plus" +import { Effect, ElTableColumn, ElTooltip } from "element-plus" import { defineComponent, h } from "vue" import { periodFormatter } from "../../formatter" +import CompositionTable from './composition-table' const columnLabel = t(msg => msg.item.focus) const _default = defineComponent({ name: "FocusColumn", props: { - timeFormat: { - type: String as PropType - } + timeFormat: String as PropType, + readRemote: Boolean, }, setup(props) { return () => h(ElTableColumn, { @@ -29,7 +29,23 @@ const _default = defineComponent({ align: "center", sortable: "custom" }, { - default: ({ row }: { row: timer.stat.Row }) => periodFormatter(row.focus, props.timeFormat || "default") + default({ row }: { row: timer.stat.Row }) { + const valueStr = periodFormatter(row.focus, props.timeFormat || "default") + if (!props.readRemote) { + return valueStr + } + return h(ElTooltip, { + placement: "top", + effect: Effect.LIGHT, + offset: 10 + }, { + default: () => valueStr, + content: () => h(CompositionTable, { + data: row.composition?.focus || [], + valueFormatter: val => periodFormatter(val, props.timeFormat || 'default'), + }) + }) + } }) } }) diff --git a/src/app/components/report/table/columns/operation-delete-button.ts b/src/app/components/report/table/columns/operation-delete-button.ts index 398bd3914..8ffd44e54 100644 --- a/src/app/components/report/table/columns/operation-delete-button.ts +++ b/src/app/components/report/table/columns/operation-delete-button.ts @@ -4,8 +4,9 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ +import type { PropType } from "vue" -import { computed, defineComponent, h, PropType, Ref } from "vue" +import { computed, defineComponent, h, Ref } from "vue" import OperationPopupConfirmButton from "@app/components/common/popup-confirm-button" import { Delete } from "@element-plus/icons-vue" import { t } from "@app/locale" @@ -44,7 +45,9 @@ function computeRangeConfirmText(url: string, dateRange: Array): string { const _default = defineComponent({ name: "OperationDeleteButton", - emits: ["confirm"], + emits: { + confirm: () => true + }, props: { mergeDate: Boolean, // Filter of date range diff --git a/src/app/components/report/table/columns/operation.ts b/src/app/components/report/table/columns/operation.ts index d6ebc7eaa..526a7c5d7 100644 --- a/src/app/components/report/table/columns/operation.ts +++ b/src/app/components/report/table/columns/operation.ts @@ -7,10 +7,10 @@ /** * Generate operation buttons - * - * @todo !!!! Remaining code of optimizing performance */ -import { computed, defineComponent, h, PropType } from "vue" +import type { PropType } from "vue" + +import { computed, defineComponent, h } from "vue" import { ElButton, ElMessage, ElTableColumn } from "element-plus" import TimerDatabase from "@db/timer-database" import whitelistService from "@service/whitelist-service" @@ -51,7 +51,10 @@ const _default = defineComponent({ dateRange: Array as PropType>, whitelist: Array as PropType> }, - emits: ["whitelistChange", "delete"], + emits: { + whitelistChange: (_host: string, _isWhite: boolean) => true, + delete: (_row: timer.stat.Row) => true, + }, setup(props, ctx) { const canOperate = computed(() => !props.mergeHost) const width = computed(() => props.mergeHost ? 110 : locale === "zh_CN" ? 290 : 330) diff --git a/src/app/components/report/table/columns/time.ts b/src/app/components/report/table/columns/time.ts index b9f3c2692..6c93af23b 100644 --- a/src/app/components/report/table/columns/time.ts +++ b/src/app/components/report/table/columns/time.ts @@ -6,14 +6,18 @@ */ import { defineComponent, h } from "vue" -import { ElTableColumn } from "element-plus" +import { Effect, ElTableColumn, ElTooltip } from "element-plus" import { t } from "@app/locale" +import CompositionTable from "./composition-table" const columnLabel = t(msg => msg.item.time) const _default = defineComponent({ name: "TimeColumn", - setup() { + props: { + readRemote: Boolean, + }, + setup(props) { return () => h(ElTableColumn, { prop: "time", label: columnLabel, @@ -21,9 +25,25 @@ const _default = defineComponent({ align: 'center', sortable: 'custom' }, { - default: ({ row }: { row: timer.stat.Row }) => row.time?.toString?.() || '0' + default: ({ row }: { row: timer.stat.Row }) => { + const valueStr = row.time?.toString?.() || '0' + if (!props.readRemote) { + return valueStr + } + return h(ElTooltip, { + placement: "top", + effect: Effect.LIGHT, + offset: 10 + }, { + default: () => valueStr, + content: () => h(CompositionTable, { + data: row.composition?.time || [], + valueFormatter: val => val?.toString() || '0' + }) + }) + } }) } }) -export default _default \ No newline at end of file +export default _default diff --git a/src/app/components/report/table/columns/total.ts b/src/app/components/report/table/columns/total.ts deleted file mode 100644 index f00d4b2d5..000000000 --- a/src/app/components/report/table/columns/total.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { PropType } from "vue" - -import { t } from "@app/locale" -import { ElIcon, ElTableColumn, ElTooltip } from "element-plus" -import { defineComponent, h } from "vue" -import { periodFormatter } from "../../formatter" -import { QuestionFilled } from "@element-plus/icons-vue" - -function renderHeader() { - return [ - t(msg => msg.item.total), - h(ElTooltip, { - content: t(msg => msg.item.totalTip), - placement: 'top', - }, () => h(ElIcon, { - style: { paddingLeft: '3px' }, - }, () => h(QuestionFilled))) - ] -} - -const _default = defineComponent({ - name: "TotalColumn", - props: { - timeFormat: { - type: String as PropType - } - }, - setup(props) { - return () => h(ElTableColumn, { - prop: "total", - minWidth: 130, - align: "center", - }, { - header: renderHeader, - default: ({ row }: { row: timer.stat.Row }) => periodFormatter(row.total, props.timeFormat) - }) - } -}) - -export default _default \ No newline at end of file diff --git a/src/app/components/report/table/index.ts b/src/app/components/report/table/index.ts index 2ad98a482..17aee7099 100644 --- a/src/app/components/report/table/index.ts +++ b/src/app/components/report/table/index.ts @@ -8,26 +8,15 @@ import type { PropType } from "vue" import { ElTable } from "element-plus" -import { defineComponent, h } from "vue" +import { defineComponent, h, ref, watch } from "vue" import SelectionColumn from "./columns/selection" import DateColumn from "./columns/date" import HostColumn from "./columns/host" import AliasColumn from "./columns/alias" import FocusColumn from "./columns/focus" -import TotalColumn from "./columns/total" import TimeColumn from "./columns/time" import OperationColumn from "./columns/operation" -export enum ElSortDirect { - ASC = 'ascending', - DESC = 'descending' -} - -export type SortInfo = { - prop: timer.stat.Dimension | 'host' - order: ElSortDirect -} - const _default = defineComponent({ name: "ReportTable", props: { @@ -37,11 +26,19 @@ const _default = defineComponent({ mergeHost: Boolean, timeFormat: String as PropType, dateRange: Array as PropType, - whitelist: Array as PropType + whitelist: Array as PropType, + readRemote: Boolean, + }, + emits: { + sortChange: (_newSortInfo: SortInfo) => true, + aliasChange: (_host: string, _newAlias: string) => true, + itemDelete: (_row: timer.stat.Row) => true, + whitelistChange: (_host: string, _addOrRemove: boolean) => true, }, - emits: ["sortChange", "aliasChange", "itemDelete", "whitelistChange"], setup(props, ctx) { let selectedRows: timer.stat.Row[] = [] + const readRemote = ref(props.readRemote) + watch(() => props.readRemote, newVal => readRemote.value = newVal) ctx.expose({ getSelected(): timer.stat.Row[] { return selectedRows || [] @@ -62,21 +59,22 @@ const _default = defineComponent({ h(SelectionColumn, { disabled: props.mergeHost }) ] props.mergeDate || result.push(h(DateColumn)) - result.push(h(HostColumn, { mergeHost: props.mergeHost })) - result.push(h(AliasColumn, { - onAliasChange: (host: string, newAlias: string) => ctx.emit("aliasChange", host, newAlias) - })) - result.push(h(FocusColumn, { timeFormat: props.timeFormat })) - result.push(h(TimeColumn)) - result.push(h(TotalColumn, { timeFormat: props.timeFormat })) - result.push(h(OperationColumn, { - mergeDate: props.mergeDate, - mergeHost: props.mergeHost, - dateRange: props.dateRange, - whitelist: props.whitelist, - onDelete: (row: timer.stat.Row) => ctx.emit("itemDelete", row), - onWhitelistChange: (host: string, addOrRemove: boolean) => ctx.emit("whitelistChange", host, addOrRemove) - })) + result.push( + h(HostColumn, { mergeHost: props.mergeHost }), + h(AliasColumn, { + onAliasChange: (host: string, newAlias: string) => ctx.emit("aliasChange", host, newAlias) + }), + h(FocusColumn, { timeFormat: props.timeFormat, readRemote: props.readRemote }), + h(TimeColumn, { readRemote: props.readRemote }), + h(OperationColumn, { + mergeDate: props.mergeDate, + mergeHost: props.mergeHost, + dateRange: props.dateRange, + whitelist: props.whitelist, + onDelete: (row: timer.stat.Row) => ctx.emit("itemDelete", row), + onWhitelistChange: (host: string, addOrRemove: boolean) => ctx.emit("whitelistChange", host, addOrRemove) + }), + ) return result }) } diff --git a/src/app/components/rule-merge/components/add-button.ts b/src/app/components/rule-merge/components/add-button.ts index 930b1648a..68cf25f15 100644 --- a/src/app/components/rule-merge/components/add-button.ts +++ b/src/app/components/rule-merge/components/add-button.ts @@ -15,7 +15,9 @@ const buttonText = `+ ${t(msg => msg.operation.newOne)}` const _default = defineComponent({ name: "MergeRuleAddButton", - emits: ["saved"], + emits: { + save: (_origin: string, _merged: string | number) => true, + }, setup(_props, ctx) { const editing: Ref = ref(false) const origin: Ref = ref('') @@ -29,13 +31,13 @@ const _default = defineComponent({ ? h(ItemInput, { origin: origin.value, merged: merged.value, - onSaved: (newOrigin, newMerged) => { + onSave: (newOrigin, newMerged) => { const newMergedVal = tryParseInteger(newMerged?.trim())[1] merged.value = newMergedVal origin.value = newOrigin - ctx.emit('saved', newOrigin, newMergedVal) + ctx.emit('save', newOrigin, newMergedVal) }, - onCanceled: () => editing.value = false + onCancel: () => editing.value = false }) : h(ElButton, { size: "small", diff --git a/src/app/components/rule-merge/components/item-input.ts b/src/app/components/rule-merge/components/item-input.ts index bf601f061..7661210eb 100644 --- a/src/app/components/rule-merge/components/item-input.ts +++ b/src/app/components/rule-merge/components/item-input.ts @@ -26,7 +26,10 @@ const _default = defineComponent({ default: "" } }, - emits: ["saved", "canceled"], + emits: { + save: (_origin: string, _merged: string) => true, + cancel: () => true, + }, setup(props, ctx) { const origin: Ref = ref(props.origin) const merged: Ref = ref(props.merged?.toString()) @@ -54,14 +57,14 @@ const _default = defineComponent({ onClick: () => { origin.value = props.origin merged.value = props.merged?.toString() - ctx.emit("canceled") + ctx.emit("cancel") } }), h(ElButton, { size: 'small', icon: Check, class: 'item-check-button editable-item', - onClick: () => isValidHost(origin.value) ? ctx.emit("saved", origin.value, merged.value) : ElMessage.warning(invalidTxt) + onClick: () => isValidHost(origin.value) ? ctx.emit("save", origin.value, merged.value) : ElMessage.warning(invalidTxt) }) ]) } diff --git a/src/app/components/rule-merge/components/item.ts b/src/app/components/rule-merge/components/item.ts index 719e50009..59de3d592 100644 --- a/src/app/components/rule-merge/components/item.ts +++ b/src/app/components/rule-merge/components/item.ts @@ -35,7 +35,10 @@ const _default = defineComponent({ type: Number } }, - emits: ["changed", "deleted"], + emits: { + change: (_origin: string, _merged: string | number, _idx: number) => true, + delete: (_origin: string) => true, + }, setup(props, ctx) { const origin: Ref = ref(props.origin) watch(() => props.origin, newVal => origin.value = newVal) @@ -56,14 +59,14 @@ const _default = defineComponent({ ? h(ItemInput, { origin: origin.value, merged: merged.value, - onSaved: (newOrigin: string, newMerged: string) => { + onSave: (newOrigin: string, newMerged: string) => { origin.value = newOrigin const newMergedVal = tryParseInteger(newMerged?.trim())[1] merged.value = newMergedVal editing.value = false - ctx.emit("changed", newOrigin, newMergedVal, id.value) + ctx.emit("change", newOrigin, newMergedVal, id.value) }, - onCanceled: () => { + onCancel: () => { origin.value = props.origin merged.value = props.merged editing.value = false @@ -73,7 +76,7 @@ const _default = defineComponent({ class: 'editable-item', type: type.value, closable: true, - onClose: () => ctx.emit("deleted", origin.value) + onClose: () => ctx.emit("delete", origin.value) }, () => [`${origin.value} >>> ${txt.value}`, h(Edit, { class: "edit-icon", onclick: () => editing.value = true })]) } }) diff --git a/src/app/components/rule-merge/item-list.ts b/src/app/components/rule-merge/item-list.ts index 7d313244a..ee62cd0de 100644 --- a/src/app/components/rule-merge/item-list.ts +++ b/src/app/components/rule-merge/item-list.ts @@ -85,8 +85,8 @@ function generateTagItem(ruleItem: timer.merge.Rule, index: number): VNode { index, origin, merged, - onDeleted: origin => handleTagClose(origin), - onChanged: (origin, merged, index) => handleChange(origin, merged, index, itemRef) + onDelete: origin => handleTagClose(origin), + onChange: (origin, merged, index) => handleChange(origin, merged, index, itemRef) }) } @@ -98,7 +98,7 @@ const itemList = () => { const addButtonRef: Ref = ref() const addButton = h(AddButton, { ref: addButtonRef, - onSaved: (origin, merged) => handleInputConfirm(origin, merged, addButtonRef) + onSave: (origin, merged) => handleInputConfirm(origin, merged, addButtonRef) }) result.push(addButton) return h('div', { class: 'editable-tag-container' }, result) diff --git a/src/app/components/site-manage/common.ts b/src/app/components/site-manage/common.ts index 049f3b37b..ee137db5b 100644 --- a/src/app/components/site-manage/common.ts +++ b/src/app/components/site-manage/common.ts @@ -24,8 +24,8 @@ export function aliasKeyOf(value: string): timer.site.AliasKey { return { host, merged } } -const EXIST_MSG = t(msg => msg.siteManage.msg.existedTag) -const MERGED_MSG = t(msg => msg.siteManage.msg.mergedTag) +export const EXIST_MSG = t(msg => msg.siteManage.msg.existedTag) +export const MERGED_MSG = t(msg => msg.siteManage.msg.mergedTag) /** * Calclate the label of alias key to display diff --git a/src/app/components/site-manage/filter.ts b/src/app/components/site-manage/filter.ts index 20aafeeb3..5fba88970 100644 --- a/src/app/components/site-manage/filter.ts +++ b/src/app/components/site-manage/filter.ts @@ -17,12 +17,6 @@ const aliasPlaceholder = t(msg => msg.siteManage.aliasPlaceholder) const onlyDetectedLabel = t(msg => msg.siteManage.onlyDetected) const addButtonText = t(msg => msg.siteManage.button.add) -export type SiteManageFilterOption = { - host: string, - alias: string, - onlyDetected: boolean, -} - const _default = defineComponent({ name: "SiteManageFilter", props: { @@ -30,7 +24,10 @@ const _default = defineComponent({ alias: String, onlyDetected: Boolean }, - emits: ["change", "create"], + emits: { + change: (_option: SiteManageFilterOption) => true, + create: () => true, + }, setup(props, ctx) { const host: Ref = ref(props.host) const alias: Ref = ref(props.alias) @@ -39,7 +36,7 @@ const _default = defineComponent({ host: host.value, alias: alias.value, onlyDetected: onlyDetected.value - } as SiteManageFilterOption) + }) return () => [ h(InputFilterItem, { placeholder: hostPlaceholder, diff --git a/src/app/components/site-manage/index.ts b/src/app/components/site-manage/index.ts index 92c8d37ba..f59e55e5d 100644 --- a/src/app/components/site-manage/index.ts +++ b/src/app/components/site-manage/index.ts @@ -9,7 +9,7 @@ import type { Ref, UnwrapRef, ComputedRef, WritableComputedRef } from "vue" import { reactive, defineComponent, h, ref, computed, } from "vue" import ContentContainer from "../common/content-container" -import SiteManageFilter, { SiteManageFilterOption } from "./filter" +import SiteManageFilter from "./filter" import Pagination from "../common/pagination" import SiteManageTable from "./table" import hostAliasService, { HostAliasQueryParam } from "@service/host-alias-service" diff --git a/src/app/components/site-manage/modify/host-form-item.ts b/src/app/components/site-manage/modify/host-form-item.ts index 049317658..20808923f 100644 --- a/src/app/components/site-manage/modify/host-form-item.ts +++ b/src/app/components/site-manage/modify/host-form-item.ts @@ -5,14 +5,14 @@ * https://opensource.org/licenses/MIT */ -import type { PropType, Ref } from "vue" +import type { PropType, Ref, VNode } from "vue" import { t } from "@app/locale" import HostAliasDatabase from "@db/host-alias-database" import timerService, { HostSet } from "@service/timer-service" -import { ElFormItem, ElInput, ElOption, ElSelect } from "element-plus" +import { ElFormItem, ElInput, ElOption, ElSelect, ElTag } from "element-plus" import { defineComponent, h, ref } from "vue" -import { aliasKeyOf, labelOf, optionValueOf } from "../common" +import { aliasKeyOf, EXIST_MSG, labelOf, MERGED_MSG, optionValueOf } from "../common" import { ALL_HOSTS, MERGED_HOST } from "@util/constant/remain-host" const hostAliasDatabase = new HostAliasDatabase(chrome.storage.local) @@ -51,9 +51,22 @@ async function handleRemoteSearch(query: string, searching: Ref, search searching.value = false } +function renderOptionSlots(aliasKey: timer.site.AliasKey, hasAlias: boolean): VNode[] { + const { host, merged } = aliasKey + const result = [ + h('span', {}, host) + ] + merged && result.push(h(ElTag, { size: 'small' }, MERGED_MSG)) + hasAlias && result.push(h(ElTag, { size: 'small', type: 'info' }, EXIST_MSG)) + return result +} + function renderOption({ aliasKey, hasAlias }: _OptionInfo) { - let label = labelOf(aliasKey, hasAlias) - return h(ElOption, { value: optionValueOf(aliasKey), disabled: hasAlias, label }) + return h(ElOption, { + value: optionValueOf(aliasKey), + disabled: hasAlias, + label: labelOf(aliasKey, hasAlias) + }, () => renderOptionSlots(aliasKey, hasAlias)) } const HOST_LABEL = t(msg => msg.siteManage.column.host) @@ -63,7 +76,9 @@ const _default = defineComponent({ editing: Boolean, modelValue: Object as PropType }, - emits: ["change"], + emits: { + change: (_aliasKey: timer.site.AliasKey) => true + }, setup(props, ctx) { const searching: Ref = ref(false) const searchedHosts: Ref<_OptionInfo[]> = ref([]) diff --git a/src/app/components/site-manage/modify/index.ts b/src/app/components/site-manage/modify/index.ts index 5db12ae81..5085ec8c7 100644 --- a/src/app/components/site-manage/modify/index.ts +++ b/src/app/components/site-manage/modify/index.ts @@ -5,8 +5,10 @@ * https://opensource.org/licenses/MIT */ +import type { Ref, SetupContext, UnwrapRef } from "vue" + import { ElButton, ElDialog, ElForm, ElMessage } from "element-plus" -import { computed, defineComponent, h, reactive, ref, Ref, SetupContext, UnwrapRef } from "vue" +import { computed, defineComponent, h, reactive, ref } from "vue" import { t } from "@app/locale" import { Check } from "@element-plus/icons-vue" import hostAliasDatabase from "@service/host-alias-service" @@ -47,7 +49,7 @@ function validateForm(formRef: Ref): Promise { }) } -async function handleSave(ctx: SetupContext<_Emit[]>, isNew: boolean, formData: _FormData): Promise { +async function handleSave(ctx: SetupContext<_Emit>, isNew: boolean, formData: _FormData): Promise { const aliasKey = formData.key const name = formData.name?.trim() if (isNew && await hostAliasDatabase.exist(aliasKey)) { @@ -65,15 +67,19 @@ async function handleSave(ctx: SetupContext<_Emit[]>, isNew: boolean, formData: return true } -type _Emit = "save" +type _Emit = { + save: (isNew: boolean, aliasKey: timer.site.AliasKey, name: string) => void +} const BTN_ADD_TXT = t(msg => msg.siteManage.button.add) const BTN_MDF_TXT = t(msg => msg.siteManage.button.modify) const _default = defineComponent({ name: "HostAliasModify", - emits: ['save'], - setup: (_, context: SetupContext<_Emit[]>) => { + emits: { + save: () => true + }, + setup: (_, context: SetupContext<_Emit>) => { const isNew: Ref = ref(false) const visible: Ref = ref(false) const buttonText = computed(() => isNew ? BTN_ADD_TXT : BTN_MDF_TXT) diff --git a/src/app/components/site-manage/modify/name-form-item.ts b/src/app/components/site-manage/modify/name-form-item.ts index b635951d2..92e4ec8ed 100644 --- a/src/app/components/site-manage/modify/name-form-item.ts +++ b/src/app/components/site-manage/modify/name-form-item.ts @@ -15,7 +15,10 @@ const _default = defineComponent({ props: { modelValue: String }, - emits: ["input", "enter"], + emits: { + input: (_val: string) => true, + enter: () => true, + }, setup(props, ctx) { return () => h(ElFormItem, { prop: 'name', label: LABEL }, diff --git a/src/app/components/site-manage/site-manage.d.ts b/src/app/components/site-manage/site-manage.d.ts new file mode 100644 index 000000000..d7bfcb18e --- /dev/null +++ b/src/app/components/site-manage/site-manage.d.ts @@ -0,0 +1,5 @@ +declare type SiteManageFilterOption = { + host: string, + alias: string, + onlyDetected: boolean, +} \ No newline at end of file diff --git a/src/app/components/site-manage/table/column/operation.ts b/src/app/components/site-manage/table/column/operation.ts index c7c1a2536..3aedced9b 100644 --- a/src/app/components/site-manage/table/column/operation.ts +++ b/src/app/components/site-manage/table/column/operation.ts @@ -5,14 +5,21 @@ * https://opensource.org/licenses/MIT */ +import type { SetupContext } from "vue" + import { ElButton, ElTableColumn } from "element-plus" import { t } from "@app/locale" -import { defineComponent, h, SetupContext } from "vue" +import { defineComponent, h } from "vue" import { Delete, Edit } from "@element-plus/icons-vue" import PopupConfirmButton from "@app/components/common/popup-confirm-button" +type _Emit = { + delete: (row: timer.site.AliasIcon) => void + modify: (row: timer.site.AliasIcon) => void +} + const deleteButtonText = t(msg => msg.siteManage.button.delete) -const deleteButton = (ctx: SetupContext<_Emit[]>, row: timer.site.AliasIcon) => h(PopupConfirmButton, { +const deleteButton = (ctx: SetupContext<_Emit>, row: timer.site.AliasIcon) => h(PopupConfirmButton, { buttonIcon: Delete, buttonType: "danger", buttonText: deleteButtonText, @@ -21,20 +28,21 @@ const deleteButton = (ctx: SetupContext<_Emit[]>, row: timer.site.AliasIcon) => }) const modifyButtonText = t(msg => msg.siteManage.button.modify) -const modifyButton = (ctx: SetupContext<_Emit[]>, row: timer.site.AliasIcon) => h(ElButton, { +const modifyButton = (ctx: SetupContext<_Emit>, row: timer.site.AliasIcon) => h(ElButton, { size: 'small', type: "primary", icon: Edit, onClick: () => ctx.emit("modify", row) }, () => modifyButtonText) -type _Emit = "delete" | "modify" - const label = t(msg => msg.item.operation.label) const _default = defineComponent({ name: "OperationColumn", - emits: ["delete", "modify"], - setup(_, ctx: SetupContext<_Emit[]>) { + emits: { + delete: (_row: timer.site.AliasIcon) => true, + modify: () => true, + }, + setup(_, ctx: SetupContext<_Emit>) { return () => h(ElTableColumn, { minWidth: 100, label, diff --git a/src/app/components/site-manage/table/index.ts b/src/app/components/site-manage/table/index.ts index 9048155a0..233958677 100644 --- a/src/app/components/site-manage/table/index.ts +++ b/src/app/components/site-manage/table/index.ts @@ -17,7 +17,10 @@ const _default = defineComponent({ props: { data: Array as PropType }, - emits: ["rowDelete", "rowModify"], + emits: { + rowDelete: (_row: timer.site.AliasIcon) => true, + rowModify: (_row: timer.site.AliasIcon) => true, + }, setup(props, ctx) { return () => h(ElTable, { data: props.data, diff --git a/src/app/components/trend/components/chart/index.ts b/src/app/components/trend/components/chart/index.ts index 0b1ec72c8..1ff38457b 100644 --- a/src/app/components/trend/components/chart/index.ts +++ b/src/app/components/trend/components/chart/index.ts @@ -16,7 +16,7 @@ const _default = defineComponent({ const elRef: Ref = ref() const chartWrapper: ChartWrapper = new ChartWrapper() - function render(filterOption: timer.app.trend.FilterOption, isOnMounted: boolean, row: timer.stat.Row[]) { + function render(filterOption: TrendFilterOption, isOnMounted: boolean, row: timer.stat.Row[]) { chartWrapper.render({ ...filterOption, isFirst: isOnMounted }, row) } diff --git a/src/app/components/trend/components/chart/wrapper.ts b/src/app/components/trend/components/chart/wrapper.ts index d059e1596..fc108689c 100644 --- a/src/app/components/trend/components/chart/wrapper.ts +++ b/src/app/components/trend/components/chart/wrapper.ts @@ -19,7 +19,7 @@ import type { import { init, use } from "@echarts/core" import LineChart from "@echarts/chart/line" import GridComponent from "@echarts/component/grid" -import CanvasRenderer from "@echarts/canvas-renderer" +import SVGRenderer from "@echarts/svg-renderer" import LegendComponent from "@echarts/component/legend" import TitleComponent from "@echarts/component/title" import ToolboxComponent from "@echarts/component/toolbox" @@ -38,7 +38,7 @@ use([ TitleComponent, ToolboxComponent, TooltipComponent, - CanvasRenderer, + SVGRenderer, ]) type EcOption = ComposeOption< @@ -135,7 +135,7 @@ function optionOf( ], legend: [{ left: 'left', - data: [t(msg => msg.item.total), t(msg => msg.item.focus), t(msg => msg.item.time)], + data: [t(msg => msg.item.focus), t(msg => msg.item.time)], textStyle: { color: textColor }, }], series: [{ @@ -185,7 +185,7 @@ function getAxias(format: string, dateRange: Date[] | undefined): string[] { return xAxisData } -async function processSubtitle(host: timer.app.trend.HostInfo) { +async function processSubtitle(host: TrendHostInfo) { let subtitle = labelOfHostInfo(host) if (!subtitle) { return DEFAULT_SUB_TITLE @@ -224,7 +224,7 @@ class ChartWrapper { this.instance = init(container) } - async render(renderOption: timer.app.trend.RenderOption, rows: timer.stat.Row[]) { + async render(renderOption: TrendRenderOption, rows: timer.stat.Row[]) { const { host, dateRange, timeFormat } = renderOption // 1. x-axis data let xAxisData: string[], allDates: string[] @@ -243,12 +243,6 @@ class ChartWrapper { const dataItems = processDataItems(allDates, timeFormat, rows) const option: EcOption = optionOf(xAxisData, subtitle, timeFormat, dataItems) - if (renderOption.isFirst) { - // Close the running time by default - const closedLegends: Record = {} - closedLegends[t(msg => msg.item.total)] = false - option.legend[0].selected = closedLegends - } this.instance?.setOption(option) } diff --git a/src/app/components/trend/components/common.ts b/src/app/components/trend/components/common.ts index c07deb8ee..cb8ecc0a0 100644 --- a/src/app/components/trend/components/common.ts +++ b/src/app/components/trend/components/common.ts @@ -10,7 +10,7 @@ import { t } from "@app/locale" /** * Transfer host info to label */ -export function labelOfHostInfo(hostInfo: timer.app.trend.HostInfo): string { +export function labelOfHostInfo(hostInfo: TrendHostInfo): string { if (!hostInfo) return '' const { host, merged } = hostInfo if (!host) return '' diff --git a/src/app/components/trend/components/filter.ts b/src/app/components/trend/components/filter.ts index a006336d3..438876d07 100644 --- a/src/app/components/trend/components/filter.ts +++ b/src/app/components/trend/components/filter.ts @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import type { Ref, PropType } from "vue" +import type { Ref, PropType, VNode } from "vue" -import { ElOption, ElSelect } from "element-plus" +import { ElOption, ElSelect, ElTag } from "element-plus" import { ref, h, defineComponent } from "vue" import timerService, { HostSet } from "@service/timer-service" import { daysAgo } from "@util/time" @@ -18,14 +18,14 @@ import SelectFilterItem from "@app/components/common/select-filter-item" import { ElementDatePickerShortcut } from "@src/element-ui/date" import { labelOfHostInfo } from "./common" -async function handleRemoteSearch(queryStr: string, trendDomainOptions: Ref, searching: Ref) { +async function handleRemoteSearch(queryStr: string, trendDomainOptions: Ref, searching: Ref) { if (!queryStr) { trendDomainOptions.value = [] return } searching.value = true const domains: HostSet = await timerService.listHosts(queryStr) - const options: timer.app.trend.HostInfo[] = [] + const options: TrendHostInfo[] = [] domains.origin.forEach(host => options.push({ host, merged: false })) domains.merged.forEach(host => options.push({ host, merged: true })) trendDomainOptions.value = options @@ -38,6 +38,7 @@ function datePickerShortcut(msg: keyof TrendMessage, agoOfStart?: number, agoOfE value: daysAgo(agoOfStart || 0, agoOfEnd || 0) } } + const SHORTCUTS = [ datePickerShortcut('lastWeek', 7), datePickerShortcut('last15Days', 15), @@ -57,32 +58,45 @@ const TIME_FORMAT_LABELS: { [key in timer.app.TimeFormat]: string } = { hour: t(msg => msg.timeFormat.hour) } -function keyOfHostInfo(option: timer.app.trend.HostInfo): string { +function keyOfHostInfo(option: TrendHostInfo): string { const { merged, host } = option return (merged ? "1" : '0') + (host || '') } -function hostInfoOfKey(key: string): timer.app.trend.HostInfo { +function hostInfoOfKey(key: string): TrendHostInfo { if (!key || !key.length) return { host: '', merged: false } const merged = key.charAt(0) === '1' return { host: key.substring(1), merged } } +const MERGED_TAG_TXT = t(msg => msg.trend.merged) +function renderHostLabel(hostInfo: TrendHostInfo): VNode[] { + const result = [ + h('span', {}, hostInfo.host) + ] + hostInfo.merged && result.push( + h(ElTag, { size: 'small' }, () => MERGED_TAG_TXT) + ) + return result +} + const _default = defineComponent({ name: "TrendFilter", props: { dateRange: Object as PropType, - defaultValue: Object as PropType, + defaultValue: Object as PropType, timeFormat: String as PropType }, - emits: ['change'], + emits: { + change: (_option: TrendFilterOption) => true + }, setup(props, ctx) { // @ts-ignore const dateRange: Ref = ref(props.dateRange) const domainKey: Ref = ref('') const trendSearching: Ref = ref(false) - const trendDomainOptions: Ref = ref([]) - const defaultOption: timer.app.trend.HostInfo = props.defaultValue + const trendDomainOptions: Ref = ref([]) + const defaultOption: TrendHostInfo = props.defaultValue const timeFormat: Ref = ref(props.timeFormat) if (defaultOption) { domainKey.value = keyOfHostInfo(defaultOption) @@ -90,8 +104,8 @@ const _default = defineComponent({ } function handleChange() { - const hostInfo: timer.app.trend.HostInfo = hostInfoOfKey(domainKey.value) - const option: timer.app.trend.FilterOption = { + const hostInfo: TrendHostInfo = hostInfoOfKey(domainKey.value) + const option: TrendFilterOption = { host: hostInfo, dateRange: dateRange.value, timeFormat: timeFormat.value @@ -117,7 +131,10 @@ const _default = defineComponent({ handleChange() } }, () => (trendDomainOptions.value || [])?.map( - hostInfo => h(ElOption, { value: keyOfHostInfo(hostInfo), label: labelOfHostInfo(hostInfo) }) + hostInfo => h(ElOption, { + value: keyOfHostInfo(hostInfo), + label: labelOfHostInfo(hostInfo), + }, () => renderHostLabel(hostInfo)) )), h(DateRangeFilterItem, { defaultRange: dateRange.value, diff --git a/src/app/components/trend/index.ts b/src/app/components/trend/index.ts index 6984280da..287bfa5bf 100644 --- a/src/app/components/trend/index.ts +++ b/src/app/components/trend/index.ts @@ -13,14 +13,14 @@ import { daysAgo } from "@util/time" import ContentContainer from "../common/content-container" import TrendChart from "./components/chart" import TrendFilter from "./components/filter" -import timerService, { SortDirect, TimerQueryParam } from "@service/timer-service" +import timerService, { TimerQueryParam } from "@service/timer-service" type _Queries = { host: string merge: '1' | '0' | undefined } -function initWithQuery(hostOption: Ref) { +function initWithQuery(hostOption: Ref) { // Process the query param const query: _Queries = useRoute().query as unknown as _Queries useRouter().replace({ query: {} }) @@ -29,7 +29,7 @@ function initWithQuery(hostOption: Ref) { host && (hostOption.value = { host, merged: merge === "1" }) } -async function query(hostOption: Ref, dateRange: Ref): Promise { +async function query(hostOption: Ref, dateRange: Ref): Promise { const hostVal = hostOption.value?.host if (!hostVal) { return [] @@ -41,7 +41,7 @@ async function query(hostOption: Ref, dateRange: Ref = ref(daysAgo(7, 0)) - const hostOption: Ref = ref() + const hostOption: Ref = ref() const timeFormat: Ref = ref('default') const chart: Ref = ref() const filter: Ref = ref() @@ -60,7 +60,7 @@ const _default = defineComponent({ async function queryAndRender(isOnMounted?: boolean) { const row = await query(hostOption, dateRange) - const filterOption: timer.app.trend.FilterOption = { + const filterOption: TrendFilterOption = { host: hostOption.value, dateRange: dateRange.value, timeFormat: timeFormat.value @@ -76,7 +76,7 @@ const _default = defineComponent({ timeFormat: timeFormat.value, dateRange: dateRange.value, ref: filter, - onChange(option: timer.app.trend.FilterOption) { + onChange(option: TrendFilterOption) { hostOption.value = option.host dateRange.value = option.dateRange timeFormat.value = option.timeFormat diff --git a/src/app/components/trend/trend.d.ts b/src/app/components/trend/trend.d.ts new file mode 100644 index 000000000..40a178c0b --- /dev/null +++ b/src/app/components/trend/trend.d.ts @@ -0,0 +1,17 @@ +declare type TrendHostInfo = { + host: string + merged: boolean +} + +declare type TrendFilterOption = { + host: TrendHostInfo, + dateRange: Date[], + timeFormat: timer.app.TimeFormat +} + +declare type TrendRenderOption = TrendFilterOption & { + /** + * Whether render firstly + */ + isFirst: boolean +} \ No newline at end of file diff --git a/src/app/components/whitelist/components/add-button.ts b/src/app/components/whitelist/components/add-button.ts index fe097b5bb..6966c96c7 100644 --- a/src/app/components/whitelist/components/add-button.ts +++ b/src/app/components/whitelist/components/add-button.ts @@ -14,7 +14,9 @@ const buttonText = `+ ${t(msg => msg.operation.newOne)}` const _default = defineComponent({ name: "WhitelistAddButton", - emits: ["saved"], + emits: { + save: (_white: string) => true, + }, setup(_props, ctx) { const editing: Ref = ref(false) const white: Ref = ref('') @@ -26,11 +28,11 @@ const _default = defineComponent({ return () => editing.value ? h(ItemInput, { white: white.value, - onSaved: (newWhite) => { + onSave: (newWhite) => { white.value = newWhite - ctx.emit('saved', newWhite) + ctx.emit('save', newWhite) }, - onCanceled: () => editing.value = false + onCancel: () => editing.value = false }) : h(ElButton, { size: "small", diff --git a/src/app/components/whitelist/components/item-input.ts b/src/app/components/whitelist/components/item-input.ts index 411dae2f3..eccb96af9 100644 --- a/src/app/components/whitelist/components/item-input.ts +++ b/src/app/components/whitelist/components/item-input.ts @@ -23,7 +23,10 @@ const _default = defineComponent({ default: "" } }, - emits: ["saved", "canceled"], + emits: { + save: (_white: string) => true, + cancel: () => true, + }, setup(props, ctx) { const white: Ref = ref(props.white) return () => h('div', { class: "item-input-container" }, [ @@ -41,7 +44,7 @@ const _default = defineComponent({ class: 'item-cancel-button editable-item', onClick: () => { white.value = props.white - ctx.emit("canceled") + ctx.emit("cancel") } }), h(ElButton, { @@ -49,7 +52,7 @@ const _default = defineComponent({ icon: Check, class: 'item-check-button editable-item', onClick: () => (isRemainHost(white.value) || isValidHost(white.value)) - ? ctx.emit("saved", white.value) + ? ctx.emit("save", white.value) : ElMessage.warning(invalidTxt) }) ]) diff --git a/src/app/components/whitelist/components/item.ts b/src/app/components/whitelist/components/item.ts index 2eeeca585..8859aa2f0 100644 --- a/src/app/components/whitelist/components/item.ts +++ b/src/app/components/whitelist/components/item.ts @@ -20,7 +20,10 @@ const _default = defineComponent({ type: Number } }, - emits: ["changed", "deleted"], + emits: { + change: (_white: string, _idx: number) => true, + delete: (_white: string) => true, + }, setup(props, ctx) { const white: Ref = ref(props.white) watch(() => props.white, newVal => white.value = newVal) @@ -36,12 +39,12 @@ const _default = defineComponent({ return () => editing.value ? h(ItemInput, { white: white.value, - onSaved: (newWhite: string) => { + onSave: (newWhite: string) => { editing.value = false white.value = newWhite - ctx.emit("changed", white.value, id.value) + ctx.emit("change", white.value, id.value) }, - onCanceled: () => { + onCancel: () => { white.value = props.white editing.value = false } @@ -49,7 +52,7 @@ const _default = defineComponent({ : h(ElTag, { class: 'editable-item', closable: true, - onClose: () => ctx.emit("deleted", white.value) + onClose: () => ctx.emit("delete", white.value) }, () => [white.value, h(Edit, { class: "edit-icon", onclick: () => editing.value = true })]) } }) diff --git a/src/app/components/whitelist/item-list.ts b/src/app/components/whitelist/item-list.ts index 1339132e9..887e5cc18 100644 --- a/src/app/components/whitelist/item-list.ts +++ b/src/app/components/whitelist/item-list.ts @@ -5,9 +5,11 @@ * https://opensource.org/licenses/MIT */ +import type { Ref, VNode } from "vue" + import { ElMessage, ElMessageBox } from "element-plus" import { t } from "@app/locale" -import { h, ref, Ref, VNode } from "vue" +import { h, ref } from "vue" import whitelistService from "@service/whitelist-service" import Item from './components/item' import AddButton from './components/add-button' @@ -72,15 +74,15 @@ function tags(): VNode { const itemRef: Ref = ref() const item = h(Item, { white, index, ref: itemRef, - onChanged: (newVal, index) => handleChanged(newVal, index, itemRef), - onDeleted: (val: string) => handleClose(val) + onChange: (newVal, index) => handleChanged(newVal, index, itemRef), + onDelete: (val: string) => handleClose(val) }) result.push(item) }) const addButtonRef: Ref = ref() result.push(h(AddButton, { ref: addButtonRef, - onSaved: (inputVal: string) => handleAdd(inputVal, addButtonRef) + onSave: (inputVal: string) => handleAdd(inputVal, addButtonRef) })) return h('div', { class: 'editable-tag-container' }, result) } diff --git a/src/app/layout/menu.ts b/src/app/layout/menu.ts index 994c52013..3afe77570 100644 --- a/src/app/layout/menu.ts +++ b/src/app/layout/menu.ts @@ -15,8 +15,8 @@ import { defineComponent, h, onMounted, reactive } from "vue" import { ElIcon, ElMenu, ElMenuItem, ElMenuItemGroup, MenuItemRegistered } from "element-plus" import { useRoute, useRouter } from "vue-router" import { t } from "@app/locale" -import { HOME_PAGE, FEEDBACK_QUESTIONNAIRE } from "@util/constant/url" -import { Aim, Calendar, ChatSquare, Folder, HelpFilled, HotWater, Rank, SetUp, Stopwatch, Sugar, Tickets, Timer } from "@element-plus/icons-vue" +import { HOME_PAGE, FEEDBACK_QUESTIONNAIRE, getGuidePageUrl } from "@util/constant/url" +import { Aim, Calendar, ChatSquare, Folder, HelpFilled, HotWater, Memo, Rank, SetUp, Stopwatch, Sugar, Tickets, Timer } from "@element-plus/icons-vue" import { locale } from "@i18n" import TrendIcon from "./icon/trend-icon" @@ -43,10 +43,14 @@ type _RouteProps = { */ function generateMenus(): _MenuGroup[] { const otherMenuItems: _MenuItem[] = [{ + title: 'userManual', + href: getGuidePageUrl(false), + icon: Memo, + index: '_guide', + }, { title: 'helpUs', route: '/other/help', icon: HelpFilled, - index: '_i18n' }] HOME_PAGE && otherMenuItems.push({ title: 'rate', diff --git a/src/app/styles/compatible.sass b/src/app/styles/compatible.sass index 03df3b636..58f1ad715 100644 --- a/src/app/styles/compatible.sass +++ b/src/app/styles/compatible.sass @@ -38,6 +38,14 @@ .el-select input padding-left: 11px +// The tag in select options +.el-select-dropdown__item + .el-tag--small + height: 20px !important + margin-right: 0px !important + margin-left: 7px + text-align: center + font-size: 10px \:root --el-input-height: 40px diff --git a/src/background/alarm-manager.ts b/src/background/alarm-manager.ts new file mode 100644 index 000000000..2abb14e56 --- /dev/null +++ b/src/background/alarm-manager.ts @@ -0,0 +1,78 @@ +type _AlarmConfig = { + handler: _Handler, + interval: number, +} + +type _Handler = (alarm: chrome.alarms.Alarm) => void + +const ALARM_PREFIX = 'timer-alarm-' + chrome.runtime.id + '-' +const ALARM_PREFIX_LENGTH = ALARM_PREFIX.length + +const getInnerName = (outerName: string) => ALARM_PREFIX + outerName +const getOuterName = (innerName: string) => innerName.substring(ALARM_PREFIX_LENGTH) + +/** + * The manager of alarms + * + * @since 1.4.6 + */ +class AlarmManager { + private alarms: Record = {} + + constructor() { + this.init() + } + + private init() { + chrome.alarms.onAlarm.addListener(alarm => { + const name = alarm.name + if (!name.startsWith(ALARM_PREFIX)) { + // Unknown alarm + return + } + const innerName = getOuterName(name) + const config: _AlarmConfig = this.alarms[innerName] + if (!config) { + // Not registered, or removed + return + } + // Handle alarm event + config.handler?.(alarm) + const nextTs = Date.now() + config.interval + // Clear this one + chrome.alarms.clear(name, (_cleared: boolean) => { + // Create new one + chrome.alarms.create(name, { when: nextTs }) + }) + }) + } + + /** + * Set a alarm to do sth with interval + */ + setInterval(outerName: string, interval: number, handler: _Handler): void { + if (!interval || !handler) { + return + } + const config: _AlarmConfig = { handler, interval } + if (this.alarms[outerName]) { + // Existed, only update the config + this.alarms[outerName] = config + return + } + // Initialize config + this.alarms[outerName] = config + // Create new one alarm + chrome.alarms.create(getInnerName(outerName), { when: Date.now() + interval }) + } + + /** + * Remove a interval + */ + remove(outerName: string) { + delete this.alarms[outerName] + chrome.alarms.clear(getInnerName(outerName)) + } +} + +export default new AlarmManager() \ No newline at end of file diff --git a/src/background/backup-scheduler.ts b/src/background/backup-scheduler.ts new file mode 100644 index 000000000..9f6f837ae --- /dev/null +++ b/src/background/backup-scheduler.ts @@ -0,0 +1,48 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import optionService from "@service/option-service" +import alarmManager from "./alarm-manager" +import processor from "@src/common/backup/processor" + +const ALARM_NAME = 'auto-backup-data' + +class BackupScheduler { + needBackup = false + /** + * Interval of millseconds + */ + interval: number = 0 + + init() { + optionService.getAllOption().then(opt => this.handleOption(opt)) + optionService.addOptionChangeListener(opt => this.handleOption(opt)) + } + + private handleOption(option: timer.option.BackupOption) { + this.needBackup = !!option.autoBackUp + this.interval = (option.autoBackUpInterval || 0) * 60 * 1000 + if (this.needSchedule()) { + alarmManager.setInterval(ALARM_NAME, this.interval, () => this.doBackup()) + } else { + alarmManager.remove(ALARM_NAME) + } + } + + private needSchedule(): boolean { + return !!this.needBackup && !!this.interval + } + + private async doBackup(): Promise { + const result = await processor.syncData() + if (!result.success) { + console.warn(`Failed to backup ts=${Date.now()}, msg=${result.errorMsg}`) + } + } +} + +export default BackupScheduler \ No newline at end of file diff --git a/src/background/badge-text-manager.ts b/src/background/badge-text-manager.ts index 87c05dfc5..5c993e6b3 100644 --- a/src/background/badge-text-manager.ts +++ b/src/background/badge-text-manager.ts @@ -9,6 +9,7 @@ import TimerDatabase from "@db/timer-database" import whitelistHolder from "@service/components/whitelist-holder" import optionService from "@service/option-service" import { extractHostname, isBrowserUrl } from "@util/pattern" +import alarmManager from "./alarm-manager" const storage = chrome.storage.local const timerDb: TimerDatabase = new TimerDatabase(storage) @@ -55,66 +56,56 @@ function findFocusedWindow(): Promise { ) } -function findActiveTab(): Promise { - return new Promise(resolve => findFocusedWindow().then(window => { - if (!window) { +async function findActiveTab(): Promise { + const window = await findFocusedWindow() + if (!window) { + return undefined + } + return new Promise(resolve => chrome.tabs.query({ active: true, windowId: window.id }, tabs => { + // Fix #131 + // Edge will return two active tabs, including the new tab with url 'edge://newtab/', GG + tabs = tabs.filter(tab => !isBrowserUrl(tab.url)) + if (!tabs || !tabs.length) { resolve(undefined) - return + } else { + const { url, id } = tabs[0] + resolve({ tabId: id, url }) } - chrome.tabs.query({ active: true, windowId: window.id }, tabs => { - // Fix #131 - // Edge will return two active tabs, including the new tab with url 'edge://newtab/', GG - tabs = tabs.filter(tab => !isBrowserUrl(tab.url)) - if (!tabs || !tabs.length) { - resolve(undefined) - } else { - const { url, id } = tabs[0] - resolve({ tabId: id, url }) - } - }) })) } -async function updateFocus(badgeLocation?: BadgeLocation) { +async function updateFocus(badgeLocation?: BadgeLocation, lastLocation?: BadgeLocation): Promise { + // Clear the last tab firstly + lastLocation?.tabId && setBadgeText('', lastLocation.tabId) badgeLocation = badgeLocation || await findActiveTab() if (!badgeLocation) { - return + return badgeLocation } const { url, tabId } = badgeLocation if (!url || isBrowserUrl(url)) { - return + return badgeLocation } const host = extractHostname(url)?.host if (whitelistHolder.contains(host)) { setBadgeText('W', tabId) - return + return badgeLocation } const milliseconds = host ? (await timerDb.get(host, new Date())).focus : undefined setBadgeTextOfMills(milliseconds, tabId) -} - -const ALARM_NAME = 'timer-badge-text-manager-alarm' -const ALARM_INTERVAL = 1000 -function createAlarm(beforeAction?: () => void) { - beforeAction?.() - chrome.alarms.create(ALARM_NAME, { when: Date.now() + ALARM_INTERVAL }) + return badgeLocation } class BadgeTextManager { isPaused: boolean + lastLocation: BadgeLocation async init() { - createAlarm() - chrome.alarms.onAlarm.addListener(alarm => { - if (ALARM_NAME === alarm.name) { - createAlarm(() => !this.isPaused && updateFocus()) - } - }) - const option: Partial = await optionService.getAllOption() this.pauseOrResumeAccordingToOption(!!option.displayBadgeText) optionService.addOptionChangeListener(({ displayBadgeText }) => this.pauseOrResumeAccordingToOption(displayBadgeText)) whitelistHolder.addPostHandler(updateFocus) + + alarmManager.setInterval('badage-text-manager', 1000, () => !this.isPaused && updateFocus()) } /** @@ -123,7 +114,7 @@ class BadgeTextManager { async pause() { this.isPaused = true const tab = await findActiveTab() - setBadgeText('P', tab?.tabId) + setBadgeText('', tab?.tabId) } /** @@ -135,9 +126,9 @@ class BadgeTextManager { this.forceUpdate() } - forceUpdate(badgeLocation?: BadgeLocation) { + async forceUpdate(badgeLocation?: BadgeLocation) { if (this.isPaused) { return } - updateFocus(badgeLocation) + this.lastLocation = await updateFocus(badgeLocation, this.lastLocation) } private pauseOrResumeAccordingToOption(displayBadgeText: boolean) { diff --git a/src/background/index.ts b/src/background/index.ts index 83f03e67e..6abeb382d 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -19,6 +19,8 @@ import { getGuidePageUrl } from "@util/constant/url" import MessageDispatcher from "./message-dispatcher" import initLimitProcesser from "./limit-processor" import initCsHandler from "./content-script-handler" +import { isBrowserUrl } from "@util/pattern" +import BackupScheduler from "./backup-scheduler" // Open the log of console openLog() @@ -40,7 +42,10 @@ new IconAndAliasCollector().listen() // Process version new VersionManager().init() -// Mange the context menus +// Backup scheduler +new BackupScheduler().init() + +// Manage the context menus WhitelistMenuManager() // Browser action menu @@ -54,6 +59,17 @@ new ActiveTabListener() .register(({ url, tabId }) => badgeTextManager.forceUpdate({ url, tabId })) .listen() +// Listen window focus changed +chrome.windows.onFocusChanged.addListener(windowId => { + if (windowId === chrome.windows.WINDOW_ID_NONE) { + return + } + chrome.tabs.query({ windowId }, tabs => tabs + .filter(tab => tab.active && !isBrowserUrl(tab.url)) + .forEach(({ url, id }) => badgeTextManager.forceUpdate({ url, tabId: id })) + ) +}) + // Collect the install time chrome.runtime.onInstalled.addListener(async detail => { if (detail.reason === "install") { @@ -64,4 +80,5 @@ chrome.runtime.onInstalled.addListener(async detail => { new UninstallListener().listen() }) +// Start message dispatcher messageDispatcher.start() \ No newline at end of file diff --git a/src/background/timer/collection-context.ts b/src/background/timer/collection-context.ts index bd7d9a68b..d802b379b 100644 --- a/src/background/timer/collection-context.ts +++ b/src/background/timer/collection-context.ts @@ -5,17 +5,12 @@ * https://opensource.org/licenses/MIT */ -import TimerContext, { TimeInfo } from "./context" +import TimerContext from "./context" export default class CollectionContext { realInterval: number timerContext: TimerContext - hostSet: Set - /** - * The focus host while last collection - */ - focusHost: string - focusUrl: string + init() { const now = Date.now() @@ -25,22 +20,10 @@ export default class CollectionContext { constructor() { this.timerContext = new TimerContext() - this.hostSet = new Set() this.init() } - collectHost(host: string) { this.hostSet.add(host) } - - resetFocus(focusHost: string, focusUrl: string) { - this.focusHost = focusHost - this.focusUrl = focusUrl - } - - accumulateAll() { - const interval = this.realInterval - this.hostSet.forEach((host: string) => { - const info = TimeInfo.of(interval, this.focusHost === host ? interval : 0) - this.timerContext.accumulate(host, info) - }) + accumulate(focusHost: string, focusUrl: string) { + this.timerContext.accumulate(focusHost, focusUrl, this.realInterval) } } \ No newline at end of file diff --git a/src/background/timer/collector.ts b/src/background/timer/collector.ts index da9587c7f..3878b6dfa 100644 --- a/src/background/timer/collector.ts +++ b/src/background/timer/collector.ts @@ -13,24 +13,18 @@ let countLocalFiles: boolean optionService.getAllOption().then(option => countLocalFiles = !!option.countLocalFiles) optionService.addOptionChangeListener((newVal => countLocalFiles = !!newVal.countLocalFiles)) -/** - * The promise for window query - */ -function WindowPromise(window: chrome.windows.Window, context: CollectionContext) { - return new Promise(resolve => handleWindow(resolve, window, context)) +function queryAllWindows(): Promise { + return new Promise(resolve => chrome.windows.getAll(resolve)) } -function handleWindow(resolve: (val?: unknown) => void, window: chrome.windows.Window, context: CollectionContext) { - const windowId = window.id - const windowFocused = !!window.focused - chrome.tabs.query({ windowId }, tabs => { - if (chrome.runtime.lastError) { /** prevent it from throwing error */ } - // tabs maybe undefined - if (!tabs) return - tabs.forEach(tab => handleTab(tab, windowFocused, context)) - resolve() - }) + +function queryAllTabs(windowId: number): Promise { + return new Promise(resolve => chrome.tabs.query({ windowId }, resolve)) } -function handleTab(tab: chrome.tabs.Tab, isFocusWindow: boolean, context: CollectionContext) { + +function handleTab(tab: chrome.tabs.Tab, window: chrome.windows.Window, context: CollectionContext) { + if (!tab.active || !window.focused) { + return + } const url = tab.url if (!url) return if (isBrowserUrl(url)) return @@ -40,14 +34,24 @@ function handleTab(tab: chrome.tabs.Tab, isFocusWindow: boolean, context: Collec host = extractFileHost(url) } if (host) { - context.collectHost(host) - const isFocus = isFocusWindow && tab.active - isFocus && context.resetFocus(host, url) + context.accumulate(host, url) } else { console.log('Detect blank host:', url) } } +async function doCollect(context: CollectionContext) { + const windows = await queryAllWindows() + for (const window of windows) { + const tabs = await queryAllTabs(window.id) + // tabs maybe undefined + if (!tabs) { + continue + } + tabs.forEach(tab => handleTab(tab, window, context)) + } +} + export default class TimeCollector { context: CollectionContext @@ -58,14 +62,6 @@ export default class TimeCollector { collect() { this.context.init() if (this.context.timerContext.isPaused()) return - chrome.windows.getAll(windows => processWindows(windows, this.context)) + doCollect(this.context) } -} - -async function processWindows(windows: chrome.windows.Window[], context: CollectionContext) { - context.focusHost = '' - const windowPromises = windows.map(w => WindowPromise(w, context)) - await Promise.all(windowPromises) - // Accumulate the time of all the hosts - context.accumulateAll() } \ No newline at end of file diff --git a/src/background/timer/context.ts b/src/background/timer/context.ts index 29f997ab0..75466ef4b 100644 --- a/src/background/timer/context.ts +++ b/src/background/timer/context.ts @@ -7,27 +7,6 @@ import optionService from "@service/option-service" -export class TimeInfo { - focus: number - run: number - - static zero() { - return this.of(0, 0) - } - - static of(run: number, focus?: number): TimeInfo { - const info = new TimeInfo() - info.run = run || 0 - info.focus = focus || 0 - return info - } - - selfIncrease(another: TimeInfo): void { - this.focus += another.focus - this.run += another.run - } -} - let countWhenIdle: boolean = false const setCountWhenIdle = (op: timer.option.AllOption) => countWhenIdle = op.countWhenIdle @@ -55,10 +34,11 @@ export default class TimerContext { this.resetTimeMap() } - accumulate(host: string, timeInfo: TimeInfo) { - let data = this.timeMap[host] - !data && (this.timeMap[host] = data = TimeInfo.zero()) - data.selfIncrease(timeInfo) + accumulate(host: string, url: string, focusTime: number) { + let data: TimeInfo = this.timeMap[host] + !data && (this.timeMap[host] = data = {}) + let existFocusTime = data[url] || 0 + data[url] = existFocusTime + focusTime } /** @@ -66,13 +46,6 @@ export default class TimerContext { */ resetTimeMap(): void { this.timeMap = {} } - /** - * @returns The focus info - */ - findFocus(): [host: string, timeInfo: TimeInfo] | undefined { - return Object.entries(this.timeMap).find(([_host, { focus }]) => focus) - } - setIdle(idleNow: chrome.idle.IdleState) { this.idleState = idleNow } isPaused(): boolean { diff --git a/src/background/timer/idle-listener.ts b/src/background/timer/idle-listener.ts index 0941cfc43..445a26950 100644 --- a/src/background/timer/idle-listener.ts +++ b/src/background/timer/idle-listener.ts @@ -26,7 +26,7 @@ export default class IdleListener { listen() { if (!IS_SAFARI) { - // Idle does not work in macOs + // Idle does not work in Safari chrome.idle.onStateChanged.addListener(newState => listen(this.context, newState)) } } diff --git a/src/background/timer/index.ts b/src/background/timer/index.ts index 025b19a37..14b63c800 100644 --- a/src/background/timer/index.ts +++ b/src/background/timer/index.ts @@ -10,11 +10,7 @@ import IdleListener from "./idle-listener" import save from "./save" import TimerContext from "./context" import CollectionContext from "./collection-context" - -function createAlarm(name: string, interval: number, beforeAction?: () => void) { - beforeAction?.() - chrome.alarms.create(name, { when: Date.now() + interval }) -} +import alarmManager from "../alarm-manager" /** * Timer @@ -26,23 +22,8 @@ class Timer { new IdleListener(timerContext).listen() const collector = new TimeCollector(collectionContext) - const collectAlarmName = 'timer-collect-alarm' - const saveAlarmName = 'timer-save-alarm' - createAlarm(collectAlarmName, 1000) - createAlarm(saveAlarmName, 500) - chrome.alarms.onAlarm.addListener(alarm => { - if (collectAlarmName === alarm.name) { - createAlarm(collectAlarmName, 1000, () => { - collector.collect() - chrome.alarms.clear(collectAlarmName) - }) - } else if (saveAlarmName === alarm.name) { - createAlarm(saveAlarmName, 500, () => { - save(collectionContext) - chrome.alarms.clear(saveAlarmName) - }) - } - }) + alarmManager.setInterval('collect', 1000, () => collector.collect()) + alarmManager.setInterval('save', 500, () => save(collectionContext)) } } diff --git a/src/background/timer/save.ts b/src/background/timer/save.ts index bda825601..7467a78fa 100644 --- a/src/background/timer/save.ts +++ b/src/background/timer/save.ts @@ -8,6 +8,7 @@ import limitService from "@service/limit-service" import periodService from "@service/period-service" import timerService from "@service/timer-service" +import { sum } from "@util/array" import CollectionContext from "./collection-context" function sendLimitedMessage(item: timer.limit.Item[]) { @@ -30,16 +31,19 @@ function sendLimitedMessage(item: timer.limit.Item[]) { export default async function save(collectionContext: CollectionContext) { const context = collectionContext.timerContext if (context.isPaused()) return - timerService.addFocusAndTotal(context.timeMap) - const focusEntry = context.findFocus() - - if (focusEntry) { - // Add period time - periodService.add(context.lastCollectTime, focusEntry[1].focus) - // Add limit time - const limitedRules = await limitService.addFocusTime(collectionContext.focusHost, collectionContext.focusUrl, focusEntry[1].focus) - // If time limited after this operation, send messages - limitedRules && limitedRules.length && sendLimitedMessage(limitedRules) + const timeMap = context.timeMap + timerService.addFocusAndTotal(timeMap) + const totalFocusTime = sum(Object.values(timeMap).map(timeInfo => sum(Object.values(timeInfo)))) + // Add period time + await periodService.add(context.lastCollectTime, totalFocusTime) + for (const [host, timeInfo] of Object.entries(timeMap)) { + for (const [url, focusTime] of Object.entries(timeInfo)) { + // Add limit time + const limitedRules = await limitService.addFocusTime(host, url, focusTime) + // If time limited after this operation, send messages + limitedRules && limitedRules.length && sendLimitedMessage(limitedRules) + } } + context.resetTimeMap() } diff --git a/src/background/timer/timer.d.ts b/src/background/timer/timer.d.ts new file mode 100644 index 000000000..80faa79c2 --- /dev/null +++ b/src/background/timer/timer.d.ts @@ -0,0 +1,4 @@ + +declare type TimeInfo = { + [url: string]: number // Focus time +} \ No newline at end of file diff --git a/src/background/version-manager/0-1-2/host-merge-initializer.ts b/src/background/version-manager/0-1-2/host-merge-initializer.ts index fd3c27529..8c87a2aaa 100644 --- a/src/background/version-manager/0-1-2/host-merge-initializer.ts +++ b/src/background/version-manager/0-1-2/host-merge-initializer.ts @@ -6,7 +6,6 @@ */ import MergeRuleDatabase from "@db/merge-rule-database" -import IVersionProcessor from "../i-version-processor" const mergeRuleDatabase = new MergeRuleDatabase(chrome.storage.local) @@ -15,7 +14,8 @@ const mergeRuleDatabase = new MergeRuleDatabase(chrome.storage.local) * * Initialize the merge rules */ -export default class HostMergeInitializer implements IVersionProcessor { +export default class HostMergeInitializer implements VersionProcessor { + since(): string { return '0.1.2' } diff --git a/src/background/version-manager/0-7-0/local-file-initializer.ts b/src/background/version-manager/0-7-0/local-file-initializer.ts index 021c4d7e7..6bd42f0b2 100644 --- a/src/background/version-manager/0-7-0/local-file-initializer.ts +++ b/src/background/version-manager/0-7-0/local-file-initializer.ts @@ -8,7 +8,6 @@ import MergeRuleDatabase from "@db/merge-rule-database" import HostAliasDatabase from "@db/host-alias-database" import { JSON_HOST, LOCAL_HOST_PATTERN, MERGED_HOST, PDF_HOST, PIC_HOST, TXT_HOST } from "@util/constant/remain-host" -import IVersionProcessor from "../i-version-processor" import { t2Chrome } from "@i18n/chrome/t" const storage: chrome.storage.StorageArea = chrome.storage.local @@ -21,7 +20,7 @@ const hostAliasDatabase = new HostAliasDatabase(storage) * * @since 0.7.0 */ -export default class LocalFileInitializer implements IVersionProcessor { +export default class LocalFileInitializer implements VersionProcessor { since(): string { return '0.7.0' } diff --git a/src/background/version-manager/1-4-3/running-time-clear.ts b/src/background/version-manager/1-4-3/running-time-clear.ts new file mode 100644 index 000000000..cb7b9a1de --- /dev/null +++ b/src/background/version-manager/1-4-3/running-time-clear.ts @@ -0,0 +1,38 @@ +import TimerDatabase from "@db/timer-database" +import { isNotZeroResult } from "@util/stat" + +const db = new TimerDatabase(chrome.storage.local) + +/** + * Clear the running time + * + * @since 1.4.3 + */ +export default class RunningTimeClear implements VersionProcessor { + + since(): string { + return "1.4.3" + } + + async process(reason: chrome.runtime.OnInstalledReason): Promise { + // Only trigger when updating + if (reason !== 'update') { + return + } + const allRows = await db.select() + const rows2Delete: timer.stat.Row[] = [] + let updatedCount = 0 + for (const row of allRows) { + if (isNotZeroResult(row)) { + // force update + await db.forceUpdate(row) + updatedCount++ + } else { + // delete it + rows2Delete.push(row) + } + } + await db.delete(rows2Delete) + console.log(`Updated ${updatedCount} rows, deleted ${rows2Delete.length} rows`) + } +} \ No newline at end of file diff --git a/src/background/version-manager/index.ts b/src/background/version-manager/index.ts index 9aca5cd36..9378ccda1 100644 --- a/src/background/version-manager/index.ts +++ b/src/background/version-manager/index.ts @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import IVersionProcessor from "./i-version-processor" import HostMergeInitializer from "./0-1-2/host-merge-initializer" import LocalFileInitializer from "./0-7-0/local-file-initializer" +import RunningTimeClear from "./1-4-3/running-time-clear" /** * Version manager @@ -15,12 +15,13 @@ import LocalFileInitializer from "./0-7-0/local-file-initializer" * @since 0.1.2 */ class VersionManager { - processorChain: IVersionProcessor[] = [] + processorChain: VersionProcessor[] = [] constructor() { this.processorChain.push( new HostMergeInitializer(), new LocalFileInitializer(), + new RunningTimeClear(), ) this.processorChain = this.processorChain.sort((a, b) => a.since() >= b.since() ? 1 : 0) } diff --git a/src/background/version-manager/i-version-processor.ts b/src/background/version-manager/version-manager.d.ts similarity index 82% rename from src/background/version-manager/i-version-processor.ts rename to src/background/version-manager/version-manager.d.ts index e9e8b0bb4..18ca6519b 100644 --- a/src/background/version-manager/i-version-processor.ts +++ b/src/background/version-manager/version-manager.d.ts @@ -1,6 +1,6 @@ /** - * Copyright (c) 2021 Hengyang Zhang - * + * Copyright (c) 2022 Hengyang Zhang + * * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ @@ -10,7 +10,7 @@ * * @since 0.1.2 */ -export default interface IVersionProcessor { +declare type VersionProcessor = { /** * The version number of this processor */ diff --git a/src/background/backup/gist/compressor.ts b/src/common/backup/gist/compressor.ts similarity index 88% rename from src/background/backup/gist/compressor.ts rename to src/common/backup/gist/compressor.ts index d8c291a01..661a8985c 100644 --- a/src/background/backup/gist/compressor.ts +++ b/src/common/backup/gist/compressor.ts @@ -19,7 +19,7 @@ function compress(rows: timer.stat.RowBase[]): GistData { row => row.date.substring(6), groupedRows => { const gistRow: GistRow = {} - groupedRows.forEach(({ host, focus, total, time }) => gistRow[host] = [time, focus, total]) + groupedRows.forEach(({ host, focus, time }) => gistRow[host] = [time, focus]) return gistRow } ) @@ -48,11 +48,12 @@ export function gistData2Rows(yearMonth: string, gistData: GistData): timer.stat Object.entries(gistData).forEach(([dateOfMonth, gistRow]) => { const date = yearMonth + dateOfMonth Object.entries(gistRow).forEach(([host, val]) => { - const [time, focus, total] = val + const [time, focus] = val const row: timer.stat.RowBase = { date, host, - time, focus, total + time, + focus } result.push(row) }) diff --git a/src/background/backup/gist/coordinator.ts b/src/common/backup/gist/coordinator.ts similarity index 100% rename from src/background/backup/gist/coordinator.ts rename to src/common/backup/gist/coordinator.ts diff --git a/src/background/backup/gist/gist.d.ts b/src/common/backup/gist/gist.d.ts similarity index 90% rename from src/background/backup/gist/gist.d.ts rename to src/common/backup/gist/gist.d.ts index cb5840ff8..d3ec491f8 100644 --- a/src/background/backup/gist/gist.d.ts +++ b/src/common/backup/gist/gist.d.ts @@ -15,6 +15,5 @@ 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/common/backup/processor.ts similarity index 96% rename from src/background/backup/processor.ts rename to src/common/backup/processor.ts index f3a37baad..e3111c4f2 100644 --- a/src/background/backup/processor.ts +++ b/src/common/backup/processor.ts @@ -171,10 +171,12 @@ class Processor { const clients: Client[] = (await coordinator.listAllClients(context)).filter(a => a.id !== cid) || [] clients.push(client) await coordinator.updateClients(context, clients) + // Update time + metaService.updateBackUpTime(type, Date.now()) return success() } - async query(type: timer.backup.Type, auth: string, start: Date, end: Date): Promise { + 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() @@ -186,7 +188,7 @@ class Processor { const allClients = (await coordinator.listAllClients(context)) .filter(c => filterClient(c, cid, startStr, endStr)) // 3. iterate month and clients - const result: timer.stat.RemoteRow[] = [] + const result: timer.stat.Row[] = [] const allYearMonth = new MonthIterator(start, end || new Date()).toArray() await Promise.all(allClients.map(async client => { const { id, name } = client @@ -195,7 +197,9 @@ class Processor { .filter(row => filterDate(row, startStr, endStr)) .forEach(row => result.push({ ...row, - clientName: name + cid: id, + cname: name, + mergedHosts: [] })) })) })) diff --git a/src/background/backup/synchronizer.d.ts b/src/common/backup/synchronizer.d.ts similarity index 100% rename from src/background/backup/synchronizer.d.ts rename to src/common/backup/synchronizer.d.ts diff --git a/src/common/logger.ts b/src/common/logger.ts index 867494c48..40a7f3cbe 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -8,7 +8,24 @@ const STORAGE_KEY = "_logOpen" const STORAGE_VAL = "1" -let OPEN_LOG = localStorage.getItem(STORAGE_KEY) === STORAGE_VAL +let OPEN_LOG = false + +// localStorage is not undefined in mv3 of service worker +function initOpenLog() { + try { + localStorage.getItem(STORAGE_KEY) === STORAGE_VAL + } catch (ignored) { } +} + +function updateLocalStorage(openState: boolean) { + try { + openState + ? localStorage.setItem(STORAGE_KEY, STORAGE_VAL) + : localStorage.removeItem(STORAGE_KEY) + } catch (ignored) { } +} + +initOpenLog() /** * @since 0.0.4 @@ -22,8 +39,7 @@ export function log(...args: any) { * @since 0.0.4 */ export function openLog(): string { - OPEN_LOG = true - localStorage.setItem(STORAGE_KEY, STORAGE_VAL) + updateLocalStorage(OPEN_LOG = true) return 'Opened the log manually.' } @@ -31,7 +47,6 @@ export function openLog(): string { * @since 0.0.8 */ export function closeLog(): string { - OPEN_LOG = false - localStorage.removeItem(STORAGE_KEY) + updateLocalStorage(OPEN_LOG = false) return 'Closed the log manually.' } \ No newline at end of file diff --git a/src/content-script/limit.ts b/src/content-script/limit.ts index 735b48d02..282dd9631 100644 --- a/src/content-script/limit.ts +++ b/src/content-script/limit.ts @@ -8,6 +8,7 @@ import TimeLimitItem from "@entity/time-limit-item" import optionService from "@service/option-service" import { t2Chrome } from "@i18n/chrome/t" +import { t } from "./locale" function moreMinutes(url: string): Promise { const request: timer.mq.Request = { @@ -65,7 +66,7 @@ class _Modal { const link = document.createElement('a') Object.assign(link.style, linkStyle) link.setAttribute('href', 'javascript:void(0)') - const text = t2Chrome(msg => msg.message.more5Minutes) + const text = t(msg => msg.more5Minutes) link.innerText = text link.onclick = async () => { const delayRules = await moreMinutes(_thisUrl) @@ -77,6 +78,8 @@ class _Modal { } this.delayContainer.append(link) this.mask.append(this.delayContainer) + } else if (!showDelay && this.mask.childElementCount === 2) { + this.mask.children?.[1]?.remove?.() } if (this.visible) { return @@ -87,7 +90,7 @@ class _Modal { } hideModal() { - if (!this.visible || document.body) { + if (!this.visible || !document.body) { return } this.mask.remove() @@ -150,7 +153,7 @@ function link2Setup(url: string): HTMLParagraphElement { const link = document.createElement('a') Object.assign(link.style, linkStyle) link.setAttribute('href', 'javascript:void(0)') - const text = t2Chrome(msg => msg.message.timeLimitMsg) + const text = t(msg => msg.timeLimitMsg) .replace('{appName}', t2Chrome(msg => msg.meta.name)) link.innerText = text link.onclick = () => chrome.runtime.sendMessage(openLimitPageMessage(url)) @@ -194,15 +197,15 @@ function handleLimitWaking(msg: timer.mq.Request, modal: _Mo return { code: "success" } } -function handleLimitRemoved(msg: timer.mq.Request, modal: _Modal): timer.mq.Response { - if (msg.code !== 'limitRemoved') { +function handleLimitChanged(msg: timer.mq.Request, modal: _Modal): timer.mq.Response { + if (msg.code === 'limitChanged') { + const data: timer.limit.Item[] = msg.data + const items = data.map(TimeLimitItem.of) + items?.length ? modal.process(items) : modal.hideModal() + return { code: 'success' } + } else { return { code: 'ignore' } } - if (!modal.isVisible()) { - return { code: 'ignore' } - } - modal.hideModal() - return { code: 'success' } } export default async function processLimit(url: string) { @@ -218,7 +221,7 @@ export default async function processLimit(url: string) { (msg: timer.mq.Request, _sender, sendResponse: timer.mq.Callback) => sendResponse(handleLimitWaking(msg, modal)) ) chrome.runtime.onMessage.addListener( - (msg: timer.mq.Request, _sender, sendResponse: timer.mq.Callback) => sendResponse(handleLimitRemoved(msg, modal)) + (msg: timer.mq.Request, _sender, sendResponse: timer.mq.Callback) => sendResponse(handleLimitChanged(msg, modal)) ) } diff --git a/src/content-script/locale.ts b/src/content-script/locale.ts new file mode 100644 index 000000000..2aa3a5302 --- /dev/null +++ b/src/content-script/locale.ts @@ -0,0 +1,15 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +import type { I18nKey } from "@i18n" +import type { ContentScriptMessage } from "@i18n/message/common/content-script" + +import { t as t_ } from "@i18n" +import messages from "@i18n/message/common/content-script" + +export function t(key: I18nKey): string { + return t_(messages, { key }) +} \ No newline at end of file diff --git a/src/content-script/printer.ts b/src/content-script/printer.ts index 8e1f9c1a1..670c6933b 100644 --- a/src/content-script/printer.ts +++ b/src/content-script/printer.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { t2Chrome } from "@i18n/chrome/t" +import { t } from "./locale" import { formatPeriod } from "@util/time" function getTodayInfo(host: string): Promise { @@ -24,20 +24,17 @@ function getTodayInfo(host: string): Promise { */ export default async function printInfo(host: string) { const waste: timer.stat.Result = await getTodayInfo(host) - const hourMsg = t2Chrome(root => root.message.timeWithHour) - const minuteMsg = t2Chrome(root => root.message.timeWithMinute) - const secondMsg = t2Chrome(root => root.message.timeWithSecond) + const hourMsg = t(msg => msg.timeWithHour) + const minuteMsg = t(msg => msg.timeWithMinute) + const secondMsg = t(msg => msg.timeWithSecond) const msg = { hourMsg, minuteMsg, secondMsg } - const info0 = t2Chrome(root => root.message.openTimesConsoleLog) + const info0 = t(msg => msg.consoleLog) .replace('{time}', waste.time ? '' + waste.time : '-') - .replace('{host}', host) - const info1 = t2Chrome(root => root.message.usedTimeInConsoleLog) .replace('{focus}', formatPeriod(waste.focus, msg)) - .replace('{total}', formatPeriod(waste.total, msg)) - const info2 = t2Chrome(root => root.message.closeAlert) + .replace('{host}', host) + const info1 = t(msg => msg.closeAlert) console.log(info0) console.log(info1) - console.log(info2) } \ No newline at end of file diff --git a/src/database/limit-database.ts b/src/database/limit-database.ts index 2da07e591..6949f1016 100644 --- a/src/database/limit-database.ts +++ b/src/database/limit-database.ts @@ -74,12 +74,22 @@ class LimitDatabase extends BaseDatabase { async save(data: timer.limit.Rule, rewrite?: boolean): Promise { const items = await this.getItems() - if (!rewrite && items[data.cond]) { - // Not rewrite - return + const { cond, time, enabled, allowDelay } = data + const existItem = items[cond] + if (existItem) { + if (!rewrite) { + // Not rewrite + return + } + // Rewrite + existItem.t = time + existItem.e = enabled + existItem.ad = allowDelay + } else { + // New one + items[cond] = { t: time, e: enabled, ad: allowDelay, w: 0, d: '' } } - items[data.cond] = { t: data.time, e: data.enabled, ad: data.allowDelay, w: 0, d: '' } - this.update(items) + await this.update(items) } async remove(cond: string): Promise { diff --git a/src/database/timer-database.ts b/src/database/timer-database.ts index 7e1c4e265..bead4cfdb 100644 --- a/src/database/timer-database.ts +++ b/src/database/timer-database.ts @@ -21,13 +21,6 @@ export type TimerCondition = { * Host name for query */ host?: string - /** - * Total range, milliseconds - * - * @since 0.0.9 - * @deprecated 1.3.4 - */ - totalRange?: number[] /** * Focus range, milliseconds * @@ -60,8 +53,6 @@ type _TimerCondition = TimerCondition & { timeEnd?: number focusStart?: number focusEnd?: number - totalStart?: number - totalEnd?: number } function processDateCondition(cond: _TimerCondition, paramDate: Date | Date[]) { @@ -87,11 +78,7 @@ function processParamTimeCondition(cond: _TimerCondition, paramTime: number[]) { paramTime.length >= 2 && (cond.timeEnd = paramTime[1]) paramTime.length >= 1 && (cond.timeStart = paramTime[0]) } -function processParamTotalCondition(cond: _TimerCondition, paramTotal: number[]) { - if (!paramTotal) return - paramTotal.length >= 2 && (cond.totalEnd = paramTotal[1]) - paramTotal.length >= 1 && (cond.totalStart = paramTotal[0]) -} + function processParamFocusCondition(cond: _TimerCondition, paramFocus: number[]) { if (!paramFocus) return paramFocus.length >= 2 && (cond.focusEnd = paramFocus[1]) @@ -102,14 +89,24 @@ function processCondition(condition: TimerCondition): _TimerCondition { const result: _TimerCondition = { ...condition } processDateCondition(result, condition.date) processParamTimeCondition(result, condition.timeRange) - processParamTotalCondition(result, condition.totalRange) processParamFocusCondition(result, condition.focusRange) return result } function mergeMigration(exist: timer.stat.Result | undefined, another: any) { exist = exist || createZeroResult() - return mergeResult(exist, { total: another.total || 0, focus: another.focus || 0, time: another.time || 0 }) + return mergeResult(exist, { focus: another.focus || 0, time: another.time || 0 }) +} + +/** + * Generate the key in local storage by host and date + * + * @param host host + * @param date date + */ +function generateKey(host: string, date: Date | string) { + const str = typeof date === 'object' ? formatTime(date as Date, DATE_FORMAT) : date + return str + host } function migrate(exists: { [key: string]: timer.stat.Result }, data: any): { [key: string]: timer.stat.Result } { @@ -141,23 +138,12 @@ class TimerDatabase extends BaseDatabase { return Promise.resolve(items) } - /** - * Generate the key in local storage by host and date - * - * @param host host - * @param date date - */ - private generateKey(host: string, date: Date | string) { - const str = typeof date === 'object' ? formatTime(date as Date, DATE_FORMAT) : date - return str + host - } - /** * @param host host * @since 0.1.3 */ async accumulate(host: string, date: Date | string, item: timer.stat.Result): Promise { - const key = this.generateKey(host, date) + const key = generateKey(host, date) const items = await this.storage.get(key) const exist: timer.stat.Result = mergeResult(items[key] as timer.stat.Result || createZeroResult(), item) const toUpdate = {} @@ -178,7 +164,7 @@ class TimerDatabase extends BaseDatabase { if (!hosts.length) return const dateStr = formatTime(date, DATE_FORMAT) const keys: { [host: string]: string } = {} - hosts.forEach(host => keys[host] = this.generateKey(host, dateStr)) + hosts.forEach(host => keys[host] = generateKey(host, dateStr)) const items = await this.storage.get(Object.values(keys)) @@ -210,8 +196,8 @@ class TimerDatabase extends BaseDatabase { const host = key.substring(8) const val: timer.stat.Result = items[key] if (this.filterBefore(date, host, val, _cond)) { - const { total, focus, time } = val - result.push({ date, host, total, focus, time, mergedHosts: [] }) + const { focus, time } = val + result.push({ date, host, focus, time, mergedHosts: [] }) } } @@ -256,13 +242,12 @@ class TimerDatabase extends BaseDatabase { * @return true if valid, or false */ private filterBefore(date: string, host: string, val: timer.stat.Result, condition: _TimerCondition): boolean { - const { focus, total, time } = val - const { timeStart, timeEnd, totalStart, totalEnd, focusStart, focusEnd } = condition + const { focus, time } = val + const { timeStart, timeEnd, focusStart, focusEnd } = condition return this.filterHost(host, condition) && this.filterDate(date, condition) && this.filterNumberRange(time, [timeStart, timeEnd]) - && this.filterNumberRange(total, [totalStart, totalEnd]) && this.filterNumberRange(focus, [focusStart, focusEnd]) } @@ -272,8 +257,8 @@ class TimerDatabase extends BaseDatabase { * @since 0.0.5 */ async get(host: string, date: Date): Promise { - const key = this.generateKey(host, date) - const items = await this.storage.get(null) + const key = generateKey(host, date) + const items = await this.storage.get(key) return Promise.resolve(items[key] || createZeroResult()) } @@ -285,7 +270,7 @@ class TimerDatabase extends BaseDatabase { * @since 0.0.5 */ async deleteByUrlAndDate(host: string, date: Date | string): Promise { - const key = this.generateKey(host, date) + const key = generateKey(host, date) return this.storage.remove(key) } @@ -296,10 +281,21 @@ class TimerDatabase extends BaseDatabase { * @since 0.0.9 */ async delete(rows: timer.stat.Row[]): Promise { - const keys: string[] = rows.filter(({ date, host }) => !!host && !!date).map(({ host, date }) => this.generateKey(host, date)) + const keys: string[] = rows.filter(({ date, host }) => !!host && !!date).map(({ host, date }) => generateKey(host, date)) return this.storage.remove(keys) } + /** + * Force update data + * + * @since 1.4.3 + */ + forceUpdate(row: timer.stat.Row): 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) + } + /** * @param host host * @param start start date, inclusive diff --git a/src/echarts/canvas-renderer.ts b/src/echarts/svg-renderer.ts similarity index 54% rename from src/echarts/canvas-renderer.ts rename to src/echarts/svg-renderer.ts index a71cb5c66..7671545a4 100644 --- a/src/echarts/canvas-renderer.ts +++ b/src/echarts/svg-renderer.ts @@ -5,6 +5,6 @@ * https://opensource.org/licenses/MIT */ -import { install as CanvasRenderer } from "echarts/lib/renderer/installCanvasRenderer" +import { install as SVGRenderer } from "echarts/lib/renderer/installSVGRenderer" -export default CanvasRenderer \ No newline at end of file +export default SVGRenderer \ No newline at end of file diff --git a/src/guide/component/common.ts b/src/guide/component/common.ts index 923831a61..23db0df4c 100644 --- a/src/guide/component/common.ts +++ b/src/guide/component/common.ts @@ -3,13 +3,14 @@ import type { VNode } from "vue" import { t, tN } from "@guide/locale" import { h } from "vue" +import { position2AnchorClz } from "@guide/util" -export function h1(i18nKey: I18nKey, archorClz: string, i18nParam?: any): VNode { - return h('h1', { class: `guide-h1 archor-${archorClz}` }, t(i18nKey, i18nParam)) +export function h1(i18nKey: I18nKey, position: Position, i18nParam?: any): VNode { + return h('h1', { class: `guide-h1 ${position2AnchorClz(position)}` }, t(i18nKey, i18nParam)) } -export function h2(i18nKey: I18nKey, archorClz: string): VNode { - return h('h2', { class: `guide-h2 archor-${archorClz}` }, t(i18nKey)) +export function h2(i18nKey: I18nKey, position: Position): VNode { + return h('h2', { class: `guide-h2 ${position2AnchorClz(position)}` }, t(i18nKey)) } export function paragraph(i18nKey: I18nKey, param?: any): VNode { diff --git a/src/guide/component/privacy.ts b/src/guide/component/privacy.ts index e7355bfe6..c64545097 100644 --- a/src/guide/component/privacy.ts +++ b/src/guide/component/privacy.ts @@ -5,14 +5,14 @@ const _default = defineComponent({ setup() { return () => section( h1(msg => msg.layout.menu.privacy.title, 'privacy'), - h2(msg => msg.layout.menu.privacy.scope, 'scope'), + h2(msg => msg.layout.menu.privacy.scope, 'privacy.scope'), paragraph(msg => msg.privacy.scope.p1), list( msg => msg.privacy.scope.l1, msg => msg.privacy.scope.l2, msg => msg.privacy.scope.l3, ), - h2(msg => msg.layout.menu.privacy.storage, 'storage'), + h2(msg => msg.layout.menu.privacy.storage, 'privacy.storage'), paragraph(msg => msg.privacy.storage.p1), paragraph(msg => msg.privacy.storage.p2), paragraph(msg => msg.privacy.storage.p3), diff --git a/src/guide/component/usage.ts b/src/guide/component/usage.ts index 4308dbfad..b634e1032 100644 --- a/src/guide/component/usage.ts +++ b/src/guide/component/usage.ts @@ -4,7 +4,7 @@ import { h1, h2, paragraph, list, link, section, linkInner } from "./common" import { t } from "../locale" const quickstart = () => [ - h2(msg => msg.layout.menu.usage.quickstart, 'quickstart'), + h2(msg => msg.layout.menu.usage.quickstart, 'usage.quickstart'), paragraph(msg => msg.usage.quickstart.p1), list( msg => msg.usage.quickstart.l1, @@ -17,7 +17,7 @@ const quickstart = () => [ const backgroundPageUrl = getAppPageUrl(false) const background = () => [ - h2(msg => msg.layout.menu.usage.background, 'background'), + h2(msg => msg.layout.menu.usage.background, 'usage.background'), paragraph(msg => msg.usage.background.p1, { background: linkInner(backgroundPageUrl, t(msg => msg.usage.background.backgroundPage)) }), @@ -29,7 +29,7 @@ const background = () => [ ] const advanced = () => [ - h2(msg => msg.layout.menu.usage.advanced, 'advanced'), + h2(msg => msg.layout.menu.usage.advanced, 'usage.advanced'), paragraph(msg => msg.usage.advanced.p1), list( msg => msg.usage.advanced.l1, @@ -45,6 +45,18 @@ const advanced = () => [ ), ] +const backup = () => [ + h2(msg => msg.layout.menu.usage.backup, 'usage.backup'), + paragraph(msg => msg.usage.backup.p1, { gist: link('https://gist.github.com/', 'Github Gist') }), + list( + [msg => msg.usage.backup.l1, { + token: link('https://github.com/settings/tokens', 'token') + }], + msg => msg.usage.backup.l2, + msg => msg.usage.backup.l3, + ), +] + const _default = defineComponent({ setup() { return () => section( @@ -52,6 +64,7 @@ const _default = defineComponent({ ...quickstart(), ...background(), ...advanced(), + ...backup(), ) } }) diff --git a/src/guide/guide.d.ts b/src/guide/guide.d.ts new file mode 100644 index 000000000..a14023b1c --- /dev/null +++ b/src/guide/guide.d.ts @@ -0,0 +1,14 @@ + +/** + * The anchor of menu items + */ +declare type Position = + | 'profile' + | 'usage' + | 'usage.quickstart' + | 'usage.background' + | 'usage.advanced' + | 'usage.backup' + | 'privacy' + | 'privacy.scope' + | 'privacy.storage' \ No newline at end of file diff --git a/src/guide/index.ts b/src/guide/index.ts index b87af4365..3334af4c8 100644 --- a/src/guide/index.ts +++ b/src/guide/index.ts @@ -10,13 +10,15 @@ import 'element-plus/theme-chalk/index.css' import { initLocale } from "@i18n" import { t } from "./locale" -import { init as initTheme } from "@util/dark-mode" +import { init as initTheme, toggle } from "@util/dark-mode" import { createApp } from "vue" import Main from "./layout" - +import optionService from "@service/option-service" async function main() { initTheme() + // Calculate the latest mode + optionService.isDarkMode().then(toggle) await initLocale() const app = createApp(Main) diff --git a/src/guide/layout/content.ts b/src/guide/layout/content.ts index 1343f4e90..5a9e0c944 100644 --- a/src/guide/layout/content.ts +++ b/src/guide/layout/content.ts @@ -1,25 +1,27 @@ import { ElDivider } from "element-plus" -import { watch, defineComponent, h, onMounted } from "vue" +import { watch, defineComponent, h, onMounted, PropType } from "vue" import Profile from "../component/profile" import Usage from "../component/usage" import Privacy from "../component/privacy" +import { position2AnchorClz } from "@guide/util" -function scrolePosition(position: string) { - document.querySelector(`.archor-${position}`)?.scrollIntoView?.() +function scrollPosition(position: Position) { + console.log(position) + document.querySelector(`.${position2AnchorClz(position)}`)?.scrollIntoView?.() } const _default = defineComponent({ name: 'GuideContent', props: { position: { - type: String, + type: String as PropType, required: false, } }, setup(props) { - onMounted(() => scrolePosition(props.position)) - watch(() => props.position, newVal => newVal && scrolePosition(newVal)) + onMounted(() => scrollPosition(props.position)) + watch(() => props.position, newVal => newVal && scrollPosition(newVal)) return () => [ h(Profile), h(ElDivider), diff --git a/src/guide/layout/index.ts b/src/guide/layout/index.ts index ed38ee602..7cec2f643 100644 --- a/src/guide/layout/index.ts +++ b/src/guide/layout/index.ts @@ -15,9 +15,9 @@ import Menu from "./menu" const _default = defineComponent({ name: "Guide", render() { - const position: Ref = ref() + const position: Ref = ref() return h(ElContainer, { class: 'guide-container' }, () => [ - h(ElAside, {}, () => h(Menu, { onClick: (newPosition: string) => position.value = newPosition })), + h(ElAside, {}, () => h(Menu, { onClick: (newPosition: Position) => position.value = newPosition })), h(ElContainer, { id: 'app-body' }, () => h(ElMain, {}, () => h(Content, { position: position.value }))) diff --git a/src/guide/layout/menu.ts b/src/guide/layout/menu.ts index 025e816b4..21db6fd1c 100644 --- a/src/guide/layout/menu.ts +++ b/src/guide/layout/menu.ts @@ -6,28 +6,26 @@ */ import { ref, VNode } from "vue" - import ElementIcon from "@src/element-ui/icon" import { I18nKey, t } from "@guide/locale" import { ElIcon, ElMenu, ElMenuItem, ElSubMenu } from "element-plus" import { defineComponent, h } from "vue" import { User, Memo, MagicStick } from "@element-plus/icons-vue" - type _Item = { title: I18nKey - position: string + position: Position } type _Group = { title: I18nKey - position: string + position: Position children: _Item[] icon: ElementIcon } -const quickstartPosition = 'quickstart' -const profilePosition = 'profile' +const quickstartPosition: Position = 'usage.quickstart' +const profilePosition: Position = 'profile' const menus: _Group[] = [ { title: msg => msg.layout.menu.usage.title, @@ -38,11 +36,14 @@ const menus: _Group[] = [ position: quickstartPosition }, { title: msg => msg.layout.menu.usage.background, - position: 'background' + position: 'usage.background' }, { title: msg => msg.layout.menu.usage.advanced, - position: 'advanced' - }, + position: 'usage.advanced' + }, { + title: msg => msg.layout.menu.usage.backup, + position: 'usage.backup', + } ], icon: Memo }, @@ -53,18 +54,18 @@ const menus: _Group[] = [ children: [ { title: msg => msg.layout.menu.privacy.scope, - position: 'scope' + position: 'privacy.scope' }, { title: msg => msg.layout.menu.privacy.storage, - position: 'storage' + position: 'privacy.storage' }, ], } ] -function renderMenuItem(handleClick: (position: string) => void, item: _Item, index: number): VNode { +function renderMenuItem(handleClick: (position: Position) => void, item: _Item, index: number): VNode { const { title, position } = item return h(ElMenuItem, { index: position, @@ -72,11 +73,10 @@ function renderMenuItem(handleClick: (position: string) => void, item: _Item, in }, () => h('span', {}, `${index + 1}. ${t(title)}`)) } -function renderGroup(handleClick: (position: string) => void, group: _Group): VNode { +function renderGroup(handleClick: (position: Position) => void, group: _Group): VNode { const { position, title, children, icon } = group return h(ElSubMenu, { index: position, - onClick: () => handleClick(position) }, { title: () => [ h(ElIcon, () => h(icon)), @@ -90,9 +90,11 @@ function renderGroup(handleClick: (position: string) => void, group: _Group): VN const _default = defineComponent({ name: "GuideMenu", - emits: ['click'], + emits: { + click: (_position: Position) => true, + }, setup(_, ctx) { - const handleClick = (position: string) => ctx.emit('click', position) + const handleClick = (position: Position) => ctx.emit('click', position) const menuItems = () => [ h(ElMenuItem, { index: profilePosition, diff --git a/src/guide/style/dark-theme.sass b/src/guide/style/dark-theme.sass new file mode 100644 index 000000000..38b9ef1cf --- /dev/null +++ b/src/guide/style/dark-theme.sass @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +html[data-theme='dark']:root + --el-color-primary: #409eff + --el-color-primary-light-3: #3375b9 + --el-color-primary-light-5: #2a598a + --el-color-primary-light-7: #213d5b + --el-color-primary-light-8: #1d3043 + --el-color-primary-light-9: #18222c + --el-color-primary-dark-2: #66b1ff + --el-color-success: #67c23a + --el-color-success-light-3: #4e8e2f + --el-color-success-light-5: #3e6b27 + --el-color-success-light-7: #2d481f + --el-color-success-light-8: #25371c + --el-color-success-light-9: #1c2518 + --el-color-success-dark-2: #85ce61 + --el-color-warning: #e6a23c + --el-color-warning-light-3: #a77730 + --el-color-warning-light-5: #7d5b28 + --el-color-warning-light-7: #533f20 + --el-color-warning-light-8: #3e301c + --el-color-warning-light-9: #292218 + --el-color-warning-dark-2: #ebb563 + --el-color-danger: #f56c6c + --el-color-danger-light-3: #b25252 + --el-color-danger-light-5: #854040 + --el-color-danger-light-7: #582e2e + --el-color-danger-light-8: #412626 + --el-color-danger-light-9: #2b1d1d + --el-color-danger-dark-2: #f78989 + --el-color-error: #f56c6c + --el-color-error-light-3: #b25252 + --el-color-error-light-5: #854040 + --el-color-error-light-7: #582e2e + --el-color-error-light-8: #412626 + --el-color-error-light-9: #2b1d1d + --el-color-error-dark-2: #f78989 + --el-color-info: #909399 + --el-color-info-light-3: #6b6d71 + --el-color-info-light-5: #525457 + --el-color-info-light-7: #393a3c + --el-color-info-light-8: #2d2d2f + --el-color-info-light-9: #202121 + --el-color-info-dark-2: #a6a9ad + --el-box-shadow: 0px 12px 32px 4px rgba(0 0 0 .36) 0px 8px 20px rgba(0 0 0 .72) + --el-box-shadow-light: 0px 0px 12px rgba(0 0 0 .72) + --el-box-shadow-lighter: 0px 0px 6px rgba(0 0 0 .72) + --el-box-shadow-dark: 0px 16px 48px 16px rgba(0 0 0 .72) 0px 12px 32px #000000 0px 8px 16px -8px #000000 + --el-bg-color-page: #0a0a0a + --el-bg-color: #141414 + --el-bg-color-overlay: #1d1e1f + --el-text-color-primary: #E5EAF3 + --el-text-color-regular: #CFD3DC + --el-text-color-secondary: #A3A6AD + --el-text-color-placeholder: #8D9095 + --el-text-color-disabled: #6C6E72 + --el-border-color-darker: #636466 + --el-border-color-dark: #58585B + --el-border-color: #4C4D4F + --el-border-color-light: #414243 + --el-border-color-lighter: #363637 + --el-border-color-extra-light: #2B2B2C + --el-fill-color-darker: #424243 + --el-fill-color-dark: #39393A + --el-fill-color: #303030 + --el-fill-color-light: #262727 + --el-fill-color-lighter: #1D1D1D + --el-fill-color-extra-light: #191919 + --el-fill-color-blank: var(--el-fill-color-darker) + --el-mask-color: rgba(0 0 0 .8) + --el-mask-color-extra-light: rgba(0 0 0 .3) + --guide-container-bg-color: var(--el-fill-color-dark) + // menu + --el-menu-bg-color: var(--el-fill-color-light) diff --git a/src/guide/style.sass b/src/guide/style/index.sass similarity index 82% rename from src/guide/style.sass rename to src/guide/style/index.sass index 13dddcee8..b1a9ffe1f 100644 --- a/src/guide/style.sass +++ b/src/guide/style/index.sass @@ -1,10 +1,13 @@ /** - * Copyright (c) 2022 Hengyang Zhang + * Copyright (c) 2023 Hengyang Zhang * * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ +@import './light-theme' +@import './dark-theme' + body margin: 0px @@ -13,6 +16,8 @@ body height: 100vh width: 100% scroll-behavior: smooth + .guide-container + background: var(--guide-container-bg-color) .el-menu height: 100% .el-main @@ -21,14 +26,14 @@ body .guide-area padding: 0 9vw padding-bottom: 10px + h1,h2,span,div,li + color: var(--el-text-color-primary) h1 - color: #3c4043 font-weight: 500 font-size: 1.8rem letter-spacing: 0 line-height: 1.333 h2 - color: #3c4043 font-weight: 500 font-size: 1.4rem letter-spacing: 0 @@ -36,7 +41,6 @@ body margin: 40px 0 margin-left: 2px .guide-paragragh,.guide-list - color: rgba(0,0,0,.87) font-size: 0.9375rem font-weight: 400 margin-left: 2px @@ -55,7 +59,7 @@ body text-decoration: none cursor: pointer .guide-link,.guide-link:hover,.guide-link:visited - color: #3367d6 + color: var(--el-color-primary) #app-body margin-bottom: 40px diff --git a/src/guide/style/light-theme.sass b/src/guide/style/light-theme.sass new file mode 100644 index 000000000..e75ed01cf --- /dev/null +++ b/src/guide/style/light-theme.sass @@ -0,0 +1,12 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +\:root + --el-menu-bg-color: #1d222d + --el-menu-text-color: #c1c6c8 + --el-menu-hover-bg-color: #262f3e + --guide-container-bg-color: var(--el-fill-color-blank) diff --git a/src/guide/util.ts b/src/guide/util.ts new file mode 100644 index 000000000..474f93566 --- /dev/null +++ b/src/guide/util.ts @@ -0,0 +1,3 @@ +export function position2AnchorClz(position: Position): string { + return `anchor-${position?.replace('.', '-')}` +} \ No newline at end of file diff --git a/src/i18n/chrome/message.ts b/src/i18n/chrome/message.ts index c04c2dca8..9ef326c60 100644 --- a/src/i18n/chrome/message.ts +++ b/src/i18n/chrome/message.ts @@ -6,7 +6,6 @@ */ import metaMessages, { MetaMessage } from "../message/common/meta" -import contentScriptMessages, { ContentScriptMessage } from "../message/common/content-script" import contextMenusMessages, { ContextMenusMessage } from "../message/common/context-menus" import initialMessages, { InitialMessage } from "../message/common/initial" import baseMessages, { BaseMessage } from "@i18n/message/common/base" @@ -14,7 +13,6 @@ import baseMessages, { BaseMessage } from "@i18n/message/common/base" export type ChromeMessage = { meta: MetaMessage base: BaseMessage - message: ContentScriptMessage contextMenus: ContextMenusMessage initial: InitialMessage } @@ -23,28 +21,24 @@ const messages: Messages = { zh_CN: { meta: metaMessages.zh_CN, base: baseMessages.zh_CN, - message: contentScriptMessages.zh_CN, contextMenus: contextMenusMessages.zh_CN, initial: initialMessages.zh_CN, }, zh_TW: { meta: metaMessages.zh_TW, base: baseMessages.zh_TW, - message: contentScriptMessages.zh_TW, contextMenus: contextMenusMessages.zh_TW, initial: initialMessages.zh_TW, }, en: { meta: metaMessages.en, base: baseMessages.en, - message: contentScriptMessages.en, contextMenus: contextMenusMessages.en, initial: initialMessages.en, }, ja: { meta: metaMessages.ja, base: baseMessages.ja, - message: contentScriptMessages.ja, contextMenus: contextMenusMessages.ja, initial: initialMessages.ja, } @@ -63,16 +57,6 @@ const placeholder: ChromeMessage = { allFunction: '', guidePage: '', }, - message: { - openTimesConsoleLog: '', - usedTimeInConsoleLog: '', - closeAlert: '', - timeWithHour: '', - timeWithMinute: '', - timeWithSecond: '', - timeLimitMsg: '', - more5Minutes: '' - }, contextMenus: { add2Whitelist: '', removeFromWhitelist: '', diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 54a6a3b19..e724ffcfb 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -18,17 +18,39 @@ const FEEDBACK_LOCALE: timer.Locale = "en" export const defaultLocale: timer.Locale = "zh_CN" -// export type Messages = { -// [key in timer.Locale]: T -// } - // Standardize the locale code according to the Chrome locale code const chrome2I18n: { [key: string]: timer.Locale } = { 'zh-CN': "zh_CN", 'zh-TW': "zh_TW", 'en-US': "en", 'en-GB': "en", - 'ja': "ja" + 'ja': "ja", +} + +const translationChrome2I18n: { [key: string]: timer.TranslatingLocale } = { + de: 'de', + "es-ES": 'es', + "es-MX": 'es', + ko: 'ko', + pl: 'pl', + "pt-PT": 'pt', + "pt-BR": 'pt_BR', + ru: 'ru', + uk: 'uk', + fr: 'fr', + it: 'it', + sv: 'sv', + fi: 'fi', + da: 'da', + hr: 'hr', + id: 'id', + tr: 'tr', + cs: 'cs', + ro: 'ro', + nl: 'nl', + vi: 'vi', + sk: 'sk', + mn: 'mn', } /** @@ -51,6 +73,17 @@ export function chromeLocale2ExtensionLocale(chromeLocale: string): timer.Locale */ export let localeSameAsBrowser: timer.Locale = chromeLocale2ExtensionLocale(chrome.i18n.getUILanguage()) +/** + * @since 1.5.0 + */ +export function isTranslatingLocale(): boolean { + const uiLang = chrome.i18n.getUILanguage() + if (chrome2I18n[uiLang]) { + return false + } + return !!translationChrome2I18n[uiLang] +} + /** * Real locale with locale option */ diff --git a/src/i18n/message/app/data-manage.ts b/src/i18n/message/app/data-manage.ts index c7f36eb81..91023047f 100644 --- a/src/i18n/message/app/data-manage.ts +++ b/src/i18n/message/app/data-manage.ts @@ -12,7 +12,6 @@ export type DataManageMessage = { operationAlert: string filterItems: string filterFocus: string - filterTotal: string filterTime: string filterDate: string unlimited: string @@ -36,10 +35,9 @@ const _default: Messages = { totalMemoryAlert: '浏览器为每个扩展提供 {size}MB 来存储本地数据', totalMemoryAlert1: '无法确定浏览器允许的最大可用内存', usedMemoryAlert: '当前已使用 {size}MB', - operationAlert: '您可以归档或者删除那些无关紧要的数据,来减小内存空间', + operationAlert: '您可以删除那些无关紧要的数据,来减小内存空间', filterItems: '数据筛选', filterFocus: '当日阅览时间在 {start} 秒至 {end} 秒之间。', - filterTotal: '当日运行时间在 {start} 秒至 {end} 秒之间。', filterTime: '当日打开次数在 {start} 次至 {end} 次之间。', filterDate: '{picker} 产生的数据。', unlimited: '无限', @@ -61,10 +59,9 @@ const _default: Messages = { totalMemoryAlert: '瀏覽器爲每個擴充提供 {size}MB 來存儲本地數據', totalMemoryAlert1: '無法確定瀏覽器允許的最大可用內存', usedMemoryAlert: '當前已使用 {size}MB', - operationAlert: '您可以歸檔或者刪除那些無關緊要的數據,來減小內存空間', + operationAlert: '您可以刪除那些無關緊要的數據,來減小內存空間', filterItems: '數據篩選', filterFocus: '當日閱覽時間在 {start} 秒至 {end} 秒之間。', - filterTotal: '當日運行時間在 {start} 秒至 {end} 秒之間。', filterTime: '當日打開次數在 {start} 次至 {end} 次之間。', filterDate: '{picker} 産生的數據。', unlimited: '無限', @@ -89,7 +86,6 @@ const _default: Messages = { operationAlert: 'You can delete those unimportant data to reduce memory usage', filterItems: 'Filter data', filterFocus: 'The browsing time of the day is between {start} seconds and {end} seconds', - filterTotal: 'The running time of the day is between {start} seconds and {end} seconds', filterTime: 'The number of visits for the day is between {start} and {end}', filterDate: 'Recorded between {picker}', unlimited: '∞', @@ -111,10 +107,9 @@ const _default: Messages = { totalMemoryAlert: 'ブラウザは、データを保存するために各拡張機能に {size}MB のメモリを提供します', totalMemoryAlert1: 'ブラウザで許可されている各拡張機能で使用可能な最大メモリを特定できません', usedMemoryAlert: '現在 {size}MB が使用されています', - operationAlert: '不要なデータを削除してメモリ容量を減らすことができます', + operationAlert: 'これらの重要でないデータを削除して、メモリ使用量を減らすことができます', filterItems: 'データフィルタリング', filterFocus: '当日の閲覧時間は、{start} 秒から {end} 秒の間です。', - filterTotal: '当日の実行時間は、{start} 秒から {end} 秒の間です。', filterTime: '当日のオープン数は {start} から {end} の間です。', filterDate: '{picker} までに生成されたデータ', unlimited: '無制限', diff --git a/src/i18n/message/app/limit.ts b/src/i18n/message/app/limit.ts index 8d91c659d..50a2fc8e4 100644 --- a/src/i18n/message/app/limit.ts +++ b/src/i18n/message/app/limit.ts @@ -11,7 +11,6 @@ export type LimitMessage = { addTitle: string useWildcard: string urlPlaceholder: string - item: { condition: string time: string @@ -24,14 +23,11 @@ export type LimitMessage = { button: { add: string test: string + testSimple: string paste: string save: string delete: string - } - timeUnit: { - hour: string - minute: string - second: string + modify: string } message: { noUrl: string @@ -40,7 +36,12 @@ export type LimitMessage = { deleteConfirm: string deleted: string noPermissionFirefox: string + inputTestUrl: string + clickTestButton: string + noRuleMatched: string + rulesMatched: string } + testUrlLabel: string } const _default: Messages = { @@ -59,17 +60,14 @@ const _default: Messages = { button: { add: '新增', test: '网址测试', + testSimple: '测试', paste: '粘贴', save: '保存', delete: '删除', + modify: '修改', }, addTitle: '新增限制', useWildcard: '是否使用通配符', - timeUnit: { - hour: '小时', - minute: '分钟', - second: '秒', - }, message: { saved: '保存成功', noUrl: '未填写限制网址', @@ -77,7 +75,12 @@ const _default: Messages = { deleteConfirm: '是否删除限制:{cond}?', deleted: '删除成功', noPermissionFirefox: '请先在插件管理页[about:addons]开启该插件的粘贴板权限', + inputTestUrl: '请先输入需要测试的网址链接', + clickTestButton: '输入完成后请点击【{buttonText}】按钮', + noRuleMatched: '该网址未命中任何规则', + rulesMatched: '该网址命中以下规则:', }, + testUrlLabel: '测试网址', urlPlaceholder: '请直接粘贴网址 ➡️', }, zh_TW: { @@ -95,17 +98,14 @@ const _default: Messages = { button: { add: '新增', test: '網址測試', + testSimple: '測試', paste: '粘貼', save: '保存', delete: '刪除', + modify: '修改', }, addTitle: '新增限製', useWildcard: '是否使用通配符', - timeUnit: { - hour: '小時', - minute: '分鐘', - second: '秒', - }, message: { saved: '保存成功', noUrl: '未填冩限製網址', @@ -113,8 +113,13 @@ const _default: Messages = { deleteConfirm: '是否刪除限製:{cond}?', deleted: '刪除成功', noPermissionFirefox: '請先在插件管理頁[about:addons]開啟該插件的粘貼闆權限', + inputTestUrl: '請先輸入需要測試的網址鏈接', + clickTestButton: '輸入完成後請點擊【{buttonText}】按鈕', + noRuleMatched: '該網址未命中任何規則', + rulesMatched: '該網址命中以下規則:', }, urlPlaceholder: '請直接粘貼網址 ➡️', + testUrlLabel: '測試網址', }, en: { conditionFilter: 'URL', @@ -131,17 +136,14 @@ const _default: Messages = { button: { add: 'New', test: 'Test URL', + testSimple: 'Test', paste: 'Paste', save: 'Save', delete: 'Delete', + modify: 'Modify', }, addTitle: 'New', useWildcard: 'Whether to use wildcard', - timeUnit: { - hour: 'Hours', - minute: 'Minutes', - second: 'Seconds', - }, message: { saved: 'Saved successfully', noUrl: 'Unfilled limited URL', @@ -149,8 +151,13 @@ const _default: Messages = { deleteConfirm: 'Do you want to delete the rule of {cond}?', deleted: 'Deleted successfully', noPermissionFirefox: 'Please enable the clipboard permission of this addon on the management page (about:addons) first', + inputTestUrl: 'Please enter the URL link to be tested first', + clickTestButton: 'After inputting, please click the button ({buttonText})', + noRuleMatched: 'The URL does not hit any rules', + rulesMatched: 'The URL hits the following rules:', }, urlPlaceholder: 'Please paste the URL directly ➡️', + testUrlLabel: 'Test URL', }, ja: { conditionFilter: 'URL', @@ -167,17 +174,14 @@ const _default: Messages = { button: { add: '新增', test: 'テストURL', + testSimple: 'テスト', paste: 'ペースト', save: 'セーブ', delete: '削除', + modify: '変更', }, addTitle: '新增', useWildcard: 'ワイルドカードを使用するかどうか', - timeUnit: { - hour: '時間', - minute: '分', - second: '秒', - }, message: { noUrl: '埋められていない制限URL', noTime: '1日の制限時間を記入しない', @@ -185,8 +189,13 @@ const _default: Messages = { deleteConfirm: '{cond} の制限を削除しますか?', deleted: '正常に削除', noPermissionFirefox: '最初にプラグイン管理ページでプラグインのペーストボード権限を有効にしてください', + inputTestUrl: '最初にテストする URL リンクを入力してください', + clickTestButton: '入力後、ボタン({buttonText})をクリックしてください', + noRuleMatched: 'URL がどのルールとも一致しません', + rulesMatched: 'URL は次のルールに一致します。', }, urlPlaceholder: 'URLを直接貼り付けてください➡️', + testUrlLabel: 'テスト URL', }, } diff --git a/src/i18n/message/app/menu.ts b/src/i18n/message/app/menu.ts index de8bac01d..ccf04f374 100644 --- a/src/i18n/message/app/menu.ts +++ b/src/i18n/message/app/menu.ts @@ -23,6 +23,7 @@ export type MenuMessage = { feedback: string rate: string helpUs: string + userManual: string } const _default: Messages = { @@ -44,6 +45,7 @@ const _default: Messages = { feedback: '有什么反馈吗?', rate: '打个分吧!', helpUs: '帮助我们~', + userManual: '用户手册', }, zh_TW: { dashboard: '儀錶盤', @@ -63,6 +65,7 @@ const _default: Messages = { feedback: '有什麼反饋嗎?', rate: '打個分吧!', helpUs: '帮助我们~', + userManual: '用戶手冊', }, en: { dashboard: 'Dashboard', @@ -82,6 +85,7 @@ const _default: Messages = { feedback: 'Feedback Questionnaire', rate: 'Rate It', helpUs: 'Help Us', + userManual: 'User Manual', }, ja: { dashboard: 'ダッシュボード', @@ -101,6 +105,7 @@ const _default: Messages = { feedback: 'フィードバックアンケート', rate: 'それを評価', helpUs: 'Help Us', + userManual: 'ユーザーマニュアル', } } diff --git a/src/i18n/message/app/option.ts b/src/i18n/message/app/option.ts index 54be89966..9035cef47 100644 --- a/src/i18n/message/app/option.ts +++ b/src/i18n/message/app/option.ts @@ -73,6 +73,10 @@ export type OptionMessage = { alert: string test: string operation: string + auto: { + label: string + interval: string + } } resetButton: string resetSuccess: string @@ -164,6 +168,10 @@ const _default: Messages = { alert: '这是一项实验性功能,如果有任何问题请联系作者~ (returnzhy1996@outlook.com)', test: '测试', operation: '备份数据', + auto: { + label: '是否开启自动备份', + interval: '每 {input} 分钟备份一次', + } }, resetButton: '恢复默认', resetSuccess: '成功重置为默认值', @@ -245,6 +253,10 @@ const _default: Messages = { alert: '這是一項實驗性功能,如果有任何問題請聯繫作者 (returnzhy1996@outlook.com) ~', test: '測試', operation: '備份數據', + auto: { + label: '是否開啟自動備份', + interval: '每 {input} 分鐘備份一次', + } }, resetButton: '恢複默認', resetSuccess: '成功重置爲默認值', @@ -326,6 +338,10 @@ const _default: Messages = { alert: 'This is an experimental feature, if you have any questions please contact the author via returnzhy1996@outlook.com~', test: 'Test', operation: 'Backup', + auto: { + label: 'Whether to enable automatic backup', + interval: 'and run every {input} minutes', + }, }, resetButton: 'Reset', resetSuccess: 'Reset to default successfully!', @@ -407,6 +423,10 @@ const _default: Messages = { alert: 'これは実験的な機能です。質問がある場合は、作成者に連絡してください (returnzhy1996@outlook.com)', test: 'テスト', operation: 'バックアップ', + auto: { + label: '自動バックアップを有効にするかどうか', + interval: ' {input} 分ごとに実行', + }, }, resetButton: 'リセット', resetSuccess: 'デフォルトに正常にリセット', diff --git a/src/i18n/message/app/report.ts b/src/i18n/message/app/report.ts index 061f9ad01..dccc20432 100644 --- a/src/i18n/message/app/report.ts +++ b/src/i18n/message/app/report.ts @@ -29,6 +29,12 @@ export type ReportMessage = { remoteReading: { on: string off: string + table: { + client: string + localData: string + value: string + percentage: string + } } } @@ -57,6 +63,12 @@ const _default: Messages = { remoteReading: { on: '正在查询远端备份数据', off: '单击以开启远端备份数据查询功能', + table: { + client: '客户端', + localData: '本地数据', + value: '对应数值', + percentage: '百分比', + } }, }, zh_TW: { @@ -83,6 +95,12 @@ const _default: Messages = { remoteReading: { on: '正在查詢遠端備份數據', off: '單擊以開啟遠端備份數據查詢功能', + table: { + client: '客户端', + localData: '本地數據', + value: '對應數值', + percentage: '百分比', + } }, }, en: { @@ -109,6 +127,12 @@ const _default: Messages = { remoteReading: { on: 'Reading remote backuped data', off: 'Click to read remote backuped data', + table: { + client: 'Client\'s Name', + localData: 'Local Data', + value: 'Value', + percentage: 'Percentage', + } }, }, ja: { @@ -135,6 +159,12 @@ const _default: Messages = { remoteReading: { on: 'リモート バックアップ データのクエリ', off: 'クリックして、リモート バックアップ データのクエリ機能を有効にします', + table: { + client: 'クライアントの名前', + localData: 'ローカル データ', + value: '対応する値', + percentage: 'パーセンテージ', + }, }, }, } diff --git a/src/i18n/message/common/content-script.ts b/src/i18n/message/common/content-script.ts index 91db29146..457567bc8 100644 --- a/src/i18n/message/common/content-script.ts +++ b/src/i18n/message/common/content-script.ts @@ -6,8 +6,7 @@ */ export type ContentScriptMessage = { - openTimesConsoleLog: string - usedTimeInConsoleLog: string + consoleLog: string closeAlert: string timeWithHour: string timeWithMinute: string @@ -15,10 +14,10 @@ export type ContentScriptMessage = { timeLimitMsg: string more5Minutes: string } + const _default: Messages = { zh_CN: { - openTimesConsoleLog: '今天您打开了 {time} 次 {host}。', - usedTimeInConsoleLog: '它今天在您的电脑上运行了 {total},其中您花费了 {focus}来浏览它。', + consoleLog: '今天您打开了 {time} 次 {host},花费了 {focus} 来浏览它。', closeAlert: '你可以在【网费很贵】的选项中关闭以上提示!', timeWithHour: '{hour} 小时 {minute} 分 {second} 秒', timeWithMinute: '{minute} 分 {second} 秒', @@ -27,8 +26,7 @@ const _default: Messages = { more5Minutes: '再看 5 分钟!!我保证!', }, zh_TW: { - openTimesConsoleLog: '今天您打開了 {time} 次 {host}。', - usedTimeInConsoleLog: '它今天在您的電腦上運行了 {total},其中您花費了 {focus}來瀏覽它。', + consoleLog: '今天您打開了 {time} 次 {host},花費了 {focus} 來瀏覽它。', closeAlert: '你可以在【網費很貴】的選項中關閉以上提示!', timeWithHour: '{hour} 小時 {minute} 分 {second} 秒', timeWithMinute: '{minute} 分 {second} 秒', @@ -37,8 +35,7 @@ const _default: Messages = { more5Minutes: '再看 5 分鐘!!我保証!', }, en: { - openTimesConsoleLog: 'You have open {host} for {time} time(s) today.', - usedTimeInConsoleLog: 'And it runs on your PC for {total} today, and is browsed for {focus}.', + consoleLog: 'You have open {host} for {time} time(s) and browsed it for {focus} today.', closeAlert: 'You can turn off the above tips in the option of Timer!', timeWithHour: '{hour} hour(s) {minute} minute(s) {second} second(s)', timeWithMinute: '{minute} minute(s) {second} second(s)', @@ -47,8 +44,7 @@ const _default: Messages = { more5Minutes: 'More 5 minutes, please!!', }, ja: { - openTimesConsoleLog: '今日、あなたは {host} を {time} 回開きました。', - usedTimeInConsoleLog: 'それは今日あなたのコンピュータで {total} 実行されました、そのうちあなたはそれを閲覧するために {focus} を費やしました。', + consoleLog: '{host} を {time} 回開いて、今日 {focus} をブラウズしました。', closeAlert: 'Timer のオプションで上記のヒントをオフにすることができます!', timeWithHour: '{hour} 時間 {minute} 分 {second} 秒', timeWithMinute: '{minute} 分 {second} 秒', diff --git a/src/i18n/message/common/item.ts b/src/i18n/message/common/item.ts index 15fc4f98d..0faab152f 100644 --- a/src/i18n/message/common/item.ts +++ b/src/i18n/message/common/item.ts @@ -8,11 +8,6 @@ export type ItemMessage = { date: string host: string - // @deprecated v1.3.4 - total: string - // @since v1.3.4 - // @deprecated v1.3.4 - totalTip: string focus: string time: string operation: { @@ -33,8 +28,6 @@ const _default: Messages = { zh_CN: { date: '日期', host: '域名', - total: '运行时长', - totalTip: '许多用户反应该数据实际作用不大。作者深思熟虑,打算于不久的将来下线它。如果您有疑问,欢迎提交反馈。', focus: '浏览时长', time: '打开次数', operation: { @@ -53,8 +46,6 @@ const _default: Messages = { zh_TW: { date: '日期', host: '域名', - total: '運行時長', - totalTip: '許多用戶反應該數據實際作用不大。作者深思熟慮,打算於不久的將來下線它。如果您有疑問,歡迎提交反饋。', focus: '瀏覽時長', time: '訪問次數', operation: { @@ -73,8 +64,6 @@ const _default: Messages = { en: { date: 'Date', host: 'Site URL', - total: 'Running Time', - totalTip: 'The author plans to take this column offline in the near future. If you have questions, feel free to submit feedback.', focus: 'Browsing Time', time: 'Site Visits', operation: { @@ -93,8 +82,6 @@ const _default: Messages = { ja: { date: '日期', host: 'URL', - total: '実行時間', - totalTip: '著者は、近い将来にオフラインにする予定です。 ご不明な点がございましたら、お気軽にフィードバックを送信してください。', focus: '閲覧時間', time: '拜訪回数', operation: { diff --git a/src/i18n/message/common/locale.ts b/src/i18n/message/common/locale.ts index b48008dc7..135fa455d 100644 --- a/src/i18n/message/common/locale.ts +++ b/src/i18n/message/common/locale.ts @@ -5,31 +5,109 @@ * https://opensource.org/licenses/MIT */ +type MetaBase = { + name: string +} + +type Meta = MetaBase & { + comma: string +} + /** * Meta info of locales * * @since 0.8.0 */ -export type LocaleMessages = { - [locale in timer.Locale | timer.TranslatingLocale]: string -} +export type LocaleMessages = + { + [locale in timer.Locale]: Meta + } & { + [translatingLocale in timer.TranslatingLocale]: MetaBase + } const _default: LocaleMessages = { - zh_CN: '简体中文', - zh_TW: '正體中文', - en: 'English', - ja: '日本語', - pl: 'Polski', - pt: 'Português', - pt_BR: 'Portugues, Brasil', - ko: '한국인', - de: 'Deutsch', - es: 'Español', - ru: 'Русский', - uk: "українська", - fr: "Français", - it: "italiano", - sv: "Sverige", + zh_CN: { + name: '简体中文', + comma: ',' + }, + zh_TW: { + name: '正體中文', + comma: ',', + }, + en: { + name: 'English', + comma: ', ' + }, + ja: { + name: '日本語', + comma: '、' + }, + pl: { + name: 'Polski' + }, + pt: { + name: 'Português' + }, + pt_BR: { + name: 'Portugues, Brasil' + }, + ko: { + name: '한국인' + }, + de: { + name: 'Deutsch' + }, + es: { + name: 'Español' + }, + ru: { + name: 'Русский' + }, + uk: { + name: "українська" + }, + fr: { + name: "Français" + }, + it: { + name: "italiano" + }, + sv: { + name: "Sverige" + }, + fi: { + name: "Suomalainen", + }, + da: { + name: "dansk", + }, + hr: { + name: "Hrvatski", + }, + id: { + name: "bahasa Indonesia", + }, + tr: { + name: "Türkçe", + }, + cs: { + name: "čeština", + }, + ro: { + name: "Română", + }, + nl: { + name: "Nederlands", + }, + vi: { + name: "Tiếng Việt", + }, + sk: { + name: "slovenský", + }, + mn: { + name: "Монгол", + }, } export default _default \ No newline at end of file diff --git a/src/i18n/message/common/meta.ts b/src/i18n/message/common/meta.ts index e5891fdbd..43b9b2b0e 100644 --- a/src/i18n/message/common/meta.ts +++ b/src/i18n/message/common/meta.ts @@ -23,7 +23,7 @@ const _default: Messages = { }, en: { name: 'Timer', - marketName: 'Timer - Running & Browsing Time & Visit count', + marketName: 'Timer - Browsing Time & Visit count', description: 'To be the BEST web timer.', }, } diff --git a/src/i18n/message/common/popup-duration.ts b/src/i18n/message/common/popup-duration.ts index 2a3a59900..a79846ba7 100644 --- a/src/i18n/message/common/popup-duration.ts +++ b/src/i18n/message/common/popup-duration.ts @@ -5,28 +5,32 @@ * https://opensource.org/licenses/MIT */ -export type PopupDurationMessage = { [key in timer.popup.Duration]: string } +export type PopupDurationMessage = { [key in timer.option.PopupDuration]: string } const _default: Messages = { zh_CN: { today: '今日', thisWeek: '本周', thisMonth: '本月', + last30Days: '近 30 天', }, zh_TW: { today: '今日', thisWeek: '本週', thisMonth: '本月', + last30Days: '近 30 天', }, en: { today: 'Today\'s', thisWeek: 'This Week\'s', thisMonth: 'This Month\'s', + last30Days: 'Last 30 days\'', }, ja: { today: '今日の', thisWeek: '今週の', thisMonth: '今月の', + last30Days: '過去 30 日間', }, } diff --git a/src/i18n/message/guide/layout.ts b/src/i18n/message/guide/layout.ts index 5552e96eb..66f7982c4 100644 --- a/src/i18n/message/guide/layout.ts +++ b/src/i18n/message/guide/layout.ts @@ -13,6 +13,7 @@ export type LayoutMessage = { quickstart: string background: string advanced: string + backup: string } privacy: { title: string @@ -31,6 +32,7 @@ const _default: Messages = { quickstart: '快速开始', background: '访问后台页面', advanced: '高级功能', + backup: '使用 Gist 备份数据', }, privacy: { title: '隐私声明', @@ -47,6 +49,7 @@ const _default: Messages = { quickstart: '快速開始', background: '訪問後台頁面', advanced: '高級功能', + backup: '使用 Gist 備份數據', }, privacy: { title: '隱私聲明', @@ -63,6 +66,7 @@ const _default: Messages = { quickstart: 'Quickstart', background: 'Using all functions', advanced: 'Advanced features', + backup: 'Backup your data with Gist', }, privacy: { title: 'Privary Policy', @@ -79,6 +83,7 @@ const _default: Messages = { quickstart: 'クイックスタート', background: 'すべての機能', advanced: '高度な機能', + backup: 'Backup your data with Gist', }, privacy: { title: 'ポリシーと規約', diff --git a/src/i18n/message/guide/usage.ts b/src/i18n/message/guide/usage.ts index 6d47d9e49..4b048c8c5 100644 --- a/src/i18n/message/guide/usage.ts +++ b/src/i18n/message/guide/usage.ts @@ -30,10 +30,17 @@ type _AdvancedKey = | 'l7' | 'l8' +type _BackupKey = + | 'p1' + | 'l1' + | 'l2' + | 'l3' + export type UsageMessage = { quickstart: { [key in _QuickstartKey]: string } background: { [key in _BackgroundKey]: string } advanced: { [key in _AdvancedKey]: string } + backup: { [key in _BackupKey]: string } } const _default: Messages = { @@ -63,6 +70,12 @@ const _default: Messages = { l7: '7. 它支持夜间模式,同样需要在选项里启用。', l8: '8. 它支持使用 Github Gist 作为云端存储多个浏览器的数据,并进行聚合查询。需要您准备一个至少包含 gist 权限的 token。', }, + backup: { + p1: '您可以按以下步骤使用 {gist} 备份您的数据。之后,您可在其他终端上查询已备份数据。', + l1: '1. 首先,您需要在 Github 生成一个包含 gist 权限的 {token}。', + l2: '2. 然后在选项页面将同步方式选为 Github Gist,将你的 token 填入下方出现的输入框中。', + l3: '3. 最后,点击备份按钮即可将本地数据导入到你的 gist 里。' + }, }, zh_TW: { quickstart: { @@ -90,6 +103,12 @@ const _default: Messages = { l7: '7. 它支持夜間模式,同樣需要在選項裡啟用。', l8: '8. 它支持使用 Github Gist 作為雲端存儲多個瀏覽器的數據,並進行聚合查詢。需要您準備一個至少包含 gist 權限的 token。', }, + backup: { + p1: '您可以按以下步驟使用 {gist} 備份您的數據。之後,您可在其他終端上查詢已備份數據。', + l1: '1. 首先,您需要在 Github 生成一個包含 gist 權限的 {token}。', + l2: '2. 然後在選項頁面將同步方式選為 Github Gist,將你的 token 填入下方出現的輸入框中。', + l3: '3. 最後,點擊備份按鈕即可將本地數據導入到你的 gist 裡。' + }, }, en: { quickstart: { @@ -117,6 +136,14 @@ const _default: Messages = { l7: '7. It supports night mode, which also needs to be enabled in the options.', l8: '8. It supports using Github Gist as the cloud to store data of multiple browsers and perform aggregated queries. You need to prepare a token with at least gist permission.', }, + backup: { + p1: 'You can use {gist} to backup your data by following the steps below. \ + Afterwards, you can query the backed up data on other terminals.', + l1: '1. First, you need to generate a {token} with gist permissions on Github.', + l2: '2. Then select Github Gist as the synchronization method on the options page, \ + and fill in your token in the input box that appears below.', + l3: '3. Finally, click the backup button to import the local data into your gist.' + }, }, ja: { quickstart: { @@ -144,6 +171,12 @@ const _default: Messages = { l7: '7.オプションで有効にする必要があるナイトモードをサポートしています。', l8: '8. Github Gist をクラウドとして使用して、複数のブラウザーのデータを保存し、集約されたクエリを実行することをサポートします。 少なくとも gist 権限を持つトークンを準備する必要があります。', }, + backup: { + p1: '以下の手順に従って、{gist} を使用してデータをバックアップできます。その後、バックアップされたデータを他の端末で照会できます。', + l1: '1. まず、Github で Gist 権限を持つ {token} を生成する必要があります。', + l2: '2. 次に、オプション ページで同期方法として [Github Gist] を選択し、下に表示される入力ボックスにトークンを入力します。', + l3: '3. 最後に、バックアップ ボタンをクリックして、ローカル データを Gist にインポートします。' + }, }, } diff --git a/src/i18n/message/popup/chart.ts b/src/i18n/message/popup/chart.ts index 38cd104bf..71a55a4a4 100644 --- a/src/i18n/message/popup/chart.ts +++ b/src/i18n/message/popup/chart.ts @@ -6,7 +6,7 @@ */ export type ChartMessage = { - title: { [key in timer.popup.Duration]: string } + title: { [key in PopupDuration]: string } mergeHostLabel: string fileName: string saveAsImageTitle: string @@ -28,6 +28,7 @@ const _default: Messages = { today: '今日数据', thisWeek: '本周数据', thisMonth: '本月数据', + last30Days: '近 30 天数据' }, mergeHostLabel: '合并子域名', fileName: '上网时长清单_{today}_by_{app}', @@ -48,6 +49,7 @@ const _default: Messages = { today: '今日數據', thisWeek: '本週數據', thisMonth: '本月數據', + last30Days: '近 30 天數據', }, mergeHostLabel: '合並子網域', fileName: '上網時長清單_{today}_by_{app}', @@ -68,6 +70,7 @@ const _default: Messages = { today: 'Today\'s Data', thisWeek: 'This Week\'s Data', thisMonth: 'This Month\'s Data', + last30Days: 'Last 30 days\' data', }, mergeHostLabel: 'Merge Sites', fileName: 'Web_Time_List_{today}_By_{app}', @@ -88,6 +91,7 @@ const _default: Messages = { today: '今日のデータ', thisWeek: '今週のデータ', thisMonth: '今月のデータ', + last30Days: '過去 30 日間のデータ', }, mergeHostLabel: 'URLをマージ', fileName: 'オンライン時間_{today}_by_{app}', diff --git a/src/popup/components/chart/click-handler.ts b/src/popup/components/chart/click-handler.ts index 945a2d7ff..fb5980990 100644 --- a/src/popup/components/chart/click-handler.ts +++ b/src/popup/components/chart/click-handler.ts @@ -10,12 +10,12 @@ import type { CallbackDataParams } from "echarts/types/dist/shared" import { REPORT_ROUTE } from "@app/router/constants" import { getAppPageUrl } from "@util/constant/url" -function generateUrl(data: timer.popup.Row, queryResult: timer.popup.QueryResult): string { +function generateUrl(data: PopupRow, queryResult: PopupQueryResult): string { const { host, isOther } = data if (!isOther) { return host ? `http://${host}` : undefined } - const query: timer.app.report.QueryParam = {} + const query: ReportQueryParam = {} // Merge host queryResult.mergeHost && (query.mh = "1") // Date @@ -37,8 +37,8 @@ function generateUrl(data: timer.popup.Row, queryResult: timer.popup.QueryResult return getAppPageUrl(false, REPORT_ROUTE, query) } -function handleClick(params: CallbackDataParams, queryResult: timer.popup.QueryResult) { - const data: timer.popup.Row = params.data as timer.popup.Row +function handleClick(params: CallbackDataParams, queryResult: PopupQueryResult) { + const data: PopupRow = params.data as PopupRow if (!data) { return } diff --git a/src/popup/components/chart/index.ts b/src/popup/components/chart/index.ts index 883161792..cf0f9417d 100644 --- a/src/popup/components/chart/index.ts +++ b/src/popup/components/chart/index.ts @@ -14,10 +14,10 @@ import TitleComponent from "@echarts/component/title" import ToolboxComponent from "@echarts/component/toolbox" import TooltipComponent from "@echarts/component/tooltip" import LegendComponent from "@echarts/component/legend" -import CanvasRenderer from "@echarts/canvas-renderer" +import SVGRenderer from "@echarts/svg-renderer" // Register echarts -use([TitleComponent, ToolboxComponent, TooltipComponent, LegendComponent, CanvasRenderer, PieChart]) +use([TitleComponent, ToolboxComponent, TooltipComponent, LegendComponent, SVGRenderer, PieChart]) import { defaultStatistics } from "@util/constant/option" import OptionDatabase from "@db/option-database" @@ -45,9 +45,9 @@ export const handleRestore = (handler: () => void) => { } // Store -let _queryResult: timer.popup.QueryResult +let _queryResult: PopupQueryResult -function renderChart(queryResult: timer.popup.QueryResult) { +function renderChart(queryResult: PopupQueryResult) { _queryResult = queryResult pie.setOption(pieOptions({ ...queryResult, displaySiteName }, chartContainer), true, false) } diff --git a/src/popup/components/chart/option.ts b/src/popup/components/chart/option.ts index 2864a865a..7d2fdfe8c 100644 --- a/src/popup/components/chart/option.ts +++ b/src/popup/components/chart/option.ts @@ -31,6 +31,10 @@ type EcOption = ComposeOption< | LegendComponentOption > +type ChartProps = PopupQueryResult & { + displaySiteName: boolean +} + const today = formatTime(new Date(), '{y}_{m}_{d}') /** @@ -56,10 +60,10 @@ function calculateAverageText(type: timer.stat.Dimension, averageValue: number): return undefined } -function toolTipFormatter({ type, dateLength }: timer.popup.QueryResult, params: any): string { +function toolTipFormatter({ type, dateLength }: PopupQueryResult, params: any): string { const format = params instanceof Array ? params[0] : params const { name, value, percent } = format - const data = format.data as timer.popup.Row + const data = format.data as PopupRow const host = data.host const siteLabel = generateSiteLabel(host, name) let result = siteLabel @@ -75,10 +79,10 @@ function toolTipFormatter({ type, dateLength }: timer.popup.QueryResult, params: return result } -function labelFormatter({ mergeHost }: timer.popup.QueryResult, params: any): string { +function labelFormatter({ mergeHost }: PopupQueryResult, params: any): string { const format = params instanceof Array ? params[0] : params const { name } = format - const data = format.data as timer.popup.Row + const data = format.data as PopupRow // Un-supported to get favicon url in Safari return mergeHost || data.isOther || IS_SAFARI ? name @@ -147,7 +151,7 @@ function calculateSubTitleText(date: Date | Date[]) { } } -export function pieOptions(props: timer.popup.ChartProps, container: HTMLDivElement): EcOption { +export function pieOptions(props: ChartProps, container: HTMLDivElement): EcOption { const { type, data, displaySiteName, chartTitle, date } = props const titleText = chartTitle const subTitleText = `${calculateSubTitleText(date)} @ ${t(msg => msg.meta.name)}` diff --git a/src/popup/components/footer/index.ts b/src/popup/components/footer/index.ts index 1cbe6c8a0..072fe9485 100644 --- a/src/popup/components/footer/index.ts +++ b/src/popup/components/footer/index.ts @@ -13,7 +13,7 @@ import TotalInfoWrapper from "./total-info" import MergeHostWrapper from "./merge-host" import TimeSelectWrapper from "./select/time-select" import TypeSelectWrapper from "./select/type-select" -import timerService, { SortDirect } from "@service/timer-service" +import timerService from "@service/timer-service" import { t } from "@popup/locale" // Import from i18n import { locale } from "@i18n" @@ -25,13 +25,15 @@ type FooterParam = TimerQueryParam & { chartTitle: string } +type QueryResultHandler = (result: PopupQueryResult) => void + const FILL_FLAG_PARAM: FillFlagParam = { iconUrl: !IS_SAFARI, alias: true } -function calculateDateRange(duration: timer.popup.Duration, weekStart: timer.option.WeekStartOption): Date | Date[] { - const now = new Date() - if (duration == 'today') { - return now - } else if (duration == 'thisWeek') { +type DateRangeCalculator = (now: Date, weekStart: timer.option.WeekStartOption) => Date | [Date, Date] + +const dateRangeCalculators: { [duration in PopupDuration]: DateRangeCalculator } = { + today: now => now, + thisWeek(now, weekStart) { const weekStartAsNormal = !weekStart || weekStart === 'default' if (weekStartAsNormal) { return getWeekTime(now, locale === 'zh_CN') @@ -52,20 +54,19 @@ function calculateDateRange(duration: timer.popup.Duration, weekStart: timer.opt } return [start, now] } - } else if (duration == 'thisMonth') { - const startOfMonth = getMonthTime(now)[0] - return [startOfMonth, now] - } + }, + thisMonth: now => [getMonthTime(now)[0], now], + last30Days: now => [new Date(now.getTime() - MILL_PER_DAY * 29), now], } class FooterWrapper { - private afterQuery: timer.popup.QueryResultHandler + private afterQuery: QueryResultHandler private timeSelectWrapper: TimeSelectWrapper private typeSelectWrapper: TypeSelectWrapper private mergeHostWrapper: MergeHostWrapper private totalInfoWrapper: TotalInfoWrapper - constructor(afterQuery: timer.popup.QueryResultHandler) { + constructor(afterQuery: QueryResultHandler) { this.afterQuery = afterQuery } @@ -82,8 +83,7 @@ class FooterWrapper { const option = await optionService.getAllOption() this.timeSelectWrapper.init(option.defaultDuration) - // Remove total @since v1.3.4 - const defaultType = option.defaultType === 'total' ? 'focus' : option.defaultType + const defaultType = option.defaultType this.typeSelectWrapper.init(defaultType) this.mergeHostWrapper.init(option.defaultMergeDomain) this.query() @@ -94,11 +94,10 @@ class FooterWrapper { const itemCount = option.popupMax const queryParam = this.getQueryParam(option.weekStart) const rows = await timerService.select(queryParam, FILL_FLAG_PARAM) - const popupRows: timer.popup.Row[] = [] - const other: timer.popup.Row = { + const popupRows: PopupRow[] = [] + const other: PopupRow = { host: t(msg => msg.chart.otherLabel, { count: 0 }), focus: 0, - total: 0, date: '0000-00-00', time: 0, mergedHosts: [], @@ -111,7 +110,6 @@ class FooterWrapper { popupRows.push(row) } else { other.focus += row.focus - other.total += row.total otherCount++ } } @@ -121,7 +119,7 @@ class FooterWrapper { const data = popupRows.filter(item => item[type]) const date = queryParam.date - const queryResult: timer.popup.QueryResult = { + const queryResult: PopupQueryResult = { data, mergeHost: queryParam.mergeHost, type, @@ -134,12 +132,12 @@ class FooterWrapper { } getQueryParam(weekStart: timer.option.WeekStartOption): FooterParam { - const duration: timer.popup.Duration = this.timeSelectWrapper.getSelectedTime() + const duration: PopupDuration = this.timeSelectWrapper.getSelectedTime() const param: FooterParam = { - date: calculateDateRange(duration, weekStart), + date: dateRangeCalculators[duration]?.(new Date(), weekStart), mergeHost: this.mergeHostWrapper.mergedHost(), sort: this.typeSelectWrapper.getSelectedType(), - sortOrder: SortDirect.DESC, + sortOrder: 'DESC', chartTitle: t(msg => msg.chart.title[duration]), mergeDate: true, } diff --git a/src/popup/components/footer/select/time-select.ts b/src/popup/components/footer/select/time-select.ts index 8cbe74554..41662e823 100644 --- a/src/popup/components/footer/select/time-select.ts +++ b/src/popup/components/footer/select/time-select.ts @@ -15,17 +15,17 @@ class TimeSelectWrapper { private timeSelectPopup: HTMLElement private timeSelectInput: HTMLInputElement private isOpen: boolean = false - private currentSelected: timer.popup.Duration = undefined + private currentSelected: PopupDuration = undefined private handleSelected: Function private optionList: HTMLElement - private optionItems: Map = new Map() + private optionItems: Map = new Map() constructor(handleSelected: Function) { this.handleSelected = handleSelected } - async init(initialVal: timer.popup.Duration): Promise { + async init(initialVal: PopupDuration): Promise { this.timeSelect = document.getElementById('time-select-container') this.timeSelectPopup = document.getElementById('time-select-popup') this.timeSelectInput = document.getElementById('time-select-input') as HTMLInputElement @@ -38,7 +38,7 @@ class TimeSelectWrapper { this.selected(initialVal) } - private initOption(item: timer.popup.Duration) { + private initOption(item: PopupDuration) { const li = document.createElement('li') li.classList.add('el-select-dropdown__item') li.innerText = t(msg => msg.duration[item]) @@ -51,7 +51,7 @@ class TimeSelectWrapper { this.optionItems.set(item, li) } - private selected(item: timer.popup.Duration) { + private selected(item: PopupDuration) { this.currentSelected = item Array.from(this.optionItems.values()).forEach(item => item.classList.remove(SELECTED_CLASS)) this.optionItems.get(item).classList.add(SELECTED_CLASS) @@ -70,7 +70,7 @@ class TimeSelectWrapper { this.isOpen = false } - getSelectedTime(): timer.popup.Duration { + getSelectedTime(): PopupDuration { return this.currentSelected } } diff --git a/src/popup/popup.d.ts b/src/popup/popup.d.ts new file mode 100644 index 000000000..ebf7073ae --- /dev/null +++ b/src/popup/popup.d.ts @@ -0,0 +1,13 @@ +type PopupRow = timer.stat.Row & { isOther?: boolean } + +type PopupQueryResult = { + type: timer.stat.Dimension + mergeHost: boolean + data: PopupRow[] + // Filter items + chartTitle: string + date: Date | Date[] + dateLength: number +} + +type PopupDuration = timer.option.PopupDuration \ No newline at end of file diff --git a/src/popup/style/index.sass b/src/popup/style/index.sass index 7f1242636..99978b05c 100644 --- a/src/popup/style/index.sass +++ b/src/popup/style/index.sass @@ -69,7 +69,7 @@ $optionPadding: 10px #time-select-popup z-index: 2004 left: 489px - top: 380px + top: 342px margin: 0px .el-svg-icon diff --git a/src/service/limit-service.ts b/src/service/limit-service.ts index 978d8507e..8002a00b6 100644 --- a/src/service/limit-service.ts +++ b/src/service/limit-service.ts @@ -36,35 +36,40 @@ async function select(cond?: QueryParam): Promise { .filter(item => url ? item.matches(url) : true) } -async function update({ cond, time, enabled, allowDelay }: timer.limit.Item, rewrite?: boolean): Promise { - if (rewrite === undefined) { - rewrite = true - } +/** + * Fired if the item is removed or disabled + * + * @param item + */ +async function handleLimitChanged() { + const allItems: TimeLimitItem[] = await select({ filterDisabled: false, url: undefined }) + chrome.tabs.query({}, tabs => tabs.forEach(tab => { + const limitedItems = allItems.filter(item => item.matches(tab.url) && item.enabled && item.hasLimited()) + chrome.tabs.sendMessage, timer.mq.Response>(tab.id, { + code: 'limitChanged', + data: limitedItems + }, _result => { + const error = chrome.runtime.lastError + error && console.log(error.message) + }) + })) +} + +async function updateEnabled(item: timer.limit.Item): Promise { + const { cond, time, enabled, allowDelay } = item const limit: timer.limit.Rule = { cond, time, enabled, allowDelay } - await db.save(limit, rewrite) + await db.save(limit, true) + await handleLimitChanged() } async function updateDelay(item: timer.limit.Item) { await db.updateDelay(item.cond, item.allowDelay) + await handleLimitChanged() } async function remove(item: timer.limit.Item): Promise { await db.remove(item.cond) - const allItems: TimeLimitItem[] = await select({ filterDisabled: true, url: undefined }) - chrome.tabs.query({}, tabs => tabs.forEach(tab => { - if (allItems.find(item => item.matches(tab.url) && item.hasLimited())) { - // Needn't remove - return - } - chrome.tabs.sendMessage, timer.mq.Response>(tab.id, { - code: 'limitRemoved', - data: undefined - }, result => { - if (result?.code === "fail") { - console.error(`Failed to handle limit removed: cond=${JSON.stringify(item)}, msg=${result.msg}`) - } - }) - })) + await handleLimitChanged() } async function getLimited(url: string): Promise { @@ -116,7 +121,7 @@ async function moreMinutes(url: string, rules?: TimeLimitItem[]): Promise { + const meta = await db.getMeta() + return meta?.backup?.[type] +} + class MetaService { getInstallTime = getInstallTime updateInstallTime = updateInstallTime @@ -70,6 +84,14 @@ class MetaService { updateCid = updateCid increaseApp = increaseApp increasePopup = increasePopup + /** + * @since 1.4.7 + */ + updateBackUpTime = updateBackUpTime + /** + * @since 1.4.7 + */ + getLastBackUp = getLastBackUp } export default new MetaService() \ No newline at end of file diff --git a/src/service/timer-service.ts b/src/service/timer-service/index.ts similarity index 59% rename from src/service/timer-service.ts rename to src/service/timer-service/index.ts index a6404c3ca..abc010168 100644 --- a/src/service/timer-service.ts +++ b/src/service/timer-service/index.ts @@ -6,17 +6,18 @@ */ import TimerDatabase, { TimerCondition } from "@db/timer-database" -import { log } from "../common/logger" -import CustomizedHostMergeRuler from "./components/host-merge-ruler" +import { log } from "../../common/logger" +import CustomizedHostMergeRuler from "../components/host-merge-ruler" import MergeRuleDatabase from "@db/merge-rule-database" import IconUrlDatabase from "@db/icon-url-database" import HostAliasDatabase from "@db/host-alias-database" -import { slicePageResult } from "./components/page-info" -import whitelistHolder from './components/whitelist-holder' -import { resultOf, rowOf } from "@util/stat" +import { slicePageResult } from "../components/page-info" +import whitelistHolder from '../components/whitelist-holder' +import { resultOf } from "@util/stat" import OptionDatabase from "@db/option-database" -import processor from "@src/background/backup/processor" +import processor from "@src/common/backup/processor" import { getBirthday } from "@util/time" +import { mergeDate, mergeHost } from "./merge" const storage = chrome.storage.local @@ -26,10 +27,7 @@ const hostAliasDatabase = new HostAliasDatabase(storage) const mergeRuleDatabase = new MergeRuleDatabase(storage) const optionDatabase = new OptionDatabase(storage) -export enum SortDirect { - ASC = 1, - DESC = -1 -} +export type SortDirect = 'ASC' | 'DESC' export type TimerQueryParam = TimerCondition & { /** @@ -77,22 +75,99 @@ export type HostSet = { merged: Set } +function calcFocusInfo(timeInfo: TimeInfo): number { + return Object.values(timeInfo).reduce((a, b) => a + b, 0) +} + +const keyOf = (row: timer.stat.RowKey) => `${row.date}${row.host}` + +async function processRemote(param: TimerCondition, origin: timer.stat.Row[]): Promise { + const { backupType, backupAuths } = await optionDatabase.getOption() + const auth = backupAuths?.[backupType] + const canReadRemote = await canReadRemote0(backupType, auth) + if (!canReadRemote) { + return origin + } + // Map to merge + const originMap: Record = {} + origin.forEach(row => originMap[keyOf(row)] = { + ...row, + composition: { + focus: [row.focus], + time: [row.time], + } + }) + // 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 => processRemoteRow(originMap, row)) + return Object.values(originMap) +} + +function processRemoteRow(rowMap: Record, row: timer.stat.Row) { + const key = keyOf(row) + let exist = rowMap[key] + !exist && (exist = rowMap[key] = { + date: row.date, + host: row.host, + time: 0, + focus: 0, + composition: { + focus: [], + time: [], + }, + mergedHosts: [], + }) + + const focus = row.focus || 0 + const time = row.time || 0 + + exist.focus += focus + exist.time += time + focus && exist.composition.focus.push({ cid: row.cid, cname: row.cname, value: focus }) + time && exist.composition.time.push({ cid: row.cid, cname: row.cname, value: time }) +} + + +async function canReadRemote0(backupType: timer.backup.Type, auth: string): Promise { + return backupType && backupType !== 'none' && !await processor.test(backupType, auth) +} + /** * Service of timer * @since 0.0.5 */ class TimerService { - async addFocusAndTotal(data: { [host: string]: { run: number, focus: number } }): Promise { + async addFocusAndTotal(data: { [host: string]: TimeInfo }): Promise { const toUpdate = {} Object.entries(data) .filter(([host]) => whitelistHolder.notContains(host)) - .forEach(([host, item]) => toUpdate[host] = resultOf(item.run, item.focus, 0)) + .forEach(([host, timeInfo]) => toUpdate[host] = resultOf(calcFocusInfo(timeInfo), 0)) return timerDatabase.accumulateBatch(toUpdate, new Date()) } async addOneTime(host: string) { - timerDatabase.accumulate(host, new Date(), resultOf(0, 0, 1)) + timerDatabase.accumulate(host, new Date(), resultOf(0, 1)) } /** @@ -141,12 +216,12 @@ class TimerService { const { sort, sortOrder } = param if (!sort) return - const order = sortOrder || SortDirect.ASC + const order = sortOrder || 'ASC' origin.sort((a, b) => { const aa = a[sort] const bb = b[sort] if (aa === bb) return 0 - return order * (aa > bb ? 1 : -1) + return (order === 'ASC' ? 1 : -1) * (aa > bb ? 1 : -1) }) } @@ -177,17 +252,17 @@ class TimerService { param = param || {} let origin = await timerDatabase.select(param as TimerCondition) if (param.inclusiveRemote) { - origin = await this.processRemote(param, origin) + origin = await processRemote(param, origin) } // Process after select // 1st merge if (param.mergeHost) { // Merge with rules - origin = await this.mergeHost(origin) + origin = await mergeHost(origin) // filter again, cause of the exchange of the host, if the param.mergeHost is true origin = this.filter(origin, param) } - param.mergeDate && (origin = this.mergeDate(origin)) + param.mergeDate && (origin = mergeDate(origin)) // 2nd sort this.processSort(origin, param) // 3rd get icon url and alias if need @@ -204,60 +279,6 @@ class TimerService { return timerDatabase.get(host, date) } - 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, @@ -287,56 +308,6 @@ class TimerService { return paramHost ? origin.filter(o => o.host.includes(paramHost)) : origin } - private async mergeHost(origin: T[]): Promise { - const newRows = [] - const map = {} - - // Generate ruler - const mergeRuleItems: timer.merge.Rule[] = await mergeRuleDatabase.selectAll() - const mergeRuler = new CustomizedHostMergeRuler(mergeRuleItems) - - origin.forEach(o => { - const host = o.host - const date = o.date - let mergedHost = mergeRuler.merge(host) - const merged = this.merge(map, o, mergedHost + date) - merged.host = mergedHost - const mergedHosts = merged.mergedHosts || (merged.mergedHosts = []) - mergedHosts.push(o) - }) - for (let key in map) { - newRows.push(map[key]) - } - return newRows - } - - private mergeDate(origin: T[]): T[] { - const newRows = [] - const map = {} - - origin.forEach(o => this.merge(map, o, o.host).date = '') - for (let key in map) { - newRows.push(map[key]) - } - return newRows - } - - private merge(map: {}, origin: timer.stat.Row, key: string): timer.stat.Row { - let exist: timer.stat.Row = map[key] - if (exist === undefined) { - exist = map[key] = rowOf({ host: origin.host, date: origin.date }) - exist.mergedHosts = origin.mergedHosts || [] - } - exist.time += origin.time - exist.focus += origin.focus - exist.total += origin.total - - origin.mergedHosts && origin.mergedHosts.forEach(originHost => - !exist.mergedHosts.find(existOrigin => existOrigin.host === originHost.host) && exist.mergedHosts.push(originHost) - ) - return exist - } - /** * Aable to read remote backup data * @@ -345,11 +316,7 @@ class TimerService { */ 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) + return await canReadRemote0(backupType, backupAuths?.[backupType]) } } diff --git a/src/service/timer-service/merge.ts b/src/service/timer-service/merge.ts new file mode 100644 index 000000000..3d4cfb392 --- /dev/null +++ b/src/service/timer-service/merge.ts @@ -0,0 +1,103 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import MergeRuleDatabase from "@db/merge-rule-database" +import CustomizedHostMergeRuler from "@service/components/host-merge-ruler" + +const storage = chrome.storage.local + +const mergeRuleDatabase = new MergeRuleDatabase(storage) + +function merge(map: Record, origin: timer.stat.Row, key: string): timer.stat.Row { + let exist: timer.stat.Row = map[key] + !exist && (exist = map[key] = { + host: origin.host, + date: origin.date, + focus: 0, + time: 0, + mergedHosts: [], + composition: { focus: [], time: [] }, + cid: origin.cid, + cname: origin.cname, + }) + + exist.time += origin.time + exist.focus += origin.focus + exist.composition = mergeComposition(exist.composition, origin.composition) + + origin.mergedHosts && origin.mergedHosts.forEach(originHost => + !exist.mergedHosts.find(existOrigin => existOrigin.host === originHost.host) && exist.mergedHosts.push(originHost) + ) + return exist +} + +type _RemoteCompositionMap = Record<'_' | string, timer.stat.RemoteCompositionVal> + +function mergeComposition(c1: timer.stat.RemoteComposition, c2: timer.stat.RemoteComposition): timer.stat.RemoteComposition { + const focusMap: _RemoteCompositionMap = {} + const timeMap: _RemoteCompositionMap = {} + c1?.focus?.forEach(e => accCompositionValue(focusMap, e)) + c2?.focus?.forEach(e => accCompositionValue(focusMap, e)) + c1?.time?.forEach(e => accCompositionValue(timeMap, e)) + c2?.time?.forEach(e => accCompositionValue(timeMap, e)) + + const result = { + focus: Object.values(focusMap), + time: Object.values(timeMap), + } + return result +} + +function accCompositionValue(map: _RemoteCompositionMap, value: timer.stat.RemoteCompositionVal) { + if (typeof value === 'number') { + const cid = '_' + const existVal = map[cid] + if (!existVal || typeof existVal !== 'number') { + map[cid] = value + } else { + map[cid] = existVal + value + } + } else { + const cid = value.cid + const existVal = map[cid] + if (!existVal || typeof existVal === 'number') { + map[cid] = value + } else { + existVal.value = existVal.value + value.value + } + } +} + +export function mergeDate(origin: T[]): T[] { + const map: Record = {} + origin.forEach(o => merge(map, o, o.host).date = '') + const newRows = Object.values(map) + return newRows +} + +export async function mergeHost(origin: T[]): Promise { + const newRows = [] + const map = {} + + // Generate ruler + const mergeRuleItems: timer.merge.Rule[] = await mergeRuleDatabase.selectAll() + const mergeRuler = new CustomizedHostMergeRuler(mergeRuleItems) + + origin.forEach(o => { + const host = o.host + const date = o.date + let mergedHost = mergeRuler.merge(host) + const merged = merge(map, o, mergedHost + date) + merged.host = mergedHost + const mergedHosts = merged.mergedHosts || (merged.mergedHosts = []) + mergedHosts.push(o) + }) + for (let key in map) { + newRows.push(map[key]) + } + return newRows +} \ No newline at end of file diff --git a/src/util/constant/option.ts b/src/util/constant/option.ts index c03805941..ba4d7b178 100644 --- a/src/util/constant/option.ts +++ b/src/util/constant/option.ts @@ -40,7 +40,7 @@ export function defaultAppearance(): timer.option.AppearanceOption { export function defaultStatistics(): timer.option.StatisticsOption { return { - countWhenIdle: false, + countWhenIdle: true, collectSiteName: true, countLocalFiles: false } @@ -50,7 +50,9 @@ export function defaultBackup(): timer.option.BackupOption { return { backupType: 'none', clientName: 'unknown', - backupAuths: {} + backupAuths: {}, + autoBackUp: false, + autoBackUpInterval: 30, } } diff --git a/src/util/constant/popup.ts b/src/util/constant/popup.ts index 3bd45df96..18b4b9581 100644 --- a/src/util/constant/popup.ts +++ b/src/util/constant/popup.ts @@ -5,4 +5,4 @@ * https://opensource.org/licenses/MIT */ -export const ALL_POPUP_DURATION: timer.popup.Duration[] = ["today", "thisWeek", "thisMonth"] \ No newline at end of file +export const ALL_POPUP_DURATION: PopupDuration[] = ["today", "thisWeek", "thisMonth", "last30Days"] \ No newline at end of file diff --git a/src/util/constant/url.ts b/src/util/constant/url.ts index 2f2c8036d..966db8498 100644 --- a/src/util/constant/url.ts +++ b/src/util/constant/url.ts @@ -121,12 +121,6 @@ export function iconUrlOfBrowser(protocol: string, host: string): string { */ export const PSL_HOMEPAGE = 'https://publicsuffix.org/' -/** - * @since 0.9.3 - * @deprecated 1.4.0 Use crowdin to manage translations - */ -export const TRANSLATION_ISSUE_PAGE = 'https://docs.google.com/forms/d/e/1FAIpQLSdZSmEZp6Xfmb5v-3H4hsubgeCReDayDOuWDWWU5C1W80exGA/viewform?usp=sf_link' - /** * The id of project on crowdin.com * diff --git a/src/util/dark-mode.ts b/src/util/dark-mode.ts index b15f86f87..0b0679305 100644 --- a/src/util/dark-mode.ts +++ b/src/util/dark-mode.ts @@ -33,6 +33,6 @@ export function toggle(isDarkMode: boolean) { localStorage.setItem(STORAGE_KEY, isDarkMode ? STORAGE_FLAG : undefined) } -export function isDarkMode() { +function isDarkMode() { return localStorage.getItem(STORAGE_KEY) === STORAGE_FLAG } \ No newline at end of file diff --git a/src/util/fifo-cache.ts b/src/util/fifo-cache.ts index 131e01ad3..c1fc9548f 100644 --- a/src/util/fifo-cache.ts +++ b/src/util/fifo-cache.ts @@ -44,7 +44,6 @@ class FIFOCache { 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() diff --git a/src/util/pattern.ts b/src/util/pattern.ts index 29282b424..ea3910ade 100644 --- a/src/util/pattern.ts +++ b/src/util/pattern.ts @@ -18,12 +18,14 @@ export function isBrowserUrl(url: string) { // Firefox addons' pages || /^moz-extension:/.test(url) || /^edge.*?:\/\/.*$/.test(url) + // Edge extensions' pages + || /^extension:/.test(url) || /^safari.*?:\/\/.*/.test(url) } const isNotValidPort = (portStr: string) => { const port = parseInt(portStr) - return port === NaN || port < 0 || port > 65535 || port.toString() !== portStr + return port < 0 || port > 65535 || port.toString() !== portStr } /** diff --git a/src/util/stat.ts b/src/util/stat.ts index 7d8a0e01a..2bf0357eb 100644 --- a/src/util/stat.ts +++ b/src/util/stat.ts @@ -6,26 +6,25 @@ */ export function isNotZeroResult(target: timer.stat.Result): boolean { - return !!target.total || !!target.focus || !!target.time + return !!target.focus || !!target.time } export function createZeroResult(): timer.stat.Result { - return { focus: 0, time: 0, total: 0 } + return { focus: 0, time: 0 } } export function mergeResult(a: timer.stat.Result, b: timer.stat.Result): timer.stat.Result { - return { total: a.total + b.total, focus: a.focus + b.focus, time: a.time + b.time } + return { focus: a.focus + b.focus, time: a.time + b.time } } -export function resultOf(total: number, focus: number, time: number): timer.stat.Result { - return { total, focus, time } +export function resultOf(focus: number, time: number): timer.stat.Result { + return { focus, time } } export function rowOf(key: timer.stat.RowKey, item?: timer.stat.Result): timer.stat.Row { return { host: key.host, date: key.date, - total: item && item.total || 0, focus: item && item.focus || 0, time: item && item.time || 0, mergedHosts: [] diff --git a/src/util/window.ts b/src/util/window.ts new file mode 100644 index 000000000..00c64595e --- /dev/null +++ b/src/util/window.ts @@ -0,0 +1,12 @@ +import { onMounted, onUnmounted } from "vue" + +/** + * handle window visible change + * + * @since 1.4.4 + */ +export function handleWindowVisibleChange(handler: () => void) { + const hanlderInner = () => document.visibilityState === 'visible' && handler() + onMounted(() => document.addEventListener('visibilitychange', hanlderInner)) + onUnmounted(() => document.removeEventListener('visibilitychange', hanlderInner)) +} diff --git a/test/background/backup/gist/compressor.test.ts b/test/background/backup/gist/compressor.test.ts index c8fac1012..642fc5aff 100644 --- a/test/background/backup/gist/compressor.test.ts +++ b/test/background/backup/gist/compressor.test.ts @@ -1,4 +1,4 @@ -import { devide2Buckets, gistData2Rows } from "@src/background/backup/gist/compressor" +import { devide2Buckets, gistData2Rows } from "@src/common/backup/gist/compressor" test('devide 1', () => { const rows: timer.stat.Row[] = [{ @@ -6,7 +6,6 @@ test('devide 1', () => { date: '20220801', focus: 0, time: 10, - total: 1000, mergedHosts: [] }, { host: 'www.baidu.com', @@ -14,27 +13,27 @@ test('devide 1', () => { date: '', 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({ + const expectData: GistData = { "01": { - "www.baidu.com": [10, 0, 1000] + "www.baidu.com": [10, 0] } - }) + } + expect(gistData).toEqual(expectData) }) test('gistData2Rows', () => { const gistData: GistData = { '01': { - 'baidu.com': [0, 1, 2] + 'baidu.com': [0, 1] }, '08': { - 'google.com': [1, 1, 1,] + 'google.com': [1, 1] } } const rows = gistData2Rows('202209', gistData) @@ -45,10 +44,8 @@ test('gistData2Rows', () => { expect(row0.date).toEqual('20220901') expect(row0.time).toEqual(0) expect(row0.focus).toEqual(1) - expect(row0.total).toEqual(2) expect(row1.date).toEqual('20220908') expect(row1.time).toEqual(1) expect(row1.focus).toEqual(1) - expect(row1.total).toEqual(1) }) \ No newline at end of file diff --git a/test/database/timer-database.test.ts b/test/database/timer-database.test.ts index 1e66bba60..9a5c3923f 100644 --- a/test/database/timer-database.test.ts +++ b/test/database/timer-database.test.ts @@ -16,42 +16,42 @@ describe('timer-database', () => { beforeEach(async () => storage.local.clear()) test('1', async () => { - await db.accumulate(baidu, now, resultOf(100, 100, 0)) + await db.accumulate(baidu, now, resultOf(100, 0)) const data: timer.stat.Result = await db.get(baidu, now) - expect(data).toEqual(resultOf(100, 100, 0)) + expect(data).toEqual(resultOf(100, 0)) }) test('2', async () => { - await db.accumulate(baidu, now, resultOf(100, 200, 0)) - await db.accumulate(baidu, now, resultOf(100, 200, 0)) + await db.accumulate(baidu, now, resultOf(200, 0)) + await db.accumulate(baidu, now, resultOf(200, 0)) let data = await db.get(baidu, now) - expect(data).toEqual(resultOf(200, 400, 0)) - await db.accumulate(baidu, now, resultOf(0, 0, 1)) + expect(data).toEqual(resultOf(400, 0)) + await db.accumulate(baidu, now, resultOf(0, 1)) data = await db.get(baidu, now) - expect(data).toEqual(resultOf(200, 400, 1)) + expect(data).toEqual(resultOf(400, 1)) }) test('3', async () => { await db.accumulateBatch( { - [google]: resultOf(11, 11, 0), - [baidu]: resultOf(1, 1, 0) + [google]: resultOf(11, 0), + [baidu]: resultOf(1, 0) }, now ) expect((await db.select()).length).toEqual(2) await db.accumulateBatch( { - [google]: resultOf(12, 12, 1), - [baidu]: resultOf(2, 2, 1) + [google]: resultOf(12, 1), + [baidu]: resultOf(2, 1) }, yesterday ) expect((await db.select()).length).toEqual(4) await db.accumulateBatch( { - [google]: resultOf(13, 13, 2), - [baidu]: resultOf(3, 3, 2) + [google]: resultOf(13, 2), + [baidu]: resultOf(3, 2) }, beforeYesterday ) expect((await db.select()).length).toEqual(6) @@ -72,9 +72,9 @@ describe('timer-database', () => { // By daterange cond = {} cond.date = [now, now] - const expectedResult = [ - { date: nowStr, focus: 11, host: google, mergedHosts: [], time: 0, total: 11 }, - { date: nowStr, focus: 1, host: baidu, mergedHosts: [], time: 0, total: 1 } + const expectedResult: timer.stat.Row[] = [ + { date: nowStr, focus: 11, host: google, mergedHosts: [], time: 0 }, + { date: nowStr, focus: 1, host: baidu, mergedHosts: [], time: 0 } ] expect(await db.select(cond)).toEqual(expectedResult) // Only use start @@ -90,22 +90,9 @@ describe('timer-database', () => { // test item cond = {} - // No range, all returned - cond.totalRange = [] - expect((await db.select(cond)).length).toEqual(6) - // Only 2 item - cond.totalRange = [, 2] - expect((await db.select(cond)).length).toEqual(2) - // Expect 1 item - cond.totalRange = [2,] - expect((await db.select(cond)).length).toEqual(5) - // focus [0,10] && total [2, unlimited) + // focus [0,10] cond.focusRange = [0, 10] - expect((await db.select(cond)).length).toEqual(2) - - // only focus [0,10] - cond.totalRange = [] expect((await db.select(cond)).length).toEqual(3) // time [2, 3] @@ -115,8 +102,8 @@ describe('timer-database', () => { }) test('5', async () => { - await db.accumulate(baidu, now, resultOf(10, 10, 0)) - await db.accumulate(baidu, yesterday, resultOf(10, 12, 0)) + await db.accumulate(baidu, now, resultOf(10, 0)) + await db.accumulate(baidu, yesterday, resultOf(12, 0)) expect((await db.select()).length).toEqual(2) // Delete yesterday's data await db.deleteByUrlAndDate(baidu, yesterday) @@ -125,8 +112,8 @@ describe('timer-database', () => { await db.deleteByUrlAndDate(baidu, yesterday) expect((await db.get(baidu, now)).focus).toEqual(10) // Add one again, and another - await db.accumulate(baidu, beforeYesterday, resultOf(1, 1, 1)) - await db.accumulate(google, now, resultOf(0, 0, 0)) + await db.accumulate(baidu, beforeYesterday, resultOf(1, 1)) + await db.accumulate(google, now, resultOf(0, 0)) expect((await db.select()).length).toEqual(3) // Delete all the baidu await db.deleteByUrl(baidu) @@ -138,7 +125,7 @@ describe('timer-database', () => { const list = await db.select(cond) expect(list.length).toEqual(1) // Add one item of baidu again again - await db.accumulate(baidu, now, resultOf(1, 1, 1)) + await db.accumulate(baidu, now, resultOf(1, 1)) // But delete google await db.delete(list) // Then only one item of baidu @@ -150,11 +137,11 @@ describe('timer-database', () => { expect((await db.select()).length).toEqual(0) // Return zero instance const result = await db.get(baidu, now) - expect([result.focus, result.time, result.total]).toEqual([0, 0, 0]) + expect([result.focus, result.time]).toEqual([0, 0]) }) test('7', async () => { - const foo = resultOf(1, 1, 1) + const foo = resultOf(1, 1) await db.accumulate(baidu, now, foo) await db.accumulate(baidu, yesterday, foo) await db.accumulate(baidu, beforeYesterday, foo) @@ -166,7 +153,7 @@ describe('timer-database', () => { }) test("importData", async () => { - const foo = resultOf(1, 1, 1) + const foo = resultOf(1, 1) await db.accumulate(baidu, now, foo) const data2Import = await db.storage.get() storage.local.clear() @@ -179,7 +166,6 @@ describe('timer-database', () => { expect(item.date).toEqual(formatTime(now, "{y}{m}{d}")) expect(item.host).toEqual(baidu) expect(item.focus).toEqual(1) - expect(item.total).toEqual(1) expect(item.time).toEqual(1) }) @@ -188,8 +174,7 @@ describe('timer-database', () => { // Valid "20210910github.com": { focus: 1, - time: 1, - total: 1 + time: 1 }, // Valid "20210911github.com": { @@ -216,10 +201,10 @@ describe('timer-database', () => { }) test("count", async () => { - await db.accumulate(baidu, now, resultOf(1, 1, 1)) - await db.accumulate(baidu, yesterday, resultOf(1, 2, 1)) - await db.accumulate(google, now, resultOf(1, 3, 1)) - await db.accumulate(google, yesterday, resultOf(1, 4, 1)) + await db.accumulate(baidu, now, resultOf(1, 1)) + await db.accumulate(baidu, yesterday, resultOf(2, 1)) + await db.accumulate(google, now, resultOf(3, 1)) + await db.accumulate(google, yesterday, resultOf(4, 1)) // Count by host expect(await db.count({ host: baidu, diff --git a/test/util/pattern.test.ts b/test/util/pattern.test.ts index 0dda1e930..64bc57df8 100644 --- a/test/util/pattern.test.ts +++ b/test/util/pattern.test.ts @@ -9,6 +9,7 @@ test('browser url', () => { expect(isBrowserUrl('about:addons')).toBeTruthy() // edge expect(isBrowserUrl('edge://extensions/')).toBeTruthy() + expect(isBrowserUrl('extension://ifckodfehjfpfddhjhpejmidkhelbnpa/static/app.html#/additional/option')).toBeTruthy() expect(isBrowserUrl('https://www.jss.com.cn/')).toBeFalsy() }) diff --git a/test/util/stat.test.ts b/test/util/stat.test.ts index 655548fd6..9db5dbc39 100644 --- a/test/util/stat.test.ts +++ b/test/util/stat.test.ts @@ -11,8 +11,7 @@ test('default values of WastePerDay', () => { const newOne = createZeroResult() expect(newOne.time).toBe(0) expect(newOne.focus).toBe(0) - expect(newOne.total).toBe(0) - const another = mergeResult(newOne, resultOf(1, 1, 2)) - expect(another).toEqual(resultOf(1, 1, 2)) + const another = mergeResult(newOne, resultOf(1, 2)) + expect(another).toEqual(resultOf(1, 2)) }) \ No newline at end of file diff --git a/webpack/webpack.prod.ts b/webpack/webpack.prod.ts index 2aea4c1c4..baa398ba9 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', "test", 'package.json', 'tsconfig.json', 'webpack', 'global.d.ts', "jest.config.ts", "script"] +const srcDir = ['public', 'src', "test", 'package.json', 'tsconfig.json', 'webpack', 'global.d.ts', "jest.config.ts", "script", ".gitignore"] 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')