From 830f97047f433d8c3f16d7768101d88f4dc42d1d Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 14 Nov 2025 00:07:00 +0800 Subject: [PATCH 001/174] fix: not to reject if timeout sendMsg2Runtime --- src/api/chrome/runtime.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/api/chrome/runtime.ts b/src/api/chrome/runtime.ts index ffe1622c3..3f40223cb 100644 --- a/src/api/chrome/runtime.ts +++ b/src/api/chrome/runtime.ts @@ -24,7 +24,10 @@ function cloneData(data: T | undefined): T | undefined { export function sendMsg2Runtime(code: timer.mq.ReqCode, data?: T): Promise { const request: timer.mq.Request = { code, data: cloneData(data) } return new Promise((resolve, reject) => { - const timeout = setTimeout(() => reject(new Error('Message timeout: no response from runtime')), 10_000) + const timeout = setTimeout(() => { + // timeout: no response from runtime + resolve(undefined) + }, 10_000) try { chrome.runtime.sendMessage(request, (response: timer.mq.Response) => { clearTimeout(timeout) From e8e10f17d5a080bcc817b92730c7e67b3694b023 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 14 Nov 2025 00:12:05 +0800 Subject: [PATCH 002/174] chore: migrate to new repo URL --- CONTRIBUTING.md | 2 +- README-zh.md | 6 +++--- README.md | 4 ++-- doc/for-fire-fox.md | 2 +- doc/safari-install.md | 4 ++-- doc/similar-comparison.md | 25 ------------------------- package.json | 4 ++-- src/util/constant/url.ts | 8 ++++---- test/util/limit.test.ts | 8 ++++---- 9 files changed, 19 insertions(+), 44 deletions(-) delete mode 100644 doc/similar-comparison.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 010d6b83e..a8ec90705 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -286,7 +286,7 @@ ts-node ./script/crowdin/sync-source.ts ts-node ./script/crowdin/sync-translation.ts ``` -Because the above two scripts rely on the Crowdin access secret in the environment variable, I integrated them into Github's [Action](https://github.com/sheepzh/timer/actions/workflows/crowdin-sync.yml) +Because the above two scripts rely on the Crowdin access secret in the environment variable, I integrated them into Github's [Action](https://github.com/sheepzh/time-tracker-4-browser/actions/workflows/crowdin-sync.yml) 2. Export translations from Crowdin diff --git a/README-zh.md b/README-zh.md index 498162e6b..40d009a7c 100644 --- a/README-zh.md +++ b/README-zh.md @@ -1,8 +1,8 @@ # 网费很贵 -[![codecov](https://codecov.io/gh/sheepzh/timer/branch/main/graph/badge.svg?token=S98QSBSKCR&style=flat-square)](https://codecov.io/gh/sheepzh/timer) +[![codecov](https://codecov.io/gh/sheepzh/time-tracker-4-browser/branch/main/graph/badge.svg?token=S98QSBSKCR&style=flat-square)](https://codecov.io/gh/sheepzh/time-tracker-4-browser) [![](https://img.shields.io/badge/license-Anti%20996-blue)](https://github.com/996icu/996.ICU) -[![](https://img.shields.io/github/v/release/sheepzh/timer)](https://github.com/sheepzh/timer/releases) +[![](https://img.shields.io/github/v/release/sheepzh/time-tracker-4-browser)](https://github.com/sheepzh/time-tracker-4-browser/releases) \[ 简体中文 | [English](./README.md) \] @@ -54,7 +54,7 @@ #### 提交 Issue -如果您有一些好的想法,或者 bug 反馈,可以新建一条 [issue](https://github.com/sheepzh/timer/issues) 。作者会在第一时间进行回复。 +如果您有一些好的想法,或者 bug 反馈,可以新建一条 [issue](https://github.com/sheepzh/time-tracker-4-browser/issues) 。作者会在第一时间进行回复。 #### 参与开发 diff --git a/README.md b/README.md index b8bf12a3a..bf3d8e7ab 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Time Tracker for Browser -[![codecov](https://codecov.io/gh/sheepzh/timer/branch/main/graph/badge.svg?token=S98QSBSKCR&style=flat-square)](https://codecov.io/gh/sheepzh/timer) +[![codecov](https://codecov.io/gh/sheepzh/time-tracker-4-browser/branch/main/graph/badge.svg?token=S98QSBSKCR&style=flat-square)](https://codecov.io/gh/sheepzh/time-tracker-4-browser) [![](https://img.shields.io/badge/license-Anti%20996-blue)](https://github.com/996icu/996.ICU) [![Crowdin](https://badges.crowdin.net/timer-chrome-edge-firefox/localized.svg)](https://crowdin.com/project/timer-chrome-edge-firefox) @@ -53,7 +53,7 @@ There are some things you can do to contribute to this software. #### 1. Submit issues -You can [submit one issue](https://github.com/sheepzh/timer/issues) to us if you have some suggestions, feature requests, or feedback of bugs. And we will reply it as soon as possible. +You can [submit one issue](https://github.com/sheepzh/time-tracker-4-browser/issues) to us if you have some suggestions, feature requests, or feedback of bugs. And we will reply it as soon as possible. #### 2. Participate in development diff --git a/doc/for-fire-fox.md b/doc/for-fire-fox.md index 9f1ce08f4..177081947 100644 --- a/doc/for-fire-fox.md +++ b/doc/for-fire-fox.md @@ -11,4 +11,4 @@ The output directory is `dist_prod_firefox` and you can find a zip file named `t Also you could visit the sourcecode on GitHub -[sheepzh/timer](https://github.com/sheepzh/timer) +[sheepzh/time-tracker-4-browser](https://github.com/sheepzh/time-tracker-4-browser) diff --git a/doc/safari-install.md b/doc/safari-install.md index a3c8c9b79..f2c67a2b3 100644 --- a/doc/safari-install.md +++ b/doc/safari-install.md @@ -6,7 +6,7 @@ So please install it **manually**, GG Safari. ## 0. Download this repository ```shell -git clone https://github.com/sheepzh/timer.git +git clone https://github.com/sheepzh/time-tracker-4-browser.git cd timer ``` @@ -32,7 +32,7 @@ npm run build:safari Then there will be one folder called **Timer**. -Also, you can download the archived file from [the release page](https://github.com/sheepzh/timer/releases), and unzip it to gain this folder. +Also, you can download the archived file from [the release page](https://github.com/sheepzh/time-tracker-4-browser/releases), and unzip it to gain this folder. 2. Convert js bundles to Xcode project diff --git a/doc/similar-comparison.md b/doc/similar-comparison.md deleted file mode 100644 index c0b7df02a..000000000 --- a/doc/similar-comparison.md +++ /dev/null @@ -1,25 +0,0 @@ - -|Features|Webtime Tracker|Web Activity Time Tracker|Time Tracker|TimeYourWeb Time Tracker| -|----|----|----|----|----| -|Chrome|[👤 60k+
⭐ 4.79/5](https://chrome.google.com/webstore/detail/webtime-tracker/ppaojnbmmaigjmlpjaldnkgnklhicppk)|[👤 20k+
⭐ 4.71/5](https://chrome.google.com/webstore/detail/web-activity-time-tracker/hhfnghjdeddcfegfekjeihfmbjenlomm)|[👤 2k+
⭐ 4.97/5](https://chrome.google.com/webstore/detail/time-tracker/dkdhhcbjijekmneelocdllcldcpmekmm)|[👤 13k+
⭐ 4.38/5](https://chrome.google.com/webstore/detail/timeyourweb-time-tracker/kfmlkgchpffnaphmlmjnimonlldbcpnh) -|Edge|❌|[👤 4k+
⭐ 4.50/5](https://chrome.google.com/webstore/detail/web-activity-time-tracker/hhfnghjdeddcfegfekjeihfmbjenlomm)|[👤 3k+
⭐ 5.00/5](https://microsoftedge.microsoft.com/addons/detail/timer-running-browsin/fepjgblalcnepokjblgbgmapmlkgfahc)|[👤 400+
⭐ Not rated](https://microsoftedge.microsoft.com/addons/detail/insight-track-and-optim/kkcmfbejfopnopfcmcjgfkpalecbleio)|[👤 1k+
⭐ 5.00/5](https://microsoftedge.microsoft.com/addons/detail/timeyourweb-time-tracker/jodkkcjbahdphacgjhacggclncbaffoe) -|Firefox|[👤 90+
⭐ Not rated](https://addons.mozilla.org/en-US/firefox/addon/webtime-tracker-2/)|❌|[👤 100+
⭐ 5.00/5](https://addons.mozilla.org/en-US/firefox/addon/besttimetracker/)|❌ -|Track by domain|✅|✅|✅|✅| -|Track any URL|❌|❌|✅|❌ -|Track local files|❌|✅|❌|✅| -|Whitelist|❌|✅|✅|✅ -|Data chart screenshot|✅|❌|✅|❌ -|Daily browsing restrictions|❌|✅|✅|❌ -|Daily notifications|❌|✅|❌|❌|❌ -|Pause switch|❌|❌|❌|✅ -|Access calendar|❌|✅|✅|❌ -|Site analysis|❌|❌|✅|❌ -|Active moments per day|❌|❌|✅|✅ -|Beginner's guide|✅|❌|✅|❌ -|Dark mode|❌|❌|✅|❌ -|Export data|✅|✅|✅|❌| -|Sync data across browsers|❌|❌|✅|❌ -|Free|✅|✅|✅|✅| -|Open Source|❌|[GitHub](https://github.com/Stigmatoz/web-activity-time-tracker)|[GitHub](https://github.com/sheepzh/timer)|❌ -|Languages|English|Deutsch, English, русский|English, português (Portugal), 中文 (简体), 中文 (繁體), 日本語|English -|Memory usage with 5 tabs open on Chrome, MacOS
|21+M|19+M|21+M|20+M diff --git a/package.json b/package.json index 2089103e8..5f64d7ca6 100644 --- a/package.json +++ b/package.json @@ -24,7 +24,7 @@ "url": "https://www.github.com/sheepzh" }, "repository": { - "url": "https://github.com/sheepzh/timer" + "url": "https://github.com/sheepzh/time-tracker-4-browser" }, "license": "MIT", "devDependencies": { @@ -71,4 +71,4 @@ "engines": { "node": ">=20" } -} +} \ No newline at end of file diff --git a/src/util/constant/url.ts b/src/util/constant/url.ts index febbbe1d5..e65022b3c 100644 --- a/src/util/constant/url.ts +++ b/src/util/constant/url.ts @@ -16,17 +16,17 @@ export const EDGE_HOMEPAGE = 'https://microsoftedge.microsoft.com/addons/detail/ /** * @since 0.4.0 */ -export const SOURCE_CODE_PAGE = 'https://github.com/sheepzh/timer' +export const SOURCE_CODE_PAGE = 'https://github.com/sheepzh/time-tracker-4-browser' /** * @since 1.9.4 */ -export const CHANGE_LOG_PAGE = 'https://github.com/sheepzh/timer/blob/main/CHANGELOG.md' +export const CHANGE_LOG_PAGE = 'https://github.com/sheepzh/time-tracker-4-browser/blob/main/CHANGELOG.md' /** * @since 0.0.6 */ -export const GITHUB_ISSUE_ADD = 'https://github.com/sheepzh/timer/issues/new/choose' +export const GITHUB_ISSUE_ADD = 'https://github.com/sheepzh/time-tracker-4-browser/issues/new/choose' /** * Feedback powered by www.wjx.cn @@ -44,7 +44,7 @@ export const TU_CAO_PAGE = `https://support.qq.com/products/402895?os=${BROWSER_ export const PRIVACY_PAGE = 'https://www.wfhg.cc/en/privacy.html' -export const LICENSE_PAGE = 'https://github.com/sheepzh/timer/blob/main/LICENSE' +export const LICENSE_PAGE = 'https://github.com/sheepzh/time-tracker-4-browser/blob/main/LICENSE' /** * @since 0.9.6 diff --git a/test/util/limit.test.ts b/test/util/limit.test.ts index 4055013ea..dda39391e 100644 --- a/test/util/limit.test.ts +++ b/test/util/limit.test.ts @@ -13,20 +13,20 @@ describe('util/limit', () => { }) test('matches', () => { - const cond = ['www.baidu.com', '*.google.com', 'github.com/sheepzh', '+github.com/sheepzh/timer','+www.bilibili.com/cheese','*.bilibili.com*'] + const cond = ['www.baidu.com', '*.google.com', 'github.com/sheepzh', '+github.com/sheepzh/time-tracker-4-browser', '+www.bilibili.com/cheese', '*.bilibili.com*'] expect(matches(cond, 'https://www.baidu.com')).toBe(true) expect(matches(cond, 'http://hk.google.com')).toBe(true) expect(matches(cond, 'http://github.com/sheepzh/poetry')).toBe(true) - expect(matches(cond, 'http://github.com/sheepzh/timer')).toBe(false) - expect(matches(cond, 'http://github.com/sheepzh/timer/test')).toBe(false) + expect(matches(cond, 'http://github.com/sheepzh/time-tracker-4-browser')).toBe(false) + expect(matches(cond, 'http://github.com/sheepzh/time-tracker-4-browser/test')).toBe(false) expect(matches(cond, 'http://www.bilibili.com/cheese/list')).toBe(false) expect(matches(cond, 'http://t.bilibili.com/')).toBe(true) expect(matches(cond, 'https://www.bilibili.com/video/BV3527/')).toBe(true) }) test('matchCond', () => { - const cond = ['www.baidu.com', '*.google.com', 'github.com/sheepzh', 'github.com','+www.bilibili.com/cheese','*.bilibili.com*'] + const cond = ['www.baidu.com', '*.google.com', 'github.com/sheepzh', 'github.com', '+www.bilibili.com/cheese', '*.bilibili.com*'] expect(matchCond(cond, 'http://www.baidu.com')).toEqual(['www.baidu.com']) expect(matchCond(cond, 'https://github.com/sheepzh/time-tracker-for-browser')).toEqual(['github.com', 'github.com/sheepzh']) expect(matchCond(cond, 'https://www.github.com')).toEqual([]) From 650ce2df3eb7713c62291bb4c59d87328e7005d1 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 14 Nov 2025 00:42:07 +0800 Subject: [PATCH 003/174] style: optimize the display of time --- package.json | 14 +++++++------- .../components/Habit/components/Site/Summary.tsx | 7 ++----- src/pages/app/util/time.ts | 6 +++--- 3 files changed, 12 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 5f64d7ca6..d31360fcf 100644 --- a/package.json +++ b/package.json @@ -28,18 +28,18 @@ }, "license": "MIT", "devDependencies": { - "@crowdin/crowdin-api-client": "^1.48.3", + "@crowdin/crowdin-api-client": "^1.49.0", "@emotion/babel-plugin": "^11.13.5", "@emotion/css": "^11.13.5", - "@rsdoctor/rspack-plugin": "^1.3.8", - "@rspack/cli": "^1.6.1", - "@rspack/core": "^1.6.1", + "@rsdoctor/rspack-plugin": "^1.3.9", + "@rspack/cli": "^1.6.3", + "@rspack/core": "^1.6.3", "@swc/core": "^1.15.1", "@swc/jest": "^0.2.39", - "@types/chrome": "0.1.27", + "@types/chrome": "0.1.28", "@types/decompress": "^4.2.7", "@types/jest": "^30.0.0", - "@types/node": "^24.10.0", + "@types/node": "^24.10.1", "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.0.0", @@ -54,7 +54,7 @@ "postcss": "^8.5.6", "postcss-loader": "^8.2.0", "postcss-rtlcss": "^5.7.1", - "puppeteer": "^24.29.1", + "puppeteer": "^24.30.0", "ts-loader": "^9.5.4", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", diff --git a/src/pages/app/components/Habit/components/Site/Summary.tsx b/src/pages/app/components/Habit/components/Site/Summary.tsx index 9b1d413e7..a87127589 100644 --- a/src/pages/app/components/Habit/components/Site/Summary.tsx +++ b/src/pages/app/components/Habit/components/Site/Summary.tsx @@ -58,10 +58,7 @@ const _default = defineComponent(() => { const isXs = useXsState() return () => ( - + msg.analysis.common.focusTotal)} mainValue={periodFormatter(summary.value?.focus?.total, { format: filter.timeFormat })} @@ -74,7 +71,7 @@ const _default = defineComponent(() => { mainName={t(msg => msg.habit.site.countTotal)} mainValue={computeCountText(summary.value?.count)} subTips={msg => msg.habit.site.siteAverage} - subValue={summary.value?.count?.siteAverage?.toFixed(0) || '-'} + subValue={summary.value?.count?.siteAverage?.toFixed(1) ?? '-'} subInfo={summary.value?.exclusiveToday4Average ? t(msg => msg.habit.site.exclusiveToday) : undefined} containerStyle={GRID_CELL_STYLE} /> diff --git a/src/pages/app/util/time.ts b/src/pages/app/util/time.ts index 68b47191a..2ae73b399 100644 --- a/src/pages/app/util/time.ts +++ b/src/pages/app/util/time.ts @@ -30,9 +30,9 @@ type PeriodFormatOption = { } const UNIT_MAP: { [unit in Exclude]: string } = { - second: ' s', - minute: ' m', - hour: ' h', + second: 's', + minute: 'm', + hour: 'h', } /** From b933e1809fcb324c9b03a17d9a29a888e36c74e1 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 14 Nov 2025 00:51:17 +0800 Subject: [PATCH 004/174] docs: fix directory of safari build output --- doc/safari-install.md | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/doc/safari-install.md b/doc/safari-install.md index f2c67a2b3..f08b32824 100644 --- a/doc/safari-install.md +++ b/doc/safari-install.md @@ -7,7 +7,7 @@ So please install it **manually**, GG Safari. ```shell git clone https://github.com/sheepzh/time-tracker-4-browser.git -cd timer +cd time-tracker-4-browser ``` ## 1. Install tools @@ -30,14 +30,12 @@ npm install npm run build:safari ``` -Then there will be one folder called **Timer**. - -Also, you can download the archived file from [the release page](https://github.com/sheepzh/time-tracker-4-browser/releases), and unzip it to gain this folder. +Then there will be one folder called **dist_prod_safari**. 2. Convert js bundles to Xcode project ```shell -[YOUR_PATH]/Xcode.app/Contents/Developer/usr/bin/safari-web-extension-converter ./Timer +[YOUR_PATH]/Xcode.app/Contents/Developer/usr/bin/safari-web-extension-converter ./dist_prod_safari ``` 3. Run Xcode project and one extension app will installed on your macOS From 59dde3b161cedad1741362e452922415eb8fe290 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sat, 15 Nov 2025 00:50:00 +0800 Subject: [PATCH 005/174] ci(firefox): add package-lock.json into source code file --- .github/workflows/publish-all.yml | 2 +- .github/workflows/publish-firefox.yml | 2 +- doc/for-fire-fox.md | 14 -------------- doc/for-firefox.md | 24 ++++++++++++++++++++++++ rspack/rspack.prod.firefox.ts | 14 ++++++++++++-- 5 files changed, 38 insertions(+), 18 deletions(-) delete mode 100644 doc/for-fire-fox.md create mode 100644 doc/for-firefox.md diff --git a/.github/workflows/publish-all.yml b/.github/workflows/publish-all.yml index 7f08ba52c..4fd25d1c8 100644 --- a/.github/workflows/publish-all.yml +++ b/.github/workflows/publish-all.yml @@ -17,7 +17,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "v20.11.0" + node-version: "v22" - name: Install dependencies run: npm install diff --git a/.github/workflows/publish-firefox.yml b/.github/workflows/publish-firefox.yml index 3bc021f53..b1dc24e1e 100644 --- a/.github/workflows/publish-firefox.yml +++ b/.github/workflows/publish-firefox.yml @@ -10,7 +10,7 @@ jobs: - name: Setup NodeJS uses: actions/setup-node@v4 with: - node-version: "v20.11.0" + node-version: "v22" - name: Install dependencies run: npm install - name: Build diff --git a/doc/for-fire-fox.md b/doc/for-fire-fox.md deleted file mode 100644 index 177081947..000000000 --- a/doc/for-fire-fox.md +++ /dev/null @@ -1,14 +0,0 @@ -## How to build for Firefox - -```shell -npm install -npm run build:firefox -``` - -The output directory is `dist_prod_firefox` and you can find a zip file named `target.firefox.zip` in the `market_packages` directory - -## Another - -Also you could visit the sourcecode on GitHub - -[sheepzh/time-tracker-4-browser](https://github.com/sheepzh/time-tracker-4-browser) diff --git a/doc/for-firefox.md b/doc/for-firefox.md new file mode 100644 index 000000000..6a5a680b6 --- /dev/null +++ b/doc/for-firefox.md @@ -0,0 +1,24 @@ +## Building for Firefox + +### Prerequisites + ++ Node.js v22 + +### Build Steps + +```shell +npm install +npm run build:firefox +``` + +### Output Files + ++ Build directory: `dist_prod_firefox` + ++ Marketplace package: `market_packages/target.firefox.zip` + +## Source Code + +Visit our GitHub repository for the latest source code: + +[sheepzh/time-tracker-4-browser](https://github.com/sheepzh/time-tracker-4-browser) diff --git a/rspack/rspack.prod.firefox.ts b/rspack/rspack.prod.firefox.ts index 36bd6144c..b36fa0cb2 100644 --- a/rspack/rspack.prod.firefox.ts +++ b/rspack/rspack.prod.firefox.ts @@ -12,10 +12,20 @@ const normalZipFilePath = path.resolve(marketPkgPath, `${name}-${version}.firefo const targetZipFilePath = path.resolve(marketPkgPath, 'target.firefox.zip') const normalSourceCodePath = path.resolve(__dirname, '..', 'market_packages', `${name}-${version}-src.zip`) const targetSourceCodePath = path.resolve(__dirname, '..', 'market_packages', 'target.src.zip') -const readmeForFirefox = path.join(__dirname, '..', 'doc', 'for-fire-fox.md') +const readmeForFirefox = path.join(__dirname, '..', 'doc', 'for-firefox.md') // Temporary directory for source code to archive on Firefox const sourceTempDir = path.resolve(__dirname, '..', 'source_temp') -const srcDir = ['public', 'src', "test", "types", 'package.json', 'tsconfig.json', 'rspack', "jest.config.ts", "script", ".gitignore"] +const srcDir = [ + 'public', + 'src', + 'test', 'types', + 'package.json', 'package-lock.json', + 'tsconfig.json', + 'rspack', + 'jest.config.ts', + 'script', + ".gitignore", +] const copyMapper = srcDir.map(p => { return { source: path.resolve(__dirname, '..', p), destination: path.resolve(sourceTempDir, p) } }) const filemanagerPlugin = new FileManagerPlugin({ From 09534f3b43cd8fdddc98b1e4cb755eadc3e3314e Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sat, 15 Nov 2025 00:52:29 +0800 Subject: [PATCH 006/174] v3.7.4 --- CHANGELOG.md | 5 +++++ package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 07af2530e..aed2193c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [3.7.4] - 2025-11-15 + +- Improved the time display on popup page +- Supported exporting and importing data for Android + ## [3.7.3] - 2025-11-11 - Supported Polish diff --git a/package.json b/package.json index d31360fcf..14e950f7c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "3.7.3", + "version": "3.7.4", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { From d4efb233d0e84fe467ad59f4693dde282e41ab0a Mon Sep 17 00:00:00 2001 From: sheepie Date: Sun, 16 Nov 2025 23:40:58 +0800 Subject: [PATCH 007/174] feat(limit): optimize rule modification (#622) --- package.json | 6 +- src/background/content-script-handler.ts | 4 +- src/background/icon-and-alias-collector.ts | 6 +- src/background/migrator/cate-initializer.ts | 4 +- .../migrator/local-file-initializer.ts | 10 +- src/database/option-database.ts | 2 +- src/database/whitelist-database.ts | 4 +- src/i18n/message/app/limit-resource.json | 14 +-- src/i18n/message/app/limit.ts | 2 +- .../AnalysisFilter/TargetSelect.tsx | 12 +- .../components/Summary/TargetInfo.tsx | 4 +- .../app/components/Limit/LimitFilter.tsx | 52 ++++++-- .../Limit/LimitModify/Sop/Step2/SiteInput.tsx | 88 ++++++++++++++ .../Sop/{Step2.tsx => Step2/index.tsx} | 52 ++------ .../Limit/LimitModify/Sop/context.ts | 2 +- src/pages/app/components/Limit/LimitTest.tsx | 57 +++------ src/pages/app/components/Limit/context.ts | 11 +- .../components/Report/ReportTable/index.tsx | 6 +- .../SiteManageModify/HostSelect.tsx | 4 +- .../SiteManage/SiteManageModify/index.tsx | 7 +- .../SiteManageTable/column/AliasColumn.tsx | 10 +- .../column/OperationColumn.tsx | 5 +- .../SiteManage/SiteManageTable/index.tsx | 6 +- src/pages/app/components/SiteManage/index.tsx | 8 +- .../components/SiteManage/useSiteManage.ts | 9 +- .../Whitelist/WhitePanel/WhiteInput.tsx | 4 +- .../common/category/CategoryEditable.tsx | 10 +- .../category/CategorySelect/OptionItem.tsx | 4 +- src/pages/hooks/useDebounce.ts | 34 ++++-- src/service/site-service.ts | 115 ++++++++---------- test-e2e/limit/common.ts | 6 +- test/__mock__/storage.ts | 2 +- 32 files changed, 310 insertions(+), 250 deletions(-) create mode 100644 src/pages/app/components/Limit/LimitModify/Sop/Step2/SiteInput.tsx rename src/pages/app/components/Limit/LimitModify/Sop/{Step2.tsx => Step2/index.tsx} (51%) diff --git a/package.json b/package.json index 14e950f7c..34a663ab3 100644 --- a/package.json +++ b/package.json @@ -34,9 +34,9 @@ "@rsdoctor/rspack-plugin": "^1.3.9", "@rspack/cli": "^1.6.3", "@rspack/core": "^1.6.3", - "@swc/core": "^1.15.1", + "@swc/core": "^1.15.2", "@swc/jest": "^0.2.39", - "@types/chrome": "0.1.28", + "@types/chrome": "0.1.29", "@types/decompress": "^4.2.7", "@types/jest": "^30.0.0", "@types/node": "^24.10.1", @@ -63,7 +63,7 @@ "dependencies": { "@element-plus/icons-vue": "^2.3.2", "echarts": "^6.0.0", - "element-plus": "2.11.7", + "element-plus": "2.11.8", "punycode": "^2.3.1", "vue": "^3.5.24", "vue-router": "^4.6.3" diff --git a/src/background/content-script-handler.ts b/src/background/content-script-handler.ts index fb292b017..43551fc68 100644 --- a/src/background/content-script-handler.ts +++ b/src/background/content-script-handler.ts @@ -10,7 +10,7 @@ import { createTab } from "@api/chrome/tab" import { ANALYSIS_ROUTE, LIMIT_ROUTE } from "@app/router/constants" import optionHolder from "@service/components/option-holder" import limitService from "@service/limit-service" -import siteService from "@service/site-service" +import { getSite } from "@service/site-service" import timelineThrottler from '@service/throttler/timeline-throttler' import whitelistHolder from "@service/whitelist/holder" import { getAppPageUrl } from "@util/constant/url" @@ -71,7 +71,7 @@ export default function init(dispatcher: MessageDispatcher) { const { host } = extractHostname(url) || {} if (!host) return null const site: timer.site.SiteKey = { host, type: 'normal' } - const exist = await siteService.get(site) + const exist = await getSite(site) return exist?.run ? site : null }) .register('cs.getAudible', async (_, sender) => !!sender.tab?.audible) diff --git a/src/background/icon-and-alias-collector.ts b/src/background/icon-and-alias-collector.ts index 734aa2850..8c1516fd3 100644 --- a/src/background/icon-and-alias-collector.ts +++ b/src/background/icon-and-alias-collector.ts @@ -6,7 +6,7 @@ */ import { getTab } from "@api/chrome/tab" -import siteService from "@service/site-service" +import { saveAlias, saveIconUrl } from "@service/site-service" import { IS_ANDROID, IS_CHROME, IS_SAFARI } from "@util/constant/environment" import { extractHostname, isBrowserUrl, isHomepage } from "@util/pattern" import { extractSiteName } from "@util/site" @@ -19,7 +19,7 @@ async function collectAlias(key: timer.site.SiteKey, tabTitle: string) { if (!tabTitle) return if (isUrl(tabTitle)) return const siteName = extractSiteName(tabTitle, key.host) - siteName && await siteService.saveAlias(key, siteName, true) + siteName && await saveAlias(key, siteName, true) } /** @@ -35,7 +35,7 @@ async function processTabInfo(tab: ChromeTab): Promise { // localhost hosts with Chrome use cache, so keep the favIcon url undefined IS_CHROME && /^localhost(:.+)?/.test(host) && (favIconUrl = undefined) const siteKey: timer.site.SiteKey = { host, type: 'normal' } - favIconUrl && await siteService.saveIconUrl(siteKey, favIconUrl) + favIconUrl && await saveIconUrl(siteKey, favIconUrl) !IS_ANDROID && !isBrowserUrl(url) && isHomepage(url) diff --git a/src/background/migrator/cate-initializer.ts b/src/background/migrator/cate-initializer.ts index d42cb61f4..ca81f6ecf 100644 --- a/src/background/migrator/cate-initializer.ts +++ b/src/background/migrator/cate-initializer.ts @@ -1,5 +1,5 @@ import cateService from "@service/cate-service" -import siteService from "@service/site-service" +import { batchSaveSiteCate } from "@service/site-service" import { Migrator } from "./common" type InitialCate = { @@ -34,7 +34,7 @@ async function initItem(item: InitialCate) { const cate = await cateService.add(name) const cateId = cate.id const siteKeys = hosts.map(host => ({ host, type: 'normal' } satisfies timer.site.SiteKey)) - await siteService.batchSaveCate(cateId, siteKeys) + await batchSaveSiteCate(cateId, siteKeys) } export default class CateInitializer implements Migrator { diff --git a/src/background/migrator/local-file-initializer.ts b/src/background/migrator/local-file-initializer.ts index 0ee28e495..47af11916 100644 --- a/src/background/migrator/local-file-initializer.ts +++ b/src/background/migrator/local-file-initializer.ts @@ -7,7 +7,7 @@ import mergeRuleDatabase from "@db/merge-rule-database" import { t2Chrome } from "@i18n/chrome/t" -import siteService from "@service/site-service" +import { saveAlias } from '@service/site-service' import { JSON_HOST, LOCAL_HOST_PATTERN, MERGED_HOST, PDF_HOST, PIC_HOST, TXT_HOST } from "@util/constant/remain-host" import { type Migrator } from "./common" @@ -27,19 +27,19 @@ export default class LocalFileInitializer implements Migrator { merged: MERGED_HOST, }).then(() => console.log('Local file merge rules initialized')) // Add site name - siteService.saveAlias( + saveAlias( { host: PDF_HOST, type: 'normal' }, t2Chrome(msg => msg.initial.localFile.pdf), ) - siteService.saveAlias( + saveAlias( { host: JSON_HOST, type: 'normal' }, t2Chrome(msg => msg.initial.localFile.json), ) - siteService.saveAlias( + saveAlias( { host: PIC_HOST, type: 'normal' }, t2Chrome(msg => msg.initial.localFile.pic), ) - siteService.saveAlias( + saveAlias( { host: TXT_HOST, type: 'normal' }, t2Chrome(msg => msg.initial.localFile.txt), ) diff --git a/src/database/option-database.ts b/src/database/option-database.ts index 72ceac680..f7b07f72a 100644 --- a/src/database/option-database.ts +++ b/src/database/option-database.ts @@ -44,7 +44,7 @@ class OptionDatabase extends BaseDatabase { _areaName: chrome.storage.AreaName, ) => { const optionInfo = changes[DB_KEY] - optionInfo && listener(optionInfo.newValue || {} as timer.option.AllOption) + optionInfo && listener(optionInfo.newValue as timer.option.AllOption ?? {}) } chrome.storage.onChanged.addListener(storageListener) } diff --git a/src/database/whitelist-database.ts b/src/database/whitelist-database.ts index 47aff8c4a..bf12e7795 100644 --- a/src/database/whitelist-database.ts +++ b/src/database/whitelist-database.ts @@ -47,7 +47,9 @@ class WhitelistDatabase extends BaseDatabase { _areaName: chrome.storage.AreaName, ) => { const changeInfo = changes[WHITELIST_KEY] - changeInfo && listener(changeInfo.newValue || []) + const newValue = changeInfo?.newValue + const whitelists: string[] = Array.isArray(newValue) ? newValue.map(n => new String(n).toString()) : [] + changeInfo && listener(whitelists) } chrome.storage.onChanged.addListener(storageListener) } diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index a9b821929..a4538f435 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -35,7 +35,6 @@ "deleteConfirm": "是否要删除规则 [{name}]?", "lockConfirm": "锁定后,即使未触发此规则,所有操作也需要验证", "inputTestUrl": "请先输入需要测试的网址链接", - "clickTestButton": "输入完成后请点击【{buttonText}】按钮", "noRuleMatched": "该网址未命中任何规则", "rulesMatched": "该网址命中以下规则:", "timeout": "倒计时已结束 XD" @@ -88,7 +87,6 @@ "deleteConfirm": "確定要刪除規則「{name}」嗎?", "lockConfirm": "如果鎖定,所有操作都需要驗證,即使規則未觸發也一樣。", "inputTestUrl": "請輸入要測試的網址", - "clickTestButton": "輸入完成後請點擊【{buttonText}】按鈕", "noRuleMatched": "此網址未符合任何規則", "rulesMatched": "此網址符合以下規則:", "timeout": "時間到啦!XDDD" @@ -108,6 +106,7 @@ "en": { "filterDisabled": "Only enabled", "wildcardTip": "You can use wildcards to match subdomains or subpages, and use \"+\" as a prefix to exclude subpages!", + "emptyTips": "Click here to create one rule!", "item": { "name": "Rule name", "condition": "Restricted URL", @@ -141,7 +140,6 @@ "deleteConfirm": "Do you want to delete the rule [{name}]?", "lockConfirm": "If locked, all operations will require verification even if the rule is not triggered.", "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:", "timeout": "Time is up! XD" @@ -192,7 +190,6 @@ "noRule": "ルールが記入されていません", "deleteConfirm": "ルール [{name}] を削除しますか?", "inputTestUrl": "最初にテストする URL リンクを入力してください", - "clickTestButton": "入力後、ボタン({buttonText})をクリックしてください", "noRuleMatched": "URL がどのルールとも一致しません", "rulesMatched": "URL は次のルールに一致します。" }, @@ -244,7 +241,6 @@ "deleteConfirm": "Eliminar a regra [{name}]?", "lockConfirm": "Se bloqueado, exigirá verificação mesmo sem ser acionado", "inputTestUrl": "Introduza primeiro o URL a testar", - "clickTestButton": "Clique no botão ({buttonText}) após introduzir", "noRuleMatched": "O URL não corresponde a nenhuma regra", "rulesMatched": "O URL corresponde às seguintes regras:", "timeout": "Tempo esgotado! XD" @@ -297,7 +293,6 @@ "deleteConfirm": "Ви дійсно хочете видалити правило {cond}?", "lockConfirm": "Якщо заблоковано, всі операції потребують перевірки, навіть якщо правило не спрацьовує.", "inputTestUrl": "Спочатку введіть URL-адресу посилання для перевірки", - "clickTestButton": "Після введення натисніть кнопку ({buttonText})", "noRuleMatched": "URL не відповідає жодному правилу", "rulesMatched": "URL-адреса отримує такі правила:", "timeout": "Час вийшов! XD" @@ -348,7 +343,6 @@ "deleteConfirm": "¿Deseas eliminar la regla de {cond}?", "lockConfirm": "Si está bloqueado, todas las operaciones requerirán verificación incluso si no se activa la regla.", "inputTestUrl": "Por favor, introduce primero el enlace URL a ser probado", - "clickTestButton": "Después de ingresarla, haz clic en el botón ({buttonText})", "noRuleMatched": "La URL no sigue ninguna regla", "rulesMatched": "La URL sigue las siguientes reglas:", "timeout": "¡Tiempo se acabó! XD" @@ -401,7 +395,6 @@ "deleteConfirm": "Möchten Sie die Regel [{name}] löschen?", "lockConfirm": "Wenn gesperrt, müssen alle Operationen geprüft werden, auch wenn die Regel nicht ausgelöst wird.", "inputTestUrl": "Bitte geben Sie zunächst den zu testenden URL-Link ein", - "clickTestButton": "Klicken Sie nach der Eingabe bitte auf die Schaltfläche ({buttonText}).", "noRuleMatched": "Die URL entspricht keinen Regeln", "rulesMatched": "Die URL erfüllt die folgenden Regeln:", "timeout": "Zeit ist abgelaufen! XD" @@ -454,7 +447,6 @@ "deleteConfirm": "Voulez-vous supprimer la règle [{name}]?", "lockConfirm": "Si verrouillé, toutes les opérations nécessiteront une vérification, même si la règle n'est pas activée.", "inputTestUrl": "Veuillez entrer le lien URL à tester en premier", - "clickTestButton": "Après l'entrée, cliquez sur le bouton ({buttonText})", "noRuleMatched": "L'URL ne correspond à aucune règle", "rulesMatched": "L'URL atteint les règles suivantes :", "timeout": "Temps écoulé ! XD" @@ -500,7 +492,6 @@ "noRule": "Нет заполненных правил", "deleteConfirm": "Вы хотите удалить правило [{name}]?", "inputTestUrl": "Пожалуйста, введите ссылку для тестирования", - "clickTestButton": "После ввода информации, пожалуйста, нажмите кнопку ({buttonText})", "noRuleMatched": "URL не содержит правил", "rulesMatched": "URL попадает в следующие правила:" }, @@ -551,7 +542,6 @@ "deleteConfirm": "هل تريد حذف القاعدة [{name}]؟", "lockConfirm": "إذا تم القَفل، فإن جميع العمليات ستتطلب التحقق حتى وإن لم يتم تفعيل القاعدة.", "inputTestUrl": "الرجاء إدخال رابط URL ليتم اختباره أولاً", - "clickTestButton": "بعد الإدخال، الرجاء الضغط على الزر ({buttonText})", "noRuleMatched": "عنوان URL لا يصطدم بأي قواعد", "rulesMatched": "يتوافق عنوان URL مع القواعد التالية:", "timeout": "انتهى الوقت! XD" @@ -604,7 +594,6 @@ "deleteConfirm": "{name} kuralını silmek istiyor musunuz?", "lockConfirm": "Kilitliyse, kural tetiklenmemiş olsa bile tüm işlemler doğrulama gerektirecektir.", "inputTestUrl": "Lütfen önce test edilecek URL bağlantısını girin", - "clickTestButton": "Giriş yaptıktan sonra, lütfen düğmeye tıklayın ({buttonText})", "noRuleMatched": "URL hiçbir kurala uymuyor", "rulesMatched": "URL aşağıdaki kurallara uygundur:", "timeout": "Zaman doldu!" @@ -657,7 +646,6 @@ "deleteConfirm": "Na pewno chcesz usunąć regułę [{name}]?", "lockConfirm": "Jeśli zablokowane, wszystkie operacje będą wymagały weryfikacji, nawet jeśli reguła nie zostanie uruchomiona.", "inputTestUrl": "Wpisz URL do przetestowania", - "clickTestButton": "Po wpisaniu kliknij przycisk ({buttonText})", "noRuleMatched": "Podany URL nie aktywuje żadnej reguły", "rulesMatched": "Podany URL aktywuje następujące reguły:", "timeout": "Czas minął! XD" diff --git a/src/i18n/message/app/limit.ts b/src/i18n/message/app/limit.ts index e01ff833c..d8ea5907d 100644 --- a/src/i18n/message/app/limit.ts +++ b/src/i18n/message/app/limit.ts @@ -10,6 +10,7 @@ import resource from './limit-resource.json' export type LimitMessage = { filterDisabled: string wildcardTip: string + emptyTips: string step: { base: string url: string @@ -43,7 +44,6 @@ export type LimitMessage = { deleteConfirm: string lockConfirm: string inputTestUrl: string - clickTestButton: string noRuleMatched: string rulesMatched: string timeout: string diff --git a/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx b/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx index 28bae33cf..67e050a52 100644 --- a/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx +++ b/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx @@ -1,8 +1,8 @@ import { useCategory } from "@app/context" import { t } from "@app/locale" -import { useDebounce, useRequest, useState } from "@hooks" +import { useDebounceState, useRequest } from "@hooks" import Flex from "@pages/components/Flex" -import siteService from "@service/site-service" +import { selectAllSites } from "@service/site-service" import { listHosts } from "@service/stat-service" import { identifySiteKey, parseSiteKeyFromIdentity, SiteMap } from "@util/site" import { ElSelectV2, ElTag, useNamespace } from "element-plus" @@ -67,7 +67,7 @@ const fetchItems = async (categories: timer.site.Cate[]): Promise<[siteItems: Ta collectHosts(hosts, siteSet) // 2.2 query sites from sites - const sites = await siteService.selectAll() + const sites = await selectAllSites() sites?.forEach(site => siteSet.put(site, site)) const siteItems = siteSet?.map((_, site) => site) @@ -115,11 +115,9 @@ const TargetSelect = defineComponent(() => { { defaultValue: [[], []], deps: [() => cate.all] }, ) - const [query, setQuery] = useState('') - const debouncedQuery = useDebounce(query, 50) - + const [query, setQuery] = useDebounceState('', 50) const options = computed(() => { - const q = debouncedQuery.value?.trim?.() + const q = query.value?.trim?.() let [cateItems, siteItems] = allItems.value if (q) { siteItems = siteItems.filter(item => { diff --git a/src/pages/app/components/Analysis/components/Summary/TargetInfo.tsx b/src/pages/app/components/Analysis/components/Summary/TargetInfo.tsx index 7a51482d6..b1d57dbe9 100644 --- a/src/pages/app/components/Analysis/components/Summary/TargetInfo.tsx +++ b/src/pages/app/components/Analysis/components/Summary/TargetInfo.tsx @@ -9,7 +9,7 @@ import { useCategory } from "@app/context" import { t } from "@app/locale" import { useRequest } from "@hooks" import Flex from "@pages/components/Flex" -import siteService from "@service/site-service" +import { getSite } from '@service/site-service' import { ElTag } from "element-plus" import { computed, defineComponent, type StyleValue, toRef } from "vue" import { useAnalysisTarget } from "../../context" @@ -35,7 +35,7 @@ const SUBTITLE_STYLE: StyleValue = { const SiteInfo = defineComponent<{ value: timer.site.SiteKey }>(props => { const key = toRef(props, 'value') - const { data: site } = useRequest(() => key.value ? siteService.get(key.value) : undefined, { deps: key }) + const { data: site } = useRequest(() => key.value ? getSite(key.value) : undefined, { deps: key }) const iconUrl = computed(() => site.value?.iconUrl) const title = computed(() => site.value?.alias ?? labelOfHostInfo(site.value)) const subtitle = computed(() => site.value?.alias ? labelOfHostInfo(site.value) : undefined) diff --git a/src/pages/app/components/Limit/LimitFilter.tsx b/src/pages/app/components/Limit/LimitFilter.tsx index 6476a3998..f70896544 100644 --- a/src/pages/app/components/Limit/LimitFilter.tsx +++ b/src/pages/app/components/Limit/LimitFilter.tsx @@ -11,10 +11,11 @@ import InputFilterItem from "@app/components/common/filter/InputFilterItem" import SwitchFilterItem from "@app/components/common/filter/SwitchFilterItem" import { t } from "@app/locale" import { OPTION_ROUTE } from "@app/router/constants" -import { Delete, Open, Operation, Plus, SetUp, TurnOff } from "@element-plus/icons-vue" +import { Delete, Open, Operation, Plus, SetUp, TurnOff, WarningFilled } from "@element-plus/icons-vue" import Flex from "@pages/components/Flex" import { getAppPageUrl } from "@util/constant/url" -import { defineComponent } from "vue" +import { ElIcon, ElText, ElTooltip } from 'element-plus' +import { defineComponent, ref, Ref, watch } from "vue" import DropdownButton, { type DropdownButtonItem } from "../common/DropdownButton" import { useLimitAction, useLimitBatch, useLimitFilter } from "./context" @@ -22,8 +23,22 @@ const optionPageUrl = getAppPageUrl(OPTION_ROUTE, { i: 'limit' }) type BatchOpt = 'delete' | 'enable' | 'disable' +const useCreateTip = (empty: Ref) => { + const tipVisible = ref(false) + let initialized = false + watch(empty, emptyVal => { + if (initialized || !emptyVal) return + tipVisible.value = true + initialized = true + setTimeout(closeTip, 10000) + }) + const closeTip = () => tipVisible.value = false + return { tipVisible, closeTip } +} + const _default = defineComponent(() => { - const { create, test } = useLimitAction() + const { create, test, empty } = useLimitAction() + const { tipVisible, closeTip } = useCreateTip(empty) const filter = useLimitFilter() const { batchDelete, batchDisable, batchEnable } = useLimitBatch() @@ -46,6 +61,11 @@ const _default = defineComponent(() => { }, ] + const handleCreateClick = () => { + closeTip() + create() + } + return () => ( @@ -73,11 +93,27 @@ const _default = defineComponent(() => { icon={SetUp} onClick={() => createTabAfterCurrent(optionPageUrl)} /> - msg.button.create} - type="success" - icon={Plus} - onClick={create} + !val && closeTip()} + placement='bottom' + effect='light' + content={t(msg => msg.limit.emptyTips)} + v-slots={{ + content: () => ( + + {t(msg => msg.limit.emptyTips)} + ), + default: () => ( + msg.button.create} + type="success" + icon={Plus} + onClick={handleCreateClick} + /> + ) + }} /> diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step2/SiteInput.tsx b/src/pages/app/components/Limit/LimitModify/Sop/Step2/SiteInput.tsx new file mode 100644 index 000000000..1fccf30e8 --- /dev/null +++ b/src/pages/app/components/Limit/LimitModify/Sop/Step2/SiteInput.tsx @@ -0,0 +1,88 @@ +import { listTabs } from '@api/chrome/tab' +import { t } from '@app/locale' +import { useDebounceState, useRequest } from '@hooks' +import Flex from '@pages/components/Flex' +import { selectAllSites } from '@service/site-service' +import { cleanCond } from '@util/limit' +import { extractHostname, isBrowserUrl } from '@util/pattern' +import { ElMessage, ElSelectV2, ElText, type SelectV2Instance } from 'element-plus' +import { computed, defineComponent, ref, type StyleValue } from 'vue' + +type Props = { + onAdd: (url: string) => string | undefined +} + +const fetchAllHosts = async () => { + const tabs = await listTabs() + const hostSet = new Set() + tabs.map(({ url }) => { + if (!url) return + const { protocol, host } = extractHostname(url) + if (protocol === 'https' || protocol === 'http' && !isBrowserUrl(url)) { + hostSet.add(host) + } + }) + const sites = await selectAllSites({ types: ['normal', 'virtual'] }) + sites.forEach(({ host }) => hostSet.add(host)) + return Array.from(hostSet) +} + +const useUrlSelect = () => { + const { data: allHosts } = useRequest(fetchAllHosts) + const [input, onFilter] = useDebounceState('', 50) + const selectEl = ref() + const inputUrl = computed(() => { + const inputVal = input.value + return inputVal ? cleanCond(inputVal) : undefined + }) + + const options = computed(() => { + const urlVal = inputUrl.value + const result: string[] = [] + if (urlVal) { + result.push(urlVal) + allHosts.value?.forEach(host => host.includes(urlVal) && host !== urlVal && result.push(host)) + } else { + allHosts.value?.forEach(h => result.push(h)) + } + return result.map(value => ({ value, label: value })) + }) + + return { options, onFilter } +} + +const SiteInput = defineComponent(props => { + const { options, onFilter } = useUrlSelect() + const selectEl = ref() + + const warnAndFocus = (msg: string) => { + ElMessage.warning(msg) + selectEl.value?.focus() + } + + const handleAdd = (url: string) => { + const errMsg = props.onAdd(url) + if (errMsg) { + return warnAndFocus(errMsg) + } else { + selectEl.value?.handleClear() + } + } + + return () => ( + + + + {t(msg => msg.limit.wildcardTip)} + + + ) +}, { props: ['onAdd'] }) + +export default SiteInput \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step2.tsx b/src/pages/app/components/Limit/LimitModify/Sop/Step2/index.tsx similarity index 51% rename from src/pages/app/components/Limit/LimitModify/Sop/Step2.tsx rename to src/pages/app/components/Limit/LimitModify/Sop/Step2/index.tsx index 1b14a2fc1..7a47f7f4b 100644 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step2.tsx +++ b/src/pages/app/components/Limit/LimitModify/Sop/Step2/index.tsx @@ -7,69 +7,37 @@ import { t } from "@app/locale" import { Delete, WarnTriangleFilled } from "@element-plus/icons-vue" -import { useState } from "@hooks" import Flex from "@pages/components/Flex" import { EXCLUDING_PREFIX } from '@util/constant/remain-host' -import { cleanCond } from "@util/limit" import { - ElButton, ElDivider, ElIcon, ElInput, ElLink, ElMessage, ElScrollbar, ElText, - type InputInstance, type ScrollbarInstance, + ElDivider, ElIcon, + ElLink, + ElScrollbar, ElText, + type ScrollbarInstance } from "element-plus" -import { type StyleValue, defineComponent, ref, watch } from "vue" -import { useSopData, useUrlMiss } from "./context" +import { defineComponent, ref } from "vue" +import { useSopData, useUrlMiss } from "../context" +import SiteInput from './SiteInput' const _default = defineComponent(() => { const data = useSopData() const urlMiss = useUrlMiss() const scrollbar = ref() - const inputEl = ref() - watch(urlMiss, v => v && inputEl.value?.focus()) - const warnAndFocus = (msg: string) => { - ElMessage.warning(msg) - inputEl.value?.focus() - } - - const handleAdd = () => { - let url: string | undefined = inputting.value?.trim?.()?.toLowerCase?.() - if (!url) return warnAndFocus('URL is blank') - url = cleanCond(url) - if (!url) return warnAndFocus('URL is invalid') + const handleAdd = (url: string) => { const urls = data.cond - if (urls.includes(url)) return warnAndFocus('Duplicated URL') + if (urls.includes(url)) return 'URL added already' urls.unshift(url) urlMiss.value = false scrollbar.value?.scrollTo(0) - - clearInputting() } const handleRemove = (idx: number) => data.cond.splice(idx, 1) - const [inputting, setInputting, clearInputting] = useState('') - return () => ( - - (ev as KeyboardEvent)?.code === "Enter" && handleAdd()} - // change the color of append button - style={{ '--el-color-info': `var(--el-color-${urlMiss.value ? 'danger' : 'primary'}` }} - placeholder="www.demo.com, *.demo.com, demo.com/blog/*, demo.com/**, +www.demo.com/blog/list" - v-slots={{ - append: () => {t(msg => msg.button.add)} - }} - /> - - {t(msg => msg.limit.wildcardTip)} - - + diff --git a/src/pages/app/components/Limit/LimitModify/Sop/context.ts b/src/pages/app/components/Limit/LimitModify/Sop/context.ts index 6b05da2bd..51eb12b71 100644 --- a/src/pages/app/components/Limit/LimitModify/Sop/context.ts +++ b/src/pages/app/components/Limit/LimitModify/Sop/context.ts @@ -14,7 +14,7 @@ type Context = { } const createInitial = (): SopData => ({ - name: '', + name: `RULE-${new String(new Date().getTime() % 10000).padStart(4, '0')}`, time: 3600, weekly: 0, cond: [], diff --git a/src/pages/app/components/Limit/LimitTest.tsx b/src/pages/app/components/Limit/LimitTest.tsx index ba1138e51..3a6c37c0a 100644 --- a/src/pages/app/components/Limit/LimitTest.tsx +++ b/src/pages/app/components/Limit/LimitTest.tsx @@ -6,23 +6,21 @@ */ import { t } from "@app/locale" -import { useState, useSwitch } from "@hooks" +import { useDebounce, useRequest, useState, useSwitch } from "@hooks" +import Flex from '@pages/components/Flex' import limitService from "@service/limit-service" -import { ElButton, ElDialog, ElFormItem, ElInput } from "element-plus" -import { computed, defineComponent } from "vue" +import { ElDialog, ElInput } from "element-plus" +import { defineComponent } from "vue" import AlertLines, { type AlertLinesProps } from '../common/AlertLines' import { type TestInstance } from "./context" -function computeResult(url: string | undefined, inputting: boolean, matched: timer.limit.Rule[]): AlertLinesProps { +async function fetchResult(url: string | undefined): Promise { if (!url) { - return { type: 'info', title: msg => msg.limit.message.inputTestUrl } - } - if (inputting) { - const title = t(msg => msg.limit.message.clickTestButton, { buttonText: t(msg => msg.button.test) }) - return { type: 'info', title } + return { type: 'warning', title: msg => msg.limit.message.inputTestUrl } } + const matched = await limitService.select({ url, filterDisabled: true }) if (!matched?.length) { - return { type: 'warning', title: msg => msg.limit.message.noRuleMatched } + return { type: 'info', title: msg => msg.limit.message.noRuleMatched } } else { return { type: 'success', @@ -33,50 +31,33 @@ function computeResult(url: string | undefined, inputting: boolean, matched: tim } const _default = defineComponent((_props, ctx) => { - const [url, , clearUrl] = useState() - const [matched, , clearMatched] = useState([]) + const [url, setUrl, clearUrl] = useState() + const debouncedUrl = useDebounce(url) const [visible, open, close] = useSwitch() - const [urlInputting, startInput, endInput] = useSwitch(true) - const result = computed(() => computeResult(url.value, urlInputting.value, matched.value)) - - const changeInput = (newVal?: string) => { - startInput() - url.value = newVal?.trim() - } - - const handleTest = async () => { - endInput() - matched.value = await limitService.select({ url: url.value, filterDisabled: true }) - } + const { data: result } = useRequest(() => fetchResult(debouncedUrl.value), { deps: debouncedUrl }) ctx.expose({ show: () => { clearUrl() open() - startInput() - clearMatched() } } satisfies TestInstance) + return () => ( msg.button.test)} + title={t(msg => msg.limit.button.test)} modelValue={visible.value} closeOnClickModal={false} onClose={close} > - msg.limit.button.test)}> + changeInput()} - onKeydown={ev => (ev as KeyboardEvent).key === "Enter" && handleTest()} - onInput={changeInput} - v-slots={{ - append: () => {t(msg => msg.button.test)} - }} + modelValue={url.value} onInput={setUrl} + clearable onClear={clearUrl} + placeholder='e.g. https://www.github.com/sheepzh/time-tracker-4-browser' /> - - + {result.value && } + ) }) diff --git a/src/pages/app/components/Limit/context.ts b/src/pages/app/components/Limit/context.ts index 49a95c7db..3f7ab5638 100644 --- a/src/pages/app/components/Limit/context.ts +++ b/src/pages/app/components/Limit/context.ts @@ -2,7 +2,7 @@ import { t } from "@app/locale" import { useDocumentVisibility, useManualRequest, useProvide, useProvider, useRequest } from "@hooks" import limitService from "@service/limit-service" import { ElMessage, ElMessageBox, type TableInstance } from "element-plus" -import { Reactive, reactive, ref, toRaw, watch, type Ref } from "vue" +import { computed, Reactive, reactive, ref, toRaw, watch, type Ref } from "vue" import { useRoute, useRouter } from "vue-router" import { verifyCanModify } from "./common" import type { LimitFilterOption } from "./types" @@ -30,6 +30,7 @@ type Context = { modify: (item: timer.limit.Item) => void create: () => void test: () => void + empty: Ref } const NAMESPACE = 'limit' @@ -44,7 +45,7 @@ const initialUrl = () => { export const useLimitProvider = () => { const filter = reactive({ url: initialUrl(), onlyEnabled: false }) - const { data: list, refresh } = useRequest( + const { data: list, refresh, loading } = useRequest( () => limitService.select({ filterDisabled: filter.onlyEnabled, url: filter.url ?? '' }), { defaultValue: [], @@ -140,17 +141,17 @@ export const useLimitProvider = () => { } } - const modifyInst = ref() const testInst = ref() const modify = (row: timer.limit.Item) => modifyInst.value?.modify?.(toRaw(row)) const create = () => modifyInst.value?.create?.() const test = () => testInst.value?.show?.() + const empty = computed(() => !loading.value && !list.value.length) useProvide(NAMESPACE, { table, filter, - list, refresh, + list, empty, refresh, deleteRow, batchDelete: () => selectedAndThen(handleBatchDelete), batchEnable: () => selectedAndThen(handleBatchEnable), @@ -172,4 +173,4 @@ export const useLimitBatch = () => useProvider useProvider(NAMESPACE, 'modify', 'test', 'create') \ No newline at end of file +export const useLimitAction = () => useProvider(NAMESPACE, 'modify', 'test', 'create', 'empty') \ No newline at end of file diff --git a/src/pages/app/components/Report/ReportTable/index.tsx b/src/pages/app/components/Report/ReportTable/index.tsx index 40175a7f3..dbd0f6a53 100644 --- a/src/pages/app/components/Report/ReportTable/index.tsx +++ b/src/pages/app/components/Report/ReportTable/index.tsx @@ -12,7 +12,7 @@ import { periodFormatter } from "@app/util/time" import { Histogram } from "@element-plus/icons-vue" import { useDocumentVisibility, useManualRequest, useRequest, useState } from "@hooks" import Flex from "@pages/components/Flex" -import siteService from "@service/site-service" +import { removeAlias, saveAlias } from '@service/site-service' import { selectCate, selectGroup, selectSite, type SiteQuery } from "@service/stat-service" import { sum } from "@util/array" import { isRtl } from "@util/document" @@ -34,9 +34,9 @@ import VisitColumn from "./columns/VisitColumn" async function handleAliasChange(key: timer.site.SiteKey, newAlias: string | undefined, data: timer.stat.Row[]) { newAlias = newAlias?.trim?.() if (!newAlias) { - await siteService.removeAlias(key) + await removeAlias(key) } else { - await siteService.saveAlias(key, newAlias) + await saveAlias(key, newAlias) } data?.filter(isSite) ?.filter(item => siteEqual(item.siteKey, key)) diff --git a/src/pages/app/components/SiteManage/SiteManageModify/HostSelect.tsx b/src/pages/app/components/SiteManage/SiteManageModify/HostSelect.tsx index 05f60d593..eff4d8c71 100644 --- a/src/pages/app/components/SiteManage/SiteManageModify/HostSelect.tsx +++ b/src/pages/app/components/SiteManage/SiteManageModify/HostSelect.tsx @@ -4,8 +4,8 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ +import siteDatabase from '@db/site-database' import { useManualRequest } from "@hooks" -import siteService from "@service/site-service" import { listHosts } from "@service/stat-service" import { MERGED_HOST, ALL_HOSTS as REMAIN_HOSTS } from "@util/constant/remain-host" import { isValidVirtualHost, judgeVirtualFast } from "@util/pattern" @@ -41,7 +41,7 @@ async function handleRemoteSearch(query: string): Promise<_OptionInfo[]> { REMAIN_HOSTS.filter(h => h.includes(query)).forEach(remain => allAlias.push({ host: remain, type: 'normal' })) MERGED_HOST.includes(query) && allAlias.push({ host: MERGED_HOST, type: 'merged' }) const existedAliasSet = new Set() - const existedKeys = await siteService.existBatch(allAlias) + const existedKeys = await siteDatabase.existBatch(allAlias) existedKeys.forEach(key => existedAliasSet.add(cvt2OptionValue(key))) const existedOptions: _OptionInfo[] = [] const notExistedOptions: _OptionInfo[] = [] diff --git a/src/pages/app/components/SiteManage/SiteManageModify/index.tsx b/src/pages/app/components/SiteManage/SiteManageModify/index.tsx index bab96b087..0e763d249 100644 --- a/src/pages/app/components/SiteManage/SiteManageModify/index.tsx +++ b/src/pages/app/components/SiteManage/SiteManageModify/index.tsx @@ -5,9 +5,10 @@ * https://opensource.org/licenses/MIT */ import { t } from "@app/locale" +import siteDatabase from '@db/site-database' import { Check } from "@element-plus/icons-vue" import { useSwitch } from "@hooks" -import siteService from "@service/site-service" +import { addSite } from "@service/site-service" import { supportCategory } from "@util/site" import { ElButton, ElDialog, ElForm, ElFormItem, ElInput, ElMessage, type FormInstance, type FormItemRule } from "element-plus" import { computed, defineComponent, reactive, ref } from "vue" @@ -50,7 +51,7 @@ function validateForm(form: FormInstance | undefined): Promise { } async function handleAdd(siteInfo: timer.site.SiteInfo): Promise { - const existed = await siteService.exist(siteInfo) + const existed = await siteDatabase.exist(siteInfo) if (existed) { ElMessage({ type: 'warning', @@ -59,7 +60,7 @@ async function handleAdd(siteInfo: timer.site.SiteInfo): Promise { duration: 1600 }) } else { - await siteService.add(siteInfo) + await addSite(siteInfo) } return !existed } diff --git a/src/pages/app/components/SiteManage/SiteManageTable/column/AliasColumn.tsx b/src/pages/app/components/SiteManage/SiteManageTable/column/AliasColumn.tsx index f127eb7a9..2369ab7b3 100644 --- a/src/pages/app/components/SiteManage/SiteManageTable/column/AliasColumn.tsx +++ b/src/pages/app/components/SiteManage/SiteManageTable/column/AliasColumn.tsx @@ -9,7 +9,7 @@ import Editable from "@app/components/common/Editable" import { t } from "@app/locale" import { MagicStick } from "@element-plus/icons-vue" import Flex from "@pages/components/Flex" -import siteService from "@service/site-service" +import { batchGetSites, batchSaveAliasNoRewrite, removeAlias, saveAlias } from "@service/site-service" import { getSuffix as getPslSuffix } from "@util/psl" import { identifySiteKey, SiteMap } from "@util/site" import { ElIcon, ElMessage, ElPopconfirm, ElTableColumn, ElText } from "element-plus" @@ -46,9 +46,9 @@ const AliasColumn = defineComponent<{}>(() => { newAlias = newAlias?.trim?.() row.alias = newAlias if (newAlias) { - await siteService.saveAlias(row, newAlias) + await saveAlias(row, newAlias) } else { - await siteService.removeAlias(row) + await removeAlias(row) } refresh() } @@ -59,12 +59,12 @@ const AliasColumn = defineComponent<{}>(() => { return ElMessage.info("No data") } const toSave = new SiteMap() - const items = await siteService.batchSelect(data) + const items = await batchGetSites(data) items.filter(i => !i.alias).forEach(site => { const newAlias = genInitialAlias(site) newAlias && toSave.put(site, newAlias) }) - await siteService.batchSaveAliasNoRewrite(toSave) + await batchSaveAliasNoRewrite(toSave) refresh() ElMessage.success(t(msg => msg.operation.successMsg)) } diff --git a/src/pages/app/components/SiteManage/SiteManageTable/column/OperationColumn.tsx b/src/pages/app/components/SiteManage/SiteManageTable/column/OperationColumn.tsx index f6fad3d3a..4821e56d0 100644 --- a/src/pages/app/components/SiteManage/SiteManageTable/column/OperationColumn.tsx +++ b/src/pages/app/components/SiteManage/SiteManageTable/column/OperationColumn.tsx @@ -7,14 +7,15 @@ import PopupConfirmButton from "@app/components/common/PopupConfirmButton" import { t } from "@app/locale" import { Delete } from "@element-plus/icons-vue" -import siteService from "@service/site-service" +import { useRequest } from '@hooks' +import { removeSites } from "@service/site-service" import { ElTableColumn, type RenderRowData } from "element-plus" import { defineComponent } from "vue" import { useSiteManageTable } from '../../useSiteManage' const OperationColumn = defineComponent<{}>(() => { const { refresh } = useSiteManageTable() - const handleConfirm = (key: timer.site.SiteKey) => siteService.remove(key).then(refresh).catch(() => { }) + const { refresh: handleConfirm } = useRequest(removeSites, { onSuccess: refresh }) return () => ( (() => { const { setSelected, refresh, pagination } = useSiteManageTable() const handleIconError = async (row: timer.site.SiteInfo) => { - await siteService.removeIconUrl(row) + await removeIconUrl(row) row.iconUrl = undefined } const handleRunChange = async (val: boolean, row: timer.site.SiteInfo) => { // Save - await siteService.saveRun(row, val) + await saveSiteRunState(row, val) row.run = val refresh() } diff --git a/src/pages/app/components/SiteManage/index.tsx b/src/pages/app/components/SiteManage/index.tsx index fb8c35805..aba58bb31 100644 --- a/src/pages/app/components/SiteManage/index.tsx +++ b/src/pages/app/components/SiteManage/index.tsx @@ -9,7 +9,7 @@ import { t } from "@app/locale" import { Check, Close, WarnTriangleFilled } from "@element-plus/icons-vue" import { useState, useSwitch } from "@hooks" import Flex from "@pages/components/Flex" -import siteService from "@service/site-service" +import { batchSaveSiteCate, removeSites } from "@service/site-service" import { supportCategory } from "@util/site" import { ElButton, ElDialog, ElForm, ElFormItem, ElMessage, ElMessageBox } from "element-plus" import { computed, defineComponent, markRaw, ref, type VNode } from "vue" @@ -49,7 +49,7 @@ export default defineComponent(() => { if (!cateId) { return ElMessage.warning("Category not selected") } - await siteService.batchSaveCate(cateId, cateSupported.value) + await batchSaveSiteCate(cateId, cateSupported.value) ElMessage.success(t(msg => msg.operation.successMsg)) closeCateChange() refresh() @@ -68,7 +68,7 @@ export default defineComponent(() => { } ).then(async () => { const need2Clear = cateSupported.value?.filter(s => s.cate) - need2Clear?.length && await siteService.batchSaveCate(undefined, need2Clear) + need2Clear?.length && await batchSaveSiteCate(undefined, need2Clear) ElMessage.success(t(msg => msg.operation.successMsg)) refresh() }).catch(() => { }) @@ -87,7 +87,7 @@ export default defineComponent(() => { icon: markRaw(WarnTriangleFilled), } ).then(async () => { - await siteService.remove(...(selected.value ?? [])) + await removeSites(...(selected.value ?? [])) ElMessage.success(t(msg => msg.operation.successMsg)) refresh() }).catch(() => { }) diff --git a/src/pages/app/components/SiteManage/useSiteManage.ts b/src/pages/app/components/SiteManage/useSiteManage.ts index 68a39cbbb..f5dfd57ee 100644 --- a/src/pages/app/components/SiteManage/useSiteManage.ts +++ b/src/pages/app/components/SiteManage/useSiteManage.ts @@ -1,5 +1,8 @@ -import { RequestOption, useLocalStorage, useProvide, useProvider, useRequest, useState } from '@hooks' -import siteService, { type SiteQueryParam } from '@service/site-service' +import { + type RequestOption, + useLocalStorage, useProvide, useProvider, useRequest, useState +} from '@hooks' +import { selectSitePage, type SiteQueryParam } from '@service/site-service' import { type Reactive, reactive, type ShallowRef, watch } from 'vue' type FilterOption = { @@ -34,7 +37,7 @@ export const initSiteManage = (loadingTarget: RequestOption[ const { data: pagination, refresh, loading } = useRequest(() => { const { query: fuzzyQuery, cateIds, types } = filter const param: SiteQueryParam = { fuzzyQuery, cateIds, types } - return siteService.selectByPage(param, page) + return selectSitePage(param, page) }, { loadingTarget, deps: [() => filter, () => page] }) useProvide(NAMESPACE, { pagination, filter, selected, setSelected, refresh }) diff --git a/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx b/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx index 8f5809940..70ecdbaba 100644 --- a/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx +++ b/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx @@ -10,7 +10,7 @@ import { Check, CirclePlus, Close } from "@element-plus/icons-vue" import { useRequest, useShadow } from "@hooks" import Box from "@pages/components/Box" import Flex from '@pages/components/Flex' -import siteService from "@service/site-service" +import { selectAllSites } from '@service/site-service' import { EXCLUDING_PREFIX, isRemainHost } from "@util/constant/remain-host" import { isValidHost, judgeVirtualFast } from "@util/pattern" import { ElButton, ElIcon, ElMessage, ElOption, ElSelect, ElTag } from "element-plus" @@ -28,7 +28,7 @@ async function remoteSearch(query: string): Promise { } if (!query) return [] - let sites: SearchItem[] = await siteService.selectAll({ fuzzyQuery: query }) + let sites: SearchItem[] = await selectAllSites({ fuzzyQuery: query }) const idx = sites.findIndex(s => s.host === query) const target = idx >= 0 diff --git a/src/pages/app/components/common/category/CategoryEditable.tsx b/src/pages/app/components/common/category/CategoryEditable.tsx index 0e7a4d66f..6ddcaf686 100644 --- a/src/pages/app/components/common/category/CategoryEditable.tsx +++ b/src/pages/app/components/common/category/CategoryEditable.tsx @@ -2,7 +2,7 @@ import { useCategory } from "@app/context" import { Edit } from "@element-plus/icons-vue" import { useManualRequest, useSwitch } from "@hooks" import Flex from "@pages/components/Flex" -import siteService from "@service/site-service" +import { saveSiteCate } from '@service/site-service' import { supportCategory } from "@util/site" import { ElIcon, ElTag } from "element-plus" import { computed, defineComponent, nextTick, ref } from "vue" @@ -23,9 +23,9 @@ const CategoryEditable = defineComponent(props => { return categories.find(c => c.id == id) }) - const { refresh: saveSiteCate } = useManualRequest(async (cateId: number | string | undefined) => { + const { refresh: doSave } = useManualRequest(async (cateId: number | string | undefined) => { const realCateId = typeof cateId === 'string' ? parseInt(cateId) : cateId - await siteService.saveCate(props.siteKey, realCateId) + await saveSiteCate(props.siteKey, realCateId) return realCateId }, { onSuccess(realCateId) { @@ -49,7 +49,7 @@ const CategoryEditable = defineComponent(props => { size="small" width="100px" modelValue={props.modelValue} - onChange={saveSiteCate} + onChange={doSave} onVisibleChange={visible => !visible && closeEditing()} /> : @@ -58,7 +58,7 @@ const CategoryEditable = defineComponent(props => { saveSiteCate(undefined)} + onClose={() => doSave(undefined)} > {current.value.name} diff --git a/src/pages/app/components/common/category/CategorySelect/OptionItem.tsx b/src/pages/app/components/common/category/CategorySelect/OptionItem.tsx index 495ccb2a3..f075c081f 100644 --- a/src/pages/app/components/common/category/CategorySelect/OptionItem.tsx +++ b/src/pages/app/components/common/category/CategorySelect/OptionItem.tsx @@ -4,7 +4,7 @@ import { Check, Close, Delete, Edit } from "@element-plus/icons-vue" import { useManualRequest, useRequest, useState, useSwitch } from "@hooks" import Flex from "@pages/components/Flex" import cateService from "@service/cate-service" -import siteService from "@service/site-service" +import { selectAllSites } from '@service/site-service' import { stopPropagationAfter } from "@util/document" import { ElButton, ElInput, ElMessage, ElMessageBox } from "element-plus" import { defineComponent, nextTick, ref } from "vue" @@ -34,7 +34,7 @@ const OptionItem = defineComponent<{ value: timer.site.Cate }>(props => { ElMessage.success(t(msg => msg.operation.successMsg)) } }) - const { data: relatedSites, loading: queryingSites } = useRequest(() => siteService.selectAll({ cateIds: props.value?.id })) + const { data: relatedSites, loading: queryingSites } = useRequest(() => selectAllSites({ cateIds: props.value?.id })) const onRemoveClick = (e: MouseEvent) => { e.stopPropagation() diff --git a/src/pages/hooks/useDebounce.ts b/src/pages/hooks/useDebounce.ts index dd13b4da2..85abecc7e 100644 --- a/src/pages/hooks/useDebounce.ts +++ b/src/pages/hooks/useDebounce.ts @@ -1,8 +1,9 @@ import { shallowRef, watch, type MaybeRefOrGetter, type Ref } from 'vue' +import { useState } from './useState' type FunctionArgs = (...args: any[]) => any -const DEFAULT_TIMEOUT = 200 +const DEFAULT_TIMEOUT = 100 export function useDebounceFn( fn: T, @@ -10,25 +11,24 @@ export function useDebounceFn( ): T { let timeoutId: ReturnType | undefined - const resolveValue = (value: MaybeRefOrGetter): number => { - if (typeof value === 'function') { - return value() - } else if (value && typeof value === 'object' && 'value' in value) { - return value.value + const resolveDelay = (ms: MaybeRefOrGetter | undefined): number => { + if (typeof ms === 'function') { + return ms() + } else if (typeof ms === 'object' && 'value' in ms) { + return ms.value + } else if (typeof ms === 'number') { + return ms + } else { + return DEFAULT_TIMEOUT } - return value } const debounced = ((...args: Parameters) => { - if (timeoutId) { - clearTimeout(timeoutId) - } - - const delay = ms !== undefined ? resolveValue(ms) : DEFAULT_TIMEOUT + timeoutId && clearTimeout(timeoutId) timeoutId = setTimeout(() => { fn(...args) - }, delay) + }, resolveDelay(ms)) }) as T return debounced @@ -41,4 +41,12 @@ export function useDebounce(original: Ref, ms?: MaybeRefOrGetter): watch(original, newVal => debouncedFn(newVal)) return inner +} + +export function useDebounceState(defaultValue: T, ms?: MaybeRefOrGetter): [Ref, ArgCallback] { + const [inner, setInner] = useState(defaultValue) + + const debouncedSet = useDebounceFn(setInner, ms) + + return [inner, debouncedSet] } \ No newline at end of file diff --git a/src/service/site-service.ts b/src/service/site-service.ts index 35d06c3eb..3113a9b80 100644 --- a/src/service/site-service.ts +++ b/src/service/site-service.ts @@ -13,14 +13,14 @@ import { slicePageResult } from "./components/page-info" export type SiteQueryParam = SiteCondition -async function removeAlias(key: timer.site.SiteKey) { +export async function removeAlias(key: timer.site.SiteKey) { const exist = await siteDatabase.get(key) if (!exist) return delete exist.alias await siteDatabase.save(exist) } -async function saveAlias(key: timer.site.SiteKey, alias: string, noRewrite?: boolean) { +export async function saveAlias(key: timer.site.SiteKey, alias: string, noRewrite?: boolean) { const exist = await siteDatabase.get(key) let toUpdate: timer.site.SiteInfo if (exist) { @@ -34,7 +34,7 @@ async function saveAlias(key: timer.site.SiteKey, alias: string, noRewrite?: boo await siteDatabase.save(toUpdate) } -async function batchSaveAliasNoRewrite(siteMap: SiteMap): Promise { +export async function batchSaveAliasNoRewrite(siteMap: SiteMap): Promise { if (!siteMap?.count?.()) return const allSites = await siteDatabase.getBatch(siteMap.keys()) const existMap = new SiteMap() @@ -49,14 +49,14 @@ async function batchSaveAliasNoRewrite(siteMap: SiteMap): Promise await siteDatabase.save(...toSave) } -async function removeIconUrl(key: timer.site.SiteKey) { +export async function removeIconUrl(key: timer.site.SiteKey) { const exist = await siteDatabase.get(key) if (!exist) return delete exist.iconUrl await siteDatabase.save(exist) } -async function saveIconUrl(key: timer.site.SiteKey, iconUrl: string) { +export async function saveIconUrl(key: timer.site.SiteKey, iconUrl: string) { const exist = await siteDatabase.get(key) let toUpdate: timer.site.SiteInfo if (exist) { @@ -68,7 +68,7 @@ async function saveIconUrl(key: timer.site.SiteKey, iconUrl: string) { await siteDatabase.save(toUpdate) } -async function saveRun(key: timer.site.SiteKey, run: boolean) { +export async function saveSiteRunState(key: timer.site.SiteKey, run: boolean) { const exist = await siteDatabase.get(key) if (!exist) return exist.run = run @@ -82,76 +82,57 @@ async function saveRun(key: timer.site.SiteKey, run: boolean) { } } -class SiteService { - async add(siteInfo: timer.site.SiteInfo): Promise { - if (await siteDatabase.exist(siteInfo)) { - return - } - if (!supportCategory(siteInfo)) siteInfo.cate = undefined - await siteDatabase.save(siteInfo) - } - - async selectByPage(param?: SiteQueryParam, page?: timer.common.PageQuery): Promise> { - const origin: timer.site.SiteInfo[] = await siteDatabase.select(param) - const result: timer.common.PageResult = slicePageResult(origin, page) - return result - } - - selectAll(param?: SiteQueryParam): Promise { - return siteDatabase.select(param) - } - - async batchSelect(keys: timer.site.SiteKey[]): Promise { - return siteDatabase.getBatch(keys) - } - - async remove(...sites: timer.site.SiteKey[]): Promise { - await siteDatabase.remove(...sites) +export async function addSite(siteInfo: timer.site.SiteInfo): Promise { + if (await siteDatabase.exist(siteInfo)) { + return } + if (!supportCategory(siteInfo)) siteInfo.cate = undefined + await siteDatabase.save(siteInfo) +} - saveAlias = saveAlias - batchSaveAliasNoRewrite = batchSaveAliasNoRewrite - removeAlias = removeAlias - saveIconUrl = saveIconUrl - removeIconUrl = removeIconUrl - saveRun = saveRun +export async function selectSitePage(param?: SiteQueryParam, page?: timer.common.PageQuery): Promise> { + const origin: timer.site.SiteInfo[] = await siteDatabase.select(param) + const result: timer.common.PageResult = slicePageResult(origin, page) + return result +} - async saveCate(key: timer.site.SiteKey, cateId: number | undefined): Promise { - if (!supportCategory(key)) return +export function selectAllSites(param?: SiteQueryParam): Promise { + return siteDatabase.select(param) +} - const exist = await siteDatabase.get(key) - await siteDatabase.save({ ...exist || key, cate: cateId }) - } +export function batchGetSites(keys: timer.site.SiteKey[]): Promise { + return siteDatabase.getBatch(keys) +} - async batchSaveCate(cateId: number | undefined, keys: timer.site.SiteKey[]): Promise { - keys = keys?.filter(supportCategory) - if (!keys?.length) return +export function removeSites(...sites: timer.site.SiteKey[]): Promise { + return siteDatabase.remove(...sites) +} - const allSites = await siteDatabase.getBatch(keys) - const siteMap = toMap(allSites, identifySiteKey) +export async function saveSiteCate(key: timer.site.SiteKey, cateId: number | undefined): Promise { + if (!supportCategory(key)) return - const toSave = keys.map(k => { - const s = siteMap[identifySiteKey(k)] - return ({ ...s || k, cate: cateId }) - }) - await siteDatabase.save(...toSave) - } + const exist = await siteDatabase.get(key) + await siteDatabase.save({ ...exist || key, cate: cateId }) +} - exist(host: timer.site.SiteKey): Promise { - return siteDatabase.exist(host) - } +export async function batchSaveSiteCate(cateId: number | undefined, keys: timer.site.SiteKey[]): Promise { + keys = keys?.filter(supportCategory) + if (!keys?.length) return - existBatch(hosts: timer.site.SiteKey[]): Promise { - return siteDatabase.existBatch(hosts) - } + const allSites = await siteDatabase.getBatch(keys) + const siteMap = toMap(allSites, identifySiteKey) - /** - * @since 0.9.0 - */ - async get(siteKey: timer.site.SiteKey): Promise { - const info = await siteDatabase.get(siteKey) - return info ?? siteKey - } + const toSave = keys.map(k => { + const s = siteMap[identifySiteKey(k)] + return ({ ...s || k, cate: cateId }) + }) + await siteDatabase.save(...toSave) } -export default new SiteService() +/** +* @since 0.9.0 +*/ +export async function getSite(siteKey: timer.site.SiteKey): Promise { + const info = await siteDatabase.get(siteKey) + return info ?? siteKey +} diff --git a/test-e2e/limit/common.ts b/test-e2e/limit/common.ts index e8a34a321..18836b199 100644 --- a/test-e2e/limit/common.ts +++ b/test-e2e/limit/common.ts @@ -8,15 +8,19 @@ export async function createLimitRule(rule: timer.limit.Rule, page: Page) { await page.waitForSelector('.el-dialog .el-input input') const nameInput = await page.$('.el-dialog .el-input input') await nameInput!.focus() + await nameInput?.click({ count: 3 }) + await sleep(.1) page.keyboard.type(rule.name) await new Promise(resolve => setTimeout(resolve, 400)) await page.click('.el-dialog .el-button.el-button--primary') // 2. Fill the condition - const configInput = await page.$('.el-dialog .el-input.el-input-group input') + const configInput = await page.$('.el-dialog #site-input') for (const url of rule.cond || []) { await configInput!.focus() await page.keyboard.type(url) await sleep(.1) + await page.keyboard.press('ArrowDown') + await sleep(.1) await page.keyboard.press('Enter') } await sleep(.1) diff --git a/test/__mock__/storage.ts b/test/__mock__/storage.ts index 4e909e3b5..0a1bfc37c 100644 --- a/test/__mock__/storage.ts +++ b/test/__mock__/storage.ts @@ -67,7 +67,7 @@ const onChanged = { addListener: jest.fn(), removeListener: jest.fn(), hasListener: jest.fn() -} as unknown as chrome.storage.StorageChangedEvent +} as unknown as typeof chrome.storage.onChanged export const mockStorage = () => { global.chrome = { From 9f5e107500020784220c7e253a81df3505071eea Mon Sep 17 00:00:00 2001 From: sheepzh Date: Mon, 17 Nov 2025 00:30:22 +0800 Subject: [PATCH 008/174] fix: echarts uses fonts inherited from body (#623) --- src/pages/hooks/useEcharts.ts | 21 ++++++++++---- src/util/echarts.ts | 52 +++++++++++++++++++++++++++-------- 2 files changed, 56 insertions(+), 17 deletions(-) diff --git a/src/pages/hooks/useEcharts.ts b/src/pages/hooks/useEcharts.ts index 507c938b1..b35abd470 100644 --- a/src/pages/hooks/useEcharts.ts +++ b/src/pages/hooks/useEcharts.ts @@ -6,15 +6,19 @@ * https://opensource.org/licenses/MIT */ import optionHolder from "@service/components/option-holder" -import { processAnimation, processAria, processRtl } from "@util/echarts" -import { type AriaComponentOption, type ComposeOption } from "echarts" +import { processAnimation, processAria, processFont, processRtl } from "@util/echarts" +import { type AriaComponentOption, type ComposeOption, SeriesOption, TitleComponentOption } from "echarts" import { type ECharts, init } from "echarts/core" import { ElLoading } from "element-plus" import { type Ref, isRef, onMounted, ref, watch } from "vue" import { useElementSize } from './useElementSize' import { useWindowSize } from "./useWindowSize" -type BaseEchartsOption = ComposeOption +type BaseEchartsOption = ComposeOption< + | AriaComponentOption + | TitleComponentOption + | SeriesOption +> export abstract class EchartsWrapper { public instance: ECharts | undefined @@ -27,9 +31,15 @@ export abstract class EchartsWrapper { */ protected replaceSeries: boolean = false private lastBizOption: BizOption | undefined + /** + * Fix the font family + * @see https://github.com/sheepzh/time-tracker-4-browser/issues/623 + */ + private textFontFamily: string | undefined init(container: HTMLDivElement) { this.instance = init(container) + this.textFontFamily = getComputedStyle(container).fontFamily } async render(biz: BizOption) { @@ -40,7 +50,7 @@ export abstract class EchartsWrapper { private async innerRender() { const biz = this.lastBizOption - const option = biz && await this.generateOption(biz) as (EchartsOption & BaseEchartsOption) + const option = biz && await this.generateOption(biz) if (!option) return await this.postChartOption(option) @@ -54,6 +64,7 @@ export abstract class EchartsWrapper { processAnimation(option, chartAnimationDuration) processAria(option, chartDecal) processRtl(option) + this.textFontFamily && processFont(option, this.textFontFamily) } async resize() { @@ -66,7 +77,7 @@ export abstract class EchartsWrapper { return this.instance!.getDom() } - protected abstract generateOption(biz: BizOption): Promise | EchartsOption + protected abstract generateOption(biz: BizOption): Awaitable protected getDomWidth(): number { return this.getDom()?.clientWidth ?? 0 diff --git a/src/util/echarts.ts b/src/util/echarts.ts index 6a95230d7..cd4688b22 100644 --- a/src/util/echarts.ts +++ b/src/util/echarts.ts @@ -1,14 +1,15 @@ -import { - type AriaComponentOption, - type BarSeriesOption, - type ComposeOption, - type GridComponentOption, - type LegendComponentOption, - type LineSeriesOption, - type PieSeriesOption, - type ScatterSeriesOption, - type ToolboxComponentOption, - type VisualMapComponentOption, +import type { + AriaComponentOption, + BarSeriesOption, + ComposeOption, + GridComponentOption, + LegendComponentOption, + LineSeriesOption, + PieSeriesOption, + ScatterSeriesOption, + TitleComponentOption, + ToolboxComponentOption, + VisualMapComponentOption, } from "echarts" import { isRtl } from "./document" @@ -66,10 +67,37 @@ const generateAriaOption = (chartDecal: boolean): AriaComponentOption => { type SupportedSeriesOption = PieSeriesOption | LineSeriesOption | BarSeriesOption | ScatterSeriesOption type GlobalEcOption = ComposeOption< - | GridComponentOption | LegendComponentOption | ToolboxComponentOption | VisualMapComponentOption + | TitleComponentOption | GridComponentOption | LegendComponentOption | ToolboxComponentOption | VisualMapComponentOption | SupportedSeriesOption > +export const processFont = (toProcess: unknown, elFont: string) => { + const options = toProcess as GlobalEcOption + const { series, title, legend } = options + processArrayLike(title, t => { + t.textStyle = { + ...t.textStyle, + fontFamily: t.textStyle?.fontFamily ?? elFont, + } + t.subtextStyle = { + ...t.subtextStyle, + fontFamily: t.subtextStyle?.fontFamily ?? elFont, + } + }) + processArrayLike(series, s => { + s.label = { + ...s.label, + fontFamily: s.label?.fontFamily ?? elFont, + } + }) + processArrayLike(legend, l => { + l.textStyle = { + ...l.textStyle, + fontFamily: l.textStyle?.fontFamily ?? elFont, + } + }) +} + export const processRtl = (toProcess: unknown) => { if (!isRtl() || !toProcess) return const option = toProcess as GlobalEcOption From 8977629067b20b1df5dc840ebc92fdaf212f5526 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 17 Nov 2025 09:58:46 +0800 Subject: [PATCH 009/174] i18n(download): download translations by bot (#624) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/dashboard-resource.json | 8 ++++++-- src/i18n/message/app/option-resource.json | 1 + src/i18n/message/app/whitelist-resource.json | 1 + 3 files changed, 8 insertions(+), 2 deletions(-) diff --git a/src/i18n/message/app/dashboard-resource.json b/src/i18n/message/app/dashboard-resource.json index f335458db..16018a9d6 100644 --- a/src/i18n/message/app/dashboard-resource.json +++ b/src/i18n/message/app/dashboard-resource.json @@ -195,7 +195,7 @@ }, "indicator": { "installedDays": "Installé depuis {number} jours", - "visitCount": "Visité {site} sites Web {visit} fois", + "visitCount": "{site} sites visités {visit} fois", "browsingTime": "Parcouru pendant {minute} minutes", "mostUse": "Navigation favorite entre {start} et {end}" }, @@ -203,7 +203,11 @@ "title": "Tendance mensuelle du temps de navigation" }, "timeline": { - "focusScoreDesc": "Lié à la durée totale de la navigation continue sur le même site. Voir le code source pour la formule de calcul" + "title": "Chronologie des derniers {n} jours", + "busyScore": "Niveau d'occupation", + "busyScoreDesc": "En rapport avec le temps total de navigation et le nombre de sites visités par heure. Voir le code source pour la formule du calcul", + "focusScore": "Niveau de concentration", + "focusScoreDesc": "Lié à la durée totale de la navigation continue sur le même site. Voir le code source pour la formule du calcul" } }, "ru": { diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 3242a66e2..3130ed203 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -1049,6 +1049,7 @@ "tracking": { "title": "Statistiques", "autoPauseTrack": "{input} Pause de suivi si aucune activité n'a détecté {info} pour {maxTime}", + "noActivityInfo": "La souris et le clavier sont inactifs, pas en plein écran, et il n'y a pas de son joué", "countLocalFiles": "{input} S'il faut compter le temps jusqu'à {localFileTime} {info} dans le navigateur", "localFileTime": "lire un fichier local", "localFilesInfo": "Prend en charge les fichiers de types tels que PDF, image, txt et json.", diff --git a/src/i18n/message/app/whitelist-resource.json b/src/i18n/message/app/whitelist-resource.json index 46b324a87..7f0f3085d 100644 --- a/src/i18n/message/app/whitelist-resource.json +++ b/src/i18n/message/app/whitelist-resource.json @@ -84,6 +84,7 @@ "infoAlertTitle": "Vous pouvez ajouter des sites à la liste blanche sur cette page", "infoAlert0": "Les sites sur liste blanche ne seront pas comptés", "infoAlert1": "Les sites sur liste blanche ne seront pas restreints", + "infoAlert2": "Vous pouvez utiliser un signe générique(*) pour cibler plusieurs sites, comme *.example.com/**, et utiliser + comme un préfixe pour exclure des sites, comme +need.example.com/**", "errorInput": "URL du site invalide" }, "ru": { From b53f490a0f7e22fdf0d3fe0965a37c35be97d030 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Mon, 17 Nov 2025 12:26:21 +0800 Subject: [PATCH 010/174] fix: fix axis label font family (#623) --- src/util/echarts.ts | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/src/util/echarts.ts b/src/util/echarts.ts index cd4688b22..1b678ac75 100644 --- a/src/util/echarts.ts +++ b/src/util/echarts.ts @@ -11,6 +11,7 @@ import type { ToolboxComponentOption, VisualMapComponentOption, } from "echarts" +import type { AxisBaseOption } from 'echarts/types/src/coord/axisCommonTypes.js' import { isRtl } from "./document" export const processAria = (option: ComposeOption, chartDecal: boolean) => { @@ -73,7 +74,7 @@ type GlobalEcOption = ComposeOption< export const processFont = (toProcess: unknown, elFont: string) => { const options = toProcess as GlobalEcOption - const { series, title, legend } = options + const { series, title, legend, xAxis, yAxis } = options processArrayLike(title, t => { t.textStyle = { ...t.textStyle, @@ -96,6 +97,15 @@ export const processFont = (toProcess: unknown, elFont: string) => { fontFamily: l.textStyle?.fontFamily ?? elFont, } }) + processArrayLike(xAxis, a => processAxisFont(a, elFont)) + processArrayLike(yAxis, a => processAxisFont(a, elFont)) +} + +const processAxisFont = (a: AxisBaseOption, elFont: string) => { + a.axisLabel = { + ...a.axisLabel, + fontFamily: a.axisLabel?.fontFamily ?? elFont, + } } export const processRtl = (toProcess: unknown) => { From 24056005be4c0edbeb542062e0b74e007a03b920 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Mon, 17 Nov 2025 15:43:27 +0800 Subject: [PATCH 011/174] ci: add e2e setup script --- .github/workflows/end-to-end-tests.yml | 19 +- CONTRIBUTING.md | 57 +++- jest.config.ts | 15 +- package.json | 1 - script/setup-e2e.sh | 385 +++++++++++++++++++++++++ 5 files changed, 446 insertions(+), 31 deletions(-) create mode 100755 script/setup-e2e.sh diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index 965d682a5..8015ec452 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -1,5 +1,5 @@ name: End-to-end tests CI -on: [pull_request] +on: [pull_request, workflow_dispatch] jobs: test: runs-on: ubuntu-latest @@ -8,20 +8,13 @@ jobs: - name: Test using Node.js uses: actions/setup-node@v1 with: - node-version: "v20.11.0" + node-version: "v22" - name: Install dependencies run: npm install - - name: Install http-server - run: npm install -g http-server pm2 - - name: Build e2e outputs - run: npm run dev:e2e - - name: Build production outputs - run: npm run build - - - name: Start up mock server - run: | - pm2 start 'http-server ./test-e2e/example -p 12345' - pm2 start 'http-server ./test-e2e/example -p 12346' + - name: Setup e2e environment + run: bash script/setup-e2e.sh --all + - name: Start test servers + run: bash script/setup-e2e.sh --start-servers - name: Run tests env: diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a8ec90705..7f810cac8 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -85,7 +85,7 @@ npm run build:firefox npm run build:safari ``` -### 5. Testing Your Extension +### 5. Debugging #### Chrome/Edge @@ -126,23 +126,24 @@ This will generate coverage reports in the `coverage/` directory. #### Setup E2E Testing -1. Build the test environment: +Use the provided setup script to initialize the e2e testing environment: ```shell -npm run dev:e2e -npm run build +# Initialize e2e environment (install dependencies and build outputs) +bash script/setup-e2e.sh --init --build + +# Start test servers +bash script/setup-e2e.sh --start-servers ``` -2. Start test servers: +The setup script will: -```shell -npm install -g http-server pm2 - -pm2 start 'http-server ./test-e2e/example -p 12345' -pm2 start 'http-server ./test-e2e/example -p 12346' -``` +- Install/upgrade global dependencies (`http-server`, `pm2`) +- Download browser for Puppeteer (if needed) +- Build e2e test output (`dist_e2e/`) +- Start test servers on ports 12345 and 12346 -3. Run E2E tests: +#### Run E2E Tests ```shell npm run test-e2e @@ -157,6 +158,35 @@ export USE_HEADLESS_PUPPETEER=true npm run test-e2e ``` +#### Stop Test Servers + +After testing, stop the servers: + +```shell +pm2 stop all && pm2 delete all +``` + +#### Setup Script Options + +The `setup-e2e.sh` script supports multiple options: + +```shell +# Show help +bash script/setup-e2e.sh --help + +# Initialize only (install dependencies) +bash script/setup-e2e.sh --init + +# Build e2e output only +bash script/setup-e2e.sh --build + +# Initialize and build +bash script/setup-e2e.sh --init --build + +# Run all steps (init + build + build production) +bash script/setup-e2e.sh --all +``` + ## Code Quality ### Code Formatting @@ -234,7 +264,8 @@ time-tracker-4-browser/ ├── types/ # TypeScript declarations ├── rspack/ # Build configuration ├── script/ # Build and utility scripts -│ └── android-firefox.sh # Android development helper +│ ├── android-firefox.sh # Android development helper +│ └── setup-e2e.sh # E2E test environment setup ├── public/ # Static assets ├── doc/ # Documentation ├── dist_dev/ # Chrome/Edge dev build diff --git a/jest.config.ts b/jest.config.ts index 4b4b1b320..d6882b7dd 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,5 +1,12 @@ -import { type Config } from "@jest/types" -import { compilerOptions } from './tsconfig.json' +import { readFileSync } from 'fs' +import type { Config } from "jest" +import { dirname, join } from 'path' +import { fileURLToPath } from 'url' + +const __filename = fileURLToPath(import.meta.url) +const __dirname = dirname(__filename) +const tsconfig = JSON.parse(readFileSync(join(__dirname, 'tsconfig.json'), 'utf-8')) +const { compilerOptions } = tsconfig const { paths } = compilerOptions @@ -13,7 +20,7 @@ Object.entries(paths).forEach(([alias, sourceArr]) => { if (!aliasMatch) { return } - if (sourceArr.length !== 1) { + if (!Array.isArray(sourceArr) || sourceArr.length !== 1) { return } const sourceMath = sourceArr[0]?.match(sourcePattern) @@ -30,7 +37,7 @@ Object.entries(paths).forEach(([alias, sourceArr]) => { console.log("The moduleNameMapper parsed from tsconfig.json: ") console.log(moduleNameMapper) -const config: Config.InitialOptions = { +const config: Config = { moduleNameMapper, roots: [ "/test", diff --git a/package.json b/package.json index 34a663ab3..9135a901a 100644 --- a/package.json +++ b/package.json @@ -38,7 +38,6 @@ "@swc/jest": "^0.2.39", "@types/chrome": "0.1.29", "@types/decompress": "^4.2.7", - "@types/jest": "^30.0.0", "@types/node": "^24.10.1", "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", diff --git a/script/setup-e2e.sh b/script/setup-e2e.sh new file mode 100755 index 000000000..56e6f2e1e --- /dev/null +++ b/script/setup-e2e.sh @@ -0,0 +1,385 @@ +#!/bin/bash + +# Exit immediately on error +set -e + +PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +# Color definitions +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +# No Color +NC='\033[0m' + +log_info() { + echo -e "${BLUE}[ INFO]${NC} $1" +} + +log_success() { + echo -e "${GREEN}[ SUCC]${NC} $1" +} + +log_warning() { + echo -e "${YELLOW}[ WARN]${NC} $1" +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" +} + +log_step() { + echo -e "${CYAN}[ STEP]${NC} $1" +} + +# Detect OS +detect_os() { + case "$(uname -s)" in + Linux*) + echo "linux" + ;; + Darwin*) + echo "macos" + ;; + MINGW*|MSYS*|CYGWIN*) + echo "windows" + ;; + *) + echo "unknown" + ;; + esac +} + +# Check if command exists +check_command() { + if ! command -v "$1" &> /dev/null; then + return 1 + fi + return 0 +} + +# Check Node.js version +check_node_version() { + if ! check_command node; then + log_error "Node.js is not installed" + log_info "Please install Node.js >= 20 from https://nodejs.org/" + exit 1 + fi + + local node_version + node_version=$(node -v | sed 's/v//') + local major_version + major_version=$(echo "$node_version" | cut -d. -f1) + + log_info "Current Node.js version: $node_version" + + if [ "$major_version" -lt 20 ]; then + log_error "Node.js version must be >= 20" + log_info "Current version: $node_version" + log_info "Please upgrade Node.js from https://nodejs.org/" + exit 1 + fi + + log_success "Node.js version check passed" +} + +# Check npm +check_npm() { + if ! check_command npm; then + log_error "npm is not installed" + log_info "npm should come with Node.js, please reinstall Node.js" + exit 1 + fi + + local npm_version + npm_version=$(npm -v) + log_info "Current npm version: $npm_version" + log_success "npm is available" +} + +# Install global npm package (only if not installed) +install_global_package() { + local package_name=$1 + if command -v "$package_name" &> /dev/null; then + log_info "$package_name is already installed" + return 0 + fi + + log_info "$package_name is not installed, installing..." + if npm install -g "${package_name}@latest" &> /dev/null; then + log_success "$package_name installed successfully" + else + log_error "Failed to install $package_name" + exit 1 + fi +} + +# Upgrade global npm package +upgrade_global_package() { + local package_name=$1 + if ! command -v "$package_name" &> /dev/null; then + log_warning "$package_name is not installed, skipping upgrade" + return 0 + fi + + log_info "$package_name is already installed, upgrading..." + if npm install -g "${package_name}@latest" &> /dev/null; then + log_success "$package_name upgraded successfully" + else + log_error "Failed to upgrade $package_name" + exit 1 + fi +} + +# Install e2e dependencies +install_e2e_dependencies() { + log_step "Installing e2e test dependencies..." + + if [ ! -d "$PROJECT_ROOT/node_modules/puppeteer" ]; then + log_error "puppeteer is not installed. Please run 'npm install' first." + exit 1 + fi + + local browser_path + browser_path=$(node -e "try { const p = require('puppeteer'); console.log(p.executablePath()); } catch(e) { process.exit(1); }" 2>/dev/null) + if [ -n "$browser_path" ] && [ -f "$browser_path" ]; then + log_info "Browser is already downloaded" + elif [ -f "$PROJECT_ROOT/node_modules/puppeteer/install.mjs" ]; then + log_info "Downloading browser for puppeteer..." + if node "$PROJECT_ROOT/node_modules/puppeteer/install.mjs" &> /dev/null; then + log_success "Browser downloaded successfully" + else + log_error "Failed to download browser for puppeteer" + exit 1 + fi + else + log_warning "puppeteer install script not found, browser may already be downloaded" + fi + + install_global_package "http-server" + install_global_package "pm2" + + log_success "E2E dependencies installed successfully" +} + +# Upgrade e2e dependencies +upgrade_e2e_dependencies() { + log_step "Upgrading e2e test dependencies..." + + upgrade_global_package "http-server" + upgrade_global_package "pm2" + + log_success "E2E dependencies upgraded successfully" +} + +# Build npm script output +build_npm_script() { + local script_name=$1 + local output_dir=$2 + local is_required=${3:-true} + + log_step "Building $script_name output..." + + cd "$PROJECT_ROOT" || exit 1 + + log_info "Running: npm run $script_name" + local build_output + local build_exit_code + set +e + build_output=$(npm run "$script_name" 2>&1) + build_exit_code=$? + set -e + + if [ $build_exit_code -eq 0 ]; then + if [ -d "$PROJECT_ROOT/$output_dir" ]; then + log_success "$script_name output built successfully in $output_dir/" + else + if [ "$is_required" = true ]; then + log_error "$script_name output directory $output_dir/ not found after build" + exit 1 + else + log_warning "$script_name output directory $output_dir/ not found after build" + fi + fi + else + log_error "Failed to build $script_name output" + echo "$build_output" >&2 + exit 1 + fi +} + +# Build e2e output +build_e2e_output() { + build_npm_script "dev:e2e" "dist_e2e" true +} + +# Build production output (optional) +build_production_output() { + build_npm_script "build" "dist_prod" false +} + +# Start test servers +start_test_servers() { + log_step "Starting test servers..." + + if ! command -v pm2 &> /dev/null || ! command -v http-server &> /dev/null; then + log_error "pm2 or http-server is not installed. Please run setup first." + exit 1 + fi + + cd "$PROJECT_ROOT" || exit 1 + + # Check if servers are already running + if pm2 list 2>/dev/null | grep -q "http-server.*12345" || pm2 list 2>/dev/null | grep -q "http-server.*12346"; then + log_warning "Test servers might already be running" + log_info "Stopping existing servers..." + pm2 stop all 2>/dev/null || true + pm2 delete all 2>/dev/null || true + fi + + log_info "Starting test server on port 12345..." + pm2 start "http-server ./test-e2e/example -p 12345" --name "e2e-server-1" + + log_info "Starting test server on port 12346..." + pm2 start "http-server ./test-e2e/example -p 12346" --name "e2e-server-2" + + log_success "Test servers started" + log_info "Server 1: http://127.0.0.1:12345" + log_info "Server 2: http://127.0.0.1:12346" + log_info "To stop servers, run: pm2 stop all && pm2 delete all" + log_info "To view server logs, run: pm2 logs" +} + +# Show usage +show_usage() { + echo "Usage: $0 [OPTIONS]" + echo "" + echo "E2E Test Environment Setup Script" + echo "" + echo "This script initializes the e2e testing environment and can build e2e outputs." + echo "It installs/upgrades required dependencies and compiles the code for e2e testing." + echo "" + echo "Options:" + echo " --init, -i Initialize e2e environment (install dependencies if not installed)" + echo " --upgrade, -u Upgrade e2e dependencies (http-server, pm2)" + echo " --build, -b Build e2e output (runs 'npm run dev:e2e')" + echo " --build-prod Also build production output (runs 'npm run build')" + echo " --start-servers, -s Start test servers (http-server on ports 12345 and 12346)" + echo " --all, -a Run all steps (init + build + build-prod)" + echo " --help, -h Show this help message" + echo "" + echo "Examples:" + echo " $0 --init # Install e2e dependencies (if not installed)" + echo " $0 --upgrade # Upgrade e2e dependencies" + echo " $0 --build # Build e2e output only" + echo " $0 --init --build # Initialize and build" + echo " $0 --all # Initialize, build e2e and production outputs" + echo " $0 --build --start-servers # Build and start test servers" +} + +# Main function +main() { + local do_init=false + local do_upgrade=false + local do_build=false + local do_build_prod=false + local do_start_servers=false + + # Parse arguments + while [[ $# -gt 0 ]]; do + case $1 in + --init|-i) + do_init=true + shift + ;; + --upgrade|-u) + do_upgrade=true + shift + ;; + --build|-b) + do_build=true + shift + ;; + --build-prod) + do_build_prod=true + shift + ;; + --start-servers|-s) + do_start_servers=true + shift + ;; + --all|-a) + do_init=true + do_build=true + do_build_prod=true + shift + ;; + --help|-h) + show_usage + exit 0 + ;; + *) + log_error "Unknown option: $1" + show_usage + exit 1 + ;; + esac + done + + if [ "$do_init" = false ] && [ "$do_upgrade" = false ] && [ "$do_build" = false ] && [ "$do_build_prod" = false ] && [ "$do_start_servers" = false ]; then + show_usage + exit 0 + fi + + if [ ! -f "package.json" ]; then + log_error "Please run this script from the project root." + exit 1 + fi + + echo -e "${CYAN}=== E2E Test Environment Setup ===${NC}" + echo "" + + # Detect OS and show info + local os_type + os_type=$(detect_os) + log_info "Detected OS: $os_type" + + if [ "$os_type" = "windows" ]; then + log_warning "Running on Windows. Make sure you're using Git Bash or WSL." + fi + + # Check prerequisites + check_node_version + check_npm + + # Run requested operations + if [ "$do_init" = true ]; then + install_e2e_dependencies + fi + + if [ "$do_upgrade" = true ]; then + upgrade_e2e_dependencies + fi + + if [ "$do_build" = true ]; then + build_e2e_output + fi + + if [ "$do_build_prod" = true ]; then + build_production_output + fi + + if [ "$do_start_servers" = true ]; then + start_test_servers + fi + + log_success "All operations completed successfully!" + log_info "Run e2e tests:" + log_info " 1. npm run test-e2e" + log_info " 2. USE_HEADLESS_PUPPETEER=true npm run test-e2e" +} + +# Run main function +main "$@" \ No newline at end of file From cc3ca483cf8d105e491a9762400a8bdaa64e83f0 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Mon, 17 Nov 2025 15:56:17 +0800 Subject: [PATCH 012/174] ci: nodejs v20 => v22 --- .github/workflows/crowdin-sync.yml | 2 +- .github/workflows/publish-chrome.yml | 2 +- .github/workflows/publish-edge.yml | 2 +- .github/workflows/tests.yml | 2 +- jest.config.ts | 9 +++------ package.json | 5 +++-- script/setup-e2e.sh | 6 +++--- 7 files changed, 13 insertions(+), 15 deletions(-) diff --git a/.github/workflows/crowdin-sync.yml b/.github/workflows/crowdin-sync.yml index 805105ffb..ff42e5b46 100644 --- a/.github/workflows/crowdin-sync.yml +++ b/.github/workflows/crowdin-sync.yml @@ -10,7 +10,7 @@ jobs: - name: Test using Node.js uses: actions/setup-node@v1 with: - node-version: "v20.11.0" + node-version: "v22" - name: Install ts-node run: npm i -g ts-node - name: Install dependencies diff --git a/.github/workflows/publish-chrome.yml b/.github/workflows/publish-chrome.yml index a3b8b12ab..68e3e7bb6 100644 --- a/.github/workflows/publish-chrome.yml +++ b/.github/workflows/publish-chrome.yml @@ -10,7 +10,7 @@ jobs: - name: Setup NodeJS uses: actions/setup-node@v4 with: - node-version: "v20.11.0" + node-version: "v22" - name: Install dependencies run: npm install - name: Build diff --git a/.github/workflows/publish-edge.yml b/.github/workflows/publish-edge.yml index 240e52d8e..1fde28733 100644 --- a/.github/workflows/publish-edge.yml +++ b/.github/workflows/publish-edge.yml @@ -10,7 +10,7 @@ jobs: - name: Setup NodeJS uses: actions/setup-node@v4 with: - node-version: "v20.11.0" + node-version: "v22" - name: Install dependencies run: npm install - name: Build diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1cf191d92..4fb4dfe1c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -8,7 +8,7 @@ jobs: - name: Test using Node.js uses: actions/setup-node@v1 with: - node-version: "v20.11.0" + node-version: "v22" - run: npm install - run: npm run test-c - name: Tests ✅ diff --git a/jest.config.ts b/jest.config.ts index d6882b7dd..60007387e 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -1,12 +1,9 @@ import { readFileSync } from 'fs' import type { Config } from "jest" -import { dirname, join } from 'path' -import { fileURLToPath } from 'url' +import { join } from 'path' -const __filename = fileURLToPath(import.meta.url) -const __dirname = dirname(__filename) -const tsconfig = JSON.parse(readFileSync(join(__dirname, 'tsconfig.json'), 'utf-8')) -const { compilerOptions } = tsconfig +const tsconfig = JSON.parse(readFileSync(join(process.cwd(), 'tsconfig.json'), 'utf-8')) +const { compilerOptions } = tsconfig as { compilerOptions: { paths: { [key: string]: string[] } } } const { paths } = compilerOptions diff --git a/package.json b/package.json index 9135a901a..2e0a090b7 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "@swc/jest": "^0.2.39", "@types/chrome": "0.1.29", "@types/decompress": "^4.2.7", + "@types/jest": "^30.0.0", "@types/node": "^24.10.1", "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", @@ -68,6 +69,6 @@ "vue-router": "^4.6.3" }, "engines": { - "node": ">=20" + "node": ">=22" } -} \ No newline at end of file +} diff --git a/script/setup-e2e.sh b/script/setup-e2e.sh index 56e6f2e1e..9d86928b8 100755 --- a/script/setup-e2e.sh +++ b/script/setup-e2e.sh @@ -64,7 +64,7 @@ check_command() { check_node_version() { if ! check_command node; then log_error "Node.js is not installed" - log_info "Please install Node.js >= 20 from https://nodejs.org/" + log_info "Please install Node.js >= 22 from https://nodejs.org/" exit 1 fi @@ -75,8 +75,8 @@ check_node_version() { log_info "Current Node.js version: $node_version" - if [ "$major_version" -lt 20 ]; then - log_error "Node.js version must be >= 20" + if [ "$major_version" -lt 22 ]; then + log_error "Node.js version must be >= 22" log_info "Current version: $node_version" log_info "Please upgrade Node.js from https://nodejs.org/" exit 1 From 41021ac0c2b532fe2d40fea2c7cf902fe27fc462 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 18 Nov 2025 00:06:48 +0800 Subject: [PATCH 013/174] style: optimize ui --- .../components/Limit/LimitModify/Sop/Step1.tsx | 7 +++---- .../Limit/LimitModify/Sop/Step2/SiteInput.tsx | 1 - src/pages/popup/components/Footer/index.tsx | 18 ++++++++---------- src/pages/popup/components/Percentage/chart.ts | 17 +++++++++-------- 4 files changed, 20 insertions(+), 23 deletions(-) diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step1.tsx b/src/pages/app/components/Limit/LimitModify/Sop/Step1.tsx index d4dd76e86..c27a9713e 100644 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step1.tsx +++ b/src/pages/app/components/Limit/LimitModify/Sop/Step1.tsx @@ -1,5 +1,5 @@ import { t } from "@app/locale" -import { ElCol, ElForm, ElFormItem, ElInput, ElOption, ElRow, ElSelect, ElSwitch } from "element-plus" +import { ElCol, ElForm, ElFormItem, ElInput, ElRow, ElSelect, ElSwitch } from "element-plus" import { defineComponent } from "vue" import { useSopData } from "./context" @@ -31,9 +31,8 @@ const _default = defineComponent(() => { modelValue={data.weekdays} onChange={v => data.weekdays = v} placeholder="" - > - {t(msg => msg.calendar.weekDays).split('|').map((weekDay, idx) => )} - + options={t(msg => msg.calendar.weekDays).split('|').map((label, idx) => ({ value: idx, label }))} + /> diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step2/SiteInput.tsx b/src/pages/app/components/Limit/LimitModify/Sop/Step2/SiteInput.tsx index 1fccf30e8..4d18d4690 100644 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step2/SiteInput.tsx +++ b/src/pages/app/components/Limit/LimitModify/Sop/Step2/SiteInput.tsx @@ -30,7 +30,6 @@ const fetchAllHosts = async () => { const useUrlSelect = () => { const { data: allHosts } = useRequest(fetchAllHosts) const [input, onFilter] = useDebounceState('', 50) - const selectEl = ref() const inputUrl = computed(() => { const inputVal = input.value return inputVal ? cleanCond(inputVal) : undefined diff --git a/src/pages/popup/components/Footer/index.tsx b/src/pages/popup/components/Footer/index.tsx index ec073cbe3..ae137e65f 100644 --- a/src/pages/popup/components/Footer/index.tsx +++ b/src/pages/popup/components/Footer/index.tsx @@ -4,7 +4,7 @@ import DurationSelect from "@popup/components/Footer/DurationSelect" import { useQuery } from "@popup/context" import { t } from "@popup/locale" import { ALL_DIMENSIONS } from "@util/stat" -import { ElOption, ElSelect, ElText } from "element-plus" +import { ElSelect, ElText } from "element-plus" import { defineComponent } from "vue" import Menu from "./Menu" @@ -28,12 +28,11 @@ const Footer = defineComponent(() => { placeholder={t(msg => msg.shared.merge.mergeMethod.notMerge)} popperOptions={{ placement: 'top' }} style={{ width: '90px' }} - > - msg.shared.merge.mergeMethod.notMerge)} /> - {mergeItems.value.map(method => ( - msg.shared.merge.mergeMethod[method])} /> - ))} - + options={[ + { value: '', label: t(msg => msg.shared.merge.mergeMethod.notMerge) }, + ...mergeItems.value.map(value => ({ value, label: t(msg => msg.shared.merge.mergeMethod[value]) })), + ]} + /> { onChange={v => query.dimension = v} popperOptions={{ placement: 'top' }} style={{ width: '120px' }} - > - {ALL_DIMENSIONS.map(item => msg.item[item])} />)} - + options={ALL_DIMENSIONS.map(value => ({ value, label: t(msg => msg.item[value]) }))} + /> ) diff --git a/src/pages/popup/components/Percentage/chart.ts b/src/pages/popup/components/Percentage/chart.ts index 3a9c03449..f2dd4400a 100644 --- a/src/pages/popup/components/Percentage/chart.ts +++ b/src/pages/popup/components/Percentage/chart.ts @@ -76,6 +76,9 @@ function calculateSubTitleText(result: PercentageResult): string { let { date, dataDate, rows, query: { dimension, duration } = {}, dateLength } = result const dateStr = dataDate ? formatDateStr(date, dataDate) : '' const totalStr = formatTotalStr(rows, dimension) + let firstLineParts = [totalStr, dateStr].filter(s => !!s) + isRtl() && (firstLineParts = firstLineParts.reverse()) + const firstLine = firstLineParts.join(' ') // Calculate average per day let averageStr = '' @@ -88,29 +91,27 @@ function calculateSubTitleText(result: PercentageResult): string { const total = sum(rows.map(r => r?.focus ?? 0)) const averagePerDay = total / dateLength const averageTime = formatPeriodCommon(averagePerDay) - averageStr = '(' + t(msg => msg.content.percentage.averageTime, { value: averageTime }) + ')' + averageStr = t(msg => msg.content.percentage.averageTime, { value: averageTime }) } else if (dimension === 'time') { // Average visits per day const totalCount = sum(rows.map(r => r.time ?? 0)) const averagePerDay = totalCount / dateLength const averageCount = averagePerDay.toFixed(1) - averageStr = '(' + t(msg => msg.content.percentage.averageCount, { value: averageCount }) + ')' + averageStr = t(msg => msg.content.percentage.averageCount, { value: averageCount }) } } - let parts = [totalStr, dateStr, averageStr].filter(str => !!str) - isRtl() && (parts = parts.reverse()) - return parts.join(' ') + return [firstLine, averageStr].filter(s => !!s).join('\n') } export function generateTitleOption(result: PercentageResult, suffix?: string): TitleComponentOption { return { text: [result?.chartTitle, suffix].filter(v => !!v).join(' - '), subtext: calculateSubTitleText(result), - left: 'center', textStyle: { color: getPrimaryTextColor() }, - subtextStyle: { color: getSecondaryTextColor() }, - top: 15, + subtextStyle: { color: getSecondaryTextColor(), lineHeight: 15, fontSize: 12 }, + left: 'center', + top: 14, } } From fc66413ae7fbea27956759b9411b4dbb66040382 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 18 Nov 2025 09:04:46 +0800 Subject: [PATCH 014/174] i18n(download): download translations by bot (#625) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/limit-resource.json | 1 + src/i18n/message/app/option-resource.json | 1 + 2 files changed, 2 insertions(+) diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index a4538f435..4b5691312 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -561,6 +561,7 @@ "tr": { "filterDisabled": "Yalnızca etkinleştirilmiş", "wildcardTip": "Alt alan adlarını veya alt sayfaları eşleştirmek için joker karakterler kullanabilir ve alt sayfaları hariç tutmak için “+” ön ekini kullanabilirsiniz!", + "emptyTips": "Bir kural oluşturmak için burayı tıklayın!", "item": { "name": "Kural adı", "condition": "Kısıtlanmış URL", diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 3130ed203..281417660 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -1348,6 +1348,7 @@ "tracking": { "title": "İzleme", "autoPauseTrack": "{input} {maxTime} süresince herhangi bir etkinlik algılanmazsa izlemeyi duraklat {info}", + "noActivityInfo": "Fare ve klavye etkin değil, tam ekran modunda değil ve ses çalınmıyor", "countLocalFiles": "{input} Tarayıcının {localFileTime} {info} dosyasını okuduğu zamanı izlemek isteyip istemediğinizi seçin", "localFileTime": "yerel dosyalar", "localFilesInfo": "PDF, resim, txt ve json gibi dosya türlerini destekler.", From c15be934e011c8441b24604b64a591a9ccf9d03e Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 18 Nov 2025 20:03:18 +0800 Subject: [PATCH 015/174] refactor: extract log --- script/android-firefox.sh | 37 +++++++------------------------------ script/lib/log.sh | 31 +++++++++++++++++++++++++++++++ script/setup-e2e.sh | 31 +++---------------------------- 3 files changed, 41 insertions(+), 58 deletions(-) create mode 100755 script/lib/log.sh diff --git a/script/android-firefox.sh b/script/android-firefox.sh index 47911382e..d5446ed69 100755 --- a/script/android-firefox.sh +++ b/script/android-firefox.sh @@ -21,29 +21,10 @@ cleanup() { # Set trap to cleanup on exit trap cleanup EXIT -# Color definitions -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -# No Color -NC='\033[0m' - -log_info() { - echo -e "${BLUE}[ INFO]${NC} $1" >&4 -} - -log_success() { - echo -e "${GREEN}[ SUCC]${NC} $1" >&4 -} - -log_warning() { - echo -e "${YELLOW}[ WARN]${NC} $1" >&4 -} - -log_error() { - echo -e "${RED}[ERROR]${NC} $1" >&4 -} +# Source logging functions +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +LOG_FD=4 +source "${SCRIPT_DIR}/lib/log.sh" # Check if command exists check_command() { @@ -163,23 +144,19 @@ check_firefox_nightly() { build_and_run_extension() { local device_id="$1" local extension_dir="dist_dev_firefox" - # Clear existing extension directory if [ -d "$extension_dir" ]; then log_info "Clearing existing extension directory..." rm -rf "$extension_dir" fi - # Start npm run dev:firefox in background log_info "Starting npm run dev:firefox in background..." npm run dev:firefox > /dev/null 2>&1 & NPM_PID=$! - # Wait for manifest.json to be created local max_wait=60 local wait_count=0 log_info "Waiting for building finished..." - while [ ! -f "$extension_dir/manifest.json" ] && [ $wait_count -lt $max_wait ]; do sleep 1 wait_count=$((wait_count + 1)) @@ -187,19 +164,19 @@ build_and_run_extension() { log_info "Still waiting for building finished... ($wait_count/$max_wait)" fi done - + if [ ! -f "$extension_dir/manifest.json" ]; then log_error "Building not finished yet after ${max_wait}s" cleanup exit 1 fi - + log_success "Extension built successfully, manifest.json found" log_info "Background build process PID: $NPM_PID" log_info "Starting extension development server..." log_info "Command: web-ext run -t firefox-android --firefox-apk org.mozilla.fenix -s $extension_dir --adb-device $device_id --verbose" - + if web-ext run \ -t firefox-android \ --firefox-apk org.mozilla.fenix \ diff --git a/script/lib/log.sh b/script/lib/log.sh new file mode 100755 index 000000000..c5861f732 --- /dev/null +++ b/script/lib/log.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# Color definitions +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +# No Color +NC='\033[0m' + +log_info() { + echo -e "${BLUE}[ INFO]${NC} $1" >&${LOG_FD:-1} +} + +log_success() { + echo -e "${GREEN}[ SUCC]${NC} $1" >&${LOG_FD:-1} +} + +log_warning() { + echo -e "${YELLOW}[ WARN]${NC} $1" >&${LOG_FD:-1} +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" >&${LOG_FD:-1} +} + +log_step() { + echo -e "${CYAN}[ STEP]${NC} $1" >&${LOG_FD:-1} +} + diff --git a/script/setup-e2e.sh b/script/setup-e2e.sh index 9d86928b8..af80d5a6f 100755 --- a/script/setup-e2e.sh +++ b/script/setup-e2e.sh @@ -5,34 +5,9 @@ set -e PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" -# Color definitions -RED='\033[0;31m' -GREEN='\033[0;32m' -YELLOW='\033[1;33m' -BLUE='\033[0;34m' -CYAN='\033[0;36m' -# No Color -NC='\033[0m' - -log_info() { - echo -e "${BLUE}[ INFO]${NC} $1" -} - -log_success() { - echo -e "${GREEN}[ SUCC]${NC} $1" -} - -log_warning() { - echo -e "${YELLOW}[ WARN]${NC} $1" -} - -log_error() { - echo -e "${RED}[ERROR]${NC} $1" -} - -log_step() { - echo -e "${CYAN}[ STEP]${NC} $1" -} +# Source logging functions +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCRIPT_DIR}/lib/log.sh" # Detect OS detect_os() { From 18596444928592c9b02da3190699573b712a1a76 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 19 Nov 2025 01:42:45 +0800 Subject: [PATCH 016/174] build(deps-dev): bump @types/chrome from 0.1.29 to 0.1.30 (#626) Bumps [@types/chrome](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/chrome) from 0.1.29 to 0.1.30. - [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases) - [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/chrome) --- updated-dependencies: - dependency-name: "@types/chrome" dependency-version: 0.1.30 dependency-type: direct:development update-type: version-update:semver-patch ... Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index 2e0a090b7..369f7ed9e 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@rspack/core": "^1.6.3", "@swc/core": "^1.15.2", "@swc/jest": "^0.2.39", - "@types/chrome": "0.1.29", + "@types/chrome": "0.1.30", "@types/decompress": "^4.2.7", "@types/jest": "^30.0.0", "@types/node": "^24.10.1", From dc11d6d62d4ba3b41d5a4c3da5cddc9a4b74ee65 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Thu, 20 Nov 2025 11:43:24 +0800 Subject: [PATCH 017/174] fix: mask layer css not working (#621, #574) --- src/content-script/limit/modal/style/modal.css | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content-script/limit/modal/style/modal.css b/src/content-script/limit/modal/style/modal.css index b02d0880c..c4669803d 100644 --- a/src/content-script/limit/modal/style/modal.css +++ b/src/content-script/limit/modal/style/modal.css @@ -13,7 +13,7 @@ body { width: 100vw; padding: 0; margin: 0; - position: absolute; + position: fixed; top: 0; left: 0; z-index: 999999; From 5fe0736c16d6889255be33cae2977be6153d8a2e Mon Sep 17 00:00:00 2001 From: sheepzh Date: Thu, 20 Nov 2025 11:43:57 +0800 Subject: [PATCH 018/174] feat: use Enter to select the first url item --- .../Limit/LimitModify/Sop/Step2/SiteInput.tsx | 68 +++++++++++++------ 1 file changed, 47 insertions(+), 21 deletions(-) diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step2/SiteInput.tsx b/src/pages/app/components/Limit/LimitModify/Sop/Step2/SiteInput.tsx index 4d18d4690..18f05704f 100644 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step2/SiteInput.tsx +++ b/src/pages/app/components/Limit/LimitModify/Sop/Step2/SiteInput.tsx @@ -5,8 +5,8 @@ import Flex from '@pages/components/Flex' import { selectAllSites } from '@service/site-service' import { cleanCond } from '@util/limit' import { extractHostname, isBrowserUrl } from '@util/pattern' -import { ElMessage, ElSelectV2, ElText, type SelectV2Instance } from 'element-plus' -import { computed, defineComponent, ref, type StyleValue } from 'vue' +import { ElMessage, ElSelectV2, ElText, useNamespace, type SelectV2Instance } from 'element-plus' +import { computed, defineComponent, onMounted, ref, type StyleValue } from 'vue' type Props = { onAdd: (url: string) => string | undefined @@ -27,54 +27,80 @@ const fetchAllHosts = async () => { return Array.from(hostSet) } -const useUrlSelect = () => { +const useUrlSelect = ({ onAdd }: Props) => { const { data: allHosts } = useRequest(fetchAllHosts) const [input, onFilter] = useDebounceState('', 50) const inputUrl = computed(() => { - const inputVal = input.value - return inputVal ? cleanCond(inputVal) : undefined + const clean = cleanCond(input.value) + if (!clean) return {} + const slashIdx = clean.indexOf('/') + return slashIdx === -1 ? { full: clean } : { full: clean, domain: clean.substring(0, slashIdx) } }) const options = computed(() => { - const urlVal = inputUrl.value + const { full, domain } = inputUrl.value const result: string[] = [] - if (urlVal) { - result.push(urlVal) - allHosts.value?.forEach(host => host.includes(urlVal) && host !== urlVal && result.push(host)) + if (full) { + result.push(full) + domain && result.push(domain) + allHosts.value?.forEach(host => host.includes(full) && host !== full && host !== domain && result.push(host)) } else { allHosts.value?.forEach(h => result.push(h)) } return result.map(value => ({ value, label: value })) }) - return { options, onFilter } -} - -const SiteInput = defineComponent(props => { - const { options, onFilter } = useUrlSelect() - const selectEl = ref() + const selectInst = ref() const warnAndFocus = (msg: string) => { ElMessage.warning(msg) - selectEl.value?.focus() + selectInst.value?.focus() } - const handleAdd = (url: string) => { - const errMsg = props.onAdd(url) + const onSelected = (url: string) => { + const errMsg = onAdd(url) if (errMsg) { return warnAndFocus(errMsg) } else { - selectEl.value?.handleClear() + selectInst.value?.handleClear() } } + const selectNs = useNamespace('select') + + onMounted(() => { + const el = (selectInst.value?.$el as HTMLDivElement) + if (!el) return + const input = el.querySelector('input') + input?.addEventListener('keyup', ev => { + if (ev.code !== 'Enter') return + console.log(`.${selectNs.be('dropdown', 'item')}.is-hovering`) + const hovered = el.querySelector(`.${selectNs.be('dropdown', 'item')}.is-hovering`) + const first = options.value[0]?.value + if (!hovered && first) { + onSelected(first) + ev.stopImmediatePropagation() + } + }) + }) + + return { + options, onFilter, onSelected, + selectInst, + } +} + +const SiteInput = defineComponent(props => { + const { options, onFilter, onSelected, selectInst } = useUrlSelect(props) + return () => ( From 285c873bbab60ddcf7e3c61f26b218a4f1435ff5 Mon Sep 17 00:00:00 2001 From: sheepie Date: Thu, 20 Nov 2025 12:03:25 +0800 Subject: [PATCH 019/174] fix: button spacing in mask layer Add gap to message-box buttons in dark theme. --- src/content-script/limit/modal/style/modal.css | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/src/content-script/limit/modal/style/modal.css b/src/content-script/limit/modal/style/modal.css index c4669803d..e86ed0a36 100644 --- a/src/content-script/limit/modal/style/modal.css +++ b/src/content-script/limit/modal/style/modal.css @@ -23,4 +23,9 @@ body { html[data-theme=dark] body { background-color: var(--el-fill-color-dark); -} \ No newline at end of file +} + +/* Fix message-box buttons */ +.el-message-box__btns { + gap: 12px; +} From 728cd2221eb78fb133f26dd446c84b2b3c703823 Mon Sep 17 00:00:00 2001 From: sheepie Date: Sun, 23 Nov 2025 03:20:06 +0800 Subject: [PATCH 020/174] feat: track local files in background script for FF (#628) --- package.json | 12 ++-- src/api/chrome/window.ts | 8 ++- src/background/track-server/file-tracker.ts | 58 ++++++++++++++++++ src/background/track-server/group.ts | 14 +++++ src/background/track-server/index.ts | 26 ++++++++ .../normal.ts} | 60 ++----------------- src/background/track-server/runtime.ts | 28 +++++++++ src/i18n/message/app/option-resource.json | 11 ---- src/i18n/message/app/option.ts | 1 - .../Option/components/TrackingOption.tsx | 14 +++-- src/util/constant/option.ts | 2 +- src/util/pattern.ts | 16 ++--- test-e2e/common/base.ts | 1 + 13 files changed, 163 insertions(+), 88 deletions(-) create mode 100644 src/background/track-server/file-tracker.ts create mode 100644 src/background/track-server/group.ts create mode 100644 src/background/track-server/index.ts rename src/background/{track-server.ts => track-server/normal.ts} (57%) create mode 100644 src/background/track-server/runtime.ts diff --git a/package.json b/package.json index 369f7ed9e..c5ab18a44 100644 --- a/package.json +++ b/package.json @@ -31,12 +31,12 @@ "@crowdin/crowdin-api-client": "^1.49.0", "@emotion/babel-plugin": "^11.13.5", "@emotion/css": "^11.13.5", - "@rsdoctor/rspack-plugin": "^1.3.9", - "@rspack/cli": "^1.6.3", - "@rspack/core": "^1.6.3", - "@swc/core": "^1.15.2", + "@rsdoctor/rspack-plugin": "^1.3.11", + "@rspack/cli": "^1.6.4", + "@rspack/core": "^1.6.4", + "@swc/core": "^1.15.3", "@swc/jest": "^0.2.39", - "@types/chrome": "0.1.30", + "@types/chrome": "0.1.31", "@types/decompress": "^4.2.7", "@types/jest": "^30.0.0", "@types/node": "^24.10.1", @@ -54,7 +54,7 @@ "postcss": "^8.5.6", "postcss-loader": "^8.2.0", "postcss-rtlcss": "^5.7.1", - "puppeteer": "^24.30.0", + "puppeteer": "^24.31.0", "ts-loader": "^9.5.4", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", diff --git a/src/api/chrome/window.ts b/src/api/chrome/window.ts index 52cc97516..8c29b079d 100644 --- a/src/api/chrome/window.ts +++ b/src/api/chrome/window.ts @@ -1,4 +1,4 @@ -import { IS_ANDROID } from "@util/constant/environment" +import { IS_ANDROID, IS_FIREFOX } from "@util/constant/environment" import { handleError } from "./common" export function listAllWindows(): Promise { @@ -43,7 +43,11 @@ class FocusedWindowCtx { // init this.last = await this.getInner() if (!this.listened) { - chrome.windows.onFocusChanged.addListener(wid => this.last = wid, { windowTypes: this.windowsTypes }) + // filter argument is not supported for Firefox + // @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/windows/onFocusChanged#addlistener_syntax + IS_FIREFOX + ? chrome.windows.onFocusChanged.addListener(wid => this.last = wid) + : chrome.windows.onFocusChanged.addListener(wid => this.last = wid, { windowTypes: this.windowsTypes }) this.listened = true } return this.last diff --git a/src/background/track-server/file-tracker.ts b/src/background/track-server/file-tracker.ts new file mode 100644 index 000000000..6efac5e59 --- /dev/null +++ b/src/background/track-server/file-tracker.ts @@ -0,0 +1,58 @@ +import { getTab, onTabActivated } from '@api/chrome/tab' +import optionHolder from '@service/components/option-holder' +import { extractFileHost } from '@util/pattern' +import { handleTrackTimeEvent } from './normal' + +type Context = { + host: string + tab: ChromeTab + // Start timestamp of this tick + start: number +} + +async function convertContext(tabId: number): Promise { + const tab = await getTab(tabId) + const { active, url } = tab + if (!active || !url) return null + const fileHost = extractFileHost(url) + if (!fileHost) return null + return { + host: fileHost, tab, + start: Date.now(), + } +} + +/** + * Local file tracker for firefox + */ +class FileTracker { + private enabled = false + private current: Context | null = null + + init() { + optionHolder.get().then(v => this.enabled = v.countLocalFiles) + optionHolder.addChangeListener(v => this.enabled = v.countLocalFiles) + + onTabActivated(async tabId => { + this.tick() + this.current = await convertContext(tabId) + }) + + // NOTE: if migrate to MV3, this line won't work expectedly + setInterval(() => this.tick(), 1000) + } + + private tick() { + if (!this.current) return + const { host, tab, start } = this.current + const end = Date.now() + this.enabled && handleTrackTimeEvent({ + host, start, end, + url: tab.url ?? '', + ignoreTabCheck: false, + }, tab) + this.current.start = end + } +} + +export default FileTracker \ No newline at end of file diff --git a/src/background/track-server/group.ts b/src/background/track-server/group.ts new file mode 100644 index 000000000..123d1f16c --- /dev/null +++ b/src/background/track-server/group.ts @@ -0,0 +1,14 @@ +import itemService from '@service/item-service' + +function handleTabGroupRemove(group: chrome.tabGroups.TabGroup) { + itemService.batchDeleteGroupById(group.id) +} + +export function handleTabGroupEnabled() { + try { + chrome.tabGroups.onRemoved.removeListener(handleTabGroupRemove) + chrome.tabGroups.onRemoved.addListener(handleTabGroupRemove) + } catch (e) { + console.warn('failed to handle event: enableTabGroup', e) + } +} \ No newline at end of file diff --git a/src/background/track-server/index.ts b/src/background/track-server/index.ts new file mode 100644 index 000000000..fd7c734f6 --- /dev/null +++ b/src/background/track-server/index.ts @@ -0,0 +1,26 @@ +import itemService from "@service/item-service" +import { IS_ANDROID, IS_FIREFOX } from '@util/constant/environment' +import { isFileUrl } from '@util/pattern' +import type MessageDispatcher from "../message-dispatcher" +import FileTracker from './file-tracker' +import { handleTabGroupEnabled } from './group' +import { handleIncVisitEvent, handleTrackTimeEvent } from './normal' +import { handleTrackRunTimeEvent } from './runtime' + +export default function initTrackServer(messageDispatcher: MessageDispatcher) { + messageDispatcher + .register('cs.trackTime', (ev, sender) => { + // not to process cs events from local files for FF + if (IS_FIREFOX && isFileUrl(ev.url)) return + + handleTrackTimeEvent(ev, sender.tab) + }) + .register('cs.trackRunTime', handleTrackRunTimeEvent) + .register<{ host: string, url: string }, void>('cs.incVisitCount', handleIncVisitEvent) + .register('cs.getTodayInfo', host => itemService.getResult(host, new Date())) + .register('enableTabGroup', handleTabGroupEnabled) + + // Track file time in background script for FF + // Not accurate, since can't detect if the tabs are active or not + IS_FIREFOX && !IS_ANDROID && new FileTracker().init() +} diff --git a/src/background/track-server.ts b/src/background/track-server/normal.ts similarity index 57% rename from src/background/track-server.ts rename to src/background/track-server/normal.ts index 52d76ca13..20441b791 100644 --- a/src/background/track-server.ts +++ b/src/background/track-server/normal.ts @@ -7,9 +7,7 @@ import periodThrottler from '@service/throttler/period-throttler' import whitelistHolder from "@service/whitelist/holder" import { IS_ANDROID } from "@util/constant/environment" import { extractHostname } from "@util/pattern" -import { formatTimeYMD, getStartOfDay, MILL_PER_DAY } from "@util/time" -import badgeManager from "./badge-manager" -import MessageDispatcher from "./message-dispatcher" +import badgeManager from "../badge-manager" async function handleTime(context: ItemIncContext, timeRange: [number, number], tabId: number | undefined): Promise { const { host, url } = context @@ -28,9 +26,9 @@ async function handleTime(context: ItemIncContext, timeRange: [number, number], return focusTime } -async function handleTrackTimeEvent(event: timer.core.Event, sender: ChromeMessageSender): Promise { +export async function handleTrackTimeEvent(event: timer.core.Event, senderTab: ChromeTab | undefined): Promise { const { url, start, end, ignoreTabCheck } = event - const { id: tabId, windowId, groupId } = sender?.tab || {} + const { id: tabId, windowId, groupId } = senderTab ?? {} if (!ignoreTabCheck) { if (await windowNotFocused(windowId)) return if (await tabNotActive(tabId)) return @@ -86,58 +84,12 @@ async function handleVisit(context: ItemIncContext) { metLimits?.length && sendLimitedMessage(metLimits) } -async function handleIncVisitEvent(param: { host: string, url: string }, sender: ChromeMessageSender): Promise { - const { host, url } = param || {} +export async function handleIncVisitEvent(param: { host: string, url: string }, sender: ChromeMessageSender): Promise { + const { host, url } = param const { groupId } = sender?.tab ?? {} - const { protocol } = extractHostname(url) || {} + const { protocol } = extractHostname(url) const option = await optionHolder.get() if (protocol === "file" && !option.countLocalFiles) return await handleVisit({ host, url, groupId }) } -function splitRunTime(start: number, end: number): Record { - const res: Record = {} - while (start < end) { - const startOfNextDay = getStartOfDay(start).getTime() + MILL_PER_DAY - const newStart = Math.min(end, startOfNextDay) - const runTime = newStart - start - runTime && (res[formatTimeYMD(start)] = runTime) - start = newStart - } - return res -} - -const RUN_TIME_END_CACHE: { [host: string]: number } = {} - -async function handleTrackRunTimeEvent(event: timer.core.Event): Promise { - const { start, end, url, host } = event || {} - if (!host || !start || !end) return - if (whitelistHolder.contains(host, url)) return - const realStart = Math.max(RUN_TIME_END_CACHE[host] ?? 0, start) - const byDate = splitRunTime(realStart, end) - if (!Object.keys(byDate).length) return - await itemService.addRunTime(host, byDate) - RUN_TIME_END_CACHE[host] = Math.max(end, realStart) -} - -function handleTabGroupRemove(group: chrome.tabGroups.TabGroup) { - itemService.batchDeleteGroupById(group.id) -} - -function handleTabGroupEnabled() { - try { - chrome.tabGroups.onRemoved.removeListener(handleTabGroupRemove) - chrome.tabGroups.onRemoved.addListener(handleTabGroupRemove) - } catch (e) { - console.warn('failed to handle event: enableTabGroup', e) - } -} - -export default function initTrackServer(messageDispatcher: MessageDispatcher) { - messageDispatcher - .register('cs.trackTime', handleTrackTimeEvent) - .register('cs.trackRunTime', handleTrackRunTimeEvent) - .register<{ host: string, url: string }, void>('cs.incVisitCount', handleIncVisitEvent) - .register('cs.getTodayInfo', host => itemService.getResult(host, new Date())) - .register('enableTabGroup', handleTabGroupEnabled) -} diff --git a/src/background/track-server/runtime.ts b/src/background/track-server/runtime.ts new file mode 100644 index 000000000..30df313c5 --- /dev/null +++ b/src/background/track-server/runtime.ts @@ -0,0 +1,28 @@ +import itemService from "@service/item-service" +import whitelistHolder from "@service/whitelist/holder" +import { formatTimeYMD, getStartOfDay, MILL_PER_DAY } from "@util/time" + +function splitRunTime(start: number, end: number): Record { + const res: Record = {} + while (start < end) { + const startOfNextDay = getStartOfDay(start).getTime() + MILL_PER_DAY + const newStart = Math.min(end, startOfNextDay) + const runTime = newStart - start + runTime && (res[formatTimeYMD(start)] = runTime) + start = newStart + } + return res +} + +const RUN_TIME_END_CACHE: { [host: string]: number } = {} + +export async function handleTrackRunTimeEvent(event: timer.core.Event): Promise { + const { start, end, url, host } = event || {} + if (!host || !start || !end) return + if (whitelistHolder.contains(host, url)) return + const realStart = Math.max(RUN_TIME_END_CACHE[host] ?? 0, start) + const byDate = splitRunTime(realStart, end) + if (!Object.keys(byDate).length) return + await itemService.addRunTime(host, byDate) + RUN_TIME_END_CACHE[host] = Math.max(end, realStart) +} \ No newline at end of file diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 281417660..dc46e5392 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -43,7 +43,6 @@ "tabGroupInfo": "删除标签组后,数据也会被删除", "tabGroupsPermGrant": "该功能需要授予相关权限", "fileAccessDisabled": "目前不允许访问文件网址,请先在管理界面开启", - "fileAccessFirefox": "很抱歉,该功能在 Firefox 中不支持", "weekStart": "每周的第一天 {input}", "weekStartAsNormal": "按照惯例" }, @@ -171,7 +170,6 @@ "tabGroupInfo": "刪除分頁群組時,相關的時間追蹤數據也會一併清除。", "tabGroupsPermGrant": "此功能需要相關權限才能運作", "fileAccessDisabled": "目前不允許存取檔案 URL,請至管理頁面啟用", - "fileAccessFirefox": "抱歉,Firefox 不支援此功能", "weekStart": "每週起始日 {input}", "weekStartAsNormal": "依慣例" }, @@ -300,7 +298,6 @@ "tabGroupInfo": "When you delete a tag group, the data will also be deleted.", "tabGroupsPermGrant": "This feature requires relevant permissions", "fileAccessDisabled": "Access to file URLs is currently not allowed. Please enable it on the manage page first", - "fileAccessFirefox": "Sorry, this feature is not supported in Firefox", "weekStart": "The first day for each week {input}", "weekStartAsNormal": "As Normal" }, @@ -426,7 +423,6 @@ "localFileTime": "ローカルファイルの読み取り", "localFilesInfo": "PDF、画像、txt、jsonを含む", "fileAccessDisabled": "ファイル URL へのアクセスは現在許可されていません。まず管理ページで有効にしてください。", - "fileAccessFirefox": "申し訳ありませんが、この機能はFirefoxではサポートされていません", "weekStart": "週の最初の日 {input}", "weekStartAsNormal": "いつものように" }, @@ -551,7 +547,6 @@ "localFileTime": "ler ficheiros locais", "localFilesInfo": "Suporta PDF, imagens, txt e json.", "fileAccessDisabled": "Acesso a URLs de ficheiro não permitido. Ative na página de gestão.", - "fileAccessFirefox": "Não suportado no Firefox", "weekStart": "Primeiro dia da semana {input}", "weekStartAsNormal": "Normal" }, @@ -672,7 +667,6 @@ "tabGroupInfo": "Якщо видалити групу вкладок, дані також видаляться.", "tabGroupsPermGrant": "Ця функція потребує відповідних дозволів", "fileAccessDisabled": "Доступ до URL-адрес файлу наразі не дозволено. Спершу ввімкніть на сторінці керування", - "fileAccessFirefox": "На жаль, ця функція не підтримується у Firefox", "weekStart": "Перший день тижня: {input}", "weekStartAsNormal": "Типово" }, @@ -800,7 +794,6 @@ "tabGroupInfo": "Al eliminar un grupo de pestañas, sus datos también se borrarán.", "tabGroupsPermGrant": "Esta función requiere permisos pertinentes", "fileAccessDisabled": "Actualmente no se permite el acceso a las URL de archivos. Habilítelo primero en la página de administración", - "fileAccessFirefox": "Lo sentimos, esta función no es compatible con Firefox", "weekStart": "El primer día de cada semana {input}", "weekStartAsNormal": "Como normalmente" }, @@ -928,7 +921,6 @@ "tabGroupInfo": "Wenn Sie eine Tag-Gruppe löschen, werden auch die Daten gelöscht.", "tabGroupsPermGrant": "Dieses Feature benötigt entsprechende Berechtigungen", "fileAccessDisabled": "Der Zugriff auf Datei-URLs ist derzeit nicht erlaubt. Bitte aktiviere ihn zuerst auf der Verwaltungsseite", - "fileAccessFirefox": "Leider wird diese Funktion in Firefox nicht unterstützt", "weekStart": "Erster Tag der Woche {input}", "weekStartAsNormal": "Wie normal" }, @@ -1057,7 +1049,6 @@ "tabGroupInfo": "Lorsque vous supprimez un groupe d'onglets, les données seront également supprimées.", "tabGroupsPermGrant": "Cette fonctionnalité nécessite des autorisations pertinentes", "fileAccessDisabled": "L'accès aux URL des fichiers n'est actuellement pas autorisé. Veuillez d'abord l'activer dans la page de gestion.", - "fileAccessFirefox": "Désolé, cette fonctionnalité n'est pas prise en charge dans Firefox", "weekStart": "Le premier jour de chaque semaine {input}", "weekStartAsNormal": "Comme d'habitude" }, @@ -1227,7 +1218,6 @@ "tabGroupInfo": "عند حذف مجموعة علامات تبويب، سيتم حذف البيانات أيضًا.", "tabGroupsPermGrant": "هذه الميزة تتطلب أذونات ذات صلة", "fileAccessDisabled": "الوصول إلى عناوين الملفات غير مسموح به حاليًا. يرجى تفعيل الخاصية من صفحة الإدارة أولاً", - "fileAccessFirefox": "عذراً، هذه الميزة غير مدعومة في فايرفوكس", "weekStart": "اليوم الأول لكل أسبوع {input}", "weekStartAsNormal": "بشكل عادي" }, @@ -1356,7 +1346,6 @@ "tabGroupInfo": "Bir etiket grubunu sildiğinizde, veriler de silinir.", "tabGroupsPermGrant": "Bu özellik ilgili izinleri gerektirir", "fileAccessDisabled": "Dosya URL'lerine erişim şu anda izin verilmiyor. Lütfen önce yönetim sayfasında bunu etkinleştirin", - "fileAccessFirefox": "Üzgünüz, bu özellik Firefox'ta desteklenmiyor", "weekStart": "Her haftanın ilk günü {input}", "weekStartAsNormal": "Normal" }, diff --git a/src/i18n/message/app/option.ts b/src/i18n/message/app/option.ts index a0fe0657e..85b56baf8 100644 --- a/src/i18n/message/app/option.ts +++ b/src/i18n/message/app/option.ts @@ -48,7 +48,6 @@ export type OptionMessage = { tabGroupInfo: string tabGroupsPermGrant: string fileAccessDisabled: string - fileAccessFirefox: string weekStart: string weekStartAsNormal: string } diff --git a/src/pages/app/components/Option/components/TrackingOption.tsx b/src/pages/app/components/Option/components/TrackingOption.tsx index 34062b4fb..c10c8a684 100644 --- a/src/pages/app/components/Option/components/TrackingOption.tsx +++ b/src/pages/app/components/Option/components/TrackingOption.tsx @@ -22,6 +22,8 @@ import OptionLines from './OptionLines' import OptionTag from './OptionTag' import OptionTooltip from './OptionTooltip' +const DEFAULT_VALUE = defaultTracking() + const weekStartOptionPairs: [[timer.option.WeekStartOption, string]] = [ ['default', t(msg => msg.option.tracking.weekStartAsNormal)] ] @@ -94,7 +96,7 @@ const _default = defineComponent((_props, ctx) => { {!IS_ANDROID && <> msg.option.tracking.autoPauseTrack} - defaultValue={t(msg => msg.option.no)} + defaultValue={DEFAULT_VALUE.autoPauseTracking} v-slots={{ info: () => {t(msg => msg.option.tracking.noActivityInfo)}, maxTime: () => { /> msg.option.tracking.countLocalFiles} - defaultValue={fileAccess.value ? t(msg => msg.option.yes) : undefined} + defaultValue={DEFAULT_VALUE.countLocalFiles} v-slots={{ info: () => {t(msg => msg.option.tracking.localFilesInfo)}, localFileTime: () => {t(msg => msg.option.tracking.localFileTime)}, - default: () => fileAccess.value - ? option.countLocalFiles = val as boolean} /> + default: () => fileAccess.value || IS_FIREFOX + ? option.countLocalFiles = !!val} /> : IS_FIREFOX ? t(msg => msg.option.tracking.fileAccessFirefox) : t(msg => msg.option.tracking.fileAccessDisabled), + content: () => t(msg => msg.option.tracking.fileAccessDisabled), default: () => , }} />, @@ -140,7 +142,7 @@ const _default = defineComponent((_props, ctx) => { } msg.option.tracking.weekStart} - defaultValue={t(msg => msg.option.tracking.weekStartAsNormal)} + defaultValue={msg => msg.option.tracking.weekStartAsNormal} > { const page = await this.browser.newPage() await page.goto(`chrome-extension://${this.extensionId}/static/app.html#${route}`) + await page.waitForNetworkIdle() return page } From a59b69d09fe005416c465cc5ef75deac82b43c2e Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 28 Nov 2025 10:35:56 +0800 Subject: [PATCH 021/174] v3.7.5 --- CHANGELOG.md | 7 +++++++ package.json | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aed2193c2..c4b125373 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [3.7.5] - 2025-11-28 + +- Supported tracking files for Firefox in background script +- All charts use system fonts +- Optimized the interaction for modifying restriction rules +- Fixed some bugs + ## [3.7.4] - 2025-11-15 - Improved the time display on popup page diff --git a/package.json b/package.json index c5ab18a44..00436600f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "3.7.4", + "version": "3.7.5", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { From b8346f43bd2ee69dd218636b43be23a9db081957 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 29 Nov 2025 14:10:11 +0800 Subject: [PATCH 022/174] i18n(download): download translations by bot (#631) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/dashboard-resource.json | 19 +++++++++++++------ src/i18n/message/app/option-resource.json | 9 ++++++++- 2 files changed, 21 insertions(+), 7 deletions(-) diff --git a/src/i18n/message/app/dashboard-resource.json b/src/i18n/message/app/dashboard-resource.json index 16018a9d6..74154c15e 100644 --- a/src/i18n/message/app/dashboard-resource.json +++ b/src/i18n/message/app/dashboard-resource.json @@ -94,20 +94,27 @@ }, "pt_PT": { "heatMap": { - "title0": "Navegou {hour}h no último ano", - "title1": "Navegou <1h no último ano" + "title0": "Navegou por {hour} no último ano", + "title1": "Navegou menos que 1 hora no último ano" }, "topK": { - "title": "TOP {k} mais visitados em {day} dias" + "title": "TOP {k} mais visitados nos últimos {day} dias" }, "indicator": { "installedDays": "Instalado há {number} dias", - "visitCount": "{visit} visitas a {site} sites", - "browsingTime": "Navegou {minute} min", - "mostUse": "Prefere navegar entre {start}h-{end}h" + "visitCount": "Visitou sites {site} {visit} vezes", + "browsingTime": "Navegou mais de {minute} minutos", + "mostUse": "Prefere navegar entre {start} h às {end} h" }, "monthOnMonth": { "title": "Tendência mensal de tempo de navegação" + }, + "timeline": { + "title": "Linha do tempo dos últimos {n} dias", + "busyScore": "Ocupado", + "busyScoreDesc": "Relacionado ao tempo de navegação total e o número de sites visitados por hora. Veja código-fonte para a fórmula", + "focusScore": "Focado", + "focusScoreDesc": "Relacionado a contagem do tempo contínuo total navegado no mesmo site. Veja o código-fonte para a fórmula do cálculo" } }, "uk": { diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index dc46e5392..3e6977175 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -613,7 +613,7 @@ "lastTimeTip": "Última cópia: {lastTime}", "auto": { "label": "Cópia automática", - "interval": "executar a cada {input} minutos" + "interval": "e executar a cada {input} minutos" } }, "accessibility": { @@ -622,6 +622,13 @@ }, "resetButton": "Repor", "resetSuccess": "Reposição concluída!", + "exportButton": "Exportar configurações", + "importButton": "Importar configurações", + "exportSuccess": "Configurações exportadas com sucesso", + "importSuccess": "\n", + "importError": "Falha na Importação: Documento de configurações inválido", + "importConfirm": "Configurações importadas com sucesso, recarregue a página para aplicar às mudanças!", + "reloadButton": "Recarregar", "defaultValue": "Predefinido: {default}" }, "uk": { From 09ab1f7a5d3373df73375e8de329b38d5b0ba3a1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 1 Dec 2025 10:58:48 +0800 Subject: [PATCH 023/174] chore(psl): update PSL list by bot (#632) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/util/psl/rules.json | 304 ++++++++++++++++++++++++++++++++++++++-- 1 file changed, 289 insertions(+), 15 deletions(-) diff --git a/src/util/psl/rules.json b/src/util/psl/rules.json index 0997d0e2a..c502f6504 100644 --- a/src/util/psl/rules.json +++ b/src/util/psl/rules.json @@ -299,6 +299,7 @@ }, "flutterflow": 1, "framer": 1, + "gadget": 1, "github": 1, "hackclub": 1, "hasura": 1, @@ -313,7 +314,7 @@ "luyani": 1, "medusajs": 1, "messerli": 1, - "netfy": 1, + "mocha": 1, "netlify": 1, "ngrok": 1, "ngrok-free": 1, @@ -369,7 +370,6 @@ "vercel": 1, "wal": 1, "wasmer": 1, - "wdh": 1, "web": 1, "windsurf": 1, "wnext": 1, @@ -545,8 +545,7 @@ "hrsn": { "c": { "vps": 1 - }, - "l": 1 + } }, "id": 1, "net": 1, @@ -587,6 +586,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -597,6 +597,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -607,6 +608,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -617,6 +619,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -627,6 +630,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -637,6 +641,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -657,6 +662,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -667,6 +673,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -677,6 +684,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -707,6 +715,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -727,6 +736,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -747,6 +757,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -757,6 +768,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -777,6 +789,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -787,6 +800,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -797,6 +811,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -827,6 +842,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -837,6 +853,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -847,6 +864,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -857,6 +875,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -879,6 +898,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -889,6 +909,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } } @@ -2004,6 +2025,7 @@ "convex": 1, "diadem": 1, "elementor": 1, + "emergent": 1, "encoway": { "c": { "eu": 1 @@ -2248,6 +2270,11 @@ "emrnotebooks-prod": 1, "emrstudio-prod": 1, "execute-api": 1, + "rds": { + "c": { + "*": 1 + } + }, "s3": 1, "s3-accesspoint": 1, "s3-deprecated": 1, @@ -2267,6 +2294,11 @@ "emrnotebooks-prod": 1, "emrstudio-prod": 1, "execute-api": 1, + "rds": { + "c": { + "*": 1 + } + }, "s3": 1, "s3-accesspoint": 1, "s3-object-lambda": 1, @@ -2431,6 +2463,7 @@ }, "l": 1 }, + "umso": 1, "xmit": { "c": { "*": 1 @@ -2575,6 +2608,11 @@ "*": 1 } }, + "ap-southeast-7": { + "c": { + "*": 1 + } + }, "ca-central-1": { "c": { "*": 1 @@ -3323,6 +3361,180 @@ "s3-website": 1 } }, + "rds": { + "c": { + "af-south-1": { + "c": { + "*": 1 + } + }, + "ap-east-1": { + "c": { + "*": 1 + } + }, + "ap-east-2": { + "c": { + "*": 1 + } + }, + "ap-northeast-1": { + "c": { + "*": 1 + } + }, + "ap-northeast-2": { + "c": { + "*": 1 + } + }, + "ap-northeast-3": { + "c": { + "*": 1 + } + }, + "ap-south-1": { + "c": { + "*": 1 + } + }, + "ap-south-2": { + "c": { + "*": 1 + } + }, + "ap-southeast-1": { + "c": { + "*": 1 + } + }, + "ap-southeast-2": { + "c": { + "*": 1 + } + }, + "ap-southeast-3": { + "c": { + "*": 1 + } + }, + "ap-southeast-4": { + "c": { + "*": 1 + } + }, + "ap-southeast-5": { + "c": { + "*": 1 + } + }, + "ap-southeast-6": { + "c": { + "*": 1 + } + }, + "ap-southeast-7": { + "c": { + "*": 1 + } + }, + "ca-central-1": { + "c": { + "*": 1 + } + }, + "ca-west-1": { + "c": { + "*": 1 + } + }, + "eu-central-1": { + "c": { + "*": 1 + } + }, + "eu-central-2": { + "c": { + "*": 1 + } + }, + "eu-west-1": { + "c": { + "*": 1 + } + }, + "eu-west-2": { + "c": { + "*": 1 + } + }, + "eu-west-3": { + "c": { + "*": 1 + } + }, + "il-central-1": { + "c": { + "*": 1 + } + }, + "me-central-1": { + "c": { + "*": 1 + } + }, + "me-south-1": { + "c": { + "*": 1 + } + }, + "mx-central-1": { + "c": { + "*": 1 + } + }, + "sa-east-1": { + "c": { + "*": 1 + } + }, + "us-east-1": { + "c": { + "*": 1 + } + }, + "us-east-2": { + "c": { + "*": 1 + } + }, + "us-gov-east-1": { + "c": { + "*": 1 + } + }, + "us-gov-west-1": { + "c": { + "*": 1 + } + }, + "us-northeast-1": { + "c": { + "*": 1 + } + }, + "us-west-1": { + "c": { + "*": 1 + } + }, + "us-west-2": { + "c": { + "*": 1 + } + } + } + }, "s3": 1, "s3-1": 1, "s3-ap-east-1": 1, @@ -3780,6 +3992,11 @@ "l": 1 }, "atmeta": 1, + "auiusercontent": { + "c": { + "*": 1 + } + }, "authgear-staging": 1, "authgearapps": 1, "awsapprunner": { @@ -3931,14 +4148,18 @@ "ap-southeast-1": 1, "ap-southeast-2": 1, "ap-southeast-3": 1, + "ap-southeast-5": 1, + "ap-southeast-7": 1, "ca-central-1": 1, "eu-central-1": 1, "eu-north-1": 1, "eu-south-1": 1, + "eu-south-2": 1, "eu-west-1": 1, "eu-west-2": 1, "eu-west-3": 1, "il-central-1": 1, + "me-central-1": 1, "me-south-1": 1, "sa-east-1": 1, "us-east-1": 1, @@ -4204,6 +4425,7 @@ }, "mex": 1, "miniserver": 1, + "mochausercontent": 1, "modelscape": 1, "mwcloudnonprod": 1, "myactivedirectory": 1, @@ -4488,6 +4710,7 @@ }, "l": 1 }, + "xtooldevice": 1, "yolasite": 1, "za": 1 }, @@ -4819,6 +5042,16 @@ "barsy": 1, "bearblog": 1, "botdash": 1, + "brave": { + "c": { + "s": { + "c": { + "*": 1 + } + } + }, + "l": 1 + }, "crm": { "c": { "d": { @@ -4884,6 +5117,7 @@ } }, "githubpreview": 1, + "grebedoc": 1, "hrsn": 1, "inbrowser": { "c": { @@ -4926,6 +5160,7 @@ "l": 1 }, "mediatech": 1, + "mocha-sandbox": 1, "modx": 1, "myaddr": 1, "ngrok": 1, @@ -5278,6 +5513,7 @@ "*": 1 } }, + "prvw": 1, "spdns": 1, "transurl": { "c": { @@ -5376,7 +5612,9 @@ "ac": 1, "biz": 1, "com": 1, + "edu": 1, "gov": 1, + "id": 1, "info": 1, "mil": 1, "name": 1, @@ -5537,6 +5775,16 @@ }, "l": 1 }, + "ply": { + "c": { + "at": { + "c": { + "*": 1 + } + }, + "d6": 1 + } + }, "stackit": 1 }, "l": 1 @@ -5788,8 +6036,10 @@ "bolt": 1, "cloudaccess": 1, "easypanel": 1, + "emergent": 1, "fastvps": 1, "freesite": 1, + "gadget": 1, "half": 1, "iserv": 1, "jele": 1, @@ -5910,6 +6160,7 @@ "ponpes": 1, "sch": 1, "web": 1, + "xn--9tfky": 1, "zone": 1 }, "l": 1 @@ -5973,6 +6224,7 @@ "ac": 1, "ai": 1, "am": 1, + "bank": 1, "barsy": 1, "bihar": 1, "biz": 1, @@ -5988,6 +6240,7 @@ "dr": 1, "edu": 1, "er": 1, + "fin": 1, "firm": 1, "gen": 1, "gov": 1, @@ -9269,6 +9522,7 @@ "*": 1 } }, + "joinmc": 1, "myfritz": 1, "mypep": 1, "nftstorage": { @@ -9756,7 +10010,8 @@ "c": { "forgot": 1 } - } + }, + "ispmanager": 1 }, "l": 1 }, @@ -9889,6 +10144,7 @@ "dattolocal": 1, "ddns": 1, "ddns-ip": 1, + "de5": 1, "debian": 1, "definima": 1, "deno": 1, @@ -10107,9 +10363,16 @@ }, "l": 1 }, + "tunnelmole": 1, "twmail": 1, "uk": 1, "uni5": 1, + "usgovcloudapi": { + "c": { + "servicebus": 1 + } + }, + "usgovcloudapp": 1, "vpndns": 1, "vps-host": { "c": { @@ -10345,7 +10608,6 @@ "bievat": 1, "bindal": 1, "birkenes": 1, - "bjarkoy": 1, "bjerkreim": 1, "bjugn": 1, "bodo": 1, @@ -10640,7 +10902,6 @@ "mosjoen": 1, "moskenes": 1, "moss": 1, - "mosvik": 1, "mr": { "c": { "gs": 1 @@ -10987,7 +11248,6 @@ "xn--bhccavuotna-k7a": 1, "xn--bidr-5nac": 1, "xn--bievt-0qa": 1, - "xn--bjarky-fya": 1, "xn--bjddar-pta": 1, "xn--blt-elab": 1, "xn--bmlo-gra": 1, @@ -11247,12 +11507,7 @@ }, "l": 1 }, - "ong": { - "c": { - "obl": 1 - }, - "l": 1 - }, + "ong": 1, "onion": 1, "onl": 1, "online": { @@ -11905,7 +12160,22 @@ "play": 1, "playstation": 1, "plumbing": 1, - "plus": 1, + "plus": { + "c": { + "playit": { + "c": { + "at": { + "c": { + "*": 1 + } + }, + "with": 1 + }, + "l": 1 + } + }, + "l": 1 + }, "pm": { "c": { "name": 1, @@ -12529,11 +12799,13 @@ "*": 1 } }, + "co": 1, "convex": 1, "cpanel": 1, "cyon": 1, "fastvps": 1, "figma": 1, + "figma-gov": 1, "heyflow": 1, "jele": 1, "jouwweb": 1, @@ -13343,6 +13615,8 @@ }, "l": 1 }, + "azure-api": 1, + "azurewebsites": 1, "ca": { "c": { "cc": 1, From fb498eebf9c0f881c56eb47585624fb0db3a2693 Mon Sep 17 00:00:00 2001 From: sheepie Date: Mon, 1 Dec 2025 19:58:41 +0800 Subject: [PATCH 024/174] chore: update crowdin url --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index bf3d8e7ab..9da6906bf 100644 --- a/README.md +++ b/README.md @@ -63,7 +63,7 @@ See the [Development Guide](./CONTRIBUTING.md) #### 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 on [Crowdin](https://crowdin.com/project/timer-chrome-edge-firefox). +Most of the software's localization relies on machine translation. You can also submit translation suggestions on [Crowdin](https://crowdin.com/project/timer-chrome-edge-firefox). #### 4. Rate 5 stars From f23f18075bd07d7c54d5e5f442fbc6f08b9883ac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 2 Dec 2025 12:48:30 +0800 Subject: [PATCH 025/174] i18n(download): download translations by bot (#634) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/limit-resource.json | 9 ++++++-- src/i18n/message/app/option-resource.json | 21 +++++++++++++++++++ src/i18n/message/app/report-resource.json | 1 + .../message/app/site-manage-resource.json | 7 ++++++- src/i18n/message/common/item-resource.json | 1 + src/i18n/message/common/shared-resource.json | 2 ++ src/i18n/message/popup/header-resource.json | 8 +++++-- 7 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index 4b5691312..c7da6b296 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -465,9 +465,11 @@ }, "ru": { "filterDisabled": "Только включен", + "emptyTips": "Нажмите здесь, чтобы создать новое правило!", "item": { "name": "Имя правила", "condition": "Ограниченный URL", + "daily": "Дневной лимит", "weekly": "Недельный лимит", "weekStartInfo": "Первый день каждой недели {weekStart}, вы можете изменить это значение в настройках статистики", "delayCount": "Отложенный", @@ -477,7 +479,9 @@ "enabled": "Включено", "effectiveDay": "Эффективный", "delayAllowed": "Еще 5 минут", - "delayAllowedInfo": "Если время истекло, разрешите временную задержку 5 минут" + "delayAllowedInfo": "Если время истекло, разрешите временную задержку 5 минут", + "visits": "посещения", + "or": "или" }, "step": { "base": "Основная информация", @@ -493,7 +497,8 @@ "deleteConfirm": "Вы хотите удалить правило [{name}]?", "inputTestUrl": "Пожалуйста, введите ссылку для тестирования", "noRuleMatched": "URL не содержит правил", - "rulesMatched": "URL попадает в следующие правила:" + "rulesMatched": "URL попадает в следующие правила:", + "timeout": "Время вышло! XD" }, "verification": { "inputTip": "Правило было активировано или заблокировано. Чтобы продолжить, введите ответ на следующий вопрос в течение {second} секунд: {prompt}", diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 3e6977175..542002c2c 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -1171,15 +1171,36 @@ "title": "Статистика", "localFilesInfo": "Поддержка типов файлов, таких как PDF, изображения, txt и json." }, + "limit": { + "level": { + "verificationDifficulty": { + "easy": "Лёгкий", + "hard": "Сложный" + } + } + }, "backup": { "title": "Резервное копирование данных", + "label": { + "account": "Имя пользователя {input}", + "password": "Пароль {input}" + }, "operation": "Резервное копирование", "download": { "btn": "Скачать" + }, + "confirmStep": "Подтвердить данные", + "clientTable": { + "selectTip": "Выбрать клиент", + "notSelected": "Клиент не выбран", + "current": "Текущий" } }, "resetButton": "Сброс", "resetSuccess": "Сброс к настройкам по умолчанию выполнен успешно!", + "exportButton": "Экспорт настроек", + "importButton": "Импорт настроек", + "reloadButton": "Обновить", "defaultValue": "По умолчанию: {default}" }, "ar": { diff --git a/src/i18n/message/app/report-resource.json b/src/i18n/message/app/report-resource.json index 099d5c276..a257efd0b 100644 --- a/src/i18n/message/app/report-resource.json +++ b/src/i18n/message/app/report-resource.json @@ -84,6 +84,7 @@ }, "pt_PT": { "exportFileName": "Meu_Tempo_de_Navegação", + "total": "Visitas Totais: {visit} vezes, total duração: {focus}", "batchDelete": { "noSelectedMsg": "Por favor, selecione a linha que deseja excluir na tabela primeiro", "confirmMsg": "{count} registros de sites como {example} em {date} serão excluídos!", diff --git a/src/i18n/message/app/site-manage-resource.json b/src/i18n/message/app/site-manage-resource.json index b8c6dbd95..53c5f912e 100644 --- a/src/i18n/message/app/site-manage-resource.json +++ b/src/i18n/message/app/site-manage-resource.json @@ -407,13 +407,18 @@ "info": "подсчитайте любой URL в формате Ant Pattern, вы можете добавить пользовательский сайт в правом верхнем углу" } }, + "cate": { + "removeConfirm": "Вы уверены, что хотите удалить категорию: {category}?" + }, "form": { "emptyAlias": "Введите название сайта", "emptyHost": "Введите URL-адрес сайта" }, "msg": { "hostExistWarn": "{host} существует", - "existedTag": "ВЫПОЛНЕНО" + "existedTag": "ВЫПОЛНЕНО", + "noSelected": "Сайт не выбран", + "batchDeleteMsg": "Вы уверенны, что хотите удалить выбранные сайты?" } }, "ar": { diff --git a/src/i18n/message/common/item-resource.json b/src/i18n/message/common/item-resource.json index a858de097..089188664 100644 --- a/src/i18n/message/common/item-resource.json +++ b/src/i18n/message/common/item-resource.json @@ -161,6 +161,7 @@ "ru": { "date": "Дата", "host": "Сайт", + "group": "Группа вкладок", "focus": "Длительность", "run": "Время работы", "time": "Визиты", diff --git a/src/i18n/message/common/shared-resource.json b/src/i18n/message/common/shared-resource.json index cd04e1065..314ec4a48 100644 --- a/src/i18n/message/common/shared-resource.json +++ b/src/i18n/message/common/shared-resource.json @@ -65,6 +65,7 @@ "date": "Data", "domain": "URL", "cate": "Categoria", + "group": "Grupo de Separadores", "notMerge": "Não Agrupar" } }, @@ -139,6 +140,7 @@ "date": "Дата", "domain": "URL", "cate": "Категория", + "group": "Группа вкладок", "notMerge": "Не объединять" } }, diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index 93f46e208..75509103a 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -15,7 +15,9 @@ "showTopN": "顯示前{n}个" }, "ja": { - "rate": "評価する" + "rate": "評価する", + "showSiteName": "サイト名を表示", + "showTopN": "トップ {n} を表示" }, "pt_PT": { "rate": "Avaliar" @@ -37,7 +39,9 @@ "rate": "Noter" }, "ru": { - "rate": "Оценить" + "rate": "Оценить", + "showSiteName": "Отобразить имя сайта", + "showTopN": "Отображать заголовок {n}" }, "ar": { "rate": "قيمنا" From 273f5559fabf03e5158f6594269509aae1385be1 Mon Sep 17 00:00:00 2001 From: sheepie Date: Thu, 4 Dec 2025 01:01:01 +0800 Subject: [PATCH 026/174] fix: not working to import data from WATT (#637) --- .../Migration/ImportOtherButton/Sop.tsx | 106 +++++++++--------- .../Migration/ImportOtherButton/Step1.tsx | 15 ++- .../Migration/ImportOtherButton/Step2.tsx | 40 +++---- .../Migration/ImportOtherButton/processor.ts | 66 +++++++++-- .../BackupOption/Download/Step2.tsx | 2 +- .../common/imported/CompareTable.tsx | 18 +-- 6 files changed, 138 insertions(+), 109 deletions(-) diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Sop.tsx b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Sop.tsx index 245657805..ceb8c4ca0 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Sop.tsx +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Sop.tsx @@ -15,63 +15,59 @@ import { defineComponent, ref } from "vue" import Step1 from "./Step1" import Step2 from "./Step2" -const _default = defineComponent({ - emits: { - cancel: () => true, - import: () => true, - }, - setup(_, ctx) { - const step = ref<0 | 1>(0) - const step1 = ref>() - const step2 = ref>() +type Props = { + onCancel: NoArgCallback + onImport: NoArgCallback +} - const { data, refresh: handleNext, loading: parsing } = useManualRequest(() => step1.value!.parseData(), { - defaultValue: { rows: [] }, - onSuccess: () => step.value = 1, - onError: e => ElMessage.error((e as Error)?.message ?? 'Unknown Error') - }) +const _default = defineComponent((props) => { + const step = ref<0 | 1>(0) + const step1 = ref>() + const step2 = ref>() - const { loading: importing, refresh: doImport } = useManualRequest( - async () => { - const resolution = await step2.value?.parseData?.() - if (!resolution) throw new Error(t(msg => msg.dataManage.importOther.conflictNotSelected)) - await processImportedData(data.value, resolution) - }, - { - onSuccess: () => { - ElMessage.success(t(msg => msg.operation.successMsg)) - ctx.emit('import') - }, - onError: e => ElMessage.warning((e as Error)?.message ?? 'Unknown error'), - } - ) + const { data, refresh: handleNext, loading: parsing } = useManualRequest(() => step1.value!.parseData(), { + defaultValue: { rows: [] }, + onSuccess: () => step.value = 1, + onError: e => ElMessage.error((e as Error)?.message ?? 'Unknown Error') + }) - return () => ( - ctx.emit('cancel')} - onBack={() => step.value = 0} - onNext={handleNext} - onFinish={doImport} - nextLoading={parsing.value} - finishLoading={importing.value} - v-slots={{ - steps: () => ( - - msg.dataManage.importOther.step1)} /> - msg.dataManage.importOther.step2)} /> - - ), - content: () => ( - - {step.value === 0 ? : } - - ), - }} - /> - ) - } -}) + const { loading: importing, refresh: doImport } = useManualRequest(async () => { + const resolution = await step2.value?.parseData?.() + if (!resolution) throw new Error(t(msg => msg.dataManage.importOther.conflictNotSelected)) + await processImportedData(data.value, resolution) + }, { + onSuccess: () => { + ElMessage.success(t(msg => msg.operation.successMsg)) + props.onImport?.() + }, + onError: e => ElMessage.warning((e as Error)?.message ?? 'Unknown error'), + }) + + return () => ( + step.value = 0} + onNext={handleNext} + onFinish={doImport} + nextLoading={parsing.value} + finishLoading={importing.value} + v-slots={{ + steps: () => ( + + msg.dataManage.importOther.step1)} /> + msg.dataManage.importOther.step2)} /> + + ), + content: () => ( + + {step.value === 0 ? : } + + ), + }} + /> + ) +}, { props: ['onCancel', 'onImport'] }) export default _default \ No newline at end of file diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step1.tsx b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step1.tsx index 30c6469e8..bdbe556a2 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step1.tsx +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step1.tsx @@ -10,7 +10,7 @@ import { t } from "@app/locale" import { Document } from "@element-plus/icons-vue" import { useState } from "@hooks" import Flex from "@pages/components/Flex" -import { ElButton, ElForm, ElFormItem, ElOption, ElSelect } from "element-plus" +import { ElButton, ElForm, ElFormItem, ElSelect } from "element-plus" import { defineComponent, ref } from "vue" import { type OtherExtension, parseFile } from "./processor" @@ -22,13 +22,13 @@ const OTHER_NAMES: { [ext in OtherExtension]: string } = { const OTHER_FILE_FORMAT: { [ext in OtherExtension]: string } = { webtime_tracker: '.csv,.json', - web_activity_time_tracker: '.csv', + web_activity_time_tracker: '.csv,.json', history_trends_unlimited: '.tsv', } const ALL_TYPES: OtherExtension[] = Object.keys(OTHER_NAMES) as OtherExtension[] -const _default = defineComponent((_, ctx) => { +const _default = defineComponent<{}>((_, ctx) => { const [type, setType] = useState('webtime_tracker') const [selectedFile, setSelectedFile] = useState() const fileInput = ref() @@ -48,11 +48,10 @@ const _default = defineComponent((_, ctx) => { return () => ( msg.dataManage.importOther.dataSource)} required> - - { - ALL_TYPES.map(type => ) - } - + ({ value, label: OTHER_NAMES[value] }))} + /> msg.dataManage.importOther.file)} required> diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step2.tsx b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step2.tsx index 791d38a57..363c6133b 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step2.tsx +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step2.tsx @@ -8,37 +8,25 @@ import { type SopStepInstance } from "@app/components/common/DialogSop" import CompareTable from "@app/components/common/imported/CompareTable" import ResolutionRadio from "@app/components/common/imported/ResolutionRadio" -import { t } from "@app/locale" import { useState } from "@hooks" import Flex from "@pages/components/Flex" -import { defineComponent, type PropType } from "vue" +import { defineComponent } from "vue" -const _default = defineComponent({ - props: { - data: { - type: Object as PropType, - required: true, - }, - }, - setup(props, ctx) { - const [resolution, setResolution] = useState() +const _default = defineComponent<{ data: timer.imported.Data }>((props, ctx) => { + const [resolution, setResolution] = useState() - ctx.expose({ - parseData: () => resolution.value - } satisfies SopStepInstance) + ctx.expose({ + parseData: () => resolution.value + } satisfies SopStepInstance) - return () => ( - - msg.dataManage.importOther.imported)} - /> - - - + return () => ( + + msg.dataManage.importOther.imported} /> + + - ) - } -}) + + ) +}, { props: ['data'] }) export default _default diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts b/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts index 7a97196d2..de50e3b63 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts @@ -31,7 +31,7 @@ export async function parseFile(ext: OtherExtension, file: File): Promise { +async function parseWebActivityTimeTracker(file: File): Promise<[timer.imported.Row[], time: boolean]> { const text = await file.text() - const lines = text.split('\n').map(line => line.trim()).filter(line => !!line).splice(1) - const rows = lines.map(line => { - const [host, date, seconds] = line.split(',').map(cell => cell.trim()) - !host || !date || (!seconds && seconds !== '0') && throwError() - const [year, month, day] = date.split('/') - !year || !month || !day && throwError() - const realDate = `${year}${month.length == 2 ? month : '0' + month}${day.length == 2 ? day : '0' + day}` - return { host, date: realDate, focus: parseInt(seconds) * MILL_PER_SECOND, time: 0 } satisfies timer.imported.Row + if (isCsvFile(file)) { + const lines = text.split('\n').map(line => line.trim()).filter(line => !!line).splice(1) + const rows = lines.map(line => { + const [host, date, seconds] = line.split(',').map(cell => cell.trim()) + !host || !date || (!seconds && seconds !== '0') && throwError() + const [year, month, day] = date.split('/') + !year || !month || !day && throwError() + const realDate = `${year}${month.length == 2 ? month : '0' + month}${day.length == 2 ? day : '0' + day}` + return { host, date: realDate, focus: parseInt(seconds) * MILL_PER_SECOND, time: 0 } satisfies timer.imported.Row + }) + return [rows, false] + } else if (isJsonFile(file)) { + return [parseWattJsonFile(text), true] + } else { + throw new Error("Invalid file format") + } +} + +type WattJsonItem = { + url?: string + favicon?: string + summaryTime?: number + counter?: number + days?: { + // new Date().toLocaleDateString("en-US") => "7/22/2023" + // @see https://github.com/Stigmatoz/web-activity-time-tracker/blob/master/src/utils/date.ts#L6 + date?: string + // seconds + summary?: number + // visit count + counter?: number + }[] +} + +const parseWattJsonFile = (fileContent: string) => { + const rows: timer.imported.Row[] = [] + const data = JSON.parse(fileContent) as WattJsonItem[] + data.forEach(({ url: host, days }) => { + if (!host) throw new Error("Invalid item without url") + if (!days) throw new Error("Invalid item without days") + days.forEach(({ date, summary, counter: time }) => { + if (!date) throw new Error("Invalid day without date") + if (!summary && !time) throw new Error("Invalid day without summary and counter") + const [month, day, year] = date.split('/') + if (!year || !month || !day) throw new Error("Invalid date format: " + date) + const dateObj = new Date(parseInt(year), parseInt(month) - 1, parseInt(day)) + const realDate = formatTimeYMD(dateObj) + rows.push({ + host, + date: realDate, + focus: (summary ?? 0) * MILL_PER_SECOND, + time: time ?? 0, + }) + }) }) return rows } diff --git a/src/pages/app/components/Option/components/BackupOption/Download/Step2.tsx b/src/pages/app/components/Option/components/BackupOption/Download/Step2.tsx index a53b5e610..03f6fdcc9 100644 --- a/src/pages/app/components/Option/components/BackupOption/Download/Step2.tsx +++ b/src/pages/app/components/Option/components/BackupOption/Download/Step2.tsx @@ -34,7 +34,7 @@ const _default = defineComponent((props, ctx) => { }) } - msg.option.backup.download.willDownload)} /> + msg.option.backup.download.willDownload} /> diff --git a/src/pages/app/components/common/imported/CompareTable.tsx b/src/pages/app/components/common/imported/CompareTable.tsx index ba9715064..d1af02a0a 100644 --- a/src/pages/app/components/common/imported/CompareTable.tsx +++ b/src/pages/app/components/common/imported/CompareTable.tsx @@ -6,7 +6,7 @@ */ import HostAlert from "@app/components/common/HostAlert" -import { t } from "@app/locale" +import { type I18nKey, t } from "@app/locale" import { cvt2LocaleTime, periodFormatter } from "@app/util/time" import { useState } from "@hooks" import Box from "@pages/components/Box" @@ -34,11 +34,11 @@ function computeList(sort: SortInfo, originRows: timer.imported.Row[]): timer.im return originRows.sort(comparator) } -const focusCol = (comparedColName: string): Column[] => [ +const focusCol = (comparedCol: I18nKey): Column[] => [ { width: 150, align: 'center', - title: comparedColName, + title: t(comparedCol), cellRenderer: ({ rowData }) => {periodFormatter((rowData as timer.imported.Row).focus)}, }, { width: 150, @@ -48,11 +48,11 @@ const focusCol = (comparedColName: string): Column[] => [ } ] -const timeCol = (comparedColName: string): Column[] => [ +const timeCol = (comparedCol: I18nKey): Column[] => [ { width: 150, align: 'center', - title: comparedColName, + title: t(comparedCol), cellRenderer: ({ rowData }) => {(rowData as timer.imported.Row).time ?? 0}, }, { width: 150, @@ -64,7 +64,7 @@ const timeCol = (comparedColName: string): Column[] => [ type Props = { data: timer.imported.Data - comparedColName: string + comparedCol: I18nKey } const BASE_COLUMNS: Column[] = [ @@ -99,8 +99,8 @@ const _default = defineComponent((props) => { const columns = computed(() => { const value = [...BASE_COLUMNS] const { focus, time } = data.value - focus && value.push(...focusCol(props.comparedColName)) - time && value.push(...timeCol(props.comparedColName)) + focus && value.push(...focusCol(props.comparedCol)) + time && value.push(...timeCol(props.comparedCol)) return value }) @@ -120,6 +120,6 @@ const _default = defineComponent((props) => { } /> ) -}, { props: ['data', 'comparedColName'] }) +}, { props: ['data', 'comparedCol'] }) export default _default \ No newline at end of file From 330db86dd6e96191c564f86d46291ffe5d1d2f3c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 4 Dec 2025 09:36:06 +0800 Subject: [PATCH 027/174] i18n(download): download translations by bot (#636) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/limit-resource.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index c7da6b296..f49c658a9 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -2,6 +2,7 @@ "zh_CN": { "filterDisabled": "过滤无效规则", "wildcardTip": "您可以使用通配符来匹配子域名或子页面,使用\"+\"作为前缀来排除子页面!", + "emptyTips": "点击这里创建一条规则!", "item": { "name": "规则名称", "condition": "限制网址", From 9e590845d6be432744f4ee597a9f7cdfc0fba578 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 5 Dec 2025 09:54:59 +0800 Subject: [PATCH 028/174] i18n(download): download translations by bot (#639) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/limit-resource.json | 1 + src/i18n/message/app/option-resource.json | 3 ++- src/i18n/message/common/button-resource.json | 4 +++- src/i18n/message/common/item-resource.json | 1 + src/i18n/message/popup/header-resource.json | 3 ++- 5 files changed, 9 insertions(+), 3 deletions(-) diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index f49c658a9..1a7b968b3 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -209,6 +209,7 @@ "pt_PT": { "filterDisabled": "Apenas ativos", "wildcardTip": "Pode usar wildcards para corresponder a subdomínios ou subpáginas, e usar o \"+\" como prefixo para excluir subpáginas!", + "emptyTips": "Clica aqui para criar uma regra!", "item": { "name": "Nome da regra", "condition": "URL restrito", diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 542002c2c..af15663a4 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -546,6 +546,7 @@ "countLocalFiles": "{input} Contar tempo a {localFileTime} {info}", "localFileTime": "ler ficheiros locais", "localFilesInfo": "Suporta PDF, imagens, txt e json.", + "tabGroupsPermGrant": "Esta funcionalidade precisa de permissões relevantes", "fileAccessDisabled": "Acesso a URLs de ficheiro não permitido. Ative na página de gestão.", "weekStart": "Primeiro dia da semana {input}", "weekStartAsNormal": "Normal" @@ -625,7 +626,7 @@ "exportButton": "Exportar configurações", "importButton": "Importar configurações", "exportSuccess": "Configurações exportadas com sucesso", - "importSuccess": "\n", + "importSuccess": "Configurações importadas com sucesso\n", "importError": "Falha na Importação: Documento de configurações inválido", "importConfirm": "Configurações importadas com sucesso, recarregue a página para aplicar às mudanças!", "reloadButton": "Recarregar", diff --git a/src/i18n/message/common/button-resource.json b/src/i18n/message/common/button-resource.json index 3b51bebfe..49a19b96a 100644 --- a/src/i18n/message/common/button-resource.json +++ b/src/i18n/message/common/button-resource.json @@ -109,7 +109,9 @@ "clear": "Limpar", "enable": "Ativar", "batchEnable": "Ativar em Lote", - "batchDisable": "Desativar em Lote" + "batchDisable": "Desativar em Lote", + "collapse": "Colapsar", + "expand": "Expandir" }, "uk": { "create": "Новий", diff --git a/src/i18n/message/common/item-resource.json b/src/i18n/message/common/item-resource.json index 089188664..b8bc3e475 100644 --- a/src/i18n/message/common/item-resource.json +++ b/src/i18n/message/common/item-resource.json @@ -73,6 +73,7 @@ "pt_PT": { "date": "Data", "host": "Domínio", + "group": "Grupo de Separadores", "focus": "Duração", "run": "Tempo de Execução", "time": "Visitas", diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index 75509103a..a396c6648 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -20,7 +20,8 @@ "showTopN": "トップ {n} を表示" }, "pt_PT": { - "rate": "Avaliar" + "rate": "Avaliar", + "showSiteName": "Mostrar nome do site" }, "uk": { "rate": "Оцінити" From aa6e555dbc53a664734e8a4b094c99bc65d11267 Mon Sep 17 00:00:00 2001 From: sheepie Date: Sun, 7 Dec 2025 10:17:41 +0800 Subject: [PATCH 029/174] feat: add mask layer for date picker on the side page (#620) (#641) --- package.json | 16 ++++---- src/api/chrome/tab.ts | 5 ++- src/background/track-server/file-tracker.ts | 1 + src/background/whitelist-menu-manager.ts | 2 +- src/pages/side/components/Search/Cell.tsx | 41 +++++++++++++++++++ .../{Search.tsx => Search/index.tsx} | 39 ++++++++++-------- .../side/components/Search/useDatePicker.ts | 38 +++++++++++++++++ 7 files changed, 115 insertions(+), 27 deletions(-) create mode 100644 src/pages/side/components/Search/Cell.tsx rename src/pages/side/components/{Search.tsx => Search/index.tsx} (67%) create mode 100644 src/pages/side/components/Search/useDatePicker.ts diff --git a/package.json b/package.json index 00436600f..7ad557bdf 100644 --- a/package.json +++ b/package.json @@ -31,19 +31,19 @@ "@crowdin/crowdin-api-client": "^1.49.0", "@emotion/babel-plugin": "^11.13.5", "@emotion/css": "^11.13.5", - "@rsdoctor/rspack-plugin": "^1.3.11", - "@rspack/cli": "^1.6.4", - "@rspack/core": "^1.6.4", + "@rsdoctor/rspack-plugin": "^1.3.12", + "@rspack/cli": "^1.6.6", + "@rspack/core": "^1.6.6", "@swc/core": "^1.15.3", "@swc/jest": "^0.2.39", - "@types/chrome": "0.1.31", + "@types/chrome": "0.1.32", "@types/decompress": "^4.2.7", "@types/jest": "^30.0.0", "@types/node": "^24.10.1", "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.0.0", - "commitlint": "^20.1.0", + "commitlint": "^20.2.0", "css-loader": "^7.1.2", "decompress": "^4.2.1", "husky": "^9.1.7", @@ -54,7 +54,7 @@ "postcss": "^8.5.6", "postcss-loader": "^8.2.0", "postcss-rtlcss": "^5.7.1", - "puppeteer": "^24.31.0", + "puppeteer": "^24.32.0", "ts-loader": "^9.5.4", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", @@ -63,9 +63,9 @@ "dependencies": { "@element-plus/icons-vue": "^2.3.2", "echarts": "^6.0.0", - "element-plus": "2.11.8", + "element-plus": "2.12.0", "punycode": "^2.3.1", - "vue": "^3.5.24", + "vue": "^3.5.25", "vue-router": "^4.6.3" }, "engines": { diff --git a/src/api/chrome/tab.ts b/src/api/chrome/tab.ts index eb145cb0a..183ca09cf 100644 --- a/src/api/chrome/tab.ts +++ b/src/api/chrome/tab.ts @@ -7,7 +7,10 @@ import { handleError } from "./common" -export function getTab(id: number): Promise { +export function getTab(id: number): Promise { + if (id < 0) { + return Promise.resolve(undefined) + } return new Promise(resolve => chrome.tabs.get(id, tab => { handleError("getTab") resolve(tab) diff --git a/src/background/track-server/file-tracker.ts b/src/background/track-server/file-tracker.ts index 6efac5e59..03b8f5af5 100644 --- a/src/background/track-server/file-tracker.ts +++ b/src/background/track-server/file-tracker.ts @@ -12,6 +12,7 @@ type Context = { async function convertContext(tabId: number): Promise { const tab = await getTab(tabId) + if (!tab) return null const { active, url } = tab if (!active || !url) return null const fileHost = extractFileHost(url) diff --git a/src/background/whitelist-menu-manager.ts b/src/background/whitelist-menu-manager.ts index 5a45089da..e90bcb283 100644 --- a/src/background/whitelist-menu-manager.ts +++ b/src/background/whitelist-menu-manager.ts @@ -33,7 +33,7 @@ const menuInitialOptions: ChromeContextMenuCreateProps = { async function updateContextMenuInner(param: ChromeTab | number | undefined): Promise { if (typeof param === 'number') { // If number, get the tabInfo first - const tab: ChromeTab = await getTab(currentActiveId) + const tab = await getTab(currentActiveId) tab && await updateContextMenuInner(tab) } else { const { url } = param || {} diff --git a/src/pages/side/components/Search/Cell.tsx b/src/pages/side/components/Search/Cell.tsx new file mode 100644 index 000000000..113133854 --- /dev/null +++ b/src/pages/side/components/Search/Cell.tsx @@ -0,0 +1,41 @@ +import { formatTimeYMD } from '@util/time' +import { type DateCell, useNamespace } from "element-plus" +import { computed, defineComponent } from 'vue' + +const DOT_SIZE = '3px' +const DATE_SIZE = '22px' + +const Cell = defineComponent<{ cell: DateCell, dataDates: string[] }>(props => { + const text = computed(() => { + const { renderText, text } = props.cell + return renderText ?? text + }) + const hasData = computed(() => { + const { date } = props.cell + if (!date) return false + const dateStr = formatTimeYMD(date) + return props.dataDates.includes(dateStr) + }) + const ns = useNamespace('date-table-cell') + + return () => ( +
+ {text.value} + {hasData.value && } +
+ ) +}, { props: ['cell', 'dataDates'] }) + +export default Cell diff --git a/src/pages/side/components/Search.tsx b/src/pages/side/components/Search/index.tsx similarity index 67% rename from src/pages/side/components/Search.tsx rename to src/pages/side/components/Search/index.tsx index ef503ec08..a55447ad2 100644 --- a/src/pages/side/components/Search.tsx +++ b/src/pages/side/components/Search/index.tsx @@ -2,10 +2,12 @@ import { Search } from "@element-plus/icons-vue" import { css } from '@emotion/css' import { useState } from "@hooks" import Flex from "@pages/components/Flex" -import { getDatePickerIconSlots } from "@pages/element-ui/rtl" +import { getDatePickerIconSlots } from '@pages/element-ui/rtl' import { t } from "@side/locale" -import { ElDatePicker, ElInput, useNamespace } from "element-plus" -import { defineComponent, watch } from "vue" +import { type DateCell, ElDatePicker, ElInput, useNamespace } from "element-plus" +import { defineComponent, h } from "vue" +import Cell from './Cell' +import { useDatePicker } from './useDatePicker' const useCalendarStyle = () => { const inputNs = useNamespace('input') @@ -48,13 +50,12 @@ type Props = { } const _default = defineComponent(props => { - const now = Date.now() - const [query, setQuery] = useState(props.defaultQuery) - const [date, setDate] = useState(props.defaultDate) const handleSearch = () => props.onSearch?.(query.value.trim(), date.value) - - watch(date, handleSearch) + const { + date, setDate, dataDates, + disabledDate, onPanelChange, + } = useDatePicker({ onChange: handleSearch }) const [calendarCls, popoverCls] = useCalendarStyle() @@ -72,15 +73,19 @@ const _default = defineComponent(props => { }} onKeydown={kv => (kv as KeyboardEvent).code === 'Enter' && handleSearch()} /> - date.getTime() > now} - modelValue={date.value} - onUpdate:modelValue={setDate} - class={calendarCls} - popperClass={popoverCls} - v-slots={getDatePickerIconSlots()} - /> + {/* The events of date picker is not compatible with typescript, so use h() to workaround */} + {h(ElDatePicker, { + clearable: false, + disabledDate, + modelValue: date.value, + 'onUpdate:modelValue': setDate, + class: calendarCls, + popperClass: popoverCls, + onPanelChange, + }, { + ...getDatePickerIconSlots(), + default: (cell: DateCell) => , + })}
) }, { props: ['defaultDate', 'defaultQuery', 'onSearch'] }) diff --git a/src/pages/side/components/Search/useDatePicker.ts b/src/pages/side/components/Search/useDatePicker.ts new file mode 100644 index 000000000..0e2551429 --- /dev/null +++ b/src/pages/side/components/Search/useDatePicker.ts @@ -0,0 +1,38 @@ +import { useRequest } from '@hooks/useRequest' +import { useState } from '@hooks/useState' +import { selectSite } from '@service/stat-service' +import { getMonthTime, MILL_PER_WEEK } from '@util/time' +import { watch } from 'vue' + +export const useDatePicker = (options: { onChange: ArgCallback }) => { + const { onChange } = options + const [date, setDate] = useState(new Date()) + + watch(date, val => onChange(val)) + + const { data: dataDates, refresh: refreshDates } = useRequest(async (dateInMonth: Date) => { + const [ms, me] = getMonthTime(dateInMonth) + const start = new Date(ms.getTime() - ms.getDay() * MILL_PER_WEEK) + const end = new Date(me.getTime() + (6 - me.getDay()) * MILL_PER_WEEK) + + const stats = await selectSite({ date: [start, end] }) + const dateSet = new Set() + stats.forEach(({ date }) => date && dateSet.add(date)) + return Array.from(dateSet) + }, { defaultValue: [], defaultParam: [new Date()] }) + + const onPanelChange = (val: Date | Date[], mode: 'year' | 'month') => { + if (mode !== 'month') return + const date = Array.isArray(val) ? val[0] : val + if (!date) return + refreshDates(date) + } + + const disabledDate = (date: Date) => date.getTime() > Date.now() + + return { + dataDates, date, setDate, + onPanelChange, + disabledDate, + } +} \ No newline at end of file From 1e7d86aa803e5af393c8f9bbf81b715d92c750c4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 8 Dec 2025 13:01:35 +0800 Subject: [PATCH 030/174] i18n(download): download translations by bot (#642) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/analysis-resource.json | 4 ++++ src/i18n/message/app/dashboard-resource.json | 4 ++++ src/i18n/message/app/option-resource.json | 23 +++++++++++++++++--- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/src/i18n/message/app/analysis-resource.json b/src/i18n/message/app/analysis-resource.json index 795068e07..92fc2d34b 100644 --- a/src/i18n/message/app/analysis-resource.json +++ b/src/i18n/message/app/analysis-resource.json @@ -279,6 +279,10 @@ } }, "ru": { + "target": { + "site": "Сайт", + "cate": "Категория" + }, "common": { "focusTotal": "Общее время просмотра", "visitTotal": "Всего посещений", diff --git a/src/i18n/message/app/dashboard-resource.json b/src/i18n/message/app/dashboard-resource.json index 74154c15e..131cdf741 100644 --- a/src/i18n/message/app/dashboard-resource.json +++ b/src/i18n/message/app/dashboard-resource.json @@ -233,6 +233,10 @@ }, "monthOnMonth": { "title": "Время просмотра за последние 30 дней" + }, + "timeline": { + "busyScore": "Занятость", + "focusScore": "Сфокусированность" } }, "ar": { diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index af15663a4..2a71240d8 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -1144,6 +1144,7 @@ "ru": { "yes": "Да", "no": "Нет", + "followBrowser": "Как в браузере", "appearance": { "title": "Появление", "displayWhitelist": "{input} Отображать ли {whitelist} в {contextMenu}", @@ -1155,22 +1156,30 @@ "badgeTextContent": "время просмотра текущего веб-сайта", "locale": { "label": "Язык {input}", + "changeConfirm": "Язык был успешно изменён, пожалуйста, перезагрузите эту страницу!", "reloadButton": "Обновить" }, "printInConsole": { - "console": "консоль" + "label": "{input} Отображать ли {info} в {console}", + "console": "консоль", + "info": "посещений текущего сайта за сегодня" }, "darkMode": { "label": "Тёмный режим {input}", "options": { - "on": "Всегда включен" + "on": "Всегда включен", + "off": "Всегда выключен", + "timed": "По времени" } }, "animationDuration": "Длительность начальной анимации графика {input}" }, "tracking": { "title": "Статистика", - "localFilesInfo": "Поддержка типов файлов, таких как PDF, изображения, txt и json." + "autoPauseTrack": "{input} Приостановить отслеживание, если нет активности {info} в течение {maxTime}", + "localFilesInfo": "Поддержка типов файлов, таких как PDF, изображения, txt и json.", + "countTabGroup": "{input} Отслеживать время для групп вкладок {info}", + "weekStartAsNormal": "Как обычно (Воскресенье)" }, "limit": { "level": { @@ -1197,10 +1206,18 @@ "current": "Текущий" } }, + "accessibility": { + "title": "Специальные возможности", + "chartDecal": "{input} Отображать клетки на графике" + }, "resetButton": "Сброс", "resetSuccess": "Сброс к настройкам по умолчанию выполнен успешно!", "exportButton": "Экспорт настроек", "importButton": "Импорт настроек", + "exportSuccess": "Настройки успешно экспортированы", + "importSuccess": "Настройки успешно импортированы", + "importError": "Не удалось импортировать: недопустимый файл настроек", + "importConfirm": "Настройки успешно импортированы, пожалуйста, перезагрузите страницу, чтобы применить изменения!", "reloadButton": "Обновить", "defaultValue": "По умолчанию: {default}" }, From e7991b152d18dd58436c0f396647e5573325cbb8 Mon Sep 17 00:00:00 2001 From: sheepie Date: Tue, 9 Dec 2025 17:46:23 +0800 Subject: [PATCH 031/174] feat: support tracking time in split-screen mode (#643) --- src/api/chrome/tab.ts | 36 +++++++++++++++++++++++++++ src/api/chrome/window.ts | 2 +- src/background/badge-manager.ts | 10 ++++---- src/background/track-server/normal.ts | 21 ++++++---------- 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/src/api/chrome/tab.ts b/src/api/chrome/tab.ts index 183ca09cf..91f6944eb 100644 --- a/src/api/chrome/tab.ts +++ b/src/api/chrome/tab.ts @@ -7,6 +7,42 @@ import { handleError } from "./common" +class ActivatedTabCtx { + lastTabId?: number + listened: boolean = false + + async apply(): Promise { + if (this.listened) { + return this.lastTabId + } + + // init + chrome.tabs.onActivated.addListener(activeInfo => { + this.lastTabId = activeInfo.tabId + }) + this.listened = true + + return this.getInner() + } + + private getInner(): Promise { + return new Promise(resolve => chrome.tabs.query( + { active: true }, + tabs => { + handleError('getActivatedTab') + const activeTab = tabs?.[0] + resolve(activeTab?.id) + } + )) + } +} + +const ACTIVATED_TAB_CTX = new ActivatedTabCtx() + +export function lastActivatedTabId(): Promise { + return ACTIVATED_TAB_CTX.apply() +} + export function getTab(id: number): Promise { if (id < 0) { return Promise.resolve(undefined) diff --git a/src/api/chrome/window.ts b/src/api/chrome/window.ts index 8c29b079d..3585b7657 100644 --- a/src/api/chrome/window.ts +++ b/src/api/chrome/window.ts @@ -71,7 +71,7 @@ class FocusedWindowCtx { } const context = new FocusedWindowCtx(['normal']) -export const getFocusedNormalWindowId = () => context.apply() +export const lastFocusedNormalWinId = () => context.apply() export async function getWindow(id: number): Promise { if (IS_ANDROID) { diff --git a/src/background/badge-manager.ts b/src/background/badge-manager.ts index 8175027c1..d00725e03 100644 --- a/src/background/badge-manager.ts +++ b/src/background/badge-manager.ts @@ -7,7 +7,7 @@ import { setBadgeBgColor, setBadgeText } from "@api/chrome/action" import { listTabs } from "@api/chrome/tab" -import { getFocusedNormalWindowId } from "@api/chrome/window" +import { lastFocusedNormalWinId } from "@api/chrome/window" import statDatabase from "@db/stat-database" import optionHolder from "@service/components/option-holder" import whitelistHolder from "@service/whitelist/holder" @@ -46,11 +46,11 @@ function setBadgeTextOfMills(milliseconds: number | undefined, tabId: number | u } async function findActiveTab(): Promise { - const windowId = await getFocusedNormalWindowId() - if (!windowId) { + const lastFocusWinId = await lastFocusedNormalWinId() + if (!lastFocusWinId) { return undefined } - const tabs = await listTabs({ active: true, windowId }) + const tabs = await listTabs({ active: true, windowId: lastFocusWinId }) // Fix #131 // Edge will return two active tabs, including the new tab with url 'edge://newtab/', GG for (const { id: tabId, url } of tabs) { @@ -75,7 +75,7 @@ interface BadgeManager { updateFocus(location?: BadgeLocation): void } -class DefaultBadgeManager { +class DefaultBadgeManager implements BadgeManager { pausedTabId: number | undefined current: BadgeLocation | undefined visible: boolean | undefined diff --git a/src/background/track-server/normal.ts b/src/background/track-server/normal.ts index 20441b791..a5d5cfea0 100644 --- a/src/background/track-server/normal.ts +++ b/src/background/track-server/normal.ts @@ -1,5 +1,5 @@ -import { getTab, listTabs, sendMsg2Tab } from "@api/chrome/tab" -import { getWindow } from "@api/chrome/window" +import { lastActivatedTabId, listTabs, sendMsg2Tab } from "@api/chrome/tab" +import { lastFocusedNormalWinId } from "@api/chrome/window" import optionHolder from "@service/components/option-holder" import itemService, { type ItemIncContext } from "@service/item-service" import limitService from "@service/limit-service" @@ -7,7 +7,7 @@ import periodThrottler from '@service/throttler/period-throttler' import whitelistHolder from "@service/whitelist/holder" import { IS_ANDROID } from "@util/constant/environment" import { extractHostname } from "@util/pattern" -import badgeManager from "../badge-manager" +import badgeManager from '../badge-manager' async function handleTime(context: ItemIncContext, timeRange: [number, number], tabId: number | undefined): Promise { const { host, url } = context @@ -41,26 +41,21 @@ export async function handleTrackTimeEvent(event: timer.core.Event, senderTab: C await handleTime({ host, url, groupId }, [start, end], tabId) if (tabId) { - const winTabs = await listTabs({ active: true, windowId }) - const firstActiveTab = winTabs?.[0] - // Cause there is no way to determine whether this tab is selected in screen-split mode - // So only show badge for first tab for screen-split mode - // @see #246 - firstActiveTab?.id === tabId && badgeManager.updateFocus({ tabId, url }) + badgeManager.updateFocus({ tabId, url }) } } async function windowNotFocused(winId: number | undefined): Promise { if (IS_ANDROID) return false if (!winId) return true - const window = await getWindow(winId) - return !window?.focused + const focusedWinId = await lastFocusedNormalWinId() + return focusedWinId !== winId } async function tabNotActive(tabId: number | undefined): Promise { if (!tabId) return true - const tab = await getTab(tabId) - return !tab?.active + const lastActivated = await lastActivatedTabId() + return lastActivated !== tabId } async function sendLimitedMessage(items: timer.limit.Item[]) { From 5518cb027220bad964b8b8bb3aa1a461f215434c Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 9 Dec 2025 23:01:40 +0800 Subject: [PATCH 032/174] v3.7.6 --- CHANGELOG.md | 6 ++++++ package.json | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c4b125373..26d95eba5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [3.7.6] - 2025-12-10 + +- Supported tracking the time in split-screen mode for Chrome +- Added the mask layer for date picker on the side page +- Fixed bugs that caused errors when importing data from Web Activity Time Tracker + ## [3.7.5] - 2025-11-28 - Supported tracking files for Firefox in background script diff --git a/package.json b/package.json index 7ad557bdf..ab41f4f38 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "3.7.5", + "version": "3.7.6", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { @@ -71,4 +71,4 @@ "engines": { "node": ">=22" } -} +} \ No newline at end of file From 5112fbcb5398ae7c88764c25439ec794a0bf53c9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 10 Dec 2025 09:05:38 +0800 Subject: [PATCH 033/174] i18n(download): download translations by bot (#645) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/option-resource.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 2a71240d8..21058e0ba 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -543,9 +543,12 @@ "tracking": { "title": "Estatísticas", "autoPauseTrack": "{input} Pausar sem atividade {info} por {maxTime}", + "noActivityInfo": "O mouse e o teclado estão inativos, não está em modo tela cheia, e nenhum som sendo reproduzido", "countLocalFiles": "{input} Contar tempo a {localFileTime} {info}", "localFileTime": "ler ficheiros locais", "localFilesInfo": "Suporta PDF, imagens, txt e json.", + "countTabGroup": "{input} Se quiser acompanhar o tempo da aba{info}", + "tabGroupInfo": "Quando você excluir um grupo de tags, os dados também serão excluídos.", "tabGroupsPermGrant": "Esta funcionalidade precisa de permissões relevantes", "fileAccessDisabled": "Acesso a URLs de ficheiro não permitido. Ative na página de gestão.", "weekStart": "Primeiro dia da semana {input}", From 8a664768c7a807ef0da9c26125038436ebc0a68a Mon Sep 17 00:00:00 2001 From: sheepie Date: Wed, 10 Dec 2025 13:40:52 +0800 Subject: [PATCH 034/174] feat: donut chart on the popup page (#646) (#647) --- src/i18n/message/popup/header-resource.json | 3 +- src/i18n/message/popup/header.ts | 1 + .../components/Trend/Dimension/Chart.tsx | 9 ++-- src/pages/hooks/useEcharts.ts | 17 ++++---- src/pages/popup/components/Header/Option.tsx | 13 ++++-- .../components/Percentage/Cate/Wrapper.ts | 10 +++-- .../components/Percentage/Cate/index.tsx | 5 +-- .../components/Percentage/Site/index.tsx | 5 +-- .../popup/components/Percentage/chart.ts | 43 ++++++++++++++++++- .../popup/components/Percentage/query.ts | 2 + src/pages/popup/context.ts | 2 + 11 files changed, 81 insertions(+), 29 deletions(-) diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index a396c6648..8d3c8ef22 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -2,7 +2,8 @@ "en": { "rate": "Rate Us", "showSiteName": "Display site name", - "showTopN": "Display top {n}" + "showTopN": "Display top {n}", + "donutChart": "Displayed as donut charts" }, "zh_CN": { "rate": "评分", diff --git a/src/i18n/message/popup/header.ts b/src/i18n/message/popup/header.ts index 8bab47331..ab034e7d5 100644 --- a/src/i18n/message/popup/header.ts +++ b/src/i18n/message/popup/header.ts @@ -8,6 +8,7 @@ import resource from './header-resource.json' export type HeaderMessage = { + donutChart: string rate: string showSiteName: string showTopN: string diff --git a/src/pages/app/components/Analysis/components/Trend/Dimension/Chart.tsx b/src/pages/app/components/Analysis/components/Trend/Dimension/Chart.tsx index 090211f38..bdaddbe4b 100644 --- a/src/pages/app/components/Analysis/components/Trend/Dimension/Chart.tsx +++ b/src/pages/app/components/Analysis/components/Trend/Dimension/Chart.tsx @@ -7,7 +7,7 @@ import { type DimensionEntry, type ValueFormatter } from "@app/components/Analysis/util" import { useEcharts } from "@hooks/useEcharts" -import { defineComponent, watch } from "vue" +import { defineComponent } from "vue" import Wrapper from "./Wrapper" type Props = { @@ -18,14 +18,15 @@ type Props = { } const _default = defineComponent(props => { - const { elRef, refresh } = useEcharts(Wrapper, () => ({ + const { elRef } = useEcharts(Wrapper, () => ({ entries: props.data, preEntries: props.previous, title: props.title, valueFormatter: props.valueFormatter, - })) + }), { + deps: [() => props.data, () => props.valueFormatter], + }) - watch([() => props.data, () => props.valueFormatter], refresh) return () =>
}, { props: ['data', 'previous', 'title', 'valueFormatter'] }) diff --git a/src/pages/hooks/useEcharts.ts b/src/pages/hooks/useEcharts.ts index b35abd470..3ebc620d7 100644 --- a/src/pages/hooks/useEcharts.ts +++ b/src/pages/hooks/useEcharts.ts @@ -10,7 +10,7 @@ import { processAnimation, processAria, processFont, processRtl } from "@util/ec import { type AriaComponentOption, type ComposeOption, SeriesOption, TitleComponentOption } from "echarts" import { type ECharts, init } from "echarts/core" import { ElLoading } from "element-plus" -import { type Ref, isRef, onMounted, ref, watch } from "vue" +import { type Ref, type WatchSource, isRef, onMounted, ref, watch } from "vue" import { useElementSize } from './useElementSize' import { useWindowSize } from "./useWindowSize" @@ -86,7 +86,6 @@ export abstract class EchartsWrapper { type WrapperResult> = { - refresh: () => Promise elRef: Ref wrapper: EW } @@ -97,7 +96,8 @@ export const useEcharts = void + afterInit?: ArgCallback, + deps?: WatchSource | WatchSource[], }): WrapperResult => { const elRef = ref() const wrapperInstance = new Wrapper() @@ -105,7 +105,8 @@ export const useEcharts = { const loading = hideLoading ? null : ElLoading.service({ target: elRef.value }) @@ -124,13 +125,11 @@ export const useEcharts = wrapperInstance?.resize?.()) - return { - refresh, - elRef, - wrapper: wrapperInstance, - } + return { elRef, wrapper: wrapperInstance } } \ No newline at end of file diff --git a/src/pages/popup/components/Header/Option.tsx b/src/pages/popup/components/Header/Option.tsx index b9e4b0cef..2ebc77f9b 100644 --- a/src/pages/popup/components/Header/Option.tsx +++ b/src/pages/popup/components/Header/Option.tsx @@ -7,7 +7,6 @@ import { ROUTE_PERCENTAGE } from '@popup/router' import { ElCheckbox, ElIcon, ElInputNumber, ElPopover, ElText, useNamespace } from "element-plus" import { computed, defineComponent, type StyleValue } from "vue" import { useRoute } from 'vue-router' -// import "./option.sass" const reference = () => ( @@ -20,12 +19,16 @@ const reference = () => ( const Option = defineComponent(() => { const option = useOption() const route = useRoute() - const showTopN = computed(() => !!route.path?.endsWith(ROUTE_PERCENTAGE)) + const isPercentage = computed(() => !!route.path?.endsWith(ROUTE_PERCENTAGE)) const toggleName = () => { option.showName = !option.showName close() } + const toggleDonutChart = () => { + option.donutChart = !option.donutChart + close() + } const handleSiteChange = (v: number | undefined) => { if (!v) return option.topN = v @@ -59,7 +62,11 @@ const Option = defineComponent(() => { {t(msg => msg.header.showSiteName)} - + + + {t(msg => msg.header.donutChart)} + + {tN(msg => msg.header.showTopN, { n: r.cateKey === this.selectedCache)[0] @@ -113,8 +113,8 @@ export default class SiteWrapper extends EchartsWrapper ({ value: row[dimension], row, @@ -135,6 +135,8 @@ export default class SiteWrapper extends EchartsWrapper (b[dimension] ?? 0) - (a[dimension] ?? 0)) diff --git a/src/pages/popup/components/Percentage/Cate/index.tsx b/src/pages/popup/components/Percentage/Cate/index.tsx index 0f3d59d56..cf3e31880 100644 --- a/src/pages/popup/components/Percentage/Cate/index.tsx +++ b/src/pages/popup/components/Percentage/Cate/index.tsx @@ -1,6 +1,6 @@ import { useEcharts } from "@hooks/useEcharts" import { usePopupContext } from "@popup/context" -import { defineComponent, toRef, watch } from "vue" +import { defineComponent, toRef } from "vue" import { type PercentageResult } from "../query" import Wrapper from "./Wrapper" @@ -11,8 +11,7 @@ type Props = { const Cate = defineComponent(props => { const { darkMode } = usePopupContext() const data = toRef(props, 'value') - const { elRef, refresh } = useEcharts(Wrapper, data) - watch(darkMode, refresh) + const { elRef } = useEcharts(Wrapper, data, { deps: darkMode }) return () =>
}, { props: ['value'] }) diff --git a/src/pages/popup/components/Percentage/Site/index.tsx b/src/pages/popup/components/Percentage/Site/index.tsx index 7d8788416..565cd9f41 100644 --- a/src/pages/popup/components/Percentage/Site/index.tsx +++ b/src/pages/popup/components/Percentage/Site/index.tsx @@ -1,6 +1,6 @@ import { useEcharts } from "@hooks/useEcharts" import { usePopupContext } from "@popup/context" -import { defineComponent, toRef, watch } from "vue" +import { defineComponent, toRef } from "vue" import { type PercentageResult } from "../query" import Wrapper from "./Wrapper" @@ -11,8 +11,7 @@ type Props = { const Site = defineComponent(props => { const { darkMode } = usePopupContext() const data = toRef(props, 'value') - const { elRef, refresh } = useEcharts(Wrapper, data) - watch(darkMode, refresh) + const { elRef } = useEcharts(Wrapper, data, { deps: darkMode }) return () =>
}, { props: ['value'] }) diff --git a/src/pages/popup/components/Percentage/chart.ts b/src/pages/popup/components/Percentage/chart.ts index f2dd4400a..c0db8ffd0 100644 --- a/src/pages/popup/components/Percentage/chart.ts +++ b/src/pages/popup/components/Percentage/chart.ts @@ -221,7 +221,7 @@ type CustomOption = Pick< > export function generateSiteSeriesOption(rows: timer.stat.Row[], result: PercentageResult, customOption: CustomOption): PieSeriesOption { - const { displaySiteName, query: { dimension }, itemCount, groups } = result || {} + const { displaySiteName, query: { dimension }, itemCount, groups, donutChart } = result const groupMap = toMap(groups, g => g.id) const chartRows = cvt2ChartRows(rows, dimension, itemCount) @@ -268,7 +268,46 @@ export function generateSiteSeriesOption(rows: timer.stat.Row[], result: Percent }, }, avoidLabelOverlap: true, - ...customOption || {}, + ...customOption, + ...adaptDonutSeries(donutChart, customOption.radius), + } +} + +export function adaptDonutSeries( + donutChart: boolean, + radius: CustomOption['radius'], + donutRadiusRatio: number = 0.5, +): Pick { + return { + ...donutChart ? { + itemStyle: { borderRadius: 5 }, + padAngle: 1, + minAngle: 1.5, + } : { + itemStyle: { borderRadius: 0 }, + padAngle: 0, + minAngle: 0, + }, + radius: calcRealRadius(donutChart, radius, donutRadiusRatio), + } +} + +function calcRealRadius( + donutChart: boolean, + radius: CustomOption['radius'], + donutRadiusRatio: number = 0.5, +): CustomOption['radius'] { + if (!donutChart) return radius + if (!radius) return radius + if (Array.isArray(radius)) return radius + if (typeof radius === 'number') return [radius * donutRadiusRatio, radius] + // String + try { + const percent = parseFloat(radius.replace('%', '')) + const inner = percent * donutRadiusRatio + return [`${inner}%`, radius] + } catch { + return radius } } diff --git a/src/pages/popup/components/Percentage/query.ts b/src/pages/popup/components/Percentage/query.ts index f205b20d8..2b1a05649 100644 --- a/src/pages/popup/components/Percentage/query.ts +++ b/src/pages/popup/components/Percentage/query.ts @@ -15,6 +15,7 @@ export type PercentageResult = { itemCount: number dateLength: number groups: chrome.tabGroups.TabGroup[] + donutChart: boolean } const findAllDates = (row: timer.stat.Row): Set => { @@ -68,5 +69,6 @@ export const doQuery = async (query: PopupQuery, option: PopupOption): Promise

msg.content.percentage.title[query?.duration], { n: query?.durationNum }), itemCount, groups, + donutChart: !!option.donutChart, } satisfies PercentageResult } diff --git a/src/pages/popup/context.ts b/src/pages/popup/context.ts index 925727f8a..70cd3decd 100644 --- a/src/pages/popup/context.ts +++ b/src/pages/popup/context.ts @@ -22,6 +22,7 @@ export type PopupQuery = { export type PopupOption = { showName: boolean topN: number + donutChart: boolean } type PopupContextValue = { @@ -79,6 +80,7 @@ const initOption = () => { const [optionCache, setOptionCache] = useLocalStorage('popup-option', { showName: true, topN: 10, + donutChart: false, }) const option = reactive(optionCache) From 460895119d676de589fe0e06c08b118d843407dd Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sat, 13 Dec 2025 01:30:54 +0800 Subject: [PATCH 035/174] Revert "feat: support tracking time in split-screen mode (#643) " This reverts commit e7991b152d18dd58436c0f396647e5573325cbb8. --- src/api/chrome/tab.ts | 36 --------------------------- src/api/chrome/window.ts | 2 +- src/background/badge-manager.ts | 10 ++++---- src/background/track-server/normal.ts | 21 ++++++++++------ 4 files changed, 19 insertions(+), 50 deletions(-) diff --git a/src/api/chrome/tab.ts b/src/api/chrome/tab.ts index 91f6944eb..183ca09cf 100644 --- a/src/api/chrome/tab.ts +++ b/src/api/chrome/tab.ts @@ -7,42 +7,6 @@ import { handleError } from "./common" -class ActivatedTabCtx { - lastTabId?: number - listened: boolean = false - - async apply(): Promise { - if (this.listened) { - return this.lastTabId - } - - // init - chrome.tabs.onActivated.addListener(activeInfo => { - this.lastTabId = activeInfo.tabId - }) - this.listened = true - - return this.getInner() - } - - private getInner(): Promise { - return new Promise(resolve => chrome.tabs.query( - { active: true }, - tabs => { - handleError('getActivatedTab') - const activeTab = tabs?.[0] - resolve(activeTab?.id) - } - )) - } -} - -const ACTIVATED_TAB_CTX = new ActivatedTabCtx() - -export function lastActivatedTabId(): Promise { - return ACTIVATED_TAB_CTX.apply() -} - export function getTab(id: number): Promise { if (id < 0) { return Promise.resolve(undefined) diff --git a/src/api/chrome/window.ts b/src/api/chrome/window.ts index 3585b7657..8c29b079d 100644 --- a/src/api/chrome/window.ts +++ b/src/api/chrome/window.ts @@ -71,7 +71,7 @@ class FocusedWindowCtx { } const context = new FocusedWindowCtx(['normal']) -export const lastFocusedNormalWinId = () => context.apply() +export const getFocusedNormalWindowId = () => context.apply() export async function getWindow(id: number): Promise { if (IS_ANDROID) { diff --git a/src/background/badge-manager.ts b/src/background/badge-manager.ts index d00725e03..8175027c1 100644 --- a/src/background/badge-manager.ts +++ b/src/background/badge-manager.ts @@ -7,7 +7,7 @@ import { setBadgeBgColor, setBadgeText } from "@api/chrome/action" import { listTabs } from "@api/chrome/tab" -import { lastFocusedNormalWinId } from "@api/chrome/window" +import { getFocusedNormalWindowId } from "@api/chrome/window" import statDatabase from "@db/stat-database" import optionHolder from "@service/components/option-holder" import whitelistHolder from "@service/whitelist/holder" @@ -46,11 +46,11 @@ function setBadgeTextOfMills(milliseconds: number | undefined, tabId: number | u } async function findActiveTab(): Promise { - const lastFocusWinId = await lastFocusedNormalWinId() - if (!lastFocusWinId) { + const windowId = await getFocusedNormalWindowId() + if (!windowId) { return undefined } - const tabs = await listTabs({ active: true, windowId: lastFocusWinId }) + const tabs = await listTabs({ active: true, windowId }) // Fix #131 // Edge will return two active tabs, including the new tab with url 'edge://newtab/', GG for (const { id: tabId, url } of tabs) { @@ -75,7 +75,7 @@ interface BadgeManager { updateFocus(location?: BadgeLocation): void } -class DefaultBadgeManager implements BadgeManager { +class DefaultBadgeManager { pausedTabId: number | undefined current: BadgeLocation | undefined visible: boolean | undefined diff --git a/src/background/track-server/normal.ts b/src/background/track-server/normal.ts index a5d5cfea0..20441b791 100644 --- a/src/background/track-server/normal.ts +++ b/src/background/track-server/normal.ts @@ -1,5 +1,5 @@ -import { lastActivatedTabId, listTabs, sendMsg2Tab } from "@api/chrome/tab" -import { lastFocusedNormalWinId } from "@api/chrome/window" +import { getTab, listTabs, sendMsg2Tab } from "@api/chrome/tab" +import { getWindow } from "@api/chrome/window" import optionHolder from "@service/components/option-holder" import itemService, { type ItemIncContext } from "@service/item-service" import limitService from "@service/limit-service" @@ -7,7 +7,7 @@ import periodThrottler from '@service/throttler/period-throttler' import whitelistHolder from "@service/whitelist/holder" import { IS_ANDROID } from "@util/constant/environment" import { extractHostname } from "@util/pattern" -import badgeManager from '../badge-manager' +import badgeManager from "../badge-manager" async function handleTime(context: ItemIncContext, timeRange: [number, number], tabId: number | undefined): Promise { const { host, url } = context @@ -41,21 +41,26 @@ export async function handleTrackTimeEvent(event: timer.core.Event, senderTab: C await handleTime({ host, url, groupId }, [start, end], tabId) if (tabId) { - badgeManager.updateFocus({ tabId, url }) + const winTabs = await listTabs({ active: true, windowId }) + const firstActiveTab = winTabs?.[0] + // Cause there is no way to determine whether this tab is selected in screen-split mode + // So only show badge for first tab for screen-split mode + // @see #246 + firstActiveTab?.id === tabId && badgeManager.updateFocus({ tabId, url }) } } async function windowNotFocused(winId: number | undefined): Promise { if (IS_ANDROID) return false if (!winId) return true - const focusedWinId = await lastFocusedNormalWinId() - return focusedWinId !== winId + const window = await getWindow(winId) + return !window?.focused } async function tabNotActive(tabId: number | undefined): Promise { if (!tabId) return true - const lastActivated = await lastActivatedTabId() - return lastActivated !== tabId + const tab = await getTab(tabId) + return !tab?.active } async function sendLimitedMessage(items: timer.limit.Item[]) { From 36a4daf90d0cde89ccdabfd789de71893e498c87 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sat, 13 Dec 2025 01:35:53 +0800 Subject: [PATCH 036/174] 3.7.7 --- CHANGELOG.md | 5 +++++ package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 26d95eba5..4d3e0e46e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [3.7.7] - 2025-12-13 + +- Reverted tracking the time in split-screen mode for Chrome, which caused tracking stopping +- Support donut chart on the popup pages + ## [3.7.6] - 2025-12-10 - Supported tracking the time in split-screen mode for Chrome diff --git a/package.json b/package.json index ab41f4f38..747f77f84 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "3.7.6", + "version": "3.7.7", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { From db7fd3fc6aaf30e12f3e20f7aafa908043e99a0f Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sat, 13 Dec 2025 02:24:25 +0800 Subject: [PATCH 037/174] v3.7.8 --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4d3e0e46e..76f47dcb1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [3.7.8] - 2025-12-13 [For Chrome] + +- Same as v3.7.7 + ## [3.7.7] - 2025-12-13 - Reverted tracking the time in split-screen mode for Chrome, which caused tracking stopping diff --git a/package.json b/package.json index 747f77f84..a8bcc3a1a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "3.7.7", + "version": "3.7.8", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { From e6e97b8e62bef96651ad923eb8532a5fde3a689d Mon Sep 17 00:00:00 2001 From: sheepzh Date: Wed, 17 Dec 2025 00:48:41 +0800 Subject: [PATCH 038/174] chore: swap chrome and edge --- script/user-chart/render.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/user-chart/render.ts b/script/user-chart/render.ts index b026106a3..0cd76bb24 100644 --- a/script/user-chart/render.ts +++ b/script/user-chart/render.ts @@ -16,7 +16,7 @@ type EcOption = ComposeOption< | GridComponentOption > -const ALL_BROWSERS: Browser[] = ['firefox', 'edge', 'chrome'] +const ALL_BROWSERS: Browser[] = ['firefox', 'chrome', 'edge'] const POINT_COUNT = 500 From 918f14df561eef70db81dd91ed4910f8fb45debe Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 17 Dec 2025 08:51:17 +0800 Subject: [PATCH 039/174] i18n(download): download translations by bot (#651) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/popup/header-resource.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index 8d3c8ef22..9e00c4ae2 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -51,6 +51,7 @@ "tr": { "rate": "Bizi Değerlendirin", "showSiteName": "Site adını göster", - "showTopN": "En iyi {n} göster" + "showTopN": "En iyi {n} göster", + "donutChart": "Halka grafik olarak göster" } } \ No newline at end of file From d8d63aa7012e673b60779b5c81c7b44a3790e3d1 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Wed, 17 Dec 2025 23:06:11 +0800 Subject: [PATCH 040/174] fix: fix the sort of series --- script/user-chart/add.ts | 2 +- script/user-chart/common.ts | 7 +++++++ script/user-chart/render.ts | 15 ++++++++++----- script/user-chart/user-chart.d.ts | 6 ------ 4 files changed, 18 insertions(+), 12 deletions(-) delete mode 100644 script/user-chart/user-chart.d.ts diff --git a/script/user-chart/add.ts b/script/user-chart/add.ts index 5021576f5..880c2d398 100644 --- a/script/user-chart/add.ts +++ b/script/user-chart/add.ts @@ -8,7 +8,7 @@ import { } from "@src/api/gist" import fs from "fs" import { exitWith } from "../util/process" -import { descriptionOf, filenameOf, getExistGist, validateTokenFromEnv } from "./common" +import { type Browser, descriptionOf, filenameOf, getExistGist, type UserCount, validateTokenFromEnv } from "./common" type AddArgv = { browser: Browser diff --git a/script/user-chart/common.ts b/script/user-chart/common.ts index 6b69633d4..505fd3439 100644 --- a/script/user-chart/common.ts +++ b/script/user-chart/common.ts @@ -1,6 +1,13 @@ import { findTarget, type Gist } from "@api/gist" import { exitWith } from "../util/process" +export type Browser = + | 'chrome' + | 'firefox' + | 'edge' + +export type UserCount = Record + /** * Validate the token from environment variables */ diff --git a/script/user-chart/render.ts b/script/user-chart/render.ts index 0cd76bb24..57f45b2ce 100644 --- a/script/user-chart/render.ts +++ b/script/user-chart/render.ts @@ -8,7 +8,7 @@ import { } from "echarts" import { writeFileSync } from "fs" import { exit } from 'process' -import { filenameOf, getExistGist, validateTokenFromEnv } from "./common" +import { filenameOf, getExistGist, validateTokenFromEnv, type Browser, type UserCount } from "./common" type EcOption = ComposeOption< | LineSeriesOption @@ -175,10 +175,15 @@ const USER_COUNT_GIST_DESC = "User count of timer, auto-generated" const USER_COUNT_SVG_FILE_NAME = "user_count.svg" async function getOriginData(token: string): Promise { - const [firefox, edge, chrome]: UserCount[] = await Promise.all( - ALL_BROWSERS.map(b => getDataFromGist(token, b)) - ) - return { chrome, firefox, edge } + const result: OriginData = { + chrome: {}, + firefox: {}, + edge: {}, + } + for (const b of ALL_BROWSERS) { + result[b] = await getDataFromGist(token, b) + } + return result } /** diff --git a/script/user-chart/user-chart.d.ts b/script/user-chart/user-chart.d.ts deleted file mode 100644 index 56d0300c8..000000000 --- a/script/user-chart/user-chart.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -type Browser = - | 'chrome' - | 'firefox' - | 'edge' - -type UserCount = Record \ No newline at end of file From be0e9fffb9d6a7477d94da08b9f6fdb3b27d65b0 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Mon, 22 Dec 2025 23:28:21 +0800 Subject: [PATCH 041/174] feat: add the option to disable side panel (#655) --- src/api/chrome/sidePanel.ts | 33 +++++++++++++++++++ src/i18n/message/app/option-resource.json | 6 ++-- src/i18n/message/app/option.ts | 1 + .../components/AppearanceOption/index.tsx | 26 ++++++++++++++- 4 files changed, 63 insertions(+), 3 deletions(-) create mode 100644 src/api/chrome/sidePanel.ts diff --git a/src/api/chrome/sidePanel.ts b/src/api/chrome/sidePanel.ts new file mode 100644 index 000000000..74cf7961f --- /dev/null +++ b/src/api/chrome/sidePanel.ts @@ -0,0 +1,33 @@ +import { IS_ANDROID, IS_MV3 } from '@util/constant/environment' +import { handleError } from './common' + +// NOT SUPPORTED in Firefox +// Keep noticing at chrome.sidebarAction for Firefox +export const SIDE_PANEL_STATE_SUPPORTED_CONTROL = !!chrome.sidePanel?.setOptions + +export async function isSidePanelEnabled(): Promise { + if (IS_ANDROID || !SIDE_PANEL_STATE_SUPPORTED_CONTROL) return false + + if (IS_MV3) { + const result = await chrome.sidePanel.getOptions({}) + return result.enabled ?? true + } else { + return new Promise(resolve => chrome.sidePanel.getOptions({}, options => { + handleError('isSidePanelEnabled') + resolve(options.enabled ?? true) + })) + } +} + +export async function setSidePanelEnabled(enabled: boolean): Promise { + if (IS_ANDROID || !SIDE_PANEL_STATE_SUPPORTED_CONTROL) return + + if (IS_MV3) { + await chrome.sidePanel.setOptions({ enabled }) + } else { + return new Promise(resolve => chrome.sidePanel.setOptions({ enabled }, () => { + handleError('setSidePanelEnabled') + resolve() + })) + } +} \ No newline at end of file diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 21058e0ba..e05848e5f 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -30,7 +30,8 @@ "timed": "定时开启" } }, - "animationDuration": "图表初始动画的时长 {input}" + "animationDuration": "图表初始动画的时长 {input}", + "sidePanel": "{input} 是否启用侧边栏视图" }, "tracking": { "title": "统计", @@ -285,7 +286,8 @@ "timed": "Timed on" } }, - "animationDuration": "The duration of the chart's initial animation {input}" + "animationDuration": "The duration of the chart's initial animation {input}", + "sidePanel": "{input} Whether to enable the side panel" }, "tracking": { "title": "Tracking", diff --git a/src/i18n/message/app/option.ts b/src/i18n/message/app/option.ts index 85b56baf8..b067ac162 100644 --- a/src/i18n/message/app/option.ts +++ b/src/i18n/message/app/option.ts @@ -36,6 +36,7 @@ export type OptionMessage = { options: Omit, 'default'> } animationDuration: string + sidePanel: string } tracking: { title: string diff --git a/src/pages/app/components/Option/components/AppearanceOption/index.tsx b/src/pages/app/components/Option/components/AppearanceOption/index.tsx index a66f3fff8..8b6b41d38 100644 --- a/src/pages/app/components/Option/components/AppearanceOption/index.tsx +++ b/src/pages/app/components/Option/components/AppearanceOption/index.tsx @@ -5,7 +5,9 @@ * https://opensource.org/licenses/MIT */ +import { isSidePanelEnabled, setSidePanelEnabled, SIDE_PANEL_STATE_SUPPORTED_CONTROL } from '@api/chrome/sidePanel' import { type I18nKey, t, tWith } from "@app/locale" +import { useRequest } from '@hooks/useRequest' import { ALL_LOCALES, localeSameAsBrowser } from "@i18n" import localeMessages from "@i18n/message/common/locale" import optionService from "@service/option-service" @@ -39,6 +41,7 @@ function copy(target: timer.option.AppearanceOption, source: timer.option.Appear } const DEFAULT_ANIMA_DURATION = defaultAppearance().chartAnimationDuration +const DEFAULT_SIDE_PANEL_ENABLED = true const FOLLOW_BROWSER: I18nKey = msg => msg.option.followBrowser const _default = defineComponent((_props, ctx) => { @@ -46,9 +49,19 @@ const _default = defineComponent((_props, ctx) => { defaultValue: defaultAppearance, copy, onChange: async val => optionService.isDarkMode(val).then(toggle) }) + const { data: sidePanelEnabled, refresh: refreshSidePanel } = useRequest(isSidePanelEnabled, { + defaultValue: DEFAULT_SIDE_PANEL_ENABLED, + }) + const handleSidePanelChange = async (val: boolean) => { + await setSidePanelEnabled(val) + refreshSidePanel() + } ctx.expose({ - reset: () => copy(option, defaultAppearance()) + reset() { + handleSidePanelChange(DEFAULT_SIDE_PANEL_ENABLED) + copy(option, defaultAppearance()) + } } satisfies OptionInstance) const handleLocaleChange = (newVal: timer.option.LocaleOption) => { @@ -152,6 +165,17 @@ const _default = defineComponent((_props, ctx) => { /> } + {SIDE_PANEL_STATE_SUPPORTED_CONTROL && ( + msg.option.appearance.sidePanel} + defaultValue={DEFAULT_SIDE_PANEL_ENABLED} + > + handleSidePanelChange(!!v)} + /> + + )} msg.option.appearance.animationDuration} defaultValue={`${DEFAULT_ANIMA_DURATION}ms`} From 775dfe6f34987ee1644ed3fc37229434f569e48b Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 23 Dec 2025 00:26:25 +0800 Subject: [PATCH 042/174] feat: jump to existing app tab from popup page (#653) --- package.json | 20 +++--- src/api/chrome/tab.ts | 13 ++++ src/pages/popup/components/Header/index.tsx | 67 +++++++++++---------- 3 files changed, 58 insertions(+), 42 deletions(-) diff --git a/package.json b/package.json index a8bcc3a1a..4bba5b5ee 100644 --- a/package.json +++ b/package.json @@ -28,18 +28,18 @@ }, "license": "MIT", "devDependencies": { - "@crowdin/crowdin-api-client": "^1.49.0", + "@crowdin/crowdin-api-client": "^1.51.1", "@emotion/babel-plugin": "^11.13.5", "@emotion/css": "^11.13.5", - "@rsdoctor/rspack-plugin": "^1.3.12", - "@rspack/cli": "^1.6.6", - "@rspack/core": "^1.6.6", - "@swc/core": "^1.15.3", + "@rsdoctor/rspack-plugin": "^1.3.16", + "@rspack/cli": "^1.6.8", + "@rspack/core": "^1.6.8", + "@swc/core": "^1.15.7", "@swc/jest": "^0.2.39", "@types/chrome": "0.1.32", "@types/decompress": "^4.2.7", "@types/jest": "^30.0.0", - "@types/node": "^24.10.1", + "@types/node": "^25.0.3", "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.0.0", @@ -54,7 +54,7 @@ "postcss": "^8.5.6", "postcss-loader": "^8.2.0", "postcss-rtlcss": "^5.7.1", - "puppeteer": "^24.32.0", + "puppeteer": "^24.34.0", "ts-loader": "^9.5.4", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", @@ -63,10 +63,10 @@ "dependencies": { "@element-plus/icons-vue": "^2.3.2", "echarts": "^6.0.0", - "element-plus": "2.12.0", + "element-plus": "2.13.0", "punycode": "^2.3.1", - "vue": "^3.5.25", - "vue-router": "^4.6.3" + "vue": "^3.5.26", + "vue-router": "^4.6.4" }, "engines": { "node": ">=22" diff --git a/src/api/chrome/tab.ts b/src/api/chrome/tab.ts index 183ca09cf..a0d36d3c8 100644 --- a/src/api/chrome/tab.ts +++ b/src/api/chrome/tab.ts @@ -5,6 +5,7 @@ * https://opensource.org/licenses/MIT */ +import { IS_MV3 } from '@util/constant/environment' import { handleError } from "./common" export function getTab(id: number): Promise { @@ -122,4 +123,16 @@ export function onTabUpdated(handler: TabHandler): void { handleError("tabUpdated") handler(tabId, changeInfo, tab) }) +} + +export function updateTab(tabId: number, updateProperties: chrome.tabs.UpdateProperties): Promise { + if (IS_MV3) { + return chrome.tabs.update(tabId, updateProperties) + } + return new Promise((resolve) => { + chrome.tabs.update(tabId, updateProperties, (tab) => { + handleError("updateTab") + resolve(tab) + }) + }) } \ No newline at end of file diff --git a/src/pages/popup/components/Header/index.tsx b/src/pages/popup/components/Header/index.tsx index 6b632f601..42de85979 100644 --- a/src/pages/popup/components/Header/index.tsx +++ b/src/pages/popup/components/Header/index.tsx @@ -1,4 +1,4 @@ -import { createTab } from "@api/chrome/tab" +import { createTab, listTabs, updateTab } from "@api/chrome/tab" import { View } from "@element-plus/icons-vue" import Flex from "@pages/components/Flex" import LangSelect from "@popup/components/Header/LangSelect" @@ -6,47 +6,50 @@ import { t } from "@popup/locale" import { IS_ANDROID } from "@util/constant/environment" import { getAppPageUrl } from "@util/constant/url" import { ElLink } from "element-plus" -import { defineComponent } from "vue" +import { FunctionalComponent } from "vue" import DarkSwitch from "./DarkSwitch" import Github from "./Github" import Logo from "./Logo" import Option from "./Option" import RateUs from './RateUs' -const Header = defineComponent<{}>(() => { - const handleAllFuncClick = () => { - const appPageUrl = getAppPageUrl() - if (IS_ANDROID) { - location.replace(appPageUrl) - } else { - createTab(appPageUrl) +const openAppPage = async () => { + const appPageUrl = getAppPageUrl() + if (IS_ANDROID) return location.replace(appPageUrl) + try { + const tabs = await listTabs({ currentWindow: true }) + // If there are non-highlighted app page tab, jump to it + const tabId2Jump = tabs.find(tab => !!tab.url?.startsWith(appPageUrl) && !tab.highlighted)?.id + if (tabId2Jump) { + await updateTab(tabId2Jump, { highlighted: true }) + return } + } catch (ignored) { } + // fall back to open new tab + await createTab(appPageUrl) +} - return () => ( - - +const Header: FunctionalComponent = () => ( + + + - - - - {t(msg => msg.base.allFunction)} - - - - - - + + + {t(msg => msg.base.allFunction)} + - - ) -}) + + + + + + +) + +Header.displayName = "PopupHeader" export default Header \ No newline at end of file From d892bd25b56a5cb259310822f910318b437a243f Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 23 Dec 2025 00:28:40 +0800 Subject: [PATCH 043/174] 3.7.9 --- CHANGELOG.md | 7 ++++++- package.json | 2 +- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 76f47dcb1..ec550c4c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [3.7.9] - 2025-12-23 + +- Optimized UI +- Supported to disable the side panel for Chrome and Edge + ## [3.7.8] - 2025-12-13 [For Chrome] - Same as v3.7.7 @@ -11,7 +16,7 @@ It is worth mentioning that the release time of each change refers to the time w ## [3.7.7] - 2025-12-13 - Reverted tracking the time in split-screen mode for Chrome, which caused tracking stopping -- Support donut chart on the popup pages +- Supported donut chart on the popup pages ## [3.7.6] - 2025-12-10 diff --git a/package.json b/package.json index 4bba5b5ee..7eea93ea0 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "3.7.8", + "version": "3.7.9", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { From 21ff2e0e91eb6abf87cd545cef4cabcc56f8c1d0 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 23 Dec 2025 01:04:57 +0800 Subject: [PATCH 044/174] fix: highlighted tab not active on Firefox --- src/pages/popup/components/Header/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/pages/popup/components/Header/index.tsx b/src/pages/popup/components/Header/index.tsx index 42de85979..651e839b8 100644 --- a/src/pages/popup/components/Header/index.tsx +++ b/src/pages/popup/components/Header/index.tsx @@ -21,7 +21,7 @@ const openAppPage = async () => { // If there are non-highlighted app page tab, jump to it const tabId2Jump = tabs.find(tab => !!tab.url?.startsWith(appPageUrl) && !tab.highlighted)?.id if (tabId2Jump) { - await updateTab(tabId2Jump, { highlighted: true }) + await updateTab(tabId2Jump, { highlighted: true, active: true }) return } } catch (ignored) { From 214d02430a76fd34c1a95634ee5d597d336017e8 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 23 Dec 2025 01:05:46 +0800 Subject: [PATCH 045/174] v3.7.10 --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ec550c4c9..8c3d119e6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [3.7.10] - 2025-12-23 + +- Fixed a bug on Firefox + ## [3.7.9] - 2025-12-23 - Optimized UI diff --git a/package.json b/package.json index 7eea93ea0..923d848e9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "3.7.9", + "version": "3.7.10", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { From 6e5ce36aad2e1ae957b91d2d81abefd6b3969cec Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 24 Dec 2025 18:28:08 +0800 Subject: [PATCH 046/174] i18n(download): download translations by bot (#657) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/popup/header-resource.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index 9e00c4ae2..d4d6543bf 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -8,7 +8,8 @@ "zh_CN": { "rate": "评分", "showSiteName": "显示网站名称", - "showTopN": "显示前 {n} 名" + "showTopN": "显示前 {n} 名", + "donutChart": "以圆环图显示" }, "zh_TW": { "rate": "評分", From 872d98539365b931a4e7a86a79b0ff508417badc Mon Sep 17 00:00:00 2001 From: sheepie Date: Thu, 25 Dec 2025 20:49:00 +0800 Subject: [PATCH 047/174] chore: use form template for feature request --- .../ISSUE_TEMPLATE/feature-request-------.md | 10 ----- .github/ISSUE_TEMPLATE/feature-request.yaml | 45 +++++++++++++++++++ 2 files changed, 45 insertions(+), 10 deletions(-) delete mode 100644 .github/ISSUE_TEMPLATE/feature-request-------.md create mode 100644 .github/ISSUE_TEMPLATE/feature-request.yaml diff --git a/.github/ISSUE_TEMPLATE/feature-request-------.md b/.github/ISSUE_TEMPLATE/feature-request-------.md deleted file mode 100644 index 5db3c2de7..000000000 --- a/.github/ISSUE_TEMPLATE/feature-request-------.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: Feature request / 需求提交 -about: Suggest an idea for this project / 说出你宝贵的需求! -title: "[FEATURE]" -labels: '' -assignees: '' - ---- - - diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml new file mode 100644 index 000000000..fcf43128e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yaml @@ -0,0 +1,45 @@ +name: Feature Request +title: "[FEAT] " +labels: feature +about: Suggest an idea for this project +body: + - type: markdown + attributes: + value: | + Tell us your wants~ + - type: dropdown + id: browser + validations: + required: true + attributes: + label: Browser + multiple: true + description: Which browser are you using? (Chrome/Firefox/Edge/Other) + options: + - Chrome + - Firefox + - Edge + - Other + default: 0 + - type: dropdown + id: os + validations: + required: false + attributes: + label: OS + description: Which operating system are you using? (Mac/Linux/Win, Optional) + options: + - Win + - Mac + - Linux + - Android + - Other + default: 0 + - type: textarea + id: description + validations: + required: false + attributes: + label: Feature Description + description: Please describe the feature in detail. + placeholder: "1. What you want.\n2. Why you want it.\n3. Any additional context." From b543db5aad44de5220e726cf2035bd68f77da29f Mon Sep 17 00:00:00 2001 From: sheepie Date: Thu, 25 Dec 2025 20:49:33 +0800 Subject: [PATCH 048/174] chore: use form template for feature request --- .github/ISSUE_TEMPLATE/feature-request.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml index fcf43128e..8e995e511 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/feature-request.yaml @@ -1,7 +1,7 @@ name: Feature Request title: "[FEAT] " labels: feature -about: Suggest an idea for this project +description: Suggest an idea for this project body: - type: markdown attributes: From b0aff2a635b3749312066c123f5ae0616f1d41a3 Mon Sep 17 00:00:00 2001 From: sheepie Date: Thu, 25 Dec 2025 20:53:10 +0800 Subject: [PATCH 049/174] chore: use form template for feature request --- .github/ISSUE_TEMPLATE/feature-request.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml index 8e995e511..889ce99bd 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/feature-request.yaml @@ -1,5 +1,5 @@ name: Feature Request -title: "[FEAT] " +title: "[FEATURE] " labels: feature description: Suggest an idea for this project body: @@ -14,7 +14,7 @@ body: attributes: label: Browser multiple: true - description: Which browser are you using? (Chrome/Firefox/Edge/Other) + description: Which browsers are you using? (Chrome/Firefox/Edge/Other) options: - Chrome - Firefox From 269a9525131cc7d03f085825b5e3578bdb70ecac Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 26 Dec 2025 10:12:39 +0800 Subject: [PATCH 050/174] i18n(download): download translations by bot (#659) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/option-resource.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index e05848e5f..ac5110e7c 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -1384,7 +1384,8 @@ "timed": "Zamanlanmış" } }, - "animationDuration": "Grafiğin ilk animasyonunun süresi {input}" + "animationDuration": "Grafiğin ilk animasyonunun süresi {input}", + "sidePanel": "{input} Yan panelin gösterilip gösterilmeyeceğini seçin" }, "tracking": { "title": "İzleme", From 5c7c82d8b671b3faed5d78e5f0fd2e7df944185f Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sat, 27 Dec 2025 00:26:27 +0800 Subject: [PATCH 051/174] fix: remove scrollbars of the charts --- package.json | 2 +- src/pages/popup/components/Percentage/index.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/package.json b/package.json index 923d848e9..b71935de6 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@crowdin/crowdin-api-client": "^1.51.1", "@emotion/babel-plugin": "^11.13.5", "@emotion/css": "^11.13.5", - "@rsdoctor/rspack-plugin": "^1.3.16", + "@rsdoctor/rspack-plugin": "^1.4.0", "@rspack/cli": "^1.6.8", "@rspack/core": "^1.6.8", "@swc/core": "^1.15.7", diff --git a/src/pages/popup/components/Percentage/index.tsx b/src/pages/popup/components/Percentage/index.tsx index 0632fc660..d7b0fa614 100644 --- a/src/pages/popup/components/Percentage/index.tsx +++ b/src/pages/popup/components/Percentage/index.tsx @@ -21,7 +21,7 @@ const Percentage = defineComponent(() => { {query.mergeMethod === 'cate' ? From 864c9062c25630cb5f5e815838027463029f046c Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 30 Dec 2025 21:36:42 +0800 Subject: [PATCH 052/174] fix: refreshing report table after category change --- src/pages/app/components/Report/ReportTable/index.tsx | 9 +-------- .../app/components/SiteManage/SiteManageTable/index.tsx | 4 ++-- 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/src/pages/app/components/Report/ReportTable/index.tsx b/src/pages/app/components/Report/ReportTable/index.tsx index dbd0f6a53..32972008c 100644 --- a/src/pages/app/components/Report/ReportTable/index.tsx +++ b/src/pages/app/components/Report/ReportTable/index.tsx @@ -108,13 +108,6 @@ const _default = defineComponent((_, ctx) => { () => filter.siteMerge, ], () => table.value?.doLayout?.()) - const handleCateChange = (key: timer.site.SiteKey, newCateId: number | undefined) => { - data.value?.list - ?.filter(isSite) - ?.filter(item => siteEqual(item.siteKey, key)) - ?.forEach(i => i.cateId = newCateId) - } - return () => ( @@ -145,7 +138,7 @@ const _default = defineComponent((_, ctx) => { /> } {visible.value.group && } - {visible.value.cate && } + {visible.value.cate && } {runColVisible.value && } diff --git a/src/pages/app/components/SiteManage/SiteManageTable/index.tsx b/src/pages/app/components/SiteManage/SiteManageTable/index.tsx index ceab99fcf..50cf8245a 100644 --- a/src/pages/app/components/SiteManage/SiteManageTable/index.tsx +++ b/src/pages/app/components/SiteManage/SiteManageTable/index.tsx @@ -10,7 +10,7 @@ import Flex from "@pages/components/Flex" import { removeIconUrl, saveSiteRunState } from '@service/site-service' import { ElSwitch, ElTable, ElTableColumn, type RenderRowData } from "element-plus" import { defineComponent } from "vue" -import Category from "../../common/category/CategoryEditable" +import CategoryEditable from "../../common/category/CategoryEditable" import { useSiteManageTable } from '../useSiteManage' import AliasColumn from "./column/AliasColumn" import OperationColumn from "./column/OperationColumn" @@ -70,7 +70,7 @@ const _default = defineComponent<{}>(() => { minWidth={140} align="center" v-slots={({ row }: RenderRowData) => ( - row.cate = val} /> + row.cate = val} /> )} /> Date: Tue, 30 Dec 2025 21:42:17 +0800 Subject: [PATCH 053/174] fix: bypass the password of time limit --- .../components/Option/components/LimitOption/usePswEdit.tsx | 4 ++-- .../components/Option/components/LimitOption/useVerify.ts | 2 +- src/pages/app/util/limit.tsx | 5 ++++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/pages/app/components/Option/components/LimitOption/usePswEdit.tsx b/src/pages/app/components/Option/components/LimitOption/usePswEdit.tsx index a2953f5a5..3cff5f95d 100644 --- a/src/pages/app/components/Option/components/LimitOption/usePswEdit.tsx +++ b/src/pages/app/components/Option/components/LimitOption/usePswEdit.tsx @@ -36,10 +36,10 @@ export const usePswEdit = (options: Options) => { message: ( msg.option.limit.level.pswFormLabel)}> - + msg.option.limit.level.pswFormAgain)}> - + ), diff --git a/src/pages/app/components/Option/components/LimitOption/useVerify.ts b/src/pages/app/components/Option/components/LimitOption/useVerify.ts index dab534573..cd4e98498 100644 --- a/src/pages/app/components/Option/components/LimitOption/useVerify.ts +++ b/src/pages/app/components/Option/components/LimitOption/useVerify.ts @@ -7,7 +7,7 @@ export const useVerify = (option: timer.option.LimitOption) => { const verify = async (): Promise => { if (verified.value) return - const items = await limitService.select({ filterDisabled: true, url: undefined }) + const items = await limitService.select() const triggerResults = await Promise.all((items || []).map(judgeVerificationRequired)) const anyTrigger = triggerResults.some(t => !!t) if (anyTrigger) { diff --git a/src/pages/app/util/limit.tsx b/src/pages/app/util/limit.tsx index 6ebc390b6..8e0b2378e 100644 --- a/src/pages/app/util/limit.tsx +++ b/src/pages/app/util/limit.tsx @@ -5,7 +5,7 @@ import { I18nResultItem, locale } from "@i18n" import { getCssVariable } from "@pages/util/style" import verificationProcessor from "@service/limit-service/verification/processor" import { dateMinute2Idx, hasLimited, isEnabledAndEffective } from "@util/limit" -import { ElMessage, ElMessageBox, type ElMessageBoxOptions, useId } from "element-plus" +import { ElMessage, ElMessageBox, type ElMessageBoxOptions, type InputType, useId } from "element-plus" import { defineComponent, onMounted, ref, type VNode } from "vue" /** @@ -101,6 +101,7 @@ export function processVerification(option: timer.option.LimitOption, context?: message:

{t(msg => msg.limit.verification.strictTip)}
, }).catch(() => { })) } + let inputType: InputType | undefined let answerValue: string | undefined let messageNode: I18nResultItem[] | undefined | I18nResultItem let incorrectMessage: string @@ -109,6 +110,7 @@ export function processVerification(option: timer.option.LimitOption, context?: answerValue = limitPassword messageNode = t(msg => msg.limit.verification.pswInputTip) incorrectMessage = t(msg => msg.limit.verification.incorrectPsw) + inputType = 'password' } else if (limitLevel === 'verification') { const pair = verificationProcessor.generate(limitVerifyDifficulty ?? 'easy', locale) const { prompt, promptParam, answer, second = 60 } = pair || {} @@ -138,6 +140,7 @@ export function processVerification(option: timer.option.LimitOption, context?: title: '', message:
{messageNode}
, showInput: true, + inputType, showCancelButton: true, showClose: false, confirmButtonText: countdown ? btnText(countdown) : okBtnTxt, From e1d8fab276d7ce21c0e82e2fcd629e7daba81756 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 30 Dec 2025 21:45:15 +0800 Subject: [PATCH 054/174] v3.7.11 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c3d119e6..72a58bb12 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [3.7.11] - 2025-12-30 + +- Fixed an issue that allowed bypassing time-limited passwords in certain situations +- Fixed an issue where report tables did not refresh after category changes +- Removed scrollbars from pop-up charts for improved user experience + ## [3.7.10] - 2025-12-23 - Fixed a bug on Firefox diff --git a/package.json b/package.json index b71935de6..ef0ab94b1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "3.7.10", + "version": "3.7.11", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { From d072ee9b25cb30938b2dddd73c810452132acbfc Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 1 Jan 2026 10:37:53 +0800 Subject: [PATCH 055/174] chore(psl): update PSL list by bot (#660) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/util/psl/rules.json | 159 +++++++++++++++++++++++++++++++++++++--- 1 file changed, 147 insertions(+), 12 deletions(-) diff --git a/src/util/psl/rules.json b/src/util/psl/rules.json index c502f6504..cc2ec7f03 100644 --- a/src/util/psl/rules.json +++ b/src/util/psl/rules.json @@ -312,6 +312,7 @@ "loginline": 1, "lovable": 1, "luyani": 1, + "magicpatterns": 1, "medusajs": 1, "messerli": 1, "mocha": 1, @@ -327,6 +328,7 @@ "nyat": 1, "on-fleek": 1, "ondigitalocean": 1, + "onhercules": 1, "railway": { "c": { "up": 1 @@ -358,7 +360,7 @@ } } }, - "storipress": 1, + "sprites": 1, "streamlit": 1, "telebit": 1, "typedream": 1, @@ -382,7 +384,86 @@ }, "l": 1 }, - "apple": 1, + "apple": { + "c": { + "int": { + "c": { + "cloud": { + "c": { + "*": 1, + "r": { + "c": { + "*": 1, + "ap-north-1": { + "c": { + "*": 1 + } + }, + "ap-south-1": { + "c": { + "*": 1 + } + }, + "ap-south-2": { + "c": { + "*": 1 + } + }, + "eu-central-1": { + "c": { + "*": 1 + } + }, + "eu-north-1": { + "c": { + "*": 1 + } + }, + "us-central-1": { + "c": { + "*": 1 + } + }, + "us-central-2": { + "c": { + "*": 1 + } + }, + "us-east-1": { + "c": { + "*": 1 + } + }, + "us-east-2": { + "c": { + "*": 1 + } + }, + "us-west-1": { + "c": { + "*": 1 + } + }, + "us-west-2": { + "c": { + "*": 1 + } + }, + "us-west-3": { + "c": { + "*": 1 + } + } + } + } + } + } + }, + "l": 1 + } + }, + "l": 1 + }, "aq": 1, "aquarelle": 1, "ar": { @@ -1859,7 +1940,12 @@ }, "l": 1 }, - "case": 1, + "case": { + "c": { + "sav": 1 + }, + "l": 1 + }, "cash": 1, "casino": 1, "cat": 1, @@ -1870,13 +1956,17 @@ "cbre": 1, "cc": { "c": { + "ccwu": 1, "cleverapps": 1, "cloud-ip": 1, "cloudns": 1, "csx": 1, + "ec": 1, + "eu": 1, "fantasyleague": 1, "ftpaccess": 1, "game-server": 1, + "gu": 1, "myphotos": 1, "scrapping": 1, "spawn": { @@ -1884,12 +1974,15 @@ "instances": 1 } }, - "twmail": 1 + "twmail": 1, + "uk": 1, + "us": 1 }, "l": 1 }, "cd": { "c": { + "cc": 1, "gov": 1 }, "l": 1 @@ -1982,6 +2075,7 @@ "net": 1, "or": 1, "org": 1, + "us": 1, "xn--aroport-bya": 1 }, "l": 1 @@ -2017,6 +2111,7 @@ "clothing": 1, "cloud": { "c": { + "antagonist": 1, "axarnet": { "c": { "es-1": 1 @@ -2450,6 +2545,20 @@ "*": 1 } }, + "rdpa": { + "c": { + "clusters": { + "c": { + "*": 1 + } + }, + "srvrless": { + "c": { + "*": 1 + } + } + } + }, "repl": { "c": { "id": 1 @@ -4171,6 +4280,11 @@ }, "l": 1 }, + "emergentagent": { + "c": { + "preview": 1 + } + }, "encoreapi": 1, "est-a-la-maison": 1, "est-a-la-masion": 1, @@ -4270,6 +4384,8 @@ "hatenablog": 1, "hatenadiary": 1, "health-carereform": 1, + "hercules-app": 1, + "hercules-dev": 1, "herokuapp": 1, "hk": 1, "hobby-site": 1, @@ -4410,6 +4526,7 @@ "*": 1 } }, + "magicpatternsapp": 1, "massivegrid": { "c": { "paas": 1 @@ -5673,7 +5790,6 @@ "chirurgiens-dentistes-en-france": 1, "com": 1, "dedibox": 1, - "en-root": 1, "experts-comptables": 1, "fbx-os": 1, "fbxos": 1, @@ -6246,6 +6362,7 @@ "gov": 1, "gujarat": 1, "ind": 1, + "indevs": 1, "info": 1, "int": 1, "internet": 1, @@ -6503,7 +6620,6 @@ "client": 1 } }, - "shw": 1, "stolos": { "c": { "*": 1 @@ -9275,6 +9391,7 @@ "kfh": 1, "kg": { "c": { + "ae": 1, "com": 1, "edu": 1, "gov": 1, @@ -10161,7 +10278,6 @@ "dynu": 1, "dynv6": 1, "eating-organic": 1, - "edgeapp": 1, "edgekey": 1, "edgekey-staging": 1, "edgesuite": 1, @@ -10420,6 +10536,7 @@ "*": 1 } }, + "appwrite": 1, "arvo": 1, "azimuth": 1, "co": 1, @@ -12450,7 +12567,6 @@ "int": 1, "kalmykia": 1, "kustanai": 1, - "lk3": 1, "marine": 1, "mcdir": { "c": { @@ -12632,12 +12748,15 @@ "science": 1, "scot": { "c": { + "co": 1, "gov": { "c": { "service": 1 }, "l": 1 - } + }, + "me": 1, + "org": 1 }, "l": 1 }, @@ -12835,7 +12954,12 @@ "l": 1 }, "sj": 1, - "sk": 1, + "sk": { + "c": { + "org": 1 + }, + "l": 1 + }, "ski": 1, "skin": 1, "sky": 1, @@ -12927,6 +13051,11 @@ }, "st": { "c": { + "cn": { + "c": { + "*": 1 + } + }, "co": 1, "com": 1, "consulado": 1, @@ -13217,6 +13346,7 @@ "gov": 1, "mil": 1, "net": 1, + "nett": 1, "org": 1, "oya": 1, "quickconnect": { @@ -13932,7 +14062,6 @@ }, "l": 1 }, - "platterp": 1, "pointto": 1, "pr": { "c": { @@ -14059,6 +14188,7 @@ "com": 1, "edu": 1, "gub": 1, + "gv": 1, "mil": 1, "net": 1, "org": 1 @@ -14309,7 +14439,12 @@ "wme": 1, "wolterskluwer": 1, "woodside": 1, - "work": 1, + "work": { + "c": { + "imagine-proxy": 1 + }, + "l": 1 + }, "works": 1, "world": 1, "wow": 1, From b5011f5a06a805b06139faeef90055fa1a6c2c81 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 3 Jan 2026 13:46:35 +0800 Subject: [PATCH 056/174] i18n(download): download translations by bot (#661) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/limit-resource.json | 1 + src/i18n/message/app/option-resource.json | 5 +++-- src/i18n/message/common/button-resource.json | 4 +++- src/i18n/message/popup/header-resource.json | 5 ++++- 4 files changed, 11 insertions(+), 4 deletions(-) diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index 1a7b968b3..eb2bb22ba 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -416,6 +416,7 @@ "fr": { "filterDisabled": "Activé uniquement", "wildcardTip": "Vous pouvez utiliser des wildcards pour correspondre à des sous-domaines ou sous-pages, et utiliser le \"+\" comme préfixe pour exclure des sous-pages !", + "emptyTips": "Cliquez ici pour créer une règle !", "item": { "name": "Nom de règle", "condition": "URL restreinte", diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index ac5110e7c..3aea797b8 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -1044,12 +1044,13 @@ "darkMode": { "label": "Mode sombre {input}", "options": { - "on": "Toujours On", + "on": "Toujours activé", "off": "Toujours éteint", "timed": "Horaire" } }, - "animationDuration": "La durée de l'animation initiale du graphique {input}" + "animationDuration": "La durée de l'animation initiale du graphique {input}", + "sidePanel": "{input} Autoriser l'affichage du panneau latéral" }, "tracking": { "title": "Statistiques", diff --git a/src/i18n/message/common/button-resource.json b/src/i18n/message/common/button-resource.json index 49a19b96a..b29a6a85f 100644 --- a/src/i18n/message/common/button-resource.json +++ b/src/i18n/message/common/button-resource.json @@ -203,7 +203,9 @@ "clear": "Effacer", "enable": "Activer", "batchEnable": "Activation par Lot", - "batchDisable": "Désactivation par Lot" + "batchDisable": "Désactivation par Lot", + "collapse": "Réduire", + "expand": "Développer" }, "ru": { "create": "Создать", diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index d4d6543bf..c61590eb1 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -39,7 +39,10 @@ "showTopN": "Top {n} anzeigen" }, "fr": { - "rate": "Noter" + "rate": "Évaluez-nous", + "showSiteName": "Afficher le nom du site", + "showTopN": "Afficher les {n} premiers", + "donutChart": "Affichés sous forme de graphiques en anneau" }, "ru": { "rate": "Оценить", From 0b9945394ea3ccacce5b974a988a43c52f5598ec Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 9 Jan 2026 21:08:38 +0800 Subject: [PATCH 057/174] fix: timeline duplicated (#662) --- package.json | 10 +++++----- src/content-script/timeline.ts | 21 ++++++++++++--------- 2 files changed, 17 insertions(+), 14 deletions(-) diff --git a/package.json b/package.json index ef0ab94b1..e0edb2561 100644 --- a/package.json +++ b/package.json @@ -32,9 +32,9 @@ "@emotion/babel-plugin": "^11.13.5", "@emotion/css": "^11.13.5", "@rsdoctor/rspack-plugin": "^1.4.0", - "@rspack/cli": "^1.6.8", - "@rspack/core": "^1.6.8", - "@swc/core": "^1.15.7", + "@rspack/cli": "^1.7.1", + "@rspack/core": "^1.7.1", + "@swc/core": "^1.15.8", "@swc/jest": "^0.2.39", "@types/chrome": "0.1.32", "@types/decompress": "^4.2.7", @@ -43,7 +43,7 @@ "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.0.0", - "commitlint": "^20.2.0", + "commitlint": "^20.3.1", "css-loader": "^7.1.2", "decompress": "^4.2.1", "husky": "^9.1.7", @@ -63,7 +63,7 @@ "dependencies": { "@element-plus/icons-vue": "^2.3.2", "echarts": "^6.0.0", - "element-plus": "2.13.0", + "element-plus": "2.13.1", "punycode": "^2.3.1", "vue": "^3.5.26", "vue-router": "^4.6.4" diff --git a/src/content-script/timeline.ts b/src/content-script/timeline.ts index 47eddc29d..fc008f919 100644 --- a/src/content-script/timeline.ts +++ b/src/content-script/timeline.ts @@ -7,22 +7,23 @@ class TimelineCollector { * Bind page visibility and focus events */ init(): void { - document.addEventListener('visibilitychange', () => { - if (document.hidden) { - this.collect() - } else { + const onStateChange = () => { + if (!document.hidden && document.hasFocus()) { this.startTracking() + } else { + this.collect() } - }) + } - window.addEventListener('focus', () => this.startTracking()) - window.addEventListener('blur', () => this.collect()) + document.addEventListener('visibilitychange', onStateChange) + window.addEventListener('focus', onStateChange) + window.addEventListener('blur', onStateChange) window.addEventListener('beforeunload', () => this.collect()) if (document.readyState === 'complete') { - this.startTracking() + onStateChange() } else { - window.addEventListener('load', () => this.startTracking()) + window.addEventListener('load', onStateChange) } } @@ -30,6 +31,8 @@ class TimelineCollector { * Start tracking current page */ public startTracking(): void { + if (document.hidden || !document.hasFocus()) return + if (this.startTime !== null) return this.startTime = Date.now() } From 0ec89787ce951f13a9908ff636fce15911700772 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 9 Jan 2026 21:09:03 +0800 Subject: [PATCH 058/174] fix: remove scrollbars of charts --- .../components/Dashboard/DashboardCard.tsx | 1 + .../Dashboard/components/Indicator.tsx | 58 ++++++++++--------- 2 files changed, 31 insertions(+), 28 deletions(-) diff --git a/src/pages/app/components/Dashboard/DashboardCard.tsx b/src/pages/app/components/Dashboard/DashboardCard.tsx index e3353f8c6..7e2f5698b 100644 --- a/src/pages/app/components/Dashboard/DashboardCard.tsx +++ b/src/pages/app/components/Dashboard/DashboardCard.tsx @@ -27,6 +27,7 @@ const DashboardCard: FunctionalComponent = ({ span, height, width: '100%', height: cvtPxScale(bodyHeight ?? '100%'), boxSizing: 'border-box', + overflow: 'hidden', }} v-slots={ctx.slots} /> diff --git a/src/pages/app/components/Dashboard/components/Indicator.tsx b/src/pages/app/components/Dashboard/components/Indicator.tsx index a12d12d5e..7cc461d7e 100644 --- a/src/pages/app/components/Dashboard/components/Indicator.tsx +++ b/src/pages/app/components/Dashboard/components/Indicator.tsx @@ -14,7 +14,7 @@ import Flex from "@pages/components/Flex" import { selectSite } from "@service/stat-service" import { calcMostPeriodOf2Hours } from "@util/period" import { getStartOfDay, MILL_PER_DAY, MILL_PER_MINUTE } from "@util/time" -import { ElIcon } from "element-plus" +import { ElIcon, ElScrollbar } from "element-plus" import { computed, defineComponent, toRef, type VNode } from "vue" type _Value = { @@ -102,34 +102,36 @@ const _default = defineComponent(() => { const most2HourParam = computed(() => computeMost2HourParam(data.value)) return () => ( - - - - - + + + + + + + + msg.dashboard.indicator.installedDays} + param={{ number: data.value?.installedDays || 0 }} + duration={1.5} + /> + msg.dashboard.indicator.visitCount} + param={{ visit: data.value?.visits || 0, site: data.value?.sites || 0 }} + duration={1.75} + /> + msg.dashboard.indicator.browsingTime} + param={{ minute: Math.floor((data.value?.browsingTime || 0) / MILL_PER_MINUTE) }} + duration={2} + /> + msg.dashboard.indicator.mostUse} + param={most2HourParam.value} + duration={2.25} + /> - msg.dashboard.indicator.installedDays} - param={{ number: data.value?.installedDays || 0 }} - duration={1.5} - /> - msg.dashboard.indicator.visitCount} - param={{ visit: data.value?.visits || 0, site: data.value?.sites || 0 }} - duration={1.75} - /> - msg.dashboard.indicator.browsingTime} - param={{ minute: Math.floor((data.value?.browsingTime || 0) / MILL_PER_MINUTE) }} - duration={2} - /> - msg.dashboard.indicator.mostUse} - param={most2HourParam.value} - duration={2.25} - /> - + ) }) From 89fd7926bf45d704d11918f7b1fe362392d1310c Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 9 Jan 2026 21:11:49 +0800 Subject: [PATCH 059/174] v3.7.12 --- CHANGELOG.md | 5 +++++ package.json | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 72a58bb12..70a097a31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [3.7.12] - 2026-01-09 + +- The first version of new year! +- Fixed some bugs + ## [3.7.11] - 2025-12-30 - Fixed an issue that allowed bypassing time-limited passwords in certain situations diff --git a/package.json b/package.json index e0edb2561..96d3dd685 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "3.7.11", + "version": "3.7.12", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { @@ -71,4 +71,4 @@ "engines": { "node": ">=22" } -} \ No newline at end of file +} From 9198472dbe680ad2f3b3c5a83f246e9826de2440 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 10 Jan 2026 18:21:11 +0800 Subject: [PATCH 060/174] i18n(download): download translations by bot (#663) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/option-resource.json | 3 ++- src/i18n/message/app/whitelist-resource.json | 1 + src/i18n/message/popup/header-resource.json | 3 ++- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 3aea797b8..a91b5dff0 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -540,7 +540,8 @@ "timed": "Ativo por tempo" } }, - "animationDuration": "Duração da animação inicial {input}" + "animationDuration": "Duração da animação inicial {input}", + "sidePanel": "{input} Se ativar ou não o painel lateral" }, "tracking": { "title": "Estatísticas", diff --git a/src/i18n/message/app/whitelist-resource.json b/src/i18n/message/app/whitelist-resource.json index 7f0f3085d..cce864819 100644 --- a/src/i18n/message/app/whitelist-resource.json +++ b/src/i18n/message/app/whitelist-resource.json @@ -45,6 +45,7 @@ "infoAlertTitle": "Pode adicionar sites à lista de permissões nesta página", "infoAlert0": "Os sites na lista de permissões não serão contabilizados", "infoAlert1": "Os sites na lista de permissões não serão restringidos", + "infoAlert2": "Podes usar wildcards(*) para corresponder a vários sites, como *.example.com/**, e usar + como prefixo para excluir sites específicos, como +need.example.com/**", "errorInput": "URL do site inválido" }, "uk": { diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index c61590eb1..c274c2e2b 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -23,7 +23,8 @@ }, "pt_PT": { "rate": "Avaliar", - "showSiteName": "Mostrar nome do site" + "showSiteName": "Mostrar nome do site", + "donutChart": "Mostrado como gráfico estilo donut" }, "uk": { "rate": "Оцінити" From fbb421fb4c26df3abe14c22563f25d36f984cb02 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 13 Jan 2026 18:02:27 +0800 Subject: [PATCH 061/174] feat: add donation link --- package.json | 12 ++--- .../app/components/About/Description.tsx | 7 +++ src/pages/popup/components/Header/Coffee.tsx | 44 +++++++++++++++++++ src/pages/popup/components/Header/index.tsx | 6 ++- src/pages/util/icon.tsx | 5 +++ src/util/constant/url.ts | 5 +++ 6 files changed, 72 insertions(+), 7 deletions(-) create mode 100644 src/pages/popup/components/Header/Coffee.tsx create mode 100644 src/pages/util/icon.tsx diff --git a/package.json b/package.json index 96d3dd685..80851b878 100644 --- a/package.json +++ b/package.json @@ -32,14 +32,14 @@ "@emotion/babel-plugin": "^11.13.5", "@emotion/css": "^11.13.5", "@rsdoctor/rspack-plugin": "^1.4.0", - "@rspack/cli": "^1.7.1", - "@rspack/core": "^1.7.1", + "@rspack/cli": "^1.7.2", + "@rspack/core": "^1.7.2", "@swc/core": "^1.15.8", "@swc/jest": "^0.2.39", - "@types/chrome": "0.1.32", + "@types/chrome": "0.1.33", "@types/decompress": "^4.2.7", "@types/jest": "^30.0.0", - "@types/node": "^25.0.3", + "@types/node": "^25.0.7", "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.0.0", @@ -54,7 +54,7 @@ "postcss": "^8.5.6", "postcss-loader": "^8.2.0", "postcss-rtlcss": "^5.7.1", - "puppeteer": "^24.34.0", + "puppeteer": "^24.35.0", "ts-loader": "^9.5.4", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", @@ -71,4 +71,4 @@ "engines": { "node": ">=22" } -} +} \ No newline at end of file diff --git a/src/pages/app/components/About/Description.tsx b/src/pages/app/components/About/Description.tsx index dfe813a17..8e7468397 100644 --- a/src/pages/app/components/About/Description.tsx +++ b/src/pages/app/components/About/Description.tsx @@ -3,9 +3,11 @@ import { css } from '@emotion/css' import { MediaSize, useMediaSize } from "@hooks" import { locale } from "@i18n" import Flex from "@pages/components/Flex" +import { CoffeeIcon } from '@pages/util/icon' import { saveFlag } from "@service/meta-service" import packageInfo, { AUTHOR_EMAIL } from "@src/package" import { + BUY_ME_A_COFFEE_PAGE, CHANGE_LOG_PAGE, CHROME_HOMEPAGE, EDGE_HOMEPAGE, FEEDBACK_QUESTIONNAIRE, @@ -151,6 +153,11 @@ const _default = defineComponent<{}>(() => { }>Element Plus
+ {locale !== 'zh_CN' && ( + + {BUY_ME_A_COFFEE_PAGE} + + )} diff --git a/src/pages/popup/components/Header/Coffee.tsx b/src/pages/popup/components/Header/Coffee.tsx new file mode 100644 index 000000000..eade24775 --- /dev/null +++ b/src/pages/popup/components/Header/Coffee.tsx @@ -0,0 +1,44 @@ +import { locale } from '@i18n' +import { CoffeeIcon } from '@pages/util/icon' +import { BUY_ME_A_COFFEE_PAGE } from '@util/constant/url' +import { ElLink, ElTooltip } from 'element-plus' +import { defineComponent, onMounted, onUnmounted, ref } from 'vue' + +const useCoffee = () => { + const coffeeTip = ref() + + const resetTip = () => { + const now = new Date() + const hours = now.getHours() + let newVal = undefined + if (hours == 8) { + newVal = 'Buy me a coffee for a vibrant morning!' + } else if (hours == 13) { + newVal = 'Buy me a coffee for a pleasant afternoon!' + } + newVal !== coffeeTip.value && (coffeeTip.value = newVal) + } + const timer = setInterval(resetTip, 1000) + + onMounted(resetTip) + onUnmounted(() => clearInterval(timer)) + + return coffeeTip +} + +const Coffee = defineComponent(() => { + const tip = useCoffee() + return () => tip.value && locale !== 'zh_CN' ? + + + + : null +}) + +export default Coffee \ No newline at end of file diff --git a/src/pages/popup/components/Header/index.tsx b/src/pages/popup/components/Header/index.tsx index 651e839b8..c5235105e 100644 --- a/src/pages/popup/components/Header/index.tsx +++ b/src/pages/popup/components/Header/index.tsx @@ -7,6 +7,7 @@ import { IS_ANDROID } from "@util/constant/environment" import { getAppPageUrl } from "@util/constant/url" import { ElLink } from "element-plus" import { FunctionalComponent } from "vue" +import Coffee from './Coffee' import DarkSwitch from "./DarkSwitch" import Github from "./Github" import Logo from "./Logo" @@ -32,7 +33,10 @@ const openAppPage = async () => { const Header: FunctionalComponent = () => ( - + + + + diff --git a/src/pages/util/icon.tsx b/src/pages/util/icon.tsx new file mode 100644 index 000000000..510b14363 --- /dev/null +++ b/src/pages/util/icon.tsx @@ -0,0 +1,5 @@ +export const CoffeeIcon = ( + + + +) \ No newline at end of file diff --git a/src/util/constant/url.ts b/src/util/constant/url.ts index e65022b3c..344301b40 100644 --- a/src/util/constant/url.ts +++ b/src/util/constant/url.ts @@ -128,3 +128,8 @@ export const WEBSTORE_PAGE = webstorePage * @since 2.2.4 */ export const REVIEW_PAGE = reviewPage + +/** + * @since 3.7.13 + */ +export const BUY_ME_A_COFFEE_PAGE = 'https://buymeacoffee.com/sheepysheep' From 17b29f8748f78b5e91c18ca93e90bf7b071330b2 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 13 Jan 2026 18:04:55 +0800 Subject: [PATCH 062/174] v3.7.13 --- CHANGELOG.md | 4 ++++ package.json | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 70a097a31..7e31bc076 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [3.7.13] - 2026-01-13 + +- Added the donation link. Thank you all guys ^^ + ## [3.7.12] - 2026-01-09 - The first version of new year! diff --git a/package.json b/package.json index 80851b878..709bb3692 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "3.7.12", + "version": "3.7.13", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { @@ -71,4 +71,4 @@ "engines": { "node": ">=22" } -} \ No newline at end of file +} From e7d496781e86ad190589df82f11cc96679ec1543 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 13 Jan 2026 22:35:04 +0800 Subject: [PATCH 063/174] chore: parse all the files --- script/user-chart/add.ts | 171 ++++++++++++++++++++++++++++++--------- 1 file changed, 131 insertions(+), 40 deletions(-) diff --git a/script/user-chart/add.ts b/script/user-chart/add.ts index 880c2d398..76357df59 100644 --- a/script/user-chart/add.ts +++ b/script/user-chart/add.ts @@ -6,47 +6,78 @@ import { type GistForm, updateGist as updateGistApi } from "@src/api/gist" +import { CHROME_ID } from "@src/util/constant/meta" import fs from "fs" import { exitWith } from "../util/process" import { type Browser, descriptionOf, filenameOf, getExistGist, type UserCount, validateTokenFromEnv } from "./common" -type AddArgv = { +type AutoMode = { + mode: 'auto' + dirPath: string +} + +type ManualMode = { + mode: 'manual' browser: Browser fileName: string } +type AddArgv = AutoMode | ManualMode + +const BROWSER_MAP: Record = { + c: 'chrome', + e: 'edge', + f: 'firefox', +} + 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 [a0, a1] = argv + + if (!a0) { + exitWith("add.ts [c/e/f] [file_name] OR add.ts auto [dir_path]") } - const browserArgvMap: Record = { - c: 'chrome', - e: 'edge', - f: 'firefox', + + if (a0 === 'auto') { + if (!a1) exitWith("add.ts auto [dir_path]") + return { mode: 'auto', dirPath: a1 } } - const browser: Browser = browserArgvMap[browserArgv] + + if (!a1) { + exitWith("add.ts [c/e/f] [file_name] OR add.ts auto [dir_path]") + } + + const browser: Browser = BROWSER_MAP[a0] if (!browser) { exitWith("add.ts [c/e/f] [file_name]") } - return { - browser, - fileName - } + + return { mode: 'manual', browser, fileName: a1 } } -async function createGist(token: string, browser: Browser, data: UserCount) { - const description = descriptionOf(browser) - const filename = filenameOf(browser) +function detectBrowser(fileName: string): Browser | null { + if (fileName.includes(CHROME_ID)) return 'chrome' + if (fileName.includes('usage-day-')) return 'firefox' + if (fileName.includes('edgeaddon_analytics')) return 'edge' + return null +} - // 1. sort by key +function sortDataByKey(data: UserCount): UserCount { const sorted: UserCount = {} Object.keys(data).sort().forEach(key => sorted[key] = data[key]) - // 2. create - const files: Record = {} - files[filename] = { filename: filename, content: JSON.stringify(sorted, null, 2) } + return sorted +} + +async function createGist(token: string, browser: Browser, data: UserCount) { + const description = descriptionOf(browser) + const filename = filenameOf(browser) + const sorted = sortDataByKey(data) + const files: Record = { + [filename]: { + filename, + content: JSON.stringify(sorted, null, 2) + } + } const gistForm: GistForm = { public: true, description, @@ -67,12 +98,8 @@ async function updateGist(token: string, browser: Browser, data: UserCount, gist Object.keys(existData).sort().forEach(key => sorted[key] = existData[key]) const files: Record = {} files[filename] = { filename: filename, content: JSON.stringify(sorted, null, 2) } - const gistForm: GistForm = { - public: true, - description, - files - } - updateGistApi(token, gist.id, gistForm) + const gistForm: GistForm = { public: true, description, files } + await updateGistApi(token, gist.id, gistForm) } function parseChrome(content: string): UserCount { @@ -142,22 +169,86 @@ function rjust(str: string, num: number, padding: string): string { return Array.from(new Array(num - str.length).keys()).map(_ => padding).join('') + str } +const PARSERS: Record UserCount> = { + chrome: parseChrome, + edge: parseEdge, + firefox: parseFirefox, +} + +async function processFile(browser: Browser, filePath: string): Promise { + const content = fs.readFileSync(filePath, { encoding: 'utf-8' }) + return PARSERS[browser](content) +} + +function groupFilesByBrowser(files: string[]): Record { + const grouped: Record = { chrome: [], edge: [], firefox: [] } + for (const file of files) { + const browser = detectBrowser(file) + if (browser) { + grouped[browser].push(file) + } else { + console.warn(`Unknown file format, skipping: ${file}`) + } + } + return grouped +} + +function mergeData(target: UserCount, source: UserCount): void { + Object.entries(source).forEach(([key, val]) => { + target[key] = target[key] ? Math.max(target[key], val) : val + }) +} + +async function processDirectory(token: string, dirPath: string) { + const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.csv')) + if (files.length === 0) exitWith(`No CSV files found in ${dirPath}`) + + const filesByBrowser = groupFilesByBrowser(files) + + for (const [browser, browserFiles] of Object.entries(filesByBrowser)) { + if (browserFiles.length === 0) continue + + const typedBrowser = browser as Browser + console.log(`Processing ${browser.toUpperCase()}: ${browserFiles.length} file(s)`) + + let mergedData: UserCount = {} + for (const file of browserFiles) { + const filePath = `${dirPath}/${file}` + console.log(`Processing: ${file}`) + const data = await processFile(typedBrowser, filePath) + mergeData(mergedData, data) + } + + if (Object.keys(mergedData).length === 0) { + console.log(`No valid data found for ${browser}`) + continue + } + + console.log(`Merged ${Object.keys(mergedData).length} date entries for ${browser}`) + + const gist = await getExistGist(token, typedBrowser) + if (!gist) { + console.log(`Creating new gist for ${browser}...`) + await createGist(token, typedBrowser, mergedData) + } else { + console.log(`Updating existing gist for ${browser}...`) + await updateGist(token, typedBrowser, mergedData, gist) + } + console.log(`${browser.toUpperCase()} completed successfully!`) + } +} + 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') { - newData = parseChrome(content) - } else if (browser === 'edge') { - newData = parseEdge(content) - } else if (browser === 'firefox') { - newData = parseFirefox(content) - } else { - exitWith("Un-supported browser: " + browser) + const argv = parseArgv() + + if (argv.mode === 'auto') { + return await processDirectory(token, argv.dirPath) } + + const { browser, fileName } = argv + + const newData = await processFile(browser, fileName) const gist = await getExistGist(token, browser) if (!gist) { await createGist(token, browser, newData) From 998aed23308d373db46872bed7153ce1b3eabfcf Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 16 Jan 2026 01:24:05 +0800 Subject: [PATCH 064/174] feat: support more languages for the guide page --- package.json | 4 ++-- src/pages/app/Layout/menu/Nav.tsx | 11 ++++++----- src/pages/app/Layout/menu/Side.tsx | 7 ++++--- src/pages/app/Layout/menu/item.ts | 4 ++-- src/pages/app/Layout/menu/route.ts | 4 ++-- src/util/constant/url.ts | 6 +++++- 6 files changed, 21 insertions(+), 15 deletions(-) diff --git a/package.json b/package.json index 709bb3692..a07ee28b2 100644 --- a/package.json +++ b/package.json @@ -31,7 +31,7 @@ "@crowdin/crowdin-api-client": "^1.51.1", "@emotion/babel-plugin": "^11.13.5", "@emotion/css": "^11.13.5", - "@rsdoctor/rspack-plugin": "^1.4.0", + "@rsdoctor/rspack-plugin": "^1.5.0", "@rspack/cli": "^1.7.2", "@rspack/core": "^1.7.2", "@swc/core": "^1.15.8", @@ -39,7 +39,7 @@ "@types/chrome": "0.1.33", "@types/decompress": "^4.2.7", "@types/jest": "^30.0.0", - "@types/node": "^25.0.7", + "@types/node": "^25.0.8", "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.0.0", diff --git a/src/pages/app/Layout/menu/Nav.tsx b/src/pages/app/Layout/menu/Nav.tsx index a4307f423..fd092329d 100644 --- a/src/pages/app/Layout/menu/Nav.tsx +++ b/src/pages/app/Layout/menu/Nav.tsx @@ -14,7 +14,7 @@ import Flex from '@pages/components/Flex' import { ElBreadcrumb, ElBreadcrumbItem, ElIcon, ElMenu, ElMenuItem, useNamespace } from "element-plus" import { defineComponent, h, onBeforeMount, ref, watch } from "vue" import { useRouter } from "vue-router" -import { type MenuItem, NAV_MENUS } from "./item" +import { type MenuItem, navMenus } from "./item" import { handleClick, initTitle } from "./route" import { colorMenu } from './style' @@ -42,12 +42,13 @@ const useStyle = () => { return { containerCls, menuWrapperCls } } -const findTitle = (routePath: string): string => { - const title = NAV_MENUS.find(v => routePath === v.route)?.title +const findTitle = (routePath: string, menus: MenuItem[]): string => { + const title = menus.find(v => routePath === v.route)?.title return title ? t(title) : '' } const _default = defineComponent<{}>(() => { + const menus = navMenus() const router = useRouter() const title = ref('') const [showMenu, , closeMenu, toggleMenu] = useSwitch(false) @@ -58,7 +59,7 @@ const _default = defineComponent<{}>(() => { const syncRouter = () => { const route = router.currentRoute.value - route && (title.value = findTitle(route.path)) + route && (title.value = findTitle(route.path, menus)) } watch(router.currentRoute, syncRouter) @@ -86,7 +87,7 @@ const _default = defineComponent<{}>(() => {
- {NAV_MENUS.map(item => ( + {menus.map(item => ( handleItemClick(item)} diff --git a/src/pages/app/Layout/menu/Side.tsx b/src/pages/app/Layout/menu/Side.tsx index bb9c72792..d438effad 100644 --- a/src/pages/app/Layout/menu/Side.tsx +++ b/src/pages/app/Layout/menu/Side.tsx @@ -16,7 +16,7 @@ import { colorVariant } from '@pages/util/style' import { ElCollapseTransition, ElIcon, ElMenu, ElMenuItem, ElMenuItemGroup, ElScrollbar, ElText, ElTooltip, useNamespace } from "element-plus" import { defineComponent, h, nextTick, onMounted, type Ref, ref, type StyleValue, watch } from "vue" import { type Router, useRouter } from "vue-router" -import { MENU_GROUPS, type MenuItem } from "./item" +import { menuGroups, type MenuItem } from "./item" import { handleClick, initTitle } from "./route" import { colorMenu } from './style' @@ -83,6 +83,7 @@ const _default = defineComponent(() => { tooltipVisible, setTooltipVisible, } = useCollapseState() const cls = useStyle() + const menus = menuGroups() return () => ( { } satisfies StyleValue} > {collapsed.value - ? MENU_GROUPS.flatMap(g => g.children).map(item => renderItem(item, router, curr)) - : MENU_GROUPS.map(({ children, title }) => ( + ? menus.flatMap(g => g.children).map(item => renderItem(item, router, curr)) + : menus.map(({ children, title }) => ( {children.map(item => renderItem(item, router, curr))} diff --git a/src/pages/app/Layout/menu/item.ts b/src/pages/app/Layout/menu/item.ts index e5d9e8e39..f146fd20b 100644 --- a/src/pages/app/Layout/menu/item.ts +++ b/src/pages/app/Layout/menu/item.ts @@ -39,7 +39,7 @@ export type MenuGroup = Omit & { /** * Menu items */ -export const MENU_GROUPS: MenuGroup[] = [{ +export const menuGroups = (): MenuGroup[] => [{ title: msg => msg.menu.data, index: 'data', icon: Histogram, @@ -121,4 +121,4 @@ export const MENU_GROUPS: MenuGroup[] = [{ }] }] -export const NAV_MENUS: MenuItem[] = MENU_GROUPS.flatMap(g => g.children || []).filter(m => m.mobile) \ No newline at end of file +export const navMenus = (): MenuItem[] => menuGroups().flatMap(g => g.children || []).filter(m => m.mobile) \ No newline at end of file diff --git a/src/pages/app/Layout/menu/route.ts b/src/pages/app/Layout/menu/route.ts index 5cde0a5fa..88bc2e21e 100644 --- a/src/pages/app/Layout/menu/route.ts +++ b/src/pages/app/Layout/menu/route.ts @@ -2,7 +2,7 @@ import { createTabAfterCurrent } from "@api/chrome/tab" import { type I18nKey, t } from "@app/locale" import { type Ref } from "vue" import { type Router } from "vue-router" -import { type MenuItem, MENU_GROUPS } from "./item" +import { type MenuItem, menuGroups } from "./item" function openMenu(route: string, title: I18nKey, router: Router) { const currentPath = router.currentRoute.value?.path @@ -27,7 +27,7 @@ export function handleClick(menuItem: MenuItem, router: Router, currentActive?: export async function initTitle(router: Router) { await router.isReady() const currentPath = router.currentRoute.value.path - for (const group of MENU_GROUPS) { + for (const group of menuGroups()) { for (const { route, title } of group.children) { const docTitle = route === currentPath && t(title) if (docTitle) { diff --git a/src/util/constant/url.ts b/src/util/constant/url.ts index 344301b40..9aaecb4b5 100644 --- a/src/util/constant/url.ts +++ b/src/util/constant/url.ts @@ -78,7 +78,11 @@ export function getAppPageUrl(route?: string, query?: any): string { } export const HOMEPAGE = "https://www.wfhg.cc" -const HOMEPAGE_LOCALES: timer.Locale[] = ["zh_CN", "zh_TW", "en"] +const HOMEPAGE_LOCALES: timer.Locale[] = [ + "zh_CN", "zh_TW", "en", + "ja", "de", "ru", + "fr", "es", "pt_PT", +] export function getHomepageWithLocale(): string { const homepageLocale: timer.Locale = HOMEPAGE_LOCALES.includes(locale) ? locale : "en" From 73c4d254e82a6a83e0cf87d3e1e843fde5111686 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 16 Jan 2026 01:25:12 +0800 Subject: [PATCH 065/174] v3.7.14 --- CHANGELOG.md | 4 ++++ package.json | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7e31bc076..0177aed49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [3.7.14] - 2026-01-16 + +- Supported more languages for the guide page + ## [3.7.13] - 2026-01-13 - Added the donation link. Thank you all guys ^^ diff --git a/package.json b/package.json index a07ee28b2..31113036a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "3.7.13", + "version": "3.7.14", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { @@ -71,4 +71,4 @@ "engines": { "node": ">=22" } -} +} \ No newline at end of file From 6d310d0de40d4a0f189108146e5c97f34b27d629 Mon Sep 17 00:00:00 2001 From: sheepie Date: Wed, 21 Jan 2026 11:54:18 +0800 Subject: [PATCH 066/174] refactor: not to use el-option for el-select (#669) --- package.json | 14 ++-- .../TopKVisit/Title/TitleSelect.tsx | 5 +- .../AppearanceOption/DarkModeInput.tsx | 15 ++-- .../components/AppearanceOption/index.tsx | 20 +++--- .../Option/components/BackupOption/index.tsx | 7 +- .../Option/components/LimitOption/index.tsx | 12 ++-- .../Option/components/TrackingOption.tsx | 7 +- .../common/category/CategorySelect/index.tsx | 2 +- .../common/filter/CategoryFilter.tsx | 13 ++-- .../common/filter/MultiSelectFilterItem.tsx | 72 +++++++++---------- .../common/filter/SelectFilterItem.tsx | 56 +++++++-------- 11 files changed, 105 insertions(+), 118 deletions(-) diff --git a/package.json b/package.json index 31113036a..ddb10d2ac 100644 --- a/package.json +++ b/package.json @@ -28,18 +28,18 @@ }, "license": "MIT", "devDependencies": { - "@crowdin/crowdin-api-client": "^1.51.1", + "@crowdin/crowdin-api-client": "^1.52.0", "@emotion/babel-plugin": "^11.13.5", "@emotion/css": "^11.13.5", "@rsdoctor/rspack-plugin": "^1.5.0", - "@rspack/cli": "^1.7.2", - "@rspack/core": "^1.7.2", - "@swc/core": "^1.15.8", + "@rspack/cli": "^1.7.3", + "@rspack/core": "^1.7.3", + "@swc/core": "^1.15.10", "@swc/jest": "^0.2.39", - "@types/chrome": "0.1.33", + "@types/chrome": "0.1.35", "@types/decompress": "^4.2.7", "@types/jest": "^30.0.0", - "@types/node": "^25.0.8", + "@types/node": "^25.0.9", "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.0.0", @@ -65,7 +65,7 @@ "echarts": "^6.0.0", "element-plus": "2.13.1", "punycode": "^2.3.1", - "vue": "^3.5.26", + "vue": "^3.5.27", "vue-router": "^4.6.4" }, "engines": { diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/Title/TitleSelect.tsx b/src/pages/app/components/Dashboard/components/TopKVisit/Title/TitleSelect.tsx index 24f1840ca..b2e3d89ff 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/Title/TitleSelect.tsx +++ b/src/pages/app/components/Dashboard/components/TopKVisit/Title/TitleSelect.tsx @@ -53,9 +53,8 @@ const TitleSelect = defineComponent(({ values, field }) => { onChange={val => filter[field] = val as number} popperOptions={{ placement: 'bottom' }} popperClass={popoverCls} - > - {values.map(k => )} - + options={values.map(k => ({ label: k, value: k }))} + /> ) }, { props: ['field', "values"] }) diff --git a/src/pages/app/components/Option/components/AppearanceOption/DarkModeInput.tsx b/src/pages/app/components/Option/components/AppearanceOption/DarkModeInput.tsx index 3b2a3bdf6..9622d8449 100644 --- a/src/pages/app/components/Option/components/AppearanceOption/DarkModeInput.tsx +++ b/src/pages/app/components/Option/components/AppearanceOption/DarkModeInput.tsx @@ -5,10 +5,13 @@ * https://opensource.org/licenses/MIT */ import { t } from "@app/locale" -import { ElOption, ElSelect, ElTimePicker } from "element-plus" +import { ElSelect, ElTimePicker } from "element-plus" import { computed, defineComponent, StyleValue } from "vue" const ALL_MODES: timer.option.DarkMode[] = ["default", "on", "off", "timed"] +const labelOfMode = (mode: timer.option.DarkMode) => mode === 'default' + ? t(msg => msg.option.followBrowser) + : t(msg => msg.option.appearance.darkMode.options[mode]) function computeSecondToDate(secondOfDate: number): Date { const now = new Date() @@ -46,14 +49,8 @@ const _default = defineComponent(props => { size="small" style={{ width: "120px" }} onChange={val => props.onChange?.(val as timer.option.DarkMode, [props.startSecond, props.endSecond])} - > - { - ALL_MODES.map(value => value === 'default' ? msg.option.followBrowser : msg.option.appearance.darkMode.options[value])} - />) - } - + options={ALL_MODES.map(value => ({ value, label: labelOfMode(value) }))} + /> {props.modelValue === "timed" && <> msg.option.followBrowser + const SORTED_LOCALES: timer.Locale[] = ALL_LOCALES // Keep the locale same as this browser first position .sort((a, _b) => a === localeSameAsBrowser ? -1 : 0) -const allLocaleOptions: timer.option.LocaleOption[] = ["default", ...SORTED_LOCALES] + +const allLocaleOptions = (["default", ...SORTED_LOCALES] satisfies timer.option.LocaleOption[]).map(locale => ({ + value: locale, + label: locale === "default" ? t(FOLLOW_BROWSER) : localeMessages[locale].name +})) function copy(target: timer.option.AppearanceOption, source: timer.option.AppearanceOption) { target.displayWhitelistMenu = source.displayWhitelistMenu @@ -42,7 +48,7 @@ function copy(target: timer.option.AppearanceOption, source: timer.option.Appear const DEFAULT_ANIMA_DURATION = defaultAppearance().chartAnimationDuration const DEFAULT_SIDE_PANEL_ENABLED = true -const FOLLOW_BROWSER: I18nKey = msg => msg.option.followBrowser + const _default = defineComponent((_props, ctx) => { const { option } = useOption({ @@ -107,12 +113,8 @@ const _default = defineComponent((_props, ctx) => { style={{ width: "120px" }} onChange={(newVal: timer.option.LocaleOption) => handleLocaleChange(newVal)} filterable - > - {allLocaleOptions.map(locale => )} - + options={allLocaleOptions} + /> {!IS_ANDROID && <> { modelValue={backupType.value} size="small" onChange={(val: timer.backup.Type) => backupType.value = val} - > - {ALL_TYPES.map(type => )} - + options={ALL_TYPES.map(value => ({ value, label: TYPE_NAMES[value] }))} + /> { class={levelSelectStyle.cls} style={{ width: `${levelSelectStyle.width}px` }} onChange={handleLevelChange} - > - {ALL_LEVEL.map(item => msg.option.limit.level[item])} />)} - + options={ALL_LEVEL.map(value => ({ value, label: t(msg => msg.option.limit.level[value]) }))} + /> { .then(() => option.limitVerifyDifficulty = val) .catch(console.warn) } - > - {ALL_DIFF.map(item => msg.option.limit.level.verificationDifficulty[item])} />)} - + options={ALL_DIFF.map(value => ({ value, label: t(msg => msg.option.limit.level.verificationDifficulty[value]) }))} + /> { size="small" style={{ width: '120px' }} onChange={(val: timer.option.WeekStartOption) => option.weekStart = val} - > - {weekStartOptionPairs.map(([val, label]) => )} - + options={weekStartOptionPairs.map(([value, label]) => ({ value, label }))} + /> }) diff --git a/src/pages/app/components/common/category/CategorySelect/index.tsx b/src/pages/app/components/common/category/CategorySelect/index.tsx index b0ce94d2c..bc74edab4 100644 --- a/src/pages/app/components/common/category/CategorySelect/index.tsx +++ b/src/pages/app/components/common/category/CategorySelect/index.tsx @@ -48,4 +48,4 @@ const CategorySelect = defineComponent((props, ctx) => { ) }, { props: ['clearable', 'modelValue', 'size', 'width', 'onVisibleChange', 'onChange'] }) -export default CategorySelect \ No newline at end of file +export default CategorySelect diff --git a/src/pages/app/components/common/filter/CategoryFilter.tsx b/src/pages/app/components/common/filter/CategoryFilter.tsx index a8f494ed6..2631e3081 100644 --- a/src/pages/app/components/common/filter/CategoryFilter.tsx +++ b/src/pages/app/components/common/filter/CategoryFilter.tsx @@ -1,7 +1,7 @@ import { useCategory } from "@app/context" import { t } from "@app/locale" import { CATE_NOT_SET_ID } from "@util/site" -import { ElOption, ElSelect } from "element-plus" +import { ElSelect } from "element-plus" import { computed, defineComponent, type StyleValue } from "vue" type Props = ModelValue & { @@ -12,9 +12,9 @@ type Props = ModelValue & { const CategoryFilter = defineComponent(props => { const cate = useCategory() - const displayCategories = computed(() => [ - { id: CATE_NOT_SET_ID, name: t(msg => msg.shared.cate.notSet) } satisfies timer.site.Cate, - ...cate.all, + const options = computed(() => [ + { value: CATE_NOT_SET_ID, label: t(msg => msg.shared.cate.notSet) }, + ...cate.all.map(c => ({ value: c.id, label: c.name })) ]) return () => cate.enabled ? ( @@ -29,9 +29,8 @@ const CategoryFilter = defineComponent(props => { onClear={() => props.onChange?.(undefined)} placeholder={t(msg => msg.siteManage.column.cate)} style={{ width: '200px' } satisfies StyleValue} - > - {displayCategories.value?.map(cate => )} - + options={options.value} + /> ) : null }, { props: ['modelValue', 'onChange', 'disabled', 'useCache'] }) diff --git a/src/pages/app/components/common/filter/MultiSelectFilterItem.tsx b/src/pages/app/components/common/filter/MultiSelectFilterItem.tsx index 138404aea..f943eaa65 100644 --- a/src/pages/app/components/common/filter/MultiSelectFilterItem.tsx +++ b/src/pages/app/components/common/filter/MultiSelectFilterItem.tsx @@ -1,44 +1,42 @@ import { useCached } from "@hooks" -import { ElOption, ElSelect } from "element-plus" -import { defineComponent, type PropType, watch } from "vue" +import { ElSelect } from "element-plus" +import { defineComponent, watch } from "vue" import { useRoute } from "vue-router" import { SELECT_WRAPPER_STYLE } from "./common" -const MultiSelectFilterItem = defineComponent({ - props: { - defaultValue: Array as PropType<(string | number)[]>, - /** - * Whether to save the value in the localStorage with {@param historyName} - */ - historyName: String, - placeholder: String, - disabled: Boolean, - options: Array as PropType<{ value: string | number, label?: string }[]>, - }, - emits: { - change: (_val: (string | number)[]) => true, - }, - setup(props, ctx) { - const cacheKey = props.historyName && `__filter_item_multi_select_${useRoute().path}_${props.historyName}` - const { data, setter } = useCached(cacheKey, props.defaultValue) - watch(data, () => ctx.emit('change', data.value ?? [])) +type Data = string | number - return () => ( - setter([])} - placeholder={props.placeholder} - style={SELECT_WRAPPER_STYLE} - > - {props.options?.map(({ value, label }) => )} - - ) - } -}) +type Props = { + defaultValue?: Data[] + /** + * Whether to save the value in the localStorage with {@param historyName} + */ + historyName?: string + placeholder?: string + disabled?: boolean + options?: { value: Data, label?: string }[] + onChange?: (val: Data[]) => void +} + +const MultiSelectFilterItem = defineComponent(props => { + const cacheKey = props.historyName && `__filter_item_multi_select_${useRoute().path}_${props.historyName}` + const { data, setter } = useCached(cacheKey, props.defaultValue) + watch(data, val => props.onChange?.(val ?? [])) + + return () => ( + setter([])} + placeholder={props.placeholder} + style={SELECT_WRAPPER_STYLE} + options={props.options} + /> + ) +}, { props: ['defaultValue', 'historyName', 'placeholder', 'disabled', 'options'] }) export default MultiSelectFilterItem \ No newline at end of file diff --git a/src/pages/app/components/common/filter/SelectFilterItem.tsx b/src/pages/app/components/common/filter/SelectFilterItem.tsx index edf39cdea..25ee0e992 100644 --- a/src/pages/app/components/common/filter/SelectFilterItem.tsx +++ b/src/pages/app/components/common/filter/SelectFilterItem.tsx @@ -6,37 +6,33 @@ */ import { useCached } from "@hooks" -import { ElOption, ElSelect } from "element-plus" -import { defineComponent, watch, type PropType } from "vue" +import { ElSelect } from "element-plus" +import { defineComponent, watch } from "vue" import { useRoute } from "vue-router" import { SELECT_WRAPPER_STYLE } from "./common" -const _default = defineComponent({ - props: { - defaultValue: String, - /** - * Whether to save the value in the localStorage with {@param historyName} - */ - historyName: String, - options: Object as PropType> - }, - emits: { - select: (_val: string | undefined) => true - }, - setup(props, ctx) { - const cacheKey = props.historyName && `__filter_item_select_${useRoute().path}_${props.historyName}` - const { data, setter } = useCached(cacheKey, props.defaultValue) - watch(data, () => ctx.emit('select', data.value)) - return () => ( - - {Object.entries(props.options || {}).map(([value, label]) => )} - - ) - } -}) +type Props = { + defaultValue?: string + /** + * Whether to save the value in the localStorage with {@param historyName} + */ + historyName?: string + options: Record + onSelect?: (val: string | undefined) => void +} -export default _default \ No newline at end of file +const SelectFilterItem = defineComponent(props => { + const cacheKey = props.historyName && `__filter_item_select_${useRoute().path}_${props.historyName}` + const { data, setter } = useCached(cacheKey, props.defaultValue) + watch(data, val => props.onSelect?.(val)) + return () => ( + ({ label, value }))} + /> + ) +}, { props: ['defaultValue', 'historyName', 'options', 'onSelect'] }) + +export default SelectFilterItem \ No newline at end of file From 602f7bf9052d2e284f324e7612c8ff3837b1c679 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Wed, 21 Jan 2026 19:13:27 +0800 Subject: [PATCH 067/174] fix: the data of virtual site (#668) --- src/database/site-database.ts | 10 +++++----- src/pages/app/components/Report/common.ts | 5 ++++- src/service/components/virtual-site-holder.ts | 2 +- src/util/pattern.ts | 2 +- 4 files changed, 11 insertions(+), 8 deletions(-) diff --git a/src/database/site-database.ts b/src/database/site-database.ts index 19419cdf9..71826de80 100644 --- a/src/database/site-database.ts +++ b/src/database/site-database.ts @@ -204,17 +204,17 @@ class SiteDatabase extends BaseDatabase { * * @since 1.6.0 */ - addChangeListener(listener: (oldAndNew: [timer.site.SiteInfo, timer.site.SiteInfo][]) => void) { + addChangeListener(listener: (oldAndNew: [timer.site.SiteInfo?, timer.site.SiteInfo?][]) => void) { const storageListener = ( changes: { [key: string]: chrome.storage.StorageChange }, _areaName: chrome.storage.AreaName, ) => { - const changedSites: [timer.site.SiteInfo, timer.site.SiteInfo][] = Object.entries(changes) + const changedSites: [timer.site.SiteInfo?, timer.site.SiteInfo?][] = Object.entries(changes) .filter(([k]) => k.startsWith(DB_KEY_PREFIX)) - .map(([k, v]) => { + .map(([k, { oldValue, newValue }]) => { const siteKey = cvt2SiteKey(k) - const oldVal = cvt2SiteInfo(siteKey, v?.oldValue as _Entry) - const newVal = cvt2SiteInfo(siteKey, v?.newValue as _Entry) + const oldVal = oldValue ? cvt2SiteInfo(siteKey, oldValue as _Entry) : undefined + const newVal = newValue ? cvt2SiteInfo(siteKey, newValue as _Entry) : undefined return [oldVal, newVal] }) changedSites.length && listener?.(changedSites) diff --git a/src/pages/app/components/Report/common.ts b/src/pages/app/components/Report/common.ts index ad1d69db2..958f36485 100644 --- a/src/pages/app/components/Report/common.ts +++ b/src/pages/app/components/Report/common.ts @@ -92,7 +92,10 @@ const cvt2SiteQuery = ( { dateRange: date, mergeDate, siteMerge, query, cateIds, readRemote: inclusiveRemote }: ReportFilterOption, { prop, order }: ReportSort, ): SiteQuery => ({ - date, mergeDate, mergeHost: siteMerge === 'domain', query, cateIds, inclusiveRemote, + date, mergeDate, + mergeHost: siteMerge === 'domain', + query, cateIds, inclusiveRemote, + virtual: true, sortKey: prop, sortDirection: cvtOrderDir(order), }) diff --git a/src/service/components/virtual-site-holder.ts b/src/service/components/virtual-site-holder.ts index 835bb61eb..46a3f7d14 100644 --- a/src/service/components/virtual-site-holder.ts +++ b/src/service/components/virtual-site-holder.ts @@ -17,7 +17,7 @@ class VirtualSiteHolder { siteDatabase.addChangeListener(oldAndNew => oldAndNew.forEach(([oldVal, newVal]) => { if (!newVal) { // deleted - delete this.hostSiteRegMap[oldVal.host] + oldVal?.host && delete this.hostSiteRegMap[oldVal.host] } else { this.updateRegularExp(newVal) } diff --git a/src/util/pattern.ts b/src/util/pattern.ts index 25b51c5fa..22f39aa0b 100644 --- a/src/util/pattern.ts +++ b/src/util/pattern.ts @@ -204,5 +204,5 @@ export function compileAntPattern(antPattern: string): RegExp { patternStr = patternStr.substring(0, patternStr.length - 3) + ".*" } - return new RegExp("^(.+://)?" + patternStr + "/?(\\?.*)?$") + return new RegExp("^(.+://)?" + patternStr + "/?([\\?#].*)?$") } \ No newline at end of file From 0d01de923ada6d1ed865d660f189bbd1f3897c50 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Wed, 21 Jan 2026 19:16:47 +0800 Subject: [PATCH 068/174] v3.7.15 --- CHANGELOG.md | 4 ++++ package.json | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0177aed49..66205e14f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [3.7.15] - 2026-01-21 + +- Fixed virtual sites' data + ## [3.7.14] - 2026-01-16 - Supported more languages for the guide page diff --git a/package.json b/package.json index ddb10d2ac..e60ca434a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "3.7.14", + "version": "3.7.15", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { @@ -71,4 +71,4 @@ "engines": { "node": ">=22" } -} \ No newline at end of file +} From af7c54cf013b8ed99a74c50c3747dc6b9ffc7956 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 23 Jan 2026 10:30:45 +0800 Subject: [PATCH 069/174] feat: analyze uncategorized websites (#671) --- .../components/AnalysisFilter/TargetSelect.tsx | 4 ++-- .../Analysis/components/Summary/TargetInfo.tsx | 13 +++++++++---- .../app/components/common/filter/CategoryFilter.tsx | 2 +- 3 files changed, 12 insertions(+), 7 deletions(-) diff --git a/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx b/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx index 67e050a52..a0a96e82e 100644 --- a/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx +++ b/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx @@ -4,7 +4,7 @@ import { useDebounceState, useRequest } from "@hooks" import Flex from "@pages/components/Flex" import { selectAllSites } from "@service/site-service" import { listHosts } from "@service/stat-service" -import { identifySiteKey, parseSiteKeyFromIdentity, SiteMap } from "@util/site" +import { CATE_NOT_SET_ID, identifySiteKey, parseSiteKeyFromIdentity, SiteMap } from "@util/site" import { ElSelectV2, ElTag, useNamespace } from "element-plus" import type { OptionType } from "element-plus/es/components/select-v2/src/select.types" import { computed, defineComponent, type FunctionalComponent, onMounted, ref, type StyleValue } from "vue" @@ -111,7 +111,7 @@ const TargetSelect = defineComponent(() => { }) const { data: allItems } = useRequest( - () => fetchItems(cate.all), + () => fetchItems([...cate.all, { id: CATE_NOT_SET_ID, name: t(msg => msg.shared.cate.notSet) }]), { defaultValue: [[], []], deps: [() => cate.all] }, ) diff --git a/src/pages/app/components/Analysis/components/Summary/TargetInfo.tsx b/src/pages/app/components/Analysis/components/Summary/TargetInfo.tsx index b1d57dbe9..326977566 100644 --- a/src/pages/app/components/Analysis/components/Summary/TargetInfo.tsx +++ b/src/pages/app/components/Analysis/components/Summary/TargetInfo.tsx @@ -10,6 +10,7 @@ import { t } from "@app/locale" import { useRequest } from "@hooks" import Flex from "@pages/components/Flex" import { getSite } from '@service/site-service' +import { CATE_NOT_SET_ID } from '@util/site' import { ElTag } from "element-plus" import { computed, defineComponent, type StyleValue, toRef } from "vue" import { useAnalysisTarget } from "../../context" @@ -50,13 +51,17 @@ const SiteInfo = defineComponent<{ value: timer.site.SiteKey }>(props => { }, { props: ['value'] }) const CateInfo = defineComponent<{ value: number }>(props => { - const cateId = toRef(props, 'value') - const cateInst = useCategory() - const cate = computed(() => cateInst.all.find(c => c.id === cateId.value)) + const { all } = useCategory() + const cateName = computed(() => { + const cateId = props.value + return cateId === CATE_NOT_SET_ID + ? t(msg => msg.shared.cate.notSet) + : all.find(c => c.id === cateId)?.name ?? '' + }) return () => ( -

{cate.value?.name}

+

{cateName.value}

{t(msg => msg.analysis.target.cate)}
) diff --git a/src/pages/app/components/common/filter/CategoryFilter.tsx b/src/pages/app/components/common/filter/CategoryFilter.tsx index 2631e3081..438cfc3ce 100644 --- a/src/pages/app/components/common/filter/CategoryFilter.tsx +++ b/src/pages/app/components/common/filter/CategoryFilter.tsx @@ -13,8 +13,8 @@ const CategoryFilter = defineComponent(props => { const cate = useCategory() const options = computed(() => [ + ...cate.all.map(c => ({ value: c.id, label: c.name })), { value: CATE_NOT_SET_ID, label: t(msg => msg.shared.cate.notSet) }, - ...cate.all.map(c => ({ value: c.id, label: c.name })) ]) return () => cate.enabled ? ( From 4f4376b0eff1a800306f10c366389b68017eff47 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 1 Feb 2026 13:54:49 +0800 Subject: [PATCH 070/174] chore(psl): update PSL list by bot (#673) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/util/psl/rules.json | 151 ++++++++++++++++++++++++++++++++++------ 1 file changed, 128 insertions(+), 23 deletions(-) diff --git a/src/util/psl/rules.json b/src/util/psl/rules.json index cc2ec7f03..63a739909 100644 --- a/src/util/psl/rules.json +++ b/src/util/psl/rules.json @@ -172,6 +172,7 @@ "c": { "com": 1, "framer": 1, + "kiloapps": 1, "net": 1, "off": 1, "org": 1, @@ -244,6 +245,7 @@ "c": { "adaptable": 1, "aiven": 1, + "base44": 1, "beget": { "c": { "*": 1 @@ -265,6 +267,7 @@ "clerkstage": 1, "cloudflare": 1, "convex": 1, + "corespeed": 1, "csb": { "c": { "preview": 1 @@ -315,6 +318,7 @@ "magicpatterns": 1, "medusajs": 1, "messerli": 1, + "miren": 1, "mocha": 1, "netlify": 1, "ngrok": 1, @@ -350,6 +354,7 @@ } } }, + "shiptoday": 1, "snowflake": { "c": { "*": 1, @@ -360,6 +365,7 @@ } } }, + "spawnbase": 1, "sprites": 1, "streamlit": 1, "telebit": 1, @@ -789,6 +795,11 @@ "transfer-webapp": 1 } }, + "ap-southeast-7": { + "c": { + "transfer-webapp": 1 + } + }, "ca-central-1": { "c": { "airflow": { @@ -927,6 +938,11 @@ "transfer-webapp": 1 } }, + "mx-central-1": { + "c": { + "transfer-webapp": 1 + } + }, "sa-east-1": { "c": { "airflow": { @@ -1816,6 +1832,7 @@ }, "build": { "c": { + "shiptoday": 1, "v0": 1, "windsurf": 1 }, @@ -3797,7 +3814,8 @@ "s3": 1, "s3-accesspoint": 1, "s3-accesspoint-fips": 1, - "s3-fips": 1 + "s3-fips": 1, + "s3-website": 1 } }, "emrappui-prod": 1, @@ -3819,7 +3837,8 @@ "s3": 1, "s3-accesspoint": 1, "s3-accesspoint-fips": 1, - "s3-fips": 1 + "s3-fips": 1, + "s3-website": 1 } }, "emrappui-prod": 1, @@ -4118,6 +4137,7 @@ "balena-devices": 1, "barsycenter": 1, "barsyonline": 1, + "base44-sandbox": 1, "blogdns": 1, "blogspot": 1, "blogsyte": 1, @@ -4735,7 +4755,6 @@ "simple-url": 1, "simplesite": 1, "sinaapp": 1, - "skygearapp": 1, "smushcdn": 1, "space-to-rent": 1, "stackhero-network": 1, @@ -5016,6 +5035,11 @@ "4lima": 1, "barsy": 1, "bplaced": 1, + "bwcloud-os-instance": { + "c": { + "*": 1 + } + }, "co": 1, "com": 1, "community-pro": 1, @@ -5024,7 +5048,6 @@ "dyn": 1 } }, - "dd-dns": 1, "ddnss": { "c": { "dyn": 1, @@ -5035,14 +5058,10 @@ "diskussionsbereich": 1, "dnshome": 1, "dnsupdater": 1, - "dray-dns": 1, - "draydns": 1, "dyn-berlin": 1, "dyn-ip24": 1, - "dyn-vpn": 1, "dynamisches-dns": 1, "dyndns1": 1, - "dynvpn": 1, "firewall-gateway": 1, "frusky": { "c": { @@ -5085,12 +5104,9 @@ "lima-city": 1, "logoip": 1, "mein-iserv": 1, - "mein-vigor": 1, "my": 1, "my-gateway": 1, "my-router": 1, - "my-vigor": 1, - "my-wan": 1, "myhome-server": 1, "myspreadshop": 1, "rub": 1, @@ -5115,9 +5131,6 @@ }, "square7": 1, "svn-repos": 1, - "syno-ds": 1, - "synology-diskstation": 1, - "synology-ds": 1, "taifun-dns": 1, "test-iserv": 1, "traeumtgerade": 1, @@ -5171,11 +5184,76 @@ }, "crm": { "c": { + "aa": { + "c": { + "*": 1 + } + }, + "ab": { + "c": { + "*": 1 + } + }, + "ac": { + "c": { + "*": 1 + } + }, + "ad": { + "c": { + "*": 1 + } + }, + "ae": { + "c": { + "*": 1 + } + }, + "af": { + "c": { + "*": 1 + } + }, + "ci": { + "c": { + "*": 1 + } + }, "d": { "c": { "*": 1 } }, + "pa": { + "c": { + "*": 1 + } + }, + "pb": { + "c": { + "*": 1 + } + }, + "pc": { + "c": { + "*": 1 + } + }, + "pd": { + "c": { + "*": 1 + } + }, + "pe": { + "c": { + "*": 1 + } + }, + "pf": { + "c": { + "*": 1 + } + }, "w": { "c": { "*": 1 @@ -5363,7 +5441,12 @@ "discount": 1, "discover": 1, "dish": 1, - "diy": 1, + "diy": { + "c": { + "discourse": 1 + }, + "l": 1 + }, "dj": 1, "dk": { "c": { @@ -5616,10 +5699,27 @@ }, "eu": { "c": { + "amazonwebservices": { + "c": { + "on": { + "c": { + "eusc-de-east-1": { + "c": { + "cognito-idp": { + "c": { + "auth": 1 + } + } + } + } + } + } + } + }, "barsy": 1, "cloudns": 1, + "deuxfleurs": 1, "directwp": 1, - "diskstation": 1, "dogado": { "c": { "jelastic": 1 @@ -6530,6 +6630,8 @@ "l": 1 }, "jele": 1, + "keenetic": 1, + "kiloapps": 1, "lair": { "c": { "apps": 1 @@ -9484,6 +9586,7 @@ "co": 1, "daegu": 1, "daejeon": 1, + "eliv-api": 1, "eliv-cdn": 1, "eliv-dns": 1, "es": 1, @@ -9640,6 +9743,7 @@ } }, "joinmc": 1, + "keenetic": 1, "myfritz": 1, "mypep": 1, "nftstorage": { @@ -10128,7 +10232,8 @@ "forgot": 1 } }, - "ispmanager": 1 + "ispmanager": 1, + "keenetic": 1 }, "l": 1 }, @@ -10399,7 +10504,6 @@ "no-ip": 1, "now-dns": 1, "office-on-the": 1, - "onavstack": 1, "oninferno": 1, "ovh": { "c": { @@ -11677,7 +11781,6 @@ "collegefan": 1, "couchpotatofries": 1, "ddnss": 1, - "diskstation": 1, "dnsalias": 1, "dnsdojo": 1, "doesntexist": 1, @@ -11901,14 +12004,14 @@ "c": { "aem": 1, "codeberg": 1, + "deuxfleurs": 1, "heyflow": 1, "hlx": 1, "pdns": 1, "plesk": 1, "prvcy": 1, "rocky": 1, - "statichost": 1, - "translated": 1 + "statichost": 1 }, "l": 1 }, @@ -12350,6 +12453,7 @@ "cpa": 1, "eng": 1, "jur": 1, + "keenetic": 1, "law": 1, "med": 1, "ngrok": 1, @@ -12940,6 +13044,7 @@ } }, "preview": 1, + "sol": 1, "sourcecraft": 1, "square": 1, "srht": 1, @@ -12985,7 +13090,6 @@ "edu": 1, "gouv": 1, "org": 1, - "perso": 1, "univ": 1 }, "l": 1 @@ -13196,7 +13300,8 @@ "sydney": 1, "systems": { "c": { - "knightpoint": 1 + "knightpoint": 1, + "miren": 1 }, "l": 1 }, From 3fee05513cc72ef07c2927f2cabdc15efd688cc8 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Wed, 11 Feb 2026 01:07:54 +0800 Subject: [PATCH 071/174] chore: exclude files based on .gitignore --- script/zip.sh | 26 +++++++++++++++++--------- 1 file changed, 17 insertions(+), 9 deletions(-) diff --git a/script/zip.sh b/script/zip.sh index 2491e975c..b36efda56 100755 --- a/script/zip.sh +++ b/script/zip.sh @@ -6,12 +6,20 @@ FOLDER=$( ) TARGET_PATH="${FOLDER}/aaa" -COPYFILE_DISABLE=1 tar -zcvf ${TARGET_PATH} \ - --exclude=dist*/ \ - --exclude=.git/ \ - --exclude=package-lock.json \ - --exclude=node_modules \ - --exclude=firefox_dev*/ \ - --exclude=market_packages \ - --exclude=aaa \ - ./ +EXCLUDE_ARGS="" + +if [ -f "${FOLDER}/.gitignore" ]; then + while IFS= read -r line || [ -n "$line" ]; do + [[ -z "$line" || "$line" =~ ^# ]] && continue + line=$(echo "$line" | sed 's/^[[:space:]]*//;s/[[:space:]]*$//') + [ -z "$line" ] && continue + + pattern="${line#/}" + EXCLUDE_ARGS="${EXCLUDE_ARGS} --exclude=${pattern}" + done < "${FOLDER}/.gitignore" +fi + +EXCLUDE_ARGS="${EXCLUDE_ARGS} --exclude=.git" + +cd "${FOLDER}" +COPYFILE_DISABLE=1 tar -zcf ${TARGET_PATH} ${EXCLUDE_ARGS} ./ From 20c3ac457d92aa991716b7b32b78f74bf197a7f1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 12 Feb 2026 11:54:41 +0800 Subject: [PATCH 072/174] i18n(download): download translations by bot (#679) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/dashboard-resource.json | 4 +-- src/i18n/message/app/habit-resource.json | 1 + src/i18n/message/app/help-us-resource.json | 6 ++-- src/i18n/message/app/limit-resource.json | 1 + src/i18n/message/app/merge-rule-resource.json | 6 +++- src/i18n/message/app/option-resource.json | 28 +++++++++++++++++++ src/i18n/message/app/report-resource.json | 4 ++- .../message/app/site-manage-resource.json | 20 +++++++++++++ 8 files changed, 63 insertions(+), 7 deletions(-) diff --git a/src/i18n/message/app/dashboard-resource.json b/src/i18n/message/app/dashboard-resource.json index 131cdf741..f36cc5e9c 100644 --- a/src/i18n/message/app/dashboard-resource.json +++ b/src/i18n/message/app/dashboard-resource.json @@ -284,7 +284,7 @@ }, "pl": { "heatMap": { - "title0": "Przeglądano przez {hour} godzin w zeszłym roku", + "title0": "Przeglądano przez {hour} godzin(y) w zeszłym roku", "title1": "Przeglądano mniej niż 1 godzinę w ubiegłym roku" }, "topK": { @@ -293,7 +293,7 @@ "indicator": { "installedDays": "Zainstalowane przez {number} dni", "visitCount": "Odwiedzono {site} różnych stron, łącznie {visit} razy", - "browsingTime": "Przeglądano przez {minute} minut", + "browsingTime": "Przeglądano przez {minute} minut(y)", "mostUse": "Najchętniej przeglądano w godzinach od {start} do {end}" }, "monthOnMonth": { diff --git a/src/i18n/message/app/habit-resource.json b/src/i18n/message/app/habit-resource.json index 09bb02c72..3015ebf81 100644 --- a/src/i18n/message/app/habit-resource.json +++ b/src/i18n/message/app/habit-resource.json @@ -460,6 +460,7 @@ "focusAverage": "Średnio {value} dziennie" }, "period": { + "title": "Nawyki w określonym przedziale czasowym", "busiest": "Najbardziej aktywny czas dnia", "idle": "Najdłuższy czas bezczynności", "chartType": { diff --git a/src/i18n/message/app/help-us-resource.json b/src/i18n/message/app/help-us-resource.json index 26a4eee41..ac342515e 100644 --- a/src/i18n/message/app/help-us-resource.json +++ b/src/i18n/message/app/help-us-resource.json @@ -134,9 +134,9 @@ "pl": { "title": "Możesz pomóc w ulepszaniu tłumaczeń tego rozszerzenia!", "alert": { - "l1": "Ze względu na umiejętności językowe autora, rozszerzenie domyślnie obsługuje jedynie język chiński uproszczony i angielski, natomiast pozostałe języki są albo nieobsługiwane, albo przetłumaczone maszynowo.", - "l2": "W celu zapewnienia lepszego doświadczenia użytkownikom hostuję pliki pliki do przetłumaczenia na platformie Crowdin. Crowdin to system zarządzania tłumaczeniami, który jest darmowy dla projektów open source.", - "l3": "Jeżeli uważasz, że to rozszerzenie jest dla Ciebie przydatne i chciałbyś poprawić jego tłumaczenie, kliknij przycisk poniżej, aby przejść do strony głównej projektu na Crowdin." + "l1": "W celu zapewnienia lepszego doświadczenia użytkownikom hostuję pliki do przetłumaczenia na platformie Crowdin.", + "l2": "Jeżeli uważasz, że to rozszerzenie jest dla Ciebie przydatne i chciałbyś poprawić jego tłumaczenie, kliknij przycisk poniżej, aby przejść do strony głównej projektu na Crowdin.", + "l3": "Kiedy postęp w tłumaczeniu języka osiągnie 50%, rozważę poparcie go w tym rozszerzeniu." }, "button": "Przejdź do Crowdin", "loading": "Sprawdzanie postępu tłumaczenia...", diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index eb2bb22ba..9fd82c845 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -622,6 +622,7 @@ "pl": { "filterDisabled": "Tylko aktywne", "wildcardTip": "Możesz używać symbolu \"*\", aby dopasowywać subdomeny lub podstrony, a także użyć znaku „+” jako prefiksu, aby wykluczyć podstrony!", + "emptyTips": "Kliknij tutaj, aby utworzyć zasadę!", "item": { "name": "Nazwa zasady", "condition": "Limitowany URL", diff --git a/src/i18n/message/app/merge-rule-resource.json b/src/i18n/message/app/merge-rule-resource.json index d58fed4c7..1424e8eb1 100644 --- a/src/i18n/message/app/merge-rule-resource.json +++ b/src/i18n/message/app/merge-rule-resource.json @@ -238,6 +238,10 @@ "infoAlert0": "Kliknij przycisk [New One], pola wprowadzania strony źródłowej oraz strony scalenia zostaną wyświetlone, wypełnij i zapisz regułę", "infoAlert1": "Oryginalna strona może być wypełniona konkretną stroną lub wyrażeniem regularnym, takim jak *.baidu.com, *.google.com.*, aby określić, które witryny będą odpowiadać tej regule podczas scalania", "infoAlert2": "Scalona witryna może być wypełniona określoną witryną, liczbą lub zostawiona pusta", - "infoAlert3": "Liczba oznacza poziom scalonej witryny. Na przykład istnieje reguła '*.*.edu.cn >>> 3', a następnie 'www.hust.edu.cn' zostanie połączona z 'hust.edu.cn'" + "infoAlert3": "Liczba oznacza poziom scalonej witryny. Na przykład istnieje reguła '*.*.edu.cn >>> 3', a następnie 'www.hust.edu.cn' zostanie połączona z 'hust.edu.cn'", + "infoAlert4": "Puste oznacza, że oryginalna strona nie zostanie połączona", + "tagResult": { + "blank": "Nie scalaj" + } } } \ No newline at end of file diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index a91b5dff0..94593ded6 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -1493,6 +1493,7 @@ "whitelistItem": "skrótów związanych z whitelistą", "contextMenu": "menu kontekstowym", "displayBadgeText": "{input} Wyświetlanie {timeInfo} na {icon}", + "badgeBgColor": "Kolor tła tekstu na ikonie paska rozszerzeń {input}", "icon": "ikonie rozszerzenia", "badgeTextContent": "czasu przeglądania bieżącej strony", "locale": { @@ -1515,6 +1516,33 @@ }, "animationDuration": "Długość trwania początkowej animacji wykresu {input}" }, + "tracking": { + "title": "Monitorowanie", + "autoPauseTrack": "{input} Wstrzymaj monitorowanie, jeżeli nie wykryto aktywności {info} przez {maxTime}", + "localFileTime": "pliki lokalne", + "tabGroupInfo": "Po usunięciu grupy tagów, dane również zostaną usunięte.", + "tabGroupsPermGrant": "Ta funkcja wymaga odpowiednich uprawnień", + "weekStart": "Pierwszy dzień tygodnia {input}" + }, + "limit": { + "reminder": "Przypomnienie {input} {minInput} minut(y) przed limitem czasu", + "level": { + "label": "Jak odblokować podczas ograniczenia {input}", + "password": "Musisz wprowadzić hasło, aby odblokować", + "verification": "Musisz wprowadzić kod weryfikacyjny, aby odblokować", + "passwordLabel": "Hasło odblokowujące {input}", + "verificationLabel": "Trudność kodu weryfikacyjnego {input}", + "verificationDifficulty": { + "easy": "Łatwy", + "hard": "Trudny", + "disgusting": "Odrzucający" + }, + "strict": "Nie zezwalaj na odblokowanie mimo to", + "strictTitle": "Potwierdzenie operacji", + "strictContent": "Gdy wybierzesz tę opcję, jeśli witryna osiągnie dzienny limit, nie będzie wolno jej odblokować ręcznie inaczej, niż czekając do następnego dnia. Jeśli reguły nie są poprawnie skonfigurowane, mogą one skutecznie utrudnić twoją rutynę!", + "pswFormAgain": "Wprowadź ponownie" + } + }, "backup": { "title": "Kopia zapasowa danych", "client": "Nazwa klienta {input}", diff --git a/src/i18n/message/app/report-resource.json b/src/i18n/message/app/report-resource.json index a257efd0b..8a5675c64 100644 --- a/src/i18n/message/app/report-resource.json +++ b/src/i18n/message/app/report-resource.json @@ -253,7 +253,9 @@ "exportFileName": "My_Browsing_Time", "remoteReading": { "table": { - "client": "Nazwa klienta" + "client": "Nazwa klienta", + "value": "Wartość", + "percentage": "Procent" } } } diff --git a/src/i18n/message/app/site-manage-resource.json b/src/i18n/message/app/site-manage-resource.json index 53c5f912e..6b5eb02ed 100644 --- a/src/i18n/message/app/site-manage-resource.json +++ b/src/i18n/message/app/site-manage-resource.json @@ -508,12 +508,32 @@ } }, "pl": { + "deleteConfirmMsg": "{host} zostanie usunięty", "column": { + "type": "Typ witryny", "alias": "Nazwa strony", + "cate": "Kategoria witryny", "icon": "Ikona" }, + "type": { + "merged": { + "name": "scalone" + } + }, + "cate": { + "name": "Nazwa", + "relatedMsg": "Ta kategoria została przypisana do witryn {siteCount} i nie może zostać usunięta", + "removeConfirm": "Potwierdzasz usunięcie kategorii: {category}?", + "batchChange": "Zmień kategorie" + }, "form": { + "emptyAlias": "Wprowadź nazwę witryny", "emptyHost": "Wprowadź adres URL witryny" + }, + "msg": { + "noSelected": "Nie wybrano żadnej witryny", + "disassociatedMsg": "Czy chcesz wyczyścić kategorie wszystkich wybranych witryn?", + "batchDeleteMsg": "Czy chcesz usunąć wszystkie wybrane witryny?" } } } \ No newline at end of file From 44323bf8c4306f7a185246c06bf53cfbcf90de11 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Thu, 12 Feb 2026 12:19:35 +0800 Subject: [PATCH 073/174] fix: the title style of top-k chart --- package.json | 24 ++++++++-------- src/pages/app/Layout/menu/Side.tsx | 7 ++--- src/pages/app/Layout/menu/item.ts | 4 +-- .../components/TopKVisit/Title/index.tsx | 28 ++++++++++--------- src/pages/app/util/limit.tsx | 1 + src/pages/components/Flex.tsx | 4 ++- tsconfig.json | 6 ++-- 7 files changed, 38 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index e60ca434a..8e358d1ce 100644 --- a/package.json +++ b/package.json @@ -31,20 +31,20 @@ "@crowdin/crowdin-api-client": "^1.52.0", "@emotion/babel-plugin": "^11.13.5", "@emotion/css": "^11.13.5", - "@rsdoctor/rspack-plugin": "^1.5.0", - "@rspack/cli": "^1.7.3", - "@rspack/core": "^1.7.3", - "@swc/core": "^1.15.10", + "@rsdoctor/rspack-plugin": "^1.5.2", + "@rspack/cli": "^1.7.6", + "@rspack/core": "^1.7.6", + "@swc/core": "^1.15.11", "@swc/jest": "^0.2.39", - "@types/chrome": "0.1.35", + "@types/chrome": "0.1.36", "@types/decompress": "^4.2.7", "@types/jest": "^30.0.0", - "@types/node": "^25.0.9", + "@types/node": "^25.2.3", "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.0.0", - "commitlint": "^20.3.1", - "css-loader": "^7.1.2", + "commitlint": "^20.4.1", + "css-loader": "^7.1.3", "decompress": "^4.2.1", "husky": "^9.1.7", "jest": "^30.2.0", @@ -54,7 +54,7 @@ "postcss": "^8.5.6", "postcss-loader": "^8.2.0", "postcss-rtlcss": "^5.7.1", - "puppeteer": "^24.35.0", + "puppeteer": "^24.37.2", "ts-loader": "^9.5.4", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", @@ -63,10 +63,10 @@ "dependencies": { "@element-plus/icons-vue": "^2.3.2", "echarts": "^6.0.0", - "element-plus": "2.13.1", + "element-plus": "2.13.2", "punycode": "^2.3.1", - "vue": "^3.5.27", - "vue-router": "^4.6.4" + "vue": "^3.5.28", + "vue-router": "^5.0.2" }, "engines": { "node": ">=22" diff --git a/src/pages/app/Layout/menu/Side.tsx b/src/pages/app/Layout/menu/Side.tsx index d438effad..7168d671a 100644 --- a/src/pages/app/Layout/menu/Side.tsx +++ b/src/pages/app/Layout/menu/Side.tsx @@ -103,10 +103,9 @@ const _default = defineComponent(() => { > {collapsed.value ? menus.flatMap(g => g.children).map(item => renderItem(item, router, curr)) - : menus.map(({ children, title }) => ( - - {children.map(item => renderItem(item, router, curr))} - + // Not export type of ElMenuItemGroup, so use h() directly + : menus.map(({ children, title }) => h(ElMenuItemGroup, { title: t(title) }, + () => children.map(item => renderItem(item, router, curr)) ))}
diff --git a/src/pages/app/Layout/menu/item.ts b/src/pages/app/Layout/menu/item.ts index f146fd20b..c8f1c4b27 100644 --- a/src/pages/app/Layout/menu/item.ts +++ b/src/pages/app/Layout/menu/item.ts @@ -10,7 +10,7 @@ import { type I18nKey } from "@app/locale" import { ANALYSIS_ROUTE, MERGE_ROUTE } from "@app/router/constants" import { Aim, Connection, HelpFilled, Histogram, Memo, MoreFilled, Rank, SetUp, Stopwatch, Timer, View } from "@element-plus/icons-vue" import { getGuidePageUrl } from "@util/constant/url" -import { type IconProps } from "element-plus" +import { type Component } from 'vue' import About from "../icons/About" import Database from "../icons/Database" import Table from "../icons/Table" @@ -20,7 +20,7 @@ import Whitelist from "../icons/Whitelist" export type MenuItem = { title: I18nKey - icon: IconProps | string + icon: Component | string route?: string href?: string index?: string diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/Title/index.tsx b/src/pages/app/components/Dashboard/components/TopKVisit/Title/index.tsx index 5125fe64c..b6eb5d790 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/Title/index.tsx +++ b/src/pages/app/components/Dashboard/components/TopKVisit/Title/index.tsx @@ -49,24 +49,26 @@ const Title = defineComponent(() => { return () => ( - + {tN(msg => msg.dashboard.topK.title, { k: , day: , })} - filter.topKChartType = val as TopKChartType} - > - {Object.entries(CHART_CONFIG).map(([k, v]) => ( - - {v} - - ))} - + + filter.topKChartType = val as TopKChartType} + > + {Object.entries(CHART_CONFIG).map(([k, v]) => ( + + {v} + + ))} + + ) }) diff --git a/src/pages/app/util/limit.tsx b/src/pages/app/util/limit.tsx index 8e0b2378e..4f3d8f264 100644 --- a/src/pages/app/util/limit.tsx +++ b/src/pages/app/util/limit.tsx @@ -168,6 +168,7 @@ export function processVerification(option: timer.option.LimitOption, context?: // Double check const btn = (appendTo ?? document).querySelector(`.${okBtnClz}`) if (!btn) return + if (typeof data === 'string') return const { value } = data if (value === answerValue) return resolve() ElMessage.error({ appendTo, message: incorrectMessage }) diff --git a/src/pages/components/Flex.tsx b/src/pages/components/Flex.tsx index c54d81362..f611489a4 100644 --- a/src/pages/components/Flex.tsx +++ b/src/pages/components/Flex.tsx @@ -16,6 +16,7 @@ type Props = { justify?: CSSProperties['justifyContent'] gap?: string | number columnGap?: string | number + rowGap?: string | number wrap?: CSSProperties['flexWrap'] | boolean href?: string target?: HTMLAnchorElement['target'] @@ -38,6 +39,7 @@ const Flex = defineComponent(props => { justifyContent: props.justify, flexWrap: cvtFlexWrap(props.wrap), columnGap: cvtPxScale(props.columnGap), + rowGap: cvtPxScale(props.rowGap), gap: cvtPxScale(props.gap), ...cvt2BaseStyle(props), }} @@ -47,6 +49,6 @@ const Flex = defineComponent(props => { {defaultSlots && h(defaultSlots)} ) -}, { props: [...ALL_BASE_PROPS, 'direction', 'column', 'flex', 'align', 'justify', 'gap', 'columnGap', 'wrap', 'as'] }) +}, { props: [...ALL_BASE_PROPS, 'direction', 'column', 'flex', 'align', 'justify', 'gap', 'columnGap', 'rowGap', 'wrap', 'as'] }) export default Flex \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index e743153aa..80e4dce0f 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,8 @@ "module": "esnext", "target": "esnext", "lib": [ - "ES2023" + "ES2023", + "DOM", ], "jsx": "preserve", "jsxFactory": "h", @@ -64,9 +65,6 @@ ] } }, - "lib": [ - "dom" - ], "exclude": [ "node_modules", "dist" From 9cdecf5ae9b32aa54b36de7c4acd500c3f49690a Mon Sep 17 00:00:00 2001 From: sheepzh Date: Thu, 12 Feb 2026 12:43:51 +0800 Subject: [PATCH 074/174] test: complete test cases for whitelist processor --- test/service/whitelist/processor.test.ts | 111 +++++++++++++++++++---- tsconfig.json | 5 +- 2 files changed, 97 insertions(+), 19 deletions(-) diff --git a/test/service/whitelist/processor.test.ts b/test/service/whitelist/processor.test.ts index 4a5de79c6..eadc3521f 100644 --- a/test/service/whitelist/processor.test.ts +++ b/test/service/whitelist/processor.test.ts @@ -5,28 +5,103 @@ describe('whitelist-holder', () => { beforeEach(() => processor = new WhitelistProcessor()) - test('normal', () => { - processor.setWhitelist([ - "www.google.com", - "github.com", + const verify = (whitelist: string[], cases: Array<[string, string, boolean]>) => { + processor.setWhitelist(whitelist) + cases.forEach(([host, url, expected]) => expect(processor.contains(host, url)).toBe(expected)) + } + + test('setWhitelist basic', () => { + processor.setWhitelist([]) + expect(processor.contains("github.com", "https://github.com/")).toBeFalsy() + + processor.setWhitelist(['', 'github.com', '', null as any, undefined as any]) + expect(processor.contains("github.com", "https://github.com/")).toBeTruthy() + + processor.setWhitelist(['google.com']) + expect(processor.contains("github.com", "https://github.com/")).toBeFalsy() + expect(processor.contains("google.com", "https://google.com/")).toBeTruthy() + }) + + test('normal hosts', () => { + verify(["www.google.com", "github.com"], [ + ["github.com", "", true], + ["github.com", "https://unrelated.com/", true], // URL ignored + ["www.google.com", "http://www.google.com/search", true], + ["www.github.com", "https://www.github.com/", false], + ["google.com", "https://google.com/", false], + ]) + }) + + test('virtual hosts with wildcards', () => { + verify(["github.com/*", "*.google.com/**", "*.example.com/path/*"], [ + // Single wildcard: matches one level + ["", "https://github.com/sheepzh", true], + ["", "https://github.com", false], + ["", "https://github.com/sheepzh/timer", false], + // Host wildcard + ["", "http://map.google.com/search", true], + ["", "http://foo.bar.google.com/path", true], + ["", "https://google.com/", false], + // Complex pattern + ["", "https://sub.example.com/path/to", true], + ["", "https://sub.example.com/path/to/nested", false], + ]) + + verify(["gitlab.com/**"], [ + // Double wildcard: matches any depth + ["", "https://gitlab.com/", true], + ["", "https://gitlab.com/group/project/issues", true], + ]) + }) + + test('exclude patterns', () => { + verify( + ["github.com", "*.google.com/**", "+github.com/login", "+www.google.com/**"], + [ + ["github.com", "https://github.com/", true], + ["github.com", "https://github.com/login", false], + ["", "http://map.google.com/search", true], + ["", "https://www.google.com/search", false], + ] + ) + + verify(["example.com/**", "+example.com/admin/**", "+example.com/login"], [ + ["", "https://example.com/page", true], + ["", "https://example.com/login", false], + ["", "https://example.com/admin/dashboard", false], ]) - expect(processor.contains("github.com", "")).toBeTruthy() - expect(processor.contains("www.github.com", "")).toBeFalsy() - expect(processor.contains("www.google.com", "http://www.google.com/search")).toBeTruthy() }) - test('wildcards', () => { - processor.setWhitelist([ - "www.github.com", - "*.google.com/**", - "+www.google.com/**", + test('mixed patterns', () => { + verify(["example.com", "test.com/**", "github.com", "*.example.com/**"], [ + // Normal host: only checks host param + ["example.com", "https://other.com/", true], + ["other.com", "https://example.com/", false], + // Virtual host: checks URL + ["", "https://test.com/path", true], + ["", "https://other.com/path", false], + // Empty host param: only virtual patterns work + ["", "https://github.com/", false], + ["", "https://sub.example.com/path", true], ]) - expect(processor.contains("google.com", "https://google.com/")).toBeFalsy() - expect(processor.contains("", "http://map.google.com/search")).toBeTruthy() + }) + + test('edge cases', () => { + // Trailing slashes, special chars, duplicates, long list + verify(["github.com/**", "example.com/path-with-dash/**"], [ + ["", "https://github.com/", true], + ["", "https://github.com", true], + ["", "https://example.com/path-with-dash/page", true], + ["", "https://example.com/path_with_dash/page", false], + ]) + + processor.setWhitelist(['github.com', 'github.com']) + expect(processor.contains("github.com", "")).toBeTruthy() - // virtual sites only use url - expect(processor.contains("www.google.com", "https://foo.bar.google.com/")).toBeTruthy() - // hit "+www.google.com/**" - expect(processor.contains("www.google.com", "https://www.google.com/")).toBeFalsy() + const longList = Array.from({ length: 100 }, (_, i) => `site${i}.com`) + processor.setWhitelist(longList) + expect(processor.contains("site0.com", "")).toBeTruthy() + expect(processor.contains("site99.com", "")).toBeTruthy() + expect(processor.contains("site100.com", "")).toBeFalsy() }) }) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 80e4dce0f..0dfc5b408 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,7 +4,10 @@ "target": "esnext", "lib": [ "ES2023", - "DOM", + "DOM" + ], + "types": [ + "jest" ], "jsx": "preserve", "jsxFactory": "h", From 10e92da2dda7094ea99d950aa69d9fa9b2e03034 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 24 Feb 2026 01:13:22 +0800 Subject: [PATCH 075/174] ci: fix translation download --- .github/workflows/crowdin-export.yml | 32 ++++++++++++++++++++++------ package.json | 18 ++++++++-------- script/crowdin/common.ts | 8 +++++-- tsconfig.json | 3 ++- 4 files changed, 42 insertions(+), 19 deletions(-) diff --git a/.github/workflows/crowdin-export.yml b/.github/workflows/crowdin-export.yml index afd0cded5..caf84e91a 100644 --- a/.github/workflows/crowdin-export.yml +++ b/.github/workflows/crowdin-export.yml @@ -14,20 +14,38 @@ jobs: with: fetch-depth: 1 token: ${{secrets.GITHUB_TOKEN}} + - name: Test using Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v6 with: - node-version: "v22" - - name: Install ts-node - run: npm i -g ts-node + node-version: "lts/*" + - name: Install dependencies run: npm install + - name: Export translations - run: ts-node --project ./tsconfig.node.json ./script/crowdin/export-translation.ts + run: | + npx ts-node --project ./tsconfig.node.json ./script/crowdin/export-translation.ts 2>&1 | tee /tmp/export-output.log + exit ${PIPESTATUS[0]} + continue-on-error: true + + - name: Check exporting logs + if: always() + run: | + if grep -q -F "[CROWDIN-ERROR]" /tmp/export-output.log; then + echo "::error:: Found errors in export logs" + exit 1 + else + echo "No errors found in export logs" + fi + - name: Test typescript - uses: icrawl/action-tsc@v1 + if: success() + run: npx tsc --noEmit + - name: Create Pull Request - uses: peter-evans/create-pull-request@v7 + if: success() + uses: peter-evans/create-pull-request@v8 with: commit-message: "i18n(download): download translations by bot" branch: crowdin-export/patch diff --git a/package.json b/package.json index 8e358d1ce..2a8469952 100644 --- a/package.json +++ b/package.json @@ -34,17 +34,17 @@ "@rsdoctor/rspack-plugin": "^1.5.2", "@rspack/cli": "^1.7.6", "@rspack/core": "^1.7.6", - "@swc/core": "^1.15.11", + "@swc/core": "^1.15.13", "@swc/jest": "^0.2.39", - "@types/chrome": "0.1.36", + "@types/chrome": "0.1.37", "@types/decompress": "^4.2.7", "@types/jest": "^30.0.0", - "@types/node": "^25.2.3", + "@types/node": "^25.3.0", "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.0.0", - "commitlint": "^20.4.1", - "css-loader": "^7.1.3", + "commitlint": "^20.4.2", + "css-loader": "^7.1.4", "decompress": "^4.2.1", "husky": "^9.1.7", "jest": "^30.2.0", @@ -52,9 +52,9 @@ "jest-junit": "^16.0.0", "jszip": "^3.10.1", "postcss": "^8.5.6", - "postcss-loader": "^8.2.0", + "postcss-loader": "^8.2.1", "postcss-rtlcss": "^5.7.1", - "puppeteer": "^24.37.2", + "puppeteer": "^24.37.5", "ts-loader": "^9.5.4", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", @@ -66,9 +66,9 @@ "element-plus": "2.13.2", "punycode": "^2.3.1", "vue": "^3.5.28", - "vue-router": "^5.0.2" + "vue-router": "^5.0.3" }, "engines": { "node": ">=22" } -} +} \ No newline at end of file diff --git a/script/crowdin/common.ts b/script/crowdin/common.ts index 8971278c7..bdddecb56 100644 --- a/script/crowdin/common.ts +++ b/script/crowdin/common.ts @@ -139,7 +139,7 @@ export async function mergeMessage( const filePath = path.join(dirPath, filename) const existMessages = (await import(`@i18n/message/${dir}/${filename}`))?.default as Messages if (!existMessages) { - console.error(`Failed to find local code: dir=${dir}, filename=${filename}`) + logError(`Failed to find local code: dir=${dir}, filename=${filename}`) return } const sourceItemSet = transMsg(existMessages[SOURCE_LOCALE]) @@ -155,7 +155,7 @@ export async function mergeMessage( // Deleted key if (!sourceText) return if (!checkPlaceholder(text, sourceText)) { - console.error(`Invalid placeholder: dir=${dir}, filename=${filename}, path=${path}, source=${sourceText}, translated=${text}`) + logError(`Invalid placeholder: locale=${locale}, dir=${dir}, filename=${filename}, path=${path}, source=${sourceText}, translated=${text}`) return } const pathSeg = path.split('.') @@ -168,6 +168,10 @@ export async function mergeMessage( fs.writeFileSync(filePath, newFileContent, { encoding: 'utf-8' }) } +function logError(msg: string) { + console.error(`[CROWDIN-ERROR] ${msg}`) +} + function checkPlaceholder(translated: string, source: string) { const allSourcePlaceholders = Array.from(source.matchAll(/\{(.*?)\}/g)) diff --git a/tsconfig.json b/tsconfig.json index 0dfc5b408..8c8d779c9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -7,7 +7,8 @@ "DOM" ], "types": [ - "jest" + "jest", + "chrome" ], "jsx": "preserve", "jsxFactory": "h", From 034df4245803e37cc3754666cff08eda7eeae9b4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 24 Feb 2026 01:23:05 +0800 Subject: [PATCH 076/174] i18n(download): download translations by bot (#683) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/dashboard-resource.json | 2 +- src/i18n/message/app/limit-resource.json | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/src/i18n/message/app/dashboard-resource.json b/src/i18n/message/app/dashboard-resource.json index f36cc5e9c..ba228606f 100644 --- a/src/i18n/message/app/dashboard-resource.json +++ b/src/i18n/message/app/dashboard-resource.json @@ -284,7 +284,7 @@ }, "pl": { "heatMap": { - "title0": "Przeglądano przez {hour} godzin(y) w zeszłym roku", + "title0": "Przeglądano przez {hour} godzin w zeszłym roku", "title1": "Przeglądano mniej niż 1 godzinę w ubiegłym roku" }, "topK": { diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index 9fd82c845..5d651b351 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -300,6 +300,8 @@ "timeout": "Час вийшов! XD" }, "verification": { + "inputTip": "Правило було запущено або заблоковано. Щоб продовжити, введіть відповідь на таке запитання протягом {second} секунд: {prompt}", + "inputTip2": "Правило було запущено або заблоковано. Щоб продовжити, введіть його таким, яким він є, протягом {second} секунд: {answer}", "pswInputTip": "Правило обмеження вже було запущено. Щоб продовжити, введіть пароль", "strictTip": "Правило обмеження вже активовано і ручне розблокування не дозволено!", "incorrectPsw": "Неправильний пароль", From e357bfe9cc2157fc6c4a2f46ec01b0f8603a90e8 Mon Sep 17 00:00:00 2001 From: sheepie Date: Wed, 25 Feb 2026 20:50:44 +0700 Subject: [PATCH 077/174] feat: support IndexedDB (#656) --- .vscode/settings.json | 1 + jest.config.ts | 3 + package.json | 6 +- rspack/rspack.common.ts | 1 - src/background/data-cleaner.ts | 2 +- src/background/limit-processor.ts | 5 +- src/background/message-dispatcher.ts | 2 +- src/background/migrator/cate-initializer.ts | 5 +- .../migrator/host-merge-initializer.ts | 2 +- src/background/migrator/index.ts | 6 +- src/background/migrator/indexed-migrator.ts | 14 + .../migrator/limit-rule-migrator.ts | 35 -- .../migrator/local-file-initializer.ts | 2 +- .../migrator/{common.ts => types.ts} | 0 .../migrator/whitelist-initializer.ts | 5 +- src/background/track-server/group.ts | 2 +- src/background/track-server/runtime.ts | 2 +- src/database/backup-database.ts | 4 - src/database/common/base-database.ts | 8 - src/database/common/indexed-storage.ts | 210 ++++++++++ src/database/common/migratable.ts | 29 ++ src/database/common/storage-holder.ts | 23 ++ src/database/common/storage-promise.ts | 2 +- src/database/limit-database.ts | 73 +++- src/database/merge-rule-database.ts | 44 ++- src/database/meta-database.ts | 15 - src/database/option-database.ts | 8 - src/database/period-database.ts | 45 +-- src/database/site-cate-database.ts | 24 +- src/database/site-database.ts | 4 - src/database/stat-database/classic.ts | 264 +++++++++++++ src/database/stat-database/common.ts | 24 ++ src/database/stat-database/condition.ts | 70 ++++ src/database/stat-database/constants.ts | 1 - src/database/stat-database/filter.ts | 130 ------ src/database/stat-database/idb.ts | 359 +++++++++++++++++ src/database/stat-database/index.ts | 370 +++++------------- src/database/stat-database/types.ts | 73 ++++ src/database/timeline-database.ts | 101 ----- src/database/timeline-database/classic.ts | 66 ++++ src/database/timeline-database/idb.ts | 130 ++++++ src/database/timeline-database/index.ts | 26 ++ src/database/timeline-database/types.ts | 12 + src/database/types.d.ts | 35 ++ src/database/whitelist-database.ts | 30 +- .../message/app/data-manage-resource.json | 1 + src/i18n/message/app/data-manage.ts | 1 + src/i18n/message/app/option-resource.json | 4 +- src/i18n/message/app/option.ts | 2 + .../Dashboard/components/Indicator.tsx | 2 +- .../components/Timeline/Chart/index.tsx | 4 +- .../components/Timeline/Chart/useMerge.ts | 9 +- .../components/Timeline/constants.ts | 4 + .../Dashboard/components/Timeline/index.tsx | 5 +- .../DataManage/ClearPanel/index.tsx | 2 +- .../app/components/DataManage/MemoryInfo.tsx | 53 ++- .../DataManage/Migration/ImportButton.tsx | 4 +- .../components/DataManage/Migration/index.tsx | 6 +- .../Option/components/OptionItem.tsx | 2 +- .../Option/components/OptionLines.tsx | 23 +- .../Option/components/TrackingOption.tsx | 49 ++- .../Report/ReportFilter/BatchDelete.tsx | 4 +- .../components/Report/ReportTable/index.tsx | 2 +- src/pages/app/components/Report/common.ts | 10 +- .../common/kanban/IndicatorCell.tsx | 5 +- src/pages/hooks/useRequest.ts | 7 +- src/pages/util/style.ts | 6 +- src/service/components/immigration.ts | 78 ++-- src/service/components/import-processor.ts | 2 +- src/service/item-service.ts | 10 +- src/service/stat-service/index.ts | 4 +- src/util/constant/option.ts | 1 + src/util/guard.ts | 7 + src/util/time.ts | 20 +- test/__mock__/runtime.ts | 7 + test/database/idb.ts | 17 + test/database/limit-database.test.ts | 7 +- test/database/merge-rule-database.test.ts | 3 +- test/database/migratable.ts | 10 + test/database/period-database.test.ts | 60 +-- .../classic.test.ts} | 60 ++- test/database/stat-database/idb.test.ts | 224 +++++++++++ test/jest.setup.ts | 4 + test/util/time.test.ts | 15 +- tsconfig.json | 4 + types/timer/backup.d.ts | 19 + types/timer/option.d.ts | 11 + 87 files changed, 2117 insertions(+), 924 deletions(-) create mode 100644 src/background/migrator/indexed-migrator.ts delete mode 100644 src/background/migrator/limit-rule-migrator.ts rename src/background/migrator/{common.ts => types.ts} (100%) create mode 100644 src/database/common/indexed-storage.ts create mode 100644 src/database/common/migratable.ts create mode 100644 src/database/common/storage-holder.ts create mode 100644 src/database/stat-database/classic.ts create mode 100644 src/database/stat-database/common.ts create mode 100644 src/database/stat-database/condition.ts delete mode 100644 src/database/stat-database/constants.ts delete mode 100644 src/database/stat-database/filter.ts create mode 100644 src/database/stat-database/idb.ts create mode 100644 src/database/stat-database/types.ts delete mode 100644 src/database/timeline-database.ts create mode 100644 src/database/timeline-database/classic.ts create mode 100644 src/database/timeline-database/idb.ts create mode 100644 src/database/timeline-database/index.ts create mode 100644 src/database/timeline-database/types.ts create mode 100644 src/database/types.d.ts create mode 100644 src/pages/app/components/Dashboard/components/Timeline/constants.ts create mode 100644 src/util/guard.ts create mode 100644 test/__mock__/runtime.ts create mode 100644 test/database/idb.ts create mode 100644 test/database/migratable.ts rename test/database/{stat-database.test.ts => stat-database/classic.test.ts} (79%) create mode 100644 test/database/stat-database/idb.test.ts create mode 100644 test/jest.setup.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 02e9869d3..e0dc432ac 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -33,6 +33,7 @@ "ensp", "filemanager", "Hengyang", + "indexeddb", "Kanban", "MKCOL", "newtab", diff --git a/jest.config.ts b/jest.config.ts index 60007387e..0c46e306e 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -45,6 +45,9 @@ const config: Config = { "^.+\\.tsx?$": "@swc/jest" }, moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], + setupFiles: [ + '/test/jest.setup.ts', + ], } export default config \ No newline at end of file diff --git a/package.json b/package.json index 2a8469952..24f4a178c 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,7 @@ "commitlint": "^20.4.2", "css-loader": "^7.1.4", "decompress": "^4.2.1", + "fake-indexeddb": "^6.2.5", "husky": "^9.1.7", "jest": "^30.2.0", "jest-environment-jsdom": "^30.2.0", @@ -65,10 +66,11 @@ "echarts": "^6.0.0", "element-plus": "2.13.2", "punycode": "^2.3.1", - "vue": "^3.5.28", + "typescript-guard": "^0.2.1", + "vue": "^3.5.29", "vue-router": "^5.0.3" }, "engines": { "node": ">=22" } -} \ No newline at end of file +} diff --git a/rspack/rspack.common.ts b/rspack/rspack.common.ts index 8fbcda9c7..3f2554c0c 100644 --- a/rspack/rspack.common.ts +++ b/rspack/rspack.common.ts @@ -15,7 +15,6 @@ const generateJsonPlugins: RspackPluginInstance[] = [] const localeJsonFiles = Object.entries(i18nChrome) .map(([locale, message]) => new GenerateJsonPlugin(`_locales/${locale}/messages.json`, message)) - .map(plugin => plugin as unknown as RspackPluginInstance) generateJsonPlugins.push(...localeJsonFiles) type EntryConfig = { diff --git a/src/background/data-cleaner.ts b/src/background/data-cleaner.ts index 32d6dbf01..7468b6fd8 100644 --- a/src/background/data-cleaner.ts +++ b/src/background/data-cleaner.ts @@ -16,7 +16,7 @@ const cleanPeriodData = async () => { export default function initDataCleaner() { alarmManager.setWhen( PERIOD_ALARM_NAME, - () => getStartOfDay(new Date()).getTime() + MILL_PER_DAY, + () => getStartOfDay(new Date()) + MILL_PER_DAY, cleanPeriodData, ) } \ No newline at end of file diff --git a/src/background/limit-processor.ts b/src/background/limit-processor.ts index 3723b35a8..baca1709f 100644 --- a/src/background/limit-processor.ts +++ b/src/background/limit-processor.ts @@ -46,10 +46,7 @@ function initDailyBroadcast() { // Broadcast rules at the start of each day alarmManager.setWhen( 'limit-daily-broadcast', - () => { - const startOfThisDay = getStartOfDay(new Date()) - return startOfThisDay.getTime() + MILL_PER_DAY - }, + () => getStartOfDay(new Date()) + MILL_PER_DAY, () => limitService.broadcastRules(), ) } diff --git a/src/background/message-dispatcher.ts b/src/background/message-dispatcher.ts index 7ee147690..d6b94f8ea 100644 --- a/src/background/message-dispatcher.ts +++ b/src/background/message-dispatcher.ts @@ -33,7 +33,7 @@ class MessageDispatcher { const result = await handler(message.data, sender) return { code: 'success', data: result } } catch (error) { - const msg = (error as Error)?.message ?? error?.toString?.() + const msg = error instanceof Error ? error.message : error?.toString?.() return { code: 'fail', msg } } } diff --git a/src/background/migrator/cate-initializer.ts b/src/background/migrator/cate-initializer.ts index ca81f6ecf..3049d30c6 100644 --- a/src/background/migrator/cate-initializer.ts +++ b/src/background/migrator/cate-initializer.ts @@ -1,6 +1,6 @@ import cateService from "@service/cate-service" import { batchSaveSiteCate } from "@service/site-service" -import { Migrator } from "./common" +import type { Migrator } from "./types" type InitialCate = { name: string @@ -44,7 +44,6 @@ export default class CateInitializer implements Migrator { } } - onUpdate(version: string): void { - version === '3.0.1' && this.onInstall() + onUpdate(_version: string): void { } } \ No newline at end of file diff --git a/src/background/migrator/host-merge-initializer.ts b/src/background/migrator/host-merge-initializer.ts index 31d5af773..37b54d403 100644 --- a/src/background/migrator/host-merge-initializer.ts +++ b/src/background/migrator/host-merge-initializer.ts @@ -6,7 +6,7 @@ */ import mergeRuleDatabase from "@db/merge-rule-database" -import { type Migrator } from "./common" +import type { Migrator } from "./types" /** * v0.1.2 diff --git a/src/background/migrator/index.ts b/src/background/migrator/index.ts index 3884040fd..ebd78210d 100644 --- a/src/background/migrator/index.ts +++ b/src/background/migrator/index.ts @@ -7,10 +7,10 @@ import { getVersion, onInstalled } from "@api/chrome/runtime" import CateInitializer from "./cate-initializer" -import { type Migrator } from "./common" import HostMergeInitializer from "./host-merge-initializer" -import LimitRuleMigrator from "./limit-rule-migrator" +import IndexedDBMigrator from './indexed-migrator' import LocalFileInitializer from "./local-file-initializer" +import type { Migrator } from "./types" import WhitelistInitializer from "./whitelist-initializer" /** @@ -27,7 +27,7 @@ class VersionManager { new LocalFileInitializer(), new WhitelistInitializer(), new CateInitializer(), - new LimitRuleMigrator(), + new IndexedDBMigrator(), ) } diff --git a/src/background/migrator/indexed-migrator.ts b/src/background/migrator/indexed-migrator.ts new file mode 100644 index 000000000..5c2c71198 --- /dev/null +++ b/src/background/migrator/indexed-migrator.ts @@ -0,0 +1,14 @@ +import timelineDatabase from '@db/timeline-database' +import type { Migrator } from './types' + +class IndexedMigrator implements Migrator { + onInstall(): void { + } + onUpdate(_version: string): void { + timelineDatabase.migrateFromClassic() + .then(() => console.log('Timeline data migrated to IndexedDB')) + .catch(e => console.error('Failed to migrate timeline data to IndexedDB', e)) + } +} + +export default IndexedMigrator \ No newline at end of file diff --git a/src/background/migrator/limit-rule-migrator.ts b/src/background/migrator/limit-rule-migrator.ts deleted file mode 100644 index 2dac2a865..000000000 --- a/src/background/migrator/limit-rule-migrator.ts +++ /dev/null @@ -1,35 +0,0 @@ -import limitService from "@service/limit-service" -import { cleanCond } from "@util/limit" -import type { Migrator } from "./common" - -export default class LimitRuleMigrator implements Migrator { - onInstall(): void { - } - - async onUpdate(_version: string): Promise { - const rules = await limitService.select() - if (!rules?.length) return - const needUpdate: timer.limit.Rule[] = [] - const needRemoved: timer.limit.Rule[] = [] - rules.forEach(async rule => { - const { cond } = rule - let changed = false - const newCond: string[] = [] - cond?.forEach(url => { - const clean = cleanCond(url) - changed = changed || clean !== url - clean && newCond.push(clean) - }) - if (!changed) return - if (rule.cond.length) { - rule.cond = newCond - needUpdate.push(rule) - } else { - needRemoved.push(rule) - } - - }) - needRemoved.length && await limitService.remove(...needRemoved) - needUpdate.length && await limitService.update(...needUpdate) - } -} \ No newline at end of file diff --git a/src/background/migrator/local-file-initializer.ts b/src/background/migrator/local-file-initializer.ts index 47af11916..4a19e57fa 100644 --- a/src/background/migrator/local-file-initializer.ts +++ b/src/background/migrator/local-file-initializer.ts @@ -9,7 +9,7 @@ import mergeRuleDatabase from "@db/merge-rule-database" import { t2Chrome } from "@i18n/chrome/t" import { saveAlias } from '@service/site-service' import { JSON_HOST, LOCAL_HOST_PATTERN, MERGED_HOST, PDF_HOST, PIC_HOST, TXT_HOST } from "@util/constant/remain-host" -import { type Migrator } from "./common" +import { type Migrator } from "./types" /** * Process the host of local files diff --git a/src/background/migrator/common.ts b/src/background/migrator/types.ts similarity index 100% rename from src/background/migrator/common.ts rename to src/background/migrator/types.ts diff --git a/src/background/migrator/whitelist-initializer.ts b/src/background/migrator/whitelist-initializer.ts index 5b6da1b43..e2cb2d1f0 100644 --- a/src/background/migrator/whitelist-initializer.ts +++ b/src/background/migrator/whitelist-initializer.ts @@ -1,12 +1,11 @@ import whitelistService from "@service/whitelist/service" -import { type Migrator } from "./common" +import type { Migrator } from "./types" export default class WhitelistInitializer implements Migrator { onInstall(): void { whitelistService.add('localhost:*/**') } - onUpdate(version: string): void { - version === '2.5.7' && this.onInstall() + onUpdate(_version: string): void { } } \ No newline at end of file diff --git a/src/background/track-server/group.ts b/src/background/track-server/group.ts index 123d1f16c..5e73906b4 100644 --- a/src/background/track-server/group.ts +++ b/src/background/track-server/group.ts @@ -1,7 +1,7 @@ import itemService from '@service/item-service' function handleTabGroupRemove(group: chrome.tabGroups.TabGroup) { - itemService.batchDeleteGroupById(group.id) + itemService.deleteByGroup(group.id) } export function handleTabGroupEnabled() { diff --git a/src/background/track-server/runtime.ts b/src/background/track-server/runtime.ts index 30df313c5..37588bd66 100644 --- a/src/background/track-server/runtime.ts +++ b/src/background/track-server/runtime.ts @@ -5,7 +5,7 @@ import { formatTimeYMD, getStartOfDay, MILL_PER_DAY } from "@util/time" function splitRunTime(start: number, end: number): Record { const res: Record = {} while (start < end) { - const startOfNextDay = getStartOfDay(start).getTime() + MILL_PER_DAY + const startOfNextDay = getStartOfDay(start) + MILL_PER_DAY const newStart = Math.min(end, startOfNextDay) const runTime = newStart - start runTime && (res[formatTimeYMD(start)] = runTime) diff --git a/src/database/backup-database.ts b/src/database/backup-database.ts index 082cdca46..ebabe390d 100644 --- a/src/database/backup-database.ts +++ b/src/database/backup-database.ts @@ -36,10 +36,6 @@ class BackupDatabase extends BaseDatabase { async updateCache(type: timer.backup.Type, newVal: unknown): Promise { return this.storage.put(cacheKeyOf(type), newVal as Object) } - - async importData(_data: any): Promise { - // Do nothing - } } const backupDatabase = new BackupDatabase() diff --git a/src/database/common/base-database.ts b/src/database/common/base-database.ts index 0137adc43..2c00a4e19 100644 --- a/src/database/common/base-database.ts +++ b/src/database/common/base-database.ts @@ -24,12 +24,4 @@ export default abstract class BaseDatabase { protected setByKey(key: string, val: any): Promise { return this.storage.put(key, val) } - - /** - * Import data - * - * @since 0.2.5 - * @param data backup data - */ - abstract importData(data: any): Promise } \ No newline at end of file diff --git a/src/database/common/indexed-storage.ts b/src/database/common/indexed-storage.ts new file mode 100644 index 000000000..893d19be2 --- /dev/null +++ b/src/database/common/indexed-storage.ts @@ -0,0 +1,210 @@ +const ALL_TABLES = ['stat', 'timeline'] as const + +export type Table = typeof ALL_TABLES[number] + +export type Key> = keyof T & string + +type IndexConfig> = { + key: Key | Key[] + unique?: boolean +} + +export type Index> = Key | Key[] | IndexConfig + +function normalizeIndex>(index: Index): IndexConfig { + return typeof index === 'string' || Array.isArray(index) ? { key: index } : index +} + +function formatIdxName>(key: IndexConfig['key']): string { + const keyStr = Array.isArray(key) ? [...key].sort().join('_') : key + return `idx_${keyStr}` +} + +export function req2Promise(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result) + req.onerror = (ev) => { + console.error("Failed to request indexed-db", ev, req.error) + reject(req.error) + } + }) +} + +export async function iterateCursor( + req: IDBRequest +): Promise +export async function iterateCursor( + req: IDBRequest, + processor: (cursor: IDBCursorWithValue) => void | Promise +): Promise + +export async function iterateCursor( + req: IDBRequest, + processor?: (cursor: IDBCursorWithValue) => void | Promise +): Promise { + const collectResults = !processor + const results: T[] = [] + + return new Promise((resolve, reject) => { + req.onsuccess = async () => { + const cursor = req.result + if (!cursor) return resolve(collectResults ? results : undefined) + + try { + collectResults && results.push(cursor.value as T) + await processor?.(cursor) + cursor.continue() + } catch (error) { + reject(error) + } + } + + req.onerror = () => reject(req.error) + }) +} + +export function closedRangeKey(lower: IDBValidKey | undefined, upper: IDBValidKey | undefined): IDBKeyRange | undefined { + if (lower !== undefined && upper !== undefined) { + if (lower > upper) { + [lower, upper] = [upper, lower] + } + return IDBKeyRange.bound(lower, upper, false, false) + } else if (lower !== undefined) { + return IDBKeyRange.lowerBound(lower, false) + } else if (upper !== undefined) { + return IDBKeyRange.upperBound(upper, false) + } else { + return undefined + } +} + +export type IndexResult = { + cursorReq: IDBRequest + coverage?: FilterCoverage +} + +export abstract class BaseIDBStorage> { + private DB_NAME = `tt4b_${chrome.runtime.id}` as const + + private db: IDBDatabase | undefined + abstract indexes: Index[] + abstract key: Key | Key[] + abstract table: Table + + protected async initDb(): Promise { + if (this.db) return this.db + + const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB + const checkRequest = factory.open(this.DB_NAME) + + return new Promise((resolve, reject) => { + checkRequest.onsuccess = () => { + const db = checkRequest.result + const storeExisted = db.objectStoreNames.contains(this.table) + const needUpgrade = !storeExisted || this.needUpgradeIndexes(db) + + if (!needUpgrade) { + this.db = db + return resolve(db) + } + + const currentVersion = db.version + db.close() + + const upgradeRequest = factory.open(this.DB_NAME, currentVersion + 1) + + upgradeRequest.onupgradeneeded = () => { + const upgradeDb = upgradeRequest.result + const transaction = upgradeRequest.transaction + if (!transaction) return reject("Failed to get transaction of upgrading request") + + let store = upgradeDb.objectStoreNames.contains(this.table) + ? transaction.objectStore(this.table) + : upgradeDb.createObjectStore(this.table, { keyPath: this.key }) + this.createIndexes(store) + } + + upgradeRequest.onsuccess = () => { + console.log("IndexedDB upgraded") + this.db = upgradeRequest.result + resolve(upgradeRequest.result) + } + + upgradeRequest.onerror = () => reject(upgradeRequest.error) + } + + checkRequest.onerror = () => reject(checkRequest.error) + }) + } + + private needUpgradeIndexes(db: IDBDatabase): boolean { + try { + const transaction = db.transaction(this.table, 'readonly') + const store = transaction.objectStore(this.table) + const indexNames = store.indexNames + + for (const index of this.indexes) { + const { key } = normalizeIndex(index) + const idxName = formatIdxName(key) + if (!indexNames.contains(idxName)) { + return true + } + } + return false + } catch (e) { + console.error("Failed to check indexes", e) + return true + } + } + + private createIndexes(store: IDBObjectStore) { + const existingIndexes = store.indexNames + + for (const index of this.indexes) { + const { key, unique } = normalizeIndex(index) + const idxName = formatIdxName(key) + if (!existingIndexes.contains(idxName)) { + store.createIndex(idxName, key, { unique }) + } + } + } + + protected async withStore(operation: (store: IDBObjectStore) => T | Promise, mode?: IDBTransactionMode): Promise { + const db = await this.initDb() + const trans = db.transaction(this.table, mode ?? 'readwrite') + try { + const store = trans.objectStore(this.table) + const result = await operation(store) + // Waiting for transaction completed + await new Promise((resolve, reject) => { + trans.oncomplete = () => resolve() + trans.onerror = () => reject(trans.error) + trans.onabort = () => reject(new Error('Transaction aborted')) + }) + return result + } catch (e) { + console.error("Failed to process with transaction", e) + if (!trans.error && trans.mode !== 'readonly') { + try { + trans.abort() + } catch (ignored) { } + } + throw e + } + } + + protected assertIndex(store: IDBObjectStore, key: Key | Key[]): IDBIndex { + const idxName = formatIdxName(key) + try { + return store.index(idxName) + } catch (err) { + console.error(`Failed to query index: table=${this.table}`, err) + throw err + } + } + + protected assertIndexCursor(store: IDBObjectStore, key: Key | Key[], range: IDBKeyRange): IDBRequest { + const index = this.assertIndex(store, key) + return index.openCursor(range) + } +} diff --git a/src/database/common/migratable.ts b/src/database/common/migratable.ts new file mode 100644 index 000000000..0c429ff01 --- /dev/null +++ b/src/database/common/migratable.ts @@ -0,0 +1,29 @@ +import type { BrowserMigratableNamespace } from '@db/types' +import { isRecord } from '@util/guard' +import { createObjectGuard, isInt, isString, TypeGuard } from 'typescript-guard' + +export const isExportData = createObjectGuard>({ + __meta__: createObjectGuard({ + version: isString, + ts: isInt, + }), +}) + +export const isLegacyVersion = (data: unknown): data is timer.backup.ExportData => { + if (!isExportData(data)) return false + + const version = data.__meta__.version + const match = version.match(/^(\d+)\.(\d+)\.(\d+)/) + if (!match) return true + + const major = parseInt(match[1]) + + return major < 4 +} + +export const extractNamespace = (data: unknown, namespace: BrowserMigratableNamespace, guard: TypeGuard): T | undefined => { + if (!isRecord(data)) return undefined + if (!(namespace in data)) return undefined + const nsData = data[namespace] + return guard(nsData) ? nsData : undefined +} \ No newline at end of file diff --git a/src/database/common/storage-holder.ts b/src/database/common/storage-holder.ts new file mode 100644 index 000000000..0f36a4acd --- /dev/null +++ b/src/database/common/storage-holder.ts @@ -0,0 +1,23 @@ +import optionHolder from '@service/components/option-holder' + +export class StorageHolder { + current: Database + delegates: Record + + constructor(delegates: Record) { + this.delegates = delegates + this.current = delegates.classic + + optionHolder.get().then(val => this.handleOption(val)) + optionHolder.addChangeListener(val => this.handleOption(val)) + } + + private handleOption(option: timer.option.TrackingOption) { + const delegate = this.delegates[option.storage] + delegate && (this.current = delegate) + } + + get(type: timer.option.StorageType): Database | null { + return this.delegates[type] ?? null + } +} \ No newline at end of file diff --git a/src/database/common/storage-promise.ts b/src/database/common/storage-promise.ts index 91c8d9c5a..14c47dc97 100644 --- a/src/database/common/storage-promise.ts +++ b/src/database/common/storage-promise.ts @@ -44,7 +44,7 @@ export default class StoragePromise { /** * @since 0.5.0 */ - put(key: string, val: Object): Promise { + put(key: string, val: unknown): Promise { return this.set({ [key]: val }) } diff --git a/src/database/limit-database.ts b/src/database/limit-database.ts index f8327bd3e..17e70bc5b 100644 --- a/src/database/limit-database.ts +++ b/src/database/limit-database.ts @@ -5,9 +5,13 @@ * https://opensource.org/licenses/MIT */ +import { isOptionalInt, isRecord, isVector2 } from '@util/guard' import { formatTimeYMD, MILL_PER_DAY } from "@util/time" +import { createArrayGuard, createGuard, createObjectGuard, createOptionalGuard, isBoolean, isInt, isString } from 'typescript-guard' import BaseDatabase from "./common/base-database" import { REMAIN_WORD_PREFIX } from "./common/constant" +import { extractNamespace, isExportData, isLegacyVersion } from './common/migratable' +import type { BrowserMigratable } from './types' const KEY = REMAIN_WORD_PREFIX + 'LIMIT' @@ -23,6 +27,26 @@ type LimitRecord = timer.limit.Rule & { records: DateRecords } +type PartialRule = MakeRequired, 'name' | 'cond'> + +const isValidRow = createObjectGuard({ + id: isOptionalInt, + name: isString, + cond: createArrayGuard(isString), + time: isOptionalInt, + count: isOptionalInt, + weekly: isOptionalInt, + weeklyCount: isOptionalInt, + visitTime: isOptionalInt, + enabled: createOptionalGuard(isBoolean), + locked: createOptionalGuard(isBoolean), + weekdays: createOptionalGuard(createArrayGuard(createGuard(val => isInt(val) && val >= 0 && val <= 6))), + allowDelay: createOptionalGuard(isBoolean), + periods: createOptionalGuard(createArrayGuard(isVector2)), +}) + +const isValidImportRows = createArrayGuard(isValidRow) + type ItemValue = { /** * ID @@ -121,7 +145,8 @@ const cvtItem2Rec = (item: ItemValue): LimitRecord => { type Items = Record -function migrate(exist: Items, toMigrate: any) { +function migrate(exist: Items, toMigrate: unknown) { + if (!isRecord(toMigrate)) return const idBase = Object.keys(exist).map(parseInt).sort().reverse()?.[0] ?? 0 + 1 Object.values(toMigrate).forEach((value, idx) => { const id = idBase + idx @@ -139,7 +164,9 @@ function migrate(exist: Items, toMigrate: any) { * * @since 0.2.2 */ -class LimitDatabase extends BaseDatabase { +class LimitDatabase extends BaseDatabase implements BrowserMigratable<'__limit__'> { + namespace: '__limit__' = '__limit__' + private async getItems(): Promise { let items = await this.storage.getOne(KEY) || {} return items @@ -259,14 +286,46 @@ class LimitDatabase extends BaseDatabase { await this.update(items) } - async importData(data: any): Promise { - let toImport = data[KEY] as Items - // Not import - if (typeof toImport !== 'object') return - const exists: Items = await this.getItems() + async importData(data: unknown): Promise { + if (!isExportData(data)) return + if (isLegacyVersion(data)) { + return this.importLegacyData(data) + } + + const rows = extractNamespace(data, this.namespace, isValidImportRows) ?? [] + for (const row of rows) { + const toImport: Omit = { + name: row.name, + cond: row.cond, + time: row.time, + count: row.count, + weekly: row.weekly, + weeklyCount: row.weeklyCount, + visitTime: row.visitTime, + periods: row.periods, + enabled: row.enabled ?? true, + locked: row.locked ?? false, + allowDelay: row.allowDelay ?? false, + weekdays: row.weekdays ?? [], + } + await this.save(toImport) + } + } + + /** + * @deprecated Only for legacy data, will be removed in future version + */ + private async importLegacyData(data: unknown): Promise { + if (!isRecord(data)) return + let toImport = data[KEY] + const exists = await this.getItems() migrate(exists, toImport) this.setByKey(KEY, exists) } + + exportData(): Promise { + return this.all() + } } const limitDatabase = new LimitDatabase() diff --git a/src/database/merge-rule-database.ts b/src/database/merge-rule-database.ts index 82fd23307..aa4f1a502 100644 --- a/src/database/merge-rule-database.ts +++ b/src/database/merge-rule-database.ts @@ -5,19 +5,33 @@ * https://opensource.org/licenses/MIT */ +import { isRecord } from '@util/guard' +import { createArrayGuard, createObjectGuard, createRecordGuard, createUnionGuard, isInt, isString } from 'typescript-guard' import BaseDatabase from "./common/base-database" import { REMAIN_WORD_PREFIX } from "./common/constant" +import { extractNamespace, isLegacyVersion } from './common/migratable' +import type { BrowserMigratable } from './types' const DB_KEY = REMAIN_WORD_PREFIX + 'MERGE_RULES' type MergeRuleSet = { [key: string]: string | number } +const isMergeValue = createUnionGuard(isString, isInt) + +const isMergeRuleSet = createRecordGuard(isMergeValue) + +const isMergeRule = createObjectGuard({ + origin: isString, + merged: isMergeValue, +}) + /** * Rules to merge host * * @since 0.1.2 */ -class MergeRuleDatabase extends BaseDatabase { +class MergeRuleDatabase extends BaseDatabase implements BrowserMigratable<'__merge__'> { + namespace: '__merge__' = '__merge__' async refresh(): Promise { const result = await this.storage.getOne(DB_KEY) @@ -50,17 +64,31 @@ class MergeRuleDatabase extends BaseDatabase { await this.update(set) } - async importData(data: any): Promise { - const toMigrate = data?.[DB_KEY] - if (!toMigrate) return + async importData(data: unknown): Promise { + if (isLegacyVersion(data)) { + return this.importLegacyData(data) + } + const rules = extractNamespace(data, this.namespace, createArrayGuard(isMergeRule)) ?? [] + await this.add(...rules) + } + + /** + * @deprecated Only for legacy version + */ + private async importLegacyData(data: unknown): Promise { + if (!isRecord(data)) return + const toMigrate = data[DB_KEY] + if (!isMergeRuleSet(toMigrate)) return const exist = await this.refresh() - const valueTypes = ['string', 'number'] - Object.entries(toMigrate as MergeRuleSet) - .filter(([_key, value]) => valueTypes.includes(typeof value)) + Object.entries(toMigrate satisfies MergeRuleSet) // Not rewrite .filter(([key]) => !exist[key]) .forEach(([key, value]) => exist[key] = value) - this.update(exist) + await this.update(exist) + } + + exportData(): Promise { + return this.selectAll() } } diff --git a/src/database/meta-database.ts b/src/database/meta-database.ts index 9475bdc50..a4ee18283 100644 --- a/src/database/meta-database.ts +++ b/src/database/meta-database.ts @@ -17,21 +17,6 @@ class MetaDatabase extends BaseDatabase { return meta || {} } - async importData(data: any): Promise { - const meta: timer.ExtensionMeta = data[META_KEY] as timer.ExtensionMeta - if (!meta) return - - const existMeta = await this.getMeta() - const { popupCounter = {}, appCounter = {} } = existMeta - popupCounter._total = (popupCounter._total ?? 0) + (popupCounter._total ?? 0) - if (meta.appCounter) { - Object.entries(meta.appCounter).forEach(([routePath, count]) => { - appCounter[routePath] = (appCounter[routePath] ?? 0) + count - }) - } - await this.update({ ...existMeta, popupCounter, appCounter }) - } - async update(existMeta: timer.ExtensionMeta): Promise { await this.storage.put(META_KEY, existMeta) } diff --git a/src/database/option-database.ts b/src/database/option-database.ts index f7b07f72a..d2ee7c88f 100644 --- a/src/database/option-database.ts +++ b/src/database/option-database.ts @@ -17,14 +17,6 @@ const DB_KEY = REMAIN_WORD_PREFIX + 'OPTION' * @since 0.3.0 */ class OptionDatabase extends BaseDatabase { - async importData(data: any): Promise { - const newVal = data[DB_KEY] - const exist = await this.getOption() - if (exist) { - Object.entries(exist).forEach(([key, value]) => (exist as any)[key] = value) - } - await this.setOption(newVal) - } async getOption(): Promise { const option = await this.storage.getOne(DB_KEY) diff --git a/src/database/period-database.ts b/src/database/period-database.ts index 1ff81dd94..1105f9d66 100644 --- a/src/database/period-database.ts +++ b/src/database/period-database.ts @@ -5,16 +5,14 @@ * https://opensource.org/licenses/MIT */ -import { getDateString, keyOf, MAX_PERIOD_ORDER, MILL_PER_PERIOD } from "@util/period" +import { getDateString, keyOf } from "@util/period" import BaseDatabase from "./common/base-database" import { REMAIN_WORD_PREFIX } from "./common/constant" -type DailyResult = { - /** - * order => milliseconds of focus - */ - [minuteOrder: number]: number -} +/** + * order => milliseconds of focus + */ +type DailyResult = Record const KEY_PREFIX = REMAIN_WORD_PREFIX + 'PERIOD' const KEY_PREFIX_LENGTH = KEY_PREFIX.length @@ -49,9 +47,11 @@ function db2PeriodInfos(data: { [dateKey: string]: DailyResult }): timer.period. })) return result } - /** * @since v0.2.1 + * @deprecated + * Starting with v4, all timeline data will be stored in IndexedDB, and periodic results will be queried using the timeline database. + * Therefore, this will be removed one year after the release of version 4 (around 2027-04-01) */ class PeriodDatabase extends BaseDatabase { @@ -103,35 +103,6 @@ class PeriodDatabase extends BaseDatabase { await this.storage.remove(keys) } - async importData(data: any): Promise { - if (typeof data !== "object") return - const items = await this.storage.get() - const keyReg = new RegExp(`^${KEY_PREFIX}20\\d{2}[01]\\d[0-3]\\d$`) - const toSave: Record = {} - Object.entries(data) - .filter(([key]) => keyReg.test(key)) - .forEach(([key, value]) => toSave[key] = migrate(items[key], value as _Value)) - this.storage.set(toSave) - } -} - -type _Value = { [key: string]: number } - -function migrate(exist: _Value | undefined, toMigrate: _Value) { - const result: _Value = exist || {} - Object.entries(toMigrate) - .filter(([key]) => /^\d{1,2}$/.test(key)) - .forEach(([key, value]) => { - const index = Number.parseInt(key) - if (index < 0 || index > MAX_PERIOD_ORDER) return - let mills: number = (result[key] || 0) + (typeof value === "number" ? value : parseInt(value || "0")) - if (isNaN(mills) || mills <= 0) return - if (mills > MILL_PER_PERIOD) { - mills = MILL_PER_PERIOD - } - result[key] = mills - }) - return result } const periodDatabase = new PeriodDatabase() diff --git a/src/database/site-cate-database.ts b/src/database/site-cate-database.ts index feb063b00..5203f5755 100644 --- a/src/database/site-cate-database.ts +++ b/src/database/site-cate-database.ts @@ -19,26 +19,13 @@ type Item = { type Items = Record -function migrate(exist: Items, toMigrate: any) { - let idBase = Object.keys(exist).map(parseInt).sort().reverse()?.[0] ?? 0 + 1 - const existLabels = new Set(Object.values(exist).map(e => e.n)) - - Object.values(toMigrate).forEach(value => { - const { n } = (value as Item) || {} - if (!n || existLabels.has(n)) return - - const id = idBase - idBase++ - exist[id] = { n } - }) -} - /** * Site tag * * @since 3.0.0 */ class SiteCateDatabase extends BaseDatabase { + private async getItems(): Promise { return await this.storage.getOne(KEY) || {} } @@ -86,15 +73,6 @@ class SiteCateDatabase extends BaseDatabase { await this.saveItems(items) } - async importData(data: any): Promise { - let toImport = data[KEY] as Items - // Not import - if (typeof toImport !== 'object') return - const exists: Items = await this.getItems() - migrate(exists, toImport) - this.setByKey(KEY, exists) - } - async delete(id: number): Promise { const items = await this.getItems() diff --git a/src/database/site-database.ts b/src/database/site-database.ts index 71826de80..45555da81 100644 --- a/src/database/site-database.ts +++ b/src/database/site-database.ts @@ -179,9 +179,6 @@ async function existBatch(this: SiteDatabase, siteKeys: timer.site.SiteKey[]): P return Object.entries(items).map(([key]) => cvt2SiteKey(key)) } -async function importData(this: SiteDatabase, _data: any) { - throw new Error("Method not implemented.") -} //////////////////////////////////////////////////////////////////////////// ///////////////////////// ///////////////////////// @@ -197,7 +194,6 @@ class SiteDatabase extends BaseDatabase { remove = remove exist = exist existBatch = existBatch - importData = importData /** * Add listener to listen changes diff --git a/src/database/stat-database/classic.ts b/src/database/stat-database/classic.ts new file mode 100644 index 000000000..c6a5cea3d --- /dev/null +++ b/src/database/stat-database/classic.ts @@ -0,0 +1,264 @@ +import BaseDatabase from '@db/common/base-database' +import { REMAIN_WORD_PREFIX } from '@db/common/constant' +import { log } from '@src/common/logger' +import { isOptionalInt } from '@util/guard' +import { escapeRegExp } from '@util/pattern' +import { isNotZeroResult } from '@util/stat' +import { createObjectGuard } from 'typescript-guard' +import { cvtGroupId2Host, formatDateStr, GROUP_PREFIX, increase, zeroResult } from './common' +import { filterDate, filterHost, filterNumberRange, processCondition, type ProcessedCondition } from './condition' +import type { StatCondition, StatDatabase } from './types' + +/** + * Generate the key in local storage by host and date + * + * @param host host + * @param date date + */ +const generateKey = (host: string, date: Date | string) => formatDateStr(date) + host +const generateHostReg = (host: string): RegExp => RegExp(`^\\d{8}${escapeRegExp(host)}$`) + +const generateGroupKey = (groupId: number, date: Date | string) => formatDateStr(date) + cvtGroupId2Host(groupId) +const generateGroupReg = (groupId: number): RegExp => RegExp(`^\\d{8}${escapeRegExp(cvtGroupId2Host(groupId))}$`) + +const isPartialResult = createObjectGuard>({ + focus: isOptionalInt, + time: isOptionalInt, + run: isOptionalInt, +}) + +function filterRow(row: timer.core.Row, condition: ProcessedCondition): boolean { + const { host, date, focus, time } = row + const { timeStart, timeEnd, focusStart, focusEnd, keys, virtual } = condition + + return filterHost(host, keys, virtual) + && filterDate(date, condition) + && filterNumberRange(time, [timeStart, timeEnd]) + && filterNumberRange(focus, [focusStart, focusEnd]) +} + +/** + * Default implementation by `chrome.storage.local` + */ +export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { + + async refresh(): Promise<{ [key: string]: unknown }> { + const result = await this.storage.get() + const items: Record = {} + Object.entries(result) + .filter(([key]) => !key.startsWith(REMAIN_WORD_PREFIX)) + .forEach(([key, value]) => items[key] = value) + return items + } + + /** + * @param host host + * @since 0.1.3 + */ + accumulate(host: string, date: Date | string, item: timer.core.Result): Promise { + const key = generateKey(host, date) + return this.accumulateInner(key, item) + } + + /** + * @param host host + * @since 0.1.3 + */ + accumulateGroup(groupId: number, date: Date | string, item: timer.core.Result): Promise { + const key = generateGroupKey(groupId, date) + return this.accumulateInner(key, item) + } + + private async accumulateInner(key: string, item: timer.core.Result): Promise { + const exist = await this.storage.getOne(key) + const value = increase(item, exist) + await this.setByKey(key, value) + return value + } + + /** + * Batch accumulate + * + * @param data data: {host=>waste_per_day} + * @param date date + * @since 0.1.8 + */ + async batchAccumulate(data: Record, date: Date | string): Promise> { + const hosts = Object.keys(data) + if (!hosts.length) return {} + const dateStr = formatDateStr(date) + const keys: { [host: string]: string } = {} + hosts.forEach(host => keys[host] = generateKey(host, dateStr)) + + const items = await this.storage.get(Object.values(keys)) + + const toUpdate: Record = {} + const afterUpdated: Record = {} + Object.entries(keys).forEach(([host, key]) => { + const item = data[host] + const exist: timer.core.Result = increase(item, items[key] as timer.core.Result) + toUpdate[key] = afterUpdated[host] = exist + }) + await this.storage.set(toUpdate) + return afterUpdated + } + + /** + * Filter by query parameters + */ + private async filter(condition?: StatCondition, onlyGroup?: boolean): Promise { + const cond = processCondition(condition ?? {}) + const items = await this.refresh() + const result: timer.core.Row[] = [] + Object.entries(items).forEach(([key, value]) => { + const date = key.substring(0, 8) + let host = key.substring(8) + if (onlyGroup) { + if (host.startsWith(GROUP_PREFIX)) { + host = host.substring(GROUP_PREFIX.length) + } else { + return + } + } else if (host.startsWith(GROUP_PREFIX)) { + return + } + const { focus, time, run } = value as timer.core.Result + const row: timer.core.Row = { host, date, focus, time } + run !== undefined && (row.run = run) + filterRow(row, cond) && result.push(row) + }) + return result + } + + /** + * Select + * + * @param condition condition + */ + async select(condition?: StatCondition): Promise { + log("select:{condition}", condition) + return this.filter(condition) + } + + async selectGroup(condition?: StatCondition): Promise { + return this.filter(condition, true) + } + + /** + * Get by host and date + * + * @since 0.0.5 + */ + async get(host: string, date: Date | string): Promise { + const key = generateKey(host, date) + const exist = await this.storage.getOne(key) + const result = exist ?? zeroResult() + return { host, date: formatDateStr(date), ...result } + } + + /** + * Delete by key + * + * @param rows site rows, the host and date mustn't be null + * @since 0.0.9 + */ + async delete(...rows: timer.core.RowKey[]): Promise { + const keys: string[] = rows.map(({ host, date }) => generateKey(host, date)) + return this.storage.remove(keys) + } + + async deleteGroup(...rows: [groupId: number, date: string][]): Promise { + const keys: string[] = rows.map(([groupId, date]) => generateGroupKey(groupId, date)) + return this.storage.remove(keys) + } + + /** + * Force update data + * + * @since 1.4.3 + */ + forceUpdate(...rows: timer.core.Row[]): Promise { + const toSet = Object.fromEntries(rows.map(({ host, date, time, focus, run }) => { + const key = generateKey(host, date) + const result: timer.core.Result = { time, focus } + run && (result.run = run) + return [key, result] + })) + + return this.storage.set(toSet) + } + + forceUpdateGroup(...rows: timer.core.Row[]): Promise { + const toSet = Object.fromEntries(rows.map(({ host, date, time, focus, run }) => { + const key = generateGroupKey(Number(host), date) + const result: timer.core.Result = { time, focus } + run && (result.run = run) + return [key, result] + })) + + return this.storage.set(toSet) + } + + /** + * @param host host + * @param range [start date (inclusive), end date (inclusive)] + * @returns [dates] + * @since 0.0.7 + */ + async deleteByHost(host: string, range?: [start?: Date | string, end?: Date | string]): Promise { + const [start, end] = range ?? [] + const startStr = start && formatDateStr(start) + const endStr = end && formatDateStr(end) + if (startStr && startStr === endStr) { + // Delete one day + const key = generateKey(host, start) + await this.storage.remove(key) + return [startStr] + } + + const dateFilter = (date: string) => (startStr ? startStr <= date : true) && (endStr ? date <= endStr : true) + const items = await this.refresh() + + // Key format: 20201112www.google.com + const keyReg = generateHostReg(host) + const keys: string[] = Object.keys(items) + .filter(key => keyReg.test(key) && dateFilter(key.substring(0, 8))) + + await this.storage.remove(keys) + return keys.map(k => k.substring(0, 8)) + } + + async deleteByGroup(groupId: number, range?: [start?: Date | string, end?: Date | string]): Promise { + const [start, end] = range ?? [] + const startStr = start && formatDateStr(start) + const endStr = end && formatDateStr(end) + const dateFilter = (date: string) => (startStr ? startStr <= date : true) && (endStr ? date <= endStr : true) + const items = await this.refresh() + + const keyReg = generateGroupReg(groupId) + const keys: string[] = Object.keys(items).filter(key => keyReg.test(key) && dateFilter(key.substring(0, 8))) + + await this.storage.remove(keys) + } +} + +/** + * Legacy data extract + * + * @deprecated since 4.0.0, legacy data is not supported for export, this method will be removed in future versions + */ +export function parseImportData(data: unknown): timer.core.Row[] { + if (typeof data !== "object" || data === null) return [] + const rows: timer.core.Row[] = [] + Object.entries(data) + .filter(([key]) => /^20\d{2}[01]\d[0-3]\d.*/.test(key) && !key.substring(8).startsWith(GROUP_PREFIX)) + .forEach(([key, value]) => { + if (typeof value !== "object") return + if (!isPartialResult(value)) return + const date = key.substring(0, 8) + const host = key.substring(8) + const row: timer.core.Row = { host, date, focus: value.focus ?? 0, time: value.time ?? 0 } + isNotZeroResult(row) && rows.push(row) + }) + return rows +} \ No newline at end of file diff --git a/src/database/stat-database/common.ts b/src/database/stat-database/common.ts new file mode 100644 index 000000000..6a94d3649 --- /dev/null +++ b/src/database/stat-database/common.ts @@ -0,0 +1,24 @@ +import { formatTimeYMD } from '@util/time' + +export const GROUP_PREFIX = "_g_" + +export const cvtGroupId2Host = (groupId: number): string => `${GROUP_PREFIX}${groupId}` + +export const formatDateStr = (date: string | Date): string => { + if (typeof date === 'string') return date + return formatTimeYMD(date) +} + +export const zeroResult = (): timer.core.Result => ({ focus: 0, time: 0 }) + +export const zeroRow = (host: string, date: string): timer.core.Row => ({ host, date, focus: 0, time: 0 }) + +export const increase = (a: timer.core.Result, b: timer.core.Result | undefined) => { + const res: timer.core.Result = { + focus: a.focus + (b?.focus ?? 0), + time: a.time + (b?.time ?? 0), + } + const run = (a.run ?? 0) + (b?.run ?? 0) + run && (res.run = run) + return res +} diff --git a/src/database/stat-database/condition.ts b/src/database/stat-database/condition.ts new file mode 100644 index 000000000..66901a87d --- /dev/null +++ b/src/database/stat-database/condition.ts @@ -0,0 +1,70 @@ +import { judgeVirtualFast } from "@util/pattern" +import { formatTimeYMD } from "@util/time" +import type { StatCondition } from './types' + +export type ProcessedCondition = StatCondition & { + useExactDate?: boolean + exactDateStr?: string + startDateStr?: string + endDateStr?: string + timeStart?: number + timeEnd?: number + focusStart?: number + focusEnd?: number +} + +export function filterHost(host: string, keys: ProcessedCondition['keys'], virtual?: boolean): boolean { + if (!virtual && judgeVirtualFast(host)) return false + if (keys === undefined) return true + return typeof keys === 'string' ? host === keys : keys.includes(host) +} + +export function filterDate( + date: string, + { useExactDate, exactDateStr, startDateStr, endDateStr }: ProcessedCondition +): boolean { + if (useExactDate) { + if (exactDateStr !== date) return false + } else { + if (startDateStr && startDateStr > date) return false + if (endDateStr && endDateStr < date) return false + } + return true +} + +export function filterNumberRange(val: number, [start, end]: [start?: number, end?: number]): boolean { + if (start !== null && start !== undefined && start > val) return false + if (end !== null && end !== undefined && end < val) return false + return true +} + +export function processCondition(condition?: StatCondition): ProcessedCondition { + const result: ProcessedCondition = { ...condition } + + const paramDate = condition?.date + if (paramDate) { + if (paramDate instanceof Date) { + result.useExactDate = true + result.exactDateStr = formatTimeYMD(paramDate) + } else { + const [startDate, endDate] = paramDate + result.useExactDate = false + startDate && (result.startDateStr = formatTimeYMD(startDate)) + endDate && (result.endDateStr = formatTimeYMD(endDate)) + } + } + + const paramTime = condition?.timeRange + if (paramTime) { + paramTime.length >= 2 && (result.timeEnd = paramTime[1]) + paramTime.length >= 1 && (result.timeStart = paramTime[0]) + } + + const paramFocus = condition?.focusRange + if (paramFocus) { + paramFocus.length >= 2 && (result.focusEnd = paramFocus[1]) + paramFocus.length >= 1 && (result.focusStart = paramFocus[0]) + } + + return result +} diff --git a/src/database/stat-database/constants.ts b/src/database/stat-database/constants.ts deleted file mode 100644 index d1ca9f5e4..000000000 --- a/src/database/stat-database/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const GROUP_PREFIX = "_g_" \ No newline at end of file diff --git a/src/database/stat-database/filter.ts b/src/database/stat-database/filter.ts deleted file mode 100644 index 384f9a6bb..000000000 --- a/src/database/stat-database/filter.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { judgeVirtualFast } from "@util/pattern" -import { formatTimeYMD } from "@util/time" -import { type StatCondition, type StatDatabase } from "." -import { GROUP_PREFIX } from "./constants" - -type _StatCondition = StatCondition & { - // Use exact date condition - useExactDate?: boolean - // date str - exactDateStr?: string - startDateStr?: string - endDateStr?: string - // time range - timeStart?: number - timeEnd?: number - focusStart?: number - focusEnd?: number -} - -type _FilterResult = { - host: string - date: string - value: timer.core.Result -} - -function filterHost(host: string, condition: _StatCondition): boolean { - const { keys, virtual } = condition - const keyArr = typeof keys === 'string' ? [keys] : keys - // 1. virtual - if (!virtual && judgeVirtualFast(host)) return false - // 2. host - if (keyArr?.length && !keyArr.includes(host)) return false - return true -} - -function filterDate( - date: string, - { useExactDate, exactDateStr, startDateStr, endDateStr }: _StatCondition -): boolean { - if (useExactDate) { - if (exactDateStr !== date) return false - } else { - if (startDateStr && startDateStr > date) return false - if (endDateStr && endDateStr < date) return false - } - return true -} - -function filterNumberRange(val: number, [start, end]: [start?: number, end?: number]): boolean { - if (start !== null && start !== undefined && start > val) return false - if (end !== null && end !== undefined && end < val) return false - return true -} - -/** - * Filter by query parameters - * - * @param date date of item - * @param host host of item - * @param val val of item - * @param condition query parameters - * @return true if valid, or false - */ -function filterByCond(result: _FilterResult, condition: _StatCondition): boolean { - const { host, date, value } = result - const { focus, time } = value - const { timeStart, timeEnd, focusStart, focusEnd } = condition - - return filterHost(host, condition) - && filterDate(date, condition) - && filterNumberRange(time, [timeStart, timeEnd]) - && filterNumberRange(focus, [focusStart, focusEnd]) -} - - -function processDateCondition(cond: _StatCondition, paramDate?: Date | [Date?, Date?]) { - if (!paramDate) return - - if (paramDate instanceof Date) { - cond.useExactDate = true - cond.exactDateStr = formatTimeYMD(paramDate as Date) - } else { - const [startDate, endDate] = paramDate - cond.useExactDate = false - startDate && (cond.startDateStr = formatTimeYMD(startDate)) - endDate && (cond.endDateStr = formatTimeYMD(endDate)) - } -} - -function processParamTimeCondition(cond: _StatCondition, paramTime?: [number, number?]) { - if (!paramTime) return - paramTime.length >= 2 && (cond.timeEnd = paramTime[1]) - paramTime.length >= 1 && (cond.timeStart = paramTime[0]) -} - -function processParamFocusCondition(cond: _StatCondition, paramFocus?: Vector<2>) { - if (!paramFocus) return - paramFocus.length >= 2 && (cond.focusEnd = paramFocus[1]) - paramFocus.length >= 1 && (cond.focusStart = paramFocus[0]) -} - -function processCondition(condition: StatCondition): _StatCondition { - const result: _StatCondition = { ...condition } - processDateCondition(result, condition.date) - processParamTimeCondition(result, condition.timeRange) - processParamFocusCondition(result, condition.focusRange) - return result -} - -/** - * Filter by query parameters - */ -export async function filter(this: StatDatabase, condition?: StatCondition, onlyGroup?: boolean): Promise<_FilterResult[]> { - const cond = processCondition(condition ?? {}) - const items = await this.refresh() - const result: _FilterResult[] = [] - Object.entries(items).forEach(([key, value]) => { - const date = key.substring(0, 8) - let host = key.substring(8) - if (onlyGroup) { - if (host.startsWith(GROUP_PREFIX)) { - host = host.substring(GROUP_PREFIX.length) - result.push({ date, host, value: value as timer.core.Result }) - } - } else if (!host.startsWith(GROUP_PREFIX)) { - result.push({ date, host, value: value as timer.core.Result }) - } - }) - return result.filter(item => filterByCond(item, cond)) -} diff --git a/src/database/stat-database/idb.ts b/src/database/stat-database/idb.ts new file mode 100644 index 000000000..b24b99fe1 --- /dev/null +++ b/src/database/stat-database/idb.ts @@ -0,0 +1,359 @@ +import { BaseIDBStorage, closedRangeKey, IndexResult, iterateCursor, type Key, req2Promise, type Table } from '@db/common/indexed-storage' +import { cvtGroupId2Host, formatDateStr, increase, zeroRow } from './common' +import { filterDate, filterHost, filterNumberRange, processCondition, type ProcessedCondition } from './condition' +import type { StatCondition, StatDatabase } from './types' + +type StoredRow = timer.core.Row & { + // If present, this is a group row + groupId?: number +} + +function fromStoredRow(stored: StoredRow): timer.core.Row { + if (stored.groupId !== undefined) { + const { groupId, ...row } = stored + return { ...row, host: cvtGroupId2Host(groupId) } + } + return stored +} + +const GROUP_HOST_PATTERN = /^_g_(\d+)$/ + +const INDEXES: (Key | Key[])[] = [ + 'date', 'host', 'groupId', + 'focus', 'time', + ['date', 'host'], +] as const + +const isGroup = (row: StoredRow): boolean => row.groupId !== undefined + +type IndexCoverage = { + date?: boolean + host?: boolean + time?: boolean + focus?: boolean +} + +function buildFilter(cond: ProcessedCondition, coverage: IndexCoverage): (row: StoredRow) => boolean { + return (row: StoredRow) => { + if (!coverage.time && !filterNumberRange(row.time, [cond.timeStart, cond.timeEnd])) { + return false + } + + if (!coverage.focus && !filterNumberRange(row.focus, [cond.focusStart, cond.focusEnd])) { + return false + } + + if (!coverage.date && !filterDate(row.date, cond)) { + return false + } + + // Only check virtual if host keys are not fully covered by index + const keys = coverage.host ? undefined : cond.keys + if (!filterHost(row.host, keys, cond.virtual)) { + return false + } + + return true + } +} + +type StatIndex = typeof INDEXES[number] + +export class IDBStatDatabase extends BaseIDBStorage implements StatDatabase { + table: Table = 'stat' + key: StatIndex = ['date', 'host'] + indexes: StatIndex[] = INDEXES + + get(host: string, date: Date | string): Promise { + return this.withStore(async store => { + const index = super.assertIndex(store, ['date', 'host']) + const dateStr = formatDateStr(date) + const req = index.get([dateStr, host]) + return await req2Promise(req) ?? zeroRow(host, dateStr) + }, 'readonly') + } + + private judgeIndex(store: IDBObjectStore, cond: ProcessedCondition, expectGroup: boolean): IndexResult { + const keys = typeof cond.keys === 'string' ? [cond.keys] : cond.keys + const { + useExactDate, exactDateStr, + timeStart, timeEnd, + focusStart, focusEnd, + startDateStr, endDateStr, + } = cond + + if (expectGroup) { + if (keys?.length === 1) { + const groupId = parseInt(keys[0]) + if (!isNaN(groupId)) { + return { + cursorReq: this.assertIndexCursor(store, 'groupId', IDBKeyRange.only(groupId)), + coverage: { host: true } + } + } + } + + return { + cursorReq: this.assertIndexCursor(store, 'groupId', IDBKeyRange.lowerBound(0)) + } + } + + if (useExactDate && exactDateStr) { + return keys?.length === 1 ? { + cursorReq: this.assertIndexCursor(store, ['date', 'host'], IDBKeyRange.only([exactDateStr, keys[0]])), + coverage: { date: true, host: true } + } : { + cursorReq: this.assertIndexCursor(store, 'date', IDBKeyRange.only(exactDateStr)), + coverage: { date: true } + } + } + const dateRange = closedRangeKey(startDateStr, endDateStr) + if (dateRange) { + return { + cursorReq: this.assertIndexCursor(store, 'date', dateRange), + coverage: { date: true } + } + } + + const timeRange = closedRangeKey(timeStart, timeEnd) + if (timeRange) { + return { + cursorReq: super.assertIndexCursor(store, 'time', timeRange), + coverage: { time: true } + } + } + + const focusRange = closedRangeKey(focusStart, focusEnd) + if (focusRange) { + return { + cursorReq: super.assertIndexCursor(store, 'focus', focusRange), + coverage: { focus: true } + } + } + + return { + cursorReq: store.openCursor(), + coverage: {} + } + } + + private async selectInternal(store: IDBObjectStore, cond: ProcessedCondition, expectGroup: boolean): Promise { + const allRows: timer.core.Row[] = [] + const { cursorReq, coverage = {} } = this.judgeIndex(store, cond, expectGroup) + const filter = buildFilter(cond, coverage) + + const rows = await iterateCursor(cursorReq) + for (const row of rows) { + if (expectGroup) { + if (!isGroup(row)) continue + } else { + if (isGroup(row)) continue + } + + if (!filter(row)) continue + + if (expectGroup) { + allRows.push({ + host: row.groupId?.toString() ?? '', + date: row.date, + time: row.time, + focus: row.focus, + run: row.run, + }) + } else { + allRows.push(row) + } + } + + return allRows + } + + select(condition?: StatCondition): Promise { + return this.withStore(async store => { + const cond = processCondition(condition) + return this.selectInternal(store, cond, false) + }, 'readonly') + } + + accumulate(host: string, date: Date | string, item: timer.core.Result): Promise { + return this.withStore(async store => { + const index = super.assertIndex(store, ['date', 'host']) + const dateStr = formatDateStr(date) + const req = index.get([dateStr, host]) + const existing = await req2Promise(req) + const newVal = increase(item, existing) + const newData: StoredRow = { host, date: dateStr, ...newVal } + await req2Promise(store.put(newData)) + return newData + }, 'readwrite') + } + + batchAccumulate(data: Record, date: Date | string): Promise> { + return this.withStore(async store => { + const dateStr = formatDateStr(date) + const cursorReq = super.assertIndexCursor(store, 'date', IDBKeyRange.only(dateStr)) + const toUpdate: Record = {} + + await iterateCursor(cursorReq, cursor => { + const stored = cursor.value as StoredRow | undefined + if (stored && !isGroup(stored)) { + toUpdate[stored.host] = fromStoredRow(stored) + } + }) + + for (const [host, result] of Object.entries(data)) { + const existing = toUpdate[host] + const newValue: timer.core.Row = { host, date: dateStr, ...increase(result, existing) } + toUpdate[host] = newValue + store.put(newValue) + } + return toUpdate + }, 'readwrite') + } + + delete(...rows: timer.core.RowKey[]): Promise { + return this.withStore(async store => { + const index = super.assertIndex(store, ['date', 'host']) + for (const { host, date } of rows) { + const dateStr = formatDateStr(date) + const req = index.getKey([dateStr, host]) + const key = await req2Promise(req) + if (key) { + await req2Promise(store.delete(key)) + } + } + }, 'readwrite') + } + + deleteByHost(host: string, range?: [start?: Date | string, end?: Date | string]): Promise { + return this.withStore(async store => { + const [start, end] = range ?? [] + const startStr = start ? formatDateStr(start) : undefined + const endStr = end ? formatDateStr(end) : undefined + + if (startStr && startStr === endStr) { + // Delete one day + const index = super.assertIndex(store, ['date', 'host']) + const req = index.getKey([startStr, host]) + const key = await req2Promise(req) + if (key) { + await req2Promise(store.delete(key)) + } + return [startStr] + } + + // Delete by range + const index = super.assertIndex(store, 'host') + const cursorReq = index.openCursor(IDBKeyRange.only(host)) + const deletedDates = new Set() + + await iterateCursor(cursorReq, cursor => { + const r = cursor.value as StoredRow | undefined + if (!r || isGroup(r)) return + + const dateStr = r.date + const inRange = (!startStr || startStr <= dateStr) && (!endStr || dateStr <= endStr) + if (inRange) { + cursor.delete() + deletedDates.add(dateStr) + } + }) + + return Array.from(deletedDates) + }, 'readwrite') + } + + accumulateGroup(groupId: number, date: Date | string, item: timer.core.Result): Promise { + return this.withStore(async store => { + const index = super.assertIndex(store, ['date', 'host']) + const dateStr = formatDateStr(date) + const host = cvtGroupId2Host(groupId) + const req = index.get([dateStr, host]) + const existing = await req2Promise(req) + const newVal = increase(item, existing) + const newData: StoredRow = { host, date: dateStr, groupId, ...newVal } + await req2Promise(store.put(newData)) + return newData + }, 'readwrite') + } + + selectGroup(condition?: StatCondition): Promise { + return this.withStore(async store => { + const cond = processCondition(condition) + return this.selectInternal(store, cond, true) + }, 'readonly') + } + + deleteGroup(...rows: [groupId: number, date: string][]): Promise { + return this.withStore(async store => { + const index = super.assertIndex(store, ['date', 'host']) + for (const [groupId, date] of rows) { + const host = cvtGroupId2Host(groupId) + const dateStr = formatDateStr(date) + const req = index.getKey([dateStr, host]) + const key = await req2Promise(req) + if (key) { + await req2Promise(store.delete(key)) + } + } + }, 'readwrite') + } + + deleteByGroup(groupId: number, range?: [start?: Date | string, end?: Date | string]): Promise { + return this.withStore(async store => { + const [start, end] = range ?? [] + const startStr = start ? formatDateStr(start) : undefined + const endStr = end ? formatDateStr(end) : undefined + const host = cvtGroupId2Host(groupId) + + if (startStr && startStr === endStr) { + // Delete one day + const index = super.assertIndex(store, ['date', 'host']) + const req = index.getKey([startStr, host]) + const key = await req2Promise(req) + if (key) { + await req2Promise(store.delete(key)) + } + return + } + + // Delete by range + const index = super.assertIndex(store, 'host') + const cursorReq = index.openCursor(IDBKeyRange.only(host)) + + await iterateCursor(cursorReq, cursor => { + const r = cursor.value as StoredRow | undefined + if (!r) return + const dateStr = r.date + const inRange = (!startStr || startStr <= dateStr) && (!endStr || dateStr <= endStr) + inRange && cursor.delete() + }) + }, 'readwrite') + } + + forceUpdate(...rows: timer.core.Row[]): Promise { + return this.withStore(store => { + for (const row of rows) { + const { host, date, time, focus, run } = row + const groupMatch = host.match(GROUP_HOST_PATTERN) + const newData: StoredRow = { host, date, time, focus, run } + groupMatch && (newData.groupId = parseInt(groupMatch[1])) + store.put(newData) + } + }, 'readwrite') + } + + forceUpdateGroup(...rows: timer.core.Row[]): Promise { + return this.withStore(store => { + for (const row of rows) { + const { host, date, time, focus, run } = row + const groupId = parseInt(host) + if (isNaN(groupId)) { + throw new Error(`Invalid group host: ${host}`) + } + const newData: StoredRow = { host, date, time, focus, run, groupId } + store.put(newData) + } + }, 'readwrite') + } +} \ No newline at end of file diff --git a/src/database/stat-database/index.ts b/src/database/stat-database/index.ts index 75ac11eaf..57277b335 100644 --- a/src/database/stat-database/index.ts +++ b/src/database/stat-database/index.ts @@ -5,312 +5,142 @@ * https://opensource.org/licenses/MIT */ -import { escapeRegExp } from "@util/pattern" -import { isNotZeroResult } from "@util/stat" -import { formatTimeYMD } from "@util/time" -import { log } from "../../common/logger" -import BaseDatabase from "../common/base-database" -import { REMAIN_WORD_PREFIX } from "../common/constant" -import { GROUP_PREFIX } from "./constants" -import { filter } from "./filter" - -export type StatCondition = { - /** - * Date - * {y}{m}{d} - */ - date?: Date | [Date?, Date?] - /** - * Focus range, milliseconds - * - * @since 0.0.9 - */ - focusRange?: Vector<2> - /** - * Time range - * - * @since 0.0.9 - */ - timeRange?: [number, number?] - /** - * Whether to include virtual sites - * - * @since 1.6.1 - */ - virtual?: boolean - /** - * Host or groupId, full match - */ - keys?: string[] | string -} - -function increase(a: timer.core.Result, b: timer.core.Result) { - const res: timer.core.Result = { - focus: (a?.focus ?? 0) + (b?.focus ?? 0), - time: (a?.time ?? 0) + (b?.time ?? 0), +import { extractNamespace, isExportData, isLegacyVersion } from '@db/common/migratable' +import { StorageHolder } from '@db/common/storage-holder' +import type { BrowserMigratable, StorageMigratable } from '@db/types' +import { isOptionalInt } from '@util/guard' +import { isNotZeroResult } from '@util/stat' +import { createArrayGuard, createObjectGuard, isString } from 'typescript-guard' +import { ClassicStatDatabase, parseImportData } from './classic' +import { IDBStatDatabase } from './idb' +import type { StatCondition, StatDatabase } from './types' + +type StateDatabaseComposite = + & StatDatabase + & StorageMigratable<[tabs: timer.core.Row[], groups: timer.core.Row[]]> + & BrowserMigratable<'__stat__'> + +// Only `date` and `host` are required for import, other fields are optional, and will be set to default if not provided +type ValidImportRow = MakeRequired, 'date' | 'host'> + +const isValidImportRow = createObjectGuard({ + focus: isOptionalInt, + time: isOptionalInt, + run: isOptionalInt, + date: isString, + host: isString, +}) + +const isValidImportRows = createArrayGuard(isValidImportRow) + +class StatDatabaseWrapper implements StateDatabaseComposite { + namespace: '__stat__' = '__stat__' + private holder = new StorageHolder({ + classic: new ClassicStatDatabase(), + indexed_db: new IDBStatDatabase(), + }) + private current = () => this.holder.current + + get(host: string, date: Date | string): Promise { + return this.current().get(host, date) + } + + select(condition?: StatCondition): Promise { + return this.current().select(condition) } - const run = (a?.run ?? 0) + (b?.run ?? 0) - run && (res.run = run) - return res -} - -function createZeroResult(): timer.core.Result { - return { focus: 0, time: 0 } -} - -function mergeMigration(exist: timer.core.Result | undefined, another: any) { - exist = exist || createZeroResult() - return increase(exist, { focus: another.focus ?? 0, time: another.time ?? 0, run: another.run ?? 0 }) -} - -/** - * Generate the key in local storage by host and date - * - * @param host host - * @param date date - */ -function generateKey(host: string, date: Date | string) { - const str = typeof date === 'object' ? formatTimeYMD(date as Date) : date - return str + host -} - -const generateHostReg = (host: string): RegExp => RegExp(`^\\d{8}${escapeRegExp(host)}$`) - -function generateGroupKey(groupId: number, date: Date | string) { - const str = typeof date === 'object' ? formatTimeYMD(date as Date) : date - return str + GROUP_PREFIX + groupId -} - -const generateGroupReg = (groupId: number): RegExp => RegExp(`^\\d{8}${escapeRegExp(`${GROUP_PREFIX}${groupId}`)}$`) - -function migrate(exists: { [key: string]: timer.core.Result }, data: any): Record { - const result: Record = {} - Object.entries(data) - .filter(([key]) => /^20\d{2}[01]\d[0-3]\d.*/.test(key) && !key.substring(8).startsWith(GROUP_PREFIX)) - .forEach(([key, value]) => { - if (typeof value !== "object") return - const exist = exists[key] - const merged = mergeMigration(exist, value) - merged && isNotZeroResult(merged) && (result[key] = mergeMigration(exist, value)) - }) - return result -} -export class StatDatabase extends BaseDatabase { - - async refresh(): Promise<{ [key: string]: unknown }> { - const result = await this.storage.get() - const items: Record = {} - Object.entries(result) - .filter(([key]) => !key.startsWith(REMAIN_WORD_PREFIX)) - .forEach(([key, value]) => items[key] = value) - return items + accumulate(host: string, date: Date | string, item: timer.core.Result): Promise { + return this.current().accumulate(host, date, item) } - /** - * @param host host - * @since 0.1.3 - */ - accumulate(host: string, date: Date | string, item: timer.core.Result): Promise { - const key = generateKey(host, date) - return this.accumulateInner(key, item) + batchAccumulate(data: Record, date: Date | string): Promise> { + return this.current().batchAccumulate(data, date) } - /** - * @param host host - * @since 0.1.3 - */ accumulateGroup(groupId: number, date: Date | string, item: timer.core.Result): Promise { - const key = generateGroupKey(groupId, date) - return this.accumulateInner(key, item) + return this.current().accumulateGroup(groupId, date, item) } - private async accumulateInner(key: string, item: timer.core.Result): Promise { - let exist = await this.storage.getOne(key) - exist = increase(exist || createZeroResult(), item) - await this.setByKey(key, exist) - return exist + delete(...rows: timer.core.RowKey[]): Promise { + return this.current().delete(...rows) } - /** - * Batch accumulate - * - * @param data data: {host=>waste_per_day} - * @param date date - * @since 0.1.8 - */ - async accumulateBatch(data: Record, date: Date | string): Promise> { - const hosts = Object.keys(data) - if (!hosts.length) return {} - const dateStr = typeof date === 'string' ? date : formatTimeYMD(date) - const keys: { [host: string]: string } = {} - hosts.forEach(host => keys[host] = generateKey(host, dateStr)) - - const items = await this.storage.get(Object.values(keys)) - - const toUpdate: Record = {} - const afterUpdated: Record = {} - Object.entries(keys).forEach(([host, key]) => { - const item = data[host] - const exist: timer.core.Result = increase(items[key] as timer.core.Result || createZeroResult(), item) - toUpdate[key] = afterUpdated[host] = exist - }) - await this.storage.set(toUpdate) - return afterUpdated + deleteByHost(host: string, range?: [start?: Date | string, end?: Date | string]): Promise { + return this.current().deleteByHost(host, range) } - filter = filter - - /** - * Select - * - * @param condition condition - */ - async select(condition?: StatCondition): Promise { - log("select:{condition}", condition) - const filterResults = await this.filter(condition) - return filterResults.map(({ date, host, value }) => { - const { focus, time, run } = value - return { date, host, focus, time, run } - }) - } - - async selectGroup(condition?: StatCondition): Promise { - const filterResults = await this.filter(condition, true) - return filterResults.map(({ date, host, value }) => { - const { focus, time, run } = value - return { date, host, focus, time, run } - }) + selectGroup(condition?: StatCondition): Promise { + return this.current().selectGroup(condition) } - /** - * Get by host and date - * - * @since 0.0.5 - */ - async get(host: string, date: Date | string): Promise { - const key = generateKey(host, date) - const exist = await this.storage.getOne(key) - return exist || createZeroResult() + deleteGroup(...rows: [groupId: number, date: string][]): Promise { + return this.current().deleteGroup(...rows) } - /** - * Delete the record - * - * @param host host - * @param date date - * @since 0.0.5 - */ - async deleteByUrlAndDate(host: string, date: Date | string): Promise { - const key = generateKey(host, date) - return this.storage.remove(key) + deleteByGroup(groupId: number, range: [start?: Date | string, end?: Date | string]): Promise { + return this.current().deleteByGroup(groupId, range) } - async deleteByGroupAndDate(groupId: number, date: Date | string): Promise { - const key = generateGroupKey(groupId, date) - return this.storage.remove(key) + forceUpdate(...rows: timer.core.Row[]): Promise { + return this.current().forceUpdate(...rows) } - /** - * Delete by key - * - * @param rows site rows, the host and date mustn't be null - * @since 0.0.9 - */ - async delete(rows: timer.core.RowKey[]): Promise { - const keys: string[] = rows.map(({ host, date }) => generateKey(host, date)) - return this.storage.remove(keys) + forceUpdateGroup(...rows: timer.core.Row[]): Promise { + return this.current().forceUpdateGroup(...rows) } - async deleteGroup(rows: [groupId: number, date: string][]): Promise { - const keys: string[] = rows.map(([groupId, date]) => generateGroupKey(groupId, date)) - return this.storage.remove(keys) - } - async batchDeleteGroup(groupId: number): Promise { - const keyReg = generateGroupReg(groupId) - const items = await this.refresh() - const keys: string[] = Object.keys(items).filter(key => keyReg.test(key)) - await this.storage.remove(keys) + async migrateStorage(type: timer.option.StorageType): Promise<[timer.core.Row[], timer.core.Row[]]> { + const target = this.holder.get(type) + if (!target) return [[], []] + const tabs = await this.select({ virtual: true }) + await target.forceUpdate(...tabs) + const groups = await this.selectGroup() + await target.forceUpdateGroup(...groups) + return [tabs, groups] } - /** - * Force update data - * - * @since 1.4.3 - */ - forceUpdate({ host, date, time, focus, run }: timer.core.Row): Promise { - const key = generateKey(host, date) - const result: timer.core.Result = { time, focus } - run && (result.run = run) - return this.storage.put(key, result) + async afterStorageMigrated([tabs, groups]: [timer.core.Row[], timer.core.Row[]]): Promise { + await this.current().delete(...tabs) + const groupKeys = groups.map(({ host, date }) => [parseInt(host), date] satisfies [number, string]) + await this.current().deleteGroup(...groupKeys) } - /** - * @param host host - * @param start start date, inclusive - * @param end end date, inclusive - * @since 0.0.7 - */ - async deleteByUrlBetween(host: string, start?: Date, end?: Date): Promise { - const startStr = start ? formatTimeYMD(start) : undefined - const endStr = end ? formatTimeYMD(end) : undefined - const dateFilter = (date: string) => (startStr ? startStr <= date : true) && (endStr ? date <= endStr : true) - const items = await this.refresh() - - // Key format: 20201112www.google.com - const keyReg = generateHostReg(host) - const keys: string[] = Object.keys(items) - .filter(key => keyReg.test(key) && dateFilter(key.substring(0, 8))) - - await this.storage.remove(keys) - return keys.map(k => k.substring(0, 8)) + async importData(data: unknown): Promise { + const rows = this.parseImportRows(data) + await this.forceUpdate(...rows) } - async deleteByGroupBetween(groupId: number, start?: Date, end?: Date): Promise { - const startStr = start ? formatTimeYMD(start) : undefined - const endStr = end ? formatTimeYMD(end) : undefined - const dateFilter = (date: string) => (startStr ? startStr <= date : true) && (endStr ? date <= endStr : true) - const items = await this.refresh() - - const keyReg = generateGroupReg(groupId) - const keys: string[] = Object.keys(items).filter(key => keyReg.test(key) && dateFilter(key.substring(0, 8))) - - await this.storage.remove(keys) + async exportData(): Promise { + return this.select({ virtual: true }) } - /** - * Delete the record - * - * @param host host - * @since 0.0.5 - */ - async deleteByUrl(host: string): Promise { - const items = await this.refresh() + private parseImportRows(data: unknown): timer.core.Row[] { + if (!isExportData(data)) return [] + if (isLegacyVersion(data)) { + return parseImportData(data) ?? [] + } - // Key format: 20201112www.google.com - const keyReg = generateHostReg(host) - const keys: string[] = Object.keys(items).filter(key => keyReg.test(key)) - await this.storage.remove(keys) + if (!(this.namespace in data)) return [] - return keys.map(k => k.substring(0, 8)) - } - - async deleteByGroup(groupId: number): Promise { - const items = await this.refresh() - const keyReg = generateGroupReg(groupId) - const keys: string[] = Object.keys(items).filter(key => keyReg.test(key)) - await this.storage.remove(keys) - } - - async importData(data: any): Promise { - if (typeof data !== "object") return - const items = await this.storage.get() - const toSave = migrate(items, data) - this.storage.set(toSave) + const nsData = extractNamespace(data, this.namespace, isValidImportRows) ?? [] + const rows: timer.core.Row[] = [] + for (const item of nsData) { + const row: timer.core.Row = { + host: item.host, + date: item.date, + time: item.time ?? 0, + focus: item.focus ?? 0, + run: item.run ?? 0, + } + isNotZeroResult(row) && rows.push(row) + } + return rows } } -const statDatabase = new StatDatabase() +const statDatabase: StateDatabaseComposite = new StatDatabaseWrapper() export default statDatabase + +export * from "./types" diff --git a/src/database/stat-database/types.ts b/src/database/stat-database/types.ts new file mode 100644 index 000000000..4e2ba1376 --- /dev/null +++ b/src/database/stat-database/types.ts @@ -0,0 +1,73 @@ + +export type StatCondition = { + /** + * Date + * {y}{m}{d} + */ + date?: Date | [Date?, Date?] + /** + * Focus range, milliseconds + * + * @since 0.0.9 + */ + focusRange?: Vector<2> + /** + * Time range + * + * @since 0.0.9 + */ + timeRange?: [number, number?] + /** + * Whether to include virtual sites + * + * @since 1.6.1 + */ + virtual?: boolean + /** + * Host or groupId, full match + */ + keys?: string[] | string +} + +export interface StatDatabase { + get(host: string, date: Date | string): Promise + select(condition?: StatCondition): Promise + /** + * Accumulate data + */ + accumulate(host: string, date: Date | string, item: timer.core.Result): Promise + batchAccumulate(data: Record, date: Date | string): Promise> + delete(...rows: timer.core.RowKey[]): Promise + /** + * Delete by host + * + * @param host host + * @param range date range, inclusive start and end, if null, delete all + */ + deleteByHost(host: string, range?: [start?: Date | string, end?: Date | string]): Promise + + /******* GROUP *******/ + /** + * Accumulate data for tab group + */ + accumulateGroup(groupId: number, date: Date | string, item: timer.core.Result): Promise + selectGroup(condition?: StatCondition): Promise + deleteGroup(...rows: [groupId: number, date: string][]): Promise + /** + * Delete group data + * + * @param groupId the id of group + * @param range date range, inclusive start and end, if null, delete all + */ + deleteByGroup(groupId: number, range?: [start?: Date | string, end?: Date | string]): Promise + + /** + * Force update data with overwriting + */ + forceUpdate(...rows: timer.core.Row[]): Promise + + /** + * Force update group data with overwriting + */ + forceUpdateGroup(...rows: timer.core.Row[]): Promise +} \ No newline at end of file diff --git a/src/database/timeline-database.ts b/src/database/timeline-database.ts deleted file mode 100644 index 98f270f2d..000000000 --- a/src/database/timeline-database.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { formatTimeYMD, MILL_PER_DAY } from '@util/time' -import BaseDatabase from './common/base-database' -import { REMAIN_WORD_PREFIX } from './common/constant' - -const DB_KEY = REMAIN_WORD_PREFIX + 'TL' - -type Item = { - // start - s: number - // duration - d: number -} - -type TimelineData = { - [date: string]: { - [host: string]: Item[] - } -} - -// If two tick with the same host is near 1 sec, then merge them to one -const MERGE_THRESHOLD = 1000 - -const canMerge = (item: Item, tick: timer.timeline.Tick) => { - const { s: is, d: id } = item - const { start } = tick - return start >= is + id - && start <= id + MERGE_THRESHOLD -} - -const isConflict = (item: Item, tick: timer.timeline.Tick) => { - const { s: is, d: id } = item - const { start } = tick - return is <= start && start < is + id -} - -const merge = (data: TimelineData, tick: timer.timeline.Tick) => { - const { start, duration, host } = tick - const date = formatTimeYMD(start) - const hostData = data[date] ?? {} - const items = hostData[host] ?? [] - items.sort((a, b) => (a?.s ?? 0) - (b?.s ?? 0)) - for (const item of items) { - if (isConflict(item, tick)) { - return - } - if (canMerge(item, tick)) { - item.d = start + duration - item.s - return - } - } - // normal tick - items.push({ s: start, d: duration }) - hostData[host] = items - data[date] = hostData -} - -export const TIMELINE_LIFE_CYCLE = 3 - -const removeOutdated = (data: TimelineData, currTime: number) => { - const minDate = formatTimeYMD(currTime - MILL_PER_DAY * (TIMELINE_LIFE_CYCLE - 1)) - const keys = Object.keys(data).filter(k => k < minDate) - keys.forEach(key => delete data[key]) -} - -class TimelineDatabase extends BaseDatabase { - private async getData(): Promise { - const data = await this.storage.getOne(DB_KEY) - return data ?? {} - } - - private setData(data: TimelineData): Promise { - return this.setByKey(DB_KEY, data) - } - - async batchSave(ticks: timer.timeline.Tick[]) { - const data = await this.getData() - ticks.forEach(tick => { - merge(data, tick) - removeOutdated(data, tick.start) - }) - await this.setData(data) - } - - async getAll(): Promise { - const data = await this.getData() - const result: timer.timeline.Tick[] = [] - Object.values(data).forEach(hostData => { - Object.entries(hostData).forEach(([host, items]) => { - items.forEach(({ s: start, d: duration }) => result.push({ host, start, duration })) - }) - }) - return result - } - - async importData(_: any): Promise { - // do nothing - } -} -const timelineDatabase = new TimelineDatabase() - -export default timelineDatabase \ No newline at end of file diff --git a/src/database/timeline-database/classic.ts b/src/database/timeline-database/classic.ts new file mode 100644 index 000000000..669e27ba1 --- /dev/null +++ b/src/database/timeline-database/classic.ts @@ -0,0 +1,66 @@ +import BaseDatabase from '../common/base-database' +import { REMAIN_WORD_PREFIX } from '../common/constant' +import type { TimelineCondition, TimelineDatabase } from './types' + +const DB_KEY = REMAIN_WORD_PREFIX + 'TL' + +type Item = { + // start + s: number + // duration + d: number +} + +type TimelineData = { + [date: string]: { + [host: string]: Item[] + } +} + +function filter(ticks: timer.timeline.Tick[], cond?: TimelineCondition): timer.timeline.Tick[] { + if (!cond) { + return ticks + } + const { start, host } = cond + + return ticks.filter(tick => { + if (start && tick.start < start) { + return false + } + if (host && tick.host !== host) { + return false + } + return true + }) +} + +/** + * @deprecated Use IDBTimelineDatabase instead, this is for old version data migration + */ +export default class ClassicTimelineDatabase extends BaseDatabase implements TimelineDatabase { + + private async getData(): Promise { + const data = await this.storage.getOne(DB_KEY) + return data ?? {} + } + + async batchSave(_ticks: timer.timeline.Tick[]): Promise { + console.warn("ClassicTimelineDatabase is deprecated, data will not be saved to it. This invoking is not expected") + return + } + + async select(cond?: TimelineCondition): Promise { + const data = await this.getData() + const ticks: timer.timeline.Tick[] = [] + Object.values(data).forEach(hostData => { + Object.entries(hostData).forEach(([host, items]) => { + items.forEach(({ s: start, d: duration }) => ticks.push({ host, start, duration })) + }) + }) + return filter(ticks, cond) + } + + async clear(): Promise { + await this.storage.remove(DB_KEY) + } +} diff --git a/src/database/timeline-database/idb.ts b/src/database/timeline-database/idb.ts new file mode 100644 index 000000000..dd7cb4b8f --- /dev/null +++ b/src/database/timeline-database/idb.ts @@ -0,0 +1,130 @@ +import { + BaseIDBStorage, iterateCursor, req2Promise, + type Index, type IndexResult, type Key, type Table, +} from '@db/common/indexed-storage' +import { MILL_PER_DAY, MILL_PER_SECOND } from '@util/time' +import type { TimelineCondition, TimelineDatabase } from './types' + +const TIME_LIFE_CYCLE = MILL_PER_DAY * 366 + +// If two tick with the same host is near 1 sec, then merge them to one +const MERGE_THRESHOLD = MILL_PER_SECOND + +const canMerge = (exist: timer.timeline.Tick, tick: timer.timeline.Tick) => { + const { start: existStart, host: existHost } = exist + const { start, host } = tick + return existHost === host && start >= existStart && start <= existStart + MERGE_THRESHOLD +} + +const isConflict = (item: timer.timeline.Tick, tick: timer.timeline.Tick) => { + const { start: itemStart, duration: itemDuration } = item + const { start } = tick + return itemStart <= start && start < itemStart + itemDuration +} + +type IndexCoverage = { + host?: boolean + start?: boolean +} + +class CleanThrottle { + private lastTime = 0 + private readonly interval: number = MILL_PER_DAY + + tryClean(doClean: () => void): void { + const now = Date.now() + if (now - this.lastTime >= this.interval) { + this.lastTime = now + doClean() + } + } +} + +export default class IDBTimelineDatabase extends BaseIDBStorage implements TimelineDatabase { + indexes: Index[] = [ + 'host', 'start', + ] + key: Key | Key[] = ['host', 'start'] + table: Table = 'timeline' + private cleanThrottle = new CleanThrottle() + + batchSave(ticks: timer.timeline.Tick[]): Promise { + return this.withStore(async store => { + const index = this.assertIndex(store, 'host') + const hosts = Array.from(new Set(ticks.map(tick => tick.host))) + + // Fetch existing records for all hosts + const existByHost = new Map() + await Promise.all(hosts.map(async host => { + const req = index.getAll(IDBKeyRange.only(host)) + const exist = await req2Promise(req) + exist && existByHost.set(host, exist) + })) + + const toSave: timer.timeline.Tick[] = [] + const toDelete: timer.timeline.Tick[] = [] + ticks.forEach(tick => { + const existForHost = existByHost.get(tick.host) ?? [] + + // Check if there's any conflict + const anyConflict = existForHost.some(exist => isConflict(exist, tick)) + if (anyConflict) return + + // Find a record that can be merged + const mergeTarget = existForHost.find(exist => canMerge(exist, tick)) + if (mergeTarget) { + toDelete.push(mergeTarget) + const { host, start, duration } = tick + const newStart = Math.min(mergeTarget.start, start) + const newEnd = Math.max(mergeTarget.start + mergeTarget.duration, start + duration) + const newDuration = newEnd - newStart + toSave.push({ host, start: newStart, duration: newDuration }) + } else { + // No conflict and no merge, save the new tick + toSave.push(tick) + } + }) + toDelete.forEach(tick => store.delete([tick.host, tick.start])) + toSave.forEach(tick => store.put(tick)) + }, 'readwrite') + } + + async select(cond?: TimelineCondition): Promise { + const rows = await this.withStore(async store => { + const { cursorReq, coverage = {} } = this.judgeIndex(store, cond) + const rows = await iterateCursor(cursorReq) + const { start: cs, host: ch } = cond ?? {} + return rows.filter(tick => { + const { host, start } = tick + if (cs && !coverage.start && start < cs) return false + if (ch && !coverage.host && host !== ch) return false + return true + }) + }, 'readonly') + + // Cleanup outdated ticks periodically + this.cleanThrottle.tryClean(() => this.withStore(store => { + const index = this.assertIndex(store, 'start') + const req = index.openCursor(IDBKeyRange.upperBound(Date.now() - TIME_LIFE_CYCLE, true)) + iterateCursor(req, cursor => { cursor.delete() }) + }, 'readwrite').catch(e => console.error('Failed to cleanup outdated ticks', e))) + return rows + } + + private judgeIndex(store: IDBObjectStore, cond?: TimelineCondition): IndexResult { + const { host, start } = cond ?? {} + if (host) { + return { + cursorReq: this.assertIndexCursor(store, 'host', IDBKeyRange.only(host)), + coverage: { host: true }, + } + } else if (start !== undefined && start > 0) { + return { + cursorReq: this.assertIndexCursor(store, 'start', IDBKeyRange.lowerBound(start, false)), + coverage: { start: true }, + } + } else { + return { cursorReq: store.openCursor() } + } + } +} \ No newline at end of file diff --git a/src/database/timeline-database/index.ts b/src/database/timeline-database/index.ts new file mode 100644 index 000000000..5dfb589a5 --- /dev/null +++ b/src/database/timeline-database/index.ts @@ -0,0 +1,26 @@ +import ClassicTimelineDatabase from './classic' +import IDBTimelineDatabase from './idb' +import type { TimelineCondition, TimelineDatabase } from './types' + +class TimelineDatabaseWrapper implements TimelineDatabase { + private classic = new ClassicTimelineDatabase() + private idb = new IDBTimelineDatabase() + + batchSave(ticks: timer.timeline.Tick[]): Promise { + return this.idb.batchSave(ticks) + } + + select(cond?: TimelineCondition): Promise { + return this.idb.select(cond) + } + + async migrateFromClassic(): Promise { + const ticks = await this.classic.select() + await this.idb.batchSave(ticks) + await this.classic.clear() + } +} + +const timelineDatabase = new TimelineDatabaseWrapper() + +export default timelineDatabase \ No newline at end of file diff --git a/src/database/timeline-database/types.ts b/src/database/timeline-database/types.ts new file mode 100644 index 000000000..8ab103884 --- /dev/null +++ b/src/database/timeline-database/types.ts @@ -0,0 +1,12 @@ +export type TimelineCondition = { + host?: string + /** + * Start time in milliseconds, inclusive + */ + start?: number +} + +export interface TimelineDatabase { + batchSave(ticks: timer.timeline.Tick[]): Promise + select(cond?: TimelineCondition): Promise +} \ No newline at end of file diff --git a/src/database/types.d.ts b/src/database/types.d.ts new file mode 100644 index 000000000..e0426312a --- /dev/null +++ b/src/database/types.d.ts @@ -0,0 +1,35 @@ +/** + * Migrate data among storages (chrome.storage.local / IndexedDB) + * + * @since 4.0.0 + */ +export interface StorageMigratable { + /** + * Migrate data to target storage + * + * NOTE: MUST NOT change the inner storage type + * + * @param type the type of target storage + */ + migrateStorage(type: timer.option.StorageType): Promise + /** + * Handler after migration finished. Clean the old data here + * + * @param allData + */ + afterStorageMigrated(allData: AllData): Promise +} + +export type BrowserMigratableNamespace = keyof Omit + +/** + * Migrate data among browsers (export / import) + */ +export interface BrowserMigratable { + /** + * The name space for migration + */ + namespace: N + exportData(): Promise[N]> + importData(data: unknown): Promise +} \ No newline at end of file diff --git a/src/database/whitelist-database.ts b/src/database/whitelist-database.ts index bf12e7795..7547fd5f1 100644 --- a/src/database/whitelist-database.ts +++ b/src/database/whitelist-database.ts @@ -5,10 +5,14 @@ * https://opensource.org/licenses/MIT */ +import { isStringArray } from 'typescript-guard' import BaseDatabase from "./common/base-database" import { WHITELIST_KEY } from "./common/constant" +import { extractNamespace, isExportData, isLegacyVersion } from './common/migratable' +import type { BrowserMigratable } from './types' -class WhitelistDatabase extends BaseDatabase { +class WhitelistDatabase extends BaseDatabase implements BrowserMigratable<'__whitelist__'> { + namespace: '__whitelist__' = '__whitelist__' private async update(toUpdate: string[]): Promise { await this.setByKey(WHITELIST_KEY, toUpdate || []) @@ -54,13 +58,29 @@ class WhitelistDatabase extends BaseDatabase { chrome.storage.onChanged.addListener(storageListener) } - async importData(data: any): Promise { - const toMigrate = data[WHITELIST_KEY] - if (!Array.isArray(toMigrate)) return + async importData(data: unknown): Promise { + if (!isExportData(data)) return + const toImport = isLegacyVersion(data) + ? this.parseLegacyData(data) + : extractNamespace(data, this.namespace, isStringArray) + const exist = await this.selectAll() - toMigrate.forEach(white => !exist.includes(white) && exist.push(white)) + toImport?.forEach(white => !exist.includes(white) && exist.push(white)) + await this.update(exist) } + + /** + * @deprecated Only for legacy data, will be removed in future version + */ + private parseLegacyData(data: timer.backup.ExportData): string[] { + const toMigrate = data[WHITELIST_KEY] + return isStringArray(toMigrate) ? toMigrate : [] + } + + exportData(): Promise { + return this.selectAll() + } } const whitelistDatabase = new WhitelistDatabase() diff --git a/src/i18n/message/app/data-manage-resource.json b/src/i18n/message/app/data-manage-resource.json index 8970871c5..ff2ce9163 100644 --- a/src/i18n/message/app/data-manage-resource.json +++ b/src/i18n/message/app/data-manage-resource.json @@ -61,6 +61,7 @@ "totalMemoryAlert": "The browser provides {size}MB to store local data for each extension", "totalMemoryAlert1": "Unable to determine the maximum storage available allowed by the browser", "usedMemoryAlert": "{size}MB is currently used", + "idbAlert": "Move tracking data to IndexedDB to reduce storage usage", "operationAlert": "You can delete those unimportant data to reduce storage usage", "filterItems": "Filter data", "filterFocus": "The browsing time of the day is between {start} seconds and {end} seconds", diff --git a/src/i18n/message/app/data-manage.ts b/src/i18n/message/app/data-manage.ts index 95fc17a18..912514735 100644 --- a/src/i18n/message/app/data-manage.ts +++ b/src/i18n/message/app/data-manage.ts @@ -11,6 +11,7 @@ export type DataManageMessage = { totalMemoryAlert: string totalMemoryAlert1: string usedMemoryAlert: string + idbAlert: string operationAlert: string filterItems: string filterFocus: string diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 94593ded6..c8842e325 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -301,7 +301,9 @@ "tabGroupsPermGrant": "This feature requires relevant permissions", "fileAccessDisabled": "Access to file URLs is currently not allowed. Please enable it on the manage page first", "weekStart": "The first day for each week {input}", - "weekStartAsNormal": "As Normal" + "weekStartAsNormal": "As Normal", + "storage": "Store the tracking data in {input}", + "storageConfirm": "Do you want to change the storage type to {type}?" }, "limit": { "prompt": "Prompt displayed when restricted {input}", diff --git a/src/i18n/message/app/option.ts b/src/i18n/message/app/option.ts index b067ac162..d66101bf1 100644 --- a/src/i18n/message/app/option.ts +++ b/src/i18n/message/app/option.ts @@ -51,6 +51,8 @@ export type OptionMessage = { fileAccessDisabled: string weekStart: string weekStartAsNormal: string + storage: string + storageConfirm: string } limit: { prompt: string diff --git a/src/pages/app/components/Dashboard/components/Indicator.tsx b/src/pages/app/components/Dashboard/components/Indicator.tsx index 7cc461d7e..f22cd40b7 100644 --- a/src/pages/app/components/Dashboard/components/Indicator.tsx +++ b/src/pages/app/components/Dashboard/components/Indicator.tsx @@ -29,7 +29,7 @@ type _Value = { * @return days used */ function calculateInstallDays(installTime: Date, now: Date): number { - const deltaMills = getStartOfDay(now).getTime() - getStartOfDay(installTime).getTime() + const deltaMills = getStartOfDay(now) - getStartOfDay(installTime) return Math.round(deltaMills / MILL_PER_DAY) } diff --git a/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx b/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx index 3008520dc..7c9e2c1bd 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx +++ b/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx @@ -7,7 +7,6 @@ import ChartTitle from '@app/components/Dashboard/ChartTitle' import { t } from '@app/locale' -import { TIMELINE_LIFE_CYCLE } from '@db/timeline-database' import { Collection, Files, Link } from '@element-plus/icons-vue' import { useShadow } from '@hooks' import { useEcharts } from "@hooks/useEcharts" @@ -16,6 +15,7 @@ import { type ECElementEvent, type ECharts } from "echarts/core" import { ElIcon, ElRadioButton, ElRadioGroup } from 'element-plus' import { computed, defineComponent } from "vue" import { type JSX } from 'vue/jsx-runtime' +import { TIMELINE_DAY_COUNT } from '../constants' import Wrapper, { EcOption, type BizData } from './Wrapper' import { useMerge, type MergeMethod } from './useMerge' @@ -77,7 +77,7 @@ const TimelineChart = defineComponent<{ data: timer.timeline.Tick[] }>(props => - {t(msg => msg.dashboard.timeline.title, { n: TIMELINE_LIFE_CYCLE })} + {t(msg => msg.dashboard.timeline.title, { n: TIMELINE_DAY_COUNT })} diff --git a/src/pages/app/components/Dashboard/components/Timeline/Chart/useMerge.ts b/src/pages/app/components/Dashboard/components/Timeline/Chart/useMerge.ts index 838c8bb51..e933af2b4 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/Chart/useMerge.ts +++ b/src/pages/app/components/Dashboard/components/Timeline/Chart/useMerge.ts @@ -2,13 +2,13 @@ import { useCategory } from '@app/context' import { t } from '@app/locale' import mergeRuleDatabase from '@db/merge-rule-database' import siteDatabase from '@db/site-database' -import { TIMELINE_LIFE_CYCLE } from '@db/timeline-database' import { useState } from '@hooks' import CustomizedHostMergeRuler from '@service/components/host-merge-ruler' import { toMap } from '@util/array' import { CATE_NOT_SET_ID } from '@util/site' import { formatTime, getAllDatesBetween, getStartOfDay, MILL_PER_DAY } from '@util/time' import { type Ref, ref, watch } from 'vue' +import { TIMELINE_DAY_COUNT } from '../constants' export type Activity = { date: string @@ -32,14 +32,11 @@ const isMergeMethod = (val: unknown): val is MergeMethod => { const MONTH_DATE_FORMAT = t(msg => msg.calendar.monthDateFormat) const formatDate = (date: Date | number) => formatTime(date, MONTH_DATE_FORMAT) -const calcOffsetOfDay = (ts: number) => { - const startOfDate = getStartOfDay(ts) - return ts - startOfDate.getTime() -} +const calcOffsetOfDay = (ts: number) => ts - getStartOfDay(ts) const genLatestDates = () => { const now = new Date() - const start = new Date(now.getTime() - MILL_PER_DAY * (TIMELINE_LIFE_CYCLE - 1)) + const start = new Date(now.getTime() - MILL_PER_DAY * (TIMELINE_DAY_COUNT - 1)) return getAllDatesBetween(start, now, formatDate) } diff --git a/src/pages/app/components/Dashboard/components/Timeline/constants.ts b/src/pages/app/components/Dashboard/components/Timeline/constants.ts new file mode 100644 index 000000000..84da821c7 --- /dev/null +++ b/src/pages/app/components/Dashboard/components/Timeline/constants.ts @@ -0,0 +1,4 @@ +/** + * The days shown in the timeline + */ +export const TIMELINE_DAY_COUNT = 3 \ No newline at end of file diff --git a/src/pages/app/components/Dashboard/components/Timeline/index.tsx b/src/pages/app/components/Dashboard/components/Timeline/index.tsx index 346f0f6be..46089638b 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/index.tsx +++ b/src/pages/app/components/Dashboard/components/Timeline/index.tsx @@ -1,12 +1,15 @@ import timelineDatabase from '@db/timeline-database' import { useRequest } from '@hooks' +import { getStartOfDay, MILL_PER_DAY } from '@util/time' import { defineComponent } from 'vue' import DashboardCard from '../../DashboardCard' import TimelineChart from './Chart' import Summary from './Summary' +import { TIMELINE_DAY_COUNT } from './constants' const Timeline = defineComponent<{ height: number }>(({ height }) => { - const { data } = useRequest(() => timelineDatabase.getAll(), { defaultValue: [] }) + const start = getStartOfDay(Date.now() - MILL_PER_DAY * (TIMELINE_DAY_COUNT - 1)) + const { data } = useRequest(() => timelineDatabase.select({ start }), { defaultValue: [] }) return () => <> diff --git a/src/pages/app/components/DataManage/ClearPanel/index.tsx b/src/pages/app/components/DataManage/ClearPanel/index.tsx index 7e4cf8861..48b14b0a2 100644 --- a/src/pages/app/components/DataManage/ClearPanel/index.tsx +++ b/src/pages/app/components/DataManage/ClearPanel/index.tsx @@ -94,7 +94,7 @@ const _default = defineComponent(() => { cancelButtonText: t(msg => msg.button.cancel), confirmButtonText: t(msg => msg.button.confirm) }).then(async () => { - await db.delete(result) + await db.delete(...result) ElMessage.success(t(msg => msg.operation.successMsg)) refreshMemory?.() }).catch(() => { }) diff --git a/src/pages/app/components/DataManage/MemoryInfo.tsx b/src/pages/app/components/DataManage/MemoryInfo.tsx index 4022e1dc6..ceac99a20 100644 --- a/src/pages/app/components/DataManage/MemoryInfo.tsx +++ b/src/pages/app/components/DataManage/MemoryInfo.tsx @@ -6,37 +6,50 @@ */ import { t } from "@app/locale" +import { OPTION_ROUTE } from '@app/router/constants' +import optionDatabase from '@db/option-database' +import { useRequest } from '@hooks/useRequest' import Flex from "@pages/components/Flex" -import { ElCard, ElProgress } from "element-plus" +import { getColor } from '@pages/util/style' +import { getAppPageUrl } from '@util/constant/url' +import { ElCard, ElLink, ElProgress, ElText } from "element-plus" import { computed, defineComponent, type StyleValue } from "vue" import { useDataMemory } from "./context" import DataManageAlert from './DataManageAlert' const byte2Mb = (size: number) => Math.round((size || 0) / 1024.0 / 1024.0 * 1000) / 1000 -function computeColor(percentage: number, total: number): string { - // Danger color - let typeColor = '#F56C6C' - // Primary color - if (percentage < 50) typeColor = '#409EFF' - // Warning color - else if (percentage < 75) typeColor = '#E6A23C' - // Specially, show warning color if not detect the max memory - if (!total) typeColor = '#E6A23C' - return typeColor +const IDB_THRESHOLD_MB = 5 +const IDB_THRESHOLD_PERCENTAGE = 75 + +function computeColor(percentage: number, total: number): string | undefined { + if (!total) { + return getColor('warning') + } else if (percentage < 50) { + return getColor('primary') + } else if (percentage < IDB_THRESHOLD_PERCENTAGE) { + return getColor('warning') + } else { + return getColor('danger') + } } const totalTitle = (totalMb: number) => totalMb ? t(msg => msg.dataManage.totalMemoryAlert, { size: totalMb }) : t(msg => msg.dataManage.totalMemoryAlert1) + const _default = defineComponent(() => { const { memory } = useDataMemory() - + const { data: option } = useRequest(() => optionDatabase.getOption()) const usedMb = computed(() => byte2Mb(memory.value?.used)) const totalMb = computed(() => byte2Mb(memory.value?.total)) const percentage = computed(() => memory.value?.total ? Math.round(memory.value?.used * 10000.0 / memory.value.total) / 100 : 0) const color = computed(() => computeColor(percentage.value, memory.value.total)) + const idbTipVisible = computed(() => { + if (option.value?.storage !== 'classic') return false + return totalMb.value ? percentage.value > IDB_THRESHOLD_PERCENTAGE : usedMb.value > IDB_THRESHOLD_MB + }) return () => ( { style={{ display: 'flex', marginTop: '30px' } satisfies StyleValue} /> -
-

+ + {t(msg => msg.dataManage.usedMemoryAlert, { size: usedMb.value })} -

-
+ + {idbTipVisible.value && ( + + {t(msg => msg.dataManage.idbAlert)} + + )} +
) diff --git a/src/pages/app/components/DataManage/Migration/ImportButton.tsx b/src/pages/app/components/DataManage/Migration/ImportButton.tsx index 1e0f09a23..be13563c4 100644 --- a/src/pages/app/components/DataManage/Migration/ImportButton.tsx +++ b/src/pages/app/components/DataManage/Migration/ImportButton.tsx @@ -7,14 +7,12 @@ import { t } from "@app/locale" import { Upload } from "@element-plus/icons-vue" -import Immigration from "@service/components/immigration" +import immigration from '@service/components/immigration' import { deserialize } from "@util/file" import { ElButton, ElLoading, ElMessage } from "element-plus" import { defineComponent, ref } from "vue" import { useDataMemory } from "../context" -const immigration: Immigration = new Immigration() - async function handleFileSelected(fileInput: HTMLInputElement | undefined, callback: () => void) { const files = fileInput?.files if (!files?.length) { diff --git a/src/pages/app/components/DataManage/Migration/index.tsx b/src/pages/app/components/DataManage/Migration/index.tsx index f9ca7c6ad..e47f22ce7 100644 --- a/src/pages/app/components/DataManage/Migration/index.tsx +++ b/src/pages/app/components/DataManage/Migration/index.tsx @@ -8,7 +8,7 @@ import { t } from "@app/locale" import { Download } from "@element-plus/icons-vue" import Flex from "@pages/components/Flex" -import Immigration from "@service/components/immigration" +import immigration from '@service/components/immigration' import { exportJson } from "@util/file" import { formatTime } from "@util/time" import { ElButton, ElCard } from "element-plus" @@ -17,10 +17,8 @@ import DataManageAlert from '../DataManageAlert' import ImportButton from "./ImportButton" import ImportOtherButton from "./ImportOtherButton" -const immigration: Immigration = new Immigration() - async function handleExport() { - const data = await immigration.getExportingData() + const data = await immigration.exportData() const timestamp = formatTime(new Date(), '{y}{m}{d}_{h}{i}{s}') exportJson(data, `timer_backup_${timestamp}`) } diff --git a/src/pages/app/components/Option/components/OptionItem.tsx b/src/pages/app/components/Option/components/OptionItem.tsx index 9a6a1b011..664f7c3e0 100644 --- a/src/pages/app/components/Option/components/OptionItem.tsx +++ b/src/pages/app/components/Option/components/OptionItem.tsx @@ -100,7 +100,7 @@ const OptionItem = defineComponent((props, { slots }) => { return ( - {!!props.required && *} + {!!props.required && *} {renderLabel(props.label, param)} {defaultText.value && !isSmScreen.value && ( diff --git a/src/pages/app/components/Option/components/OptionLines.tsx b/src/pages/app/components/Option/components/OptionLines.tsx index 96dbf4337..a1f315495 100644 --- a/src/pages/app/components/Option/components/OptionLines.tsx +++ b/src/pages/app/components/Option/components/OptionLines.tsx @@ -2,10 +2,6 @@ import { ElDivider } from 'element-plus' import { type FunctionalComponent, h, isVNode, type VNode } from "vue" import { isOptionItem } from './OptionItem' -function isFragment({ type, children }: VNode): boolean { - return typeof type === 'symbol' && Array.isArray(children) -} - function isComment({ type, children }: VNode): boolean { return typeof type === 'symbol' && (children === null || children === undefined) } @@ -16,18 +12,20 @@ function isHidden({ dirs }: VNode): boolean { function flattenChildren(original: VNode[]): VNode[] { const flat: VNode[] = [] + const stack: unknown[] = [...original] - for (const child of original) { + let child: unknown | undefined + while ((child = stack.shift()) != undefined) { if (!isVNode(child)) { - flat.push(child) + console.log('Found non-VNode child, ignored:', child) continue } - if (isFragment(child) && child.children) { - const fragmentChildren = Array.isArray(child.children) - ? child.children - : [child.children] - flat.push(...flattenChildren(fragmentChildren as VNode[])) + const { type, children } = child + if (typeof type === 'symbol' && Array.isArray(children)) { + // is Fragment, flatten it by pushing its children to stack + stack.unshift(...children) + continue } else { flat.push(child) } @@ -50,8 +48,7 @@ const OptionLines: FunctionalComponent<{}> = (_, { slots }) => { children.push(child) beforeIsItem = thisIsItem } - - return h('div', {}, children) + return
{children}
} OptionLines.displayName = 'OptionLines' diff --git a/src/pages/app/components/Option/components/TrackingOption.tsx b/src/pages/app/components/Option/components/TrackingOption.tsx index c8b01d4c4..b258b1931 100644 --- a/src/pages/app/components/Option/components/TrackingOption.tsx +++ b/src/pages/app/components/Option/components/TrackingOption.tsx @@ -7,8 +7,9 @@ import { hasPerm, requestPerm } from "@api/chrome/permission" import { isAllowedFileSchemeAccess, sendMsg2Runtime } from "@api/chrome/runtime" import { t } from "@app/locale" -import { useRequest } from "@hooks" +import { useManualRequest, useRequest } from "@hooks" import { locale } from "@i18n" +import immigration from '@service/components/immigration' import { rotate } from "@util/array" import { IS_ANDROID, IS_FIREFOX } from "@util/constant/environment" import { defaultTracking } from "@util/constant/option" @@ -22,6 +23,11 @@ import OptionLines from './OptionLines' import OptionTag from './OptionTag' import OptionTooltip from './OptionTooltip' +const ALL_STORAGES: Record = { + classic: 'chrome.storage.local', + indexed_db: 'IndexedDB', +} + const DEFAULT_VALUE = defaultTracking() const weekStartOptionPairs: [[timer.option.WeekStartOption, string]] = [ @@ -39,18 +45,35 @@ function copy(target: timer.option.TrackingOption, source: timer.option.Tracking target.weekStart = source.weekStart target.autoPauseTracking = source.autoPauseTracking target.autoPauseInterval = source.autoPauseInterval + target.storage = source.storage } const _default = defineComponent((_props, ctx) => { const { option } = useOption({ defaultValue: defaultTracking, copy }) const { data: fileAccess } = useRequest(isAllowedFileSchemeAccess) - ctx.expose({ - reset: () => { - const oldInterval = option.autoPauseInterval - copy(option, defaultTracking()) - option.autoPauseInterval = oldInterval - } - } satisfies OptionInstance) + const reset = () => { + // Not to reset these fields + const { + autoPauseInterval: oldInterval, + storage: oldStorage, + } = option + copy(option, defaultTracking()) + option.autoPauseInterval = oldInterval + option.storage = oldStorage + } + ctx.expose({ reset } satisfies OptionInstance) + + const { refresh: changeStorageType, loading: storageMigrating } = useManualRequest(async (type: timer.option.StorageType) => { + await immigration.migrateStorage(type) + option.storage = type + }, { loadingText: 'Data migrating...' }) + + const handleChangeStorage = (type: timer.option.StorageType) => { + const msg = t(msg => msg.option.tracking.storageConfirm, { type: ALL_STORAGES[type] }) + ElMessageBox.confirm(msg, { type: 'warning' }) + .then(() => changeStorageType(type)) + .catch(() => ElMessage.info('Cancelled by user')) + } const interval = computed({ get: _oldValue => { @@ -152,6 +175,16 @@ const _default = defineComponent((_props, ctx) => { options={weekStartOptionPairs.map(([value, label]) => ({ value, label }))} /> + msg.option.tracking.storage}> + handleChangeStorage(val)} + options={Object.entries(ALL_STORAGES).map(([value, label]) => ({ value, label }))} + /> + }) diff --git a/src/pages/app/components/Report/ReportFilter/BatchDelete.tsx b/src/pages/app/components/Report/ReportFilter/BatchDelete.tsx index bff1b31a7..88caaa9b4 100644 --- a/src/pages/app/components/Report/ReportFilter/BatchDelete.tsx +++ b/src/pages/app/components/Report/ReportFilter/BatchDelete.tsx @@ -106,8 +106,8 @@ async function deleteBatch(selected: timer.stat.Row[], mergeDate: boolean, dateR // Delete according to the date range const [start, end] = dateRange ?? [] for (const row of selected) { - isNormalSite(row) && await statDatabase.deleteByUrlBetween(row.siteKey.host, start, end) - isGroup(row) && await statDatabase.deleteByGroupBetween(row.groupKey, start, end) + isNormalSite(row) && await statDatabase.deleteByHost(row.siteKey.host, [start, end]) + isGroup(row) && await statDatabase.deleteByGroup(row.groupKey, [start, end]) } } else { // If not merge date, batch delete diff --git a/src/pages/app/components/Report/ReportTable/index.tsx b/src/pages/app/components/Report/ReportTable/index.tsx index 32972008c..cafc9956e 100644 --- a/src/pages/app/components/Report/ReportTable/index.tsx +++ b/src/pages/app/components/Report/ReportTable/index.tsx @@ -63,7 +63,7 @@ const _default = defineComponent((_, ctx) => { const filter = useReportFilter() const visible = computed(() => computeVisible(filter)) const { data, refresh, loading } = useRequest(() => queryPage(filter, sort.value, page.value), { - loadingTarget: () => table.value?.$el as HTMLDivElement, + loadingTarget: () => table.value?.$el, deps: [() => ({ ...filter }), sort, page], defaultValue: { list: [], total: 0 }, }) diff --git a/src/pages/app/components/Report/common.ts b/src/pages/app/components/Report/common.ts index 958f36485..fe19bb68f 100644 --- a/src/pages/app/components/Report/common.ts +++ b/src/pages/app/components/Report/common.ts @@ -55,22 +55,22 @@ export async function handleDelete(row: timer.stat.Row, filterOption: ReportFilt const { mergeDate, dateRange } = filterOption if (!mergeDate) { // Delete one day - isSite(row) && date && await statDatabase.deleteByUrlAndDate(row.siteKey.host, date) - isGroup(row) && date && await statDatabase.deleteByGroupAndDate(row.groupKey, date) + isSite(row) && date && await statDatabase.delete({ host: row.siteKey.host, date }) + isGroup(row) && date && await statDatabase.deleteGroup([row.groupKey, date]) return } const start = dateRange?.[0] const end = dateRange?.[1] if (!start && !end) { // Delete all - isSite(row) && await statDatabase.deleteByUrl(row.siteKey.host) + isSite(row) && await statDatabase.deleteByHost(row.siteKey.host) isGroup(row) && await statDatabase.deleteByGroup(row.groupKey) return } // Delete by range - isSite(row) && await statDatabase.deleteByUrlBetween(row.siteKey.host, start, end) - isGroup(row) && await statDatabase.deleteByGroupBetween(row.groupKey, start, end) + isSite(row) && await statDatabase.deleteByHost(row.siteKey.host, [start, end]) + isGroup(row) && await statDatabase.deleteByGroup(row.groupKey, [start, end]) } const cvtOrderDir = (order: ReportSort['order']): timer.common.SortDirection | undefined => { diff --git a/src/pages/app/components/common/kanban/IndicatorCell.tsx b/src/pages/app/components/common/kanban/IndicatorCell.tsx index a1bbd6dbd..39d6777ac 100644 --- a/src/pages/app/components/common/kanban/IndicatorCell.tsx +++ b/src/pages/app/components/common/kanban/IndicatorCell.tsx @@ -11,7 +11,7 @@ import { BottomRight, InfoFilled, TopRight } from "@element-plus/icons-vue" import { useXsState } from '@hooks/useMediaSize' import Box from "@pages/components/Box" import Flex from "@pages/components/Flex" -import { colorVariant } from '@pages/util/style' +import { colorVariant, getCssVariable } from '@pages/util/style' import { range } from "@util/array" import { ElIcon, ElTooltip } from "element-plus" import { computed, defineComponent, type CSSProperties } from "vue" @@ -27,7 +27,8 @@ const SubVal = defineComponent<{ value: string }>(props => { const computeComparison = (value: RingValue) => { const [current = 0, last = 0] = value if (current === last) return false - const color = current > last ? colorVariant('danger') : colorVariant('success', 'light', 3) + const colorVar = current > last ? colorVariant('danger') : colorVariant('success', 'light', 3) + const color = getCssVariable(colorVar) const Icon = current > last ? TopRight : BottomRight let count = 0 if (current === 0 || last === 0) { diff --git a/src/pages/hooks/useRequest.ts b/src/pages/hooks/useRequest.ts index fc0b9f429..7dee0a875 100644 --- a/src/pages/hooks/useRequest.ts +++ b/src/pages/hooks/useRequest.ts @@ -30,14 +30,11 @@ const findLoadingEl = async (target: RequestOption['loadingT if (typeof target === 'string') { return target } else if (typeof target === 'function') { - const res = await target?.() - if (res instanceof HTMLElement) { - return res - } + const res = await target() + return res instanceof HTMLElement ? res : undefined } else { return target.value } - return undefined } export function useRequest

( diff --git a/src/pages/util/style.ts b/src/pages/util/style.ts index 2ba711016..6d7e6a347 100644 --- a/src/pages/util/style.ts +++ b/src/pages/util/style.ts @@ -67,5 +67,9 @@ export function getSecondaryTextColor(): string | undefined { } export function getInfoColor(): string | undefined { - return getCssVariable(colorVariant('info')) + return getColor('info') } + +export function getColor(variant: Variant): string | undefined { + return getCssVariable(colorVariant(variant)) +} \ No newline at end of file diff --git a/src/service/components/immigration.ts b/src/service/components/immigration.ts index cc74ad70e..a6769311c 100644 --- a/src/service/components/immigration.ts +++ b/src/service/components/immigration.ts @@ -5,63 +5,63 @@ * https://opensource.org/licenses/MIT */ -import BaseDatabase from "@db/common/base-database" -import StoragePromise from "@db/common/storage-promise" import limitDatabase from "@db/limit-database" import mergeRuleDatabase from "@db/merge-rule-database" -import periodDatabase from "@db/period-database" -import siteCateDatabase from "@db/site-cate-database" import statDatabase from "@db/stat-database" +import type { BrowserMigratable, StorageMigratable } from '@db/types' import whitelistDatabase from "@db/whitelist-database" import packageInfo from "@src/package" -type MetaInfo = { - version: string - ts: number -} - -export type BackupData = { - __meta__: MetaInfo -} & any - -function initDatabase(): BaseDatabase[] { - const result: BaseDatabase[] = [ - statDatabase, - periodDatabase, - limitDatabase, - mergeRuleDatabase, - whitelistDatabase, - siteCateDatabase, - ] - - return result -} - /** - * Data is citizens + * Data export/import and storage migration * * @since 0.2.5 */ class Immigration { - private storage: StoragePromise - private databaseArray: BaseDatabase[] + private browserMigratables: BrowserMigratable[] + private storageMigratables: StorageMigratable[] constructor() { - const localStorage = chrome.storage.local - this.storage = new StoragePromise(localStorage) - this.databaseArray = initDatabase() + this.browserMigratables = [ + statDatabase, + limitDatabase, + mergeRuleDatabase, + whitelistDatabase, + ] + this.storageMigratables = [ + statDatabase, + ] } - async getExportingData(): Promise { - const data = await this.storage.get() as BackupData - const meta: MetaInfo = { version: packageInfo.version, ts: Date.now() } - data.__meta__ = meta + async exportData(): Promise { + const data: timer.backup.ExportData = { + __meta__: { version: packageInfo.version, ts: Date.now() }, + } + for (const migratable of this.browserMigratables) { + const namespace = migratable.namespace + const exportData = await migratable.exportData() + data[namespace] = exportData + } return data } - async importData(data: any): Promise { - for (const db of this.databaseArray) await db.importData(data) + async importData(data: unknown): Promise { + for (const db of this.browserMigratables) await db.importData(data) + } + + async migrateStorage(type: timer.option.StorageType): Promise { + const dataList: unknown[] = [] + // 1. migrate all the databases firstly + for (const migratable of this.storageMigratables) { + const data = await migratable.migrateStorage(type) + dataList.push(data) + } + // 2. after migration + for (const migratable of this.storageMigratables) { + const [data] = dataList.splice(0, 1) + await migratable.afterStorageMigrated(data) + } } } -export default Immigration \ No newline at end of file +export default new Immigration() \ No newline at end of file diff --git a/src/service/components/import-processor.ts b/src/service/components/import-processor.ts index e76d49546..3c935bb11 100644 --- a/src/service/components/import-processor.ts +++ b/src/service/components/import-processor.ts @@ -28,7 +28,7 @@ async function processOverwrite(data: timer.imported.Data): Promise { const exist = await statDatabase.get(host, date) focus && (exist.focus = row.focus || 0) time && (exist.time = row.time || 0) - await statDatabase.forceUpdate({ host, date, ...exist }) + await statDatabase.forceUpdate({ ...exist, host, date }) })) } diff --git a/src/service/item-service.ts b/src/service/item-service.ts index 80ba7bf7f..cd4da98f0 100644 --- a/src/service/item-service.ts +++ b/src/service/item-service.ts @@ -19,7 +19,7 @@ async function addFocusTime(context: ItemIncContext, focusTime: number): Promise const now = new Date() - await db.accumulateBatch(resultSet, now) + await db.batchAccumulate(resultSet, now) const { countTabGroup } = await optionHolder.get() countTabGroup && isValidGroup(groupId) && db.accumulateGroup(groupId, now, resultOf(focusTime, 0)) @@ -38,7 +38,7 @@ async function increaseVisit(context: ItemIncContext) { const now = new Date() - await db.accumulateBatch(resultSet, now) + await db.batchAccumulate(resultSet, now) const { countTabGroup } = await optionHolder.get() countTabGroup && isValidGroup(groupId) && await db.accumulateGroup(groupId, now, resultOf(0, 1)) @@ -48,8 +48,8 @@ const getResult = (host: string, date: Date | string) => db.get(host, date) const selectItems = (cond: StatCondition) => db.select(cond) -async function batchDeleteGroupById(groupId: number): Promise { - await db.batchDeleteGroup(groupId) +async function deleteByGroup(groupId: number): Promise { + await db.deleteByGroup(groupId) } export default { @@ -58,5 +58,5 @@ export default { increaseVisit, getResult, selectItems, - batchDeleteGroupById, + deleteByGroup, } \ No newline at end of file diff --git a/src/service/stat-service/index.ts b/src/service/stat-service/index.ts index 239b0f468..e26971b0b 100644 --- a/src/service/stat-service/index.ts +++ b/src/service/stat-service/index.ts @@ -294,8 +294,8 @@ export async function batchDelete(targets: timer.stat.Row[]) { isNormalSite(row) && siteKeys.push({ host: row.siteKey.host, date }) isGroup(row) && groupKeys.push([row.groupKey, date]) }) - await statDatabase.delete(siteKeys) - await statDatabase.deleteGroup(groupKeys) + await statDatabase.delete(...siteKeys) + await statDatabase.deleteGroup(...groupKeys) } export async function selectGroupByPage(param?: GroupQuery, page?: timer.common.PageQuery): Promise> { diff --git a/src/util/constant/option.ts b/src/util/constant/option.ts index dc51fe667..718e38171 100644 --- a/src/util/constant/option.ts +++ b/src/util/constant/option.ts @@ -35,6 +35,7 @@ export function defaultTracking(): TrackingRequired { countLocalFiles: false, countTabGroup: false, weekStart: 'default', + storage: 'classic', } } diff --git a/src/util/guard.ts b/src/util/guard.ts new file mode 100644 index 000000000..d1d57cbb2 --- /dev/null +++ b/src/util/guard.ts @@ -0,0 +1,7 @@ +import { createOptionalGuard, isInt } from 'typescript-guard' + +export const isOptionalInt = createOptionalGuard(isInt) + +export const isRecord = (unk: unknown): unk is Record => typeof unk === 'object' && unk !== null + +export const isVector2 = (unk: unknown): unk is Vector<2> => Array.isArray(unk) && unk.length === 2 && unk.every(isInt) \ No newline at end of file diff --git a/src/util/time.ts b/src/util/time.ts index 2782444c5..8cb3e195b 100644 --- a/src/util/time.ts +++ b/src/util/time.ts @@ -18,22 +18,12 @@ import { isRtl } from "./document" * * Parse the time to string */ -export function formatTime(time: Date | string | number, cFormat?: string) { +export function formatTime(time: Date | number, cFormat?: string) { const format = cFormat || '{y}-{m}-{d} {h}:{i}:{s}' let date: Date if (time instanceof Date) { date = time } else { - if ((typeof time === 'string')) { - if ((/^[0-9]+$/.test(time))) { - // support "1548221490638" - time = parseInt(time) - } else { - // support safari - time = time.replace(new RegExp(/-/gm), '/') - } - } - if ((typeof time === 'number') && (time.toString().length === 10)) { time = time * 1000 } @@ -57,7 +47,7 @@ export function formatTime(time: Date | string | number, cFormat?: string) { return timeStr } -export function formatTimeYMD(time: Date | string | number) { +export function formatTimeYMD(time: Date | number) { return formatTime(time, '{y}{m}{d}') } @@ -175,13 +165,13 @@ export function getMonthTime(target: Date): [Date, Date] { * Get the start time of this day * * @param target the specific time - * @returns the start of this day + * @returns the start of this day, in milliseconds * @since 1.0.0 */ -export function getStartOfDay(target: Date | number) { +export function getStartOfDay(target: Date | number): number { const date = new Date(target) date.setHours(0, 0, 0, 0) - return date + return date.getTime() } /** diff --git a/test/__mock__/runtime.ts b/test/__mock__/runtime.ts new file mode 100644 index 000000000..81db493de --- /dev/null +++ b/test/__mock__/runtime.ts @@ -0,0 +1,7 @@ +export const mockRuntime = () => { + global.chrome = { + runtime: { + id: 'mock_runtime_id', + } satisfies Pick + } as unknown as typeof global.chrome +} \ No newline at end of file diff --git a/test/database/idb.ts b/test/database/idb.ts new file mode 100644 index 000000000..1295425f3 --- /dev/null +++ b/test/database/idb.ts @@ -0,0 +1,17 @@ +import { } from 'fake-indexeddb' +import 'fake-indexeddb/auto' + +/** + * Clean up all IndexedDB databases + */ +export async function cleanupIDB() { + return new Promise((resolve) => { + try { + global.indexedDB = new IDBFactory() + setTimeout(resolve, 0) + } catch (error) { + console.error('Failed to cleanup database:', error) + resolve(undefined) + } + }) +} \ No newline at end of file diff --git a/test/database/limit-database.test.ts b/test/database/limit-database.test.ts index 49d3244c3..e37263cc8 100644 --- a/test/database/limit-database.test.ts +++ b/test/database/limit-database.test.ts @@ -1,6 +1,7 @@ import db from "@db/limit-database" import { formatTimeYMD } from "@util/time" import { mockStorage } from "../__mock__/storage" +import { mockLegacyData } from './migratable' describe('limit-database', () => { beforeAll(() => mockStorage()) @@ -103,7 +104,7 @@ describe('limit-database', () => { chrome.storage.local.clear() expect(await db.all()).toEqual([]) - await db.importData(data2Import) + await db.importData(mockLegacyData(data2Import)) const imported = await db.all() const cond2After = imported.find(a => a.cond?.includes("cond2")) @@ -115,10 +116,10 @@ describe('limit-database', () => { test("import data2", async () => { const importData: Record = {} // Invalid data, no error throws - await db.importData(importData) + await db.importData(mockLegacyData(importData)) // Valid data importData["__timer__LIMIT"] = {} - await db.importData(importData) + await db.importData(mockLegacyData(importData)) expect(await db.all()).toEqual([]) }) diff --git a/test/database/merge-rule-database.test.ts b/test/database/merge-rule-database.test.ts index 318e7a8f1..ed31f6208 100644 --- a/test/database/merge-rule-database.test.ts +++ b/test/database/merge-rule-database.test.ts @@ -1,5 +1,6 @@ import db from "@db/merge-rule-database" import { mockStorage } from "../__mock__/storage" +import { mockLegacyData } from './migratable' function of(origin: string, merged?: string | number): timer.merge.Rule { return { origin, merged: merged || '' } @@ -40,7 +41,7 @@ describe('merge-rule-database.test', () => { await chrome.storage.local.clear() expect(await db.selectAll()).toEqual([]) - await db.importData(data2Import) + await db.importData(mockLegacyData(data2Import)) const imported: timer.merge.Rule[] = await db.selectAll() expect(imported).toEqual([ { origin: "www.baidu.com", merged: 2 }, diff --git a/test/database/migratable.ts b/test/database/migratable.ts new file mode 100644 index 000000000..ce2e3692a --- /dev/null +++ b/test/database/migratable.ts @@ -0,0 +1,10 @@ +export function mockLegacyData(data: Record): timer.backup.ExportData { + const withMeta: timer.backup.ExportData = { + ...data, + __meta__: { + version: "3.8.15", + ts: Date.now(), + }, + } + return withMeta +} \ No newline at end of file diff --git a/test/database/period-database.test.ts b/test/database/period-database.test.ts index 8fcffd308..c0b206f0c 100644 --- a/test/database/period-database.test.ts +++ b/test/database/period-database.test.ts @@ -1,5 +1,5 @@ import db from "@db/period-database" -import { keyOf, MILL_PER_PERIOD } from "@util/period" +import { keyOf } from "@util/period" import { formatTimeYMD } from "@util/time" import { mockStorage } from "../__mock__/storage" @@ -50,62 +50,4 @@ describe('period-database', () => { let all = await db.getAll() expect(all).toEqual(toAdd) }) - - test("importData", async () => { - const date = new Date(2021, 5, 7) - const yesterday = new Date(2021, 5, 6) - const toAdd: timer.period.Result[] = [ - resultOf(date, 0, 56999), - resultOf(date, 1, 2), - resultOf(yesterday, 95, 2) - ] - await db.accumulate(toAdd) - - const data2Import = await db.storage.get() - chrome.storage.local.clear() - expect(await db.getAll()).toEqual([]) - data2Import.foo = "bar" - db.importData(data2Import) - - const imported = await db.getAll() - expect(imported.length).toEqual(3) - }) - - // Invalid data - test("importData2", async () => { - await db.importData(undefined) - expect(await db.getAll()).toEqual([]) - await db.importData({ foo: "bar" }) - expect(await db.getAll()).toEqual([]) - await db.importData([]) - expect(await db.getAll()).toEqual([]) - await db.importData(1) - expect(await db.getAll()).toEqual([]) - await db.importData({ - __timer__PERIOD20210607: { - "-1": 100, - foo: "bar", - 96: 1000, - 85: "???", - 3: undefined, - 4: "", - } - }) - expect(await db.getAll()).toEqual([]) - }) - - test("importData3", async () => { - await db.importData({ - __timer__PERIOD20210607: { - 0: MILL_PER_PERIOD + 1, - 1: 100, - 2: "100", - } - }) - const imported: timer.period.Result[] = await db.getAll() - expect(imported.length).toEqual(3) - const orderMillMap: Record = {} - imported.forEach(({ milliseconds, order }) => orderMillMap[order] = milliseconds) - expect(orderMillMap).toEqual({ 0: MILL_PER_PERIOD, 1: 100, 2: 100 }) - }) }) diff --git a/test/database/stat-database.test.ts b/test/database/stat-database/classic.test.ts similarity index 79% rename from test/database/stat-database.test.ts rename to test/database/stat-database/classic.test.ts index 20006923c..a3ab65c08 100644 --- a/test/database/stat-database.test.ts +++ b/test/database/stat-database/classic.test.ts @@ -1,8 +1,10 @@ -import db, { type StatCondition } from "@db/stat-database" +import type { StatCondition } from '@db/stat-database' +import { ClassicStatDatabase, parseImportData } from "@db/stat-database/classic" import { resultOf } from "@util/stat" import { formatTimeYMD, MILL_PER_DAY } from "@util/time" -import { mockStorage } from "../__mock__/storage" +import { mockStorage } from '../../__mock__/storage' +let db: ClassicStatDatabase const now = new Date() const nowStr = formatTimeYMD(now) const yesterday = new Date(now.getTime() - MILL_PER_DAY) @@ -10,29 +12,32 @@ const beforeYesterday = new Date(now.getTime() - MILL_PER_DAY * 2) const baidu = 'www.baidu.com' const google = 'www.google.com.hk' -describe('stat-database', () => { - beforeAll(mockStorage) +describe('stat-database/classic', () => { + beforeAll(() => { + mockStorage() + db = new ClassicStatDatabase(chrome.storage.local) + }) beforeEach(async () => chrome.storage.local.clear()) test('1', async () => { await db.accumulate(baidu, nowStr, resultOf(100, 0)) const data: timer.core.Result = await db.get(baidu, now) - expect(data).toEqual(resultOf(100, 0)) + expect(data).toMatchObject(resultOf(100, 0)) }) test('2', async () => { await db.accumulate(baidu, nowStr, resultOf(200, 0)) await db.accumulate(baidu, nowStr, resultOf(200, 0)) let data = await db.get(baidu, now) - expect(data).toEqual(resultOf(400, 0)) + expect(data).toEqual({ host: baidu, date: nowStr, focus: 400, time: 0 } satisfies timer.core.Row) await db.accumulate(baidu, nowStr, resultOf(0, 1)) data = await db.get(baidu, now) - expect(data).toEqual(resultOf(400, 1)) + expect(data).toEqual({ host: baidu, date: nowStr, focus: 400, time: 1 } satisfies timer.core.Row) }) test('3', async () => { - await db.accumulateBatch( + await db.batchAccumulate( { [google]: resultOf(11, 0), [baidu]: resultOf(1, 0) @@ -40,7 +45,7 @@ describe('stat-database', () => { ) expect((await db.select()).length).toEqual(2) - await db.accumulateBatch( + await db.batchAccumulate( { [google]: resultOf(12, 1), [baidu]: resultOf(2, 1) @@ -48,7 +53,7 @@ describe('stat-database', () => { ) expect((await db.select()).length).toEqual(4) - await db.accumulateBatch( + await db.batchAccumulate( { [google]: resultOf(13, 2), [baidu]: resultOf(3, 2) @@ -96,17 +101,17 @@ describe('stat-database', () => { await db.accumulate(baidu, formatTimeYMD(yesterday), resultOf(12, 0)) expect((await db.select()).length).toEqual(2) // Delete yesterday's data - await db.deleteByUrlAndDate(baidu, yesterday) + await db.delete({ host: baidu, date: formatTimeYMD(yesterday) }) expect((await db.select()).length).toEqual(1) // Delete yesterday's data again, nothing changed - await db.deleteByUrlAndDate(baidu, yesterday) + await db.delete({ host: baidu, date: formatTimeYMD(yesterday) }) expect((await db.get(baidu, now)).focus).toEqual(10) // Add one again, and another await db.accumulate(baidu, formatTimeYMD(beforeYesterday), resultOf(1, 1)) await db.accumulate(google, nowStr, resultOf(0, 0)) expect((await db.select()).length).toEqual(3) // Delete all the baidu - await db.deleteByUrl(baidu) + await db.deleteByHost(baidu) const cond: StatCondition = { keys: baidu } // Nothing of baidu remained expect((await db.select(cond)).length).toEqual(0) @@ -117,13 +122,13 @@ describe('stat-database', () => { // Add one item of baidu again again await db.accumulate(baidu, nowStr, resultOf(1, 1)) // But delete google - await db.delete(list) + await db.delete(...list) // Then only one item of baidu expect((await db.select()).length).toEqual(1) }) test('6', async () => { - await db.accumulateBatch({}, now) + await db.batchAccumulate({}, now) expect((await db.select()).length).toEqual(0) // Return zero instance const result = await db.get(baidu, now) @@ -135,22 +140,21 @@ describe('stat-database', () => { await db.accumulate(baidu, nowStr, foo) await db.accumulate(baidu, formatTimeYMD(yesterday), foo) await db.accumulate(baidu, formatTimeYMD(beforeYesterday), foo) - await db.deleteByUrlBetween(baidu, now, now) + await db.delete({ host: baidu, date: formatTimeYMD(now) }) expect((await db.select()).length).toEqual(2) - await db.deleteByUrlBetween(baidu, now, beforeYesterday) // Invalid + await db.deleteByHost(baidu, [now, beforeYesterday]) // Invalid expect((await db.select()).length).toEqual(2) }) - test("importData", async () => { + test("parseImportData", async () => { const foo = resultOf(1, 1) await db.accumulate(baidu, nowStr, foo) const data2Import = await db.storage.get() chrome.storage.local.clear() data2Import.foo = "bar" - await db.importData(data2Import) - const data = await db.select({}) + const data = parseImportData(data2Import) expect(data.length).toEqual(1) const item = data[0] expect(item.date).toEqual(nowStr) @@ -159,8 +163,8 @@ describe('stat-database', () => { expect(item.time).toEqual(1) }) - test("importData2", async () => { - await db.importData({ + test("parseImportData2", async () => { + const data = parseImportData({ // Valid "20210910github.com": { focus: 1, @@ -177,16 +181,6 @@ describe('stat-database', () => { // Ignored with zero info "20210914github.com": {} }) - const imported = await db.select() - expect(imported.length).toEqual(2) - }) - - test("importData3", async () => { - await db.importData([]) - expect(await db.select()).toEqual([]) - await db.importData({ foo: "bar" }) - expect(await db.select()).toEqual([]) - await db.importData(false) - expect(await db.select()).toEqual([]) + expect(data.length).toEqual(2) }) }) \ No newline at end of file diff --git a/test/database/stat-database/idb.test.ts b/test/database/stat-database/idb.test.ts new file mode 100644 index 000000000..4f0ed31fe --- /dev/null +++ b/test/database/stat-database/idb.test.ts @@ -0,0 +1,224 @@ +import { zeroResult, zeroRow } from '@db/stat-database/common' +import { IDBStatDatabase } from '@db/stat-database/idb' +import 'fake-indexeddb/auto' +import { mockRuntime } from '../../__mock__/runtime' +import { cleanupIDB } from '../idb' + +let db: IDBStatDatabase +const GOOGLE = 'www.google.com' +const GITHUB = 'www.github.com' +const GITHUB_VIRTUAL = 'www.github.com/sheepzh/**' +const GROUP_1 = 1 +const GROUP_2 = 2 +const MAYBE_GROUP_1 = '1' + +describe('stat-database/idb', () => { + beforeAll(() => mockRuntime()) + beforeEach(async () => { + await cleanupIDB() + db = new IDBStatDatabase() + }) + + test('accumulate', async () => { + await db.accumulate(GITHUB, '20240601', { focus: 10, time: 20 }) + await db.accumulate(GOOGLE, new Date(2025, 10, 1), { focus: 1, time: 0 }) + await db.accumulateGroup(GROUP_1, '20240601', { focus: 5, time: 10 }) + + // Hosts + const github = await db.get(GITHUB, '20240601') + expect(github).toEqual({ host: GITHUB, date: '20240601', focus: 10, time: 20 } satisfies timer.core.Row) + + const google = await db.get(GOOGLE, new Date(2025, 10, 1)) + expect(google).toEqual({ host: GOOGLE, date: '20251101', focus: 1, time: 0 } satisfies timer.core.Row) + + // Date not exist + const notExist = await db.get(GOOGLE, '20240601') + expect(notExist).toEqual(zeroRow(GOOGLE, '20240601')) + + // list + const list = await db.select() + expect(list).toEqual([ + { host: GITHUB, date: '20240601', focus: 10, time: 20 }, + { host: GOOGLE, date: '20251101', focus: 1, time: 0 }, + ] satisfies timer.core.Row[]) + + // Groups + const byGroupId = await db.get(`${GROUP_1}`, '20240601') + expect(byGroupId).toMatchObject(zeroResult()) + + const groups = await db.selectGroup({ date: new Date(2024, 5, 1) }) + expect(groups).toEqual([{ host: `${GROUP_1}`, date: '20240601', focus: 5, time: 10 } satisfies timer.core.Row]) + }) + + test('batchAccumulate', async () => { + // Noise data + await db.accumulateGroup(GROUP_1, '20240602', { focus: 5, time: 10 }) + + await db.batchAccumulate({ + [GOOGLE]: { focus: 1, time: 0 }, + [GITHUB]: { focus: 10, time: 20 }, + [MAYBE_GROUP_1]: { focus: 5, time: 10 }, + }, '20240602') + + expect(await db.get(GITHUB, '20240602')) + .toEqual({ host: GITHUB, date: '20240602', focus: 10, time: 20 } satisfies timer.core.Row) + expect(await db.get(GOOGLE, '20240602')) + .toEqual({ host: GOOGLE, date: '20240602', focus: 1, time: 0 } satisfies timer.core.Row) + expect(await db.get(MAYBE_GROUP_1, '20240602')) + .toEqual({ host: MAYBE_GROUP_1, date: '20240602', focus: 5, time: 10 } satisfies timer.core.Row) + + await db.batchAccumulate({ [GOOGLE]: { focus: 0, time: 1 } }, '20240602') + + expect(await db.get(GOOGLE, '20240602')).toEqual({ host: GOOGLE, date: '20240602', focus: 1, time: 1 } satisfies timer.core.Row) + }) + + test('multiple indexes', async () => { + // Insert noise data + await db.accumulateGroup(GROUP_1, '20240603', { focus: 30, time: 20 }) + + await db.batchAccumulate({ + [GOOGLE]: { focus: 1, time: 0 }, + [GITHUB]: { focus: 10, time: 20 }, + [GITHUB_VIRTUAL]: { focus: 5, time: 10 }, + }, '20240603') + + await db.batchAccumulate({ + [GOOGLE]: { focus: 1, time: 0 }, + [GITHUB]: { focus: 10, time: 20 }, + }, '20240602') + + await db.accumulateGroup(GROUP_1, '20240603', { focus: 5, time: 10 }) + await db.accumulateGroup(GROUP_1, '20240602', { focus: 1, time: 1 }) + + // Query by date index + expect(await db.select({ date: [, new Date(2024, 5, 2)] })).toEqual([ + { host: GITHUB, date: '20240602', focus: 10, time: 20 }, + { host: GOOGLE, date: '20240602', focus: 1, time: 0 }, + ] satisfies timer.core.Row[]) + expect(await db.select({ date: new Date(2024, 5, 3) })).toEqual([ + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, + ] satisfies timer.core.Row[]) + expect(await db.select({ date: [new Date(2024, 5, 2), new Date(2024, 5, 3)] })).toEqual([ + { host: GITHUB, date: '20240602', focus: 10, time: 20 }, + { host: GOOGLE, date: '20240602', focus: 1, time: 0 }, + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, + ] satisfies timer.core.Row[]) + // Same as above, but reversed order + expect(await db.select({ date: [new Date(2024, 5, 3), new Date(2024, 5, 2)] })).toEqual([ + { host: GITHUB, date: '20240602', focus: 10, time: 20 }, + { host: GOOGLE, date: '20240602', focus: 1, time: 0 }, + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, + ] satisfies timer.core.Row[]) + // Including virtual + expect(await db.select({ date: new Date(2024, 5, 3), virtual: true })).toEqual([ + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, + { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, + ] satisfies timer.core.Row[]) + + // Query by host + expect(await db.select({ keys: GOOGLE })).toEqual([ + { host: GOOGLE, date: '20240602', focus: 1, time: 0 }, + { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, + ] satisfies timer.core.Row[]) + expect(await db.select({ keys: GITHUB })).toEqual([ + { host: GITHUB, date: '20240602', focus: 10, time: 20 }, + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + ] satisfies timer.core.Row[]) + expect(await db.select({ keys: GITHUB_VIRTUAL })).toEqual([] satisfies timer.core.Row[]) + expect(await db.select({ keys: GITHUB_VIRTUAL, virtual: true })).toEqual([ + { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, + ] satisfies timer.core.Row[]) + + // Query by date and host index + expect(await db.select({ date: new Date(2024, 5, 3), keys: GOOGLE })).toEqual([ + { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, + ] satisfies timer.core.Row[]) + expect(await db.select({ date: new Date(2024, 5, 3), keys: GITHUB_VIRTUAL })).toEqual([] satisfies timer.core.Row[]) + expect(await db.select({ date: new Date(2024, 5, 3), keys: GITHUB_VIRTUAL, virtual: true })).toEqual([ + { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, + ] satisfies timer.core.Row[]) + + // Query by time index + expect(await db.select({ timeRange: [10, 20] })).toEqual([ + { host: GITHUB, date: '20240602', focus: 10, time: 20 }, + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + ] satisfies timer.core.Row[]) + expect(await db.select({ timeRange: [10, 20], virtual: true })).toEqual([ + { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, + { host: GITHUB, date: '20240602', focus: 10, time: 20 }, + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + ] satisfies timer.core.Row[]) + expect(await db.select({ timeRange: [10, 20], date: new Date(2024, 5, 3), virtual: true })).toEqual([ + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, + ] satisfies timer.core.Row[]) + + // Query by focus index + expect(await db.select({ focusRange: [5, 10] })).toEqual([ + { host: GITHUB, date: '20240602', focus: 10, time: 20 }, + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + ] satisfies timer.core.Row[]) + expect(await db.select({ focusRange: [5, 10], virtual: true })).toEqual([ + { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, + { host: GITHUB, date: '20240602', focus: 10, time: 20 }, + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + ] satisfies timer.core.Row[]) + expect(await db.select({ focusRange: [5, 10], date: new Date(2024, 5, 3), virtual: true })).toEqual([ + { host: GITHUB, date: '20240603', focus: 10, time: 20 }, + { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, + ] satisfies timer.core.Row[]) + }) + + test('delete', async () => { + await db.batchAccumulate({ + [GOOGLE]: { focus: 1, time: 0 }, + [GITHUB]: { focus: 10, time: 20 }, + [GITHUB_VIRTUAL]: { focus: 5, time: 10 }, + [MAYBE_GROUP_1]: { focus: 222, time: 222 }, + }, '20240603') + + expect((await db.select({ virtual: true })).length).toEqual(4) + + await db.delete({ host: GOOGLE, date: '20240603' }) + expect(await db.get(GOOGLE, '20240603')).toEqual(zeroRow(GOOGLE, '20240603')) + + await db.deleteByHost(GITHUB) + expect(await db.get(GITHUB, '20240603')).toEqual(zeroRow(GITHUB, '20240603')) + + await db.deleteByGroup(GROUP_1) + expect(await db.get(MAYBE_GROUP_1, '20240603')) + .toEqual({ host: MAYBE_GROUP_1, date: '20240603', focus: 222, time: 222 } satisfies timer.core.Row) + + await db.delete({ host: MAYBE_GROUP_1, date: '20240603' }, { host: GITHUB_VIRTUAL, date: '20240603' }) + expect(await db.select({ virtual: true })).toEqual([] satisfies timer.core.Row[]) + }) + + test('multiple select groups', async () => { + // Insert noise data + await db.batchAccumulate({ + [GOOGLE]: { focus: 1, time: 0 }, + [GITHUB]: { focus: 10, time: 20 }, + [GITHUB_VIRTUAL]: { focus: 5, time: 10 }, + [MAYBE_GROUP_1]: { focus: 222, time: 222 }, + }, '20240603') + + await db.accumulateGroup(GROUP_1, '20240602', { focus: 1, time: 1 }) + await db.accumulateGroup(GROUP_2, '20240602', { focus: 2, time: 2 }) + await db.accumulateGroup(GROUP_1, '20240603', { focus: 3, time: 3 }) + await db.accumulateGroup(GROUP_2, '20240603', { focus: 4, time: 4 }) + + expect(await db.selectGroup({ date: new Date(2024, 5, 3) })).toEqual([ + { date: '20240603', host: `${GROUP_1}`, focus: 3, time: 3 }, + { date: '20240603', host: `${GROUP_2}`, focus: 4, time: 4 }, + ] satisfies timer.core.Row[]) + + expect(await db.selectGroup({ date: [new Date(2024, 5, 2), new Date(2024, 5, 3)], keys: GROUP_1.toString() })).toEqual([ + { date: '20240602', host: `${GROUP_1}`, focus: 1, time: 1 }, + { date: '20240603', host: `${GROUP_1}`, focus: 3, time: 3 }, + ] satisfies timer.core.Row[]) + }) +}) \ No newline at end of file diff --git a/test/jest.setup.ts b/test/jest.setup.ts new file mode 100644 index 000000000..6124e6ede --- /dev/null +++ b/test/jest.setup.ts @@ -0,0 +1,4 @@ +if (typeof global.structuredClone === 'undefined') { + // Used for fake-indexeddb + global.structuredClone = (v) => JSON.parse(JSON.stringify(v)) +} \ No newline at end of file diff --git a/test/util/time.test.ts b/test/util/time.test.ts index 40785c103..0b3563ae4 100644 --- a/test/util/time.test.ts +++ b/test/util/time.test.ts @@ -14,18 +14,10 @@ test('time', () => { const result = '20200501 000001五' // default format - expect(formatTime(dateStr)).toEqual('2020-05-01 00:00:01') - - expect(formatTime(dateStr, format)).toEqual(result) - expect(formatTime(date, format)).toEqual(result) // use seconds expect(formatTime(Math.floor(date / 1000), format)).toEqual(result) - // use string - expect(formatTime(date.toString(), format)).toEqual(result) - expect(formatTime(Math.floor(date / 1000).toString(), format)).toEqual(result) - expect(formatTime(new Date(date), format)).toEqual(result) }) @@ -83,10 +75,5 @@ test("get start of day", () => { const now = new Date(2022, 4, 2) now.setHours(11, 30, 29, 999) const start = getStartOfDay(now) - expect(start.getMonth()).toEqual(4) - expect(start.getDate()).toEqual(2) - expect(start.getHours()).toEqual(0) - expect(start.getMinutes()).toEqual(0) - expect(start.getSeconds()).toEqual(0) - expect(start.getMilliseconds()).toEqual(0) + expect(start).toEqual(new Date(2022, 4, 2).getTime()) }) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 8c8d779c9..0084d9fc8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -17,6 +17,10 @@ "esModuleInterop": true, "sourceMap": true, "strict": true, + "noImplicitAny": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "strictPropertyInitialization": true, "resolveJsonModule": true, "importHelpers": true, "skipLibCheck": true, diff --git a/types/timer/backup.d.ts b/types/timer/backup.d.ts index 28ea41ce7..172e351ce 100644 --- a/types/timer/backup.d.ts +++ b/types/timer/backup.d.ts @@ -123,4 +123,23 @@ declare namespace timer.backup { } type Row = core.Row & RowExtend + + /** + * The data format for export and import + */ + type ExportMeta = { + version: string + ts: number + } + + type ExportData = { + __meta__: ExportMeta + __stat__?: timer.core.Row[] + __limit__?: timer.limit.Rule[] + __merge__?: timer.merge.Rule[] + __whitelist__?: string[] + __cate__?: timer.site.Cate[] + // Legacy data before v4.0.0 + [key: string]: unknown + } } \ No newline at end of file diff --git a/types/timer/option.d.ts b/types/timer/option.d.ts index 089f4f639..9b4cf0d71 100644 --- a/types/timer/option.d.ts +++ b/types/timer/option.d.ts @@ -100,6 +100,12 @@ declare namespace timer.option { * @since 2.4.1 */ weekStart?: WeekStartOption + /** + * Where to store the tracking data + * + * @since 4.0.0 + */ + storage: StorageType } type LimitOption = { @@ -188,4 +194,9 @@ declare namespace timer.option { * @since 0.8.0 */ type LocaleOption = Locale | "default" + + /** + * @since 4.0.0 + */ + type StorageType = 'classic' | 'indexed_db' } From e601eb05d3adb663f1cbb1604c9e1a49b9cbd39b Mon Sep 17 00:00:00 2001 From: sheepzh Date: Wed, 25 Feb 2026 23:16:52 +0800 Subject: [PATCH 078/174] refactor: refactor the header of popup page --- src/i18n/message/popup/header-resource.json | 19 +---- src/i18n/message/popup/header.ts | 2 +- .../app/components/About/Description.tsx | 6 +- src/pages/app/components/About/Icon.tsx | 6 -- src/pages/popup/components/Header/Coffee.tsx | 44 ----------- src/pages/popup/components/Header/Github.tsx | 25 ------ .../popup/components/Header/MoreInfo.tsx | 79 +++++++++++++++++++ src/pages/popup/components/Header/RateUs.tsx | 37 --------- src/pages/popup/components/Header/index.tsx | 20 ++--- src/pages/util/icon.tsx | 16 +++- src/pages/util/style.ts | 2 + src/util/constant/url.ts | 7 ++ 12 files changed, 116 insertions(+), 147 deletions(-) delete mode 100644 src/pages/popup/components/Header/Coffee.tsx delete mode 100644 src/pages/popup/components/Header/Github.tsx create mode 100644 src/pages/popup/components/Header/MoreInfo.tsx delete mode 100644 src/pages/popup/components/Header/RateUs.tsx diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index c274c2e2b..08c916cb4 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -1,60 +1,47 @@ { "en": { - "rate": "Rate Us", + "rating": "Submit rating", "showSiteName": "Display site name", "showTopN": "Display top {n}", "donutChart": "Displayed as donut charts" }, "zh_CN": { - "rate": "评分", "showSiteName": "显示网站名称", "showTopN": "显示前 {n} 名", "donutChart": "以圆环图显示" }, "zh_TW": { - "rate": "評分", "showSiteName": "顯示網站名稱", "showTopN": "顯示前{n}个" }, "ja": { - "rate": "評価する", "showSiteName": "サイト名を表示", "showTopN": "トップ {n} を表示" }, "pt_PT": { - "rate": "Avaliar", "showSiteName": "Mostrar nome do site", "donutChart": "Mostrado como gráfico estilo donut" }, - "uk": { - "rate": "Оцінити" - }, + "uk": {}, "es": { - "rate": "Calificar", "showSiteName": "Mostrar nombre del sitio", "showTopN": "Mostrar primeros {n}" }, "de": { - "rate": "Bewerten", "showSiteName": "Seitenname anzeigen", "showTopN": "Top {n} anzeigen" }, "fr": { - "rate": "Évaluez-nous", "showSiteName": "Afficher le nom du site", "showTopN": "Afficher les {n} premiers", "donutChart": "Affichés sous forme de graphiques en anneau" }, "ru": { - "rate": "Оценить", "showSiteName": "Отобразить имя сайта", "showTopN": "Отображать заголовок {n}" }, - "ar": { - "rate": "قيمنا" - }, + "ar": {}, "tr": { - "rate": "Bizi Değerlendirin", "showSiteName": "Site adını göster", "showTopN": "En iyi {n} göster", "donutChart": "Halka grafik olarak göster" diff --git a/src/i18n/message/popup/header.ts b/src/i18n/message/popup/header.ts index ab034e7d5..0f403d837 100644 --- a/src/i18n/message/popup/header.ts +++ b/src/i18n/message/popup/header.ts @@ -8,8 +8,8 @@ import resource from './header-resource.json' export type HeaderMessage = { + rating: string donutChart: string - rate: string showSiteName: string showTopN: string } diff --git a/src/pages/app/components/About/Description.tsx b/src/pages/app/components/About/Description.tsx index 8e7468397..442d3f899 100644 --- a/src/pages/app/components/About/Description.tsx +++ b/src/pages/app/components/About/Description.tsx @@ -3,7 +3,7 @@ import { css } from '@emotion/css' import { MediaSize, useMediaSize } from "@hooks" import { locale } from "@i18n" import Flex from "@pages/components/Flex" -import { CoffeeIcon } from '@pages/util/icon' +import { Coffee, GitHub } from '@pages/util/icon' import { saveFlag } from "@service/meta-service" import packageInfo, { AUTHOR_EMAIL } from "@src/package" import { @@ -22,7 +22,7 @@ import { import { type ComponentSize, ElCard, ElDescriptions, ElDescriptionsItem, ElDivider, ElText, useNamespace } from "element-plus" import { computed, defineComponent, reactive } from "vue" import DescLink from "./DescLink" -import { Chrome, Echarts, Edge, ElementPlus, Firefox, GitHub, Vue } from './Icon' +import { Chrome, Echarts, Edge, ElementPlus, Firefox, Vue } from './Icon' import InstallationLink from "./InstallationLink" const useStyle = () => { @@ -155,7 +155,7 @@ const _default = defineComponent<{}>(() => { {locale !== 'zh_CN' && ( - {BUY_ME_A_COFFEE_PAGE} + } href={BUY_ME_A_COFFEE_PAGE}>{BUY_ME_A_COFFEE_PAGE} )} diff --git a/src/pages/app/components/About/Icon.tsx b/src/pages/app/components/About/Icon.tsx index 777fb4a7b..17935e0c5 100644 --- a/src/pages/app/components/About/Icon.tsx +++ b/src/pages/app/components/About/Icon.tsx @@ -140,12 +140,6 @@ export const Firefox: FunctionalComponent<{}> = () => ( ) -export const GitHub: FunctionalComponent<{}> = () => ( - - - -) - export const Vue: FunctionalComponent<{}> = () => ( diff --git a/src/pages/popup/components/Header/Coffee.tsx b/src/pages/popup/components/Header/Coffee.tsx deleted file mode 100644 index eade24775..000000000 --- a/src/pages/popup/components/Header/Coffee.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { locale } from '@i18n' -import { CoffeeIcon } from '@pages/util/icon' -import { BUY_ME_A_COFFEE_PAGE } from '@util/constant/url' -import { ElLink, ElTooltip } from 'element-plus' -import { defineComponent, onMounted, onUnmounted, ref } from 'vue' - -const useCoffee = () => { - const coffeeTip = ref() - - const resetTip = () => { - const now = new Date() - const hours = now.getHours() - let newVal = undefined - if (hours == 8) { - newVal = 'Buy me a coffee for a vibrant morning!' - } else if (hours == 13) { - newVal = 'Buy me a coffee for a pleasant afternoon!' - } - newVal !== coffeeTip.value && (coffeeTip.value = newVal) - } - const timer = setInterval(resetTip, 1000) - - onMounted(resetTip) - onUnmounted(() => clearInterval(timer)) - - return coffeeTip -} - -const Coffee = defineComponent(() => { - const tip = useCoffee() - return () => tip.value && locale !== 'zh_CN' ? - - - - : null -}) - -export default Coffee \ No newline at end of file diff --git a/src/pages/popup/components/Header/Github.tsx b/src/pages/popup/components/Header/Github.tsx deleted file mode 100644 index 819bf31e3..000000000 --- a/src/pages/popup/components/Header/Github.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import { createTab } from "@api/chrome/tab" -import Flex from "@pages/components/Flex" -import { SOURCE_CODE_PAGE } from "@util/constant/url" -import { ElIcon } from "element-plus" -import { defineComponent } from "vue" - -const Github = defineComponent(() => { - const handleClick = () => createTab({ url: SOURCE_CODE_PAGE }) - - return () => ( - - - - - - - - ) -}) - -export default Github \ No newline at end of file diff --git a/src/pages/popup/components/Header/MoreInfo.tsx b/src/pages/popup/components/Header/MoreInfo.tsx new file mode 100644 index 000000000..484a21639 --- /dev/null +++ b/src/pages/popup/components/Header/MoreInfo.tsx @@ -0,0 +1,79 @@ +import { createTab } from "@api/chrome/tab" +import { Collection, MoreFilled } from '@element-plus/icons-vue' +import { locale } from '@i18n' +import Flex from '@pages/components/Flex' +import { Coffee, GitHub, Heart } from '@pages/util/icon' +import { getColor, type ColorVariant } from '@pages/util/style' +import { t } from '@popup/locale' +import { saveFlag } from "@service/meta-service" +import { BUY_ME_A_COFFEE_PAGE, CHANGE_LOG_PAGE, DONATION_PAGE, REVIEW_PAGE, SOURCE_CODE_PAGE } from "@util/constant/url" +import { ElDropdown, ElDropdownItem, ElDropdownMenu, ElIcon } from "element-plus" +import { defineComponent, type StyleValue } from "vue" +import { type JSX } from 'vue/jsx-runtime' + +type Command = 'rate' | 'coffee' | 'donation' | 'github' | 'changelog' + +type ItemLinkProps = { + icon: JSX.Element + text: string + iconColor?: ColorVariant +} +const ItemLink = ({ icon, text, iconColor }: ItemLinkProps) => ( + + {icon} + {text} + +) + +const MoreInfo = defineComponent<{}>(() => { + const handleCmd = async (cmd: Command) => { + if (cmd === 'rate') { + await saveFlag("rateOpen") + createTab(REVIEW_PAGE) + } else if (cmd === 'coffee') { + createTab(BUY_ME_A_COFFEE_PAGE) + } else if (cmd === 'github') { + createTab(SOURCE_CODE_PAGE) + } else if (cmd === 'changelog') { + createTab(CHANGE_LOG_PAGE) + } else if (cmd === 'donation') { + createTab(DONATION_PAGE) + } + } + + return () => ( + , + dropdown: () => ( + + + } text={t(msg => msg.base.sourceCode)} /> + + + } text={t(msg => msg.base.changeLog)} /> + + + } text={t(msg => msg.header.rating)} iconColor="danger" /> + + {locale === 'zh_CN' ? ( + + } text="请他喝杯咖啡~" iconColor="warning" /> + + ) : ( + + } text="Buy me a coffee" iconColor="warning" /> + + )} + + ) + }}> + + ) +}) + +export default MoreInfo \ No newline at end of file diff --git a/src/pages/popup/components/Header/RateUs.tsx b/src/pages/popup/components/Header/RateUs.tsx deleted file mode 100644 index d79c22101..000000000 --- a/src/pages/popup/components/Header/RateUs.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import { createTab } from "@api/chrome/tab" -import { useRequest } from "@hooks/useRequest" -import { t } from "@popup/locale" -import { recommendRate, saveFlag } from "@service/meta-service" -import { REVIEW_PAGE } from "@util/constant/url" -import { ElLink } from "element-plus" -import { defineComponent } from "vue" - -const HeartIcon = ( - - - -) - -const RateUs = defineComponent<{}>(() => { - const { data: rateVisible } = useRequest(recommendRate) - - const handleRateClick = async () => { - await saveFlag("rateOpen") - createTab(REVIEW_PAGE) - } - - return () => ( - - {t(msg => msg.header.rate)} - - ) -}) - -export default RateUs \ No newline at end of file diff --git a/src/pages/popup/components/Header/index.tsx b/src/pages/popup/components/Header/index.tsx index c5235105e..41aa376b9 100644 --- a/src/pages/popup/components/Header/index.tsx +++ b/src/pages/popup/components/Header/index.tsx @@ -7,12 +7,10 @@ import { IS_ANDROID } from "@util/constant/environment" import { getAppPageUrl } from "@util/constant/url" import { ElLink } from "element-plus" import { FunctionalComponent } from "vue" -import Coffee from './Coffee' import DarkSwitch from "./DarkSwitch" -import Github from "./Github" import Logo from "./Logo" +import MoreInfo from './MoreInfo' import Option from "./Option" -import RateUs from './RateUs' const openAppPage = async () => { const appPageUrl = getAppPageUrl() @@ -33,22 +31,16 @@ const openAppPage = async () => { const Header: FunctionalComponent = () => ( - - - - + - - - - {t(msg => msg.base.allFunction)} - - + + {t(msg => msg.base.allFunction)} + - + diff --git a/src/pages/util/icon.tsx b/src/pages/util/icon.tsx index 510b14363..86ad37f90 100644 --- a/src/pages/util/icon.tsx +++ b/src/pages/util/icon.tsx @@ -1,5 +1,19 @@ -export const CoffeeIcon = ( +import { type FunctionalComponent } from 'vue' + +export const Coffee: FunctionalComponent<{}> = () => ( +) + +export const GitHub: FunctionalComponent<{}> = () => ( + + + +) + +export const Heart: FunctionalComponent<{}> = () => ( + + + ) \ No newline at end of file diff --git a/src/pages/util/style.ts b/src/pages/util/style.ts index 6d7e6a347..0db056e1d 100644 --- a/src/pages/util/style.ts +++ b/src/pages/util/style.ts @@ -8,6 +8,8 @@ import { camelize, type CSSProperties } from "vue" type Variant = "primary" | "success" | "warning" | "info" | "danger" +export type ColorVariant = Variant + type TextVariant = "primary" | "regular" | "secondary" type ColorUsage = 'fill' diff --git a/src/util/constant/url.ts b/src/util/constant/url.ts index 9aaecb4b5..37e256ed4 100644 --- a/src/util/constant/url.ts +++ b/src/util/constant/url.ts @@ -137,3 +137,10 @@ export const REVIEW_PAGE = reviewPage * @since 3.7.13 */ export const BUY_ME_A_COFFEE_PAGE = 'https://buymeacoffee.com/sheepysheep' + +/** + * Only available for simplified chinese users + * + * @since 4.0.0 + */ +export const DONATION_PAGE = 'https://github.com/sheepzh/sheepzh?tab=contributing-ov-file' \ No newline at end of file From 4eb27a831ccbcf4497bb1ed3adb14fee90dac6ee Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 25 Feb 2026 23:33:22 +0800 Subject: [PATCH 079/174] i18n(download): download translations by bot (#689) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/data-manage-resource.json | 1 + src/i18n/message/app/option-resource.json | 4 +++- src/i18n/message/popup/header-resource.json | 1 + 3 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/i18n/message/app/data-manage-resource.json b/src/i18n/message/app/data-manage-resource.json index ff2ce9163..7b3df4c3a 100644 --- a/src/i18n/message/app/data-manage-resource.json +++ b/src/i18n/message/app/data-manage-resource.json @@ -3,6 +3,7 @@ "totalMemoryAlert": "浏览器为每个扩展提供 {size}MB 来存储本地数据", "totalMemoryAlert1": "无法确定浏览器允许的最大可用内存", "usedMemoryAlert": "当前已使用 {size}MB", + "idbAlert": "可以将数据移动到 IndexedDB 来减少存储空间占用", "operationAlert": "您可以删除那些无关紧要的数据,来减小内存空间", "filterItems": "数据筛选", "filterFocus": "当日阅览时间在 {start} 秒至 {end} 秒之间。", diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index c8842e325..a16f19e90 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -45,7 +45,9 @@ "tabGroupsPermGrant": "该功能需要授予相关权限", "fileAccessDisabled": "目前不允许访问文件网址,请先在管理界面开启", "weekStart": "每周的第一天 {input}", - "weekStartAsNormal": "按照惯例" + "weekStartAsNormal": "按照惯例", + "storage": "将数据存储在 {input} 中", + "storageConfirm": "是否要将存储类型更改为 {type}?" }, "limit": { "prompt": "受限时显示的提示文本 {input}", diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index 08c916cb4..6efdaf2a0 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -6,6 +6,7 @@ "donutChart": "Displayed as donut charts" }, "zh_CN": { + "rating": "提交评价", "showSiteName": "显示网站名称", "showTopN": "显示前 {n} 名", "donutChart": "以圆环图显示" From a597acaed56722ab66e95f688d386da9994eca5a Mon Sep 17 00:00:00 2001 From: sheepzh Date: Wed, 25 Feb 2026 23:33:38 +0800 Subject: [PATCH 080/174] v4.0.0 --- CHANGELOG.md | 5 +++++ package.json | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 66205e14f..914d110c0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.0.0] - 2026-02-26 + +- Supported IndexedDB to store the tracking data +- Refactor the header of popup page + ## [3.7.15] - 2026-01-21 - Fixed virtual sites' data diff --git a/package.json b/package.json index 24f4a178c..e470059d8 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "3.7.15", + "version": "4.0.0", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { @@ -73,4 +73,4 @@ "engines": { "node": ">=22" } -} +} \ No newline at end of file From ef989a36242b97b3bec58856b16ea6cb3b9803ad Mon Sep 17 00:00:00 2001 From: Frederik Shull Date: Thu, 26 Feb 2026 03:48:13 +0100 Subject: [PATCH 081/174] i18n: fix typo (#690) fixed a typo I found tab group --- src/i18n/message/app/option-resource.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index a16f19e90..e79fe62ac 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -299,7 +299,7 @@ "localFileTime": "local files", "localFilesInfo": "Supports files of types such as PDF, image, txt and json.", "countTabGroup": "{input} Whether to track the time of tab groups {info}", - "tabGroupInfo": "When you delete a tag group, the data will also be deleted.", + "tabGroupInfo": "When you delete a tab group, the data will also be deleted.", "tabGroupsPermGrant": "This feature requires relevant permissions", "fileAccessDisabled": "Access to file URLs is currently not allowed. Please enable it on the manage page first", "weekStart": "The first day for each week {input}", @@ -1595,4 +1595,4 @@ "reloadButton": "Odśwież", "defaultValue": "Domyślnie: {default}" } -} \ No newline at end of file +} From ac9f1b6f9e2ea3f9e1827c385fbb9be3a0715f19 Mon Sep 17 00:00:00 2001 From: sheepie Date: Fri, 27 Feb 2026 00:25:02 +0800 Subject: [PATCH 082/174] fix: upgrade idb (#691) --- package.json | 2 +- src/background/index.ts | 2 +- src/background/migrator/indexed-migrator.ts | 20 +- src/database/common/indexed-storage.ts | 195 +++++++++++++++----- src/database/stat-database/idb.ts | 34 +--- test/database/idb.ts | 17 -- test/database/stat-database/idb.test.ts | 12 +- 7 files changed, 187 insertions(+), 95 deletions(-) delete mode 100644 test/database/idb.ts diff --git a/package.json b/package.json index e470059d8..d587a9b27 100644 --- a/package.json +++ b/package.json @@ -39,7 +39,7 @@ "@types/chrome": "0.1.37", "@types/decompress": "^4.2.7", "@types/jest": "^30.0.0", - "@types/node": "^25.3.0", + "@types/node": "^25.3.1", "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.0.0", diff --git a/src/background/index.ts b/src/background/index.ts index 1c6fbfa00..4dbad3cbe 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -15,7 +15,7 @@ import badgeTextManager from "./badge-manager" import initBrowserAction from "./browser-action-manager" import initCsHandler from "./content-script-handler" import initDataCleaner from "./data-cleaner" -import handleInstall from "./install-handler" +import handleInstall from './install-handler' import initLimitProcessor from "./limit-processor" import MessageDispatcher from "./message-dispatcher" import VersionMigrator from "./migrator" diff --git a/src/background/migrator/indexed-migrator.ts b/src/background/migrator/indexed-migrator.ts index 5c2c71198..63ee0169b 100644 --- a/src/background/migrator/indexed-migrator.ts +++ b/src/background/migrator/indexed-migrator.ts @@ -1,10 +1,28 @@ +import { BaseIDBStorage } from '@db/common/indexed-storage' +import { IDBStatDatabase } from '@db/stat-database/idb' import timelineDatabase from '@db/timeline-database' +import IDBTimelineDatabase from '@db/timeline-database/idb' import type { Migrator } from './types' +async function upgradeIndexedDB() { + try { + const storages: BaseIDBStorage[] = [new IDBStatDatabase(), new IDBTimelineDatabase()] + for (const storage of storages) { + await storage.upgrade() + } + console.log('IndexedDB upgraded successfully') + } catch (error) { + console.error('Failed to upgrade IndexedDB', error) + } +} + class IndexedMigrator implements Migrator { onInstall(): void { } - onUpdate(_version: string): void { + + async onUpdate(_version: string): Promise { + await upgradeIndexedDB() + timelineDatabase.migrateFromClassic() .then(() => console.log('Timeline data migrated to IndexedDB')) .catch(e => console.error('Failed to migrate timeline data to IndexedDB', e)) diff --git a/src/database/common/indexed-storage.ts b/src/database/common/indexed-storage.ts index 893d19be2..b228bb5f4 100644 --- a/src/database/common/indexed-storage.ts +++ b/src/database/common/indexed-storage.ts @@ -63,6 +63,21 @@ export async function iterateCursor( }) } +type TransactionError = 'Connection' | 'StoreNotFound' | 'Unknown' + +const detectTransactionError = (err: unknown): TransactionError => { + if (!(err instanceof DOMException)) { + return 'Unknown' + } + if (err.name === 'InvalidStateError' || err.name === 'AbortError') { + return 'Connection' + } + if (err.name === 'NotFoundError') { + return 'StoreNotFound' + } + return 'Unknown' +} + export function closedRangeKey(lower: IDBValidKey | undefined, upper: IDBValidKey | undefined): IDBKeyRange | undefined { if (lower !== undefined && upper !== undefined) { if (lower > upper) { @@ -87,6 +102,8 @@ export abstract class BaseIDBStorage> { private DB_NAME = `tt4b_${chrome.runtime.id}` as const private db: IDBDatabase | undefined + private static initPromises = new Map>() + abstract indexes: Index[] abstract key: Key | Key[] abstract table: Table @@ -94,46 +111,122 @@ export abstract class BaseIDBStorage> { protected async initDb(): Promise { if (this.db) return this.db + let initPromise = BaseIDBStorage.initPromises.get(this.table) + if (!initPromise) { + initPromise = this.doInitDb() + BaseIDBStorage.initPromises.set(this.table, initPromise) + } + + try { + this.db = await initPromise + this.setupDbCloseHandler(this.db) + return this.db + } catch (error) { + BaseIDBStorage.initPromises.delete(this.table) + throw error + } + } + + private setupDbCloseHandler(db: IDBDatabase): void { + db.onclose = () => { + if (this.db !== db) return + + this.db = undefined + BaseIDBStorage.initPromises.delete(this.table) + } + } + + private async doInitDb(): Promise { const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB - const checkRequest = factory.open(this.DB_NAME) - return new Promise((resolve, reject) => { - checkRequest.onsuccess = () => { - const db = checkRequest.result - const storeExisted = db.objectStoreNames.contains(this.table) - const needUpgrade = !storeExisted || this.needUpgradeIndexes(db) + const checkDb = await new Promise((resolve, reject) => { + const checkRequest = factory.open(this.DB_NAME) + checkRequest.onsuccess = () => resolve(checkRequest.result) + checkRequest.onerror = () => reject(checkRequest.error || new Error("Failed to open database")) + }) - if (!needUpgrade) { - this.db = db - return resolve(db) - } + return checkDb + } + + // Only used for testing, be careful when using in production + public async clear(): Promise { + await this.withStore(store => store.clear(), 'readwrite') + } + + async upgrade(): Promise { + const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB + + const checkDb = await new Promise((resolve, reject) => { + const checkRequest = factory.open(this.DB_NAME) + checkRequest.onsuccess = () => resolve(checkRequest.result) + checkRequest.onerror = () => reject(checkRequest.error || new Error("Failed to open database")) + checkRequest.onblocked = () => { + console.warn(`Database check blocked for "${this.table}" (DB: ${this.DB_NAME}), waiting for other connections to close`) + } + }) + + const storeExisted = checkDb.objectStoreNames.contains(this.table) + const needUpgrade = !storeExisted || this.needUpgradeIndexes(checkDb) + + if (!needUpgrade) { + checkDb.close() + return + } - const currentVersion = db.version - db.close() + const currentVersion = checkDb.version + checkDb.close() - const upgradeRequest = factory.open(this.DB_NAME, currentVersion + 1) + return new Promise((resolve, reject) => { + const upgradeRequest = factory.open(this.DB_NAME, currentVersion + 1) - upgradeRequest.onupgradeneeded = () => { + upgradeRequest.onupgradeneeded = () => { + try { const upgradeDb = upgradeRequest.result const transaction = upgradeRequest.transaction - if (!transaction) return reject("Failed to get transaction of upgrading request") + if (!transaction) { + reject(new Error("Failed to get transaction of upgrading request")) + return + } + + transaction.onerror = () => { + reject(transaction.error || new Error("Transaction failed")) + } + + transaction.onabort = () => { + reject(new Error("Upgrade transaction was aborted")) + } let store = upgradeDb.objectStoreNames.contains(this.table) ? transaction.objectStore(this.table) - : upgradeDb.createObjectStore(this.table, { keyPath: this.key }) + : upgradeDb.createObjectStore(this.table, { keyPath: this.key as string | string[] }) this.createIndexes(store) + } catch (error) { + console.error("Failed to upgrade database in onupgradeneeded", error) + upgradeRequest.transaction?.abort() + reject(error instanceof Error ? error : new Error(String(error))) } + } - upgradeRequest.onsuccess = () => { - console.log("IndexedDB upgraded") - this.db = upgradeRequest.result - resolve(upgradeRequest.result) - } + upgradeRequest.onsuccess = () => { + console.log(`IndexedDB upgraded for table "${this.table}"`) + upgradeRequest.result.close() + resolve() + } - upgradeRequest.onerror = () => reject(upgradeRequest.error) + upgradeRequest.onerror = (event) => { + console.error("Failed to upgrade database", event, upgradeRequest.error) + reject(upgradeRequest.error || new Error("Failed to upgrade database")) } - checkRequest.onerror = () => reject(checkRequest.error) + upgradeRequest.onblocked = () => { + const blockingTables = Array.from(BaseIDBStorage.initPromises.keys()) + .filter(table => table !== this.table) + console.warn( + `Database upgrade blocked for table "${this.table}" (DB: ${this.DB_NAME}), ` + + `waiting for other connections to close. ` + + `Other tables with active connections: ${blockingTables.length > 0 ? blockingTables.join(', ') : 'none'}` + ) + } }) } @@ -170,27 +263,45 @@ export abstract class BaseIDBStorage> { } protected async withStore(operation: (store: IDBObjectStore) => T | Promise, mode?: IDBTransactionMode): Promise { - const db = await this.initDb() - const trans = db.transaction(this.table, mode ?? 'readwrite') - try { - const store = trans.objectStore(this.table) - const result = await operation(store) - // Waiting for transaction completed - await new Promise((resolve, reject) => { - trans.oncomplete = () => resolve() - trans.onerror = () => reject(trans.error) - trans.onabort = () => reject(new Error('Transaction aborted')) - }) - return result - } catch (e) { - console.error("Failed to process with transaction", e) - if (!trans.error && trans.mode !== 'readonly') { - try { - trans.abort() - } catch (ignored) { } + let db = await this.initDb() + + for (let retryCount = 0; retryCount < 2; retryCount++) { + let trans: IDBTransaction | undefined + try { + trans = db.transaction(this.table, mode ?? 'readwrite') + const store = trans.objectStore(this.table) + const result = await operation(store) + const transaction = trans + await new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve() + transaction.onerror = () => reject(transaction.error) + transaction.onabort = () => reject(new Error('Transaction aborted')) + }) + return result + } catch (e) { + const errorType = detectTransactionError(e) + + if (errorType === 'Unknown') { + console.error("Failed to process with transaction", e) + if (trans && !trans.error && trans.mode !== 'readonly') { + try { + trans.abort() + } catch (ignored) { } + } + throw e + } + + if (errorType === 'StoreNotFound') { + this.db?.close() + await this.upgrade() + } + + this.db = undefined + BaseIDBStorage.initPromises.delete(this.table) + db = await this.initDb() } - throw e } + throw new Error("Max retries exceeded") } protected assertIndex(store: IDBObjectStore, key: Key | Key[]): IDBIndex { diff --git a/src/database/stat-database/idb.ts b/src/database/stat-database/idb.ts index b24b99fe1..f9e7fba54 100644 --- a/src/database/stat-database/idb.ts +++ b/src/database/stat-database/idb.ts @@ -1,5 +1,5 @@ import { BaseIDBStorage, closedRangeKey, IndexResult, iterateCursor, type Key, req2Promise, type Table } from '@db/common/indexed-storage' -import { cvtGroupId2Host, formatDateStr, increase, zeroRow } from './common' +import { cvtGroupId2Host, formatDateStr, GROUP_PREFIX, increase, zeroRow } from './common' import { filterDate, filterHost, filterNumberRange, processCondition, type ProcessedCondition } from './condition' import type { StatCondition, StatDatabase } from './types' @@ -8,15 +8,7 @@ type StoredRow = timer.core.Row & { groupId?: number } -function fromStoredRow(stored: StoredRow): timer.core.Row { - if (stored.groupId !== undefined) { - const { groupId, ...row } = stored - return { ...row, host: cvtGroupId2Host(groupId) } - } - return stored -} - -const GROUP_HOST_PATTERN = /^_g_(\d+)$/ +const GROUP_HOST_PATTERN = new RegExp(`^${GROUP_PREFIX}(\\d+)$`) const INDEXES: (Key | Key[])[] = [ 'date', 'host', 'groupId', @@ -144,12 +136,7 @@ export class IDBStatDatabase extends BaseIDBStorage implements StatDa const rows = await iterateCursor(cursorReq) for (const row of rows) { - if (expectGroup) { - if (!isGroup(row)) continue - } else { - if (isGroup(row)) continue - } - + if (expectGroup !== isGroup(row)) continue if (!filter(row)) continue if (expectGroup) { @@ -196,9 +183,8 @@ export class IDBStatDatabase extends BaseIDBStorage implements StatDa await iterateCursor(cursorReq, cursor => { const stored = cursor.value as StoredRow | undefined - if (stored && !isGroup(stored)) { - toUpdate[stored.host] = fromStoredRow(stored) - } + if (!stored || isGroup(stored)) return + toUpdate[stored.host] = stored }) for (const [host, result] of Object.entries(data)) { @@ -332,15 +318,7 @@ export class IDBStatDatabase extends BaseIDBStorage implements StatDa } forceUpdate(...rows: timer.core.Row[]): Promise { - return this.withStore(store => { - for (const row of rows) { - const { host, date, time, focus, run } = row - const groupMatch = host.match(GROUP_HOST_PATTERN) - const newData: StoredRow = { host, date, time, focus, run } - groupMatch && (newData.groupId = parseInt(groupMatch[1])) - store.put(newData) - } - }, 'readwrite') + return this.withStore(store => rows.forEach(row => store.put(row)), 'readwrite') } forceUpdateGroup(...rows: timer.core.Row[]): Promise { diff --git a/test/database/idb.ts b/test/database/idb.ts deleted file mode 100644 index 1295425f3..000000000 --- a/test/database/idb.ts +++ /dev/null @@ -1,17 +0,0 @@ -import { } from 'fake-indexeddb' -import 'fake-indexeddb/auto' - -/** - * Clean up all IndexedDB databases - */ -export async function cleanupIDB() { - return new Promise((resolve) => { - try { - global.indexedDB = new IDBFactory() - setTimeout(resolve, 0) - } catch (error) { - console.error('Failed to cleanup database:', error) - resolve(undefined) - } - }) -} \ No newline at end of file diff --git a/test/database/stat-database/idb.test.ts b/test/database/stat-database/idb.test.ts index 4f0ed31fe..b5afe6a31 100644 --- a/test/database/stat-database/idb.test.ts +++ b/test/database/stat-database/idb.test.ts @@ -2,9 +2,7 @@ import { zeroResult, zeroRow } from '@db/stat-database/common' import { IDBStatDatabase } from '@db/stat-database/idb' import 'fake-indexeddb/auto' import { mockRuntime } from '../../__mock__/runtime' -import { cleanupIDB } from '../idb' -let db: IDBStatDatabase const GOOGLE = 'www.google.com' const GITHUB = 'www.github.com' const GITHUB_VIRTUAL = 'www.github.com/sheepzh/**' @@ -12,13 +10,17 @@ const GROUP_1 = 1 const GROUP_2 = 2 const MAYBE_GROUP_1 = '1' +let db: IDBStatDatabase + describe('stat-database/idb', () => { - beforeAll(() => mockRuntime()) - beforeEach(async () => { - await cleanupIDB() + beforeAll(async () => { + mockRuntime() db = new IDBStatDatabase() + await db.upgrade() }) + beforeEach(() => db.clear()) + test('accumulate', async () => { await db.accumulate(GITHUB, '20240601', { focus: 10, time: 20 }) await db.accumulate(GOOGLE, new Date(2025, 10, 1), { focus: 1, time: 0 }) From a72908ebbb81510d19e683e99dc79142e4ff6704 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 27 Feb 2026 00:42:12 +0800 Subject: [PATCH 083/174] v4.0.1 --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 914d110c0..ba8942f36 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.0.1] - 2026-02-27 + +- Fixed an IndexedDB upgrade bug on Edge + ## [4.0.0] - 2026-02-26 - Supported IndexedDB to store the tracking data diff --git a/package.json b/package.json index d587a9b27..cd87a5fae 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "4.0.0", + "version": "4.0.1", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { From 834a7d833cea846f1d315f9b8f6123f177090e41 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 27 Feb 2026 13:14:27 +0800 Subject: [PATCH 084/174] i18n(download): download translations by bot (#692) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/data-manage-resource.json | 1 + src/i18n/message/app/option-resource.json | 6 ++++-- src/i18n/message/popup/header-resource.json | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/src/i18n/message/app/data-manage-resource.json b/src/i18n/message/app/data-manage-resource.json index 7b3df4c3a..697882a9f 100644 --- a/src/i18n/message/app/data-manage-resource.json +++ b/src/i18n/message/app/data-manage-resource.json @@ -324,6 +324,7 @@ "totalMemoryAlert": "Tarayıcı, her uzantı için yerel verileri depolamak üzere {size} MB alan sağlar", "totalMemoryAlert1": "Tarayıcı tarafından izin verilen maksimum depolama alanı belirlenemiyor", "usedMemoryAlert": "Şu anda {size} MB kullanılıyor", + "idbAlert": "Depolama kullanımını azaltmak için verilerinizi IndexedDB'ye taşı", "operationAlert": "Depolama alanını azaltmak için önemsiz verileri silebilirsiniz", "filterItems": "Verileri filtrele", "filterFocus": "Günün gezinme süresi {start} saniye ile {end} saniye arasındadır", diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index e79fe62ac..07a31146b 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -1405,7 +1405,9 @@ "tabGroupsPermGrant": "Bu özellik ilgili izinleri gerektirir", "fileAccessDisabled": "Dosya URL'lerine erişim şu anda izin verilmiyor. Lütfen önce yönetim sayfasında bunu etkinleştirin", "weekStart": "Her haftanın ilk günü {input}", - "weekStartAsNormal": "Normal" + "weekStartAsNormal": "Normal", + "storage": "İzleme verilerini {input} içinde saklayın.", + "storageConfirm": "Depolama türünü {type} olarak değiştirmek ister misiniz?" }, "limit": { "prompt": "Kısıtlı olduğunda gösterilecek uyarı {input}", @@ -1595,4 +1597,4 @@ "reloadButton": "Odśwież", "defaultValue": "Domyślnie: {default}" } -} +} \ No newline at end of file diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index 6efdaf2a0..c6a2299ca 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -43,6 +43,7 @@ }, "ar": {}, "tr": { + "rating": "Değerlendirmeyi Gönder", "showSiteName": "Site adını göster", "showTopN": "En iyi {n} göster", "donutChart": "Halka grafik olarak göster" From 6dce672268a5a72e773a62001f5fb5e43441beb0 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:01:21 +0800 Subject: [PATCH 085/174] chore(psl): update PSL list by bot (#694) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/util/psl/rules.json | 92 +++++++++++++++++++++++++++++++---------- 1 file changed, 70 insertions(+), 22 deletions(-) diff --git a/src/util/psl/rules.json b/src/util/psl/rules.json index 63a739909..751dbeeeb 100644 --- a/src/util/psl/rules.json +++ b/src/util/psl/rules.json @@ -2134,7 +2134,18 @@ "es-1": 1 } }, - "convex": 1, + "begetcdn": { + "c": { + "*": 1 + } + }, + "convex": { + "c": { + "eu-west-1": 1, + "us-east-1": 1 + }, + "l": 1 + }, "diadem": 1, "elementor": 1, "emergent": 1, @@ -2620,6 +2631,7 @@ } }, "180r": 1, + "1cooldns": 1, "1kapp": 1, "3utilities": 1, "4u": 1, @@ -4145,6 +4157,7 @@ "bplaced": 1, "br": 1, "builtwithdark": 1, + "bumbleshrimp": 1, "cafjs": 1, "canva-apps": 1, "canva-hosted-embed": 1, @@ -4219,6 +4232,7 @@ "dattoweb": 1, "ddnsfree": 1, "ddnsgeek": 1, + "ddnsguru": 1, "ddnsking": 1, "de": 1, "deus-canvas": 1, @@ -4247,6 +4261,7 @@ "dopaas": 1, "drayddns": 1, "dreamhosters": 1, + "drive-platform": 1, "dsmynas": 1, "durumis": 1, "dyn-o-saur": 1, @@ -4266,6 +4281,8 @@ "dyndns-wiki": 1, "dyndns-work": 1, "dynns": 1, + "dynuddns": 1, + "dynuhosting": 1, "elasticbeanstalk": { "c": { "af-south-1": 1, @@ -4499,15 +4516,6 @@ "demo": 1 } }, - "joyent": { - "c": { - "cns": { - "c": { - "*": 1 - } - } - } - }, "jpn": 1, "kasserver": 1, "kozow": 1, @@ -4648,6 +4656,7 @@ } }, "pgfog": 1, + "pivohosting": 1, "pixolino": 1, "playstation-cloud": 1, "pleskns": 1, @@ -4822,6 +4831,7 @@ "pages": 1 } }, + "wiredbladehosting": 1, "withgoogle": 1, "withyoutube": 1, "wixsite": 1, @@ -4861,7 +4871,12 @@ }, "l": 1 }, - "company": 1, + "company": { + "c": { + "mybox": 1 + }, + "l": 1 + }, "compare": 1, "computer": 1, "comsec": 1, @@ -5168,7 +5183,6 @@ }, "dev": { "c": { - "12chars": 1, "barsy": 1, "bearblog": 1, "botdash": 1, @@ -5443,7 +5457,8 @@ "dish": 1, "diy": { "c": { - "discourse": 1 + "discourse": 1, + "imagine": 1 }, "l": 1 }, @@ -5634,6 +5649,7 @@ "on": 1 } }, + "intouch": 1, "tawk": { "c": { "p": 1 @@ -5899,6 +5915,7 @@ "gouv": 1, "greta": 1, "huissier-justice": 1, + "kdns": 1, "medecin": 1, "myspreadshop": 1, "nom": 1, @@ -6072,7 +6089,6 @@ "gold": 1, "goldpoint": 1, "golf": 1, - "goo": 1, "goodyear": 1, "goog": { "c": { @@ -6592,6 +6608,7 @@ "darklang": 1, "dedyn": 1, "definima": 1, + "drive-platform": 1, "editorx": 1, "edu": 1, "edugit": 1, @@ -6802,7 +6819,6 @@ "it": { "c": { "123homepage": 1, - "12chars": 1, "16-b": 1, "32-b": 1, "64-b": 1, @@ -9507,8 +9523,13 @@ }, "kh": { "c": { - "*": 1 - } + "com": 1, + "edu": 1, + "gov": 1, + "net": 1, + "org": 1 + }, + "l": 1 }, "ki": { "c": { @@ -9947,6 +9968,7 @@ "loginto": 1, "lohmus": 1, "mcdir": 1, + "mybox": 1, "myds": 1, "net": 1, "nohost": 1, @@ -10369,7 +10391,12 @@ "de5": 1, "debian": 1, "definima": 1, - "deno": 1, + "deno": { + "c": { + "sandbox": 1 + }, + "l": 1 + }, "dns-cloud": 1, "dns-dynamic": 1, "dnsalias": 1, @@ -10381,6 +10408,7 @@ "dynalias": 1, "dynathome": 1, "dynu": 1, + "dynuddns": 1, "dynv6": 1, "eating-organic": 1, "edgekey": 1, @@ -10500,11 +10528,13 @@ "myradweb": 1, "mysecuritycamera": 1, "myspreadshop": 1, + "mysynology": 1, "nhlfan": 1, "no-ip": 1, "now-dns": 1, "office-on-the": 1, "oninferno": 1, + "opik": 1, "ovh": { "c": { "hosting": { @@ -10553,6 +10583,7 @@ "server-on": 1, "shopselect": 1, "siteleaf": 1, + "spryt": 1, "square7": 1, "squares": 1, "srcf": { @@ -10589,10 +10620,18 @@ "uni5": 1, "usgovcloudapi": { "c": { + "core": { + "c": { + "blob": 1, + "file": 1, + "web": 1 + } + }, "servicebus": 1 } }, "usgovcloudapp": 1, + "usgovtrafficmanager": 1, "vpndns": 1, "vps-host": { "c": { @@ -10612,7 +10651,9 @@ "c": { "core": { "c": { - "blob": 1 + "blob": 1, + "file": 1, + "web": 1 } }, "servicebus": 1 @@ -11939,6 +11980,7 @@ "read-books": 1, "readmyblog": 1, "routingthecloud": 1, + "roxa": 1, "selfip": 1, "sellsyourhome": 1, "servebbs": 1, @@ -12007,6 +12049,7 @@ "deuxfleurs": 1, "heyflow": 1, "hlx": 1, + "mybox": 1, "pdns": 1, "plesk": 1, "prvcy": 1, @@ -12442,7 +12485,6 @@ "prime": 1, "pro": { "c": { - "12chars": 1, "aaa": 1, "aca": 1, "acct": 1, @@ -13023,7 +13065,13 @@ } }, "co": 1, - "convex": 1, + "convex": { + "c": { + "eu-west-1": 1, + "us-east-1": 1 + }, + "l": 1 + }, "cpanel": 1, "cyon": 1, "fastvps": 1, @@ -14440,6 +14488,7 @@ "haugiang": 1, "health": 1, "hoabinh": 1, + "hue": 1, "hungyen": 1, "id": 1, "info": 1, @@ -14542,7 +14591,6 @@ "wine": 1, "winners": 1, "wme": 1, - "wolterskluwer": 1, "woodside": 1, "work": { "c": { From 4457cc6b7ebb21dfb557febcbe31f5b3826a3458 Mon Sep 17 00:00:00 2001 From: sheepie Date: Sun, 1 Mar 2026 16:55:22 +0800 Subject: [PATCH 086/174] feat: support site blocking for mobile (#579) --- package.json | 6 +- .../limit/modal/components/Alert.tsx | 9 +- .../limit/modal/components/Reason.tsx | 19 +- src/pages/app/Layout/menu/item.ts | 3 +- src/pages/app/components/Dashboard/index.tsx | 2 +- .../Limit/LimitModify/Sop/Step3/TimeInput.tsx | 258 ------------------ .../Limit/LimitTable/RuleContent.tsx | 78 ------ .../app/components/Limit/LimitTable/Waste.tsx | 52 ---- .../Filter.tsx} | 21 +- .../components/Limit/components/List/Card.tsx | 111 ++++++++ .../components/Limit/components/List/Rule.tsx | 115 ++++++++ .../Limit/components/List/index.tsx | 28 ++ .../Modify}/Sop/Step1.tsx | 6 +- .../Modify}/Sop/Step2/SiteInput.tsx | 0 .../Modify}/Sop/Step2/index.tsx | 0 .../Modify}/Sop/Step3/PeriodInput.tsx | 46 ++-- .../components/Modify/Sop/Step3/TimeInput.tsx | 251 +++++++++++++++++ .../Modify}/Sop/Step3/index.tsx | 14 +- .../Modify}/Sop/context.ts | 2 +- .../Modify}/Sop/index.tsx | 37 ++- .../Modify}/index.tsx | 11 +- .../Table/OperationColumn.tsx} | 4 +- .../Limit/components/Table/Rule.tsx | 68 +++++ .../Limit/components/Table/Waste.tsx | 48 ++++ .../Table}/Weekday.tsx | 0 .../Table}/index.tsx | 25 +- .../{LimitTest.tsx => components/Test.tsx} | 8 +- .../app/components/Limit/components/index.tsx | 5 + .../app/components/Limit/components/style.ts | 5 + src/pages/app/components/Limit/context.ts | 33 ++- src/pages/app/components/Limit/index.tsx | 20 +- src/pages/app/components/Option/Select.tsx | 11 +- .../Option/components/LimitOption/index.tsx | 2 +- .../app/components/common/TooltipWrapper.tsx | 4 +- .../common/filter/ButtonFilterItem.tsx | 21 +- src/pages/components/Flex.tsx | 50 ++-- src/pages/popup/components/Header/index.tsx | 4 +- src/util/time.ts | 52 ++-- test-e2e/common/base.ts | 2 +- .../{whitelist.ts => whitelist.test.ts} | 16 +- test-e2e/tracker/base.test.ts | 2 +- test-e2e/tracker/run-time.test.ts | 2 +- 42 files changed, 897 insertions(+), 554 deletions(-) delete mode 100644 src/pages/app/components/Limit/LimitModify/Sop/Step3/TimeInput.tsx delete mode 100644 src/pages/app/components/Limit/LimitTable/RuleContent.tsx delete mode 100644 src/pages/app/components/Limit/LimitTable/Waste.tsx rename src/pages/app/components/Limit/{LimitFilter.tsx => components/Filter.tsx} (85%) create mode 100644 src/pages/app/components/Limit/components/List/Card.tsx create mode 100644 src/pages/app/components/Limit/components/List/Rule.tsx create mode 100644 src/pages/app/components/Limit/components/List/index.tsx rename src/pages/app/components/Limit/{LimitModify => components/Modify}/Sop/Step1.tsx (90%) rename src/pages/app/components/Limit/{LimitModify => components/Modify}/Sop/Step2/SiteInput.tsx (100%) rename src/pages/app/components/Limit/{LimitModify => components/Modify}/Sop/Step2/index.tsx (100%) rename src/pages/app/components/Limit/{LimitModify => components/Modify}/Sop/Step3/PeriodInput.tsx (82%) create mode 100644 src/pages/app/components/Limit/components/Modify/Sop/Step3/TimeInput.tsx rename src/pages/app/components/Limit/{LimitModify => components/Modify}/Sop/Step3/index.tsx (80%) rename src/pages/app/components/Limit/{LimitModify => components/Modify}/Sop/context.ts (98%) rename src/pages/app/components/Limit/{LimitModify => components/Modify}/Sop/index.tsx (54%) rename src/pages/app/components/Limit/{LimitModify => components/Modify}/index.tsx (91%) rename src/pages/app/components/Limit/{LimitTable/column/LimitOperationColumn.tsx => components/Table/OperationColumn.tsx} (93%) create mode 100644 src/pages/app/components/Limit/components/Table/Rule.tsx create mode 100644 src/pages/app/components/Limit/components/Table/Waste.tsx rename src/pages/app/components/Limit/{LimitTable => components/Table}/Weekday.tsx (100%) rename src/pages/app/components/Limit/{LimitTable => components/Table}/index.tsx (92%) rename src/pages/app/components/Limit/{LimitTest.tsx => components/Test.tsx} (87%) create mode 100644 src/pages/app/components/Limit/components/index.tsx create mode 100644 src/pages/app/components/Limit/components/style.ts rename test-e2e/common/{whitelist.ts => whitelist.test.ts} (70%) diff --git a/package.json b/package.json index cd87a5fae..4c7f19ce9 100644 --- a/package.json +++ b/package.json @@ -34,12 +34,12 @@ "@rsdoctor/rspack-plugin": "^1.5.2", "@rspack/cli": "^1.7.6", "@rspack/core": "^1.7.6", - "@swc/core": "^1.15.13", + "@swc/core": "^1.15.17", "@swc/jest": "^0.2.39", "@types/chrome": "0.1.37", "@types/decompress": "^4.2.7", "@types/jest": "^30.0.0", - "@types/node": "^25.3.1", + "@types/node": "^25.3.2", "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.0.0", @@ -64,7 +64,7 @@ "dependencies": { "@element-plus/icons-vue": "^2.3.2", "echarts": "^6.0.0", - "element-plus": "2.13.2", + "element-plus": "2.13.3", "punycode": "^2.3.1", "typescript-guard": "^0.2.1", "vue": "^3.5.29", diff --git a/src/content-script/limit/modal/components/Alert.tsx b/src/content-script/limit/modal/components/Alert.tsx index b8d96977e..af0ce41d2 100644 --- a/src/content-script/limit/modal/components/Alert.tsx +++ b/src/content-script/limit/modal/components/Alert.tsx @@ -1,5 +1,6 @@ import { getUrl } from "@api/chrome/runtime" import { t } from "@cs/locale" +import { useXsState } from '@hooks/useMediaSize' import { useRequest } from "@hooks/useRequest" import Box from '@pages/components/Box' import Flex from '@pages/components/Flex' @@ -21,13 +22,19 @@ const _default = defineComponent(() => { return option?.limitPrompt || defaultPrompt }, { defaultValue: defaultPrompt }) + const isXs = useXsState() + return () => ( {t(msg => msg.meta.name)?.toUpperCase()} - + {prompt.value} diff --git a/src/content-script/limit/modal/components/Reason.tsx b/src/content-script/limit/modal/components/Reason.tsx index 78bbe160a..1dc2204ec 100644 --- a/src/content-script/limit/modal/components/Reason.tsx +++ b/src/content-script/limit/modal/components/Reason.tsx @@ -1,4 +1,5 @@ import { t } from "@cs/locale" +import { useXsState } from '@hooks/index' import { useRequest } from "@hooks/useRequest" import Flex from "@pages/components/Flex" import { matchCond, meetLimit, meetTimeLimit, period2Str } from "@util/limit" @@ -7,8 +8,13 @@ import { ElDescriptions, ElDescriptionsItem, ElTag } from 'element-plus' import { computed, defineComponent, type StyleValue } from "vue" import { useGlobalParam, useReason, useRule } from "../context" -const DESCRIPTIONS_STYLE: StyleValue = { - width: '400px', +const useDescriptions = () => { + const isXs = useXsState() + const style = computed(() => ({ + width: isXs.value ? '90vw' : '400px', + } satisfies StyleValue)) + const size = computed(() => isXs.value ? 'small' : undefined) + return { style, size } } const renderBaseItems = (rule: timer.limit.Rule | null, url: string) => <> @@ -35,12 +41,13 @@ const TimeDescriptions = defineComponent({ const rule = useRule() const reason = useReason() const { url } = useGlobalParam() + const { style, size } = useDescriptions() const timeLimited = computed(() => meetTimeLimit(props.time ?? 0, props.waste ?? 0, !!reason.value?.allowDelay, reason.value?.delayCount ?? 0)) const visitLimited = computed(() => meetLimit(props.count ?? 0, props.visit ?? 0)) return () => ( - + {renderBaseItems(rule.value, url)} @@ -90,6 +97,8 @@ const _default = defineComponent(() => { setInterval(refreshBrowsingTime, 1000) + const { style, size } = useDescriptions() + return () => ( { ruleLabel={t(msg => msg.limit.item.weekly)} dataLabel={t(msg => msg.calendar.range.thisWeek)} /> - + {renderBaseItems(rule.value, url)} msg.limit.item.visitTime)} labelAlign="right"> {formatPeriodCommon((rule.value?.visitTime ?? 0) * MILL_PER_SECOND) || '-'} @@ -124,7 +133,7 @@ const _default = defineComponent(() => { {reason.value?.delayCount ?? 0} - + {renderBaseItems(rule.value, url)} msg.limit.item.period)} labelAlign="right"> {rule.value?.periods?.length diff --git a/src/pages/app/Layout/menu/item.ts b/src/pages/app/Layout/menu/item.ts index c8f1c4b27..90c9f206f 100644 --- a/src/pages/app/Layout/menu/item.ts +++ b/src/pages/app/Layout/menu/item.ts @@ -76,7 +76,8 @@ export const menuGroups = (): MenuGroup[] => [{ }, { title: msg => msg.menu.limit, route: '/behavior/limit', - icon: Timer + icon: Timer, + mobile: true, }] }, { title: msg => msg.menu.additional, diff --git a/src/pages/app/components/Dashboard/index.tsx b/src/pages/app/components/Dashboard/index.tsx index 55673ab9f..c9d9d62b5 100644 --- a/src/pages/app/components/Dashboard/index.tsx +++ b/src/pages/app/components/Dashboard/index.tsx @@ -12,7 +12,7 @@ import Flex from "@pages/components/Flex" import { recommendRate, saveFlag } from "@service/meta-service" import { REVIEW_PAGE } from "@util/constant/url" import { ElRow, ElScrollbar } from "element-plus" -import { computed, defineComponent, FunctionalComponent } from "vue" +import { computed, defineComponent, type FunctionalComponent } from "vue" import { useRouter } from "vue-router" import ContentContainer from "../common/ContentContainer" import Calendar from "./components/Calendar" diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step3/TimeInput.tsx b/src/pages/app/components/Limit/LimitModify/Sop/Step3/TimeInput.tsx deleted file mode 100644 index fde155be6..000000000 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step3/TimeInput.tsx +++ /dev/null @@ -1,258 +0,0 @@ -import { CircleClose, Clock } from "@element-plus/icons-vue" -import { useDebounceFn, useState } from "@hooks" -import { getStyle } from "@pages/util/style" -import { range } from "@util/array" -import { Effect, ElIcon, ElInput, ElPopover, ElScrollbar, ScrollbarInstance, useLocale, useNamespace } from "element-plus" -import { computed, defineComponent, nextTick, onMounted, ref, Transition, watch } from "vue" - -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 formatTimeVal = (val: number): string => { - return val?.toString?.()?.padStart?.(2, '0') ?? 'NaN' -} - -const TimeSpinner = defineComponent({ - props: { - max: { - type: Number, - required: true, - }, - visible: Boolean, - modelValue: { - type: Number, - required: true, - }, - }, - emits: { - change: (_val: number) => true, - }, - setup(props, ctx) { - const ns = useNamespace('time') - const scrollbar = ref() - const scrolling = ref(false) - - const debounceChangeValue = useDebounceFn((val: number) => { - scrolling.value = false - ctx.emit('change', val) - }, 200) - - const getScrollbarElement = () => { - const el = scrollbar.value?.$el - return el?.querySelector(`.${ns.namespace.value}-scrollbar__wrap`) as HTMLElement - } - - const adjustSpinner = (value: number) => { - let scrollbarEl = getScrollbarElement() - if (!scrollbarEl) return - - scrollbarEl.scrollTop = Math.max(0, value * typeItemHeight()) - } - - watch(() => props.modelValue, () => adjustSpinner(props.modelValue)) - watch(() => props.visible, () => props.visible && nextTick(() => adjustSpinner(props.modelValue))) - - const typeItemHeight = (): number => { - const listItem = scrollbar.value?.$el.querySelector('li') as HTMLLinkElement - if (listItem) { - return Number.parseFloat(getStyle(listItem, 'height')) || 0 - } - return 0 - } - - const bindScroll = () => { - let scrollbarEl = getScrollbarElement() - if (!scrollbarEl) return - - scrollbarEl.addEventListener('scroll', () => { - scrolling.value = true - const scrollTop = getScrollbarElement()?.scrollTop ?? 0 - const scrollbarH = (scrollbar.value?.$el as HTMLUListElement)!.offsetHeight ?? 0 - const itemH = typeItemHeight() - const estimatedIdx = Math.round((scrollTop - (scrollbarH * 0.5 - 10) / itemH + 3) / itemH) - const value = Math.min(estimatedIdx, props.max - 1) - debounceChangeValue(value) - }, { passive: true }) - } - - onMounted(() => { - bindScroll() - adjustSpinner(props.modelValue) - }) - - return () => ( - - {range(props.max).map(idx => ( -

  • ctx.emit('change', idx)} - class={[ - ns.be('spinner', 'item'), - ns.is('active', idx === props.modelValue), - ]} - > - {idx.toString().padStart(2, '0')} -
  • - ))} - - ) - }, -}) - -const useTimeInput = (source: () => number) => { - const [initialHour, initialMin, initialSec] = computeSecond2LimitInfo(source?.() ?? 0) - const [hour, setHour] = useState(initialHour) - const [minute, setMinute] = useState(initialMin) - const [second, setSecond] = useState(initialSec) - - const reset = () => { - const [hour, min, sec] = computeSecond2LimitInfo(source?.() ?? 0) - setHour(hour) - setMinute(min) - setSecond(sec) - } - - watch(source, reset) - - const getTotalSecond = () => { - let time = 0 - time += (hour.value ?? 0) * 3600 - time += (minute.value ?? 0) * 60 - time += (second.value ?? 0) - return time - } - - return { - hour, minute, second, - setHour, setMinute, setSecond, - reset, getTotalSecond, - } -} - -/** - * Rewrite - * - * https://github.com/element-plus/element-plus/blob/dev/packages/components/time-picker/src/time-picker-com/panel-time-pick.vue - */ -const TimeInput = defineComponent({ - props: { - modelValue: { - type: Number, - required: true, - }, - hourMax: Number, - }, - emits: { - change: (_val: number) => true, - }, - setup(props, ctx) { - const [popoverVisible, setPopoverVisible] = useState(false) - const { - hour, minute, second, - setHour, setMinute, setSecond, - reset, getTotalSecond, - } = useTimeInput(() => props.modelValue) - - const inputText = computed(() => `${formatTimeVal(hour.value)} h ${formatTimeVal(minute.value)} m ${formatTimeVal(second.value)} s`) - - const ns = useNamespace('time') - const nsDate = useNamespace('date') - const nsInput = useNamespace('input') - - const { t: tEle } = useLocale() - - const transitionName = computed(() => popoverVisible.value ? '' : `${ns.namespace.value}-zoom-in-top`) - - const handleCancel = () => { - reset() - setPopoverVisible(false) - } - - const handleConfirm = () => { - ctx.emit('change', getTotalSecond()) - setPopoverVisible(false) - } - - const handleVisibleChange = (newVal: boolean) => { - setPopoverVisible(newVal) - !newVal && handleCancel() - } - - const handleClear = (ev: MouseEvent) => { - ctx.emit('change', 0) - ev.stopPropagation() - } - - return () => ( - ( - !!props.modelValue && ( -
    - - - -
    - ) - }} - /> - ) - }}> - -
    -
    -
    - - - -
    -
    -
    - - -
    -
    -
    -
    - ) - }, -}) - -export default TimeInput \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitTable/RuleContent.tsx b/src/pages/app/components/Limit/LimitTable/RuleContent.tsx deleted file mode 100644 index 0a969f2c2..000000000 --- a/src/pages/app/components/Limit/LimitTable/RuleContent.tsx +++ /dev/null @@ -1,78 +0,0 @@ -import { t } from "@app/locale" -import Flex from "@pages/components/Flex" -import { period2Str } from "@util/limit" -import { formatPeriod, MILL_PER_SECOND } from "@util/time" -import { ElTag } from "element-plus" -import { computed, defineComponent, toRef, type PropType } from "vue" - -const TIME_FORMAT = { - dayMsg: '{day}d{hour}h{minute}m{second}s', - hourMsg: '{hour}h{minute}m{second}s', - minuteMsg: '{minute}m{second}s', - secondMsg: '{second}s', -} - -const TimeCountTag = defineComponent({ - props: { - time: Number, - count: Number, - label: String, - }, - setup(props) { - const visible = computed(() => !!props.time || !!props.count) - const content = computed(() => { - const timeContent = props.time ? formatPeriod(props.time * MILL_PER_SECOND, TIME_FORMAT) : '' - const countContent = props.count ? `${props.count} ${t(msg => msg.limit.item.visits)}` : '' - return [timeContent, countContent].filter(str => !!str).join(` ${t(msg => msg.limit.item.or)} `) - }) - - return () => ( -
    - - {props.label}: {content.value} - -
    - ) - }, -}) - -const RuleContent = defineComponent({ - props: { - value: Object as PropType - }, - setup(props) { - const row = toRef(props, 'value') - - return () => ( - - msg.limit.item.daily)} - /> - msg.limit.item.weekly)} - /> - {!!row.value?.visitTime && ( -
    - - {t(msg => msg.limit.item.visitTime)}: {formatPeriod(row.value?.visitTime * MILL_PER_SECOND, TIME_FORMAT)} - -
    - )} - {!!row.value?.periods?.length && <> -
    - {t(msg => msg.limit.item.period)} -
    - - {row.value?.periods?.map(p => {period2Str(p)})} - - } -
    - ) - }, -}) - -export default RuleContent \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitTable/Waste.tsx b/src/pages/app/components/Limit/LimitTable/Waste.tsx deleted file mode 100644 index 0b1baa8f4..000000000 --- a/src/pages/app/components/Limit/LimitTable/Waste.tsx +++ /dev/null @@ -1,52 +0,0 @@ -import TooltipWrapper from "@app/components/common/TooltipWrapper" -import { t } from "@app/locale" -import Flex from "@pages/components/Flex" -import { meetLimit, meetTimeLimit } from "@util/limit" -import { formatPeriodCommon } from "@util/time" -import { ElTag } from "element-plus" -import { computed, defineComponent } from "vue" - -const Waste = defineComponent({ - props: { - time: Number, - waste: { - type: Number, - required: true, - }, - count: Number, - visit: Number, - delayCount: Number, - allowDelay: Boolean, - }, - setup(props) { - const timeType = computed(() => meetTimeLimit(props.time, props.waste, props.allowDelay, props.delayCount) ? 'danger' : 'info') - const visitType = computed(() => meetLimit(props.count, props.visit) ? 'danger' : 'info') - - return () => ( - -
    - `${t(msg => msg.limit.item.delayCount)}: ${props.delayCount ?? 0}`, - default: () => ( - - {formatPeriodCommon(props.waste)} - - ), - }} - /> -
    -
    - - {props.visit ?? 0} {t(msg => msg.limit.item.visits)} - -
    -
    - ) - }, -}) - -export default Waste \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitFilter.tsx b/src/pages/app/components/Limit/components/Filter.tsx similarity index 85% rename from src/pages/app/components/Limit/LimitFilter.tsx rename to src/pages/app/components/Limit/components/Filter.tsx index f70896544..0c2302094 100644 --- a/src/pages/app/components/Limit/LimitFilter.tsx +++ b/src/pages/app/components/Limit/components/Filter.tsx @@ -12,18 +12,19 @@ import SwitchFilterItem from "@app/components/common/filter/SwitchFilterItem" import { t } from "@app/locale" import { OPTION_ROUTE } from "@app/router/constants" import { Delete, Open, Operation, Plus, SetUp, TurnOff, WarningFilled } from "@element-plus/icons-vue" +import { useXsState } from '@hooks/useMediaSize' import Flex from "@pages/components/Flex" import { getAppPageUrl } from "@util/constant/url" import { ElIcon, ElText, ElTooltip } from 'element-plus' -import { defineComponent, ref, Ref, watch } from "vue" -import DropdownButton, { type DropdownButtonItem } from "../common/DropdownButton" -import { useLimitAction, useLimitBatch, useLimitFilter } from "./context" +import { computed, defineComponent, ref, Ref, watch } from "vue" +import DropdownButton, { type DropdownButtonItem } from "../../common/DropdownButton" +import { useLimitAction, useLimitBatch, useLimitFilter } from "../context" const optionPageUrl = getAppPageUrl(OPTION_ROUTE, { i: 'limit' }) type BatchOpt = 'delete' | 'enable' | 'disable' -const useCreateTip = (empty: Ref) => { +const useCreateTip = (empty: Ref, isXs: Ref) => { const tipVisible = ref(false) let initialized = false watch(empty, emptyVal => { @@ -33,12 +34,14 @@ const useCreateTip = (empty: Ref) => { setTimeout(closeTip, 10000) }) const closeTip = () => tipVisible.value = false - return { tipVisible, closeTip } + const finalVisible = computed(() => !isXs.value && tipVisible.value) + return { tipVisible: finalVisible, closeTip } } const _default = defineComponent(() => { const { create, test, empty } = useLimitAction() - const { tipVisible, closeTip } = useCreateTip(empty) + const isXs = useXsState() + const { tipVisible, closeTip } = useCreateTip(empty, isXs) const filter = useLimitFilter() const { batchDelete, batchDisable, batchEnable } = useLimitBatch() @@ -75,20 +78,22 @@ const _default = defineComponent(() => { onSearch={val => filter.url = val} /> msg.limit.filterDisabled)} defaultValue={filter.onlyEnabled} onChange={val => filter.onlyEnabled = val} />
    - - + + msg.limit.button.test} icon={Operation} onClick={test} /> msg.base.option} icon={SetUp} onClick={() => createTabAfterCurrent(optionPageUrl)} diff --git a/src/pages/app/components/Limit/components/List/Card.tsx b/src/pages/app/components/Limit/components/List/Card.tsx new file mode 100644 index 000000000..d09ec6e29 --- /dev/null +++ b/src/pages/app/components/Limit/components/List/Card.tsx @@ -0,0 +1,111 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +import { t } from "@app/locale" +import { Delete, EditPen } from "@element-plus/icons-vue" +import { css } from '@emotion/css' +import Flex from "@pages/components/Flex" +import { ElButton, ElCard, ElDivider, ElMessageBox, ElTag, type TagProps, useNamespace } from "element-plus" +import { defineComponent, type FunctionalComponent, type StyleValue } from "vue" +import { verifyCanModify } from "../../common" +import { useLimitAction, useLimitData } from "../../context" +import Rule from "./Rule" + +type Props = { + value: timer.limit.Item +} + +const CARD_PADDING = 10 + +const useStyle = () => { + const cardNs = useNamespace('card') + const cardCls = css` + .${cardNs.e('body')} { + padding: ${CARD_PADDING}px; + } + ` + return cardCls +} + +const ALL_WEEKDAYS = t(msg => msg.calendar.weekDays).split('|') + +const Divider: FunctionalComponent<{}> = () => { + const marginInline = `${-CARD_PADDING}px` + const width = `calc(100% + ${CARD_PADDING * 2}px)` + return +} + +const EffectiveDays: FunctionalComponent<{ weekdays?: number[] }> = ({ weekdays = [] }) => { + const weekdayNum = weekdays?.length + let text: string = '' + let type: TagProps['type'] | undefined = undefined + if (!weekdayNum || weekdayNum === 7) { + text = t(msg => msg.calendar.range.everyday) + type = 'success' + } else if (weekdayNum === 1) { + text = ALL_WEEKDAYS[weekdays[0]] + } else { + const firstDay = ALL_WEEKDAYS[weekdays[0]] + text = `${firstDay}...${weekdayNum}` + } + + return {text} +} + +const _default = defineComponent(props => { + const { deleteRow } = useLimitData() + const { modify } = useLimitAction() + + const handleModify = () => verifyCanModify(props.value) + .then(() => modify(props.value)) + .catch(() => {/** Do nothing */ }) + + const handleDelete = () => verifyCanModify(props.value) + .then(() => ElMessageBox.confirm(t(msg => msg.limit.message.deleteConfirm, { name: props.value.name }))) + .then(() => deleteRow(props.value)) + .catch(() => {/** Do nothing */ }) + + const clz = useStyle() + + return () => ( + + + + + + {props.value.name ?? 'Unnamed'} + + + + + + + {/* Sites */} + + {props.value.cond.map((c, idx) => {c})} + + + {/** Content */} + + + {/* Footer Button */} + + + {t(msg => msg.button.modify)} + + + + + ) +}, { props: ['value'] }) + +export default _default diff --git a/src/pages/app/components/Limit/components/List/Rule.tsx b/src/pages/app/components/Limit/components/List/Rule.tsx new file mode 100644 index 000000000..b53664d77 --- /dev/null +++ b/src/pages/app/components/Limit/components/List/Rule.tsx @@ -0,0 +1,115 @@ +import { t } from '@app/locale' +import Flex from '@pages/components/Flex' +import { isEffective, meetLimit, meetTimeLimit, period2Str } from '@util/limit' +import { formatPeriodCommon, MILL_PER_SECOND } from '@util/time' +import { ElDescriptions, ElDescriptionsItem, ElTag } from 'element-plus' +import { computed, defineComponent, type FunctionalComponent, toRefs } from 'vue' +import { DAILY_WEEKLY_TAG_TYPE, PERIOD_TAG_TYPE, VISIT_TAG_TYPE } from '../style' + +type Props = { + value: timer.limit.Item +} + +const TimeCountPair: FunctionalComponent<{ time?: number, count?: number }> = ({ time, count }) => { + if (!time && !count) return null + return ( + + {!!time && ( + {formatPeriodCommon(time * MILL_PER_SECOND, true)} + )} + {!!count && ( + {`${count} ${t(msg => msg.limit.item.visits)}`} + )} + + ) +} + +type WastePairProps = { + time?: number + waste: number + count?: number + visit?: number + delayCount?: number + allowDelay?: boolean +} + +const WastePair: FunctionalComponent = props => { + const timeType = computed(() => meetTimeLimit(props.time, props.waste, props.allowDelay, props.delayCount) ? 'danger' : 'info') + const visitType = computed(() => meetLimit(props.count, props.visit) ? 'danger' : 'info') + + return ( + + + {formatPeriodCommon(props.waste)} + + + {props.visit ?? 0} {t(msg => msg.limit.item.visits)} + + + ) +} + +const Rule = defineComponent(({ value }) => { + const { + time, count, waste, visit, + weekly, weeklyCount, weeklyWaste, weeklyVisit, + visitTime, periods, + weekdays, + allowDelay, delayCount, weeklyDelayCount, + } = toRefs(value) + + return () => <> + + msg.limit.item.daily)} v-show={time?.value || count?.value}> + + + msg.limit.item.weekly)} v-show={weekly?.value || weeklyCount?.value}> + + + {!!visitTime?.value && ( + msg.limit.item.visitTime)}> + {formatPeriodCommon(visitTime.value * MILL_PER_SECOND, true)} + + )} + {!!periods?.value?.length && ( + msg.limit.item.period)}> + + {periods.value.map((p, idx) => ( + {period2Str(p)} + ))} + + + )} + + + msg.calendar.range.today)}> + {isEffective(weekdays?.value) ? ( + + ) : ( + + {t(msg => msg.limit.item.notEffective)} + + )} + + msg.calendar.range.thisWeek)}> + + + + +}, { props: ['value'] }) + +export default Rule \ No newline at end of file diff --git a/src/pages/app/components/Limit/components/List/index.tsx b/src/pages/app/components/Limit/components/List/index.tsx new file mode 100644 index 000000000..5dd519205 --- /dev/null +++ b/src/pages/app/components/Limit/components/List/index.tsx @@ -0,0 +1,28 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +import Flex from '@pages/components/Flex' +import { ElScrollbar } from 'element-plus' +import { defineComponent } from "vue" +import { useLimitData } from "../../context" +import Card from "./Card" + +const _default = defineComponent(() => { + const { list } = useLimitData() + + return () => ( + + + {list.value.map(row => )} + + + ) +}) + +export default _default diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step1.tsx b/src/pages/app/components/Limit/components/Modify/Sop/Step1.tsx similarity index 90% rename from src/pages/app/components/Limit/LimitModify/Sop/Step1.tsx rename to src/pages/app/components/Limit/components/Modify/Sop/Step1.tsx index c27a9713e..e9f4d1de2 100644 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step1.tsx +++ b/src/pages/app/components/Limit/components/Modify/Sop/Step1.tsx @@ -1,15 +1,17 @@ import { t } from "@app/locale" +import { useXsState } from '@hooks/useMediaSize' import { ElCol, ElForm, ElFormItem, ElInput, ElRow, ElSelect, ElSwitch } from "element-plus" import { defineComponent } from "vue" import { useSopData } from "./context" const _default = defineComponent(() => { const data = useSopData() + const isXs = useXsState() return () => ( - + msg.limit.item.name)} required> data.name = val} @@ -17,7 +19,7 @@ const _default = defineComponent(() => { /> - + msg.limit.item.enabled)} required> data.enabled = !!v} /> diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step2/SiteInput.tsx b/src/pages/app/components/Limit/components/Modify/Sop/Step2/SiteInput.tsx similarity index 100% rename from src/pages/app/components/Limit/LimitModify/Sop/Step2/SiteInput.tsx rename to src/pages/app/components/Limit/components/Modify/Sop/Step2/SiteInput.tsx diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step2/index.tsx b/src/pages/app/components/Limit/components/Modify/Sop/Step2/index.tsx similarity index 100% rename from src/pages/app/components/Limit/LimitModify/Sop/Step2/index.tsx rename to src/pages/app/components/Limit/components/Modify/Sop/Step2/index.tsx diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step3/PeriodInput.tsx b/src/pages/app/components/Limit/components/Modify/Sop/Step3/PeriodInput.tsx similarity index 82% rename from src/pages/app/components/Limit/LimitModify/Sop/Step3/PeriodInput.tsx rename to src/pages/app/components/Limit/components/Modify/Sop/Step3/PeriodInput.tsx index 6c90e74df..59f1fcb3c 100644 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step3/PeriodInput.tsx +++ b/src/pages/app/components/Limit/components/Modify/Sop/Step3/PeriodInput.tsx @@ -8,7 +8,7 @@ import { t } from "@app/locale" import { Check, Close, Plus } from "@element-plus/icons-vue" import { css } from '@emotion/css' -import { useState, useSwitch } from "@hooks" +import { useState, useSwitch, useXsState } from "@hooks" import Flex from "@pages/components/Flex" import { dateMinute2Idx, period2Str } from "@util/limit" import { MILL_PER_HOUR } from "@util/time" @@ -95,6 +95,7 @@ const usePickerStyle = () => { const PeriodInput = defineComponent>(props => { const [editing, openEditing, closeEditing] = useSwitch(false) const [editingRange, setEditingRange] = useState(rangeInitial()) + const isXs = useXsState() const handleEdit = () => { openEditing() @@ -118,17 +119,19 @@ const PeriodInput = defineComponent>(props => { const pickerCls = usePickerStyle() return () => ( - - {props.modelValue?.map((p, idx) => - handleDelete(idx)} - > - {period2Str(p)} - - )} -
    + + + {props.modelValue?.map((p, idx) => + handleDelete(idx)} + > + {period2Str(p)} + + )} + + >(props => { onClick={handleSave} style={{ ...BUTTON_STYLE, marginInlineStart: 0 } satisfies StyleValue} /> + +
    + + {t(msg => msg.button.create)} +
    - - {t(msg => msg.button.create)} -
    ) }, { props: ['modelValue', 'onChange'] }) diff --git a/src/pages/app/components/Limit/components/Modify/Sop/Step3/TimeInput.tsx b/src/pages/app/components/Limit/components/Modify/Sop/Step3/TimeInput.tsx new file mode 100644 index 000000000..a04b4dc92 --- /dev/null +++ b/src/pages/app/components/Limit/components/Modify/Sop/Step3/TimeInput.tsx @@ -0,0 +1,251 @@ +import { CircleClose, Clock } from "@element-plus/icons-vue" +import { useDebounceFn, useState, useXsState } from "@hooks" +import { getStyle } from "@pages/util/style" +import { range } from "@util/array" +import { + Effect, ElIcon, ElInput, ElPopover, ElScrollbar, + type ScrollbarInstance, + useLocale, useNamespace +} from "element-plus" +import { computed, defineComponent, nextTick, onMounted, ref, Transition, watch } from "vue" + +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 formatTimeVal = (val: number): string => { + return val?.toString?.()?.padStart?.(2, '0') ?? 'NaN' +} + +type TimeSpinnerProps = { + max: number + modelValue: number + visible: boolean + onChange?: (val: number) => void +} + +const TimeSpinner = defineComponent(props => { + const ns = useNamespace('time') + const scrollbar = ref() + const scrolling = ref(false) + + const debounceChangeValue = useDebounceFn((val: number) => { + scrolling.value = false + props.onChange?.(val) + }, 200) + + const getScrollbarElement = () => { + const el = scrollbar.value?.$el + return el?.querySelector(`.${ns.namespace.value}-scrollbar__wrap`) as HTMLElement + } + + const adjustSpinner = (value: number) => { + let scrollbarEl = getScrollbarElement() + if (!scrollbarEl) return + + scrollbarEl.scrollTop = Math.max(0, value * typeItemHeight()) + } + + watch(() => props.modelValue, () => adjustSpinner(props.modelValue)) + watch(() => props.visible, () => props.visible && nextTick(() => adjustSpinner(props.modelValue))) + + const typeItemHeight = (): number => { + const listItem = scrollbar.value?.$el.querySelector('li') as HTMLLinkElement + if (listItem) { + return Number.parseFloat(getStyle(listItem, 'height')) || 0 + } + return 0 + } + + const bindScroll = () => { + let scrollbarEl = getScrollbarElement() + if (!scrollbarEl) return + + scrollbarEl.addEventListener('scroll', () => { + scrolling.value = true + const scrollTop = getScrollbarElement()?.scrollTop ?? 0 + const scrollbarH = (scrollbar.value?.$el as HTMLUListElement)!.offsetHeight ?? 0 + const itemH = typeItemHeight() + const estimatedIdx = Math.round((scrollTop - (scrollbarH * 0.5 - 10) / itemH + 3) / itemH) + const value = Math.min(estimatedIdx, props.max - 1) + debounceChangeValue(value) + }, { passive: true }) + } + + onMounted(() => { + bindScroll() + adjustSpinner(props.modelValue) + }) + + return () => ( + + {range(props.max).map(idx => ( +
  • props.onChange?.(idx)} + class={[ + ns.be('spinner', 'item'), + ns.is('active', idx === props.modelValue), + ]} + > + {idx.toString().padStart(2, '0')} +
  • + ))} +
    + ) +}, { props: ['max', 'modelValue', 'visible', 'onChange'] }) + +const useTimeInput = (source: () => number) => { + const [initialHour, initialMin, initialSec] = computeSecond2LimitInfo(source?.() ?? 0) + const [hour, setHour] = useState(initialHour) + const [minute, setMinute] = useState(initialMin) + const [second, setSecond] = useState(initialSec) + + const reset = () => { + const [hour, min, sec] = computeSecond2LimitInfo(source?.() ?? 0) + setHour(hour) + setMinute(min) + setSecond(sec) + } + + watch(source, reset) + + const getTotalSecond = () => { + let time = 0 + time += (hour.value ?? 0) * 3600 + time += (minute.value ?? 0) * 60 + time += (second.value ?? 0) + return time + } + + return { + hour, minute, second, + setHour, setMinute, setSecond, + reset, getTotalSecond, + } +} + +type TimeInputProps = { + modelValue: number + hourMax?: number + onChange?: (val: number) => void +} + +/** + * Rewrite + * + * https://github.com/element-plus/element-plus/blob/dev/packages/components/time-picker/src/time-picker-com/panel-time-pick.vue + */ +const TimeInput = defineComponent(props => { + const [popoverVisible, setPopoverVisible] = useState(false) + const { + hour, minute, second, + setHour, setMinute, setSecond, + reset, getTotalSecond, + } = useTimeInput(() => props.modelValue) + + const inputText = computed(() => `${formatTimeVal(hour.value)} h ${formatTimeVal(minute.value)} m ${formatTimeVal(second.value)} s`) + + const ns = useNamespace('time') + const nsDate = useNamespace('date') + const nsInput = useNamespace('input') + + const { t: tEle } = useLocale() + + const transitionName = computed(() => popoverVisible.value ? '' : `${ns.namespace.value}-zoom-in-top`) + + const handleCancel = () => { + reset() + setPopoverVisible(false) + } + + const handleConfirm = () => { + props.onChange?.(getTotalSecond()) + setPopoverVisible(false) + } + + const handleVisibleChange = (newVal: boolean) => { + setPopoverVisible(newVal) + !newVal && handleCancel() + } + + const handleClear = (ev: MouseEvent) => { + props.onChange?.(0) + ev.stopPropagation() + } + + const isXs = useXsState() + + return () => ( + ( + !!props.modelValue && ( +
    + + + +
    + ) + }} + /> + ) + }}> + +
    +
    +
    + + + +
    +
    +
    + + +
    +
    +
    +
    + ) +}, { props: ['hourMax', 'modelValue', 'onChange'] }) + +export default TimeInput \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitModify/Sop/Step3/index.tsx b/src/pages/app/components/Limit/components/Modify/Sop/Step3/index.tsx similarity index 80% rename from src/pages/app/components/Limit/LimitModify/Sop/Step3/index.tsx rename to src/pages/app/components/Limit/components/Modify/Sop/Step3/index.tsx index 25acb2b8f..6c815967c 100644 --- a/src/pages/app/components/Limit/LimitModify/Sop/Step3/index.tsx +++ b/src/pages/app/components/Limit/components/Modify/Sop/Step3/index.tsx @@ -6,6 +6,7 @@ */ import { t } from "@app/locale" +import { useXsState } from '@hooks/useMediaSize' import Flex from "@pages/components/Flex" import { ElForm, ElFormItem, ElInputNumber } from "element-plus" import { defineComponent } from "vue" @@ -17,32 +18,35 @@ const MAX_HOUR_WEEKLY = 7 * 24 const _default = defineComponent(() => { const data = useSopData() + const isXs = useXsState() return () => ( - + msg.limit.item.daily)}> - + data.time = v} /> - {t(msg => msg.limit.item.or)} + {!isXs.value && t(msg => msg.limit.item.or)} data.count = v ?? 0} + size={isXs.value ? 'small' : undefined} v-slots={{ suffix: () => t(msg => msg.limit.item.visits) }} /> msg.limit.item.weekly)}> - + data.weekly = v} hourMax={MAX_HOUR_WEEKLY} /> - {t(msg => msg.limit.item.or)} + {!isXs.value && t(msg => msg.limit.item.or)} data.weeklyCount = v ?? 0} + size={isXs.value ? 'small' : undefined} v-slots={{ suffix: () => t(msg => msg.limit.item.visits) }} /> diff --git a/src/pages/app/components/Limit/LimitModify/Sop/context.ts b/src/pages/app/components/Limit/components/Modify/Sop/context.ts similarity index 98% rename from src/pages/app/components/Limit/LimitModify/Sop/context.ts rename to src/pages/app/components/Limit/components/Modify/Sop/context.ts index 51eb12b71..d2ea4273c 100644 --- a/src/pages/app/components/Limit/LimitModify/Sop/context.ts +++ b/src/pages/app/components/Limit/components/Modify/Sop/context.ts @@ -4,7 +4,7 @@ import { range } from "@util/array" import { ElMessage } from "element-plus" import { type Reactive, reactive, type Ref, ref, toRaw } from "vue" -type Step = 0 | 1 | 2 +export type Step = 0 | 1 | 2 type SopData = Required> diff --git a/src/pages/app/components/Limit/LimitModify/Sop/index.tsx b/src/pages/app/components/Limit/components/Modify/Sop/index.tsx similarity index 54% rename from src/pages/app/components/Limit/LimitModify/Sop/index.tsx rename to src/pages/app/components/Limit/components/Modify/Sop/index.tsx index 6dbd7b5a9..a3d5a11f3 100644 --- a/src/pages/app/components/Limit/LimitModify/Sop/index.tsx +++ b/src/pages/app/components/Limit/components/Modify/Sop/index.tsx @@ -7,9 +7,11 @@ import DialogSop from "@app/components/common/DialogSop" import { t } from "@app/locale" -import { ElStep, ElSteps } from "element-plus" -import { computed, defineComponent } from "vue" -import { initSop } from "./context" +import { useXsState } from '@hooks' +import Flex from '@pages/components/Flex' +import { ElDivider, ElStep, ElSteps, ElText } from "element-plus" +import { computed, defineComponent, StyleValue } from "vue" +import { initSop, type Step } from "./context" import Step1 from "./Step1" import Step2 from "./Step2" import Step3 from "./Step3" @@ -26,12 +28,20 @@ type Props = { onSave?: ArgCallback> } +const STEP_TITLE: Record = { + 0: t(msg => msg.limit.step.base), + 1: t(msg => msg.limit.step.url), + 2: t(msg => msg.limit.step.rule), +} + const _default = defineComponent(({ onSave, onCancel }, ctx) => { const { reset, step, handleNext } = initSop({ onSave }) const last = computed(() => step.value === 2) const first = computed(() => step.value === 0) ctx.expose({ reset } satisfies SopInstance) + const isXs = useXsState() + return () => ( (({ onSave, onCancel }, ctx) => { onNext={handleNext} onFinish={handleNext} v-slots={{ - steps: () => ( - - msg.limit.step.base)} /> - msg.limit.step.url)} /> - msg.limit.step.rule)} /> - - ), + steps: () => isXs.value + ? ( + + {STEP_TITLE[step.value]} + + + ) + : ( + + + + + + ), content: () => <> diff --git a/src/pages/app/components/Limit/LimitModify/index.tsx b/src/pages/app/components/Limit/components/Modify/index.tsx similarity index 91% rename from src/pages/app/components/Limit/LimitModify/index.tsx rename to src/pages/app/components/Limit/components/Modify/index.tsx index 2abe7dbe6..1a01ad798 100644 --- a/src/pages/app/components/Limit/LimitModify/index.tsx +++ b/src/pages/app/components/Limit/components/Modify/index.tsx @@ -6,17 +6,17 @@ */ import { t } from "@app/locale" -import { useSwitch } from "@hooks" +import { useSwitch, useXsState } from "@hooks" import limitService from "@service/limit-service" import { ElDialog, ElMessage } from "element-plus" import { computed, defineComponent, nextTick, ref, toRaw } from "vue" -import { type ModifyInstance, useLimitTable } from "../context" +import { type ModifyInstance, useLimitData } from "../../context" import Sop, { type SopInstance } from "./Sop" type Mode = "create" | "modify" const _default = defineComponent((_, ctx) => { - const { refresh } = useLimitTable() + const { refresh } = useLimitData() const [visible, open, close] = useSwitch() const sop = ref() const mode = ref() @@ -68,12 +68,15 @@ const _default = defineComponent((_, ctx) => { }, } satisfies ModifyInstance) + const isXs = useXsState() + return () => ( diff --git a/src/pages/app/components/Limit/LimitTable/column/LimitOperationColumn.tsx b/src/pages/app/components/Limit/components/Table/OperationColumn.tsx similarity index 93% rename from src/pages/app/components/Limit/LimitTable/column/LimitOperationColumn.tsx rename to src/pages/app/components/Limit/components/Table/OperationColumn.tsx index 3d6e7f08f..9d531f128 100644 --- a/src/pages/app/components/Limit/LimitTable/column/LimitOperationColumn.tsx +++ b/src/pages/app/components/Limit/components/Table/OperationColumn.tsx @@ -10,7 +10,7 @@ import { locale } from "@i18n" import { ElButton, ElTableColumn, type RenderRowData } from "element-plus" import { defineComponent } from "vue" import { verifyCanModify } from "../../common" -import { useLimitAction, useLimitTable } from "../../context" +import { useLimitAction, useLimitData } from "../../context" const LOCALE_WIDTH: { [locale in timer.Locale]: number } = { en: 220, @@ -29,7 +29,7 @@ const LOCALE_WIDTH: { [locale in timer.Locale]: number } = { } const _default = defineComponent<{}>(() => { - const { deleteRow } = useLimitTable() + const { deleteRow } = useLimitData() const { modify } = useLimitAction() const handleModify = (row: timer.limit.Item) => verifyCanModify(row) diff --git a/src/pages/app/components/Limit/components/Table/Rule.tsx b/src/pages/app/components/Limit/components/Table/Rule.tsx new file mode 100644 index 000000000..6ed4ce8ee --- /dev/null +++ b/src/pages/app/components/Limit/components/Table/Rule.tsx @@ -0,0 +1,68 @@ +import { t } from "@app/locale" +import Flex from "@pages/components/Flex" +import { period2Str } from '@util/limit' +import { formatPeriodCommon, MILL_PER_SECOND } from '@util/time' +import { ElTag, TagProps } from 'element-plus' +import { defineComponent, type FunctionalComponent, toRef } from "vue" +import { DAILY_WEEKLY_TAG_TYPE, VISIT_TAG_TYPE } from '../style' + +type TimeCountPairProps = { + time?: number + count?: number + label: string + type?: TagProps['type'] +} + +const TimeCountPair: FunctionalComponent = ({ time, count, label, type = DAILY_WEEKLY_TAG_TYPE }) => { + if (!time && !count) return null + + const timeContent = time ? formatPeriodCommon(time * MILL_PER_SECOND, true) : null + const countContent = count ? `${count} ${t(msg => msg.limit.item.visits)}` : null + const content = [timeContent, countContent].filter(str => !!str).join(` ${t(msg => msg.limit.item.or)} `) + + return ( +
    + {label}: {content} +
    + ) +} + +const PeriodTag: FunctionalComponent<{ periods?: timer.limit.Period[], }> = ({ periods }) => { + if (!periods?.length) return null + + return <> +
    + {t(msg => msg.limit.item.period)} +
    + + {periods.map((p, idx) => {period2Str(p)})} + + +} + +const Rule = defineComponent<{ value: timer.limit.Item }>(props => { + const row = toRef(props, 'value') + + return () => ( + + msg.limit.item.daily)} + /> + msg.limit.item.weekly)} + /> + msg.limit.item.visitTime)} + type={VISIT_TAG_TYPE} + /> + + + ) +}, { props: ['value'] }) + +export default Rule \ No newline at end of file diff --git a/src/pages/app/components/Limit/components/Table/Waste.tsx b/src/pages/app/components/Limit/components/Table/Waste.tsx new file mode 100644 index 000000000..226a05ac2 --- /dev/null +++ b/src/pages/app/components/Limit/components/Table/Waste.tsx @@ -0,0 +1,48 @@ +import TooltipWrapper from "@app/components/common/TooltipWrapper" +import { t } from "@app/locale" +import Flex from "@pages/components/Flex" +import { meetLimit, meetTimeLimit } from "@util/limit" +import { formatPeriodCommon } from "@util/time" +import { ElTag } from "element-plus" +import { computed, defineComponent } from "vue" + +type Props = { + time?: number + waste: number + count?: number + visit?: number + delayCount?: number + allowDelay?: boolean +} + +const Waste = defineComponent(props => { + const timeType = computed(() => meetTimeLimit(props.time, props.waste, props.allowDelay, props.delayCount) ? 'danger' : 'info') + const visitType = computed(() => meetLimit(props.count, props.visit) ? 'danger' : 'info') + + return () => ( + +
    + `${t(msg => msg.limit.item.delayCount)}: ${props.delayCount ?? 0}`, + default: () => ( + + {formatPeriodCommon(props.waste)} + + ), + }} + /> +
    +
    + + {props.visit ?? 0} {t(msg => msg.limit.item.visits)} + +
    +
    + ) +}, { props: ['time', 'waste', 'count', 'visit', 'allowDelay', 'delayCount'] }) + +export default Waste \ No newline at end of file diff --git a/src/pages/app/components/Limit/LimitTable/Weekday.tsx b/src/pages/app/components/Limit/components/Table/Weekday.tsx similarity index 100% rename from src/pages/app/components/Limit/LimitTable/Weekday.tsx rename to src/pages/app/components/Limit/components/Table/Weekday.tsx diff --git a/src/pages/app/components/Limit/LimitTable/index.tsx b/src/pages/app/components/Limit/components/Table/index.tsx similarity index 92% rename from src/pages/app/components/Limit/LimitTable/index.tsx rename to src/pages/app/components/Limit/components/Table/index.tsx index ea7c6401a..18f806431 100644 --- a/src/pages/app/components/Limit/LimitTable/index.tsx +++ b/src/pages/app/components/Limit/components/Table/index.tsx @@ -9,11 +9,11 @@ import { t } from "@app/locale" import { useLocalStorage, useRequest, useState } from "@hooks" import weekHelper from "@service/components/week-helper" import { isEffective } from "@util/limit" -import { ElSwitch, ElTable, ElTableColumn, ElTag, type RenderRowData, type Sort } from "element-plus" -import { defineComponent, watch } from "vue" -import { useLimitTable } from "../context" -import LimitOperationColumn from "./column/LimitOperationColumn" -import RuleContent from "./RuleContent" +import { ElSwitch, ElTable, ElTableColumn, ElTag, type RenderRowData, type Sort, type TableInstance } from "element-plus" +import { defineComponent, ref, watch } from "vue" +import { useLimitData, type LimitInstance } from "../../context" +import LimitOperationColumn from "./OperationColumn" +import Rule from "./Rule" import Waste from "./Waste" import Weekday from "./Weekday" @@ -27,17 +27,14 @@ const sortMethodByNumVal = (key: keyof timer.limit.Item & 'waste' | 'weeklyWaste const sortByEffectiveDays = ({ weekdays: a }: timer.limit.Item, { weekdays: b }: timer.limit.Item) => (a?.length ?? 0) - (b?.length ?? 0) -const _default = defineComponent(() => { +const _default = defineComponent((_, ctx) => { const { data: weekStartName } = useRequest(async () => { const offset = await weekHelper.getRealWeekStart() const name = t(msg => msg.calendar.weekDays)?.split('|')?.[offset] return name || 'NaN' }) - const { - list, table, - changeEnabled, changeDelay, changeLocked - } = useLimitTable() + const { list, changeEnabled, changeDelay, changeLocked } = useLimitData() const [cachedSort, setCachedSort] = useLocalStorage( '__limit_sort_default__', { prop: DEFAULT_SORT_COL, order: 'descending' } @@ -46,6 +43,12 @@ const _default = defineComponent(() => { const [sort, setSort] = useState(cachedSort) watch(sort, () => setCachedSort(sort.value)) + const table = ref() + + ctx.expose({ + getSelected: () => table.value?.getSelectionRows?.() ?? [] + } satisfies LimitInstance) + return () => ( { minWidth={200} align="center" > - {({ row }: RenderRowData) => } + {({ row }: RenderRowData) => } { if (!url) { @@ -35,6 +35,7 @@ const _default = defineComponent((_props, ctx) => { const debouncedUrl = useDebounce(url) const [visible, open, close] = useSwitch() const { data: result } = useRequest(() => fetchResult(debouncedUrl.value), { deps: debouncedUrl }) + const isXs = useXsState() ctx.expose({ show: () => { @@ -49,6 +50,7 @@ const _default = defineComponent((_props, ctx) => { modelValue={visible.value} closeOnClickModal={false} onClose={close} + fullscreen={isXs.value} > filter: Reactive - list: Ref, refresh: NoArgCallback, + list: Ref + refresh: NoArgCallback deleteRow: ArgCallback batchDelete: NoArgCallback batchEnable: NoArgCallback @@ -69,10 +73,10 @@ export const useLimitProvider = () => { } }) - const table = ref() + const inst = ref() const selectedAndThen = (then: (list: timer.limit.Item[]) => void): void => { - const list = table.value?.getSelectionRows?.() + const list = inst.value?.getSelected?.() if (!list?.length) { ElMessage.info('No limit rule selected') return @@ -85,10 +89,14 @@ export const useLimitProvider = () => { refresh() } - const handleBatchDelete = (list: timer.limit.Item[]) => verifyCanModify(...list) - .then(() => limitService.remove(...list)) - .then(onBatchSuccess) - .catch(() => { }) + const handleBatchDelete = (list: timer.limit.Item[]) => { + const names = list.map(item => item.name ?? item.id).join(', ') + verifyCanModify(...list) + .then(() => ElMessageBox.confirm(t(msg => msg.limit.message.deleteConfirm, { name: names }), { type: "warning" })) + .then(() => limitService.remove(...list)) + .then(onBatchSuccess) + .catch(() => { }) + } const handleBatchEnable = (list: timer.limit.Item[]) => { list.forEach(item => item.enabled = true) @@ -149,7 +157,6 @@ export const useLimitProvider = () => { const empty = computed(() => !loading.value && !list.value.length) useProvide(NAMESPACE, { - table, filter, list, empty, refresh, deleteRow, @@ -160,13 +167,13 @@ export const useLimitProvider = () => { modify, create, test, }) - return { modifyInst, testInst } + return { modifyInst, testInst, inst } } export const useLimitFilter = (): Reactive => useProvider(NAMESPACE, "filter").filter -export const useLimitTable = () => useProvider( - NAMESPACE, 'list', 'table', 'refresh', 'deleteRow', 'changeEnabled', 'changeDelay', 'changeLocked' +export const useLimitData = () => useProvider( + NAMESPACE, 'list', 'refresh', 'deleteRow', 'changeEnabled', 'changeDelay', 'changeLocked' ) export const useLimitBatch = () => useProvider( diff --git a/src/pages/app/components/Limit/index.tsx b/src/pages/app/components/Limit/index.tsx index 77877f850..611ca303d 100644 --- a/src/pages/app/components/Limit/index.tsx +++ b/src/pages/app/components/Limit/index.tsx @@ -5,24 +5,24 @@ * https://opensource.org/licenses/MIT */ +import { useXsState } from '@hooks' import { defineComponent } from "vue" +import ContentCard from '../common/ContentCard' import ContentContainer from "../common/ContentContainer" +import { Filter, List, Modify, Table, Test } from "./components" import { useLimitProvider } from "./context" -import LimitFilter from "./LimitFilter" -import LimitModify from "./LimitModify" -import LimitTable from "./LimitTable" -import LimitTest from "./LimitTest" const _default = defineComponent(() => { - const { modifyInst, testInst } = useLimitProvider() + const { modifyInst, testInst, inst } = useLimitProvider() + const isXs = useXsState() return () => ( , - content: () => <> - - - + filter: () => , + default: () => <> + {isXs.value ? : } + + }} /> ) diff --git a/src/pages/app/components/Option/Select.tsx b/src/pages/app/components/Option/Select.tsx index 77f880af5..d1c178712 100644 --- a/src/pages/app/components/Option/Select.tsx +++ b/src/pages/app/components/Option/Select.tsx @@ -5,8 +5,6 @@ import { useRouter } from "vue-router" import ContentContainer from "../common/ContentContainer" import { CATE_LABELS, changeQuery, type OptionCategory, parseQuery } from "./common" -const IGNORED_CATE: OptionCategory[] = ['limit'] - const _default = defineComponent(() => { const tab = ref(parseQuery() || 'appearance') const router = useRouter() @@ -21,12 +19,9 @@ const _default = defineComponent(() => { modelValue={tab.value} onChange={val => tab.value = val} > - {Object.keys(slots) - .filter(key => !IGNORED_CATE.includes(key as OptionCategory) && key !== 'default') - .map(cate => ( - - )) - } + {Object.keys(slots).map(cate => ( + + ))} ), default: () => { diff --git a/src/pages/app/components/Option/components/LimitOption/index.tsx b/src/pages/app/components/Option/components/LimitOption/index.tsx index b414d8d7d..aebf7517f 100644 --- a/src/pages/app/components/Option/components/LimitOption/index.tsx +++ b/src/pages/app/components/Option/components/LimitOption/index.tsx @@ -126,7 +126,7 @@ const _default = defineComponent((_, ctx) => { default: () => ( option.limitReminder = val as boolean} + onChange={val => option.limitReminder = !!val} /> ), minInput: () => ( diff --git a/src/pages/app/components/common/TooltipWrapper.tsx b/src/pages/app/components/common/TooltipWrapper.tsx index 3d502c141..e4f15b184 100644 --- a/src/pages/app/components/common/TooltipWrapper.tsx +++ b/src/pages/app/components/common/TooltipWrapper.tsx @@ -1,7 +1,7 @@ -import { ElTooltip, ElTooltipProps } from "element-plus" +import { ElTooltip, type UseTooltipProps } from "element-plus" import { defineComponent, ref, useSlots } from "vue" -type Props = PartialPick & { +type Props = PartialPick & { usePopover?: boolean } diff --git a/src/pages/app/components/common/filter/ButtonFilterItem.tsx b/src/pages/app/components/common/filter/ButtonFilterItem.tsx index 4e173b2e8..9eda1817b 100644 --- a/src/pages/app/components/common/filter/ButtonFilterItem.tsx +++ b/src/pages/app/components/common/filter/ButtonFilterItem.tsx @@ -5,6 +5,7 @@ * https://opensource.org/licenses/MIT */ import { type I18nKey, t } from '@app/locale' +import { useXsState } from '@hooks/useMediaSize' import { type ButtonProps, ElButton } from "element-plus" import { defineComponent } from "vue" @@ -16,11 +17,21 @@ type Props = { } const ButtonFilterItem = defineComponent(props => { - return () => ( - - {t(props.text)} - - ) + const isXs = useXsState() + return () => isXs.value + ? ( + + ) : ( + + {t(props.text)} + + ) }, { props: ['icon', 'onClick', 'text', 'type'] }) export default ButtonFilterItem \ No newline at end of file diff --git a/src/pages/components/Flex.tsx b/src/pages/components/Flex.tsx index f611489a4..711c256fe 100644 --- a/src/pages/components/Flex.tsx +++ b/src/pages/components/Flex.tsx @@ -23,32 +23,34 @@ type Props = { } & BaseProps const Flex = defineComponent(props => { - const { default: defaultSlots } = useSlots() const Comp = props.as ?? 'div' - return () => ( - - {defaultSlots && h(defaultSlots)} - - ) + return () => { + const { default: defaultSlots } = useSlots() + return ( + + {defaultSlots && h(defaultSlots)} + + ) + } }, { props: [...ALL_BASE_PROPS, 'direction', 'column', 'flex', 'align', 'justify', 'gap', 'columnGap', 'rowGap', 'wrap', 'as'] }) export default Flex \ No newline at end of file diff --git a/src/pages/popup/components/Header/index.tsx b/src/pages/popup/components/Header/index.tsx index 41aa376b9..caaf3366a 100644 --- a/src/pages/popup/components/Header/index.tsx +++ b/src/pages/popup/components/Header/index.tsx @@ -6,7 +6,7 @@ import { t } from "@popup/locale" import { IS_ANDROID } from "@util/constant/environment" import { getAppPageUrl } from "@util/constant/url" import { ElLink } from "element-plus" -import { FunctionalComponent } from "vue" +import type { FunctionalComponent } from "vue" import DarkSwitch from "./DarkSwitch" import Logo from "./Logo" import MoreInfo from './MoreInfo' @@ -29,7 +29,7 @@ const openAppPage = async () => { await createTab(appPageUrl) } -const Header: FunctionalComponent = () => ( +const Header: FunctionalComponent<{}> = () => ( diff --git a/src/util/time.ts b/src/util/time.ts index 8cb3e195b..9d5dd1372 100644 --- a/src/util/time.ts +++ b/src/util/time.ts @@ -51,10 +51,12 @@ export function formatTimeYMD(time: Date | number) { return formatTime(time, '{y}{m}{d}') } +type PeriodMsgFormat = Record<'dayMsg' | 'hourMsg' | 'minuteMsg' | 'secondMsg', string> + /** * Format milliseconds for display */ -export function formatPeriod(milliseconds: number, message: Record<'dayMsg' | 'hourMsg' | 'minuteMsg' | 'secondMsg', string>): string { +export function formatPeriod(milliseconds: number, message: PeriodMsgFormat): string { const prefix = milliseconds < 0 ? '-' : '' milliseconds = Math.abs(milliseconds) const { dayMsg, hourMsg, minuteMsg, secondMsg } = message @@ -82,29 +84,45 @@ export function formatPeriod(milliseconds: number, message: Record<'dayMsg' | 'h return prefix + result } +const PERIOD_RTL: PeriodMsgFormat = { + dayMsg: 's{second} m{minute} h{hour} d{day}', + hourMsg: 's{second} m{minute} h{hour}', + minuteMsg: 's{second} m{minute}', + secondMsg: 's{second}', +} +const PERIOD_RTL_SIMPLIFIED: PeriodMsgFormat = { + dayMsg: 's{second}m{minute}h{hour}d{day}', + hourMsg: 's{second}m{minute}h{hour}', + minuteMsg: 's{second}m{minute}', + secondMsg: 's{second}', +} +const PERIOD_LTR: PeriodMsgFormat = { + dayMsg: '{day}d {hour}h {minute}m {second}s', + hourMsg: '{hour}h {minute}m {second}s', + minuteMsg: '{minute}m {second}s', + secondMsg: '{second}s', +} +const PERIOD_LTR_SIMPLIFIED: PeriodMsgFormat = { + dayMsg: '{day}d{hour}h{minute}m{second}s', + hourMsg: '{hour}h{minute}m{second}s', + minuteMsg: '{minute}m{second}s', + secondMsg: '{second}s', +} + /** * e.g. * - * 100h0m0s - * 20h10m59s - * 20h0m1s - * 10m20s + * 2d 10h 0m 0s + * 20h 0m 1s + * 10m 20s * 30s * * @return (xx+h)(xx+m)xx+s */ -export function formatPeriodCommon(milliseconds: number): string { - const defaultMessage = isRtl() ? { - dayMsg: 's{second} m{minute} h{hour} d{day}', - hourMsg: 's{second} m{minute} h{hour}', - minuteMsg: 's{second} m{minute}', - secondMsg: 's{second}', - } : { - dayMsg: '{day}d {hour}h {minute}m {second}s', - hourMsg: '{hour}h {minute}m {second}s', - minuteMsg: '{minute}m {second}s', - secondMsg: '{second}s', - } +export function formatPeriodCommon(milliseconds: number, simplified?: boolean): string { + const defaultMessage = isRtl() + ? (simplified ? PERIOD_RTL_SIMPLIFIED : PERIOD_RTL) + : (simplified ? PERIOD_LTR_SIMPLIFIED : PERIOD_LTR) return formatPeriod(milliseconds, defaultMessage) } diff --git a/test-e2e/common/base.ts b/test-e2e/common/base.ts index eb5e75e3d..1ce962712 100644 --- a/test-e2e/common/base.ts +++ b/test-e2e/common/base.ts @@ -1,6 +1,6 @@ import { type Browser, launch, type Page } from "puppeteer" import { E2E_OUTPUT_PATH } from "../../rspack/constant" -import { removeAllWhitelist } from './whitelist' +import { removeAllWhitelist } from './whitelist.test' const USE_HEADLESS_PUPPETEER = !!process.env['USE_HEADLESS_PUPPETEER'] diff --git a/test-e2e/common/whitelist.ts b/test-e2e/common/whitelist.test.ts similarity index 70% rename from test-e2e/common/whitelist.ts rename to test-e2e/common/whitelist.test.ts index 357e7afc5..c15ed8336 100644 --- a/test-e2e/common/whitelist.ts +++ b/test-e2e/common/whitelist.test.ts @@ -1,4 +1,4 @@ -import { type LaunchContext, sleep } from "./base" +import { launchBrowser, type LaunchContext, sleep } from "./base" export async function createWhitelist(context: LaunchContext, white: string) { const whitePage = await context.openAppPage('/additional/whitelist') @@ -11,8 +11,10 @@ export async function createWhitelist(context: LaunchContext, white: string) { await input?.focus() await whitePage.keyboard.type(white) await sleep(.4) - const selectItem = await whitePage.waitForSelector('.el-popper .el-select-dropdown li:nth-child(1)') - await selectItem?.click() + await whitePage.keyboard.press('ArrowDown') + await sleep(.2) + await whitePage.keyboard.press('Enter') + await whitePage.click('.el-button:nth-child(3)') const checkBtn = await whitePage.waitForSelector('.el-overlay.is-message-box .el-button.el-button--primary') await checkBtn?.click() @@ -25,4 +27,10 @@ export async function removeAllWhitelist(context: LaunchContext) { await chrome.storage.local.remove('__timer__WHITELIST') }) await whitePage.close() -} \ No newline at end of file +} + +// Run to test the function, but skip it in normal test runs +test.skip('create whitelist', async () => { + const context = await launchBrowser() + await createWhitelist(context, 'example.com') +}) \ No newline at end of file diff --git a/test-e2e/tracker/base.test.ts b/test-e2e/tracker/base.test.ts index aeb4fccf8..3857b3942 100644 --- a/test-e2e/tracker/base.test.ts +++ b/test-e2e/tracker/base.test.ts @@ -1,6 +1,6 @@ import { launchBrowser, type LaunchContext, MOCK_HOST, MOCK_URL, MOCK_URL_2, sleep } from "../common/base" import { readRecordsOfFirstPage } from "../common/record" -import { createWhitelist } from "../common/whitelist" +import { createWhitelist } from "../common/whitelist.test" let context: LaunchContext diff --git a/test-e2e/tracker/run-time.test.ts b/test-e2e/tracker/run-time.test.ts index 238791bf9..ce35a29f6 100644 --- a/test-e2e/tracker/run-time.test.ts +++ b/test-e2e/tracker/run-time.test.ts @@ -1,6 +1,6 @@ import { launchBrowser, MOCK_HOST, MOCK_URL, sleep, type LaunchContext } from "../common/base" import { parseTime2Sec, readRecordsOfFirstPage } from "../common/record" -import { createWhitelist } from "../common/whitelist" +import { createWhitelist } from "../common/whitelist.test" let context: LaunchContext From 7f0796bc2c1af4cdeb68069b85e9f9c24460bbcf Mon Sep 17 00:00:00 2001 From: sheepie Date: Fri, 6 Mar 2026 10:47:08 +0800 Subject: [PATCH 087/174] feat: scheduled notifications (#681, #695) --- examples/notification/demo-server.ts | 56 +++++++ package.json | 17 +- src/api/chrome/alarm.ts | 31 +++- src/api/chrome/notifications.ts | 42 +++++ src/api/chrome/runtime.ts | 4 + src/background/alarm-manager.ts | 35 ++-- src/background/backup-scheduler.ts | 50 ------ src/background/index.ts | 6 +- src/background/scheduler.ts | 86 ++++++++++ .../limit/modal/components/Alert.tsx | 6 +- .../limit/modal/components/Footer.tsx | 10 +- src/content-script/limit/modal/index.ts | 4 +- .../limit/processor/message-adaptor.ts | 4 +- .../limit/processor/period-processor.ts | 4 +- .../limit/processor/visit-processor.ts | 4 +- .../limit/reminder/component.ts | 4 +- src/content-script/printer.ts | 7 +- src/content-script/skeleton.ts | 4 +- src/content-script/timeline.ts | 4 +- src/content-script/tracker/run-time.ts | 12 +- src/i18n/index.ts | 2 +- src/i18n/message/app/option-resource.json | 158 +++++++----------- src/i18n/message/app/option.ts | 21 ++- .../message/common/calendar-resource.json | 1 + src/i18n/message/common/calendar.ts | 1 + src/manifest-firefox.ts | 3 +- src/manifest.ts | 1 + src/pages/app/Layout/menu/Nav.tsx | 4 +- src/pages/app/Layout/menu/item.ts | 21 +-- src/pages/app/components/Option/Select.tsx | 29 +--- src/pages/app/components/Option/Tabs.tsx | 135 ++++++--------- .../Accessibility.tsx} | 6 +- .../Appearance}/DarkModeInput.tsx | 11 +- .../Appearance}/index.tsx | 8 +- .../Backup}/AutoInput.tsx | 0 .../Backup}/Clear/Sop.tsx | 0 .../Backup}/Clear/Step1.tsx | 0 .../Backup}/Clear/Step2.tsx | 0 .../Backup}/Clear/index.tsx | 0 .../Backup}/ClientTable.tsx | 0 .../Backup}/Download/Sop.tsx | 0 .../Backup}/Download/Step2.tsx | 0 .../Backup}/Download/index.tsx | 0 .../Backup}/Footer.tsx | 2 +- .../Backup}/index.tsx | 96 +++++------ .../Option/categories/Backup/useBackup.ts | 86 ++++++++++ .../Limit}/index.tsx | 7 +- .../Limit}/usePswEdit.tsx | 0 .../Limit}/useVerify.ts | 0 .../Option/categories/Notification/Footer.tsx | 28 ++++ .../Option/categories/Notification/index.tsx | 102 +++++++++++ .../Notification/useNotification.ts | 69 ++++++++ .../Tracking.tsx} | 9 +- .../app/components/Option/categories/index.ts | 8 + .../app/components/Option/categories/types.ts | 4 + src/pages/app/components/Option/common.tsx | 40 ----- .../Option/components/BackupOption/state.ts | 118 ------------- .../components/{OptionItem.tsx => Item.tsx} | 11 +- .../components/{OptionLines.tsx => Lines.tsx} | 8 +- .../Option/components/OptionTag.tsx | 10 -- .../Option/components/OptionTooltip.tsx | 14 -- .../components/Option/components/index.tsx | 24 +++ src/pages/app/components/Option/index.tsx | 24 ++- .../app/components/Option/useCategory.ts | 53 ++++++ src/pages/app/components/Option/useOption.ts | 20 ++- src/pages/popup/components/Header/Logo.tsx | 3 +- src/service/notification/browser/notifier.ts | 61 +++++++ src/service/notification/callback/notifier.ts | 44 +++++ src/service/notification/common.ts | 5 + src/service/notification/processor.ts | 107 ++++++++++++ src/service/notification/types.ts | 45 +++++ src/util/constant/option.ts | 10 ++ types/timer/index.d.ts | 7 + types/timer/mq.d.ts | 3 + types/timer/notification.d.ts | 4 + types/timer/option.d.ts | 25 +++ 76 files changed, 1217 insertions(+), 621 deletions(-) create mode 100644 examples/notification/demo-server.ts create mode 100644 src/api/chrome/notifications.ts delete mode 100644 src/background/backup-scheduler.ts create mode 100644 src/background/scheduler.ts rename src/pages/app/components/Option/{components/AccessibilityOption.tsx => categories/Accessibility.tsx} (86%) rename src/pages/app/components/Option/{components/AppearanceOption => categories/Appearance}/DarkModeInput.tsx (88%) rename src/pages/app/components/Option/{components/AppearanceOption => categories/Appearance}/index.tsx (97%) rename src/pages/app/components/Option/{components/BackupOption => categories/Backup}/AutoInput.tsx (100%) rename src/pages/app/components/Option/{components/BackupOption => categories/Backup}/Clear/Sop.tsx (100%) rename src/pages/app/components/Option/{components/BackupOption => categories/Backup}/Clear/Step1.tsx (100%) rename src/pages/app/components/Option/{components/BackupOption => categories/Backup}/Clear/Step2.tsx (100%) rename src/pages/app/components/Option/{components/BackupOption => categories/Backup}/Clear/index.tsx (100%) rename src/pages/app/components/Option/{components/BackupOption => categories/Backup}/ClientTable.tsx (100%) rename src/pages/app/components/Option/{components/BackupOption => categories/Backup}/Download/Sop.tsx (100%) rename src/pages/app/components/Option/{components/BackupOption => categories/Backup}/Download/Step2.tsx (100%) rename src/pages/app/components/Option/{components/BackupOption => categories/Backup}/Download/index.tsx (100%) rename src/pages/app/components/Option/{components/BackupOption => categories/Backup}/Footer.tsx (98%) rename src/pages/app/components/Option/{components/BackupOption => categories/Backup}/index.tsx (62%) create mode 100644 src/pages/app/components/Option/categories/Backup/useBackup.ts rename src/pages/app/components/Option/{components/LimitOption => categories/Limit}/index.tsx (97%) rename src/pages/app/components/Option/{components/LimitOption => categories/Limit}/usePswEdit.tsx (100%) rename src/pages/app/components/Option/{components/LimitOption => categories/Limit}/useVerify.ts (100%) create mode 100644 src/pages/app/components/Option/categories/Notification/Footer.tsx create mode 100644 src/pages/app/components/Option/categories/Notification/index.tsx create mode 100644 src/pages/app/components/Option/categories/Notification/useNotification.ts rename src/pages/app/components/Option/{components/TrackingOption.tsx => categories/Tracking.tsx} (96%) create mode 100644 src/pages/app/components/Option/categories/index.ts create mode 100644 src/pages/app/components/Option/categories/types.ts delete mode 100644 src/pages/app/components/Option/common.tsx delete mode 100644 src/pages/app/components/Option/components/BackupOption/state.ts rename src/pages/app/components/Option/components/{OptionItem.tsx => Item.tsx} (94%) rename src/pages/app/components/Option/components/{OptionLines.tsx => Lines.tsx} (89%) delete mode 100644 src/pages/app/components/Option/components/OptionTag.tsx delete mode 100644 src/pages/app/components/Option/components/OptionTooltip.tsx create mode 100644 src/pages/app/components/Option/components/index.tsx create mode 100644 src/pages/app/components/Option/useCategory.ts create mode 100644 src/service/notification/browser/notifier.ts create mode 100644 src/service/notification/callback/notifier.ts create mode 100644 src/service/notification/common.ts create mode 100644 src/service/notification/processor.ts create mode 100644 src/service/notification/types.ts create mode 100644 types/timer/notification.d.ts diff --git a/examples/notification/demo-server.ts b/examples/notification/demo-server.ts new file mode 100644 index 000000000..b1af9e66c --- /dev/null +++ b/examples/notification/demo-server.ts @@ -0,0 +1,56 @@ +import hash from "hash.js" +import { createServer } from "http" + +const AUTH: string | undefined = process.env.AUTH + +function genSign(meta: any, auth: string): string { + return hash.hmac(hash.sha256 as any, auth).update(meta).digest('hex') +} + +function verifySign(meta: any, receivedSign: string | string[] | undefined): boolean { + if (!AUTH) return true + if (!receivedSign) return false + return genSign(meta, AUTH) === receivedSign +} + +function main() { + const server = createServer(async (req, res) => { + const method = req.method + if (method === 'HEAD') { + res.writeHead(200).end() + return + } + + if (method === 'POST') { + try { + const body = await new Promise((resolve, reject) => { + let data = '' + req.on('data', (chunk: Buffer) => { data += chunk.toString() }) + req.on('end', () => resolve(data)) + req.on('error', reject) + }) + + const { meta } = JSON.parse(body) + const sign = req.headers['tt4b-sign'] + if (!verifySign(meta, sign)) { + res.writeHead(401).end('Unauthorized') + return + } + + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end("Thanks!!") + } catch (e) { + console.error("Failed to parse request", e) + res.writeHead(400).end('Bad Request') + } + return + } + + res.writeHead(405).end('Method Not Allowed') + }) + + const port = 3000 + server.listen(port, () => console.log(`Notification server listening on port ${port}`)) +} + +main() diff --git a/package.json b/package.json index 4c7f19ce9..d586dd16a 100644 --- a/package.json +++ b/package.json @@ -31,19 +31,19 @@ "@crowdin/crowdin-api-client": "^1.52.0", "@emotion/babel-plugin": "^11.13.5", "@emotion/css": "^11.13.5", - "@rsdoctor/rspack-plugin": "^1.5.2", - "@rspack/cli": "^1.7.6", - "@rspack/core": "^1.7.6", - "@swc/core": "^1.15.17", + "@rsdoctor/rspack-plugin": "^1.5.3", + "@rspack/cli": "^1.7.7", + "@rspack/core": "^1.7.7", + "@swc/core": "^1.15.18", "@swc/jest": "^0.2.39", "@types/chrome": "0.1.37", "@types/decompress": "^4.2.7", "@types/jest": "^30.0.0", - "@types/node": "^25.3.2", + "@types/node": "^25.3.3", "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.0.0", - "commitlint": "^20.4.2", + "commitlint": "^20.4.3", "css-loader": "^7.1.4", "decompress": "^4.2.1", "fake-indexeddb": "^6.2.5", @@ -52,10 +52,10 @@ "jest-environment-jsdom": "^30.2.0", "jest-junit": "^16.0.0", "jszip": "^3.10.1", - "postcss": "^8.5.6", + "postcss": "^8.5.8", "postcss-loader": "^8.2.1", "postcss-rtlcss": "^5.7.1", - "puppeteer": "^24.37.5", + "puppeteer": "^24.38.0", "ts-loader": "^9.5.4", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", @@ -65,6 +65,7 @@ "@element-plus/icons-vue": "^2.3.2", "echarts": "^6.0.0", "element-plus": "2.13.3", + "hash.js": "^1.1.7", "punycode": "^2.3.1", "typescript-guard": "^0.2.1", "vue": "^3.5.29", diff --git a/src/api/chrome/alarm.ts b/src/api/chrome/alarm.ts index 0b4c2c807..bb136462c 100644 --- a/src/api/chrome/alarm.ts +++ b/src/api/chrome/alarm.ts @@ -1,3 +1,4 @@ +import { IS_MV3 } from '@util/constant/environment' import { handleError } from "./common" type AlarmHandler = (alarm: ChromeAlarm) => PromiseLike | void @@ -6,13 +7,33 @@ export function onAlarm(handler: AlarmHandler) { chrome.alarms.onAlarm.addListener(handler) } -export function clearAlarm(name: string): Promise { - return new Promise(resolve => chrome.alarms.clear(name, () => { - handleError('clearAlarm') +export async function clearAlarm(name: string): Promise { + if (IS_MV3) { + return chrome.alarms.clear(name) + } else { + return new Promise(resolve => chrome.alarms.clear(name, removed => { + handleError('clearAlarm') + resolve(removed) + })) + } +} + +export function createAlarm(name: string, when: number): Promise { + if (IS_MV3) { + return chrome.alarms.create(name, { when }) + } + return new Promise(resolve => chrome.alarms.create(name, { when }, () => { + handleError('createAlarm') resolve() })) } -export function createAlarm(name: string, when: number): void { - chrome.alarms.create(name, { when }) +export async function getAlarm(name: string): Promise { + if (IS_MV3) { + return chrome.alarms.get(name) + } + return new Promise(resolve => chrome.alarms.get(name, alarm => { + handleError('getAlarm') + resolve(alarm) + })) } \ No newline at end of file diff --git a/src/api/chrome/notifications.ts b/src/api/chrome/notifications.ts new file mode 100644 index 000000000..4f19e1f84 --- /dev/null +++ b/src/api/chrome/notifications.ts @@ -0,0 +1,42 @@ +import { IS_MV3 } from "@util/constant/environment" +import { handleError } from "./common" + +export type NotificationTopic = 'time' + +export async function createNotification( + topic: NotificationTopic, + options: MakeRequired +): Promise { + if (IS_MV3) { + return await chrome.notifications.create(topic, options) + } else { + return new Promise((resolve, reject) => { + chrome.notifications.create(topic, options, (id: string) => { + const error = handleError('createNotification') + if (error) { + reject(new Error(error)) + } else { + resolve(id) + } + }) + }) + } +} + +export async function clearNotification(notificationId: string): Promise { + if (IS_MV3) { + try { + await chrome.notifications.clear(notificationId) + return true + } catch { + return false + } + } else { + return new Promise(resolve => { + chrome.notifications.clear(notificationId, (wasCleared) => { + handleError('clearNotification') + resolve(wasCleared) + }) + }) + } +} diff --git a/src/api/chrome/runtime.ts b/src/api/chrome/runtime.ts index 3f40223cb..225398d4c 100644 --- a/src/api/chrome/runtime.ts +++ b/src/api/chrome/runtime.ts @@ -8,6 +8,10 @@ export function getRuntimeName(): string { return chrome.runtime.getManifest().name } +export function getIconUrl(): string { + return getUrl('static/images/icon.png') +} + /** * Fix proxy data failed to serialized in Firefox */ diff --git a/src/background/alarm-manager.ts b/src/background/alarm-manager.ts index e3728a8a2..7bf8e1668 100644 --- a/src/background/alarm-manager.ts +++ b/src/background/alarm-manager.ts @@ -1,10 +1,10 @@ -import { clearAlarm, createAlarm, onAlarm } from "@api/chrome/alarm" +import { clearAlarm, createAlarm, getAlarm, onAlarm } from "@api/chrome/alarm" import { getRuntimeId } from "@api/chrome/runtime" type _AlarmConfig = { handler: _Handler, interval?: number, - when?: () => number, + when?: () => number | null, } type _Handler = (alarm: ChromeAlarm) => void @@ -15,7 +15,7 @@ const ALARM_PREFIX_LENGTH = ALARM_PREFIX.length const getInnerName = (outerName: string) => ALARM_PREFIX + outerName const getOuterName = (innerName: string) => innerName.substring(ALARM_PREFIX_LENGTH) -const calcNextTs = (config: _AlarmConfig): number => { +const calcNextTs = (config: _AlarmConfig): number | null => { const { interval, when } = config if (interval) return Date.now() + interval if (when) return when() @@ -53,10 +53,10 @@ class AlarmManager { } catch (e) { console.info("Failed to handle alarm event", e) } finally { - const nextTs = calcNextTs(config) // Clear this one await clearAlarm(name) - createAlarm(name, nextTs) + const nextTs = calcNextTs(config) + nextTs && await createAlarm(name, nextTs) } }) } @@ -66,7 +66,7 @@ class AlarmManager { * * @param interval mills */ - setInterval(outerName: string, interval: number, handler: _Handler): void { + async setInterval(outerName: string, interval: number, handler: _Handler): Promise { if (!interval || !handler) { return } @@ -79,13 +79,13 @@ class AlarmManager { // Initialize config this.alarms[outerName] = config // Create new one alarm - createAlarm(getInnerName(outerName), Date.now() + interval) + await createAlarm(getInnerName(outerName), Date.now() + interval) } /** * Set a alarm to do sth if the time arrives */ - setWhen(outerName: string, when: () => number, handler: _Handler): void { + async setWhen(outerName: string, when: () => number | null, handler: _Handler): Promise { if (!when || !handler) { return } @@ -98,15 +98,28 @@ class AlarmManager { // Initialize config this.alarms[outerName] = config // Create new one alarm - createAlarm(getInnerName(outerName), when()) + const next = calcNextTs(config) + next && await createAlarm(getInnerName(outerName), next) } /** * Remove a interval */ - remove(outerName: string) { + async remove(outerName: string): Promise { delete this.alarms[outerName] - clearAlarm(getInnerName(outerName)) + await clearAlarm(getInnerName(outerName)) + } + + /** + * Judge if exist + */ + async getAlarm(outerName: string): Promise { + const innerName = getInnerName(outerName) + const existed = await getAlarm(innerName) + if (!existed && this.alarms[outerName]) { + delete this.alarms[outerName] + } + return existed } } diff --git a/src/background/backup-scheduler.ts b/src/background/backup-scheduler.ts deleted file mode 100644 index 7dc55a9ed..000000000 --- a/src/background/backup-scheduler.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import processor from "@service/backup/processor" -import optionHolder from "@service/components/option-holder" -import { MILL_PER_MINUTE } from "@util/time" -import alarmManager from "./alarm-manager" - -const ALARM_NAME = 'auto-backup-data' - -class BackupScheduler { - needBackup = false - /** - * Interval of milliseconds - */ - interval: number = 0 - - init() { - optionHolder.get().then(opt => this.handleOption(opt)) - optionHolder.addChangeListener(opt => this.handleOption(opt)) - } - - private handleOption(option: timer.option.BackupOption) { - const { autoBackUp, backupType, autoBackUpInterval = 0 } = option || {} - this.needBackup = backupType !== "none" && !!backupType && !!autoBackUp - this.interval = autoBackUpInterval * MILL_PER_MINUTE - 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/index.ts b/src/background/index.ts index 4dbad3cbe..a49bd13e0 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -10,7 +10,6 @@ import { isNoneWindowId, onNormalWindowFocusChanged } from "@api/chrome/window" import optionHolder from "@service/components/option-holder" import { isBrowserUrl } from "@util/pattern" import { openLog } from "../common/logger" -import BackupScheduler from "./backup-scheduler" import badgeTextManager from "./badge-manager" import initBrowserAction from "./browser-action-manager" import initCsHandler from "./content-script-handler" @@ -19,6 +18,7 @@ import handleInstall from './install-handler' import initLimitProcessor from "./limit-processor" import MessageDispatcher from "./message-dispatcher" import VersionMigrator from "./migrator" +import { initScheduler } from './scheduler' import initSidePanel from "./side-panel" import TabListener from './tab-listener' import initTrackServer from "./track-server" @@ -50,8 +50,8 @@ initTrackServer(messageDispatcher) // Process version new VersionMigrator().init() -// Backup scheduler -new BackupScheduler().init() +// scheduler +initScheduler(messageDispatcher) // Manage the context menus initWhitelistMenuManager() diff --git a/src/background/scheduler.ts b/src/background/scheduler.ts new file mode 100644 index 000000000..ca4939145 --- /dev/null +++ b/src/background/scheduler.ts @@ -0,0 +1,86 @@ +import optionDatabase from '@db/option-database' +import backupProcessor from "@service/backup/processor" +import notificationProcessor from "@service/notification/processor" +import { MILL_PER_MINUTE } from "@util/time" +import alarmManager from "./alarm-manager" +import type MessageDispatcher from './message-dispatcher' + +const BACKUP_ALARM_NAME = 'auto-backup-data' +const NOTIFICATION_ALARM_NAME = 'notification-data' + +export async function initScheduler(dispatcher: MessageDispatcher): Promise { + dispatcher.register('resetBackupScheduler', resetBackup) + .register('resetNotificationScheduler', resetNotification) + + const existBackup = await alarmManager.getAlarm(BACKUP_ALARM_NAME) + !existBackup && await resetBackup() + + const existNotification = await alarmManager.getAlarm(NOTIFICATION_ALARM_NAME) + !existNotification && await resetNotification() +} + +async function resetBackup(): Promise { + // MUST read latest option from database + const option = await optionDatabase.getOption() + + await alarmManager.remove(BACKUP_ALARM_NAME) + + const { autoBackUp, backupType, autoBackUpInterval = 0 } = option + if (backupType === 'none' || !autoBackUp || !autoBackUpInterval) { + return + } + + const interval = autoBackUpInterval * MILL_PER_MINUTE + await alarmManager.setInterval(BACKUP_ALARM_NAME, interval, async () => { + const result = await backupProcessor.syncData() + if (!result.success) { + console.warn(`Failed to backup ts=${Date.now()}, msg=${result.errorMsg}`) + } + }) +} + +type OffsetHandler = (offsetMin: number) => number +const OFFSET_HANDLERS: Record, OffsetHandler> = { + daily: offset => { + const next = new Date() + next.setHours(0, offset, 0, 0) + const now = new Date() + while (next.getTime() < now.getTime()) { + next.setDate(next.getDate() + 1) + } + return next.getTime() + }, + weekly: offset => { + const next = new Date() + const weekday = next.getDay() + // Convert JS Sunday-based weekday (Sun=0) to Monday-based (Mon=0) + const mondayBasedWeekday = (weekday + 6) % 7 + next.setDate(next.getDate() - mondayBasedWeekday) + next.setHours(0, offset, 0, 0) + const now = new Date() + while (next.getTime() < now.getTime()) { + next.setDate(next.getDate() + 7) + } + return next.getTime() + } +} + +async function resetNotification(): Promise { + await alarmManager.remove(NOTIFICATION_ALARM_NAME) + + const option = await optionDatabase.getOption() + const { notificationCycle: cycle, notificationOffset: offset } = option + + if (cycle === 'none') return + + await alarmManager.setWhen( + NOTIFICATION_ALARM_NAME, + () => OFFSET_HANDLERS[cycle](offset), + async () => { + const result = await notificationProcessor.doSend() + if (!result.success) { + console.warn(`Failed to send notification ts=${Date.now()}, msg=${result.errorMsg}`) + } + } + ) +} \ No newline at end of file diff --git a/src/content-script/limit/modal/components/Alert.tsx b/src/content-script/limit/modal/components/Alert.tsx index af0ce41d2..24033ad15 100644 --- a/src/content-script/limit/modal/components/Alert.tsx +++ b/src/content-script/limit/modal/components/Alert.tsx @@ -1,4 +1,4 @@ -import { getUrl } from "@api/chrome/runtime" +import { getIconUrl } from "@api/chrome/runtime" import { t } from "@cs/locale" import { useXsState } from '@hooks/useMediaSize' import { useRequest } from "@hooks/useRequest" @@ -7,8 +7,6 @@ import Flex from '@pages/components/Flex' import optionHolder from "@service/components/option-holder" import { defineComponent, type StyleValue } from "vue" -const ICON_URL = getUrl('static/images/icon.png') - const IMG_STYLE: StyleValue = { width: '1.4em', height: '1.4em', @@ -27,7 +25,7 @@ const _default = defineComponent(() => { return () => ( - + {t(msg => msg.meta.name)?.toUpperCase()} { round icon={Trend} type="success" - onClick={() => sendMsg2Runtime('cs.openAnalysis')} + onClick={() => trySendMsg2Runtime('cs.openAnalysis')} > {t(msg => msg.menu.siteAnalysis)} @@ -68,11 +68,7 @@ const _default = defineComponent(() => { > {t(msg => msg.modal.more5Minutes)} - sendMsg2Runtime('cs.openLimit')} - > + trySendMsg2Runtime('cs.openLimit')}> {t(msg => msg.modal.ruleDetail)} diff --git a/src/content-script/limit/modal/index.ts b/src/content-script/limit/modal/index.ts index a6534c3f9..6368b6632 100644 --- a/src/content-script/limit/modal/index.ts +++ b/src/content-script/limit/modal/index.ts @@ -1,4 +1,4 @@ -import { getRuntimeId, getUrl, sendMsg2Runtime } from '@api/chrome/runtime' +import { getRuntimeId, getUrl, trySendMsg2Runtime } from '@api/chrome/runtime' import optionService from '@service/option-service' import { init as initTheme, toggle } from '@util/dark-mode' import { createApp, Ref, type App } from 'vue' @@ -78,7 +78,7 @@ class ModalInstance implements MaskModal { rootElement: RootElement | undefined body: HTMLBodyElement | undefined delayHandlers: (() => void)[] = [ - () => sendMsg2Runtime('cs.moreMinutes', this.url), + () => trySendMsg2Runtime('cs.moreMinutes', this.url), ] reasons: LimitReason[] = [] reason: Ref | undefined diff --git a/src/content-script/limit/processor/message-adaptor.ts b/src/content-script/limit/processor/message-adaptor.ts index 0cea7bf7c..645c5a01d 100644 --- a/src/content-script/limit/processor/message-adaptor.ts +++ b/src/content-script/limit/processor/message-adaptor.ts @@ -1,4 +1,4 @@ -import { sendMsg2Runtime } from "@api/chrome/runtime" +import { trySendMsg2Runtime } from "@api/chrome/runtime" import { hasDailyLimited, hasWeeklyLimited, matches } from "@util/limit" import { type LimitReason, type ModalContext, type Processor } from "../common" @@ -55,7 +55,7 @@ class MessageAdaptor implements Processor { async initRules(): Promise { this.context.modal?.removeReasonsByType?.('DAILY', 'WEEKLY') - const limitedRules = await sendMsg2Runtime('cs.getLimitedRules', this.context.url) + const limitedRules = await trySendMsg2Runtime('cs.getLimitedRules', this.context.url) limitedRules ?.flatMap?.(cvtItem2AddReason) diff --git a/src/content-script/limit/processor/period-processor.ts b/src/content-script/limit/processor/period-processor.ts index e22c7f29c..bb64d5e3d 100644 --- a/src/content-script/limit/processor/period-processor.ts +++ b/src/content-script/limit/processor/period-processor.ts @@ -1,4 +1,4 @@ -import { sendMsg2Runtime } from "@api/chrome/runtime" +import { trySendMsg2Runtime } from "@api/chrome/runtime" import { date2Idx } from "@util/limit" import { MILL_PER_SECOND } from "@util/time" import { type LimitReason, type ModalContext, type Processor } from "../common" @@ -44,7 +44,7 @@ class PeriodProcessor implements Processor { } private async init0(rules?: timer.limit.Item[]) { - rules = rules || await sendMsg2Runtime("cs.getRelatedRules", this.context.url) + rules = rules ?? await trySendMsg2Runtime("cs.getRelatedRules", this.context.url) // Clear first this.context.modal.removeReasonsByType("PERIOD") const nowSeconds = date2Idx(new Date()) diff --git a/src/content-script/limit/processor/visit-processor.ts b/src/content-script/limit/processor/visit-processor.ts index 9514eb442..c9cb3dfca 100644 --- a/src/content-script/limit/processor/visit-processor.ts +++ b/src/content-script/limit/processor/visit-processor.ts @@ -1,4 +1,4 @@ -import { sendMsg2Runtime } from "@api/chrome/runtime" +import { trySendMsg2Runtime } from "@api/chrome/runtime" import NormalTracker from "@cs/tracker/normal" import { DELAY_MILL } from "@util/limit" import { MILL_PER_SECOND } from "@util/time" @@ -50,7 +50,7 @@ class VisitProcessor implements Processor { } async initRules() { - this.rules = await sendMsg2Runtime("cs.getRelatedRules", this.context.url) ?? [] + this.rules = await trySendMsg2Runtime("cs.getRelatedRules", this.context.url) ?? [] this.context.modal.removeReasonsByType("VISIT") } diff --git a/src/content-script/limit/reminder/component.ts b/src/content-script/limit/reminder/component.ts index 06378f176..8cbe47c9d 100644 --- a/src/content-script/limit/reminder/component.ts +++ b/src/content-script/limit/reminder/component.ts @@ -1,4 +1,4 @@ -import { getUrl } from "@api/chrome/runtime" +import { getIconUrl } from "@api/chrome/runtime" import { t } from "@cs/locale" const containerStyle = (dark: boolean): Partial => ({ @@ -63,7 +63,7 @@ function createIcon(): HTMLImageElement { const icon = document.createElement('img') icon.width = 32 icon.height = 32 - icon.src = getUrl('static/images/icon.png') + icon.src = getIconUrl() return icon } diff --git a/src/content-script/printer.ts b/src/content-script/printer.ts index 11f619d9c..7cdd9d76f 100644 --- a/src/content-script/printer.ts +++ b/src/content-script/printer.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { sendMsg2Runtime } from "@api/chrome/runtime" +import { trySendMsg2Runtime } from "@api/chrome/runtime" import { formatPeriodCommon } from "@util/time" import { t } from "./locale" @@ -13,8 +13,9 @@ import { t } from "./locale" * Print info of today */ export default async function printInfo(host: string) { - const waste = await sendMsg2Runtime('cs.getTodayInfo', host) - const { time, focus } = waste || {} + const waste = await trySendMsg2Runtime('cs.getTodayInfo', host) + if (!waste) return + const { time, focus } = waste const param = { time: `${time ?? '-'}`, diff --git a/src/content-script/skeleton.ts b/src/content-script/skeleton.ts index ed5d80338..e1ff2da4a 100644 --- a/src/content-script/skeleton.ts +++ b/src/content-script/skeleton.ts @@ -1,4 +1,4 @@ -import { sendMsg2Runtime } from "@api/chrome/runtime" +import { trySendMsg2Runtime } from "@api/chrome/runtime" function awaitDocumentReady() { if (document.readyState === 'complete') { @@ -12,7 +12,7 @@ function awaitDocumentReady() { const main = async () => { await awaitDocumentReady() - sendMsg2Runtime('cs.onInjected') + trySendMsg2Runtime('cs.onInjected') } main() diff --git a/src/content-script/timeline.ts b/src/content-script/timeline.ts index fc008f919..0dd4ea97c 100644 --- a/src/content-script/timeline.ts +++ b/src/content-script/timeline.ts @@ -1,4 +1,4 @@ -import { sendMsg2Runtime } from '@api/chrome/runtime' +import { trySendMsg2Runtime } from '@api/chrome/runtime' class TimelineCollector { private startTime: number | null = null @@ -43,7 +43,7 @@ class TimelineCollector { if (!this.startTime) return const url = document?.location?.href - url && sendMsg2Runtime('cs.timelineEv', { + url && trySendMsg2Runtime('cs.timelineEv', { start: this.startTime, end: Date.now(), url, diff --git a/src/content-script/tracker/run-time.ts b/src/content-script/tracker/run-time.ts index f0543ae9e..65fefecd7 100644 --- a/src/content-script/tracker/run-time.ts +++ b/src/content-script/tracker/run-time.ts @@ -1,4 +1,4 @@ -import { onRuntimeMessage, sendMsg2Runtime } from "@api/chrome/runtime" +import { onRuntimeMessage, trySendMsg2Runtime } from "@api/chrome/runtime" class RunTimeTracker { private start: number = Date.now() @@ -25,11 +25,9 @@ class RunTimeTracker { setInterval(() => this.collect(), 1000) } - private fetchSite() { - sendMsg2Runtime('cs.getRunSites', this.url) - .then((site: timer.site.SiteKey) => this.host = site?.host) - // Extension reloaded, so terminate - .catch(() => this.host = undefined) + private async fetchSite() { + const site: timer.site.SiteKey | undefined = await trySendMsg2Runtime('cs.getRunSites', this.url) + this.host = site?.host } private async collect() { @@ -45,7 +43,7 @@ class RunTimeTracker { ignoreTabCheck: false, host: this.host, } - await sendMsg2Runtime('cs.trackRunTime', event) + await trySendMsg2Runtime('cs.trackRunTime', event) } this.start = now } catch { diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 146739205..f83f67a5d 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -98,7 +98,7 @@ export function isTranslatingLocale(): boolean { */ export let locale: timer.Locale = localeSameAsBrowser -function cvtOption2Locale(option: timer.option.LocaleOption): timer.Locale { +export function cvtOption2Locale(option: timer.option.LocaleOption): timer.Locale { if (!option || option === 'default') { return chromeLocale2ExtensionLocale(getUILanguage()) } diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 07a31146b..6c1629362 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -2,6 +2,8 @@ "zh_CN": { "yes": "是", "no": "否", + "on": "始终开启", + "off": "始终关闭", "followBrowser": "跟随浏览器", "appearance": { "title": "外观", @@ -24,11 +26,7 @@ }, "darkMode": { "label": "夜间模式 {input}", - "options": { - "on": "始终开启", - "off": "始终关闭", - "timed": "定时开启" - } + "timed": "定时开启" }, "animationDuration": "图表初始动画的时长 {input}", "sidePanel": "{input} 是否启用侧边栏视图" @@ -76,9 +74,6 @@ "type": "远端类型 {input}", "client": "客户端标识 {input}", "meta": { - "none": { - "label": "不开启备份" - }, "gist": { "authInfo": "需要创建一个至少包含 gist 权限的 token" }, @@ -133,6 +128,8 @@ "zh_TW": { "yes": "是", "no": "否", + "on": "始終開啟", + "off": "始終關閉", "followBrowser": "跟隨瀏覽器", "appearance": { "title": "外觀設定", @@ -155,11 +152,7 @@ }, "darkMode": { "label": "深色模式 {input}", - "options": { - "on": "始終開啟", - "off": "始終關閉", - "timed": "定時開啟" - } + "timed": "定時開啟" }, "animationDuration": "圖表動畫持續時間 {input}" }, @@ -203,9 +196,6 @@ "type": "雲端服務類型 {input}", "client": "用戶端識別碼 {input}", "meta": { - "none": { - "label": "關閉備份" - }, "gist": { "authInfo": "需建立至少包含 gist 權限的 token" }, @@ -260,6 +250,8 @@ "en": { "yes": "Yes", "no": "No", + "on": "Always on", + "off": "Always off", "followBrowser": "Follow browser", "appearance": { "title": "Appearance", @@ -282,11 +274,7 @@ }, "darkMode": { "label": "Dark mode {input}", - "options": { - "on": "Always on", - "off": "Always off", - "timed": "Timed on" - } + "timed": "Timed on" }, "animationDuration": "The duration of the chart's initial animation {input}", "sidePanel": "{input} Whether to enable the side panel" @@ -334,9 +322,7 @@ "type": "Remote type {input}", "client": "Client name {input}", "meta": { - "none": { - "label": "Always off" - }, + "none": {}, "gist": { "authInfo": "One token with at least gist permission is required" }, @@ -378,6 +364,22 @@ "title": "Accessibility", "chartDecal": "{input} Whether to display the chart decal" }, + "notification": { + "title": "Notification", + "cycle": { + "label": "Notification cycle {input}", + "daily": "Daily", + "weekly": "Weekly" + }, + "method": { + "label": "Notification method {input}", + "browser": "Browser", + "callback": { + "label": "HTTP Callback", + "url": "Callback URL {input}" + } + } + }, "resetButton": "Reset", "resetSuccess": "Reset to default successfully!", "exportButton": "Export Settings", @@ -392,6 +394,8 @@ "ja": { "yes": "はい", "no": "いいえ", + "on": "常にオン", + "off": "常にオフ", "followBrowser": "ブラウザと同じ", "appearance": { "title": "外観", @@ -414,11 +418,7 @@ }, "darkMode": { "label": "ダークモード {input}", - "options": { - "on": "常にオン", - "off": "常にオフ", - "timed": "時限スタート" - } + "timed": "時限スタート" }, "animationDuration": "チャートの初期アニメーションの持続時間 {input}" }, @@ -459,9 +459,7 @@ "type": "バックアップ方法 {input}", "client": "クライアント名 {input}", "meta": { - "none": { - "label": "バックアップを有効にしない" - }, + "none": {}, "gist": { "authInfo": "少なくとも gist 権限を持つトークンが 1 つ必要です" }, @@ -516,6 +514,8 @@ "pt_PT": { "yes": "Sim", "no": "Não", + "on": "Sempre ativo", + "off": "Sempre inativo", "followBrowser": "Usar do navegador", "appearance": { "title": "Aparência", @@ -538,11 +538,7 @@ }, "darkMode": { "label": "Modo escuro {input}", - "options": { - "on": "Sempre ativo", - "off": "Sempre inativo", - "timed": "Ativo por tempo" - } + "timed": "Ativo por tempo" }, "animationDuration": "Duração da animação inicial {input}", "sidePanel": "{input} Se ativar ou não o painel lateral" @@ -588,9 +584,7 @@ "type": "Tipo remoto {input}", "client": "Nome do cliente {input}", "meta": { - "none": { - "label": "Sempre inativo" - }, + "none": {}, "gist": { "authInfo": "Necessário token com permissão gist" }, @@ -645,6 +639,8 @@ "uk": { "yes": "Так", "no": "Ні", + "on": "Увімкнено", + "off": "Вимкнено", "followBrowser": "Як у браузері", "appearance": { "title": "Зовнішній вигляд", @@ -667,11 +663,7 @@ }, "darkMode": { "label": "Темний режим: {input}", - "options": { - "on": "Увімкнено", - "off": "Вимкнено", - "timed": "За розкладом" - } + "timed": "За розкладом" }, "animationDuration": "Тривалість початкової анімації діаграми {input}" }, @@ -715,9 +707,7 @@ "type": "Тип резервного копіювання {input}", "client": "Назва клієнта {input}", "meta": { - "none": { - "label": "Завжди вимкнено" - }, + "none": {}, "gist": { "authInfo": "Потрібно вказати токен для доступу gist" }, @@ -772,6 +762,8 @@ "es": { "yes": "Sí", "no": "No", + "on": "Siempre encendido", + "off": "Siempre apagado", "followBrowser": "Igual que el navegador", "appearance": { "title": "Apariencia", @@ -794,11 +786,7 @@ }, "darkMode": { "label": "Modo oscuro {input}", - "options": { - "on": "Siempre encendido", - "off": "Siempre apagado", - "timed": "Cronometrado" - } + "timed": "Cronometrado" }, "animationDuration": "Duración de la animación inicial del gráfico {input}" }, @@ -842,9 +830,7 @@ "type": "Tipo remoto {input}", "client": "Nombre del cliente {input}", "meta": { - "none": { - "label": "Siempre apagado" - }, + "none": {}, "gist": { "authInfo": "Se requiere un token con al menos los permisos esenciales" }, @@ -969,9 +955,7 @@ "type": "Remote Typ {input}", "client": "Kundenname {input}", "meta": { - "none": { - "label": "Immer aus" - }, + "none": {}, "gist": { "authInfo": "Ein Token mit mindestens gist Berechtigung ist erforderlich" }, @@ -1026,6 +1010,8 @@ "fr": { "yes": "Oui", "no": "Non", + "on": "Toujours activé", + "off": "Toujours éteint", "followBrowser": "Suivre le navigateur", "appearance": { "title": "Apparence", @@ -1048,11 +1034,7 @@ }, "darkMode": { "label": "Mode sombre {input}", - "options": { - "on": "Toujours activé", - "off": "Toujours éteint", - "timed": "Horaire" - } + "timed": "Horaire" }, "animationDuration": "La durée de l'animation initiale du graphique {input}", "sidePanel": "{input} Autoriser l'affichage du panneau latéral" @@ -1098,9 +1080,7 @@ "type": "Type distant {input}", "client": "Nom du client {input}", "meta": { - "none": { - "label": "Toujours éteint" - }, + "none": {}, "gist": { "authInfo": "Un jeton avec au moins une permission de gist est requis" }, @@ -1155,6 +1135,8 @@ "ru": { "yes": "Да", "no": "Нет", + "on": "Всегда включен", + "off": "Всегда выключен", "followBrowser": "Как в браузере", "appearance": { "title": "Появление", @@ -1177,11 +1159,7 @@ }, "darkMode": { "label": "Тёмный режим {input}", - "options": { - "on": "Всегда включен", - "off": "Всегда выключен", - "timed": "По времени" - } + "timed": "По времени" }, "animationDuration": "Длительность начальной анимации графика {input}" }, @@ -1235,6 +1213,8 @@ "ar": { "yes": "نعم", "no": "لا", + "on": "تفعيل دائم", + "off": "إيقاف دائم", "followBrowser": "نفس وضع المتصفح", "appearance": { "title": "المظهر", @@ -1257,11 +1237,7 @@ }, "darkMode": { "label": "الوضع الداكن {input}", - "options": { - "on": "تفعيل دائم", - "off": "إيقاف دائم", - "timed": "تفعيل مؤقت" - } + "timed": "تفعيل مؤقت" }, "animationDuration": "مدة تحريك الرسوم البيانية {input}" }, @@ -1305,9 +1281,7 @@ "type": "نوع الربط الخارجي {input}", "client": "اسم العميل {input}", "meta": { - "none": { - "label": "معطّل دائمًا" - }, + "none": {}, "gist": { "authInfo": "مطلوب رمز وصول واحد على الأقل مع صلاحية Gist" }, @@ -1362,6 +1336,8 @@ "tr": { "yes": "Evet", "no": "Hayır", + "on": "Her zaman açık", + "off": "Her zaman kapalı", "followBrowser": "Tarayıcıyı takip et", "appearance": { "title": "Görünüm", @@ -1384,11 +1360,7 @@ }, "darkMode": { "label": "Karanlık mod {input}", - "options": { - "on": "Her zaman açık", - "off": "Her zaman kapalı", - "timed": "Zamanlanmış" - } + "timed": "Zamanlanmış" }, "animationDuration": "Grafiğin ilk animasyonunun süresi {input}", "sidePanel": "{input} Yan panelin gösterilip gösterilmeyeceğini seçin" @@ -1436,9 +1408,7 @@ "type": "Uzaktan yedekleme tipi {input}", "client": "Uygulama adı {input}", "meta": { - "none": { - "label": "Her zaman kapalı" - }, + "none": {}, "gist": { "authInfo": "Gist iznine sahip bir token gereklidir" }, @@ -1493,6 +1463,8 @@ "pl": { "yes": "Tak", "no": "Nie", + "on": "Zawsze włączone", + "off": "Zawsze wyłączone", "appearance": { "title": "Wygląd", "displayWhitelist": "{input} Wyświetlanie {whitelist} w {contextMenu}", @@ -1514,11 +1486,7 @@ }, "darkMode": { "label": "Tryb ciemny {input}", - "options": { - "on": "Zawsze włączone", - "off": "Zawsze wyłączone", - "timed": "Czas na" - } + "timed": "Czas na" }, "animationDuration": "Długość trwania początkowej animacji wykresu {input}" }, @@ -1553,9 +1521,7 @@ "title": "Kopia zapasowa danych", "client": "Nazwa klienta {input}", "meta": { - "none": { - "label": "Zawsze wyłączone" - } + "none": {} }, "label": { "endpoint": "Adres punktu końcowego {info} {input}", diff --git a/src/i18n/message/app/option.ts b/src/i18n/message/app/option.ts index d66101bf1..9a3a33f78 100644 --- a/src/i18n/message/app/option.ts +++ b/src/i18n/message/app/option.ts @@ -9,6 +9,8 @@ import resource from './option-resource.json' export type OptionMessage = { yes: string no: string + on: string + off: string followBrowser: string appearance: { title: string @@ -33,7 +35,7 @@ export type OptionMessage = { }, darkMode: { label: string - options: Omit, 'default'> + timed: string } animationDuration: string sidePanel: string @@ -78,7 +80,6 @@ export type OptionMessage = { client: string meta: { [type in timer.backup.Type]: { - label?: string authInfo?: string } } & { @@ -119,6 +120,22 @@ export type OptionMessage = { title: string chartDecal: string } + notification: { + title: string + cycle: { + label: string + daily: string + weekly: string + } + method: { + label: string + browser: string + callback: { + label: string + url: string + } + } + } resetButton: string resetSuccess: string exportButton: string diff --git a/src/i18n/message/common/calendar-resource.json b/src/i18n/message/common/calendar-resource.json index 75ae0a5d9..760cc68ba 100644 --- a/src/i18n/message/common/calendar-resource.json +++ b/src/i18n/message/common/calendar-resource.json @@ -62,6 +62,7 @@ "everyday": "Everyday", "thisWeek": "This week", "thisMonth": "This month", + "lastWeek": "Last week", "lastDays": "Last {n} days", "tillYesterday": "Through yesterday", "tillDaysAgo": "Through {n} days ago", diff --git a/src/i18n/message/common/calendar.ts b/src/i18n/message/common/calendar.ts index 8e11c891d..6e838d44c 100644 --- a/src/i18n/message/common/calendar.ts +++ b/src/i18n/message/common/calendar.ts @@ -24,6 +24,7 @@ export type CalendarMessage = { yesterday: string thisWeek: string thisMonth: string + lastWeek: string lastDays: string tillYesterday: string tillDaysAgo: string diff --git a/src/manifest-firefox.ts b/src/manifest-firefox.ts index 85994c87c..824406be5 100644 --- a/src/manifest-firefox.ts +++ b/src/manifest-firefox.ts @@ -51,7 +51,8 @@ const _default: chrome.runtime.ManifestFirefox = { '', ], optional_permissions: [ - "tabGroups", + 'tabGroups', + 'notifications', ], browser_action: { default_popup: "static/popup_skeleton.html", diff --git a/src/manifest.ts b/src/manifest.ts index 4a987b339..fed0b248e 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -53,6 +53,7 @@ const _default: chrome.runtime.ManifestV3 = { ], optional_permissions: [ 'tabGroups', + 'notifications', ], host_permissions: [ "", diff --git a/src/pages/app/Layout/menu/Nav.tsx b/src/pages/app/Layout/menu/Nav.tsx index fd092329d..10c60b568 100644 --- a/src/pages/app/Layout/menu/Nav.tsx +++ b/src/pages/app/Layout/menu/Nav.tsx @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { getUrl } from "@api/chrome/runtime" +import { getIconUrl } from "@api/chrome/runtime" import { t } from "@app/locale" import { CloseBold, Link, Menu } from "@element-plus/icons-vue" import { css } from '@emotion/css' @@ -72,7 +72,7 @@ const _default = defineComponent<{}>(() => { - + {t(msg => msg.meta.name)} diff --git a/src/pages/app/Layout/menu/item.ts b/src/pages/app/Layout/menu/item.ts index 90c9f206f..c032a240c 100644 --- a/src/pages/app/Layout/menu/item.ts +++ b/src/pages/app/Layout/menu/item.ts @@ -47,22 +47,18 @@ export const menuGroups = (): MenuGroup[] => [{ title: msg => msg.menu.dashboard, route: '/data/dashboard', icon: Stopwatch, - mobile: true, }, { title: msg => msg.menu.dataReport, route: '/data/report', icon: Table, - mobile: true, }, { title: msg => msg.menu.siteAnalysis, route: ANALYSIS_ROUTE, icon: Trend, - mobile: true, }, { title: msg => msg.menu.dataClear, route: '/data/manage', icon: Database, - mobile: true, }] }, { title: msg => msg.menu.behavior, @@ -72,12 +68,10 @@ export const menuGroups = (): MenuGroup[] => [{ title: msg => msg.menu.habit, route: '/behavior/habit', icon: Aim, - mobile: true, }, { title: msg => msg.menu.limit, route: '/behavior/limit', icon: Timer, - mobile: true, }] }, { title: msg => msg.menu.additional, @@ -86,20 +80,22 @@ export const menuGroups = (): MenuGroup[] => [{ children: [{ title: msg => msg.menu.siteManage, route: '/additional/site-manage', - icon: Website + icon: Website, + mobile: false, }, { title: msg => msg.menu.whitelist, route: '/additional/whitelist', - icon: Whitelist + icon: Whitelist, + mobile: false, }, { title: msg => msg.menu.mergeRule, route: MERGE_ROUTE, - icon: Rank + icon: Rank, + mobile: false, }, { title: msg => msg.base.option, route: '/additional/option', icon: SetUp, - mobile: true, }] }, { title: msg => msg.menu.other, @@ -110,16 +106,17 @@ export const menuGroups = (): MenuGroup[] => [{ href: getGuidePageUrl(), icon: Memo, index: '_guide', + mobile: false, }, { title: msg => msg.menu.helpUs, route: '/other/help', icon: HelpFilled, + mobile: false, }, { title: msg => msg.menu.about, route: '/other/about', icon: About, - mobile: true, }] }] -export const navMenus = (): MenuItem[] => menuGroups().flatMap(g => g.children || []).filter(m => m.mobile) \ No newline at end of file +export const navMenus = (): MenuItem[] => menuGroups().flatMap(g => g.children || []).filter(({ mobile = true }) => mobile) \ No newline at end of file diff --git a/src/pages/app/components/Option/Select.tsx b/src/pages/app/components/Option/Select.tsx index d1c178712..a4fddbdc7 100644 --- a/src/pages/app/components/Option/Select.tsx +++ b/src/pages/app/components/Option/Select.tsx @@ -1,35 +1,22 @@ -import { t } from "@app/locale" -import { ElCard, ElSelect } from "element-plus" -import { defineComponent, h, ref, useSlots, watch } from "vue" -import { useRouter } from "vue-router" +import { ElCard, ElOption, ElSelect } from "element-plus" +import { defineComponent, h, useSlots } from "vue" import ContentContainer from "../common/ContentContainer" -import { CATE_LABELS, changeQuery, type OptionCategory, parseQuery } from "./common" +import { useCategory } from "./useCategory" const _default = defineComponent(() => { - const tab = ref(parseQuery() || 'appearance') - const router = useRouter() - watch(tab, () => changeQuery(tab.value, router)) - + const { category, setCategory, getLabel } = useCategory() const slots = useSlots() return () => ( ( - tab.value = val} - > - {Object.keys(slots).map(cate => ( - - ))} + + {Object.keys(slots).map(c => )} ), default: () => { - const slot = slots[tab.value] - return !!slot && {h(slot)} + const slot = slots[category.value] + return !!slot && {h(slot)} } }} /> ) diff --git a/src/pages/app/components/Option/Tabs.tsx b/src/pages/app/components/Option/Tabs.tsx index 3d60d60be..892163e49 100644 --- a/src/pages/app/components/Option/Tabs.tsx +++ b/src/pages/app/components/Option/Tabs.tsx @@ -2,81 +2,48 @@ import { t } from "@app/locale" import { Download, Refresh, Upload } from "@element-plus/icons-vue" import { css } from '@emotion/css' import Flex from "@pages/components/Flex" -import { ElLink, ElMessage, ElMessageBox, ElTabPane, ElTabs, ElTooltip, TabPaneName, useNamespace } from "element-plus" -import { defineComponent, h, ref, useSlots } from "vue" -import { useRouter } from "vue-router" +import { ElButton, ElMessage, ElMessageBox, ElTabPane, ElTabs, TabPaneName, useNamespace } from "element-plus" +import { defineComponent, h, useSlots } from "vue" import ContentContainer from "../common/ContentContainer" -import { CATE_LABELS, changeQuery, type OptionCategory, parseQuery } from "./common" import { createFileInput, exportSettings, importSettings } from "./export-import" +import { type OptionCategory, useCategory } from './useCategory' -const TOOLBAR_NAME = "toolbar" - -type TooltipProps = { - onReset?: NoArgCallback -} - -const Toolbar = defineComponent(props => { - const handleExport = async () => { - try { - await exportSettings() - ElMessage.success(t(msg => msg.option.exportSuccess)) - } catch (error) { - ElMessage.error('Export failed: ' + (error as Error).message) - } +const handleExport = async () => { + try { + await exportSettings() + ElMessage.success(t(msg => msg.option.exportSuccess)) + } catch (error) { + ElMessage.error('Export failed: ' + (error as Error).message) } +} - const handleImport = async () => { - try { - const fileContent = await createFileInput() - // User cancelled, don't show error message - if (!fileContent) return - await importSettings(fileContent) - ElMessageBox({ - message: t(msg => msg.option.importConfirm), - type: "success", - confirmButtonText: t(msg => msg.option.reloadButton), - closeOnPressEscape: false, - closeOnClickModal: false - }).then(() => { - window.location.reload() - }).catch(() => {/* do nothing */ }) - } catch (error) { - ElMessage.error(t(msg => msg.option.importError)) - } +const handleImport = async () => { + try { + const fileContent = await createFileInput() + // User cancelled, don't show error message + if (!fileContent) return + await importSettings(fileContent) + ElMessageBox({ + message: t(msg => msg.option.importConfirm), + type: "success", + confirmButtonText: t(msg => msg.option.reloadButton), + closeOnPressEscape: false, + closeOnClickModal: false + }).then(() => { + window.location.reload() + }).catch(() => {/* do nothing */ }) + } catch (error) { + ElMessage.error(t(msg => msg.option.importError)) } +} - return () => ( - ev.stopPropagation()}> - msg.option.exportButton)}> - - - msg.option.importButton)}> - - - - {t(msg => msg.option.resetButton)} - - - ) -}, { - props: ['onReset'] -}) - -type Props = { onReset: (cate: OptionCategory) => Promise | void } +type Props = { onReset: (category: OptionCategory) => Promise | void } const useStyle = () => { const tabsNs = useNamespace('tabs') return css` & { - .${tabsNs.e('nav')} { - float: none !important; - - #tab-${TOOLBAR_NAME} { - position: absolute; - right: 0px; - } - } .${tabsNs.e('item')} { font-size: 16px; height: 43px; @@ -91,39 +58,41 @@ const useStyle = () => { } const _default = defineComponent(props => { - const tab = ref(parseQuery() ?? 'appearance') - const router = useRouter() - const handleReset = () => props.onReset?.(tab.value) + const { category, setCategory, getLabel } = useCategory() + + const handleReset = () => props.onReset?.(category.value) const handleBeforeLeave = (activeName: TabPaneName): Promise => { - if (activeName === TOOLBAR_NAME) { - // do nothing, and never happen - return Promise.reject() - } - const cate = activeName as OptionCategory - tab.value = cate - // Change the query of current route - changeQuery(cate, router) + setCategory(activeName) return Promise.resolve(true) } - const tabsCls = useStyle() + + const cls = useStyle() + return () => ( + + + {t(msg => msg.option.exportButton)} + + + {t(msg => msg.option.importButton)} + + + {t(msg => msg.option.resetButton)} + + {Object.entries(useSlots()).filter(([key]) => key !== 'default').map(([key, slot]) => ( - + {!!slot && h(slot)} ))} - }} - /> ) diff --git a/src/pages/app/components/Option/components/AccessibilityOption.tsx b/src/pages/app/components/Option/categories/Accessibility.tsx similarity index 86% rename from src/pages/app/components/Option/components/AccessibilityOption.tsx rename to src/pages/app/components/Option/categories/Accessibility.tsx index 9fbd03495..44a315621 100644 --- a/src/pages/app/components/Option/components/AccessibilityOption.tsx +++ b/src/pages/app/components/Option/categories/Accessibility.tsx @@ -1,9 +1,9 @@ import { defaultAccessibility } from "@util/constant/option" import { ElSwitch } from "element-plus" import { defineComponent } from "vue" -import { type OptionInstance } from "../common" +import { OptionItem } from '../components' import { useOption } from "../useOption" -import OptionItem from "./OptionItem" +import type { CategoryInstance } from './types' function copy(target: timer.option.AccessibilityOption, source: timer.option.AccessibilityOption) { target.chartDecal = source.chartDecal @@ -13,7 +13,7 @@ const _default = defineComponent((_, ctx) => { const { option } = useOption({ defaultValue: defaultAccessibility, copy }) ctx.expose({ reset: () => copy(option, defaultAccessibility()) - } satisfies OptionInstance) + } satisfies CategoryInstance) return () => ( msg.option.accessibility.chartDecal} defaultValue={false}> option.chartDecal = val as boolean} /> diff --git a/src/pages/app/components/Option/components/AppearanceOption/DarkModeInput.tsx b/src/pages/app/components/Option/categories/Appearance/DarkModeInput.tsx similarity index 88% rename from src/pages/app/components/Option/components/AppearanceOption/DarkModeInput.tsx rename to src/pages/app/components/Option/categories/Appearance/DarkModeInput.tsx index 9622d8449..12c17f92e 100644 --- a/src/pages/app/components/Option/components/AppearanceOption/DarkModeInput.tsx +++ b/src/pages/app/components/Option/categories/Appearance/DarkModeInput.tsx @@ -9,9 +9,12 @@ import { ElSelect, ElTimePicker } from "element-plus" import { computed, defineComponent, StyleValue } from "vue" const ALL_MODES: timer.option.DarkMode[] = ["default", "on", "off", "timed"] -const labelOfMode = (mode: timer.option.DarkMode) => mode === 'default' - ? t(msg => msg.option.followBrowser) - : t(msg => msg.option.appearance.darkMode.options[mode]) +const MODE_LABELS: Record = { + default: t(msg => msg.option.followBrowser), + on: t(msg => msg.option.on), + off: t(msg => msg.option.off), + timed: t(msg => msg.option.appearance.darkMode.timed) +} function computeSecondToDate(secondOfDate: number): Date { const now = new Date() @@ -49,7 +52,7 @@ const _default = defineComponent(props => { size="small" style={{ width: "120px" }} onChange={val => props.onChange?.(val as timer.option.DarkMode, [props.startSecond, props.endSecond])} - options={ALL_MODES.map(value => ({ value, label: labelOfMode(value) }))} + options={ALL_MODES.map(value => ({ value, label: MODE_LABELS[value] }))} /> {props.modelValue === "timed" && <> msg.option.followBrowser @@ -68,7 +66,7 @@ const _default = defineComponent((_props, ctx) => { handleSidePanelChange(DEFAULT_SIDE_PANEL_ENABLED) copy(option, defaultAppearance()) } - } satisfies OptionInstance) + } satisfies CategoryInstance) const handleLocaleChange = (newVal: timer.option.LocaleOption) => { option.locale = newVal diff --git a/src/pages/app/components/Option/components/BackupOption/AutoInput.tsx b/src/pages/app/components/Option/categories/Backup/AutoInput.tsx similarity index 100% rename from src/pages/app/components/Option/components/BackupOption/AutoInput.tsx rename to src/pages/app/components/Option/categories/Backup/AutoInput.tsx diff --git a/src/pages/app/components/Option/components/BackupOption/Clear/Sop.tsx b/src/pages/app/components/Option/categories/Backup/Clear/Sop.tsx similarity index 100% rename from src/pages/app/components/Option/components/BackupOption/Clear/Sop.tsx rename to src/pages/app/components/Option/categories/Backup/Clear/Sop.tsx diff --git a/src/pages/app/components/Option/components/BackupOption/Clear/Step1.tsx b/src/pages/app/components/Option/categories/Backup/Clear/Step1.tsx similarity index 100% rename from src/pages/app/components/Option/components/BackupOption/Clear/Step1.tsx rename to src/pages/app/components/Option/categories/Backup/Clear/Step1.tsx diff --git a/src/pages/app/components/Option/components/BackupOption/Clear/Step2.tsx b/src/pages/app/components/Option/categories/Backup/Clear/Step2.tsx similarity index 100% rename from src/pages/app/components/Option/components/BackupOption/Clear/Step2.tsx rename to src/pages/app/components/Option/categories/Backup/Clear/Step2.tsx diff --git a/src/pages/app/components/Option/components/BackupOption/Clear/index.tsx b/src/pages/app/components/Option/categories/Backup/Clear/index.tsx similarity index 100% rename from src/pages/app/components/Option/components/BackupOption/Clear/index.tsx rename to src/pages/app/components/Option/categories/Backup/Clear/index.tsx diff --git a/src/pages/app/components/Option/components/BackupOption/ClientTable.tsx b/src/pages/app/components/Option/categories/Backup/ClientTable.tsx similarity index 100% rename from src/pages/app/components/Option/components/BackupOption/ClientTable.tsx rename to src/pages/app/components/Option/categories/Backup/ClientTable.tsx diff --git a/src/pages/app/components/Option/components/BackupOption/Download/Sop.tsx b/src/pages/app/components/Option/categories/Backup/Download/Sop.tsx similarity index 100% rename from src/pages/app/components/Option/components/BackupOption/Download/Sop.tsx rename to src/pages/app/components/Option/categories/Backup/Download/Sop.tsx diff --git a/src/pages/app/components/Option/components/BackupOption/Download/Step2.tsx b/src/pages/app/components/Option/categories/Backup/Download/Step2.tsx similarity index 100% rename from src/pages/app/components/Option/components/BackupOption/Download/Step2.tsx rename to src/pages/app/components/Option/categories/Backup/Download/Step2.tsx diff --git a/src/pages/app/components/Option/components/BackupOption/Download/index.tsx b/src/pages/app/components/Option/categories/Backup/Download/index.tsx similarity index 100% rename from src/pages/app/components/Option/components/BackupOption/Download/index.tsx rename to src/pages/app/components/Option/categories/Backup/Download/index.tsx diff --git a/src/pages/app/components/Option/components/BackupOption/Footer.tsx b/src/pages/app/components/Option/categories/Backup/Footer.tsx similarity index 98% rename from src/pages/app/components/Option/components/BackupOption/Footer.tsx rename to src/pages/app/components/Option/categories/Backup/Footer.tsx index 16687e34d..3d74b33aa 100644 --- a/src/pages/app/components/Option/components/BackupOption/Footer.tsx +++ b/src/pages/app/components/Option/categories/Backup/Footer.tsx @@ -55,7 +55,7 @@ const _default = defineComponent<{ type: timer.backup.Type }>(props => { return () => <> - + {t(msg => msg.button.test)} diff --git a/src/pages/app/components/Option/components/BackupOption/index.tsx b/src/pages/app/components/Option/categories/Backup/index.tsx similarity index 62% rename from src/pages/app/components/Option/components/BackupOption/index.tsx rename to src/pages/app/components/Option/categories/Backup/index.tsx index 9e4f0a52a..d752bd397 100644 --- a/src/pages/app/components/Option/components/BackupOption/index.tsx +++ b/src/pages/app/components/Option/categories/Backup/index.tsx @@ -11,13 +11,11 @@ import { import { t } from "@app/locale" import { ElInput, ElSelect } from "element-plus" import { computed, defineComponent } from "vue" -import { type OptionInstance } from "../../common" -import OptionItem from "../OptionItem" -import OptionLines from '../OptionLines' -import OptionTooltip from "../OptionTooltip" +import { OptionItem, OptionLines, OptionTooltip } from '../../components' +import type { CategoryInstance } from '../types' import AutoInput from "./AutoInput" import Footer from "./Footer" -import { useOptionState } from "./state" +import { useBackup } from "./useBackup" const ALL_TYPES: timer.backup.Type[] = [ 'none', @@ -26,8 +24,8 @@ const ALL_TYPES: timer.backup.Type[] = [ 'obsidian_local_rest_api', ] -const TYPE_NAMES: { [t in timer.backup.Type]: string } = { - none: t(msg => msg.option.backup.meta.none.label), +const TYPE_NAMES: Record = { + none: t(msg => msg.option.off), gist: 'GitHub Gist', obsidian_local_rest_api: 'Obsidian - Local REST API', web_dav: 'WebDAV' @@ -37,58 +35,49 @@ const LONG_INPUT_WIDTH = 'min(400px, calc(100vw - 80px))' const _default = defineComponent((_, ctx) => { const { - backupType, clientName, reset, - autoBackUp, autoBackUpInterval, - auth, account, password, + option, auth, account, password, reset, ext, setExtField, - } = useOptionState() + } = useBackup() - const isNotNone = computed(() => backupType.value && backupType.value !== 'none') + const isNotNone = computed(() => option.backupType !== 'none') - ctx.expose({ reset } satisfies OptionInstance) + ctx.expose({ reset } satisfies CategoryInstance) return () => msg.option.backup.type} defaultValue={TYPE_NAMES['none']}> backupType.value = val} + onChange={(val: timer.backup.Type) => option.backupType = val} options={ALL_TYPES.map(value => ({ value, label: TYPE_NAMES[value] }))} /> + + option.autoBackUp = val} + onIntervalChange={val => val !== undefined && (option.autoBackUpInterval = val)} + /> + "{input}"} - defaultValue={t(msg => msg.option.no)} + v-show={option.backupType === 'gist'} + label="Personal Access Token {info} {input}" + v-slots={{ + info: () => {t(msg => msg.option.backup.meta.gist.authInfo)} + }} > - autoBackUp.value = val} - onIntervalChange={val => autoBackUpInterval.value = val} + auth.value = val?.trim?.() || ''} /> - {backupType.value === 'gist' && <> - 'Personal Access Token {info} {input}'} - v-slots={{ - info: () => {t(msg => msg.option.backup.meta.gist.authInfo)} - }} - > - auth.value = val?.trim?.() || ''} - /> - - } - {backupType.value === 'obsidian_local_rest_api' && <> + {option.backupType === 'obsidian_local_rest_api' && <> msg.option.backup.label.endpoint} v-slots={{ info: () => {t(msg => msg.option.backup.meta.obsidian_local_rest_api.endpointInfo)} @@ -102,7 +91,7 @@ const _default = defineComponent((_, ctx) => { onInput={val => setExtField('endpoint', val)} /> - "Vault Name {input}"}> + { onInput={val => setExtField('bucket', val)} /> - msg.option.backup.label.path} required> + msg.option.backup.label.path} required> { onInput={val => setExtField('dirPath', val)} /> - "Authorization {input}"} required> + { /> } - {backupType.value === 'web_dav' && <> + {option.backupType === 'web_dav' && <> msg.option.backup.label.endpoint} v-slots={{ info: () => '' }} required @@ -145,7 +133,7 @@ const _default = defineComponent((_, ctx) => { onInput={val => setExtField('endpoint', val)} /> - msg.option.backup.label.path} required> + msg.option.backup.label.path} required> { onInput={val => setExtField('dirPath', val)} /> - msg.option.backup.label.account} required> + msg.option.backup.label.account} required> { onInput={val => account.value = val?.trim?.()} /> - msg.option.backup.label.password} required> + msg.option.backup.label.password} required> { } msg.option.backup.client}> clientName.value = val?.trim?.() || ''} + onInput={val => option.clientName = val?.trim?.() ?? ''} /> - {isNotNone.value &&
    } + {isNotNone.value &&
    } }) diff --git a/src/pages/app/components/Option/categories/Backup/useBackup.ts b/src/pages/app/components/Option/categories/Backup/useBackup.ts new file mode 100644 index 000000000..0857290c0 --- /dev/null +++ b/src/pages/app/components/Option/categories/Backup/useBackup.ts @@ -0,0 +1,86 @@ +import { trySendMsg2Runtime } from '@api/chrome/runtime' +import { defaultBackup } from "@util/constant/option" +import { computed, watch } from "vue" +import { useOption } from '../../useOption' + +function copy(target: timer.option.BackupOption, source: timer.option.BackupOption) { + target.backupType = source.backupType + target.autoBackUp = source.autoBackUp + target.autoBackUpInterval = source.autoBackUpInterval + target.backupExts = source.backupExts + target.backupAuths = source.backupAuths + target.clientName = source.clientName + target.backupLogin = source.backupLogin +} + +export const useBackup = () => { + const { option, loading } = useOption({ defaultValue: defaultBackup, copy }) + + watch([ + () => option.autoBackUp, + () => option.autoBackUpInterval, + ], () => !loading.value && setTimeout(() => trySendMsg2Runtime('resetBackupScheduler'))) + + const reset = () => { + const defaultOption = defaultBackup() + // Only reset type and auto flag + option.backupType = defaultOption.backupType + option.autoBackUp = defaultOption.autoBackUp + } + + const auth = computed({ + get: () => option.backupAuths[option.backupType], + set: val => { + const typeVal = option.backupType + if (!typeVal) return + const newAuths = { + ...option.backupAuths, + [typeVal]: val, + } + option.backupAuths = newAuths + } + }) + + const ext = computed({ + get: () => option.backupExts?.[option.backupType], + set: val => { + const typeVal = option.backupType + if (!typeVal) return + const newExts = { + ...option.backupExts, + [typeVal]: val, + } + option.backupExts = newExts + }, + }) + + const setExtField = (field: keyof timer.backup.TypeExt, val: string) => { + const newVal = { ...(ext.value || {}), [field]: val?.trim?.() } + ext.value = newVal + } + + const setLoginField = (field: keyof timer.backup.LoginInfo, val: string) => { + const typeVal = option.backupType + if (!typeVal) return + const newLogin = { + ...option.backupLogin, + [typeVal]: { ...option.backupLogin?.[typeVal], [field]: val } + } + option.backupLogin = newLogin + } + + const account = computed({ + get: () => option.backupLogin?.[option.backupType]?.acc, + set: val => setLoginField('acc', val ?? '') + }) + + const password = computed({ + get: () => option.backupLogin?.[option.backupType]?.psw, + set: val => setLoginField('psw', val ?? '') + }) + + return { + option, auth, account, password, reset, + ext, setExtField, + } +} diff --git a/src/pages/app/components/Option/components/LimitOption/index.tsx b/src/pages/app/components/Option/categories/Limit/index.tsx similarity index 97% rename from src/pages/app/components/Option/components/LimitOption/index.tsx rename to src/pages/app/components/Option/categories/Limit/index.tsx index aebf7517f..33bb92ded 100644 --- a/src/pages/app/components/Option/components/LimitOption/index.tsx +++ b/src/pages/app/components/Option/categories/Limit/index.tsx @@ -12,10 +12,9 @@ import { locale } from '@i18n' import { defaultLimit } from "@util/constant/option" import { ElButton, ElInput, ElInputNumber, ElMessage, ElMessageBox, ElSelect, ElSwitch, useNamespace } from "element-plus" import { defineComponent, type StyleValue } from "vue" -import { type OptionInstance } from "../../common" +import { OptionItem, OptionLines } from '../../components' import { useOption } from "../../useOption" -import OptionItem from "../OptionItem" -import OptionLines from '../OptionLines' +import type { CategoryInstance } from '../types' import { usePswEdit } from "./usePswEdit" import { useVerify } from "./useVerify" @@ -90,7 +89,7 @@ const _default = defineComponent((_, ctx) => { ctx.expose({ reset: () => verify().then(() => reset(option)).catch(() => { }) - } satisfies OptionInstance) + } satisfies CategoryInstance) const handleLevelChange = async (val: timer.limit.RestrictionLevel) => { try { diff --git a/src/pages/app/components/Option/components/LimitOption/usePswEdit.tsx b/src/pages/app/components/Option/categories/Limit/usePswEdit.tsx similarity index 100% rename from src/pages/app/components/Option/components/LimitOption/usePswEdit.tsx rename to src/pages/app/components/Option/categories/Limit/usePswEdit.tsx diff --git a/src/pages/app/components/Option/components/LimitOption/useVerify.ts b/src/pages/app/components/Option/categories/Limit/useVerify.ts similarity index 100% rename from src/pages/app/components/Option/components/LimitOption/useVerify.ts rename to src/pages/app/components/Option/categories/Limit/useVerify.ts diff --git a/src/pages/app/components/Option/categories/Notification/Footer.tsx b/src/pages/app/components/Option/categories/Notification/Footer.tsx new file mode 100644 index 000000000..bdd929603 --- /dev/null +++ b/src/pages/app/components/Option/categories/Notification/Footer.tsx @@ -0,0 +1,28 @@ +import { t } from '@app/locale' +import { Operation } from '@element-plus/icons-vue' +import Flex from '@pages/components/Flex' +import processor from '@service/notification/processor' +import { ElButton, ElDivider, ElMessage } from 'element-plus' +import type { FunctionalComponent } from 'vue' + +const Footer: FunctionalComponent<{}> = () => { + const handleTest = async () => { + const result = await processor.doSend() + if (result.success) { + ElMessage.success('Valid!') + } else { + ElMessage.error(`${result.errorMsg}`) + } + } + + return <> + + + + {t(msg => msg.button.test)} + + + +} + +export default Footer \ No newline at end of file diff --git a/src/pages/app/components/Option/categories/Notification/index.tsx b/src/pages/app/components/Option/categories/Notification/index.tsx new file mode 100644 index 000000000..7a9c40089 --- /dev/null +++ b/src/pages/app/components/Option/categories/Notification/index.tsx @@ -0,0 +1,102 @@ +import { t } from '@app/locale' +import { ElInput, ElSelect, ElTimePicker } from 'element-plus' +import { computed, defineComponent, StyleValue } from 'vue' +import { OptionItem, OptionLines } from '../../components' +import type { CategoryInstance } from '../types' +import Footer from './Footer' +import { useNotification } from './useNotification' + +const CYCLE_LABELS: Record = { + none: t(msg => msg.option.off), + daily: t(msg => msg.option.notification.cycle.daily), + weekly: t(msg => msg.option.notification.cycle.weekly), +} + +const METHOD_LABELS: Record = { + browser: t(msg => msg.option.notification.method.browser), + callback: t(msg => msg.option.notification.method.callback.label), +} + +const ALL_WEEKDAYS = t(msg => msg.calendar.weekDays)?.split('|') || [] + +const PADDING: StyleValue = { paddingInlineStart: '2px' } + +const Notification = defineComponent((_, ctx) => { + const { option, weekday, datetime, reset } = useNotification() + + const isNotNone = computed(() => option.notificationCycle !== 'none') + + ctx.expose({ + reset, + } satisfies CategoryInstance) + + return () => ( + + msg.option.notification.cycle.label} defaultValue={msg => msg.option.off}> + option.notificationCycle = val as timer.notification.Cycle} + options={Object.entries(CYCLE_LABELS).map(([value, label]) => ({ value: value as timer.notification.Cycle, label }))} + /> + {option.notificationCycle === 'weekly' && <> + weekday.value = val as number} + options={ALL_WEEKDAYS.map((label, idx) => ({ value: idx, label }))} + /> + - + } + {(option.notificationCycle === 'daily' || option.notificationCycle === 'weekly') && ( + datetime.value = val as Date} + format="HH:mm" + clearable={false} + /> + )} + + msg.option.notification.method.label}> + option.notificationMethod = val as timer.notification.Method} + options={Object.entries(METHOD_LABELS).map(([value, label]) => ({ value: value as timer.notification.Method, label }))} + /> + + {isNotNone.value && option.notificationMethod === 'callback' && ( + <> + msg.option.notification.method.callback.url} required> + option.notificationEndpoint = val} + placeholder="https://example.com/api/notification" + /> + + + option.notificationAuthToken = val} + placeholder="Optional" + /> + + + )} + {isNotNone.value &&
    } + + ) +}) + +export default Notification diff --git a/src/pages/app/components/Option/categories/Notification/useNotification.ts b/src/pages/app/components/Option/categories/Notification/useNotification.ts new file mode 100644 index 000000000..8b60e263d --- /dev/null +++ b/src/pages/app/components/Option/categories/Notification/useNotification.ts @@ -0,0 +1,69 @@ +import { trySendMsg2Runtime } from '@api/chrome/runtime' +import { defaultNotification } from '@util/constant/option' +import { computed, watch } from 'vue' +import { useOption } from '../../useOption' + +function copy(target: timer.option.NotificationOption, source: timer.option.NotificationOption) { + target.notificationCycle = source.notificationCycle + target.notificationOffset = source.notificationOffset + target.notificationMethod = source.notificationMethod + target.notificationEndpoint = source.notificationEndpoint + target.notificationAuthToken = source.notificationAuthToken +} + +const MIN_PER_DAY = 24 * 60 + +export const useNotification = () => { + const { option, loading } = useOption({ defaultValue: defaultNotification, copy }) + + watch([ + () => option.notificationCycle, + () => option.notificationOffset, + ], () => !loading.value && setTimeout(() => trySendMsg2Runtime('resetNotificationScheduler'))) + + const weekday = computed({ + get() { + if (option.notificationCycle !== 'weekly') return null + let offset = option.notificationOffset + offset = Number.isNaN(offset) ? 0 : offset + return Math.floor(offset / MIN_PER_DAY) + }, + set(val) { + if (option.notificationCycle !== 'weekly' || val === null) return + const timeMinutes = option.notificationOffset % MIN_PER_DAY + option.notificationOffset = val * MIN_PER_DAY + timeMinutes + }, + }) + + const datetime = computed({ + get() { + if (option.notificationCycle === 'none') return null + let offset = option.notificationOffset + offset = Number.isNaN(offset) ? 0 : offset + const hours = Math.floor(offset / 60) + const minutes = offset % 60 + const date = new Date() + date.setHours(hours, minutes, 0, 0) + return date + }, + set(val) { + if (val === null || option.notificationCycle === 'none') return + const hours = val.getHours() + const minutes = val.getMinutes() + const dateMinutes = hours * 60 + minutes + + if (option.notificationCycle === 'daily') { + option.notificationOffset = dateMinutes + } else if (option.notificationCycle === 'weekly') { + option.notificationOffset = (weekday.value ?? 0) * MIN_PER_DAY + dateMinutes + } + }, + }) + + const reset = () => { + option.notificationCycle = defaultNotification().notificationCycle + // other fields needn't be reset + } + + return { option, weekday, datetime, reset } +} diff --git a/src/pages/app/components/Option/components/TrackingOption.tsx b/src/pages/app/components/Option/categories/Tracking.tsx similarity index 96% rename from src/pages/app/components/Option/components/TrackingOption.tsx rename to src/pages/app/components/Option/categories/Tracking.tsx index b258b1931..7f9f6da2b 100644 --- a/src/pages/app/components/Option/components/TrackingOption.tsx +++ b/src/pages/app/components/Option/categories/Tracking.tsx @@ -16,12 +16,9 @@ import { defaultTracking } from "@util/constant/option" import { MILL_PER_SECOND } from "@util/time" import { ElMessage, ElMessageBox, ElSelect, ElSwitch, ElTimePicker, ElTooltip } from "element-plus" import { computed, defineComponent } from "vue" -import { type OptionInstance } from "../common" +import { OptionItem, OptionLines, OptionTag, OptionTooltip } from '../components' import { useOption } from "../useOption" -import OptionItem from "./OptionItem" -import OptionLines from './OptionLines' -import OptionTag from './OptionTag' -import OptionTooltip from './OptionTooltip' +import type { CategoryInstance } from './types' const ALL_STORAGES: Record = { classic: 'chrome.storage.local', @@ -61,7 +58,7 @@ const _default = defineComponent((_props, ctx) => { option.autoPauseInterval = oldInterval option.storage = oldStorage } - ctx.expose({ reset } satisfies OptionInstance) + ctx.expose({ reset } satisfies CategoryInstance) const { refresh: changeStorageType, loading: storageMigrating } = useManualRequest(async (type: timer.option.StorageType) => { await immigration.migrateStorage(type) diff --git a/src/pages/app/components/Option/categories/index.ts b/src/pages/app/components/Option/categories/index.ts new file mode 100644 index 000000000..70a309ed1 --- /dev/null +++ b/src/pages/app/components/Option/categories/index.ts @@ -0,0 +1,8 @@ +export { default as Accessibility } from "./Accessibility" +export { default as Appearance } from "./Appearance" +export { default as Backup } from "./Backup" +export { default as Limit } from "./Limit" +export { default as Notification } from "./Notification" +export { default as Tracking } from "./Tracking" +export * from "./types" + diff --git a/src/pages/app/components/Option/categories/types.ts b/src/pages/app/components/Option/categories/types.ts new file mode 100644 index 000000000..42d15e510 --- /dev/null +++ b/src/pages/app/components/Option/categories/types.ts @@ -0,0 +1,4 @@ + +export interface CategoryInstance { + reset(): Promise | void +} diff --git a/src/pages/app/components/Option/common.tsx b/src/pages/app/components/Option/common.tsx deleted file mode 100644 index 189fed850..000000000 --- a/src/pages/app/components/Option/common.tsx +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { type I18nKey } from "@app/locale" -import { type Router, useRoute } from "vue-router" - -export const ALL_CATEGORIES = ["appearance", "tracking", 'limit', 'accessibility', 'backup'] as const -export type OptionCategory = typeof ALL_CATEGORIES[number] - -export type OptionInstance = { - reset: () => Promise | void -} - -const PARAM = "i" - -export function parseQuery(): OptionCategory | undefined { - const initialQuery = useRoute().query?.[PARAM] - const queryVal: string | null | undefined = Array.isArray(initialQuery) ? initialQuery[0] : initialQuery - if (!queryVal) return undefined - const cate = queryVal as OptionCategory - return ALL_CATEGORIES.includes(cate) ? cate : undefined -} - -export function changeQuery(cate: OptionCategory, router: Router) { - const query: Record = {} - query[PARAM] = cate - router.replace({ query }) -} - -export const CATE_LABELS: Record = { - appearance: msg => msg.option.appearance.title, - tracking: msg => msg.option.tracking.title, - limit: msg => msg.menu.limit, - accessibility: msg => msg.option.accessibility.title, - backup: msg => msg.option.backup.title, -} \ No newline at end of file diff --git a/src/pages/app/components/Option/components/BackupOption/state.ts b/src/pages/app/components/Option/components/BackupOption/state.ts deleted file mode 100644 index b77e492c2..000000000 --- a/src/pages/app/components/Option/components/BackupOption/state.ts +++ /dev/null @@ -1,118 +0,0 @@ -import optionHolder from "@service/components/option-holder" -import optionService from "@service/option-service" -import { defaultBackup } from "@util/constant/option" -import { computed, onBeforeMount, type Ref, ref, toRaw, watch } from "vue" - -type Result = { - reset: () => void - backupType: Ref - clientName: Ref - autoBackUp: Ref - autoBackUpInterval: Ref - auth: Ref - account: Ref - password: Ref - ext: Ref - setExtField: (field: keyof timer.backup.TypeExt, val: string) => void -} - -export const useOptionState = (): Result => { - const defaultOption = defaultBackup() - const backupType = ref(defaultOption.backupType) - const autoBackUp = ref(defaultOption.autoBackUp) - const autoBackUpInterval = ref(defaultOption.autoBackUpInterval) - const backupExts = ref(defaultOption.backupExts) - const backupAuths = ref(defaultOption.backupAuths) - const clientName = ref(defaultOption.clientName) - const login = ref(defaultOption.backupLogin) - - watch([ - backupType, - autoBackUp, autoBackUpInterval, - backupExts, backupAuths, login, - clientName, - ], () => optionService.setBackupOption({ - backupType: backupType.value, - autoBackUp: autoBackUp.value, - autoBackUpInterval: autoBackUpInterval.value, - backupExts: toRaw(backupExts.value), - backupAuths: toRaw(backupAuths.value), - clientName: clientName.value, - backupLogin: toRaw(login.value), - })) - - onBeforeMount(async () => { - const val = await optionHolder.get() - backupType.value = val?.backupType - autoBackUp.value = val?.autoBackUp - autoBackUpInterval.value = val?.autoBackUpInterval - backupExts.value = val?.backupExts - backupAuths.value = val?.backupAuths - clientName.value = val?.clientName - login.value = val?.backupLogin - }) - - const reset = () => { - // Only reset type and auto flag - backupType.value = defaultOption.backupType - autoBackUp.value = defaultOption.autoBackUp - } - - const auth = computed({ - get: () => backupAuths.value?.[backupType?.value], - set: val => { - const typeVal = backupType.value - if (!typeVal) return - const newAuths = { - ...backupAuths.value || {}, - [typeVal]: val, - } - backupAuths.value = newAuths - } - }) - - const ext = computed({ - get: () => backupExts.value?.[backupType.value], - set: val => { - const typeVal = backupType.value - if (!typeVal) return - const newExts = { - ...backupExts.value || {}, - [typeVal]: val, - } - backupExts.value = newExts - }, - }) - - const setExtField = (field: keyof timer.backup.TypeExt, val: string) => { - const newVal = { ...(ext.value || {}), [field]: val?.trim?.() } - ext.value = newVal - } - - const setLoginField = (field: keyof timer.backup.LoginInfo, val: string) => { - const typeVal = backupType.value - if (!typeVal) return - const newLogin = { - ...login.value || {}, - [typeVal]: { ...(login.value?.[typeVal] || {}), [field]: val } - } - login.value = newLogin - } - - const account = computed({ - get: () => login.value?.[backupType?.value]?.acc, - set: (val: string | undefined) => setLoginField('acc', val ?? '') - }) - - const password = computed({ - get: () => login.value?.[backupType?.value]?.psw, - set: (val: string | undefined) => setLoginField('psw', val ?? '') - }) - - return { - backupType, clientName, reset, - autoBackUp, autoBackUpInterval, - auth, account, password, - ext, setExtField, - } -} diff --git a/src/pages/app/components/Option/components/OptionItem.tsx b/src/pages/app/components/Option/components/Item.tsx similarity index 94% rename from src/pages/app/components/Option/components/OptionItem.tsx rename to src/pages/app/components/Option/components/Item.tsx index 664f7c3e0..068f6c381 100644 --- a/src/pages/app/components/Option/components/OptionItem.tsx +++ b/src/pages/app/components/Option/components/Item.tsx @@ -27,7 +27,8 @@ const computedDefValText = (defVal: Props['defaultValue']): string | number | un const TAG_STYLE: StyleValue = { height: '20px', marginInlineStart: '4px' } const renderLabel = (label: Props['label'], param: any) => { - return typeof label === 'string' ? label : tN(label, param) + const key = typeof label === 'string' ? () => label : label + return tN(key, param) } export function isOptionItem(component: VNode): boolean { @@ -88,7 +89,7 @@ const useStyle = () => { return { lineCls, smCls } } -const OptionItem = defineComponent((props, { slots }) => { +const Item = defineComponent((props, { slots }) => { const defaultText = computed(() => computedDefValText(props.defaultValue)) const mediaSize = useMediaSize() const isSmScreen = computed(() => mediaSize.value <= MediaSize.sm) @@ -115,7 +116,7 @@ const OptionItem = defineComponent((props, { slots }) => { } }, { props: ['label', 'required', 'defaultValue'] }) -const OptionItemAny = OptionItem as any -OptionItemAny[OPTION_ITEM_SYMBOL] = true +const ItemAny = Item as any +ItemAny[OPTION_ITEM_SYMBOL] = true -export default OptionItem \ No newline at end of file +export default Item \ No newline at end of file diff --git a/src/pages/app/components/Option/components/OptionLines.tsx b/src/pages/app/components/Option/components/Lines.tsx similarity index 89% rename from src/pages/app/components/Option/components/OptionLines.tsx rename to src/pages/app/components/Option/components/Lines.tsx index a1f315495..efb2fa36f 100644 --- a/src/pages/app/components/Option/components/OptionLines.tsx +++ b/src/pages/app/components/Option/components/Lines.tsx @@ -1,6 +1,6 @@ import { ElDivider } from 'element-plus' import { type FunctionalComponent, h, isVNode, type VNode } from "vue" -import { isOptionItem } from './OptionItem' +import { isOptionItem } from './Item' function isComment({ type, children }: VNode): boolean { return typeof type === 'symbol' && (children === null || children === undefined) @@ -33,7 +33,7 @@ function flattenChildren(original: VNode[]): VNode[] { return flat } -const OptionLines: FunctionalComponent<{}> = (_, { slots }) => { +const Lines: FunctionalComponent<{}> = (_, { slots }) => { const children: VNode[] = [] let beforeIsItem = false @@ -50,7 +50,7 @@ const OptionLines: FunctionalComponent<{}> = (_, { slots }) => { } return
    {children}
    } -OptionLines.displayName = 'OptionLines' +Lines.displayName = 'OptionLines' -export default OptionLines \ No newline at end of file +export default Lines \ No newline at end of file diff --git a/src/pages/app/components/Option/components/OptionTag.tsx b/src/pages/app/components/Option/components/OptionTag.tsx deleted file mode 100644 index ca275a862..000000000 --- a/src/pages/app/components/Option/components/OptionTag.tsx +++ /dev/null @@ -1,10 +0,0 @@ -import { h, type FunctionalComponent, type StyleValue } from "vue" - -const OptionTag: FunctionalComponent<{}> = (_, { slots: { default: default_ } }) => ( - - {default_ && h(default_)} - -) -OptionTag.displayName = 'OptionTag' - -export default OptionTag diff --git a/src/pages/app/components/Option/components/OptionTooltip.tsx b/src/pages/app/components/Option/components/OptionTooltip.tsx deleted file mode 100644 index f75fc29e3..000000000 --- a/src/pages/app/components/Option/components/OptionTooltip.tsx +++ /dev/null @@ -1,14 +0,0 @@ -import { InfoFilled } from "@element-plus/icons-vue" -import { ElIcon, ElTooltip } from "element-plus" -import { type FunctionalComponent } from "vue" - -const OptionTooltip: FunctionalComponent<{}> = (_, { slots: { default: content } }) => ( - content ? ( - - - - ) : null -) -OptionTooltip.displayName = 'OptionTooltip' - -export default OptionTooltip \ No newline at end of file diff --git a/src/pages/app/components/Option/components/index.tsx b/src/pages/app/components/Option/components/index.tsx new file mode 100644 index 000000000..2b4899687 --- /dev/null +++ b/src/pages/app/components/Option/components/index.tsx @@ -0,0 +1,24 @@ +import { InfoFilled } from '@element-plus/icons-vue' +import { ElIcon, ElTooltip } from 'element-plus' +import { type FunctionalComponent, h, type StyleValue } from 'vue' + +const Tag: FunctionalComponent<{}> = (_, { slots: { default: default_ } }) => ( + + {default_ && h(default_)} + +) +Tag.displayName = 'OptionTag' +export const OptionTag = Tag + +const Tooltip: FunctionalComponent<{}> = (_, { slots: { default: content } }) => ( + content ? ( + + + + ) : null +) +Tooltip.displayName = 'OptionTooltip' +export const OptionTooltip = Tooltip + +export { default as OptionItem } from "./Item" +export { default as OptionLines } from "./Lines" diff --git a/src/pages/app/components/Option/index.tsx b/src/pages/app/components/Option/index.tsx index 4cd2818d1..766b20fbf 100644 --- a/src/pages/app/components/Option/index.tsx +++ b/src/pages/app/components/Option/index.tsx @@ -7,33 +7,31 @@ import { MediaSize, useMediaSize } from "@hooks" import { ElScrollbar } from 'element-plus' import { defineComponent, ref, type Ref, type StyleValue } from "vue" -import { type JSX } from "vue/jsx-runtime" -import { type OptionCategory, type OptionInstance } from "./common" -import AccessibilityOption from "./components/AccessibilityOption" -import AppearanceOption from "./components/AppearanceOption" -import BackupOption from './components/BackupOption' -import LimitOption from './components/LimitOption' -import TrackingOption from "./components/TrackingOption" +import type { JSX } from "vue/jsx-runtime" +import { Accessibility, Appearance, Backup, type CategoryInstance, Limit, Notification, Tracking } from './categories' import Select from "./Select" import Tabs from "./Tabs" +import type { OptionCategory } from "./useCategory" const _default = defineComponent(() => { - const paneRefMap: Record> = { + const paneRefMap: Record> = { appearance: ref(), tracking: ref(), backup: ref(), limit: ref(), accessibility: ref(), + notification: ref(), } const mediaSize = useMediaSize() const slots: Record JSX.Element> = { - appearance: () => , - tracking: () => , - limit: () => , - accessibility: () => , - backup: () => , + appearance: () => , + tracking: () => , + limit: () => , + accessibility: () => , + backup: () => , + notification: () => , } const handleReset = (cate: OptionCategory) => paneRefMap[cate]?.value?.reset?.() diff --git a/src/pages/app/components/Option/useCategory.ts b/src/pages/app/components/Option/useCategory.ts new file mode 100644 index 000000000..ebba33dac --- /dev/null +++ b/src/pages/app/components/Option/useCategory.ts @@ -0,0 +1,53 @@ +import { t, type I18nKey } from '@app/locale' +import { createStringUnionGuard } from 'typescript-guard' +import { computed, type ShallowRef } from 'vue' +import { useRoute, useRouter, type LocationQuery } from 'vue-router' + +export type OptionCategory = 'appearance' | 'tracking' | 'limit' | 'accessibility' | 'backup' | 'notification' +const isCategory = createStringUnionGuard('appearance', 'tracking', 'limit', 'accessibility', 'backup', 'notification') + +const CATE_LABELS: Record = { + appearance: msg => msg.option.appearance.title, + tracking: msg => msg.option.tracking.title, + limit: msg => msg.menu.limit, + accessibility: msg => msg.option.accessibility.title, + backup: msg => msg.option.backup.title, + notification: msg => msg.option.notification.title, +} + +const PARAM = "i" + +function parseInit(query: LocationQuery): OptionCategory | undefined { + const initialQuery = query[PARAM] + const queryVal = Array.isArray(initialQuery) ? initialQuery[0] : initialQuery + return isCategory(queryVal) ? queryVal : undefined +} + +export const useCategory = (): { + category: ShallowRef + setCategory: (value: unknown) => void + getLabel: (cate: unknown) => string | undefined +} => { + const route = useRoute() + const router = useRouter() + + const category = computed({ + set(value: OptionCategory) { + const oldQuery = route.query + const query: LocationQuery = { + ...oldQuery, + [PARAM]: value, + } + router.replace({ query }) + }, + get: () => parseInit(route.query) ?? 'appearance', + }) + + const setCategory = (value: unknown) => isCategory(value) && (category.value = value) + const getLabel = (cate: unknown) => { + const key = isCategory(cate) ? CATE_LABELS[cate] : undefined + return key ? t(key) : undefined + } + + return { category, setCategory, getLabel } +} \ No newline at end of file diff --git a/src/pages/app/components/Option/useOption.ts b/src/pages/app/components/Option/useOption.ts index 48e6d6404..2461dae2d 100644 --- a/src/pages/app/components/Option/useOption.ts +++ b/src/pages/app/components/Option/useOption.ts @@ -1,5 +1,6 @@ +import { useRequest } from '@hooks/useRequest' import optionHolder from "@service/components/option-holder" -import { onBeforeMount, type Reactive, reactive, toRaw, watch } from "vue" +import { reactive, toRaw, watch } from "vue" type Options = { defaultValue: () => T @@ -7,19 +8,20 @@ type Options = { onChange?: (newVal: T) => void } -export const useOption = >(options: Options): { option: Reactive } => { +export const useOption = >(options: Options) => { const { defaultValue, copy, onChange } = options const option = reactive(defaultValue?.()) - onBeforeMount(async () => { + const { loading } = useRequest(async () => { const currentVal = await optionHolder.get() as T copy(option as T, currentVal) - watch(option, async () => { - const newVal = toRaw(option) as T - await optionHolder.set(newVal) - onChange?.(newVal) - }) }) - return { option } + watch(option, async () => { + const newVal = toRaw(option) as T + !loading.value && await optionHolder.set(newVal) + onChange?.(newVal) + }) + + return { option, loading } } \ No newline at end of file diff --git a/src/pages/popup/components/Header/Logo.tsx b/src/pages/popup/components/Header/Logo.tsx index f158a30a1..60b2cd2e7 100644 --- a/src/pages/popup/components/Header/Logo.tsx +++ b/src/pages/popup/components/Header/Logo.tsx @@ -1,3 +1,4 @@ +import { getIconUrl } from '@api/chrome/runtime' import Flex from "@pages/components/Flex" import { t } from "@popup/locale" import packageInfo from "@src/package" @@ -7,7 +8,7 @@ import type { FunctionalComponent } from "vue" const Logo: FunctionalComponent = () => ( - + {t(msg => msg.meta.name)} diff --git a/src/service/notification/browser/notifier.ts b/src/service/notification/browser/notifier.ts new file mode 100644 index 000000000..8075ac771 --- /dev/null +++ b/src/service/notification/browser/notifier.ts @@ -0,0 +1,61 @@ +import { createNotification } from "@api/chrome/notifications" +import { hasPerm, requestPerm } from "@api/chrome/permission" +import { getIconUrl } from "@api/chrome/runtime" +import { t } from '@i18n' +import calendarMessages from "@i18n/message/common/calendar" +import metaMessages from "@i18n/message/common/meta" +import { formatPeriodCommon } from '@util/time' +import type { NotificationData, NotificationRequest, Notifier } from '../types' + +/** + * Send notification with `chrome.notifications` API + */ +export default class BrowserNotifier implements Notifier { + /** + * Test if the permission granted, if not granted, then try to grant + */ + private async assertPerm(): Promise { + const hasPermission = await hasPerm('notifications') + if (hasPermission) { + return undefined + } + + const granted = await requestPerm('notifications') + if (!granted) { + return "Notification permission is required but was denied" + } + + return undefined + } + + /** + * Send notification with summary + * + * @param option + * @param data + */ + async send(_: NotificationRequest, data: NotificationData): Promise { + const errMsg = await this.assertPerm() + if (errMsg) return errMsg + + const { + cycle, + meta: { locale }, + summary: { focus, visit, siteCount }, + } = data + + const appName = t(metaMessages, { key: msg => msg.name }, locale) + const calendar = t(calendarMessages, { key: cycle === 'daily' ? msg => msg.range.yesterday : msg => msg.range.lastWeek }, locale) + const title = `${appName} - ${calendar}` + const focusStr = formatPeriodCommon(focus, true) + + const message = `Focus time: ${focusStr}, Visits: ${visit}, Sites: ${siteCount}` + + await createNotification('time', { + type: 'basic', + iconUrl: getIconUrl(), + title, + message, + }) + } +} diff --git a/src/service/notification/callback/notifier.ts b/src/service/notification/callback/notifier.ts new file mode 100644 index 000000000..7cf09d260 --- /dev/null +++ b/src/service/notification/callback/notifier.ts @@ -0,0 +1,44 @@ +import hash from 'hash.js' +import type { NotificationData, NotificationMeta, NotificationRequest, Notifier } from '../types' + +function buildHeaders(meta: NotificationMeta, token: string | undefined): Record { + const headers: Record = { + 'Content-Type': 'application/json', + } + if (token) { + const sign = genSign(meta, token) + headers['Tt4b-Sign'] = sign + } + return headers +} + +function genSign(meta: NotificationMeta, auth: string): string { + return hash.hmac(hash.sha256 as any, auth).update(meta).digest('hex') +} + +export default class CallbackNotifier implements Notifier { + async send(req: NotificationRequest, data: NotificationData): Promise { + const { endpoint, authToken } = req + + if (!endpoint) return "Endpoint is required for HTTP callback" + + try { + const url = new URL(endpoint) + if (!['http:', 'https:'].includes(url.protocol)) { + return "Endpoint must use HTTP or HTTPS protocol" + } + } catch (e) { + return "Invalid endpoint URL" + } + + const { meta } = data + const headers = buildHeaders(meta, authToken) + + const response = await fetch(endpoint, { + method: 'POST', headers, + body: JSON.stringify(data), + }) + + return response.ok ? undefined : `Server error: ${response.statusText}` + } +} diff --git a/src/service/notification/common.ts b/src/service/notification/common.ts new file mode 100644 index 000000000..e90fec1de --- /dev/null +++ b/src/service/notification/common.ts @@ -0,0 +1,5 @@ +import type { NotificationData } from './types' + +export function formatNotificationData(data: NotificationData): string { + return JSON.stringify(data, null, 2) +} diff --git a/src/service/notification/processor.ts b/src/service/notification/processor.ts new file mode 100644 index 000000000..75bb7c594 --- /dev/null +++ b/src/service/notification/processor.ts @@ -0,0 +1,107 @@ +import { getVersion } from "@api/chrome/runtime" +import { cvtOption2Locale } from "@i18n" +import optionHolder from "@service/components/option-holder" +import itemService from "@service/item-service" +import { formatTimeYMD, MILL_PER_DAY, MILL_PER_WEEK } from "@util/time" +import BrowserNotifier from "./browser/notifier" +import CallbackNotifier from "./callback/notifier" +import type { NotificationData, NotificationRequest, Notifier } from "./types" + +type Result = { + success: true + data: T +} | { + success: false + errorMsg: string +} + +function error(msg: string): Result { + return { success: false, errorMsg: msg } +} + +function success(data: T): Result { + return { success: true, data } +} + +const DATE_RANGE_CALCULATORS: Record Date | [Date, Date]> = { + daily: now => new Date(now - MILL_PER_DAY), + weekly: now => [new Date(now - MILL_PER_WEEK), new Date(now - MILL_PER_DAY)], +} + +class Processor { + private notifiers: { + [method in timer.notification.Method]: Notifier + } + + constructor() { + this.notifiers = { + browser: new BrowserNotifier(), + callback: new CallbackNotifier(), + } + } + + async doSend(): Promise> { + const option = await optionHolder.get() + const { + notificationCycle: cycle, notificationMethod: method, + notificationEndpoint: endpoint, notificationAuthToken: authToken, + } = option + if (cycle === 'none') return success(undefined) + + const notifier = this.notifiers[method] + const req: NotificationRequest = { cycle, method, endpoint, authToken } + const data = await this.buildData(req) + + try { + const errMsg = await notifier.send(req, data) + return errMsg ? error(errMsg) : success(undefined) + } catch (e) { + console.error("Error to send notification", e) + const msg = e instanceof Error ? e.message : String(e) + return error(msg) + } + } + + private async buildData(req: NotificationRequest): Promise { + const now = Date.now() + const date = DATE_RANGE_CALCULATORS[req.cycle](now) + + // Query rows + const rows = await itemService.selectItems({ date }) + + // Calculate summary + let totalFocus = 0 + let totalVisit = 0 + const uniqueHosts = new Set() + + rows.forEach(row => { + totalFocus += row.focus + totalVisit += row.time + uniqueHosts.add(row.host) + }) + + const option = await optionHolder.get() + const locale = cvtOption2Locale(option.locale) + + const [dateStart, dateEnd] = Array.isArray(date) ? date : [date, date] + + return { + cycle: req.cycle, + meta: { + locale, + version: getVersion(), + ts: Date.now(), + }, + summary: { + focus: totalFocus, + visit: totalVisit, + siteCount: uniqueHosts.size, + dateStart: formatTimeYMD(dateStart), + dateEnd: formatTimeYMD(dateEnd), + }, + row: rows, + } + } +} + +export default new Processor() diff --git a/src/service/notification/types.ts b/src/service/notification/types.ts new file mode 100644 index 000000000..d621defb3 --- /dev/null +++ b/src/service/notification/types.ts @@ -0,0 +1,45 @@ +export type Count = { + total: number + average: number +} + +export type Summary = { + focus: number + visit: number + siteCount: number + dateStart: string + dateEnd: string +} + +export type NotificationMeta = { + locale: timer.Locale + version: string + ts: number +} + +export type NotificationRequest = { + cycle: Exclude + method: timer.notification.Method + endpoint?: timer.option.NotificationOption['notificationEndpoint'] + authToken?: timer.option.NotificationOption['notificationAuthToken'] +} + +/** + * Notification data to be sent + */ +export type NotificationData = { + meta: NotificationMeta + cycle: Exclude + summary: Summary + row: timer.core.Row[] +} + +/** + * Notifier interface for different notification methods + */ +export interface Notifier { + /** + * Send notification + */ + send(req: NotificationRequest, data: NotificationData): Promise +} \ No newline at end of file diff --git a/src/util/constant/option.ts b/src/util/constant/option.ts index 718e38171..6de315b02 100644 --- a/src/util/constant/option.ts +++ b/src/util/constant/option.ts @@ -69,9 +69,18 @@ export function defaultAccessibility(): timer.option.AccessibilityOption { } } +export function defaultNotification(): timer.option.NotificationOption { + return { + notificationCycle: 'none', + notificationMethod: 'browser', + notificationOffset: 0, + } +} + export type DefaultOption = & AppearanceRequired & TrackingRequired & LimitRequired & timer.option.BackupOption & timer.option.AccessibilityOption + & timer.option.NotificationOption export function defaultOption(): DefaultOption { return { @@ -80,5 +89,6 @@ export function defaultOption(): DefaultOption { ...defaultBackup(), ...defaultLimit(), ...defaultAccessibility(), + ...defaultNotification(), } } \ No newline at end of file diff --git a/types/timer/index.d.ts b/types/timer/index.d.ts index e8b803e4a..52e97f7cd 100644 --- a/types/timer/index.d.ts +++ b/types/timer/index.d.ts @@ -71,6 +71,13 @@ declare namespace timer { msg?: string } } + notification?: { + [key in timer.notification.Method]?: { + ts: number + endDate: string + msg?: string + } + } // Flags flag?: Partial> } diff --git a/types/timer/mq.d.ts b/types/timer/mq.d.ts index d83d410af..804e62982 100644 --- a/types/timer/mq.d.ts +++ b/types/timer/mq.d.ts @@ -20,6 +20,9 @@ declare namespace timer.mq { | "enableTabGroup" // @since 3.7.3 | "syncAudible" + | "resetBackupScheduler" + // @since 4.1.0 + | "resetNotificationScheduler" // Request by content script // @since 1.3.0 | "cs.isInWhitelist" diff --git a/types/timer/notification.d.ts b/types/timer/notification.d.ts new file mode 100644 index 000000000..010cd7388 --- /dev/null +++ b/types/timer/notification.d.ts @@ -0,0 +1,4 @@ +declare namespace timer.notification { + type Method = 'browser' | 'callback' + type Cycle = 'none' | 'daily' | 'weekly' +} \ No newline at end of file diff --git a/types/timer/option.d.ts b/types/timer/option.d.ts index 9b4cf0d71..a8bcf2fda 100644 --- a/types/timer/option.d.ts +++ b/types/timer/option.d.ts @@ -184,12 +184,37 @@ declare namespace timer.option { chartDecal: boolean } + type NotificationOption = { + /** + * Notification cycle: none, daily, or weekly + */ + notificationCycle: timer.notification.Cycle + /** + * Offset time in minutes relative to the start of the cycle + */ + notificationOffset: number + /** + * Notification method: browser or callback + */ + notificationMethod: timer.notification.Method + /** + * HTTP callback endpoint URL + */ + notificationEndpoint?: string + /** + * Auth token for HTTP callback (optional) + */ + notificationAuthToken?: string + } + type AllOption = & AppearanceOption & TrackingOption & LimitOption & AccessibilityOption & BackupOption + & NotificationOption + /** * @since 0.8.0 */ From b6539e94ab0190549e07531d5268f6de9d2d59f8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 7 Mar 2026 01:13:49 +0800 Subject: [PATCH 088/174] i18n(download): download translations by bot (#698) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/option-resource.json | 34 +++++++++---------- .../message/common/calendar-resource.json | 1 + 2 files changed, 18 insertions(+), 17 deletions(-) diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 6c1629362..1e21cce18 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -114,6 +114,22 @@ "title": "无障碍功能", "chartDecal": "{input} 是否显示图表的贴花图案" }, + "notification": { + "title": "消息推送", + "cycle": { + "label": "消息推送周期 {input}", + "daily": "每天", + "weekly": "每周" + }, + "method": { + "label": "消息推送方式 {input}", + "browser": "浏览器通知", + "callback": { + "label": "HTTP 回调", + "url": "回调 URL {input}" + } + } + }, "resetButton": "恢复默认", "resetSuccess": "成功重置为默认值", "exportButton": "导出设置", @@ -459,7 +475,6 @@ "type": "バックアップ方法 {input}", "client": "クライアント名 {input}", "meta": { - "none": {}, "gist": { "authInfo": "少なくとも gist 権限を持つトークンが 1 つ必要です" }, @@ -584,7 +599,6 @@ "type": "Tipo remoto {input}", "client": "Nome do cliente {input}", "meta": { - "none": {}, "gist": { "authInfo": "Necessário token com permissão gist" }, @@ -707,7 +721,6 @@ "type": "Тип резервного копіювання {input}", "client": "Назва клієнта {input}", "meta": { - "none": {}, "gist": { "authInfo": "Потрібно вказати токен для доступу gist" }, @@ -830,7 +843,6 @@ "type": "Tipo remoto {input}", "client": "Nombre del cliente {input}", "meta": { - "none": {}, "gist": { "authInfo": "Se requiere un token con al menos los permisos esenciales" }, @@ -906,12 +918,7 @@ "info": "Die Anzahl der Besuche der aktuellen Website heute" }, "darkMode": { - "label": "Dunkler Modus {input}", - "options": { - "on": "Immer an", - "off": "Immer aus", - "timed": "Zeitgesteuert" - } + "label": "Dunkler Modus {input}" }, "animationDuration": "Die Dauer der ersten Animation des Diagramms {input}" }, @@ -955,7 +962,6 @@ "type": "Remote Typ {input}", "client": "Kundenname {input}", "meta": { - "none": {}, "gist": { "authInfo": "Ein Token mit mindestens gist Berechtigung ist erforderlich" }, @@ -1080,7 +1086,6 @@ "type": "Type distant {input}", "client": "Nom du client {input}", "meta": { - "none": {}, "gist": { "authInfo": "Un jeton avec au moins une permission de gist est requis" }, @@ -1281,7 +1286,6 @@ "type": "نوع الربط الخارجي {input}", "client": "اسم العميل {input}", "meta": { - "none": {}, "gist": { "authInfo": "مطلوب رمز وصول واحد على الأقل مع صلاحية Gist" }, @@ -1408,7 +1412,6 @@ "type": "Uzaktan yedekleme tipi {input}", "client": "Uygulama adı {input}", "meta": { - "none": {}, "gist": { "authInfo": "Gist iznine sahip bir token gereklidir" }, @@ -1520,9 +1523,6 @@ "backup": { "title": "Kopia zapasowa danych", "client": "Nazwa klienta {input}", - "meta": { - "none": {} - }, "label": { "endpoint": "Adres punktu końcowego {info} {input}", "account": "Nazwa użytkownika {input}", diff --git a/src/i18n/message/common/calendar-resource.json b/src/i18n/message/common/calendar-resource.json index 760cc68ba..025236df6 100644 --- a/src/i18n/message/common/calendar-resource.json +++ b/src/i18n/message/common/calendar-resource.json @@ -16,6 +16,7 @@ "everyday": "每天", "thisWeek": "本周", "thisMonth": "本月", + "lastWeek": "上周", "lastDays": "最近{n}天", "tillYesterday": "截至昨天", "tillDaysAgo": "截至{n}天前", From 195aad0d96484ac5bdb5b7e5eff4ffc3f1b878ab Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sat, 7 Mar 2026 01:15:33 +0800 Subject: [PATCH 089/174] v4.1.0 --- CHANGELOG.md | 5 +++++ package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba8942f36..f64361715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.1.0] - 2026-03-07 + +- Added notification +- Supported time limits for mobile browsers + ## [4.0.1] - 2026-02-27 - Fixed an IndexedDB upgrade bug on Edge diff --git a/package.json b/package.json index d586dd16a..f18bacde5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "4.0.1", + "version": "4.1.0", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { From 64e4fd71f56d9e4617909570d515f70acfe6e729 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sun, 8 Mar 2026 14:09:32 +0800 Subject: [PATCH 090/174] perf: use fifi-cache for runtime tracker --- src/background/track-server/runtime.ts | 7 ++++--- src/service/components/host-merge-ruler.ts | 8 +++++--- src/util/fifo-cache.ts | 4 ++++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/src/background/track-server/runtime.ts b/src/background/track-server/runtime.ts index 37588bd66..0cf582814 100644 --- a/src/background/track-server/runtime.ts +++ b/src/background/track-server/runtime.ts @@ -1,5 +1,6 @@ import itemService from "@service/item-service" import whitelistHolder from "@service/whitelist/holder" +import FIFOCache from '@util/fifo-cache' import { formatTimeYMD, getStartOfDay, MILL_PER_DAY } from "@util/time" function splitRunTime(start: number, end: number): Record { @@ -14,15 +15,15 @@ function splitRunTime(start: number, end: number): Record { return res } -const RUN_TIME_END_CACHE: { [host: string]: number } = {} +const RUN_TIME_END_CACHE = new FIFOCache(500) export async function handleTrackRunTimeEvent(event: timer.core.Event): Promise { const { start, end, url, host } = event || {} if (!host || !start || !end) return if (whitelistHolder.contains(host, url)) return - const realStart = Math.max(RUN_TIME_END_CACHE[host] ?? 0, start) + const realStart = Math.max(RUN_TIME_END_CACHE.get(host) ?? 0, start) const byDate = splitRunTime(realStart, end) if (!Object.keys(byDate).length) return await itemService.addRunTime(host, byDate) - RUN_TIME_END_CACHE[host] = Math.max(end, realStart) + RUN_TIME_END_CACHE.set(host, Math.max(end, realStart)) } \ No newline at end of file diff --git a/src/service/components/host-merge-ruler.ts b/src/service/components/host-merge-ruler.ts index 046d06495..c62c2f142 100644 --- a/src/service/components/host-merge-ruler.ts +++ b/src/service/components/host-merge-ruler.ts @@ -5,6 +5,7 @@ * https://opensource.org/licenses/MIT */ +import FIFOCache from '@util/fifo-cache' import { isIpAndPort, judgeVirtualFast } from "@util/pattern" import { get } from "@util/psl" @@ -58,7 +59,7 @@ export default class CustomizedHostMergeRuler { private regulars: RegRuleItem[] = [] - private cache: Record = {} + private cache: FIFOCache = new FIFOCache(500) constructor(rules: timer.merge.Rule[]) { rules.map(item => convert(item)) @@ -68,9 +69,10 @@ export default class CustomizedHostMergeRuler { } merge(origin: string): string { - let result = this.cache[origin] + let result = this.cache.get(origin) if (result) return result - result = this.cache[origin] = this.mergeInner(origin) + result = this.mergeInner(origin) + this.cache.set(origin, result) return result } diff --git a/src/util/fifo-cache.ts b/src/util/fifo-cache.ts index 44052f2e2..2a8756c02 100644 --- a/src/util/fifo-cache.ts +++ b/src/util/fifo-cache.ts @@ -41,6 +41,10 @@ class FIFOCache { } } + get(origin: string): T | undefined { + return this.map[origin] + } + async getOrSupply(key: string, supplier: () => PromiseLike): Promise { const exist = this.map[key] if (exist) { From 18e244fa65c410d06edcc7fd9b0358d30db06592 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 9 Mar 2026 14:24:27 +0800 Subject: [PATCH 091/174] i18n(download): download translations by bot (#699) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/dashboard-resource.json | 5 +- .../message/app/data-manage-resource.json | 2 + src/i18n/message/app/limit-resource.json | 9 +- src/i18n/message/app/merge-rule-resource.json | 4 +- src/i18n/message/app/option-resource.json | 95 +++++++++++++++++-- src/i18n/message/app/report-resource.json | 13 ++- .../message/app/site-manage-resource.json | 11 ++- src/i18n/message/app/whitelist-resource.json | 2 + src/i18n/message/common/base-resource.json | 8 ++ src/i18n/message/common/button-resource.json | 23 +++++ .../message/common/calendar-resource.json | 22 +++++ .../common/context-menus-resource.json | 5 + src/i18n/message/common/initial-resource.json | 8 ++ src/i18n/message/common/item-resource.json | 15 +++ src/i18n/message/common/shared-resource.json | 15 +++ src/i18n/message/cs/console-resource.json | 1 + src/i18n/message/cs/modal-resource.json | 6 ++ src/i18n/message/popup/content-resource.json | 17 ++++ src/i18n/message/popup/footer-resource.json | 6 ++ src/i18n/message/popup/header-resource.json | 5 + src/i18n/message/side/list-resource.json | 4 + 21 files changed, 264 insertions(+), 12 deletions(-) diff --git a/src/i18n/message/app/dashboard-resource.json b/src/i18n/message/app/dashboard-resource.json index ba228606f..ae51f9504 100644 --- a/src/i18n/message/app/dashboard-resource.json +++ b/src/i18n/message/app/dashboard-resource.json @@ -235,8 +235,11 @@ "title": "Время просмотра за последние 30 дней" }, "timeline": { + "title": "Лента времени последних {n} дней", "busyScore": "Занятость", - "focusScore": "Сфокусированность" + "busyScoreDesc": "Относится к общему времени просмотра и количеству веб-сайтов в час. Смотрите исходный код для формулы расчета", + "focusScore": "Сфокусированность", + "focusScoreDesc": "Относится к общему времени непрерывного просмотра того же веб-сайта. Смотрите исходный код для формулы расчета" } }, "ar": { diff --git a/src/i18n/message/app/data-manage-resource.json b/src/i18n/message/app/data-manage-resource.json index 697882a9f..d5c8de1f7 100644 --- a/src/i18n/message/app/data-manage-resource.json +++ b/src/i18n/message/app/data-manage-resource.json @@ -266,6 +266,7 @@ "totalMemoryAlert": "Браузер предоставляет {size}MB для хранения локальных данных по каждому расширению", "totalMemoryAlert1": "Невозможно определить максимальный объём памяти, доступный браузером", "usedMemoryAlert": "{size}MB сейчас используется", + "idbAlert": "Переместить данные для отслеживания в IndexedDB для уменьшения использования хранилища", "operationAlert": "Вы можете удалить эти неважные данные для уменьшения использования памяти", "filterItems": "Фильтровать данные", "filterFocus": "Время просмотра дня между {start} секунд и {end} секунд", @@ -354,6 +355,7 @@ "totalMemoryAlert": "Przeglądarka udostępnia {size} MB do przechowywania lokalnych danych dla każdego rozszerzenia", "totalMemoryAlert1": "Nie można określić maksymalnej ilości pamięci udostępnianej przez przeglądarkę", "usedMemoryAlert": "{size} MB jest obecnie używanych", + "idbAlert": "Przenieś dane do IndexedDB, aby zmniejszyć zużycie pamięci", "operationAlert": "Możesz usunąć te nieważne dane, aby zmniejszyć zużycie pamięci", "filterItems": "Filtruj dane", "filterFocus": "Czas przeglądania w ciągu dnia wynosi od {start} do {end} sekund", diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index 5d651b351..d57c32264 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -470,6 +470,7 @@ }, "ru": { "filterDisabled": "Только включен", + "wildcardTip": "Вы можете использовать шаблоны для совпадения с поддоменами или подстраницами, а также использовать «+» в качестве префикса, чтобы исключить подстраницы!", "emptyTips": "Нажмите здесь, чтобы создать новое правило!", "item": { "name": "Имя правила", @@ -482,11 +483,13 @@ "visitTime": "Лимит за посещение", "period": "Заблокированное время", "enabled": "Включено", + "locked": "Заблокировано", "effectiveDay": "Эффективный", "delayAllowed": "Еще 5 минут", "delayAllowedInfo": "Если время истекло, разрешите временную задержку 5 минут", "visits": "посещения", - "or": "или" + "or": "или", + "notEffective": "Не активировано" }, "step": { "base": "Основная информация", @@ -500,6 +503,7 @@ "noUrl": "Ограничение URL-адресов не настроено", "noRule": "Нет заполненных правил", "deleteConfirm": "Вы хотите удалить правило [{name}]?", + "lockConfirm": "Если заблокировано, то все операции потребуют проверки, даже если правило не срабатывает.", "inputTestUrl": "Пожалуйста, введите ссылку для тестирования", "noRuleMatched": "URL не содержит правил", "rulesMatched": "URL попадает в следующие правила:", @@ -514,7 +518,8 @@ "incorrectAnswer": "Неправильный ответ", "pi": "{digitCount} цифр от {startIndex} до {endIndex} десятичной части числа π", "confession": "Время быстротечно" - } + }, + "reminder": "Осталось меньше чем {min} минут до конца лимита времени!" }, "ar": { "filterDisabled": "تم التمكين فقط", diff --git a/src/i18n/message/app/merge-rule-resource.json b/src/i18n/message/app/merge-rule-resource.json index 1424e8eb1..2beb3e90f 100644 --- a/src/i18n/message/app/merge-rule-resource.json +++ b/src/i18n/message/app/merge-rule-resource.json @@ -240,8 +240,10 @@ "infoAlert2": "Scalona witryna może być wypełniona określoną witryną, liczbą lub zostawiona pusta", "infoAlert3": "Liczba oznacza poziom scalonej witryny. Na przykład istnieje reguła '*.*.edu.cn >>> 3', a następnie 'www.hust.edu.cn' zostanie połączona z 'hust.edu.cn'", "infoAlert4": "Puste oznacza, że oryginalna strona nie zostanie połączona", + "infoAlert5": "Jeśli żadna reguła nie jest dopasowana, będzie domyślnie przed {psl}", "tagResult": { - "blank": "Nie scalaj" + "blank": "Nie scalaj", + "level": "Zachowaj poziom {level}" } } } \ No newline at end of file diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 1e21cce18..93091b09c 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -1166,44 +1166,102 @@ "label": "Тёмный режим {input}", "timed": "По времени" }, - "animationDuration": "Длительность начальной анимации графика {input}" + "animationDuration": "Длительность начальной анимации графика {input}", + "sidePanel": "{input} включает ли боковую панель" }, "tracking": { "title": "Статистика", "autoPauseTrack": "{input} Приостановить отслеживание, если нет активности {info} в течение {maxTime}", + "noActivityInfo": "Мышь и клавиатура неактивны, не в полноэкранном режиме, и нет воспроизводимого звука.", + "countLocalFiles": "{input} Отслеживать ли время, когда браущер читает {localFileTime} {info}", "localFilesInfo": "Поддержка типов файлов, таких как PDF, изображения, txt и json.", "countTabGroup": "{input} Отслеживать время для групп вкладок {info}", - "weekStartAsNormal": "Как обычно (Воскресенье)" + "tabGroupInfo": "При удалении группы вкладок данные также будут удалены.", + "tabGroupsPermGrant": "Эта функция требует соответствующих разрешений", + "fileAccessDisabled": "Доступ к URL-адресам файлов в настоящее время запрещен. Пожалуйста, сначала включите его на странице управления", + "weekStart": "Первый день каждой недели {input}", + "weekStartAsNormal": "Как обычно (Воскресенье)", + "storage": "Сохранять данные отслеживания в {input}", + "storageConfirm": "Вы хотите изменить тип хранилища на {type}" }, "limit": { + "prompt": "Шаблон, отображаемый при ограниченных {input}", + "reminder": "{input} Напоминать за {minInput} минут до окончания времени", "level": { + "label": "Как разблокировать при ограничении {input}", + "nothing": "Разрешить прямую разблокировку на странице администратора", + "password": "Необходимо ввести пароль для разблокировки", + "verification": "Необходимо ввести проверочный код для разблокировки", + "passwordLabel": "Пароль для разблокировки {input}", + "verificationLabel": "Сложность проверочного кода {input}", "verificationDifficulty": { "easy": "Лёгкий", - "hard": "Сложный" - } + "hard": "Сложный", + "disgusting": "Отвратительный" + }, + "strict": "Не разрешать разблокировку при любых обстоятельствах", + "strictTitle": "Подтверждение операции", + "strictContent": "При выборе этой опции, если сайт вызывает ежедневное ограничение, вам не будет разрешено разблокировать его вручную, кроме ожидания, до следующего дня. Если правила настроены неправильно, они могут очень затруднять ваши процедуры!", + "pswFormLabel": "Пароль", + "pswFormAgain": "Введите повторно" } }, "backup": { "title": "Резервное копирование данных", + "type": "Дистанционный тип {input}", + "client": "Наименование клиента {input}", + "meta": { + "gist": { + "authInfo": "Требуется один токен с как минимум разрешением на создание фрагментов кода (gist permission)" + }, + "obsidian_local_rest_api": { + "endpointInfo": "Доступен только HTTP, так как CORS не может быть настроен для страниц расширений" + } + }, "label": { + "endpoint": "Конечный адрес {info} {input}", + "path": "Путь к директории {input}", "account": "Имя пользователя {input}", "password": "Пароль {input}" }, "operation": "Резервное копирование", "download": { - "btn": "Скачать" + "btn": "Скачать", + "willDownload": "Для скачивания", + "confirmTip": "{size} фрагментов данных из [{clientName}] будут загружены" + }, + "clear": { + "btn": "Очистить", + "confirmTip": "{rowCount} фрагментов данных для {hostCount} сайтов, отслеживаемых [{clientName}] будут удалены!" }, "confirmStep": "Подтвердить данные", "clientTable": { "selectTip": "Выбрать клиент", + "dataRange": "Диапазон данных", "notSelected": "Клиент не выбран", "current": "Текущий" + }, + "lastTimeTip": "Время последнего резервного копирования: {lastTime}", + "auto": { + "label": "Включить ли автоматическое резервное копирование?", + "interval": "и запускать каждые {input} минут" } }, "accessibility": { "title": "Специальные возможности", "chartDecal": "{input} Отображать клетки на графике" }, + "notification": { + "title": "Уведомление", + "cycle": { + "label": "Цикл уведомлений {input}", + "daily": "Ежедневно", + "weekly": "Еженедельно" + }, + "method": { + "label": "Метод оповещения {input}" + } + }, "resetButton": "Сброс", "resetSuccess": "Сброс к настройкам по умолчанию выполнен успешно!", "exportButton": "Экспорт настроек", @@ -1496,10 +1554,13 @@ "tracking": { "title": "Monitorowanie", "autoPauseTrack": "{input} Wstrzymaj monitorowanie, jeżeli nie wykryto aktywności {info} przez {maxTime}", + "noActivityInfo": "Mysz i klawiatura są nieaktywne, nie są w trybie pełnoekranowym i nie ma dźwięku", "localFileTime": "pliki lokalne", "tabGroupInfo": "Po usunięciu grupy tagów, dane również zostaną usunięte.", "tabGroupsPermGrant": "Ta funkcja wymaga odpowiednich uprawnień", - "weekStart": "Pierwszy dzień tygodnia {input}" + "weekStart": "Pierwszy dzień tygodnia {input}", + "weekStartAsNormal": "Normalnie", + "storage": "Przechowuj dane śledzenia w {input}" }, "limit": { "reminder": "Przypomnienie {input} {minInput} minut(y) przed limitem czasu", @@ -1517,12 +1578,18 @@ "strict": "Nie zezwalaj na odblokowanie mimo to", "strictTitle": "Potwierdzenie operacji", "strictContent": "Gdy wybierzesz tę opcję, jeśli witryna osiągnie dzienny limit, nie będzie wolno jej odblokować ręcznie inaczej, niż czekając do następnego dnia. Jeśli reguły nie są poprawnie skonfigurowane, mogą one skutecznie utrudnić twoją rutynę!", + "pswFormLabel": "Hasło", "pswFormAgain": "Wprowadź ponownie" } }, "backup": { "title": "Kopia zapasowa danych", "client": "Nazwa klienta {input}", + "meta": { + "obsidian_local_rest_api": { + "endpointInfo": "Tylko HTTP jest dostępny, ponieważ CORS nie może być skonfigurowany dla stron rozszerzenia" + } + }, "label": { "endpoint": "Adres punktu końcowego {info} {input}", "account": "Nazwa użytkownika {input}", @@ -1552,6 +1619,22 @@ "accessibility": { "title": "Ułatwienia dostępu" }, + "notification": { + "title": "Powiadomienie", + "cycle": { + "label": "Cykl powiadomień {input}", + "daily": "Codziennie", + "weekly": "Co tydzień" + }, + "method": { + "label": "Metoda powiadomienia {input}", + "browser": "Przeglądarka", + "callback": { + "label": "HTTP Callback", + "url": "Callback URL {input}" + } + } + }, "resetButton": "Reset", "resetSuccess": "Pomyślnie przywrócono ustawienia domyślne!", "exportButton": "Eksportuj ustawienia", diff --git a/src/i18n/message/app/report-resource.json b/src/i18n/message/app/report-resource.json index 8a5675c64..ca90edca1 100644 --- a/src/i18n/message/app/report-resource.json +++ b/src/i18n/message/app/report-resource.json @@ -251,12 +251,23 @@ }, "pl": { "exportFileName": "My_Browsing_Time", + "total": "Całkowita liczba wizyt: {visit}, całkowity czas trwania: {focus}", + "batchDelete": { + "noSelectedMsg": "Proszę najpierw wybrać wiersz, który chcesz usunąć z tabeli", + "confirmMsg": "{count} rekord/y/ów takich jak [{example}] z {date} zostaną usunięte!", + "confirmMsgAll": "{count} rekord/y/ów takich jak [{example}] zostaną usunięte!", + "confirmMsgRange": "{count} rekord/y/ów takich jak [{example}] pomiędzy {start} i {end} zostaną usunięte!" + }, "remoteReading": { + "on": "Czytanie zdalnych kopii zapasowych", + "off": "Kliknij, aby przeczytać zdalne kopie zapasowe", "table": { "client": "Nazwa klienta", + "localData": "Lokalne Dane", "value": "Wartość", "percentage": "Procent" } - } + }, + "noMore": "Nie więcej" } } \ No newline at end of file diff --git a/src/i18n/message/app/site-manage-resource.json b/src/i18n/message/app/site-manage-resource.json index 6b5eb02ed..660dd732d 100644 --- a/src/i18n/message/app/site-manage-resource.json +++ b/src/i18n/message/app/site-manage-resource.json @@ -408,7 +408,10 @@ } }, "cate": { - "removeConfirm": "Вы уверены, что хотите удалить категорию: {category}?" + "name": "Наименование", + "relatedMsg": "Эта категория была связана с {siteCount} сайтами и не может быть удалена", + "removeConfirm": "Вы уверены, что хотите удалить категорию: {category}?", + "batchChange": "Изменить категории" }, "form": { "emptyAlias": "Введите название сайта", @@ -509,6 +512,7 @@ }, "pl": { "deleteConfirmMsg": "{host} zostanie usunięty", + "genAliasConfirmMsg": "Czy automatycznie uzupełniać nazwy witryny w seriach?", "column": { "type": "Typ witryny", "alias": "Nazwa strony", @@ -516,6 +520,10 @@ "icon": "Ikona" }, "type": { + "normal": { + "name": "normalny", + "info": "statystyki według nazwy domeny" + }, "merged": { "name": "scalone" } @@ -531,6 +539,7 @@ "emptyHost": "Wprowadź adres URL witryny" }, "msg": { + "hostExistWarn": "{host} istnieje", "noSelected": "Nie wybrano żadnej witryny", "disassociatedMsg": "Czy chcesz wyczyścić kategorie wszystkich wybranych witryn?", "batchDeleteMsg": "Czy chcesz usunąć wszystkie wybrane witryny?" diff --git a/src/i18n/message/app/whitelist-resource.json b/src/i18n/message/app/whitelist-resource.json index cce864819..b7c20c130 100644 --- a/src/i18n/message/app/whitelist-resource.json +++ b/src/i18n/message/app/whitelist-resource.json @@ -119,9 +119,11 @@ "pl": { "addConfirmMsg": "{url} zostanie dodany do whitelisty.", "removeConfirmMsg": "{url} zostanie usunięty z whitelisty.", + "duplicateMsg": "Duplikat", "infoAlertTitle": "Na tej stronie możesz dodawać strony do whitelisty", "infoAlert0": "Strony znajdujące się na whiteliście nie będą liczone", "infoAlert1": "Strony znajdujące się na whiteliście nie będą limitowane", + "infoAlert2": "Możesz użyć symboli wieloznacznych(*) do dopasowania wielu witryn, takich jak *.przykład.com/**, i użyć + jako prefiks do wykluczenia witryn, takich jak +potrzebny.przykład.com/**", "errorInput": "Niepoprawny URL strony" } } \ No newline at end of file diff --git a/src/i18n/message/common/base-resource.json b/src/i18n/message/common/base-resource.json index 14b2a21c9..169ccc4c1 100644 --- a/src/i18n/message/common/base-resource.json +++ b/src/i18n/message/common/base-resource.json @@ -94,5 +94,13 @@ "option": "Seçenekler", "sourceCode": "Kaynak Kodu", "changeLog": "Sürüm Notları" + }, + "pl": { + "sidebar": "Pasek boczny", + "allFunction": "Wszystkie funkcje", + "guidePage": "Podręcznik użytkownika", + "option": "Ustawienia", + "sourceCode": "Kod źródłowy", + "changeLog": "Lista Zmian" } } \ No newline at end of file diff --git a/src/i18n/message/common/button-resource.json b/src/i18n/message/common/button-resource.json index b29a6a85f..075e16b2a 100644 --- a/src/i18n/message/common/button-resource.json +++ b/src/i18n/message/common/button-resource.json @@ -209,6 +209,7 @@ }, "ru": { "create": "Создать", + "add": "Добавить", "delete": "Удалить", "batchDelete": "Массовое удаление", "modify": "Редактировать", @@ -224,6 +225,7 @@ "operation": "Действия", "configuration": "Настройки", "clear": "Очистить", + "enable": "Включить", "batchEnable": "Массовое включение", "batchDisable": "Массовое отключение" }, @@ -272,5 +274,26 @@ "batchDisable": "Toplu Devre Dışı Bırakma", "collapse": "Daralt", "expand": "Genişlet" + }, + "pl": { + "create": "Nowe", + "add": "Dodaj", + "delete": "Usuń", + "batchDelete": "Usuń wiele", + "modify": "Edytuj", + "save": "Zapisz", + "test": "Test", + "paste": "Wklej", + "confirm": "Potwierdź", + "cancel": "Anuluj", + "previous": "Wstecz", + "next": "Dalej", + "okey": "Ok", + "operation": "Działania", + "configuration": "Ustawienia", + "clear": "Wyczyść", + "enable": "Włącz", + "collapse": "Zwiń", + "expand": "Rozwiń" } } \ No newline at end of file diff --git a/src/i18n/message/common/calendar-resource.json b/src/i18n/message/common/calendar-resource.json index 025236df6..21dff2dbe 100644 --- a/src/i18n/message/common/calendar-resource.json +++ b/src/i18n/message/common/calendar-resource.json @@ -276,5 +276,27 @@ "tillDaysAgo": "{n} gün önce", "allTime": "Tüm zamanlar" } + }, + "pl": { + "weekDays": "Pn|Wt|Śr|Cz|Pt|Sb|Nd", + "months": "Sty|Lut|Mar|Kwi|Maj|Cze|Lip|Sie|Wrz|Paź|Lis|Gru", + "dateFormat": "{m}/{d}/{y}", + "simpleTimeFormat": "{m}/{d} {h}:{i}", + "label": { + "startDate": "Data rozpoczęcia", + "endDate": "Data zakończenia" + }, + "range": { + "today": "Dzisiaj", + "yesterday": "Wczoraj", + "everyday": "Codziennie", + "thisWeek": "W tym tygodniu", + "thisMonth": "W tym miesiącu", + "lastWeek": "W zeszłym tygodniu", + "lastDays": "W ciągu ostatnich {n} dni", + "tillYesterday": "Wczoraj", + "tillDaysAgo": "Przez {n} dni temu", + "allTime": "Od początku" + } } } \ No newline at end of file diff --git a/src/i18n/message/common/context-menus-resource.json b/src/i18n/message/common/context-menus-resource.json index 72f6fad81..78e116567 100644 --- a/src/i18n/message/common/context-menus-resource.json +++ b/src/i18n/message/common/context-menus-resource.json @@ -58,5 +58,10 @@ "add2Whitelist": "{host} adresini beyaz listeye ekle", "removeFromWhitelist": "{host} adresini beyaz listeden çıkart", "feedbackPage": "Sorunlar" + }, + "pl": { + "add2Whitelist": "Dodaj {host} do Whitelisty", + "removeFromWhitelist": "Usuń {host} z Whitelisty", + "feedbackPage": "Zgłoszenia" } } \ No newline at end of file diff --git a/src/i18n/message/common/initial-resource.json b/src/i18n/message/common/initial-resource.json index dee10ecd0..5369869ad 100644 --- a/src/i18n/message/common/initial-resource.json +++ b/src/i18n/message/common/initial-resource.json @@ -94,5 +94,13 @@ "pic": "Resimler", "txt": "Metin" } + }, + "pl": { + "localFile": { + "json": "JSON", + "pdf": "PDF", + "pic": "Zdjęcia", + "txt": "Tekst" + } } } \ No newline at end of file diff --git a/src/i18n/message/common/item-resource.json b/src/i18n/message/common/item-resource.json index b8bc3e475..3b27cfb36 100644 --- a/src/i18n/message/common/item-resource.json +++ b/src/i18n/message/common/item-resource.json @@ -212,5 +212,20 @@ "importWholeData": "İçe aktar", "importOtherData": "Uzantılardan içe aktar" } + }, + "pl": { + "date": "Data", + "host": "Domena", + "group": "Grupa zakładek", + "focus": "Czas trwania", + "run": "Czas pracy", + "time": "Liczba wizyt", + "operation": { + "add2Whitelist": "Whitelista", + "analysis": "Analizuj", + "exportWholeData": "Eksportuj", + "importWholeData": "Importuj", + "importOtherData": "Importuj z rozszerzenia" + } } } \ No newline at end of file diff --git a/src/i18n/message/common/shared-resource.json b/src/i18n/message/common/shared-resource.json index 314ec4a48..cc3446cb9 100644 --- a/src/i18n/message/common/shared-resource.json +++ b/src/i18n/message/common/shared-resource.json @@ -177,5 +177,20 @@ "cate": { "notSet": "Ayarlanmadı" } + }, + "pl": { + "merge": { + "mergeBy": "Połącz przez", + "mergeMethod": { + "date": "Data", + "domain": "Adres URL", + "cate": "Kategoria", + "group": "Grupa zakładek", + "notMerge": "Nie scalaj" + } + }, + "cate": { + "notSet": "Nie ustawiono" + } } } \ No newline at end of file diff --git a/src/i18n/message/cs/console-resource.json b/src/i18n/message/cs/console-resource.json index a851db9a6..4545bb79d 100644 --- a/src/i18n/message/cs/console-resource.json +++ b/src/i18n/message/cs/console-resource.json @@ -48,6 +48,7 @@ "closeAlert": "Bu bildirimleri [{appName}] ayarlarından devre dışı bırakabilirsiniz!" }, "pl": { + "consoleLog": "Dziś otworzyłeś {host} {time} raz(y) i spędziłeś {focus} przeglądając go.", "closeAlert": "Możesz wyłączyć te powiadomienia w ustawieniach [{appName}]!" } } \ No newline at end of file diff --git a/src/i18n/message/cs/modal-resource.json b/src/i18n/message/cs/modal-resource.json index d7cb7a6da..a5bbdfd8c 100644 --- a/src/i18n/message/cs/modal-resource.json +++ b/src/i18n/message/cs/modal-resource.json @@ -70,5 +70,11 @@ "more5Minutes": "5 dakika daha", "browsingTime": "Gezinme süresi", "ruleDetail": "Kural Detayları" + }, + "pl": { + "defaultPrompt": "Ta strona została zablokowana!", + "more5Minutes": "jeszcze 5 minut", + "browsingTime": "Czas przeglądania", + "ruleDetail": "Szczegóły zasad" } } \ No newline at end of file diff --git a/src/i18n/message/popup/content-resource.json b/src/i18n/message/popup/content-resource.json index e223ce3d7..ee57c87d9 100644 --- a/src/i18n/message/popup/content-resource.json +++ b/src/i18n/message/popup/content-resource.json @@ -250,5 +250,22 @@ "ranking": { "includingCount": "{siteCount} site dahil" } + }, + "pl": { + "percentage": { + "title": { + "today": "Dzisiejsze dane", + "yesterday": "Dane wczorajsze", + "thisWeek": "Dane w tym tygodniu", + "thisMonth": "Dane z tego miesiąca", + "lastDays": "Ostatnie dane z {n} dnia/i", + "allTime": "Wszystkie dane" + }, + "saveAsImageTitle": "Podsumowanie", + "totalTime": "Ogółem {totalTime}" + }, + "ranking": { + "includingCount": "Wliczając {siteCount} stron" + } } } \ No newline at end of file diff --git a/src/i18n/message/popup/footer-resource.json b/src/i18n/message/popup/footer-resource.json index 842eba522..de5ab42c8 100644 --- a/src/i18n/message/popup/footer-resource.json +++ b/src/i18n/message/popup/footer-resource.json @@ -64,5 +64,11 @@ "percentage": "Dağılım", "ranking": "Sıralama" } + }, + "pl": { + "route": { + "percentage": "Rozkład", + "ranking": "Ranking" + } } } \ No newline at end of file diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index c6a2299ca..2b7ac794a 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -38,6 +38,7 @@ "donutChart": "Affichés sous forme de graphiques en anneau" }, "ru": { + "rating": "Отправить оценку", "showSiteName": "Отобразить имя сайта", "showTopN": "Отображать заголовок {n}" }, @@ -47,5 +48,9 @@ "showSiteName": "Site adını göster", "showTopN": "En iyi {n} göster", "donutChart": "Halka grafik olarak göster" + }, + "pl": { + "rating": "Prześlij ocenę", + "showSiteName": "Wyświetl nazwę witryny" } } \ No newline at end of file diff --git a/src/i18n/message/side/list-resource.json b/src/i18n/message/side/list-resource.json index d5f38ece9..a6e81242e 100644 --- a/src/i18n/message/side/list-resource.json +++ b/src/i18n/message/side/list-resource.json @@ -46,5 +46,9 @@ "tr": { "searchPlaceholder": "Alan adı veya URL giriniz", "title": "Gezinme Analizi" + }, + "pl": { + "searchPlaceholder": "Wprowadź domenę lub adres URL", + "title": "Analiza przeglądania " } } \ No newline at end of file From e078a51131621610502f02471cef844ac492e1a1 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Thu, 12 Mar 2026 20:03:09 +0800 Subject: [PATCH 092/174] fix: timeline display error --- package.json | 20 +++++++++---------- .../Analysis/components/Trend/Filter.tsx | 6 +++--- .../ClearPanel/ClearFilter/DateFilter.tsx | 4 ++-- .../Habit/components/HabitFilter.tsx | 4 ++-- .../components/Report/ReportFilter/index.tsx | 8 ++++---- .../common/filter/DateRangeFilterItem.tsx | 6 +++--- src/pages/hooks/useEcharts.ts | 3 +++ src/pages/types.ts | 4 ++++ 8 files changed, 31 insertions(+), 24 deletions(-) create mode 100644 src/pages/types.ts diff --git a/package.json b/package.json index f18bacde5..3fb1e03d7 100644 --- a/package.json +++ b/package.json @@ -32,30 +32,30 @@ "@emotion/babel-plugin": "^11.13.5", "@emotion/css": "^11.13.5", "@rsdoctor/rspack-plugin": "^1.5.3", - "@rspack/cli": "^1.7.7", - "@rspack/core": "^1.7.7", + "@rspack/cli": "^1.7.8", + "@rspack/core": "^1.7.8", "@swc/core": "^1.15.18", "@swc/jest": "^0.2.39", "@types/chrome": "0.1.37", "@types/decompress": "^4.2.7", "@types/jest": "^30.0.0", - "@types/node": "^25.3.3", + "@types/node": "^25.4.0", "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", - "babel-loader": "^10.0.0", - "commitlint": "^20.4.3", + "babel-loader": "^10.1.1", + "commitlint": "^20.4.4", "css-loader": "^7.1.4", "decompress": "^4.2.1", "fake-indexeddb": "^6.2.5", "husky": "^9.1.7", - "jest": "^30.2.0", - "jest-environment-jsdom": "^30.2.0", + "jest": "^30.3.0", + "jest-environment-jsdom": "^30.3.0", "jest-junit": "^16.0.0", "jszip": "^3.10.1", "postcss": "^8.5.8", "postcss-loader": "^8.2.1", "postcss-rtlcss": "^5.7.1", - "puppeteer": "^24.38.0", + "puppeteer": "^24.39.0", "ts-loader": "^9.5.4", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", @@ -64,11 +64,11 @@ "dependencies": { "@element-plus/icons-vue": "^2.3.2", "echarts": "^6.0.0", - "element-plus": "2.13.3", + "element-plus": "2.13.5", "hash.js": "^1.1.7", "punycode": "^2.3.1", "typescript-guard": "^0.2.1", - "vue": "^3.5.29", + "vue": "^3.5.30", "vue-router": "^5.0.3" }, "engines": { diff --git a/src/pages/app/components/Analysis/components/Trend/Filter.tsx b/src/pages/app/components/Analysis/components/Trend/Filter.tsx index e4328609b..e85eae98f 100644 --- a/src/pages/app/components/Analysis/components/Trend/Filter.tsx +++ b/src/pages/app/components/Analysis/components/Trend/Filter.tsx @@ -7,15 +7,15 @@ import DateRangeFilterItem from '@app/components/common/filter/DateRangeFilterItem' import { t } from "@app/locale" +import type { ElDatePickerShortcut } from '@pages/types' import { daysAgo } from "@util/time" -import type { Shortcut } from "element-plus/es/components/date-picker-panel/src/composables/use-shortcut" import { defineComponent } from "vue" import { useAnalysisTrendDateRange } from "./context" -const shortcut = (agoOfStart: number) => ({ +const shortcut = (agoOfStart: number): ElDatePickerShortcut => ({ text: t(msg => msg.calendar.range.lastDays, { n: agoOfStart }), value: daysAgo(agoOfStart - 1, 0), -} satisfies Shortcut) +}) const SHORTCUTS = [7, 15, 30, 90].map(shortcut) diff --git a/src/pages/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx b/src/pages/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx index 3d419a42a..3cdd8241c 100644 --- a/src/pages/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx +++ b/src/pages/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx @@ -7,9 +7,9 @@ import { t, tN } from "@app/locale" import { dateFormat as elDateFormat } from "@i18n/element" import { getDatePickerIconSlots } from "@pages/element-ui/rtl" +import type { ElDatePickerShortcut } from '@pages/types' import { formatTime, getBirthday, MILL_PER_DAY } from "@util/time" import { ElDatePicker } from "element-plus" -import type { Shortcut } from "element-plus/es/components/date-picker-panel/src/composables/use-shortcut" import { defineComponent, type PropType, type StyleValue } from "vue" const _default = defineComponent({ @@ -24,7 +24,7 @@ const _default = defineComponent({ const daysBefore = (days: number) => new Date(new Date().getTime() - days * MILL_PER_DAY) const birthday = getBirthday() - const pickerShortcuts: Shortcut[] = [ + const pickerShortcuts: ElDatePickerShortcut[] = [ { text: t(msg => msg.calendar.range.tillYesterday), value: [birthday, daysBefore(1)], diff --git a/src/pages/app/components/Habit/components/HabitFilter.tsx b/src/pages/app/components/Habit/components/HabitFilter.tsx index dea1e225a..e51a79a0f 100644 --- a/src/pages/app/components/Habit/components/HabitFilter.tsx +++ b/src/pages/app/components/Habit/components/HabitFilter.tsx @@ -9,8 +9,8 @@ import DateRangeFilterItem from "@app/components/common/filter/DateRangeFilterIt import TimeFormatFilterItem from "@app/components/common/filter/TimeFormatFilterItem" import { t } from "@app/locale" import Flex from "@pages/components/Flex" +import type { ElDatePickerShortcut } from '@pages/types' import { daysAgo, MILL_PER_DAY } from "@util/time" -import type { Shortcut } from "element-plus/es/components/date-picker-panel/src/composables/use-shortcut" import { defineComponent } from "vue" import { useHabitFilter } from "./context" @@ -26,7 +26,7 @@ const shortcutProps: ShortCutProp[] = [ [t(msg => msg.calendar.range.lastDays, { n: 365 }), 365], ] -const SHORTCUTS: Shortcut[] = shortcutProps.map(([text, agoOfStart]) => ({ text, value: daysAgo(agoOfStart, 0) })) +const SHORTCUTS: ElDatePickerShortcut[] = shortcutProps.map(([text, agoOfStart]) => ({ text, value: daysAgo(agoOfStart, 0) })) const _default = defineComponent(() => { const filter = useHabitFilter() diff --git a/src/pages/app/components/Report/ReportFilter/index.tsx b/src/pages/app/components/Report/ReportFilter/index.tsx index a05db7ac8..aeffdab75 100644 --- a/src/pages/app/components/Report/ReportFilter/index.tsx +++ b/src/pages/app/components/Report/ReportFilter/index.tsx @@ -11,8 +11,8 @@ import InputFilterItem from '@app/components/common/filter/InputFilterItem' import TimeFormatFilterItem from "@app/components/common/filter/TimeFormatFilterItem" import { t } from "@app/locale" import Flex from "@pages/components/Flex" +import type { ElDatePickerShortcut } from '@pages/types' import { daysAgo } from "@util/time" -import type { Shortcut } from "element-plus/es/components/date-picker-panel/src/composables/use-shortcut" import { defineComponent } from "vue" import { useReportFilter } from "../context" import BatchDelete from "./BatchDelete" @@ -20,11 +20,11 @@ import DownloadFile from "./DownloadFile" import MergeFilterItem from "./MergeFilterItem" import RemoteClient from "./RemoteClient" -const shortcut = (text: string, agoOfStart?: number, agoOfEnd?: number) => ( - { text, value: daysAgo(agoOfStart ?? 0, agoOfEnd ?? 0) } satisfies Shortcut +const shortcut = (text: string, agoOfStart?: number, agoOfEnd?: number): ElDatePickerShortcut => ( + { text, value: daysAgo(agoOfStart ?? 0, agoOfEnd ?? 0) } ) -const dateShortcuts: Shortcut[] = [ +const dateShortcuts: ElDatePickerShortcut[] = [ shortcut(t(msg => msg.calendar.range.today)), shortcut(t(msg => msg.calendar.range.yesterday), 1, 1), shortcut(t(msg => msg.calendar.range.lastDays, { n: 7 }), 7), diff --git a/src/pages/app/components/common/filter/DateRangeFilterItem.tsx b/src/pages/app/components/common/filter/DateRangeFilterItem.tsx index faaaef54f..caa243477 100644 --- a/src/pages/app/components/common/filter/DateRangeFilterItem.tsx +++ b/src/pages/app/components/common/filter/DateRangeFilterItem.tsx @@ -12,13 +12,13 @@ import { useXsState } from '@hooks/useMediaSize' import { dateFormat } from "@i18n/element" import Flex from '@pages/components/Flex' import { getDatePickerIconSlots } from "@pages/element-ui/rtl" +import type { ElDatePickerShortcut } from '@pages/types' import { isRtl } from '@util/document' import { MILL_PER_DAY } from '@util/time' import { type DatePickerProps, ElButton, ElDatePicker, ElText, useNamespace } from "element-plus" -import type { Shortcut } from "element-plus/es/components/date-picker-panel/src/composables/use-shortcut" import { computed, defineComponent, type FunctionalComponent, type StyleValue, toRaw, toRef } from "vue" -const clearShortcut = (): Shortcut => ({ +const clearShortcut = (): ElDatePickerShortcut => ({ text: t(msg => msg.button.clear), value: [new Date(0), new Date(0)], }) @@ -29,7 +29,7 @@ type Props = ModelValue & { disabledDate?: (date: Date) => boolean startPlaceholder?: string endPlaceholder?: string - shortcuts?: Shortcut[] + shortcuts?: ElDatePickerShortcut[] clearable?: boolean } diff --git a/src/pages/hooks/useEcharts.ts b/src/pages/hooks/useEcharts.ts index 3ebc620d7..7a0261b29 100644 --- a/src/pages/hooks/useEcharts.ts +++ b/src/pages/hooks/useEcharts.ts @@ -123,6 +123,9 @@ export const useEcharts = elRef.value && wrapperInstance.init(elRef.value)) }) deps && watch(deps, refresh) diff --git a/src/pages/types.ts b/src/pages/types.ts new file mode 100644 index 000000000..2c7287855 --- /dev/null +++ b/src/pages/types.ts @@ -0,0 +1,4 @@ +export type ElDatePickerShortcut = { + text: string + value: [Date, Date] +} \ No newline at end of file From 2655078e135cb1d905eba212f0617fb3520fd0ad Mon Sep 17 00:00:00 2001 From: sheepzh Date: Thu, 12 Mar 2026 20:04:24 +0800 Subject: [PATCH 093/174] v4.1.1 --- CHANGELOG.md | 4 ++++ package.json | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f64361715..858a8644c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.1.1] - 2026-03-13 + +- Fixed the issue of timeline + ## [4.1.0] - 2026-03-07 - Added notification diff --git a/package.json b/package.json index 3fb1e03d7..d32478347 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "4.1.0", + "version": "4.1.1", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { @@ -74,4 +74,4 @@ "engines": { "node": ">=22" } -} \ No newline at end of file +} From f38e3f46f1fd392a0fd23519774f3e0d936f1691 Mon Sep 17 00:00:00 2001 From: sheepie Date: Fri, 13 Mar 2026 10:50:18 +0800 Subject: [PATCH 094/174] ci: update checkout action version to v6 --- .github/workflows/crowdin-export.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/crowdin-export.yml b/.github/workflows/crowdin-export.yml index caf84e91a..e0f993a53 100644 --- a/.github/workflows/crowdin-export.yml +++ b/.github/workflows/crowdin-export.yml @@ -10,7 +10,7 @@ jobs: TIMER_CROWDIN_AUTH: ${{ secrets.TIMER_CROWDIN_AUTH }} steps: - name: Prepare branch - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 token: ${{secrets.GITHUB_TOKEN}} From d57b66b5b110eb7cea1d4870072db99b1ca601be Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 15 Mar 2026 23:38:37 +0800 Subject: [PATCH 095/174] i18n(download): download translations by bot (#700) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/option-resource.json | 27 +++++++++++++++---- .../message/app/site-manage-resource.json | 12 +++++++-- src/i18n/message/common/button-resource.json | 3 +++ .../message/common/calendar-resource.json | 4 ++- src/i18n/message/common/item-resource.json | 3 +++ src/i18n/message/popup/content-resource.json | 6 ++++- src/i18n/message/popup/header-resource.json | 4 ++- 7 files changed, 49 insertions(+), 10 deletions(-) diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 93091b09c..c072bc747 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -1526,6 +1526,7 @@ "no": "Nie", "on": "Zawsze włączone", "off": "Zawsze wyłączone", + "followBrowser": "Śledź przeglądarkę", "appearance": { "title": "Wygląd", "displayWhitelist": "{input} Wyświetlanie {whitelist} w {contextMenu}", @@ -1549,23 +1550,31 @@ "label": "Tryb ciemny {input}", "timed": "Czas na" }, - "animationDuration": "Długość trwania początkowej animacji wykresu {input}" + "animationDuration": "Długość trwania początkowej animacji wykresu {input}", + "sidePanel": "{input} - czy włączyć panel boczny" }, "tracking": { "title": "Monitorowanie", "autoPauseTrack": "{input} Wstrzymaj monitorowanie, jeżeli nie wykryto aktywności {info} przez {maxTime}", "noActivityInfo": "Mysz i klawiatura są nieaktywne, nie są w trybie pełnoekranowym i nie ma dźwięku", + "countLocalFiles": "{input} Śledzenie czasu, kiedy przeglądarka odczytuje {localFileTime} {info}", "localFileTime": "pliki lokalne", + "localFilesInfo": "Obsługuje pliki takich typów jak PDF, obrazy, txt lub JSON.", + "countTabGroup": "{input} Śledzenie czasu w grupach kart {info}", "tabGroupInfo": "Po usunięciu grupy tagów, dane również zostaną usunięte.", "tabGroupsPermGrant": "Ta funkcja wymaga odpowiednich uprawnień", + "fileAccessDisabled": "Dostęp do adresów URL plików jest obecnie niedozwolony. Proszę najpierw włączyć go na stronie zarządzania", "weekStart": "Pierwszy dzień tygodnia {input}", "weekStartAsNormal": "Normalnie", - "storage": "Przechowuj dane śledzenia w {input}" + "storage": "Przechowuj dane śledzenia w {input}", + "storageConfirm": "Czy chcesz zmienić typ pamięci na {type}?" }, "limit": { + "prompt": "Wyświetlane zapytanie, gdy ograniczono {input}", "reminder": "Przypomnienie {input} {minInput} minut(y) przed limitem czasu", "level": { "label": "Jak odblokować podczas ograniczenia {input}", + "nothing": "Zezwalaj na bezpośrednie odblokowanie na stronie administratora", "password": "Musisz wprowadzić hasło, aby odblokować", "verification": "Musisz wprowadzić kod weryfikacyjny, aby odblokować", "passwordLabel": "Hasło odblokowujące {input}", @@ -1584,24 +1593,31 @@ }, "backup": { "title": "Kopia zapasowa danych", + "type": "Zdalny typ: {input}", "client": "Nazwa klienta {input}", "meta": { + "gist": { + "authInfo": "Wymagany jest przynajmniej jeden token z gist" + }, "obsidian_local_rest_api": { "endpointInfo": "Tylko HTTP jest dostępny, ponieważ CORS nie może być skonfigurowany dla stron rozszerzenia" } }, "label": { "endpoint": "Adres punktu końcowego {info} {input}", + "path": "Ścieżka do folderu {input}", "account": "Nazwa użytkownika {input}", "password": "Hasło {input}" }, "operation": "Kopia zapasowa", "download": { "btn": "Pobierz", - "willDownload": "Do pobrania" + "willDownload": "Do pobrania", + "confirmTip": "{size} części danych z [{clientName}] zostaną pobrane" }, "clear": { - "btn": "Wyczyść" + "btn": "Wyczyść", + "confirmTip": "{rowCount} części danych dla stron {hostCount} śledzonych przez [{clientName}] zostaną usunięte!" }, "confirmStep": "Potwierdź dane", "clientTable": { @@ -1617,7 +1633,8 @@ } }, "accessibility": { - "title": "Ułatwienia dostępu" + "title": "Ułatwienia dostępu", + "chartDecal": "{input} - czy wyświetlić wykres typu decal" }, "notification": { "title": "Powiadomienie", diff --git a/src/i18n/message/app/site-manage-resource.json b/src/i18n/message/app/site-manage-resource.json index 660dd732d..c5ef63f04 100644 --- a/src/i18n/message/app/site-manage-resource.json +++ b/src/i18n/message/app/site-manage-resource.json @@ -525,14 +525,20 @@ "info": "statystyki według nazwy domeny" }, "merged": { - "name": "scalone" + "name": "scalone", + "info": "łączenie statystyk wielu powiązanych nazw stron i reguły łączenia mogą być dostosowywane" + }, + "virtual": { + "name": "wirtualne", + "info": "policz dowolny adres URL w formacie Ant Pattern, możesz dodać niestandardową stronę w prawym górnym rogu" } }, "cate": { "name": "Nazwa", "relatedMsg": "Ta kategoria została przypisana do witryn {siteCount} i nie może zostać usunięta", "removeConfirm": "Potwierdzasz usunięcie kategorii: {category}?", - "batchChange": "Zmień kategorie" + "batchChange": "Zmień kategorie", + "batchDisassociate": "Odłącz kategorie" }, "form": { "emptyAlias": "Wprowadź nazwę witryny", @@ -540,7 +546,9 @@ }, "msg": { "hostExistWarn": "{host} istnieje", + "existedTag": "WYŁĄCZONE", "noSelected": "Nie wybrano żadnej witryny", + "noSupported": "Wybrane witryny nie mogą ustawić kategorii", "disassociatedMsg": "Czy chcesz wyczyścić kategorie wszystkich wybranych witryn?", "batchDeleteMsg": "Czy chcesz usunąć wszystkie wybrane witryny?" } diff --git a/src/i18n/message/common/button-resource.json b/src/i18n/message/common/button-resource.json index 075e16b2a..cb31c2f4c 100644 --- a/src/i18n/message/common/button-resource.json +++ b/src/i18n/message/common/button-resource.json @@ -289,10 +289,13 @@ "previous": "Wstecz", "next": "Dalej", "okey": "Ok", + "dont": "Nie", "operation": "Działania", "configuration": "Ustawienia", "clear": "Wyczyść", "enable": "Włącz", + "batchEnable": "Włączanie serii", + "batchDisable": "Seria wyłączenia", "collapse": "Zwiń", "expand": "Rozwiń" } diff --git a/src/i18n/message/common/calendar-resource.json b/src/i18n/message/common/calendar-resource.json index 21dff2dbe..d8afca072 100644 --- a/src/i18n/message/common/calendar-resource.json +++ b/src/i18n/message/common/calendar-resource.json @@ -280,7 +280,9 @@ "pl": { "weekDays": "Pn|Wt|Śr|Cz|Pt|Sb|Nd", "months": "Sty|Lut|Mar|Kwi|Maj|Cze|Lip|Sie|Wrz|Paź|Lis|Gru", - "dateFormat": "{m}/{d}/{y}", + "dateFormat": "{d}/{m}/{y}", + "monthDateFormat": "{d}/{m}", + "timeFormat": "{d}/{m}/{y} {h}:{i}:{s}", "simpleTimeFormat": "{m}/{d} {h}:{i}", "label": { "startDate": "Data rozpoczęcia", diff --git a/src/i18n/message/common/item-resource.json b/src/i18n/message/common/item-resource.json index 3b27cfb36..2de440d0c 100644 --- a/src/i18n/message/common/item-resource.json +++ b/src/i18n/message/common/item-resource.json @@ -223,6 +223,9 @@ "operation": { "add2Whitelist": "Whitelista", "analysis": "Analizuj", + "deleteConfirmMsgAll": "Wszystkie wpisy wizyt dla [{url}] zostaną usunięte", + "deleteConfirmMsgRange": "Sprawdź wpisy dla [{url}] pomiędzy {start} a {end} zostaną usunięte", + "deleteConfirmMsg": "Wpis wizyt dla [{url}] z {date} zostanie usunięty", "exportWholeData": "Eksportuj", "importWholeData": "Importuj", "importOtherData": "Importuj z rozszerzenia" diff --git a/src/i18n/message/popup/content-resource.json b/src/i18n/message/popup/content-resource.json index ee57c87d9..175c4c241 100644 --- a/src/i18n/message/popup/content-resource.json +++ b/src/i18n/message/popup/content-resource.json @@ -262,7 +262,11 @@ "allTime": "Wszystkie dane" }, "saveAsImageTitle": "Podsumowanie", - "totalTime": "Ogółem {totalTime}" + "averageCount": "Średnio {value} razy co dzień", + "averageTime": "Średnio {value} razy dziennie", + "totalTime": "Ogółem {totalTime}", + "totalCount": "Razem: {totalCount}", + "otherLabel": "Pozostałe strony: {count}" }, "ranking": { "includingCount": "Wliczając {siteCount} stron" diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index 2b7ac794a..be79a1c00 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -51,6 +51,8 @@ }, "pl": { "rating": "Prześlij ocenę", - "showSiteName": "Wyświetl nazwę witryny" + "showSiteName": "Wyświetl nazwę witryny", + "showTopN": "Wyświetl górną część {n}", + "donutChart": "Wyświetlane jako wykresy" } } \ No newline at end of file From 9e217a6491d6027ccd7fac5fd02869f4c32c21fd Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 18 Mar 2026 08:34:15 +0800 Subject: [PATCH 096/174] i18n(download): download translations by bot (#701) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../message/app/data-manage-resource.json | 1 + src/i18n/message/app/option-resource.json | 26 +++++++++++++++++-- .../message/common/calendar-resource.json | 1 + src/i18n/message/popup/header-resource.json | 4 ++- 4 files changed, 29 insertions(+), 3 deletions(-) diff --git a/src/i18n/message/app/data-manage-resource.json b/src/i18n/message/app/data-manage-resource.json index d5c8de1f7..ff045e46c 100644 --- a/src/i18n/message/app/data-manage-resource.json +++ b/src/i18n/message/app/data-manage-resource.json @@ -208,6 +208,7 @@ "totalMemoryAlert": "Der Browser stellt {size}MB zur Verfügung, um lokale Daten für jede Erweiterung zu speichern", "totalMemoryAlert1": "Der vom Browser maximal verfügbare Speicher kann nicht ermittelt werden", "usedMemoryAlert": "Derzeit werden {size}MB verwendet", + "idbAlert": "Bewege Tracking Daten nach IndexedDB um Speichernutzung zu Reduzieren", "operationAlert": "Sie können diese unwichtigen Daten löschen, um den Speicherverbrauch zu reduzieren", "filterItems": "Daten filtern", "filterFocus": "Die Browsing-Zeit am Tag liegt zwischen {start} Sekunden und {end} Sekunden", diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index c072bc747..f677c7d63 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -897,6 +897,8 @@ "de": { "yes": "Ja", "no": "Nein", + "on": "Immer an", + "off": "Immer aus", "followBrowser": "Browser verfolgen", "appearance": { "title": "Aussehen", @@ -920,11 +922,13 @@ "darkMode": { "label": "Dunkler Modus {input}" }, - "animationDuration": "Die Dauer der ersten Animation des Diagramms {input}" + "animationDuration": "Die Dauer der ersten Animation des Diagramms {input}", + "sidePanel": "{input} Ob das Seitenpanel aktiviert wird" }, "tracking": { "title": "Statistik", "autoPauseTrack": "{input} Tracking pausieren, wenn {maxTime} keine Aktivität erkannt wird {info}", + "noActivityInfo": "Maus und Tastatur sind inaktiv, nicht im Vollbildmodus und es gibt kein Audio", "countLocalFiles": "{input} Zeit an {localFileTime} {info} im Browser zählen", "localFileTime": "eine lokale Datei lesen", "localFilesInfo": "Unterstützt Dateitypen, wie PDF, Bilder, .txt und .json", @@ -933,7 +937,9 @@ "tabGroupsPermGrant": "Dieses Feature benötigt entsprechende Berechtigungen", "fileAccessDisabled": "Der Zugriff auf Datei-URLs ist derzeit nicht erlaubt. Bitte aktiviere ihn zuerst auf der Verwaltungsseite", "weekStart": "Erster Tag der Woche {input}", - "weekStartAsNormal": "Wie normal" + "weekStartAsNormal": "Wie normal", + "storage": "Speichern der Trackingdaten in {input}", + "storageConfirm": "Möchten Sie die Speicherart ändern zu {type}?" }, "limit": { "prompt": "Eingabeaufforderung wird angezeigt, wenn eingeschränkt {input}", @@ -1002,6 +1008,22 @@ "title": "Barrierefreiheit", "chartDecal": "{input} Ob das Diagramm-Aufkleber angezeigt werden soll" }, + "notification": { + "title": "Benachrichtigungen", + "cycle": { + "label": "Benachrichtigungszyklus {input}", + "daily": "Täglich", + "weekly": "Wöchentlich" + }, + "method": { + "label": "Nachrichtenmethode {input}", + "browser": "Browser", + "callback": { + "label": "HTTP Callback", + "url": "Callback URL {input}" + } + } + }, "resetButton": "Zurücksetzen", "resetSuccess": "Auf Standardwerte zurückgesetzt!", "exportButton": "Einstellungen exportieren", diff --git a/src/i18n/message/common/calendar-resource.json b/src/i18n/message/common/calendar-resource.json index d8afca072..e0295ccce 100644 --- a/src/i18n/message/common/calendar-resource.json +++ b/src/i18n/message/common/calendar-resource.json @@ -179,6 +179,7 @@ "everyday": "Täglich", "thisWeek": "Diese Woche", "thisMonth": "Diesen Monat", + "lastWeek": "Letzte Woche", "lastDays": "Letzte {n} Tage", "tillYesterday": "Bis gestern", "tillDaysAgo": "Bis vor {n} Tagen", diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index be79a1c00..71ea76185 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -29,8 +29,10 @@ "showTopN": "Mostrar primeros {n}" }, "de": { + "rating": "Bewertung absenden", "showSiteName": "Seitenname anzeigen", - "showTopN": "Top {n} anzeigen" + "showTopN": "Top {n} anzeigen", + "donutChart": "Als Donut-Diagramme angezeigt" }, "fr": { "showSiteName": "Afficher le nom du site", From 48b653fa584524cee79a44d0cc3d343ded7bfbe9 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 20 Mar 2026 20:29:10 +0800 Subject: [PATCH 097/174] i18n: support Italian --- script/crowdin/common.ts | 6 +- src/i18n/chrome/index.ts | 1 + src/i18n/element.ts | 1 + src/i18n/message/app/about-resource.json | 17 ++++++ src/i18n/message/app/analysis-resource.json | 31 +++++++++++ src/i18n/message/app/dashboard-resource.json | 25 +++++++++ .../message/app/data-manage-resource.json | 30 ++++++++++ src/i18n/message/app/habit-resource.json | 38 +++++++++++++ src/i18n/message/app/help-us-resource.json | 9 +++ src/i18n/message/app/limit-resource.json | 52 +++++++++++++++++- src/i18n/message/app/menu-resource.json | 16 ++++++ src/i18n/message/app/merge-rule-resource.json | 18 ++++++ src/i18n/message/app/option-resource.json | 55 +++++++++++++++++++ src/i18n/message/app/report-resource.json | 20 +++++++ .../message/app/site-manage-resource.json | 36 ++++++++++++ src/i18n/message/app/whitelist-resource.json | 8 +++ .../message/common/calendar-resource.json | 1 + src/i18n/message/common/locale-resource.json | 11 ++-- src/i18n/message/common/meta-resource.json | 27 +++++---- src/i18n/message/merge.ts | 1 + src/i18n/message/popup/content-resource.json | 19 +++++++ src/i18n/message/popup/header-resource.json | 4 +- src/i18n/message/side/list-resource.json | 3 + .../components/Table/OperationColumn.tsx | 1 + .../ReportTable/columns/OperationColumn.tsx | 1 + types/timer/index.d.ts | 2 + 26 files changed, 414 insertions(+), 19 deletions(-) diff --git a/script/crowdin/common.ts b/script/crowdin/common.ts index bdddecb56..96d8fa686 100644 --- a/script/crowdin/common.ts +++ b/script/crowdin/common.ts @@ -22,6 +22,7 @@ export const ALL_CROWDIN_LANGUAGES = [ 'zh-CN', 'zh-TW', 'ja', 'pt-PT', 'uk', 'es-ES', 'de', 'fr', 'ru', 'pl', 'ar', 'tr', + 'it', ] as const /** @@ -45,7 +46,8 @@ const OPTIONAL_PLACEHOLDER: Record = { zh_CN: 0, zh_TW: 0, pt_PT: 0, - es: 0 + es: 0, + it: 0, } export const ALL_TRANS_LOCALES = Object.keys(OPTIONAL_PLACEHOLDER) as timer.OptionalLocale[] @@ -63,6 +65,7 @@ const CROWDIN_I18N_MAP: Record = { ar: 'ar', tr: 'tr', pl: 'pl', + it: 'it', } const I18N_CROWDIN_MAP: Record = { @@ -78,6 +81,7 @@ const I18N_CROWDIN_MAP: Record = { ar: 'ar', tr: 'tr', pl: 'pl', + it: 'it', } export const crowdinLangOf = (locale: timer.OptionalLocale): CrowdinLanguage => I18N_CROWDIN_MAP[locale] diff --git a/src/i18n/chrome/index.ts b/src/i18n/chrome/index.ts index 525970e45..26c9980d9 100644 --- a/src/i18n/chrome/index.ts +++ b/src/i18n/chrome/index.ts @@ -27,6 +27,7 @@ const _default: { [locale in FakedLocale]: any } = { ar: compile(messages.ar), tr: compile(messages.tr), pl: compile(messages.pl), + it: compile(messages.it), } export default _default \ No newline at end of file diff --git a/src/i18n/element.ts b/src/i18n/element.ts index 8ef9da038..9436586bf 100644 --- a/src/i18n/element.ts +++ b/src/i18n/element.ts @@ -18,6 +18,7 @@ const LOCALES: { [locale in timer.Locale]: () => Promise<{ default: Language }> ar: () => import('element-plus/es/locale/lang/ar'), tr: () => import('element-plus/es/locale/lang/tr'), pl: () => import('element-plus/es/locale/lang/pl'), + it: () => import('element-plus/es/locale/lang/it'), } export const initElementLocale = async (app: App) => { diff --git a/src/i18n/message/app/about-resource.json b/src/i18n/message/app/about-resource.json index c8e087b1a..953962ada 100644 --- a/src/i18n/message/app/about-resource.json +++ b/src/i18n/message/app/about-resource.json @@ -219,5 +219,22 @@ "rate": "Oceń 5 gwiazdek, aby pomóc innym dowiedzieć się o tym~~", "feedback": "Zapraszamy do przesłania opinii i próśb o funkcje !!" } + }, + "it": { + "label": { + "name": "Nome", + "version": "Versione", + "website": "Sito Web", + "installation": "Installa", + "thanks": "Riconoscimenti", + "privacy": "Informativa Privacy", + "license": "Licenza", + "support": "Supporto" + }, + "text": { + "greet": "Ti piace questa estensione?", + "rate": "Valuta 5 stelle per supportarci!", + "feedback": "Le tue idee ci rendono migliori!" + } } } \ No newline at end of file diff --git a/src/i18n/message/app/analysis-resource.json b/src/i18n/message/app/analysis-resource.json index 92fc2d34b..3b5841056 100644 --- a/src/i18n/message/app/analysis-resource.json +++ b/src/i18n/message/app/analysis-resource.json @@ -401,5 +401,36 @@ "focusTitle": "Trendy czasu przeglądania", "visitTitle": "Trendy wizyt" } + }, + "it": { + "target": { + "site": "Sito web", + "cate": "Categoria" + }, + "common": { + "focusTotal": "Tempo totale di navigazione", + "visitTotal": "Visite totali", + "merged": "Uniti", + "virtual": "Virtuale", + "hostPlaceholder": "Cerca un sito da analizzare", + "emptyDesc": "Nessun sito selezionato" + }, + "summary": { + "title": "Riepilogo", + "day": "Giorni attivi in totale", + "firstDay": "Prima visita {value}", + "calendarTitle": "Attività nelle ultime settimane" + }, + "trend": { + "title": "Tendenze", + "activeDay": "Giorni attivi", + "totalDay": "Periodo: Giorni", + "maxFocus": "Tempo di navigazione massimo giornaliero", + "averageFocus": "Tempo medio di navigazione giornaliero", + "maxVisit": "Visite massime giornaliere", + "averageVisit": "Visite medie giornaliere", + "focusTitle": "Tendenze di Navigazione nel periodo", + "visitTitle": "Tendenze Di Visite" + } } } \ No newline at end of file diff --git a/src/i18n/message/app/dashboard-resource.json b/src/i18n/message/app/dashboard-resource.json index ae51f9504..a88b3a82c 100644 --- a/src/i18n/message/app/dashboard-resource.json +++ b/src/i18n/message/app/dashboard-resource.json @@ -309,5 +309,30 @@ "focusScore": "Skupienie", "focusScoreDesc": "Powiązane z całkowitym czasem ciągłego przebywania na tej samej stronie. Wzór znajduje się w kodzie źródłowym" } + }, + "it": { + "heatMap": { + "title0": "Navigato per {hour} ore nell'ultimo anno", + "title1": "Navigato meno di 1 ora l'anno scorso" + }, + "topK": { + "title": "TOP {k} più visitati negli ultimi {day} giorni" + }, + "indicator": { + "installedDays": "Installato per {number} giorni", + "visitCount": "Visitato {site} {visit} volte", + "browsingTime": "Navigato per {minute} minuti", + "mostUse": "Navigazione preferita tra le {start} e le {end}" + }, + "monthOnMonth": { + "title": "Tendenza del tempo di navigazione mese per mese" + }, + "timeline": { + "title": "Cronologia degli ultimi {n} giorni", + "busyScore": "Frenesia", + "busyScoreDesc": "Relativo al tempo totale di navigazione e al numero di siti web per ora. Guarda il codice sorgente per la formula di calcolo", + "focusScore": "Focalizzazione", + "focusScoreDesc": "Relativo al tempo totale di navigazione continua dello stesso sito web. Guarda il codice sorgente per la formula di calcolo" + } } } \ No newline at end of file diff --git a/src/i18n/message/app/data-manage-resource.json b/src/i18n/message/app/data-manage-resource.json index ff045e46c..3199a1343 100644 --- a/src/i18n/message/app/data-manage-resource.json +++ b/src/i18n/message/app/data-manage-resource.json @@ -381,5 +381,35 @@ "deleteConfirm": "Wyfiltrowano w sumie {count} wpisów. Czy chcesz je usunąć?", "migrationAlert": "Przenieś dane między przeglądarkami za pomocą importu i eksportu", "importError": "Błędne rozszerzenie pliku" + }, + "it": { + "totalMemoryAlert": "Il browser fornisce {size}MB per memorizzare i dati locali per ogni estensione", + "totalMemoryAlert1": "Impossibile determinare la memoria massima disponibile consentita dal browser", + "usedMemoryAlert": "{size}MB è attualmente in uso", + "idbAlert": "Sposta i dati di tracciamento su IndexedDB per ridurre l'utilizzo dello storage", + "operationAlert": "È possibile eliminare i dati non importanti per ridurre l'utilizzo della memoria", + "filterItems": "Filtro dati", + "filterFocus": "L'ora di navigazione del giorno è tra {start} secondi e {end} secondi", + "filterTime": "Il numero di visite per il giorno è compreso tra {start} e {end}", + "filterDate": "Registrato tra {picker}", + "importOther": { + "step1": "Seleziona dati", + "step2": "Conferma dei dati", + "dataSource": "Origine dati", + "file": "File di dati", + "selectFileBtn": "Seleziona", + "conflictType": "Risoluzione dei conflitti", + "conflictTip": "Cosa fare se i dati importati sono in conflitto con i dati locali", + "overwrite": "Sovrascrivere", + "accumulate": "Accumulare", + "imported": "Importato", + "local": "Locali", + "fileNotSelected": "File non selezionato", + "conflictNotSelected": "Risoluzione dei conflitti non selezionata" + }, + "paramError": "Il parametro è errato, per favore controlla!", + "deleteConfirm": "Un totale di {count} record sono stati filtrati. Vuoi eliminarli tutti?", + "migrationAlert": "Migrare i dati tra browser utilizzando importazione ed esportazione", + "importError": "Estensione del file sbagliata" } } \ No newline at end of file diff --git a/src/i18n/message/app/habit-resource.json b/src/i18n/message/app/habit-resource.json index 3015ebf81..4b186b063 100644 --- a/src/i18n/message/app/habit-resource.json +++ b/src/i18n/message/app/habit-resource.json @@ -492,5 +492,43 @@ "siteCount": "Liczba stron" } } + }, + "it": { + "common": { + "focusAverage": "{value} medio giornaliera" + }, + "period": { + "title": "Abitudine per periodi di tempo", + "busiest": "Ora più attiva della giornata", + "idle": "Periodo d'inattività più lungo", + "chartType": { + "average": "Media giornaliera", + "trend": "Tendenza", + "stack": "Stack" + }, + "sizes": { + "fifteen": "Per 15 minuti", + "halfHour": "Per mezz'ora", + "hour": "Per un'ora", + "twoHour": "Per due ore" + } + }, + "site": { + "title": "Abitudine dei siti", + "histogramTitle": "TOP {n} più visitati", + "exclusiveToday": "I dati di oggi non sono inclusi nella media", + "countTotal": "Totale visite/siti", + "siteAverage": "Visite medie a {value} siti web al giorno", + "distribution": { + "title": "Distribuzione di frequenza media giornaliera", + "aveTime": "Tempo medio di navigazione giornaliero", + "aveVisit": "Numero medio di visite giornaliere", + "tooltip": "Totale: {value} siti" + }, + "trend": { + "title": "Tendenze quotidiane", + "siteCount": "Conteggio siti web" + } + } } } \ No newline at end of file diff --git a/src/i18n/message/app/help-us-resource.json b/src/i18n/message/app/help-us-resource.json index ac342515e..0496a171b 100644 --- a/src/i18n/message/app/help-us-resource.json +++ b/src/i18n/message/app/help-us-resource.json @@ -141,5 +141,14 @@ "button": "Przejdź do Crowdin", "loading": "Sprawdzanie postępu tłumaczenia...", "contributors": "Lista współtwórców" + }, + "it": { + "title": "Sentitevi liberi di contribuire a migliorare le traduzioni di localizzazione dell'estensione!", + "alert": { + "l1": "Al fine di fornire una migliore esperienza utente, ospito le attività di traduzione su Crowdin.", + "l3": "Quando il progresso della traduzione di una lingua raggiunge il 50 per cento, considererò di sostenerla in questa estensione." + }, + "loading": "Controllo avanzamento traduzione...", + "contributors": "Lista Contributori" } } \ No newline at end of file diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index d57c32264..a695d74cf 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -320,7 +320,7 @@ "daily": "Límite diario", "weekly": "Límite semanal", "weekStartInfo": "El primer día de cada semana es {weekStart}, puedes cambiar este valor en las opciones de estadísticas", - "delayCount": "Contagem de atraso", + "delayCount": "Contador de retraso", "detail": "Detalle de la regla", "visitTime": "Límite por visita", "period": "Periodos no permitidos", @@ -678,5 +678,55 @@ "confession": "Czas ucieka" }, "reminder": "Mniej niż {min} minut do osiągnięcia limitu!" + }, + "it": { + "filterDisabled": "Solo Abilitati", + "emptyTips": "Clicca qui per creare una regola!", + "item": { + "name": "Nome della regola", + "condition": "Url Ristretto", + "daily": "Limite giornaliero", + "weekly": "Limite settimanale", + "weekStartInfo": "Il primo giorno di ogni settimana è {weekStart}, puoi cambiare questo valore nelle opzioni di tracciamento", + "delayCount": "Ritardo conteggio", + "detail": "Dettagli regola", + "visitTime": "Limite di tempo per visita", + "period": "Periodi bloccati", + "enabled": "Attivata", + "locked": "Bloccato", + "effectiveDay": "Effettivo On", + "delayAllowed": "Ritardabile", + "delayAllowedInfo": "Se va in timeout, aspetta 5 minuti prima di riprovare", + "visits": "visite", + "or": "o", + "notEffective": "Non valido" + }, + "step": { + "base": "Informazione Base", + "url": "URL Di Configurazione", + "rule": "Regola di configurazione" + }, + "button": { + "test": "Test URL" + }, + "message": { + "noUrl": "Nessuna URL aggiunta", + "noRule": "Nessuna regola inserita", + "deleteConfirm": "Vorresti eliminare la regola [{name}]?", + "lockConfirm": "Se bloccato, tutte le operazioni richiederanno la verifica anche se la regola non viene attivata.", + "inputTestUrl": "Si prega d'inserire il link URL da testare per primo", + "noRuleMatched": "L'URL non soddisfa alcuna regola", + "rulesMatched": "L'URL soddisfa le seguenti regole:", + "timeout": "Tempo scaduto! XD" + }, + "verification": { + "inputTip": "La regola è stata attivata o bloccata. Per continuare, inserisci la risposta alla seguente domanda entro {second} secondi: {prompt}", + "inputTip2": "La regola è stata attivata o bloccata. Per continuare, inseriscila così com'è entro {second} secondi: {answer}", + "strictTip": "Attivato, nessuna operazione è consentita prima del rilascio!", + "incorrectPsw": "Password non e corretta", + "incorrectAnswer": "Risposta sbagliata", + "pi": "{digitCount} cifre da {startIndex} a {endIndex} della parte decimale di π" + }, + "reminder": "Meno di {min} minuti fino al limite!" } } \ No newline at end of file diff --git a/src/i18n/message/app/menu-resource.json b/src/i18n/message/app/menu-resource.json index 768597798..f509684bc 100644 --- a/src/i18n/message/app/menu-resource.json +++ b/src/i18n/message/app/menu-resource.json @@ -219,5 +219,21 @@ "other": "Pozostałe funkcje", "helpUs": "Pomóż w tłumaczeniu", "about": "O rozszerzeniu" + }, + "it": { + "dashboard": "Dashboard", + "data": "Miei Dati", + "dataReport": "Record", + "siteAnalysis": "Analiso Sito", + "dataClear": "Memoria", + "behavior": "Comportamento Dell'Utente", + "habit": "Abitudini", + "limit": "Limite di tempo", + "additional": "Funzionalità aggiuntive", + "siteManage": "Gestione del sito", + "whitelist": "Whitelist", + "other": "Altre funzioni", + "helpUs": "Aiuto Traduzione", + "about": "Info su" } } \ No newline at end of file diff --git a/src/i18n/message/app/merge-rule-resource.json b/src/i18n/message/app/merge-rule-resource.json index 2beb3e90f..109ffac37 100644 --- a/src/i18n/message/app/merge-rule-resource.json +++ b/src/i18n/message/app/merge-rule-resource.json @@ -245,5 +245,23 @@ "blank": "Nie scalaj", "level": "Zachowaj poziom {level}" } + }, + "it": { + "removeConfirmMsg": "{origin} Verrà rimosso dalle regole di unione personalizzate.", + "originPlaceholder": "Sito originale", + "mergedPlaceholder": "Uniti", + "errorOrigin": "Il formato del sito originale non è valido.", + "duplicateMsg": "La regola esiste già: {origin}", + "addConfirmMsg": "Verranno impostate regole di unione personalizzate per {origin}", + "infoAlert0": "Fai clic sul pulsante [Nuovo]; verranno visualizzati i campi d'immissione relativi al sito di origine e al sito di unione; compila i campi e salva la regola.", + "infoAlert1": "Il sito originale può essere compilato con un sito specifico o un'espressione regolare, ad esempio www.baidu.com, *.baidu.com, *.google.com*, per determinare quali siti saranno interessati da questa regola durante l'unione", + "infoAlert2": "Il sito unito può contenere il nome di un sito specifico, un numero o essere lasciato vuoto.", + "infoAlert3": "Un numero indica il livello del sito a cui viene unito. Ad esempio, se esiste una regola del tipo '*.*.edu.cn >>> 3', allora 'www.hust.edu.cn' verrà unito a 'hust.edu.cn'", + "infoAlert4": "Vuoto significa che il sito originale non verrà unito", + "infoAlert5": "Se non viene individuata alcuna regola corrispondente, verrà utilizzato per impostazione predefinita il livello precedente {psl}", + "tagResult": { + "blank": "Non unite", + "level": "Mantiene Livello {level}" + } } } \ No newline at end of file diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index f677c7d63..4990678ea 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -1684,5 +1684,60 @@ "importConfirm": "Ustawienia zaimportowane pomyślnie, proszę odświeżyć stronę aby zastosować zmiany!", "reloadButton": "Odśwież", "defaultValue": "Domyślnie: {default}" + }, + "it": { + "yes": "Si", + "no": "No", + "appearance": { + "title": "Apparenza", + "displayWhitelist": "{input} Se visualizzare {whitelist} in {contextMenu}", + "locale": { + "label": "Linguaggio {input}", + "changeConfirm": "La lingua è stata modificata con successo, si prega di aggiorna questa pagina!", + "reloadButton": "Aggiorna" + }, + "printInConsole": { + "console": "console" + }, + "darkMode": { + "label": "Modalità oscura {input}" + } + }, + "tracking": { + "storageConfirm": "Vuoi cambiare il tipo di storage in {type}?" + }, + "backup": { + "client": "Nome dell' Cliente {input}", + "label": { + "account": "Nome Utente {input}", + "password": "Password {input}" + }, + "operation": "Backup", + "download": { + "btn": "Scarica", + "willDownload": "Da scaricare" + }, + "clear": { + "btn": "Cancella" + }, + "confirmStep": "Conferma i dati", + "clientTable": { + "selectTip": "Seleziona cliente", + "current": "Attuale" + }, + "lastTimeTip": "L' Ultimo tempo di backup: {lastTime}", + "auto": { + "label": "Indica se abilitare il backup automatico", + "interval": "e esegui ogni {input} minuti" + } + }, + "accessibility": { + "title": "Accessibilità" + }, + "resetButton": "Reset", + "resetSuccess": "Reset completato!", + "exportButton": "Impostazioni di esportazione", + "importButton": "Impostazioni di importazione", + "defaultValue": "Default: {default}" } } \ No newline at end of file diff --git a/src/i18n/message/app/report-resource.json b/src/i18n/message/app/report-resource.json index ca90edca1..b0309cec1 100644 --- a/src/i18n/message/app/report-resource.json +++ b/src/i18n/message/app/report-resource.json @@ -269,5 +269,25 @@ } }, "noMore": "Nie więcej" + }, + "it": { + "exportFileName": "My_Browsing_Time", + "total": "Visite totali: {visit} volte, durata totale: {focus}", + "batchDelete": { + "noSelectedMsg": "Si prega di selezionare la riga che si desidera eliminare nella prima tabella", + "confirmMsg": "{count} record come [{example}] di {date} saranno eliminati!", + "confirmMsgAll": "{count} record come [{example}], saranno eliminati!" + }, + "remoteReading": { + "on": "Lettura dei dati di backup remoti", + "off": "Clicca per leggere i dati di backup remoti", + "table": { + "client": "Nome del cliente", + "localData": "Dati locali", + "value": "Valore", + "percentage": "Percentuale" + } + }, + "noMore": "Nient'altro" } } \ No newline at end of file diff --git a/src/i18n/message/app/site-manage-resource.json b/src/i18n/message/app/site-manage-resource.json index c5ef63f04..66a75141d 100644 --- a/src/i18n/message/app/site-manage-resource.json +++ b/src/i18n/message/app/site-manage-resource.json @@ -552,5 +552,41 @@ "disassociatedMsg": "Czy chcesz wyczyścić kategorie wszystkich wybranych witryn?", "batchDeleteMsg": "Czy chcesz usunąć wszystkie wybrane witryny?" } + }, + "it": { + "deleteConfirmMsg": "{host} sarà eliminato", + "column": { + "type": "Site Type", + "alias": "Nome del sito", + "cate": "Categoria di Sito", + "icon": "Icone" + }, + "type": { + "normal": { + "name": "normale", + "info": "statistiche in base al nome dominio" + }, + "merged": { + "name": "uniti" + }, + "virtual": { + "name": "virtuale" + } + }, + "cate": { + "name": "Nome", + "removeConfirm": "Confermi di voler eliminare la categoria: {category}?", + "batchChange": "Cambia categorie" + }, + "form": { + "emptyAlias": "Inserisci il nome del sito", + "emptyHost": "Inserisci l'URL del sito" + }, + "msg": { + "hostExistWarn": "{host} esiste", + "noSelected": "Nessun sito selezionato", + "disassociatedMsg": "Vuoi cancellare le categorie di tutti i siti selezionati?", + "batchDeleteMsg": "Vuoi eliminare tutti i siti selezionati?" + } } } \ No newline at end of file diff --git a/src/i18n/message/app/whitelist-resource.json b/src/i18n/message/app/whitelist-resource.json index b7c20c130..dfd8f85cb 100644 --- a/src/i18n/message/app/whitelist-resource.json +++ b/src/i18n/message/app/whitelist-resource.json @@ -125,5 +125,13 @@ "infoAlert1": "Strony znajdujące się na whiteliście nie będą limitowane", "infoAlert2": "Możesz użyć symboli wieloznacznych(*) do dopasowania wielu witryn, takich jak *.przykład.com/**, i użyć + jako prefiks do wykluczenia witryn, takich jak +potrzebny.przykład.com/**", "errorInput": "Niepoprawny URL strony" + }, + "it": { + "addConfirmMsg": "{url} sarà aggiunto alla whitelist.", + "removeConfirmMsg": "{url} sarà rimosso dalla whitelist.", + "duplicateMsg": "Duplicato", + "infoAlert0": "I siti in whitelist non saranno conteggiati", + "infoAlert1": "I siti in whitelist non saranno conteggiati", + "errorInput": "URL del sito non valido" } } \ No newline at end of file diff --git a/src/i18n/message/common/calendar-resource.json b/src/i18n/message/common/calendar-resource.json index e0295ccce..2e2dc64ad 100644 --- a/src/i18n/message/common/calendar-resource.json +++ b/src/i18n/message/common/calendar-resource.json @@ -156,6 +156,7 @@ "everyday": "Diario", "thisWeek": "Esta semana", "thisMonth": "Este mes", + "lastWeek": "Semana pasada", "lastDays": "Últimos {n} días", "tillYesterday": "Hasta ayer", "tillDaysAgo": "Hasta hace {n} días", diff --git a/src/i18n/message/common/locale-resource.json b/src/i18n/message/common/locale-resource.json index 74b0791d7..382d71596 100644 --- a/src/i18n/message/common/locale-resource.json +++ b/src/i18n/message/common/locale-resource.json @@ -45,18 +45,19 @@ }, "tr": { "name": "Türkçe", - "comma": "," + "comma": ", " }, "pl": { "name": "Polski", - "comma": "," + "comma": ", " + }, + "it": { + "name": "italiano", + "comma": ", " }, "ko": { "name": "한국인" }, - "it": { - "name": "italiano" - }, "sv": { "name": "Sverige" }, diff --git a/src/i18n/message/common/meta-resource.json b/src/i18n/message/common/meta-resource.json index 45e5b580d..31eea7b3f 100644 --- a/src/i18n/message/common/meta-resource.json +++ b/src/i18n/message/common/meta-resource.json @@ -12,7 +12,7 @@ "ja": { "name": "タイムトラッカー", "marketName": "タイムトラッカー - Web習慣ビルダー", - "description": "時間を追跡し、あなたの習慣を分析して、中毒性の高いサイトをブロック" + "description": "時間を追跡し、習慣を分析し、依存性のあるサイトをブロック" }, "en": { "name": "Time Tracker", @@ -26,23 +26,23 @@ }, "uk": { "name": "Веб-трекер часу", - "marketName": "Веб-трекер часу", - "description": "Відстежуйте, аналізуйте, контролюйте, а потім підвищуйте ефективність" + "marketName": "Веб-трекер часу - Творець веб-звичок", + "description": "Відстежуйте час, аналізуйте звички та блокуйте сайти, що викликають залежність" }, "es": { "name": "Rastreador de tiempo web", - "marketName": "Rastreador de tiempo web", - "description": "Realice un seguimiento, analice, controle y luego mejore la eficiencia" + "marketName": "Rastreador de tiempo web - Creador de hábitos web", + "description": "Realice un seguimiento, analice sus hábitos y bloquee sitios adictivos" }, "de": { "name": "Zeiterfassung", - "marketName": "Zeiterfassung", - "description": "Verfolgen, analysieren, kontrollieren und dann die Effizienz verbessern" + "marketName": "Zeiterfassung - Web-Gewohnheitstrainer", + "description": "Verfolgen Sie die Zeit, analysieren Sie Ihre Gewohnheiten und blockieren Sie süchtig machende Websites" }, "fr": { "name": "Suivi du temps Web", - "marketName": "Suivi du temps Web - Online Habit Formers", - "description": "Suivre, analyser, contrôler, puis améliorer l’efficacité" + "marketName": "Suivi du temps Web - Créateur d'habitudes web", + "description": "Suivez votre temps, analysez vos habitudes et bloquez les sites addictifs" }, "ru": { "name": "Трекер времени", @@ -52,16 +52,21 @@ "ar": { "name": "متتبع الوقت", "marketName": "متتبع الوقت - منشئ عادات الويب", - "description": "تتبع وتحليل ومراقبة ثم تحسين الكفاءة" + "description": "تتبع الوقت، حلل عاداتك، واحظر المواقع الإدمانية" }, "tr": { "name": "Zaman Takipçisi", "marketName": "Zaman Takipçisi – Alışkanlık Oluşturucu ve Site Engelleyici", - "description": "Zamanınızı takip edin, alışkanlıklarınızı analiz edin, bağımlılık yapan siteleri engelleyin." + "description": "Zamanınızı takip edin, alışkanlıklarınızı analiz edin, bağımlılık yapan siteleri engelleyin" }, "pl": { "name": "Zegar Czasu", "marketName": "Zegar Czasu - Kreator Nawyków Internetowych", "description": "Śledź czas, analizuj swoje nawyki i blokuj uzależniające strony" + }, + "it": { + "name": "Tracker del Tempo", + "marketName": "Tracker del Tempo - Costruttore di Abitudini Web", + "description": "Traccia il tempo, analizza le tue abitudini e blocca i siti che creano dipendenza" } } \ No newline at end of file diff --git a/src/i18n/message/merge.ts b/src/i18n/message/merge.ts index eef4fd75a..5ce41cec3 100644 --- a/src/i18n/message/merge.ts +++ b/src/i18n/message/merge.ts @@ -12,6 +12,7 @@ const ALL_LOCALE_VALIDATOR: { [locale in timer.Locale]: 0 } = { ar: 0, tr: 0, pl: 0, + it: 0, } export const ALL_LOCALES: timer.Locale[] = Object.keys(ALL_LOCALE_VALIDATOR) as timer.Locale[] diff --git a/src/i18n/message/popup/content-resource.json b/src/i18n/message/popup/content-resource.json index 175c4c241..7b1ffd118 100644 --- a/src/i18n/message/popup/content-resource.json +++ b/src/i18n/message/popup/content-resource.json @@ -271,5 +271,24 @@ "ranking": { "includingCount": "Wliczając {siteCount} stron" } + }, + "it": { + "percentage": { + "title": { + "today": "Dati di oggi", + "yesterday": "Data di Ieri", + "thisWeek": "Dati di questa Settimana", + "thisMonth": "Dati di questo Mese", + "lastDays": "Ultimo {n} giorni dati", + "allTime": "Tutti i dati" + }, + "saveAsImageTitle": "Snapshot", + "averageTime": "{value} al giorno in media", + "totalTime": "Totale {totalTime}", + "totalCount": "{totalCount} volte Totale" + }, + "ranking": { + "includingCount": "Inclusi siti {siteCount}" + } } } \ No newline at end of file diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index 71ea76185..8878972bd 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -25,8 +25,10 @@ }, "uk": {}, "es": { + "rating": "Enviar Puntuación", "showSiteName": "Mostrar nombre del sitio", - "showTopN": "Mostrar primeros {n}" + "showTopN": "Mostrar primeros {n}", + "donutChart": "Mostrar como gráfico de donas" }, "de": { "rating": "Bewertung absenden", diff --git a/src/i18n/message/side/list-resource.json b/src/i18n/message/side/list-resource.json index a6e81242e..592cf21ca 100644 --- a/src/i18n/message/side/list-resource.json +++ b/src/i18n/message/side/list-resource.json @@ -50,5 +50,8 @@ "pl": { "searchPlaceholder": "Wprowadź domenę lub adres URL", "title": "Analiza przeglądania " + }, + "it": { + "title": "Analisi Di Esplorazione" } } \ No newline at end of file diff --git a/src/pages/app/components/Limit/components/Table/OperationColumn.tsx b/src/pages/app/components/Limit/components/Table/OperationColumn.tsx index 9d531f128..9f7f528a9 100644 --- a/src/pages/app/components/Limit/components/Table/OperationColumn.tsx +++ b/src/pages/app/components/Limit/components/Table/OperationColumn.tsx @@ -26,6 +26,7 @@ const LOCALE_WIDTH: { [locale in timer.Locale]: number } = { ar: 220, tr: 220, pl: 220, + it: 220, } const _default = defineComponent<{}>(() => { diff --git a/src/pages/app/components/Report/ReportTable/columns/OperationColumn.tsx b/src/pages/app/components/Report/ReportTable/columns/OperationColumn.tsx index a131e7463..71837898d 100644 --- a/src/pages/app/components/Report/ReportTable/columns/OperationColumn.tsx +++ b/src/pages/app/components/Report/ReportTable/columns/OperationColumn.tsx @@ -35,6 +35,7 @@ const LOCALE_WIDTH: { [locale in timer.Locale]: number } = { ar: 320, tr: 320, pl: 320, + it: 320, } type Props = { diff --git a/types/timer/index.d.ts b/types/timer/index.d.ts index 52e97f7cd..4602115f6 100644 --- a/types/timer/index.d.ts +++ b/types/timer/index.d.ts @@ -23,6 +23,8 @@ declare namespace timer { | 'tr' // @since 3.7.3 | 'pl' + // @since 4.1.2 + | 'it' /** * @since 0.8.0 From 6d08d77183d8095f092ecdd15a97c51d3c49d17e Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 20 Mar 2026 20:30:27 +0800 Subject: [PATCH 098/174] v4.1.2 --- CHANGELOG.md | 4 ++++ package.json | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 858a8644c..ce8f26aea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.1.2] - 2026-03-20 + +- Supported Italian + ## [4.1.1] - 2026-03-13 - Fixed the issue of timeline diff --git a/package.json b/package.json index d32478347..ba9599a15 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "4.1.1", + "version": "4.1.2", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { @@ -74,4 +74,4 @@ "engines": { "node": ">=22" } -} +} \ No newline at end of file From de1dc16aa5a582b4a1e0b465a3109cacc34d4d9a Mon Sep 17 00:00:00 2001 From: sheepzh Date: Fri, 20 Mar 2026 23:58:33 +0800 Subject: [PATCH 099/174] ci: upgrade actions --- .github/workflows/publish-all.yml | 22 +++++++++++----------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/publish-all.yml b/.github/workflows/publish-all.yml index 4fd25d1c8..715cb6a97 100644 --- a/.github/workflows/publish-all.yml +++ b/.github/workflows/publish-all.yml @@ -10,14 +10,14 @@ jobs: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: "v22" + node-version: "v24" - name: Install dependencies run: npm install @@ -53,19 +53,19 @@ jobs: fi - name: Upload Chrome/Edge Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: chrome-edge-package path: market_packages/target.zip - name: Upload Firefox XPI Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: firefox-xpi-package path: market_packages/target.firefox.zip - name: Upload Firefox Source Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: firefox-source-package path: market_packages/target.src.zip @@ -75,7 +75,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download Chrome/Edge Artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: chrome-edge-package @@ -94,7 +94,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download Chrome/Edge Artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: chrome-edge-package @@ -112,17 +112,17 @@ jobs: runs-on: ubuntu-latest steps: - name: Download Firefox XPI Artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: firefox-xpi-package - name: Download Firefox Source Artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: firefox-source-package - name: Upload to Firefox Add-on Store - uses: wdzeng/firefox-addon@v1.2.0-alpha.0 + uses: wdzeng/firefox-addon@v1.2.0 with: addon-guid: "{a8cf72f7-09b7-4cd4-9aaa-7a023bf09916}" xpi-path: target.firefox.zip From e07f38df0a63a2020fea8d853ce3ca9430cd2875 Mon Sep 17 00:00:00 2001 From: sheepie Date: Mon, 23 Mar 2026 10:51:24 +0800 Subject: [PATCH 100/174] fix: a11y alarm of injection flag (#704) --- src/content-script/index.ts | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/content-script/index.ts b/src/content-script/index.ts index dc11cb3ae..2ec914059 100644 --- a/src/content-script/index.ts +++ b/src/content-script/index.ts @@ -21,8 +21,7 @@ const FLAG_ID = '__TIMER_INJECTION_FLAG__' + chrome.runtime.id function getOrSetFlag(): boolean { const pre = document?.getElementById(FLAG_ID) if (!pre) { - const flag = document.createElement('a') - flag.href = '#' + const flag = document.createElement('span') flag.style && (flag.style.visibility = 'hidden') flag && (flag.id = FLAG_ID) @@ -68,4 +67,4 @@ async function main() { await trySendMsg2Runtime('cs.incVisitCount', { host, url }) } -main() \ No newline at end of file +main() From 963ba572e3105f11394a7740a5d57ebf75a07cc4 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 24 Mar 2026 09:56:37 +0800 Subject: [PATCH 101/174] i18n(download): download translations by bot (#705) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/data-manage-resource.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/i18n/message/app/data-manage-resource.json b/src/i18n/message/app/data-manage-resource.json index 3199a1343..287876283 100644 --- a/src/i18n/message/app/data-manage-resource.json +++ b/src/i18n/message/app/data-manage-resource.json @@ -238,6 +238,7 @@ "totalMemoryAlert": "Le navigateur fournit {size}Mo pour stocker des données locales pour chaque extension", "totalMemoryAlert1": "Impossible de déterminer la mémoire maximale disponible autorisée par le navigateur", "usedMemoryAlert": "{size}Mo sont actuellement utilisés", + "idbAlert": "Déplacer les données de suivi vers \"IndexedDB\" pour réduire l'utilisation du stockage", "operationAlert": "Vous pouvez supprimer ces données sans importance afin de réduire l'utilisation de la mémoire", "filterItems": "Filtrer les données", "filterFocus": "Le temps de navigation de la journée est compris entre {start} secondes et {end} secondes", From 259262539acc7b05f8f0bb3373cbfd4bbea24e3e Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 24 Mar 2026 22:23:07 +0800 Subject: [PATCH 102/174] feat: require permission for HTTP notification (#681) --- .vscode/settings.json | 13 ++--- package.json | 29 +++++----- rspack/plugins/generate-json.ts | 4 +- rspack/rspack.common.ts | 2 +- rspack/rspack.dev.firefox.ts | 2 - src/database/option-database.ts | 3 +- src/i18n/message/app/option-resource.json | 14 ++--- src/i18n/message/app/option.ts | 2 +- src/manifest-firefox.ts | 3 +- .../Option/categories/Notification/index.tsx | 43 ++++++++++++-- .../categories/Notification/usePermission.ts | 57 +++++++++++++++++++ .../components/Option/categories/Tracking.tsx | 2 +- src/service/notification/callback/notifier.ts | 17 ++++++ src/util/lang.ts | 9 ++- tsconfig.json | 3 +- types/chrome.d.ts | 40 +------------ 16 files changed, 158 insertions(+), 85 deletions(-) create mode 100644 src/pages/app/components/Option/categories/Notification/usePermission.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index e0dc432ac..07e911641 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,14 +8,10 @@ }, "editor.foldingImportsByDefault": true, "editor.trimAutoWhitespace": true, - "javascript.format.insertSpaceAfterCommaDelimiter": true, - "javascript.preferences.quoteStyle": "single", - "javascript.format.semicolons": "remove", - "javascript.format.insertSpaceBeforeFunctionParenthesis": false, - "typescript.format.insertSpaceAfterCommaDelimiter": true, - "typescript.preferences.quoteStyle": "single", - "typescript.format.semicolons": "remove", - "typescript.format.insertSpaceBeforeFunctionParenthesis": false, + "js/ts.format.insertSpaceAfterCommaDelimiter": true, + "js/ts.preferences.quoteStyle": "single", + "js/ts.format.semicolons": "remove", + "js/ts.format.insertSpaceBeforeFunctionParenthesis": false, "files.eol": "\n", "cSpell.words": [ "Arrayable", @@ -53,6 +49,7 @@ "Vnode", "vueuse", "webcomponents", + "webext", "webstore", "webtime", "wfhg", diff --git a/package.json b/package.json index ba9599a15..7505483a7 100644 --- a/package.json +++ b/package.json @@ -28,22 +28,23 @@ }, "license": "MIT", "devDependencies": { - "@crowdin/crowdin-api-client": "^1.52.0", + "@crowdin/crowdin-api-client": "^1.54.0", "@emotion/babel-plugin": "^11.13.5", "@emotion/css": "^11.13.5", - "@rsdoctor/rspack-plugin": "^1.5.3", - "@rspack/cli": "^1.7.8", - "@rspack/core": "^1.7.8", - "@swc/core": "^1.15.18", + "@rsdoctor/rspack-plugin": "^1.5.5", + "@rspack/cli": "^1.7.10", + "@rspack/core": "^1.7.10", + "@swc/core": "^1.15.21", "@swc/jest": "^0.2.39", - "@types/chrome": "0.1.37", + "@types/chrome": "0.1.38", "@types/decompress": "^4.2.7", + "@types/firefox-webext-browser": "^143.0.0", "@types/jest": "^30.0.0", - "@types/node": "^25.4.0", + "@types/node": "^25.5.0", "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.1.1", - "commitlint": "^20.4.4", + "commitlint": "^20.5.0", "css-loader": "^7.1.4", "decompress": "^4.2.1", "fake-indexeddb": "^6.2.5", @@ -55,23 +56,23 @@ "postcss": "^8.5.8", "postcss-loader": "^8.2.1", "postcss-rtlcss": "^5.7.1", - "puppeteer": "^24.39.0", + "puppeteer": "^24.40.0", "ts-loader": "^9.5.4", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "5.9.3" + "typescript": "6.0.2" }, "dependencies": { "@element-plus/icons-vue": "^2.3.2", "echarts": "^6.0.0", - "element-plus": "2.13.5", + "element-plus": "2.13.6", "hash.js": "^1.1.7", "punycode": "^2.3.1", - "typescript-guard": "^0.2.1", + "typescript-guard": "^0.2.2", "vue": "^3.5.30", - "vue-router": "^5.0.3" + "vue-router": "^5.0.4" }, "engines": { "node": ">=22" } -} \ No newline at end of file +} diff --git a/rspack/plugins/generate-json.ts b/rspack/plugins/generate-json.ts index 4682f3cac..5117dada8 100644 --- a/rspack/plugins/generate-json.ts +++ b/rspack/plugins/generate-json.ts @@ -1,14 +1,14 @@ import { Compilation, Compiler, sources } from '@rspack/core' type GenerateJsonPluginOptions = { - data: Record + data: unknown outputPath: string } export class GenerateJsonPlugin { private options: GenerateJsonPluginOptions - constructor(outputPath: string, data: Record) { + constructor(outputPath: string, data: unknown) { if (!data || typeof data !== 'object') { throw new Error('Invalid data option') } diff --git a/rspack/rspack.common.ts b/rspack/rspack.common.ts index 3f2554c0c..c55c05a0d 100644 --- a/rspack/rspack.common.ts +++ b/rspack/rspack.common.ts @@ -118,7 +118,7 @@ const staticOptions: Configuration = { type Option = { outputPath: string - manifest: chrome.runtime.ManifestV3 | chrome.runtime.ManifestFirefox + manifest: chrome.runtime.ManifestV3 | browser._manifest.WebExtensionManifest mode: Configuration["mode"] } diff --git a/rspack/rspack.dev.firefox.ts b/rspack/rspack.dev.firefox.ts index ff149a7a4..71eef5dbc 100644 --- a/rspack/rspack.dev.firefox.ts +++ b/rspack/rspack.dev.firefox.ts @@ -3,8 +3,6 @@ import manifest from "../src/manifest-firefox" import generateOption from "./rspack.common" manifest.name = "IS DEV" -// Fix the crx id for development mode -manifest.key = "clbbddpinhgdejpoepalbfnkogbobfdb" // The manifest.json is different from Chrome's with add-on ID manifest.browser_specific_settings = { ...manifest.browser_specific_settings, diff --git a/src/database/option-database.ts b/src/database/option-database.ts index d2ee7c88f..ead1de21a 100644 --- a/src/database/option-database.ts +++ b/src/database/option-database.ts @@ -6,6 +6,7 @@ */ import { defaultOption } from "@util/constant/option" +import { mergeObject } from '@util/lang' import BaseDatabase from "./common/base-database" import { REMAIN_WORD_PREFIX } from "./common/constant" @@ -20,7 +21,7 @@ class OptionDatabase extends BaseDatabase { async getOption(): Promise { const option = await this.storage.getOne(DB_KEY) - return option || defaultOption() + return mergeObject(defaultOption(), option) } async setOption(option: timer.option.AllOption): Promise { diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 4990678ea..6f2607cc8 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -5,6 +5,7 @@ "on": "始终开启", "off": "始终关闭", "followBrowser": "跟随浏览器", + "permGrantConfirm": "该功能需要授予相关权限", "appearance": { "title": "外观", "displayWhitelist": "{input} 是否在 {contextMenu} 里,显示 {whitelist} 相关功能", @@ -40,7 +41,6 @@ "localFilesInfo": "支持 PDF、图片、txt 以及 json 等格式", "countTabGroup": "{input} 是否统计标签组的时间 {info}", "tabGroupInfo": "删除标签组后,数据也会被删除", - "tabGroupsPermGrant": "该功能需要授予相关权限", "fileAccessDisabled": "目前不允许访问文件网址,请先在管理界面开启", "weekStart": "每周的第一天 {input}", "weekStartAsNormal": "按照惯例", @@ -147,6 +147,7 @@ "on": "始終開啟", "off": "始終關閉", "followBrowser": "跟隨瀏覽器", + "permGrantConfirm": "此功能需要相關權限才能運作", "appearance": { "title": "外觀設定", "displayWhitelist": "{input} 是否在 {contextMenu} 顯示 {whitelist} 功能", @@ -180,7 +181,6 @@ "localFilesInfo": "支援 PDF、圖片、txt 及 json 等格式", "countTabGroup": "{input} 是否追蹤分頁群組的時間 {info}", "tabGroupInfo": "刪除分頁群組時,相關的時間追蹤數據也會一併清除。", - "tabGroupsPermGrant": "此功能需要相關權限才能運作", "fileAccessDisabled": "目前不允許存取檔案 URL,請至管理頁面啟用", "weekStart": "每週起始日 {input}", "weekStartAsNormal": "依慣例" @@ -269,6 +269,7 @@ "on": "Always on", "off": "Always off", "followBrowser": "Follow browser", + "permGrantConfirm": "This feature requires relevant permissions", "appearance": { "title": "Appearance", "displayWhitelist": "{input} Whether to display {whitelist} in {contextMenu}", @@ -304,7 +305,6 @@ "localFilesInfo": "Supports files of types such as PDF, image, txt and json.", "countTabGroup": "{input} Whether to track the time of tab groups {info}", "tabGroupInfo": "When you delete a tab group, the data will also be deleted.", - "tabGroupsPermGrant": "This feature requires relevant permissions", "fileAccessDisabled": "Access to file URLs is currently not allowed. Please enable it on the manage page first", "weekStart": "The first day for each week {input}", "weekStartAsNormal": "As Normal", @@ -532,6 +532,7 @@ "on": "Sempre ativo", "off": "Sempre inativo", "followBrowser": "Usar do navegador", + "permGrantConfirm": "Esta funcionalidade precisa de permissões relevantes", "appearance": { "title": "Aparência", "displayWhitelist": "{input} Mostrar {whitelist} no {contextMenu}", @@ -567,7 +568,6 @@ "localFilesInfo": "Suporta PDF, imagens, txt e json.", "countTabGroup": "{input} Se quiser acompanhar o tempo da aba{info}", "tabGroupInfo": "Quando você excluir um grupo de tags, os dados também serão excluídos.", - "tabGroupsPermGrant": "Esta funcionalidade precisa de permissões relevantes", "fileAccessDisabled": "Acesso a URLs de ficheiro não permitido. Ative na página de gestão.", "weekStart": "Primeiro dia da semana {input}", "weekStartAsNormal": "Normal" @@ -656,6 +656,7 @@ "on": "Увімкнено", "off": "Вимкнено", "followBrowser": "Як у браузері", + "permGrantConfirm": "Ця функція потребує відповідних дозволів", "appearance": { "title": "Зовнішній вигляд", "displayWhitelist": "{input} Показувати {whitelist} в {contextMenu}", @@ -689,7 +690,6 @@ "localFilesInfo": "Підтримуються файли PDF, зображення, текстові та формат json", "countTabGroup": "{input} Відстежувати час груп вкладок {info}", "tabGroupInfo": "Якщо видалити групу вкладок, дані також видаляться.", - "tabGroupsPermGrant": "Ця функція потребує відповідних дозволів", "fileAccessDisabled": "Доступ до URL-адрес файлу наразі не дозволено. Спершу ввімкніть на сторінці керування", "weekStart": "Перший день тижня: {input}", "weekStartAsNormal": "Типово" @@ -778,6 +778,7 @@ "on": "Siempre encendido", "off": "Siempre apagado", "followBrowser": "Igual que el navegador", + "permGrantConfirm": "Esta función requiere permisos pertinentes", "appearance": { "title": "Apariencia", "displayWhitelist": "{input} Mostrar {whitelist} en {contextMenu}", @@ -811,7 +812,6 @@ "localFilesInfo": "Soporta archivos de tipos como PDF, imagen, TXT y JSON", "countTabGroup": "{input} Rastrear el tiempo de los grupos de pestañas {info}", "tabGroupInfo": "Al eliminar un grupo de pestañas, sus datos también se borrarán.", - "tabGroupsPermGrant": "Esta función requiere permisos pertinentes", "fileAccessDisabled": "Actualmente no se permite el acceso a las URL de archivos. Habilítelo primero en la página de administración", "weekStart": "El primer día de cada semana {input}", "weekStartAsNormal": "Como normalmente" @@ -900,6 +900,7 @@ "on": "Immer an", "off": "Immer aus", "followBrowser": "Browser verfolgen", + "permGrantConfirm": "Dieses Feature benötigt entsprechende Berechtigungen", "appearance": { "title": "Aussehen", "displayWhitelist": "{input} {whitelist} in {contextMenu} anzeigen", @@ -934,7 +935,6 @@ "localFilesInfo": "Unterstützt Dateitypen, wie PDF, Bilder, .txt und .json", "countTabGroup": "{input} Ob die Zeit der Tab-Gruppen {info} verfolgt werden soll", "tabGroupInfo": "Wenn Sie eine Tag-Gruppe löschen, werden auch die Daten gelöscht.", - "tabGroupsPermGrant": "Dieses Feature benötigt entsprechende Berechtigungen", "fileAccessDisabled": "Der Zugriff auf Datei-URLs ist derzeit nicht erlaubt. Bitte aktiviere ihn zuerst auf der Verwaltungsseite", "weekStart": "Erster Tag der Woche {input}", "weekStartAsNormal": "Wie normal", diff --git a/src/i18n/message/app/option.ts b/src/i18n/message/app/option.ts index 9a3a33f78..0329ea5af 100644 --- a/src/i18n/message/app/option.ts +++ b/src/i18n/message/app/option.ts @@ -12,6 +12,7 @@ export type OptionMessage = { on: string off: string followBrowser: string + permGrantConfirm: string appearance: { title: string // whitelist @@ -49,7 +50,6 @@ export type OptionMessage = { localFilesInfo: string countTabGroup: string tabGroupInfo: string - tabGroupsPermGrant: string fileAccessDisabled: string weekStart: string weekStartAsNormal: string diff --git a/src/manifest-firefox.ts b/src/manifest-firefox.ts index 824406be5..129cfc06b 100644 --- a/src/manifest-firefox.ts +++ b/src/manifest-firefox.ts @@ -15,7 +15,7 @@ import packageInfo from "./package" const { version, author: { name: authorName }, homepage } = packageInfo -const _default: chrome.runtime.ManifestFirefox = { +const _default: browser._manifest.WebExtensionManifest = { name: '__MSG_meta_marketName__', description: "__MSG_meta_description__", version, @@ -63,6 +63,7 @@ const _default: chrome.runtime.ManifestFirefox = { id: '{a8cf72f7-09b7-4cd4-9aaa-7a023bf09916}', data_collection_permissions: { required: ['none'], + optional: ['technicalAndInteraction'], }, }, }, diff --git a/src/pages/app/components/Option/categories/Notification/index.tsx b/src/pages/app/components/Option/categories/Notification/index.tsx index 7a9c40089..d414428c6 100644 --- a/src/pages/app/components/Option/categories/Notification/index.tsx +++ b/src/pages/app/components/Option/categories/Notification/index.tsx @@ -1,10 +1,13 @@ import { t } from '@app/locale' -import { ElInput, ElSelect, ElTimePicker } from 'element-plus' +import { Check, Close } from '@element-plus/icons-vue' +import Flex from '@pages/components/Flex' +import { ElButton, ElDialog, ElInput, ElSelect, ElText, ElTimePicker } from 'element-plus' import { computed, defineComponent, StyleValue } from 'vue' import { OptionItem, OptionLines } from '../../components' import type { CategoryInstance } from '../types' import Footer from './Footer' import { useNotification } from './useNotification' +import usePermission from './usePermission' const CYCLE_LABELS: Record = { none: t(msg => msg.option.off), @@ -23,9 +26,22 @@ const PADDING: StyleValue = { paddingInlineStart: '2px' } const Notification = defineComponent((_, ctx) => { const { option, weekday, datetime, reset } = useNotification() - const isNotNone = computed(() => option.notificationCycle !== 'none') + const { + confirmVisible, setConfirmVisible, granted, checkBeforeRequest, doRequest + } = usePermission() + + const onCycleChange = (val: timer.notification.Cycle) => { + const setVal = () => option.notificationCycle = val + val === 'none' + ? setVal() + // need to check the permission + : checkBeforeRequest(option.notificationMethod, setVal) + } + + const onMethodChange = (val: timer.notification.Method) => checkBeforeRequest(val, () => option.notificationMethod = val) + ctx.expose({ reset, } satisfies CategoryInstance) @@ -37,7 +53,7 @@ const Notification = defineComponent((_, ctx) => { modelValue={option.notificationCycle} size="small" style={{ width: "120px" } satisfies StyleValue} - onChange={val => option.notificationCycle = val as timer.notification.Cycle} + onChange={val => onCycleChange(val as timer.notification.Cycle)} options={Object.entries(CYCLE_LABELS).map(([value, label]) => ({ value: value as timer.notification.Cycle, label }))} /> {option.notificationCycle === 'weekly' && <> @@ -66,7 +82,7 @@ const Notification = defineComponent((_, ctx) => { modelValue={option.notificationMethod} size="small" style={{ width: "150px" } satisfies StyleValue} - onChange={val => option.notificationMethod = val as timer.notification.Method} + onChange={val => onMethodChange(val as timer.notification.Method)} options={Object.entries(METHOD_LABELS).map(([value, label]) => ({ value: value as timer.notification.Method, label }))} /> @@ -95,6 +111,25 @@ const Notification = defineComponent((_, ctx) => { )} {isNotNone.value &&
    } + + + + {t(msg => msg.option.permGrantConfirm)} + + + setConfirmVisible(false)} icon={Close}> + {t(msg => msg.button.cancel)} + + + {t(msg => msg.button.confirm)} + + + + ) }) diff --git a/src/pages/app/components/Option/categories/Notification/usePermission.ts b/src/pages/app/components/Option/categories/Notification/usePermission.ts new file mode 100644 index 000000000..935514e2a --- /dev/null +++ b/src/pages/app/components/Option/categories/Notification/usePermission.ts @@ -0,0 +1,57 @@ +import { hasPerm, requestPerm } from '@api/chrome/permission' +import { useState } from '@hooks/useState' +import { IS_FIREFOX } from '@util/constant/environment' +import { ElMessage } from 'element-plus' +import { onBeforeMount, reactive } from 'vue' + +const DATA_PERM: browser._manifest.OptionalDataCollectionPermission = 'technicalAndInteraction' +const BASE_PERM: chrome.runtime.ManifestPermission = 'notifications' + +const usePermission = () => { + const granted = reactive>({ + browser: false, + callback: false, + }) + + let toCheck: timer.notification.Method | undefined + let _onRequested: NoArgCallback | undefined + + const [confirmVisible, setConfirmVisible] = useState(false) + + onBeforeMount(async () => { + if (IS_FIREFOX) { + const perm = await browser?.permissions?.getAll() + granted.callback = !!perm?.data_collection?.includes?.(DATA_PERM) + } else { + granted.callback = true + } + granted.browser = await hasPerm(BASE_PERM) + }) + + const checkBeforeRequest = (method: timer.notification.Method, onRequested: NoArgCallback) => { + if (granted[method]) { + onRequested() + } else { + _onRequested = onRequested + toCheck = method + setConfirmVisible(true) + } + } + + const doRequest = async () => { + // Invalid method to check + if (!toCheck) return + + const result = toCheck === 'browser' + ? await requestPerm(BASE_PERM) + // Need't require data permission if not Firefox + : !IS_FIREFOX || await browser?.permissions?.request({ data_collection: [DATA_PERM] }) + result ? _onRequested?.() : ElMessage.info('Denied by user') + granted[toCheck] = result + setConfirmVisible(false) + } + + return { granted, confirmVisible, setConfirmVisible, checkBeforeRequest, doRequest } +} + +export default usePermission \ No newline at end of file diff --git a/src/pages/app/components/Option/categories/Tracking.tsx b/src/pages/app/components/Option/categories/Tracking.tsx index 7f9f6da2b..2f66b6479 100644 --- a/src/pages/app/components/Option/categories/Tracking.tsx +++ b/src/pages/app/components/Option/categories/Tracking.tsx @@ -98,7 +98,7 @@ const _default = defineComponent((_props, ctx) => { const handleTabGroupChange = async (val: boolean) => { if (val && !await hasPerm("tabGroups")) { try { - const granted = await ElMessageBox.confirm(t(msg => msg.option.tracking.tabGroupsPermGrant), { type: 'primary' }) + const granted = await ElMessageBox.confirm(t(msg => msg.option.permGrantConfirm), { type: 'primary' }) .then(() => requestPerm("tabGroups")) if (!granted) { ElMessage.error("Grant permission failed") diff --git a/src/service/notification/callback/notifier.ts b/src/service/notification/callback/notifier.ts index 7cf09d260..c7242d0ee 100644 --- a/src/service/notification/callback/notifier.ts +++ b/src/service/notification/callback/notifier.ts @@ -1,3 +1,4 @@ +import { IS_FIREFOX } from '@util/constant/environment' import hash from 'hash.js' import type { NotificationData, NotificationMeta, NotificationRequest, Notifier } from '../types' @@ -17,7 +18,23 @@ function genSign(meta: NotificationMeta, auth: string): string { } export default class CallbackNotifier implements Notifier { + private async assertPerm(): Promise { + // Not need to check data permission if not FF + if (!IS_FIREFOX) return undefined + + const perm = await browser?.permissions?.getAll?.() + const granted = perm?.data_collection?.includes?.('technicalAndInteraction') + if (!granted) { + // Unable to request permissions in FF's Service Worker + // So fast fail + return "Required permission is not granted" + } + } + async send(req: NotificationRequest, data: NotificationData): Promise { + const errMsg = await this.assertPerm() + if (errMsg) return errMsg + const { endpoint, authToken } = req if (!endpoint) return "Endpoint is required for HTTP callback" diff --git a/src/util/lang.ts b/src/util/lang.ts index 96656669a..2af7348c4 100644 --- a/src/util/lang.ts +++ b/src/util/lang.ts @@ -25,12 +25,15 @@ export const deepCopy = (obj: T): T => { return deep as T } -export const mergeObject = >(defaults: T, newVal: Partial): T => { +export const mergeObject = >(defaults: T, newVal: Partial | undefined): T => { + if (newVal === undefined) return defaults + Object.entries(newVal).forEach(([k, v]) => { - if (typeof v === 'object' && !!v && !Array.isArray(v)) { + if (typeof v === 'object' && !!v && !Array.isArray(v) && typeof defaults[k] === 'object' && !!defaults[k]) { (defaults as any)[k] = mergeObject(defaults[k], v as Record) + } else { + (defaults as any)[k] = v } - (defaults as any)[k] = v }) return defaults } \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 0084d9fc8..f18ae3200 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,8 @@ ], "types": [ "jest", - "chrome" + "chrome", + "firefox-webext-browser" ], "jsx": "preserve", "jsxFactory": "h", diff --git a/types/chrome.d.ts b/types/chrome.d.ts index b1f5c7dec..3d12301a6 100644 --- a/types/chrome.d.ts +++ b/types/chrome.d.ts @@ -15,42 +15,4 @@ declare type ChromeAlarm = chrome.alarms.Alarm // chrome.runtime declare type ChromeOnInstalledReason = `${chrome.runtime.OnInstalledReason}` declare type ChromeMessageSender = chrome.runtime.MessageSender -declare type ChromeMessageHandler = (req: timer.mq.Request, sender: ChromeMessageSender) => Promise> - -declare namespace chrome { - namespace runtime { - type ManifestFirefox = Pick< - ManifestV2, - | 'name' | 'description' | 'version' | 'manifest_version' - | 'icons' | 'background' | 'content_scripts' | 'permissions' | 'optional_permissions' | 'browser_action' - | 'default_locale' | 'homepage_url' | 'key' - > & { - // "author" must be string for Firefox - author?: string - browser_specific_settings?: { - gecko?: { - id?: string - data_collection_permissions?: { - required?: DataCollectionPermission[] - optional?: DataCollectionPermission[] - } - } - } - // see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/manifest.json/sidebar_action - sidebar_action?: Pick & { - default_panel?: string - open_at_install?: boolean - } - } - type DataCollectionPermission = 'none' - } -} - -/** - * Firefox-specific APIs - */ -declare namespace browser { - namespace sidebarAction { - export function open(): Promise - } -} \ No newline at end of file +declare type ChromeMessageHandler = (req: timer.mq.Request, sender: ChromeMessageSender) => Promise> \ No newline at end of file From a42e106881880ab9aa201b2c0ac20b51cf7d9252 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 24 Mar 2026 22:35:26 +0800 Subject: [PATCH 103/174] v4.1.3 --- CHANGELOG.md | 4 ++++ package.json | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ce8f26aea..dda798e8c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.1.3] - 2026-03-24 + +- Add data collection permission for Firefox + ## [4.1.2] - 2026-03-20 - Supported Italian diff --git a/package.json b/package.json index 7505483a7..678b81d08 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "4.1.2", + "version": "4.1.3", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { @@ -75,4 +75,4 @@ "engines": { "node": ">=22" } -} +} \ No newline at end of file From 5a5593a50ff802829467555947079f232fa936f9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 25 Mar 2026 19:11:57 +0800 Subject: [PATCH 104/174] i18n(download): download translations by bot (#706) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/option-resource.json | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 6f2607cc8..8982698a2 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -1076,7 +1076,6 @@ "localFilesInfo": "Prend en charge les fichiers de types tels que PDF, image, txt et json.", "countTabGroup": "{input} Suivre le temps des groupes d'onglets {info}", "tabGroupInfo": "Lorsque vous supprimez un groupe d'onglets, les données seront également supprimées.", - "tabGroupsPermGrant": "Cette fonctionnalité nécessite des autorisations pertinentes", "fileAccessDisabled": "L'accès aux URL des fichiers n'est actuellement pas autorisé. Veuillez d'abord l'activer dans la page de gestion.", "weekStart": "Le premier jour de chaque semaine {input}", "weekStartAsNormal": "Comme d'habitude" @@ -1199,7 +1198,6 @@ "localFilesInfo": "Поддержка типов файлов, таких как PDF, изображения, txt и json.", "countTabGroup": "{input} Отслеживать время для групп вкладок {info}", "tabGroupInfo": "При удалении группы вкладок данные также будут удалены.", - "tabGroupsPermGrant": "Эта функция требует соответствующих разрешений", "fileAccessDisabled": "Доступ к URL-адресам файлов в настоящее время запрещен. Пожалуйста, сначала включите его на странице управления", "weekStart": "Первый день каждой недели {input}", "weekStartAsNormal": "Как обычно (Воскресенье)", @@ -1334,7 +1332,6 @@ "localFilesInfo": "يدعم ملفات مثل PDF، الصور، txt و json.", "countTabGroup": "{input} هل يتم تتبع وقت مجموعات علامات التبويب {info}", "tabGroupInfo": "عند حذف مجموعة علامات تبويب، سيتم حذف البيانات أيضًا.", - "tabGroupsPermGrant": "هذه الميزة تتطلب أذونات ذات صلة", "fileAccessDisabled": "الوصول إلى عناوين الملفات غير مسموح به حاليًا. يرجى تفعيل الخاصية من صفحة الإدارة أولاً", "weekStart": "اليوم الأول لكل أسبوع {input}", "weekStartAsNormal": "بشكل عادي" @@ -1458,7 +1455,6 @@ "localFilesInfo": "PDF, resim, txt ve json gibi dosya türlerini destekler.", "countTabGroup": "{input} Sekme gruplarının zamanını takip etmek isteyip istemediğinizi seçin {info}", "tabGroupInfo": "Bir etiket grubunu sildiğinizde, veriler de silinir.", - "tabGroupsPermGrant": "Bu özellik ilgili izinleri gerektirir", "fileAccessDisabled": "Dosya URL'lerine erişim şu anda izin verilmiyor. Lütfen önce yönetim sayfasında bunu etkinleştirin", "weekStart": "Her haftanın ilk günü {input}", "weekStartAsNormal": "Normal", @@ -1584,7 +1580,6 @@ "localFilesInfo": "Obsługuje pliki takich typów jak PDF, obrazy, txt lub JSON.", "countTabGroup": "{input} Śledzenie czasu w grupach kart {info}", "tabGroupInfo": "Po usunięciu grupy tagów, dane również zostaną usunięte.", - "tabGroupsPermGrant": "Ta funkcja wymaga odpowiednich uprawnień", "fileAccessDisabled": "Dostęp do adresów URL plików jest obecnie niedozwolony. Proszę najpierw włączyć go na stronie zarządzania", "weekStart": "Pierwszy dzień tygodnia {input}", "weekStartAsNormal": "Normalnie", From d0aa5a28bdd23d441f00cd9fcadd62dafb66f82d Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sat, 28 Mar 2026 15:34:06 +0800 Subject: [PATCH 105/174] fix: limit modal not working on flex body (#710) --- src/content-script/limit/element.ts | 11 +++++++++++ src/content-script/limit/modal/index.ts | 6 +++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/src/content-script/limit/element.ts b/src/content-script/limit/element.ts index f7bb6ea11..58f573f53 100644 --- a/src/content-script/limit/element.ts +++ b/src/content-script/limit/element.ts @@ -5,3 +5,14 @@ export class RootElement extends HTMLElement { super() } } + +export function createRootElement(): RootElement { + const element = document.createElement(TAG_NAME) as RootElement + element.style.display = 'block' + element.style.position = 'fixed' + element.style.inset = '0' + element.style.width = '100vw' + element.style.height = '100vh' + element.style.zIndex = String(Number.MAX_SAFE_INTEGER) + return element +} diff --git a/src/content-script/limit/modal/index.ts b/src/content-script/limit/modal/index.ts index 6368b6632..2d1d7fa11 100644 --- a/src/content-script/limit/modal/index.ts +++ b/src/content-script/limit/modal/index.ts @@ -3,7 +3,7 @@ import optionService from '@service/option-service' import { init as initTheme, toggle } from '@util/dark-mode' import { createApp, Ref, type App } from 'vue' import { exitFullscreen, isSameReason, type LimitReason, type MaskModal } from '../common' -import { TAG_NAME, type RootElement } from '../element' +import { createRootElement, TAG_NAME, type RootElement } from '../element' import Main from './Main' import { provideDelayHandler, provideGlobalParam, provideReason } from './context' @@ -163,12 +163,12 @@ class ModalInstance implements MaskModal { private async prepareRoot(): Promise { const inner = (): ShadowRoot | null => { - const exist = this.rootElement || document.querySelector(TAG_NAME) as RootElement + const exist = this.rootElement ?? document.querySelector(TAG_NAME) as RootElement if (exist) { this.rootElement = exist return exist.shadowRoot } - this.rootElement = document.createElement(TAG_NAME) as RootElement + this.rootElement = createRootElement() document.body.appendChild(this.rootElement) return this.rootElement.attachShadow({ mode: 'open' }) } From 45ecd3153751ef9346237f07f522dcce80cfed4f Mon Sep 17 00:00:00 2001 From: sheepie Date: Sun, 29 Mar 2026 00:49:00 +0800 Subject: [PATCH 106/174] refactor: sop (#709, #708) --- examples/README.md | 26 ++ examples/gist/README.md | 45 +++ examples/gist/mock-server.ts | 298 ++++++++++++++++++ examples/host/index.html | 8 + examples/package.json | 18 ++ package.json | 2 +- script/setup-e2e.sh | 99 +++++- .../Migration/ImportOtherButton/Sop.tsx | 73 ----- .../Migration/ImportOtherButton/Step1.tsx | 28 +- .../Migration/ImportOtherButton/Step2.tsx | 18 +- .../Migration/ImportOtherButton/index.tsx | 61 +++- .../Migration/ImportOtherButton/processor.ts | 10 +- .../Migration/ImportOtherButton/types.ts | 11 + .../Limit/components/Modify/Sop/context.ts | 101 ------ .../Limit/components/Modify/Sop/index.tsx | 78 ----- .../components/Modify/{Sop => }/Step1.tsx | 5 +- .../Modify/{Sop => }/Step2/SiteInput.tsx | 0 .../Modify/{Sop => }/Step2/index.tsx | 8 +- .../Modify/{Sop => }/Step3/PeriodInput.tsx | 0 .../Modify/{Sop => }/Step3/TimeInput.tsx | 0 .../Modify/{Sop => }/Step3/index.tsx | 13 +- .../Limit/components/Modify/index.tsx | 138 +++++--- .../Limit/components/Modify/types.ts | 3 + .../Option/categories/Backup/Clear/Sop.tsx | 63 ---- .../Option/categories/Backup/Clear/Step1.tsx | 55 ---- .../Option/categories/Backup/Clear/Step2.tsx | 12 +- .../Option/categories/Backup/Clear/index.tsx | 70 ++-- .../Option/categories/Backup/Clear/types.ts | 9 + .../Option/categories/Backup/Download/Sop.tsx | 106 ------- .../categories/Backup/Download/Step2.tsx | 26 +- .../categories/Backup/Download/index.tsx | 73 ++++- .../categories/Backup/Download/types.ts | 5 + .../Option/categories/Backup/Footer.tsx | 16 +- .../Option/categories/Backup/index.tsx | 1 + src/pages/app/components/common/DialogSop.tsx | 83 ----- .../components/common/DialogSop/context.ts | 98 ++++++ .../app/components/common/DialogSop/index.tsx | 80 +++++ test-e2e/backup/common.ts | 204 ++++++++++++ test-e2e/backup/gist.test.ts | 65 ++++ test-e2e/common/base.ts | 56 +++- test-e2e/common/message.ts | 16 + test-e2e/example/index.html | 6 - test-e2e/install.test.ts | 2 +- 43 files changed, 1331 insertions(+), 758 deletions(-) create mode 100644 examples/README.md create mode 100644 examples/gist/README.md create mode 100644 examples/gist/mock-server.ts create mode 100644 examples/host/index.html create mode 100644 examples/package.json delete mode 100644 src/pages/app/components/DataManage/Migration/ImportOtherButton/Sop.tsx create mode 100644 src/pages/app/components/DataManage/Migration/ImportOtherButton/types.ts delete mode 100644 src/pages/app/components/Limit/components/Modify/Sop/context.ts delete mode 100644 src/pages/app/components/Limit/components/Modify/Sop/index.tsx rename src/pages/app/components/Limit/components/Modify/{Sop => }/Step1.tsx (91%) rename src/pages/app/components/Limit/components/Modify/{Sop => }/Step2/SiteInput.tsx (100%) rename src/pages/app/components/Limit/components/Modify/{Sop => }/Step2/index.tsx (92%) rename src/pages/app/components/Limit/components/Modify/{Sop => }/Step3/PeriodInput.tsx (100%) rename src/pages/app/components/Limit/components/Modify/{Sop => }/Step3/TimeInput.tsx (100%) rename src/pages/app/components/Limit/components/Modify/{Sop => }/Step3/index.tsx (79%) create mode 100644 src/pages/app/components/Limit/components/Modify/types.ts delete mode 100644 src/pages/app/components/Option/categories/Backup/Clear/Sop.tsx delete mode 100644 src/pages/app/components/Option/categories/Backup/Clear/Step1.tsx create mode 100644 src/pages/app/components/Option/categories/Backup/Clear/types.ts delete mode 100644 src/pages/app/components/Option/categories/Backup/Download/Sop.tsx create mode 100644 src/pages/app/components/Option/categories/Backup/Download/types.ts delete mode 100644 src/pages/app/components/common/DialogSop.tsx create mode 100644 src/pages/app/components/common/DialogSop/context.ts create mode 100644 src/pages/app/components/common/DialogSop/index.tsx create mode 100644 test-e2e/backup/common.ts create mode 100644 test-e2e/backup/gist.test.ts create mode 100644 test-e2e/common/message.ts delete mode 100644 test-e2e/example/index.html diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 000000000..7066cce1f --- /dev/null +++ b/examples/README.md @@ -0,0 +1,26 @@ +# Examples Package + +The `examples` directory is an independent npm package for local demo and e2e helper servers. + +## Setup + +```bash +cd examples +npm install +``` + +## Available scripts + +- `npm run start:gist` - start Gist mock server +- `npm run start:notification` - start notification demo server + +## Environment variables + +### Gist mock server + +- `PORT` (default: `12347`) +- `GIST_TOKEN` (optional auth token; supports `token` or `Bearer` authorization header) + +### Notification demo server + +- `AUTH` (optional signature secret) diff --git a/examples/gist/README.md b/examples/gist/README.md new file mode 100644 index 000000000..412014f3e --- /dev/null +++ b/examples/gist/README.md @@ -0,0 +1,45 @@ +# Gist Mock Server + +This mock server provides an in-memory Gist API for end-to-end testing. +It follows the GitHub Gist REST contract for the endpoints needed by this project. + +## Supported APIs + +- `GET /gists?per_page=100&page=1` +- `GET /gists/:id` +- `POST /gists` (create) +- `PATCH /gists/:id` (official update API) +- `POST /gists/:id` (compat mode for current project call path) +- `DELETE /gists/:id` +- `GET /raw/:id/:filename` + +Response shape is primarily aligned with GitHub Gist REST API, while keeping compatibility with current project call paths (for example `POST /gists/:id`). + +## Run + +```bash +cd examples +npm install +npm run start:gist +``` + +Optional environment variables: + +- `PORT` (default: `12347`) +- `GIST_TOKEN` (if set, requests must contain `Authorization: token ` or `Authorization: Bearer `) + +## Test with curl + +```bash +curl -X POST "http://localhost:12347/gists" \ + -H "Authorization: token demo" \ + -H "Content-Type: application/json" \ + -d '{ + "public": false, + "description": "Used for timer to save meta info. Don'\''t change this description :)", + "files": { + "README.md": {"filename": "README.md", "content": "hello"}, + "clients.json": {"filename": "clients.json", "content": "[]"} + } + }' +``` diff --git a/examples/gist/mock-server.ts b/examples/gist/mock-server.ts new file mode 100644 index 000000000..61f11e027 --- /dev/null +++ b/examples/gist/mock-server.ts @@ -0,0 +1,298 @@ +import { randomUUID } from "crypto" +import { createServer, type IncomingMessage, type ServerResponse } from "http" + +type GistFormFile = { + filename: string + content: string +} + +type GistFile = { + filename: string + type: string + language: string + raw_url: string + size: number + truncated: boolean + content?: string +} + +type GistForm = Partial<{ + public: boolean + description: string + files: { [filename: string]: GistFormFile | null } +}> + +type Gist = { + url: string + forks_url: string + commits_url: string + id: string + node_id: string + git_pull_url: string + git_push_url: string + html_url: string + public: boolean + description: string + files: { [filename: string]: GistFile | null } + comments: number + comments_url: string + owner: null + user: null + truncated: boolean + history: unknown[] + forks: unknown[] + created_at: string + updated_at: string +} + +const PORT = Number(process.env.PORT ?? 12347) +// The same as e2e tests +const REQUIRED_TOKEN = 'github_gist_mock_token' + +const gists = new Map() +const gistOrder: string[] = [] + +function nowIso(): string { + return new Date().toISOString() +} + +function inferFileType(filename: string): string { + if (filename.endsWith(".json")) return "application/json" + if (filename.endsWith(".md")) return "text/markdown" + return "text/plain" +} + +function inferLanguage(filename: string): string { + if (filename.endsWith(".json")) return "JSON" + if (filename.endsWith(".md")) return "Markdown" + return "Text" +} + +function sendJson(res: ServerResponse, statusCode: number, payload: unknown) { + res.writeHead(statusCode, { "Content-Type": "application/json" }) + res.end(JSON.stringify(payload)) +} + +function sendNoContent(res: ServerResponse) { + res.writeHead(204).end() +} + +function getOrigin(req: IncomingMessage): string { + const host = req.headers.host || `localhost:${PORT}` + return `http://${host}` +} + +async function readBody(req: IncomingMessage): Promise { + const body = await new Promise((resolve, reject) => { + let data = "" + req.on("data", (chunk: Buffer) => { data += chunk.toString() }) + req.on("end", () => resolve(data)) + req.on("error", reject) + }) + return JSON.parse(body) as T +} + +function isAuthed(req: IncomingMessage): boolean { + if (!REQUIRED_TOKEN) return true + const auth = req.headers.authorization || "" + return auth === `token ${REQUIRED_TOKEN}` || auth === `Bearer ${REQUIRED_TOKEN}` +} + +function buildGistFile(origin: string, gistId: string, filename: string, content: string): GistFile { + const encodedFilename = encodeURIComponent(filename) + return { + filename, + type: inferFileType(filename), + language: inferLanguage(filename), + raw_url: `${origin}/raw/${gistId}/${encodedFilename}`, + size: Buffer.byteLength(content, "utf-8"), + truncated: false, + content, + } +} + +function buildCreatedGist(origin: string, form: GistForm): Gist { + const id = randomUUID().replaceAll("-", "") + const ts = nowIso() + const files: Record = {} + for (const [filename, file] of Object.entries(form.files || {})) { + if (!file) continue + files[filename] = buildGistFile(origin, id, filename, file.content) + } + const gistUrl = `${origin}/gists/${id}` + return { + url: gistUrl, + forks_url: `${gistUrl}/forks`, + commits_url: `${gistUrl}/commits`, + id, + node_id: `MOCK_${id}`, + git_pull_url: `${origin}/${id}.git`, + git_push_url: `${origin}/${id}.git`, + html_url: `${origin}/mock/gist/${id}`, + public: !!form.public, + description: form.description ?? "", + files, + comments: 0, + comments_url: `${gistUrl}/comments`, + owner: null, + user: null, + truncated: false, + history: [], + forks: [], + created_at: ts, + updated_at: ts, + } +} + +function updateExistingGist(origin: string, gist: Gist, form: GistForm): Gist { + const next: Gist = { + ...gist, + description: form.description ?? gist.description, + public: typeof form.public === "boolean" ? form.public : gist.public, + files: { ...gist.files }, + updated_at: nowIso(), + } + for (const [filename, file] of Object.entries(form.files || {})) { + if (file === null) { + delete next.files[filename] + continue + } + const oldFilename = filename + const newFilename = file.filename || oldFilename + if (newFilename !== oldFilename) { + delete next.files[oldFilename] + } + next.files[newFilename] = buildGistFile(origin, gist.id, newFilename, file.content) + } + return next +} + +function notFound(res: ServerResponse) { + sendJson(res, 404, { message: "Not Found" }) +} + +function unauthorized(res: ServerResponse) { + sendJson(res, 401, { message: "Bad credentials" }) +} + +function validationFailed(res: ServerResponse, message: string) { + sendJson(res, 422, { message }) +} + +function listGists(req: IncomingMessage, res: ServerResponse) { + const url = new URL(req.url || "/", getOrigin(req)) + const perPageRaw = Number(url.searchParams.get("per_page") || "30") + const pageRaw = Number(url.searchParams.get("page") || "1") + const perPage = Math.min(100, Math.max(1, Number.isNaN(perPageRaw) ? 30 : perPageRaw)) + const page = Math.max(1, Number.isNaN(pageRaw) ? 1 : pageRaw) + const since = url.searchParams.get("since") + const all = gistOrder.map(id => gists.get(id)).filter(Boolean) as Gist[] + const filtered = since + ? all.filter(gist => gist.updated_at > since) + : all + const start = (page - 1) * perPage + const end = start + Math.max(0, perPage) + sendJson(res, 200, filtered.slice(start, end)) +} + +async function handleCreateGist(req: IncomingMessage, res: ServerResponse) { + const form = await readBody(req) + if (!form.files || Object.keys(form.files).length === 0) { + return validationFailed(res, "Validation failed: files is required") + } + const origin = getOrigin(req) + const gist = buildCreatedGist(origin, form) + gists.set(gist.id, gist) + gistOrder.unshift(gist.id) + sendJson(res, 201, gist) +} + +function handleGetGist(req: IncomingMessage, res: ServerResponse, gistId: string) { + const gist = gists.get(gistId) + if (!gist) return notFound(res) + sendJson(res, 200, gist) +} + +async function handleUpdateGist(req: IncomingMessage, res: ServerResponse, gistId: string) { + const gist = gists.get(gistId) + if (!gist) return notFound(res) + const form = await readBody(req) + if (!form.description && !form.files) { + return validationFailed(res, "Validation failed: description or files is required") + } + const updated = updateExistingGist(getOrigin(req), gist, form) + gists.set(gistId, updated) + sendJson(res, 200, updated) +} + +function handleDeleteGist(res: ServerResponse, gistId: string) { + if (!gists.has(gistId)) return notFound(res) + gists.delete(gistId) + const idx = gistOrder.findIndex(id => id === gistId) + if (idx >= 0) gistOrder.splice(idx, 1) + sendNoContent(res) +} + +function handleRawFile(res: ServerResponse, gistId: string, filename: string) { + const gist = gists.get(gistId) + const file = gist?.files[filename] + if (!file) return notFound(res) + res.writeHead(200, { "Content-Type": file.type || "text/plain" }) + res.end(file.content || "") +} + +function main() { + const server = createServer(async (req, res) => { + try { + const needAuth = req.method === "GET" || req.method === "POST" || req.method === "PATCH" || req.method === "DELETE" + if (needAuth && !isAuthed(req)) { + return unauthorized(res) + } + + if (req.method === "HEAD") { + res.writeHead(200).end() + return + } + + const origin = getOrigin(req) + const parsed = new URL(req.url || "/", origin) + const pathname = parsed.pathname + + if (req.method === "GET" && pathname === "/gists") { + return listGists(req, res) + } + + if (req.method === "POST" && pathname === "/gists") { + return await handleCreateGist(req, res) + } + + const gistMatch = pathname.match(/^\/gists\/([^/]+)$/) + if (gistMatch?.[1]) { + const gistId = gistMatch[1] + if (req.method === "GET") return handleGetGist(req, res, gistId) + if (req.method === "POST") return await handleUpdateGist(req, res, gistId) + if (req.method === "PATCH") return await handleUpdateGist(req, res, gistId) + if (req.method === "DELETE") return handleDeleteGist(res, gistId) + } + + const rawMatch = pathname.match(/^\/raw\/([^/]+)\/(.+)$/) + if (req.method === "GET" && rawMatch?.[1] && rawMatch?.[2]) { + const gistId = rawMatch[1] + const filename = decodeURIComponent(rawMatch[2]) + return handleRawFile(res, gistId, filename) + } + + return notFound(res) + } catch (e) { + console.error("Mock gist server error:", e) + sendJson(res, 400, { message: "Bad Request" }) + } + }) + + server.listen(PORT, () => { + console.log(`Gist mock server listening on http://localhost:${PORT}`) + console.log("Set GIST_TOKEN to enable token validation") + }) +} + +main() diff --git a/examples/host/index.html b/examples/host/index.html new file mode 100644 index 000000000..5812731a0 --- /dev/null +++ b/examples/host/index.html @@ -0,0 +1,8 @@ + + + + +
    Time Tracker test page
    + + + \ No newline at end of file diff --git a/examples/package.json b/examples/package.json new file mode 100644 index 000000000..78537dceb --- /dev/null +++ b/examples/package.json @@ -0,0 +1,18 @@ +{ + "name": "@timer/examples", + "version": "1.0.0", + "private": true, + "description": "Standalone examples package for local/e2e servers", + "scripts": { + "start:gist": "ts-node gist/mock-server.ts", + "start:notification": "ts-node notification/demo-server.ts" + }, + "dependencies": { + "hash.js": "^1.1.7" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "ts-node": "^10.9.2", + "typescript": "6.0.2" + } +} \ No newline at end of file diff --git a/package.json b/package.json index 678b81d08..ef8d134b3 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "hash.js": "^1.1.7", "punycode": "^2.3.1", "typescript-guard": "^0.2.2", - "vue": "^3.5.30", + "vue": "^3.5.31", "vue-router": "^5.0.4" }, "engines": { diff --git a/script/setup-e2e.sh b/script/setup-e2e.sh index af80d5a6f..63b76f057 100755 --- a/script/setup-e2e.sh +++ b/script/setup-e2e.sh @@ -139,6 +139,20 @@ install_e2e_dependencies() { log_success "E2E dependencies installed successfully" } +install_mock_server_dependencies() { + if [ -f "$PROJECT_ROOT/examples/package.json" ]; then + log_step "Installing dependencies for mock server..." + if npm install --prefix "$PROJECT_ROOT/examples" &> /dev/null; then + log_success "Mock server dependencies installed successfully" + else + log_error "Failed to install mock server dependencies" + exit 1 + fi + else + log_warning "examples/package.json not found, skipping mock server dependencies installation" + fi +} + # Upgrade e2e dependencies upgrade_e2e_dependencies() { log_step "Upgrading e2e test dependencies..." @@ -195,6 +209,33 @@ build_production_output() { build_npm_script "build" "dist_prod" false } +is_port_open() { + local port=$1 + lsof -nP -iTCP:"$port" -sTCP:LISTEN > /dev/null 2>&1 +} + +wait_port_open() { + local port=$1 + local timeout_seconds=${2:-5} + local start_seconds=$SECONDS + while true; do + if is_port_open "$port"; then + return 0 + fi + if [ $((SECONDS - start_seconds)) -ge "$timeout_seconds" ]; then + return 1 + fi + done +} + +remove_pm2_app_if_exists() { + local app_name=$1 + if pm2 describe "$app_name" > /dev/null 2>&1; then + log_info "Removing existing pm2 app: $app_name" + pm2 delete "$app_name" > /dev/null 2>&1 || true + fi +} + # Start test servers start_test_servers() { log_step "Starting test servers..." @@ -206,24 +247,56 @@ start_test_servers() { cd "$PROJECT_ROOT" || exit 1 - # Check if servers are already running - if pm2 list 2>/dev/null | grep -q "http-server.*12345" || pm2 list 2>/dev/null | grep -q "http-server.*12346"; then - log_warning "Test servers might already be running" - log_info "Stopping existing servers..." - pm2 stop all 2>/dev/null || true - pm2 delete all 2>/dev/null || true + log_info "Checking and starting e2e-server-1..." + remove_pm2_app_if_exists "e2e-server-1" + if is_port_open 12345; then + log_warning "Port 12345 is already in use before starting e2e-server-1" + fi + pm2 start http-server --name "e2e-server-1" -- ./examples/host -p 12345 + if ! wait_port_open 12345 5; then + log_error "e2e-server-1 failed to start on port 12345" + log_info "Check logs with: pm2 logs e2e-server-1 --lines 50" + exit 1 + fi + + if [ ! -f "$PROJECT_ROOT/examples/package.json" ]; then + log_error "examples package is missing. Please check examples/package.json" + exit 1 + fi + if [ ! -d "$PROJECT_ROOT/examples/node_modules" ]; then + log_error "examples dependencies are not installed. Please run: cd examples && npm install" + exit 1 fi - log_info "Starting test server on port 12345..." - pm2 start "http-server ./test-e2e/example -p 12345" --name "e2e-server-1" + log_info "Checking and starting e2e-server-2..." + remove_pm2_app_if_exists "e2e-server-2" + if is_port_open 12346; then + log_warning "Port 12346 is already in use before starting e2e-server-2" + fi + pm2 start http-server --name "e2e-server-2" -- ./examples/host -p 12346 + if ! wait_port_open 12346 5; then + log_error "e2e-server-2 failed to start on port 12346" + log_info "Check logs with: pm2 logs e2e-server-2 --lines 50" + exit 1 + fi - log_info "Starting test server on port 12346..." - pm2 start "http-server ./test-e2e/example -p 12346" --name "e2e-server-2" + log_info "Checking and starting gist-mock-server..." + remove_pm2_app_if_exists "gist-mock-server" + if is_port_open 12347; then + log_warning "Port 12347 is already in use before starting gist-mock-server" + fi + PORT=12347 pm2 start npm --name "gist-mock-server" --cwd "$PROJECT_ROOT/examples" -- run start:gist + if ! wait_port_open 12347 8; then + log_error "gist-mock-server failed to start on port 12347" + log_info "Check logs with: pm2 logs gist-mock-server --lines 50" + exit 1 + fi log_success "Test servers started" log_info "Server 1: http://127.0.0.1:12345" log_info "Server 2: http://127.0.0.1:12346" - log_info "To stop servers, run: pm2 stop all && pm2 delete all" + log_info "Gist mock: http://127.0.0.1:12347" + log_info "To stop servers, run: pm2 delete e2e-server-1 e2e-server-2 gist-mock-server" log_info "To view server logs, run: pm2 logs" } @@ -241,7 +314,7 @@ show_usage() { echo " --upgrade, -u Upgrade e2e dependencies (http-server, pm2)" echo " --build, -b Build e2e output (runs 'npm run dev:e2e')" echo " --build-prod Also build production output (runs 'npm run build')" - echo " --start-servers, -s Start test servers (http-server on ports 12345 and 12346)" + echo " --start-servers, -s Start test servers (12345/12346) and gist mock server (12347)" echo " --all, -a Run all steps (init + build + build-prod)" echo " --help, -h Show this help message" echo "" @@ -289,6 +362,7 @@ main() { do_init=true do_build=true do_build_prod=true + do_start_servers=true shift ;; --help|-h) @@ -332,6 +406,7 @@ main() { # Run requested operations if [ "$do_init" = true ]; then install_e2e_dependencies + install_mock_server_dependencies fi if [ "$do_upgrade" = true ]; then diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Sop.tsx b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Sop.tsx deleted file mode 100644 index ceb8c4ca0..000000000 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Sop.tsx +++ /dev/null @@ -1,73 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import DialogSop, { type SopStepInstance } from "@app/components/common/DialogSop" -import { t } from "@app/locale" -import { useManualRequest } from "@hooks" -import Flex from "@pages/components/Flex" -import { processImportedData } from "@service/components/import-processor" -import { ElMessage, ElStep, ElSteps } from "element-plus" -import { defineComponent, ref } from "vue" -import Step1 from "./Step1" -import Step2 from "./Step2" - -type Props = { - onCancel: NoArgCallback - onImport: NoArgCallback -} - -const _default = defineComponent((props) => { - const step = ref<0 | 1>(0) - const step1 = ref>() - const step2 = ref>() - - const { data, refresh: handleNext, loading: parsing } = useManualRequest(() => step1.value!.parseData(), { - defaultValue: { rows: [] }, - onSuccess: () => step.value = 1, - onError: e => ElMessage.error((e as Error)?.message ?? 'Unknown Error') - }) - - const { loading: importing, refresh: doImport } = useManualRequest(async () => { - const resolution = await step2.value?.parseData?.() - if (!resolution) throw new Error(t(msg => msg.dataManage.importOther.conflictNotSelected)) - await processImportedData(data.value, resolution) - }, { - onSuccess: () => { - ElMessage.success(t(msg => msg.operation.successMsg)) - props.onImport?.() - }, - onError: e => ElMessage.warning((e as Error)?.message ?? 'Unknown error'), - }) - - return () => ( - step.value = 0} - onNext={handleNext} - onFinish={doImport} - nextLoading={parsing.value} - finishLoading={importing.value} - v-slots={{ - steps: () => ( - - msg.dataManage.importOther.step1)} /> - msg.dataManage.importOther.step2)} /> - - ), - content: () => ( - - {step.value === 0 ? : } - - ), - }} - /> - ) -}, { props: ['onCancel', 'onImport'] }) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step1.tsx b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step1.tsx index bdbe556a2..014856377 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step1.tsx +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step1.tsx @@ -5,14 +5,13 @@ * https://opensource.org/licenses/MIT */ -import { type SopStepInstance } from "@app/components/common/DialogSop" +import { useDialogSop } from '@app/components/common/DialogSop/context' import { t } from "@app/locale" import { Document } from "@element-plus/icons-vue" -import { useState } from "@hooks" import Flex from "@pages/components/Flex" import { ElButton, ElForm, ElFormItem, ElSelect } from "element-plus" import { defineComponent, ref } from "vue" -import { type OtherExtension, parseFile } from "./processor" +import type { ImportForm, OtherExtension } from './types' const OTHER_NAMES: { [ext in OtherExtension]: string } = { webtime_tracker: "Webtime Tracker", @@ -29,27 +28,14 @@ const OTHER_FILE_FORMAT: { [ext in OtherExtension]: string } = { const ALL_TYPES: OtherExtension[] = Object.keys(OTHER_NAMES) as OtherExtension[] const _default = defineComponent<{}>((_, ctx) => { - const [type, setType] = useState('webtime_tracker') - const [selectedFile, setSelectedFile] = useState() + const { form } = useDialogSop() const fileInput = ref() - const parseData = async () => { - const file = selectedFile.value - if (!file) throw new Error(t(msg => msg.dataManage.importOther.fileNotSelected)) - - const data = await parseFile(type.value, file) - if (!data?.rows?.length) throw new Error("No rows parsed") - - return data - } - - ctx.expose({ parseData } satisfies SopStepInstance) - return () => ( msg.dataManage.importOther.dataSource)} required> form.ext = v} options={ALL_TYPES.map(value => ({ value, label: OTHER_NAMES[value] }))} /> @@ -60,12 +46,12 @@ const _default = defineComponent<{}>((_, ctx) => { setSelectedFile(fileInput.value?.files?.[0])} + onChange={() => form.file = fileInput.value?.files?.[0]} /> - {selectedFile.value?.name && {selectedFile.value?.name}} + {{form.file?.name ?? ''}} diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step2.tsx b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step2.tsx index 363c6133b..f91390acb 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step2.tsx +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step2.tsx @@ -5,28 +5,24 @@ * https://opensource.org/licenses/MIT */ -import { type SopStepInstance } from "@app/components/common/DialogSop" +import { useDialogSop } from '@app/components/common/DialogSop/context' import CompareTable from "@app/components/common/imported/CompareTable" import ResolutionRadio from "@app/components/common/imported/ResolutionRadio" -import { useState } from "@hooks" import Flex from "@pages/components/Flex" import { defineComponent } from "vue" +import type { ImportForm } from './types' -const _default = defineComponent<{ data: timer.imported.Data }>((props, ctx) => { - const [resolution, setResolution] = useState() - - ctx.expose({ - parseData: () => resolution.value - } satisfies SopStepInstance) +const _default = defineComponent<{}>((props, ctx) => { + const { form } = useDialogSop() return () => ( - msg.dataManage.importOther.imported} /> + msg.dataManage.importOther.imported} /> - + form.resolution = v} /> ) -}, { props: ['data'] }) +}) export default _default diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/index.tsx b/src/pages/app/components/DataManage/Migration/ImportOtherButton/index.tsx index d34532b59..9fa3c5241 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/index.tsx +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/index.tsx @@ -5,42 +5,69 @@ * https://opensource.org/licenses/MIT */ +import DialogSop from '@app/components/common/DialogSop' +import { initDialogSopContext } from '@app/components/common/DialogSop/context' import { t } from "@app/locale" import { Upload } from "@element-plus/icons-vue" -import { useSwitch } from "@hooks" -import { ElButton, ElDialog } from "element-plus" +import Flex from '@pages/components/Flex' +import { processImportedData } from '@service/components/import-processor' +import { ElButton } from "element-plus" import { defineComponent } from "vue" import { useDataMemory } from "../../context" -import Sop from "./Sop" +import { parseFile } from './processor' +import Step1 from './Step1' +import Step2 from './Step2' +import type { ImportForm } from './types' + +const STEP_TITLES = [ + t(msg => msg.dataManage.importOther.step1), + t(msg => msg.dataManage.importOther.step2), +] const _default = defineComponent(() => { const { refreshMemory } = useDataMemory() - const [visible, open, close] = useSwitch() - const handleImported = () => { - close() - refreshMemory?.() - } + + const { step, open } = initDialogSopContext({ + stepCount: 2, + init: () => ({ ext: 'webtime_tracker', data: { rows: [], focus: true, time: true } }), + onNext: async ({ form }) => { + const file = form.file + if (!file) throw new Error(t(msg => msg.dataManage.importOther.fileNotSelected)) + + const data = await parseFile(form.ext, file) + if (!data.rows.length) throw new Error("No rows parsed") + form.data = data + }, + onFinish: async ({ form }) => { + const data = form.data + if (!data) throw new Error(t(msg => msg.dataManage.importOther.fileNotSelected)) + const resolution = form.resolution + if (!resolution) throw new Error(t(msg => msg.dataManage.importOther.conflictNotSelected)) + await processImportedData(data, resolution) + refreshMemory?.() + }, + }) return () => <> open()} style={{ margin: 0, flex: 1, width: '100%', textWrap: 'wrap', lineHeight: '1.4em' }} > {t(msg => msg.item.operation.importOtherData)} - msg.item.operation.importOtherData)} - width="80%" - closeOnClickModal={false} - onClose={close} + stepTitles={STEP_TITLES} + width='80%' top="10vh" > - - + + + + + }) diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts b/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts index de50e3b63..8dfb19cbe 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts @@ -10,11 +10,7 @@ import { AUTHOR_EMAIL } from "@src/package" import { IS_WINDOWS } from "@util/constant/environment" import { extractHostname, isBrowserUrl } from "@util/pattern" import { formatTimeYMD, MILL_PER_SECOND } from "@util/time" - -export type OtherExtension = - | "webtime_tracker" - | "web_activity_time_tracker" - | "history_trends_unlimited" +import type { OtherExtension } from './types' const throwError = () => { throw new Error("Failed to parse, please check your file or contact the author via " + AUTHOR_EMAIL) } @@ -50,7 +46,9 @@ async function parseWebActivityTimeTracker(file: File): Promise<[timer.imported. const lines = text.split('\n').map(line => line.trim()).filter(line => !!line).splice(1) const rows = lines.map(line => { const [host, date, seconds] = line.split(',').map(cell => cell.trim()) - !host || !date || (!seconds && seconds !== '0') && throwError() + if (!host || !date || (!seconds && seconds !== '0')) { + throwError() + } const [year, month, day] = date.split('/') !year || !month || !day && throwError() const realDate = `${year}${month.length == 2 ? month : '0' + month}${day.length == 2 ? day : '0' + day}` diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/types.ts b/src/pages/app/components/DataManage/Migration/ImportOtherButton/types.ts new file mode 100644 index 000000000..2c31cae13 --- /dev/null +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/types.ts @@ -0,0 +1,11 @@ +export type ImportForm = { + ext: OtherExtension + file?: File + data: timer.imported.Data + resolution?: timer.imported.ConflictResolution +} + +export type OtherExtension = + | "webtime_tracker" + | "web_activity_time_tracker" + | "history_trends_unlimited" diff --git a/src/pages/app/components/Limit/components/Modify/Sop/context.ts b/src/pages/app/components/Limit/components/Modify/Sop/context.ts deleted file mode 100644 index d2ea4273c..000000000 --- a/src/pages/app/components/Limit/components/Modify/Sop/context.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { t } from "@app/locale" -import { useProvide, useProvider } from "@hooks" -import { range } from "@util/array" -import { ElMessage } from "element-plus" -import { type Reactive, reactive, type Ref, ref, toRaw } from "vue" - -export type Step = 0 | 1 | 2 - -type SopData = Required> - -type Context = { - data: Reactive - urlMiss: Ref -} - -const createInitial = (): SopData => ({ - name: `RULE-${new String(new Date().getTime() % 10000).padStart(4, '0')}`, - time: 3600, - weekly: 0, - cond: [], - visitTime: 0, - periods: [], - enabled: true, - weekdays: range(7), - count: 0, - weeklyCount: 0, - allowDelay: false, - locked: false, -}) - -type Options = { - onSave?: (data: SopData) => void -} - -const NAMESPACE = 'limit_sop_model' - -export const initSop = ({ onSave }: Options) => { - const step = ref(0) - const data = reactive(createInitial()) - const urlMiss = ref(false) - - const validator: Record Promise> = { - 0: async () => { - const nameVal = data.name?.trim?.() - const weekdaysVal = data.weekdays - if (!nameVal) { - ElMessage.error("Name is empty") - return false - } if (!weekdaysVal?.length) { - ElMessage.error("Effective days are empty") - return false - } - return true - }, - 1: async () => { - if (!data.cond?.length) { - ElMessage.error(t(msg => msg.limit.message.noUrl)) - urlMiss.value = true - return false - } - urlMiss.value = false - return true - }, - 2: async () => { - const { time, count, weekly, weeklyCount, visitTime, periods } = data - if (true - && !time && !count - && !weekly && !weeklyCount - && !visitTime && !periods?.length - ) { - ElMessage.error(t(msg => msg.limit.message.noRule)) - return false - } - return true - }, - } - - const reset = (rule?: timer.limit.Rule) => { - const rawRule = rule ? toRaw(rule) : createInitial() - Object.entries(rawRule).forEach(([k, v]) => (data as any)[k] = v) - step.value = 0 - } - - const handleNext = async () => { - const stepVal = step.value - const isValid = await validator[stepVal]?.() - if (isValid) { - stepVal === 2 ? onSave?.(toRaw(data)) : step.value++ - } - } - - useProvide(NAMESPACE, { data, urlMiss }) - - return { - step, reset, handleNext - } -} - -export const useSopData = () => useProvider(NAMESPACE, 'data').data - -export const useUrlMiss = () => useProvider(NAMESPACE, 'urlMiss').urlMiss \ No newline at end of file diff --git a/src/pages/app/components/Limit/components/Modify/Sop/index.tsx b/src/pages/app/components/Limit/components/Modify/Sop/index.tsx deleted file mode 100644 index a3d5a11f3..000000000 --- a/src/pages/app/components/Limit/components/Modify/Sop/index.tsx +++ /dev/null @@ -1,78 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import DialogSop from "@app/components/common/DialogSop" -import { t } from "@app/locale" -import { useXsState } from '@hooks' -import Flex from '@pages/components/Flex' -import { ElDivider, ElStep, ElSteps, ElText } from "element-plus" -import { computed, defineComponent, StyleValue } from "vue" -import { initSop, type Step } from "./context" -import Step1 from "./Step1" -import Step2 from "./Step2" -import Step3 from "./Step3" - -export type SopInstance = { - /** - * Reset with rule or initial value - */ - reset: (rule?: timer.limit.Rule) => void -} - -type Props = { - onCancel?: NoArgCallback - onSave?: ArgCallback> -} - -const STEP_TITLE: Record = { - 0: t(msg => msg.limit.step.base), - 1: t(msg => msg.limit.step.url), - 2: t(msg => msg.limit.step.rule), -} - -const _default = defineComponent(({ onSave, onCancel }, ctx) => { - const { reset, step, handleNext } = initSop({ onSave }) - const last = computed(() => step.value === 2) - const first = computed(() => step.value === 0) - ctx.expose({ reset } satisfies SopInstance) - - const isXs = useXsState() - - return () => ( - step.value--} - onCancel={onCancel} - onNext={handleNext} - onFinish={handleNext} - v-slots={{ - steps: () => isXs.value - ? ( - - {STEP_TITLE[step.value]} - - - ) - : ( - - - - - - ), - content: () => <> - - - - - }} - /> - ) -}, { props: ['onCancel', 'onSave'] }) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Limit/components/Modify/Sop/Step1.tsx b/src/pages/app/components/Limit/components/Modify/Step1.tsx similarity index 91% rename from src/pages/app/components/Limit/components/Modify/Sop/Step1.tsx rename to src/pages/app/components/Limit/components/Modify/Step1.tsx index e9f4d1de2..6e0a83a44 100644 --- a/src/pages/app/components/Limit/components/Modify/Sop/Step1.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step1.tsx @@ -1,11 +1,12 @@ +import { useDialogSop } from '@app/components/common/DialogSop/context' import { t } from "@app/locale" import { useXsState } from '@hooks/useMediaSize' import { ElCol, ElForm, ElFormItem, ElInput, ElRow, ElSelect, ElSwitch } from "element-plus" import { defineComponent } from "vue" -import { useSopData } from "./context" +import { ModifyForm } from './types' const _default = defineComponent(() => { - const data = useSopData() + const { form: data } = useDialogSop() const isXs = useXsState() return () => ( diff --git a/src/pages/app/components/Limit/components/Modify/Sop/Step2/SiteInput.tsx b/src/pages/app/components/Limit/components/Modify/Step2/SiteInput.tsx similarity index 100% rename from src/pages/app/components/Limit/components/Modify/Sop/Step2/SiteInput.tsx rename to src/pages/app/components/Limit/components/Modify/Step2/SiteInput.tsx diff --git a/src/pages/app/components/Limit/components/Modify/Sop/Step2/index.tsx b/src/pages/app/components/Limit/components/Modify/Step2/index.tsx similarity index 92% rename from src/pages/app/components/Limit/components/Modify/Sop/Step2/index.tsx rename to src/pages/app/components/Limit/components/Modify/Step2/index.tsx index 7a47f7f4b..6fadbf79b 100644 --- a/src/pages/app/components/Limit/components/Modify/Sop/Step2/index.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step2/index.tsx @@ -5,6 +5,7 @@ * https://opensource.org/licenses/MIT */ +import { useDialogSop } from '@app/components/common/DialogSop/context' import { t } from "@app/locale" import { Delete, WarnTriangleFilled } from "@element-plus/icons-vue" import Flex from "@pages/components/Flex" @@ -16,12 +17,11 @@ import { type ScrollbarInstance } from "element-plus" import { defineComponent, ref } from "vue" -import { useSopData, useUrlMiss } from "../context" +import type { ModifyForm } from '../types' import SiteInput from './SiteInput' const _default = defineComponent(() => { - const data = useSopData() - const urlMiss = useUrlMiss() + const { form: data } = useDialogSop() const scrollbar = ref() const handleAdd = (url: string) => { @@ -29,7 +29,7 @@ const _default = defineComponent(() => { if (urls.includes(url)) return 'URL added already' urls.unshift(url) - urlMiss.value = false + data.urlMiss = false scrollbar.value?.scrollTo(0) } diff --git a/src/pages/app/components/Limit/components/Modify/Sop/Step3/PeriodInput.tsx b/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx similarity index 100% rename from src/pages/app/components/Limit/components/Modify/Sop/Step3/PeriodInput.tsx rename to src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx diff --git a/src/pages/app/components/Limit/components/Modify/Sop/Step3/TimeInput.tsx b/src/pages/app/components/Limit/components/Modify/Step3/TimeInput.tsx similarity index 100% rename from src/pages/app/components/Limit/components/Modify/Sop/Step3/TimeInput.tsx rename to src/pages/app/components/Limit/components/Modify/Step3/TimeInput.tsx diff --git a/src/pages/app/components/Limit/components/Modify/Sop/Step3/index.tsx b/src/pages/app/components/Limit/components/Modify/Step3/index.tsx similarity index 79% rename from src/pages/app/components/Limit/components/Modify/Sop/Step3/index.tsx rename to src/pages/app/components/Limit/components/Modify/Step3/index.tsx index 6c815967c..e8a91bf27 100644 --- a/src/pages/app/components/Limit/components/Modify/Sop/Step3/index.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step3/index.tsx @@ -5,19 +5,20 @@ * https://opensource.org/licenses/MIT */ +import { useDialogSop } from '@app/components/common/DialogSop/context' import { t } from "@app/locale" import { useXsState } from '@hooks/useMediaSize' import Flex from "@pages/components/Flex" import { ElForm, ElFormItem, ElInputNumber } from "element-plus" import { defineComponent } from "vue" -import { useSopData } from "../context" +import { ModifyForm } from '../types' import PeriodInput from "./PeriodInput" import TimeInput from "./TimeInput" const MAX_HOUR_WEEKLY = 7 * 24 const _default = defineComponent(() => { - const data = useSopData() + const { form: data } = useDialogSop() const isXs = useXsState() return () => ( @@ -25,7 +26,7 @@ const _default = defineComponent(() => { msg.limit.item.daily)}> - data.time = v} /> + data.time = v} /> {!isXs.value && t(msg => msg.limit.item.or)} { msg.limit.item.weekly)}> - data.weekly = v} hourMax={MAX_HOUR_WEEKLY} /> + data.weekly = v} hourMax={MAX_HOUR_WEEKLY} /> {!isXs.value && t(msg => msg.limit.item.or)} { msg.limit.item.visitTime)}> - data.visitTime = v} /> + data.visitTime = v} /> msg.limit.item.period)}> - data.periods = v} /> + data.periods = v} /> diff --git a/src/pages/app/components/Limit/components/Modify/index.tsx b/src/pages/app/components/Limit/components/Modify/index.tsx index 1a01ad798..15ad02be0 100644 --- a/src/pages/app/components/Limit/components/Modify/index.tsx +++ b/src/pages/app/components/Limit/components/Modify/index.tsx @@ -5,82 +5,122 @@ * https://opensource.org/licenses/MIT */ +import DialogSop from '@app/components/common/DialogSop' +import { initDialogSopContext } from '@app/components/common/DialogSop/context' import { t } from "@app/locale" -import { useSwitch, useXsState } from "@hooks" import limitService from "@service/limit-service" -import { ElDialog, ElMessage } from "element-plus" -import { computed, defineComponent, nextTick, ref, toRaw } from "vue" +import { range } from '@util/array' +import { computed, defineComponent, ref, toRaw } from "vue" import { type ModifyInstance, useLimitData } from "../../context" -import Sop, { type SopInstance } from "./Sop" +import Step1 from './Step1' +import Step2 from './Step2' +import Step3 from './Step3' +import { ModifyForm } from './types' type Mode = "create" | "modify" +const STEP_TITLES = [ + t(msg => msg.limit.step.base), + t(msg => msg.limit.step.url), + t(msg => msg.limit.step.rule), +] + +const createInitial = (): ModifyForm => ({ + name: `RULE-${new String(new Date().getTime() % 10000).padStart(4, '0')}`, + time: 3600, + weekly: 0, + cond: [], + visitTime: 0, + periods: [], + enabled: true, + weekdays: range(7), + count: 0, + weeklyCount: 0, + allowDelay: false, + locked: false, +}) + const _default = defineComponent((_, ctx) => { const { refresh } = useLimitData() - const [visible, open, close] = useSwitch() - const sop = ref() const mode = ref() const title = computed(() => mode.value === "create" ? t(msg => msg.button.create) : t(msg => msg.button.modify)) - // Cache - let modifyingItem: timer.limit.Rule | undefined = undefined - const handleSave = async (rule: MakeOptional) => { - if (!rule) return - const { cond, enabled, name, time, weekly, visitTime, periods, weekdays, count, weeklyCount } = rule - let saved: timer.limit.Rule - if (mode.value === 'modify') { - if (!modifyingItem) return - saved = { - ...modifyingItem, - cond, enabled, name, time, weekly, visitTime, weekdays, count, weeklyCount, - // Object to array - periods: periods?.map(i => ([i?.[0], i?.[1]] satisfies Vector)), - } satisfies timer.limit.Rule - await limitService.update(saved) - } else { - const toCreate = { - cond, enabled, name, time, weekly, visitTime, weekdays, count, weeklyCount, - // Object to array - periods: periods?.map(i => ([i?.[0], i?.[1]] satisfies Vector)), - allowDelay: false, locked: false, - } satisfies MakeOptional - const id = await limitService.create(toCreate) - saved = { ...toCreate, id } + const { step, open } = initDialogSopContext({ + stepCount: STEP_TITLES.length, + init: createInitial, + onNext: ({ form, current }) => { + if (current === 0) { + const nameVal = form.name?.trim?.() + const weekdaysVal = form.weekdays + if (!nameVal) { + throw new Error("Name is empty") + } if (!weekdaysVal?.length) { + throw new Error("Effective days are empty") + } + } else if (current === 1) { + if (!form.cond?.length) { + form.urlMiss = true + throw new Error(t(msg => msg.limit.message.noUrl)) + } + form.urlMiss = false + } + }, + onFinish: async ({ form }) => { + const { cond, enabled, name, time, count, weekly, weeklyCount, visitTime, periods, weekdays } = toRaw(form) + if (true + && !time && !count + && !weekly && !weeklyCount + && !visitTime && !periods?.length + ) { + throw new Error(t(msg => msg.limit.message.noRule)) + } + let saved: timer.limit.Rule + if (mode.value === 'modify') { + if (!modifyingItem) return + saved = { + ...modifyingItem, + cond, enabled, name, time, weekly, visitTime, weekdays, count, weeklyCount, + // Object to array + periods: periods?.map(i => ([i?.[0], i?.[1]] satisfies Vector)), + } satisfies timer.limit.Rule + await limitService.update(saved) + } else { + const toCreate = { + cond, enabled, name, time, weekly, visitTime, weekdays, count, weeklyCount, + // Object to array + periods: periods?.map(i => ([i?.[0], i?.[1]] satisfies Vector)), + allowDelay: false, locked: false, + } satisfies MakeOptional + const id = await limitService.create(toCreate) + saved = { ...toCreate, id } + } + refresh?.() } - close() - ElMessage.success(t(msg => msg.operation.successMsg)) - sop.value?.reset?.() - refresh?.() - } + }) + // Cache + let modifyingItem: timer.limit.Rule | undefined = undefined ctx.expose({ create() { open() mode.value = 'create' modifyingItem = undefined - nextTick(() => sop.value?.reset()) }, modify(row: timer.limit.Item) { - open() + open(toRaw(row)) mode.value = 'modify' modifyingItem = { ...row } - nextTick(() => sop.value?.reset?.(toRaw(row))) }, } satisfies ModifyInstance) - const isXs = useXsState() return () => ( - - - + + + + + + ) }) diff --git a/src/pages/app/components/Limit/components/Modify/types.ts b/src/pages/app/components/Limit/components/Modify/types.ts new file mode 100644 index 000000000..ae99a74e3 --- /dev/null +++ b/src/pages/app/components/Limit/components/Modify/types.ts @@ -0,0 +1,3 @@ +export type ModifyForm = Omit & { + urlMiss?: boolean +} \ No newline at end of file diff --git a/src/pages/app/components/Option/categories/Backup/Clear/Sop.tsx b/src/pages/app/components/Option/categories/Backup/Clear/Sop.tsx deleted file mode 100644 index 0cce76b66..000000000 --- a/src/pages/app/components/Option/categories/Backup/Clear/Sop.tsx +++ /dev/null @@ -1,63 +0,0 @@ -import DialogSop, { type SopInstance, type SopStepInstance } from "@app/components/common/DialogSop" -import { t } from "@app/locale" -import { useManualRequest, useState } from "@hooks" -import processor from "@service/backup/processor" -import { ElMessage, ElStep, ElSteps } from "element-plus" -import { defineComponent, ref } from "vue" -import Step1, { type StatResult } from "./Step1" -import Step2 from "./Step2" - -type Props = { - onCancel: NoArgCallback - onClear: NoArgCallback -} - -const _default = defineComponent((props, ctx) => { - const [step, setStep] = useState<0 | 1>(0) - const step1 = ref>() - - const { data, refresh: handleNext, loading: readingClient } = useManualRequest(() => step1.value?.parseData?.(), { - onSuccess: () => setStep(1), - onError: e => ElMessage.warning((e as Error)?.message || 'Unknown error'), - }) - - const { refresh: handleClear, loading: deleting } = useManualRequest(async () => { - const cid = data.value?.client?.id - if (!cid) throw new Error('Client not selected') - const result = await processor.clear(cid) - if (!result.success) throw new Error(result.errorMsg) - }, { - onSuccess: () => { - ElMessage.success(t(msg => msg.operation.successMsg)) - props.onClear?.() - }, - onError: e => ElMessage.warning((e as Error)?.message || 'Unknown error'), - }) - - ctx.expose({ init: () => setStep(0) } satisfies SopInstance) - - return () => ( - step.value = 0} - onFinish={handleClear} - finishBtn={{ text: t(msg => msg.option.backup.clear.btn), type: 'danger' }} - finishLoading={deleting.value} - v-slots={{ - steps: () => ( - - msg.option.backup.clientTable.selectTip)} /> - msg.option.backup.confirmStep)} /> - - ), - content: () => step.value === 0 ? : - }} - /> - ) -}, { props: ['onCancel', 'onClear'] }) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Option/categories/Backup/Clear/Step1.tsx b/src/pages/app/components/Option/categories/Backup/Clear/Step1.tsx deleted file mode 100644 index 1961c7d1d..000000000 --- a/src/pages/app/components/Option/categories/Backup/Clear/Step1.tsx +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { type SopStepInstance } from "@app/components/common/DialogSop" -import { t } from "@app/locale" -import { useState } from "@hooks" -import processor from "@service/backup/processor" -import { BIRTHDAY, getBirthday, parseTime } from "@util/time" -import { defineComponent } from "vue" -import ClientTable from "../ClientTable" - -export type StatResult = { - rowCount: number - hostCount: number - client: timer.backup.Client -} - -async function fetchStatResult(client: timer.backup.Client): Promise { - const { id: specCid, maxDate, minDate = BIRTHDAY } = client - const start = parseTime(minDate) ?? getBirthday() - const end = parseTime(maxDate) ?? new Date() - const remoteRows: timer.core.Row[] = await processor.query({ specCid, start, end }) - const siteSet: Set = new Set() - remoteRows?.forEach(row => { - const { host } = row || {} - host && siteSet.add(host) - }) - const rowCount = remoteRows?.length || 0 - const hostCount = siteSet?.size || 0 - return { - rowCount, - hostCount, - client, - } -} - -const _default = defineComponent((_, ctx) => { - const [client, setClient] = useState() - - const parseData = (): Promise => { - const clientVal = client.value - if (!clientVal) throw new Error(t(msg => msg.option.backup.clientTable.notSelected)) - return fetchStatResult(clientVal) - } - - ctx.expose({ parseData } satisfies SopStepInstance) - - return () => -}) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Option/categories/Backup/Clear/Step2.tsx b/src/pages/app/components/Option/categories/Backup/Clear/Step2.tsx index 9778251d9..b326be791 100644 --- a/src/pages/app/components/Option/categories/Backup/Clear/Step2.tsx +++ b/src/pages/app/components/Option/categories/Backup/Clear/Step2.tsx @@ -5,20 +5,22 @@ * https://opensource.org/licenses/MIT */ +import { useDialogSop } from '@app/components/common/DialogSop/context' import { t } from "@app/locale" import { ElAlert } from "element-plus" import { defineComponent, toRaw } from "vue" -import { type StatResult } from "./Step1" +import type { ClearForm } from './types' -const _default = defineComponent<{ data?: StatResult }>(props => { +const _default = defineComponent<{}>(props => { + const { form } = useDialogSop() return () => ( {t(msg => msg.option.backup.clear.confirmTip, { - ...toRaw(props.data), - clientName: props.data?.client?.name || '' + ...toRaw(form.result), + clientName: form.client?.name ?? '' })} ) -}, { props: ['data'] }) +}) export default _default \ No newline at end of file diff --git a/src/pages/app/components/Option/categories/Backup/Clear/index.tsx b/src/pages/app/components/Option/categories/Backup/Clear/index.tsx index e6ce843bc..2accb825e 100644 --- a/src/pages/app/components/Option/categories/Backup/Clear/index.tsx +++ b/src/pages/app/components/Option/categories/Backup/Clear/index.tsx @@ -5,35 +5,67 @@ * https://opensource.org/licenses/MIT */ -import { type SopInstance } from "@app/components/common/DialogSop" +import DialogSop from "@app/components/common/DialogSop" +import { initDialogSopContext } from '@app/components/common/DialogSop/context' import { t } from "@app/locale" import { Delete } from "@element-plus/icons-vue" -import { useSwitch } from "@hooks" -import { ElButton, ElDialog } from "element-plus" -import { defineComponent, ref, type StyleValue } from "vue" -import Sop from "./Sop" +import processor from '@service/backup/processor' +import { BIRTHDAY, getBirthday, parseTime } from '@util/time' +import { ElButton } from "element-plus" +import { defineComponent } from "vue" +import ClientTable from '../ClientTable' +import Step2 from './Step2' +import type { ClearForm, StatResult } from './types' + +const STEP_TITLES = [ + t(msg => msg.option.backup.clientTable.selectTip), + t(msg => msg.option.backup.confirmStep), +] + +async function fetchStatResult(client: timer.backup.Client): Promise { + const { id: specCid, maxDate, minDate = BIRTHDAY } = client + const start = parseTime(minDate) ?? getBirthday() + const end = parseTime(maxDate) ?? new Date() + const remoteRows: timer.core.Row[] = await processor.query({ specCid, start, end }) + const siteSet: Set = new Set() + remoteRows?.forEach(row => { + const { host } = row || {} + host && siteSet.add(host) + }) + const rowCount = remoteRows?.length || 0 + const hostCount = siteSet?.size || 0 + return { rowCount, hostCount } +} const _default = defineComponent(() => { - const [dialogVisible, open, close] = useSwitch(false) - const sop = ref() + const { step, form, open } = initDialogSopContext({ + stepCount: STEP_TITLES.length, + init: () => ({}), + onNext: async ({ form }) => { + const client = form.client + if (!client) throw new Error(t(msg => msg.option.backup.clientTable.notSelected)) + form.result = await fetchStatResult(client) + }, + onFinish: async ({ form }) => { + const cid = form.client?.id + if (!cid) throw new Error(t(msg => msg.option.backup.clientTable.notSelected)) + const result = await processor.clear(cid) + if (!result.success) throw new Error(result.errorMsg) + }, + }) + return () => <> - + open()}> {t(msg => msg.option.backup.clear.btn)} - msg.option.backup.clear.btn)} - modelValue={dialogVisible.value} - onOpen={() => sop.value?.init?.()} - onClose={close} + finishButton={{ type: 'danger', text: t(msg => msg.option.backup.clear.btn) }} + stepTitles={STEP_TITLES} > - - + {step.value === 0 ? form.client = c} /> : } + }) diff --git a/src/pages/app/components/Option/categories/Backup/Clear/types.ts b/src/pages/app/components/Option/categories/Backup/Clear/types.ts new file mode 100644 index 000000000..4bd4a7b09 --- /dev/null +++ b/src/pages/app/components/Option/categories/Backup/Clear/types.ts @@ -0,0 +1,9 @@ +export type ClearForm = { + client?: timer.backup.Client + result?: StatResult +} + +export type StatResult = { + rowCount: number + hostCount: number +} diff --git a/src/pages/app/components/Option/categories/Backup/Download/Sop.tsx b/src/pages/app/components/Option/categories/Backup/Download/Sop.tsx deleted file mode 100644 index 6e5ab43d5..000000000 --- a/src/pages/app/components/Option/categories/Backup/Download/Sop.tsx +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import DialogSop, { type SopInstance, type SopStepInstance } from "@app/components/common/DialogSop" -import { t } from "@app/locale" -import { useManualRequest, useState } from "@hooks" -import processor from "@service/backup/processor" -import { fillExist, processImportedData } from "@service/components/import-processor" -import { getBirthday, parseTime } from "@util/time" -import { ElMessage, ElStep, ElSteps } from "element-plus" -import { defineComponent, ref } from "vue" -import ClientTable from "../ClientTable" -import Step2 from "./Step2" - -async function fetchData(client: timer.backup.Client): Promise { - const { id: specCid, maxDate, minDate } = client - const start = parseTime(minDate) ?? getBirthday() - const end = parseTime(maxDate) ?? new Date() - const remoteRows = await processor.query({ specCid, start, end }) - const rows: timer.imported.Row[] = remoteRows.map(rr => ({ - date: rr.date, - host: rr.host, - focus: rr.focus, - time: rr.time, - })) - await fillExist(rows) - return { rows, focus: true, time: true } -} - -type Props = { - onCancel: NoArgCallback - onDownload: NoArgCallback -} - -const _default = defineComponent((props, ctx) => { - const [step, setStep] = useState<0 | 1>(0) - const [client, setClient] = useState() - const step2 = ref>() - - const init = () => { - setStep(0) - setClient() - } - - ctx.expose({ init } satisfies SopInstance) - - const { data, refresh: handleNext, loading: dataFetching } = useManualRequest(() => { - if (step.value !== 0) throw new Error("Data already loaded") - - const clientVal = client.value - if (!clientVal) throw new Error(t(msg => msg.option.backup.clientTable.notSelected)) - return fetchData(clientVal) - }, { - defaultValue: { rows: [] }, - onSuccess: () => step.value = 1, - onError: e => ElMessage.error((e as Error)?.message || 'Unknown error...'), - }) - - const { refresh: handleDownload, loading: downloading } = useManualRequest(async () => { - const resolution = await step2.value?.parseData?.() - if (!resolution) throw new Error(t(msg => msg.dataManage.importOther.conflictNotSelected)) - - await processImportedData(data.value, resolution) - }, { - onSuccess: () => { - ElMessage.success(t(msg => msg.operation.successMsg)) - props.onDownload?.() - }, - onError: e => ElMessage.error((e as Error)?.message || 'Unknown error...'), - }) - - return () => ( - msg.option.backup.download.btn)} - finishLoading={downloading.value} - v-slots={{ - steps: () => ( - - msg.option.backup.clientTable.selectTip)} /> - msg.option.backup.confirmStep)} /> - - ), - content: () => step.value === 0 - ? - : - }} - /> - ) -}, { props: ['onCancel', 'onDownload'] }) - -export default _default \ No newline at end of file diff --git a/src/pages/app/components/Option/categories/Backup/Download/Step2.tsx b/src/pages/app/components/Option/categories/Backup/Download/Step2.tsx index 03f6fdcc9..25503cc36 100644 --- a/src/pages/app/components/Option/categories/Backup/Download/Step2.tsx +++ b/src/pages/app/components/Option/categories/Backup/Download/Step2.tsx @@ -4,42 +4,34 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ - -import type { SopStepInstance } from "@app/components/common/DialogSop" +import { useDialogSop } from '@app/components/common/DialogSop/context' import CompareTable from "@app/components/common/imported/CompareTable" import ResolutionRadio from "@app/components/common/imported/ResolutionRadio" import { t } from "@app/locale" -import { useState } from "@hooks" import Flex from "@pages/components/Flex" import { ElAlert } from "element-plus" import { defineComponent } from "vue" +import { DownloadForm } from './types' -type Props = { - data: timer.imported.Data - clientName: string -} - -const _default = defineComponent((props, ctx) => { - const [resolution, setResolution] = useState() - - ctx.expose({ parseData: () => resolution.value } satisfies SopStepInstance) +const _default = defineComponent<{}>(() => { + const { form } = useDialogSop() return () => ( { t(msg => msg.option.backup.download.confirmTip, { - clientName: props.clientName, - size: props.data?.rows?.length || 0 + clientName: form.client?.name, + size: form.data.rows.length, }) } - msg.option.backup.download.willDownload} /> + msg.option.backup.download.willDownload} /> - + form.resolution = v} /> ) -}, { props: ['data', 'clientName'] }) +}) export default _default diff --git a/src/pages/app/components/Option/categories/Backup/Download/index.tsx b/src/pages/app/components/Option/categories/Backup/Download/index.tsx index c32ccf22e..f503f23cb 100644 --- a/src/pages/app/components/Option/categories/Backup/Download/index.tsx +++ b/src/pages/app/components/Option/categories/Backup/Download/index.tsx @@ -4,33 +4,72 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ - -import type { SopInstance } from "@app/components/common/DialogSop" +import DialogSop from '@app/components/common/DialogSop' +import { initDialogSopContext } from '@app/components/common/DialogSop/context' import { t } from "@app/locale" import { Files } from "@element-plus/icons-vue" -import { useSwitch } from "@hooks" -import { ElButton, ElDialog } from "element-plus" -import { defineComponent, ref } from "vue" -import Sop from "./Sop" +import processor from '@service/backup/processor' +import { fillExist, processImportedData } from '@service/components/import-processor' +import { getBirthday, parseTime } from '@util/time' +import { ElButton } from "element-plus" +import { defineComponent, toRaw } from "vue" +import ClientTable from '../ClientTable' +import Step2 from './Step2' +import type { DownloadForm } from './types' + +const STEP_TITLES = [ + t(msg => msg.option.backup.clientTable.selectTip), + t(msg => msg.option.backup.confirmStep), +] + +async function fetchData(client: timer.backup.Client): Promise { + const { id: specCid, maxDate, minDate } = client + const start = parseTime(minDate) ?? getBirthday() + const end = parseTime(maxDate) ?? new Date() + const remoteRows = await processor.query({ specCid, start, end }) + const rows: timer.imported.Row[] = remoteRows.map(rr => ({ + date: rr.date, + host: rr.host, + focus: rr.focus, + time: rr.time, + })) + await fillExist(rows) + return { rows, focus: true, time: true } +} const _default = defineComponent(() => { - const [dialogVisible, open, close] = useSwitch() - const sop = ref() + const { open, step, form } = initDialogSopContext({ + stepCount: STEP_TITLES.length, + init: () => ({ data: { rows: [], focus: true, time: true }, resolution: undefined }), + onNext: async ({ form, target }) => { + if (target === 1) { + const clientVal = form.client + if (!clientVal) throw new Error(t(msg => msg.option.backup.clientTable.notSelected)) + form.data = await fetchData(clientVal) + } + }, + onFinish: async ({ form }) => { + const resolution = form.resolution + if (!resolution) throw new Error(t(msg => msg.dataManage.importOther.conflictNotSelected)) + const data = form.data + if (!data) throw new Error(t(msg => msg.option.backup.clientTable.notSelected)) + await processImportedData(toRaw(data), resolution) + }, + }) return () => <> - + open()}> {t(msg => msg.option.backup.download.btn)} - msg.option.backup.download.btn)} - width="70%" - modelValue={dialogVisible.value} - onOpen={() => sop.value?.init?.()} - onClose={close} + stepTitles={STEP_TITLES} + finishButton={{ text: t(msg => msg.option.backup.download.btn) }} > - - + form.client = c} /> + + }) diff --git a/src/pages/app/components/Option/categories/Backup/Download/types.ts b/src/pages/app/components/Option/categories/Backup/Download/types.ts new file mode 100644 index 000000000..215cc3c9a --- /dev/null +++ b/src/pages/app/components/Option/categories/Backup/Download/types.ts @@ -0,0 +1,5 @@ +export type DownloadForm = { + client?: timer.backup.Client + resolution?: timer.imported.ConflictResolution + data: timer.imported.Data +} \ No newline at end of file diff --git a/src/pages/app/components/Option/categories/Backup/Footer.tsx b/src/pages/app/components/Option/categories/Backup/Footer.tsx index 3d74b33aa..eb325e535 100644 --- a/src/pages/app/components/Option/categories/Backup/Footer.tsx +++ b/src/pages/app/components/Option/categories/Backup/Footer.tsx @@ -7,16 +7,26 @@ import { t } from "@app/locale" import { Operation, UploadFilled } from "@element-plus/icons-vue" +import { css } from '@emotion/css' import { useManualRequest, useRequest, useState } from "@hooks" import Flex from "@pages/components/Flex" import processor from "@service/backup/processor" import { getLastBackUp } from "@service/meta-service" import { formatTime } from "@util/time" -import { ElButton, ElDivider, ElLoading, ElMessage, ElText } from "element-plus" +import { ElButton, ElDivider, ElLoading, ElMessage, ElText, useNamespace } from "element-plus" import { defineComponent, type StyleValue } from "vue" import Clear from "./Clear" import Download from "./Download" +const useStyle = () => { + const buttonNs = useNamespace('button') + return css` + .${buttonNs.b()}+.${buttonNs.b()} { + margin-inline-start: 0px; + } + ` +} + async function handleTest() { const loading = ElLoading.service({ text: "Please wait...." }) try { @@ -53,9 +63,11 @@ const _default = defineComponent<{ type: timer.backup.Type }>(props => { }, }) + const footerCls = useStyle() + return () => <> - + {t(msg => msg.button.test)} diff --git a/src/pages/app/components/Option/categories/Backup/index.tsx b/src/pages/app/components/Option/categories/Backup/index.tsx index d752bd397..ba2fa317b 100644 --- a/src/pages/app/components/Option/categories/Backup/index.tsx +++ b/src/pages/app/components/Option/categories/Backup/index.tsx @@ -68,6 +68,7 @@ const _default = defineComponent((_, ctx) => { }} > = { parseData: Getter } - -export type SopInstance = { init: NoArgCallback } - -const FLAGS = ['first', 'last', 'nextLoading', 'finishLoading'] as const -const EMITS = ['onBack', 'onNext', 'onCancel', 'onFinish'] as const - -type FinishBtn = { - text?: string - type?: ButtonProps['type'] -} - -type Props = { - [F in typeof FLAGS[number]]?: boolean -} & { - [C in typeof EMITS[number]]?: NoArgCallback -} & { - finishBtn?: FinishBtn['text'] | FinishBtn -} - -const DialogSop = defineComponent(props => { - const { steps, content } = useSlots() - const stepWrapperCls = css` - & .${useNamespace('step').b()} { - width: 200px; - } - ` - - const finishBtn = computed(() => { - const prop = props.finishBtn - if (!prop) return {} - if (typeof prop === 'string') return { text: prop } - return prop - }) - - return () => ( - -
    - {!!steps && h(steps)} -
    - - {!!content && h(content)} - - - - {props.first ? ( - - {t(msg => msg.button.cancel)} - - ) : ( - - {t(msg => msg.button.previous)} - - )}{ - props.last ? ( - - {finishBtn.value.text ?? t(msg => msg.button.save)} - - ) : ( - - {t(msg => msg.button.next)} - - ) - } - - -
    - ) -}, { props: [...FLAGS, ...EMITS, 'finishBtn'] }) - -export default DialogSop \ No newline at end of file diff --git a/src/pages/app/components/common/DialogSop/context.ts b/src/pages/app/components/common/DialogSop/context.ts new file mode 100644 index 000000000..9968d6549 --- /dev/null +++ b/src/pages/app/components/common/DialogSop/context.ts @@ -0,0 +1,98 @@ +import { t } from '@app/locale' +import { useManualRequest, useProvide, useProvider } from "@hooks" +import { mergeObject } from '@util/lang' +import { ElMessage } from "element-plus" +import { computed, nextTick, Reactive, reactive, ref, ShallowRef, type Ref } from "vue" + +type DialogSopContext> = { + visible: ShallowRef + step: Ref + form: Reactive + isFirst: ShallowRef + isLast: ShallowRef + doNext: NoArgCallback + nextLoading: ShallowRef + doPrevious: NoArgCallback + hide: NoArgCallback +} + +const NAMESPACE = "dialogSop" + +type TransmitParam> = { + form: Reactive + current: number + target: number +} + +export type DialogSopInitOptions> = { + stepCount: number + init: () => TForm + onNext?: (p: TransmitParam) => Awaitable + onFinish?: (p: Omit, 'target'>) => Awaitable + onBack?: (p: TransmitParam) => Awaitable +} + +export function initDialogSopContext>(options: DialogSopInitOptions) { + const { + stepCount, + init, onNext, onFinish, onBack, + } = options + if (!Number.isInteger(stepCount) || stepCount < 1) throw new Error("Invalid step count, must be positive integer") + const lastIdx = stepCount - 1 + + const visible = ref(false) + const step = ref(0) + const isLast = computed(() => step.value === lastIdx) + const isFirst = computed(() => step.value === 0) + const form = reactive(init()) + const hide = () => visible.value = false + const open = (val?: TForm) => { + visible.value = true + step.value = 0 + nextTick(() => mergeObject(form as TForm, val ?? init())) + } + + const { loading: nextLoading, refresh: doNext } = useManualRequest(async () => { + const current = step.value + if (isLast.value) { + await onFinish?.({ form, current }) + visible.value = false + ElMessage.success(t(msg => msg.operation.successMsg)) + } else { + const target = current + 1 + await onNext?.({ form, current, target }) + step.value = target + } + }, { + onError: e => ElMessage.error(e instanceof Error ? e.message : String(e)), + }) + + const doPrevious = async () => { + if (isFirst.value) { + hide() + } else { + try { + const current = step.value + const target = current - 1 + await onBack?.({ current, target, form }) + step.value = target + } catch (e) { + ElMessage.warning(e instanceof Error ? e.message : String(e)) + } + } + } + + useProvide>(NAMESPACE, { + visible, form, isFirst, isLast, + step, doNext, doPrevious, nextLoading, hide + }) + + return { open, step, form } +} + +export const useDialogSop = >() => useProvider< + DialogSopContext, + 'form' | 'isLast' | 'isFirst' | 'step' | 'doPrevious' | 'doNext' | 'nextLoading' | 'hide' | 'visible' +>( + NAMESPACE, 'form', 'doPrevious', 'doNext', 'isFirst', 'isLast', 'step', 'nextLoading', 'hide', 'visible' +) \ No newline at end of file diff --git a/src/pages/app/components/common/DialogSop/index.tsx b/src/pages/app/components/common/DialogSop/index.tsx new file mode 100644 index 000000000..01b76c449 --- /dev/null +++ b/src/pages/app/components/common/DialogSop/index.tsx @@ -0,0 +1,80 @@ +import { t } from "@app/locale" +import { Back, Check, Close } from "@element-plus/icons-vue" +import { css } from '@emotion/css' +import { useXsState } from '@hooks/index' +import Box from "@pages/components/Box" +import Flex from "@pages/components/Flex" +import { type ButtonProps, DialogProps, ElButton, ElDialog, ElDivider, ElStep, ElSteps, ElText, useNamespace } from "element-plus" +import { defineComponent, h, type StyleValue, useSlots } from "vue" +import { useDialogSop } from './context' + +type Props = { + stepTitles: string[] + finishButton?: { + text?: string + type?: ButtonProps['type'] + } +} & Pick + +const DialogSop = defineComponent(props => { + const { + step, visible, + isFirst, isLast, nextLoading, + doNext, doPrevious, hide, + } = useDialogSop() + + const isXs = useXsState() + + const stepWrapperCls = css` + & .${useNamespace('step').b()} { + width: 200px; + } + ` + + return () => { + const { default: children } = useSlots() + + return ( + + +
    + {isXs.value ? ( + + {props.stepTitles[step.value]} + + + ) : ( + + {props.stepTitles.map(stepTitle => )} + + )} +
    + + {!!children && h(children)} + + + + {t(msg => msg.button[isFirst.value ? 'cancel' : 'previous'])} + + + {isLast.value ? props.finishButton?.text ?? t(msg => msg.button.save) : t(msg => msg.button.next)} + + +
    +
    + ) + } +}, { props: ['title', 'stepTitles', 'finishButton', 'width', 'top'] }) + +export default DialogSop diff --git a/test-e2e/backup/common.ts b/test-e2e/backup/common.ts new file mode 100644 index 000000000..d48b1bec4 --- /dev/null +++ b/test-e2e/backup/common.ts @@ -0,0 +1,204 @@ +import type { ElementHandle, Page } from 'puppeteer' +import { type LaunchContext, sleep } from '../common/base' +import { waitForMessage, waitForSuccMessage } from '../common/message' + +const typeNames: Record = { + gist: 'gist', + web_dav: 'dav', + obsidian_local_rest_api: 'obsidian', + none: 'none', +} + +const OPTION_TAB_ID = 'pane-backup' + +async function waitClientReady(page: Page): Promise | null> { + await page.waitForFunction(() => { + const emptyBody = document.querySelector('.el-dialog .el-table__empty-block') + const rows = document.querySelectorAll('.el-dialog .el-table .el-table__row') + return !!emptyBody || !!rows.length + }, { timeout: 1000 }) + + const nextBtn = await page.waitForSelector( + '.el-overlay:not([style*="display: none"]) .el-dialog .el-button.el-button--primary:not([disabled])', + { timeout: 1000 }, + ) + return nextBtn +} + +async function findCurrentClientRow(page: Page): Promise | null> { + const table = await page.waitForSelector( + '.el-overlay:not([style*="display: none"]) .el-dialog .el-table', + { timeout: 2000 }, + ) + const rows = await table!.$$('.el-table__row') + + for (const row of rows) { + const currVisible = await row.evaluate(el => { + const tag = el.querySelector('.el-table__cell .el-tag') + if (!tag) return false + const text = tag.textContent + if (!text.toLowerCase().includes('current')) return false + const style = window.getComputedStyle(tag) + return style && style.display !== 'none' + }) + if (currVisible) return row + } + return null +} + +async function selectCurrentClient(page: Page): Promise { + const currRow = await findCurrentClientRow(page) + expect(currRow).toBeTruthy() + await currRow!.click() + await sleep(.1) +} + +export class BackupOptionWrapper { + private _page: Page | undefined + + constructor(private context: LaunchContext) { } + + private async page() { + if (this._page) { + await this._page.bringToFront() + return this._page + } + this._page = await this.context.openAppPage('/additional/option?i=backup') + return this._page + } + + async $(selector: string) { + const page = await this.page() + return await page.$(`#${OPTION_TAB_ID} ${selector}`) + } + + async changeType(type: timer.backup.Type) { + const page = await this.page() + const pane = await page.$(`#${OPTION_TAB_ID} .el-select`) + await pane?.click() + await sleep(.1) + + const backupOptions = await page.$$('.el-popper[data-popper-reference-hidden="false"] .el-select-dropdown__item') + + const labelPart = typeNames[type] + for (const option of backupOptions) { + const text = await option.evaluate(el => el.textContent) + if (text?.toLowerCase().includes(labelPart)) { + await option.click() + break + } + } + await sleep(.1) + + return page + } + + async assertTestInvalid() { + const page = await this.clickButton('test') + // The same as mock server + await waitForMessage(page, 'Unauthorized') + } + + async assertTestValid() { + const page = await this.clickButton('test') + await waitForMessage(page, 'Valid!') + } + + async assertBackupSuccess() { + const page = await this.clickButton('backup') + + // Wait for the success message + await waitForSuccMessage(page) + + const lastSyncTime = await this.findLastSyncTime() + expect(lastSyncTime).toBeTruthy() + // Less than 2 seconds + expect(Date.now() - lastSyncTime!.getTime()).toBeLessThan(2 * 1000) + } + + private async clickButton(textPart: string): Promise { + const page = await this.page() + const buttons = await page.$$(`#${OPTION_TAB_ID} .el-button`) + for (const button of buttons) { + const text = await button.evaluate(el => el.textContent) + if (text?.toLowerCase().includes(textPart.toLowerCase())) { + await button.click() + return page + } + } + throw new Error(`Button with text "${textPart}" not found`) + } + + private async findLastSyncTime() { + const page = await this.page() + const texts = await page.$$(`#${OPTION_TAB_ID} .el-text`) + for (const textEl of texts) { + const text = await textEl.evaluate(el => el.textContent) + const matchRes = /(?\d{2})\/(?\d{2})\/(?\d{4}) (?\d{2}):(?\d{2}):(?\d{2})/.exec(text ?? '') + if (!matchRes) continue + const groups = matchRes.groups + if (!groups) continue + const { M, d, y, h, m, s } = groups + return new Date( + Number.parseInt(y), Number.parseInt(M) - 1, Number.parseInt(d), + Number.parseInt(h), Number.parseInt(m), Number.parseInt(s), + ) + } + return undefined + } + + async downloadCurrentWithAcc() { + // Open dialog + const page = await this.clickButton('download') + + const nextBtn = await waitClientReady(page) + + // Select current client row + await selectCurrentClient(page) + + // Next step + await nextBtn!.click() + + // Wait for download enabled + const downloadBtn = await page.waitForSelector('.el-dialog .el-button.el-button--success:not([disabled])', { timeout: 1000 }) + + // Choose the solution + const solutionRadio = await page.waitForSelector('.el-dialog .el-radio-group > label:nth-child(2)', { timeout: 1000 }) + await solutionRadio!.click() + + // Do downloading + await downloadBtn!.click() + + await waitForSuccMessage(page) + } + + async clearData() { + const page = await this.clickButton('clear') + + const nextBtn = await waitClientReady(page) + + // Select current client row + await selectCurrentClient(page) + + // Next step + await nextBtn!.click() + + // Wait for clear enabled + const clearBtn = await page.waitForSelector('.el-dialog .el-button.el-button--danger:not([disabled])', { timeout: 1000 }) + + await clearBtn!.click() + + await waitForSuccMessage(page) + } + + async assertCantDownloadCurr() { + // Open dialog + const page = await this.clickButton('download') + + await waitClientReady(page) + + // Select current client row + const currRow = await findCurrentClientRow(page) + expect(currRow).toBeFalsy() + } +} \ No newline at end of file diff --git a/test-e2e/backup/gist.test.ts b/test-e2e/backup/gist.test.ts new file mode 100644 index 000000000..1a9fce883 --- /dev/null +++ b/test-e2e/backup/gist.test.ts @@ -0,0 +1,65 @@ +import { launchBrowser, MOCK_URL, sleep, type LaunchContext } from '../common/base' +import { readRecordsOfFirstPage } from '../common/record' +import { BackupOptionWrapper } from './common' + +const GIST_MOCK_ORIGIN = 'http://127.0.0.1:12347' +const GIST_MOCK_TOKEN = 'github_gist_mock_token' + +let context: LaunchContext + +describe('Backup with gist', () => { + beforeEach(async () => { + context = await launchBrowser({ proxies: [{ host: 'api.github.com', target: GIST_MOCK_ORIGIN }] }) + }) + + afterEach(() => context.close()) + + test('create and update gist', async () => { + // Fill in gist parameters + const option = new BackupOptionWrapper(context) + const page = await option.changeType('gist') + + const tokenInput = await option.$('input[name="token"]') + expect(tokenInput).toBeTruthy() + + // Assert test invalid with invalid token + await tokenInput!.type('foobar' + Date.now()) + await option.assertTestInvalid() + + // Assert token is valid + await tokenInput!.focus() + await page.keyboard.down('Control') + await page.keyboard.press('a') + await page.keyboard.up('Control') + await tokenInput!.type(GIST_MOCK_TOKEN) + await option.assertTestValid() + + // Visit site + const sitePage = await context.newPageAndWaitCsInjected(MOCK_URL) + await sleep(2) + await sitePage.close() + let originalRecords = await readRecordsOfFirstPage(context) + expect(originalRecords.length).toEqual(1) + const original = originalRecords[0] + + // Upload the data to gist + await option.assertBackupSuccess() + + // Check download content + await sleep(1) + await option.downloadCurrentWithAcc() + + const twiceRecords = await readRecordsOfFirstPage(context) + expect(twiceRecords.length).toEqual(1) + const after = twiceRecords[0] + expect(after.url).toEqual(original.url) + expect(after.visit).toEqual('2') + + // Clear data + await option.clearData() + + // Assert can't download current + await sleep(1) + await option.assertCantDownloadCurr() + }, 50000) +}) \ No newline at end of file diff --git a/test-e2e/common/base.ts b/test-e2e/common/base.ts index 1ce962712..6c881564b 100644 --- a/test-e2e/common/base.ts +++ b/test-e2e/common/base.ts @@ -1,9 +1,52 @@ -import { type Browser, launch, type Page } from "puppeteer" +import { type Browser, launch, type Page, type Target } from "puppeteer" import { E2E_OUTPUT_PATH } from "../../rspack/constant" import { removeAllWhitelist } from './whitelist.test' const USE_HEADLESS_PUPPETEER = !!process.env['USE_HEADLESS_PUPPETEER'] +export interface HostProxy { + host: string + target: string +} + +const setupProxies = async (target: Target, proxies: HostProxy[]) => { + try { + const session = await target.createCDPSession() + await session.send('Fetch.enable', { + patterns: [{ urlPattern: '*', requestStage: 'Request' }], + }) + session.on('Fetch.requestPaused', async (event: { requestId: string; request: { url: string } }) => { + try { + const requestUrl = new URL(event.request.url) + const proxy = proxies.find(p => requestUrl.hostname === p.host || requestUrl.host === p.host) + if (proxy) { + const targetUrl = new URL(proxy.target) + requestUrl.protocol = targetUrl.protocol + requestUrl.hostname = targetUrl.hostname + requestUrl.port = targetUrl.port + await session.send('Fetch.continueRequest', { + requestId: event.requestId, + url: requestUrl.toString(), + }) + } else { + await session.send('Fetch.continueRequest', { requestId: event.requestId }) + } + } catch { + try { await session.send('Fetch.continueRequest', { requestId: event.requestId }) } catch { /* ignore */ } + } + }) + } catch { + // not all targets support CDP sessions (e.g. browser target) + } +} + +async function applyProxies(browser: Browser, proxies: HostProxy[]): Promise { + for (const target of browser.targets()) { + await setupProxies(target, proxies) + } + browser.on('targetcreated', target => setupProxies(target, proxies)) +} + export interface LaunchContext { browser: Browser extensionId: string @@ -53,8 +96,13 @@ class LaunchContextWrapper implements LaunchContext { } } -export async function launchBrowser(dirPath?: string): Promise { - dirPath = dirPath ?? E2E_OUTPUT_PATH +type BrowserOptions = { + dirPath?: string + proxies?: HostProxy[] +} + +export async function launchBrowser(options?: BrowserOptions): Promise { + const { dirPath = E2E_OUTPUT_PATH, proxies } = options ?? {} const args = [ `--disable-extensions-except=${dirPath}`, `--load-extension=${dirPath}`, @@ -78,6 +126,8 @@ export async function launchBrowser(dirPath?: string): Promise { const context = new LaunchContextWrapper(browser, extensionId) + proxies?.length && await applyProxies(browser, proxies) + // remove whitelist added by service_worker await removeAllWhitelist(context) diff --git a/test-e2e/common/message.ts b/test-e2e/common/message.ts new file mode 100644 index 000000000..6d8e1a1ac --- /dev/null +++ b/test-e2e/common/message.ts @@ -0,0 +1,16 @@ +import { type Page } from 'puppeteer' + +export async function waitForMessage(page: Page, msg: string): Promise { + await page.waitForFunction( + (msg: string) => { + const messages = document.querySelectorAll('.el-message') + return Array.from(messages).some(el => el.textContent === msg) + }, + { timeout: 5000 }, + msg, + ) +} + +export async function waitForSuccMessage(page: Page) { + return waitForMessage(page, 'Successfully!') +} \ No newline at end of file diff --git a/test-e2e/example/index.html b/test-e2e/example/index.html deleted file mode 100644 index b1dc95fad..000000000 --- a/test-e2e/example/index.html +++ /dev/null @@ -1,6 +0,0 @@ - - - -
    Time Tracker test page
    - - \ No newline at end of file diff --git a/test-e2e/install.test.ts b/test-e2e/install.test.ts index bb774d355..b52202a50 100644 --- a/test-e2e/install.test.ts +++ b/test-e2e/install.test.ts @@ -6,7 +6,7 @@ let context: LaunchContext describe('After installed', () => { beforeEach(async () => { const path = join(__dirname, '..', 'dist_prod') - context = await launchBrowser(path) + context = await launchBrowser({ dirPath: path }) }) afterEach(async () => context.close()) From d7625e2690682f73d783736dea6e80efd924f95b Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sun, 29 Mar 2026 22:50:28 +0800 Subject: [PATCH 107/174] v4.1.4 --- CHANGELOG.md | 5 +++++ package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index dda798e8c..11ca89aec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.1.4] - 2026-03-30 + +- Fixed some issue for Gist + + ## [4.1.3] - 2026-03-24 - Add data collection permission for Firefox diff --git a/package.json b/package.json index ef8d134b3..47b982c5f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "4.1.3", + "version": "4.1.4", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { From 407e2ef882498b8ea45ccd39c5d957e7fee7dd6e Mon Sep 17 00:00:00 2001 From: linwenqi <153589923+linwenqi1@users.noreply.github.com> Date: Mon, 30 Mar 2026 21:40:22 +0800 Subject: [PATCH 108/174] fix: resolve empty alert render when exactly one total rule exists and matches successfully (#712) --- .../app/components/common/AlertLines.tsx | 33 +++++++++++-------- 1 file changed, 19 insertions(+), 14 deletions(-) diff --git a/src/pages/app/components/common/AlertLines.tsx b/src/pages/app/components/common/AlertLines.tsx index 9ab1717d9..4ee9df148 100644 --- a/src/pages/app/components/common/AlertLines.tsx +++ b/src/pages/app/components/common/AlertLines.tsx @@ -13,20 +13,25 @@ export type AlertLinesProps = { type?: AlertProps['type'] } -const AlertLines: FunctionalComponent = ({ lines, title, type }) => ( - - {lines?.map(l =>
  • { - Array.isArray(l) - ? tN(l[0], l[1]) - : (typeof l === 'string' ? l : t(l)) - }
  • )} -
    -) +const AlertLines: FunctionalComponent = ({ lines, title, type }) => { + return ( + + {lines?.map((l, i) => ( +
  • + {Array.isArray(l) + ? tN(l[0], l[1]) + : (typeof l === 'string' ? l : t(l))} +
  • + ))} +
    + ) +} AlertLines.displayName = 'AlertLines' From 238f0d757dcaa26d2d237c8dbc22ea800fce7ee8 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 31 Mar 2026 00:44:51 +0800 Subject: [PATCH 109/174] fix: endpoint of backup not visible --- package.json | 6 +- .../Option/categories/Backup/index.tsx | 73 ++++++++++--------- .../components/Option/components/Lines.tsx | 4 +- 3 files changed, 44 insertions(+), 39 deletions(-) diff --git a/package.json b/package.json index 47b982c5f..6e426aeb7 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,10 @@ }, "license": "MIT", "devDependencies": { - "@crowdin/crowdin-api-client": "^1.54.0", + "@crowdin/crowdin-api-client": "^1.55.0", "@emotion/babel-plugin": "^11.13.5", "@emotion/css": "^11.13.5", - "@rsdoctor/rspack-plugin": "^1.5.5", + "@rsdoctor/rspack-plugin": "^1.5.6", "@rspack/cli": "^1.7.10", "@rspack/core": "^1.7.10", "@swc/core": "^1.15.21", @@ -55,7 +55,7 @@ "jszip": "^3.10.1", "postcss": "^8.5.8", "postcss-loader": "^8.2.1", - "postcss-rtlcss": "^5.7.1", + "postcss-rtlcss": "^6.0.0", "puppeteer": "^24.40.0", "ts-loader": "^9.5.4", "ts-node": "^10.9.2", diff --git a/src/pages/app/components/Option/categories/Backup/index.tsx b/src/pages/app/components/Option/categories/Backup/index.tsx index ba2fa317b..a963d3576 100644 --- a/src/pages/app/components/Option/categories/Backup/index.tsx +++ b/src/pages/app/components/Option/categories/Backup/index.tsx @@ -51,32 +51,35 @@ const _default = defineComponent((_, ctx) => { onChange={(val: timer.backup.Type) => option.backupType = val} options={ALL_TYPES.map(value => ({ value, label: TYPE_NAMES[value] }))} /> - - - option.autoBackUp = val} - onIntervalChange={val => val !== undefined && (option.autoBackUpInterval = val)} - /> - - {t(msg => msg.option.backup.meta.gist.authInfo)} - }} - > - auth.value = val?.trim?.() || ''} - /> + {isNotNone.value && ( + + option.autoBackUp = val} + onIntervalChange={val => val !== undefined && (option.autoBackUpInterval = val)} + /> + + )} + {option.backupType === 'gist' && ( + {t(msg => msg.option.backup.meta.gist.authInfo)} + }} + > + auth.value = val?.trim?.() || ''} + /> + + )} {option.backupType === 'obsidian_local_rest_api' && <> msg.option.backup.label.endpoint} @@ -161,15 +164,17 @@ const _default = defineComponent((_, ctx) => { /> } - msg.option.backup.client}> - option.clientName = val?.trim?.() ?? ''} - /> - - {isNotNone.value &&
    } + {isNotNone.value && <> + msg.option.backup.client}> + option.clientName = val?.trim?.() ?? ''} + /> + +
    + } }) diff --git a/src/pages/app/components/Option/components/Lines.tsx b/src/pages/app/components/Option/components/Lines.tsx index efb2fa36f..865ba4b43 100644 --- a/src/pages/app/components/Option/components/Lines.tsx +++ b/src/pages/app/components/Option/components/Lines.tsx @@ -1,5 +1,5 @@ import { ElDivider } from 'element-plus' -import { type FunctionalComponent, h, isVNode, type VNode } from "vue" +import { type FunctionalComponent, h, isVNode, type VNode, vShow } from "vue" import { isOptionItem } from './Item' function isComment({ type, children }: VNode): boolean { @@ -7,7 +7,7 @@ function isComment({ type, children }: VNode): boolean { } function isHidden({ dirs }: VNode): boolean { - return !!dirs?.some(({ dir, value }) => (dir as any)?.name === 'show' && value === false) + return !!dirs?.some(({ dir, value }) => dir === vShow && value === false) } function flattenChildren(original: VNode[]): VNode[] { From 3cfb0b226fd5741847be81b33d47c47a6722edd8 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 31 Mar 2026 00:45:28 +0800 Subject: [PATCH 110/174] v4.1.5 --- CHANGELOG.md | 4 ++++ package.json | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 11ca89aec..9baab8bf5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.1.5] - 2026-03-31 + +- Fixed some issues + ## [4.1.4] - 2026-03-30 - Fixed some issue for Gist diff --git a/package.json b/package.json index 6e426aeb7..44bef4be5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "4.1.4", + "version": "4.1.5", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { From 4486dabb2175b667fee88b6e9cfd159ccf9586ad Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 31 Mar 2026 18:04:08 +0800 Subject: [PATCH 111/174] i18n(download): download translations by bot (#717) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/option-resource.json | 17 +++++++++++++++++ src/i18n/message/common/calendar-resource.json | 1 + 2 files changed, 18 insertions(+) diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 8982698a2..d8555f51f 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -1420,6 +1420,7 @@ "on": "Her zaman açık", "off": "Her zaman kapalı", "followBrowser": "Tarayıcıyı takip et", + "permGrantConfirm": "Bu özellik ilgili izinleri gerektirir", "appearance": { "title": "Görünüm", "displayWhitelist": "{input} {contextMenu} içinde {whitelist} gösterilip gösterilmeyeceğini seçin", @@ -1528,6 +1529,22 @@ "title": "Erişilebilirlik", "chartDecal": "{input} Grafik çıkartmasını gösterip göstermemeyi seçin" }, + "notification": { + "title": "Bildirim", + "cycle": { + "label": "Bildirim döngüsü {input}", + "daily": "Günlük", + "weekly": "Haftalık" + }, + "method": { + "label": "Bildirim methodu {input}", + "browser": "Tarayıcı", + "callback": { + "label": "HTTP Callback", + "url": "Callback URL {input}" + } + } + }, "resetButton": "Sıfırla", "resetSuccess": "Varsayılan ayarlara sıfırlama işlemi başarıyla tamamlandı!", "exportButton": "Ayarları Dışa Aktar", diff --git a/src/i18n/message/common/calendar-resource.json b/src/i18n/message/common/calendar-resource.json index 2e2dc64ad..44081ec14 100644 --- a/src/i18n/message/common/calendar-resource.json +++ b/src/i18n/message/common/calendar-resource.json @@ -273,6 +273,7 @@ "everyday": "Her gün", "thisWeek": "Bu hafta", "thisMonth": "Bu ay", + "lastWeek": "Geçen hafta", "lastDays": "Son {n} gün", "tillYesterday": "Dün boyunca", "tillDaysAgo": "{n} gün önce", From 37fca1f35ff5c4478009e3ebfd178b0fab03fa51 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 1 Apr 2026 09:53:28 +0800 Subject: [PATCH 112/174] chore(psl): update PSL list by bot (#718) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/util/psl/rules.json | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/src/util/psl/rules.json b/src/util/psl/rules.json index 751dbeeeb..58110d7d9 100644 --- a/src/util/psl/rules.json +++ b/src/util/psl/rules.json @@ -296,7 +296,13 @@ }, "expo": { "c": { - "staging": 1 + "on": 1, + "staging": { + "c": { + "on": 1 + }, + "l": 1 + } }, "l": 1 }, @@ -1351,6 +1357,7 @@ "ezproxy": 1 } }, + "my": 1, "myspreadshop": 1, "transurl": { "c": { @@ -4560,7 +4567,6 @@ "paas": 1 } }, - "mazeplay": 1, "messwithdns": 1, "meteorapp": { "c": { @@ -5937,7 +5943,12 @@ "frontier": 1, "ftr": 1, "fujitsu": 1, - "fun": 1, + "fun": { + "c": { + "ms": 1 + }, + "l": 1 + }, "fund": 1, "furniture": 1, "futbol": 1, @@ -13037,7 +13048,12 @@ }, "shopping": 1, "shouji": 1, - "show": 1, + "show": { + "c": { + "ms": 1 + }, + "l": 1 + }, "si": { "c": { "f5": 1, @@ -14842,6 +14858,7 @@ "c": { "botdash": 1, "caffeine": 1, + "exe": 1, "telebit": { "c": { "*": 1 From 077c6319f460bf300878e6bd1f6a5f56a85f2750 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Tue, 7 Apr 2026 23:10:20 +0800 Subject: [PATCH 113/174] style: make sliding button of date range picker wider (#721) --- package.json | 18 +++++++++--------- .../common/filter/DateRangeFilterItem.tsx | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 44bef4be5..e8dcfe3f7 100644 --- a/package.json +++ b/package.json @@ -28,19 +28,19 @@ }, "license": "MIT", "devDependencies": { - "@crowdin/crowdin-api-client": "^1.55.0", + "@crowdin/crowdin-api-client": "^1.55.1", "@emotion/babel-plugin": "^11.13.5", "@emotion/css": "^11.13.5", - "@rsdoctor/rspack-plugin": "^1.5.6", - "@rspack/cli": "^1.7.10", - "@rspack/core": "^1.7.10", - "@swc/core": "^1.15.21", + "@rsdoctor/rspack-plugin": "^1.5.7", + "@rspack/cli": "^1.7.11", + "@rspack/core": "^1.7.11", + "@swc/core": "^1.15.24", "@swc/jest": "^0.2.39", - "@types/chrome": "0.1.38", + "@types/chrome": "0.1.39", "@types/decompress": "^4.2.7", "@types/firefox-webext-browser": "^143.0.0", "@types/jest": "^30.0.0", - "@types/node": "^25.5.0", + "@types/node": "^25.5.2", "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.1.1", @@ -57,7 +57,7 @@ "postcss-loader": "^8.2.1", "postcss-rtlcss": "^6.0.0", "puppeteer": "^24.40.0", - "ts-loader": "^9.5.4", + "ts-loader": "^9.5.7", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "6.0.2" @@ -69,7 +69,7 @@ "hash.js": "^1.1.7", "punycode": "^2.3.1", "typescript-guard": "^0.2.2", - "vue": "^3.5.31", + "vue": "^3.5.32", "vue-router": "^5.0.4" }, "engines": { diff --git a/src/pages/app/components/common/filter/DateRangeFilterItem.tsx b/src/pages/app/components/common/filter/DateRangeFilterItem.tsx index caa243477..992b22d01 100644 --- a/src/pages/app/components/common/filter/DateRangeFilterItem.tsx +++ b/src/pages/app/components/common/filter/DateRangeFilterItem.tsx @@ -36,7 +36,7 @@ type Props = ModelValue & { const ALL_PROPS: (keyof Props)[] = ["clearable", "disabledDate", "endPlaceholder", "modelValue", "onChange", "shortcuts", "startPlaceholder"] const ARROW_BTN_STYLE: StyleValue = { - padding: '8px 1px', + padding: '8px 5px', } const usePopperStyle = () => { From 022ab90bc5283df6df2a858803cb6ed4cfb25790 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 8 Apr 2026 09:37:53 +0800 Subject: [PATCH 114/174] i18n(download): download translations by bot (#722) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/option-resource.json | 1 + 1 file changed, 1 insertion(+) diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index d8555f51f..945c37590 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -1562,6 +1562,7 @@ "on": "Zawsze włączone", "off": "Zawsze wyłączone", "followBrowser": "Śledź przeglądarkę", + "permGrantConfirm": "Ta opcja wymaga odpowiednich uprawnień", "appearance": { "title": "Wygląd", "displayWhitelist": "{input} Wyświetlanie {whitelist} w {contextMenu}", From 539710b2a4c8df210a9744bc34f7f8538bcaf7bc Mon Sep 17 00:00:00 2001 From: sheepzh Date: Thu, 9 Apr 2026 22:30:04 +0800 Subject: [PATCH 115/174] feat: custom data collection for lower version FF --- package.json | 4 +- .../Option/categories/Notification/index.tsx | 45 +++++--------- .../categories/Notification/usePermission.ts | 57 ------------------ .../categories/Notification/usePermission.tsx | 58 +++++++++++++++++++ 4 files changed, 73 insertions(+), 91 deletions(-) delete mode 100644 src/pages/app/components/Option/categories/Notification/usePermission.ts create mode 100644 src/pages/app/components/Option/categories/Notification/usePermission.tsx diff --git a/package.json b/package.json index e8dcfe3f7..c4d86ccab 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "@rspack/core": "^1.7.11", "@swc/core": "^1.15.24", "@swc/jest": "^0.2.39", - "@types/chrome": "0.1.39", + "@types/chrome": "0.1.40", "@types/decompress": "^4.2.7", "@types/firefox-webext-browser": "^143.0.0", "@types/jest": "^30.0.0", @@ -53,7 +53,7 @@ "jest-environment-jsdom": "^30.3.0", "jest-junit": "^16.0.0", "jszip": "^3.10.1", - "postcss": "^8.5.8", + "postcss": "^8.5.9", "postcss-loader": "^8.2.1", "postcss-rtlcss": "^6.0.0", "puppeteer": "^24.40.0", diff --git a/src/pages/app/components/Option/categories/Notification/index.tsx b/src/pages/app/components/Option/categories/Notification/index.tsx index d414428c6..7ca81ba3e 100644 --- a/src/pages/app/components/Option/categories/Notification/index.tsx +++ b/src/pages/app/components/Option/categories/Notification/index.tsx @@ -1,7 +1,5 @@ import { t } from '@app/locale' -import { Check, Close } from '@element-plus/icons-vue' -import Flex from '@pages/components/Flex' -import { ElButton, ElDialog, ElInput, ElSelect, ElText, ElTimePicker } from 'element-plus' +import { ElInput, ElMessage, ElSelect, ElTimePicker } from 'element-plus' import { computed, defineComponent, StyleValue } from 'vue' import { OptionItem, OptionLines } from '../../components' import type { CategoryInstance } from '../types' @@ -28,19 +26,21 @@ const Notification = defineComponent((_, ctx) => { const { option, weekday, datetime, reset } = useNotification() const isNotNone = computed(() => option.notificationCycle !== 'none') - const { - confirmVisible, setConfirmVisible, granted, checkBeforeRequest, doRequest - } = usePermission() + const { checkRequest } = usePermission() - const onCycleChange = (val: timer.notification.Cycle) => { - const setVal = () => option.notificationCycle = val - val === 'none' - ? setVal() - // need to check the permission - : checkBeforeRequest(option.notificationMethod, setVal) + const onCycleChange = async (val: timer.notification.Cycle) => { + if (val === 'none') { + option.notificationCycle = val + return + } + const result = await checkRequest(option.notificationMethod) + result ? option.notificationCycle = val : ElMessage.info('Denied by user') } - const onMethodChange = (val: timer.notification.Method) => checkBeforeRequest(val, () => option.notificationMethod = val) + const onMethodChange = async (val: timer.notification.Method) => { + const result = await checkRequest(val) + result ? option.notificationMethod = val : ElMessage.info('Denied by user') + } ctx.expose({ reset, @@ -111,25 +111,6 @@ const Notification = defineComponent((_, ctx) => { )} {isNotNone.value &&
    } - - - - {t(msg => msg.option.permGrantConfirm)} - - - setConfirmVisible(false)} icon={Close}> - {t(msg => msg.button.cancel)} - - - {t(msg => msg.button.confirm)} - - - - ) }) diff --git a/src/pages/app/components/Option/categories/Notification/usePermission.ts b/src/pages/app/components/Option/categories/Notification/usePermission.ts deleted file mode 100644 index 935514e2a..000000000 --- a/src/pages/app/components/Option/categories/Notification/usePermission.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { hasPerm, requestPerm } from '@api/chrome/permission' -import { useState } from '@hooks/useState' -import { IS_FIREFOX } from '@util/constant/environment' -import { ElMessage } from 'element-plus' -import { onBeforeMount, reactive } from 'vue' - -const DATA_PERM: browser._manifest.OptionalDataCollectionPermission = 'technicalAndInteraction' -const BASE_PERM: chrome.runtime.ManifestPermission = 'notifications' - -const usePermission = () => { - const granted = reactive>({ - browser: false, - callback: false, - }) - - let toCheck: timer.notification.Method | undefined - let _onRequested: NoArgCallback | undefined - - const [confirmVisible, setConfirmVisible] = useState(false) - - onBeforeMount(async () => { - if (IS_FIREFOX) { - const perm = await browser?.permissions?.getAll() - granted.callback = !!perm?.data_collection?.includes?.(DATA_PERM) - } else { - granted.callback = true - } - granted.browser = await hasPerm(BASE_PERM) - }) - - const checkBeforeRequest = (method: timer.notification.Method, onRequested: NoArgCallback) => { - if (granted[method]) { - onRequested() - } else { - _onRequested = onRequested - toCheck = method - setConfirmVisible(true) - } - } - - const doRequest = async () => { - // Invalid method to check - if (!toCheck) return - - const result = toCheck === 'browser' - ? await requestPerm(BASE_PERM) - // Need't require data permission if not Firefox - : !IS_FIREFOX || await browser?.permissions?.request({ data_collection: [DATA_PERM] }) - result ? _onRequested?.() : ElMessage.info('Denied by user') - granted[toCheck] = result - setConfirmVisible(false) - } - - return { granted, confirmVisible, setConfirmVisible, checkBeforeRequest, doRequest } -} - -export default usePermission \ No newline at end of file diff --git a/src/pages/app/components/Option/categories/Notification/usePermission.tsx b/src/pages/app/components/Option/categories/Notification/usePermission.tsx new file mode 100644 index 000000000..60964e238 --- /dev/null +++ b/src/pages/app/components/Option/categories/Notification/usePermission.tsx @@ -0,0 +1,58 @@ +import { hasPerm, requestPerm } from '@api/chrome/permission' +import { BROWSER_MAJOR_VERSION, IS_FIREFOX } from '@util/constant/environment' +import { ElMessageBox } from 'element-plus' +import { onBeforeMount, reactive } from 'vue' + +const DATA_PERM: browser._manifest.OptionalDataCollectionPermission = 'technicalAndInteraction' +const BASE_PERM: chrome.runtime.ManifestPermission = 'notifications' + +const judgeDataPerm = async () => { + if (!IS_FIREFOX) return true + const perm = await browser?.permissions?.getAll() + return !!perm?.data_collection?.includes?.(DATA_PERM) +} + +const doRequestPerm = async (method: timer.notification.Method | undefined): Promise => { + if (!method) return true + if (method === 'browser') { + return await requestPerm(BASE_PERM) + } + + if (!IS_FIREFOX) return true + + if (BROWSER_MAJOR_VERSION && BROWSER_MAJOR_VERSION >= 140) { + return await browser.permissions.request({ data_collection: [DATA_PERM] }) + } + + // Must use message box if firefox version <140 + return new Promise(resolve => ElMessageBox.confirm( + "This option will transfer your local data to the callback endpoint you specify. Do you agree?", + { + confirmButtonText: "Yes, I agree", + cancelButtonText: "No, I don't agree", + }, + ).then(() => resolve(true)).catch(() => resolve(false))) +} + +const usePermission = () => { + const granted = reactive>({ + browser: false, + callback: false, + }) + + onBeforeMount(async () => { + granted.callback = await judgeDataPerm() + granted.browser = await hasPerm(BASE_PERM) + }) + + const checkRequest = async (method: timer.notification.Method) => { + // Invalid method to check + if (granted[method]) return true + + return granted[method] = await doRequestPerm(method) + } + + return { checkRequest } +} + +export default usePermission \ No newline at end of file From 4f1e1faf1b6f1c0f86295a68995826d512fed838 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Thu, 9 Apr 2026 22:31:18 +0800 Subject: [PATCH 116/174] v4.1.6 --- CHANGELOG.md | 5 +++++ package.json | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9baab8bf5..5a23429da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. + +## [4.1.6] - 2026-04-09 + +- Optimized some UI + ## [4.1.5] - 2026-03-31 - Fixed some issues diff --git a/package.json b/package.json index c4d86ccab..74709de64 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "4.1.5", + "version": "4.1.6", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { @@ -75,4 +75,4 @@ "engines": { "node": ">=22" } -} \ No newline at end of file +} From 6a7bb016b6eb3939b212f40e4f3053a057e1e674 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 14 Apr 2026 10:01:29 +0800 Subject: [PATCH 117/174] i18n(download): download translations by bot (#728) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/about-resource.json | 6 ++--- src/i18n/message/app/dashboard-resource.json | 4 +-- .../message/app/data-manage-resource.json | 4 +-- src/i18n/message/app/limit-resource.json | 6 ++--- src/i18n/message/app/menu-resource.json | 2 +- src/i18n/message/app/merge-rule-resource.json | 4 +-- src/i18n/message/app/option-resource.json | 27 ++++++++++++++++--- .../message/common/calendar-resource.json | 1 + src/i18n/message/common/item-resource.json | 3 ++- src/i18n/message/cs/modal-resource.json | 2 +- src/i18n/message/popup/header-resource.json | 1 + 11 files changed, 41 insertions(+), 19 deletions(-) diff --git a/src/i18n/message/app/about-resource.json b/src/i18n/message/app/about-resource.json index 953962ada..9d2e58c7f 100644 --- a/src/i18n/message/app/about-resource.json +++ b/src/i18n/message/app/about-resource.json @@ -130,9 +130,9 @@ "support": "Soutien" }, "text": { - "greet": "Vous aimez cette extension?", - "rate": "Notez-la 5 étoiles pour nous soutenir!", - "feedback": "Vos retours et suggestions sont les bienvenus!" + "greet": "Vous aimez cette extension ?", + "rate": "Notez-la 5 étoiles pour nous soutenir !", + "feedback": "Vos retours et suggestions sont les bienvenus !" } }, "es": { diff --git a/src/i18n/message/app/dashboard-resource.json b/src/i18n/message/app/dashboard-resource.json index a88b3a82c..00638668e 100644 --- a/src/i18n/message/app/dashboard-resource.json +++ b/src/i18n/message/app/dashboard-resource.json @@ -203,8 +203,8 @@ "indicator": { "installedDays": "Installé depuis {number} jours", "visitCount": "{site} sites visités {visit} fois", - "browsingTime": "Parcouru pendant {minute} minutes", - "mostUse": "Navigation favorite entre {start} et {end}" + "browsingTime": "Visité pendant {minute} minutes", + "mostUse": "Navigation la plus fréquente entre {start} et {end} heure" }, "monthOnMonth": { "title": "Tendance mensuelle du temps de navigation" diff --git a/src/i18n/message/app/data-manage-resource.json b/src/i18n/message/app/data-manage-resource.json index 287876283..c0fce3151 100644 --- a/src/i18n/message/app/data-manage-resource.json +++ b/src/i18n/message/app/data-manage-resource.json @@ -238,10 +238,10 @@ "totalMemoryAlert": "Le navigateur fournit {size}Mo pour stocker des données locales pour chaque extension", "totalMemoryAlert1": "Impossible de déterminer la mémoire maximale disponible autorisée par le navigateur", "usedMemoryAlert": "{size}Mo sont actuellement utilisés", - "idbAlert": "Déplacer les données de suivi vers \"IndexedDB\" pour réduire l'utilisation du stockage", + "idbAlert": "Déplacer les données de suivi vers IndexedDB pour réduire l'utilisation du stockage", "operationAlert": "Vous pouvez supprimer ces données sans importance afin de réduire l'utilisation de la mémoire", "filterItems": "Filtrer les données", - "filterFocus": "Le temps de navigation de la journée est compris entre {start} secondes et {end} secondes", + "filterFocus": "Le temps de navigation de la journée est compris entre {start} et {end} secondes", "filterTime": "Le nombre de visites du jour se situe entre {start} et {end}", "filterDate": "Enregistré entre {picker}", "importOther": { diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index a695d74cf..8b09c771c 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -417,7 +417,7 @@ }, "fr": { "filterDisabled": "Activé uniquement", - "wildcardTip": "Vous pouvez utiliser des wildcards pour correspondre à des sous-domaines ou sous-pages, et utiliser le \"+\" comme préfixe pour exclure des sous-pages !", + "wildcardTip": "Vous pouvez utiliser des caractères génériques pour cibler des sous-domaines ou des sous-pages, et le préfixe \"+\" pour en exclure !", "emptyTips": "Cliquez ici pour créer une règle !", "item": { "name": "Nom de règle", @@ -432,7 +432,7 @@ "enabled": "Activé", "locked": "Verrouillé", "effectiveDay": "Effectif le", - "delayAllowed": "Plus de 5 minutes", + "delayAllowed": "Retardable", "delayAllowedInfo": "Si le délai est écoulé, autorisez un délai temporaire de 5 minutes", "visits": "visites", "or": "ou", @@ -449,7 +449,7 @@ "message": { "noUrl": "Aucune URL de restriction configurée", "noRule": "Aucune règle remplie", - "deleteConfirm": "Voulez-vous supprimer la règle [{name}]?", + "deleteConfirm": "Voulez-vous supprimer la règle [{name}] ?", "lockConfirm": "Si verrouillé, toutes les opérations nécessiteront une vérification, même si la règle n'est pas activée.", "inputTestUrl": "Veuillez entrer le lien URL à tester en premier", "noRuleMatched": "L'URL ne correspond à aucune règle", diff --git a/src/i18n/message/app/menu-resource.json b/src/i18n/message/app/menu-resource.json index f509684bc..c33b16440 100644 --- a/src/i18n/message/app/menu-resource.json +++ b/src/i18n/message/app/menu-resource.json @@ -146,7 +146,7 @@ "limit": "Limite de temps", "additional": "Fonctionnalités supplémentaires", "siteManage": "Gestion des sites", - "whitelist": "Whitelist", + "whitelist": "Liste blanche", "mergeRule": "Fusionner les règles du site", "other": "Autres fonctionnalités", "helpUs": "Aider à la traduction", diff --git a/src/i18n/message/app/merge-rule-resource.json b/src/i18n/message/app/merge-rule-resource.json index 109ffac37..20fa7e4df 100644 --- a/src/i18n/message/app/merge-rule-resource.json +++ b/src/i18n/message/app/merge-rule-resource.json @@ -160,11 +160,11 @@ "addConfirmMsg": "Les règles de fusion personnalisées seront définies pour {origin}", "infoAlertTitle": "Vous pouvez définir les règles de fusion lorsque vous comptez des sites sur cette page", "infoAlert0": "Cliquez sur le bouton [Nouveau], les boîtes de saisie du site source et le site de fusion sera affiché, remplissez et enregistrez la règle", - "infoAlert1": "Le site d'origine peut être rempli avec un site spécifique ou une expression régulière, telle que www.baidu.com, *.baidu.com, *.google.com.*, pour déterminer quels sites correspondront à cette règle lors de la fusion.", + "infoAlert1": "Le site d'origine peut être rempli avec un site spécifique ou une expression régulière, telle que www.baidu.com, *.baidu.com, *.google.com.*, pour déterminer quels sites correspondront à cette règle lors de la fusion", "infoAlert2": "Le site fusionné peut être rempli avec un site spécifique, un numéro ou vide", "infoAlert3": "Un nombre signifie le niveau du site fusionné. Par exemple, il existe une règle '*.*.edu.cn >>> 3', alors 'www.hust.edu.cn' sera fusionné avec 'hust.edu.cn'", "infoAlert4": "Vide signifie que le site d'origine ne sera pas fusionné", - "infoAlert5": "Si aucune règle ne correspond, le niveau par défaut sera celui d'avant {psl}.", + "infoAlert5": "Si aucune règle ne correspond, le niveau par défaut sera celui d'avant {psl}", "tagResult": { "blank": "Non Fusionner", "level": "Garder le niveau {level}" diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 945c37590..8687283f9 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -1041,11 +1041,12 @@ "on": "Toujours activé", "off": "Toujours éteint", "followBrowser": "Suivre le navigateur", + "permGrantConfirm": "Cette fonctionnalité nécessite des autorisations appropriées", "appearance": { "title": "Apparence", "displayWhitelist": "{input} S'il faut afficher là {whitelist} dans {contextMenu}", - "whitelistItem": "whitelist related shortcuts", - "contextMenu": "le menu contextuel.", + "whitelistItem": "raccourcis liés à la liste blanche", + "contextMenu": "le menu contextuel", "displayBadgeText": "{input} S'il faut afficher là {timeInfo} dans {icon}", "badgeBgColor": "La couleur de fond du texte sur l'icône {input}", "icon": "l'icône de l'extension", @@ -1076,9 +1077,11 @@ "localFilesInfo": "Prend en charge les fichiers de types tels que PDF, image, txt et json.", "countTabGroup": "{input} Suivre le temps des groupes d'onglets {info}", "tabGroupInfo": "Lorsque vous supprimez un groupe d'onglets, les données seront également supprimées.", - "fileAccessDisabled": "L'accès aux URL des fichiers n'est actuellement pas autorisé. Veuillez d'abord l'activer dans la page de gestion.", + "fileAccessDisabled": "L'accès aux URL des fichiers n'est actuellement pas autorisé. Veuillez d'abord l'activer dans la page de gestion", "weekStart": "Le premier jour de chaque semaine {input}", - "weekStartAsNormal": "Comme d'habitude" + "weekStartAsNormal": "Comme d'habitude", + "storage": "Stocker les données de suivi dans {input}", + "storageConfirm": "Voulez-vous changer le type de stockage en {type} ?" }, "limit": { "prompt": "Invite affichée en cas de restriction {input}", @@ -1147,6 +1150,22 @@ "title": "Accessibilité", "chartDecal": "{input} S'il faut afficher l'autocollant de la carte" }, + "notification": { + "title": "Notification", + "cycle": { + "label": "Cycle des notifications {input}", + "daily": "Quotidien", + "weekly": "Hebdomadaire" + }, + "method": { + "label": "Méthode de notification {input}", + "browser": "Navigateur Web", + "callback": { + "label": "HTTP Callback", + "url": "Callback URL {input}" + } + } + }, "resetButton": "Réinitialiser", "resetSuccess": "Remise à zéro avec succès !", "exportButton": "Exporter les paramètres", diff --git a/src/i18n/message/common/calendar-resource.json b/src/i18n/message/common/calendar-resource.json index 44081ec14..e5e796307 100644 --- a/src/i18n/message/common/calendar-resource.json +++ b/src/i18n/message/common/calendar-resource.json @@ -204,6 +204,7 @@ "everyday": "Quotidien", "thisWeek": "Cette semaine", "thisMonth": "Ce mois-ci", + "lastWeek": "Dernière semaine", "lastDays": "{n} derniers jours", "tillYesterday": "Jusqu'à hier", "tillDaysAgo": "Jusqu'à {n} jours", diff --git a/src/i18n/message/common/item-resource.json b/src/i18n/message/common/item-resource.json index 2de440d0c..a2d6c47ce 100644 --- a/src/i18n/message/common/item-resource.json +++ b/src/i18n/message/common/item-resource.json @@ -150,10 +150,11 @@ "run": "Durée d'exécution", "time": "Visites", "operation": { - "add2Whitelist": "Whitelist", + "add2Whitelist": "Liste blanche", "analysis": "Analyser", "deleteConfirmMsgAll": "Tous les enregistrements de {url} seront supprimés", "deleteConfirmMsgRange": "Les enregistrements de {url} entre {start} et {end} seront supprimés", + "deleteConfirmMsg": "L'enregistrement des visites pour [{url}] du [{date}] seront supprimés", "exportWholeData": "Exporter", "importWholeData": "Importer", "importOtherData": "Depuis d'autres extensions" diff --git a/src/i18n/message/cs/modal-resource.json b/src/i18n/message/cs/modal-resource.json index a5bbdfd8c..a502814a1 100644 --- a/src/i18n/message/cs/modal-resource.json +++ b/src/i18n/message/cs/modal-resource.json @@ -36,7 +36,7 @@ "ruleDetail": "Подробиці правила" }, "fr": { - "defaultPrompt": "Cette page a été bloquée!", + "defaultPrompt": "Cette page a été bloquée !", "more5Minutes": "5 minutes supplémentaires", "browsingTime": "Temps de navigation", "ruleDetail": "Détails des règles" diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index 8878972bd..d3fd43f0a 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -37,6 +37,7 @@ "donutChart": "Als Donut-Diagramme angezeigt" }, "fr": { + "rating": "Donner une note", "showSiteName": "Afficher le nom du site", "showTopN": "Afficher les {n} premiers", "donutChart": "Affichés sous forme de graphiques en anneau" From a849b9a035caadb804b3e1d044b54c9576385af8 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 15 Apr 2026 16:23:21 +0800 Subject: [PATCH 118/174] i18n(download): download translations by bot (#731) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/option-resource.json | 14 +++++++------- src/i18n/message/app/time-format-resource.json | 2 +- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 8687283f9..a1c67caf2 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -1044,10 +1044,10 @@ "permGrantConfirm": "Cette fonctionnalité nécessite des autorisations appropriées", "appearance": { "title": "Apparence", - "displayWhitelist": "{input} S'il faut afficher là {whitelist} dans {contextMenu}", + "displayWhitelist": "{input} S'il faut afficher les {whitelist} dans {contextMenu}", "whitelistItem": "raccourcis liés à la liste blanche", "contextMenu": "le menu contextuel", - "displayBadgeText": "{input} S'il faut afficher là {timeInfo} dans {icon}", + "displayBadgeText": "{input} S'il faut afficher {timeInfo} dans {icon}", "badgeBgColor": "La couleur de fond du texte sur l'icône {input}", "icon": "l'icône de l'extension", "badgeTextContent": "le temps de navigation du site actuel", @@ -1057,7 +1057,7 @@ "reloadButton": "Redémarrer" }, "printInConsole": { - "label": "{input} S'il faut imprimer {info} dans là {console}", + "label": "{input} S'il faut afficher {info} dans la {console}", "console": "console", "info": "le nombre de visites du site actuel aujourd'hui" }, @@ -1072,7 +1072,7 @@ "title": "Statistiques", "autoPauseTrack": "{input} Pause de suivi si aucune activité n'a détecté {info} pour {maxTime}", "noActivityInfo": "La souris et le clavier sont inactifs, pas en plein écran, et il n'y a pas de son joué", - "countLocalFiles": "{input} S'il faut compter le temps jusqu'à {localFileTime} {info} dans le navigateur", + "countLocalFiles": "{input} S'il faut compter le temps passé à {localFileTime} {info} dans le navigateur", "localFileTime": "lire un fichier local", "localFilesInfo": "Prend en charge les fichiers de types tels que PDF, image, txt et json.", "countTabGroup": "{input} Suivre le temps des groupes d'onglets {info}", @@ -1087,7 +1087,7 @@ "prompt": "Invite affichée en cas de restriction {input}", "reminder": "{input} Rappel {minInput} minutes avant la fin du temps", "level": { - "label": "Comment déverrouiller en étant restreint {input}", + "label": "Comment déverrouiller en étant limité {input}", "nothing": "Autoriser le déverrouillage direct sur la page d'administration", "password": "Vous devez entrer le mot de passe pour déverrouiller", "verification": "Vous devez entrer le code de vérification pour déverrouiller", @@ -1107,7 +1107,7 @@ }, "backup": { "title": "Sauvegarde des données", - "type": "Type distant {input}", + "type": "Choix de la sauvegarde {input}", "client": "Nom du client {input}", "meta": { "gist": { @@ -1148,7 +1148,7 @@ }, "accessibility": { "title": "Accessibilité", - "chartDecal": "{input} S'il faut afficher l'autocollant de la carte" + "chartDecal": "{input} afficher les motifs de la carte" }, "notification": { "title": "Notification", diff --git a/src/i18n/message/app/time-format-resource.json b/src/i18n/message/app/time-format-resource.json index 477ff4514..1057a1c37 100644 --- a/src/i18n/message/app/time-format-resource.json +++ b/src/i18n/message/app/time-format-resource.json @@ -48,7 +48,7 @@ "second": "Anzeige in Sekunden" }, "fr": { - "default": "Format d'heure par défaut", + "default": "Format de temps par défaut", "hour": "Afficher en heures", "minute": "Afficher en minutes", "second": "Afficher en secondes" From 00c76e97855889cd68924fddcb52cdbdf6801722 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Wed, 15 Apr 2026 23:30:10 +0800 Subject: [PATCH 119/174] fix: the first day of week (#730) --- src/service/components/week-helper.ts | 75 +++++++++++++++++++-------- 1 file changed, 54 insertions(+), 21 deletions(-) diff --git a/src/service/components/week-helper.ts b/src/service/components/week-helper.ts index e42567566..c4ec15894 100644 --- a/src/service/components/week-helper.ts +++ b/src/service/components/week-helper.ts @@ -2,15 +2,55 @@ import optionDatabase from "@db/option-database" import { locale } from "@i18n" import { formatTimeYMD, getWeekDay, MILL_PER_DAY } from "@util/time" -function getRealWeekStart(weekStart: timer.option.WeekStartOption | undefined, locale: timer.Locale): number { - weekStart = weekStart ?? 'default' - if (weekStart === 'default') { - return locale === 'zh_CN' ? 0 : 6 - } else { - return weekStart - 1 +function getDefaultWeekStart(localeOpt: timer.option.LocaleOption): number { + const parts = navigator.language.split(/[-_]/) + const region = parts[parts.length - 1]?.toLowerCase() ?? '' + switch (locale) { + // Only Venezuela uses Sunday as the first day of week + case 'es': return 've' === region ? 6 : 0 + // Lebanon, Morocco and Tunisia use Monday as the first day of week + case 'ar': return ['la', 'ma', 'tn'].includes(region) ? 0 : 6 + // Other countries or fallbacked to English use Monday as the first day of week + case 'en': + if (['us', 'ca', 'in', 'za', 'jm', 'ph'].includes(region)) { + // US, Canaca, India, South Africa, Jamaica, Philippines use Sunday as the first day of week + return 6 + } else if (['gb', 'au', 'nz'].includes(region)) { + // UK, Australia and New Zealand use Monday as the first day of week + return 0 + } else if (localeOpt === 'en') { + // If locale option is set to English by user, use Sunday as the first day of week + return 6 + } else { + // FALLBACK + return 0 + } + case 'ja': + case 'pt_PT': + // Taiwan, Hong Kong and Macau use Sunday as the first day of week + case 'zh_TW': return 6 + case 'zh_CN': + case 'uk': + case 'de': + case 'fr': + case 'ru': + case 'tr': + case 'pl': + case 'it': return 0 } } +/** + * Get the real week start according to the option + * + * @param weekStart option value + * @returns 0-6 + */ +function getRealWeekStart(option: timer.option.AllOption): number { + const { weekStart = 'default', locale: localeOpt } = option + return weekStart === 'default' ? getDefaultWeekStart(localeOpt) : weekStart - 1 +} + /** * Get the start time and end time of this week * @param now the specific time @@ -36,14 +76,14 @@ function getWeekTime(now: Date, weekStart: number): [Date, Date] { } class WeekHelper { - private weekStart: timer.option.WeekStartOption | undefined - private initialized: boolean = false + private option: timer.option.AllOption | undefined - private async init(): Promise { + private async checkInit(): Promise { + if (this.option) return this.option const option = await optionDatabase.getOption() - this.weekStart = option?.weekStart - optionDatabase.addOptionChangeListener(val => this.weekStart = val?.weekStart) - this.initialized = true + optionDatabase.addOptionChangeListener(opt => this.option = opt) + this.option = option + return option } async getWeekDateRange(now: Date): Promise<[startDate: string, endDateOrToday: string]> { @@ -56,21 +96,14 @@ class WeekHelper { return getWeekTime(typeof now === 'number' ? new Date(now) : now, weekStart) } - private async getWeekStartOpt(): Promise { - if (!this.initialized) { - await this.init() - } - return this.weekStart - } - /** * Week start * * @returns 0-6 */ async getRealWeekStart(): Promise { - const weekStart = await this.getWeekStartOpt() - return getRealWeekStart(weekStart, locale) + const option = await this.checkInit() + return getRealWeekStart(option) } } From 2de0ebba41c3e0338f9869c18d7130614cd7b757 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 18 Apr 2026 23:05:31 +0800 Subject: [PATCH 120/174] i18n(download): download translations by bot (#734) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/option-resource.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index a1c67caf2..a7145dfa5 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -1251,7 +1251,7 @@ "client": "Наименование клиента {input}", "meta": { "gist": { - "authInfo": "Требуется один токен с как минимум разрешением на создание фрагментов кода (gist permission)" + "authInfo": "Необходим хотя бы один Gist токен с разрешением" }, "obsidian_local_rest_api": { "endpointInfo": "Доступен только HTTP, так как CORS не может быть настроен для страниц расширений" From 043b7666b44274f8520d154d2c25dc9eb9a67002 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Sun, 19 Apr 2026 14:20:12 +0800 Subject: [PATCH 121/174] feat: remove decimal part of badge if over 10 hours --- src/background/badge-manager.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/background/badge-manager.ts b/src/background/badge-manager.ts index 8175027c1..8dce39574 100644 --- a/src/background/badge-manager.ts +++ b/src/background/badge-manager.ts @@ -36,7 +36,8 @@ function mill2Str(milliseconds: number) { // no more than 1 hour return `${Math.round(milliseconds / MILL_PER_MINUTE)}m` } else { - return `${(milliseconds / MILL_PER_HOUR).toFixed(1)}h` + const hours = milliseconds / MILL_PER_HOUR + return hours < 10 ? `${hours.toFixed(1)}h` : `${Math.round(hours)}h` } } From f9ef112f4780c8f6fc254c27df1670240a84ff3a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 22 Apr 2026 13:05:17 +0800 Subject: [PATCH 122/174] i18n(download): download translations by bot (#736) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- .../message/app/data-manage-resource.json | 1 + src/i18n/message/app/limit-resource.json | 1 + src/i18n/message/app/option-resource.json | 38 +++++++++++++++++-- src/i18n/message/common/button-resource.json | 4 +- .../message/common/calendar-resource.json | 1 + src/i18n/message/popup/header-resource.json | 4 +- 6 files changed, 44 insertions(+), 5 deletions(-) diff --git a/src/i18n/message/app/data-manage-resource.json b/src/i18n/message/app/data-manage-resource.json index c0fce3151..39f4828a7 100644 --- a/src/i18n/message/app/data-manage-resource.json +++ b/src/i18n/message/app/data-manage-resource.json @@ -33,6 +33,7 @@ "totalMemoryAlert": "瀏覽器為每個擴充功能提供 {size}MB 本地儲存空間", "totalMemoryAlert1": "無法取得瀏覽器允許的最大儲存空間", "usedMemoryAlert": "目前已使用 {size}MB", + "idbAlert": "將追蹤資料移到IndexedDB以釋放儲存空間", "operationAlert": "您可以刪除不重要的資料來釋放儲存空間", "filterItems": "資料篩選", "filterFocus": "當日瀏覽時間:{start}~{end} 秒", diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index 8b09c771c..b4c267cd7 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -55,6 +55,7 @@ "zh_TW": { "filterDisabled": "過濾無效規則", "wildcardTip": "你可以使用萬用字元來匹配子網域或子頁面,使用\"+\"作為前置詞來排除子頁面!", + "emptyTips": "點擊此處新增規則!", "item": { "name": "規則名稱", "condition": "限制網址", diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index a7145dfa5..ed0654141 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -171,11 +171,13 @@ "label": "深色模式 {input}", "timed": "定時開啟" }, - "animationDuration": "圖表動畫持續時間 {input}" + "animationDuration": "圖表動畫持續時間 {input}", + "sidePanel": "{input}是否開啟側邊面板" }, "tracking": { "title": "統計設定", "autoPauseTrack": "{input} 若 {maxTime} 內無活動 {info} 則暫停追蹤", + "noActivityInfo": "滑鼠跟鍵盤非使用中,未處於全螢幕模式,且沒有聲音撥放中", "countLocalFiles": "{input} 是否統計瀏覽器 {localFileTime} {info}", "localFileTime": "讀取本機檔案時間", "localFilesInfo": "支援 PDF、圖片、txt 及 json 等格式", @@ -183,7 +185,9 @@ "tabGroupInfo": "刪除分頁群組時,相關的時間追蹤數據也會一併清除。", "fileAccessDisabled": "目前不允許存取檔案 URL,請至管理頁面啟用", "weekStart": "每週起始日 {input}", - "weekStartAsNormal": "依慣例" + "weekStartAsNormal": "依慣例", + "storage": "將追蹤資料儲存至{input}", + "storageConfirm": "您想將儲存格式改成{type}嗎?" }, "limit": { "prompt": "限制時顯示提示文字 {input}", @@ -252,6 +256,22 @@ "title": "無障礙設定", "chartDecal": "{input} 是否顯示圖表裝飾" }, + "notification": { + "title": "通知", + "cycle": { + "label": "通知頻率{input}", + "daily": "每日", + "weekly": "每週" + }, + "method": { + "label": "通知方式 {input}", + "browser": "瀏覽器", + "callback": { + "label": "HTTP 回應", + "url": "回應 URL {input}" + } + } + }, "resetButton": "重設", "resetSuccess": "已恢復預設值!", "exportButton": "匯出設定", @@ -446,7 +466,8 @@ "localFilesInfo": "PDF、画像、txt、jsonを含む", "fileAccessDisabled": "ファイル URL へのアクセスは現在許可されていません。まず管理ページで有効にしてください。", "weekStart": "週の最初の日 {input}", - "weekStartAsNormal": "いつものように" + "weekStartAsNormal": "いつものように", + "storageConfirm": "ストレージタイプを {type}に変更しますか?" }, "limit": { "prompt": "制限時に表示されるプロンプト {input}", @@ -515,6 +536,17 @@ "title": "ユーザー補助機能", "chartDecal": "{input} チャートデカールを表示するかどうか" }, + "notification": { + "title": "通知", + "cycle": { + "daily": "毎日", + "weekly": "毎週" + }, + "method": { + "label": "通知方法{input}", + "browser": "ブラウザ" + } + }, "resetButton": "リセット", "resetSuccess": "デフォルトに正常にリセット", "exportButton": "設定をエクスポート", diff --git a/src/i18n/message/common/button-resource.json b/src/i18n/message/common/button-resource.json index cb31c2f4c..da0aad6ad 100644 --- a/src/i18n/message/common/button-resource.json +++ b/src/i18n/message/common/button-resource.json @@ -67,7 +67,9 @@ "clear": "清除", "enable": "啟用", "batchEnable": "批次啟用", - "batchDisable": "批次停用" + "batchDisable": "批量停用", + "collapse": "收合", + "expand": "展開" }, "ja": { "create": "新規", diff --git a/src/i18n/message/common/calendar-resource.json b/src/i18n/message/common/calendar-resource.json index e5e796307..d16c23343 100644 --- a/src/i18n/message/common/calendar-resource.json +++ b/src/i18n/message/common/calendar-resource.json @@ -40,6 +40,7 @@ "everyday": "每天", "thisWeek": "本週", "thisMonth": "本月", + "lastWeek": "上週", "lastDays": "最近{n}天", "tillYesterday": "截至昨天", "tillDaysAgo": "截至{n}天前", diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index d3fd43f0a..be5d3e717 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -12,8 +12,10 @@ "donutChart": "以圆环图显示" }, "zh_TW": { + "rating": "送出評分", "showSiteName": "顯示網站名稱", - "showTopN": "顯示前{n}个" + "showTopN": "顯示前{n}个", + "donutChart": "以圓餅圖顯示" }, "ja": { "showSiteName": "サイト名を表示", From 0274e95554dd116aeda663f7c63ab7d91ce8df72 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Thu, 23 Apr 2026 21:38:07 +0800 Subject: [PATCH 123/174] chore: dropped support for Firefox versions below 140 --- examples/README.md | 2 +- examples/notification/README.md | 157 ++++++++++++++++++ package.json | 26 +-- src/manifest-firefox.ts | 1 + .../Option/categories/Notification/index.tsx | 11 +- .../categories/Notification/usePermission.tsx | 16 +- 6 files changed, 184 insertions(+), 29 deletions(-) create mode 100644 examples/notification/README.md diff --git a/examples/README.md b/examples/README.md index 7066cce1f..df92792ee 100644 --- a/examples/README.md +++ b/examples/README.md @@ -12,7 +12,7 @@ npm install ## Available scripts - `npm run start:gist` - start Gist mock server -- `npm run start:notification` - start notification demo server +- `npm run start:notification` - start notification demo server ([notification/README.md](notification/README.md)) ## Environment variables diff --git a/examples/notification/README.md b/examples/notification/README.md new file mode 100644 index 000000000..3c13280ed --- /dev/null +++ b/examples/notification/README.md @@ -0,0 +1,157 @@ +# HTTP Notification Callback + +The extension can push usage data to your server via an HTTP callback. This page describes: + +1. [Protocol contract](#protocol-contract) — what your HTTP endpoint must satisfy. +2. [Request body (JSON)](#request-body) — every field the extension sends. +3. [Signature verification](#signature-verification) — how to verify `Tt4b-Sign`. +4. [Full example](#full-example) — a complete request you can use for testing. +5. [Local demo server](#local-demo-server) — run a sample receiver locally. + +--- + +## Protocol contract + +Your endpoint **must** satisfy every requirement below. The extension will only ever call the URL the user saved in settings. + +| # | Requirement | +|---|-------------| +| 1 | Accept **`POST`** requests. The extension never uses any other HTTP method for a real callback. | +| 2 | The URL scheme must be **`http://`** or **`https://`**. The extension will not use other URL schemes. | +| 3 | The request carries **`Content-Type: application/json`** with a **UTF-8** JSON body whose structure is defined in [Request body](#request-body). | +| 4 | If the user configured an **auth token**, the request includes a **`Tt4b-Sign`** header (see [Signature verification](#signature-verification)). If no token is configured the header is **omitted** — your server must not require it in that case. | +| 5 | Return a **2xx** status (e.g. `200`) to signal success. The extension only treats the delivery as successful for **2xx** responses. The **response body is not used**. | +| 6 | Return a meaningful **4xx / 5xx** status to signal failure. The user may see the **HTTP status text** of your response, so use a descriptive reason phrase (e.g. `401 Unauthorized`, `400 Bad Request`). | + +**Out of scope:** what you do with the data (store, forward, log, etc.) is entirely up to you — as long as you return **2xx** when you have successfully accepted the payload. + +--- + +## Request body + +The top-level JSON object contains four keys: + +| Field | Type | Description | +|-------|------|-------------| +| `meta` | `object` | Client metadata — see [`meta`](#meta). | +| `cycle` | `string` | `"daily"` or `"weekly"` — the reporting period the user chose. | +| `summary` | `object` | Aggregated totals for the period — see [`summary`](#summary). | +| `row` | `array` | Per-site, per-day breakdown — see [`row`](#row-items). | + +### `meta` + +| Field | Type | Example | Description | +|-------|------|---------|-------------| +| `locale` | `string` | `"en"` | The app's UI locale at the time the payload was built. | +| `version` | `string` | `"1.0.0"` | Extension version string. | +| `ts` | `number` | `1710000000000` | When the payload was built: milliseconds since the Unix epoch. | + +### `summary` + +| Field | Type | Example | Description | +|-------|------|---------|-------------| +| `focus` | `number` | `3600` | Total **focus time in milliseconds** across all sites in the period. Focus time is the accumulated duration a tab was in the foreground and the user was actively interacting. | +| `visit` | `number` | `42` | Total page-visit count across all sites in the period. | +| `siteCount` | `number` | `3` | Number of distinct hosts present in `row`. | +| `dateStart` | `string` | `"2025-01-15"` | First date of the period (`YYYY-MM-DD`). | +| `dateEnd` | `string` | `"2025-01-15"` | Last date of the period (`YYYY-MM-DD`). For a daily cycle `dateStart === dateEnd`. | + +### `row` items + +Each element in the `row` array represents **one host on one day**: + +| Field | Type | Required | Example | Description | +|-------|------|----------|---------|-------------| +| `host` | `string` | yes | `"example.com"` | The site's hostname (no scheme, no path). | +| `date` | `string` | yes | `"2025-01-15"` | The calendar date (`YYYY-MM-DD`). | +| `focus` | `number` | yes | `1200` | Focus time **in milliseconds** for this host on this date. | +| `time` | `number` | yes | `10` | **Visit count** for this host on that calendar day (how many page visits were recorded; **not** a length of time). | +| `run` | `number` | no | `5000` | **Run time** in milliseconds for this host on this date (only when run-time tracking is enabled for the site; not the same as focus time). | + +> **Consistency check:** `summary.focus` equals the sum of all `row[].focus` values, and `summary.visit` equals the sum of all `row[].time` values. + +--- + +## Signature verification + +| Auth token configured? | `Tt4b-Sign` header | +|-------------------------|--------------------| +| No (empty) | **Not sent.** Do not require or check it. | +| Yes | `Tt4b-Sign: <64 lowercase hex characters>` | + +**Algorithm:** HMAC-SHA256. + +- **Key** — the auth-token string (UTF-8). +- **Data** — an **empty string** (`""`). + +> ⚠️ The HMAC is computed over an **empty** input, **not** over the request body. This is by design — the signature proves the caller knows the shared token, not that the body is untampered. If you need body-integrity verification, hash the body separately after validating the token. + +```js +import { createHmac } from 'node:crypto'; + +function verifySign(header, token) { + const expected = createHmac('sha256', token).update('').digest('hex'); + return expected === header; +} +``` + +--- + +## Full example + +**Request:** + +``` +POST /callback HTTP/1.1 +Content-Type: application/json +Tt4b-Sign: <64 hex chars — only if token is set> +``` + +```json +{ + "meta": { + "locale": "en", + "version": "1.0.0", + "ts": 1710000000000 + }, + "cycle": "daily", + "summary": { + "focus": 3600, + "visit": 42, + "siteCount": 3, + "dateStart": "2025-01-15", + "dateEnd": "2025-01-15" + }, + "row": [ + { "host": "example.com", "date": "2025-01-15", "focus": 1200, "time": 10 }, + { "host": "github.com", "date": "2025-01-15", "focus": 1800, "time": 25 }, + { "host": "docs.test.io", "date": "2025-01-15", "focus": 600, "time": 7 } + ] +} +``` + +**Expected response:** `200 OK` (body is ignored). + +> The three rows sum to `focus=3600` and `time=42`, matching `summary.focus` and `summary.visit`. + +--- + +## Local demo server + +From the `examples/` package ([setup](../README.md)): + +```bash +# Optional: set AUTH to the same value as the extension's auth token +export AUTH="my-secret-token" +npm run start:notification +``` + +This starts `demo-server.ts` on **port 3000**. + +| Method | Demo behavior | +|--------|---------------| +| `POST` | Parses JSON, optionally verifies `Tt4b-Sign`, responds `200` / `401` / `400`. | +| `HEAD` | `200` (this demo only; the extension’s callback does not use `HEAD`.) | +| Any other | `405 Method Not Allowed`. **The real callback always uses `POST`.** | + +> The demo only checks `meta` for the signature; it does **not** validate the full body shape. A production service should follow the [protocol contract](#protocol-contract) and field definitions above. \ No newline at end of file diff --git a/package.json b/package.json index 74709de64..99735a9ee 100644 --- a/package.json +++ b/package.json @@ -31,16 +31,16 @@ "@crowdin/crowdin-api-client": "^1.55.1", "@emotion/babel-plugin": "^11.13.5", "@emotion/css": "^11.13.5", - "@rsdoctor/rspack-plugin": "^1.5.7", - "@rspack/cli": "^1.7.11", - "@rspack/core": "^1.7.11", - "@swc/core": "^1.15.24", + "@rsdoctor/rspack-plugin": "^1.5.9", + "@rspack/cli": "^2.0.0", + "@rspack/core": "^2.0.0", + "@swc/core": "^1.15.30", "@swc/jest": "^0.2.39", "@types/chrome": "0.1.40", "@types/decompress": "^4.2.7", "@types/firefox-webext-browser": "^143.0.0", "@types/jest": "^30.0.0", - "@types/node": "^25.5.2", + "@types/node": "^25.6.0", "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.1.1", @@ -53,26 +53,26 @@ "jest-environment-jsdom": "^30.3.0", "jest-junit": "^16.0.0", "jszip": "^3.10.1", - "postcss": "^8.5.9", + "postcss": "^8.5.10", "postcss-loader": "^8.2.1", "postcss-rtlcss": "^6.0.0", - "puppeteer": "^24.40.0", + "puppeteer": "^24.42.0", "ts-loader": "^9.5.7", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "6.0.2" + "typescript": "6.0.3" }, "dependencies": { "@element-plus/icons-vue": "^2.3.2", "echarts": "^6.0.0", - "element-plus": "2.13.6", + "element-plus": "2.13.7", "hash.js": "^1.1.7", "punycode": "^2.3.1", - "typescript-guard": "^0.2.2", - "vue": "^3.5.32", - "vue-router": "^5.0.4" + "typescript-guard": "^0.2.4", + "vue": "^3.5.33", + "vue-router": "^5.0.6" }, "engines": { "node": ">=22" } -} +} \ No newline at end of file diff --git a/src/manifest-firefox.ts b/src/manifest-firefox.ts index 129cfc06b..f56d54222 100644 --- a/src/manifest-firefox.ts +++ b/src/manifest-firefox.ts @@ -23,6 +23,7 @@ const _default: browser._manifest.WebExtensionManifest = { default_locale: 'en', homepage_url: homepage, manifest_version: 2, + minimum_opera_version: '140', icons: { 16: "static/images/icon-16.png", 48: "static/images/icon-48.png", diff --git a/src/pages/app/components/Option/categories/Notification/index.tsx b/src/pages/app/components/Option/categories/Notification/index.tsx index 7ca81ba3e..71004e1b4 100644 --- a/src/pages/app/components/Option/categories/Notification/index.tsx +++ b/src/pages/app/components/Option/categories/Notification/index.tsx @@ -1,5 +1,6 @@ import { t } from '@app/locale' -import { ElInput, ElMessage, ElSelect, ElTimePicker } from 'element-plus' +import { QuestionFilled } from '@element-plus/icons-vue' +import { ElIcon, ElInput, ElLink, ElMessage, ElSelect, ElTimePicker, ElTooltip } from 'element-plus' import { computed, defineComponent, StyleValue } from 'vue' import { OptionItem, OptionLines } from '../../components' import type { CategoryInstance } from '../types' @@ -96,6 +97,14 @@ const Notification = defineComponent((_, ctx) => { onInput={val => option.notificationEndpoint = val} placeholder="https://example.com/api/notification" /> + + + + + = 140) { - return await browser.permissions.request({ data_collection: [DATA_PERM] }) - } - - // Must use message box if firefox version <140 - return new Promise(resolve => ElMessageBox.confirm( - "This option will transfer your local data to the callback endpoint you specify. Do you agree?", - { - confirmButtonText: "Yes, I agree", - cancelButtonText: "No, I don't agree", - }, - ).then(() => resolve(true)).catch(() => resolve(false))) + return await browser.permissions.request({ data_collection: [DATA_PERM] }) } const usePermission = () => { From 44c29263367d22113bb465edb969d4192494cb04 Mon Sep 17 00:00:00 2001 From: sheepzh Date: Thu, 23 Apr 2026 21:40:59 +0800 Subject: [PATCH 124/174] v4.1.7 --- CHANGELOG.md | 5 +++++ package.json | 4 ++-- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5a23429da..55212b9f7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.1.7] - 2026-04-23 + +- Dropped support for Firefox versions below 140 +- Fixed some issues + ## [4.1.6] - 2026-04-09 diff --git a/package.json b/package.json index 99735a9ee..ea402ea5b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "timer", - "version": "4.1.6", + "version": "4.1.7", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { @@ -75,4 +75,4 @@ "engines": { "node": ">=22" } -} \ No newline at end of file +} From 3a5602b9793bcd6440c940b6f90b61093859c9cb Mon Sep 17 00:00:00 2001 From: sheepie Date: Mon, 27 Apr 2026 15:13:58 +0800 Subject: [PATCH 125/174] feat: v4.2.0 (#732) * refactor: using C/S architecture (#729) * feat: supports custom delay duration (#512) (#733) * feat: add time limit on the popup page (#332) (#739) * fix: vue router with functional component --- .commitlintrc.ts | 40 +-- .github/workflows/end-to-end-tests.yml | 11 +- .github/workflows/knip.yml | 19 ++ .github/workflows/publish-all.yml | 3 + .github/workflows/publish-firefox.yml | 2 + .gitignore | 2 + .vscode/settings.json | 18 +- CONTRIBUTING.md | 2 +- examples/gist/mock-server.ts | 259 ++++++++------- examples/package.json | 2 +- jest.config.ts | 53 --- knip.ts | 34 ++ package.json | 34 +- rspack/plugins/file-manager.ts | 9 +- rspack/plugins/generate-json.ts | 31 +- rspack/plugins/import-checker.ts | 97 ++++++ rspack/rspack.common.ts | 122 ++++++- rspack/rspack.dev.firefox.ts | 2 +- rspack/rspack.dev.ts | 24 ++ rspack/rspack.prod.firefox.ts | 40 +-- rspack/rspack.prod.safari.ts | 4 +- rspack/rspack.prod.ts | 4 +- rspack/util.ts | 2 +- script/crowdin/client.ts | 15 +- script/crowdin/common.ts | 20 +- script/crowdin/sync-translation.ts | 4 +- script/psl.ts | 9 +- script/setup-e2e.sh | 18 +- script/source-archive.sh | 13 + script/user-chart/add.ts | 10 +- script/user-chart/common.ts | 2 +- script/user-chart/render.ts | 4 +- src/api/chrome/action.ts | 10 +- src/api/chrome/notifications.ts | 19 +- src/api/chrome/permission.ts | 4 +- src/api/chrome/runtime.ts | 65 +--- src/api/chrome/tab.ts | 49 ++- src/api/chrome/tabGroups.ts | 15 - src/api/chrome/window.ts | 90 ++---- src/api/crowdin.ts | 54 +++- src/api/gist.ts | 4 +- src/api/http.ts | 5 - src/api/obsidian.ts | 2 +- src/api/sw/backup.ts | 15 + src/api/sw/cate.ts | 3 + src/api/sw/common.ts | 63 ++++ src/api/sw/immigration.ts | 5 + src/api/sw/limit.ts | 11 + src/api/sw/merge.ts | 7 + src/api/sw/option.ts | 14 + src/api/sw/period.ts | 3 + src/api/sw/site.ts | 31 ++ src/api/sw/stat.ts | 31 ++ src/api/sw/whitelist.ts | 7 + src/api/web-dav.ts | 2 +- src/background/badge-manager.ts | 149 ++++----- src/background/content-script-handler.ts | 99 +++--- src/background/data-cleaner.ts | 7 +- .../database/backup-database.ts | 12 - .../database/cate-database.ts} | 8 +- .../database/common/base-database.ts | 0 .../database/common/constant.ts | 0 .../database/common/indexed-storage.ts | 22 +- .../database/common/migratable.ts | 2 +- .../database/common/storage-holder.ts | 1 - .../database/common/storage-promise.ts | 0 .../database/limit-database.ts | 87 ++--- .../database/memory-detector.ts | 16 +- .../database/merge-rule-database.ts | 0 .../database/meta-database.ts | 0 .../database/option-database.ts | 16 +- .../database/period-database.ts | 10 +- src/background/database/site-database.ts | 141 ++++++++ .../database/stat-database/classic.ts | 35 +- .../database/stat-database/common.ts | 0 .../database/stat-database/condition.ts | 9 +- .../database/stat-database/idb.ts | 96 +++--- .../database/stat-database/index.ts | 23 +- .../database/stat-database/types.ts | 24 +- .../database/timeline-database/classic.ts | 0 .../database/timeline-database/idb.ts | 4 +- .../database/timeline-database/index.ts | 0 .../database/timeline-database/types.ts | 0 src/{ => background}/database/types.d.ts | 2 +- .../database/whitelist-database.ts | 20 +- src/background/icon-and-alias-collector.ts | 52 --- src/background/index.ts | 41 +-- .../browser-action.ts} | 17 +- src/background/install-handler/index.ts | 43 ++- .../install-handler/uninstall-listener.ts | 26 -- .../version}/cate-initializer.ts | 8 +- .../version}/host-merge-initializer.ts | 0 .../version}/index.ts | 12 +- .../version}/indexed-migrator.ts | 0 .../version}/local-file-initializer.ts | 0 .../version}/types.ts | 0 .../version}/whitelist-initializer.ts | 4 +- src/background/limit-processor.ts | 81 ++--- src/background/message-dispatcher.ts | 135 +++++++- src/{util => background}/psl/index.ts | 15 +- src/{util => background}/psl/rules.json | 0 src/background/scheduler.ts | 37 ++- src/{ => background}/service/backup/common.ts | 0 .../service/backup/gist/compressor.ts | 2 +- .../service/backup/gist/coordinator.ts | 12 +- .../service/backup/markdown.ts | 0 .../service/backup/obsidian/coordinator.ts | 7 +- .../service/backup/processor.ts | 128 ++------ .../service/backup/web-dav/coordinator.ts | 5 +- .../service/components/host-merge-ruler.ts | 6 +- .../service/components/immigration.ts | 54 ++++ .../service/components/import-processor.ts | 50 +++ .../service/components/option-holder.ts | 40 +++ .../service/components/page-info.ts | 0 .../service/components/period-calculator.ts | 47 +-- .../service/components/virtual-site-holder.ts | 39 +++ .../service/components/week-helper.ts | 70 ++-- src/{ => background}/service/item-service.ts | 27 +- src/background/service/limit-service.ts | 204 ++++++++++++ src/background/service/meta-service.ts | 82 +++++ .../service/notification/browser/notifier.ts | 0 .../service/notification/callback/notifier.ts | 0 .../service/notification/processor.ts | 34 +- .../service/notification/types.ts | 7 +- src/background/service/period-service.ts | 38 +++ src/background/service/site-service.ts | 204 ++++++++++++ .../service/stat-service/common.ts | 0 .../service/stat-service/index.ts | 170 ++-------- .../service/stat-service/merge/cate.ts | 0 .../service/stat-service/merge/common.ts | 0 .../service/stat-service/merge/date.ts | 3 +- .../service/stat-service/merge/host.ts | 0 .../service/stat-service/remote.ts | 24 +- .../service/throttler/firefox-throttler.ts | 0 .../service/throttler/period-throttler.ts | 2 +- .../service/throttler/timeline-throttler.ts | 0 src/background/service/timeline-service.ts | 76 +++++ src/background/service/whitelist/holder.ts | 56 ++++ .../service/whitelist/processor.ts | 4 + src/background/side-panel.ts | 17 - src/background/track-server/file-tracker.ts | 5 +- src/background/track-server/group.ts | 22 +- src/background/track-server/index.ts | 18 +- src/background/track-server/normal.ts | 44 +-- src/background/track-server/runtime.ts | 10 +- src/background/whitelist-menu-manager.ts | 51 ++- src/common/logger.ts | 24 -- src/common/timer.ts | 49 --- src/content-script/dispatcher.ts | 62 ++++ src/content-script/index.ts | 26 +- src/content-script/limit/common.ts | 25 +- src/content-script/limit/index.ts | 51 ++- src/content-script/limit/modal/Main.tsx | 10 +- src/content-script/limit/modal/bridge.ts | 91 ++++++ .../limit/modal/components/Alert.tsx | 22 +- .../limit/modal/components/Footer.tsx | 92 +++--- .../limit/modal/components/Reason.tsx | 46 ++- src/content-script/limit/modal/context.ts | 82 +++-- .../limit/{ => modal}/element.ts | 0 src/content-script/limit/modal/index.ts | 222 ++----------- src/content-script/limit/modal/instance.ts | 193 +++++++++++ .../limit/modal/style/modal.css | 2 +- src/content-script/limit/modal/types.ts | 17 + .../limit/processor/message-adaptor.ts | 68 ++-- .../limit/processor/period-processor.ts | 34 +- .../limit/processor/visit-processor.ts | 53 ++- .../limit/reminder/component.ts | 2 +- src/content-script/limit/reminder/index.ts | 20 +- src/content-script/limit/types.ts | 27 ++ src/content-script/printer.ts | 8 +- src/content-script/skeleton.ts | 18 -- src/content-script/timeline.ts | 10 +- .../tracker/normal/idle-detector.ts | 53 ++- src/content-script/tracker/normal/index.ts | 45 +-- src/content-script/tracker/run-time.ts | 30 +- src/content-script/types.ts | 3 + src/database/site-database.ts | 224 ------------- src/i18n/chrome/message.ts | 3 +- src/i18n/chrome/t.ts | 2 +- src/i18n/element.ts | 10 +- src/i18n/index.ts | 35 +- src/i18n/message/app/index.ts | 12 +- src/i18n/message/app/limit-resource.json | 107 +----- src/i18n/message/app/limit.ts | 18 +- src/i18n/message/app/menu-resource.json | 28 -- src/i18n/message/app/menu.ts | 2 - src/i18n/message/app/merge-rule-resource.json | 2 +- src/i18n/message/app/option-resource.json | 28 +- src/i18n/message/app/option.ts | 1 + .../message/app/site-manage-resource.json | 12 +- src/i18n/message/base.ts | 1 + src/i18n/message/button.ts | 1 + src/i18n/message/calendar.ts | 1 + src/i18n/message/common/base-resource.json | 56 +++- src/i18n/message/common/base.ts | 3 +- src/i18n/message/common/button-resource.json | 29 +- src/i18n/message/common/button.ts | 2 +- .../message/common/calendar-resource.json | 4 +- src/i18n/message/common/item-resource.json | 3 - src/i18n/message/common/locale.ts | 2 +- src/i18n/message/common/shared-resource.json | 92 +++++- src/i18n/message/common/shared.ts | 8 + src/i18n/message/cs/index.ts | 7 +- src/i18n/message/cs/modal-resource.json | 26 +- src/i18n/message/cs/modal.ts | 2 +- src/i18n/message/item.ts | 1 + src/i18n/message/meta.ts | 1 + src/i18n/message/popup/content-resource.json | 6 +- src/i18n/message/popup/content.ts | 6 +- src/i18n/message/popup/footer.ts | 6 +- src/i18n/message/popup/header-resource.json | 4 +- src/i18n/message/popup/index.ts | 10 +- src/i18n/message/popup/limit-resource.json | 15 + src/i18n/message/popup/limit.ts | 13 + src/manifest-firefox.ts | 10 +- src/manifest.ts | 17 +- src/pages/app/Layout/icons/Trend.tsx | 16 - src/pages/app/Layout/index.tsx | 2 +- src/pages/app/Layout/menu/Nav.tsx | 11 +- src/pages/app/Layout/menu/Side.tsx | 7 +- src/pages/app/Layout/menu/item.ts | 12 +- src/pages/app/Layout/menu/route.ts | 2 +- src/pages/app/components/About/DescLink.tsx | 3 +- .../app/components/About/Description.tsx | 25 +- .../app/components/About/InstallationLink.tsx | 2 +- src/pages/app/components/About/index.tsx | 2 +- .../AnalysisFilter/TargetSelect.tsx | 97 +++--- .../components/AnalysisFilter/index.tsx | 4 +- .../components/Summary/Calendar/Wrapper.ts | 34 +- .../components/Summary/Calendar/index.tsx | 4 +- .../components/Summary/TargetInfo.tsx | 35 +- .../Analysis/components/Summary/index.tsx | 14 +- .../components/Trend/Dimension/Chart.tsx | 5 +- .../components/Trend/Dimension/Wrapper.ts | 22 +- .../components/Trend/Dimension/index.tsx | 15 +- .../Analysis/components/Trend/Filter.tsx | 2 +- .../Analysis/components/Trend/Total.tsx | 16 +- .../Analysis/components/Trend/context.ts | 9 +- .../Analysis/components/Trend/index.tsx | 8 +- src/pages/app/components/Analysis/context.ts | 19 +- src/pages/app/components/Analysis/index.tsx | 2 +- src/pages/app/components/Analysis/types.d.ts | 12 +- src/pages/app/components/Analysis/util.ts | 31 +- .../Dashboard/components/Calendar/Wrapper.ts | 24 +- .../Dashboard/components/Calendar/index.tsx | 31 +- .../Dashboard/components/Indicator.tsx | 45 +-- .../components/MonthOnMonth/Wrapper.ts | 14 +- .../components/MonthOnMonth/index.tsx | 12 +- .../components}/NumberGrow.tsx | 2 +- .../components/Timeline/Chart/Wrapper.ts | 60 ++-- .../components/Timeline/Chart/index.tsx | 31 +- .../components/Timeline/Chart/useMerge.ts | 138 -------- .../Dashboard/components/Timeline/Summary.tsx | 63 ++-- .../Dashboard/components/Timeline/common.ts | 5 + .../components/Timeline/constants.ts | 4 - .../Dashboard/components/Timeline/context.ts | 38 +++ .../Dashboard/components/Timeline/index.tsx | 14 +- .../components/TopKVisit/BarChart/Wrapper.ts | 6 +- .../components/TopKVisit/BarChart/index.tsx | 2 +- .../TopKVisit/HalfBarChart/Wrapper.ts | 17 +- .../TopKVisit/HalfBarChart/index.tsx | 2 +- .../components/TopKVisit/PieChart/Wrapper.ts | 13 +- .../components/TopKVisit/PieChart/index.tsx | 2 +- .../components/TopKVisit/Title/index.tsx | 35 +- .../Dashboard/components/TopKVisit/common.ts | 10 - .../Dashboard/components/TopKVisit/context.ts | 23 +- .../Dashboard/components/TopKVisit/index.tsx | 6 +- src/pages/app/components/Dashboard/index.tsx | 13 +- .../ClearPanel/ClearFilter/DateFilter.tsx | 4 +- .../ClearPanel/ClearFilter/index.tsx | 2 +- .../DataManage/ClearPanel/index.tsx | 52 +-- .../app/components/DataManage/MemoryInfo.tsx | 8 +- .../DataManage/Migration/ImportButton.tsx | 10 +- .../Migration/ImportOtherButton/Step1.tsx | 4 +- .../Migration/ImportOtherButton/Step2.tsx | 2 +- .../Migration/ImportOtherButton/index.tsx | 8 +- .../Migration/ImportOtherButton/processor.ts | 16 +- .../components/DataManage/Migration/index.tsx | 6 +- .../app/components/DataManage/context.ts | 13 +- src/pages/app/components/DataManage/index.tsx | 6 +- .../Habit/{components => }/HabitFilter.tsx | 8 +- .../Period/Average/Wrapper.tsx | 10 +- .../{components => }/Period/Average/index.tsx | 2 +- .../Habit/{components => }/Period/Filter.tsx | 4 +- .../{components => }/Period/Stack/Wrapper.ts | 17 +- .../{components => }/Period/Stack/index.tsx | 4 +- .../Habit/{components => }/Period/Summary.tsx | 24 +- .../{components => }/Period/Trend/Wrapper.ts | 17 +- .../{components => }/Period/Trend/index.tsx | 2 +- .../Habit/{components => }/Period/common.ts | 9 +- .../Habit/{components => }/Period/context.ts | 19 +- .../Habit/{components => }/Period/index.tsx | 2 +- .../app/components/Habit/Period/types.d.ts | 6 + .../Site/DailyTrend/Wrapper.ts | 17 +- .../Site/DailyTrend/index.tsx | 2 +- .../Site/Distribution/Wrapper.ts | 6 +- .../Site/Distribution/index.tsx | 4 +- .../Habit/{components => }/Site/Summary.tsx | 10 +- .../{components => }/Site/TopK/Wrapper.ts | 14 +- .../{components => }/Site/TopK/index.tsx | 4 +- .../Habit/{components => }/Site/common.ts | 2 +- .../app/components/Habit/Site/context.ts | 42 +++ .../Habit/{components => }/Site/index.tsx | 6 +- .../Habit/components/Site/context.ts | 40 --- .../Habit/{components => }/context.ts | 2 +- src/pages/app/components/Habit/index.tsx | 12 +- .../app/components/HelpUs/MemberList.tsx | 11 +- .../app/components/HelpUs/ProgressList.tsx | 4 +- src/pages/app/components/HelpUs/index.tsx | 8 +- src/pages/app/components/Limit/common.ts | 24 +- .../components/Limit/components/Filter.tsx | 20 +- .../components/Limit/components/List/Card.tsx | 8 +- .../components/Limit/components/List/Rule.tsx | 51 ++- .../Limit/components/List/index.tsx | 2 +- .../Limit/components/Modify/Step1.tsx | 6 +- .../components/Modify/Step2/SiteInput.tsx | 12 +- .../Limit/components/Modify/Step2/index.tsx | 11 +- .../components/Modify/Step3/PeriodInput.tsx | 4 +- .../components/Modify/Step3/TimeInput.tsx | 12 +- .../Limit/components/Modify/Step3/index.tsx | 16 +- .../Limit/components/Modify/index.tsx | 21 +- .../Limit/components/Modify/types.ts | 3 - .../components/Table/OperationColumn.tsx | 6 +- .../Limit/components/Table/Rule.tsx | 17 +- .../Limit/components/Table/Waste.tsx | 22 +- .../Limit/components/Table/Weekday.tsx | 2 +- .../Limit/components/Table/index.tsx | 37 +-- .../app/components/Limit/components/Test.tsx | 14 +- src/pages/app/components/Limit/context.ts | 87 +++-- src/pages/app/components/Limit/index.tsx | 23 +- src/pages/app/components/Limit/types.d.ts | 19 +- src/pages/app/components/Option/Select.tsx | 2 +- src/pages/app/components/Option/Tabs.tsx | 15 +- .../Option/categories/Accessibility.tsx | 8 +- .../categories/Appearance/DarkModeInput.tsx | 2 +- .../Option/categories/Appearance/index.tsx | 26 +- .../Option/categories/Backup/AutoInput.tsx | 2 +- .../Option/categories/Backup/Clear/Step2.tsx | 4 +- .../Option/categories/Backup/Clear/index.tsx | 16 +- .../Option/categories/Backup/ClientTable.tsx | 30 +- .../categories/Backup/Download/Step2.tsx | 2 +- .../categories/Backup/Download/index.tsx | 21 +- .../Option/categories/Backup/Footer.tsx | 45 +-- .../Option/categories/Backup/index.tsx | 4 +- .../Option/categories/Backup/useBackup.ts | 19 +- .../Option/categories/Limit/index.tsx | 56 ++-- .../Option/categories/Limit/usePswEdit.tsx | 4 +- .../Option/categories/Limit/useVerify.ts | 15 +- .../Option/categories/Notification/Footer.tsx | 12 +- .../Option/categories/Notification/index.tsx | 4 +- .../Notification/useNotification.ts | 18 +- .../components/Option/categories/Tracking.tsx | 34 +- .../app/components/Option/components/Item.tsx | 4 +- .../app/components/Option/export-import.ts | 14 +- src/pages/app/components/Option/index.tsx | 2 +- .../app/components/Option/useCategory.ts | 2 +- src/pages/app/components/Option/useOption.ts | 18 +- .../{ReportFilter => Filter}/BatchDelete.tsx | 31 +- .../{ReportFilter => Filter}/DownloadFile.tsx | 2 +- .../MergeFilterItem.tsx | 4 +- .../{ReportFilter => Filter}/RemoteClient.tsx | 8 +- .../Report/{ReportFilter => Filter}/common.ts | 0 .../Report/{ReportFilter => Filter}/index.tsx | 8 +- .../Report/{ReportList => List}/Item.tsx | 19 +- .../Report/{ReportList => List}/index.tsx | 10 +- .../columns/CateColumn.tsx | 31 +- .../columns/DateColumn.tsx | 6 +- .../columns/GroupColumn.tsx | 6 +- .../columns/HostColumn.tsx | 20 +- .../columns/OperationColumn.tsx | 48 ++- .../columns/TimeColumn.tsx | 14 +- .../columns/VisitColumn.tsx | 10 +- .../Report/{ReportTable => Table}/index.tsx | 49 ++- src/pages/app/components/Report/common.ts | 59 ++-- .../{ => components}/CompositionTable.tsx | 2 +- .../TooltipSiteList.tsx | 6 +- src/pages/app/components/Report/context.ts | 24 +- .../app/components/Report/file-export.ts | 7 +- src/pages/app/components/Report/index.tsx | 14 +- src/pages/app/components/Report/types.d.ts | 33 +- .../app/components/RuleMerge/ItemList.tsx | 25 +- .../RuleMerge/components/AddButton.tsx | 4 +- .../components/RuleMerge/components/Item.tsx | 4 +- .../RuleMerge/components/ItemInput.tsx | 4 +- src/pages/app/components/RuleMerge/index.tsx | 4 +- .../SiteManage/Modify/HostSelect.tsx | 69 ++++ .../{SiteManageModify => Modify}/index.tsx | 45 +-- .../SiteManage/SiteManageFilter.tsx | 12 +- .../SiteManageModify/HostSelect.tsx | 105 ------ .../column/AliasColumn.tsx | 79 ++--- .../column/OperationColumn.tsx | 14 +- .../column/TypeColumn.tsx | 6 +- .../{SiteManageTable => Table}/index.tsx | 20 +- src/pages/app/components/SiteManage/common.ts | 58 ---- src/pages/app/components/SiteManage/index.tsx | 49 ++- .../components/SiteManage/useSiteManage.ts | 22 +- .../Whitelist/WhitePanel/AddButton.tsx | 4 +- .../Whitelist/WhitePanel/WhiteInput.tsx | 12 +- .../Whitelist/WhitePanel/WhiteItem.tsx | 4 +- .../components/Whitelist/WhitePanel/index.tsx | 16 +- src/pages/app/components/Whitelist/index.tsx | 4 +- .../Editable.tsx} | 15 +- .../Select}/OptionItem.tsx | 25 +- .../Select}/SelectFooter.tsx | 4 +- .../Select}/index.tsx | 16 +- .../app/components/common/Category/index.ts | 9 + .../components/common/DialogSop/context.ts | 4 +- .../app/components/common/DialogSop/index.tsx | 2 +- src/pages/app/components/common/Editable.tsx | 16 +- src/pages/app/components/common/HostAlert.tsx | 11 +- .../app/components/common/Pagination.tsx | 2 +- .../components/common/PopupConfirmButton.tsx | 2 +- .../common/filter/ButtonFilterItem.tsx | 2 +- .../common/filter/DateRangeFilterItem.tsx | 16 +- .../common/filter/SelectFilterItem.tsx | 7 +- .../app/components/common/filter/common.ts | 2 +- .../common/imported/CompareTable.tsx | 6 +- .../common/imported/ResolutionRadio.tsx | 2 +- .../app/components/common/kanban/Card.tsx | 2 +- .../common/kanban/IndicatorCell.tsx | 24 +- .../app/components/common/kanban/index.ts | 7 +- .../app/components/common/kanban/types.d.ts | 6 + src/pages/app/context.ts | 13 +- src/pages/app/echarts.ts | 20 -- src/pages/app/index.ts | 20 +- src/pages/app/router/constants.ts | 18 +- src/pages/app/router/index.ts | 16 +- src/pages/app/styles/index.ts | 2 - src/pages/app/util/echarts.ts | 11 + .../app/util/limit}/generator/confession.ts | 2 +- .../app/util/limit}/generator/index.ts | 2 +- .../app/util/limit}/generator/pi.ts | 2 +- .../app/util/limit}/generator/ugly.ts | 4 +- .../util/limit}/generator/uncommon-chinese.ts | 2 +- .../app/util/{limit.tsx => limit/index.tsx} | 85 ++--- .../app/util/limit}/processor.ts | 6 +- .../app/util/limit/types.ts} | 1 - src/pages/app/util/time.ts | 4 +- src/pages/components/Img.tsx | 31 ++ .../common => components}/TooltipWrapper.tsx | 0 src/pages/element-ui/app.ts | 12 + src/pages/{types.ts => element-ui/types.d.ts} | 0 src/pages/hooks/index.ts | 7 +- src/pages/hooks/useCached.ts | 2 +- src/pages/hooks/useEcharts.ts | 8 +- src/pages/hooks/useLocalStorage.ts | 21 +- src/pages/hooks/useMediaSize.ts | 9 +- src/pages/hooks/useRequest.ts | 4 +- src/pages/hooks/useSiteMerge.ts | 4 +- src/pages/hooks/useTabGroups.ts | 2 +- src/pages/hooks/useWindowFocus.ts | 15 - src/pages/icons.tsx | 52 +++ src/pages/popup/common.tsx | 43 +-- .../popup/components/Footer/DataToolbar.tsx | 52 +++ .../components/Footer/DurationSelect.tsx | 6 +- .../popup/components/Footer/LimitToolbar.tsx | 52 +++ src/pages/popup/components/Footer/Menu.tsx | 49 ++- src/pages/popup/components/Footer/index.tsx | 52 +-- .../popup/components/Header/DarkSwitch.tsx | 2 +- .../popup/components/Header/LangSelect.tsx | 17 +- src/pages/popup/components/Header/Logo.tsx | 7 +- .../popup/components/Header/MoreInfo.tsx | 10 +- src/pages/popup/components/Header/Option.tsx | 24 +- src/pages/popup/components/Header/index.tsx | 6 +- .../popup/components/Limit/Content/Chart.tsx | 10 + .../popup/components/Limit/Content/Wrapper.ts | 305 ++++++++++++++++++ .../popup/components/Limit/Content/index.tsx | 24 ++ src/pages/popup/components/Limit/Summary.tsx | 54 ++++ src/pages/popup/components/Limit/index.tsx | 18 ++ .../components/Percentage/Cate/Wrapper.ts | 31 +- .../components/Percentage/Cate/index.tsx | 2 +- .../components/Percentage/Site/Wrapper.ts | 17 +- .../components/Percentage/Site/index.tsx | 2 +- .../popup/components/Percentage/chart.ts | 7 +- .../popup/components/Percentage/index.tsx | 8 +- .../popup/components/Percentage/query.ts | 10 +- src/pages/popup/components/Ranking/Item.tsx | 14 +- src/pages/popup/components/Ranking/index.tsx | 2 +- src/pages/popup/components/Ranking/query.tsx | 7 +- src/pages/popup/context.ts | 104 +++--- src/pages/popup/index.ts | 17 +- src/pages/popup/locale.ts | 2 +- src/pages/popup/message.ts | 8 - src/pages/popup/router.ts | 35 +- src/pages/popup/skeleton.ts | 6 +- src/pages/popup/types.d.ts | 28 ++ src/pages/side/Layout.tsx | 18 +- src/pages/side/components/RowList/Item.tsx | 148 +++++---- src/pages/side/components/RowList/index.tsx | 10 +- src/pages/side/components/Search/index.tsx | 4 +- .../side/components/Search/useDatePicker.ts | 13 +- src/pages/side/index.ts | 22 +- src/pages/side/locale.ts | 2 +- src/pages/util/dark-mode.ts | 66 ++++ src/pages/util/echarts.ts | 18 ++ src/pages/util/icon.tsx | 19 -- src/pages/util/rate.ts | 20 ++ src/pages/util/style.ts | 19 +- src/service/cate-service.ts | 22 -- src/service/components/immigration.ts | 67 ---- src/service/components/import-processor.ts | 49 --- src/service/components/option-holder.ts | 52 --- src/service/components/virtual-site-holder.ts | 45 --- src/service/limit-service/index.ts | 233 ------------- src/service/meta-service.ts | 110 ------- src/service/notification/common.ts | 5 - src/service/option-service.ts | 94 ------ src/service/period-service.ts | 45 --- src/service/site-service.ts | 138 -------- src/service/whitelist/holder.ts | 36 --- src/service/whitelist/service.ts | 34 -- src/shared/route.ts | 43 +++ src/util/array.ts | 40 +-- src/util/constant/environment.ts | 2 +- src/util/constant/option.ts | 138 ++++---- src/util/constant/url.ts | 24 +- src/util/dark-mode.ts | 40 --- src/util/echarts.ts | 19 +- src/util/lang.ts | 27 -- src/util/limit.ts | 100 ++---- src/util/merge.ts | 20 -- src/util/number.ts | 16 +- src/util/pattern.ts | 8 +- src/util/period.ts | 77 +---- src/util/site.ts | 25 +- src/util/stat.ts | 22 +- src/util/time.ts | 18 +- src/util/tuple.ts | 6 - test-e2e/backup/common.ts | 3 +- test-e2e/backup/gist.test.ts | 22 +- test-e2e/common/base.ts | 119 +++---- test-e2e/common/record.ts | 2 +- test-e2e/common/util.ts | 11 + test-e2e/common/whitelist-skip.test.ts | 8 + .../{whitelist.test.ts => whitelist.ts} | 11 +- test-e2e/install.test.ts | 19 -- test-e2e/limit/common.ts | 29 +- test-e2e/limit/daily-time.test.ts | 23 +- test-e2e/limit/daily-visit.test.ts | 30 +- test-e2e/limit/delay-duration.test.ts | 93 ++++++ test-e2e/limit/visit-limit.test.ts | 29 +- test-e2e/rstest.config.mts | 13 + test-e2e/tracker/base.test.ts | 7 +- test-e2e/tracker/run-time.test.ts | 7 +- test/__mock__/storage.ts | 22 +- .../background/backup/gist/compressor.test.ts | 2 +- .../database/limit-database.test.ts | 59 +--- .../database/merge-rule-database.test.ts | 6 +- test/{ => background}/database/migratable.ts | 0 .../database/period-database.test.ts | 6 +- .../database/stat-database/classic.test.ts | 65 ++-- .../database/stat-database/idb.test.ts | 28 +- .../database/whitelist-database.test.ts | 4 +- .../components/host-merge-ruler.test.ts | 0 .../components/period-calculator.test.ts | 40 +-- test/background/service/limit-service.test.ts | 67 ++++ .../service/whitelist/processor.test.ts | 4 +- test/common/logger.test.ts | 22 -- test/jest.setup.ts | 4 - test/rstest.config.mts | 7 + test/util/array.test.ts | 12 +- test/util/chrome/compile.test.ts | 14 +- test/util/limit.test.ts | 111 ++----- test/util/period.test.ts | 8 +- test/util/time.test.ts | 10 +- tsconfig.json | 30 +- types/chrome.d.ts | 3 +- types/common.d.ts | 2 + types/timer/backup.d.ts | 32 +- types/timer/common.d.ts | 18 ++ types/timer/core.d.ts | 1 - types/timer/imported.d.ts | 5 + types/timer/index.d.ts | 8 - types/timer/limit.d.ts | 17 + types/timer/message.d.ts | 157 +++++++++ types/timer/mq.d.ts | 74 ----- types/timer/option.d.ts | 15 + types/timer/period.d.ts | 13 +- types/timer/site.d.ts | 20 ++ types/timer/stat.d.ts | 137 ++++++-- types/timer/timeline.d.ts | 17 +- 581 files changed, 7307 insertions(+), 7311 deletions(-) create mode 100644 .github/workflows/knip.yml delete mode 100644 jest.config.ts create mode 100644 knip.ts create mode 100644 rspack/plugins/import-checker.ts create mode 100755 script/source-archive.sh create mode 100644 src/api/sw/backup.ts create mode 100644 src/api/sw/cate.ts create mode 100644 src/api/sw/common.ts create mode 100644 src/api/sw/immigration.ts create mode 100644 src/api/sw/limit.ts create mode 100644 src/api/sw/merge.ts create mode 100644 src/api/sw/option.ts create mode 100644 src/api/sw/period.ts create mode 100644 src/api/sw/site.ts create mode 100644 src/api/sw/stat.ts create mode 100644 src/api/sw/whitelist.ts rename src/{ => background}/database/backup-database.ts (60%) rename src/{database/site-cate-database.ts => background/database/cate-database.ts} (93%) rename src/{ => background}/database/common/base-database.ts (100%) rename src/{ => background}/database/common/constant.ts (100%) rename src/{ => background}/database/common/indexed-storage.ts (94%) rename src/{ => background}/database/common/migratable.ts (93%) rename src/{ => background}/database/common/storage-holder.ts (99%) rename src/{ => background}/database/common/storage-promise.ts (100%) rename src/{ => background}/database/limit-database.ts (82%) rename src/{ => background}/database/memory-detector.ts (66%) rename src/{ => background}/database/merge-rule-database.ts (100%) rename src/{ => background}/database/meta-database.ts (100%) rename src/{ => background}/database/option-database.ts (60%) rename src/{ => background}/database/period-database.ts (94%) create mode 100644 src/background/database/site-database.ts rename src/{ => background}/database/stat-database/classic.ts (88%) rename src/{ => background}/database/stat-database/common.ts (100%) rename src/{ => background}/database/stat-database/condition.ts (87%) rename src/{ => background}/database/stat-database/idb.ts (88%) rename src/{ => background}/database/stat-database/index.ts (90%) rename src/{ => background}/database/stat-database/types.ts (82%) rename src/{ => background}/database/timeline-database/classic.ts (100%) rename src/{ => background}/database/timeline-database/idb.ts (99%) rename src/{ => background}/database/timeline-database/index.ts (100%) rename src/{ => background}/database/timeline-database/types.ts (100%) rename src/{ => background}/database/types.d.ts (89%) rename src/{ => background}/database/whitelist-database.ts (75%) delete mode 100644 src/background/icon-and-alias-collector.ts rename src/background/{browser-action-manager.ts => install-handler/browser-action.ts} (82%) delete mode 100644 src/background/install-handler/uninstall-listener.ts rename src/background/{migrator => install-handler/version}/cate-initializer.ts (82%) rename src/background/{migrator => install-handler/version}/host-merge-initializer.ts (100%) rename src/background/{migrator => install-handler/version}/index.ts (83%) rename src/background/{migrator => install-handler/version}/indexed-migrator.ts (100%) rename src/background/{migrator => install-handler/version}/local-file-initializer.ts (100%) rename src/background/{migrator => install-handler/version}/types.ts (100%) rename src/background/{migrator => install-handler/version}/whitelist-initializer.ts (63%) rename src/{util => background}/psl/index.ts (83%) rename src/{util => background}/psl/rules.json (100%) rename src/{ => background}/service/backup/common.ts (100%) rename src/{ => background}/service/backup/gist/compressor.ts (99%) rename src/{ => background}/service/backup/gist/coordinator.ts (95%) rename src/{ => background}/service/backup/markdown.ts (100%) rename src/{ => background}/service/backup/obsidian/coordinator.ts (93%) rename src/{ => background}/service/backup/processor.ts (64%) rename src/{ => background}/service/backup/web-dav/coordinator.ts (95%) rename src/{ => background}/service/components/host-merge-ruler.ts (95%) create mode 100644 src/background/service/components/immigration.ts create mode 100644 src/background/service/components/import-processor.ts create mode 100644 src/background/service/components/option-holder.ts rename src/{ => background}/service/components/page-info.ts (100%) rename src/{ => background}/service/components/period-calculator.ts (56%) create mode 100644 src/background/service/components/virtual-site-holder.ts rename src/{ => background}/service/components/week-helper.ts (53%) rename src/{ => background}/service/item-service.ts (68%) create mode 100644 src/background/service/limit-service.ts create mode 100644 src/background/service/meta-service.ts rename src/{ => background}/service/notification/browser/notifier.ts (100%) rename src/{ => background}/service/notification/callback/notifier.ts (100%) rename src/{ => background}/service/notification/processor.ts (74%) rename src/{ => background}/service/notification/types.ts (91%) create mode 100644 src/background/service/period-service.ts create mode 100644 src/background/service/site-service.ts rename src/{ => background}/service/stat-service/common.ts (100%) rename src/{ => background}/service/stat-service/index.ts (50%) rename src/{ => background}/service/stat-service/merge/cate.ts (100%) rename src/{ => background}/service/stat-service/merge/common.ts (100%) rename src/{ => background}/service/stat-service/merge/date.ts (88%) rename src/{ => background}/service/stat-service/merge/host.ts (100%) rename src/{ => background}/service/stat-service/remote.ts (80%) rename src/{ => background}/service/throttler/firefox-throttler.ts (100%) rename src/{ => background}/service/throttler/period-throttler.ts (88%) rename src/{ => background}/service/throttler/timeline-throttler.ts (100%) create mode 100644 src/background/service/timeline-service.ts create mode 100644 src/background/service/whitelist/holder.ts rename src/{ => background}/service/whitelist/processor.ts (92%) delete mode 100644 src/background/side-panel.ts delete mode 100644 src/common/timer.ts create mode 100644 src/content-script/dispatcher.ts create mode 100644 src/content-script/limit/modal/bridge.ts rename src/content-script/limit/{ => modal}/element.ts (100%) create mode 100644 src/content-script/limit/modal/instance.ts create mode 100644 src/content-script/limit/modal/types.ts create mode 100644 src/content-script/limit/types.ts delete mode 100644 src/content-script/skeleton.ts create mode 100644 src/content-script/types.ts delete mode 100644 src/database/site-database.ts create mode 100644 src/i18n/message/base.ts create mode 100644 src/i18n/message/button.ts create mode 100644 src/i18n/message/calendar.ts create mode 100644 src/i18n/message/item.ts create mode 100644 src/i18n/message/meta.ts create mode 100644 src/i18n/message/popup/limit-resource.json create mode 100644 src/i18n/message/popup/limit.ts delete mode 100644 src/pages/app/Layout/icons/Trend.tsx rename src/pages/app/components/{common => Dashboard/components}/NumberGrow.tsx (94%) delete mode 100644 src/pages/app/components/Dashboard/components/Timeline/Chart/useMerge.ts create mode 100644 src/pages/app/components/Dashboard/components/Timeline/common.ts delete mode 100644 src/pages/app/components/Dashboard/components/Timeline/constants.ts create mode 100644 src/pages/app/components/Dashboard/components/Timeline/context.ts delete mode 100644 src/pages/app/components/Dashboard/components/TopKVisit/common.ts rename src/pages/app/components/Habit/{components => }/HabitFilter.tsx (85%) rename src/pages/app/components/Habit/{components => }/Period/Average/Wrapper.tsx (95%) rename src/pages/app/components/Habit/{components => }/Period/Average/index.tsx (95%) rename src/pages/app/components/Habit/{components => }/Period/Filter.tsx (98%) rename src/pages/app/components/Habit/{components => }/Period/Stack/Wrapper.ts (85%) rename src/pages/app/components/Habit/{components => }/Period/Stack/index.tsx (88%) rename src/pages/app/components/Habit/{components => }/Period/Summary.tsx (76%) rename src/pages/app/components/Habit/{components => }/Period/Trend/Wrapper.ts (87%) rename src/pages/app/components/Habit/{components => }/Period/Trend/index.tsx (94%) rename src/pages/app/components/Habit/{components => }/Period/common.ts (85%) rename src/pages/app/components/Habit/{components => }/Period/context.ts (81%) rename src/pages/app/components/Habit/{components => }/Period/index.tsx (97%) create mode 100644 src/pages/app/components/Habit/Period/types.d.ts rename src/pages/app/components/Habit/{components => }/Site/DailyTrend/Wrapper.ts (93%) rename src/pages/app/components/Habit/{components => }/Site/DailyTrend/index.tsx (94%) rename src/pages/app/components/Habit/{components => }/Site/Distribution/Wrapper.ts (97%) rename src/pages/app/components/Habit/{components => }/Site/Distribution/index.tsx (87%) rename src/pages/app/components/Habit/{components => }/Site/Summary.tsx (92%) rename src/pages/app/components/Habit/{components => }/Site/TopK/Wrapper.ts (94%) rename src/pages/app/components/Habit/{components => }/Site/TopK/index.tsx (86%) rename src/pages/app/components/Habit/{components => }/Site/common.ts (95%) create mode 100644 src/pages/app/components/Habit/Site/context.ts rename src/pages/app/components/Habit/{components => }/Site/index.tsx (91%) delete mode 100644 src/pages/app/components/Habit/components/Site/context.ts rename src/pages/app/components/Habit/{components => }/context.ts (93%) delete mode 100644 src/pages/app/components/Limit/components/Modify/types.ts rename src/pages/app/components/Report/{ReportFilter => Filter}/BatchDelete.tsx (78%) rename src/pages/app/components/Report/{ReportFilter => Filter}/DownloadFile.tsx (97%) rename src/pages/app/components/Report/{ReportFilter => Filter}/MergeFilterItem.tsx (98%) rename src/pages/app/components/Report/{ReportFilter => Filter}/RemoteClient.tsx (84%) rename src/pages/app/components/Report/{ReportFilter => Filter}/common.ts (100%) rename src/pages/app/components/Report/{ReportFilter => Filter}/index.tsx (92%) rename src/pages/app/components/Report/{ReportList => List}/Item.tsx (91%) rename src/pages/app/components/Report/{ReportList => List}/index.tsx (89%) rename src/pages/app/components/Report/{ReportTable => Table}/columns/CateColumn.tsx (73%) rename src/pages/app/components/Report/{ReportTable => Table}/columns/DateColumn.tsx (81%) rename src/pages/app/components/Report/{ReportTable => Table}/columns/GroupColumn.tsx (88%) rename src/pages/app/components/Report/{ReportTable => Table}/columns/HostColumn.tsx (66%) rename src/pages/app/components/Report/{ReportTable => Table}/columns/OperationColumn.tsx (72%) rename src/pages/app/components/Report/{ReportTable => Table}/columns/TimeColumn.tsx (79%) rename src/pages/app/components/Report/{ReportTable => Table}/columns/VisitColumn.tsx (79%) rename src/pages/app/components/Report/{ReportTable => Table}/index.tsx (84%) rename src/pages/app/components/Report/{ => components}/CompositionTable.tsx (98%) rename src/pages/app/components/Report/{ReportTable/columns => components}/TooltipSiteList.tsx (87%) create mode 100644 src/pages/app/components/SiteManage/Modify/HostSelect.tsx rename src/pages/app/components/SiteManage/{SiteManageModify => Modify}/index.tsx (77%) delete mode 100644 src/pages/app/components/SiteManage/SiteManageModify/HostSelect.tsx rename src/pages/app/components/SiteManage/{SiteManageTable => Table}/column/AliasColumn.tsx (50%) rename src/pages/app/components/SiteManage/{SiteManageTable => Table}/column/OperationColumn.tsx (69%) rename src/pages/app/components/SiteManage/{SiteManageTable => Table}/column/TypeColumn.tsx (90%) rename src/pages/app/components/SiteManage/{SiteManageTable => Table}/index.tsx (83%) rename src/pages/app/components/common/{category/CategoryEditable.tsx => Category/Editable.tsx} (88%) rename src/pages/app/components/common/{category/CategorySelect => Category/Select}/OptionItem.tsx (87%) rename src/pages/app/components/common/{category/CategorySelect => Category/Select}/SelectFooter.tsx (96%) rename src/pages/app/components/common/{category/CategorySelect => Category/Select}/index.tsx (75%) create mode 100644 src/pages/app/components/common/Category/index.ts create mode 100644 src/pages/app/components/common/kanban/types.d.ts delete mode 100644 src/pages/app/echarts.ts rename src/{service/limit-service/verification => pages/app/util/limit}/generator/confession.ts (83%) rename src/{service/limit-service/verification => pages/app/util/limit}/generator/index.ts (89%) rename src/{service/limit-service/verification => pages/app/util/limit}/generator/pi.ts (91%) rename src/{service/limit-service/verification => pages/app/util/limit}/generator/ugly.ts (90%) rename src/{service/limit-service/verification => pages/app/util/limit}/generator/uncommon-chinese.ts (97%) rename src/pages/app/util/{limit.tsx => limit/index.tsx} (62%) rename src/{service/limit-service/verification => pages/app/util/limit}/processor.ts (82%) rename src/{service/limit-service/verification/common.ts => pages/app/util/limit/types.ts} (99%) create mode 100644 src/pages/components/Img.tsx rename src/pages/{app/components/common => components}/TooltipWrapper.tsx (100%) create mode 100644 src/pages/element-ui/app.ts rename src/pages/{types.ts => element-ui/types.d.ts} (100%) delete mode 100644 src/pages/hooks/useWindowFocus.ts create mode 100644 src/pages/icons.tsx create mode 100644 src/pages/popup/components/Footer/DataToolbar.tsx create mode 100644 src/pages/popup/components/Footer/LimitToolbar.tsx create mode 100644 src/pages/popup/components/Limit/Content/Chart.tsx create mode 100644 src/pages/popup/components/Limit/Content/Wrapper.ts create mode 100644 src/pages/popup/components/Limit/Content/index.tsx create mode 100644 src/pages/popup/components/Limit/Summary.tsx create mode 100644 src/pages/popup/components/Limit/index.tsx delete mode 100644 src/pages/popup/message.ts create mode 100644 src/pages/popup/types.d.ts create mode 100644 src/pages/util/dark-mode.ts create mode 100644 src/pages/util/echarts.ts delete mode 100644 src/pages/util/icon.tsx create mode 100644 src/pages/util/rate.ts delete mode 100644 src/service/cate-service.ts delete mode 100644 src/service/components/immigration.ts delete mode 100644 src/service/components/import-processor.ts delete mode 100644 src/service/components/option-holder.ts delete mode 100644 src/service/components/virtual-site-holder.ts delete mode 100644 src/service/limit-service/index.ts delete mode 100644 src/service/meta-service.ts delete mode 100644 src/service/notification/common.ts delete mode 100644 src/service/option-service.ts delete mode 100644 src/service/period-service.ts delete mode 100644 src/service/site-service.ts delete mode 100644 src/service/whitelist/holder.ts delete mode 100644 src/service/whitelist/service.ts create mode 100644 src/shared/route.ts delete mode 100644 src/util/dark-mode.ts delete mode 100644 src/util/merge.ts create mode 100644 test-e2e/common/util.ts create mode 100644 test-e2e/common/whitelist-skip.test.ts rename test-e2e/common/{whitelist.test.ts => whitelist.ts} (79%) delete mode 100644 test-e2e/install.test.ts create mode 100644 test-e2e/limit/delay-duration.test.ts create mode 100644 test-e2e/rstest.config.mts rename test/{ => background}/database/limit-database.test.ts (68%) rename test/{ => background}/database/merge-rule-database.test.ts (92%) rename test/{ => background}/database/migratable.ts (100%) rename test/{ => background}/database/period-database.test.ts (91%) rename test/{ => background}/database/stat-database/classic.test.ts (71%) rename test/{ => background}/database/stat-database/idb.test.ts (88%) rename test/{ => background}/database/whitelist-database.test.ts (85%) rename test/{ => background}/service/components/host-merge-ruler.test.ts (100%) rename test/{ => background}/service/components/period-calculator.test.ts (63%) create mode 100644 test/background/service/limit-service.test.ts rename test/{ => background}/service/whitelist/processor.test.ts (96%) delete mode 100644 test/common/logger.test.ts delete mode 100644 test/jest.setup.ts create mode 100644 test/rstest.config.mts create mode 100644 types/timer/message.d.ts delete mode 100644 types/timer/mq.d.ts diff --git a/.commitlintrc.ts b/.commitlintrc.ts index 281aaf6ce..c916b3617 100644 --- a/.commitlintrc.ts +++ b/.commitlintrc.ts @@ -1,30 +1,22 @@ -import { - RuleConfigCondition, - RuleConfigSeverity, - TargetCaseType -} from "@commitlint/types" +import { RuleConfigSeverity, type UserConfig } from "@commitlint/types" -export default { +const config: UserConfig = { rules: { - "body-leading-blank": [RuleConfigSeverity.Error, "always"] as const, - "body-max-line-length": [RuleConfigSeverity.Error, "always", 100] as const, - "footer-leading-blank": [RuleConfigSeverity.Warning, "never"] as const, - "footer-max-line-length": [ - RuleConfigSeverity.Error, - "always", - 100, - ] as const, - "header-max-length": [RuleConfigSeverity.Error, "always", 100] as const, - "header-trim": [RuleConfigSeverity.Error, "always"] as const, + "body-leading-blank": [RuleConfigSeverity.Error, "always"], + "body-max-line-length": [RuleConfigSeverity.Error, "always", 100], + "footer-leading-blank": [RuleConfigSeverity.Warning, "never"], + "footer-max-line-length": [RuleConfigSeverity.Error, "always", 100], + "header-max-length": [RuleConfigSeverity.Error, "always", 100], + "header-trim": [RuleConfigSeverity.Error, "always"], "subject-case": [ RuleConfigSeverity.Error, "never", ["sentence-case", "start-case", "pascal-case", "upper-case"], - ] as [RuleConfigSeverity, RuleConfigCondition, TargetCaseType[]], - "subject-empty": [RuleConfigSeverity.Error, "never"] as const, - "subject-full-stop": [RuleConfigSeverity.Error, "never", "."] as const, - "type-case": [RuleConfigSeverity.Error, "always", "lower-case"] as const, - "type-empty": [RuleConfigSeverity.Error, "never"] as const, + ], + "subject-empty": [RuleConfigSeverity.Error, "never"], + "subject-full-stop": [RuleConfigSeverity.Error, "never", "."], + "type-case": [RuleConfigSeverity.Error, "always", "lower-case"], + "type-empty": [RuleConfigSeverity.Error, "never"], "type-enum": [ RuleConfigSeverity.Error, "always", @@ -42,7 +34,9 @@ export default { "test", "i18n", ], - ] satisfies [RuleConfigSeverity, RuleConfigCondition, string[]], + ] }, prompt: {}, -} \ No newline at end of file +} + +export default config \ No newline at end of file diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index 8015ec452..bd5e3ea2a 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -4,20 +4,19 @@ jobs: test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v6 - name: Test using Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v6 with: - node-version: "v22" + node-version: "v24" - name: Install dependencies run: npm install - name: Setup e2e environment run: bash script/setup-e2e.sh --all - - name: Start test servers - run: bash script/setup-e2e.sh --start-servers - name: Run tests env: + DEBUG: "rstest" USE_HEADLESS_PUPPETEER: true run: npm run test-e2e - name: Tests ✅ @@ -37,4 +36,4 @@ jobs: "state": "failure", "description": "Tests failed", "target_url": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - } + }' diff --git a/.github/workflows/knip.yml b/.github/workflows/knip.yml new file mode 100644 index 000000000..a65a39fac --- /dev/null +++ b/.github/workflows/knip.yml @@ -0,0 +1,19 @@ +name: Knip +on: + pull_request: +concurrency: + group: knip-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + knip: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + - name: Install dependencies + run: npm install + - name: Run Knip + run: npx knip --treat-config-hints-as-errors diff --git a/.github/workflows/publish-all.yml b/.github/workflows/publish-all.yml index 715cb6a97..ddfcb8635 100644 --- a/.github/workflows/publish-all.yml +++ b/.github/workflows/publish-all.yml @@ -28,6 +28,9 @@ jobs: - name: Build for Firefox run: npm run build:firefox + - name: Source package + run: bash script/source-archive.sh + - name: Check file sizes run: | # Check Chrome/Edge package size diff --git a/.github/workflows/publish-firefox.yml b/.github/workflows/publish-firefox.yml index b1dc24e1e..d666b8793 100644 --- a/.github/workflows/publish-firefox.yml +++ b/.github/workflows/publish-firefox.yml @@ -15,6 +15,8 @@ jobs: run: npm install - name: Build run: npm run build:firefox + - name: Source package + run: bash script/source-archive.sh - name: Upload to Firefox addon store uses: wdzeng/firefox-addon@v1.2.0-alpha.0 with: diff --git a/.gitignore b/.gitignore index ee659ba6c..86537e1bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +tsconfig.tsbuildinfo + .DS_Store node_modules diff --git a/.vscode/settings.json b/.vscode/settings.json index 07e911641..2454221db 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -17,53 +17,41 @@ "Arrayable", "Auths", "Cascader", - "clbbddpinhgdejpoepalbfnkogbobfdb", "COPYFILE", - "countup", "daterange", - "delayable", "domcontentloaded", "dont", "echarts", "emsp", "ensp", - "filemanager", "Hengyang", "indexeddb", "Kanban", + "knip", "MKCOL", "newtab", - "nohup", - "okey", - "Openeds", "Popconfirm", "PROPFIND", "Qihu", "Rsdoctor", "rspack", + "rstest", "rtlcss", "selectchanged", "sheepzh", "subpages", - "Treemap", - "Vnode", "vueuse", - "webcomponents", "webext", "webstore", "webtime", "wfhg", - "zcvf", - "Zhang", - "zrender" + "Zhang" ], "cSpell.ignorePaths": [ "package-lock.json", "node_modules", - "vscode-extension", ".git/objects", ".vscode", - ".vscode-insiders", // Ignore i18n resources "src/i18n/message/**/*.json", ], diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7f810cac8..d4a9b6945 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -14,7 +14,7 @@ And [Chrome Extension Development Documentation](https://developer.chrome.com/do Some free open source tools are also integrated: -- Testing tool [jest](https://jestjs.io/docs/getting-started) +- Testing tool [Rstest](https://rstest.rs) - End-to-end integration testing [puppeteer](https://developer.chrome.com/docs/extensions/how-to/test/puppeteer) - I18N tool [Crowdin](https://crowdin.com/project/timer-chrome-edge-firefox) diff --git a/examples/gist/mock-server.ts b/examples/gist/mock-server.ts index 61f11e027..1e0888633 100644 --- a/examples/gist/mock-server.ts +++ b/examples/gist/mock-server.ts @@ -68,36 +68,6 @@ function inferLanguage(filename: string): string { return "Text" } -function sendJson(res: ServerResponse, statusCode: number, payload: unknown) { - res.writeHead(statusCode, { "Content-Type": "application/json" }) - res.end(JSON.stringify(payload)) -} - -function sendNoContent(res: ServerResponse) { - res.writeHead(204).end() -} - -function getOrigin(req: IncomingMessage): string { - const host = req.headers.host || `localhost:${PORT}` - return `http://${host}` -} - -async function readBody(req: IncomingMessage): Promise { - const body = await new Promise((resolve, reject) => { - let data = "" - req.on("data", (chunk: Buffer) => { data += chunk.toString() }) - req.on("end", () => resolve(data)) - req.on("error", reject) - }) - return JSON.parse(body) as T -} - -function isAuthed(req: IncomingMessage): boolean { - if (!REQUIRED_TOKEN) return true - const auth = req.headers.authorization || "" - return auth === `token ${REQUIRED_TOKEN}` || auth === `Bearer ${REQUIRED_TOKEN}` -} - function buildGistFile(origin: string, gistId: string, filename: string, content: string): GistFile { const encodedFilename = encodeURIComponent(filename) return { @@ -167,127 +137,156 @@ function updateExistingGist(origin: string, gist: Gist, form: GistForm): Gist { return next } -function notFound(res: ServerResponse) { - sendJson(res, 404, { message: "Not Found" }) -} - -function unauthorized(res: ServerResponse) { - sendJson(res, 401, { message: "Bad credentials" }) -} - -function validationFailed(res: ServerResponse, message: string) { - sendJson(res, 422, { message }) -} - -function listGists(req: IncomingMessage, res: ServerResponse) { - const url = new URL(req.url || "/", getOrigin(req)) - const perPageRaw = Number(url.searchParams.get("per_page") || "30") - const pageRaw = Number(url.searchParams.get("page") || "1") - const perPage = Math.min(100, Math.max(1, Number.isNaN(perPageRaw) ? 30 : perPageRaw)) - const page = Math.max(1, Number.isNaN(pageRaw) ? 1 : pageRaw) - const since = url.searchParams.get("since") - const all = gistOrder.map(id => gists.get(id)).filter(Boolean) as Gist[] - const filtered = since - ? all.filter(gist => gist.updated_at > since) - : all - const start = (page - 1) * perPage - const end = start + Math.max(0, perPage) - sendJson(res, 200, filtered.slice(start, end)) -} -async function handleCreateGist(req: IncomingMessage, res: ServerResponse) { - const form = await readBody(req) - if (!form.files || Object.keys(form.files).length === 0) { - return validationFailed(res, "Validation failed: files is required") - } - const origin = getOrigin(req) - const gist = buildCreatedGist(origin, form) - gists.set(gist.id, gist) - gistOrder.unshift(gist.id) - sendJson(res, 201, gist) -} - -function handleGetGist(req: IncomingMessage, res: ServerResponse, gistId: string) { - const gist = gists.get(gistId) - if (!gist) return notFound(res) - sendJson(res, 200, gist) -} +class Handler { + private readonly req: IncomingMessage + private readonly res: ServerResponse + private readonly origin: string + private readonly url: URL -async function handleUpdateGist(req: IncomingMessage, res: ServerResponse, gistId: string) { - const gist = gists.get(gistId) - if (!gist) return notFound(res) - const form = await readBody(req) - if (!form.description && !form.files) { - return validationFailed(res, "Validation failed: description or files is required") + constructor(req: IncomingMessage, res: ServerResponse) { + this.req = req + this.res = res + this.origin = `http://${req.headers.host ?? `localhost:${PORT}`}` + this.url = new URL(req.url ?? "/", this.origin) } - const updated = updateExistingGist(getOrigin(req), gist, form) - gists.set(gistId, updated) - sendJson(res, 200, updated) -} - -function handleDeleteGist(res: ServerResponse, gistId: string) { - if (!gists.has(gistId)) return notFound(res) - gists.delete(gistId) - const idx = gistOrder.findIndex(id => id === gistId) - if (idx >= 0) gistOrder.splice(idx, 1) - sendNoContent(res) -} -function handleRawFile(res: ServerResponse, gistId: string, filename: string) { - const gist = gists.get(gistId) - const file = gist?.files[filename] - if (!file) return notFound(res) - res.writeHead(200, { "Content-Type": file.type || "text/plain" }) - res.end(file.content || "") -} - -function main() { - const server = createServer(async (req, res) => { + handle() { try { - const needAuth = req.method === "GET" || req.method === "POST" || req.method === "PATCH" || req.method === "DELETE" - if (needAuth && !isAuthed(req)) { - return unauthorized(res) - } - - if (req.method === "HEAD") { - res.writeHead(200).end() - return - } - - const origin = getOrigin(req) - const parsed = new URL(req.url || "/", origin) - const pathname = parsed.pathname + const { method, url, headers } = this.req + console.log(`${method} ${url} ${headers.authorization}`) - if (req.method === "GET" && pathname === "/gists") { - return listGists(req, res) - } + if (!this.isAuthValid()) return this.unauthorized() - if (req.method === "POST" && pathname === "/gists") { - return await handleCreateGist(req, res) - } + if (method === "HEAD") return this.sendNoContent() + const { pathname } = this.url + if (method === "GET" && pathname === "/gists") return this.listGists() + if (method === "POST" && pathname === "/gists") return this.createGist() const gistMatch = pathname.match(/^\/gists\/([^/]+)$/) if (gistMatch?.[1]) { const gistId = gistMatch[1] - if (req.method === "GET") return handleGetGist(req, res, gistId) - if (req.method === "POST") return await handleUpdateGist(req, res, gistId) - if (req.method === "PATCH") return await handleUpdateGist(req, res, gistId) - if (req.method === "DELETE") return handleDeleteGist(res, gistId) + if (method === "GET") return this.getGist(gistId) + if (method === "POST") return this.updateGist(gistId) + if (method === "PATCH") return this.updateGist(gistId) + if (method === "DELETE") return this.deleteGist(gistId) } const rawMatch = pathname.match(/^\/raw\/([^/]+)\/(.+)$/) - if (req.method === "GET" && rawMatch?.[1] && rawMatch?.[2]) { + if (method === "GET" && rawMatch?.[1] && rawMatch?.[2]) { const gistId = rawMatch[1] const filename = decodeURIComponent(rawMatch[2]) - return handleRawFile(res, gistId, filename) + return this.getRawFile(gistId, filename) } - return notFound(res) + return this.notFound() } catch (e) { console.error("Mock gist server error:", e) - sendJson(res, 400, { message: "Bad Request" }) + this.sendJson(400, { message: "Bad Request" }) } - }) + } + + private isAuthValid(): boolean { + const { method, headers: { authorization } } = this.req + const needAuth = method === "GET" || method === "POST" || method === "PATCH" || method === "DELETE" + if (!needAuth) return true + return !REQUIRED_TOKEN || `token ${REQUIRED_TOKEN}` === authorization + } + + private async readBody(): Promise { + const body = await new Promise((resolve, reject) => { + let data = "" + this.req.on("data", (chunk: Buffer) => { data += chunk.toString() }) + this.req.on("end", () => resolve(data)) + this.req.on("error", reject) + }) + return JSON.parse(body) as T + } + + private listGists() { + const perPageRaw = Number(this.url.searchParams.get("per_page") || "30") + const pageRaw = Number(this.url.searchParams.get("page") || "1") + const perPage = Math.min(100, Math.max(1, Number.isNaN(perPageRaw) ? 30 : perPageRaw)) + const page = Math.max(1, Number.isNaN(pageRaw) ? 1 : pageRaw) + const since = this.url.searchParams.get("since") + const all = gistOrder.map(id => gists.get(id)).filter(Boolean) as Gist[] + const filtered = since + ? all.filter(gist => gist.updated_at > since) + : all + const start = (page - 1) * perPage + const end = start + Math.max(0, perPage) + this.sendJson(200, filtered.slice(start, end)) + } + + private async createGist() { + const form = await this.readBody() + if (!form.files || Object.keys(form.files).length === 0) { + return this.validationFailed("Validation failed: files is required") + } + const gist = buildCreatedGist(this.origin, form) + gists.set(gist.id, gist) + gistOrder.unshift(gist.id) + this.sendJson(201, gist) + } + + private getGist(gistId: string) { + const gist = gists.get(gistId) + if (!gist) return this.notFound() + this.sendJson(200, gist) + } + + private async updateGist(gistId: string) { + const gist = gists.get(gistId) + if (!gist) return this.notFound() + const form = await this.readBody() + if (!form.description && !form.files) { + return this.validationFailed("Validation failed: description or files is required") + } + const updated = updateExistingGist(this.origin, gist, form) + gists.set(gistId, updated) + this.sendJson(200, updated) + } + + private async deleteGist(gistId: string) { + if (!gists.has(gistId)) return this.notFound() + gists.delete(gistId) + const idx = gistOrder.findIndex(id => id === gistId) + if (idx >= 0) gistOrder.splice(idx, 1) + this.sendNoContent() + } + + private getRawFile(gistId: string, filename: string) { + const gist = gists.get(gistId) + const file = gist?.files[filename] + if (!file) return this.notFound() + this.res.writeHead(200, { "Content-Type": file.type || "text/plain" }) + this.res.end(file.content || "") + } + + private sendJson(statusCode: number, payload: unknown) { + this.res.writeHead(statusCode, { "Content-Type": "application/json" }) + this.res.end(JSON.stringify(payload)) + } + + private unauthorized() { + this.sendJson(401, { message: "Bad credentials" }) + } + + private sendNoContent() { + this.res.writeHead(204).end() + } + + private validationFailed(message: string) { + this.sendJson(422, { message }) + } + + private notFound() { + this.sendJson(404, { message: "Not Found" }) + } +} + +function main() { + const server = createServer((req, res) => new Handler(req, res).handle()) server.listen(PORT, () => { console.log(`Gist mock server listening on http://localhost:${PORT}`) diff --git a/examples/package.json b/examples/package.json index 78537dceb..fdd09e3c0 100644 --- a/examples/package.json +++ b/examples/package.json @@ -1,5 +1,5 @@ { - "name": "@timer/examples", + "name": "@tt4b/examples", "version": "1.0.0", "private": true, "description": "Standalone examples package for local/e2e servers", diff --git a/jest.config.ts b/jest.config.ts deleted file mode 100644 index 0c46e306e..000000000 --- a/jest.config.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { readFileSync } from 'fs' -import type { Config } from "jest" -import { join } from 'path' - -const tsconfig = JSON.parse(readFileSync(join(process.cwd(), 'tsconfig.json'), 'utf-8')) -const { compilerOptions } = tsconfig as { compilerOptions: { paths: { [key: string]: string[] } } } - -const { paths } = compilerOptions - -const aliasPattern = /^(@.*)\/\*$/ -const sourcePattern = /^(.*)\/\*$/ - -const moduleNameMapper: { [key: string]: string } = {} - -Object.entries(paths).forEach(([alias, sourceArr]) => { - const aliasMatch = alias.match(aliasPattern) - if (!aliasMatch) { - return - } - if (!Array.isArray(sourceArr) || sourceArr.length !== 1) { - return - } - const sourceMath = sourceArr[0]?.match(sourcePattern) - if (!sourceMath) { - return - } - const prefix = aliasMatch[1] - const pattern = `^${prefix}/(.*)$` - const source = sourceMath[1] - const sourcePath = `/${source}/$1` - moduleNameMapper[pattern] = sourcePath -}) - -console.log("The moduleNameMapper parsed from tsconfig.json: ") -console.log(moduleNameMapper) - -const config: Config = { - moduleNameMapper, - roots: [ - "/test", - "/test-e2e", - ], - testRegex: '(.+)\\.test\\.(jsx?|tsx?)$', - transform: { - "^.+\\.tsx?$": "@swc/jest" - }, - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], - setupFiles: [ - '/test/jest.setup.ts', - ], -} - -export default config \ No newline at end of file diff --git a/knip.ts b/knip.ts new file mode 100644 index 000000000..fc9fdf434 --- /dev/null +++ b/knip.ts @@ -0,0 +1,34 @@ +import type { KnipConfig } from "knip" +const config: KnipConfig = { + entry: [ + "src/background/index.ts", + "src/pages/app/index.ts", + "src/pages/popup/{index,skeleton}.ts", + "src/pages/side/index.ts", + "src/content-script/index.ts", + "src/content-script/limit/modal/index.ts", + "script/user-chart/{add,render}.ts", + "examples/gist/mock-server.ts", + "examples/notification/demo-server.ts", + ], + ignoreDependencies: [ + "@rstest/coverage-istanbul", + "tsconfig-paths", + ], + rspack: { + config: ["rspack/rspack.{dev,prod,e2e,analyze}*.ts"], + }, + rstest: { + config: [ + "test/rstest.config.mts", + "test-e2e/rstest.config.mts", + ] + }, + commitlint: { + config: [ + ".commitlintrc.ts", + ] + } +} + +export default config \ No newline at end of file diff --git a/package.json b/package.json index ea402ea5b..617b0dfa5 100644 --- a/package.json +++ b/package.json @@ -1,5 +1,5 @@ { - "name": "timer", + "name": "tt4b", "version": "4.1.7", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", @@ -13,9 +13,9 @@ "build": "rspack --config=rspack/rspack.prod.ts", "build:firefox": "rspack --config=rspack/rspack.prod.firefox.ts", "build:safari": "rspack --config=rspack/rspack.prod.safari.ts", - "test": "jest --env=jsdom test/", - "test-c": "jest --coverage --reporters=jest-junit --env=jsdom test/", - "test-e2e": "jest test-e2e/ --runInBand", + "test": "rstest --config test/rstest.config.mts", + "test-c": "rstest --config test/rstest.config.mts --coverage --reporter=junit", + "test-e2e": "rstest --config test-e2e/rstest.config.mts", "prepare": "husky" }, "author": { @@ -28,47 +28,45 @@ }, "license": "MIT", "devDependencies": { + "@commitlint/types": "^20.5.0", "@crowdin/crowdin-api-client": "^1.55.1", "@emotion/babel-plugin": "^11.13.5", - "@emotion/css": "^11.13.5", "@rsdoctor/rspack-plugin": "^1.5.9", "@rspack/cli": "^2.0.0", "@rspack/core": "^2.0.0", - "@swc/core": "^1.15.30", - "@swc/jest": "^0.2.39", + "@rstest/core": "^0.9.9", + "@rstest/coverage-istanbul": "^0.3.2", "@types/chrome": "0.1.40", "@types/decompress": "^4.2.7", "@types/firefox-webext-browser": "^143.0.0", - "@types/jest": "^30.0.0", "@types/node": "^25.6.0", - "@types/punycode": "^2.1.4", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.1.1", - "commitlint": "^20.5.0", + "commitlint": "^20.5.2", "css-loader": "^7.1.4", "decompress": "^4.2.1", "fake-indexeddb": "^6.2.5", + "fork-ts-checker-webpack-plugin": "^9.1.0", "husky": "^9.1.7", - "jest": "^30.3.0", - "jest-environment-jsdom": "^30.3.0", - "jest-junit": "^16.0.0", + "jsdom": "^29.0.2", "jszip": "^3.10.1", - "postcss": "^8.5.10", + "knip": "^6.7.0", + "postcss": "^8.5.12", "postcss-loader": "^8.2.1", "postcss-rtlcss": "^6.0.0", "puppeteer": "^24.42.0", - "ts-loader": "^9.5.7", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "6.0.3" + "typescript": "6.0.3", + "unplugin-element-plus": "^0.11.2" }, "dependencies": { "@element-plus/icons-vue": "^2.3.2", + "@emotion/css": "^11.13.5", "echarts": "^6.0.0", "element-plus": "2.13.7", "hash.js": "^1.1.7", - "punycode": "^2.3.1", - "typescript-guard": "^0.2.4", + "typescript-guard": "0.2.4", "vue": "^3.5.33", "vue-router": "^5.0.6" }, diff --git a/rspack/plugins/file-manager.ts b/rspack/plugins/file-manager.ts index 01f442f02..224d99228 100644 --- a/rspack/plugins/file-manager.ts +++ b/rspack/plugins/file-manager.ts @@ -1,4 +1,4 @@ -import type { Compiler } from '@rspack/core' +import type { Compiler, RspackPluginInstance } from '@rspack/core' import fs from 'fs' import JSZip from 'jszip' import path from 'path' @@ -23,7 +23,8 @@ interface FileManagerOptions { context?: string } -export class FileManagerPlugin { +export class FileManagerPlugin implements RspackPluginInstance { + private static readonly NAME = 'FileManagerPlugin' private options: FileManagerOptions private outputPath: string @@ -33,11 +34,11 @@ export class FileManagerPlugin { } apply(compiler: Compiler) { - compiler.hooks.afterEnvironment.tap('FileManagerPlugin', () => { + compiler.hooks.afterEnvironment.tap(FileManagerPlugin.NAME, () => { this.outputPath = compiler.options.output.path || 'dist' }) - compiler.hooks.done.tapPromise('FileManagerPlugin', async () => { + compiler.hooks.done.tapPromise(FileManagerPlugin.NAME, async () => { if (this.options.events.onEnd) { for (const op of this.options.events.onEnd) { await this.processOperation(op) diff --git a/rspack/plugins/generate-json.ts b/rspack/plugins/generate-json.ts index 5117dada8..d635096a9 100644 --- a/rspack/plugins/generate-json.ts +++ b/rspack/plugins/generate-json.ts @@ -1,38 +1,27 @@ -import { Compilation, Compiler, sources } from '@rspack/core' +import { Compilation, type Compiler, type RspackPluginInstance, sources } from '@rspack/core' -type GenerateJsonPluginOptions = { - data: unknown - outputPath: string -} +export class GenerateJsonPlugin implements RspackPluginInstance { + private static readonly NAME = 'GenerateJsonPlugin' -export class GenerateJsonPlugin { - private options: GenerateJsonPluginOptions - - constructor(outputPath: string, data: unknown) { + constructor(private outputPath: string, private data: unknown) { if (!data || typeof data !== 'object') { throw new Error('Invalid data option') } - if (!outputPath?.endsWith('.json')) { + if (!outputPath.endsWith('.json')) { throw new Error('outputPath must be .json file') } - - this.options = { outputPath, data } } apply(compiler: Compiler) { - compiler.hooks.thisCompilation.tap('GenerateJsonPlugin', (compilation) => { + compiler.hooks.thisCompilation.tap(GenerateJsonPlugin.NAME, compilation => { compilation.hooks.processAssets.tap({ - name: 'GenerateJsonPlugin', + name: GenerateJsonPlugin.NAME, stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, }, () => { try { - const json = JSON.stringify(this.options.data) - - compilation.emitAsset(this.options.outputPath, { - source: () => json, - size: () => json.length - } as sources.Source) - + const json = JSON.stringify(this.data) + const raw = new sources.RawSource(json) + compilation.emitAsset(this.outputPath, raw) } catch (e) { compilation.errors.push(new Error(`[SimpleWriteJson] ${(e as Error)?.message}`)) } diff --git a/rspack/plugins/import-checker.ts b/rspack/plugins/import-checker.ts new file mode 100644 index 000000000..bd094d3df --- /dev/null +++ b/rspack/plugins/import-checker.ts @@ -0,0 +1,97 @@ +import type { Compiler, Module, ModuleGraph, RspackPluginInstance } from '@rspack/core' +import { NormalModule } from '@rspack/core' + +/** + * Can't import content-script & pages for background + * Can't import background for content-script & pages + */ +class ImportCheckerPlugin implements RspackPluginInstance { + static readonly NAME = 'ImportCheckerPlugin' + + apply(compiler: Compiler) { + compiler.hooks.compilation.tap(ImportCheckerPlugin.NAME, compilation => { + const moduleGraph = compilation.moduleGraph + compilation.hooks.finishModules.tap( + ImportCheckerPlugin.NAME, + modules => { + for (const mod of modules) { + processModule(mod, moduleGraph) + } + }, + ) + }) + } +} + +function processModule(mod: Module, moduleGraph: ModuleGraph) { + const resource = moduleFilesystemPath(mod) + if (!resource) return + + const incoming = moduleGraph.getIncomingConnections(mod) + if (!incoming?.length) return + + for (const conn of incoming) { + const originMod = conn.originModule + if (!originMod) continue + const issuer = moduleFilesystemPath(originMod) + if (!issuer) continue + const err = verify(issuer, resource) + if (err) throw err + } +} + +function verify(issuer: string, resource: string): Error | undefined { + const issuerPath = normalizePath(issuer) + const resourcePath = normalizePath(resource) + + const issuerInBg = isBgPath(issuerPath) + const issuerInOthers = isCsOrPagePath(issuerPath) + + const resourceInBg = isBgPath(resourcePath) + const resourceInOthers = isCsOrPagePath(resourcePath) + + if (issuerInBg && resourceInOthers) { + return new Error( + `[${ImportCheckerPlugin.NAME}] background must not import content-script or pages.\n` + + ` From: ${issuer}\n` + + ` To: ${resource}`, + ) + } + + if (issuerInOthers && resourceInBg) { + return new Error( + `[${ImportCheckerPlugin.NAME}] content-script and pages must not import background.\n` + + ` From: ${issuer}\n` + + ` To: ${resource}`, + ) + } +} + +function moduleFilesystemPath(mod: Module): string | undefined { + if (mod instanceof NormalModule) { + const { resource } = mod + if (resource) return resource + } + return mod.nameForCondition() +} + +function normalizePath(p: string): string { + return p.replace(/\\/g, '/') +} + +export function isBgPath(path: string): boolean { + return isUnder(normalizePath(path), 'background') +} + +function isCsOrPagePath(path: string): boolean { + return isUnder(path, 'content-script') || isUnder(path, 'pages') +} + +function isUnder(path: string, segment: string): boolean { + const marker = `/src/${segment}/` + if (path.includes(marker)) return true + const suffix = `/src/${segment}` + return path.endsWith(suffix) +} + +export default ImportCheckerPlugin diff --git a/rspack/rspack.common.ts b/rspack/rspack.common.ts index c55c05a0d..6f2bde349 100644 --- a/rspack/rspack.common.ts +++ b/rspack/rspack.common.ts @@ -1,15 +1,17 @@ import { CopyRspackPlugin, CssExtractRspackPlugin, DefinePlugin, HtmlRspackPlugin, - type Chunk, type Configuration, - type RspackPluginInstance, - type RuleSetRule + type Chunk, type Configuration, type Module, type RspackPluginInstance, type RuleSetRule } from "@rspack/core" +import { default as VueBabelPluginJsx } from "@vue/babel-plugin-jsx" import path, { join } from "path" import postcssRTLCSS from 'postcss-rtlcss' +import ElementPlus from 'unplugin-element-plus/rspack' import i18nChrome from "../src/i18n/chrome" +import { compilerOptions } from "../tsconfig.json" import { GenerateJsonPlugin } from "./plugins/generate-json" +import ImportCheckerPlugin, { isBgPath } from "./plugins/import-checker" -export const MANIFEST_JSON_NAME = "manifest.json" +const MANIFEST_JSON_NAME = "manifest.json" const generateJsonPlugins: RspackPluginInstance[] = [] @@ -24,7 +26,7 @@ type EntryConfig = { const BACKGROUND = 'background' const CONTENT_SCRIPT = 'content_scripts' -const CONTENT_SCRIPT_SKELETON = 'content_scripts_skeleton' +const CONTENT_SCRIPT_LIMIT = 'content_scripts_limit' const POPUP = 'popup' const entryConfigs: EntryConfig[] = [{ @@ -34,8 +36,8 @@ const entryConfigs: EntryConfig[] = [{ name: CONTENT_SCRIPT, path: './src/content-script', }, { - name: CONTENT_SCRIPT_SKELETON, - path: './src/content-script/skeleton', + name: CONTENT_SCRIPT_LIMIT, + path: './src/content-script/limit/modal', }, { name: POPUP, path: './src/pages/popup', @@ -60,9 +62,11 @@ const POSTCSS_LOADER_CONF: RuleSetRule['use'] = { } const chunkFilter = ({ name }: Chunk) => { - return !name || ![BACKGROUND, CONTENT_SCRIPT, CONTENT_SCRIPT_SKELETON].includes(name) + return !name || ![BACKGROUND, CONTENT_SCRIPT].includes(name) } +const isBackgroundModule = (module: Module) => isBgPath(module.nameForCondition?.() ?? '') + const staticOptions: Configuration = { entry() { const entry: Record = {} @@ -82,11 +86,27 @@ const staticOptions: Configuration = { iterableIsArray: true, }, plugins: [ - "@vue/babel-plugin-jsx", + VueBabelPluginJsx, "@emotion/babel-plugin", ], }, - }, 'ts-loader'], + }, { + loader: 'builtin:swc-loader', + options: { + jsc: { + parser: { + syntax: 'typescript', + tsx: true, + }, + transform: { + react: { + runtime: 'preserve', + }, + }, + target: compilerOptions.target, + }, + }, + }], }, { test: /\.css$/, use: [CssExtractRspackPlugin.loader, 'css-loader', POSTCSS_LOADER_CONF], @@ -99,18 +119,79 @@ const staticOptions: Configuration = { resolve: { extensions: ['.ts', '.tsx', '.js', '.css'], tsConfig: join(__dirname, '..', 'tsconfig.json'), + conditionNames: ['import', 'module', 'browser', 'default'], + alias: { + 'element-plus/es/components/loading-service/style/css': 'element-plus/es/components/loading/style/css', + 'element-plus/es/components/loading-directive/style/css': 'element-plus/es/components/loading/style/css', + 'element-plus/es/components/auto-resizer/style/css': 'element-plus/es/components/table-v2/style/css', + }, }, optimization: { splitChunks: { chunks: chunkFilter, + maxInitialRequests: 30, + maxAsyncRequests: 30, cacheGroups: { + echarts: { + test: /[\\/]node_modules[\\/]echarts[\\/]/, + name: 'vendor/echarts', + filename: 'vendor/echarts.js', + priority: 40, + reuseExistingChunk: true, + enforce: true, + }, elementPlus: { - name: 'element-plus', test: /[\\/]node_modules[\\/]element-plus[\\/]/, + name: 'vendor/element-plus', + filename: 'vendor/element-plus.js', + priority: 39, + reuseExistingChunk: true, + enforce: true, + }, + elementIcons: { + test: /[\\/]node_modules[\\/]@element-plus[\\/]icons-vue[\\/]/, + name: 'vendor/el-icons', + filename: 'vendor/el-icons.js', + priority: 38, + reuseExistingChunk: true, + enforce: true, + }, + vue: { + test: /[\\/]node_modules[\\/](vue|@vue|vue-router|@vueuse)[\\/]/, + name: 'vendor/vue', + filename: 'vendor/vue.js', + priority: 37, + reuseExistingChunk: true, + enforce: true, + }, + dayjs: { + test: /[\\/node_modules][\\/]dayjs[\\/]/, + priority: 37, + reuseExistingChunk: true, + enforce: true, + }, + memoizeOne: { + test: /[\\/node_modules][\\/]memoize\\-one[\\/]/, + priority: 37, + reuseExistingChunk: true, + enforce: true, + }, + /** + * Exclude src/background from the default shared chunk group so those files are + * never pulled into vendor/* (merging into entry name: 'background' panics in Rspack). + */ + default: { + minChunks: 2, + priority: -20, + reuseExistingChunk: true, + test: module => !isBackgroundModule(module), }, defaultVendors: { - filename: 'vendor/[name].js' - } + test: /[\\/]node_modules[\\/]/, + filename: 'vendor/[name].js', + priority: -10, + reuseExistingChunk: true, + }, } }, }, @@ -125,7 +206,9 @@ type Option = { const generateOption = ({ outputPath, manifest, mode }: Option) => { const plugins = [ ...generateJsonPlugins, + ElementPlus({}), new GenerateJsonPlugin(MANIFEST_JSON_NAME, manifest), + new ImportCheckerPlugin(), // copy static resources new CopyRspackPlugin({ patterns: [ @@ -135,7 +218,7 @@ const generateOption = ({ outputPath, manifest, mode }: Option) => { } ] }), - new CssExtractRspackPlugin(), + new CssExtractRspackPlugin({ ignoreOrder: true }), new HtmlRspackPlugin({ filename: path.join('static', 'app.html'), title: 'Loading...', @@ -147,6 +230,17 @@ const generateOption = ({ outputPath, manifest, mode }: Option) => { }, chunks: ['app'], }), + new HtmlRspackPlugin({ + filename: path.join('static', 'limit.html'), + title: 'Loading...', + chunks: [CONTENT_SCRIPT_LIMIT], + meta: { + viewport: { + name: "viewport", + content: 'width=device-width', + }, + } + }), new HtmlRspackPlugin({ filename: path.join('static', 'popup.html'), chunks: ['popup'], diff --git a/rspack/rspack.dev.firefox.ts b/rspack/rspack.dev.firefox.ts index 71eef5dbc..adb49d4e2 100644 --- a/rspack/rspack.dev.firefox.ts +++ b/rspack/rspack.dev.firefox.ts @@ -8,7 +8,7 @@ manifest.browser_specific_settings = { ...manifest.browser_specific_settings, gecko: { ...manifest.browser_specific_settings?.gecko, - id: 'timer@zhy', + id: 'tt4b@zhy', } } diff --git a/rspack/rspack.dev.ts b/rspack/rspack.dev.ts index 18ce606d4..5cf59f91e 100644 --- a/rspack/rspack.dev.ts +++ b/rspack/rspack.dev.ts @@ -1,3 +1,4 @@ +import ForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin" import path from "path" import manifest from "../src/manifest" import generateOption from "./rspack.common" @@ -10,4 +11,27 @@ const options = generateOption({ mode: "development", }) +const tsCheckerPlugin = new ForkTsCheckerWebpackPlugin({ + typescript: { + configOverwrite: { + compilerOptions: { + skipLibCheck: false, + }, + }, + diagnosticOptions: { + syntactic: true, + semantic: true, + declaration: true, + global: true, + }, + }, + issue: { + exclude: [ + { file: '**/node_modules/**' }, + ], + }, +}) + +options.plugins?.push(tsCheckerPlugin) + export default options diff --git a/rspack/rspack.prod.firefox.ts b/rspack/rspack.prod.firefox.ts index b36fa0cb2..b76b4c28f 100644 --- a/rspack/rspack.prod.firefox.ts +++ b/rspack/rspack.prod.firefox.ts @@ -2,6 +2,7 @@ import path from "path" import manifestFirefox from "../src/manifest-firefox" import { FileManagerPlugin } from "./plugins/file-manager" import optionGenerator from "./rspack.common" +import { enhancePluginWith } from './util' const { name, version } = require(path.join(__dirname, '..', 'package.json')) @@ -10,29 +11,10 @@ const marketPkgPath = path.resolve(__dirname, '..', 'market_packages') const normalZipFilePath = path.resolve(marketPkgPath, `${name}-${version}.firefox.zip`) const targetZipFilePath = path.resolve(marketPkgPath, 'target.firefox.zip') -const normalSourceCodePath = path.resolve(__dirname, '..', 'market_packages', `${name}-${version}-src.zip`) -const targetSourceCodePath = path.resolve(__dirname, '..', 'market_packages', 'target.src.zip') -const readmeForFirefox = path.join(__dirname, '..', 'doc', 'for-firefox.md') -// Temporary directory for source code to archive on Firefox -const sourceTempDir = path.resolve(__dirname, '..', 'source_temp') -const srcDir = [ - 'public', - 'src', - 'test', 'types', - 'package.json', 'package-lock.json', - 'tsconfig.json', - 'rspack', - 'jest.config.ts', - 'script', - ".gitignore", -] -const copyMapper = srcDir.map(p => { return { source: path.resolve(__dirname, '..', p), destination: path.resolve(sourceTempDir, p) } }) -const filemanagerPlugin = new FileManagerPlugin({ +const fileManagerPlugin = new FileManagerPlugin({ events: { - // Archive at the end onEnd: [ - // Define plugin to archive zip for different markets { delete: [normalZipFilePath], archive: [{ @@ -43,26 +25,12 @@ const filemanagerPlugin = new FileManagerPlugin({ destination: targetZipFilePath, }] }, - // Archive source code for FireFox - { - copy: [ - { source: readmeForFirefox, destination: path.join(sourceTempDir, 'README.md') }, - ...copyMapper - ], - archive: [ - { source: sourceTempDir, destination: normalSourceCodePath }, - { source: sourceTempDir, destination: targetSourceCodePath }, - ], - delete: [sourceTempDir], - }, ] } }) const option = optionGenerator({ outputPath, manifest: manifestFirefox, mode: "production" }) -const { plugins = [] } = option -plugins.push(filemanagerPlugin) -option.plugins = plugins +enhancePluginWith(option, fileManagerPlugin) option.devtool = false -export default option \ No newline at end of file +export default option diff --git a/rspack/rspack.prod.safari.ts b/rspack/rspack.prod.safari.ts index 5a9dd8a36..e4f53201e 100644 --- a/rspack/rspack.prod.safari.ts +++ b/rspack/rspack.prod.safari.ts @@ -10,7 +10,7 @@ const normalZipFilePath = path.resolve(__dirname, '..', 'market_packages', `${na const options = generateOption({ outputPath, manifest, mode: "production" }) -const filemanagerPlugin = new FileManagerPlugin({ +const fileManagerPlugin = new FileManagerPlugin({ events: { // Archive at the end onEnd: [ @@ -25,7 +25,7 @@ const filemanagerPlugin = new FileManagerPlugin({ }) const { plugins = [] } = options -plugins.push(filemanagerPlugin) +plugins.push(fileManagerPlugin) options.plugins = plugins export default options \ No newline at end of file diff --git a/rspack/rspack.prod.ts b/rspack/rspack.prod.ts index 47df7f112..6ef6db0c1 100644 --- a/rspack/rspack.prod.ts +++ b/rspack/rspack.prod.ts @@ -12,7 +12,7 @@ const marketPkgPath = path.resolve(__dirname, '..', 'market_packages') const normalZipFilePath = path.resolve(marketPkgPath, `${name}-${version}.mv3.zip`) const targetZipFilePath = path.resolve(marketPkgPath, `target.zip`) -const filemanagerPlugin = new FileManagerPlugin({ +const fileManagerPlugin = new FileManagerPlugin({ events: { // Archive at the end onEnd: [ @@ -34,7 +34,7 @@ const filemanagerPlugin = new FileManagerPlugin({ const option = optionGenerator({ outputPath, manifest, mode: "production" }) -enhancePluginWith(option, filemanagerPlugin) +enhancePluginWith(option, fileManagerPlugin) option.devtool = false export default option \ No newline at end of file diff --git a/rspack/util.ts b/rspack/util.ts index 65114cee9..6df6c9490 100644 --- a/rspack/util.ts +++ b/rspack/util.ts @@ -1,4 +1,4 @@ -import { type RspackOptions, type RspackPluginInstance } from '@rspack/core' +import type { RspackOptions, RspackPluginInstance } from '@rspack/core' export function enhancePluginWith(option: RspackOptions, ...toPush: RspackPluginInstance[]) { const { plugins = [] } = option diff --git a/script/crowdin/client.ts b/script/crowdin/client.ts index 2b9361f56..c07b46f61 100644 --- a/script/crowdin/client.ts +++ b/script/crowdin/client.ts @@ -79,8 +79,8 @@ class PaginationIterator { private async processBuf() { const pagination: Pagination = { offset: this.offset, limit: this.limit } const list = await this.query(pagination) - const data = list?.data - if (!data?.length) { + const data = list.data + if (!data.length) { this.isEnd = true } else { this.buf = data.map(obj => obj.data) @@ -98,7 +98,7 @@ export type NameKey = { branchId: number } -export type TranslationKey = { +type TranslationKey = { stringId: number lang: CrowdinLanguage } @@ -230,7 +230,7 @@ export class CrowdinClient { for (const [path, value] of Object.entries(content)) { const string = existStringsKeyMap[path] const patch: PatchRequest[] = [] - string?.text !== value && patch.push({ + string.text !== value && patch.push({ op: 'replace', path: '/text', value: value @@ -280,12 +280,11 @@ export class CrowdinClient { targetLanguageIds: [...ALL_CROWDIN_LANGUAGES], skipUntranslatedStrings: true, }) - const buildId = buildRes?.data?.id + const buildId = buildRes.data.id while (true) { // Wait finished const res = await this.crowdin.translationsApi.downloadTranslations(PROJECT_ID, buildId) - const url = res?.data?.url - if (url) return url + return res.data.url } } } @@ -296,7 +295,7 @@ export class CrowdinClient { * @returns client */ export function getClientFromEnv(): CrowdinClient { - const envVar = process.env?.TIMER_CROWDIN_AUTH + const envVar = process.env.TIMER_CROWDIN_AUTH if (!envVar) { console.error("Failed to get the variable named [TIMER_CROWDIN_AUTH]") process.exit(1) diff --git a/script/crowdin/common.ts b/script/crowdin/common.ts index 96d8fa686..a1148c67d 100644 --- a/script/crowdin/common.ts +++ b/script/crowdin/common.ts @@ -52,22 +52,6 @@ const OPTIONAL_PLACEHOLDER: Record = { export const ALL_TRANS_LOCALES = Object.keys(OPTIONAL_PLACEHOLDER) as timer.OptionalLocale[] -const CROWDIN_I18N_MAP: Record = { - "zh-CN": 'zh_CN', - ja: 'ja', - 'zh-TW': 'zh_TW', - 'pt-PT': 'pt_PT', - uk: 'uk', - 'es-ES': 'es', - de: 'de', - fr: 'fr', - ru: 'ru', - ar: 'ar', - tr: 'tr', - pl: 'pl', - it: 'it', -} - const I18N_CROWDIN_MAP: Record = { zh_CN: 'zh-CN', ja: 'ja', @@ -86,8 +70,6 @@ const I18N_CROWDIN_MAP: Record = { export const crowdinLangOf = (locale: timer.OptionalLocale): CrowdinLanguage => I18N_CROWDIN_MAP[locale] -export const localeOf = (crowdinLang: CrowdinLanguage) => CROWDIN_I18N_MAP[crowdinLang] - const IGNORED_FILE: Partial<{ [dir in Dir]: string[] }> = { common: [ // Strings for market @@ -101,7 +83,7 @@ export function isIgnored(dir: Dir, fileName: string) { return !!IGNORED_FILE[dir]?.includes(fileName) } -export const MSG_BASE = path.join(__dirname, '..', '..', 'src', 'i18n', 'message') +const MSG_BASE = path.join(__dirname, '..', '..', 'src', 'i18n', 'message') export const RSC_FILE_SUFFIX = "-resource.json" /** diff --git a/script/crowdin/sync-translation.ts b/script/crowdin/sync-translation.ts index 184cd3026..56294533e 100644 --- a/script/crowdin/sync-translation.ts +++ b/script/crowdin/sync-translation.ts @@ -28,12 +28,12 @@ async function processDirMessage(client: CrowdinClient, file: SourceFilesModel.F } const existList = await client.listTranslationByStringAndLang({ stringId: string.id, lang }) // Deleted old translations different from current - const oldByOwner = existList?.filter(t => t?.user?.id === CROWDIN_USER_ID_OF_OWNER && t.text !== text) + const oldByOwner = existList.filter(t => t.user.id === CROWDIN_USER_ID_OF_OWNER && t.text !== text) for (const toDelete of oldByOwner || []) { await client.deleteTranslation(toDelete.id) console.log(`Deleted translation by owner: stringId=${string.id}, lang=${lang}, text=${toDelete.text}`) } - if (!existList?.find(t => t.text === text)) { + if (!existList.some(t => t.text === text)) { // Create new translation await client.createTranslation({ stringId: string.id, lang }, text) console.log(`Created trans: stringId=${string.id}, lang=${lang}, text=${text}`) diff --git a/script/psl.ts b/script/psl.ts index aea4ced6d..fc33d526f 100644 --- a/script/psl.ts +++ b/script/psl.ts @@ -1,14 +1,13 @@ /** * Build psl tree */ +import { type PslTree } from '@/background/psl' import { fetchGet } from '@api/http' -import { type PslTree } from '@util/psl' import { writeFileSync } from 'fs' import path from 'path' -import punycode from "punycode" const LIST_URL = "https://publicsuffix.org/list/effective_tld_names.dat" -const JSON_PATH = path.join(__dirname, "..", "src", "util", "psl", "rules.json") +const JSON_PATH = path.join(__dirname, "..", "src", "background", "psl", "rules.json") const downloadList = async (): Promise => { const response = await fetchGet(LIST_URL) @@ -18,7 +17,7 @@ const downloadList = async (): Promise => { const parse = (tree: PslTree, parts: string[], index: number) => { if (index < 0) return const part = parts[index] - const ascii = punycode.toASCII(part) + const ascii = new URL(`http://${part}`).hostname let node = tree[ascii] if (index === 0) { if (!node) tree[ascii] = 1 @@ -58,6 +57,4 @@ async function main() { writeFileSync(JSON_PATH, JSON.stringify(tree, null, 4), { encoding: "utf-8" }) } - - main() \ No newline at end of file diff --git a/script/setup-e2e.sh b/script/setup-e2e.sh index 63b76f057..09b1da86e 100755 --- a/script/setup-e2e.sh +++ b/script/setup-e2e.sh @@ -204,11 +204,6 @@ build_e2e_output() { build_npm_script "dev:e2e" "dist_e2e" true } -# Build production output (optional) -build_production_output() { - build_npm_script "build" "dist_prod" false -} - is_port_open() { local port=$1 lsof -nP -iTCP:"$port" -sTCP:LISTEN > /dev/null 2>&1 @@ -313,7 +308,6 @@ show_usage() { echo " --init, -i Initialize e2e environment (install dependencies if not installed)" echo " --upgrade, -u Upgrade e2e dependencies (http-server, pm2)" echo " --build, -b Build e2e output (runs 'npm run dev:e2e')" - echo " --build-prod Also build production output (runs 'npm run build')" echo " --start-servers, -s Start test servers (12345/12346) and gist mock server (12347)" echo " --all, -a Run all steps (init + build + build-prod)" echo " --help, -h Show this help message" @@ -332,7 +326,6 @@ main() { local do_init=false local do_upgrade=false local do_build=false - local do_build_prod=false local do_start_servers=false # Parse arguments @@ -350,10 +343,6 @@ main() { do_build=true shift ;; - --build-prod) - do_build_prod=true - shift - ;; --start-servers|-s) do_start_servers=true shift @@ -361,7 +350,6 @@ main() { --all|-a) do_init=true do_build=true - do_build_prod=true do_start_servers=true shift ;; @@ -377,7 +365,7 @@ main() { esac done - if [ "$do_init" = false ] && [ "$do_upgrade" = false ] && [ "$do_build" = false ] && [ "$do_build_prod" = false ] && [ "$do_start_servers" = false ]; then + if [ "$do_init" = false ] && [ "$do_upgrade" = false ] && [ "$do_build" = false ] && [ "$do_start_servers" = false ]; then show_usage exit 0 fi @@ -417,10 +405,6 @@ main() { build_e2e_output fi - if [ "$do_build_prod" = true ]; then - build_production_output - fi - if [ "$do_start_servers" = true ]; then start_test_servers fi diff --git a/script/source-archive.sh b/script/source-archive.sh new file mode 100755 index 000000000..fd7bd06d9 --- /dev/null +++ b/script/source-archive.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +mkdir -p market_packages +git archive --format=zip -o market_packages/target.src.zip HEAD +zip -u -q market_packages/target.src.zip package-lock.json +zip -d -q market_packages/target.src.zip README.md 2>/dev/null || true +TMP=$(mktemp -d) +cp doc/for-firefox.md "$TMP/README.md" +( cd "$TMP" && zip -u -j -q "$ROOT/market_packages/target.src.zip" README.md ) +rm -rf "$TMP" diff --git a/script/user-chart/add.ts b/script/user-chart/add.ts index 76357df59..00006a723 100644 --- a/script/user-chart/add.ts +++ b/script/user-chart/add.ts @@ -5,8 +5,8 @@ import { type Gist, type GistForm, updateGist as updateGistApi -} from "@src/api/gist" -import { CHROME_ID } from "@src/util/constant/meta" +} from "@api/gist" +import { CHROME_ID } from "@util/constant/meta" import fs from "fs" import { exitWith } from "../util/process" import { type Browser, descriptionOf, filenameOf, getExistGist, type UserCount, validateTokenFromEnv } from "./common" @@ -105,7 +105,7 @@ async function updateGist(token: string, browser: Browser, data: UserCount, gist function parseChrome(content: string): UserCount { const lines = content.split('\n') const result: Record = {} - if (!(lines?.length > 2)) { + if (!(lines.length > 2)) { return result } lines.slice(2).forEach(line => { @@ -124,7 +124,7 @@ function parseChrome(content: string): UserCount { function parseEdge(content: string): UserCount { const lines = content.split('\n') const result: Record = {} - if (!(lines?.length > 1)) { + if (!(lines.length > 1)) { return result } lines.slice(1).forEach(line => { @@ -145,7 +145,7 @@ function parseEdge(content: string): UserCount { function parseFirefox(content: string): UserCount { const lines = content.split('\n') const result: Record = {} - if (!(lines?.length > 4)) { + if (!(lines.length > 4)) { return result } lines.slice(4).forEach(line => { diff --git a/script/user-chart/common.ts b/script/user-chart/common.ts index 505fd3439..a3a30cfc5 100644 --- a/script/user-chart/common.ts +++ b/script/user-chart/common.ts @@ -1,5 +1,5 @@ import { findTarget, type Gist } from "@api/gist" -import { exitWith } from "../util/process" +import { exitWith } from '../util/process' export type Browser = | 'chrome' diff --git a/script/user-chart/render.ts b/script/user-chart/render.ts index 57f45b2ce..85d405836 100644 --- a/script/user-chart/render.ts +++ b/script/user-chart/render.ts @@ -13,9 +13,7 @@ import { filenameOf, getExistGist, validateTokenFromEnv, type Browser, type User type EcOption = ComposeOption< | LineSeriesOption | TitleComponentOption - | GridComponentOption -> - + | GridComponentOption> const ALL_BROWSERS: Browser[] = ['firefox', 'chrome', 'edge'] const POINT_COUNT = 500 diff --git a/src/api/chrome/action.ts b/src/api/chrome/action.ts index 17aa26a54..d32d1fb54 100644 --- a/src/api/chrome/action.ts +++ b/src/api/chrome/action.ts @@ -1,10 +1,12 @@ -import { IS_FIREFOX, IS_MV3 } from "@util/constant/environment" -import { handleError } from "./common" +import { IS_FIREFOX, IS_MV3 } from "@util/constant/environment"; +import { handleError } from "./common"; const action = IS_MV3 ? chrome.action : chrome.browserAction -export function setBadgeText(text: string, tabId: number | undefined): Promise { - return new Promise(resolve => action?.setBadgeText({ text, tabId }, () => { +export function setBadgeText(text: string, tabId?: number): Promise { + const details: { text: string; tabId?: number } = + tabId === undefined ? { text } : { text, tabId } + return new Promise(resolve => action?.setBadgeText(details, () => { handleError('setBadgeText') resolve() })) diff --git a/src/api/chrome/notifications.ts b/src/api/chrome/notifications.ts index 4f19e1f84..598d04697 100644 --- a/src/api/chrome/notifications.ts +++ b/src/api/chrome/notifications.ts @@ -1,7 +1,7 @@ import { IS_MV3 } from "@util/constant/environment" import { handleError } from "./common" -export type NotificationTopic = 'time' +type NotificationTopic = 'time' export async function createNotification( topic: NotificationTopic, @@ -23,20 +23,3 @@ export async function createNotification( } } -export async function clearNotification(notificationId: string): Promise { - if (IS_MV3) { - try { - await chrome.notifications.clear(notificationId) - return true - } catch { - return false - } - } else { - return new Promise(resolve => { - chrome.notifications.clear(notificationId, (wasCleared) => { - handleError('clearNotification') - resolve(wasCleared) - }) - }) - } -} diff --git a/src/api/chrome/permission.ts b/src/api/chrome/permission.ts index c98423958..1817d1413 100644 --- a/src/api/chrome/permission.ts +++ b/src/api/chrome/permission.ts @@ -1,7 +1,7 @@ import { IS_MV3 } from "@util/constant/environment" import { handleError } from "./common" -export async function hasPerm(perm: chrome.runtime.ManifestPermissions): Promise { +export async function hasPerm(perm: chrome.runtime.ManifestPermission): Promise { if (IS_MV3) { try { return await chrome.permissions.contains({ permissions: [perm] }) @@ -18,7 +18,7 @@ export async function hasPerm(perm: chrome.runtime.ManifestPermissions): Promise } } -export async function requestPerm(perm: chrome.runtime.ManifestPermissions): Promise { +export async function requestPerm(perm: chrome.runtime.ManifestPermission): Promise { if (IS_MV3) { try { return await chrome.permissions.request({ permissions: [perm] }) diff --git a/src/api/chrome/runtime.ts b/src/api/chrome/runtime.ts index 225398d4c..837ba2130 100644 --- a/src/api/chrome/runtime.ts +++ b/src/api/chrome/runtime.ts @@ -1,4 +1,3 @@ -import { handleError } from "./common" export function getRuntimeId(): string { return chrome.runtime.id @@ -12,61 +11,19 @@ export function getIconUrl(): string { return getUrl('static/images/icon.png') } -/** - * Fix proxy data failed to serialized in Firefox - */ -function cloneData(data: T | undefined): T | undefined { - if (data === undefined) return undefined - try { - return JSON.parse(JSON.stringify(data)) - } catch (cloneError) { - console.warn("Failed clone data", cloneError) - return data - } -} - -export function sendMsg2Runtime(code: timer.mq.ReqCode, data?: T): Promise { - const request: timer.mq.Request = { code, data: cloneData(data) } - return new Promise((resolve, reject) => { - const timeout = setTimeout(() => { - // timeout: no response from runtime - resolve(undefined) - }, 10_000) - try { - chrome.runtime.sendMessage(request, (response: timer.mq.Response) => { - clearTimeout(timeout) - handleError('sendMsg2Runtime') - const resCode = response?.code - resCode === 'fail' && reject(new Error(response?.msg || 'Unknown error')) - resCode === 'success' && resolve(response.data) - }) - } catch (e) { - clearTimeout(timeout) - reject('Failed to send message: ' + (e as Error)?.message || 'Unknown error') - } - }) -} - -/** - * Wrap for hooks, after the extension reloaded or upgraded, the context of current content script will be invalid - * And sending messages to the runtime will be failed - */ -export async function trySendMsg2Runtime(code: timer.mq.ReqCode, data?: Req): Promise { - try { - return await sendMsg2Runtime(code, data) - } catch { - // ignored - } -} - -export function onRuntimeMessage(handler: ChromeMessageHandler): void { +export function onRuntimeMessage(handler: ChromeMessageHandler): void { // Be careful!!! // Can't use await/async in callback parameter - chrome.runtime.onMessage.addListener((message: timer.mq.Request, sender: chrome.runtime.MessageSender, sendResponse: timer.mq.Callback) => { - handler(message, sender).then((response: timer.mq.Response) => { - if (response.code === 'ignore') return - sendResponse(response) - }) + chrome.runtime.onMessage.addListener((message: timer.mq.Request, sender: chrome.runtime.MessageSender, sendResponse: timer.mq.Callback) => { + void handler(message, sender) + .then((response: timer.mq.Response) => { + sendResponse(response) + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err) + console.error('onRuntimeMessage handler error', err) + sendResponse({ code: 'fail', msg }) + }) // 'return true' will force chrome to wait for the response processed in the above promise. // @see https://github.com/mozilla/webextension-polyfill/issues/130 return true diff --git a/src/api/chrome/tab.ts b/src/api/chrome/tab.ts index a0d36d3c8..68dadd083 100644 --- a/src/api/chrome/tab.ts +++ b/src/api/chrome/tab.ts @@ -18,26 +18,7 @@ export function getTab(id: number): Promise { })) } -export function resetTabUrl(tabId: number, url: string): Promise { - return new Promise(resolve => chrome.tabs.update(tabId, { - url: url, - highlighted: true, - }, () => resolve())) -} - -export async function getRightOf(target: ChromeTab): Promise { - if (!target) return - const { windowId, index } = target - return new Promise(resolve => chrome.tabs.query({ windowId }, tabs => { - const rightTab = tabs - ?.sort?.((a, b) => (a?.index ?? -1) - (b?.index ?? -1)) - ?.filter?.(t => t.index > index) - ?.[0] - resolve(rightTab) - })) -} - -export function getCurrentTab(): Promise { +function getCurrentTab(): Promise { return new Promise(resolve => chrome.tabs.getCurrent(tab => { handleError("getCurrentTab") resolve(tab) @@ -82,27 +63,33 @@ export function listTabs(query?: chrome.tabs.QueryInfo): Promise { })) } -export function sendMsg2Tab(tabId: number, code: timer.mq.ReqCode, data?: T): Promise { - const request: timer.mq.Request = { code, data } +export function sendMsg2Tab(tabId: number, code: C, data?: timer.tab.ReqData): Promise | undefined> { + const request: timer.tab.Request = { code, data: data as timer.tab.ReqData } return new Promise((resolve, reject) => { const timeout = setTimeout(() => reject('sendMsg2Tab timeout'), 2000) - chrome.tabs.sendMessage, timer.mq.Response>(tabId, request, response => { + chrome.tabs.sendMessage, timer.tab.Response>(tabId, request, response => { const sendError = handleError('sendMsg2Tab') clearTimeout(timeout) - const resCode = response?.code - resCode === 'success' && resolve(response.data) - reject(new Error(response?.msg ?? sendError ?? 'Unknown error')) + if (response?.code === 'success') { + resolve(response.data as timer.tab.ResData | undefined) + return + } + if (response?.code === 'fail') { + reject(new Error(response.msg ?? sendError ?? 'Unknown error')) + return + } + reject(new Error(sendError ?? 'Unknown error')) }) }) } -export async function trySendMsg2Tab( +export async function trySendMsg2Tab( tabId: number, - code: timer.mq.ReqCode, - data?: T -): Promise { + code: C, + data?: timer.tab.ReqData +): Promise | undefined> { try { - return await sendMsg2Tab(tabId, code, data) + return await sendMsg2Tab(tabId, code, data) } catch (e) { console.warn(`Errored to send message to tab: tabId=${tabId}, code=${code}, data=${JSON.stringify(data)}`, e) return Promise.resolve(undefined) diff --git a/src/api/chrome/tabGroups.ts b/src/api/chrome/tabGroups.ts index 42f490c87..2a748e6ad 100644 --- a/src/api/chrome/tabGroups.ts +++ b/src/api/chrome/tabGroups.ts @@ -67,18 +67,3 @@ export function isValidGroup(groupId?: number): groupId is number { return false } } - -export const cvtGroupColor = (color?: `${chrome.tabGroups.Color}`): string => { - switch (color) { - case 'grey': return '#5F6369' - case 'blue': return '#1974E8' - case 'yellow': return '#F9AB03' - case 'red': return '#DA3025' - case 'green': return '#198139' - case 'pink': return '#D01984' - case 'purple': return '#A143F5' - case 'cyan': return '#027B84' - case 'orange': return '#FA913E' - default: return '#000' - } -} diff --git a/src/api/chrome/window.ts b/src/api/chrome/window.ts index 8c29b079d..dd80521d7 100644 --- a/src/api/chrome/window.ts +++ b/src/api/chrome/window.ts @@ -1,77 +1,31 @@ -import { IS_ANDROID, IS_FIREFOX } from "@util/constant/environment" +import { IS_ANDROID } from "@util/constant/environment" import { handleError } from "./common" -export function listAllWindows(): Promise { - if (IS_ANDROID) { - // windows API not supported on Firefox for Android - return Promise.resolve([]) - } - return new Promise(resolve => chrome.windows.getAll(windows => { - handleError("listAllWindows") - resolve(windows || []) - })) +export function isNoneWindowId(windowId: number | undefined) { + return windowId === undefined || windowId === chrome.windows.WINDOW_ID_NONE } -export function isNoneWindowId(windowId: number) { +export function getFocusedNormalWindowId(): Promise { if (IS_ANDROID) { - return false + return Promise.resolve(undefined) } - return !windowId || windowId === chrome.windows.WINDOW_ID_NONE -} - -/** - * Reduce invoking to improve memory leak of Firefox - * - * @see https://github.com/sheepzh/time-tracker-4-browser/issues/599 - */ -class FocusedWindowCtx { - last?: number | undefined - listened: boolean = false - windowsTypes: `${chrome.windows.WindowType}`[] - - constructor(windowTypes: `${chrome.windows.WindowType}`[]) { - this.windowsTypes = windowTypes - } - - async apply(): Promise { - if (IS_ANDROID) { - return undefined - } - if (this.last) { - return isNoneWindowId(this.last) ? undefined : this.last - } - // init - this.last = await this.getInner() - if (!this.listened) { - // filter argument is not supported for Firefox - // @see https://developer.mozilla.org/en-US/docs/Mozilla/Add-ons/WebExtensions/API/windows/onFocusChanged#addlistener_syntax - IS_FIREFOX - ? chrome.windows.onFocusChanged.addListener(wid => this.last = wid) - : chrome.windows.onFocusChanged.addListener(wid => this.last = wid, { windowTypes: this.windowsTypes }) - this.listened = true - } - return this.last - } - - private getInner(): Promise { - return new Promise(resolve => chrome.windows.getLastFocused( - // Only find normal window - { windowTypes: ['normal'] }, - window => { - handleError('getFocusedNormalWindow') - const { focused, id } = window - if (!focused || !id || isNoneWindowId(id)) { - resolve(undefined) - } else { - resolve(id) - } + return new Promise(resolve => chrome.windows.getLastFocused( + { windowTypes: ['normal'] }, + window => { + handleError('getFocusedNormalWindow') + if (!window) { + resolve(undefined) + return } - )) - } + const { focused, id } = window + if (!focused || !id || isNoneWindowId(id)) { + resolve(undefined) + } else { + resolve(id) + } + }, + )) } -const context = new FocusedWindowCtx(['normal']) - -export const getFocusedNormalWindowId = () => context.apply() export async function getWindow(id: number): Promise { if (IS_ANDROID) { @@ -82,7 +36,7 @@ export async function getWindow(id: number): Promise void -export function onNormalWindowFocusChanged(handler: _Handler) { +export function onWindowFocusChanged(handler: _Handler) { if (IS_ANDROID) { return } @@ -90,4 +44,4 @@ export function onNormalWindowFocusChanged(handler: _Handler) { handleError('onWindowFocusChanged') handler(windowId) }) -} \ No newline at end of file +} diff --git a/src/api/crowdin.ts b/src/api/crowdin.ts index c25f34261..f70f76586 100644 --- a/src/api/crowdin.ts +++ b/src/api/crowdin.ts @@ -5,8 +5,16 @@ * https://opensource.org/licenses/MIT */ -import { CROWDIN_PROJECT_ID } from "@util/constant/url" -import { fetchGet } from './http' +import { createArrayGuard, createObjectGuard, isInt, isString, TypeGuard } from 'typescript-guard' +import { CROWDIN_PROJECT_ID } from "../util/constant/url" + +type ListResponse = { + data: { data: T }[] +} + +const createListRespGuard = (itemGuard: TypeGuard) => createObjectGuard>({ + data: createArrayGuard(createObjectGuard({ data: itemGuard })) +}) /** * Used to obtain translation status @@ -21,37 +29,55 @@ export type TranslationStatusInfo = { translationProgress: number } -export type MemberInfo = { +const isStatusResp = createListRespGuard( + createObjectGuard({ + languageId: isString, + translationProgress: isInt, + }) +) + +type MemberInfo = { username: string joinedAt: string avatarUrl: string } +const isMembersResp = createListRespGuard( + createObjectGuard({ + username: isString, + joinedAt: isString, + avatarUrl: isString, + }) +) + export async function getTranslationStatus(): Promise { const limit = 500 - const auth = `Bearer ${PUBLIC_TOKEN}` const url = `https://api.crowdin.com/api/v2/projects/${CROWDIN_PROJECT_ID}/languages/progress?limit=${limit}` - const response = await fetchGet(url, { headers: { "Authorization": auth } }) - const data: { data: { data: TranslationStatusInfo }[] } = await response.json() - return data.data.map(i => i.data) + return await getList(url, isStatusResp) } export async function getMembers(): Promise { const result: MemberInfo[] = [] - const auth = `Bearer ${PUBLIC_TOKEN}` - const limit = 10 let offset = 0 while (true) { const url = `https://api.crowdin.com/api/v2/projects/${CROWDIN_PROJECT_ID}/members?limit=${limit}&offset=${offset}` - const response = await fetchGet(url, { headers: { "Authorization": auth } }) - const data: { data: { data: MemberInfo }[] } = await response.json() - const newItems = data?.data?.map(i => i.data) ?? [] + const newItems = await getList(url, isMembersResp) result.push(...newItems) - if (newItems.length < limit) break offset += limit } return result -} \ No newline at end of file +} + +async function getList(url: string, respGuard: TypeGuard>): Promise { + const resp = await fetch(url, { + method: 'GET', + headers: { "Authorization": `Bearer ${PUBLIC_TOKEN}` }, + }) + const json = await resp.json() + if (respGuard(json)) return json.data.map(i => i.data) + console.warn('Unexpected response from Crowdin API', json) + return [] +} diff --git a/src/api/gist.ts b/src/api/gist.ts index 86edd8bea..9f5933aff 100644 --- a/src/api/gist.ts +++ b/src/api/gist.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import FIFOCache from "@util/fifo-cache" +import FIFOCache from "../util/fifo-cache" import { fetchGet, fetchGetWithTry, fetchPost } from "./http" type BaseFile = { @@ -16,7 +16,7 @@ export type FileForm = BaseFile & { content: string } -export type File = BaseFile & { +type File = BaseFile & { type: string language: string raw_url: string diff --git a/src/api/http.ts b/src/api/http.ts index 6675ef081..0e3f902df 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -1,10 +1,5 @@ type Option = Omit -export type FetchResult = { - data?: T - statusCode: number -} - export async function fetchGetWithTry(url: string, maxTry: number, option?: Option): Promise { let count = 0 do { diff --git a/src/api/obsidian.ts b/src/api/obsidian.ts index 51207be58..d9a9752b1 100644 --- a/src/api/obsidian.ts +++ b/src/api/obsidian.ts @@ -12,7 +12,7 @@ export const DEFAULT_VAULT = "vault" export const INVALID_AUTH_CODE = 40101 export const NOT_FOUND_CODE = 40400 -export type ObsidianResult = { +type ObsidianResult = { message?: string errorCode?: number } & T diff --git a/src/api/sw/backup.ts b/src/api/sw/backup.ts new file mode 100644 index 000000000..4a46ff1ce --- /dev/null +++ b/src/api/sw/backup.ts @@ -0,0 +1,15 @@ +import { sendMsg2Runtime } from "./common" + +export const syncData = () => sendMsg2Runtime('backup.sync', undefined, 120_000) + +export const checkAuth = () => sendMsg2Runtime('backup.checkAuth') + +export const clearBackup = (cid: string) => sendMsg2Runtime('backup.clear', cid) + +export const queryBackup = (param: timer.backup.RemoteQuery) => sendMsg2Runtime('backup.query', param) + +export const previewBackup = (param: timer.backup.RemoteQuery) => sendMsg2Runtime('backup.preview', param) + +export const getLastBackUp = (type: timer.backup.Type) => sendMsg2Runtime('backup.lastTs', type) + +export const allBackupClients = () => sendMsg2Runtime('backup.clients') diff --git a/src/api/sw/cate.ts b/src/api/sw/cate.ts new file mode 100644 index 000000000..e3f2b5ca2 --- /dev/null +++ b/src/api/sw/cate.ts @@ -0,0 +1,3 @@ +import { sendMsg2Runtime } from "./common" + +export const listAllCategories = () => sendMsg2Runtime('cate.all') diff --git a/src/api/sw/common.ts b/src/api/sw/common.ts new file mode 100644 index 000000000..907a614c7 --- /dev/null +++ b/src/api/sw/common.ts @@ -0,0 +1,63 @@ +import { handleError } from '../chrome/common' + +/** + * Fix proxy data failed to serialized in Firefox + */ +function cloneData(data: T | undefined): T | undefined { + if (data === undefined) return undefined + try { + return JSON.parse(JSON.stringify(data)) + } catch (cloneError) { + console.warn("Failed clone data", cloneError) + return data + } +} + +type RuntimeMsgArgs = [timer.mq.ReqData] extends [undefined] + ? [data?: timer.mq.ReqData, timeout_ms?: number] + : [data: timer.mq.ReqData, timeout_ms?: number] + +export function sendMsg2Runtime( + code: C, + ...args: RuntimeMsgArgs +): Promise> { + const [data, timeout_ms] = args + const request: timer.mq.Request = { code, data: cloneData(data) as timer.mq.ReqData } + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + // timeout: no response from runtime + reject('sendMsg2Runtime timeout') + }, timeout_ms ?? 10_000) + try { + chrome.runtime.sendMessage(request, (response: timer.mq.Response) => { + clearTimeout(timeout) + handleError('sendMsg2Runtime') + const resCode = response?.code + if (resCode === 'fail') { + console.warn("Error occurred when querying service-worker", code, data, response?.msg) + return reject(new Error(response?.msg || 'Unknown error')) + } + resCode === 'success' && resolve(response.data as timer.mq.ResData) + }) + } catch (e) { + clearTimeout(timeout) + const msg = e instanceof Error ? e.message : 'Unknown error' + reject(`Failed to send message: ${msg}`) + } + }) +} + +/** + * Wrap for hooks, after the extension reloaded or upgraded, the context of current content script will be invalid + * And sending messages to the runtime will be failed + */ +export async function trySendMsg2Runtime( + code: C, + ...args: RuntimeMsgArgs +): Promise | undefined> { + try { + return await sendMsg2Runtime(code, ...args) + } catch { + return undefined + } +} diff --git a/src/api/sw/immigration.ts b/src/api/sw/immigration.ts new file mode 100644 index 000000000..5486a27d9 --- /dev/null +++ b/src/api/sw/immigration.ts @@ -0,0 +1,5 @@ +import { sendMsg2Runtime } from "./common" + +export function importOther(query: timer.imported.ProcessQuery) { + return sendMsg2Runtime('immigration.importOther', query) +} diff --git a/src/api/sw/limit.ts b/src/api/sw/limit.ts new file mode 100644 index 000000000..37685e05f --- /dev/null +++ b/src/api/sw/limit.ts @@ -0,0 +1,11 @@ +import { sendMsg2Runtime } from "./common" + +export const listLimits = (query?: timer.limit.Query) => sendMsg2Runtime('limit.list', query) + +export const getLimitSummary = () => sendMsg2Runtime('limit.summary') + +export const deleteLimits = (ids: number[]) => sendMsg2Runtime('limit.delete', ids) + +export const updateLimits = (rules: timer.limit.Rule[]) => sendMsg2Runtime('limit.update', rules) + +export const addLimit = (rule: Omit) => sendMsg2Runtime('limit.add', rule) diff --git a/src/api/sw/merge.ts b/src/api/sw/merge.ts new file mode 100644 index 000000000..19ab325f1 --- /dev/null +++ b/src/api/sw/merge.ts @@ -0,0 +1,7 @@ +import { sendMsg2Runtime } from "./common" + +export const listAllMergeRules = () => sendMsg2Runtime('merge.all') + +export const deleteMergeRule = (origin: string) => sendMsg2Runtime('merge.delete', origin) + +export const addMergeRule = (rule: timer.merge.Rule) => sendMsg2Runtime('merge.add', rule) \ No newline at end of file diff --git a/src/api/sw/option.ts b/src/api/sw/option.ts new file mode 100644 index 000000000..3cd2e20a7 --- /dev/null +++ b/src/api/sw/option.ts @@ -0,0 +1,14 @@ +import { sendMsg2Runtime } from "@api/sw/common" + +export const getOption = () => sendMsg2Runtime('option.get') + +export const setOption = (option: Partial) => sendMsg2Runtime('option.set', option) + +export const getWeekStartTime = async (now?: number | Date): Promise => { + let nowTs = typeof now === 'number' ? now : now?.getTime() + nowTs = nowTs ?? Date.now() + const startTs = await sendMsg2Runtime('option.weekStartTime', nowTs) + return new Date(startTs) +} + +export const getWeekStartDay = () => sendMsg2Runtime('option.weekStartDay') diff --git a/src/api/sw/period.ts b/src/api/sw/period.ts new file mode 100644 index 000000000..8d028488a --- /dev/null +++ b/src/api/sw/period.ts @@ -0,0 +1,3 @@ +import { sendMsg2Runtime } from './common' + +export const listPeriods = (param: timer.period.Query) => sendMsg2Runtime('period.list', param) \ No newline at end of file diff --git a/src/api/sw/site.ts b/src/api/sw/site.ts new file mode 100644 index 000000000..1a146d4de --- /dev/null +++ b/src/api/sw/site.ts @@ -0,0 +1,31 @@ +import { sendMsg2Runtime } from "./common" + +export const listSites = (param?: timer.site.Query) => sendMsg2Runtime('site.list', param) + +export function getSitePage(param?: timer.site.Query, page?: timer.common.PageQuery) { + return sendMsg2Runtime('site.page', { ...param, ...page }) +} + +export const deleteSites = (...keys: timer.site.SiteKey[]) => sendMsg2Runtime('site.delete', keys) + +export function changeSitesCate(cateId: number | undefined, ...keys: timer.site.SiteKey[]) { + return sendMsg2Runtime('site.changeCate', { keys, cateId }) +} + +export const deleteSiteIcon = (key: timer.site.SiteKey) => sendMsg2Runtime('site.deleteIcon', key) + +export async function changeSiteAlias(key: timer.site.SiteKey, alias: string | undefined): Promise { + const trimmed = alias?.trim() || undefined + await sendMsg2Runtime('site.changeAlias', { key, alias: trimmed }) + return trimmed +} + +export const fillInitialAlias = (keys: timer.site.SiteKey[]) => sendMsg2Runtime('site.fillAlias', keys) + +export const getInitialAlias = (host: string) => sendMsg2Runtime('site.initialAlias', host) + +export function changeSiteRun(key: timer.site.SiteKey, enabled: boolean) { + return sendMsg2Runtime('site.changeRun', { key, enabled }) +} + +export const searchSite = (query?: string) => sendMsg2Runtime('site.search', query) diff --git a/src/api/sw/stat.ts b/src/api/sw/stat.ts new file mode 100644 index 000000000..3acc35f26 --- /dev/null +++ b/src/api/sw/stat.ts @@ -0,0 +1,31 @@ +import { sendMsg2Runtime } from "./common" + +export const listSiteStats = (param?: timer.stat.SiteQuery) => sendMsg2Runtime('stat.sites', param) + +export const getSiteStatPage = (param?: timer.stat.SitePageQuery) => sendMsg2Runtime('stat.sitePage', param) + +export function deleteSiteStatByHost(host: string, date?: [string?, string?] | string) { + return sendMsg2Runtime('stat.deleteSite', { host, date }) +} + +export function deleteSiteStatByGroup(groupId: number, date?: [string?, string?] | string) { + return sendMsg2Runtime('stat.deleteSite', { groupId, date }) +} + +export const listCateStats = (param?: timer.stat.CateQuery) => sendMsg2Runtime('stat.cates', param) + +export const getCateStatPage = (param?: timer.stat.CatePageQuery) => sendMsg2Runtime('stat.catePage', param) + +export const listGroupStats = (param?: timer.stat.GroupQuery) => sendMsg2Runtime('stat.groups', param) + +export const getGroupStatPage = (param?: timer.stat.GroupPageQuery) => sendMsg2Runtime('stat.groupPage', param) + +export const batchDeleteStats = (targets: timer.stat.Row[]) => sendMsg2Runtime('stat.batchDelete', targets) + +export function countGroupStatsByIds(groupIds: number[], date: string | [string?, string?]) { + return sendMsg2Runtime('stat.countGroup', { groupIds, date }) +} + +export function countSiteStatsByHosts(hosts: string[], date: string | [string?, string?]) { + return sendMsg2Runtime('stat.countSite', { host: hosts, date }) +} diff --git a/src/api/sw/whitelist.ts b/src/api/sw/whitelist.ts new file mode 100644 index 000000000..14e3744df --- /dev/null +++ b/src/api/sw/whitelist.ts @@ -0,0 +1,7 @@ +import { sendMsg2Runtime } from "./common" + +export const listWhitelist = () => sendMsg2Runtime('whitelist.all') + +export const addWhitelist = (white: string) => sendMsg2Runtime('whitelist.add', white) + +export const deleteWhitelist = (white: string) => sendMsg2Runtime('whitelist.delete', white) diff --git a/src/api/web-dav.ts b/src/api/web-dav.ts index 6e445d419..b536c1895 100644 --- a/src/api/web-dav.ts +++ b/src/api/web-dav.ts @@ -3,7 +3,7 @@ * * Testing with server implemented by https://github.com/svtslv/webdav-cli */ -import { encode } from '@util/base64' +import { encode } from '../util/base64' import { fetchDelete, fetchGet } from './http' // Only support password for now diff --git a/src/background/badge-manager.ts b/src/background/badge-manager.ts index 8dce39574..810f23352 100644 --- a/src/background/badge-manager.ts +++ b/src/background/badge-manager.ts @@ -7,16 +7,16 @@ import { setBadgeBgColor, setBadgeText } from "@api/chrome/action" import { listTabs } from "@api/chrome/tab" -import { getFocusedNormalWindowId } from "@api/chrome/window" -import statDatabase from "@db/stat-database" -import optionHolder from "@service/components/option-holder" -import whitelistHolder from "@service/whitelist/holder" +import { getFocusedNormalWindowId, isNoneWindowId, onWindowFocusChanged } from "@api/chrome/window" import { IS_ANDROID } from "@util/constant/environment" import { extractHostname, isBrowserUrl } from "@util/pattern" import { MILL_PER_HOUR, MILL_PER_MINUTE, MILL_PER_SECOND } from "@util/time" -import MessageDispatcher from "./message-dispatcher" +import statDatabase from "./database/stat-database" +import type MessageDispatcher from './message-dispatcher' +import optionHolder from "./service/components/option-holder" +import whitelistHolder from "./service/whitelist/holder" -export type BadgeLocation = { +type BadgeLocation = { /** * The tab id of badge text show display with */ @@ -25,7 +25,6 @@ export type BadgeLocation = { * The url of tab */ url: string - focus?: number } function mill2Str(milliseconds: number) { @@ -41,19 +40,11 @@ function mill2Str(milliseconds: number) { } } -function setBadgeTextOfMills(milliseconds: number | undefined, tabId: number | undefined) { - const text = milliseconds === undefined ? '' : mill2Str(milliseconds) - setBadgeText(text, tabId) -} - -async function findActiveTab(): Promise { - const windowId = await getFocusedNormalWindowId() - if (!windowId) { - return undefined - } - const tabs = await listTabs({ active: true, windowId }) - // Fix #131 - // Edge will return two active tabs, including the new tab with url 'edge://newtab/', GG +async function findActiveTab(windowId?: number): Promise { + windowId ??= await getFocusedNormalWindowId() + if (isNoneWindowId(windowId)) return undefined + const tabs = await listTabs({ windowId, active: true }) + // Fix #131 — Edge can return two active tabs (e.g. edge://newtab/). for (const { id: tabId, url } of tabs) { if (!tabId || !url || isBrowserUrl(url)) continue return { tabId, url } @@ -64,106 +55,76 @@ async function findActiveTab(): Promise { async function clearAllBadge(): Promise { const tabs = await listTabs() if (!tabs?.length) return - for (const tab of tabs) { - await setBadgeText('', tab?.id) - } + for (const { id } of tabs) id != null && await setBadgeText('', id) } -type BadgeState = 'HIDDEN' | 'NOT_SUPPORTED' | 'PAUSED' | 'TIME' | 'WHITELIST' - -interface BadgeManager { - init(dispatcher: MessageDispatcher): void - updateFocus(location?: BadgeLocation): void -} - -class DefaultBadgeManager { - pausedTabId: number | undefined - current: BadgeLocation | undefined - visible: boolean | undefined - state: BadgeState | undefined +class BadgeManager { + private pausedTabId: number | undefined + private current: BadgeLocation | undefined + private visible = false async init(messageDispatcher: MessageDispatcher) { + if (IS_ANDROID) return // do nothing on Android, since badge text is not supported + const option = await optionHolder.get() - this.processOption(option) - optionHolder.addChangeListener(opt => this.processOption(opt)) - whitelistHolder.addPostHandler(() => this.render()) - messageDispatcher - .register('cs.idleChange', (isIdle, sender) => { - const tabId = sender?.tab?.id - isIdle ? this.pause(tabId) : this.resume(tabId) - }) - this.updateFocus() + await this.processOption(option) + optionHolder.addChangeListener(opt => { void this.processOption(opt) }) + whitelistHolder.addPostHandler(() => { void this.render() }) + messageDispatcher.register('cs.idleChanged', (isIdle, sender) => { + const tabId = sender?.tab?.id + void (isIdle ? this.pause(tabId) : this.resume(tabId)) + }) + onWindowFocusChanged(async windowId => { + const target = await findActiveTab(windowId) + await this.updateFocus(target) + }) + await this.updateFocus() } - /** - * Hide the badge text - */ private async pause(tabId?: number) { + if (typeof tabId !== 'number') return this.pausedTabId = tabId - this.render() + await this.render() } - /** - * Show the badge text - */ - private resume(tabId?: number) { - if (!this.pausedTabId || this.pausedTabId !== tabId) return + private async resume(tabId?: number) { + if (typeof this.pausedTabId !== 'number') return + if (typeof tabId !== 'number' || this.pausedTabId !== tabId) return this.pausedTabId = undefined - this.render() + await this.render() } async updateFocus(target?: BadgeLocation) { - this.current = target || await findActiveTab() + this.current = target ?? await findActiveTab() await this.render() } - private processOption(option: timer.option.AppearanceOption) { - const { displayBadgeText, badgeBgColor } = option || {} + private async processOption(option: timer.option.AppearanceOption) { + const { displayBadgeText, badgeBgColor } = option ?? {} const before = this.visible this.visible = !!displayBadgeText - !this.visible && before && clearAllBadge() - setBadgeBgColor(badgeBgColor) + if (!this.visible && before) await clearAllBadge() + await setBadgeBgColor(badgeBgColor) + if (before !== this.visible) await this.render() } private async render(): Promise { - this.state = await this.processState() + const badgeText = await this.resolveBadgeText() + await setBadgeText(badgeText, this.current?.tabId) } - private async processState(): Promise { - const { url, tabId, focus } = this.current || {} - if (!this.visible || !url) { - this.state !== 'HIDDEN' && setBadgeText('', tabId) - return 'HIDDEN' - } - if (isBrowserUrl(url)) { - this.state !== 'NOT_SUPPORTED' && setBadgeText('∅', tabId) - return 'NOT_SUPPORTED' - } - const host = extractHostname(url)?.host - if (whitelistHolder.contains(host, url)) { - this.state !== 'WHITELIST' && setBadgeText('W', tabId) - return 'WHITELIST' - } - if (this.pausedTabId === tabId) { - this.state !== 'PAUSED' && setBadgeText('P', tabId) - return 'PAUSED' - } - const milliseconds = focus || (host ? (await statDatabase.get(host, new Date())).focus : undefined) - setBadgeTextOfMills(milliseconds, tabId) - return 'TIME' - } -} - -class SilentBadgeManager implements BadgeManager { - init(_dispatcher: MessageDispatcher): void { - // do nothing - } - updateFocus(_location?: BadgeLocation): void { - // do nothing + private async resolveBadgeText(): Promise { + if (!this.current || !this.visible) return '' + const { url, tabId } = this.current + if (isBrowserUrl(url)) return '∅' + const { host } = extractHostname(url) + if (whitelistHolder.contains(host, url)) return 'W' + if (this.pausedTabId === tabId) return 'P' + const { focus } = await statDatabase.get(host, new Date()) + return mill2Str(focus) } } -// Don't display badge on Android -const badgeManager: BadgeManager = IS_ANDROID ? new SilentBadgeManager() : new DefaultBadgeManager() +const badgeTextManager = new BadgeManager() -export default badgeManager +export default badgeTextManager diff --git a/src/background/content-script-handler.ts b/src/background/content-script-handler.ts index 43551fc68..4f17af544 100644 --- a/src/background/content-script-handler.ts +++ b/src/background/content-script-handler.ts @@ -5,46 +5,60 @@ * https://opensource.org/licenses/MIT */ -import { executeScript } from "@api/chrome/script" -import { createTab } from "@api/chrome/tab" -import { ANALYSIS_ROUTE, LIMIT_ROUTE } from "@app/router/constants" -import optionHolder from "@service/components/option-holder" -import limitService from "@service/limit-service" -import { getSite } from "@service/site-service" -import timelineThrottler from '@service/throttler/timeline-throttler' -import whitelistHolder from "@service/whitelist/holder" -import { getAppPageUrl } from "@util/constant/url" -import { extractFileHost, extractHostname } from "@util/pattern" +import { IS_ANDROID, IS_CHROME, IS_SAFARI } from "@util/constant/environment" +import { extractHostname, isBrowserUrl, isHomepage } from "@util/pattern" +import { extractSiteName } from "@util/site" import badgeManager from "./badge-manager" -import { collectIconAndAlias } from "./icon-and-alias-collector" import MessageDispatcher from "./message-dispatcher" +import { saveAlias, saveIconUrl } from "./service/site-service" +import { incVisitCount } from './track-server/normal' -const handleOpenAnalysisPage = (sender: ChromeMessageSender) => { - const { tab, url } = sender || {} - if (!url) return - const host = extractFileHost(url) || extractHostname(url)?.host - const newTabUrl = getAppPageUrl(ANALYSIS_ROUTE, { host }) +function isUrl(title: string) { + return title.startsWith('https://') || title.startsWith('http://') || title.startsWith('ftp://') +} - const tabIndex = tab?.index - const newTabIndex = tabIndex ? tabIndex + 1 : undefined - createTab({ url: newTabUrl, index: newTabIndex }) +async function collectAlias(key: timer.site.SiteKey, tabTitle: string) { + if (!tabTitle) return + if (isUrl(tabTitle)) return + const siteName = extractSiteName(tabTitle, key.host) + siteName && await saveAlias(key, siteName, true) } -const handleOpenLimitPage = (sender: ChromeMessageSender) => { - const { tab, url } = sender || {} - if (!url) return - const newTabUrl = getAppPageUrl(LIMIT_ROUTE, { url }) - const tabIndex = tab?.index - const newTabIndex = tabIndex ? tabIndex + 1 : undefined - createTab({ url: newTabUrl, index: newTabIndex }) +/** + * Process the tab + */ +async function processTabInfo(tab: ChromeTab): Promise { + let { favIconUrl, url, title } = tab + if (!url || !title) return + if (isBrowserUrl(url)) return + const hostInfo = extractHostname(url) + const host = hostInfo.host + if (!host) return + // localhost hosts with Chrome use cache, so keep the favIcon url undefined + IS_CHROME && /^localhost(:.+)?/.test(host) && (favIconUrl = undefined) + const siteKey: timer.site.SiteKey = { host, type: 'normal' } + favIconUrl && await saveIconUrl(siteKey, favIconUrl) + !IS_ANDROID + && !isBrowserUrl(url) + && isHomepage(url) + && await collectAlias(siteKey, title) +} + +/** + * Collect the favicon of host + */ +const collectIconAndAlias = async (tab: ChromeTab) => { + if (IS_SAFARI || IS_ANDROID) return + processTabInfo(tab) } const handleInjected = async (sender: ChromeMessageSender) => { - const tabId = sender?.tab?.id - if (!tabId) return - collectIconAndAlias(tabId) - badgeManager.updateFocus() - executeScript(tabId, ['content_scripts.js']) + const { tab, url } = sender + if (!tab) return + await incVisitCount(tab) + await collectIconAndAlias(tab) + const tabId = tab.id + await badgeManager.updateFocus(tabId && url ? { tabId, url } : undefined) } /** @@ -54,26 +68,7 @@ const handleInjected = async (sender: ChromeMessageSender) => { */ export default function init(dispatcher: MessageDispatcher) { dispatcher - // Judge is in whitelist - .register<{ host?: string, url?: string }, boolean>('cs.isInWhitelist', ({ host, url } = {}) => !!host && !!url && whitelistHolder.contains(host, url)) - // Need to print the information of today - .register('cs.printTodayInfo', async () => { - const option = await optionHolder.get() - return !!option.printInConsole - }) - .register('cs.getLimitedRules', url => limitService.getLimited(url)) - .register('cs.getRelatedRules', url => limitService.getRelated(url)) - .register('cs.openAnalysis', (_, sender) => handleOpenAnalysisPage(sender)) - .register('cs.openLimit', (_, sender) => handleOpenLimitPage(sender)) - .register('cs.onInjected', async (_, sender) => handleInjected(sender)) + .register('cs.injected', (_, sender) => handleInjected(sender)) // Get sites which need to count run time - .register('cs.getRunSites', async url => { - const { host } = extractHostname(url) || {} - if (!host) return null - const site: timer.site.SiteKey = { host, type: 'normal' } - const exist = await getSite(site) - return exist?.run ? site : null - }) - .register('cs.getAudible', async (_, sender) => !!sender.tab?.audible) - .register('cs.timelineEv', ev => timelineThrottler.saveEvent(ev)) + .register('cs.getAudible', async (_, sender) => !!sender.tab?.audible) } \ No newline at end of file diff --git a/src/background/data-cleaner.ts b/src/background/data-cleaner.ts index 7468b6fd8..18e4f49e7 100644 --- a/src/background/data-cleaner.ts +++ b/src/background/data-cleaner.ts @@ -1,16 +1,15 @@ -import periodService, { type PeriodQueryParam } from "@service/period-service" import { keyOf } from "@util/period" import { getBirthday, getStartOfDay, MILL_PER_DAY } from "@util/time" import alarmManager from "./alarm-manager" +import { batchDeletePeriods } from "./service/period-service" const PERIOD_ALARM_NAME = 'period-cleaner-alarm' const START_DAY = keyOf(getBirthday()) const KEEP_RANGE_DAYS = 366 const cleanPeriodData = async () => { - const endDate = new Date().getTime() - MILL_PER_DAY * KEEP_RANGE_DAYS - const param: PeriodQueryParam = { periodRange: [START_DAY, keyOf(endDate)] } - await periodService.batchDeleteBetween(param) + const endDate = Date.now() - MILL_PER_DAY * KEEP_RANGE_DAYS + await batchDeletePeriods(START_DAY, keyOf(endDate)) } export default function initDataCleaner() { diff --git a/src/database/backup-database.ts b/src/background/database/backup-database.ts similarity index 60% rename from src/database/backup-database.ts rename to src/background/database/backup-database.ts index ebabe390d..c6c74e254 100644 --- a/src/database/backup-database.ts +++ b/src/background/database/backup-database.ts @@ -9,7 +9,6 @@ import BaseDatabase from "./common/base-database" import { REMAIN_WORD_PREFIX } from "./common/constant" const PREFIX = REMAIN_WORD_PREFIX + "backup" -const SNAPSHOT_KEY = PREFIX + "_snap" const CACHE_KEY = PREFIX + "_cache" function cacheKeyOf(type: timer.backup.Type) { @@ -18,17 +17,6 @@ function cacheKeyOf(type: timer.backup.Type) { class BackupDatabase extends BaseDatabase { - async getSnapshot(type: timer.backup.Type): Promise { - const cache = await this.storage.getOne(SNAPSHOT_KEY) - return cache?.[type] - } - - async updateSnapshot(type: timer.backup.Type, snapshot: timer.backup.Snapshot): Promise { - const cache = await this.storage.getOne(SNAPSHOT_KEY) || {} - cache[type] = snapshot - await this.storage.put(SNAPSHOT_KEY, cache) - } - async getCache(type: timer.backup.Type): Promise { return (await this.storage.getOne(cacheKeyOf(type))) || {} } diff --git a/src/database/site-cate-database.ts b/src/background/database/cate-database.ts similarity index 93% rename from src/database/site-cate-database.ts rename to src/background/database/cate-database.ts index 5203f5755..4e4f4dac3 100644 --- a/src/database/site-cate-database.ts +++ b/src/background/database/cate-database.ts @@ -20,11 +20,11 @@ type Item = { type Items = Record /** - * Site tag + * Category * * @since 3.0.0 */ -class SiteCateDatabase extends BaseDatabase { +class CateDatabase extends BaseDatabase { private async getItems(): Promise { return await this.storage.getOne(KEY) || {} @@ -82,6 +82,6 @@ class SiteCateDatabase extends BaseDatabase { } } -const siteCateDatabase = new SiteCateDatabase() +const cateDatabase = new CateDatabase() -export default siteCateDatabase \ No newline at end of file +export default cateDatabase \ No newline at end of file diff --git a/src/database/common/base-database.ts b/src/background/database/common/base-database.ts similarity index 100% rename from src/database/common/base-database.ts rename to src/background/database/common/base-database.ts diff --git a/src/database/common/constant.ts b/src/background/database/common/constant.ts similarity index 100% rename from src/database/common/constant.ts rename to src/background/database/common/constant.ts diff --git a/src/database/common/indexed-storage.ts b/src/background/database/common/indexed-storage.ts similarity index 94% rename from src/database/common/indexed-storage.ts rename to src/background/database/common/indexed-storage.ts index b228bb5f4..531e0ea76 100644 --- a/src/database/common/indexed-storage.ts +++ b/src/background/database/common/indexed-storage.ts @@ -63,19 +63,23 @@ export async function iterateCursor( }) } -type TransactionError = 'Connection' | 'StoreNotFound' | 'Unknown' +type TransactionError = 'Connection' | 'StoreNotFound' | 'DataError' | 'Unknown' const detectTransactionError = (err: unknown): TransactionError => { if (!(err instanceof DOMException)) { + console.warn("Non-DOMException error during transaction", err) return 'Unknown' } - if (err.name === 'InvalidStateError' || err.name === 'AbortError') { - return 'Connection' + const { name } = err + switch (name) { + case 'InvalidStateError': + case 'AbortError': return 'Connection' + case 'NotFoundError': return 'StoreNotFound' + case 'DataError': return 'DataError' + default: + console.warn(`Unknown dom exception: name=${name}`) + return 'Unknown' } - if (err.name === 'NotFoundError') { - return 'StoreNotFound' - } - return 'Unknown' } export function closedRangeKey(lower: IDBValidKey | undefined, upper: IDBValidKey | undefined): IDBKeyRange | undefined { @@ -128,6 +132,8 @@ export abstract class BaseIDBStorage> { } private setupDbCloseHandler(db: IDBDatabase): void { + db.onversionchange = () => db.close() + db.onclose = () => { if (this.db !== db) return @@ -262,7 +268,7 @@ export abstract class BaseIDBStorage> { } } - protected async withStore(operation: (store: IDBObjectStore) => T | Promise, mode?: IDBTransactionMode): Promise { + protected async withStore(operation: (store: IDBObjectStore) => Awaitable, mode?: IDBTransactionMode): Promise { let db = await this.initDb() for (let retryCount = 0; retryCount < 2; retryCount++) { diff --git a/src/database/common/migratable.ts b/src/background/database/common/migratable.ts similarity index 93% rename from src/database/common/migratable.ts rename to src/background/database/common/migratable.ts index 0c429ff01..2f88092bb 100644 --- a/src/database/common/migratable.ts +++ b/src/background/database/common/migratable.ts @@ -1,6 +1,6 @@ -import type { BrowserMigratableNamespace } from '@db/types' import { isRecord } from '@util/guard' import { createObjectGuard, isInt, isString, TypeGuard } from 'typescript-guard' +import type { BrowserMigratableNamespace } from '../types' export const isExportData = createObjectGuard>({ __meta__: createObjectGuard({ diff --git a/src/database/common/storage-holder.ts b/src/background/database/common/storage-holder.ts similarity index 99% rename from src/database/common/storage-holder.ts rename to src/background/database/common/storage-holder.ts index 0f36a4acd..646ae413b 100644 --- a/src/database/common/storage-holder.ts +++ b/src/background/database/common/storage-holder.ts @@ -7,7 +7,6 @@ export class StorageHolder { constructor(delegates: Record) { this.delegates = delegates this.current = delegates.classic - optionHolder.get().then(val => this.handleOption(val)) optionHolder.addChangeListener(val => this.handleOption(val)) } diff --git a/src/database/common/storage-promise.ts b/src/background/database/common/storage-promise.ts similarity index 100% rename from src/database/common/storage-promise.ts rename to src/background/database/common/storage-promise.ts diff --git a/src/database/limit-database.ts b/src/background/database/limit-database.ts similarity index 82% rename from src/database/limit-database.ts rename to src/background/database/limit-database.ts index 17e70bc5b..97ef85730 100644 --- a/src/database/limit-database.ts +++ b/src/background/database/limit-database.ts @@ -23,7 +23,7 @@ type DateRecords = { } } -type LimitRecord = timer.limit.Rule & { +export type LimitRecord = timer.limit.Rule & { records: DateRecords } @@ -159,6 +159,16 @@ function migrate(exist: Items, toMigrate: unknown) { }) } +function cvtRule2Item(rule: timer.limit.Rule): ItemValue { + const { id, name, cond, time, count, weekly, weeklyCount, visitTime, periods, enabled, locked, allowDelay, weekdays } = rule + return { + i: id, n: name, c: cond, t: time, ct: count, wt: weekly, wct: weeklyCount, + v: visitTime, p: periods, + e: !!enabled, l: !!locked, ad: !!allowDelay, + wd: weekdays, + } +} + /** * Time limit * @@ -190,41 +200,35 @@ class LimitDatabase extends BaseDatabase implements BrowserMigratable<'__limit__ return Object.values(items).map(cvtItem2Rec) } - async save(data: MakeOptional, rewrite?: boolean): Promise { + async batchUpdate(rules: timer.limit.Rule[]): Promise { + if (!rules.length) return const items = await this.getItems() - let { - id, name, weekdays, - enabled, locked, allowDelay, - cond, - time, count, - weekly, weeklyCount, - visitTime, periods, - } = data - if (!id) { - const lastId = Object.values(items) - .map(e => e.i) - .filter(i => !!i) - .sort((a, b) => b - a)?.[0] ?? 0 - id = lastId + 1 - } - const existItem = items[id] - if (existItem && !rewrite) return id - items[id] = { - // Can be overridden by existing - ...(existItem || {}), - i: id, n: name, c: cond, wd: weekdays, - e: !!enabled, l: locked, ad: !!allowDelay, - t: time, ct: count, - wt: weekly, wct: weeklyCount, - v: visitTime, p: periods, - } + rules.forEach(rule => { + const id = rule.id + const exist = items[id] + if (!exist) return + items[id] = { ...exist, ...cvtRule2Item(rule) } + }) + await this.update(items) + } + + async add(data: Omit): Promise { + const items = await this.getItems() + + const lastId = Object.values(items) + .map(e => e.i) + .filter(i => !!i) + .sort((a, b) => b - a)?.[0] ?? 0 + const id = lastId + 1 + + items[id] = cvtRule2Item({ id, ...data }) await this.update(items) return id } - async remove(id: number): Promise { + async batchRemove(ids: number[]): Promise { const items = await this.getItems() - delete items[id] + ids.forEach(id => delete items[id]) await this.update(items) } @@ -265,27 +269,6 @@ class LimitDatabase extends BaseDatabase implements BrowserMigratable<'__limit__ await this.update(items) } - async updateDelay(id: number, allowDelay: boolean) { - const items = await this.getItems() - if (!items[id]) return - items[id].ad = allowDelay - await this.update(items) - } - - async updateEnabled(id: number, enabled: boolean) { - const items = await this.getItems() - if (!items[id]) return - items[id].e = !!enabled - await this.update(items) - } - - async updateLocked(id: number, locked: boolean) { - const items = await this.getItems() - if (!items[id]) return - items[id].l = !!locked - await this.update(items) - } - async importData(data: unknown): Promise { if (!isExportData(data)) return if (isLegacyVersion(data)) { @@ -308,7 +291,7 @@ class LimitDatabase extends BaseDatabase implements BrowserMigratable<'__limit__ allowDelay: row.allowDelay ?? false, weekdays: row.weekdays ?? [], } - await this.save(toImport) + await this.add(toImport) } } diff --git a/src/database/memory-detector.ts b/src/background/database/memory-detector.ts similarity index 66% rename from src/database/memory-detector.ts rename to src/background/database/memory-detector.ts index 5579bef5e..f973f49bc 100644 --- a/src/database/memory-detector.ts +++ b/src/background/database/memory-detector.ts @@ -7,20 +7,6 @@ import StoragePromise from "./common/storage-promise" -/** - * User memory of this extension - */ -export type MemoryInfo = { - /** - * Used bytes - */ - used: number - /** - * Total bytes - */ - total: number -} - /** * 'QUOTA_BYTES' Not supported in Firefox */ @@ -31,7 +17,7 @@ const total: number = chrome.storage.local.QUOTA_BYTES || 0 * * @since 0.0.9 */ -export async function getUsedStorage(): Promise { +export async function getUsedStorage(): Promise { const used = await new StoragePromise(chrome.storage.local).getUsedMemory() return { used, total } } \ No newline at end of file diff --git a/src/database/merge-rule-database.ts b/src/background/database/merge-rule-database.ts similarity index 100% rename from src/database/merge-rule-database.ts rename to src/background/database/merge-rule-database.ts diff --git a/src/database/meta-database.ts b/src/background/database/meta-database.ts similarity index 100% rename from src/database/meta-database.ts rename to src/background/database/meta-database.ts diff --git a/src/database/option-database.ts b/src/background/database/option-database.ts similarity index 60% rename from src/database/option-database.ts rename to src/background/database/option-database.ts index ead1de21a..1fde3a263 100644 --- a/src/database/option-database.ts +++ b/src/background/database/option-database.ts @@ -21,26 +21,12 @@ class OptionDatabase extends BaseDatabase { async getOption(): Promise { const option = await this.storage.getOne(DB_KEY) - return mergeObject(defaultOption(), option) + return mergeObject(defaultOption(), option) } async setOption(option: timer.option.AllOption): Promise { option && await this.setByKey(DB_KEY, option) } - - /** - * @since 0.3.2 - */ - addOptionChangeListener(listener: (newVal: timer.option.AllOption) => void) { - const storageListener = ( - changes: { [key: string]: chrome.storage.StorageChange }, - _areaName: chrome.storage.AreaName, - ) => { - const optionInfo = changes[DB_KEY] - optionInfo && listener(optionInfo.newValue as timer.option.AllOption ?? {}) - } - chrome.storage.onChanged.addListener(storageListener) - } } const optionDatabase = new OptionDatabase() diff --git a/src/database/period-database.ts b/src/background/database/period-database.ts similarity index 94% rename from src/database/period-database.ts rename to src/background/database/period-database.ts index 1105f9d66..3efd5612d 100644 --- a/src/database/period-database.ts +++ b/src/background/database/period-database.ts @@ -38,12 +38,10 @@ function db2PeriodInfos(data: { [dateKey: string]: DailyResult }): timer.period. Number.parseInt(dateStr.substring(4, 6)) - 1, Number.parseInt(dateStr.substring(6, 8)) ) - Object - .entries(val) - .forEach(([order, milliseconds]) => result.push({ - ...keyOf(date, Number.parseInt(order)), - milliseconds - })) + Object.entries(val).forEach(([order, milliseconds]) => result.push({ + ...keyOf(date, Number.parseInt(order)), + milliseconds, + })) })) return result } diff --git a/src/background/database/site-database.ts b/src/background/database/site-database.ts new file mode 100644 index 000000000..2429d1adf --- /dev/null +++ b/src/background/database/site-database.ts @@ -0,0 +1,141 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { toMap } from '@util/array' +import { CATE_NOT_SET_ID } from "@util/site" +import BaseDatabase from "./common/base-database" +import { REMAIN_WORD_PREFIX } from "./common/constant" + +type _Entry = { + /** + * Alias + */ + a?: string + /** + * Icon url + */ + i?: string + /** + * Category ID + */ + c?: number + /** + * Count run time + */ + r?: boolean +} + +const DB_KEY_PREFIX = REMAIN_WORD_PREFIX + 'SITE_' +const HOST_KEY_PREFIX = DB_KEY_PREFIX + 'h' +const VIRTUAL_KEY_PREFIX = DB_KEY_PREFIX + 'v' +const MERGED_FLAG = 'm' + +function cvt2Key({ host, type }: timer.site.SiteKey): string { + switch (type) { + case 'virtual': return VIRTUAL_KEY_PREFIX + host + case 'merged': return HOST_KEY_PREFIX + MERGED_FLAG + host + case 'normal': return HOST_KEY_PREFIX + '_' + host + } +} + +function cvt2SiteKey(key: string): timer.site.SiteKey { + if (key.startsWith(VIRTUAL_KEY_PREFIX)) { + return { + host: key.substring(VIRTUAL_KEY_PREFIX.length), + type: 'virtual', + } + } else if (key.startsWith(HOST_KEY_PREFIX)) { + return { + host: key.substring(HOST_KEY_PREFIX.length + 1), + type: key.charAt(HOST_KEY_PREFIX.length) === MERGED_FLAG ? 'merged' : 'normal', + } + } else { + // Can't go there + return { host: key, type: 'normal' } + } +} + +function cvt2Entry({ alias, iconUrl, cate, run }: timer.site.SiteInfo): _Entry { + const entry: _Entry = { i: iconUrl } + alias && (entry.a = alias) + cate && (entry.c = cate) + run && (entry.r = true) + entry.i = iconUrl + return entry +} + +function cvt2SiteInfo(key: timer.site.SiteKey, entry: _Entry | undefined): timer.site.SiteInfo { + const { a, i, c, r } = entry ?? {} + const siteInfo: timer.site.SiteInfo = { ...key } + siteInfo.alias = a + siteInfo.cate = c ?? CATE_NOT_SET_ID + siteInfo.iconUrl = i + siteInfo.run = !!r + return siteInfo +} + +function buildFilter(condition?: timer.site.Query): (site: timer.site.SiteInfo) => boolean { + const { fuzzyQuery, cateIds, types } = condition || {} + let cateFilter = typeof cateIds === 'number' ? [cateIds] : (cateIds?.length ? cateIds : undefined) + let typeFilter = typeof types === 'string' ? [types] : (types?.length ? types : undefined) + return site => { + const { host: siteHost, alias: siteAlias, cate, type } = site || {} + if (fuzzyQuery && !(siteHost?.includes(fuzzyQuery) || siteAlias?.includes(fuzzyQuery))) return false + if (cateFilter && (!cateFilter.includes(cate ?? CATE_NOT_SET_ID) || type !== 'normal')) return false + if (typeFilter && !typeFilter.includes(type)) return false + return true + } +} + +class SiteDatabase extends BaseDatabase { + async select(condition?: timer.site.Query): Promise { + const filter = buildFilter(condition) + const data = await this.storage.get() + return Object.entries(data) + .filter(([key]) => key.startsWith(DB_KEY_PREFIX)) + .map(([key, value]) => cvt2SiteInfo(cvt2SiteKey(key), value as _Entry)) + .filter(filter) + } + + /** + * Get by key + * + * @returns site info, or undefined + */ + async get(key: timer.site.SiteKey): Promise { + const entry = await this.storage.getOne<_Entry>(cvt2Key(key)) + return entry && cvt2SiteInfo(key, entry) + } + + async getBatch(keys: timer.site.SiteKey[]): Promise { + const result = await this.storage.get(keys.map(cvt2Key)) + return Object.entries(result) + .map(([key, value]) => cvt2SiteInfo(cvt2SiteKey(key), value as _Entry)) + } + + async save(...sites: timer.site.SiteInfo[]): Promise { + if (!sites.length) return + const toSet = toMap(sites, cvt2Key, cvt2Entry) + await this.storage.set(toSet) + } + + async remove(siteKeys: timer.site.SiteKey[]): Promise { + if (!siteKeys.length) return + const keys = siteKeys.map(cvt2Key) + await this.storage.remove(keys) + } + + async exist(siteKey: timer.site.SiteKey): Promise { + const key = cvt2Key(siteKey) + const entry = await this.storage.getOne<_Entry>(key) + return !!entry + } +} + +const siteDatabase = new SiteDatabase() + +export default siteDatabase \ No newline at end of file diff --git a/src/database/stat-database/classic.ts b/src/background/database/stat-database/classic.ts similarity index 88% rename from src/database/stat-database/classic.ts rename to src/background/database/stat-database/classic.ts index c6a5cea3d..4799563ac 100644 --- a/src/database/stat-database/classic.ts +++ b/src/background/database/stat-database/classic.ts @@ -1,10 +1,10 @@ -import BaseDatabase from '@db/common/base-database' -import { REMAIN_WORD_PREFIX } from '@db/common/constant' -import { log } from '@src/common/logger' +import { log } from '@/common/logger' import { isOptionalInt } from '@util/guard' import { escapeRegExp } from '@util/pattern' import { isNotZeroResult } from '@util/stat' import { createObjectGuard } from 'typescript-guard' +import BaseDatabase from '../common/base-database' +import { REMAIN_WORD_PREFIX } from '../common/constant' import { cvtGroupId2Host, formatDateStr, GROUP_PREFIX, increase, zeroResult } from './common' import { filterDate, filterHost, filterNumberRange, processCondition, type ProcessedCondition } from './condition' import type { StatCondition, StatDatabase } from './types' @@ -156,6 +156,18 @@ export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { return { host, date: formatDateStr(date), ...result } } + async batchSelect(keys: timer.core.RowKey[]): Promise { + if (!keys.length) return [] + const storageKeys = keys.map(({ host, date }) => generateKey(host, date)) + const items = await this.storage.get>(storageKeys) + return keys.map(({ host, date }, i) => { + const sk = storageKeys[i] + const exist = items[sk] + const result = exist ?? zeroResult() + return { host, date, ...result } + }) + } + /** * Delete by key * @@ -205,18 +217,16 @@ export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { * @returns [dates] * @since 0.0.7 */ - async deleteByHost(host: string, range?: [start?: Date | string, end?: Date | string]): Promise { - const [start, end] = range ?? [] - const startStr = start && formatDateStr(start) - const endStr = end && formatDateStr(end) - if (startStr && startStr === endStr) { + async deleteByHost(host: string, range?: string | [string?, string?]): Promise { + const [start, end] = Array.isArray(range) ? range : [range, range] + if (start && start === end) { // Delete one day const key = generateKey(host, start) await this.storage.remove(key) - return [startStr] + return } - const dateFilter = (date: string) => (startStr ? startStr <= date : true) && (endStr ? date <= endStr : true) + const dateFilter = (date: string) => (start ? start <= date : true) && (end ? date <= end : true) const items = await this.refresh() // Key format: 20201112www.google.com @@ -225,11 +235,10 @@ export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { .filter(key => keyReg.test(key) && dateFilter(key.substring(0, 8))) await this.storage.remove(keys) - return keys.map(k => k.substring(0, 8)) } - async deleteByGroup(groupId: number, range?: [start?: Date | string, end?: Date | string]): Promise { - const [start, end] = range ?? [] + async deleteByGroup(groupId: number, range?: string | [string, string]): Promise { + const [start, end] = Array.isArray(range) ? range : [range, range] const startStr = start && formatDateStr(start) const endStr = end && formatDateStr(end) const dateFilter = (date: string) => (startStr ? startStr <= date : true) && (endStr ? date <= endStr : true) diff --git a/src/database/stat-database/common.ts b/src/background/database/stat-database/common.ts similarity index 100% rename from src/database/stat-database/common.ts rename to src/background/database/stat-database/common.ts diff --git a/src/database/stat-database/condition.ts b/src/background/database/stat-database/condition.ts similarity index 87% rename from src/database/stat-database/condition.ts rename to src/background/database/stat-database/condition.ts index 66901a87d..19e70f918 100644 --- a/src/database/stat-database/condition.ts +++ b/src/background/database/stat-database/condition.ts @@ -1,5 +1,4 @@ import { judgeVirtualFast } from "@util/pattern" -import { formatTimeYMD } from "@util/time" import type { StatCondition } from './types' export type ProcessedCondition = StatCondition & { @@ -43,14 +42,14 @@ export function processCondition(condition?: StatCondition): ProcessedCondition const paramDate = condition?.date if (paramDate) { - if (paramDate instanceof Date) { + if (typeof paramDate === 'string') { result.useExactDate = true - result.exactDateStr = formatTimeYMD(paramDate) + result.exactDateStr = paramDate } else { const [startDate, endDate] = paramDate result.useExactDate = false - startDate && (result.startDateStr = formatTimeYMD(startDate)) - endDate && (result.endDateStr = formatTimeYMD(endDate)) + result.startDateStr = startDate + result.endDateStr = endDate } } diff --git a/src/database/stat-database/idb.ts b/src/background/database/stat-database/idb.ts similarity index 88% rename from src/database/stat-database/idb.ts rename to src/background/database/stat-database/idb.ts index f9e7fba54..f0dca40f0 100644 --- a/src/database/stat-database/idb.ts +++ b/src/background/database/stat-database/idb.ts @@ -1,5 +1,5 @@ -import { BaseIDBStorage, closedRangeKey, IndexResult, iterateCursor, type Key, req2Promise, type Table } from '@db/common/indexed-storage' -import { cvtGroupId2Host, formatDateStr, GROUP_PREFIX, increase, zeroRow } from './common' +import { BaseIDBStorage, closedRangeKey, IndexResult, iterateCursor, type Key, req2Promise, type Table } from '../common/indexed-storage' +import { cvtGroupId2Host, formatDateStr, increase, zeroRow } from './common' import { filterDate, filterHost, filterNumberRange, processCondition, type ProcessedCondition } from './condition' import type { StatCondition, StatDatabase } from './types' @@ -8,8 +8,6 @@ type StoredRow = timer.core.Row & { groupId?: number } -const GROUP_HOST_PATTERN = new RegExp(`^${GROUP_PREFIX}(\\d+)$`) - const INDEXES: (Key | Key[])[] = [ 'date', 'host', 'groupId', 'focus', 'time', @@ -65,6 +63,20 @@ export class IDBStatDatabase extends BaseIDBStorage implements StatDa }, 'readonly') } + batchSelect(keys: timer.core.RowKey[]): Promise { + if (!keys.length) return Promise.resolve([]) + return this.withStore(async store => { + const index = super.assertIndex(store, ['date', 'host']) + const out: timer.core.Row[] = [] + for (const { host, date } of keys) { + const req = index.get([date, host]) + const row = await req2Promise(req) ?? zeroRow(host, date) + out.push(row) + } + return out + }, 'readonly') + } + private judgeIndex(store: IDBObjectStore, cond: ProcessedCondition, expectGroup: boolean): IndexResult { const keys = typeof cond.keys === 'string' ? [cond.keys] : cond.keys const { @@ -211,21 +223,18 @@ export class IDBStatDatabase extends BaseIDBStorage implements StatDa }, 'readwrite') } - deleteByHost(host: string, range?: [start?: Date | string, end?: Date | string]): Promise { + deleteByHost(host: string, range?: string | [string?, string?]): Promise { + const [start, end] = Array.isArray(range) ? range : [range, range] return this.withStore(async store => { - const [start, end] = range ?? [] - const startStr = start ? formatDateStr(start) : undefined - const endStr = end ? formatDateStr(end) : undefined - - if (startStr && startStr === endStr) { + if (start && start === end) { // Delete one day const index = super.assertIndex(store, ['date', 'host']) - const req = index.getKey([startStr, host]) + const req = index.getKey([start, host]) const key = await req2Promise(req) if (key) { await req2Promise(store.delete(key)) } - return [startStr] + return } // Delete by range @@ -238,14 +247,41 @@ export class IDBStatDatabase extends BaseIDBStorage implements StatDa if (!r || isGroup(r)) return const dateStr = r.date - const inRange = (!startStr || startStr <= dateStr) && (!endStr || dateStr <= endStr) + const inRange = (!start || start <= dateStr) && (!end || dateStr <= end) if (inRange) { cursor.delete() deletedDates.add(dateStr) } }) + }, 'readwrite') + } - return Array.from(deletedDates) + deleteByGroup(groupId: number, range?: string | [string, string]): Promise { + return this.withStore(async store => { + const [start, end] = Array.isArray(range) ? range : [range, range] + const host = cvtGroupId2Host(groupId) + if (start && start === end) { + // Delete one day + const index = super.assertIndex(store, ['date', 'host']) + const req = index.getKey([start, host]) + const key = await req2Promise(req) + if (key) { + await req2Promise(store.delete(key)) + } + return + } + + // Delete by range + const index = super.assertIndex(store, 'host') + const cursorReq = index.openCursor(IDBKeyRange.only(host)) + + await iterateCursor(cursorReq, cursor => { + const r = cursor.value as StoredRow | undefined + if (!r) return + const dateStr = r.date + const inRange = (!start || start <= dateStr) && (!end || dateStr <= end) + inRange && cursor.delete() + }) }, 'readwrite') } @@ -285,38 +321,6 @@ export class IDBStatDatabase extends BaseIDBStorage implements StatDa }, 'readwrite') } - deleteByGroup(groupId: number, range?: [start?: Date | string, end?: Date | string]): Promise { - return this.withStore(async store => { - const [start, end] = range ?? [] - const startStr = start ? formatDateStr(start) : undefined - const endStr = end ? formatDateStr(end) : undefined - const host = cvtGroupId2Host(groupId) - - if (startStr && startStr === endStr) { - // Delete one day - const index = super.assertIndex(store, ['date', 'host']) - const req = index.getKey([startStr, host]) - const key = await req2Promise(req) - if (key) { - await req2Promise(store.delete(key)) - } - return - } - - // Delete by range - const index = super.assertIndex(store, 'host') - const cursorReq = index.openCursor(IDBKeyRange.only(host)) - - await iterateCursor(cursorReq, cursor => { - const r = cursor.value as StoredRow | undefined - if (!r) return - const dateStr = r.date - const inRange = (!startStr || startStr <= dateStr) && (!endStr || dateStr <= endStr) - inRange && cursor.delete() - }) - }, 'readwrite') - } - forceUpdate(...rows: timer.core.Row[]): Promise { return this.withStore(store => rows.forEach(row => store.put(row)), 'readwrite') } diff --git a/src/database/stat-database/index.ts b/src/background/database/stat-database/index.ts similarity index 90% rename from src/database/stat-database/index.ts rename to src/background/database/stat-database/index.ts index 57277b335..04df14e34 100644 --- a/src/database/stat-database/index.ts +++ b/src/background/database/stat-database/index.ts @@ -5,12 +5,12 @@ * https://opensource.org/licenses/MIT */ -import { extractNamespace, isExportData, isLegacyVersion } from '@db/common/migratable' -import { StorageHolder } from '@db/common/storage-holder' -import type { BrowserMigratable, StorageMigratable } from '@db/types' import { isOptionalInt } from '@util/guard' import { isNotZeroResult } from '@util/stat' import { createArrayGuard, createObjectGuard, isString } from 'typescript-guard' +import { extractNamespace, isExportData, isLegacyVersion } from '../common/migratable' +import { StorageHolder } from '../common/storage-holder' +import type { BrowserMigratable, StorageMigratable } from '../types' import { ClassicStatDatabase, parseImportData } from './classic' import { IDBStatDatabase } from './idb' import type { StatCondition, StatDatabase } from './types' @@ -41,10 +41,14 @@ class StatDatabaseWrapper implements StateDatabaseComposite { }) private current = () => this.holder.current - get(host: string, date: Date | string): Promise { + get(host: string, date: Date): Promise { return this.current().get(host, date) } + batchSelect(keys: timer.core.RowKey[]): Promise { + return this.current().batchSelect(keys) + } + select(condition?: StatCondition): Promise { return this.current().select(condition) } @@ -65,10 +69,14 @@ class StatDatabaseWrapper implements StateDatabaseComposite { return this.current().delete(...rows) } - deleteByHost(host: string, range?: [start?: Date | string, end?: Date | string]): Promise { + deleteByHost(host: string, range?: string | [string, string]): Promise { return this.current().deleteByHost(host, range) } + deleteByGroup(groupId: number, range?: string | [string, string]): Promise { + return this.current().deleteByGroup(groupId, range) + } + selectGroup(condition?: StatCondition): Promise { return this.current().selectGroup(condition) } @@ -77,10 +85,6 @@ class StatDatabaseWrapper implements StateDatabaseComposite { return this.current().deleteGroup(...rows) } - deleteByGroup(groupId: number, range: [start?: Date | string, end?: Date | string]): Promise { - return this.current().deleteByGroup(groupId, range) - } - forceUpdate(...rows: timer.core.Row[]): Promise { return this.current().forceUpdate(...rows) } @@ -89,7 +93,6 @@ class StatDatabaseWrapper implements StateDatabaseComposite { return this.current().forceUpdateGroup(...rows) } - async migrateStorage(type: timer.option.StorageType): Promise<[timer.core.Row[], timer.core.Row[]]> { const target = this.holder.get(type) if (!target) return [[], []] diff --git a/src/database/stat-database/types.ts b/src/background/database/stat-database/types.ts similarity index 82% rename from src/database/stat-database/types.ts rename to src/background/database/stat-database/types.ts index 4e2ba1376..8de20377a 100644 --- a/src/database/stat-database/types.ts +++ b/src/background/database/stat-database/types.ts @@ -4,7 +4,7 @@ export type StatCondition = { * Date * {y}{m}{d} */ - date?: Date | [Date?, Date?] + date?: string | [string?, string?] /** * Focus range, milliseconds * @@ -30,7 +30,8 @@ export type StatCondition = { } export interface StatDatabase { - get(host: string, date: Date | string): Promise + get(host: string, date: Date): Promise + batchSelect(keys: timer.core.RowKey[]): Promise select(condition?: StatCondition): Promise /** * Accumulate data @@ -43,9 +44,16 @@ export interface StatDatabase { * * @param host host * @param range date range, inclusive start and end, if null, delete all + * @return dates to deleted */ - deleteByHost(host: string, range?: [start?: Date | string, end?: Date | string]): Promise - + deleteByHost(host: string, range?: string | [string?, string?]): Promise + /** + * Delete group data + * + * @param groupId the id of group + * @param range date range, inclusive start and end, if null, delete all + */ + deleteByGroup(groupId: number, range?: string | [string?, string?]): Promise /******* GROUP *******/ /** * Accumulate data for tab group @@ -53,14 +61,6 @@ export interface StatDatabase { accumulateGroup(groupId: number, date: Date | string, item: timer.core.Result): Promise selectGroup(condition?: StatCondition): Promise deleteGroup(...rows: [groupId: number, date: string][]): Promise - /** - * Delete group data - * - * @param groupId the id of group - * @param range date range, inclusive start and end, if null, delete all - */ - deleteByGroup(groupId: number, range?: [start?: Date | string, end?: Date | string]): Promise - /** * Force update data with overwriting */ diff --git a/src/database/timeline-database/classic.ts b/src/background/database/timeline-database/classic.ts similarity index 100% rename from src/database/timeline-database/classic.ts rename to src/background/database/timeline-database/classic.ts diff --git a/src/database/timeline-database/idb.ts b/src/background/database/timeline-database/idb.ts similarity index 99% rename from src/database/timeline-database/idb.ts rename to src/background/database/timeline-database/idb.ts index dd7cb4b8f..130bb3752 100644 --- a/src/database/timeline-database/idb.ts +++ b/src/background/database/timeline-database/idb.ts @@ -1,8 +1,8 @@ +import { MILL_PER_DAY, MILL_PER_SECOND } from '@util/time' import { BaseIDBStorage, iterateCursor, req2Promise, type Index, type IndexResult, type Key, type Table, -} from '@db/common/indexed-storage' -import { MILL_PER_DAY, MILL_PER_SECOND } from '@util/time' +} from '../common/indexed-storage' import type { TimelineCondition, TimelineDatabase } from './types' const TIME_LIFE_CYCLE = MILL_PER_DAY * 366 diff --git a/src/database/timeline-database/index.ts b/src/background/database/timeline-database/index.ts similarity index 100% rename from src/database/timeline-database/index.ts rename to src/background/database/timeline-database/index.ts diff --git a/src/database/timeline-database/types.ts b/src/background/database/timeline-database/types.ts similarity index 100% rename from src/database/timeline-database/types.ts rename to src/background/database/timeline-database/types.ts diff --git a/src/database/types.d.ts b/src/background/database/types.d.ts similarity index 89% rename from src/database/types.d.ts rename to src/background/database/types.d.ts index e0426312a..ed81c3ac3 100644 --- a/src/database/types.d.ts +++ b/src/background/database/types.d.ts @@ -25,7 +25,7 @@ export type BrowserMigratableNamespace = keyof Omit { +export interface BrowserMigratable { /** * The name space for migration */ diff --git a/src/database/whitelist-database.ts b/src/background/database/whitelist-database.ts similarity index 75% rename from src/database/whitelist-database.ts rename to src/background/database/whitelist-database.ts index 7547fd5f1..3b43d203b 100644 --- a/src/database/whitelist-database.ts +++ b/src/background/database/whitelist-database.ts @@ -40,24 +40,6 @@ class WhitelistDatabase extends BaseDatabase implements BrowserMigratable<'__whi return exist?.includes(white) } - /** - * Add listener to listen changes - * - * @since 0.1.9 - */ - addChangeListener(listener: (whitelist: string[]) => void) { - const storageListener = ( - changes: { [key: string]: chrome.storage.StorageChange }, - _areaName: chrome.storage.AreaName, - ) => { - const changeInfo = changes[WHITELIST_KEY] - const newValue = changeInfo?.newValue - const whitelists: string[] = Array.isArray(newValue) ? newValue.map(n => new String(n).toString()) : [] - changeInfo && listener(whitelists) - } - chrome.storage.onChanged.addListener(storageListener) - } - async importData(data: unknown): Promise { if (!isExportData(data)) return const toImport = isLegacyVersion(data) @@ -74,7 +56,7 @@ class WhitelistDatabase extends BaseDatabase implements BrowserMigratable<'__whi * @deprecated Only for legacy data, will be removed in future version */ private parseLegacyData(data: timer.backup.ExportData): string[] { - const toMigrate = data[WHITELIST_KEY] + const toMigrate = (data as any)[WHITELIST_KEY] return isStringArray(toMigrate) ? toMigrate : [] } diff --git a/src/background/icon-and-alias-collector.ts b/src/background/icon-and-alias-collector.ts deleted file mode 100644 index 8c1516fd3..000000000 --- a/src/background/icon-and-alias-collector.ts +++ /dev/null @@ -1,52 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { getTab } from "@api/chrome/tab" -import { saveAlias, saveIconUrl } from "@service/site-service" -import { IS_ANDROID, IS_CHROME, IS_SAFARI } from "@util/constant/environment" -import { extractHostname, isBrowserUrl, isHomepage } from "@util/pattern" -import { extractSiteName } from "@util/site" - -function isUrl(title: string) { - return title.startsWith('https://') || title.startsWith('http://') || title.startsWith('ftp://') -} - -async function collectAlias(key: timer.site.SiteKey, tabTitle: string) { - if (!tabTitle) return - if (isUrl(tabTitle)) return - const siteName = extractSiteName(tabTitle, key.host) - siteName && await saveAlias(key, siteName, true) -} - -/** - * Process the tab - */ -async function processTabInfo(tab: ChromeTab): Promise { - let { favIconUrl, url, title } = tab - if (!url || !title) return - if (isBrowserUrl(url)) return - const hostInfo = extractHostname(url) - const host = hostInfo.host - if (!host) return - // localhost hosts with Chrome use cache, so keep the favIcon url undefined - IS_CHROME && /^localhost(:.+)?/.test(host) && (favIconUrl = undefined) - const siteKey: timer.site.SiteKey = { host, type: 'normal' } - favIconUrl && await saveIconUrl(siteKey, favIconUrl) - !IS_ANDROID - && !isBrowserUrl(url) - && isHomepage(url) - && await collectAlias(siteKey, title) -} - -/** - * Collect the favicon of host - */ -export const collectIconAndAlias = async (tabId: number) => { - if (IS_SAFARI || IS_ANDROID) return - const tab = await getTab(tabId) - tab && processTabInfo(tab) -} \ No newline at end of file diff --git a/src/background/index.ts b/src/background/index.ts index a49bd13e0..7eead8c05 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -5,33 +5,19 @@ * https://opensource.org/licenses/MIT */ -import { listTabs, trySendMsg2Tab } from "@api/chrome/tab" -import { isNoneWindowId, onNormalWindowFocusChanged } from "@api/chrome/window" -import optionHolder from "@service/components/option-holder" -import { isBrowserUrl } from "@util/pattern" -import { openLog } from "../common/logger" +import { trySendMsg2Tab } from "@api/chrome/tab" import badgeTextManager from "./badge-manager" -import initBrowserAction from "./browser-action-manager" import initCsHandler from "./content-script-handler" import initDataCleaner from "./data-cleaner" -import handleInstall from './install-handler' +import { initAfterInstalled } from './install-handler' import initLimitProcessor from "./limit-processor" import MessageDispatcher from "./message-dispatcher" -import VersionMigrator from "./migrator" import { initScheduler } from './scheduler' -import initSidePanel from "./side-panel" import TabListener from './tab-listener' import initTrackServer from "./track-server" import initWhitelistMenuManager from "./whitelist-menu-manager" -// Open the log of console -openLog() - -// Init side panel -initSidePanel() - -// Init browser action -initBrowserAction() +initAfterInstalled() // Init data cleaner initDataCleaner() @@ -47,11 +33,8 @@ initCsHandler(messageDispatcher) // Start server initTrackServer(messageDispatcher) -// Process version -new VersionMigrator().init() - // scheduler -initScheduler(messageDispatcher) +initScheduler() // Manage the context menus initWhitelistMenuManager() @@ -65,21 +48,5 @@ new TabListener() .onUpdated((tabId, { audible }) => audible !== undefined && trySendMsg2Tab(tabId, 'syncAudible', audible)) .start() -handleInstall() - // Start message dispatcher messageDispatcher.start() - -// Listen window focus changed -onNormalWindowFocusChanged(async windowId => { - if (isNoneWindowId(windowId)) return - const tabs = await listTabs({ windowId, active: true }) - tabs.forEach(tab => { - const { url, id: tabId } = tab - if (!url || isBrowserUrl(url) || !tabId) return - badgeTextManager.updateFocus({ url, tabId }) - }) -}) - -// listen permission change event -optionHolder.listenPermChange() \ No newline at end of file diff --git a/src/background/browser-action-manager.ts b/src/background/install-handler/browser-action.ts similarity index 82% rename from src/background/browser-action-manager.ts rename to src/background/install-handler/browser-action.ts index 12af14126..61f71705a 100644 --- a/src/background/browser-action-manager.ts +++ b/src/background/install-handler/browser-action.ts @@ -4,18 +4,18 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ +import { APP_OPTION_ROUTE, APP_REPORT_ROUTE } from "@/shared/route" import { onIconClick } from "@api/chrome/action" import { createContextMenu } from "@api/chrome/context-menu" import { getRuntimeId } from "@api/chrome/runtime" import { createTab } from "@api/chrome/tab" import { locale } from "@i18n" import { t2Chrome } from "@i18n/chrome/t" -import { IS_ANDROID, IS_FIREFOX, IS_MV3, IS_SAFARI } from "@util/constant/environment" +import { IS_ANDROID, IS_MV3, IS_SAFARI } from "@util/constant/environment" import { CHANGE_LOG_PAGE, GITHUB_ISSUE_ADD, SOURCE_CODE_PAGE, TU_CAO_PAGE, getAppPageUrl, getGuidePageUrl, } from "@util/constant/url" -import { OPTION_ROUTE, REPORT_ROUTE } from "../pages/app/router/constants" const APP_PAGE_URL = getAppPageUrl() @@ -36,13 +36,6 @@ function titleOf(prefixEmoji: string, title: string) { } } -const sidebarProps: ChromeContextMenuCreateProps = { - id: getRuntimeId() + '_timer_menu_item_sidebar', - title: titleOf('🖱️', t2Chrome(msg => msg.base.sidebar)), - onclick: () => browser.sidebarAction.open(), - ...baseProps, -} - const allFunctionProps: ChromeContextMenuCreateProps = { id: getRuntimeId() + '_timer_menu_item_app_link', title: titleOf('🏷️', t2Chrome(msg => msg.base.allFunction)), @@ -53,7 +46,7 @@ const allFunctionProps: ChromeContextMenuCreateProps = { const optionPageProps: ChromeContextMenuCreateProps = { id: getRuntimeId() + '_timer_menu_item_option_link', title: titleOf('🥰', t2Chrome(msg => msg.base.option)), - onclick: () => createTab(APP_PAGE_URL + '#' + OPTION_ROUTE), + onclick: () => createTab(getAppPageUrl(APP_OPTION_ROUTE)), ...baseProps } @@ -86,8 +79,6 @@ const changeLogProps: ChromeContextMenuCreateProps = { } function initBrowserAction() { - // Create sidebar item for Firefox - createContextMenu(IS_FIREFOX ? sidebarProps : allFunctionProps) createContextMenu(allFunctionProps) createContextMenu(optionPageProps) createContextMenu(repoPageProps) @@ -97,7 +88,7 @@ function initBrowserAction() { if (IS_ANDROID) { // Forbidden popup page - onIconClick(() => createTab({ url: getAppPageUrl(REPORT_ROUTE) })) + onIconClick(() => createTab({ url: getAppPageUrl(APP_REPORT_ROUTE) })) } } diff --git a/src/background/install-handler/index.ts b/src/background/install-handler/index.ts index 426ba1ab9..3303ca238 100644 --- a/src/background/install-handler/index.ts +++ b/src/background/install-handler/index.ts @@ -1,33 +1,56 @@ -import { onInstalled } from "@api/chrome/runtime" +import { locale } from '@/i18n' +import { onInstalled, setUninstallURL } from "@api/chrome/runtime" import { executeScript } from "@api/chrome/script" import { createTabAfterCurrent, listTabs } from "@api/chrome/tab" import { updateInstallTime } from "@service/meta-service" -import { IS_E2E, IS_FROM_STORE } from "@util/constant/environment" -import { getGuidePageUrl } from "@util/constant/url" +import { IS_E2E, IS_FROM_STORE, IS_MV3 } from "@util/constant/environment" +import { getGuidePageUrl, UNINSTALL_QUESTIONNAIRE } from "@util/constant/url" import { isBrowserUrl } from "@util/pattern" -import UninstallListener from './uninstall-listener' +import initBrowserAction from './browser-action' +import versionManager from './version' async function onFirstInstall() { - updateInstallTime(new Date()) + updateInstallTime(Date.now()) !IS_E2E && createTabAfterCurrent(getGuidePageUrl()) } async function reloadContentScript() { const files = chrome.runtime.getManifest().content_scripts?.[0]?.js - if (!files?.length) { - return - } + if (!files?.length) return const tabs = await listTabs() tabs.filter(({ url }) => url && !isBrowserUrl(url)) .forEach(({ id: tabId }) => tabId && executeScript(tabId, files)) } -export default function handleInstall() { +function initQuestionnaire() { + try { + setUninstallURL(UNINSTALL_QUESTIONNAIRE[locale] ?? UNINSTALL_QUESTIONNAIRE['en']) + } catch (e) { + console.error("Failed to set uninstall URL", e) + } +} + +function initSidePanel() { + if (!IS_MV3) return + const sidePanel = chrome.sidePanel + // sidePanel not supported for Firefox + // Avoid `chrome.sidePanel.setOptions` to skip web-ext lint + if (!sidePanel?.setOptions) return + sidePanel.setOptions({ path: "/static/side.html" }) +} + +export function initAfterInstalled() { onInstalled(async reason => { reason === "install" && await onFirstInstall() // Questionnaire for uninstall - new UninstallListener().listen() + initQuestionnaire() // Reload content-script IS_FROM_STORE && await reloadContentScript() + // Initialize side panel + initSidePanel() + // Initialize context menu + initBrowserAction() + // Initialize with version + versionManager.handle(reason) }) } \ No newline at end of file diff --git a/src/background/install-handler/uninstall-listener.ts b/src/background/install-handler/uninstall-listener.ts deleted file mode 100644 index 34b9eb16d..000000000 --- a/src/background/install-handler/uninstall-listener.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { setUninstallURL } from "@api/chrome/runtime" -import { locale } from "@i18n" -import { UNINSTALL_QUESTIONNAIRE } from "@util/constant/url" - -async function listen() { - try { - const uninstallUrl = UNINSTALL_QUESTIONNAIRE[locale] || UNINSTALL_QUESTIONNAIRE['en'] - uninstallUrl && setUninstallURL(uninstallUrl) - } catch (e) { - console.error(e) - } -} - -/** - * @since 0.9.6 - */ -export default class UninstallListener { - listen = listen -} diff --git a/src/background/migrator/cate-initializer.ts b/src/background/install-handler/version/cate-initializer.ts similarity index 82% rename from src/background/migrator/cate-initializer.ts rename to src/background/install-handler/version/cate-initializer.ts index 3049d30c6..09ee420e3 100644 --- a/src/background/migrator/cate-initializer.ts +++ b/src/background/install-handler/version/cate-initializer.ts @@ -1,5 +1,5 @@ -import cateService from "@service/cate-service" -import { batchSaveSiteCate } from "@service/site-service" +import cateDatabase from '@db/cate-database' +import { batchChangeCate } from '@service/site-service' import type { Migrator } from "./types" type InitialCate = { @@ -31,10 +31,10 @@ const DEMO_ITEMS: InitialCate[] = [ async function initItem(item: InitialCate) { const { name, hosts } = item - const cate = await cateService.add(name) + const cate = await cateDatabase.add(name) const cateId = cate.id const siteKeys = hosts.map(host => ({ host, type: 'normal' } satisfies timer.site.SiteKey)) - await batchSaveSiteCate(cateId, siteKeys) + await batchChangeCate(cateId, siteKeys) } export default class CateInitializer implements Migrator { diff --git a/src/background/migrator/host-merge-initializer.ts b/src/background/install-handler/version/host-merge-initializer.ts similarity index 100% rename from src/background/migrator/host-merge-initializer.ts rename to src/background/install-handler/version/host-merge-initializer.ts diff --git a/src/background/migrator/index.ts b/src/background/install-handler/version/index.ts similarity index 83% rename from src/background/migrator/index.ts rename to src/background/install-handler/version/index.ts index ebd78210d..3c6767dc2 100644 --- a/src/background/migrator/index.ts +++ b/src/background/install-handler/version/index.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { getVersion, onInstalled } from "@api/chrome/runtime" +import { getVersion } from "@api/chrome/runtime" import CateInitializer from "./cate-initializer" import HostMergeInitializer from "./host-merge-initializer" import IndexedDBMigrator from './indexed-migrator' @@ -31,7 +31,7 @@ class VersionManager { ) } - private onChromeInstalled(reason: ChromeOnInstalledReason) { + handle(reason: ChromeOnInstalledReason) { const version: string = getVersion() if (reason === 'update') { // Update, process the latest version, which equals to current version @@ -41,10 +41,8 @@ class VersionManager { this.processorChain.forEach(processor => processor.onInstall()) } } - - init() { - onInstalled(reason => this.onChromeInstalled(reason)) - } } -export default VersionManager \ No newline at end of file +const versionManager = new VersionManager() + +export default versionManager \ No newline at end of file diff --git a/src/background/migrator/indexed-migrator.ts b/src/background/install-handler/version/indexed-migrator.ts similarity index 100% rename from src/background/migrator/indexed-migrator.ts rename to src/background/install-handler/version/indexed-migrator.ts diff --git a/src/background/migrator/local-file-initializer.ts b/src/background/install-handler/version/local-file-initializer.ts similarity index 100% rename from src/background/migrator/local-file-initializer.ts rename to src/background/install-handler/version/local-file-initializer.ts diff --git a/src/background/migrator/types.ts b/src/background/install-handler/version/types.ts similarity index 100% rename from src/background/migrator/types.ts rename to src/background/install-handler/version/types.ts diff --git a/src/background/migrator/whitelist-initializer.ts b/src/background/install-handler/version/whitelist-initializer.ts similarity index 63% rename from src/background/migrator/whitelist-initializer.ts rename to src/background/install-handler/version/whitelist-initializer.ts index e2cb2d1f0..ccce09ae4 100644 --- a/src/background/migrator/whitelist-initializer.ts +++ b/src/background/install-handler/version/whitelist-initializer.ts @@ -1,9 +1,9 @@ -import whitelistService from "@service/whitelist/service" +import whitelistHolder from '@service/whitelist/holder' import type { Migrator } from "./types" export default class WhitelistInitializer implements Migrator { onInstall(): void { - whitelistService.add('localhost:*/**') + whitelistHolder.add('localhost:*/**') } onUpdate(_version: string): void { diff --git a/src/background/limit-processor.ts b/src/background/limit-processor.ts index baca1709f..157e66d21 100644 --- a/src/background/limit-processor.ts +++ b/src/background/limit-processor.ts @@ -5,68 +5,36 @@ * https://opensource.org/licenses/MIT */ -import { createTabAfterCurrent, getRightOf, listTabs, resetTabUrl, sendMsg2Tab } from "@api/chrome/tab" -import { LIMIT_ROUTE } from "@app/router/constants" -import limitService from "@service/limit-service" -import { getAppPageUrl } from "@util/constant/url" +import { listTabs, sendMsg2Tab } from "@api/chrome/tab" +import { getSite } from '@service/site-service' import { matches } from "@util/limit" -import { isBrowserUrl } from "@util/pattern" -import { getStartOfDay, MILL_PER_DAY, MILL_PER_SECOND } from "@util/time" +import { extractHostname } from '@util/pattern' +import { getStartOfDay, MILL_PER_DAY } from "@util/time" import alarmManager from "./alarm-manager" import MessageDispatcher from "./message-dispatcher" +import { + createLimitRule, delayLimit, noticeLimitChanged, removeLimitRules, selectLimit, updateLimitRules, +} from "./service/limit-service" -function processLimitWaking(rules: timer.limit.Item[], tab: ChromeTab): void { - const { url, id: tabId } = tab - if (!url || !tabId) return - const anyMatch = rules.map(rule => matches(rule?.cond, url)).reduce((a, b) => a || b, false) - if (!anyMatch) { - return - } - sendMsg2Tab(tabId, 'limitWaking', rules) - .then(() => console.log(`Waked tab[id=${tab.id}]`)) - .catch(err => console.error(`Failed to wake with limit rule: rules=${JSON.stringify(rules)}, msg=${err.msg}`)) -} - -async function processOpenPage(limitedUrl: string, sender: ChromeMessageSender) { - const originTab = sender?.tab - if (!originTab) return - const realUrl = getAppPageUrl(LIMIT_ROUTE, { url: encodeURI(limitedUrl) }) - const baseUrl = getAppPageUrl(LIMIT_ROUTE) - const rightTab = await getRightOf(originTab) - const { id: rightId, url: rightUrl } = rightTab || {} - if (rightId && rightUrl && isBrowserUrl(rightUrl) && rightUrl.includes(baseUrl)) { - // Reset url - await resetTabUrl(rightId, realUrl) - } else { - await createTabAfterCurrent(realUrl, sender?.tab) - } -} function initDailyBroadcast() { // Broadcast rules at the start of each day alarmManager.setWhen( 'limit-daily-broadcast', () => getStartOfDay(new Date()) + MILL_PER_DAY, - () => limitService.broadcastRules(), + noticeLimitChanged, ) } -const processMoreMinutes = async (url: string) => { - const rules = await limitService.moreMinutes(url) - - const tabs = await listTabs({ status: 'complete' }) - tabs.forEach(tab => processLimitWaking(rules, tab)) -} - const processAskHitVisit = async (item: timer.limit.Item) => { let tabs = await listTabs() - const { visitTime = 0, cond } = item || {} + const { cond } = item for (const { id, url } of tabs) { try { if (!url || !matches(cond, url) || !id) continue - const tabFocus = await sendMsg2Tab(id, "askVisitTime") - if (tabFocus && tabFocus > visitTime * MILL_PER_SECOND) return true + const visitHit = await sendMsg2Tab(id, "askVisitHit", item.id) + if (visitHit) return true } catch { // Ignored } @@ -74,12 +42,29 @@ const processAskHitVisit = async (item: timer.limit.Item) => { return false } +async function querySummary(): Promise { + const tabs = await listTabs({ currentWindow: true, active: true }) + if (tabs.length !== 1) return undefined + const [tab] = tabs + const url = tab.url + if (!url) return undefined + + const { host } = extractHostname(url) + const site = await getSite({ host, type: 'normal' }) + const items = await selectLimit({ url, effective: true }) + + return { url, site, items } +} + export default function init(dispatcher: MessageDispatcher) { initDailyBroadcast() + dispatcher - .register('openLimitPage', processOpenPage) - // More minutes - .register('cs.moreMinutes', processMoreMinutes) - // Judge any tag hit the time limit per visit - .register("askHitVisit", processAskHitVisit) + .register('limit.list', selectLimit) + .register('limit.delete', removeLimitRules) + .register('limit.update', updateLimitRules) + .register('limit.add', createLimitRule) + .register('limit.hitVisit', processAskHitVisit) + .register('limit.delay', delayLimit) + .register('limit.summary', querySummary) } \ No newline at end of file diff --git a/src/background/message-dispatcher.ts b/src/background/message-dispatcher.ts index d6b94f8ea..ceb5e19cd 100644 --- a/src/background/message-dispatcher.ts +++ b/src/background/message-dispatcher.ts @@ -5,35 +5,150 @@ * https://opensource.org/licenses/MIT */ +import { log } from '@/common/logger' import { onRuntimeMessage } from "@api/chrome/runtime" +import cateDatabase from './database/cate-database' +import { getUsedStorage } from './database/memory-detector' +import mergeRuleDatabase from "./database/merge-rule-database" +import siteDatabase from './database/site-database' +import statDatabase from './database/stat-database' +import backupProcessor from "./service/backup/processor" +import { exportData, importData, migrateStorage } from "./service/components/immigration" +import { importOther, previewBackup } from "./service/components/import-processor" +import optionHolder from "./service/components/option-holder" +import { getWeekStartDay, getWeekStartTime } from "./service/components/week-helper" +import { getTodayResult } from './service/item-service' +import { getInstallTime, getLastBackUp } from "./service/meta-service" +import notificationProcessor from './service/notification/processor' +import { selectPeriods } from "./service/period-service" +import { + addSite, batchChangeCate, fillInitialAlias, getInitialAlias, getSite, removeIconUrl, removeSites, saveAlias, + saveSiteRunState, searchSites, selectSitePage, +} from "./service/site-service" +import { + batchDelete, countGroup, countSite, selectCate, selectCatePage, selectGroup, selectGroupPage, selectSite, + selectSitePage as selectStateSitePage, +} from "./service/stat-service" +import timelineThrottler from './service/throttler/timeline-throttler' +import { listTimeline } from "./service/timeline-service" +import whitelistHolder from './service/whitelist/holder' + +function processParam(param: unknown): unknown { + if (param === null || param === undefined) { + return undefined + } + const startTs = Date.now() + // Convert null to undefined, because null can't be serialized in chrome.runtime.sendMessage + const json = JSON.stringify(param) + const result = JSON.parse(json, (_key, val) => val ?? undefined) + log(`Processed param in ${Date.now() - startTs}ms`) + return result +} class MessageDispatcher { - private handlers: Partial<{ - [code in timer.mq.ReqCode]: timer.mq.Handler - }> = {} + private handlers: Partial>> = {} - register(code: timer.mq.ReqCode, handler: timer.mq.Handler): MessageDispatcher { + constructor() { + this.initServiceHandlers() + } + + register(code: C, handler: timer.mq.Handler): MessageDispatcher { if (this.handlers[code]) { - throw new Error("Duplicate handler") + throw new Error(`Duplicate handler: code=${code}`) } - this.handlers[code] = handler + this.handlers[code] = handler as unknown as timer.mq.Handler return this } - private async handle(message: timer.mq.Request, sender: ChromeMessageSender): Promise> { + private initServiceHandlers() { + this + // Statistics + .register('stat.sites', selectSite) + .register('stat.sitePage', selectStateSitePage) + .register('stat.countSite', countSite) + .register('stat.deleteSite', param => 'host' in param + ? statDatabase.deleteByHost(param.host, param.date) + : statDatabase.deleteByGroup(param.groupId, param.date)) + .register('stat.cates', selectCate) + .register('stat.catePage', selectCatePage) + .register('stat.groups', selectGroup) + .register('stat.groupPage', selectGroupPage) + .register('stat.countGroup', countGroup) + .register('stat.batchDelete', batchDelete) + .register('stat.today', getTodayResult) + .register('item.batch', keys => statDatabase.batchSelect(keys)) + // Site management + .register('site.list', param => siteDatabase.select(param)) + .register('site.page', selectSitePage) + .register('site.add', addSite) + .register('site.delete', removeSites) + .register('site.changeCate', ({ cateId, keys }) => batchChangeCate(cateId, keys)) + .register('site.deleteIcon', removeIconUrl) + .register('site.changeAlias', ({ key, alias }) => saveAlias(key, alias)) + .register('site.fillAlias', fillInitialAlias) + .register('site.initialAlias', getInitialAlias) + .register('site.changeRun', ({ key, enabled }) => saveSiteRunState(key, enabled)) + .register('site.runEnabled', host => getSite({ host, type: 'normal' }).then(s => !!s.run)) + .register('site.search', searchSites) + // Options + .register('option.get', () => optionHolder.get()) + .register('option.set', val => optionHolder.set(val)) + .register('option.changeStorage', migrateStorage) + .register('option.testNotification', () => notificationProcessor.doSend()) + .register('option.weekStartDay', getWeekStartDay) + .register('option.weekStartTime', getWeekStartTime) + // Category + .register('cate.all', () => cateDatabase.listAll()) + .register('cate.add', name => cateDatabase.add(name)) + .register('cate.change', ({ id, name }) => cateDatabase.update(id, name)) + .register('cate.delete', id => cateDatabase.delete(id)) + // Meta information + .register('meta.installTs', getInstallTime) + .register('meta.usedStorage', getUsedStorage) + // Whitelist & Merge Rule + .register('whitelist.contain', ({ host, url }) => whitelistHolder.contains(host, url)) + .register('whitelist.all', () => whitelistHolder.all()) + .register('whitelist.add', white => whitelistHolder.add(white)) + .register('whitelist.delete', white => whitelistHolder.remove(white)) + // Merge rule + .register('merge.all', () => mergeRuleDatabase.selectAll()) + .register('merge.delete', origin => mergeRuleDatabase.remove(origin)) + .register('merge.add', rule => mergeRuleDatabase.add(rule)) + // Backup + .register('backup.sync', () => backupProcessor.syncData()) + .register('backup.checkAuth', () => backupProcessor.checkAuth().then(res => res.errorMsg)) + .register('backup.clear', cid => backupProcessor.clear(cid)) + .register('backup.query', param => backupProcessor.query(param)) + .register('backup.lastTs', getLastBackUp) + .register('backup.clients', () => backupProcessor.listClients()) + .register('backup.preview', previewBackup) + // Period & Timeline + .register('period.list', selectPeriods) + .register('timeline.list', listTimeline) + .register('timeline.tick', ev => timelineThrottler.saveEvent(ev)) + // Data immigration + .register('immigration.import', importData) + .register('immigration.export', exportData) + .register('immigration.importOther', importOther) + } + + private async handle(message: timer.mq.Request, sender: ChromeMessageSender): Promise> { const code = message?.code if (!code) { return { code: 'ignore' } } + log(`Received message: ${code} with data: `, message?.data) const handler = this.handlers[code] if (!handler) { + console.warn(`Handler not registered for code: ${code}`) return { code: 'ignore' } } try { - const result = await handler(message.data, sender) - return { code: 'success', data: result } + const data = processParam(message.data) + const result = await handler(data, sender) + return { code: 'success', data: result } as timer.mq.Response } catch (error) { - const msg = error instanceof Error ? error.message : error?.toString?.() + const msg = error instanceof Error ? error.message : (error?.toString?.() ?? 'Unknown error') return { code: 'fail', msg } } } diff --git a/src/util/psl/index.ts b/src/background/psl/index.ts similarity index 83% rename from src/util/psl/index.ts rename to src/background/psl/index.ts index 18e2f56ed..4969343eb 100644 --- a/src/util/psl/index.ts +++ b/src/background/psl/index.ts @@ -1,7 +1,6 @@ -import { toASCII } from "punycode" import rules from "./rules.json" -export type PslNode = { +type PslNode = { // Children c?: PslTree // Is leaf @@ -14,7 +13,15 @@ const TREE: PslTree = rules type Chain = string | [string, boolean] -export const getSuffix = (origin: string): string | null => { +function toASCII(domain: string): string { + try { + return new URL(`http://${domain}`).hostname + } catch { + return domain + } +} + +export const getPslSuffix = (origin: string): string => { if (!origin) return origin const ascii = toASCII(origin) const parts = ascii.split(".") @@ -26,7 +33,7 @@ export const getSuffix = (origin: string): string | null => { return parts.splice(parts.length - partLen, partLen).join('.') } -export const get = (origin: string): string | null => { +export const getPsl = (origin: string): string | null => { if (!origin) return origin const ascii = toASCII(origin) const parts = ascii.split(".") diff --git a/src/util/psl/rules.json b/src/background/psl/rules.json similarity index 100% rename from src/util/psl/rules.json rename to src/background/psl/rules.json diff --git a/src/background/scheduler.ts b/src/background/scheduler.ts index ca4939145..593290629 100644 --- a/src/background/scheduler.ts +++ b/src/background/scheduler.ts @@ -1,16 +1,23 @@ -import optionDatabase from '@db/option-database' -import backupProcessor from "@service/backup/processor" -import notificationProcessor from "@service/notification/processor" import { MILL_PER_MINUTE } from "@util/time" import alarmManager from "./alarm-manager" -import type MessageDispatcher from './message-dispatcher' +import backupProcessor from "./service/backup/processor" +import optionHolder from './service/components/option-holder' +import notificationProcessor from "./service/notification/processor" const BACKUP_ALARM_NAME = 'auto-backup-data' const NOTIFICATION_ALARM_NAME = 'notification-data' -export async function initScheduler(dispatcher: MessageDispatcher): Promise { - dispatcher.register('resetBackupScheduler', resetBackup) - .register('resetNotificationScheduler', resetNotification) +const needResetBackup = (newVal: timer.option.AllOption, oldVal: timer.option.AllOption): boolean => + newVal.autoBackUp !== oldVal.autoBackUp || newVal.autoBackUpInterval !== oldVal.autoBackUpInterval + +const needResetNotification = (newVal: timer.option.AllOption, oldVal: timer.option.AllOption): boolean => + newVal.notificationCycle !== oldVal.notificationCycle || newVal.notificationOffset !== oldVal.notificationOffset + +export async function initScheduler(): Promise { + optionHolder.addChangeListener((newVal, oldVal) => { + if (needResetBackup(newVal, oldVal)) resetBackup() + if (needResetNotification(newVal, oldVal)) resetNotification() + }) const existBackup = await alarmManager.getAlarm(BACKUP_ALARM_NAME) !existBackup && await resetBackup() @@ -21,7 +28,7 @@ export async function initScheduler(dispatcher: MessageDispatcher): Promise { // MUST read latest option from database - const option = await optionDatabase.getOption() + const option = await optionHolder.get() await alarmManager.remove(BACKUP_ALARM_NAME) @@ -32,10 +39,8 @@ async function resetBackup(): Promise { const interval = autoBackUpInterval * MILL_PER_MINUTE await alarmManager.setInterval(BACKUP_ALARM_NAME, interval, async () => { - const result = await backupProcessor.syncData() - if (!result.success) { - console.warn(`Failed to backup ts=${Date.now()}, msg=${result.errorMsg}`) - } + const errorMsg = await backupProcessor.syncData() + if (errorMsg) console.warn(`Failed to backup ts=${Date.now()}, msg=${errorMsg}`) }) } @@ -68,7 +73,7 @@ const OFFSET_HANDLERS: Record, OffsetH async function resetNotification(): Promise { await alarmManager.remove(NOTIFICATION_ALARM_NAME) - const option = await optionDatabase.getOption() + const option = await optionHolder.get() const { notificationCycle: cycle, notificationOffset: offset } = option if (cycle === 'none') return @@ -77,9 +82,9 @@ async function resetNotification(): Promise { NOTIFICATION_ALARM_NAME, () => OFFSET_HANDLERS[cycle](offset), async () => { - const result = await notificationProcessor.doSend() - if (!result.success) { - console.warn(`Failed to send notification ts=${Date.now()}, msg=${result.errorMsg}`) + const errMsg = await notificationProcessor.doSend() + if (errMsg) { + console.warn(`Failed to send notification ts=${Date.now()}, msg=${errMsg}`) } } ) diff --git a/src/service/backup/common.ts b/src/background/service/backup/common.ts similarity index 100% rename from src/service/backup/common.ts rename to src/background/service/backup/common.ts diff --git a/src/service/backup/gist/compressor.ts b/src/background/service/backup/gist/compressor.ts similarity index 99% rename from src/service/backup/gist/compressor.ts rename to src/background/service/backup/gist/compressor.ts index f9e6a045d..3c2ee23df 100644 --- a/src/service/backup/gist/compressor.ts +++ b/src/background/service/backup/gist/compressor.ts @@ -21,7 +21,7 @@ export type GistData = { /** * Row stored in the gist */ -export type GistRow = { +type GistRow = { [host: string]: [ number, // Visit count number, // Browsing time diff --git a/src/service/backup/gist/coordinator.ts b/src/background/service/backup/gist/coordinator.ts similarity index 95% rename from src/service/backup/gist/coordinator.ts rename to src/background/service/backup/gist/coordinator.ts index 80226a3a9..39e89b6cc 100644 --- a/src/service/backup/gist/coordinator.ts +++ b/src/background/service/backup/gist/coordinator.ts @@ -11,7 +11,7 @@ import { } from "@api/gist" import { SOURCE_CODE_PAGE } from "@util/constant/url" import MonthIterator from "@util/month-iterator" -import { formatTimeYMD } from "@util/time" +import { getBirthday, parseTime } from "@util/time" import { calcAllBuckets, divide2Buckets, gistData2Rows, type GistData } from "./compressor" const TIMER_META_GIST_DESC = "Used for timer to save meta info. Don't change this description :)" @@ -91,11 +91,11 @@ export default class GistCoordinator implements timer.backup.Coordinator return file ? await getJsonFileContent(file) ?? [] : [] } - async download(context: timer.backup.CoordinatorContext, startTime: Date, endTime: Date, targetCid?: string): Promise { - const allYearMonth = new MonthIterator(startTime, endTime || new Date()).toArray() + async download(context: timer.backup.CoordinatorContext, start: string, end: string, targetCid?: string): Promise { + const startTime = parseTime(start) ?? getBirthday() + const endTime = parseTime(end) ?? new Date() + const allYearMonth = new MonthIterator(startTime, endTime).toArray() const result: timer.core.Row[] = [] - const start = formatTimeYMD(startTime) - const end = formatTimeYMD(endTime) await Promise.all(allYearMonth.map(async yearMonth => { const filename = bucket2filename(yearMonth, targetCid || context.cid) const gist: Gist = await this.getStatGist(context) @@ -126,7 +126,7 @@ export default class GistCoordinator implements timer.backup.Coordinator files: files2Update, description: TIMER_DATA_GIST_DESC } - updateGist(checkTokenExist(context), gist.id, gist2update) + await updateGist(checkTokenExist(context), gist.id, gist2update) } private isTargetMetaGist(gist: Gist): boolean { diff --git a/src/service/backup/markdown.ts b/src/background/service/backup/markdown.ts similarity index 100% rename from src/service/backup/markdown.ts rename to src/background/service/backup/markdown.ts diff --git a/src/service/backup/obsidian/coordinator.ts b/src/background/service/backup/obsidian/coordinator.ts similarity index 93% rename from src/service/backup/obsidian/coordinator.ts rename to src/background/service/backup/obsidian/coordinator.ts index 3a96a8eeb..26068f958 100644 --- a/src/service/backup/obsidian/coordinator.ts +++ b/src/background/service/backup/obsidian/coordinator.ts @@ -9,6 +9,7 @@ import { updateFile } from "@api/obsidian" import DateIterator from "@util/date-iterator" +import { getBirthday, parseTime } from '@util/time' import { processDir } from "../common" import { CLIENT_FILE_NAME, convertClients2Markdown, divideByDate, parseData } from "../markdown" @@ -45,10 +46,12 @@ export default class ObsidianCoordinator implements timer.backup.Coordinator, dateStart: Date, dateEnd: Date, targetCid?: string): Promise { + async download(context: timer.backup.CoordinatorContext, start: string, end: string, targetCid?: string): Promise { const { ctx, dirPath, cid } = prepareContext(context) - const dateIterator = new DateIterator(dateStart, dateEnd) + const startTime = parseTime(start) ?? getBirthday() + const endTime = parseTime(end) ?? new Date() + const dateIterator = new DateIterator(startTime, endTime) const result: timer.core.Row[] = [] await Promise.all(dateIterator.toArray().map(async date => { const filePath = `${dirPath}${targetCid || cid}/${date}.md` diff --git a/src/service/backup/processor.ts b/src/background/service/backup/processor.ts similarity index 64% rename from src/service/backup/processor.ts rename to src/background/service/backup/processor.ts index 91113599f..639e8fb7c 100644 --- a/src/service/backup/processor.ts +++ b/src/background/service/backup/processor.ts @@ -6,15 +6,14 @@ */ import syncDb from "@db/backup-database" -import optionHolder from "@service/components/option-holder" -import itemService from "@service/item-service" -import { getCid, updateBackUpTime, updateCid } from "@service/meta-service" -import { formatTimeYMD, getBirthday } from "@util/time" +import statDb from "@db/stat-database" +import optionHolder from "../components/option-holder" +import { getCid, updateBackUpTime } from "../meta-service" import GistCoordinator from "./gist/coordinator" import ObsidianCoordinator from "./obsidian/coordinator" import WebDAVCoordinator from "./web-dav/coordinator" -export type AuthCheckResult = { +type AuthCheckResult = { option: timer.option.BackupOption auth: timer.backup.Auth ext: timer.backup.TypeExt @@ -47,76 +46,18 @@ class CoordinatorContextWrapper implements timer.backup.CoordinatorContex } } -/** - * Declare type of NavigatorUAData - */ -type NavigatorUAData = { - brands?: { - brand?: string - version?: string - }[] - platform?: string -} - -type Result = { - success: boolean - errorMsg?: string - data?: T -} - -function error(msg: string): Result { - return { success: false, errorMsg: msg, } -} - -function success(data?: T): Result { - return { success: true, data } -} - -function generateCid() { - const uaData = (navigator as any)?.userAgentData as NavigatorUAData - let prefix = 'unknown' - if (uaData) { - const brand = uaData.brands - ?.map(e => e.brand) - ?.filter(brand => brand && brand !== "Chromium" && !brand.includes("Not")) - ?.[0] - ?.replace(' ', '-') - const platform = uaData.platform - brand && platform && (prefix = `${platform.toLowerCase()}-${brand.toLowerCase()}`) - } - return prefix + '-' + new Date().getTime() -} - -/** - * Get client id or generate it lazily - */ -async function lazyGetCid(): Promise { - let cid = await getCid() - if (!cid) { - cid = generateCid() - await updateCid(cid) - } - return cid -} - async function syncFull( context: timer.backup.CoordinatorContext, coordinator: timer.backup.Coordinator, client: timer.backup.Client -): Promise { +): Promise { // 1. select rows - let start = getBirthday() - let end = new Date() - const rows = await itemService.selectItems({ date: [start, end] }) + const rows = await statDb.select() const allDates = rows.map(r => r.date).sort((a, b) => a == b ? 0 : a > b ? 1 : -1) client.maxDate = allDates[allDates.length - 1] client.minDate = allDates[0] // 2. upload await coordinator.upload(context, rows) - return { - ts: end.getTime(), - date: formatTimeYMD(end), - } } function filterClient(c: timer.backup.Client, excludeLocal: boolean, localClientId: string, start?: string, end?: string) { @@ -135,13 +76,6 @@ function prepareAuth(option: timer.option.BackupOption): timer.backup.Auth { return { token, login } } -export type RemoteQueryParam = { - start: Date - end: Date - specCid?: string - excludeLocal?: boolean -} - class Processor { coordinators: { [type in timer.backup.Type]: timer.backup.Coordinator @@ -156,11 +90,11 @@ class Processor { } } - async syncData(): Promise> { + async syncData(): Promise { const { option, auth, ext, type, coordinator, errorMsg } = await this.checkAuth() - if (errorMsg) return error(errorMsg) + if (errorMsg) return errorMsg - const cid = await lazyGetCid() + const cid = await getCid() const context: timer.backup.CoordinatorContext = await new CoordinatorContextWrapper(cid, auth, ext, type).init() const client: timer.backup.Client = { id: cid, @@ -169,29 +103,25 @@ class Processor { maxDate: undefined } try { - let snapshot: timer.backup.Snapshot = await syncFull(context, coordinator, client) - await syncDb.updateSnapshot(type, snapshot) - const clients: timer.backup.Client[] = (await coordinator.listAllClients(context)).filter(a => a.id !== cid) || [] + await syncFull(context, coordinator, client) + const clients = (await coordinator.listAllClients(context)).filter(a => a.id !== cid) clients.push(client) await coordinator.updateClients(context, clients) // Update time - const now = Date.now() - updateBackUpTime(type, now) - return success(now) + await updateBackUpTime(type, Date.now()) } catch (e) { console.error("Error to sync data", e) - const msg = (e as Error)?.message ?? e?.toString?.() - return error(msg) + return e instanceof Error ? e.message : String(e ?? 'Unknown Error') } } - async listClients(): Promise> { + async listClients(): Promise<(timer.backup.Client & { current: boolean })[]> { const { auth, ext, type, coordinator, errorMsg } = await this.checkAuth() - if (errorMsg) return error(errorMsg) - const cid = await lazyGetCid() - const context: timer.backup.CoordinatorContext = await new CoordinatorContextWrapper(cid, auth, ext, type).init() + if (errorMsg) throw new Error(errorMsg) + const cid = await getCid() + const context = await new CoordinatorContextWrapper(cid, auth, ext, type).init() const clients = await coordinator.listAllClients(context) - return success(clients) + return clients.map(c => ({ ...c, current: c.id === cid })) } async checkAuth(): Promise { @@ -214,21 +144,19 @@ class Processor { return { option, auth, ext, type, coordinator, errorMsg } } - async query(param: RemoteQueryParam): Promise { + async query(param: timer.backup.RemoteQuery): Promise { const { type, coordinator, auth, ext, errorMsg } = await this.checkAuth() if (errorMsg || !coordinator) { return [] } - const { start = getBirthday(), end, specCid, excludeLocal } = param - let localCid = await lazyGetCid() + const { start, end, specCid, excludeLocal } = param + let localCid = await getCid() // 1. init context const context: timer.backup.CoordinatorContext = await new CoordinatorContextWrapper(localCid, auth, ext, type).init() // 2. query all clients, and filter them - let startStr = start ? formatTimeYMD(start) : undefined - let endStr = end ? formatTimeYMD(end) : undefined const allClients = (await coordinator.listAllClients(context)) - .filter(c => filterClient(c, !!excludeLocal, localCid, startStr, endStr)) + .filter(c => filterClient(c, !!excludeLocal, localCid, start, end)) .filter(c => !specCid || c.id === specCid) // 3. iterate clients const result: timer.backup.Row[] = [] @@ -247,24 +175,20 @@ class Processor { return result } - async clear(cid: string): Promise> { + async clear(cid: string): Promise { const { auth, ext, type, coordinator, errorMsg } = await this.checkAuth() - if (errorMsg) return error(errorMsg) - let localCid = await lazyGetCid() + if (errorMsg) return errorMsg + let localCid = await getCid() const context: timer.backup.CoordinatorContext = await new CoordinatorContextWrapper(localCid, auth, ext, type).init() // 1. Find the client const allClients = await coordinator.listAllClients(context) const client = allClients?.filter(c => c?.id === cid)?.[0] - if (!client) { - return success() - } + if (!client) return // 2. clear await coordinator.clear(context, client) // 3. remove client const newClients = allClients.filter(c => c?.id !== cid) await coordinator.updateClients(context, newClients) - - return success() } } diff --git a/src/service/backup/web-dav/coordinator.ts b/src/background/service/backup/web-dav/coordinator.ts similarity index 95% rename from src/service/backup/web-dav/coordinator.ts rename to src/background/service/backup/web-dav/coordinator.ts index 4979b4dc7..d766240c1 100644 --- a/src/service/backup/web-dav/coordinator.ts +++ b/src/background/service/backup/web-dav/coordinator.ts @@ -3,6 +3,7 @@ import { type WebDAVAuth, type WebDAVContext, } from "@api/web-dav" import DateIterator from "@util/date-iterator" +import { getBirthday, parseTime } from '@util/time' import { processDir } from "../common" import { CLIENT_FILE_NAME, convertClients2Markdown, divideByDate, parseData } from "../markdown" @@ -50,11 +51,13 @@ export default class WebDAVCoordinator implements timer.backup.Coordinator, dateStart: Date, dateEnd: Date, targetCid?: string): Promise { + async download(context: timer.backup.CoordinatorContext, start: string, end: string, targetCid?: string): Promise { const dirPath = processDir(context?.ext?.dirPath) const davContext = prepareContext(context) targetCid = targetCid || context?.cid + const dateStart = parseTime(start) ?? getBirthday() + const dateEnd = parseTime(end) ?? new Date() const dateIterator = new DateIterator(dateStart, dateEnd) const result: timer.core.Row[] = [] await Promise.all(dateIterator.toArray().map(async date => { diff --git a/src/service/components/host-merge-ruler.ts b/src/background/service/components/host-merge-ruler.ts similarity index 95% rename from src/service/components/host-merge-ruler.ts rename to src/background/service/components/host-merge-ruler.ts index c62c2f142..ab8528c45 100644 --- a/src/service/components/host-merge-ruler.ts +++ b/src/background/service/components/host-merge-ruler.ts @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ +import { getPsl } from '@/background/psl' import FIFOCache from '@util/fifo-cache' import { isIpAndPort, judgeVirtualFast } from "@util/pattern" -import { get } from "@util/psl" /** * @param origin origin host @@ -96,9 +96,7 @@ export default class CustomizedHostMergeRuler { matchResult && (merged = matchResult.result) if (merged === undefined) { // No rule matched - return isIpAndPort(host) - ? host - : get(host) || this.merge0(2, host) + return isIpAndPort(host) ? host : (getPsl(host) ?? this.merge0(2, host)) } else { return this.merge0(merged, host) } diff --git a/src/background/service/components/immigration.ts b/src/background/service/components/immigration.ts new file mode 100644 index 000000000..d24c40171 --- /dev/null +++ b/src/background/service/components/immigration.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import packageInfo from "@/package" +import limitDatabase from "@db/limit-database" +import mergeRuleDatabase from "@db/merge-rule-database" +import statDatabase from "@db/stat-database" +import type { BrowserMigratable, StorageMigratable } from '@db/types' +import whitelistDatabase from "@db/whitelist-database" + +const BROWSER_MIGRATABLES: BrowserMigratable[] = [ + statDatabase, + limitDatabase, + mergeRuleDatabase, + whitelistDatabase, +] + +const STORAGE_MIGRATABLES: StorageMigratable[] = [ + statDatabase, +] + +export async function exportData(): Promise { + const data: timer.backup.ExportData = { + __meta__: { version: packageInfo.version, ts: Date.now() }, + } + for (const migratable of BROWSER_MIGRATABLES) { + const namespace = migratable.namespace + const dataAny = data as any + dataAny[namespace] = await migratable.exportData() + } + return data +} + +export async function importData(data: unknown): Promise { + for (const db of BROWSER_MIGRATABLES) await db.importData(data) +} + +export async function migrateStorage(type: timer.option.StorageType): Promise { + const dataList: unknown[] = [] + // 1. migrate all the databases firstly + for (const migratable of STORAGE_MIGRATABLES) { + const data = await migratable.migrateStorage(type) + dataList.push(data) + } + // 2. after migration + for (const migratable of STORAGE_MIGRATABLES) { + const [data] = dataList.splice(0, 1) + await migratable.afterStorageMigrated(data) + } +} diff --git a/src/background/service/components/import-processor.ts b/src/background/service/components/import-processor.ts new file mode 100644 index 000000000..d414fd4ad --- /dev/null +++ b/src/background/service/components/import-processor.ts @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import statDatabase from "@db/stat-database" +import { mergeWith } from '@util/stat' +import backupProcessor from "../backup/processor" + +export async function importOther(query: timer.imported.ProcessQuery): Promise { + const { data, resolution } = query + if (resolution === 'overwrite') { + return processOverwrite(data) + } + return processAcc(data) +} + +async function processOverwrite(data: timer.imported.Data): Promise { + const { rows, focus, time } = data + const exist = await statDatabase.batchSelect(rows) + await mergeWith(rows, exist, async (row, exist) => { + focus && exist?.focus && (row.focus = exist.focus) + time && exist?.time && (row.time = exist.time) + await statDatabase.forceUpdate(row) + }) +} + +async function processAcc(data: timer.imported.Data): Promise { + const { rows } = data + await Promise.all(rows.map(async row => { + const { host, date, focus = 0, time = 0 } = row + await statDatabase.accumulate(host, date, { focus, time }) + })) +} + + +export async function previewBackup(param: timer.backup.RemoteQuery): Promise { + const remoteRows = await backupProcessor.query(param) + const rows: timer.imported.Row[] = remoteRows.map(rr => ({ + date: rr.date, + host: rr.host, + focus: rr.focus, + time: rr.time, + })) + const exists = await statDatabase.batchSelect(rows) + await mergeWith(rows, exists, (r, exist) => { r.exist = exist }) + return rows +} \ No newline at end of file diff --git a/src/background/service/components/option-holder.ts b/src/background/service/components/option-holder.ts new file mode 100644 index 000000000..b9119ddce --- /dev/null +++ b/src/background/service/components/option-holder.ts @@ -0,0 +1,40 @@ +import { onPermRemoved } from "@api/chrome/permission" +import db from "@db/option-database" +import { defaultOption } from '@util/constant/option' + +type ChangeListener = (newVal: timer.option.DefaultOption, oldVal: timer.option.DefaultOption) => void + +class OptionHolder { + private value: timer.option.DefaultOption | undefined + private listeners: ChangeListener[] = [] + + constructor() { + onPermRemoved(perm => { + perm.permissions?.includes('tabGroups') && this.set({ countTabGroup: false }) + }) + } + + private async reset(): Promise { + const latest = Object.assign(defaultOption(), await db.getOption()) + this.value = latest + return latest + } + + async get(): Promise { + return this.value ?? await this.reset() + } + + addChangeListener(listener: ChangeListener) { + listener && this.listeners.push(listener) + } + + async set(option: Partial): Promise { + const exist = await this.get() + const toSet = Object.assign(defaultOption(), exist, option) + await db.setOption(toSet) + this.value = toSet + this.listeners.forEach(listener => listener(toSet, exist)) + } +} + +export default new OptionHolder() \ No newline at end of file diff --git a/src/service/components/page-info.ts b/src/background/service/components/page-info.ts similarity index 100% rename from src/service/components/page-info.ts rename to src/background/service/components/page-info.ts diff --git a/src/service/components/period-calculator.ts b/src/background/service/components/period-calculator.ts similarity index 56% rename from src/service/components/period-calculator.ts rename to src/background/service/components/period-calculator.ts index 29883c414..68467a174 100644 --- a/src/service/components/period-calculator.ts +++ b/src/background/service/components/period-calculator.ts @@ -6,7 +6,7 @@ */ import { sum } from "@util/array" -import { after, compare, indexOf, keyOf, lastKeyOfLastDate, rowOf, startOfKey } from "@util/period" +import { after, compare, indexOf, keyOf, rowOf, startOfKey } from "@util/period" /** * @param timestamp current ts @@ -37,49 +37,26 @@ export function calculate(timestamp: number, milliseconds: number): timer.period return result } -/** - * Found the max divisible period - * - * @param period key - * @param periodWindowSize divisor - */ -export function getMaxDivisiblePeriod(period: timer.period.Key, periodWindowSize: number): timer.period.Key { - const maxOrder = period.order - let order = -1 - while (order <= maxOrder) order += periodWindowSize - order -= periodWindowSize - if (order === -1) return lastKeyOfLastDate(period) - period.order = order - return period -} +export function merge(periods: timer.period.Result[], size: number): timer.period.Row[] { + if (!periods?.length) return [] -export type MergeConfig = { - periodSize: number - /** - * Inclusive - */ - start: timer.period.Key - /** - * Inclusive - */ - end: timer.period.Key -} + const rows: timer.period.Row[] = [] + + periods = periods.sort(compare) + let start: timer.period.Key = periods[0] + const end: timer.period.Key = periods[periods.length - 1] -export function merge(periods: timer.period.Result[], config: MergeConfig): timer.period.Row[] { - if (!periods?.length) return [] - const result: timer.period.Row[] = [] - let { start, end, periodSize } = config const map: Map = new Map() periods.forEach(p => map.set(indexOf(p), p.milliseconds)) let mills: number[] = [] for (; compare(start, end) <= 0; start = after(start, 1)) { mills.push(map.get(indexOf(start)) ?? 0) - const isEndOfWindow = (start.order % periodSize) === periodSize - 1 + const isEndOfWindow = (start.order % size) === size - 1 if (isEndOfWindow) { - const isFullWindow = mills.length === periodSize - isFullWindow && result.push(rowOf(start, periodSize, sum(mills))) + const isFullWindow = mills.length === size + isFullWindow && rows.push(rowOf(start, size, sum(mills))) mills = [] } } - return result + return rows } \ No newline at end of file diff --git a/src/background/service/components/virtual-site-holder.ts b/src/background/service/components/virtual-site-holder.ts new file mode 100644 index 000000000..920d25b76 --- /dev/null +++ b/src/background/service/components/virtual-site-holder.ts @@ -0,0 +1,39 @@ +import db from "@db/site-database" +import { compileAntPattern } from '@util/pattern' + +/** + * The singleton implementation of virtual sites holder + * + * @since 1.6.0 + */ +class VirtualSiteHolder { + hostRegMap: Record = {} + + constructor() { + db.select().then(keys => keys.forEach(key => this.buildWith(key))) + } + + buildWith({ host, type }: timer.site.SiteKey) { + if (type !== 'virtual') return + this.hostRegMap[host] = compileAntPattern(host) + } + + onDeleted({ host, type }: timer.site.SiteKey) { + if (type !== 'virtual') return + delete this.hostRegMap[host] + } + + /** + * Find the virtual sites which matches the target url + * + * @param url + * @returns virtual sites + */ + findMatched(url: string): string[] { + return Object.entries(this.hostRegMap) + .filter(([_, reg]) => reg.test(url)) + .map(([k]) => k) + } +} + +export default new VirtualSiteHolder() \ No newline at end of file diff --git a/src/service/components/week-helper.ts b/src/background/service/components/week-helper.ts similarity index 53% rename from src/service/components/week-helper.ts rename to src/background/service/components/week-helper.ts index c4ec15894..452ab93f8 100644 --- a/src/service/components/week-helper.ts +++ b/src/background/service/components/week-helper.ts @@ -1,6 +1,6 @@ -import optionDatabase from "@db/option-database" import { locale } from "@i18n" -import { formatTimeYMD, getWeekDay, MILL_PER_DAY } from "@util/time" +import { getStartOfDay, getWeekDay, MILL_PER_DAY } from "@util/time" +import optionHolder from './option-holder' function getDefaultWeekStart(localeOpt: timer.option.LocaleOption): number { const parts = navigator.language.split(/[-_]/) @@ -41,70 +41,36 @@ function getDefaultWeekStart(localeOpt: timer.option.LocaleOption): number { } /** - * Get the real week start according to the option - * - * @param weekStart option value + * Week start + * * @returns 0-6 */ -function getRealWeekStart(option: timer.option.AllOption): number { - const { weekStart = 'default', locale: localeOpt } = option +export async function getWeekStartDay(): Promise { + const { weekStart, locale: localeOpt } = await optionHolder.get() return weekStart === 'default' ? getDefaultWeekStart(localeOpt) : weekStart - 1 } /** * Get the start time and end time of this week - * @param now the specific time - * @param weekStart 0-6 - * @returns [startTime, endTime] + * + * @param now the specific time to calculate + * @returns start time with milliseconds * * @since 0.6.0 */ -function getWeekTime(now: Date, weekStart: number): [Date, Date] { +export async function getWeekStartTime(now: number): Promise { + const weekStart = await getWeekStartDay() // Returns 0 - 6 means Monday to Sunday - const weekDayNow = getWeekDay(now) - let start: Date | undefined = undefined + const weekDayNow = getWeekDay(new Date(now)) + let startDay: number if (weekDayNow === weekStart) { - start = now + startDay = now } else if (weekDayNow < weekStart) { const millDelta = (weekDayNow + 7 - weekStart) * MILL_PER_DAY - start = new Date(now.getTime() - millDelta) + startDay = now - millDelta } else { const millDelta = (weekDayNow - weekStart) * MILL_PER_DAY - start = new Date(now.getTime() - millDelta) - } - return [start, now] -} - -class WeekHelper { - private option: timer.option.AllOption | undefined - - private async checkInit(): Promise { - if (this.option) return this.option - const option = await optionDatabase.getOption() - optionDatabase.addOptionChangeListener(opt => this.option = opt) - this.option = option - return option + startDay = now - millDelta } - - async getWeekDateRange(now: Date): Promise<[startDate: string, endDateOrToday: string]> { - const [start, end] = await this.getWeekDate(now) - return [formatTimeYMD(start), formatTimeYMD(end)] - } - - async getWeekDate(now: Date | number): Promise<[start: Date, end: Date]> { - const weekStart = await this.getRealWeekStart() - return getWeekTime(typeof now === 'number' ? new Date(now) : now, weekStart) - } - - /** - * Week start - * - * @returns 0-6 - */ - async getRealWeekStart(): Promise { - const option = await this.checkInit() - return getRealWeekStart(option) - } -} - -export default new WeekHelper() \ No newline at end of file + return getStartOfDay(startDay) +} \ No newline at end of file diff --git a/src/service/item-service.ts b/src/background/service/item-service.ts similarity index 68% rename from src/service/item-service.ts rename to src/background/service/item-service.ts index cd4da98f0..df8853527 100644 --- a/src/service/item-service.ts +++ b/src/background/service/item-service.ts @@ -1,5 +1,5 @@ import { isValidGroup } from "@api/chrome/tabGroups" -import db, { type StatCondition } from "@db/stat-database" +import db from "@db/stat-database" import { resultOf } from "@util/stat" import optionHolder from "./components/option-holder" import virtualSiteHolder from "./components/virtual-site-holder" @@ -10,7 +10,7 @@ export type ItemIncContext = { groupId?: number } -async function addFocusTime(context: ItemIncContext, focusTime: number): Promise { +export async function addFocusTime(context: ItemIncContext, focusTime: number): Promise { const { host, url, groupId } = context const resultSet: Record = { [host]: resultOf(focusTime, 0) } @@ -25,13 +25,13 @@ async function addFocusTime(context: ItemIncContext, focusTime: number): Promise countTabGroup && isValidGroup(groupId) && db.accumulateGroup(groupId, now, resultOf(focusTime, 0)) } -async function addRunTime(host: string, dateTime: Record) { +export async function addRunTime(host: string, dateTime: Record) { for (const [date, run] of Object.entries(dateTime)) { await db.accumulate(host, date, { focus: 0, time: 0, run }) } } -async function increaseVisit(context: ItemIncContext) { +export async function increaseVisit(context: ItemIncContext) { const { host, url, groupId } = context const resultSet = { [host]: resultOf(0, 1) } virtualSiteHolder.findMatched(url).forEach(virtualHost => resultSet[virtualHost] = resultOf(0, 1)) @@ -44,19 +44,8 @@ async function increaseVisit(context: ItemIncContext) { countTabGroup && isValidGroup(groupId) && await db.accumulateGroup(groupId, now, resultOf(0, 1)) } -const getResult = (host: string, date: Date | string) => db.get(host, date) - -const selectItems = (cond: StatCondition) => db.select(cond) - -async function deleteByGroup(groupId: number): Promise { - await db.deleteByGroup(groupId) -} - -export default { - addFocusTime, - addRunTime, - increaseVisit, - getResult, - selectItems, - deleteByGroup, +export async function getTodayResult(host: string) { + const option = await optionHolder.get() + if (!option.printInConsole) return undefined + return await db.get(host, new Date()) } \ No newline at end of file diff --git a/src/background/service/limit-service.ts b/src/background/service/limit-service.ts new file mode 100644 index 000000000..86d0f1bd0 --- /dev/null +++ b/src/background/service/limit-service.ts @@ -0,0 +1,204 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { listTabs, sendMsg2Tab } from "@api/chrome/tab" +import db, { type LimitRecord } from "@db/limit-database" +import { sum } from "@util/array" +import { hasLimited, isEffective, matches, meetTimeLimit } from "@util/limit" +import { formatTimeYMD, getWeekDay, MILL_PER_MINUTE, MILL_PER_SECOND } from "@util/time" +import optionHolder from "./components/option-holder" +import { getWeekStartTime } from './components/week-helper' +import whitelistHolder from "./whitelist/holder" + +export async function selectLimit(param?: timer.limit.Query): Promise { + const { enabled, url, id, limited, effective } = param ?? {} + const now = new Date() + const today = formatTimeYMD(now) + const startTime = await getWeekStartTime(now.getTime()) + const startDate = formatTimeYMD(startTime) + const weekday = getWeekDay(now) + + let list = await db.all() + + if (enabled) list = list.filter(item => item.enabled) + if (id) list = list.filter(item => item.id === id) + if (url) list = list.filter(item => matches(item.cond, url)) + + let items = list.map(rec => cvtRecord2Item(rec, today, startDate)) + + if (limited) { + const { limitDelayDuration } = await optionHolder.get() + items = items.filter(item => hasLimited(item, limitDelayDuration)) + } + if (effective || enabled) items = items.filter(item => item.enabled) + if (effective) items = items.filter(item => isEffective(item.weekdays, weekday)) + + return items +} + +function cvtRecord2Item({ records, ...others }: LimitRecord, today: string, weekStartDate: string) { + const todayRec = records[today] + const thisWeekRec = Object.entries(records) + .filter(([k]) => k >= weekStartDate && k <= today) + .map(([, v]) => v) + const weeklyWaste = sum(thisWeekRec.map(r => r.mill ?? 0)) + const weeklyDelayCount = sum(thisWeekRec.map(r => r.delay ?? 0)) + const weeklyVisit = sum(thisWeekRec.map(r => r.visit ?? 0)) + return { + ...others, + waste: todayRec?.mill ?? 0, + visit: todayRec?.visit ?? 0, + delayCount: todayRec?.delay ?? 0, + weeklyWaste, + weeklyDelayCount, + weeklyVisit, + } +} + +/** + * Fired if the item is removed or disabled + * + * @param item + */ +export async function noticeLimitChanged(): Promise { + const effectiveItems = await selectLimit({ effective: true }) + const tabs = await listTabs() + tabs.forEach(({ id, url }) => { + if (!id || !url) return + const matched = effectiveItems.filter(({ cond }) => matches(cond, url)) + if (!matched.length) return + sendMsg2Tab(id, 'limitChanged', matched).catch(err => console.warn(err.message)) + }) +} + +export async function removeLimitRules(ids: number[]): Promise { + if (!ids.length) return + await db.batchRemove(ids) + await noticeLimitChanged() +} + +type IncreaseResult = { + limited?: timer.limit.Item[] + reminder?: timer.limit.ReminderInfo +} + +/** + * Add time + * + * @param url url + * @param focusTime time, milliseconds + * @returns the rules is limit cause of this operation + */ +export async function addLimitFocusTime(host: string, url: string, focusTime: number): Promise { + if (whitelistHolder.contains(host, url)) return {} + + const allEffective = await selectLimit({ url, effective: true }) + + const toUpdate: { [cond: string]: number } = {} + const limited: timer.limit.Item[] = [] + const needReminder: timer.limit.Item[] = [] + + const { limitReminder, limitReminderDuration = 0, limitDelayDuration } = await optionHolder.get() + const durationMill = limitReminder ? limitReminderDuration * MILL_PER_MINUTE : 0 + allEffective.forEach(item => { + const [met, reminder] = addFocusForEach(item, focusTime, durationMill, limitDelayDuration) + met && limited.push(item) + reminder && needReminder.push(item) + toUpdate[item.id] = item.waste + }) + const result: IncreaseResult = { limited } + if (needReminder?.length) { + result.reminder = { + items: needReminder, + duration: limitReminderDuration, + } + } + await db.updateWaste(formatTimeYMD(new Date()), toUpdate) + return result +} + +type TimeLimitState = 'NORMAL' | 'REMINDER' | 'LIMITED' + +type LimitTimeStateResult = { + daily: TimeLimitState + weekly: TimeLimitState +} + +export function calcTimeState(item: timer.limit.Item, reminderMills: number, delayDuration: number): LimitTimeStateResult { + const res: LimitTimeStateResult = { daily: 'NORMAL', weekly: 'NORMAL' } + const { + time, waste, delayCount, + weekly, weeklyWaste, weeklyDelayCount, + allowDelay, + } = item || {} + const dailyMs = (time ?? 0) * MILL_PER_SECOND + const weeklyMs = (weekly ?? 0) * MILL_PER_SECOND + const delayDaily = { count: delayCount ?? 0, duration: delayDuration, allow: !!allowDelay } + const delayWeekly = { count: weeklyDelayCount ?? 0, duration: delayDuration, allow: !!allowDelay } + if (meetTimeLimit({ wasted: waste, maxLimit: dailyMs }, delayDaily)) res.daily = 'LIMITED' + else if (reminderMills && meetTimeLimit({ wasted: waste + reminderMills, maxLimit: dailyMs }, delayDaily)) res.daily = 'REMINDER' + if (meetTimeLimit({ wasted: weeklyWaste, maxLimit: weeklyMs }, delayWeekly)) res.weekly = 'LIMITED' + else if (reminderMills && meetTimeLimit({ wasted: weeklyWaste + reminderMills, maxLimit: weeklyMs }, delayWeekly)) res.weekly = 'REMINDER' + return res +} + +function addFocusForEach(item: timer.limit.Item, focusTime: number, durationMill: number, delayDuration: number): [met: boolean, reminder: boolean] { + const before = calcTimeState(item, durationMill, delayDuration) + item.waste += focusTime + // Fast increase + item.weeklyWaste += focusTime + const after = calcTimeState(item, durationMill, delayDuration) + const met = (before.daily !== 'LIMITED' && after.daily === 'LIMITED') || (before.weekly !== 'LIMITED' && after.weekly === 'LIMITED') + const reminder = (before.daily === 'NORMAL' && after.daily === 'REMINDER') || (before.weekly === 'NORMAL' && after.weekly === 'REMINDER') + return [met, reminder] +} + +/** + * Increase visit count + * @returns the rules is limited + */ +export async function incLimitVisit(host: string, url: string): Promise { + if (whitelistHolder.contains(host, url)) return [] + + const allEnabled = await selectLimit({ enabled: true, url }) + const { limitDelayDuration: delayDuration } = await optionHolder.get() + const result: timer.limit.Item[] = [] + await db.increaseVisit(formatTimeYMD(new Date()), allEnabled.map(item => item.id)) + allEnabled.forEach(item => { + // Fast increase + item.visit++ + item.weeklyVisit++ + + hasLimited(item, delayDuration) && result.push(item) + }) + return result +} + +export async function delayLimit(url: string): Promise { + const limitedItems = await selectLimit({ url, enabled: true, limited: true }) + limitedItems + .filter(item => item.allowDelay) + .forEach(rule => { + rule.delayCount++ + rule.weeklyDelayCount++ + }) + + const date = formatTimeYMD(new Date()) + await db.updateDelayCount(date, limitedItems) + await noticeLimitChanged() +} + +export async function updateLimitRules(rules: timer.limit.Rule[]): Promise { + await db.batchUpdate(rules) + await noticeLimitChanged() +} + +export async function createLimitRule(rule: Omit): Promise { + const id = await db.add(rule) + await noticeLimitChanged() + return id +} diff --git a/src/background/service/meta-service.ts b/src/background/service/meta-service.ts new file mode 100644 index 000000000..4ceab8350 --- /dev/null +++ b/src/background/service/meta-service.ts @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import db from "@db/meta-database" +import { IS_ANDROID, IS_FIREFOX } from '@util/constant/environment' +import { createArrayGuard, createObjectGuard, isString } from 'typescript-guard' + +export async function getInstallTime(): Promise { + const meta = await db.getMeta() + return meta.installTime ?? Date.now() +} + +export async function updateInstallTime(ts: number) { + const meta = await db.getMeta() + if (meta.installTime) { + // Must not rewrite + return + } + meta.installTime = ts + await db.update(meta) +} + +/** + * @since 1.2.0 + */ +export async function getCid(): Promise { + const meta = await db.getMeta() + const exist = meta.cid + if (exist) return exist + const initial = `${getBrand()}-${Date.now()}` + meta.cid = initial + await db.update(meta) + return initial +} + +// Only exists in chromium browsers +type NavigatorUAData = { + brands: { brand: string }[] + platform: string +} + +const hasUaData = createObjectGuard<{ userAgentData: NavigatorUAData }>({ + userAgentData: createObjectGuard({ + brands: createArrayGuard(createObjectGuard({ brand: isString })), + platform: isString, + }), +}) + +function getBrand() { + if (hasUaData(navigator)) { + const { userAgentData: { brands }, platform } = navigator + const brand = brands.map(e => e.brand) + .filter(brand => brand && brand !== "Chromium" && !brand.includes("Not"))[0]?.replace(' ', '-') + if (brand) return `${platform.toLowerCase()}-${brand.toLowerCase()}` + } + if (IS_FIREFOX) return IS_ANDROID ? 'firefox-android' : 'firefox' + return 'unknown' +} + +/** + * @since 1.4.7 + */ +export async function updateBackUpTime(type: timer.backup.Type, time: number) { + const meta = await db.getMeta() + if (!meta.backup) { + meta.backup = {} + } + meta.backup[type] = { ts: time } + await db.update(meta) +} + +/** + * @since 1.4.7 + */ +export async function getLastBackUp(type: timer.backup.Type): Promise { + const meta = await db.getMeta() + return meta.backup?.[type]?.ts +} \ No newline at end of file diff --git a/src/service/notification/browser/notifier.ts b/src/background/service/notification/browser/notifier.ts similarity index 100% rename from src/service/notification/browser/notifier.ts rename to src/background/service/notification/browser/notifier.ts diff --git a/src/service/notification/callback/notifier.ts b/src/background/service/notification/callback/notifier.ts similarity index 100% rename from src/service/notification/callback/notifier.ts rename to src/background/service/notification/callback/notifier.ts diff --git a/src/service/notification/processor.ts b/src/background/service/notification/processor.ts similarity index 74% rename from src/service/notification/processor.ts rename to src/background/service/notification/processor.ts index 75bb7c594..5117ab535 100644 --- a/src/service/notification/processor.ts +++ b/src/background/service/notification/processor.ts @@ -1,28 +1,12 @@ import { getVersion } from "@api/chrome/runtime" +import db from "@db/stat-database" import { cvtOption2Locale } from "@i18n" -import optionHolder from "@service/components/option-holder" -import itemService from "@service/item-service" -import { formatTimeYMD, MILL_PER_DAY, MILL_PER_WEEK } from "@util/time" +import { cvtDateRange2Str, formatTimeYMD, MILL_PER_DAY, MILL_PER_WEEK } from "@util/time" +import optionHolder from "../components/option-holder" import BrowserNotifier from "./browser/notifier" import CallbackNotifier from "./callback/notifier" import type { NotificationData, NotificationRequest, Notifier } from "./types" -type Result = { - success: true - data: T -} | { - success: false - errorMsg: string -} - -function error(msg: string): Result { - return { success: false, errorMsg: msg } -} - -function success(data: T): Result { - return { success: true, data } -} - const DATE_RANGE_CALCULATORS: Record Date | [Date, Date]> = { daily: now => new Date(now - MILL_PER_DAY), weekly: now => [new Date(now - MILL_PER_WEEK), new Date(now - MILL_PER_DAY)], @@ -40,25 +24,23 @@ class Processor { } } - async doSend(): Promise> { + async doSend(): Promise { const option = await optionHolder.get() const { notificationCycle: cycle, notificationMethod: method, notificationEndpoint: endpoint, notificationAuthToken: authToken, } = option - if (cycle === 'none') return success(undefined) + if (cycle === 'none') return undefined const notifier = this.notifiers[method] const req: NotificationRequest = { cycle, method, endpoint, authToken } const data = await this.buildData(req) try { - const errMsg = await notifier.send(req, data) - return errMsg ? error(errMsg) : success(undefined) + return await notifier.send(req, data) } catch (e) { console.error("Error to send notification", e) - const msg = e instanceof Error ? e.message : String(e) - return error(msg) + return e instanceof Error ? e.message : String(e) } } @@ -67,7 +49,7 @@ class Processor { const date = DATE_RANGE_CALCULATORS[req.cycle](now) // Query rows - const rows = await itemService.selectItems({ date }) + const rows = await db.select({ date: cvtDateRange2Str(date) }) // Calculate summary let totalFocus = 0 diff --git a/src/service/notification/types.ts b/src/background/service/notification/types.ts similarity index 91% rename from src/service/notification/types.ts rename to src/background/service/notification/types.ts index d621defb3..87b8f1c6b 100644 --- a/src/service/notification/types.ts +++ b/src/background/service/notification/types.ts @@ -1,9 +1,4 @@ -export type Count = { - total: number - average: number -} - -export type Summary = { +type Summary = { focus: number visit: number siteCount: number diff --git a/src/background/service/period-service.ts b/src/background/service/period-service.ts new file mode 100644 index 000000000..a5273f456 --- /dev/null +++ b/src/background/service/period-service.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import db from "@db/period-database" +import { after, compare, getDateString } from "@util/period" +import { merge } from "./components/period-calculator" + +function dateStrBetween(startDate: timer.period.Key, endDate: timer.period.Key): string[] { + const result: string[] = [] + while (compare(startDate, endDate) <= 0) { + result.push(getDateString(startDate)) + startDate = after(startDate, 1) + } + return result +} + +export async function selectPeriods(param: timer.period.Query): Promise { + let { range, size = 1 } = param + if (!Number.isInteger(size) || size <= 1) size = 1 + + if (range === undefined) { + const results = await db.getAll() + return merge(results, size) + } + const [start, end] = range + const allDates = dateStrBetween(start, end) + const results = await db.getBatch(allDates) + return merge(results, size) +} + +export async function batchDeletePeriods(start: timer.period.Key, end: timer.period.Key): Promise { + const allDates = dateStrBetween(start, end) + await db.batchDelete(allDates) +} diff --git a/src/background/service/site-service.ts b/src/background/service/site-service.ts new file mode 100644 index 000000000..ef8b08c5e --- /dev/null +++ b/src/background/service/site-service.ts @@ -0,0 +1,204 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { listTabs, sendMsg2Tab } from "@api/chrome/tab" +import siteDatabase from "@db/site-database" +import { ALL_HOSTS as ALL_FILE_HOSTS, MERGED_HOST as MERGED_FILE_HOST } from '@util/constant/remain-host' +import { extractHostname, isValidVirtualHost, judgeVirtualFast } from "@util/pattern" +import { SiteMap, supportCategory } from "@util/site" +import { toUnicode as punyCode2Unicode } from "punycode" +import mergeRuleDatabase from '../database/merge-rule-database' +import statDatabase from '../database/stat-database' +import { getPslSuffix } from '../psl' +import CustomizedHostMergeRuler from './components/host-merge-ruler' +import { slicePageResult } from "./components/page-info" +import virtualSiteHolder from './components/virtual-site-holder' + +export async function saveAlias(key: timer.site.SiteKey, alias: string | undefined, noRewrite?: boolean) { + const exist = await siteDatabase.get(key) + if (exist && noRewrite) return + await siteDatabase.save({ ...exist, ...key, alias }) +} + +export async function removeIconUrl(key: timer.site.SiteKey) { + const exist = await siteDatabase.get(key) + if (!exist) return + delete exist.iconUrl + await siteDatabase.save(exist) +} + +export async function saveIconUrl(key: timer.site.SiteKey, iconUrl: string) { + const exist = await siteDatabase.get(key) + await siteDatabase.save({ ...exist, ...key, iconUrl }) +} + +export async function saveSiteRunState(key: timer.site.SiteKey, enabled: boolean) { + const exist = await siteDatabase.get(key) + if (!exist) return + exist.run = enabled + await siteDatabase.save(exist) + // send msg to tabs + const tabs = await listTabs() + for (const { id } of tabs) { + try { + id && await sendMsg2Tab(id, 'siteRunChange') + } catch { } + } +} + +export async function addSite(siteInfo: timer.site.SiteInfo): Promise { + if (await siteDatabase.exist(siteInfo)) { + return 'Site already exists' + } + if (!supportCategory(siteInfo)) siteInfo.cate = undefined + await siteDatabase.save(siteInfo) + virtualSiteHolder.buildWith(siteInfo) +} + +export async function removeSites(keys: timer.site.SiteKey[]): Promise { + await siteDatabase.remove(keys) + keys.forEach(key => virtualSiteHolder.onDeleted(key)) +} + +export async function selectSitePage(param?: timer.site.PageQuery): Promise> { + const origin = await siteDatabase.select(param) + return slicePageResult(origin, param) +} + +export async function batchChangeCate(cateId: number | undefined, keys: timer.site.SiteKey[]): Promise { + keys = keys?.filter(supportCategory) + if (!keys?.length) return + + const sites = await siteDatabase.getBatch(keys) + const siteMap = SiteMap.identify(sites) + const toSave = keys.map(k => ({ ...siteMap.get(k), ...k, cate: cateId })) + await siteDatabase.save(...toSave) +} + +/** + * @since 0.9.0 + */ +export async function getSite(siteKey: timer.site.SiteKey): Promise { + const info = await siteDatabase.get(siteKey) + return info ?? siteKey +} + +export async function searchSites(query: string | undefined): Promise { + query = cleanSearchQuery(query) + const filter = query ? (host: string) => host.includes(query) : () => true + const [normal, merged] = await listHosts(filter) + + const keys: timer.site.SiteKey[] = [] + normal.forEach(host => keys.push({ host, type: 'normal' })) + merged.forEach(host => keys.push({ host, type: 'merged' })) + + ALL_FILE_HOSTS.forEach(fileHost => filter(fileHost) && keys.push({ host: fileHost, type: 'normal' })) + filter(MERGED_FILE_HOST) && keys.push({ host: MERGED_FILE_HOST, type: 'merged' }) + + const fromDb = await siteDatabase.getBatch(keys) + const siteMap = SiteMap.identify(fromDb) + const rows = keys.map(k => ({ ...siteMap.get(k), ...k })) + const ranked = [...rows.filter(r => !r.alias), ...rows.filter(r => r.alias)] + + const hitIdx = ranked.findIndex(r => r.host === query) + if (hitIdx >= 0) { + const [same] = ranked.splice(hitIdx, 1) + return [same, ...ranked] + } else if (!query) { + return ranked + } else if (judgeVirtualFast(query) && isValidVirtualHost(query)) { + return [{ host: query, type: 'virtual' }, ...ranked] + } else { + const { host } = extractHostname(query) + const hostIdx = ranked.findIndex(r => r.host === host) + if (hostIdx >= 0) { + const [same] = ranked.splice(hostIdx, 1) + return [same, ...ranked] + } + return [{ host, type: 'normal' }, ...ranked] + } +} + +function cleanSearchQuery(query: string | undefined): string | undefined { + query = query?.trim?.() + if (!query) return undefined + try { + // Remove protocol and search params, only keep host and path for search + const u = new URL(query) + query = u.host + u.pathname + } catch { } + if (query.endsWith('/')) query += '**' + return query +} + +/** + * Query hosts from stat databases + * + * @param query the part of host + * @since 0.0.8 + */ +async function listHosts(filter: (host: string) => boolean): Promise<[normal: string[], merged: string[]]> { + const rows = await statDatabase.select({ virtual: false }) + const hosts = new Set(rows.map(row => row.host)) + + const mergeRuleItems = await mergeRuleDatabase.selectAll() + const mergeRuler = new CustomizedHostMergeRuler(mergeRuleItems) + + const normal = new Set() + const merged = new Set() + + hosts.forEach(host => { + filter(host) && normal.add(host) + const mergedHost = mergeRuler.merge(host) + filter(mergedHost) && merged.add(mergedHost) + }) + + return [Array.from(normal), Array.from(merged)] +} + +export async function fillInitialAlias(keys: timer.site.SiteKey[]) { + const sites = await siteDatabase.getBatch(keys) + const toSave = new SiteMap() + sites.forEach(site => { + if (site.alias) return + const alias = getInitialAlias(site.host) + alias && toSave.put(site, alias) + }) + await batchSaveAlias(toSave) +} + +export function getInitialAlias(host: string): string | undefined { + let parts = host.split('.') + if (parts.length < 2) return + + const suffix = getPslSuffix(host) + const prefix = host.replace(`.${suffix}`, '').replace(/^www\./, '') + parts = prefix.split('.') + return parts.reverse().map(cvt2Alias).join(' ') +} + +function cvt2Alias(part: string): string { + try { + part = punyCode2Unicode(part) + } catch { + } + return part.charAt(0).toUpperCase() + part.slice(1) +} + +async function batchSaveAlias(siteMap: SiteMap): Promise { + if (!siteMap.count()) return + const allSites = await siteDatabase.getBatch(siteMap.keys()) + const existMap = SiteMap.identify(allSites) + + const toSave: timer.site.SiteInfo[] = [] + siteMap.forEach((k, alias) => { + const exist = existMap.get(k) + if (exist?.alias) return + toSave.push({ ...exist ?? k, alias }) + }) + await siteDatabase.save(...toSave) +} \ No newline at end of file diff --git a/src/service/stat-service/common.ts b/src/background/service/stat-service/common.ts similarity index 100% rename from src/service/stat-service/common.ts rename to src/background/service/stat-service/common.ts diff --git a/src/service/stat-service/index.ts b/src/background/service/stat-service/index.ts similarity index 50% rename from src/service/stat-service/index.ts rename to src/background/service/stat-service/index.ts index e26971b0b..3044ba802 100644 --- a/src/service/stat-service/index.ts +++ b/src/background/service/stat-service/index.ts @@ -6,16 +6,12 @@ */ import { listAllGroups } from "@api/chrome/tabGroups" -import mergeRuleDatabase from "@db/merge-rule-database" -import cateDatabase from "@db/site-cate-database" +import cateDatabase from "@db/cate-database" import siteDatabase from "@db/site-database" import statDatabase, { type StatCondition } from "@db/stat-database" import { toMap } from "@util/array" -import { judgeVirtualFast } from "@util/pattern" import { CATE_NOT_SET_ID, distinctSites, SiteMap } from "@util/site" -import { isGroup, isNormalSite, isSite } from "@util/stat" -import { log } from "../../common/logger" -import CustomizedHostMergeRuler from "../components/host-merge-ruler" +import { isGroup, isSite } from "@util/stat" import { slicePageResult } from "../components/page-info" import { cvt2SiteRow } from "./common" import { mergeCate } from "./merge/cate" @@ -51,106 +47,17 @@ function compareSortVal(a: string | number, b: string | number, direction?: time return direction === 'DESC' ? -val : val } -export type SiteQuery = Pick - & timer.common.SortBy<'date' | 'host' | timer.core.Dimension> - & { - query?: string - host?: string - mergeDate?: boolean - mergeHost?: boolean - /** - * Inclusive remote data - * - * If true the date range MUST NOT be unlimited - * - * @since 1.2.0 - */ - inclusiveRemote?: boolean - /** - * Categories - * - * @since 3.0.0 - */ - cateIds?: number[] - ignoreSite?: boolean - } - -export type CateQuery = Pick - & timer.common.SortBy<'date' | 'focus' | 'time'> - & { - query?: string - mergeDate?: boolean - inclusiveRemote?: boolean - cateIds?: number[] - } - -export type GroupQuery = Pick - & timer.common.SortBy<'date' | 'title' | 'focus' | 'time'> - & { - query?: string - mergeDate?: boolean - } - -export type CountQuery = Pick & { - keys: timer.stat.TargetKey -} - function filterByCateId(itemCateId: number | undefined, cateIds: number[] | undefined): boolean { if (!cateIds?.length) return true return cateIds.includes(itemCateId ?? CATE_NOT_SET_ID) } -/** - * Query hosts - * - * @param fuzzyQuery the part of host - * @since 0.0.8 - */ -export async function listHosts(fuzzyQuery?: string): Promise> { - const rows = await statDatabase.select() - const allHosts: Set = new Set(rows.map(row => row.host).filter(h => !!h)) - // Generate ruler - const mergeRuleItems: timer.merge.Rule[] = await mergeRuleDatabase.selectAll() - const mergeRuler = new CustomizedHostMergeRuler(mergeRuleItems) - - const normalSet: Set = new Set() - const mergedSet: Set = new Set() - const virtualSet: Set = new Set() - - const allHostArr = Array.from(allHosts) - - allHostArr.forEach(host => { - if (judgeVirtualFast(host)) { - virtualSet.add(host) - return - } - normalSet.add(host) - const mergedHost = mergeRuler.merge(host) - mergedSet.add(mergedHost) - }) - - let normal = Array.from(normalSet) - let merged = Array.from(mergedSet) - let virtual = Array.from(virtualSet) - if (fuzzyQuery) { - normal = normal.filter(host => host?.includes(fuzzyQuery)) - merged = merged.filter(host => host?.includes(fuzzyQuery)) - virtual = virtual.filter(host => host?.includes(fuzzyQuery)) - } - - return { normal, merged, virtual } +export async function countSite(param?: timer.stat.SiteQuery): Promise { + const rows = await statDatabase.select(param) + return rows.length } -export async function countSiteByHosts(hosts: string[], dateRange: StatCondition['date']): Promise { - log("service: countSiteByHosts: {hosts}, {dateRange}", hosts, dateRange) - const rows = await statDatabase.select({ keys: hosts, date: dateRange }) - const result = rows.length - log("service: countSiteByHosts: {result}", result) - return result -} - -export async function selectSite(param?: SiteQuery): Promise { - log("service: select:{param}", param) +export async function selectSite(param?: timer.stat.SiteQuery): Promise { const { mergeHost: needMerge, mergeDate: needMergeDate, date, query, host, cateIds, @@ -164,7 +71,6 @@ export async function selectSite(param?: SiteQuery): Promise> { - log("selectByPage:{param},{page}", param, page) +export async function selectSitePage(param?: timer.stat.SitePageQuery): Promise> { const rows = await selectSite(param) - let result = slicePageResult(rows, page) - log("result of selectByPage:{param}, {page}, {result}", param, page, result) - return result + return slicePageResult(rows, param) } -export async function selectCate(param?: CateQuery): Promise { +export async function selectCate(param?: timer.stat.CateQuery): Promise { const { mergeDate: needMergeDate, date, query, cateIds, @@ -213,14 +113,18 @@ export async function selectCate(param?: CateQuery): Promise !cateIds?.length || cateIds.includes(cateKey)) .filter(({ cateName }) => !query || cateName?.includes(query)) - // Merge by date - needMergeDate && (cateRows = mergeDate(cateRows)) + // Merge cates by date again + if (needMergeDate) cateRows = mergeDate(cateRows) + // Sort if (sortKey) { cateRows.sort((a, b) => compareSortVal(a[sortKey] ?? 0, b[sortKey] ?? 0, sortDirection)) @@ -228,9 +132,9 @@ export async function selectCate(param?: CateQuery): Promise> { +export async function selectCatePage(query?: timer.stat.CatePageQuery): Promise> { const rows = await selectCate(query) - return slicePageResult(rows, page) + return slicePageResult(rows, query) } async function fillSite(rows: timer.stat.SiteRow[]): Promise { @@ -238,20 +142,20 @@ async function fillSite(rows: timer.stat.SiteRow[]): Promise { extractAllSiteKeys(rows, keys) keys = distinctSites(keys) - const siteInfos = await siteDatabase.getBatch(keys) - const siteInfoMap = new SiteMap() - siteInfos.forEach(siteInfo => siteInfoMap.put(siteInfo, siteInfo)) + const sites = await siteDatabase.getBatch(keys) + const siteMap = SiteMap.identify(sites) - rows.forEach(item => fillRowWithSiteInfo(item, siteInfoMap)) + rows.forEach(item => fillRowWithSiteInfo(item, siteMap)) return true } -export async function selectGroup(param?: GroupQuery): Promise { +export async function selectGroup(param?: timer.stat.GroupQuery): Promise { const { date, query, mergeDate: needMergeDate, + focusRange, timeRange, sortKey, sortDirection, } = param ?? {} - const list = await statDatabase.selectGroup({ date }) + const list = await statDatabase.selectGroup({ date, focusRange, timeRange }) const groups = await listAllGroups() const groupMap = toMap(groups, g => g.id) let rows: timer.stat.GroupRow[] = list.map(({ date, time, focus, run, host }) => { @@ -267,38 +171,28 @@ export async function selectGroup(param?: GroupQuery): Promise { - log("service: countGroupByIds: {groupIds}, {dateRange}", groupIds, dateRange) - const keys = groupIds.map(gid => `${gid}`) - const rows = await statDatabase.selectGroup({ keys, date: dateRange }) - const result = rows.length - log("service: countGroupByIds: {result}", result) - return result +export async function countGroup(param?: timer.stat.GroupQuery): Promise { + const { groupIds, date } = param ?? {} + const keys = groupIds?.map(gid => `${gid}`) + const rows = await statDatabase.selectGroup({ keys, date }) + return rows.length } -export async function batchDelete(targets: timer.stat.Row[]) { +export async function batchDelete(targets: timer.stat.StatKey[]) { if (!targets?.length) return const siteKeys: timer.core.RowKey[] = [] const groupKeys: [groupId: number, date: string][] = [] targets.forEach(row => { const { date } = row if (!date) return - isNormalSite(row) && siteKeys.push({ host: row.siteKey.host, date }) + isSite(row) && siteKeys.push({ host: row.siteKey.host, date }) isGroup(row) && groupKeys.push([row.groupKey, date]) }) await statDatabase.delete(...siteKeys) await statDatabase.deleteGroup(...groupKeys) -} - -export async function selectGroupByPage(param?: GroupQuery, page?: timer.common.PageQuery): Promise> { - const rows = await selectGroup(param) - return slicePageResult(rows, page) } \ No newline at end of file diff --git a/src/service/stat-service/merge/cate.ts b/src/background/service/stat-service/merge/cate.ts similarity index 100% rename from src/service/stat-service/merge/cate.ts rename to src/background/service/stat-service/merge/cate.ts diff --git a/src/service/stat-service/merge/common.ts b/src/background/service/stat-service/merge/common.ts similarity index 100% rename from src/service/stat-service/merge/common.ts rename to src/background/service/stat-service/merge/common.ts diff --git a/src/service/stat-service/merge/date.ts b/src/background/service/stat-service/merge/date.ts similarity index 88% rename from src/service/stat-service/merge/date.ts rename to src/background/service/stat-service/merge/date.ts index 9c4e01088..2108f4b47 100644 --- a/src/service/stat-service/merge/date.ts +++ b/src/background/service/stat-service/merge/date.ts @@ -5,7 +5,7 @@ export function mergeDate(origin: T[]): T[] { const map: Record< string, | MakeRequired - | MakeRequired + | MakeRequired > = {} origin.forEach(ele => { const { date } = ele @@ -24,6 +24,7 @@ export function mergeDate(origin: T[]): T[] { mergeResult(exist, ele) isSite(ele) && isSite(exist) && exist.mergedRows.push(...ele.mergedRows ?? []) isCate(ele) && isCate(exist) && exist.mergedRows.push(...ele.mergedRows ?? []) + isGroup(ele) && isGroup(exist) && exist.mergedRows.push(...(ele.mergedRows ?? [])) date && exist.mergedDates.push(date) if (isNormalSite(ele) && !isGroup(exist)) { const { mergedRows, ...toMerge } = ele diff --git a/src/service/stat-service/merge/host.ts b/src/background/service/stat-service/merge/host.ts similarity index 100% rename from src/service/stat-service/merge/host.ts rename to src/background/service/stat-service/merge/host.ts diff --git a/src/service/stat-service/remote.ts b/src/background/service/stat-service/remote.ts similarity index 80% rename from src/service/stat-service/remote.ts rename to src/background/service/stat-service/remote.ts index f140b2a8b..51b871729 100644 --- a/src/service/stat-service/remote.ts +++ b/src/background/service/stat-service/remote.ts @@ -6,15 +6,12 @@ */ import type { StatCondition } from "@db/stat-database" -import processor from "@service/backup/processor" import { identifyStatKey } from "@util/stat" -import { getBirthday } from "@util/time" +import { BIRTHDAY, formatTimeYMD } from "@util/time" +import processor from "../backup/processor" import { cvt2SiteRow } from "./common" export async function processRemote(origin: timer.stat.SiteRow[], param?: StatCondition): Promise { - if (!await canReadRemote()) { - return origin - } // Map to merge const originMap: Record> = {} origin.forEach(row => originMap[identifyStatKey(row)] = { @@ -33,30 +30,19 @@ export async function processRemote(origin: timer.stat.SiteRow[], param?: StatCo : () => true // 1. query remote - let start: Date | undefined = undefined, end: Date | undefined = undefined + let start: string | undefined, end: string | undefined if (Array.isArray(date)) { [start, end] = date } else { start = date } - start = start ?? getBirthday() - end = end ?? new Date() + start = start ?? BIRTHDAY + end = end ?? formatTimeYMD(Date.now()) const remote = await processor.query({ excludeLocal: true, start, end }) remote.filter(predicate).forEach(row => processRemoteRow(originMap, row)) return Object.values(originMap) } -/** - * Enabled to read remote backup data - * - * @since 1.2.0 - * @returns T/F - */ -export async function canReadRemote(): Promise { - const { errorMsg } = await processor.checkAuth() - return !errorMsg -} - function processRemoteRow(rowMap: Record>, remoteBase: timer.core.Row) { const row = cvt2SiteRow(remoteBase) const key = identifyStatKey(row) diff --git a/src/service/throttler/firefox-throttler.ts b/src/background/service/throttler/firefox-throttler.ts similarity index 100% rename from src/service/throttler/firefox-throttler.ts rename to src/background/service/throttler/firefox-throttler.ts diff --git a/src/service/throttler/period-throttler.ts b/src/background/service/throttler/period-throttler.ts similarity index 88% rename from src/service/throttler/period-throttler.ts rename to src/background/service/throttler/period-throttler.ts index 3f43bbccf..9fa74fb21 100644 --- a/src/service/throttler/period-throttler.ts +++ b/src/background/service/throttler/period-throttler.ts @@ -1,5 +1,5 @@ import periodDatabase from '@db/period-database' -import { calculate } from '@service/components/period-calculator' +import { calculate } from '../components/period-calculator' import { FirefoxThrottler } from './firefox-throttler' class PeriodThrottler extends FirefoxThrottler { diff --git a/src/service/throttler/timeline-throttler.ts b/src/background/service/throttler/timeline-throttler.ts similarity index 100% rename from src/service/throttler/timeline-throttler.ts rename to src/background/service/throttler/timeline-throttler.ts diff --git a/src/background/service/timeline-service.ts b/src/background/service/timeline-service.ts new file mode 100644 index 000000000..17087d644 --- /dev/null +++ b/src/background/service/timeline-service.ts @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2022-present Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import cateDb from "@db/cate-database" +import mergeDb from '@db/merge-rule-database' +import siteDb from "@db/site-database" +import db from "@db/timeline-database" +import { toMap } from '@util/array' +import { CATE_NOT_SET_ID } from '@util/site' +import CustomizedHostMergeRuler from './components/host-merge-ruler' + +export async function listTimeline(query: timer.timeline.Query): Promise { + const ticks = await db.select(query) + const { merge } = query + if (merge === 'domain') { + return mergeByDomain(ticks) + } else if (merge === 'cate') { + return mergeByCate(ticks) + } else { + return fillSiteName(ticks) + } +} + +async function mergeByDomain(ticks: timer.timeline.Tick[]): Promise { + const mergeRules = await mergeDb.selectAll() + const merger = new CustomizedHostMergeRuler(mergeRules) + const allHosts = Array.from(new Set(ticks.map(t => t.host))) + const mergedMap = toMap(allHosts, h => h, h => merger.merge(h)) + + const allSiteKeys = Array.from(new Set(Object.values(mergedMap))) + .map((mergedHost) => ({ type: 'merged', host: mergedHost } satisfies timer.site.SiteKey)) + const allSites = await siteDb.getBatch(allSiteKeys) + const nameMap = toMap(allSites, s => s.host, s => s.alias) + + return ticks.map(({ start, duration, host }) => { + const seriesKey = mergedMap[host] ?? host + return { + start, duration, + seriesKey, seriesName: nameMap[seriesKey], + } + }) +} + +async function mergeByCate(ticks: timer.timeline.Tick[]): Promise { + const cates = await cateDb.listAll() + const cateNameMap = toMap(cates, c => c.id, c => c.name) + const allSiteKeys = Array.from(new Set(ticks.map(t => t.host))) + .map(host => ({ type: 'normal', host } satisfies timer.site.SiteKey)) + const allSites = await siteDb.getBatch(allSiteKeys) + const siteCateMap = toMap(allSites, s => s.host, s => s.cate) + + return ticks.map(({ start, duration, host }) => { + const cateId = siteCateMap[host] ?? CATE_NOT_SET_ID + return { + start, duration, + seriesKey: `${cateId}`, + seriesName: cateNameMap[cateId], + } + }) +} + +async function fillSiteName(ticks: timer.timeline.Tick[]): Promise { + const allSiteKeys = Array.from(new Set(ticks.map(t => t.host))) + .map(host => ({ type: 'normal', host } satisfies timer.site.SiteKey)) + const allSites = await siteDb.getBatch(allSiteKeys) + const nameMap = toMap(allSites, s => s.host, s => s.alias) + + return ticks.map(({ start, duration, host }) => ({ + start, duration, + seriesKey: host, seriesName: nameMap[host], + })) +} diff --git a/src/background/service/whitelist/holder.ts b/src/background/service/whitelist/holder.ts new file mode 100644 index 000000000..c974138c6 --- /dev/null +++ b/src/background/service/whitelist/holder.ts @@ -0,0 +1,56 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import db from "@db/whitelist-database" +import WhitelistProcessor from './processor' + +/** + * The singleton implementation of whitelist holder + */ +class WhitelistHolder { + private processor = new WhitelistProcessor() + private postHandlers: ArgCallback[] = [] + + constructor() { + this.rebuild() + } + + private async rebuild() { + const whitelist = await db.selectAll() + this.processor.setWhitelist(whitelist) + this.postHandlers.forEach(handler => handler(whitelist)) + } + + addPostHandler(handler: ArgCallback) { + this.postHandlers.push(handler) + } + + async add(white: string): Promise { + await db.add(white) + await this.rebuild() + } + + all(): Promise { + return db.selectAll() + } + + async remove(white: string): Promise { + await db.remove(white) + await this.rebuild() + } + + contains(host: string, url: string): boolean { + return this.processor.contains(host, url) + } + + containsHost(host: string): boolean { + return this.processor.containsHost(host) + } +} + +const whitelistHolder = new WhitelistHolder() +export default whitelistHolder \ No newline at end of file diff --git a/src/service/whitelist/processor.ts b/src/background/service/whitelist/processor.ts similarity index 92% rename from src/service/whitelist/processor.ts rename to src/background/service/whitelist/processor.ts index 27b95b280..fbad9fc93 100644 --- a/src/service/whitelist/processor.ts +++ b/src/background/service/whitelist/processor.ts @@ -30,4 +30,8 @@ export default class WhitelistProcessor { if (this.exclude.some(r => r.test(url))) return false return this.host.includes(host) || this.virtual.some(r => r.test(url)) } + + containsHost(host: string): boolean { + return this.host.includes(host) + } } diff --git a/src/background/side-panel.ts b/src/background/side-panel.ts deleted file mode 100644 index 9505167d7..000000000 --- a/src/background/side-panel.ts +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) 2024-present Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { IS_MV3 } from "@util/constant/environment" - -export default function initSidePanel() { - if (!IS_MV3) return - const sidePanel = chrome.sidePanel - // sidePanel not supported for Firefox - // Avoid `chrome.sidePanel.setOptions` to skip web-ext lint - if (!sidePanel?.setOptions) return - sidePanel.setOptions({ path: "/static/side.html" }) -} \ No newline at end of file diff --git a/src/background/track-server/file-tracker.ts b/src/background/track-server/file-tracker.ts index 03b8f5af5..0f4c3cbaa 100644 --- a/src/background/track-server/file-tracker.ts +++ b/src/background/track-server/file-tracker.ts @@ -30,7 +30,7 @@ class FileTracker { private enabled = false private current: Context | null = null - init() { + async init() { optionHolder.get().then(v => this.enabled = v.countLocalFiles) optionHolder.addChangeListener(v => this.enabled = v.countLocalFiles) @@ -49,9 +49,8 @@ class FileTracker { const end = Date.now() this.enabled && handleTrackTimeEvent({ host, start, end, - url: tab.url ?? '', ignoreTabCheck: false, - }, tab) + }, tab.url, tab) this.current.start = end } } diff --git a/src/background/track-server/group.ts b/src/background/track-server/group.ts index 5e73906b4..071ebec80 100644 --- a/src/background/track-server/group.ts +++ b/src/background/track-server/group.ts @@ -1,14 +1,22 @@ -import itemService from '@service/item-service' +import db from "@db/stat-database" +import optionHolder from '../service/components/option-holder' -function handleTabGroupRemove(group: chrome.tabGroups.TabGroup) { - itemService.deleteByGroup(group.id) -} +const handleRemove = (group: chrome.tabGroups.TabGroup) => db.deleteByGroup(group.id) -export function handleTabGroupEnabled() { +function handleTabGroupsEnabled(option: timer.option.TrackingOption) { + // Do nothing if not enabled + if (!option.countTabGroup) return try { - chrome.tabGroups.onRemoved.removeListener(handleTabGroupRemove) - chrome.tabGroups.onRemoved.addListener(handleTabGroupRemove) + chrome.tabGroups.onRemoved.removeListener(handleRemove) + chrome.tabGroups.onRemoved.addListener(handleRemove) } catch (e) { console.warn('failed to handle event: enableTabGroup', e) } +} + +export async function initTabGroup() { + const option = await optionHolder.get() + handleTabGroupsEnabled(option) + + optionHolder.addChangeListener(newVal => handleTabGroupsEnabled(newVal)) } \ No newline at end of file diff --git a/src/background/track-server/index.ts b/src/background/track-server/index.ts index fd7c734f6..88b6addea 100644 --- a/src/background/track-server/index.ts +++ b/src/background/track-server/index.ts @@ -1,24 +1,16 @@ -import itemService from "@service/item-service" import { IS_ANDROID, IS_FIREFOX } from '@util/constant/environment' -import { isFileUrl } from '@util/pattern' import type MessageDispatcher from "../message-dispatcher" import FileTracker from './file-tracker' -import { handleTabGroupEnabled } from './group' -import { handleIncVisitEvent, handleTrackTimeEvent } from './normal' +import { initTabGroup } from './group' +import { handleTrackTimeEvent } from './normal' import { handleTrackRunTimeEvent } from './runtime' export default function initTrackServer(messageDispatcher: MessageDispatcher) { messageDispatcher - .register('cs.trackTime', (ev, sender) => { - // not to process cs events from local files for FF - if (IS_FIREFOX && isFileUrl(ev.url)) return + .register('track.time', (ev, { tab, url }) => handleTrackTimeEvent(ev, url, tab)) + .register('track.runTime', (ev, { url }) => handleTrackRunTimeEvent(ev, url)) - handleTrackTimeEvent(ev, sender.tab) - }) - .register('cs.trackRunTime', handleTrackRunTimeEvent) - .register<{ host: string, url: string }, void>('cs.incVisitCount', handleIncVisitEvent) - .register('cs.getTodayInfo', host => itemService.getResult(host, new Date())) - .register('enableTabGroup', handleTabGroupEnabled) + initTabGroup() // Track file time in background script for FF // Not accurate, since can't detect if the tabs are active or not diff --git a/src/background/track-server/normal.ts b/src/background/track-server/normal.ts index 20441b791..d823f7e64 100644 --- a/src/background/track-server/normal.ts +++ b/src/background/track-server/normal.ts @@ -1,12 +1,16 @@ import { getTab, listTabs, sendMsg2Tab } from "@api/chrome/tab" import { getWindow } from "@api/chrome/window" import optionHolder from "@service/components/option-holder" -import itemService, { type ItemIncContext } from "@service/item-service" -import limitService from "@service/limit-service" +import { + addFocusTime as addItemFocusTime, + increaseVisit as increaseItemVisit, + type ItemIncContext +} from "@service/item-service" +import { addLimitFocusTime, incLimitVisit } from '@service/limit-service' import periodThrottler from '@service/throttler/period-throttler' import whitelistHolder from "@service/whitelist/holder" -import { IS_ANDROID } from "@util/constant/environment" -import { extractHostname } from "@util/pattern" +import { IS_ANDROID, IS_FIREFOX } from "@util/constant/environment" +import { extractHostname, isFileUrl } from "@util/pattern" import badgeManager from "../badge-manager" async function handleTime(context: ItemIncContext, timeRange: [number, number], tabId: number | undefined): Promise { @@ -14,9 +18,9 @@ async function handleTime(context: ItemIncContext, timeRange: [number, number], const [start, end] = timeRange const focusTime = end - start // 1. Save async - await itemService.addFocusTime(context, focusTime) + await addItemFocusTime(context, focusTime) // 2. Process limit - const { limited, reminder } = await limitService.addFocusTime(host, url, focusTime) + const { limited, reminder } = await addLimitFocusTime(host, url, focusTime) // If time limited after this operation, send messages limited?.length && sendLimitedMessage(limited) // If need to reminder, send messages @@ -26,14 +30,18 @@ async function handleTime(context: ItemIncContext, timeRange: [number, number], return focusTime } -export async function handleTrackTimeEvent(event: timer.core.Event, senderTab: ChromeTab | undefined): Promise { - const { url, start, end, ignoreTabCheck } = event - const { id: tabId, windowId, groupId } = senderTab ?? {} +export async function handleTrackTimeEvent(event: timer.core.Event, url: string | undefined, tab: ChromeTab | undefined): Promise { + if (!url) return + // not to process cs events from local files for FF + if (IS_FIREFOX && isFileUrl(url)) return + + const { start, end, ignoreTabCheck } = event + const { id: tabId, windowId, groupId } = tab ?? {} if (!ignoreTabCheck) { if (await windowNotFocused(windowId)) return if (await tabNotActive(tabId)) return } - const { protocol, host } = extractHostname(url) || {} + const { protocol, host } = extractHostname(url) const option = await optionHolder.get() if (protocol === "file" && !option?.countLocalFiles) return @@ -42,7 +50,7 @@ export async function handleTrackTimeEvent(event: timer.core.Event, senderTab: C await handleTime({ host, url, groupId }, [start, end], tabId) if (tabId) { const winTabs = await listTabs({ active: true, windowId }) - const firstActiveTab = winTabs?.[0] + const firstActiveTab = winTabs[0] // Cause there is no way to determine whether this tab is selected in screen-split mode // So only show badge for first tab for screen-split mode // @see #246 @@ -77,17 +85,17 @@ async function sendLimitedMessage(items: timer.limit.Item[]) { } async function handleVisit(context: ItemIncContext) { - await itemService.increaseVisit(context) + await increaseItemVisit(context) const { host, url } = context - const metLimits = await limitService.incVisit(host, url) + const metLimits = await incLimitVisit(host, url) // If time limited after this operation, send messages - metLimits?.length && sendLimitedMessage(metLimits) + metLimits.length && await sendLimitedMessage(metLimits) } -export async function handleIncVisitEvent(param: { host: string, url: string }, sender: ChromeMessageSender): Promise { - const { host, url } = param - const { groupId } = sender?.tab ?? {} - const { protocol } = extractHostname(url) +export async function incVisitCount(tab: ChromeTab | undefined): Promise { + const { groupId, url } = tab ?? {} + if (!url) return + const { protocol, host } = extractHostname(url) const option = await optionHolder.get() if (protocol === "file" && !option.countLocalFiles) return await handleVisit({ host, url, groupId }) diff --git a/src/background/track-server/runtime.ts b/src/background/track-server/runtime.ts index 0cf582814..5f0b0da8b 100644 --- a/src/background/track-server/runtime.ts +++ b/src/background/track-server/runtime.ts @@ -1,7 +1,7 @@ -import itemService from "@service/item-service" import whitelistHolder from "@service/whitelist/holder" import FIFOCache from '@util/fifo-cache' import { formatTimeYMD, getStartOfDay, MILL_PER_DAY } from "@util/time" +import { addRunTime } from '../service/item-service' function splitRunTime(start: number, end: number): Record { const res: Record = {} @@ -17,13 +17,13 @@ function splitRunTime(start: number, end: number): Record { const RUN_TIME_END_CACHE = new FIFOCache(500) -export async function handleTrackRunTimeEvent(event: timer.core.Event): Promise { - const { start, end, url, host } = event || {} - if (!host || !start || !end) return +export async function handleTrackRunTimeEvent(event: timer.core.Event, url: string | undefined): Promise { + const { start, end, host } = event + if (!host || !start || !end || !url) return if (whitelistHolder.contains(host, url)) return const realStart = Math.max(RUN_TIME_END_CACHE.get(host) ?? 0, start) const byDate = splitRunTime(realStart, end) if (!Object.keys(byDate).length) return - await itemService.addRunTime(host, byDate) + await addRunTime(host, byDate) RUN_TIME_END_CACHE.set(host, Math.max(end, realStart)) } \ No newline at end of file diff --git a/src/background/whitelist-menu-manager.ts b/src/background/whitelist-menu-manager.ts index e90bcb283..a9b058d03 100644 --- a/src/background/whitelist-menu-manager.ts +++ b/src/background/whitelist-menu-manager.ts @@ -8,20 +8,15 @@ import { createContextMenu, updateContextMenu } from "@api/chrome/context-menu" import { getRuntimeId } from "@api/chrome/runtime" import { getTab, onTabActivated, onTabUpdated } from "@api/chrome/tab" -import db from "@db/whitelist-database" import { t2Chrome } from "@i18n/chrome/t" -import { type ContextMenusMessage } from "@i18n/message/common/context-menus" -import optionHolder from "@service/components/option-holder" import { IS_ANDROID } from "@util/constant/environment" import { extractHostname, isBrowserUrl } from "@util/pattern" +import optionHolder from "./service/components/option-holder" +import whitelistHolder from './service/whitelist/holder' const menuId = '_timer_menu_item_' + getRuntimeId() let currentActiveId: number -let whitelist: string[] = [] - -const removeOrAdd = (removeOrAddFlag: boolean, white: string) => removeOrAddFlag ? db.remove(white) : db.add(white) - const menuInitialOptions: ChromeContextMenuCreateProps = { contexts: ['page', 'frame', 'selection', 'link', 'editable', 'image', 'video', 'audio'], id: menuId, @@ -31,33 +26,23 @@ const menuInitialOptions: ChromeContextMenuCreateProps = { } async function updateContextMenuInner(param: ChromeTab | number | undefined): Promise { - if (typeof param === 'number') { - // If number, get the tabInfo first - const tab = await getTab(currentActiveId) - tab && await updateContextMenuInner(tab) + const tab = typeof param === 'number' ? await getTab(currentActiveId) : param + const { url } = tab ?? {} + + const targetHost = url && !isBrowserUrl(url) ? extractHostname(url).host : undefined + const visible = (await optionHolder.get())?.displayWhitelistMenu + const changeProp: ChromeContextMenuUpdateProps = {} + if (targetHost && visible) { + const exist = whitelistHolder.containsHost(targetHost) + changeProp.visible = visible + changeProp.title = t2Chrome(root => root.contextMenus[exist ? 'removeFromWhitelist' : 'add2Whitelist']) + .replace('{host}', targetHost) + changeProp.onclick = () => exist ? whitelistHolder.remove(targetHost) : whitelistHolder.add(targetHost) } else { - const { url } = param || {} - const targetHost = url && !isBrowserUrl(url) ? extractHostname(url).host : '' - const changeProp: ChromeContextMenuUpdateProps = {} - if (!targetHost) { - // If not a valid host, hide this menu - changeProp.visible = false - } else { - // Else change the title - const visible = (await optionHolder.get())?.displayWhitelistMenu - const existsInWhitelist = whitelist.includes(targetHost) - changeProp.visible = true && visible - const titleMsgField: keyof ContextMenusMessage = existsInWhitelist ? 'removeFromWhitelist' : 'add2Whitelist' - changeProp.title = t2Chrome(root => root.contextMenus[titleMsgField]).replace('{host}', targetHost) - changeProp.onclick = () => removeOrAdd(existsInWhitelist, targetHost) - } - await updateContextMenu(menuId, changeProp) + // If not a valid host, hide this menu + changeProp.visible = false } -} - -const handleListChange = (newWhitelist: string[]) => { - whitelist = newWhitelist - updateContextMenuInner(currentActiveId) + await updateContextMenu(menuId, changeProp) } const handleTabUpdated = (tabId: number, updatedInfo: ChromeTabUpdatedInfo, tab?: ChromeTab) => { @@ -77,7 +62,7 @@ async function initWhitelistMenuManager() { createContextMenu(menuInitialOptions) onTabUpdated(handleTabUpdated) onTabActivated((_tabId, activeInfo) => handleTabActivated(activeInfo)) - db.addChangeListener(handleListChange) + whitelistHolder.addPostHandler(() => updateContextMenuInner(currentActiveId)) } export default initWhitelistMenuManager \ No newline at end of file diff --git a/src/common/logger.ts b/src/common/logger.ts index 7f4189b14..105cdb512 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -17,14 +17,6 @@ function initOpenLog() { } catch (ignored) { } } -function updateLocalStorage(openState: boolean) { - try { - openState - ? localStorage.setItem(STORAGE_KEY, STORAGE_VAL) - : localStorage.removeItem(STORAGE_KEY) - } catch (ignored) { } -} - initOpenLog() /** @@ -34,19 +26,3 @@ initOpenLog() export function log(...args: any) { OPEN_LOG && console.log(...args) } - -/** - * @since 0.0.4 - */ -export function openLog(): string { - updateLocalStorage(OPEN_LOG = true) - return 'Opened the log manually.' -} - -/** - * @since 0.0.8 - */ -export function closeLog(): string { - updateLocalStorage(OPEN_LOG = false) - return 'Closed the log manually.' -} \ No newline at end of file diff --git a/src/common/timer.ts b/src/common/timer.ts deleted file mode 100644 index be79c5f0d..000000000 --- a/src/common/timer.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { getUsedStorage } from "@db/memory-detector" -import { closeLog, openLog } from "./logger" - -/** - * Show the memory info - * - * @since 0.0.9 - */ -export function showMemory() { - getUsedStorage().then(({ used, total }) => { - console.log(`\t${used} / ${total} = ${Math.round(used * 100.0 / total * 100) / 100}%`) - }) - return 'Memory used:' -} - -export type Timer = { - openLog: () => string - closeLog: () => string - showMemory: () => void -} - -/** - * @since 0.0.8 - */ -const timer = { - openLog, - closeLog, - showMemory -} as Timer - -declare global { - interface Window { - timer: Timer - } -} - -/** - * Manually open and close the log - * - * @since 0.0.8 - */ -window.timer = timer diff --git a/src/content-script/dispatcher.ts b/src/content-script/dispatcher.ts new file mode 100644 index 000000000..58c017f6f --- /dev/null +++ b/src/content-script/dispatcher.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2022-present Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { AudibleChangeHandler } from './types' + +type Handler = (data: timer.tab.ReqData) => timer.tab.ResData + +class Dispatcher { + private handlers: Partial>> = {} + private audibleChangeHandlers: AudibleChangeHandler[] = [] + + constructor() { + // Be careful!!! + // Can't use await/async in callback parameter + chrome.runtime.onMessage.addListener((message: timer.tab.Request, _, sendResponse: timer.tab.Callback) => { + this.handle(message) + .then(sendResponse) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err) + console.error('onTabMessage handler error', err) + sendResponse({ code: 'fail', msg }) + }) + // 'return true' will force chrome to wait for the response processed in the above promise. + // @see https://github.com/mozilla/webextension-polyfill/issues/130 + return true + }) + + this.register('syncAudible', audible => this.audibleChangeHandlers.forEach(h => h.onAudibleChange(audible))) + } + + register(code: Code, handler: Handler): Dispatcher { + this.handlers[code] = handler + return this + } + + registerAudibleChange(handler: AudibleChangeHandler): Dispatcher { + this.audibleChangeHandlers.push(handler) + return this + } + + private async handle(message: timer.tab.Request): Promise> { + const code = message?.code + if (!code) { + return { code: 'ignore' } + } + const handler = this.handlers[code] + if (!handler) return { code: 'ignore' } + try { + const res = handler(message.data as timer.tab.ReqData) + return { code: "success", data: res as timer.tab.ResData } + } catch (error) { + const msg = error instanceof Error ? error.message : (error?.toString?.() ?? 'Unknown error') + return { code: 'fail', msg } + } + } +} + +export default Dispatcher diff --git a/src/content-script/index.ts b/src/content-script/index.ts index 2ec914059..bf05214f8 100644 --- a/src/content-script/index.ts +++ b/src/content-script/index.ts @@ -5,8 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { trySendMsg2Runtime } from "@api/chrome/runtime" +import { trySendMsg2Runtime } from '@api/sw/common' import { initLocale } from "@i18n" +import Dispatcher from './dispatcher' import processLimit from "./limit" import printInfo from "./printer" import processTimeline from './timeline' @@ -39,32 +40,33 @@ function getOrSetFlag(): boolean { } async function main() { + const dispatcher = new Dispatcher() + // Execute in every injections const normalTracker = new NormalTracker({ - onReport: data => trySendMsg2Runtime('cs.trackTime', data), - onResume: reason => reason === 'idle' && trySendMsg2Runtime('cs.idleChange', false), - onPause: reason => reason === 'idle' && trySendMsg2Runtime('cs.idleChange', true), + onReport: data => trySendMsg2Runtime('track.time', data), + onResume: reason => reason === 'idle' && trySendMsg2Runtime('cs.idleChanged', false), + onPause: reason => reason === 'idle' && trySendMsg2Runtime('cs.idleChanged', true), }) normalTracker.init() - const runTimeTracker = new RunTimeTracker(url) - runTimeTracker.init() + dispatcher.registerAudibleChange(normalTracker) + new RunTimeTracker(url).init(dispatcher) // Execute only one time for each dom if (getOrSetFlag()) return if (!host) return - const isWhitelist = await trySendMsg2Runtime('cs.isInWhitelist', { host, url }) + const isWhitelist = await trySendMsg2Runtime('whitelist.contain', { host, url }) if (isWhitelist) return - await initLocale() - const needPrintInfo = await trySendMsg2Runtime('cs.printTodayInfo') - !!needPrintInfo && printInfo(host) - await processLimit(url) + initLocale() + printInfo(host) + await processLimit(url, dispatcher) processTimeline() // Increase visit count at the end - await trySendMsg2Runtime('cs.incVisitCount', { host, url }) + await trySendMsg2Runtime('cs.injected') } main() diff --git a/src/content-script/limit/common.ts b/src/content-script/limit/common.ts index 37fe3abae..8f9cc2ecf 100644 --- a/src/content-script/limit/common.ts +++ b/src/content-script/limit/common.ts @@ -1,10 +1,4 @@ -export type LimitReason = - & RequiredPick - & PartialPick - & { - type: timer.limit.ReasonType - getVisitTime?: () => number - } +import type { LimitReason } from './types' export function isSameReason(a: LimitReason, b: LimitReason): boolean { if (a?.id !== b?.id || a?.type !== b?.type) return false @@ -15,23 +9,6 @@ export function isSameReason(a: LimitReason, b: LimitReason): boolean { return true } -export interface MaskModal { - addReason(...reasons: LimitReason[]): void - removeReason(...reasons: LimitReason[]): void - removeReasonsByType(...types: timer.limit.ReasonType[]): void - addDelayHandler(handler: () => void): void -} - -export type ModalContext = { - url: string - modal: MaskModal -} - -export interface Processor { - handleMsg(code: timer.mq.ReqCode, data: unknown): timer.mq.Response | Promise - init(): void | Promise -} - export async function exitFullscreen(): Promise { if (!document?.fullscreenElement) return if (!document?.exitFullscreen) return diff --git a/src/content-script/limit/index.ts b/src/content-script/limit/index.ts index 8180df1b1..0391c3171 100644 --- a/src/content-script/limit/index.ts +++ b/src/content-script/limit/index.ts @@ -1,42 +1,35 @@ -import { onRuntimeMessage } from "@api/chrome/runtime" -import { allMatch } from "@util/array" -import { type MaskModal, type ModalContext, type Processor } from "./common" -import ModalInstance from "./modal" -import MessageAdaptor from "./processor/message-adaptor" +import { getOption } from '@api/sw/option' +import Dispatcher from '../dispatcher' +import ModalInstance from "./modal/instance" +import MessageAdaptor from './processor/message-adaptor' import PeriodProcessor from "./processor/period-processor" import VisitProcessor from "./processor/visit-processor" -import Reminder from "./reminder" +import Reminder from './reminder' +import type { ModalContext, Processor } from './types' -export default async function processLimit(url: string) { - const modal: MaskModal = new ModalInstance(url) +export default async function processLimit(url: string, dispatcher: Dispatcher) { + const { limitDelayDuration: delayDuration } = await getOption() + const modal = new ModalInstance(url) const context: ModalContext = { modal, url } + const mesageAdaptor = new MessageAdaptor(context, delayDuration) + const visitProcessor = new VisitProcessor(context, delayDuration) + const processors: Processor[] = [ - new MessageAdaptor(context), + mesageAdaptor, + visitProcessor, new PeriodProcessor(context), - new VisitProcessor(context), - new Reminder(), ] - await Promise.all(processors.map(p => p.init())) - onRuntimeMessage(async msg => { - const results = await Promise.all(processors.map(async p => { - const { code, data } = msg || {} - return await p.handleMsg(code, data) - })) + const reminder = new Reminder() - const allIgnore = allMatch(results, r => r.code === "ignore") - if (allIgnore) return { code: "ignore" } + dispatcher + .register('limitChanged', items => processors.forEach(p => p.onLimitChanged(items))) + .register('limitTimeMeet', items => mesageAdaptor.onLimitTimeMeet(items)) + .register('limitReminder', data => reminder.show(data)) + .register('askVisitHit', ruleId => modal.reasons.some(r => r.type === 'VISIT' && ruleId === r.id)) + .registerAudibleChange(visitProcessor.tracker) - const anyFail = allMatch(results, r => r.code === "fail") - if (anyFail) return { code: "fail" } - // Merge data of all the handlers - const items = results - .filter(r => r.code === "success") - .map(r => r.data) - .filter(r => r !== undefined && r !== null) - const data = items.length <= 1 ? items[0] : items - return { code: "success", data } - }) + return visitProcessor.tracker } diff --git a/src/content-script/limit/modal/Main.tsx b/src/content-script/limit/modal/Main.tsx index 22da582a4..ba9653a9a 100644 --- a/src/content-script/limit/modal/Main.tsx +++ b/src/content-script/limit/modal/Main.tsx @@ -1,17 +1,9 @@ import "@pages/element-ui/dark-theme.css" -import "element-plus/theme-chalk/el-button-group.css" -import "element-plus/theme-chalk/el-button.css" -import "element-plus/theme-chalk/el-descriptions-item.css" -import "element-plus/theme-chalk/el-descriptions.css" -import "element-plus/theme-chalk/el-input.css" -import "element-plus/theme-chalk/el-message-box.css" -import "element-plus/theme-chalk/el-message.css" -import "element-plus/theme-chalk/el-tag.css" import { defineComponent } from "vue" import Alert from "./components/Alert" import Footer from "./components/Footer" import Reason from "./components/Reason" -import { provideRule } from "./context" +import { provideRule } from './context' import "./style/element-base.css" import "./style/modal.css" diff --git a/src/content-script/limit/modal/bridge.ts b/src/content-script/limit/modal/bridge.ts new file mode 100644 index 000000000..3934c666e --- /dev/null +++ b/src/content-script/limit/modal/bridge.ts @@ -0,0 +1,91 @@ +import type { BridgeCode, BridgeHandler, BridgeRequest, BridgeResponse } from './types' + +type RpcBase = { + code: C + requestId: string +} + +type RpcRequest = RpcBase & { + kind: 'request' + data: BridgeRequest +} + +type RpcResponse = RpcBase & { + kind: 'response' + data: BridgeResponse +} + +const isRpcRequest = (payload: unknown): payload is RpcRequest => { + if (typeof payload !== 'object' || payload === null) return false + // Just judge the kind + return 'kind' in payload && payload.kind === 'request' +} + +const isRpcResponse = (payload: unknown): payload is RpcResponse => { + if (typeof payload !== 'object' || payload === null) return false + // Just judge the kind + return 'kind' in payload && payload.kind === 'response' +} + +export class ModalBridge { + private readonly pendingCache = new Map>>() + private readonly handlers = new Map>() + private readonly onMessageBound: (ev: MessageEvent) => void + + constructor(private origin: string, private peer: () => Window | undefined) { + this.onMessageBound = this.onMessage.bind(this) + window.addEventListener('message', this.onMessageBound) + } + + dispose(): void { + window.removeEventListener('message', this.onMessageBound) + this.pendingCache.clear() + this.handlers.clear() + } + + register(code: C, handler: BridgeHandler): ModalBridge { + this.handlers.set(code, handler as unknown as BridgeHandler) + return this + } + + request(code: C, req: BridgeRequest): Promise> { + const requestId = `${code}-${Date.now()}-${Math.random().toString(12).slice(2)}` + if (!this.peer()) return Promise.reject("Peer window is not available") + return new Promise>((resolve, reject) => { + const t = window.setTimeout(() => { + this.pendingCache.delete(requestId) + reject("Timeout") + }, 1_000) + this.pendingCache.set(requestId, msg => { + if (msg.kind !== 'response' || msg.code !== code) return + clearTimeout(t) + this.pendingCache.delete(requestId) + resolve(msg.data) + }) + this.send({ kind: 'request', code, requestId, data: req }) + }) + } + + private send(payload: RpcRequest | RpcResponse): void { + try { + this.peer()?.postMessage(payload, this.origin) + } catch { + // ignored + } + } + + private async onMessage(ev: MessageEvent) { + if (this.peer() !== ev.source) return + const { data: payload } = ev + if (isRpcRequest(payload)) { + const { code, data: reqData, requestId } = payload + const handler = this.handlers.get(code) + if (!handler) return + const data = await handler(reqData) + this.send({ kind: 'response', requestId, code, data }) + } else if (isRpcResponse(payload)) { + const pending = this.pendingCache.get(payload.requestId) + pending?.(payload) + } + } +} diff --git a/src/content-script/limit/modal/components/Alert.tsx b/src/content-script/limit/modal/components/Alert.tsx index 24033ad15..247398a05 100644 --- a/src/content-script/limit/modal/components/Alert.tsx +++ b/src/content-script/limit/modal/components/Alert.tsx @@ -1,31 +1,25 @@ import { getIconUrl } from "@api/chrome/runtime" +import { getOption } from "@api/sw/option" import { t } from "@cs/locale" -import { useXsState } from '@hooks/useMediaSize' -import { useRequest } from "@hooks/useRequest" +import { useRequest, useXsState } from "@hooks" import Box from '@pages/components/Box' import Flex from '@pages/components/Flex' -import optionHolder from "@service/components/option-holder" -import { defineComponent, type StyleValue } from "vue" - -const IMG_STYLE: StyleValue = { - width: '1.4em', - height: '1.4em', - marginInlineEnd: '.4em', -} +import Img from '@pages/components/Img' +import { defineComponent } from "vue" const _default = defineComponent(() => { const defaultPrompt = t(msg => msg.modal.defaultPrompt) const { data: prompt } = useRequest(async () => { - const option = await optionHolder.get() - return option?.limitPrompt || defaultPrompt + const option = await getOption() + return option?.limitPrompt ?? defaultPrompt }, { defaultValue: defaultPrompt }) const isXs = useXsState() return () => ( - - + + {t(msg => msg.meta.name)?.toUpperCase()} void) { - let promise: Promise | undefined = undefined - const ele = document.querySelector(TAG_NAME)?.shadowRoot?.querySelector('body') - if (rule && await judgeVerificationRequired(rule)) { - const option = await optionHolder.get() - promise = processVerification(option, { appendTo: ele ?? undefined }) - promise ? promise.then(callback).catch(() => { }) : callback() - } else { - callback() - } -} +import { useApp, useRule } from '../context' const _default = defineComponent(() => { - const reason = useReason() + const { reason, visitTime: currVisitTime, bridge, url, delayDuration } = useApp() + + const analysisUrl = getAppPageUrl(APP_ANALYSIS_ROUTE, { url } satisfies AppAnalysisQuery) + const ruleUrl = getAppPageUrl(APP_LIMIT_ROUTE, { url: encodeURI(url) } satisfies AppLimitQuery) + const rule = useRule() const showDelay = computed(() => { - const { type, allowDelay, delayCount = 0 } = reason.value || {} + const reasonVal = reason.value + if (!reasonVal) return false + const { type, allowDelay, delayCount = 0 } = reasonVal if (!allowDelay) return false - const { time, weekly, visitTime, waste, weeklyWaste } = rule.value || {} - let realLimit = 0, realWaste = 0 + const { time, weekly, visitTime, waste, weeklyWaste } = rule.value ?? {} + let maxLimitMs = 0, wasted = 0 if (type === 'DAILY') { - realLimit = time ?? 0 - realWaste = waste ?? 0 + maxLimitMs = (time ?? 0) * MILL_PER_SECOND + wasted = waste ?? 0 } else if (type === 'WEEKLY') { - realLimit = weekly ?? 0 - realWaste = weeklyWaste ?? 0 + maxLimitMs = (weekly ?? 0) * MILL_PER_SECOND + wasted = weeklyWaste ?? 0 } else if (type === 'VISIT') { - realLimit = visitTime ?? 0 - realWaste = reason.value?.getVisitTime?.() ?? 0 + maxLimitMs = (visitTime ?? 0) * MILL_PER_SECOND + wasted = currVisitTime.value } else { return false } - return meetTimeLimit(realLimit, realWaste, allowDelay, delayCount) + return meetTimeLimit( + { wasted, maxLimit: maxLimitMs }, + { count: delayCount, duration: delayDuration.value, allow: !!allowDelay }, + ) }) - const delayHandler = useDelayHandler() + const handleDelay = async () => { + const option = await trySendMsg2Runtime('option.get') + try { + if (option) await processVerification(option) + await bridge.request('delay', undefined) + } catch { + } + } return () => ( - trySendMsg2Runtime('cs.openAnalysis')} - > - {t(msg => msg.menu.siteAnalysis)} - + + + {t(msg => msg.menu.siteAnalysis)} + + handleMore5Minutes(rule.value, delayHandler)} + round icon={Plus} onClick={handleDelay} > - {t(msg => msg.modal.more5Minutes)} - - trySendMsg2Runtime('cs.openLimit')}> - {t(msg => msg.modal.ruleDetail)} + {t(msg => msg.modal.delay, { n: delayDuration.value })} + + + {t(msg => msg.modal.ruleDetail)} + + ) }) diff --git a/src/content-script/limit/modal/components/Reason.tsx b/src/content-script/limit/modal/components/Reason.tsx index 1dc2204ec..9405f3cd5 100644 --- a/src/content-script/limit/modal/components/Reason.tsx +++ b/src/content-script/limit/modal/components/Reason.tsx @@ -1,12 +1,11 @@ import { t } from "@cs/locale" -import { useXsState } from '@hooks/index' -import { useRequest } from "@hooks/useRequest" +import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" import { matchCond, meetLimit, meetTimeLimit, period2Str } from "@util/limit" import { formatPeriodCommon, MILL_PER_SECOND } from "@util/time" import { ElDescriptions, ElDescriptionsItem, ElTag } from 'element-plus' import { computed, defineComponent, type StyleValue } from "vue" -import { useGlobalParam, useReason, useRule } from "../context" +import { useApp, useRule } from '../context' const useDescriptions = () => { const isXs = useXsState() @@ -17,7 +16,7 @@ const useDescriptions = () => { return { style, size } } -const renderBaseItems = (rule: timer.limit.Rule | null, url: string) => <> +const renderBaseItems = (rule: timer.limit.Rule | undefined, url: string) => <> msg.limit.item.name)} labelAlign="right"> {rule?.name ?? '-'} @@ -38,12 +37,18 @@ const TimeDescriptions = defineComponent({ dataLabel: String, }, setup(props) { + const { reason, url, delayDuration } = useApp() const rule = useRule() - const reason = useReason() - const { url } = useGlobalParam() const { style, size } = useDescriptions() - const timeLimited = computed(() => meetTimeLimit(props.time ?? 0, props.waste ?? 0, !!reason.value?.allowDelay, reason.value?.delayCount ?? 0)) + const timeLimited = computed(() => meetTimeLimit( + { wasted: props.waste ?? 0, maxLimit: (props.time ?? 0) * MILL_PER_SECOND }, + { + count: reason.value?.delayCount ?? 0, + duration: delayDuration.value, + allow: !!reason.value?.allowDelay, + }, + )) const visitLimited = computed(() => meetLimit(props.count ?? 0, props.visit ?? 0)) return () => ( @@ -52,7 +57,7 @@ const TimeDescriptions = defineComponent({ {formatPeriodCommon((props.time ?? 0) * MILL_PER_SECOND)} - {`${props.count ?? 0} ${t(msg => msg.limit.item.visits)}`} + {props.count && {t(msg => msg.shared.limit.visits, { n: props.count })}} @@ -67,7 +72,7 @@ const TimeDescriptions = defineComponent({ v-show={!!props.count || !!props.visit} type={visitLimited.value ? 'danger' : 'info'} > - {`${props.visit ?? 0} ${t(msg => msg.limit.item.visits)}`} + {t(msg => msg.shared.limit.visits, { n: props.visit ?? 0 })} @@ -78,24 +83,15 @@ const TimeDescriptions = defineComponent({ > {reason.value?.delayCount ?? 0} - + ) }, }) const _default = defineComponent(() => { - const reason = useReason() - const rule = useRule() - const { url } = useGlobalParam() + const { reason, visitTime, url } = useApp() const type = computed(() => reason.value?.type) - - const { data: browsingTime, refresh: refreshBrowsingTime } = useRequest(() => { - const { getVisitTime, type } = reason.value || {} - if (type !== 'VISIT') return - return getVisitTime?.() || 0 - }) - - setInterval(refreshBrowsingTime, 1000) + const rule = useRule() const { style, size } = useDescriptions() @@ -107,7 +103,7 @@ const _default = defineComponent(() => { count={rule.value?.count} waste={rule.value?.waste} visit={rule.value?.visit} - ruleLabel={t(msg => msg.limit.item.daily)} + ruleLabel={t(msg => msg.shared.limit.daily)} dataLabel={t(msg => msg.calendar.range.today)} /> { count={rule.value?.weeklyCount} waste={rule.value?.weeklyWaste} visit={rule.value?.weeklyVisit} - ruleLabel={t(msg => msg.limit.item.weekly)} + ruleLabel={t(msg => msg.shared.limit.weekly)} dataLabel={t(msg => msg.calendar.range.thisWeek)} /> @@ -125,7 +121,7 @@ const _default = defineComponent(() => { {formatPeriodCommon((rule.value?.visitTime ?? 0) * MILL_PER_SECOND) || '-'} msg.modal.browsingTime)} labelAlign="right"> - {browsingTime.value ? formatPeriodCommon(browsingTime.value) : '-'} + {visitTime.value ? formatPeriodCommon(visitTime.value) : '-'} { {renderBaseItems(rule.value, url)} - msg.limit.item.period)} labelAlign="right"> + msg.shared.limit.period)} labelAlign="right"> {rule.value?.periods?.length ?
    {rule.value.periods.map(p => {period2Str(p)})} diff --git a/src/content-script/limit/modal/context.ts b/src/content-script/limit/modal/context.ts index 1da91a102..ad3e0f50e 100644 --- a/src/content-script/limit/modal/context.ts +++ b/src/content-script/limit/modal/context.ts @@ -1,53 +1,65 @@ -import { useRequest } from '@hooks/useRequest' -import { useWindowFocus } from '@hooks/useWindowFocus' -import limitService from "@service/limit-service" -import { type App, inject, provide, type Ref, shallowRef, watch } from "vue" -import { type LimitReason } from "../common" - -const REASON_KEY = "display_reason" -const RULE_KEY = "display_rule" -const GLOBAL_KEY = "delay_global" -const DELAY_HANDLER_KEY = 'delay_handler' - -type GlobalParam = { +import { listLimits } from "@api/sw/limit" +import { getOption } from '@api/sw/option' +import { useDocumentVisibility, useRequest } from '@hooks' +import { type App, inject, provide, ref, type Ref, type ShallowRef, watch } from "vue" +import { ModalBridge } from './bridge' +import type { LimitReasonData } from './types' + +const GLOBAL_KEY = "global" +const RULE_KEY = 'rule' + +type AppContext = { + reason: ShallowRef + visitTime: ShallowRef + bridge: ModalBridge url: string + delayDuration: Ref } -export const provideGlobalParam = (app: App, gp: GlobalParam) => { - app.provide(GLOBAL_KEY, gp) -} +export const provideApp = (app: App, bridge: ModalBridge, url: string) => { + const reason = ref() + const visitTime = ref(0) + const delayDuration = ref(5) + getOption().then(({ limitDelayDuration }) => delayDuration.value = limitDelayDuration).catch(() => { }) + + bridge.register('reason', data => { reason.value = data }) + + const updateVisitTime = async () => { + bridge.request('visitTime', undefined) + .then(val => visitTime.value = val) + .catch(() => visitTime.value = 0) + } -export const useGlobalParam = () => inject(GLOBAL_KEY) as GlobalParam + watch(reason, updateVisitTime, { immediate: true }) + const intervalId = window.setInterval(updateVisitTime, 1000) -export const provideReason = (app: App): Ref => { - const reason = shallowRef() - app.provide(REASON_KEY, reason) - return reason + const _unmount = app.unmount.bind(app) + app.unmount = () => { + bridge.dispose() + clearInterval(intervalId) + _unmount() + } + + app.provide(GLOBAL_KEY, { reason, visitTime, bridge, url, delayDuration }) } -export const useReason = () => inject(REASON_KEY) as Ref +export const useApp = () => inject(GLOBAL_KEY) as AppContext export const provideRule = () => { - const reason = useReason() - const windowFocus = useWindowFocus() + const { reason } = useApp() + const visibility = useDocumentVisibility() const { data: rule, refresh } = useRequest(async () => { - if (!windowFocus.value) return null + if (visibility.value !== 'visible') return undefined const reasonId = reason.value?.id - if (!reasonId) return null - const rules = await limitService.select({ id: reasonId, filterDisabled: false }) - return rules?.[0] + if (!reasonId) return undefined + const rules = await listLimits({ id: reasonId }) + return rules[0] ?? undefined }) - watch([reason, windowFocus], refresh) + watch([reason, visibility], refresh) provide(RULE_KEY, rule) } -export const useRule = () => inject(RULE_KEY) as Ref - -export const provideDelayHandler = (app: App, handlers: () => void) => { - app?.provide(DELAY_HANDLER_KEY, handlers) -} - -export const useDelayHandler = () => inject(DELAY_HANDLER_KEY) as () => void \ No newline at end of file +export const useRule = () => inject>(RULE_KEY) as ShallowRef \ No newline at end of file diff --git a/src/content-script/limit/element.ts b/src/content-script/limit/modal/element.ts similarity index 100% rename from src/content-script/limit/element.ts rename to src/content-script/limit/modal/element.ts diff --git a/src/content-script/limit/modal/index.ts b/src/content-script/limit/modal/index.ts index 2d1d7fa11..663762f67 100644 --- a/src/content-script/limit/modal/index.ts +++ b/src/content-script/limit/modal/index.ts @@ -1,208 +1,32 @@ -import { getRuntimeId, getUrl, trySendMsg2Runtime } from '@api/chrome/runtime' -import optionService from '@service/option-service' -import { init as initTheme, toggle } from '@util/dark-mode' -import { createApp, Ref, type App } from 'vue' -import { exitFullscreen, isSameReason, type LimitReason, type MaskModal } from '../common' -import { createRootElement, TAG_NAME, type RootElement } from '../element' +import { initDarkTheme } from '@pages/util/dark-mode' +import 'element-plus/es/components/input/style/css' +import 'element-plus/es/components/message/style/css' +import { createApp } from 'vue' import Main from './Main' -import { provideDelayHandler, provideGlobalParam, provideReason } from './context' +import { ModalBridge } from './bridge' +import { provideApp } from './context' -function pauseAllVideo(): void { - const elements = document?.getElementsByTagName('video') - if (!elements) return - Array.from(elements).forEach(video => { - try { - video?.pause?.() - } catch { } - }) -} - -function pauseAllAudio(): void { - const elements = document?.getElementsByTagName('audio') - if (!elements) return - Array.from(elements).forEach(audio => { - try { - audio?.pause?.() - } catch { } - }) -} - -const TYPE_SORT: { [reason in timer.limit.ReasonType]: number } = { - PERIOD: 0, - VISIT: 1, - DAILY: 2, - WEEKLY: 3, -} - -const createHeader = () => { - const header = document.createElement('header') - // Style script - const style = document.createElement('link') - style.type = 'text/css' - style.rel = 'stylesheet' - style.href = getUrl('content_scripts.css') - header.append(style) - return header -} - -class ScreenLocker { - private styleId = `time-tracker-style-${getRuntimeId()}` - private lockedCls = `time-tracker-locked-${getRuntimeId()}` - - lock() { - this.insertStyle() - document?.documentElement?.classList?.add?.(this.lockedCls) - } - - unlock() { - document?.documentElement?.classList?.remove(this.lockedCls) - } - - private insertStyle() { - if (!document) return - if (document.getElementById(this.styleId)) return - const style = document.createElement('style') - style.id = this.styleId - const css = ` - .${this.lockedCls} { - overflow: hidden !important; - } - ` - style.appendChild(document.createTextNode(css)) - document.head?.appendChild(style) +function parsePageUrl(): string { + const raw = new URLSearchParams(window.location.search).get('url') + if (!raw) return '' + try { + return decodeURIComponent(raw) + } catch { + return raw } } -class ModalInstance implements MaskModal { - url: string - rootElement: RootElement | undefined - body: HTMLBodyElement | undefined - delayHandlers: (() => void)[] = [ - () => trySendMsg2Runtime('cs.moreMinutes', this.url), - ] - reasons: LimitReason[] = [] - reason: Ref | undefined - app: App | undefined - screenLocker = new ScreenLocker() - - constructor(url: string) { - (window as any)['__modal__'] = this - this.url = url - } - - addReason(...reasons2Add: LimitReason[]): void { - reasons2Add = reasons2Add.filter(r => { - const anyExist = this.reasons?.some(reason => isSameReason(r, reason)) - return !anyExist - }) - if (!reasons2Add?.length) return - this.reasons.push(...reasons2Add) - // Sort - this.reasons.sort((a, b) => TYPE_SORT[a.type] - TYPE_SORT[b.type]) - this.refresh() - } - - removeReason(...reasons2Remove: LimitReason[]): void { - if (!reasons2Remove?.length) return - this.reasons = this.reasons?.filter(reason => { - const anyRemove = reasons2Remove.some(r => isSameReason(reason, r)) - return !anyRemove - }) - this.refresh() - } - - removeReasonsByType(...types: timer.limit.ReasonType[]): void { - if (!types?.length) return - this.reasons = this.reasons?.filter(r => !types?.includes(r.type)) - this.refresh() - } - - addDelayHandler(handler: () => void): void { - if (!handler) return - if (this.delayHandlers?.includes(handler)) return - this.delayHandlers?.push(handler) - } - - private refresh() { - setTimeout(() => { - // update vue ref in another micro task - const reason = this.reasons?.[0] - reason ? this.show(reason) : this.hide() - }) - } - - private async init() { - // 1. Create mask element - const root = await this.prepareRoot() - const html = document.createElement('html') - root?.append(html) - - // header - const header = createHeader() - html.append(header) +function main() { + initDarkTheme() - // body - this.body = document.createElement('body') - html.append(this.body) + const bridge = new ModalBridge('*', () => window.parent) + const app = createApp(Main) + provideApp(app, bridge, parsePageUrl()) - // 2. Init dark mode - initTheme(html) - optionService.isDarkMode().then(val => toggle(val, html)) - - // 3. Init vue app instance - this.initApp() - } - - private initApp() { - this.app = createApp(Main) - this.reason = provideReason(this.app) - provideGlobalParam(this.app, { url: this.url }) - provideDelayHandler(this.app, () => this.delayHandlers?.forEach(h => h?.())) - this.body && this.app.mount(this.body) - } - - private async prepareRoot(): Promise { - const inner = (): ShadowRoot | null => { - const exist = this.rootElement ?? document.querySelector(TAG_NAME) as RootElement - if (exist) { - this.rootElement = exist - return exist.shadowRoot - } - this.rootElement = createRootElement() - document.body.appendChild(this.rootElement) - return this.rootElement.attachShadow({ mode: 'open' }) - } - if (document.body) { - return inner() - } else { - return new Promise(resolve => { - window.addEventListener('load', () => resolve(inner())) - }) - } - } - - private async show(reason: LimitReason) { - if (!this.rootElement) { - await this.init() - } - await exitFullscreen() - // Scroll to top - scrollTo(0, 0) - pauseAllVideo() - pauseAllAudio() - - this.rootElement && (this.rootElement.style.visibility = 'visible') - this.reason && (this.reason.value = reason) - this.screenLocker.lock() - this.body && (this.body.style.display = 'block') - } - - private hide() { - this.rootElement && (this.rootElement.style.visibility = 'hidden') - this.screenLocker.unlock() - this.body && (this.body.style.display = 'none') - this.reason && (this.reason.value = undefined) - } + const el = document.createElement('div') + document.body.append(el) + el.id = 'app' + app.mount(el) } -export default ModalInstance +main() diff --git a/src/content-script/limit/modal/instance.ts b/src/content-script/limit/modal/instance.ts new file mode 100644 index 000000000..8ba7072ad --- /dev/null +++ b/src/content-script/limit/modal/instance.ts @@ -0,0 +1,193 @@ +import { getRuntimeId, getUrl } from '@api/chrome/runtime' +import { trySendMsg2Runtime } from '@api/sw/common' +import { exitFullscreen, isSameReason } from '../common' +import type { LimitReason, MaskModal } from '../types' +import { ModalBridge } from './bridge' +import { createRootElement, TAG_NAME, type RootElement } from './element' +import type { LimitReasonData } from './types' + +const MODAL_URL = getUrl('static/limit.html') +const MSG_ORIGIN = new URL(MODAL_URL).origin + +function pauseAllVideo(): void { + const elements = document?.getElementsByTagName('video') + if (!elements) return + Array.from(elements).forEach(video => { + try { + video?.pause?.() + } catch { } + }) +} + +function pauseAllAudio(): void { + const elements = document?.getElementsByTagName('audio') + if (!elements) return + Array.from(elements).forEach(audio => { + try { + audio?.pause?.() + } catch { } + }) +} + +const TYPE_SORT: { [reason in timer.limit.ReasonType]: number } = { + PERIOD: 0, + VISIT: 1, + DAILY: 2, + WEEKLY: 3, +} + +class ScreenLocker { + private styleId = `time-tracker-style-${getRuntimeId()}` + private lockedCls = `time-tracker-locked-${getRuntimeId()}` + + lock() { + this.insertStyle() + document?.documentElement?.classList?.add?.(this.lockedCls) + } + + unlock() { + document?.documentElement?.classList?.remove(this.lockedCls) + } + + private insertStyle() { + if (!document) return + if (document.getElementById(this.styleId)) return + const style = document.createElement('style') + style.id = this.styleId + const css = ` + .${this.lockedCls} { + overflow: hidden !important; + } + ` + style.appendChild(document.createTextNode(css)) + document.head?.appendChild(style) + } +} + +class ModalInstance implements MaskModal { + url: string + rootElement: RootElement | undefined + iframe: HTMLIFrameElement | undefined + delayHandlers: NoArgCallback[] = [ + () => trySendMsg2Runtime('limit.delay', this.url), + ] + reasons: LimitReason[] = [] + reason: LimitReason | undefined + screenLocker = new ScreenLocker() + private bridge: ModalBridge + + constructor(url: string) { + (window as any)['__modal__'] = this + this.url = url + this.bridge = new ModalBridge(MSG_ORIGIN, () => this.iframe?.contentWindow ?? undefined) + .register('visitTime', () => this.reason?.getVisitTime?.() ?? 0) + .register('delay', () => this.delayHandlers.forEach(handler => handler())) + } + + addReason(...reasons2Add: LimitReason[]): void { + reasons2Add = reasons2Add.filter(r => !this.reasons.some(reason => isSameReason(r, reason))) + if (!reasons2Add.length) return + this.reasons.push(...reasons2Add) + // Sort + this.reasons.sort((a, b) => TYPE_SORT[a.type] - TYPE_SORT[b.type]) + this.refresh() + } + + removeReason(...reasons2Remove: LimitReason[]): void { + if (!reasons2Remove?.length) return + this.reasons = this.reasons?.filter(reason => { + const anyRemove = reasons2Remove.some(r => isSameReason(reason, r)) + return !anyRemove + }) + this.refresh() + } + + removeReasonsByType(...types: timer.limit.ReasonType[]): void { + if (!types.length) return + this.reasons = this.reasons.filter(r => !types.includes(r.type)) + this.refresh() + } + + addDelayHandler(handler: NoArgCallback): void { + if (this.delayHandlers.includes(handler)) return + this.delayHandlers.push(handler) + } + + private refresh() { + // Change reason in new microtask + setTimeout(() => { + const reason = this.reasons[0] + reason ? this.show(reason) : this.hide() + }) + } + + private async init(): Promise { + const root = await this.prepareRoot() + if (!root) return + const iframe = document.createElement('iframe') + iframe.src = `${MODAL_URL}?url=${encodeURIComponent(this.url)}` + iframe.style.width = '100vw' + iframe.style.height = '100vh' + iframe.style.border = 'none' + root.append(iframe) + + this.iframe = iframe + + return new Promise(resolve => iframe.onload = () => resolve(undefined)) + } + + private async prepareRoot(): Promise { + const inner = (): ShadowRoot | null => { + const exist = this.rootElement ?? document.querySelector(TAG_NAME) as RootElement + if (exist) { + this.rootElement = exist + return exist.shadowRoot + } + this.rootElement = createRootElement() + document.body.appendChild(this.rootElement) + return this.rootElement.attachShadow({ mode: 'open' }) + } + if (document.body) return inner() + + return new Promise(resolve => { + window.addEventListener('load', () => resolve(inner())) + }) + } + + private async show(reason: LimitReason) { + if (!this.rootElement) { + await this.init() + } + await exitFullscreen() + // Scroll to top + scrollTo(0, 0) + pauseAllVideo() + pauseAllAudio() + + this.rootElement && (this.rootElement.style.visibility = 'visible') + this.setReason(reason) + this.screenLocker.lock() + this.iframe && (this.iframe.style.visibility = 'visible') + } + + private hide() { + this.rootElement && (this.rootElement.style.visibility = 'hidden') + this.screenLocker.unlock() + this.iframe && (this.iframe.style.visibility = 'hidden') + this.setReason(undefined) + } + + private setReason(reason: LimitReason | undefined) { + if (!this.iframe?.contentWindow) return + this.reason = reason + this.bridge.request('reason', extractReason(reason)).catch(() => { }) + } +} + +const extractReason = (reason: LimitReason | undefined): LimitReasonData | undefined => { + if (!reason) return undefined + const { getVisitTime: _, ...rest } = reason + return rest +} + +export default ModalInstance diff --git a/src/content-script/limit/modal/style/modal.css b/src/content-script/limit/modal/style/modal.css index e86ed0a36..bc3da790d 100644 --- a/src/content-script/limit/modal/style/modal.css +++ b/src/content-script/limit/modal/style/modal.css @@ -28,4 +28,4 @@ html[data-theme=dark] body { /* Fix message-box buttons */ .el-message-box__btns { gap: 12px; -} +} \ No newline at end of file diff --git a/src/content-script/limit/modal/types.ts b/src/content-script/limit/modal/types.ts new file mode 100644 index 000000000..05b2a85de --- /dev/null +++ b/src/content-script/limit/modal/types.ts @@ -0,0 +1,17 @@ +import type { LimitReason } from '../types' + +export type LimitReasonData = Omit + +type MakeRegistry = { + [K in Code]: { req: Req; res: Res } +} + +type BridgeRegistry = + & MakeRegistry<'reason', LimitReasonData | undefined, void> + & MakeRegistry<'visitTime', void, number> + & MakeRegistry<'delay', void, void> + +export type BridgeCode = keyof BridgeRegistry +export type BridgeRequest = BridgeRegistry[C]['req'] +export type BridgeResponse = BridgeRegistry[C]['res'] +export type BridgeHandler = (req: BridgeRequest) => Awaitable> \ No newline at end of file diff --git a/src/content-script/limit/processor/message-adaptor.ts b/src/content-script/limit/processor/message-adaptor.ts index 645c5a01d..7bb318ace 100644 --- a/src/content-script/limit/processor/message-adaptor.ts +++ b/src/content-script/limit/processor/message-adaptor.ts @@ -1,66 +1,44 @@ -import { trySendMsg2Runtime } from "@api/chrome/runtime" +import { trySendMsg2Runtime } from '@api/sw/common' import { hasDailyLimited, hasWeeklyLimited, matches } from "@util/limit" -import { type LimitReason, type ModalContext, type Processor } from "../common" +import type { LimitReason, ModalContext, Processor } from '../types' -const cvtItem2AddReason = (item: timer.limit.Item): LimitReason[] => { +const cvtItem2AddReason = (item: timer.limit.Item, delayDuration: number): LimitReason[] => { const { cond, allowDelay, id, delayCount, weeklyDelayCount } = item const reasons2Add: LimitReason[] = [] - hasDailyLimited(item) && reasons2Add.push({ type: "DAILY", cond, allowDelay, id, delayCount }) - hasWeeklyLimited(item) && reasons2Add.push({ type: 'WEEKLY', cond, allowDelay, id, delayCount: weeklyDelayCount }) + hasDailyLimited(item, delayDuration) && reasons2Add.push({ type: "DAILY", cond, allowDelay, id, delayCount }) + hasWeeklyLimited(item, delayDuration) && reasons2Add.push({ type: 'WEEKLY', cond, allowDelay, id, delayCount: weeklyDelayCount }) return reasons2Add } -const cvtItem2RemoveReason = (item: timer.limit.Item): LimitReason[] => { - const { cond, allowDelay, id, delayCount, weeklyDelayCount } = item - const reasons2Remove: LimitReason[] = [] - !hasDailyLimited(item) && reasons2Remove.push({ type: 'DAILY', cond, allowDelay, id, delayCount }) - !hasWeeklyLimited(item) && reasons2Remove.push({ type: 'WEEKLY', cond, allowDelay, id, delayCount: weeklyDelayCount }) - return reasons2Remove -} - class MessageAdaptor implements Processor { - private context: ModalContext + constructor(private readonly context: ModalContext, private readonly delayDuration: number) { } - constructor(context: ModalContext) { - this.context = context + onLimitChanged(items: timer.limit.Item[]): void { + this.context.modal.removeReasonsByType("DAILY", "WEEKLY") + items.flatMap(item => cvtItem2AddReason(item, this.delayDuration)) + .forEach(reason => this.context.modal.addReason(reason)) } - handleMsg(code: timer.mq.ReqCode, data: unknown): timer.mq.Response | Promise { - let items = data as timer.limit.Item[] - if (code === "limitTimeMeet") { - if (!items?.length) { - return { code: "fail" } - } - items.filter(item => matches(item?.cond, this.context.url)) - .flatMap(cvtItem2AddReason) - .forEach(reason => reason && this.context.modal.addReason(reason)) - return { code: "success" } - } else if (code === "limitChanged") { - this.context.modal.removeReasonsByType("DAILY", "WEEKLY") - items?.flatMap(cvtItem2AddReason) - ?.forEach(reason => reason && this.context.modal.addReason(reason)) - return { code: "success" } - } else if (code === "limitWaking") { - const reasons2Remove = items?.flatMap(cvtItem2RemoveReason) - reasons2Remove?.length && this.context.modal.removeReason(...reasons2Remove) - return { code: "success" } - } - return { code: "ignore" } + onLimitTimeMeet(items: timer.limit.Item[]): void { + if (!items.length) return + items.filter(({ cond }) => matches(cond, this.context.url)) + .flatMap(item => cvtItem2AddReason(item, this.delayDuration)) + .forEach(reason => this.context.modal.addReason(reason)) } async init(): Promise { - this.initRules?.() - this.context.modal?.addDelayHandler(() => this.initRules()) + this.initRules() + this.context.modal.addDelayHandler(() => this.initRules()) } async initRules(): Promise { - this.context.modal?.removeReasonsByType?.('DAILY', 'WEEKLY') - const limitedRules = await trySendMsg2Runtime('cs.getLimitedRules', this.context.url) + this.context.modal.removeReasonsByType('DAILY', 'WEEKLY') + const limitedRules = await trySendMsg2Runtime('limit.list', { limited: true, url: this.context.url }) + if (!limitedRules?.length) return - limitedRules - ?.flatMap?.(cvtItem2AddReason) - ?.forEach(reason => this.context.modal.addReason(reason)) + const reasons = limitedRules.flatMap(item => cvtItem2AddReason(item, this.delayDuration)) + this.context.modal.addReason(...reasons) } } -export default MessageAdaptor \ No newline at end of file +export default MessageAdaptor diff --git a/src/content-script/limit/processor/period-processor.ts b/src/content-script/limit/processor/period-processor.ts index bb64d5e3d..9a7475a06 100644 --- a/src/content-script/limit/processor/period-processor.ts +++ b/src/content-script/limit/processor/period-processor.ts @@ -1,16 +1,17 @@ -import { trySendMsg2Runtime } from "@api/chrome/runtime" +import { trySendMsg2Runtime } from '@api/sw/common' import { date2Idx } from "@util/limit" import { MILL_PER_SECOND } from "@util/time" -import { type LimitReason, type ModalContext, type Processor } from "../common" +import type { LimitReason, ModalContext, Processor } from '../types' -function processRule(rule: timer.limit.Rule, nowSeconds: number, context: ModalContext): NodeJS.Timeout[] { +function processRule(rule: timer.limit.Rule, nowSeconds: number, context: ModalContext): ReturnType[] { const { cond, periods, id } = rule - return periods?.flatMap?.(p => { + if (!periods?.length) return [] + return periods.flatMap(p => { const [s, e] = p const startSeconds = s * 60 const endSeconds = (e + 1) * 60 const reason: LimitReason = { id, cond, type: "PERIOD" } - const timers: NodeJS.Timeout[] = [] + const timers: ReturnType[] = [] if (nowSeconds < startSeconds) { timers.push(setTimeout(() => context.modal.addReason(reason), (startSeconds - nowSeconds) * MILL_PER_SECOND)) timers.push(setTimeout(() => context.modal.removeReason(reason), (endSeconds - nowSeconds) * MILL_PER_SECOND)) @@ -19,24 +20,17 @@ function processRule(rule: timer.limit.Rule, nowSeconds: number, context: ModalC timers.push(setTimeout(() => context.modal.removeReason(reason), (endSeconds - nowSeconds) * MILL_PER_SECOND)) } return timers - }) ?? [] + }) } class PeriodProcessor implements Processor { - private context: ModalContext - private timers: NodeJS.Timeout[] = [] + private timers: ReturnType[] = [] - constructor(context: ModalContext) { - this.context = context - } + constructor(private readonly context: ModalContext) { } - async handleMsg(code: timer.mq.ReqCode, data: timer.limit.Item[]): Promise { - if (code === "limitChanged") { - this.timers?.forEach(clearTimeout) - await this.init0(data) - return { code: "success" } - } - return { code: "ignore" } + async onLimitChanged(data: timer.limit.Item[]): Promise { + this.timers.forEach(clearTimeout) + await this.init0(data) } init(): Promise { @@ -44,11 +38,11 @@ class PeriodProcessor implements Processor { } private async init0(rules?: timer.limit.Item[]) { - rules = rules ?? await trySendMsg2Runtime("cs.getRelatedRules", this.context.url) + rules = rules ?? await trySendMsg2Runtime('limit.list', { effective: true, url: this.context.url }) ?? [] // Clear first this.context.modal.removeReasonsByType("PERIOD") const nowSeconds = date2Idx(new Date()) - this.timers = rules?.flatMap?.(r => processRule(r, nowSeconds, this.context)) || [] + this.timers = rules.flatMap(r => processRule(r, nowSeconds, this.context)) } } diff --git a/src/content-script/limit/processor/visit-processor.ts b/src/content-script/limit/processor/visit-processor.ts index c9cb3dfca..25c641b0b 100644 --- a/src/content-script/limit/processor/visit-processor.ts +++ b/src/content-script/limit/processor/visit-processor.ts @@ -1,41 +1,35 @@ -import { trySendMsg2Runtime } from "@api/chrome/runtime" +import { trySendMsg2Runtime } from '@api/sw/common' import NormalTracker from "@cs/tracker/normal" -import { DELAY_MILL } from "@util/limit" -import { MILL_PER_SECOND } from "@util/time" -import { type ModalContext, type Processor } from "../common" +import { MILL_PER_MINUTE, MILL_PER_SECOND } from "@util/time" +import type { ModalContext, Processor } from '../types' class VisitProcessor implements Processor { - - private context: ModalContext private focusTime: number = 0 private rules: timer.limit.Rule[] = [] - private tracker: NormalTracker | undefined + tracker: NormalTracker private delayCount: number = 0 - constructor(context: ModalContext) { - this.context = context + constructor(private readonly context: ModalContext, private readonly delayDuration: number) { + this.tracker = new NormalTracker({ + onReport: data => this.handleTracker(data), + }) } - async handleMsg(code: timer.mq.ReqCode): Promise { - if (code === "limitChanged") { - this.initRules() - return { code: "success" } - } else if (code === "askVisitTime") { - return { code: "success", data: this.focusTime } - } - return { code: "ignore" } + onLimitChanged(_: timer.limit.Item[]): void { + this.initRules() } - hasLimited(rule: timer.limit.Rule): boolean { - const { visitTime } = rule || {} + private hasLimited(rule: timer.limit.Rule): boolean { + const { visitTime } = rule if (!visitTime) return false - return visitTime * MILL_PER_SECOND + this.delayCount * DELAY_MILL < this.focusTime + const afterDelayed = visitTime * MILL_PER_SECOND + this.delayCount * this.delayDuration * MILL_PER_MINUTE + return afterDelayed < this.focusTime } - async handleTracker(data: timer.core.Event) { - const diff = (data?.end ?? 0) - (data?.start ?? 0) + private async handleTracker({ start, end }: timer.core.Event) { + const diff = end - start this.focusTime += diff - this.rules?.forEach?.(rule => { + this.rules.forEach(rule => { if (!this.hasLimited(rule)) return const { id, cond, allowDelay } = rule this.context.modal.addReason({ @@ -49,22 +43,19 @@ class VisitProcessor implements Processor { }) } - async initRules() { - this.rules = await trySendMsg2Runtime("cs.getRelatedRules", this.context.url) ?? [] + private async initRules() { + this.rules = await trySendMsg2Runtime("limit.list", { effective: true, url: this.context.url }) ?? [] this.context.modal.removeReasonsByType("VISIT") } async init(): Promise { - this.tracker = new NormalTracker({ - onReport: data => this.handleTracker(data), - }) this.tracker.init() this.initRules() - this.context.modal.addDelayHandler(() => this.processMore5Minutes()) + this.context.modal.addDelayHandler(() => this.processDelay()) } - private processMore5Minutes() { - this.delayCount = (this.delayCount ?? 0) + 1 + private processDelay() { + this.delayCount++ this.context.modal.removeReasonsByType("VISIT") } } diff --git a/src/content-script/limit/reminder/component.ts b/src/content-script/limit/reminder/component.ts index 8cbe47c9d..15244c09f 100644 --- a/src/content-script/limit/reminder/component.ts +++ b/src/content-script/limit/reminder/component.ts @@ -1,5 +1,5 @@ -import { getIconUrl } from "@api/chrome/runtime" import { t } from "@cs/locale" +import { getIconUrl } from "@api/chrome/runtime" const containerStyle = (dark: boolean): Partial => ({ position: 'fixed', diff --git a/src/content-script/limit/reminder/index.ts b/src/content-script/limit/reminder/index.ts index 44f1663ca..706bafe06 100644 --- a/src/content-script/limit/reminder/index.ts +++ b/src/content-script/limit/reminder/index.ts @@ -1,22 +1,15 @@ -import optionService from "@service/option-service" +import { getOption } from '@api/sw/option' +import { processDarkMode } from '@pages/util/dark-mode' import { MILL_PER_MINUTE } from "@util/time" -import { exitFullscreen, type Processor } from "../common" +import { exitFullscreen } from "../common" import { createComponent } from "./component" -class Reminder implements Processor { +class Reminder { private id = 0 private el: HTMLElement | undefined private darkMode: boolean = false - handleMsg(code: timer.mq.ReqCode, data: unknown): timer.mq.Response | Promise { - if (code !== 'limitReminder') { - return { code: 'ignore' } - } - this.show(data as timer.limit.ReminderInfo) - return { code: 'success' } - } - - private async show(data: timer.limit.ReminderInfo) { + public async show(data: timer.limit.ReminderInfo) { if (!document?.body || this.el) return await exitFullscreen() @@ -38,7 +31,8 @@ class Reminder implements Processor { } async init(): Promise { - this.darkMode = await optionService.isDarkMode() + const option = await getOption() + this.darkMode = processDarkMode(option) } } diff --git a/src/content-script/limit/types.ts b/src/content-script/limit/types.ts new file mode 100644 index 000000000..707b2ce61 --- /dev/null +++ b/src/content-script/limit/types.ts @@ -0,0 +1,27 @@ + +export type LimitReason = + & RequiredPick + & PartialPick + & { + type: timer.limit.ReasonType + getVisitTime?: () => number + } + +export interface MaskModal { + readonly reasons: LimitReason[] + + addReason(...reasons: LimitReason[]): void + removeReason(...reasons: LimitReason[]): void + removeReasonsByType(...types: timer.limit.ReasonType[]): void + addDelayHandler(handler: NoArgCallback): void +} + +export type ModalContext = { + url: string + modal: MaskModal +} + +export interface Processor { + init(): Awaitable + onLimitChanged(items: timer.limit.Item[]): void +} \ No newline at end of file diff --git a/src/content-script/printer.ts b/src/content-script/printer.ts index 7cdd9d76f..53b023d07 100644 --- a/src/content-script/printer.ts +++ b/src/content-script/printer.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { trySendMsg2Runtime } from "@api/chrome/runtime" +import { trySendMsg2Runtime } from '@api/sw/common' import { formatPeriodCommon } from "@util/time" import { t } from "./locale" @@ -13,9 +13,9 @@ import { t } from "./locale" * Print info of today */ export default async function printInfo(host: string) { - const waste = await trySendMsg2Runtime('cs.getTodayInfo', host) - if (!waste) return - const { time, focus } = waste + const data = await trySendMsg2Runtime('stat.today', host) + if (!data) return + const { time, focus } = data const param = { time: `${time ?? '-'}`, diff --git a/src/content-script/skeleton.ts b/src/content-script/skeleton.ts deleted file mode 100644 index e1ff2da4a..000000000 --- a/src/content-script/skeleton.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { trySendMsg2Runtime } from "@api/chrome/runtime" - -function awaitDocumentReady() { - if (document.readyState === 'complete') { - return Promise.resolve() - } else { - return new Promise(resolve => { - document.addEventListener('DOMContentLoaded', resolve, { once: true }) - }) - } -} - -const main = async () => { - await awaitDocumentReady() - trySendMsg2Runtime('cs.onInjected') -} - -main() diff --git a/src/content-script/timeline.ts b/src/content-script/timeline.ts index 0dd4ea97c..136117391 100644 --- a/src/content-script/timeline.ts +++ b/src/content-script/timeline.ts @@ -1,4 +1,4 @@ -import { trySendMsg2Runtime } from '@api/chrome/runtime' +import { trySendMsg2Runtime } from '@api/sw/common' class TimelineCollector { private startTime: number | null = null @@ -42,18 +42,14 @@ class TimelineCollector { private collect(): void { if (!this.startTime) return const url = document?.location?.href + if (!url) return - url && trySendMsg2Runtime('cs.timelineEv', { - start: this.startTime, - end: Date.now(), - url, - } satisfies timer.timeline.Event) + trySendMsg2Runtime('timeline.tick', { start: this.startTime, end: Date.now(), url }) this.startTime = null } } - export default function processTimeline() { const collector = new TimelineCollector() collector.init() diff --git a/src/content-script/tracker/normal/idle-detector.ts b/src/content-script/tracker/normal/idle-detector.ts index be7d65b00..127804c9a 100644 --- a/src/content-script/tracker/normal/idle-detector.ts +++ b/src/content-script/tracker/normal/idle-detector.ts @@ -1,5 +1,5 @@ -import { onRuntimeMessage, trySendMsg2Runtime } from '@api/chrome/runtime' -import optionHolder from "@service/components/option-holder" +import { trySendMsg2Runtime } from '@api/sw/common' +import { getOption } from '@api/sw/option' export default class IdleDetector { fullScreen: boolean = false @@ -12,15 +12,11 @@ export default class IdleDetector { lastActiveTime: number = Date.now() userActive: boolean = true - pauseTimeout: NodeJS.Timeout | undefined + pauseTimeout: ReturnType | undefined - onIdle: () => void - onActive: () => void - - constructor({ onIdle, onActive }: { onIdle: () => void, onActive: () => void }) { + constructor(private readonly onIdle: NoArgCallback, private readonly onActive: NoArgCallback) { this.onIdle = onIdle this.onActive = onActive - this.init() } needTimeout(): boolean { @@ -32,16 +28,12 @@ export default class IdleDetector { return this.lastActiveTime + this.autoPauseInterval <= Date.now() } - private async init() { - const option = await optionHolder.get() - - this.processOption(option) - this.resetTimeout() - - optionHolder.addChangeListener(opt => { - this.processOption(opt) - this.resetTimeout() - }) + async init() { + this.reset() + document.addEventListener('visibilitychange', () => document.visibilityState === 'visible' && this.reset()) + const pollInterval = setInterval(() => this.reset(), 60_000) + const stopPoll = () => clearInterval(pollInterval) + window.addEventListener('beforeunload', stopPoll) const handleActive = () => { this.lastActiveTime = Date.now() @@ -49,15 +41,14 @@ export default class IdleDetector { if (!this.needTimeout()) return if (!this.pauseTimeout) { - // Paused, so activate - this.onActive?.() + this.onActive() this.resetTimeout() } } window.addEventListener('mousedown', handleActive) window.addEventListener('mousemove', handleActive) - window.addEventListener('keypress', handleActive) + window.addEventListener('keydown', handleActive) window.addEventListener('scroll', handleActive) window.addEventListener('wheel', handleActive) document?.addEventListener('fullscreenchange', () => { @@ -66,21 +57,18 @@ export default class IdleDetector { }) trySendMsg2Runtime('cs.getAudible').then(val => this.audible = !!val) - onRuntimeMessage(async req => { - const { code, data } = req - if (code !== 'syncAudible' || typeof data !== 'boolean') return { code: 'ignore' } - this.audible = !!data - return { code: 'success' } - }) } - private processOption(option: timer.option.TrackingOption) { - this.autoPauseTracking = !!option?.autoPauseTracking - this.autoPauseInterval = option?.autoPauseInterval * 1000 + private reset() { + getOption().then(({ autoPauseTracking, autoPauseInterval }) => { + this.autoPauseTracking = autoPauseTracking + this.autoPauseInterval = autoPauseInterval * 1000 + + this.resetTimeout() + }).catch(() => { }) } private resetTimeout() { - if (!!this.pauseTimeout) { clearTimeout(this.pauseTimeout) this.pauseTimeout = undefined @@ -104,8 +92,7 @@ export default class IdleDetector { if (!this.needTimeout()) return if (this.isIdle()) { - // Idle interval meets - this.onIdle?.() + this.onIdle() } else { this.resetTimeout() } diff --git a/src/content-script/tracker/normal/index.ts b/src/content-script/tracker/normal/index.ts index 9adb167ab..d70640d1f 100644 --- a/src/content-script/tracker/normal/index.ts +++ b/src/content-script/tracker/normal/index.ts @@ -1,3 +1,4 @@ +import type { AudibleChangeHandler } from '@cs/types' import IdleDetector from "./idle-detector" const INTERVAL = 1000 @@ -7,20 +8,18 @@ type StateChangeReason = 'visible' | 'idle' | 'initial' class TrackContext { docVisible: boolean = false idleDetector: IdleDetector - onPause: (reason: StateChangeReason) => void - onResume: (reason: StateChangeReason) => void - - constructor({ onPause, onResume }: { onPause: (reason: StateChangeReason) => void, onResume: (reason: StateChangeReason) => void }) { - this.onPause = onPause - this.onResume = onResume + constructor( + private readonly onPause: ArgCallback, + private readonly onResume: ArgCallback, + ) { this.detectDocVisible() document?.addEventListener('visibilitychange', () => this.detectDocVisible()) - this.idleDetector = new IdleDetector({ - onIdle: () => this.onPause?.('idle'), - onActive: () => this.docVisible && this.onResume?.('idle') - }) + this.idleDetector = new IdleDetector( + () => this.onPause('idle'), + () => this.docVisible && this.onResume('idle'), + ) } private detectDocVisible() { @@ -38,7 +37,7 @@ class TrackContext { } } -export type NormalTrackerOption = { +type NormalTrackerOption = { onReport: (ev: timer.core.Event) => Promise onResume?: (reason: StateChangeReason) => void onPause?: (reason: StateChangeReason) => void @@ -47,25 +46,24 @@ export type NormalTrackerOption = { /** * Normal tracker */ -export default class NormalTracker { - context: TrackContext | undefined +export default class NormalTracker implements AudibleChangeHandler { + context: TrackContext start: number = Date.now() - option: NormalTrackerOption - constructor(option: NormalTrackerOption) { - this.option = option + constructor(private readonly option: NormalTrackerOption) { + this.context = new TrackContext( + reason => this.pause(reason), + reason => this.resume(reason), + ) } init() { // Resume if idle before reloading this.resume('idle') + this.context.idleDetector.init() - this.context = new TrackContext({ - onPause: reason => this.pause(reason), - onResume: reason => this.resume(reason), - }) setInterval(() => { - if (!this.context?.isActive()) return + if (!this.context.isActive()) return this.collect() }, INTERVAL) @@ -84,7 +82,6 @@ export default class NormalTracker { const data: timer.core.Event = { start: lastTime, end: now, - url: location?.href, ignoreTabCheck: !!ignoreTabCheck } try { @@ -103,4 +100,8 @@ export default class NormalTracker { this.start = Date.now() } + + onAudibleChange(audible: boolean) { + this.context.idleDetector.audible = audible + } } diff --git a/src/content-script/tracker/run-time.ts b/src/content-script/tracker/run-time.ts index 65fefecd7..854f0ca1c 100644 --- a/src/content-script/tracker/run-time.ts +++ b/src/content-script/tracker/run-time.ts @@ -1,33 +1,26 @@ -import { onRuntimeMessage, trySendMsg2Runtime } from "@api/chrome/runtime" +import { trySendMsg2Runtime } from '@api/sw/common' +import { extractHostname } from '@util/pattern' +import Dispatcher from '../dispatcher' class RunTimeTracker { private start: number = Date.now() - private url: string // Real host, including builtin hosts private host: string | undefined - constructor(url: string) { - this.url = url - this.start = Date.now() + constructor(private readonly url: string) { } - init(): void { + init(dispatcher: Dispatcher): void { this.fetchSite() - - onRuntimeMessage(async req => { - if (req.code === 'siteRunChange') { - this.fetchSite() - return { code: 'success' } - } - return { code: 'ignore' } - }) - + dispatcher.register('siteRunChange', () => void this.fetchSite()) setInterval(() => this.collect(), 1000) } private async fetchSite() { - const site: timer.site.SiteKey | undefined = await trySendMsg2Runtime('cs.getRunSites', this.url) - this.host = site?.host + const { host } = extractHostname(this.url) + if (!host) return + const enabled = await trySendMsg2Runtime('site.runEnabled', host) + this.host = enabled ? host : undefined } private async collect() { @@ -39,11 +32,10 @@ class RunTimeTracker { const event: timer.core.Event = { start: lastTime, end: now, - url: this.url, ignoreTabCheck: false, host: this.host, } - await trySendMsg2Runtime('cs.trackRunTime', event) + await trySendMsg2Runtime('track.runTime', event) } this.start = now } catch { diff --git a/src/content-script/types.ts b/src/content-script/types.ts new file mode 100644 index 000000000..f7423260c --- /dev/null +++ b/src/content-script/types.ts @@ -0,0 +1,3 @@ +export interface AudibleChangeHandler { + onAudibleChange(audible: boolean): void +} \ No newline at end of file diff --git a/src/database/site-database.ts b/src/database/site-database.ts deleted file mode 100644 index 45555da81..000000000 --- a/src/database/site-database.ts +++ /dev/null @@ -1,224 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { CATE_NOT_SET_ID } from "@util/site" -import BaseDatabase from "./common/base-database" -import { REMAIN_WORD_PREFIX } from "./common/constant" - -export type SiteCondition = { - /** - * Fuzzy query of host or alias - */ - fuzzyQuery?: string - /** - * @since 3.0.0 - */ - cateIds?: number | number[] - types?: timer.site.Type | timer.site.Type[] -} - -type _Entry = { - /** - * Alias - */ - a?: string - /** - * Icon url - */ - i?: string - /** - * Category ID - */ - c?: number - /** - * Count run time - */ - r?: boolean -} - -const DB_KEY_PREFIX = REMAIN_WORD_PREFIX + 'SITE_' -const HOST_KEY_PREFIX = DB_KEY_PREFIX + 'h' -const VIRTUAL_KEY_PREFIX = DB_KEY_PREFIX + 'v' -const MERGED_FLAG = 'm' - -function cvt2Key({ host, type }: timer.site.SiteKey): string { - if (type === 'virtual') { - return VIRTUAL_KEY_PREFIX + host - } else if (type === 'merged') { - return HOST_KEY_PREFIX + MERGED_FLAG + host - } else { - return HOST_KEY_PREFIX + '_' + host - } -} - -function cvt2SiteKey(key: string): timer.site.SiteKey { - if (key.startsWith(VIRTUAL_KEY_PREFIX)) { - return { - host: key.substring(VIRTUAL_KEY_PREFIX.length), - type: 'virtual', - } - } else if (key.startsWith(HOST_KEY_PREFIX)) { - return { - host: key.substring(HOST_KEY_PREFIX.length + 1), - type: key.charAt(HOST_KEY_PREFIX.length) === MERGED_FLAG ? 'merged' : 'normal', - } - } else { - // Can't go there - return { host: key, type: 'normal' } - } -} - -function cvt2Entry({ alias, iconUrl, cate, run }: timer.site.SiteInfo): _Entry { - const entry: _Entry = { i: iconUrl } - alias && (entry.a = alias) - cate && (entry.c = cate) - run && (entry.r = true) - entry.i = iconUrl - return entry -} - -function cvt2SiteInfo(key: timer.site.SiteKey, entry: _Entry | undefined): timer.site.SiteInfo { - const { a, i, c, r } = entry || {} - const siteInfo: timer.site.SiteInfo = { ...key } - siteInfo.alias = a - siteInfo.cate = c ?? CATE_NOT_SET_ID - siteInfo.iconUrl = i - siteInfo.run = !!r - return siteInfo -} - -//////////////////////////////////////////////////////////////////////////// -///////////////////////// ///////////////////////// -///////////////////////// PUBLIC METHODS START ///////////////////////// -///////////////////////// ///////////////////////// -//////////////////////////////////////////////////////////////////////////// - -/** - * Select by condition - * - * @returns list not be undefined, maybe empty - */ -async function select(this: SiteDatabase, condition?: SiteCondition): Promise { - const filter = buildFilter(condition) - const data = await this.storage.get() - return Object.entries(data) - .filter(([key]) => key.startsWith(DB_KEY_PREFIX)) - .map(([key, value]) => cvt2SiteInfo(cvt2SiteKey(key), value as _Entry)) - .filter(filter) -} - -function buildFilter(condition?: SiteCondition): (site: timer.site.SiteInfo) => boolean { - const { fuzzyQuery, cateIds, types } = condition || {} - let cateFilter = typeof cateIds === 'number' ? [cateIds] : (cateIds?.length ? cateIds : undefined) - let typeFilter = typeof types === 'string' ? [types] : (types?.length ? types : undefined) - return site => { - const { host: siteHost, alias: siteAlias, cate, type } = site || {} - if (fuzzyQuery && !(siteHost?.includes(fuzzyQuery) || siteAlias?.includes(fuzzyQuery))) return false - if (cateFilter && (!cateFilter.includes(cate ?? CATE_NOT_SET_ID) || type !== 'normal')) return false - if (typeFilter && !matchType(typeFilter, site)) return false - return true - } -} - -function matchType(types: timer.site.Type[], site: timer.site.SiteInfo): boolean { - const { type } = site || {} - if (type === 'virtual') { - return types.includes('virtual') - } else if (type === 'merged') { - return types.includes('merged') - } else { - return types.includes('normal') - } -} - -/** - * Get by key - * - * @returns site info, or undefined - */ -async function get(this: SiteDatabase, key: timer.site.SiteKey): Promise { - const entry = await this.storage.getOne<_Entry>(cvt2Key(key)) - return entry ? cvt2SiteInfo(key, entry) : null -} - -async function getBatch(this: SiteDatabase, keys: timer.site.SiteKey[]): Promise { - const result = await this.storage.get(keys.map(cvt2Key)) - return Object.entries(result) - .map(([key, value]) => cvt2SiteInfo(cvt2SiteKey(key), value as _Entry)) -} - -/** - * Save site info - */ -async function save(this: SiteDatabase, ...sites: timer.site.SiteInfo[]): Promise { - if (!sites?.length) return - const toSet: Record = {} - sites?.forEach(s => toSet[cvt2Key(s)] = cvt2Entry(s)) - await this.storage.set(toSet) -} - -async function remove(this: SiteDatabase, ...siteKeys: timer.site.SiteKey[]): Promise { - const keys = siteKeys?.map(s => cvt2Key(s)) - if (!keys?.length) return - await this.storage.remove(keys) -} - -async function exist(this: SiteDatabase, siteKey: timer.site.SiteKey): Promise { - const key = cvt2Key(siteKey) - const entry = await this.storage.getOne<_Entry>(key) - return !!entry -} - -async function existBatch(this: SiteDatabase, siteKeys: timer.site.SiteKey[]): Promise { - const keys = siteKeys.map(cvt2Key) - const items = await this.storage.get(keys) - return Object.entries(items).map(([key]) => cvt2SiteKey(key)) -} - - -//////////////////////////////////////////////////////////////////////////// -///////////////////////// ///////////////////////// -///////////////////////// PUBLIC METHODS END ///////////////////////// -///////////////////////// ///////////////////////// -//////////////////////////////////////////////////////////////////////////// - -class SiteDatabase extends BaseDatabase { - select = select - get = get - getBatch = getBatch - save = save - remove = remove - exist = exist - existBatch = existBatch - - /** - * Add listener to listen changes - * - * @since 1.6.0 - */ - addChangeListener(listener: (oldAndNew: [timer.site.SiteInfo?, timer.site.SiteInfo?][]) => void) { - const storageListener = ( - changes: { [key: string]: chrome.storage.StorageChange }, - _areaName: chrome.storage.AreaName, - ) => { - const changedSites: [timer.site.SiteInfo?, timer.site.SiteInfo?][] = Object.entries(changes) - .filter(([k]) => k.startsWith(DB_KEY_PREFIX)) - .map(([k, { oldValue, newValue }]) => { - const siteKey = cvt2SiteKey(k) - const oldVal = oldValue ? cvt2SiteInfo(siteKey, oldValue as _Entry) : undefined - const newVal = newValue ? cvt2SiteInfo(siteKey, newValue as _Entry) : undefined - return [oldVal, newVal] - }) - changedSites.length && listener?.(changedSites) - } - chrome.storage.onChanged.addListener(storageListener) - } -} - -const siteDatabase = new SiteDatabase() - -export default siteDatabase \ No newline at end of file diff --git a/src/i18n/chrome/message.ts b/src/i18n/chrome/message.ts index e891d2df4..5c2904f74 100644 --- a/src/i18n/chrome/message.ts +++ b/src/i18n/chrome/message.ts @@ -36,12 +36,13 @@ const placeholder: ChromeMessage = { marketName: '', }, base: { - sidebar: '', allFunction: '', guidePage: '', changeLog: '', option: '', sourceCode: '', + limit: '', + helpUs: '', }, contextMenus: { add2Whitelist: '', diff --git a/src/i18n/chrome/t.ts b/src/i18n/chrome/t.ts index 01cd2963c..8790c872c 100644 --- a/src/i18n/chrome/t.ts +++ b/src/i18n/chrome/t.ts @@ -9,7 +9,7 @@ import { getMessage } from "@api/chrome/i18n" import { t } from ".." import messages, { router, type ChromeMessage } from "./message" -export const keyPathOf = (key: (root: ChromeMessage) => string) => key(router) +const keyPathOf = (key: (root: ChromeMessage) => string) => key(router) export const t2Chrome = (key: (root: ChromeMessage) => string) => { if (getMessage) { diff --git a/src/i18n/element.ts b/src/i18n/element.ts index 9436586bf..a67f485be 100644 --- a/src/i18n/element.ts +++ b/src/i18n/element.ts @@ -1,10 +1,8 @@ -import ElementPlus from 'element-plus' import { type Language } from "element-plus/es/locale" -import { type App } from "vue" import { locale, t } from "." import calendarMessages from "./message/common/calendar" -const LOCALES: { [locale in timer.Locale]: () => Promise<{ default: Language }> } = { +const LOCALES: Record Promise<{ default: Language }>> = { zh_CN: () => import('element-plus/es/locale/lang/zh-cn'), zh_TW: () => import('element-plus/es/locale/lang/zh-tw'), en: () => import('element-plus/es/locale/lang/en'), @@ -21,10 +19,8 @@ const LOCALES: { [locale in timer.Locale]: () => Promise<{ default: Language }> it: () => import('element-plus/es/locale/lang/it'), } -export const initElementLocale = async (app: App) => { - const module = await LOCALES[locale]?.() - const EL_LOCALE = module?.default - app.use(ElementPlus, { locale: EL_LOCALE }) +export async function initElementLocale(): Promise { + return (await LOCALES[locale]()).default } export const dateFormat = () => t(calendarMessages, { key: msg => msg.dateFormat, param: { y: 'YYYY', m: 'MM', d: 'DD' } }) diff --git a/src/i18n/index.ts b/src/i18n/index.ts index f83f67a5d..9999e1435 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -5,24 +5,23 @@ * https://opensource.org/licenses/MIT */ -import { getUILanguage } from "@api/chrome/i18n" -import optionHolder from "@service/components/option-holder" -import { setDir, setLocale } from "@util/document" +import { getUILanguage } from "../api/chrome/i18n" +import { getOption } from '../api/sw/option' +import { setDir, setLocale } from "../util/document" import { ALL_LOCALES as _ALL_LOCALES } from "./message/merge" /** * Not to import this one if not necessary */ export type FakedLocale = timer.Locale + /** * @since 0.2.2 */ -export const FEEDBACK_LOCALE: timer.Locale = "en" +const FEEDBACK_LOCALE: timer.Locale = "en" export const ALL_LOCALES: timer.Locale[] = _ALL_LOCALES -export const defaultLocale: timer.Locale = "zh_CN" - // Standardize the locale code according to the Chrome locale code const chrome2I18n: { [key: string]: timer.Locale } = { 'zh': 'zh_CN', @@ -65,22 +64,22 @@ const translationChrome2I18n: { [key: string]: timer.TranslatingLocale } = { * Codes returned by getUILanguage() are defined by Chrome browser * @see https://github.com/unicode-cldr/cldr-localenames-modern/blob/master/main/en/languages.json * But supported locale codes in Chrome extension - * @see https://developer.chrome.com/docs/extensions/reference/api/i18n#locales + * @see https://developer.chrome.com/docs/extensions/reference/i18n#locales * * They are different, so translate */ -export function chromeLocale2ExtensionLocale(chromeLocale: string): timer.Locale { - if (!chromeLocale) { - return defaultLocale - } +function chromeLocale2ExtensionLocale(chromeLocale: string): timer.Locale { + if (!chromeLocale) return FEEDBACK_LOCALE const code2 = chromeLocale.substring(0, 2) return chrome2I18n[chromeLocale] ?? chrome2I18n[code2] ?? FEEDBACK_LOCALE } +const browserUiLocale: timer.Locale = chromeLocale2ExtensionLocale(getUILanguage()) + /** * @since 0.9.0 */ -export let localeSameAsBrowser: timer.Locale = chromeLocale2ExtensionLocale(getUILanguage()) +export const localeSameAsBrowser: timer.Locale = browserUiLocale /** * @since 1.5.0 @@ -96,7 +95,7 @@ export function isTranslatingLocale(): boolean { /** * Real locale with locale option */ -export let locale: timer.Locale = localeSameAsBrowser +export let locale: timer.Locale = browserUiLocale export function cvtOption2Locale(option: timer.option.LocaleOption): timer.Locale { if (!option || option === 'default') { @@ -117,8 +116,8 @@ export function handleLocaleOption(option: timer.option.LocaleOption) { * @since 0.8.0 */ export async function initLocale() { - const option = await optionHolder.get() - handleLocaleOption(option?.locale) + const option = await getOption() + handleLocaleOption(option.locale) } function tryGetOriginalI18nVal( @@ -133,7 +132,7 @@ function tryGetOriginalI18nVal( } } -export function getI18nVal( +function getI18nVal( messages: Messages, keyPath: I18nKey, specLocale?: timer.Locale @@ -144,7 +143,7 @@ export function getI18nVal( return typeof result === 'string' ? result : JSON.stringify(result) } -export type TranslateProps = { +type TranslateProps = { key: I18nKey, param?: { [key: string]: string | number } } @@ -184,7 +183,7 @@ const findParamAndReplace = (resultArr: I18nResultItem[], [key, val return temp } -export type NodeTranslateProps = { +type NodeTranslateProps = { key: I18nKey, param: { [key: string]: I18nResultItem } } diff --git a/src/i18n/message/app/index.ts b/src/i18n/message/app/index.ts index 77cb8a8a6..d5e0ff383 100644 --- a/src/i18n/message/app/index.ts +++ b/src/i18n/message/app/index.ts @@ -5,13 +5,13 @@ * https://opensource.org/licenses/MIT */ -import buttonMessages, { type ButtonMessage } from "@i18n/message/common/button" -import calendarMessages, { type CalendarMessage } from "@i18n/message/common/calendar" -import itemMessages, { type ItemMessage } from "@i18n/message/common/item" -import metaMessages, { type MetaMessage } from "@i18n/message/common/meta" -import sharedMessages, { type SharedMessage } from "@i18n/message/common/shared" -import baseMessages, { type BaseMessage } from "../common/base" +import baseMessages, { type BaseMessage } from "../base" +import buttonMessages, { type ButtonMessage } from "../button" +import calendarMessages, { type CalendarMessage } from "../common/calendar" +import metaMessages, { type MetaMessage } from "../common/meta" +import sharedMessages, { type SharedMessage } from '../common/shared' import limitModalMessages, { type ModalMessage } from "../cs/modal" +import itemMessages, { type ItemMessage } from "../item" import { merge, type MessageRoot } from "../merge" import aboutMessages, { type AboutMessage } from "./about" import analysisMessages, { type AnalysisMessage } from "./analysis" diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index b4c267cd7..3c6564eb8 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -1,24 +1,18 @@ { "zh_CN": { - "filterDisabled": "过滤无效规则", "wildcardTip": "您可以使用通配符来匹配子域名或子页面,使用\"+\"作为前缀来排除子页面!", "emptyTips": "点击这里创建一条规则!", "item": { "name": "规则名称", "condition": "限制网址", - "daily": "每日上限", - "weekly": "每周上限", "weekStartInfo": "每周的第一天是【{weekStart}】,你可以在统计选项中修改该值", "delayCount": "延时次数", "detail": "规则详情", "visitTime": "单次访问最长时间", - "period": "不可访问的时间段", "enabled": "启用", "locked": "锁定", "effectiveDay": "生效日期", - "delayAllowed": "可延时", - "delayAllowedInfo": "上网时间超过限制时,点击【再看 5 分钟】短暂延时。如果关闭该功能则不能延时。", - "visits": "次访问", + "allowDelay": "可延时", "or": "或者", "notEffective": "未生效" }, @@ -53,25 +47,19 @@ "reminder": "距离时间限制不到 {min} 分钟!" }, "zh_TW": { - "filterDisabled": "過濾無效規則", "wildcardTip": "你可以使用萬用字元來匹配子網域或子頁面,使用\"+\"作為前置詞來排除子頁面!", "emptyTips": "點擊此處新增規則!", "item": { "name": "規則名稱", "condition": "限制網址", - "daily": "每日上限", - "weekly": "每週上限", "weekStartInfo": "每週起始日為「{weekStart}」,您可於統計設定中調整", "delayCount": "延遲次數", "detail": "規則詳細內容", "visitTime": "單次造訪時長限制", - "period": "限制時段", "enabled": "是否啟用", "locked": "已鎖定", "effectiveDay": "生效日期", - "delayAllowed": "允許延遲", - "delayAllowedInfo": "當使用時間超過限制時,可點擊【再看5分鐘】暫時延長。若關閉此功能則無法延時。", - "visits": "次造訪", + "allowDelay": "允許延遲", "or": "或", "notEffective": "未生效" }, @@ -106,25 +94,20 @@ "reminder": "距離時間限制僅剩 {min} 分鐘!" }, "en": { - "filterDisabled": "Only enabled", + "onlyEffective": "Only Effective", "wildcardTip": "You can use wildcards to match subdomains or subpages, and use \"+\" as a prefix to exclude subpages!", "emptyTips": "Click here to create one rule!", "item": { "name": "Rule name", "condition": "Restricted URL", - "daily": "Daily limit", - "weekly": "Weekly limit", "weekStartInfo": "The first day of each week is {weekStart}, you can change this value in the tracking options", "delayCount": "Delay count", "detail": "Rule detail", "visitTime": "Time limit per visit", - "period": "Blocked periods", "enabled": "Enabled", "locked": "Locked", "effectiveDay": "Effective On", - "delayAllowed": "Delayable", - "delayAllowedInfo": "If it times out, allow a temporary delay of 5 minutes", - "visits": "visits", + "allowDelay": "Allow Delay", "or": "or", "notEffective": "Not effective" }, @@ -159,23 +142,16 @@ "reminder": "Less than {min} minutes until the time limit!" }, "ja": { - "filterDisabled": "有效", "wildcardTip": "ワイルドカードを使用してサブドメインまたはサブページに一致させることができ、「+」をプレフィックスとしてサブページを除外することができます!", "item": { "name": "規則名", "condition": "制限 URL", - "daily": "1日の限度", - "weekly": "週間の限度", "weekStartInfo": "各週の最初の日は「{weekStart}」, 統計オプションでこの値を変更することができます", "delayCount": "遅延回数", "detail": "規則明細", "visitTime": "訪問ごとの制限", - "period": "許可されない期間", "enabled": "有效", "effectiveDay": "発効日", - "delayAllowed": "さらに5分間閲覧する", - "delayAllowedInfo": "時間が経過した場合は、一時的に5分遅らせることができます", - "visits": "訪問数", "or": "や", "notEffective": "効果がない" }, @@ -208,25 +184,19 @@ "reminder": "制限時間まで {min} 分未満!" }, "pt_PT": { - "filterDisabled": "Apenas ativos", "wildcardTip": "Pode usar wildcards para corresponder a subdomínios ou subpáginas, e usar o \"+\" como prefixo para excluir subpáginas!", "emptyTips": "Clica aqui para criar uma regra!", "item": { "name": "Nome da regra", "condition": "URL restrito", - "daily": "Limite diário", - "weekly": "Limite semanal", "weekStartInfo": "O primeiro dia da semana é {weekStart}. Pode alterar nas opções de estatísticas", "delayCount": "Atrasos permitidos", "detail": "Detalhe da regra", "visitTime": "Tempo limite por visita", - "period": "Períodos bloqueados", "enabled": "Ativo", "locked": "Bloqueado", "effectiveDay": "Dias de aplicação", - "delayAllowed": "Permitir atraso", - "delayAllowedInfo": "Se expirar, permite um atraso temporário de 5 minutos", - "visits": "visitas", + "allowDelay": "Permitir atraso", "or": "ou", "notEffective": "Não aplicável" }, @@ -261,24 +231,17 @@ "reminder": "Menos de {min} minutos até ao limite!" }, "uk": { - "filterDisabled": "Лише увімкнені", "wildcardTip": "Можна використовувати символи підставлення для пошуку піддоменів або підсторінок, а \"+\" як префікс для виключення підсторінки!", "item": { "name": "Назва правила", "condition": "Обмежена URL-адреса", - "daily": "Денний ліміт", - "weekly": "Тижневий ліміт", "weekStartInfo": "Перший день кожного тижня {weekStart}. Ви можете змінити це значення у налаштуваннях статистики", "delayCount": "Лічильник затримок", "detail": "Подробиці правила", "visitTime": "Ліміт на відвідування", - "period": "Недозволені періоди", "enabled": "Увімкнено", "locked": "Заблоковано", "effectiveDay": "Діє", - "delayAllowed": "Ще 5 хвилин", - "delayAllowedInfo": "Якщо час вичерпано, дозволити тимчасову затримку 5 хвилин", - "visits": "відвідування", "or": "або", "notEffective": "Не застосовується" }, @@ -313,24 +276,17 @@ "reminder": "Менш як {min} хв до ліміту часу!" }, "es": { - "filterDisabled": "Solo habilitados", "wildcardTip": "¡Puedes usar comodines para coincidir con subdominios o subpáginas, y usar el \"+\" como prefijo para excluir subpáginas!", "item": { "name": "Nombre de la regla", "condition": "URL restringida", - "daily": "Límite diario", - "weekly": "Límite semanal", "weekStartInfo": "El primer día de cada semana es {weekStart}, puedes cambiar este valor en las opciones de estadísticas", "delayCount": "Contador de retraso", "detail": "Detalle de la regla", "visitTime": "Límite por visita", - "period": "Periodos no permitidos", "enabled": "Habilitado", "locked": "Bloqueado", "effectiveDay": "En vigor él", - "delayAllowed": "Más de 5 minutos", - "delayAllowedInfo": "Si se pausa, permite un retraso temporal de 5 minutos", - "visits": "visitas", "or": "o", "notEffective": "No efectivo" }, @@ -365,24 +321,17 @@ "reminder": "¡Menos de {min} minutos hasta el límite de tiempo!" }, "de": { - "filterDisabled": "Nur Aktivierte", "wildcardTip": "Sie können Platzhalter verwenden, um Subdomains oder Unterseiten zuzuordnen, und \"+\" als Präfix verwenden, um Unterseiten auszuschließen!", "item": { "name": "Regelname", "condition": "Eingeschränkte URL", - "daily": "Tägliches Limit", - "weekly": "Wöchentliches Limit", "weekStartInfo": "Der erste Tag jeder Woche ist {weekStart}, Sie können diesen Wert in den Statistikoptionen ändern", "delayCount": "Anzahl der Verspätungen", "detail": "Regeldetail", "visitTime": "Limit pro Besuch", - "period": "Unzulässiger Zeitraum", "enabled": "Aktiviert", "locked": "Gesperrt", "effectiveDay": "Wirksam auf", - "delayAllowed": "Weitere 5 Minuten", - "delayAllowedInfo": "Wenn es zu einer Zeitüberschreitung kommt, erlauben Sie eine vorübergehende Verzögerung von 5 Minuten", - "visits": "Besuche", "or": "oder", "notEffective": "Nicht wirksam" }, @@ -417,25 +366,19 @@ "reminder": "Weniger als {min} Minuten bis zum Zeitlimit!" }, "fr": { - "filterDisabled": "Activé uniquement", "wildcardTip": "Vous pouvez utiliser des caractères génériques pour cibler des sous-domaines ou des sous-pages, et le préfixe \"+\" pour en exclure !", "emptyTips": "Cliquez ici pour créer une règle !", "item": { "name": "Nom de règle", "condition": "URL restreinte", - "daily": "Limite quotidienne", - "weekly": "Limite hebdomadaire", "weekStartInfo": "Le premier jour de chaque semaine est {weekStart}, vous pouvez modifier cette valeur dans les options de statistiques", "delayCount": "Nombre de retards", "detail": "Détail des règles", "visitTime": "Limite par visite", - "period": "Périodes bloquées", "enabled": "Activé", "locked": "Verrouillé", "effectiveDay": "Effectif le", - "delayAllowed": "Retardable", - "delayAllowedInfo": "Si le délai est écoulé, autorisez un délai temporaire de 5 minutes", - "visits": "visites", + "allowDelay": "Retardable", "or": "ou", "notEffective": "Non efficace" }, @@ -470,25 +413,18 @@ "reminder": "Moins de {min} minutes jusqu'à la limite de temps !" }, "ru": { - "filterDisabled": "Только включен", "wildcardTip": "Вы можете использовать шаблоны для совпадения с поддоменами или подстраницами, а также использовать «+» в качестве префикса, чтобы исключить подстраницы!", "emptyTips": "Нажмите здесь, чтобы создать новое правило!", "item": { "name": "Имя правила", "condition": "Ограниченный URL", - "daily": "Дневной лимит", - "weekly": "Недельный лимит", "weekStartInfo": "Первый день каждой недели {weekStart}, вы можете изменить это значение в настройках статистики", "delayCount": "Отложенный", "detail": "Детали правила", "visitTime": "Лимит за посещение", - "period": "Заблокированное время", "enabled": "Включено", "locked": "Заблокировано", "effectiveDay": "Эффективный", - "delayAllowed": "Еще 5 минут", - "delayAllowedInfo": "Если время истекло, разрешите временную задержку 5 минут", - "visits": "посещения", "or": "или", "notEffective": "Не активировано" }, @@ -523,24 +459,17 @@ "reminder": "Осталось меньше чем {min} минут до конца лимита времени!" }, "ar": { - "filterDisabled": "تم التمكين فقط", "wildcardTip": "يمكنك استخدام الرموز البديلة لتطابق النطاقات الفرعية أو الصفحات الفرعية، ويمكنك استخدام العلامة \"+\" كبادئة لاستبعاد الصفحات الفرعية!", "item": { "name": "اسم القاعدة", "condition": "عنوان URL مقيد", - "daily": "الحد اليومي", - "weekly": "الحد الأسبوعي", "weekStartInfo": "اليوم الأول من كل أسبوع هو: {weekStart}، يمكنك تغيير هذه القيمة في خيارات الإحصائيات", "delayCount": "عدد التأخير", "detail": "تفاصيل القاعدة", "visitTime": "الحد الزمني لكل زيارة", - "period": "فترات محظورة", "enabled": "مُمَكَّن", "locked": "مقفل", "effectiveDay": "ساري المفعول على", - "delayAllowed": "5 دقائق إضافية", - "delayAllowedInfo": "إذا انتهت المهلة، اسمح بتأخير مؤقت لمدة 5 دقائق", - "visits": "الزيارات", "or": "أو", "notEffective": "غير فعال" }, @@ -575,25 +504,19 @@ "reminder": "أقل من {min} دقيقة على انتهاء الوقت المحدد!" }, "tr": { - "filterDisabled": "Yalnızca etkinleştirilmiş", "wildcardTip": "Alt alan adlarını veya alt sayfaları eşleştirmek için joker karakterler kullanabilir ve alt sayfaları hariç tutmak için “+” ön ekini kullanabilirsiniz!", "emptyTips": "Bir kural oluşturmak için burayı tıklayın!", "item": { "name": "Kural adı", "condition": "Kısıtlanmış URL", - "daily": "Günlük limit", - "weekly": "Haftalık limit", "weekStartInfo": "Her haftanın ilk günü {weekStart}, bu değeri istatistik seçeneklerinden değiştirebilirsiniz", "delayCount": "Gecikme sayısı", "detail": "Kural detayı", "visitTime": "Ziyaret başına zaman sınırı", - "period": "Engellenen periyotlar", "enabled": "Etkinleştirildi", "locked": "Kilitli", "effectiveDay": "Geçerli Olduğu Gün(ler)", - "delayAllowed": "Ertelenebilir", - "delayAllowedInfo": "Zaman aşımı olursa, 5 dakikalık geçici bir gecikmeye izin verin", - "visits": "ziyaretler", + "allowDelay": "Ertelenebilir", "or": "yada", "notEffective": "Geçerli değil" }, @@ -628,25 +551,19 @@ "reminder": "Zaman sınırı dolmasına {min} dakikadan az kaldı!" }, "pl": { - "filterDisabled": "Tylko aktywne", "wildcardTip": "Możesz używać symbolu \"*\", aby dopasowywać subdomeny lub podstrony, a także użyć znaku „+” jako prefiksu, aby wykluczyć podstrony!", "emptyTips": "Kliknij tutaj, aby utworzyć zasadę!", "item": { "name": "Nazwa zasady", "condition": "Limitowany URL", - "daily": "Dzienny limit", - "weekly": "Limit tygodniowy", "weekStartInfo": "Pierwszym dniem każdego tygodnia jest {weekStart}, możesz zmienić tę wartość w opcjach śledzenia", "delayCount": "Licznik opóźnień", "detail": "Szczegóły zasady", "visitTime": "Limit czasu na wizytę", - "period": "Godziny blokowania", "enabled": "Aktywne", "locked": "Zablokowany", "effectiveDay": "Aktywna w", - "delayAllowed": "Możliwe do przedłużenia", - "delayAllowedInfo": "Jeżeli limit zostanie osiągnięty, pozwól na przedłużenie go o 5 minut", - "visits": "wizyt", + "allowDelay": "Możliwe do przedłużenia", "or": "lub", "notEffective": "Nieskuteczny" }, @@ -681,24 +598,18 @@ "reminder": "Mniej niż {min} minut do osiągnięcia limitu!" }, "it": { - "filterDisabled": "Solo Abilitati", "emptyTips": "Clicca qui per creare una regola!", "item": { "name": "Nome della regola", "condition": "Url Ristretto", - "daily": "Limite giornaliero", - "weekly": "Limite settimanale", "weekStartInfo": "Il primo giorno di ogni settimana è {weekStart}, puoi cambiare questo valore nelle opzioni di tracciamento", "delayCount": "Ritardo conteggio", "detail": "Dettagli regola", "visitTime": "Limite di tempo per visita", - "period": "Periodi bloccati", "enabled": "Attivata", "locked": "Bloccato", "effectiveDay": "Effettivo On", - "delayAllowed": "Ritardabile", - "delayAllowedInfo": "Se va in timeout, aspetta 5 minuti prima di riprovare", - "visits": "visite", + "allowDelay": "Ritardabile", "or": "o", "notEffective": "Non valido" }, diff --git a/src/i18n/message/app/limit.ts b/src/i18n/message/app/limit.ts index d8ea5907d..0d94b1997 100644 --- a/src/i18n/message/app/limit.ts +++ b/src/i18n/message/app/limit.ts @@ -8,7 +8,7 @@ import resource from './limit-resource.json' export type LimitMessage = { - filterDisabled: string + onlyEffective: string wildcardTip: string emptyTips: string step: { @@ -19,19 +19,14 @@ export type LimitMessage = { item: { name: string condition: string - daily: string - weekly: string weekStartInfo: string visitTime: string - period: string enabled: string locked: string effectiveDay: string - delayAllowed: string - delayAllowedInfo: string + allowDelay: string delayCount: string detail: string - visits: string or: string notEffective: string } @@ -61,15 +56,6 @@ export type LimitMessage = { reminder: string } -export const verificationMessages: Messages = { - en: resource.en?.verification, - zh_CN: resource.zh_CN?.verification, - zh_TW: resource.zh_TW?.verification, - ja: resource.ja?.verification, - pt_PT: resource.pt_PT?.verification, - uk: resource.uk?.verification, -} - const _default: Messages = resource export default _default diff --git a/src/i18n/message/app/menu-resource.json b/src/i18n/message/app/menu-resource.json index c33b16440..3cfe592e6 100644 --- a/src/i18n/message/app/menu-resource.json +++ b/src/i18n/message/app/menu-resource.json @@ -7,13 +7,11 @@ "dataClear": "数据管理", "behavior": "上网行为", "habit": "上网习惯", - "limit": "时间限制", "additional": "附加功能", "siteManage": "网站管理", "whitelist": "白名单管理", "mergeRule": "子域名合并", "other": "其他", - "helpUs": "帮助翻译", "about": "关于" }, "zh_TW": { @@ -24,13 +22,11 @@ "dataClear": "儲存狀況", "behavior": "用戶行爲", "habit": "習慣分析", - "limit": "時間限制", "additional": "附加功能", "siteManage": "網站管理", "whitelist": "白名單", "mergeRule": "網站合併規則", "other": "其他", - "helpUs": "協助翻譯", "about": "關於" }, "en": { @@ -41,13 +37,11 @@ "dataClear": "Storage", "behavior": "User Behavior", "habit": "Habits", - "limit": "Time Limit", "additional": "Additional Features", "siteManage": "Site Management", "whitelist": "Whitelist", "mergeRule": "Merge-site Rules", "other": "Other Features", - "helpUs": "Help Translation", "about": "About" }, "ja": { @@ -58,13 +52,11 @@ "dataClear": "記憶状況", "behavior": "ユーザーの行動", "habit": "閲覧の習慣", - "limit": "時間制限", "additional": "その他の機能", "siteManage": "ウェブサイト管理", "whitelist": "Webホワイトリスト", "mergeRule": "ドメイン合併", "other": "その他の機能", - "helpUs": "協力する", "about": "について" }, "pt_PT": { @@ -75,13 +67,11 @@ "dataClear": "Armazenamento", "behavior": "Comportamento", "habit": "Hábitos", - "limit": "Limite de Tempo", "additional": "Funcionalidades", "siteManage": "Gestão de Sites", "whitelist": "Lista Branca", "mergeRule": "Regras de Agrupamento", "other": "Outras Opções", - "helpUs": "Ajudar a Traduzir", "about": "Sobre" }, "uk": { @@ -92,13 +82,11 @@ "dataClear": "Стан пам'яті", "behavior": "Поведінка", "habit": "Звички", - "limit": "Обмеження часу", "additional": "Додаткові функції", "siteManage": "Керування сайтами", "whitelist": "Білий список", "mergeRule": "Правила об'єднання сайтів", "other": "Інші функції", - "helpUs": "Допомогти нам", "about": "Про нас" }, "es": { @@ -109,13 +97,11 @@ "dataClear": "Estado de la memoria", "behavior": "Comportamiento del usuario", "habit": "Hábitos", - "limit": "Límite de tiempo", "additional": "Funciones adicionales", "siteManage": "Gestión de sitios", "whitelist": "Lista blanca", "mergeRule": "Reglas de fusión de sitios", "other": "Otras Funciones", - "helpUs": "Ayúdanos", "about": "Acerca de" }, "de": { @@ -126,13 +112,11 @@ "dataClear": "Speicherstatus", "behavior": "Nutzerverhalten", "habit": "Gewohnheit", - "limit": "Zeitlimit", "additional": "Zusatzfunktionen", "siteManage": "Websites", "whitelist": "Whitelist", "mergeRule": "Regeln zusammenführen", "other": "Andere Eigenschaften", - "helpUs": "Hilfe bei der Übersetzung", "about": "Über uns" }, "fr": { @@ -143,13 +127,11 @@ "dataClear": "Situation de la mémoire", "behavior": "Comportement de l'utilisateur", "habit": "Habitudes", - "limit": "Limite de temps", "additional": "Fonctionnalités supplémentaires", "siteManage": "Gestion des sites", "whitelist": "Liste blanche", "mergeRule": "Fusionner les règles du site", "other": "Autres fonctionnalités", - "helpUs": "Aider à la traduction", "about": "À propos" }, "ru": { @@ -160,13 +142,11 @@ "dataClear": "Память о ситуации", "behavior": "Поведение", "habit": "Привычки", - "limit": "Ограничение по времени", "additional": "Дополнительный", "siteManage": "Управление сайтом", "whitelist": "Белый список", "mergeRule": "Объединение сайтов", "other": "Другие особенности", - "helpUs": "Помогите перевести", "about": "О нас" }, "ar": { @@ -177,13 +157,11 @@ "dataClear": "حالة الذاكرة", "behavior": "سلوك المستخدم", "habit": "العادات", - "limit": "المهلة", "additional": "ميزات إضافية", "siteManage": "أدوار النظام", "whitelist": "القائمة البيضاء", "mergeRule": "دمج قواعد الموقع", "other": "مميزات اخزي", - "helpUs": "ساعدني في التَّرْجَمَةً", "about": "حول" }, "tr": { @@ -194,13 +172,11 @@ "dataClear": "Depolama", "behavior": "Kullanıcı Davranışları", "habit": "Alışkanlıklar", - "limit": "Zaman Sınırı", "additional": "Ek Özellikler", "siteManage": "Site Yönetimi", "whitelist": "Beyaz Liste", "mergeRule": "Birleştirme Kuralları", "other": "Diğer Özellikler", - "helpUs": "Çeviriye yardım et", "about": "Hakkımızda" }, "pl": { @@ -211,13 +187,11 @@ "dataClear": "Pamięć", "behavior": "Zachowanie użytkownika", "habit": "Nawyki", - "limit": "Limit czasu", "additional": "Dodatkowe funkcje", "siteManage": "Zarządzanie stronami", "whitelist": "Whitelista", "mergeRule": "Reguły łączenia stron", "other": "Pozostałe funkcje", - "helpUs": "Pomóż w tłumaczeniu", "about": "O rozszerzeniu" }, "it": { @@ -228,12 +202,10 @@ "dataClear": "Memoria", "behavior": "Comportamento Dell'Utente", "habit": "Abitudini", - "limit": "Limite di tempo", "additional": "Funzionalità aggiuntive", "siteManage": "Gestione del sito", "whitelist": "Whitelist", "other": "Altre funzioni", - "helpUs": "Aiuto Traduzione", "about": "Info su" } } \ No newline at end of file diff --git a/src/i18n/message/app/menu.ts b/src/i18n/message/app/menu.ts index 72b61bdb8..3c0807740 100644 --- a/src/i18n/message/app/menu.ts +++ b/src/i18n/message/app/menu.ts @@ -15,13 +15,11 @@ export type MenuMessage = { dataClear: string behavior: string habit: string - limit: string additional: string siteManage: string whitelist: string mergeRule: string other: string - helpUs: string about: string } diff --git a/src/i18n/message/app/merge-rule-resource.json b/src/i18n/message/app/merge-rule-resource.json index 20fa7e4df..e569a69a9 100644 --- a/src/i18n/message/app/merge-rule-resource.json +++ b/src/i18n/message/app/merge-rule-resource.json @@ -45,7 +45,7 @@ "duplicateMsg": "The rule already exists: {origin}", "addConfirmMsg": "Customized merge rules will be set for {origin}", "infoAlertTitle": "the merge rules when counting sites on this page", - "infoAlert0": "Click the [New One] button, the input boxes of the source site and the merge site will be displayed, fill in and save the rule", + "infoAlert0": "Click the [New] button, the input boxes of the source site and the merge site will be displayed, fill in and save the rule", "infoAlert1": "The original site can be filled with a specific site or regular expression, such as www.baidu.com, *.baidu.com, *.google.com.*, to determine which sites will match this rule while merging", "infoAlert2": "The merged site can be filled with a specific site, a number or blank", "infoAlert3": "A number means the level of merged site. For example, there is a rule '*.*.edu.cn >>> 3', then 'www.hust.edu.cn' will be merged to 'hust.edu.cn'", diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index ed0654141..cf434d3fb 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -351,7 +351,8 @@ "strictContent": "When you select this option, if a site triggers daily limit, you will not be allowed to manually unblock it other than waiting until the next day. If rules are not set up properly, they can very well hinder your routines!", "pswFormLabel": "Password", "pswFormAgain": "Re-enter" - } + }, + "delayDuration": "Delay for {input} minutes per time" }, "backup": { "title": "Data Backup", @@ -1637,30 +1638,23 @@ "label": "Tryb ciemny {input}", "timed": "Czas na" }, - "animationDuration": "Długość trwania początkowej animacji wykresu {input}", - "sidePanel": "{input} - czy włączyć panel boczny" + "animationDuration": "Długość trwania początkowej animacji wykresu {input}" }, "tracking": { "title": "Monitorowanie", "autoPauseTrack": "{input} Wstrzymaj monitorowanie, jeżeli nie wykryto aktywności {info} przez {maxTime}", "noActivityInfo": "Mysz i klawiatura są nieaktywne, nie są w trybie pełnoekranowym i nie ma dźwięku", - "countLocalFiles": "{input} Śledzenie czasu, kiedy przeglądarka odczytuje {localFileTime} {info}", "localFileTime": "pliki lokalne", - "localFilesInfo": "Obsługuje pliki takich typów jak PDF, obrazy, txt lub JSON.", - "countTabGroup": "{input} Śledzenie czasu w grupach kart {info}", "tabGroupInfo": "Po usunięciu grupy tagów, dane również zostaną usunięte.", "fileAccessDisabled": "Dostęp do adresów URL plików jest obecnie niedozwolony. Proszę najpierw włączyć go na stronie zarządzania", "weekStart": "Pierwszy dzień tygodnia {input}", "weekStartAsNormal": "Normalnie", - "storage": "Przechowuj dane śledzenia w {input}", - "storageConfirm": "Czy chcesz zmienić typ pamięci na {type}?" + "storage": "Przechowuj dane śledzenia w {input}" }, "limit": { - "prompt": "Wyświetlane zapytanie, gdy ograniczono {input}", "reminder": "Przypomnienie {input} {minInput} minut(y) przed limitem czasu", "level": { "label": "Jak odblokować podczas ograniczenia {input}", - "nothing": "Zezwalaj na bezpośrednie odblokowanie na stronie administratora", "password": "Musisz wprowadzić hasło, aby odblokować", "verification": "Musisz wprowadzić kod weryfikacyjny, aby odblokować", "passwordLabel": "Hasło odblokowujące {input}", @@ -1679,31 +1673,24 @@ }, "backup": { "title": "Kopia zapasowa danych", - "type": "Zdalny typ: {input}", "client": "Nazwa klienta {input}", "meta": { - "gist": { - "authInfo": "Wymagany jest przynajmniej jeden token z gist" - }, "obsidian_local_rest_api": { "endpointInfo": "Tylko HTTP jest dostępny, ponieważ CORS nie może być skonfigurowany dla stron rozszerzenia" } }, "label": { "endpoint": "Adres punktu końcowego {info} {input}", - "path": "Ścieżka do folderu {input}", "account": "Nazwa użytkownika {input}", "password": "Hasło {input}" }, "operation": "Kopia zapasowa", "download": { "btn": "Pobierz", - "willDownload": "Do pobrania", - "confirmTip": "{size} części danych z [{clientName}] zostaną pobrane" + "willDownload": "Do pobrania" }, "clear": { - "btn": "Wyczyść", - "confirmTip": "{rowCount} części danych dla stron {hostCount} śledzonych przez [{clientName}] zostaną usunięte!" + "btn": "Wyczyść" }, "confirmStep": "Potwierdź dane", "clientTable": { @@ -1719,8 +1706,7 @@ } }, "accessibility": { - "title": "Ułatwienia dostępu", - "chartDecal": "{input} - czy wyświetlić wykres typu decal" + "title": "Ułatwienia dostępu" }, "notification": { "title": "Powiadomienie", diff --git a/src/i18n/message/app/option.ts b/src/i18n/message/app/option.ts index 0329ea5af..4c50bbcbb 100644 --- a/src/i18n/message/app/option.ts +++ b/src/i18n/message/app/option.ts @@ -73,6 +73,7 @@ export type OptionMessage = { pswFormLabel: string pswFormAgain: string } + delayDuration: string } backup: { title: string diff --git a/src/i18n/message/app/site-manage-resource.json b/src/i18n/message/app/site-manage-resource.json index 66a75141d..b52e1cb6b 100644 --- a/src/i18n/message/app/site-manage-resource.json +++ b/src/i18n/message/app/site-manage-resource.json @@ -525,20 +525,14 @@ "info": "statystyki według nazwy domeny" }, "merged": { - "name": "scalone", - "info": "łączenie statystyk wielu powiązanych nazw stron i reguły łączenia mogą być dostosowywane" - }, - "virtual": { - "name": "wirtualne", - "info": "policz dowolny adres URL w formacie Ant Pattern, możesz dodać niestandardową stronę w prawym górnym rogu" + "name": "scalone" } }, "cate": { "name": "Nazwa", "relatedMsg": "Ta kategoria została przypisana do witryn {siteCount} i nie może zostać usunięta", "removeConfirm": "Potwierdzasz usunięcie kategorii: {category}?", - "batchChange": "Zmień kategorie", - "batchDisassociate": "Odłącz kategorie" + "batchChange": "Zmień kategorie" }, "form": { "emptyAlias": "Wprowadź nazwę witryny", @@ -546,9 +540,7 @@ }, "msg": { "hostExistWarn": "{host} istnieje", - "existedTag": "WYŁĄCZONE", "noSelected": "Nie wybrano żadnej witryny", - "noSupported": "Wybrane witryny nie mogą ustawić kategorii", "disassociatedMsg": "Czy chcesz wyczyścić kategorie wszystkich wybranych witryn?", "batchDeleteMsg": "Czy chcesz usunąć wszystkie wybrane witryny?" } diff --git a/src/i18n/message/base.ts b/src/i18n/message/base.ts new file mode 100644 index 000000000..8a3aeacbf --- /dev/null +++ b/src/i18n/message/base.ts @@ -0,0 +1 @@ +export { default, type BaseMessage } from "./common/base" diff --git a/src/i18n/message/button.ts b/src/i18n/message/button.ts new file mode 100644 index 000000000..6fb6911fe --- /dev/null +++ b/src/i18n/message/button.ts @@ -0,0 +1 @@ +export { default, type ButtonMessage } from "./common/button" diff --git a/src/i18n/message/calendar.ts b/src/i18n/message/calendar.ts new file mode 100644 index 000000000..cdfb56765 --- /dev/null +++ b/src/i18n/message/calendar.ts @@ -0,0 +1 @@ +export { default, type CalendarMessage } from "./common/calendar" diff --git a/src/i18n/message/common/base-resource.json b/src/i18n/message/common/base-resource.json index 169ccc4c1..d18a68c0d 100644 --- a/src/i18n/message/common/base-resource.json +++ b/src/i18n/message/common/base-resource.json @@ -5,7 +5,9 @@ "guidePage": "User Guide", "option": "Options", "sourceCode": "Source Code", - "changeLog": "Release Notes" + "changeLog": "Release Notes", + "limit": "Time Limit", + "helpUs": "Help Translation" }, "zh_CN": { "sidebar": "侧边栏", @@ -13,7 +15,9 @@ "guidePage": "使用指南", "option": "设置", "sourceCode": "源代码", - "changeLog": "版本日志" + "changeLog": "版本日志", + "limit": "时间限制", + "helpUs": "帮助翻译" }, "zh_TW": { "sidebar": "側邊欄", @@ -21,7 +25,9 @@ "guidePage": "使用指南", "option": "設定", "sourceCode": "原始碼", - "changeLog": "版本紀錄" + "changeLog": "版本紀錄", + "limit": "時間限制", + "helpUs": "協助翻譯" }, "ja": { "sidebar": "サイドバー", @@ -29,7 +35,9 @@ "guidePage": "使い方ガイド", "option": "設定", "sourceCode": "ソースコード", - "changeLog": "リリースノート" + "changeLog": "リリースノート", + "limit": "時間制限", + "helpUs": "協力する" }, "pt_PT": { "sidebar": "Barra Lateral", @@ -37,7 +45,9 @@ "guidePage": "Guia do Utilizador", "option": "Definições", "sourceCode": "Código Fonte", - "changeLog": "Notas de Lançamento" + "changeLog": "Notas de Lançamento", + "limit": "Limite de Tempo", + "helpUs": "Ajudar a Traduzir" }, "uk": { "sidebar": "Бічна панель", @@ -45,7 +55,9 @@ "guidePage": "Посібник", "option": "Налаштування", "sourceCode": "Програмний код", - "changeLog": "Журнал змін" + "changeLog": "Журнал змін", + "limit": "Обмеження часу", + "helpUs": "Допомогти нам" }, "es": { "sidebar": "Barra Lateral", @@ -53,7 +65,9 @@ "guidePage": "Guía de Usuario", "option": "Ajustes", "sourceCode": "Código Fuente", - "changeLog": "Notas de Versión" + "changeLog": "Notas de Versión", + "limit": "Límite de tiempo", + "helpUs": "Ayúdanos" }, "de": { "sidebar": "Seitenleiste", @@ -61,7 +75,9 @@ "guidePage": "Benutzeranleitung", "option": "Einstellungen", "sourceCode": "Quellcode", - "changeLog": "Versionshinweise" + "changeLog": "Versionshinweise", + "limit": "Zeitlimit", + "helpUs": "Hilfe bei der Übersetzung" }, "fr": { "sidebar": "Barre Latérale", @@ -69,7 +85,9 @@ "guidePage": "Guide d'Utilisation", "option": "Paramètres", "sourceCode": "Code Source", - "changeLog": "Notes de Version" + "changeLog": "Notes de Version", + "limit": "Limite de temps", + "helpUs": "Aider à la traduction" }, "ru": { "sidebar": "Боковая панель", @@ -77,7 +95,9 @@ "guidePage": "Руководство", "option": "Настройки", "sourceCode": "Исходный код", - "changeLog": "Версионные изменения" + "changeLog": "Версионные изменения", + "limit": "Ограничение по времени", + "helpUs": "Помогите перевести" }, "ar": { "sidebar": "الشريط الجانبي", @@ -85,7 +105,9 @@ "guidePage": "دليل الاستخدام", "option": "الإعدادات", "sourceCode": "الكود المصدري", - "changeLog": "ملاحظات الإصدار" + "changeLog": "ملاحظات الإصدار", + "limit": "المهلة", + "helpUs": "ساعدني في التَّرْجَمَةً" }, "tr": { "sidebar": "Kenar Çubuğu", @@ -93,7 +115,9 @@ "guidePage": "Kullanım Kılavuzu", "option": "Seçenekler", "sourceCode": "Kaynak Kodu", - "changeLog": "Sürüm Notları" + "changeLog": "Sürüm Notları", + "limit": "Zaman Sınırı", + "helpUs": "Çeviriye yardım et" }, "pl": { "sidebar": "Pasek boczny", @@ -101,6 +125,12 @@ "guidePage": "Podręcznik użytkownika", "option": "Ustawienia", "sourceCode": "Kod źródłowy", - "changeLog": "Lista Zmian" + "changeLog": "Lista Zmian", + "limit": "Limit czasu", + "helpUs": "Pomóż w tłumaczeniu" + }, + "it": { + "limit": "Limite di tempo", + "helpUs": "Aiuto Traduzione" } } \ No newline at end of file diff --git a/src/i18n/message/common/base.ts b/src/i18n/message/common/base.ts index b64d19b24..f14c4ebd7 100644 --- a/src/i18n/message/common/base.ts +++ b/src/i18n/message/common/base.ts @@ -8,12 +8,13 @@ import resource from './base-resource.json' export type BaseMessage = { - sidebar: string allFunction: string guidePage: string changeLog: string option: string sourceCode: string + limit: string + helpUs: string } /** diff --git a/src/i18n/message/common/button-resource.json b/src/i18n/message/common/button-resource.json index da0aad6ad..17eccfff2 100644 --- a/src/i18n/message/common/button-resource.json +++ b/src/i18n/message/common/button-resource.json @@ -12,7 +12,7 @@ "cancel": "Cancel", "previous": "Back", "next": "Next", - "okey": "OK", + "okay": "OK", "dont": "No", "operation": "Actions", "configuration": "Settings", @@ -36,7 +36,7 @@ "cancel": "取消", "previous": "上一步", "next": "下一步", - "okey": "确定", + "okay": "确定", "dont": "取消", "operation": "操作", "configuration": "设置", @@ -60,7 +60,7 @@ "cancel": "取消", "previous": "上一步", "next": "下一步", - "okey": "確定", + "okay": "確定", "dont": "取消", "operation": "操作", "configuration": "設定", @@ -83,7 +83,7 @@ "cancel": "キャンセル", "previous": "戻る", "next": "次へ", - "okey": "OK", + "okay": "OK", "dont": "いいえ", "operation": "操作", "configuration": "設定", @@ -104,7 +104,7 @@ "cancel": "Cancelar", "previous": "Anterior", "next": "Seguinte", - "okey": "OK", + "okay": "OK", "dont": "Não", "operation": "Ações", "configuration": "Configurações", @@ -128,7 +128,7 @@ "cancel": "Скасувати", "previous": "Назад", "next": "Далі", - "okey": "Гаразд", + "okay": "Гаразд", "dont": "НІ", "operation": "Дії", "configuration": "Налаштування", @@ -150,7 +150,7 @@ "cancel": "Cancelar", "previous": "Anterior", "next": "Siguiente", - "okey": "OK", + "okay": "OK", "dont": "No", "operation": "Acciones", "configuration": "Configuración", @@ -174,7 +174,7 @@ "cancel": "Abbrechen", "previous": "Zurück", "next": "Weiter", - "okey": "OK", + "okay": "OK", "dont": "Nein", "operation": "Aktionen", "configuration": "Einstellungen", @@ -198,7 +198,7 @@ "cancel": "Annuler", "previous": "Retour", "next": "Suivant", - "okey": "OK", + "okay": "OK", "dont": "Non", "operation": "Opérations", "configuration": "Paramètres", @@ -222,7 +222,7 @@ "cancel": "Отменить", "previous": "Назад", "next": "Далее", - "okey": "ОК", + "okay": "ОК", "dont": "Нет", "operation": "Действия", "configuration": "Настройки", @@ -244,7 +244,7 @@ "cancel": "إلغاء", "previous": "السابق", "next": "التالي", - "okey": "موافق", + "okay": "موافق", "dont": "لا", "operation": "إجراءات", "configuration": "إعدادات", @@ -266,7 +266,7 @@ "cancel": "İptal Et", "previous": "Geri", "next": "İleri", - "okey": "Tamam", + "okay": "Tamam", "dont": "Hayır", "operation": "Eylemler", "configuration": "Ayarlar", @@ -290,14 +290,11 @@ "cancel": "Anuluj", "previous": "Wstecz", "next": "Dalej", - "okey": "Ok", - "dont": "Nie", + "okay": "Ok", "operation": "Działania", "configuration": "Ustawienia", "clear": "Wyczyść", "enable": "Włącz", - "batchEnable": "Włączanie serii", - "batchDisable": "Seria wyłączenia", "collapse": "Zwiń", "expand": "Rozwiń" } diff --git a/src/i18n/message/common/button.ts b/src/i18n/message/common/button.ts index b897fae63..288200b37 100644 --- a/src/i18n/message/common/button.ts +++ b/src/i18n/message/common/button.ts @@ -19,7 +19,7 @@ export type ButtonMessage = { cancel: string previous: string next: string - okey: string + okay: string dont: string operation: string configuration: string diff --git a/src/i18n/message/common/calendar-resource.json b/src/i18n/message/common/calendar-resource.json index d16c23343..cf43b3257 100644 --- a/src/i18n/message/common/calendar-resource.json +++ b/src/i18n/message/common/calendar-resource.json @@ -285,9 +285,7 @@ "pl": { "weekDays": "Pn|Wt|Śr|Cz|Pt|Sb|Nd", "months": "Sty|Lut|Mar|Kwi|Maj|Cze|Lip|Sie|Wrz|Paź|Lis|Gru", - "dateFormat": "{d}/{m}/{y}", - "monthDateFormat": "{d}/{m}", - "timeFormat": "{d}/{m}/{y} {h}:{i}:{s}", + "dateFormat": "{m}/{d}/{y}", "simpleTimeFormat": "{m}/{d} {h}:{i}", "label": { "startDate": "Data rozpoczęcia", diff --git a/src/i18n/message/common/item-resource.json b/src/i18n/message/common/item-resource.json index a2d6c47ce..bde6deb84 100644 --- a/src/i18n/message/common/item-resource.json +++ b/src/i18n/message/common/item-resource.json @@ -224,9 +224,6 @@ "operation": { "add2Whitelist": "Whitelista", "analysis": "Analizuj", - "deleteConfirmMsgAll": "Wszystkie wpisy wizyt dla [{url}] zostaną usunięte", - "deleteConfirmMsgRange": "Sprawdź wpisy dla [{url}] pomiędzy {start} a {end} zostaną usunięte", - "deleteConfirmMsg": "Wpis wizyt dla [{url}] z {date} zostanie usunięty", "exportWholeData": "Eksportuj", "importWholeData": "Importuj", "importOtherData": "Importuj z rozszerzenia" diff --git a/src/i18n/message/common/locale.ts b/src/i18n/message/common/locale.ts index ec1d4045a..7a60c889e 100644 --- a/src/i18n/message/common/locale.ts +++ b/src/i18n/message/common/locale.ts @@ -20,7 +20,7 @@ type Meta = MetaBase & { * * @since 0.8.0 */ -export type LocaleMessages = +type LocaleMessages = { [locale in timer.Locale]: Meta } & { diff --git a/src/i18n/message/common/shared-resource.json b/src/i18n/message/common/shared-resource.json index cc3446cb9..df7aa3285 100644 --- a/src/i18n/message/common/shared-resource.json +++ b/src/i18n/message/common/shared-resource.json @@ -12,7 +12,15 @@ }, "cate": { "notSet": "Not Set" - } + }, + "limit": { + "limited": "Limited", + "daily": "Daily limit", + "weekly": "Weekly limit", + "period": "Blocked periods", + "visits": "{n} visits" + }, + "all": "All" }, "zh_CN": { "merge": { @@ -27,6 +35,14 @@ }, "cate": { "notSet": "未分类" + }, + "limit": { + "effective": "生效中", + "limited": "已受限", + "daily": "每日上限", + "weekly": "每周上限", + "period": "不可访问的时间段", + "visits": "{n}次访问" } }, "ja": { @@ -41,6 +57,12 @@ }, "cate": { "notSet": "未設定" + }, + "limit": { + "daily": "1日の限度", + "weekly": "週間の限度", + "period": "許可されない期間", + "visits": "{n}訪問" } }, "zh_TW": { @@ -56,6 +78,12 @@ }, "cate": { "notSet": "未設定" + }, + "limit": { + "daily": "每日上限", + "weekly": "每週上限", + "period": "限制時段", + "visits": "{n}次造訪" } }, "pt_PT": { @@ -71,6 +99,12 @@ }, "cate": { "notSet": "Não Definido" + }, + "limit": { + "daily": "Limite diário", + "weekly": "Limite semanal", + "period": "Períodos bloqueados", + "visits": "{n} visitas" } }, "uk": { @@ -86,6 +120,20 @@ }, "cate": { "notSet": "Не встановлено" + }, + "limit": { + "daily": "Денний ліміт", + "weekly": "Тижневий ліміт", + "period": "Недозволені періоди", + "visits": "{n} відвідування" + } + }, + "it": { + "limit": { + "daily": "Limite giornaliero", + "weekly": "Limite settimanale", + "period": "Periodi bloccati", + "visits": "{n} visite" } }, "es": { @@ -101,6 +149,12 @@ }, "cate": { "notSet": "No establecido" + }, + "limit": { + "daily": "Límite diario", + "weekly": "Límite semanal", + "period": "Periodos no permitidos", + "visits": "{n} visitas" } }, "de": { @@ -116,6 +170,12 @@ }, "cate": { "notSet": "Nicht festgelegt" + }, + "limit": { + "daily": "Tägliches Limit", + "weekly": "Wöchentliches Limit", + "period": "Unzulässiger Zeitraum", + "visits": "{n} Besuche" } }, "fr": { @@ -131,6 +191,12 @@ }, "cate": { "notSet": "Non Défini" + }, + "limit": { + "daily": "Limite quotidienne", + "weekly": "Limite hebdomadaire", + "period": "Périodes bloquées", + "visits": "{n} visites" } }, "ru": { @@ -146,6 +212,12 @@ }, "cate": { "notSet": "Не задано" + }, + "limit": { + "daily": "Дневной лимит", + "weekly": "Недельный лимит", + "period": "Заблокированное время", + "visits": "{n} посещения" } }, "ar": { @@ -161,6 +233,12 @@ }, "cate": { "notSet": "غير مضبوط" + }, + "limit": { + "daily": "الحد اليومي", + "weekly": "الحد الأسبوعي", + "period": "فترات محظورة", + "visits": "{n} زيارة" } }, "tr": { @@ -176,6 +254,12 @@ }, "cate": { "notSet": "Ayarlanmadı" + }, + "limit": { + "daily": "Günlük limit", + "weekly": "Haftalık limit", + "period": "Engellenen periyotlar", + "visits": "{n} ziyaretler" } }, "pl": { @@ -191,6 +275,12 @@ }, "cate": { "notSet": "Nie ustawiono" + }, + "limit": { + "daily": "Dzienny limit", + "weekly": "Limit tygodniowy", + "period": "Godziny blokowania", + "visits": "{n} wizyt" } } } \ No newline at end of file diff --git a/src/i18n/message/common/shared.ts b/src/i18n/message/common/shared.ts index c4e0e4d01..fc6e09aab 100644 --- a/src/i18n/message/common/shared.ts +++ b/src/i18n/message/common/shared.ts @@ -8,6 +8,14 @@ export type SharedMessage = { cate: { notSet: string } + limit: { + limited: string + daily: string + weekly: string + period: string + visits: string + } + all: string } const sharedMessages = resource satisfies Messages diff --git a/src/i18n/message/cs/index.ts b/src/i18n/message/cs/index.ts index 7bdb9f4dc..28ff8cead 100644 --- a/src/i18n/message/cs/index.ts +++ b/src/i18n/message/cs/index.ts @@ -1,8 +1,9 @@ import limitMessages, { type LimitMessage } from "../app/limit" import menuMessages, { type MenuMessage } from "../app/menu" -import calendarMessages, { type CalendarMessage } from "../common/calendar" -import metaMessages, { type MetaMessage } from "../common/meta" +import calendarMessages, { type CalendarMessage } from "../calendar" +import sharedMessages, { type SharedMessage } from '../common/shared' import { merge, type MessageRoot } from "../merge" +import metaMessages, { type MetaMessage } from "../meta" import consoleMessages, { type ConsoleMessage } from "./console" import modalMessages, { type ModalMessage } from "./modal" @@ -11,6 +12,7 @@ export type CsMessage = { modal: ModalMessage meta: MetaMessage limit: LimitMessage + shared: SharedMessage menu: MenuMessage calendar: CalendarMessage } @@ -20,6 +22,7 @@ const CHILD_MESSAGES: MessageRoot = { modal: modalMessages, meta: metaMessages, limit: limitMessages, + shared: sharedMessages, menu: menuMessages, calendar: calendarMessages, } diff --git a/src/i18n/message/cs/modal-resource.json b/src/i18n/message/cs/modal-resource.json index a502814a1..6b655304b 100644 --- a/src/i18n/message/cs/modal-resource.json +++ b/src/i18n/message/cs/modal-resource.json @@ -1,79 +1,79 @@ { "zh_CN": { "defaultPrompt": "古希腊时间与浏览器之神正在阻止您访问该页面", - "more5Minutes": "延长5分钟", + "delay": "延长{n}分钟", "browsingTime": "当前浏览时长", "ruleDetail": "规则详情" }, "en": { "defaultPrompt": "This page was blocked!", - "more5Minutes": "5 more minutes", + "delay": "{n} more minutes", "browsingTime": "Browsing time", "ruleDetail": "Rule details" }, "ja": { "defaultPrompt": "このページはブロックされました!", - "more5Minutes": "5分延長", + "delay": "{n}分延長", "browsingTime": "閲覧時間", "ruleDetail": "ルール詳細" }, "zh_TW": { "defaultPrompt": "古希臘時間與瀏覽器之神暫時封鎖此頁面", - "more5Minutes": "延長5分鐘", + "delay": "延長{n}分鐘", "browsingTime": "目前瀏覽時長", "ruleDetail": "規則詳情" }, "pt_PT": { "defaultPrompt": "Esta página foi bloqueada!", - "more5Minutes": "Mais 5 minutos", + "delay": "Mais {n} minutos", "browsingTime": "Tempo de Navegação", "ruleDetail": "Detalhes da Regra" }, "uk": { "defaultPrompt": "Цю сторінку заблоковано!", - "more5Minutes": "Ще 5 хвилин", + "delay": "Ще {n} хвилин", "browsingTime": "Час перегляду", "ruleDetail": "Подробиці правила" }, "fr": { "defaultPrompt": "Cette page a été bloquée !", - "more5Minutes": "5 minutes supplémentaires", + "delay": "{n} minutes supplémentaires", "browsingTime": "Temps de navigation", "ruleDetail": "Détails des règles" }, "es": { "defaultPrompt": "¡Esta página fue bloqueada!", - "more5Minutes": "5 minutos más", + "delay": "{n} minutos más", "browsingTime": "Tiempo de navegación", "ruleDetail": "Detalles de la regla" }, "de": { "defaultPrompt": "Diese Seite wurde gesperrt!", - "more5Minutes": "Noch 5 Minuten", + "delay": "Noch {n} Minuten", "browsingTime": "Browsing-Zeit", "ruleDetail": "Regeldetails" }, "ru": { "defaultPrompt": "Эта страница заблокирована!", - "more5Minutes": "Ещё 5 минут", + "delay": "Ещё {n} минут", "browsingTime": "Время просмотра", "ruleDetail": "Подробности правила" }, "ar": { "defaultPrompt": "تم حظر هذه الصفحة!", - "more5Minutes": "5 دقائق إضافية", + "delay": "{n} دقائق إضافية", "browsingTime": "وقت التصفح", "ruleDetail": "تفاصيل القاعدة" }, "tr": { "defaultPrompt": "Bu sayfa engellendi!", - "more5Minutes": "5 dakika daha", + "delay": "{n} dakika daha", "browsingTime": "Gezinme süresi", "ruleDetail": "Kural Detayları" }, "pl": { "defaultPrompt": "Ta strona została zablokowana!", - "more5Minutes": "jeszcze 5 minut", + "delay": "jeszcze {n} minut", "browsingTime": "Czas przeglądania", "ruleDetail": "Szczegóły zasad" } diff --git a/src/i18n/message/cs/modal.ts b/src/i18n/message/cs/modal.ts index 8e8765ddc..3054c7fa6 100644 --- a/src/i18n/message/cs/modal.ts +++ b/src/i18n/message/cs/modal.ts @@ -2,7 +2,7 @@ import resource from './modal-resource.json' export type ModalMessage = { defaultPrompt: string - more5Minutes: string + delay: string browsingTime: string ruleDetail: string } diff --git a/src/i18n/message/item.ts b/src/i18n/message/item.ts new file mode 100644 index 000000000..fdc430cd1 --- /dev/null +++ b/src/i18n/message/item.ts @@ -0,0 +1 @@ +export { default, type ItemMessage } from "./common/item" diff --git a/src/i18n/message/meta.ts b/src/i18n/message/meta.ts new file mode 100644 index 000000000..e011144db --- /dev/null +++ b/src/i18n/message/meta.ts @@ -0,0 +1 @@ +export { default, type MetaMessage } from "./common/meta" diff --git a/src/i18n/message/popup/content-resource.json b/src/i18n/message/popup/content-resource.json index 7b1ffd118..976fe2047 100644 --- a/src/i18n/message/popup/content-resource.json +++ b/src/i18n/message/popup/content-resource.json @@ -262,11 +262,7 @@ "allTime": "Wszystkie dane" }, "saveAsImageTitle": "Podsumowanie", - "averageCount": "Średnio {value} razy co dzień", - "averageTime": "Średnio {value} razy dziennie", - "totalTime": "Ogółem {totalTime}", - "totalCount": "Razem: {totalCount}", - "otherLabel": "Pozostałe strony: {count}" + "totalTime": "Ogółem {totalTime}" }, "ranking": { "includingCount": "Wliczając {siteCount} stron" diff --git a/src/i18n/message/popup/content.ts b/src/i18n/message/popup/content.ts index 2b3a5c4b3..f00805ce3 100644 --- a/src/i18n/message/popup/content.ts +++ b/src/i18n/message/popup/content.ts @@ -5,9 +5,13 @@ * https://opensource.org/licenses/MIT */ -import type { PopupDuration } from '@popup/context' import resource from './content-resource.json' +type PopupDuration = + | "today" | "yesterday" | "thisWeek" | "thisMonth" + | "lastDays" + | "allTime" + export type ContentMessage = { percentage: { title: { [key in PopupDuration]: string } diff --git a/src/i18n/message/popup/footer.ts b/src/i18n/message/popup/footer.ts index f76486e33..252c4e5cf 100644 --- a/src/i18n/message/popup/footer.ts +++ b/src/i18n/message/popup/footer.ts @@ -5,11 +5,13 @@ * https://opensource.org/licenses/MIT */ -import { type PopupRoute } from '@popup/router' import resource from './footer-resource.json' export type FooterMessage = { - route: Record + route: { + percentage: string + ranking: string + } } const footerMessages = resource satisfies Messages diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index be5d3e717..1e13d94f0 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -58,8 +58,6 @@ }, "pl": { "rating": "Prześlij ocenę", - "showSiteName": "Wyświetl nazwę witryny", - "showTopN": "Wyświetl górną część {n}", - "donutChart": "Wyświetlane jako wykresy" + "showSiteName": "Wyświetl nazwę witryny" } } \ No newline at end of file diff --git a/src/i18n/message/popup/index.ts b/src/i18n/message/popup/index.ts index 890f031b0..dc5a1ee0e 100644 --- a/src/i18n/message/popup/index.ts +++ b/src/i18n/message/popup/index.ts @@ -5,37 +5,37 @@ * https://opensource.org/licenses/MIT */ -import menuMessages, { type MenuMessage } from "../app/menu" import baseMessages, { type BaseMessage } from "../common/base" import calendarMessages, { type CalendarMessage } from "../common/calendar" -import itemMessages, { type ItemMessage } from "../common/item" -import metaMessages, { type MetaMessage } from "../common/meta" import sharedMessages, { type SharedMessage } from "../common/shared" +import itemMessages, { type ItemMessage } from "../item" import { merge, type MessageRoot } from "../merge" +import metaMessages, { type MetaMessage } from "../meta" import contentMessages, { type ContentMessage } from "./content" import footerMessages, { type FooterMessage } from "./footer" import headerMessages, { type HeaderMessage } from "./header" +import limitMessages, { type LimitMessage } from './limit' export type PopupMessage = { content: ContentMessage + limit: LimitMessage item: ItemMessage meta: MetaMessage base: BaseMessage header: HeaderMessage footer: FooterMessage - menu: MenuMessage calendar: CalendarMessage shared: SharedMessage } const MESSAGE_ROOT: MessageRoot = { content: contentMessages, + limit: limitMessages, item: itemMessages, meta: metaMessages, base: baseMessages, header: headerMessages, footer: footerMessages, - menu: menuMessages, calendar: calendarMessages, shared: sharedMessages, } diff --git a/src/i18n/message/popup/limit-resource.json b/src/i18n/message/popup/limit-resource.json new file mode 100644 index 000000000..7db8162bc --- /dev/null +++ b/src/i18n/message/popup/limit-resource.json @@ -0,0 +1,15 @@ +{ + "en": { + "noData": "No limit rule effective for this URL", + "newOne": "New One", + "timeUsed": "{percent} used", + "visitUsed": "{used} count visited", + "remain": "{remaining} remaining", + "noLimit": "No Limit", + "notHit": "Not in effect" + }, + "zh_CN": { + "noLimit": "无限制", + "notHit": "未命中" + } +} \ No newline at end of file diff --git a/src/i18n/message/popup/limit.ts b/src/i18n/message/popup/limit.ts new file mode 100644 index 000000000..167044c96 --- /dev/null +++ b/src/i18n/message/popup/limit.ts @@ -0,0 +1,13 @@ +import resources from "./limit-resource.json" + +export type LimitMessage = { + noData: string + newOne: string + timeUsed: string + visitUsed: string + remain: string + noLimit: string + notHit: string +} + +export default resources satisfies Messages \ No newline at end of file diff --git a/src/manifest-firefox.ts b/src/manifest-firefox.ts index f56d54222..95fcd8bee 100644 --- a/src/manifest-firefox.ts +++ b/src/manifest-firefox.ts @@ -6,14 +6,14 @@ */ /** - * Build the manifest.json in chrome extension directory via this file + * Build the manifest.json in Firefox extension directory via this file * * @author zhy * @since 0.0.1 */ -// Not use path alias in manifest.json -import packageInfo from "./package" -const { version, author: { name: authorName }, homepage } = packageInfo +import packageJson from "../package.json" + +const { version, author: { name: authorName }, homepage } = packageJson const _default: browser._manifest.WebExtensionManifest = { name: '__MSG_meta_marketName__', @@ -39,7 +39,7 @@ const _default: browser._manifest.WebExtensionManifest = { "" ], js: [ - "content_scripts_skeleton.js", + "content_scripts.js", ], run_at: "document_start" } diff --git a/src/manifest.ts b/src/manifest.ts index fed0b248e..caa0e1d65 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -11,10 +11,9 @@ * @author zhy * @since 0.0.1 */ -// Not use path alias in manifest.json -import packageInfo from "./package" -import { OPTION_ROUTE } from "./pages/app/router/constants" -const { version, author: { email }, homepage } = packageInfo +import packageJson from "../package.json" +import { APP_OPTION_ROUTE } from "./shared/route" +const { version, author: { email }, homepage } = packageJson const _default: chrome.runtime.ManifestV3 = { name: '__MSG_meta_marketName__', @@ -38,7 +37,7 @@ const _default: chrome.runtime.ManifestV3 = { "" ], js: [ - "content_scripts_skeleton.js", + "content_scripts.js", ], run_at: "document_start" } @@ -61,9 +60,11 @@ const _default: chrome.runtime.ManifestV3 = { web_accessible_resources: [{ resources: [ 'content_scripts.js', - 'content_scripts.css', + 'content_scripts_limit.js', + 'vendor/*.js', 'static/images/*', 'static/popup.html', + 'static/limit.html', ], matches: [""], }], @@ -74,7 +75,7 @@ const _default: chrome.runtime.ManifestV3 = { /** * @since 0.4.0 */ - options_page: 'static/app.html#' + OPTION_ROUTE + options_page: 'static/app.html#' + APP_OPTION_ROUTE } -export default _default \ No newline at end of file +export default _default diff --git a/src/pages/app/Layout/icons/Trend.tsx b/src/pages/app/Layout/icons/Trend.tsx deleted file mode 100644 index a726c0997..000000000 --- a/src/pages/app/Layout/icons/Trend.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { FunctionalComponent } from "vue" - -const Trend: FunctionalComponent = () => ( - - - -) - -export default Trend \ No newline at end of file diff --git a/src/pages/app/Layout/index.tsx b/src/pages/app/Layout/index.tsx index 8b0529f6a..1bf49c714 100644 --- a/src/pages/app/Layout/index.tsx +++ b/src/pages/app/Layout/index.tsx @@ -5,11 +5,11 @@ * https://opensource.org/licenses/MIT */ -import { initAppContext } from "@app/context" import { css, injectGlobal } from '@emotion/css' import { ElAside, ElContainer, ElHeader, useNamespace } from "element-plus" import { defineComponent, type StyleValue } from "vue" import { RouterView } from "vue-router" +import { initAppContext } from "../context" import HeadNav from "./menu/Nav" import SideMenu from "./menu/Side" diff --git a/src/pages/app/Layout/menu/Nav.tsx b/src/pages/app/Layout/menu/Nav.tsx index 10c60b568..4da29c298 100644 --- a/src/pages/app/Layout/menu/Nav.tsx +++ b/src/pages/app/Layout/menu/Nav.tsx @@ -6,11 +6,12 @@ */ import { getIconUrl } from "@api/chrome/runtime" -import { t } from "@app/locale" +import { t } from '@app/locale' import { CloseBold, Link, Menu } from "@element-plus/icons-vue" import { css } from '@emotion/css' -import { useSwitch } from "@hooks" +import { useSwitch } from '@hooks' import Flex from '@pages/components/Flex' +import Img from '@pages/components/Img' import { ElBreadcrumb, ElBreadcrumbItem, ElIcon, ElMenu, ElMenuItem, useNamespace } from "element-plus" import { defineComponent, h, onBeforeMount, ref, watch } from "vue" import { useRouter } from "vue-router" @@ -72,7 +73,7 @@ const _default = defineComponent<{}>(() => { - + {t(msg => msg.meta.name)} @@ -92,9 +93,7 @@ const _default = defineComponent<{}>(() => { index={item.index ?? item.route ?? item.href} onClick={() => handleItemClick(item)} > - - {h(item.icon)} - + {h(item.icon)} {t(item.title)} {!!item.href && } diff --git a/src/pages/app/Layout/menu/Side.tsx b/src/pages/app/Layout/menu/Side.tsx index 7168d671a..086ee8794 100644 --- a/src/pages/app/Layout/menu/Side.tsx +++ b/src/pages/app/Layout/menu/Side.tsx @@ -6,11 +6,10 @@ */ import { getVersion } from '@api/chrome/runtime' -import { t } from "@app/locale" +import { t } from '@app/locale' import { Expand, Fold } from '@element-plus/icons-vue' import { css } from '@emotion/css' -import { useCached } from '@hooks/useCached' -import { useState } from '@hooks/useState' +import { useCached, useState } from '@hooks' import Flex from '@pages/components/Flex' import { colorVariant } from '@pages/util/style' import { ElCollapseTransition, ElIcon, ElMenu, ElMenuItem, ElMenuItemGroup, ElScrollbar, ElText, ElTooltip, useNamespace } from "element-plus" @@ -134,7 +133,7 @@ const _default = defineComponent(() => { - + ) }) diff --git a/src/pages/app/Layout/menu/item.ts b/src/pages/app/Layout/menu/item.ts index c032a240c..dd228d6eb 100644 --- a/src/pages/app/Layout/menu/item.ts +++ b/src/pages/app/Layout/menu/item.ts @@ -6,15 +6,15 @@ * https://opensource.org/licenses/MIT */ -import { type I18nKey } from "@app/locale" -import { ANALYSIS_ROUTE, MERGE_ROUTE } from "@app/router/constants" +import { type I18nKey } from '@app/locale' +import { ANALYSIS_ROUTE, MERGE_ROUTE } from '@app/router/constants' import { Aim, Connection, HelpFilled, Histogram, Memo, MoreFilled, Rank, SetUp, Stopwatch, Timer, View } from "@element-plus/icons-vue" +import { Trend } from '@pages/icons' import { getGuidePageUrl } from "@util/constant/url" import { type Component } from 'vue' import About from "../icons/About" import Database from "../icons/Database" import Table from "../icons/Table" -import Trend from "../icons/Trend" import Website from "../icons/Website" import Whitelist from "../icons/Whitelist" @@ -32,7 +32,7 @@ export type MenuItem = { mobile?: boolean } -export type MenuGroup = Omit & { +type MenuGroup = Omit & { children: MenuItem[] } @@ -69,7 +69,7 @@ export const menuGroups = (): MenuGroup[] => [{ route: '/behavior/habit', icon: Aim, }, { - title: msg => msg.menu.limit, + title: msg => msg.base.limit, route: '/behavior/limit', icon: Timer, }] @@ -108,7 +108,7 @@ export const menuGroups = (): MenuGroup[] => [{ index: '_guide', mobile: false, }, { - title: msg => msg.menu.helpUs, + title: msg => msg.base.helpUs, route: '/other/help', icon: HelpFilled, mobile: false, diff --git a/src/pages/app/Layout/menu/route.ts b/src/pages/app/Layout/menu/route.ts index 88bc2e21e..54a96bc75 100644 --- a/src/pages/app/Layout/menu/route.ts +++ b/src/pages/app/Layout/menu/route.ts @@ -1,5 +1,5 @@ +import { type I18nKey, t } from '@app/locale' import { createTabAfterCurrent } from "@api/chrome/tab" -import { type I18nKey, t } from "@app/locale" import { type Ref } from "vue" import { type Router } from "vue-router" import { type MenuItem, menuGroups } from "./item" diff --git a/src/pages/app/components/About/DescLink.tsx b/src/pages/app/components/About/DescLink.tsx index 4f24cd8eb..3491f5d77 100644 --- a/src/pages/app/components/About/DescLink.tsx +++ b/src/pages/app/components/About/DescLink.tsx @@ -1,4 +1,4 @@ -import { useXsState } from '@hooks/useMediaSize' +import { useXsState } from "@hooks" import Flex from '@pages/components/Flex' import { ElLink } from "element-plus" import { defineComponent, h, useSlots } from "vue" @@ -8,6 +8,7 @@ const _default = defineComponent<{ href?: string, icon?: JSX.Element }>(props => const { icon, href } = props const { default: default_, } = useSlots() const isXs = useXsState() + return () => ( {icon && !isXs.value && diff --git a/src/pages/app/components/About/Description.tsx b/src/pages/app/components/About/Description.tsx index 442d3f899..5a8be7f0d 100644 --- a/src/pages/app/components/About/Description.tsx +++ b/src/pages/app/components/About/Description.tsx @@ -1,23 +1,14 @@ -import { t } from "@app/locale" +import packageInfo, { AUTHOR_EMAIL } from "@/package" +import { t } from '@app/locale' import { css } from '@emotion/css' -import { MediaSize, useMediaSize } from "@hooks" +import { MediaSize, useMediaSize } from '@hooks' import { locale } from "@i18n" import Flex from "@pages/components/Flex" -import { Coffee, GitHub } from '@pages/util/icon' -import { saveFlag } from "@service/meta-service" -import packageInfo, { AUTHOR_EMAIL } from "@src/package" +import { Coffee, GitHub } from '@pages/icons' +import { rateClicked } from '@pages/util/rate' import { - BUY_ME_A_COFFEE_PAGE, - CHANGE_LOG_PAGE, - CHROME_HOMEPAGE, EDGE_HOMEPAGE, - FEEDBACK_QUESTIONNAIRE, - FIREFOX_HOMEPAGE, - getHomepageWithLocale, - GITHUB_ISSUE_ADD, - HOMEPAGE, - LICENSE_PAGE, PRIVACY_PAGE, - REVIEW_PAGE, - SOURCE_CODE_PAGE, + BUY_ME_A_COFFEE_PAGE, CHANGE_LOG_PAGE, CHROME_HOMEPAGE, EDGE_HOMEPAGE, FEEDBACK_QUESTIONNAIRE, FIREFOX_HOMEPAGE, + getHomepageWithLocale, GITHUB_ISSUE_ADD, HOMEPAGE, LICENSE_PAGE, PRIVACY_PAGE, REVIEW_PAGE, SOURCE_CODE_PAGE, } from "@util/constant/url" import { type ComponentSize, ElCard, ElDescriptions, ElDescriptionsItem, ElDivider, ElText, useNamespace } from "element-plus" import { computed, defineComponent, reactive } from "vue" @@ -165,7 +156,7 @@ const _default = defineComponent<{}>(() => { 🌟  {t(msg => msg.about.text.greet)}  - saveFlag("rateOpen")}> + {t(msg => msg.about.text.rate)} diff --git a/src/pages/app/components/About/InstallationLink.tsx b/src/pages/app/components/About/InstallationLink.tsx index d684a91e2..e0d38c977 100644 --- a/src/pages/app/components/About/InstallationLink.tsx +++ b/src/pages/app/components/About/InstallationLink.tsx @@ -1,5 +1,5 @@ +import { useXsState } from '@hooks' import { css } from '@emotion/css' -import { useXsState } from '@hooks/useMediaSize' import Flex from "@pages/components/Flex" import { colorUsage, colorVariant } from '@pages/util/style' import { computed, defineComponent, h, StyleValue, useSlots } from "vue" diff --git a/src/pages/app/components/About/index.tsx b/src/pages/app/components/About/index.tsx index 084769c31..61b320c04 100644 --- a/src/pages/app/components/About/index.tsx +++ b/src/pages/app/components/About/index.tsx @@ -1,6 +1,6 @@ import { ElScrollbar } from 'element-plus' import { type FunctionalComponent, type StyleValue } from "vue" -import ContentContainer from "../common/ContentContainer" +import ContentContainer from '../common/ContentContainer' import Description from "./Description" const About: FunctionalComponent = () => ( diff --git a/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx b/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx index a0a96e82e..1c0d7eab3 100644 --- a/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx +++ b/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx @@ -1,35 +1,34 @@ -import { useCategory } from "@app/context" -import { t } from "@app/locale" -import { useDebounceState, useRequest } from "@hooks" +import { searchSite } from "@api/sw/site" +import { useAnalysisTarget } from '@app/components/Analysis/context' +import type { AnalysisTarget } from '@app/components/Analysis/types' +import { labelOfHostInfo } from '@app/components/Analysis/util' +import { useCategory } from '@app/context' +import { t } from '@app/locale' +import { useDebounceState, useRequest } from '@hooks' import Flex from "@pages/components/Flex" -import { selectAllSites } from "@service/site-service" -import { listHosts } from "@service/stat-service" -import { CATE_NOT_SET_ID, identifySiteKey, parseSiteKeyFromIdentity, SiteMap } from "@util/site" +import { CATE_NOT_SET_ID, identifySiteKey, parseSiteIdentity, SiteMap } from "@util/site" import { ElSelectV2, ElTag, useNamespace } from "element-plus" import type { OptionType } from "element-plus/es/components/select-v2/src/select.types" import { computed, defineComponent, type FunctionalComponent, onMounted, ref, type StyleValue } from "vue" -import { useAnalysisTarget } from "../../context" -import type { AnalysisTarget } from "../../types" -import { labelOfHostInfo } from "../../util" const SITE_PREFIX = 'S' const CATE_PREFIX = 'C' const cvtTarget2Key = (target: AnalysisTarget | undefined): string => { - if (target?.type === 'site') { - return `${SITE_PREFIX}${identifySiteKey(target.key)}` - } else if (target?.type === 'cate') { - return `${CATE_PREFIX}${target.key}` + const { type, key } = target ?? {} + switch (type) { + case 'site': return `${SITE_PREFIX}${identifySiteKey(key)}` + case 'cate': return `${CATE_PREFIX}${key}` + default: return '' } - return '' } const cvtKey2Target = (key: string | undefined): AnalysisTarget | undefined => { if (!key) return undefined - const prefix = key?.charAt?.(0) - const content = key?.substring(1) + const prefix = key.charAt(0) + const content = key.substring(1) if (prefix === SITE_PREFIX) { - const key = parseSiteKeyFromIdentity(content) + const key = parseSiteIdentity(content) if (key) return { type: 'site', key } } else if (prefix === CATE_PREFIX) { let cateId: number | undefined @@ -45,34 +44,14 @@ type TargetItem = AnalysisTarget & { label: string } -function collectHosts(hosts: Record, collector: SiteMap) { - Object.entries(hosts).forEach(([key, arr]) => { - const type = key as timer.site.Type - arr.forEach(host => { - const site: timer.site.SiteInfo = { host, type } - collector?.put(site, site) - }) - }) -} - const fetchItems = async (categories: timer.site.Cate[]): Promise<[siteItems: TargetItem[], cateItems: TargetItem[]]> => { // 1. query categories - const cateItems = categories?.map(({ id, name }) => ({ type: 'cate', key: id, label: name } satisfies TargetItem)) + const cateItems: TargetItem[] = categories.map(({ id, name }) => ({ type: 'cate', key: id, label: name })) // 2. query sites - const siteSet = new SiteMap() - - // 2.1 sites from hosts - const hosts = await listHosts() - collectHosts(hosts, siteSet) - - // 2.2 query sites from sites - const sites = await selectAllSites() - sites?.forEach(site => siteSet.put(site, site)) - - const siteItems = siteSet?.map((_, site) => site) - .filter(site => !!site) - .map(site => ({ type: 'site', key: site, label: labelOfHostInfo(site) }) satisfies TargetItem) + const sites = await searchSite() + const siteMap = SiteMap.identify(sites) + const siteItems: TargetItem[] = siteMap.map((_, key) => ({ type: 'site', key, label: labelOfHostInfo(key) })) return [cateItems, siteItems] } @@ -84,14 +63,14 @@ const SiteTypeTag: FunctionalComponent<{ text: string }> = ({ text }) => ( ) const SiteOption = defineComponent<{ value: timer.site.SiteInfo }>(props => { - const alias = computed(() => props.value?.alias) - const type = computed(() => props.value?.type) + const alias = computed(() => props.value.alias) + const type = computed(() => props.value.type) const mergedText = t(msg => msg.analysis.common.merged) const virtualText = t(msg => msg.analysis.common.virtual) return () => ( - {props.value?.host} + {props.value.host} {alias.value} @@ -117,14 +96,15 @@ const TargetSelect = defineComponent(() => { const [query, setQuery] = useDebounceState('', 50) const options = computed(() => { - const q = query.value?.trim?.() + const q = query.value.trim() let [cateItems, siteItems] = allItems.value if (q) { - siteItems = siteItems.filter(item => { - const { host, alias } = (item.key as timer.site.SiteInfo) || {} - return host?.includes?.(q) || alias?.includes?.(q) + siteItems = siteItems.filter(({ key, type }) => { + if (type !== 'site') return false + const { host, alias } = key + return host.includes(q) || alias?.includes(q) }) - cateItems = cateItems.filter(item => item.label?.includes?.(q)) + cateItems = cateItems.filter(item => item.label.includes(q)) } let res: OptionType[] = [] @@ -148,14 +128,19 @@ const TargetSelect = defineComponent(() => { const ns = useNamespace('select') const select = ref>() onMounted(() => { + // Do nothing if target selected if (target.value) return - let el = select.value?.$el as HTMLElement | undefined - if (!el) return + + let el = select.value?.$el + if (!(el instanceof HTMLElement)) return el.click() - const input = el.querySelector(`.${ns.e('input')}`) as HTMLInputElement - (el.querySelector(`.${ns.e('wrapper')}`) as HTMLElement)?.classList?.add?.(ns.is('focused')) - input?.click?.() - input?.focus?.() + const input = el.querySelector(`.${ns.e('input')}`) + const wrapper = el.querySelector(`.${ns.e('wrapper')}`) + if (input instanceof HTMLInputElement) { + input.click() + input.focus() + } + if (wrapper instanceof HTMLElement) wrapper.classList.add(ns.is('focused')) }) return () => ( @@ -168,7 +153,7 @@ const TargetSelect = defineComponent(() => { filterMethod={setQuery} style={{ width: '240px' } as StyleValue} defaultFirstOption - options={options.value ?? []} + options={options.value} fitInputWidth={false} v-slots={({ item }: any) => { const target = (item as any).data as TargetItem diff --git a/src/pages/app/components/Analysis/components/AnalysisFilter/index.tsx b/src/pages/app/components/Analysis/components/AnalysisFilter/index.tsx index 6c8b29fea..068360266 100644 --- a/src/pages/app/components/Analysis/components/AnalysisFilter/index.tsx +++ b/src/pages/app/components/Analysis/components/AnalysisFilter/index.tsx @@ -5,10 +5,10 @@ * https://opensource.org/licenses/MIT */ -import TimeFormatFilterItem from "@app/components/common/filter/TimeFormatFilterItem" +import { useAnalysisTimeFormat } from "@app/components/Analysis/context" +import TimeFormatFilterItem from '@app/components/common/filter/TimeFormatFilterItem' import Flex from "@pages/components/Flex" import { defineComponent } from "vue" -import { useAnalysisTimeFormat } from "../../context" import TargetSelect from "./TargetSelect" const AnalysisFilter = defineComponent(() => { diff --git a/src/pages/app/components/Analysis/components/Summary/Calendar/Wrapper.ts b/src/pages/app/components/Analysis/components/Summary/Calendar/Wrapper.ts index 00d276e3a..7e6a5f219 100644 --- a/src/pages/app/components/Analysis/components/Summary/Calendar/Wrapper.ts +++ b/src/pages/app/components/Analysis/components/Summary/Calendar/Wrapper.ts @@ -4,20 +4,17 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" -import { periodFormatter } from "@app/util/time" -import { EchartsWrapper } from "@hooks/useEcharts" -import { getRegularTextColor, getSecondaryTextColor } from "@pages/util/style" -import weekHelper from "@service/components/week-helper" +import { getWeekStartDay, getWeekStartTime } from '@api/sw/option' +import { t } from '@app/locale' +import { parseValueOfFormatter } from "@app/util/echarts" +import { periodFormatter } from '@app/util/time' +import { EchartsWrapper } from '@hooks' +import { getRegularTextColor, getSecondaryTextColor } from '@pages/util/style' import { groupBy, rotate, toMap } from "@util/array" import { formatTime, getAllDatesBetween, MILL_PER_WEEK, parseTime } from "@util/time" import type { - ComposeOption, - EffectScatterSeriesOption, - GridComponentOption, - TitleComponentOption, - TooltipComponentOption, - VisualMapComponentOption + ComposeOption, EffectScatterSeriesOption, GridComponentOption, TitleComponentOption, TooltipComponentOption, + VisualMapComponentOption, } from "echarts" import type { TopLevelFormatterParams } from "echarts/types/dist/shared" @@ -81,7 +78,10 @@ function getXAxisLabelMap(data: _Value[]): { [x: string]: string } { return result } -function optionOf(data: _Value[], weekDays: string[], format: timer.app.TimeFormat, domWidth: number): EcOption { +function optionOf( + data: _Value[], weekDays: string[], + format: timer.app.TimeFormat, domWidth: number, +): EcOption { const xAxisLabelMap = getXAxisLabelMap(data) const axisTextColor = getSecondaryTextColor() const gridLeft = domWidth * 0.1 < MIN_GRID_LEFT_PX ? MIN_GRID_LEFT_PX : '10%' @@ -99,12 +99,12 @@ function optionOf(data: _Value[], weekDays: string[], format: timer.app.TimeForm tooltip: { borderWidth: 0, formatter: (params: TopLevelFormatterParams) => { - const parma = Array.isArray(params) ? params[0] : params - const { data } = parma - const { value } = data as any + const value = parseValueOfFormatter(params) + // todo: not safety const [_1, _2, mills, date] = value as _Value if (!mills) return '' const time = parseTime(date) + if (!time) return '' return time ? `${formatTime(time, t(msg => msg.calendar.dateFormat))}
    ${periodFormatter(mills, { format })}` : '' }, }, @@ -154,7 +154,7 @@ class Wrapper extends EchartsWrapper { const width = this.getDomWidth() const colNum = getWeekNum(width) const endTime = new Date() - const [startTime,] = await weekHelper.getWeekDate(endTime.getTime() - MILL_PER_WEEK * (colNum - 1)) + const startTime = await getWeekStartTime(endTime.getTime() - MILL_PER_WEEK * (colNum - 1)) const allDates = getAllDatesBetween(startTime, endTime) const value = toMap(rows, r => r.date, r => r.focus) const data: _Value[] = [] @@ -165,7 +165,7 @@ class Wrapper extends EchartsWrapper { const x = colIndex, y = 7 - (1 + weekDay) data.push([x, y, dailyMills, date]) }) - const weekStart = await weekHelper.getRealWeekStart() + const weekStart = await getWeekStartDay() const weekDays = (t(msg => msg.calendar.weekDays)?.split?.('|') || []).reverse() rotate(weekDays, weekStart, true) return optionOf(data, weekDays, timeFormat, width) diff --git a/src/pages/app/components/Analysis/components/Summary/Calendar/index.tsx b/src/pages/app/components/Analysis/components/Summary/Calendar/index.tsx index 35ca2ea6d..d9979ab43 100644 --- a/src/pages/app/components/Analysis/components/Summary/Calendar/index.tsx +++ b/src/pages/app/components/Analysis/components/Summary/Calendar/index.tsx @@ -4,8 +4,8 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { useAnalysisRows, useAnalysisTimeFormat } from "@app/components/Analysis/context" -import { useEcharts } from "@hooks/useEcharts" +import { useAnalysisRows, useAnalysisTimeFormat } from '@app/components/Analysis/context' +import { useEcharts } from '@hooks' import { computed, defineComponent } from "vue" import Wrapper, { type BizOption } from "./Wrapper" diff --git a/src/pages/app/components/Analysis/components/Summary/TargetInfo.tsx b/src/pages/app/components/Analysis/components/Summary/TargetInfo.tsx index 326977566..9565f8639 100644 --- a/src/pages/app/components/Analysis/components/Summary/TargetInfo.tsx +++ b/src/pages/app/components/Analysis/components/Summary/TargetInfo.tsx @@ -5,16 +5,15 @@ * https://opensource.org/licenses/MIT */ -import { useCategory } from "@app/context" -import { t } from "@app/locale" -import { useRequest } from "@hooks" +import { useAnalysisTarget } from "@app/components/Analysis/context" +import { labelOfHostInfo } from "@app/components/Analysis/util" +import { useCategory } from '@app/context' +import { t } from '@app/locale' import Flex from "@pages/components/Flex" -import { getSite } from '@service/site-service' +import Img from '@pages/components/Img' import { CATE_NOT_SET_ID } from '@util/site' import { ElTag } from "element-plus" -import { computed, defineComponent, type StyleValue, toRef } from "vue" -import { useAnalysisTarget } from "../../context" -import { labelOfHostInfo } from "../../util" +import { computed, defineComponent, FunctionalComponent, type StyleValue } from "vue" const TITLE_STYLE: StyleValue = { fontSize: '26px', @@ -34,21 +33,19 @@ const SUBTITLE_STYLE: StyleValue = { margin: 0, } -const SiteInfo = defineComponent<{ value: timer.site.SiteKey }>(props => { - const key = toRef(props, 'value') - const { data: site } = useRequest(() => key.value ? getSite(key.value) : undefined, { deps: key }) - const iconUrl = computed(() => site.value?.iconUrl) - const title = computed(() => site.value?.alias ?? labelOfHostInfo(site.value)) - const subtitle = computed(() => site.value?.alias ? labelOfHostInfo(site.value) : undefined) +const SiteInfo: FunctionalComponent<{ site: timer.site.SiteInfo }> = ({ site }) => { + const { iconUrl, alias } = site + const label = labelOfHostInfo(site) + const [title, subtitle] = alias ? [alias, label] : [label] - return () => ( + return ( - -

    {title.value}

    - {subtitle &&

    {subtitle.value}

    } + +

    {title}

    + {subtitle &&

    {subtitle}

    }
    ) -}, { props: ['value'] }) +} const CateInfo = defineComponent<{ value: number }>(props => { const { all } = useCategory() @@ -78,7 +75,7 @@ const TargetInfo = defineComponent(() => { padding="0 25px" > {!target.value &&

    {t(msg => msg.analysis.common.emptyDesc)}

    } - {target.value?.type === 'site' && } + {target.value?.type === 'site' && } {target.value?.type === 'cate' && }
    ) diff --git a/src/pages/app/components/Analysis/components/Summary/index.tsx b/src/pages/app/components/Analysis/components/Summary/index.tsx index de6567344..dfa555b71 100644 --- a/src/pages/app/components/Analysis/components/Summary/index.tsx +++ b/src/pages/app/components/Analysis/components/Summary/index.tsx @@ -4,14 +4,14 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { KanbanCard, KanbanIndicatorCell } from "@app/components/common/kanban" -import { t } from "@app/locale" -import { cvt2LocaleTime, periodFormatter } from "@app/util/time" +import { useAnalysisRows, useAnalysisTarget, useAnalysisTimeFormat } from "@app/components/Analysis/context" +import type { AnalysisTarget } from "@app/components/Analysis/types" +import { KanbanCard, KanbanIndicatorCell } from '@app/components/common/kanban' +import { t } from '@app/locale' +import { cvt2LocaleTime, periodFormatter } from '@app/util/time' import { css } from '@emotion/css' import Grid from '@pages/components/Grid' import { computed, defineComponent } from "vue" -import { useAnalysisRows, useAnalysisTarget, useAnalysisTimeFormat } from "../../context" -import { AnalysisTarget } from "../../types" import Calendar from "./Calendar" import TargetInfo from "./TargetInfo" @@ -26,8 +26,8 @@ function computeSummary(target: AnalysisTarget | undefined, rows: timer.stat.Row if (!target) return undefined const summary: Summary = { focus: 0, visit: 0, day: 0 } - summary.firstDay = rows?.[0]?.date - rows?.forEach(({ focus, time: visit }) => { + summary.firstDay = rows[0]?.date + rows.forEach(({ focus, time: visit }) => { summary.focus += focus summary.visit += visit focus && (summary.day += 1) diff --git a/src/pages/app/components/Analysis/components/Trend/Dimension/Chart.tsx b/src/pages/app/components/Analysis/components/Trend/Dimension/Chart.tsx index bdaddbe4b..7b9c4e087 100644 --- a/src/pages/app/components/Analysis/components/Trend/Dimension/Chart.tsx +++ b/src/pages/app/components/Analysis/components/Trend/Dimension/Chart.tsx @@ -5,8 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { type DimensionEntry, type ValueFormatter } from "@app/components/Analysis/util" -import { useEcharts } from "@hooks/useEcharts" +import type { DimensionEntry } from '@app/components/Analysis/types' +import type { ValueFormatter } from '@app/components/common/kanban/types' +import { useEcharts } from '@hooks' import { defineComponent } from "vue" import Wrapper from "./Wrapper" diff --git a/src/pages/app/components/Analysis/components/Trend/Dimension/Wrapper.ts b/src/pages/app/components/Analysis/components/Trend/Dimension/Wrapper.ts index fbf4efddc..17a2ee85e 100644 --- a/src/pages/app/components/Analysis/components/Trend/Dimension/Wrapper.ts +++ b/src/pages/app/components/Analysis/components/Trend/Dimension/Wrapper.ts @@ -5,19 +5,15 @@ * https://opensource.org/licenses/MIT */ -import { type ValueFormatter } from "@app/components/Analysis/util" -import { getLineSeriesPalette, tooltipDot, tooltipFlexLine } from "@app/util/echarts" -import { EchartsWrapper } from "@hooks/useEcharts" -import { getRegularTextColor } from "@pages/util/style" -import { type LineSeriesOption } from "echarts/charts" -import { - type GridComponentOption, - type TitleComponentOption, - type TooltipComponentOption, -} from "echarts/components" -import { type ComposeOption } from "echarts/core" -import { type TopLevelFormatterParams } from "echarts/types/dist/shared" -import { type DimensionEntry } from "../../../util" +import type { DimensionEntry } from '@app/components/Analysis/types' +import type { ValueFormatter } from '@app/components/common/kanban/types' +import { getLineSeriesPalette, tooltipDot, tooltipFlexLine } from '@app/util/echarts' +import { EchartsWrapper } from '@hooks' +import { getRegularTextColor } from '@pages/util/style' +import type { + ComposeOption, GridComponentOption, LineSeriesOption, TitleComponentOption, TooltipComponentOption, +} from "echarts" +import type { TopLevelFormatterParams } from "echarts/types/dist/shared" type EcOption = ComposeOption< | LineSeriesOption diff --git a/src/pages/app/components/Analysis/components/Trend/Dimension/index.tsx b/src/pages/app/components/Analysis/components/Trend/Dimension/index.tsx index 2ef0d954c..6026393e1 100644 --- a/src/pages/app/components/Analysis/components/Trend/Dimension/index.tsx +++ b/src/pages/app/components/Analysis/components/Trend/Dimension/index.tsx @@ -4,21 +4,18 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { formatValue, type DimensionEntry, type RingValue, type ValueFormatter } from "@app/components/Analysis/util" +import type { DimensionData } from '@app/components/Analysis/types' +import { formatValue } from "@app/components/Analysis/util" import { GRID_CELL_STYLE } from '@app/components/common/grid' -import { KanbanIndicatorCell } from "@app/components/common/kanban" -import { cvt2LocaleTime } from "@app/util/time" -import { useXsState } from "@hooks" +import { KanbanIndicatorCell } from '@app/components/common/kanban' +import type { RingValue, ValueFormatter } from '@app/components/common/kanban/types' +import { cvt2LocaleTime } from '@app/util/time' +import { useXsState } from '@hooks' import Box from "@pages/components/Box" import Flex from "@pages/components/Flex" import { defineComponent } from "vue" import Chart from "./Chart" -export type DimensionData = { - thisPeriod: DimensionEntry[] - previousPeriod: DimensionEntry[] -} - type Props = { maxLabel: string maxValue?: number diff --git a/src/pages/app/components/Analysis/components/Trend/Filter.tsx b/src/pages/app/components/Analysis/components/Trend/Filter.tsx index e85eae98f..82433d9c2 100644 --- a/src/pages/app/components/Analysis/components/Trend/Filter.tsx +++ b/src/pages/app/components/Analysis/components/Trend/Filter.tsx @@ -7,7 +7,7 @@ import DateRangeFilterItem from '@app/components/common/filter/DateRangeFilterItem' import { t } from "@app/locale" -import type { ElDatePickerShortcut } from '@pages/types' +import type { ElDatePickerShortcut } from "@pages/element-ui/types" import { daysAgo } from "@util/time" import { defineComponent } from "vue" import { useAnalysisTrendDateRange } from "./context" diff --git a/src/pages/app/components/Analysis/components/Trend/Total.tsx b/src/pages/app/components/Analysis/components/Trend/Total.tsx index 232bed63d..9b12f3489 100644 --- a/src/pages/app/components/Analysis/components/Trend/Total.tsx +++ b/src/pages/app/components/Analysis/components/Trend/Total.tsx @@ -5,15 +5,15 @@ * https://opensource.org/licenses/MIT */ -import type { RingValue } from "@app/components/Analysis/util" -import { KanbanIndicatorCell } from "@app/components/common/kanban" +import { useAnalysisTimeFormat } from '@app/components/Analysis/context' +import { GRID_CELL_STYLE } from '@app/components/common/grid' +import { KanbanIndicatorCell } from '@app/components/common/kanban' +import type { RingValue } from '@app/components/common/kanban/types' import { t } from "@app/locale" -import { periodFormatter } from "@app/util/time" +import { periodFormatter } from '@app/util/time' import Flex from "@pages/components/Flex" -import { defineComponent, StyleValue } from "vue" -import { useAnalysisTimeFormat } from "../../context" -import { useAnalysisTrendRangeLength } from "./context" -import { GRID_CELL_STYLE } from "../../../common/grid" +import { defineComponent, type CSSProperties } from "vue" +import { useAnalysisTrendRangeLength } from './context' const computeDayValue = (activeDay: RingValue | undefined, rangeLength: number) => { const thisActiveDay = activeDay?.[0] @@ -22,7 +22,7 @@ const computeDayValue = (activeDay: RingValue | undefined, rangeLength: number) type Props = Record<'activeDay' | 'visit' | 'focus', RingValue> -const INDICATOR_CONTAINER_STYLE: StyleValue = { +const INDICATOR_CONTAINER_STYLE: CSSProperties = { ...GRID_CELL_STYLE, flex: 1, width: '100%', } diff --git a/src/pages/app/components/Analysis/components/Trend/context.ts b/src/pages/app/components/Analysis/components/Trend/context.ts index 2d7ba4cb8..aac7972da 100644 --- a/src/pages/app/components/Analysis/components/Trend/context.ts +++ b/src/pages/app/components/Analysis/components/Trend/context.ts @@ -5,14 +5,13 @@ * https://opensource.org/licenses/MIT */ -import { cvt2LocaleTime } from "@app/util/time" -import { useProvide, useProvider } from "@hooks" +import { useAnalysisRows } from '@app/components/Analysis/context' +import type { DimensionData, DimensionEntry } from '@app/components/Analysis/types' +import { useProvide, useProvider } from '@hooks' +import { cvt2LocaleTime } from '@app/util/time' import { toMap } from "@util/array" import { daysAgo, getAllDatesBetween, getDayLength, MILL_PER_DAY } from "@util/time" import { computed, onMounted, ref, watch, type Ref } from "vue" -import { useAnalysisRows } from "../../context" -import type { DimensionEntry } from "../../util" -import type { DimensionData } from "./Dimension" type Context = { dateRange: Ref<[Date?, Date?]> diff --git a/src/pages/app/components/Analysis/components/Trend/index.tsx b/src/pages/app/components/Analysis/components/Trend/index.tsx index f98d2e48d..1bfe7493c 100644 --- a/src/pages/app/components/Analysis/components/Trend/index.tsx +++ b/src/pages/app/components/Analysis/components/Trend/index.tsx @@ -4,14 +4,14 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ +import { useAnalysisTimeFormat } from '@app/components/Analysis/context' import { GRID_WRAPPER_STYLE } from '@app/components/common/grid' -import { KanbanCard } from "@app/components/common/kanban" -import { t } from "@app/locale" -import { periodFormatter } from "@app/util/time" +import { KanbanCard } from '@app/components/common/kanban' import { useXsState } from "@hooks" +import { t } from "@app/locale" +import { periodFormatter } from '@app/util/time' import Flex from "@pages/components/Flex" import { defineComponent } from "vue" -import { useAnalysisTimeFormat } from '../../context' import { initAnalysisTrend } from "./context" import Dimension from "./Dimension" import Filter from "./Filter" diff --git a/src/pages/app/components/Analysis/context.ts b/src/pages/app/components/Analysis/context.ts index 8e993d540..e6385ae98 100644 --- a/src/pages/app/components/Analysis/context.ts +++ b/src/pages/app/components/Analysis/context.ts @@ -5,8 +5,10 @@ * https://opensource.org/licenses/MIT */ +import { type AppAnalysisQuery } from '@/shared/route' +import { listCateStats, listSiteStats } from "@api/sw/stat" import { useLocalStorage, useProvide, useProvider, useRequest } from "@hooks" -import { selectCate, selectSite } from "@service/stat-service" +import { extractHostname } from '@util/pattern' import { ref, watch, type Ref } from "vue" import { useRoute, useRouter } from "vue-router" import type { AnalysisTarget } from "./types" @@ -17,17 +19,14 @@ type Context = { rows: Ref } -export type AnalysisQuery = Partial & { - cateId?: string -} - function parseQuery(): AnalysisTarget | undefined { // Process the query param - const query = useRoute().query as unknown as AnalysisQuery + const query = useRoute().query as unknown as AppAnalysisQuery useRouter().replace({ query: {} }) - const { host, type: siteType, cateId } = query + const { host, type: siteType, cateId, url } = query if (cateId) return { type: 'cate', key: parseInt(cateId) } if (host && siteType) return { type: 'site', key: { host, type: siteType } } + if (url) return { type: 'site', key: { host: extractHostname(url).host, type: 'normal' } } return undefined } @@ -36,10 +35,10 @@ async function queryRows(target: AnalysisTarget | undefined): Promise<(timer.sta if (!key) return [] if (type === 'cate') { - return selectCate({ cateIds: [key], sortKey: 'date' }) + return await listCateStats({ cateIds: [key], sortKey: 'date' }) } else if (type === 'site') { - const { host, type: siteType } = key ?? {} - return selectSite({ host, mergeHost: siteType === 'merged', sortKey: 'date' }) + const { host, type: siteType } = key + return await listSiteStats({ host, mergeHost: siteType === 'merged', sortKey: 'date' }) } else { // Not supported yet return [] diff --git a/src/pages/app/components/Analysis/index.tsx b/src/pages/app/components/Analysis/index.tsx index 9adc71a13..9e18ea393 100644 --- a/src/pages/app/components/Analysis/index.tsx +++ b/src/pages/app/components/Analysis/index.tsx @@ -6,7 +6,7 @@ */ import { ElScrollbar } from "element-plus" import { defineComponent, type StyleValue } from "vue" -import ContentContainer, { FilterContainer } from "../common/ContentContainer" +import ContentContainer, { FilterContainer } from '../common/ContentContainer' import AnalysisFilter from "./components/AnalysisFilter" import Summary from "./components/Summary" import Trend from "./components/Trend" diff --git a/src/pages/app/components/Analysis/types.d.ts b/src/pages/app/components/Analysis/types.d.ts index 9bbe6135e..72e828e4b 100644 --- a/src/pages/app/components/Analysis/types.d.ts +++ b/src/pages/app/components/Analysis/types.d.ts @@ -4,4 +4,14 @@ export type AnalysisTarget = { } | { type: 'cate' key: number -} \ No newline at end of file +} + +export type DimensionEntry = { + date: string + value: number +} + +export type DimensionData = { + thisPeriod: DimensionEntry[] + previousPeriod: DimensionEntry[] +} diff --git a/src/pages/app/components/Analysis/util.ts b/src/pages/app/components/Analysis/util.ts index 86b1c0662..9c8b7e0f6 100644 --- a/src/pages/app/components/Analysis/util.ts +++ b/src/pages/app/components/Analysis/util.ts @@ -6,6 +6,7 @@ */ import { t } from "@app/locale" +import type { ValueFormatter } from '../common/kanban/types' /** * Transfer host info to label @@ -20,35 +21,5 @@ export function labelOfHostInfo(site: timer.site.SiteKey | undefined): string { return `${host}${label}` } -export type RingValue = [ - current?: number, - last?: number, -] - -/** - * Compute ring text - * - * @param ring ring value - * @param formatter formatter - * @returns text or '-' - */ -export function computeRingText(ring: RingValue, formatter?: ValueFormatter): string | undefined { - const [current, last] = ring - if (current === undefined && last === undefined) { - // return undefined if both are undefined - return undefined - } - const delta = (current ?? 0) - (last ?? 0) - let result = formatter ? formatter(delta) : delta?.toString() - delta >= 0 && (result = '+' + result) - return result -} - -export type ValueFormatter = (val: number | undefined) => string export const formatValue = (val: number | undefined, formatter?: ValueFormatter) => formatter ? formatter(val) : val?.toString() || '-' - -export type DimensionEntry = { - date: string - value: number -} \ No newline at end of file diff --git a/src/pages/app/components/Dashboard/components/Calendar/Wrapper.ts b/src/pages/app/components/Dashboard/components/Calendar/Wrapper.ts index 8441ab945..5f24bdc34 100644 --- a/src/pages/app/components/Dashboard/components/Calendar/Wrapper.ts +++ b/src/pages/app/components/Dashboard/components/Calendar/Wrapper.ts @@ -4,23 +4,19 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ +import { getWeekStartDay } from "@api/sw/option" import { t } from "@app/locale" -import { getStepColors } from "@app/util/echarts" -import { cvt2LocaleTime } from "@app/util/time" -import { EchartsWrapper } from "@hooks/useEcharts" -import { getPrimaryTextColor } from "@pages/util/style" -import weekHelper from "@service/components/week-helper" +import { getStepColors } from '@app/util/echarts' +import { cvt2LocaleTime } from '@app/util/time' +import { EchartsWrapper } from "@hooks" +import { getPrimaryTextColor } from '@pages/util/style' import { groupBy, rotate } from "@util/array" import { formatPeriodCommon, getAllDatesBetween, MILL_PER_HOUR, MILL_PER_MINUTE } from "@util/time" -import { - type ComposeOption, - type GridComponentOption, - type HeatmapSeriesOption, - type ScatterSeriesOption, - type TooltipComponentOption, - type VisualMapComponentOption, +import type { + ComposeOption, GridComponentOption, HeatmapSeriesOption, ScatterSeriesOption, TooltipComponentOption, + VisualMapComponentOption, } from "echarts" -import { TopLevelFormatterParams } from "echarts/types/dist/shared" +import type { TopLevelFormatterParams } from "echarts/types/dist/shared" export type ChartValue = [ x: number, @@ -191,7 +187,7 @@ class Wrapper extends EchartsWrapper { data.push([x, y, dailyMills, date]) }) const weekDays = (t(msg => msg.calendar.weekDays)?.split?.('|') || []).reverse() - const weekStart = await weekHelper.getRealWeekStart() + const weekStart = await getWeekStartDay() weekStart && rotate(weekDays, weekStart, true) return optionOf(data, weekDays, this.getDom()) } diff --git a/src/pages/app/components/Dashboard/components/Calendar/index.tsx b/src/pages/app/components/Dashboard/components/Calendar/index.tsx index 7586c4cd8..263e5564f 100644 --- a/src/pages/app/components/Dashboard/components/Calendar/index.tsx +++ b/src/pages/app/components/Dashboard/components/Calendar/index.tsx @@ -6,19 +6,17 @@ */ import { createTabAfterCurrent } from "@api/chrome/tab" -import type { ReportQueryParam } from "@app/components/Report/types" +import { getWeekStartTime } from '@api/sw/option' +import { listSiteStats } from '@api/sw/stat' +import ChartTitle from '@app/components/Dashboard/ChartTitle' import { t } from "@app/locale" -import { REPORT_ROUTE } from "@app/router/constants" -import { useRequest } from "@hooks" -import { useEcharts } from "@hooks/useEcharts" +import { REPORT_ROUTE, type ReportQuery } from '@app/router/constants' +import { useEcharts, useRequest } from "@hooks" import Flex from "@pages/components/Flex" -import weekHelper from "@service/components/week-helper" -import { selectSite } from '@service/stat-service' import { groupBy, sum } from "@util/array" import { getAppPageUrl } from "@util/constant/url" -import { formatTimeYMD, MILL_PER_DAY, MILL_PER_HOUR } from "@util/time" +import { cvtDateRange2Str, formatTimeYMD, MILL_PER_DAY, MILL_PER_HOUR } from "@util/time" import { computed, defineComponent } from "vue" -import ChartTitle from "../../ChartTitle" import Wrapper, { type BizOption, type ChartValue } from "./Wrapper" const titleText = (option: Result | undefined) => { @@ -38,15 +36,11 @@ type Result = BizOption & { yearAgo: Date } const fetchData = async (): Promise => { const endTime = new Date() - const yearAgo = new Date(endTime.getTime() - MILL_PER_DAY * 365) - const [startTime] = await weekHelper.getWeekDate(yearAgo) - const items = await selectSite({ date: [startTime, endTime], sortKey: 'date' }) - const value = groupBy( - items, - i => i.date, - list => sum(list?.map(i => i.focus ?? 0)) - ) - return { value, startTime, endTime, yearAgo } + const yearAgo = endTime.getTime() - MILL_PER_DAY * 365 + const startTime = await getWeekStartTime(yearAgo) + const items = await listSiteStats({ date: cvtDateRange2Str([startTime, endTime]), sortKey: 'date' }) + const value = groupBy(items, i => i.date, list => sum(list.map(i => i.focus))) + return { value, startTime, endTime, yearAgo: new Date(yearAgo) } } /** @@ -64,9 +58,8 @@ function handleClick(value: ChartValue): void { 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: ReportQueryParam = { ds: currentTs, de: currentTs } - const url = getAppPageUrl(REPORT_ROUTE, query) + const url = getAppPageUrl(REPORT_ROUTE, { ds: currentTs, de: currentTs } satisfies ReportQuery) createTabAfterCurrent(url) } diff --git a/src/pages/app/components/Dashboard/components/Indicator.tsx b/src/pages/app/components/Dashboard/components/Indicator.tsx index f22cd40b7..f8f7fb126 100644 --- a/src/pages/app/components/Dashboard/components/Indicator.tsx +++ b/src/pages/app/components/Dashboard/components/Indicator.tsx @@ -5,24 +5,24 @@ * https://opensource.org/licenses/MIT */ -import NumberGrow from "@app/components/common/NumberGrow" +import { listPeriods } from "@api/sw/period" +import { listSiteStats } from "@api/sw/stat" import { tN, type I18nKey } from "@app/locale" -import periodDatabase from "@db/period-database" import { Sunrise } from "@element-plus/icons-vue" import { useRequest, useXsState } from "@hooks" import Flex from "@pages/components/Flex" -import { selectSite } from "@service/stat-service" -import { calcMostPeriodOf2Hours } from "@util/period" -import { getStartOfDay, MILL_PER_DAY, MILL_PER_MINUTE } from "@util/time" +import { groupBy, sum } from '@util/array' +import { getStartOfDay, MILL_PER_DAY, MILL_PER_HOUR, MILL_PER_MINUTE } from "@util/time" import { ElIcon, ElScrollbar } from "element-plus" import { computed, defineComponent, toRef, type VNode } from "vue" +import NumberGrow from "./NumberGrow" type _Value = { installedDays?: number sites: number visits: number browsingTime: number - most2Hour: number + busiestClock: number | undefined } /** @@ -33,29 +33,38 @@ function calculateInstallDays(installTime: Date, now: Date): number { return Math.round(deltaMills / MILL_PER_DAY) } +function calcBusiestClock(rows: timer.period.Row[]): number | undefined { + const map = groupBy(rows, + ({ startTime }) => startTime - getStartOfDay(startTime), + list => sum(list.map(e => e.milliseconds)) + ) + const maxOffsetStr = Object.entries(map).sort((a, b) => b[1] - a[1])[0]?.[0] + if (maxOffsetStr === undefined) return undefined + return Math.floor(Number.parseInt(maxOffsetStr) / MILL_PER_HOUR) +} + async function query(): Promise<_Value> { - const allData = await selectSite() + const allData = await listSiteStats() const hostSet = new Set() let visits = 0 let browsingTime = 0 - allData.forEach(({ siteKey, focus, time }) => { - const { host } = siteKey || {} - host && hostSet.add(host) + allData.forEach(({ siteKey: { host }, focus, time }) => { + hostSet.add(host) visits += time browsingTime += focus }) - const periodInfos: timer.period.Result[] = await periodDatabase.getAll() - const most2Hour = calcMostPeriodOf2Hours(periodInfos) + const periods = await listPeriods({ size: 8 }) + const busiestClock = calcBusiestClock(periods) const result: _Value = { - sites: hostSet?.size || 0, + sites: hostSet.size, visits, browsingTime, - most2Hour + busiestClock, } // 2. if not exist, calculate from all data items - const firstDate = allData.map(a => a.date).filter(d => d?.length === 8).sort()[0] + const firstDate = allData.map(a => a.date).filter(d => d.length === 8).sort()[0] if (firstDate) { const year = parseInt(firstDate.substring(0, 4)) const month = parseInt(firstDate.substring(4, 6)) - 1 @@ -89,10 +98,10 @@ const IndicatorLabel = defineComponent(props => { }, { props: ['path', 'param', 'duration'] }) const computeMost2HourParam = (value: _Value | undefined): { start: number, end: number } => { - const most2HourIndex = value?.most2Hour - const [start, end] = most2HourIndex === undefined || isNaN(most2HourIndex) + const { busiestClock } = value ?? {} + const [start, end] = busiestClock === undefined || isNaN(busiestClock) ? [0, 0] - : [most2HourIndex * 2, most2HourIndex * 2 + 2] + : [busiestClock, busiestClock + 2] return { start, end } } diff --git a/src/pages/app/components/Dashboard/components/MonthOnMonth/Wrapper.ts b/src/pages/app/components/Dashboard/components/MonthOnMonth/Wrapper.ts index 02472e477..6fcba4ad8 100644 --- a/src/pages/app/components/Dashboard/components/MonthOnMonth/Wrapper.ts +++ b/src/pages/app/components/Dashboard/components/MonthOnMonth/Wrapper.ts @@ -1,15 +1,11 @@ import { getCompareColor, getDiffColor, tooltipDot } from "@app/util/echarts" -import { cvt2LocaleTime } from "@app/util/time" -import { EchartsWrapper } from "@hooks/useEcharts" +import { cvt2LocaleTime } from '@app/util/time' +import { EchartsWrapper } from "@hooks" import { formatPeriodCommon } from "@util/time" -import { - type BarSeriesOption, - type ComposeOption, - type GridComponentOption, - type LegendComponentOption, - type TooltipComponentOption, +import type { + BarSeriesOption, ComposeOption, GridComponentOption, LegendComponentOption, TooltipComponentOption, } from "echarts" -import { type TopLevelFormatterParams } from "echarts/types/dist/shared" +import type { TopLevelFormatterParams } from "echarts/types/dist/shared" type EcOption = ComposeOption< | BarSeriesOption diff --git a/src/pages/app/components/Dashboard/components/MonthOnMonth/index.tsx b/src/pages/app/components/Dashboard/components/MonthOnMonth/index.tsx index e08e61b52..57ed4c3c7 100644 --- a/src/pages/app/components/Dashboard/components/MonthOnMonth/index.tsx +++ b/src/pages/app/components/Dashboard/components/MonthOnMonth/index.tsx @@ -1,12 +1,12 @@ +import { listSiteStats } from "@api/sw/stat" +import ChartTitle from "@app/components/Dashboard/ChartTitle" import { t } from "@app/locale" -import { useEcharts } from "@hooks/useEcharts" +import { useEcharts } from "@hooks" import Flex from "@pages/components/Flex" -import { selectSite } from "@service/stat-service" import { groupBy, sum } from "@util/array" import DateIterator from "@util/date-iterator" -import { MILL_PER_DAY } from "@util/time" +import { cvtDateRange2Str, MILL_PER_DAY } from "@util/time" import { defineComponent } from "vue" -import ChartTitle from "../../ChartTitle" import Wrapper from "./Wrapper" const PERIOD_WIDTH = 30 @@ -37,9 +37,9 @@ const fetchData = async (): Promise<[thisMonth: Row[], lastMonth: Row[]]> => { // Query with alias // @since 1.1.8 - const lastPeriodItems = await selectSite({ date: [lastPeriodStart, lastPeriodEnd] }) + const lastPeriodItems = await listSiteStats({ date: cvtDateRange2Str([lastPeriodStart, lastPeriodEnd]) }) const lastRows = cvtRow(lastPeriodItems, lastPeriodStart, lastPeriodEnd) - const thisPeriodItems = await selectSite({ date: [thisPeriodStart, thisPeriodEnd] }) + const thisPeriodItems = await listSiteStats({ date: cvtDateRange2Str([thisPeriodStart, thisPeriodEnd]) }) const thisRows = cvtRow(thisPeriodItems, thisPeriodStart, thisPeriodEnd) return [lastRows, thisRows] } diff --git a/src/pages/app/components/common/NumberGrow.tsx b/src/pages/app/components/Dashboard/components/NumberGrow.tsx similarity index 94% rename from src/pages/app/components/common/NumberGrow.tsx rename to src/pages/app/components/Dashboard/components/NumberGrow.tsx index 195ff6051..25f71ee8c 100644 --- a/src/pages/app/components/common/NumberGrow.tsx +++ b/src/pages/app/components/Dashboard/components/NumberGrow.tsx @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { useCountUp } from '@hooks/useCount' +import { useCountUp } from '@hooks' import { tNum } from '@i18n' import { computed, defineComponent, toRef } from "vue" diff --git a/src/pages/app/components/Dashboard/components/Timeline/Chart/Wrapper.ts b/src/pages/app/components/Dashboard/components/Timeline/Chart/Wrapper.ts index 2db0f8d35..eb20b1f54 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/Chart/Wrapper.ts +++ b/src/pages/app/components/Dashboard/components/Timeline/Chart/Wrapper.ts @@ -1,18 +1,22 @@ +import { t } from '@app/locale' import { getSeriesPalette, tooltipDot } from '@app/util/echarts' -import { EchartsWrapper } from '@hooks/useEcharts' +import { EchartsWrapper } from '@hooks' import { getPrimaryTextColor } from '@pages/util/style' import { groupBy, toMap } from '@util/array' -import { formatPeriodCommon, MILL_PER_DAY, MILL_PER_HOUR, MILL_PER_MINUTE, MILL_PER_SECOND } from '@util/time' +import { CATE_NOT_SET_ID } from '@util/site' import { - type ComposeOption, type CustomSeriesOption, type CustomSeriesRenderItem, - type DataZoomComponentOption, type GridComponentOption, type LegendComponentOption, type TooltipComponentOption + formatPeriodCommon, getStartOfDay, MILL_PER_DAY, MILL_PER_HOUR, MILL_PER_MINUTE, MILL_PER_SECOND +} from '@util/time' +import type { + ComposeOption, CustomSeriesOption, CustomSeriesRenderItem, DataZoomComponentOption, GridComponentOption, + LegendComponentOption, TooltipComponentOption, } from 'echarts' import { graphic } from "echarts/core" -import type { Activity, MergeMethod } from './useMerge' +import { formatYAxis } from '../common' export type BizData = { - activities: Activity[] - merge: MergeMethod + activities: timer.timeline.Activity[] + merge: timer.timeline.MergeMethod dates: string[] } @@ -30,18 +34,12 @@ type LegendInfo = { color: string } -type MyItem = { - // host - name: string - value: [ - yIndex: number, - // seconds - start: number, - // seconds - end: number, - duration: number, - ] -} +type MyValue = [ + yIndex: number, + start: number, // milliseconds, offset of this day + end: number, // milliseconds, offset of this day + duration: number, +] const formatTimeLabel = (val: number) => { let minute = Math.floor(val / MILL_PER_MINUTE) @@ -71,7 +69,7 @@ const formatDuration = (duration: number): string => { const LEGEND_WIDTH = 180 -const collectLegends = (activities: Activity[]): LegendInfo[] => { +const collectLegends = (activities: timer.timeline.Activity[], merge: timer.timeline.MergeMethod): LegendInfo[] => { const colors = getSeriesPalette() const colorLen = colors.length || 1 @@ -82,7 +80,9 @@ const collectLegends = (activities: Activity[]): LegendInfo[] => { .sort((a, b) => b[1] - a[1]) .map(([key], idx) => ({ name: key, - displayName: keyNameMap[key], + displayName: merge === 'cate' && key === String(CATE_NOT_SET_ID) + ? t(msg => msg.shared.cate.notSet) + : keyNameMap[key], color: colors[idx % colorLen], })) } @@ -137,14 +137,20 @@ const generateSeries = (biz: BizData, legendColors: Record): EcO }, renderItem, selectedMode: true, - data: list.map(({ date, start, duration }) => ({ - value: [dates.indexOf(date), start, start + duration, duration], - })) + data: list.map(({ start, duration }) => { + const date = formatYAxis(start) + const dateIdx = dates.indexOf(date) + const dayStart = getStartOfDay(start) + const startOffset = start - dayStart + const endOffset = start + duration - dayStart + const value: MyValue = [dateIdx, startOffset, endOffset, duration] + return { value } + }) } }) } -const calcDataZoomDefaultRange = (activities: Activity[]): [start: number | undefined, end: number | undefined] => { +const calcDataZoomDefaultRange = (activities: timer.timeline.Activity[]): [start: number | undefined, end: number | undefined] => { if (!activities.length) return [undefined, undefined] let min = activities.map(a => a.start).reduce((a, b) => b < a ? b : a) let max = activities.map(({ start, duration }) => start + duration).reduce((a, b) => b > a ? b : a) @@ -166,7 +172,7 @@ class Wrapper extends EchartsWrapper { const gridLeft = Math.min(Math.max(30, domWidth * .05), 60) const primaryTextColor = getPrimaryTextColor() - const legendData = collectLegends(activities) + const legendData = collectLegends(activities, merge) const legendNames = toMap(legendData, e => e.name, e => e.displayName) const legendColor = toMap(legendData, e => e.name, e => e.color) @@ -221,7 +227,7 @@ class Wrapper extends EchartsWrapper { const color = (params as any)?.color ?? "#000" const param = Array.isArray(params) ? params[0] : params const { value, seriesName } = param - const [_1, start, _2, duration] = value as MyItem['value'] + const [_1, start, _2, duration] = value as MyValue const startStr = formatStart(start) const durStr = formatDuration(duration) return `${tooltipDot(color)} ${seriesName ? tooltipSeriesName(seriesName) : ''}` diff --git a/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx b/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx index 7c9e2c1bd..b9e4877c7 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx +++ b/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx @@ -8,18 +8,16 @@ import ChartTitle from '@app/components/Dashboard/ChartTitle' import { t } from '@app/locale' import { Collection, Files, Link } from '@element-plus/icons-vue' -import { useShadow } from '@hooks' -import { useEcharts } from "@hooks/useEcharts" +import { useEcharts } from '@hooks' import Flex from "@pages/components/Flex" import { type ECElementEvent, type ECharts } from "echarts/core" import { ElIcon, ElRadioButton, ElRadioGroup } from 'element-plus' import { computed, defineComponent } from "vue" import { type JSX } from 'vue/jsx-runtime' -import { TIMELINE_DAY_COUNT } from '../constants' -import Wrapper, { EcOption, type BizData } from './Wrapper' -import { useMerge, type MergeMethod } from './useMerge' +import { TIMELINE_DAY_COUNT, useTimelineContext } from '../context' +import Wrapper, { BizData, EcOption } from './Wrapper' -const CHART_CONFIG: Record = { +const CHART_CONFIG: Record = { none: , domain: , cate: , @@ -55,14 +53,9 @@ const handleClick = (inst: ECharts, ev: ECElementEvent) => { seriesNames2Toggle.forEach(name => inst.dispatchAction({ type: "legendToggleSelect", name })) } -const TimelineChart = defineComponent<{ data: timer.timeline.Tick[] }>(props => { - const [myData] = useShadow(() => props.data) - const { merge, setMerge, activities, dates } = useMerge(myData) - const bizData = computed(() => ({ - activities: activities.value, - merge: merge.value, - dates, - })) +const TimelineChart = defineComponent<{}>(() => { + const { dates, activities, merge, setMerge } = useTimelineContext() + const bizData = computed(() => ({ dates, activities: activities.value, merge: merge.value })) const { elRef } = useEcharts(Wrapper, bizData, { afterInit: ew => { @@ -80,7 +73,11 @@ const TimelineChart = defineComponent<{ data: timer.timeline.Tick[] }>(props => {t(msg => msg.dashboard.timeline.title, { n: TIMELINE_DAY_COUNT })}
    - + setMerge(val as timer.timeline.MergeMethod)} + > {Object.entries(CHART_CONFIG).map(([k, v]) => ( {v} @@ -89,10 +86,10 @@ const TimelineChart = defineComponent<{ data: timer.timeline.Tick[] }>(props => - +
    ) -}, { props: ['data'] }) +}) export default TimelineChart \ No newline at end of file diff --git a/src/pages/app/components/Dashboard/components/Timeline/Chart/useMerge.ts b/src/pages/app/components/Dashboard/components/Timeline/Chart/useMerge.ts deleted file mode 100644 index e933af2b4..000000000 --- a/src/pages/app/components/Dashboard/components/Timeline/Chart/useMerge.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { useCategory } from '@app/context' -import { t } from '@app/locale' -import mergeRuleDatabase from '@db/merge-rule-database' -import siteDatabase from '@db/site-database' -import { useState } from '@hooks' -import CustomizedHostMergeRuler from '@service/components/host-merge-ruler' -import { toMap } from '@util/array' -import { CATE_NOT_SET_ID } from '@util/site' -import { formatTime, getAllDatesBetween, getStartOfDay, MILL_PER_DAY } from '@util/time' -import { type Ref, ref, watch } from 'vue' -import { TIMELINE_DAY_COUNT } from '../constants' - -export type Activity = { - date: string - // offset of date (mills) - start: number - // mills - duration: number - // series - seriesKey: string - seriesName: string | undefined -} - -type ActivityInner = Omit - -export type MergeMethod = 'cate' | 'domain' | 'none' - -const isMergeMethod = (val: unknown): val is MergeMethod => { - return val === 'none' || val === 'domain' || val === 'cate' -} - -const MONTH_DATE_FORMAT = t(msg => msg.calendar.monthDateFormat) -const formatDate = (date: Date | number) => formatTime(date, MONTH_DATE_FORMAT) - -const calcOffsetOfDay = (ts: number) => ts - getStartOfDay(ts) - -const genLatestDates = () => { - const now = new Date() - const start = new Date(now.getTime() - MILL_PER_DAY * (TIMELINE_DAY_COUNT - 1)) - return getAllDatesBetween(start, now, formatDate) -} - -async function mergeByDomain(ticks: timer.timeline.Tick[]): Promise { - // 1. merge all - const mergeRules = await mergeRuleDatabase.selectAll() - const merger = new CustomizedHostMergeRuler(mergeRules) - const allHosts = Array.from(new Set(ticks.map(t => t.host))) - const mergedMap = toMap(allHosts, h => h, h => merger.merge(h)) - - // 2. query all the merged sites' names - const allSiteKeys = Array.from(new Set(Object.values(mergedMap))) - .map((mergedHost) => ({ type: 'merged', host: mergedHost } satisfies timer.site.SiteKey)) - const allSites = await siteDatabase.getBatch(allSiteKeys) - const nameMap = toMap(allSites, s => s.host, s => s.alias) - - // 3. convert - return ticks.map(({ start, duration, host }) => { - const seriesKey = mergedMap[host] ?? host - return { - start, duration, - seriesKey, seriesName: nameMap[seriesKey], - } - }) -} - -async function mergeByCate(ticks: timer.timeline.Tick[], cateNameMap: Record): Promise { - // 1. query all the sites' category - const allSiteKeys = Array.from(new Set(ticks.map(t => t.host))) - .map(host => ({ type: 'normal', host } satisfies timer.site.SiteKey)) - const allSites = await siteDatabase.getBatch(allSiteKeys) - const siteCateMap = toMap(allSites, s => s.host, s => s.cate) - - // 2. convert - return ticks.map(({ start, duration, host }) => { - const cateId = siteCateMap[host] ?? CATE_NOT_SET_ID - return { - start, duration, - seriesKey: `${cateId}`, - seriesName: cateNameMap[cateId], - } - }) -} - -async function fillSiteName(ticks: timer.timeline.Tick[]): Promise { - // 1. query all the sites' names - const allSiteKeys = Array.from(new Set(ticks.map(t => t.host))) - .map(host => ({ type: 'normal', host } satisfies timer.site.SiteKey)) - const allSites = await siteDatabase.getBatch(allSiteKeys) - const nameMap = toMap(allSites, s => s.host, s => s.alias) - - // 2. convert - return ticks.map(({ start, duration, host }) => ({ - start, duration, - seriesKey: host, seriesName: nameMap[host], - })) -} - -async function handleMerge( - ticks: timer.timeline.Tick[], - merge: MergeMethod, - cateNameMap: Record, - dates: Set -): Promise { - let activities: ActivityInner[] = [] - if (merge === 'domain') { - activities = await mergeByDomain(ticks) - } else if (merge === 'cate') { - activities = await mergeByCate(ticks, cateNameMap) - } else { - activities = await fillSiteName(ticks) - } - const result: Activity[] = [] - activities.forEach(act => { - let actStart = act.start - const date = formatDate(actStart) - if (!dates.has(date)) return - - const start = calcOffsetOfDay(act.start) - result.push({ ...act, date, start }) - }) - return result -} - -export const useMerge = (ticks: Ref) => { - const dates = genLatestDates() - const merge = ref('none') - const cate = useCategory() - const setMerge = (val: unknown) => isMergeMethod(val) && (merge.value = val) - - const [activities, setActivities] = useState([]) - - watch([ticks, merge, cate], async () => { - const newVal = await handleMerge(ticks.value, merge.value, cate.nameMap, new Set(dates)) - setActivities(newVal) - }, { immediate: true }) - - return { merge, setMerge, activities, dates } -} \ No newline at end of file diff --git a/src/pages/app/components/Dashboard/components/Timeline/Summary.tsx b/src/pages/app/components/Dashboard/components/Timeline/Summary.tsx index 0aec23a29..fd7236655 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/Summary.tsx +++ b/src/pages/app/components/Dashboard/components/Timeline/Summary.tsx @@ -6,37 +6,33 @@ import { groupBy } from '@util/array' import { MILL_PER_HOUR, MILL_PER_MINUTE } from '@util/time' import { ElIcon, ElRate, ElText, ElTooltip } from 'element-plus' import { computed, defineComponent } from 'vue' - -type AnalysisResult = { - busy: number - focus: number -} +import { useTimelineContext } from './context' const MAX_SCORE = 5 -const defaultResult = (): AnalysisResult => ({ +const defaultResult = (): { busy: number, focus: number } => ({ busy: 1, focus: 1, }) -const computeSessionScore = (ticks: timer.timeline.Tick[], hourCount: number) => { +const computeSessionScore = (activities: timer.timeline.Activity[], hourCount: number) => { let continuousSessions = 0 - let currentSession: timer.timeline.Tick[] = [] + let currentSession: timer.timeline.Activity[] = [] - ticks.sort((a, b) => a.start - b.start).forEach(currentTick => { + activities.sort((a, b) => a.start - b.start).forEach(current => { if (!currentSession.length) { - currentSession.push(currentTick) + currentSession.push(current) return } - const prevTick = currentSession[currentSession.length - 1] - const gap = currentTick.start - (prevTick.start + prevTick.duration) + const prev = currentSession[currentSession.length - 1] + const gap = current.start - (prev.start + prev.duration) if (gap <= MILL_PER_MINUTE * 3) { - currentSession.push(currentTick) + currentSession.push(current) } else { if (currentSession.length > 1) continuousSessions++ - currentSession = [currentTick] + currentSession = [current] } }) @@ -46,31 +42,31 @@ const computeSessionScore = (ticks: timer.timeline.Tick[], hourCount: number) => return Math.min(sessionDensity, MAX_SCORE) } -const analyze = (ticks: timer.timeline.Tick[]): AnalysisResult => { - if (!ticks.length) return defaultResult() +const analyze = (activities: timer.timeline.Activity[]): { busy: number, focus: number } => { + if (!activities.length) return defaultResult() - const minTime = ticks.map(t => t.start).sort((a, b) => a - b)[0]! - const maxTime = ticks.map(t => t.start + t.duration).sort((a, b) => b - a)[0]! + const minTime = activities.map(t => t.start).sort((a, b) => a - b)[0]! + const maxTime = activities.map(t => t.start + t.duration).sort((a, b) => b - a)[0]! const totalRange = maxTime - minTime - const totalActiveTime = ticks.reduce((sum, tick) => sum + tick.duration, 0) + const totalActiveTime = activities.reduce((sum, s) => sum + s.duration, 0) - // { hourStart: hosts } - const hourlyData = groupBy(ticks, + // { hourStart: distinct series keys } — same merge dimension as the chart + const hourlyData = groupBy(activities, t => Math.floor(t.start / MILL_PER_HOUR) * MILL_PER_HOUR, - l => new Set(l.map(t => t.host)), + l => new Set(l.map(t => t.seriesKey)), ) - // busyScore = timeDensity * 0.6 + hostCountPerHour * 0.4 + // busyScore = timeDensity * 0.6 + seriesCountPerHour * 0.4 const timeDensity = totalActiveTime / totalRange const timeDensityScore = Math.min(timeDensity / 0.3, MAX_SCORE) - const maxHostCount = Object.values(hourlyData).map(hosts => hosts.size).sort((a, b) => b - a)[0]! - const hostMaxScore = Math.min(maxHostCount / 4, MAX_SCORE) - const busy = timeDensityScore * 0.6 + hostMaxScore * 0.4 + const maxSeriesCount = Object.values(hourlyData).map(keys => keys.size).sort((a, b) => b - a)[0]! + const seriesMaxScore = Math.min(maxSeriesCount / 4, MAX_SCORE) + const busy = timeDensityScore * 0.6 + seriesMaxScore * 0.4 // focusScore = duration * 0.7 + session * 0.3 - const avgDuration = totalActiveTime / ticks.length + const avgDuration = totalActiveTime / activities.length const avgDurationScore = Math.min(avgDuration / (2 * MILL_PER_MINUTE), MAX_SCORE) - const sessionScore = computeSessionScore(ticks, Object.keys(hourlyData).length) + const sessionScore = computeSessionScore(activities, Object.keys(hourlyData).length) const focus = avgDurationScore * 0.7 + sessionScore * 0.3 return { busy, focus } @@ -95,23 +91,24 @@ const Score = defineComponent<{ score: number, label: string, desc: string }>(pr ) }, { props: ['desc', 'label', 'score'] }) -const Summary = defineComponent<{ data: timer.timeline.Tick[] }>(props => { - const ticks = computed(() => analyze(props.data)) +const Summary = defineComponent<{}>(() => { + const { activities } = useTimelineContext() + const scores = computed(() => analyze(activities.value)) return () => ( msg.dashboard.timeline.busyScore)} desc={t(msg => msg.dashboard.timeline.busyScoreDesc)} /> msg.dashboard.timeline.focusScore)} desc={t(msg => msg.dashboard.timeline.focusScoreDesc)} /> ) -}, { props: ['data'] }) +}) export default Summary \ No newline at end of file diff --git a/src/pages/app/components/Dashboard/components/Timeline/common.ts b/src/pages/app/components/Dashboard/components/Timeline/common.ts new file mode 100644 index 000000000..5e157153b --- /dev/null +++ b/src/pages/app/components/Dashboard/components/Timeline/common.ts @@ -0,0 +1,5 @@ +import { t } from '@app/locale' +import { formatTime } from '@util/time' + +const MONTH_DATE_FORMAT = t(msg => msg.calendar.monthDateFormat) +export const formatYAxis = (date: Date | number) => formatTime(date, MONTH_DATE_FORMAT) \ No newline at end of file diff --git a/src/pages/app/components/Dashboard/components/Timeline/constants.ts b/src/pages/app/components/Dashboard/components/Timeline/constants.ts deleted file mode 100644 index 84da821c7..000000000 --- a/src/pages/app/components/Dashboard/components/Timeline/constants.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** - * The days shown in the timeline - */ -export const TIMELINE_DAY_COUNT = 3 \ No newline at end of file diff --git a/src/pages/app/components/Dashboard/components/Timeline/context.ts b/src/pages/app/components/Dashboard/components/Timeline/context.ts new file mode 100644 index 000000000..638027f40 --- /dev/null +++ b/src/pages/app/components/Dashboard/components/Timeline/context.ts @@ -0,0 +1,38 @@ +import { sendMsg2Runtime } from '@api/sw/common' +import { useProvide, useProvider, useRequest, useState } from '@hooks' +import { getAllDatesBetween, getStartOfDay, MILL_PER_DAY } from '@util/time' +import { type ShallowRef } from 'vue' +import { formatYAxis } from './common' + +/** + * The days shown in the timeline + */ +export const TIMELINE_DAY_COUNT = 3 + +const NAMESPACE = 'dashboard-timeline' + +type ContextValue = { + dates: string[] + activities: ShallowRef + merge: ShallowRef + setMerge: ArgCallback +} + +export const initTimelineContext = () => { + const start = getStartOfDay(Date.now() - MILL_PER_DAY * (TIMELINE_DAY_COUNT - 1)) + const dates = getAllDatesBetween(new Date(start), new Date(), formatYAxis) + const [merge, setMerge] = useState('none') + const { data: activities } = useRequest( + () => sendMsg2Runtime('timeline.list', { start, merge: merge.value }), + { + deps: [merge], + defaultValue: [], + } + ) + + useProvide(NAMESPACE, { dates, activities, merge, setMerge }) +} + +export const useTimelineContext = () => useProvider(NAMESPACE, + 'dates', 'activities', 'merge', 'setMerge', +) \ No newline at end of file diff --git a/src/pages/app/components/Dashboard/components/Timeline/index.tsx b/src/pages/app/components/Dashboard/components/Timeline/index.tsx index 46089638b..0ec851c7a 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/index.tsx +++ b/src/pages/app/components/Dashboard/components/Timeline/index.tsx @@ -1,22 +1,18 @@ -import timelineDatabase from '@db/timeline-database' -import { useRequest } from '@hooks' -import { getStartOfDay, MILL_PER_DAY } from '@util/time' +import DashboardCard from '@app/components/Dashboard/DashboardCard' import { defineComponent } from 'vue' -import DashboardCard from '../../DashboardCard' import TimelineChart from './Chart' import Summary from './Summary' -import { TIMELINE_DAY_COUNT } from './constants' +import { initTimelineContext } from './context' const Timeline = defineComponent<{ height: number }>(({ height }) => { - const start = getStartOfDay(Date.now() - MILL_PER_DAY * (TIMELINE_DAY_COUNT - 1)) - const { data } = useRequest(() => timelineDatabase.select({ start }), { defaultValue: [] }) + initTimelineContext() return () => <> - + - + }, { props: ['height'] }) diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/BarChart/Wrapper.ts b/src/pages/app/components/Dashboard/components/TopKVisit/BarChart/Wrapper.ts index a68a549ce..a79609591 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/BarChart/Wrapper.ts +++ b/src/pages/app/components/Dashboard/components/TopKVisit/BarChart/Wrapper.ts @@ -1,9 +1,7 @@ import { getStepColors, tooltipDot } from "@app/util/echarts" -import { EchartsWrapper } from "@hooks/useEcharts" +import { EchartsWrapper } from "@hooks" import { generateSiteLabel } from "@util/site" -import type { BarSeriesOption } from "echarts/charts" -import type { GridComponentOption, TooltipComponentOption } from "echarts/components" -import type { ComposeOption } from "echarts/core" +import type { BarSeriesOption, ComposeOption, GridComponentOption, TooltipComponentOption } from "echarts" import { BizOption } from "../context" type EcOption = ComposeOption< diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/BarChart/index.tsx b/src/pages/app/components/Dashboard/components/TopKVisit/BarChart/index.tsx index 0852af7cc..8d0ea79d0 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/BarChart/index.tsx +++ b/src/pages/app/components/Dashboard/components/TopKVisit/BarChart/index.tsx @@ -1,4 +1,4 @@ -import { useEcharts } from "@hooks/useEcharts" +import { useEcharts } from "@hooks" import { defineComponent } from "vue" import { useTopKValue } from "../context" import Wrapper from "./Wrapper" diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/HalfBarChart/Wrapper.ts b/src/pages/app/components/Dashboard/components/TopKVisit/HalfBarChart/Wrapper.ts index 424a9b4b2..bc9587bac 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/HalfBarChart/Wrapper.ts +++ b/src/pages/app/components/Dashboard/components/TopKVisit/HalfBarChart/Wrapper.ts @@ -1,21 +1,14 @@ -import { getSeriesPalette, tooltipDot } from "@app/util/echarts" -import { EchartsWrapper } from "@hooks/useEcharts" -import { getPrimaryTextColor } from "@pages/util/style" +import { getSeriesPalette, tooltipDot } from '@app/util/echarts' +import { EchartsWrapper } from "@hooks" +import { getPrimaryTextColor } from '@pages/util/style' import { generateSiteLabel } from "@util/site" -import { - type ComposeOption, - type GridComponentOption, - type PieSeriesOption, - type TooltipComponentOption, -} from "echarts" +import type { ComposeOption, GridComponentOption, PieSeriesOption, TooltipComponentOption } from "echarts" import { BizOption } from "../context" type EcOption = ComposeOption< | PieSeriesOption | GridComponentOption - | TooltipComponentOption -> - + | TooltipComponentOption> const tooltipOption = (): EcOption['tooltip'] => ( { show: true, diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/HalfBarChart/index.tsx b/src/pages/app/components/Dashboard/components/TopKVisit/HalfBarChart/index.tsx index 0852af7cc..8d0ea79d0 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/HalfBarChart/index.tsx +++ b/src/pages/app/components/Dashboard/components/TopKVisit/HalfBarChart/index.tsx @@ -1,4 +1,4 @@ -import { useEcharts } from "@hooks/useEcharts" +import { useEcharts } from "@hooks" import { defineComponent } from "vue" import { useTopKValue } from "../context" import Wrapper from "./Wrapper" diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/PieChart/Wrapper.ts b/src/pages/app/components/Dashboard/components/TopKVisit/PieChart/Wrapper.ts index 1ded87bfb..c3ce50903 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/PieChart/Wrapper.ts +++ b/src/pages/app/components/Dashboard/components/TopKVisit/PieChart/Wrapper.ts @@ -1,14 +1,9 @@ import { getSeriesPalette, tooltipDot } from "@app/util/echarts" -import { EchartsWrapper } from "@hooks/useEcharts" -import { getPrimaryTextColor } from "@pages/util/style" +import { EchartsWrapper } from "@hooks" +import { getPrimaryTextColor } from '@pages/util/style' import { generateSiteLabel } from "@util/site" -import { - type ComposeOption, - type GridComponentOption, - type PieSeriesOption, - type TooltipComponentOption -} from "echarts" -import { BizOption } from "../context" +import type { ComposeOption, GridComponentOption, PieSeriesOption, TooltipComponentOption } from "echarts" +import type { BizOption } from "../context" type EcOption = ComposeOption< | PieSeriesOption diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/PieChart/index.tsx b/src/pages/app/components/Dashboard/components/TopKVisit/PieChart/index.tsx index 0852af7cc..8d0ea79d0 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/PieChart/index.tsx +++ b/src/pages/app/components/Dashboard/components/TopKVisit/PieChart/index.tsx @@ -1,4 +1,4 @@ -import { useEcharts } from "@hooks/useEcharts" +import { useEcharts } from "@hooks" import { defineComponent } from "vue" import { useTopKValue } from "../context" import Wrapper from "./Wrapper" diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/Title/index.tsx b/src/pages/app/components/Dashboard/components/TopKVisit/Title/index.tsx index b6eb5d790..d47a05a69 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/Title/index.tsx +++ b/src/pages/app/components/Dashboard/components/TopKVisit/Title/index.tsx @@ -1,36 +1,17 @@ import { tN } from "@app/locale" import { css } from '@emotion/css' -import { useXsState } from '@hooks/useMediaSize' +import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" +import { BarChart, HalfPieChart, RoseChart } from '@pages/icons' import { ElIcon, ElRadioButton, ElRadioGroup, useNamespace } from "element-plus" -import { defineComponent } from "vue" -import type { JSX } from "vue/jsx-runtime" +import { type Component, defineComponent, h } from "vue" import { type TopKChartType, useTopKFilter } from "../context" import TitleSelect from "./TitleSelect" -const CHART_CONFIG: { [type in TopKChartType]: JSX.Element | string } = { - pie: ( - - - - - - - ), - bar: ( - - - - - - - - ), - halfPie: ( - - - - ), +const CHART_CONFIG: { [type in TopKChartType]: Component } = { + pie: RoseChart, + bar: BarChart, + halfPie: HalfPieChart, } const useRadioStyle = () => { @@ -64,7 +45,7 @@ const Title = defineComponent(() => { > {Object.entries(CHART_CONFIG).map(([k, v]) => ( - {v} + {h(v)} ))} diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/common.ts b/src/pages/app/components/Dashboard/components/TopKVisit/common.ts deleted file mode 100644 index 8f7d1e3c8..000000000 --- a/src/pages/app/components/Dashboard/components/TopKVisit/common.ts +++ /dev/null @@ -1,10 +0,0 @@ -import { type GridComponentOption } from "echarts/components" - -export const generateGridOption = (): GridComponentOption => { - return { - top: 30, - bottom: 40, - left: 40, - right: 20, - } -} diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/context.ts b/src/pages/app/components/Dashboard/components/TopKVisit/context.ts index 90f28d607..d1def2c17 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/context.ts +++ b/src/pages/app/components/Dashboard/components/TopKVisit/context.ts @@ -1,7 +1,7 @@ +import { getSiteStatPage } from "@api/sw/stat" import { useLocalStorage, useProvide, useProvider, useRequest } from "@hooks" -import { selectSitePage, type SiteQuery } from "@service/stat-service" -import { MILL_PER_DAY } from "@util/time" -import { reactive, toRaw, watch, type Reactive, type Ref } from "vue" +import { cvtDateRange2Str, MILL_PER_DAY } from "@util/time" +import { reactive, type ShallowRef, toRaw, watch } from "vue" export type BizOption = { name: string @@ -20,8 +20,8 @@ export type TopKFilterOption = { } type Context = { - value: Ref - filter: Reactive + value: ShallowRef + filter: TopKFilterOption } const NAMESPACE = 'dashboardTopKVisit' @@ -35,18 +35,17 @@ export const initProvider = () => { const { data: value } = useRequest(async () => { const now = new Date() const startTime: Date = new Date(now.getTime() - MILL_PER_DAY * filter.dayNum) - const query: SiteQuery = { - date: [startTime, now], + const query: timer.stat.SiteQuery = { + date: cvtDateRange2Str([startTime, now]), sortKey: "time", sortDirection: 'DESC', mergeDate: true, } const SIZE = filter.topK - const top = (await selectSitePage(query, { num: 1, size: SIZE })).list - const data: BizOption[] = top.map(({ time, siteKey, alias }) => ({ - name: alias ?? siteKey?.host ?? '', - host: siteKey?.host ?? '', - alias, + const { list: top } = await getSiteStatPage({ num: 1, size: SIZE, ...query }) + const data: BizOption[] = top.map(({ time, siteKey: { host }, alias }) => ({ + name: alias ?? host, + host, alias, value: time, })) for (let realSize = top.length; realSize < SIZE; realSize++) { diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/index.tsx b/src/pages/app/components/Dashboard/components/TopKVisit/index.tsx index 1bc35e1bf..cec5ae07a 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/index.tsx +++ b/src/pages/app/components/Dashboard/components/TopKVisit/index.tsx @@ -4,10 +4,10 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { useXsState } from '@hooks/useMediaSize' +import ChartTitle from "@app/components/Dashboard/ChartTitle" +import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" import { computed, defineComponent } from "vue" -import ChartTitle from "../../ChartTitle" import BarChart from "./BarChart" import { initProvider } from "./context" import HalfBarChart from "./HalfBarChart" @@ -34,7 +34,7 @@ const _default = defineComponent(() => { - </ChartTitle > + </ChartTitle> <Flex flex={1}> {chart.value} </Flex> diff --git a/src/pages/app/components/Dashboard/index.tsx b/src/pages/app/components/Dashboard/index.tsx index c9d9d62b5..456f88648 100644 --- a/src/pages/app/components/Dashboard/index.tsx +++ b/src/pages/app/components/Dashboard/index.tsx @@ -6,15 +6,15 @@ */ import { t } from "@app/locale" -import { MediaSize, useManualRequest, useMediaSize, useRequest, useXsState } from "@hooks" +import { MediaSize, useMediaSize, useRequest, useXsState } from "@hooks" import { isTranslatingLocale, locale } from "@i18n" import Flex from "@pages/components/Flex" -import { recommendRate, saveFlag } from "@service/meta-service" +import { rateClicked, recommendRate } from '@pages/util/rate' import { REVIEW_PAGE } from "@util/constant/url" import { ElRow, ElScrollbar } from "element-plus" import { computed, defineComponent, type FunctionalComponent } from "vue" import { useRouter } from "vue-router" -import ContentContainer from "../common/ContentContainer" +import ContentContainer from '../common/ContentContainer' import Calendar from "./components/Calendar" import Indicator from "./components/Indicator" import MonthOnMonth from "./components/MonthOnMonth" @@ -43,7 +43,10 @@ const _default = defineComponent(() => { const isNotEnOrZhCn = locale !== "en" && locale !== "zh_CN" const showHelp = isTranslatingLocale() || isNotEnOrZhCn const { data: showRate, refresh } = useRequest(recommendRate) - const { refresh: handleRate } = useManualRequest(() => saveFlag("rateOpen"), { onSuccess: refresh }) + const onRateClicked = () => { + rateClicked() + refresh() + } const mediaSize = useMediaSize() const isXs = useXsState() @@ -95,7 +98,7 @@ const _default = defineComponent(() => { <a href={REVIEW_PAGE} target="_blank" - onClick={handleRate} + onClick={onRateClicked} style={{ color: 'inherit' }} > {t(msg => msg.about.text.rate)} diff --git a/src/pages/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx b/src/pages/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx index 3cdd8241c..7e203e5be 100644 --- a/src/pages/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx +++ b/src/pages/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ import { t, tN } from "@app/locale" +import { getDatePickerIconSlots } from '@pages/element-ui/rtl' +import type { ElDatePickerShortcut } from '@pages/element-ui/types' import { dateFormat as elDateFormat } from "@i18n/element" -import { getDatePickerIconSlots } from "@pages/element-ui/rtl" -import type { ElDatePickerShortcut } from '@pages/types' import { formatTime, getBirthday, MILL_PER_DAY } from "@util/time" import { ElDatePicker } from "element-plus" import { defineComponent, type PropType, type StyleValue } from "vue" diff --git a/src/pages/app/components/DataManage/ClearPanel/ClearFilter/index.tsx b/src/pages/app/components/DataManage/ClearPanel/ClearFilter/index.tsx index 8838c0fe3..cda33e85e 100644 --- a/src/pages/app/components/DataManage/ClearPanel/ClearFilter/index.tsx +++ b/src/pages/app/components/DataManage/ClearPanel/ClearFilter/index.tsx @@ -4,9 +4,9 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ +import { useState } from "@hooks" import { t } from "@app/locale" import { Delete } from "@element-plus/icons-vue" -import { useState } from "@hooks" import { ElButton } from "element-plus" import { defineComponent } from "vue" import DateFilter from "./DateFilter" diff --git a/src/pages/app/components/DataManage/ClearPanel/index.tsx b/src/pages/app/components/DataManage/ClearPanel/index.tsx index 48b14b0a2..f157f7408 100644 --- a/src/pages/app/components/DataManage/ClearPanel/index.tsx +++ b/src/pages/app/components/DataManage/ClearPanel/index.tsx @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ +import { batchDeleteStats, listGroupStats, listSiteStats } from "@api/sw/stat" import { t } from "@app/locale" -import db, { type StatCondition } from "@db/stat-database" -import { MILL_PER_DAY, MILL_PER_SECOND } from "@util/time" +import { cvtDateRange2Str, getBirthday, MILL_PER_DAY, MILL_PER_SECOND } from "@util/time" import { ElCard, ElMessage, ElMessageBox } from "element-plus" import { defineComponent, type StyleValue } from "vue" import { useDataMemory } from "../context" @@ -20,21 +20,20 @@ type FilterOption = { time: [string?, string?] } -async function generateParamAndSelect(option: FilterOption): Promise<timer.core.Row[]> { - const param = checkParam(option) - if (!param) { - ElMessage.warning(t(msg => msg.dataManage.paramError)) - return [] - } +type ClearFilterRanges = { + focusRange: [number, number] + timeRange: [number, number?] +} +function buildClearStatQuery(option: FilterOption): (timer.stat.SiteQuery & timer.stat.GroupQuery) | undefined { + const param = checkParam(option) + if (!param) return undefined const { date } = option - let [dateStart, dateEnd] = date || [] - if (dateEnd == null) { - // default end time is the yesterday - dateEnd = new Date(new Date().getTime() - MILL_PER_DAY) - } - param.date = dateStart ? [dateStart, dateEnd] : undefined - return await db.select(param) + let [ + start = getBirthday(), + end = new Date(Date.now() - MILL_PER_DAY), + ] = date ?? [] + return { ...param, date: cvtDateRange2Str([start, end]) } } /** @@ -57,7 +56,7 @@ function assertQueryParam(range: [number, number?], mustInteger?: boolean): bool const str2Num = (str: string | undefined) => str ? parseInt(str) : undefined const seconds2Milliseconds = (a: number) => a * MILL_PER_SECOND -function checkParam(option: FilterOption): StatCondition | undefined { +function checkParam(option: FilterOption): ClearFilterRanges | undefined { const { focus, time } = option let hasError = false const focusRange = str2Range(focus, seconds2Milliseconds) as [number, number] @@ -67,10 +66,7 @@ function checkParam(option: FilterOption): StatCondition | undefined { if (hasError) { return undefined } - const condition: StatCondition = {} - condition.focusRange = focusRange - condition.timeRange = timeRange - return condition + return { focusRange, timeRange } } function str2Range(startAndEnd: [string?, string?], numAmplifier?: (origin: number) => number): [number, number | undefined] { @@ -86,15 +82,23 @@ function str2Range(startAndEnd: [string?, string?], numAmplifier?: (origin: numb const _default = defineComponent(() => { const { refreshMemory } = useDataMemory() async function handleClick(option: FilterOption) { - const result = await generateParamAndSelect(option) + const q = buildClearStatQuery(option) + if (!q) { + ElMessage.warning(t(msg => msg.dataManage.paramError)) + return + } + const siteRows = await listSiteStats(q) + const groupRows = await listGroupStats(q) + + const count = siteRows.length + groupRows.length + if (!count) return - const count = result.length const confirmMsg = t(msg => msg.dataManage.deleteConfirm, { count }) ElMessageBox.confirm(confirmMsg, { cancelButtonText: t(msg => msg.button.cancel), - confirmButtonText: t(msg => msg.button.confirm) + confirmButtonText: t(msg => msg.button.confirm), }).then(async () => { - await db.delete(...result) + await batchDeleteStats([...siteRows, ...groupRows]) ElMessage.success(t(msg => msg.operation.successMsg)) refreshMemory?.() }).catch(() => { }) diff --git a/src/pages/app/components/DataManage/MemoryInfo.tsx b/src/pages/app/components/DataManage/MemoryInfo.tsx index ceac99a20..8212d981c 100644 --- a/src/pages/app/components/DataManage/MemoryInfo.tsx +++ b/src/pages/app/components/DataManage/MemoryInfo.tsx @@ -5,10 +5,10 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { getOption } from '@api/sw/option' +import { t } from '@app/locale' import { OPTION_ROUTE } from '@app/router/constants' -import optionDatabase from '@db/option-database' -import { useRequest } from '@hooks/useRequest' +import { useRequest } from '@hooks' import Flex from "@pages/components/Flex" import { getColor } from '@pages/util/style' import { getAppPageUrl } from '@util/constant/url' @@ -41,7 +41,7 @@ const totalTitle = (totalMb: number) => totalMb const _default = defineComponent(() => { const { memory } = useDataMemory() - const { data: option } = useRequest(() => optionDatabase.getOption()) + const { data: option } = useRequest(getOption) const usedMb = computed(() => byte2Mb(memory.value?.used)) const totalMb = computed(() => byte2Mb(memory.value?.total)) const percentage = computed(() => memory.value?.total ? Math.round(memory.value?.used * 10000.0 / memory.value.total) / 100 : 0) diff --git a/src/pages/app/components/DataManage/Migration/ImportButton.tsx b/src/pages/app/components/DataManage/Migration/ImportButton.tsx index be13563c4..f4885c7b2 100644 --- a/src/pages/app/components/DataManage/Migration/ImportButton.tsx +++ b/src/pages/app/components/DataManage/Migration/ImportButton.tsx @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { sendMsg2Runtime } from '@api/sw/common' +import { t } from '@app/locale' import { Upload } from "@element-plus/icons-vue" -import immigration from '@service/components/immigration' import { deserialize } from "@util/file" import { ElButton, ElLoading, ElMessage } from "element-plus" import { defineComponent, ref } from "vue" @@ -22,10 +22,8 @@ async function handleFileSelected(fileInput: HTMLInputElement | undefined, callb const file: File = files[0] const fileText = await file.text() const data = deserialize(fileText) - if (!data) { - ElMessage.error(t(msg => msg.dataManage.importError)) - } - await immigration.importData(data) + if (!data) return ElMessage.error(t(msg => msg.dataManage.importError)) + await sendMsg2Runtime('immigration.import', data) loading.close() callback?.() ElMessage.success(t(msg => msg.operation.successMsg)) diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step1.tsx b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step1.tsx index 014856377..72ca3c1d2 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step1.tsx +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step1.tsx @@ -6,7 +6,7 @@ */ import { useDialogSop } from '@app/components/common/DialogSop/context' -import { t } from "@app/locale" +import { t } from '@app/locale' import { Document } from "@element-plus/icons-vue" import Flex from "@pages/components/Flex" import { ElButton, ElForm, ElFormItem, ElSelect } from "element-plus" @@ -27,7 +27,7 @@ const OTHER_FILE_FORMAT: { [ext in OtherExtension]: string } = { const ALL_TYPES: OtherExtension[] = Object.keys(OTHER_NAMES) as OtherExtension[] -const _default = defineComponent<{}>((_, ctx) => { +const _default = defineComponent<{}>(() => { const { form } = useDialogSop<ImportForm>() const fileInput = ref<HTMLInputElement>() diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step2.tsx b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step2.tsx index f91390acb..59d40c9e9 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step2.tsx +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step2.tsx @@ -12,7 +12,7 @@ import Flex from "@pages/components/Flex" import { defineComponent } from "vue" import type { ImportForm } from './types' -const _default = defineComponent<{}>((props, ctx) => { +const _default = defineComponent<{}>(() => { const { form } = useDialogSop<ImportForm>() return () => ( diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/index.tsx b/src/pages/app/components/DataManage/Migration/ImportOtherButton/index.tsx index 9fa3c5241..cbd6e5762 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/index.tsx +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/index.tsx @@ -5,15 +5,15 @@ * https://opensource.org/licenses/MIT */ +import { importOther } from '@api/sw/immigration' import DialogSop from '@app/components/common/DialogSop' import { initDialogSopContext } from '@app/components/common/DialogSop/context' +import { useDataMemory } from '@app/components/DataManage/context' import { t } from "@app/locale" import { Upload } from "@element-plus/icons-vue" import Flex from '@pages/components/Flex' -import { processImportedData } from '@service/components/import-processor' import { ElButton } from "element-plus" -import { defineComponent } from "vue" -import { useDataMemory } from "../../context" +import { defineComponent, toRaw } from "vue" import { parseFile } from './processor' import Step1 from './Step1' import Step2 from './Step2' @@ -43,7 +43,7 @@ const _default = defineComponent(() => { if (!data) throw new Error(t(msg => msg.dataManage.importOther.fileNotSelected)) const resolution = form.resolution if (!resolution) throw new Error(t(msg => msg.dataManage.importOther.conflictNotSelected)) - await processImportedData(data, resolution) + await importOther({ data: toRaw(data), resolution }) refreshMemory?.() }, }) diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts b/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts index 8dfb19cbe..001824e8d 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts @@ -5,24 +5,17 @@ * https://opensource.org/licenses/MIT */ -import { fillExist } from "@service/components/import-processor" -import { AUTHOR_EMAIL } from "@src/package" +import { AUTHOR_EMAIL } from "@/package" +import { sendMsg2Runtime } from '@api/sw/common' import { IS_WINDOWS } from "@util/constant/environment" import { extractHostname, isBrowserUrl } from "@util/pattern" +import { mergeWith } from '@util/stat' import { formatTimeYMD, MILL_PER_SECOND } from "@util/time" import type { OtherExtension } from './types' const throwError = () => { throw new Error("Failed to parse, please check your file or contact the author via " + AUTHOR_EMAIL) } -/** - * Parse the content to rows - * - * @param type extension type - * @param file selected file - * @returns row data - */ export async function parseFile(ext: OtherExtension, file: File): Promise<timer.imported.Data> { - // const worker = new Worker() let rows: timer.imported.Row[] = [] let focus = false let time = false @@ -36,7 +29,8 @@ export async function parseFile(ext: OtherExtension, file: File): Promise<timer. rows = await parseHistoryTrendsUnlimited(file) time = true } - await fillExist(rows) + const exists = await sendMsg2Runtime('item.batch', rows) + await mergeWith(rows, exists, (r, exist) => { r.exist = exist }) return { rows, focus, time } } diff --git a/src/pages/app/components/DataManage/Migration/index.tsx b/src/pages/app/components/DataManage/Migration/index.tsx index e47f22ce7..9fb79d45f 100644 --- a/src/pages/app/components/DataManage/Migration/index.tsx +++ b/src/pages/app/components/DataManage/Migration/index.tsx @@ -5,10 +5,10 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { sendMsg2Runtime } from '@api/sw/common' +import { t } from '@app/locale' import { Download } from "@element-plus/icons-vue" import Flex from "@pages/components/Flex" -import immigration from '@service/components/immigration' import { exportJson } from "@util/file" import { formatTime } from "@util/time" import { ElButton, ElCard } from "element-plus" @@ -18,7 +18,7 @@ import ImportButton from "./ImportButton" import ImportOtherButton from "./ImportOtherButton" async function handleExport() { - const data = await immigration.exportData() + const data = await sendMsg2Runtime('immigration.export') const timestamp = formatTime(new Date(), '{y}{m}{d}_{h}{i}{s}') exportJson(data, `timer_backup_${timestamp}`) } diff --git a/src/pages/app/components/DataManage/context.ts b/src/pages/app/components/DataManage/context.ts index ac8877778..910869dc8 100644 --- a/src/pages/app/components/DataManage/context.ts +++ b/src/pages/app/components/DataManage/context.ts @@ -1,16 +1,19 @@ -import { getUsedStorage, type MemoryInfo } from "@db/memory-detector" -import { useProvide, useProvider, useRequest } from "@hooks" -import { type Ref } from "vue" +import { sendMsg2Runtime } from '@api/sw/common' +import { useProvide, useProvider, useRequest } from '@hooks' +import { type ShallowRef } from "vue" type Context = { - memory: Ref<MemoryInfo> + memory: ShallowRef<timer.common.StorageUsage> refreshMemory: () => void } const NAMESPACE = 'dataManage' export const initDataManage = () => { - const { data: memory, refresh: refreshMemory } = useRequest(getUsedStorage, { defaultValue: { used: 0, total: 1 } }) + const { data: memory, refresh: refreshMemory } = useRequest( + () => sendMsg2Runtime('meta.usedStorage'), + { defaultValue: { used: 0, total: 1 } }, + ) useProvide<Context>(NAMESPACE, { memory, refreshMemory }) } diff --git a/src/pages/app/components/DataManage/index.tsx b/src/pages/app/components/DataManage/index.tsx index a2a457f59..8e8c21013 100644 --- a/src/pages/app/components/DataManage/index.tsx +++ b/src/pages/app/components/DataManage/index.tsx @@ -5,11 +5,11 @@ * https://opensource.org/licenses/MIT */ -import { MediaSize, useMediaSize } from '@hooks/useMediaSize' +import { MediaSize, useMediaSize } from '@hooks' import Flex from "@pages/components/Flex" import { ElScrollbar } from 'element-plus' import { computed, defineComponent, type StyleValue } from "vue" -import ContentContainer from "../common/ContentContainer" +import ContentContainer from '../common/ContentContainer' import ClearPanel from './ClearPanel' import MemoryInfo from "./MemoryInfo" import Migration from "./Migration" @@ -34,7 +34,7 @@ export default defineComponent(() => { </Flex> <ClearPanel /> </Flex> - </ContentContainer > + </ContentContainer> </ElScrollbar> ) }) \ No newline at end of file diff --git a/src/pages/app/components/Habit/components/HabitFilter.tsx b/src/pages/app/components/Habit/HabitFilter.tsx similarity index 85% rename from src/pages/app/components/Habit/components/HabitFilter.tsx rename to src/pages/app/components/Habit/HabitFilter.tsx index e51a79a0f..f2d9c4e61 100644 --- a/src/pages/app/components/Habit/components/HabitFilter.tsx +++ b/src/pages/app/components/Habit/HabitFilter.tsx @@ -5,11 +5,11 @@ * https://opensource.org/licenses/MIT */ -import DateRangeFilterItem from "@app/components/common/filter/DateRangeFilterItem" -import TimeFormatFilterItem from "@app/components/common/filter/TimeFormatFilterItem" -import { t } from "@app/locale" +import DateRangeFilterItem from '@app/components/common/filter/DateRangeFilterItem' +import TimeFormatFilterItem from '@app/components/common/filter/TimeFormatFilterItem' +import { t } from '@app/locale' import Flex from "@pages/components/Flex" -import type { ElDatePickerShortcut } from '@pages/types' +import { ElDatePickerShortcut } from '@pages/element-ui/types' import { daysAgo, MILL_PER_DAY } from "@util/time" import { defineComponent } from "vue" import { useHabitFilter } from "./context" diff --git a/src/pages/app/components/Habit/components/Period/Average/Wrapper.tsx b/src/pages/app/components/Habit/Period/Average/Wrapper.tsx similarity index 95% rename from src/pages/app/components/Habit/components/Period/Average/Wrapper.tsx rename to src/pages/app/components/Habit/Period/Average/Wrapper.tsx index 42711e9c1..e271030e5 100644 --- a/src/pages/app/components/Habit/components/Period/Average/Wrapper.tsx +++ b/src/pages/app/components/Habit/Period/Average/Wrapper.tsx @@ -6,15 +6,11 @@ */ import { t } from "@app/locale" import { getCompareColor, tooltipDot, tooltipFlexLine, tooltipSpaceLine } from "@app/util/echarts" -import { EchartsWrapper } from "@hooks/useEcharts" -import { getPrimaryTextColor } from "@pages/util/style" +import { EchartsWrapper } from "@hooks" +import { getPrimaryTextColor } from '@pages/util/style' import { averageByDay, MINUTE_PER_PERIOD } from "@util/period" import { formatPeriodCommon, MILL_PER_MINUTE } from "@util/time" -import { - type BarSeriesOption, - type ComposeOption, - type GridComponentOption, type TooltipComponentOption -} from "echarts" +import type { BarSeriesOption, ComposeOption, GridComponentOption, TooltipComponentOption } from "echarts" import { type TopLevelFormatterParams } from "echarts/types/dist/shared" import { generateGridOption } from "../common" diff --git a/src/pages/app/components/Habit/components/Period/Average/index.tsx b/src/pages/app/components/Habit/Period/Average/index.tsx similarity index 95% rename from src/pages/app/components/Habit/components/Period/Average/index.tsx rename to src/pages/app/components/Habit/Period/Average/index.tsx index b60cbd5ba..9c86cd04f 100644 --- a/src/pages/app/components/Habit/components/Period/Average/index.tsx +++ b/src/pages/app/components/Habit/Period/Average/index.tsx @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { useEcharts } from "@hooks/useEcharts" +import { useEcharts } from "@hooks" import { computed, defineComponent, type StyleValue } from "vue" import { usePeriodFilter, usePeriodRange, usePeriodValue } from "../context" import Wrapper, { type BizOption } from "./Wrapper" diff --git a/src/pages/app/components/Habit/components/Period/Filter.tsx b/src/pages/app/components/Habit/Period/Filter.tsx similarity index 98% rename from src/pages/app/components/Habit/components/Period/Filter.tsx rename to src/pages/app/components/Habit/Period/Filter.tsx index cfb66da81..fcc366155 100644 --- a/src/pages/app/components/Habit/components/Period/Filter.tsx +++ b/src/pages/app/components/Habit/Period/Filter.tsx @@ -7,12 +7,12 @@ import SelectFilterItem from '@app/components/common/filter/SelectFilterItem' import { t } from '@app/locale' -import { type HabitMessage } from '@i18n/message/app/habit' import Flex from '@pages/components/Flex' +import { type HabitMessage } from '@i18n/message/app/habit' import { ElRadioButton, ElRadioGroup } from 'element-plus' import { defineComponent } from 'vue' -import { type ChartType } from './common' import { usePeriodFilter } from './context' +import type { ChartType } from './types' // [value, label] type _SizeOption = [number, keyof HabitMessage['period']['sizes']] diff --git a/src/pages/app/components/Habit/components/Period/Stack/Wrapper.ts b/src/pages/app/components/Habit/Period/Stack/Wrapper.ts similarity index 85% rename from src/pages/app/components/Habit/components/Period/Stack/Wrapper.ts rename to src/pages/app/components/Habit/Period/Stack/Wrapper.ts index 597a36e58..395ffec4c 100644 --- a/src/pages/app/components/Habit/components/Period/Stack/Wrapper.ts +++ b/src/pages/app/components/Habit/Period/Stack/Wrapper.ts @@ -1,14 +1,9 @@ -import { t } from "@app/locale" +import { t } from '@app/locale' import { getLineSeriesPalette } from "@app/util/echarts" -import { periodFormatter } from "@app/util/time" -import { EchartsWrapper } from "@hooks/useEcharts" +import { periodFormatter } from '@app/util/time' +import { EchartsWrapper } from "@hooks" import { formatTime } from "@util/time" -import { - type ComposeOption, - type GridComponentOption, - type LineSeriesOption, - type TooltipComponentOption, -} from "echarts" +import type { ComposeOption, GridComponentOption, LineSeriesOption, TooltipComponentOption } from "echarts" import { type TopLevelFormatterParams } from "echarts/types/dist/shared" import { formatXAxisTime, generateGridOption } from "../common" @@ -38,8 +33,8 @@ const generateOption = (biz: BizOption): EcOption => { const { data, timeFormat } = biz || {} let stackVal: number = 0 const seriesData = data.map(row => { - const startTime = row.startTime.getTime() - const endTime = row.endTime.getTime() + const startTime = row.startTime + const endTime = row.endTime const time = (startTime + endTime) / 2 const delta = row.milliseconds ?? 0 stackVal += delta diff --git a/src/pages/app/components/Habit/components/Period/Stack/index.tsx b/src/pages/app/components/Habit/Period/Stack/index.tsx similarity index 88% rename from src/pages/app/components/Habit/components/Period/Stack/index.tsx rename to src/pages/app/components/Habit/Period/Stack/index.tsx index 8afe72cdb..ddf610a2b 100644 --- a/src/pages/app/components/Habit/components/Period/Stack/index.tsx +++ b/src/pages/app/components/Habit/Period/Stack/index.tsx @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { useEcharts } from "@hooks/useEcharts" +import { useHabitFilter } from "@app/components/Habit/context" +import { useEcharts } from "@hooks" import { computed, defineComponent, type StyleValue } from "vue" -import { useHabitFilter } from "../../context" import { usePeriodValue } from "../context" import Wrapper, { type BizOption } from "./Wrapper" diff --git a/src/pages/app/components/Habit/components/Period/Summary.tsx b/src/pages/app/components/Habit/Period/Summary.tsx similarity index 76% rename from src/pages/app/components/Habit/components/Period/Summary.tsx rename to src/pages/app/components/Habit/Period/Summary.tsx index 8acabafb7..6d4ec66e1 100644 --- a/src/pages/app/components/Habit/components/Period/Summary.tsx +++ b/src/pages/app/components/Habit/Period/Summary.tsx @@ -1,8 +1,8 @@ -import { GRID_CELL_STYLE } from "@app/components/common/grid" -import { KanbanIndicatorCell } from "@app/components/common/kanban" -import { t } from "@app/locale" -import { periodFormatter } from "@app/util/time" -import { useXsState } from "@hooks" +import { GRID_CELL_STYLE } from '@app/components/common/grid' +import { KanbanIndicatorCell } from '@app/components/common/kanban' +import { t } from '@app/locale' +import { periodFormatter } from '@app/util/time' +import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" import { averageByDay } from "@util/period" import { formatTime } from "@util/time" @@ -38,7 +38,7 @@ const computeSummary = (rows: timer.period.Row[], periodSize: number): Result => rows.forEach(r => { if (r.milliseconds) { if (!idleStart || !idleEnd) return - const newEmptyTs = idleEnd.endTime.getTime() - idleStart.endTime.getTime() + const newEmptyTs = idleEnd.endTime - idleStart.endTime if (newEmptyTs > maxIdle[2]) { maxIdle = [idleStart, idleEnd, newEmptyTs] } @@ -54,7 +54,7 @@ const computeSummary = (rows: timer.period.Row[], periodSize: number): Result => let idleLength = '-' let idlePeriod = '' if (start && end) { - idleLength = periodFormatter(end.endTime.getTime() - start.startTime.getTime(), { format: 'hour' }) + idleLength = periodFormatter(end.endTime - start.startTime, { format: 'hour' }) const format = t(msg => msg.calendar.simpleTimeFormat) const startTime = formatTime(start.startTime, format) const endTime = formatTime(end.endTime, format) @@ -77,22 +77,22 @@ const _default = defineComponent(() => { const data = usePeriodValue() const filter = usePeriodFilter() const globalFilter = useHabitFilter() - const summary = computed(() => computeSummary(data.value?.curr, filter.periodSize)) + const summary = computed(() => computeSummary(data.value.curr, filter.periodSize)) const isXs = useXsState() return () => ( <Flex column gap={1} flex={isXs.value ? undefined : 1}> <KanbanIndicatorCell mainName={t(msg => msg.habit.period.busiest)} - mainValue={summary.value?.favorite?.period} + mainValue={summary.value.favorite.period} subTips={msg => msg.habit.common.focusAverage} - subValue={periodFormatter(summary.value?.favorite?.average, { format: globalFilter.timeFormat })} + subValue={periodFormatter(summary.value.favorite.average, { format: globalFilter.timeFormat })} containerStyle={GRID_CELL_STYLE} /> <KanbanIndicatorCell mainName={t(msg => msg.habit.period.idle)} - mainValue={summary.value?.longestIdle?.length} - subTips={() => summary.value?.longestIdle?.period} + mainValue={summary.value.longestIdle.length} + subTips={() => summary.value.longestIdle.period} containerStyle={GRID_CELL_STYLE} /> </Flex> diff --git a/src/pages/app/components/Habit/components/Period/Trend/Wrapper.ts b/src/pages/app/components/Habit/Period/Trend/Wrapper.ts similarity index 87% rename from src/pages/app/components/Habit/components/Period/Trend/Wrapper.ts rename to src/pages/app/components/Habit/Period/Trend/Wrapper.ts index 6746a279b..ef1c1f21c 100644 --- a/src/pages/app/components/Habit/components/Period/Trend/Wrapper.ts +++ b/src/pages/app/components/Habit/Period/Trend/Wrapper.ts @@ -5,16 +5,11 @@ * https://opensource.org/licenses/MIT */ import { getSeriesPalette } from "@app/util/echarts" -import { periodFormatter } from "@app/util/time" -import { EchartsWrapper } from "@hooks/useEcharts" -import { getPrimaryTextColor } from "@pages/util/style" +import { periodFormatter } from '@app/util/time' +import { EchartsWrapper } from "@hooks" +import { getPrimaryTextColor } from '@pages/util/style' import { formatTime } from "@util/time" -import { - type BarSeriesOption, - type ComposeOption, - type GridComponentOption, - type TooltipComponentOption, -} from "echarts" +import type { BarSeriesOption, ComposeOption, GridComponentOption, TooltipComponentOption } from "echarts" import type { TopLevelFormatterParams } from "echarts/types/dist/shared" import { formatXAxisTime, generateGridOption } from "../common" @@ -49,8 +44,8 @@ function formatTimeOfEcharts(params: TopLevelFormatterParams, timeFormat: timer. type BarItem = Exclude<BarSeriesOption["data"], undefined>[number] const cvt2Item = (row: timer.period.Row): BarItem => { - const startTime = row.startTime.getTime() - const endTime = row.endTime.getTime() + const startTime = row.startTime + const endTime = row.endTime const time = (startTime + endTime) / 2 const milliseconds = row.milliseconds return [time, milliseconds, startTime, endTime] diff --git a/src/pages/app/components/Habit/components/Period/Trend/index.tsx b/src/pages/app/components/Habit/Period/Trend/index.tsx similarity index 94% rename from src/pages/app/components/Habit/components/Period/Trend/index.tsx rename to src/pages/app/components/Habit/Period/Trend/index.tsx index 7503c6b12..4d2bfd5c9 100644 --- a/src/pages/app/components/Habit/components/Period/Trend/index.tsx +++ b/src/pages/app/components/Habit/Period/Trend/index.tsx @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { useEcharts } from "@hooks/useEcharts" +import { useEcharts } from "@hooks" import { computed, defineComponent, type StyleValue } from "vue" import { useHabitFilter } from "../../context" import { usePeriodValue } from "../context" diff --git a/src/pages/app/components/Habit/components/Period/common.ts b/src/pages/app/components/Habit/Period/common.ts similarity index 85% rename from src/pages/app/components/Habit/components/Period/common.ts rename to src/pages/app/components/Habit/Period/common.ts index 9334f5287..fc307fea7 100644 --- a/src/pages/app/components/Habit/components/Period/common.ts +++ b/src/pages/app/components/Habit/Period/common.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { t } from '@app/locale' import { formatTime } from "@util/time" import { type GridComponentOption } from "echarts/components" @@ -18,13 +18,6 @@ export const generateGridOption = (): GridComponentOption => { } } -export type ChartType = 'average' | 'trend' | 'stack' - -export type FilterOption = { - periodSize: number - chartType: ChartType -} - const MONTHS = t(msg => msg.calendar.months).split('|') export const formatXAxisTime = (time: number, idx: number): string => { diff --git a/src/pages/app/components/Habit/components/Period/context.ts b/src/pages/app/components/Habit/Period/context.ts similarity index 81% rename from src/pages/app/components/Habit/components/Period/context.ts rename to src/pages/app/components/Habit/Period/context.ts index 50f7d21c2..e9cdbec3b 100644 --- a/src/pages/app/components/Habit/components/Period/context.ts +++ b/src/pages/app/components/Habit/Period/context.ts @@ -5,21 +5,20 @@ * https://opensource.org/licenses/MIT */ -import { useLocalStorage, useProvide, useProvider, useRequest } from "@hooks" -import { merge } from "@service/components/period-calculator" -import periodService from "@service/period-service" +import { listPeriods } from '@api/sw/period' +import { useLocalStorage, useProvide, useProvider, useRequest } from '@hooks' import { keyOf, MAX_PERIOD_ORDER } from "@util/period" import { getDayLength, MILL_PER_DAY } from "@util/time" import { computed, reactive, toRaw, watch, type Reactive, type Ref } from "vue" import { useHabitFilter } from "../context" -import type { FilterOption } from "./common" +import type { FilterOption } from "./types" type Value = { curr: timer.period.Row[] prev: timer.period.Row[] } -export type PeriodRange = { +type PeriodRange = { curr: timer.period.KeyRange prev: timer.period.KeyRange } @@ -41,12 +40,6 @@ const computeRange = (filterDateRange: [Date, Date]): PeriodRange => { } } -const fetchRows = async (range: timer.period.KeyRange, periodSize: number) => { - const results = await periodService.listBetween({ periodRange: range }) - const [start, end] = range || [] - return merge(results, { start, end, periodSize }) -} - const NAMESPACE = 'habitPeriod' export const initProvider = () => { @@ -62,8 +55,8 @@ export const initProvider = () => { const { curr: currRange, prev: prevRange } = periodRange.value || {} const periodSize = filter.periodSize const [curr, prev] = await Promise.all([ - fetchRows(currRange, periodSize), - fetchRows(prevRange, periodSize), + listPeriods({ range: currRange, size: periodSize }), + listPeriods({ range: prevRange, size: periodSize }), ]) return { curr, prev } }, { diff --git a/src/pages/app/components/Habit/components/Period/index.tsx b/src/pages/app/components/Habit/Period/index.tsx similarity index 97% rename from src/pages/app/components/Habit/components/Period/index.tsx rename to src/pages/app/components/Habit/Period/index.tsx index 9a0f78d3e..b977fc567 100644 --- a/src/pages/app/components/Habit/components/Period/index.tsx +++ b/src/pages/app/components/Habit/Period/index.tsx @@ -7,7 +7,7 @@ import { GRID_CELL_STYLE } from "@app/components/common/grid" import { KanbanCard } from "@app/components/common/kanban" -import { useXsState } from "@hooks" +import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" import { defineComponent } from "vue" import Average from "./Average" diff --git a/src/pages/app/components/Habit/Period/types.d.ts b/src/pages/app/components/Habit/Period/types.d.ts new file mode 100644 index 000000000..cc0e80f5f --- /dev/null +++ b/src/pages/app/components/Habit/Period/types.d.ts @@ -0,0 +1,6 @@ +export type ChartType = 'average' | 'trend' | 'stack' + +export type FilterOption = { + periodSize: number + chartType: ChartType +} diff --git a/src/pages/app/components/Habit/components/Site/DailyTrend/Wrapper.ts b/src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts similarity index 93% rename from src/pages/app/components/Habit/components/Site/DailyTrend/Wrapper.ts rename to src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts index 22cc825b0..c1009c008 100644 --- a/src/pages/app/components/Habit/components/Site/DailyTrend/Wrapper.ts +++ b/src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts @@ -1,21 +1,16 @@ -import { t } from "@app/locale" +import { t } from '@app/locale' import { getLineSeriesPalette, tooltipDot, tooltipFlexLine, tooltipSpaceLine } from "@app/util/echarts" import { cvt2LocaleTime, periodFormatter } from "@app/util/time" -import { EchartsWrapper } from "@hooks/useEcharts" +import { EchartsWrapper } from '@hooks' import { groupBy, sum } from "@util/array" import { getHost } from "@util/stat" import { getAllDatesBetween } from "@util/time" -import { - type ComposeOption, - type GridComponentOption, - type LegendComponentOption, - type LineSeriesOption, - type LinearGradientObject, - type TitleComponentOption, - type TooltipComponentOption, +import type { + ComposeOption, GridComponentOption, LegendComponentOption, LineSeriesOption, LinearGradientObject, + TitleComponentOption, TooltipComponentOption, } from "echarts" -import { type TopLevelFormatterParams, type YAXisOption } from "echarts/types/dist/shared" +import type { TopLevelFormatterParams, YAXisOption } from "echarts/types/dist/shared" import { generateTitleOption } from "../common" type EcOption = ComposeOption< diff --git a/src/pages/app/components/Habit/components/Site/DailyTrend/index.tsx b/src/pages/app/components/Habit/Site/DailyTrend/index.tsx similarity index 94% rename from src/pages/app/components/Habit/components/Site/DailyTrend/index.tsx rename to src/pages/app/components/Habit/Site/DailyTrend/index.tsx index 4b08529dd..33da1ad3b 100644 --- a/src/pages/app/components/Habit/components/Site/DailyTrend/index.tsx +++ b/src/pages/app/components/Habit/Site/DailyTrend/index.tsx @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { useEcharts } from "@hooks/useEcharts" +import { useEcharts } from '@hooks' import { computed, defineComponent } from "vue" import { useHabitFilter } from "../../context" import { useRows } from "../context" diff --git a/src/pages/app/components/Habit/components/Site/Distribution/Wrapper.ts b/src/pages/app/components/Habit/Site/Distribution/Wrapper.ts similarity index 97% rename from src/pages/app/components/Habit/components/Site/Distribution/Wrapper.ts rename to src/pages/app/components/Habit/Site/Distribution/Wrapper.ts index 4e5d471a1..339dba860 100644 --- a/src/pages/app/components/Habit/components/Site/Distribution/Wrapper.ts +++ b/src/pages/app/components/Habit/Site/Distribution/Wrapper.ts @@ -1,7 +1,7 @@ -import { t } from "@app/locale" +import { t } from '@app/locale' import { getPieBorderColor, getSeriesPalette } from "@app/util/echarts" -import { EchartsWrapper } from "@hooks/useEcharts" -import { getPrimaryTextColor, getRegularTextColor } from "@pages/util/style" +import { EchartsWrapper } from '@hooks' +import { getPrimaryTextColor, getRegularTextColor } from '@pages/util/style' import { groupBy, sum } from "@util/array" import { getHost } from "@util/stat" import { MILL_PER_HOUR, MILL_PER_MINUTE, MILL_PER_SECOND } from "@util/time" diff --git a/src/pages/app/components/Habit/components/Site/Distribution/index.tsx b/src/pages/app/components/Habit/Site/Distribution/index.tsx similarity index 87% rename from src/pages/app/components/Habit/components/Site/Distribution/index.tsx rename to src/pages/app/components/Habit/Site/Distribution/index.tsx index 157c65a32..36adeb40a 100644 --- a/src/pages/app/components/Habit/components/Site/Distribution/index.tsx +++ b/src/pages/app/components/Habit/Site/Distribution/index.tsx @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { useEcharts } from "@hooks/useEcharts" +import { useHabitFilter } from "@app/components/Habit/context" +import { useEcharts } from '@hooks' import { computed, defineComponent } from "vue" -import { useHabitFilter } from "../../context" import { useDateMergedRows } from "../context" import Wrapper, { type BizOption } from "./Wrapper" diff --git a/src/pages/app/components/Habit/components/Site/Summary.tsx b/src/pages/app/components/Habit/Site/Summary.tsx similarity index 92% rename from src/pages/app/components/Habit/components/Site/Summary.tsx rename to src/pages/app/components/Habit/Site/Summary.tsx index a87127589..87dbfd3d5 100644 --- a/src/pages/app/components/Habit/components/Site/Summary.tsx +++ b/src/pages/app/components/Habit/Site/Summary.tsx @@ -1,8 +1,8 @@ -import { GRID_CELL_STYLE } from "@app/components/common/grid" -import { KanbanIndicatorCell } from "@app/components/common/kanban" -import { t } from "@app/locale" -import { periodFormatter } from "@app/util/time" -import { useXsState } from "@hooks" +import { GRID_CELL_STYLE } from '@app/components/common/grid' +import { KanbanIndicatorCell } from '@app/components/common/kanban' +import { t } from '@app/locale' +import { periodFormatter } from '@app/util/time' +import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" import { sum } from "@util/array" import { getHost } from "@util/stat" diff --git a/src/pages/app/components/Habit/components/Site/TopK/Wrapper.ts b/src/pages/app/components/Habit/Site/TopK/Wrapper.ts similarity index 94% rename from src/pages/app/components/Habit/components/Site/TopK/Wrapper.ts rename to src/pages/app/components/Habit/Site/TopK/Wrapper.ts index 85d981f7d..d889fe032 100644 --- a/src/pages/app/components/Habit/components/Site/TopK/Wrapper.ts +++ b/src/pages/app/components/Habit/Site/TopK/Wrapper.ts @@ -5,18 +5,14 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { t } from '@app/locale' import { getStepColors } from "@app/util/echarts" import { periodFormatter } from "@app/util/time" -import { EchartsWrapper } from "@hooks/useEcharts" +import { EchartsWrapper } from '@hooks' import { generateSiteLabel } from "@util/site" import { identifyTargetKey, isSite } from "@util/stat" -import { - type BarSeriesOption, - type ComposeOption, - type GridComponentOption, - type TitleComponentOption, - type TooltipComponentOption, +import type { + BarSeriesOption, ComposeOption, GridComponentOption, TitleComponentOption, TooltipComponentOption, } from "echarts" import { type TopLevelFormatterParams } from "echarts/types/dist/shared" import { type SeriesDataItem, generateTitleOption } from "../common" @@ -59,7 +55,7 @@ function mergeDate(origin: timer.stat.Row[]): timer.stat.Row[] { const map: Record< string, | MakeRequired<timer.stat.SiteRow | timer.stat.CateRow, 'mergedDates' | 'mergedRows'> - | MakeRequired<timer.stat.GroupRow, 'mergedDates'> + | MakeRequired<timer.stat.GroupRow, 'mergedDates' | 'mergedRows'> > = {} origin.forEach(ele => { const { date = '', focus, time } = ele diff --git a/src/pages/app/components/Habit/components/Site/TopK/index.tsx b/src/pages/app/components/Habit/Site/TopK/index.tsx similarity index 86% rename from src/pages/app/components/Habit/components/Site/TopK/index.tsx rename to src/pages/app/components/Habit/Site/TopK/index.tsx index 9fb83b6b1..ec8fa8e2a 100644 --- a/src/pages/app/components/Habit/components/Site/TopK/index.tsx +++ b/src/pages/app/components/Habit/Site/TopK/index.tsx @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { useEcharts } from "@hooks/useEcharts" +import { useHabitFilter } from "@app/components/Habit/context" +import { useEcharts } from '@hooks' import { computed, defineComponent } from "vue" -import { useHabitFilter } from "../../context" import { useDateMergedRows } from "../context" import Wrapper from "./Wrapper" diff --git a/src/pages/app/components/Habit/components/Site/common.ts b/src/pages/app/components/Habit/Site/common.ts similarity index 95% rename from src/pages/app/components/Habit/components/Site/common.ts rename to src/pages/app/components/Habit/Site/common.ts index 26529f06b..b2fbe6154 100644 --- a/src/pages/app/components/Habit/components/Site/common.ts +++ b/src/pages/app/components/Habit/Site/common.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { getRegularTextColor } from "@pages/util/style" +import { getRegularTextColor } from '@pages/util/style' import { formatTimeYMD, getDayLength, isSameDay } from "@util/time" import { type TitleComponentOption } from "echarts/components" diff --git a/src/pages/app/components/Habit/Site/context.ts b/src/pages/app/components/Habit/Site/context.ts new file mode 100644 index 000000000..46cb42eed --- /dev/null +++ b/src/pages/app/components/Habit/Site/context.ts @@ -0,0 +1,42 @@ +/** + * Copyright (c) 2024 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { listSiteStats } from "@api/sw/stat" +import { useProvide, useProvider, useRequest } from '@hooks' +import { cvtDateRange2Str, getDayLength } from "@util/time" +import { computed, type ShallowRef } from "vue" +import { useHabitFilter } from "../context" + +type Context = { + rows: ShallowRef<timer.stat.Row[]> + dateMergedRows: ShallowRef<timer.stat.Row[]> +} + +const NAMESPACE = 'habitSite' + +export const initProvider = () => { + const filter = useHabitFilter() + + const { data: rows } = useRequest(() => listSiteStats({ date: cvtDateRange2Str(filter.dateRange) }), { + deps: [() => filter.dateRange], + defaultValue: [], + }) + + const dateRangeLength = computed(() => getDayLength(filter.dateRange?.[0], filter.dateRange?.[1])) + + const { data: dateMergedRows } = useRequest(() => listSiteStats({ date: cvtDateRange2Str(filter.dateRange), mergeDate: true }), { + deps: [() => filter.dateRange], + defaultValue: [], + }) + useProvide<Context>(NAMESPACE, { rows, dateMergedRows }) + + return dateRangeLength +} + +export const useRows = (): ShallowRef<timer.stat.Row[]> => useProvider<Context, 'rows'>(NAMESPACE, "rows").rows + +export const useDateMergedRows = (): ShallowRef<timer.stat.Row[]> => useProvider<Context, 'dateMergedRows'>(NAMESPACE, 'dateMergedRows').dateMergedRows \ No newline at end of file diff --git a/src/pages/app/components/Habit/components/Site/index.tsx b/src/pages/app/components/Habit/Site/index.tsx similarity index 91% rename from src/pages/app/components/Habit/components/Site/index.tsx rename to src/pages/app/components/Habit/Site/index.tsx index 4dbaa843a..3fc8ec4c9 100644 --- a/src/pages/app/components/Habit/components/Site/index.tsx +++ b/src/pages/app/components/Habit/Site/index.tsx @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { GRID_CELL_STYLE, GRID_WRAPPER_STYLE } from "@app/components/common/grid" +import { GRID_CELL_STYLE, GRID_WRAPPER_STYLE } from '@app/components/common/grid' import { KanbanCard } from "@app/components/common/kanban" -import { useXsState } from "@hooks" +import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" import { computed, defineComponent, type StyleValue } from "vue" import { initProvider } from "./context" @@ -41,7 +41,7 @@ const _default = defineComponent(() => { </Flex> )} </Flex> - </KanbanCard > + </KanbanCard> ) }) diff --git a/src/pages/app/components/Habit/components/Site/context.ts b/src/pages/app/components/Habit/components/Site/context.ts deleted file mode 100644 index 729c5fba1..000000000 --- a/src/pages/app/components/Habit/components/Site/context.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright (c) 2024 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { useProvide, useProvider, useRequest } from "@hooks" -import { selectSite } from "@service/stat-service" -import { mergeDate } from "@service/stat-service/merge/date" -import { getDayLength } from "@util/time" -import { computed, type Ref } from "vue" -import { useHabitFilter } from "../context" - -type Context = { - rows: Ref<timer.stat.Row[]> - dateMergedRows: Ref<timer.stat.Row[]> -} - -const NAMESPACE = 'habitSite' - -export const initProvider = () => { - const filter = useHabitFilter() - - const { data: rows } = useRequest(() => selectSite({ date: filter.dateRange }), { - deps: [() => filter.dateRange], - defaultValue: [], - }) - - const dateRangeLength = computed(() => getDayLength(filter.dateRange?.[0], filter.dateRange?.[1])) - - const dateMergedRows = computed(() => mergeDate(rows.value ?? [])) - useProvide<Context>(NAMESPACE, { rows, dateMergedRows }) - - return dateRangeLength -} - -export const useRows = (): Ref<timer.stat.Row[]> => useProvider<Context, 'rows'>(NAMESPACE, "rows").rows - -export const useDateMergedRows = (): Ref<timer.stat.Row[]> => useProvider<Context, 'dateMergedRows'>(NAMESPACE, 'dateMergedRows').dateMergedRows \ No newline at end of file diff --git a/src/pages/app/components/Habit/components/context.ts b/src/pages/app/components/Habit/context.ts similarity index 93% rename from src/pages/app/components/Habit/components/context.ts rename to src/pages/app/components/Habit/context.ts index b1c10f41e..d217664c0 100644 --- a/src/pages/app/components/Habit/components/context.ts +++ b/src/pages/app/components/Habit/context.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { useProvide, useProvider } from "@hooks" +import { useProvide, useProvider } from '@hooks' import { daysAgo } from "@util/time" import { reactive, Reactive } from "vue" diff --git a/src/pages/app/components/Habit/index.tsx b/src/pages/app/components/Habit/index.tsx index d77a2e9cf..67ca15e82 100644 --- a/src/pages/app/components/Habit/index.tsx +++ b/src/pages/app/components/Habit/index.tsx @@ -4,20 +4,20 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import ContentContainer, { FilterContainer } from "@app/components/common/ContentContainer" import { ElScrollbar } from "element-plus" import { defineComponent, type StyleValue } from "vue" -import HabitFilter from "./components/HabitFilter" -import Period from "./components/Period" -import Site from "./components/Site" -import { initHabit } from "./components/context" +import ContentContainer, { FilterContainer } from '../common/ContentContainer' +import HabitFilter from "./HabitFilter" +import Period from "./Period" +import Site from "./Site" +import { initHabit } from "./context" const _default = defineComponent(() => { initHabit() return () => ( <ElScrollbar height="100%" style={{ width: '100%' } satisfies StyleValue}> - <ContentContainer > + <ContentContainer> <FilterContainer> <HabitFilter /> </FilterContainer> diff --git a/src/pages/app/components/HelpUs/MemberList.tsx b/src/pages/app/components/HelpUs/MemberList.tsx index 44d2c54f7..15997ab1f 100644 --- a/src/pages/app/components/HelpUs/MemberList.tsx +++ b/src/pages/app/components/HelpUs/MemberList.tsx @@ -6,10 +6,11 @@ */ import { getMembers } from "@api/crowdin" -import { t } from "@app/locale" -import { useRequest } from "@hooks" +import { t } from '@app/locale' +import { useRequest } from '@hooks' import Box from "@pages/components/Box" import Flex from "@pages/components/Flex" +import Img from '@pages/components/Img' import { ElDivider } from "element-plus" import { defineComponent } from "vue" @@ -31,10 +32,8 @@ const _default = defineComponent(() => { target="_blank" style={idx === (arr.length - 1) ? { marginInlineEnd: 'auto' } : undefined} > - <img - src={avatarUrl} - alt={username} - title={username} + <Img + src={avatarUrl} alt={username} title={username} style={{ width: '60px', height: '60px', borderRadius: '30px' }} /> </a> diff --git a/src/pages/app/components/HelpUs/ProgressList.tsx b/src/pages/app/components/HelpUs/ProgressList.tsx index ce9eecd9c..a42444f9a 100644 --- a/src/pages/app/components/HelpUs/ProgressList.tsx +++ b/src/pages/app/components/HelpUs/ProgressList.tsx @@ -6,8 +6,8 @@ */ import { getTranslationStatus, type TranslationStatusInfo } from "@api/crowdin" -import { t } from "@app/locale" -import { useRequest } from "@hooks" +import { t } from '@app/locale' +import { useRequest } from '@hooks' import localeMessages from "@i18n/message/common/locale" import Flex from "@pages/components/Flex" import { ElProgress, type ProgressProps } from "element-plus" diff --git a/src/pages/app/components/HelpUs/index.tsx b/src/pages/app/components/HelpUs/index.tsx index 2f7dfadfa..9cd0f3f35 100644 --- a/src/pages/app/components/HelpUs/index.tsx +++ b/src/pages/app/components/HelpUs/index.tsx @@ -1,12 +1,12 @@ -import { createTabAfterCurrent } from "@api/chrome/tab" -import AlertLines from '@app/components/common/AlertLines' -import { t } from "@app/locale" +import { t } from '@app/locale' import { Pointer } from "@element-plus/icons-vue" import Box from "@pages/components/Box" +import { createTabAfterCurrent } from "@api/chrome/tab" import { CROWDIN_HOMEPAGE } from "@util/constant/url" import { ElButton, ElCard, ElScrollbar } from "element-plus" import type { FunctionalComponent, StyleValue } from "vue" -import ContentContainer from "../common/ContentContainer" +import AlertLines from '../common/AlertLines' +import ContentContainer from '../common/ContentContainer' import MemberList from "./MemberList" import ProgressList from "./ProgressList" diff --git a/src/pages/app/components/Limit/common.ts b/src/pages/app/components/Limit/common.ts index 791e2263c..4dc987ebe 100644 --- a/src/pages/app/components/Limit/common.ts +++ b/src/pages/app/components/Limit/common.ts @@ -1,11 +1,12 @@ -import { judgeVerificationRequired, processVerification } from "@app/util/limit" -import optionHolder from "@service/components/option-holder" +import { getOption } from "@api/sw/option" +import { judgeVerificationRequired, processVerification } from "@app/util/limit/index" const batchJudge = async (items: timer.limit.Item[]): Promise<boolean> => { if (!items?.length) return false + const { limitDelayDuration } = await getOption() for (const item of items) { if (!item) continue - const needVerify = await judgeVerificationRequired(item) + const needVerify = await judgeVerificationRequired(item, limitDelayDuration) if (needVerify) return true } return false @@ -16,6 +17,21 @@ export const verifyCanModify = async (...items: timer.limit.Item[]) => { if (!needVerify) return // Open delay for limited rules, so verification is required - const option = await optionHolder.get() + const option = await getOption() + if (!option) return await processVerification(option) +} + +export function cleanCond(origin: string): string +export function cleanCond(origin: undefined): undefined +export function cleanCond(origin: string | undefined): string | undefined { + if (!origin) return undefined + + const startIdx = origin?.indexOf('//') + const endIdx = origin?.indexOf('?') + let res = origin.substring(startIdx === -1 ? 0 : startIdx + 2, endIdx === -1 ? undefined : endIdx) + while (res.endsWith('/')) { + res = res.substring(0, res.length - 1) + } + return res || undefined } \ No newline at end of file diff --git a/src/pages/app/components/Limit/components/Filter.tsx b/src/pages/app/components/Limit/components/Filter.tsx index 0c2302094..33fc1fdb8 100644 --- a/src/pages/app/components/Limit/components/Filter.tsx +++ b/src/pages/app/components/Limit/components/Filter.tsx @@ -6,18 +6,18 @@ */ import { createTabAfterCurrent } from "@api/chrome/tab" +import DropdownButton, { type DropdownButtonItem } from '@app/components/common/DropdownButton' import ButtonFilterItem from "@app/components/common/filter/ButtonFilterItem" import InputFilterItem from "@app/components/common/filter/InputFilterItem" -import SwitchFilterItem from "@app/components/common/filter/SwitchFilterItem" -import { t } from "@app/locale" -import { OPTION_ROUTE } from "@app/router/constants" +import SwitchFilterItem from '@app/components/common/filter/SwitchFilterItem' +import { t } from '@app/locale' +import { OPTION_ROUTE } from '@app/router/constants' import { Delete, Open, Operation, Plus, SetUp, TurnOff, WarningFilled } from "@element-plus/icons-vue" -import { useXsState } from '@hooks/useMediaSize' +import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" import { getAppPageUrl } from "@util/constant/url" import { ElIcon, ElText, ElTooltip } from 'element-plus' -import { computed, defineComponent, ref, Ref, watch } from "vue" -import DropdownButton, { type DropdownButtonItem } from "../../common/DropdownButton" +import { computed, defineComponent, ref, type Ref, watch } from "vue" import { useLimitAction, useLimitBatch, useLimitFilter } from "../context" const optionPageUrl = getAppPageUrl(OPTION_ROUTE, { i: 'limit' }) @@ -79,10 +79,10 @@ const _default = defineComponent(() => { /> <SwitchFilterItem v-show={!isXs.value} - historyName="onlyEnabled" - label={t(msg => msg.limit.filterDisabled)} - defaultValue={filter.onlyEnabled} - onChange={val => filter.onlyEnabled = val} + historyName="effective" + label={t(msg => msg.limit.onlyEffective)} + defaultValue={filter.effective} + onChange={val => filter.effective = val} /> </Flex> <Flex gap={10} align='center'> diff --git a/src/pages/app/components/Limit/components/List/Card.tsx b/src/pages/app/components/Limit/components/List/Card.tsx index d09ec6e29..55864924a 100644 --- a/src/pages/app/components/Limit/components/List/Card.tsx +++ b/src/pages/app/components/Limit/components/List/Card.tsx @@ -4,14 +4,14 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { verifyCanModify } from "@app/components/Limit/common" +import { useLimitAction, useLimitData } from "@app/components/Limit/context" +import { t } from '@app/locale' import { Delete, EditPen } from "@element-plus/icons-vue" import { css } from '@emotion/css' import Flex from "@pages/components/Flex" import { ElButton, ElCard, ElDivider, ElMessageBox, ElTag, type TagProps, useNamespace } from "element-plus" import { defineComponent, type FunctionalComponent, type StyleValue } from "vue" -import { verifyCanModify } from "../../common" -import { useLimitAction, useLimitData } from "../../context" import Rule from "./Rule" type Props = { @@ -104,7 +104,7 @@ const _default = defineComponent<Props>(props => { </ElButton> </Flex> </Flex> - </ElCard > + </ElCard> ) }, { props: ['value'] }) diff --git a/src/pages/app/components/Limit/components/List/Rule.tsx b/src/pages/app/components/Limit/components/List/Rule.tsx index b53664d77..cb52edb74 100644 --- a/src/pages/app/components/Limit/components/List/Rule.tsx +++ b/src/pages/app/components/Limit/components/List/Rule.tsx @@ -1,9 +1,10 @@ +import { useDelayDuration } from '@app/components/Limit/context' import { t } from '@app/locale' import Flex from '@pages/components/Flex' import { isEffective, meetLimit, meetTimeLimit, period2Str } from '@util/limit' import { formatPeriodCommon, MILL_PER_SECOND } from '@util/time' import { ElDescriptions, ElDescriptionsItem, ElTag } from 'element-plus' -import { computed, defineComponent, type FunctionalComponent, toRefs } from 'vue' +import { defineComponent, type FunctionalComponent, toRefs } from 'vue' import { DAILY_WEEKLY_TAG_TYPE, PERIOD_TAG_TYPE, VISIT_TAG_TYPE } from '../style' type Props = { @@ -18,32 +19,30 @@ const TimeCountPair: FunctionalComponent<{ time?: number, count?: number }> = ({ <ElTag size="small" type={DAILY_WEEKLY_TAG_TYPE}>{formatPeriodCommon(time * MILL_PER_SECOND, true)}</ElTag> )} {!!count && ( - <ElTag size="small" type={DAILY_WEEKLY_TAG_TYPE}>{`${count} ${t(msg => msg.limit.item.visits)}`}</ElTag> + <ElTag size="small" type={DAILY_WEEKLY_TAG_TYPE}>{t(msg => msg.shared.limit.visits, { n: count })}</ElTag> )} </Flex> ) } type WastePairProps = { - time?: number - waste: number + time: Parameters<typeof meetTimeLimit>[0] + delay: Parameters<typeof meetTimeLimit>[1] count?: number visit?: number - delayCount?: number - allowDelay?: boolean } const WastePair: FunctionalComponent<WastePairProps> = props => { - const timeType = computed(() => meetTimeLimit(props.time, props.waste, props.allowDelay, props.delayCount) ? 'danger' : 'info') - const visitType = computed(() => meetLimit(props.count, props.visit) ? 'danger' : 'info') + const timeType = meetTimeLimit(props.time, props.delay) ? 'danger' : 'info' + const visitType = meetLimit(props.count, props.visit) ? 'danger' : 'info' return ( <Flex gap={2}> - <ElTag size="small" type={timeType.value}> - {formatPeriodCommon(props.waste)} + <ElTag size="small" type={timeType}> + {formatPeriodCommon(props.time.wasted)} </ElTag> - <ElTag size="small" type={visitType.value}> - {props.visit ?? 0} {t(msg => msg.limit.item.visits)} + <ElTag size="small" type={visitType}> + {t(msg => msg.shared.limit.visits, { n: props.visit ?? 0 })} </ElTag> </Flex> ) @@ -58,12 +57,14 @@ const Rule = defineComponent<Props>(({ value }) => { allowDelay, delayCount, weeklyDelayCount, } = toRefs(value) + const delayDuration = useDelayDuration() + return () => <> <ElDescriptions border size='small' column={1} labelWidth={130}> - <ElDescriptionsItem label={t(msg => msg.limit.item.daily)} v-show={time?.value || count?.value}> + <ElDescriptionsItem label={t(msg => msg.shared.limit.daily)} v-show={time?.value || count?.value}> <TimeCountPair time={time?.value} count={count?.value} /> </ElDescriptionsItem> - <ElDescriptionsItem label={t(msg => msg.limit.item.weekly)} v-show={weekly?.value || weeklyCount?.value}> + <ElDescriptionsItem label={t(msg => msg.shared.limit.weekly)} v-show={weekly?.value || weeklyCount?.value}> <TimeCountPair time={weekly?.value} count={weeklyCount?.value} /> </ElDescriptionsItem> {!!visitTime?.value && ( @@ -72,7 +73,7 @@ const Rule = defineComponent<Props>(({ value }) => { </ElDescriptionsItem> )} {!!periods?.value?.length && ( - <ElDescriptionsItem label={t(msg => msg.limit.item.period)}> + <ElDescriptionsItem label={t(msg => msg.shared.limit.period)}> <Flex gap={2} wrap> {periods.value.map((p, idx) => ( <ElTag key={idx} size="small" type={PERIOD_TAG_TYPE}>{period2Str(p)}</ElTag> @@ -85,12 +86,10 @@ const Rule = defineComponent<Props>(({ value }) => { <ElDescriptionsItem label={t(msg => msg.calendar.range.today)}> {isEffective(weekdays?.value) ? ( <WastePair - waste={waste?.value} - time={time?.value} - count={count?.value} - visit={visit?.value} - allowDelay={allowDelay?.value} - delayCount={delayCount?.value} + time={{ wasted: waste.value, maxLimit: (time?.value ?? 0) * MILL_PER_SECOND }} + delay={{ count: delayCount.value, duration: delayDuration.value, allow: !!allowDelay.value }} + count={count?.value ?? 0} + visit={visit.value} /> ) : ( <ElTag type="info" size="small"> @@ -100,12 +99,10 @@ const Rule = defineComponent<Props>(({ value }) => { </ElDescriptionsItem> <ElDescriptionsItem label={t(msg => msg.calendar.range.thisWeek)}> <WastePair - waste={weeklyWaste?.value} - time={weekly?.value} - count={weeklyCount?.value} - visit={weeklyVisit?.value} - allowDelay={allowDelay?.value} - delayCount={weeklyDelayCount?.value} + time={{ wasted: weeklyWaste.value, maxLimit: (weekly?.value ?? 0) * MILL_PER_SECOND }} + delay={{ count: weeklyDelayCount.value, duration: delayDuration.value, allow: !!allowDelay.value }} + count={weeklyCount?.value ?? 0} + visit={weeklyVisit.value} /> </ElDescriptionsItem> </ElDescriptions> diff --git a/src/pages/app/components/Limit/components/List/index.tsx b/src/pages/app/components/Limit/components/List/index.tsx index 5dd519205..3effb2458 100644 --- a/src/pages/app/components/Limit/components/List/index.tsx +++ b/src/pages/app/components/Limit/components/List/index.tsx @@ -4,10 +4,10 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ +import { useLimitData } from "@app/components/Limit/context" import Flex from '@pages/components/Flex' import { ElScrollbar } from 'element-plus' import { defineComponent } from "vue" -import { useLimitData } from "../../context" import Card from "./Card" const _default = defineComponent(() => { diff --git a/src/pages/app/components/Limit/components/Modify/Step1.tsx b/src/pages/app/components/Limit/components/Modify/Step1.tsx index 6e0a83a44..3ad0585c1 100644 --- a/src/pages/app/components/Limit/components/Modify/Step1.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step1.tsx @@ -1,9 +1,9 @@ import { useDialogSop } from '@app/components/common/DialogSop/context' -import { t } from "@app/locale" -import { useXsState } from '@hooks/useMediaSize' +import type { ModifyForm } from '@app/components/Limit/types' +import { t } from '@app/locale' +import { useXsState } from '@hooks' import { ElCol, ElForm, ElFormItem, ElInput, ElRow, ElSelect, ElSwitch } from "element-plus" import { defineComponent } from "vue" -import { ModifyForm } from './types' const _default = defineComponent(() => { const { form: data } = useDialogSop<ModifyForm>() diff --git a/src/pages/app/components/Limit/components/Modify/Step2/SiteInput.tsx b/src/pages/app/components/Limit/components/Modify/Step2/SiteInput.tsx index 18f05704f..930615ce5 100644 --- a/src/pages/app/components/Limit/components/Modify/Step2/SiteInput.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step2/SiteInput.tsx @@ -1,9 +1,9 @@ import { listTabs } from '@api/chrome/tab' +import { listSites } from '@api/sw/site' +import { cleanCond } from '@app/components/Limit/common' import { t } from '@app/locale' import { useDebounceState, useRequest } from '@hooks' import Flex from '@pages/components/Flex' -import { selectAllSites } from '@service/site-service' -import { cleanCond } from '@util/limit' import { extractHostname, isBrowserUrl } from '@util/pattern' import { ElMessage, ElSelectV2, ElText, useNamespace, type SelectV2Instance } from 'element-plus' import { computed, defineComponent, onMounted, ref, type StyleValue } from 'vue' @@ -22,13 +22,13 @@ const fetchAllHosts = async () => { hostSet.add(host) } }) - const sites = await selectAllSites({ types: ['normal', 'virtual'] }) + const sites = await listSites({ types: ['normal', 'virtual'] }) sites.forEach(({ host }) => hostSet.add(host)) return Array.from(hostSet) } const useUrlSelect = ({ onAdd }: Props) => { - const { data: allHosts } = useRequest(fetchAllHosts) + const { data: allHosts } = useRequest(fetchAllHosts, { defaultValue: [] }) const [input, onFilter] = useDebounceState('', 50) const inputUrl = computed(() => { const clean = cleanCond(input.value) @@ -43,9 +43,9 @@ const useUrlSelect = ({ onAdd }: Props) => { if (full) { result.push(full) domain && result.push(domain) - allHosts.value?.forEach(host => host.includes(full) && host !== full && host !== domain && result.push(host)) + allHosts.value.forEach(host => host.includes(full) && host !== full && host !== domain && result.push(host)) } else { - allHosts.value?.forEach(h => result.push(h)) + allHosts.value.forEach(h => result.push(h)) } return result.map(value => ({ value, label: value })) }) diff --git a/src/pages/app/components/Limit/components/Modify/Step2/index.tsx b/src/pages/app/components/Limit/components/Modify/Step2/index.tsx index 6fadbf79b..f93c47f4a 100644 --- a/src/pages/app/components/Limit/components/Modify/Step2/index.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step2/index.tsx @@ -6,18 +6,13 @@ */ import { useDialogSop } from '@app/components/common/DialogSop/context' -import { t } from "@app/locale" +import type { ModifyForm } from '@app/components/Limit/types' +import { t } from '@app/locale' import { Delete, WarnTriangleFilled } from "@element-plus/icons-vue" import Flex from "@pages/components/Flex" import { EXCLUDING_PREFIX } from '@util/constant/remain-host' -import { - ElDivider, ElIcon, - ElLink, - ElScrollbar, ElText, - type ScrollbarInstance -} from "element-plus" +import { ElDivider, ElIcon, ElLink, ElScrollbar, ElText, type ScrollbarInstance } from "element-plus" import { defineComponent, ref } from "vue" -import type { ModifyForm } from '../types' import SiteInput from './SiteInput' const _default = defineComponent(() => { diff --git a/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx b/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx index 59f1fcb3c..0249b8c22 100644 --- a/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx @@ -5,10 +5,10 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { useState, useSwitch, useXsState } from '@hooks' +import { t } from '@app/locale' import { Check, Close, Plus } from "@element-plus/icons-vue" import { css } from '@emotion/css' -import { useState, useSwitch, useXsState } from "@hooks" import Flex from "@pages/components/Flex" import { dateMinute2Idx, period2Str } from "@util/limit" import { MILL_PER_HOUR } from "@util/time" diff --git a/src/pages/app/components/Limit/components/Modify/Step3/TimeInput.tsx b/src/pages/app/components/Limit/components/Modify/Step3/TimeInput.tsx index a04b4dc92..ac7e56715 100644 --- a/src/pages/app/components/Limit/components/Modify/Step3/TimeInput.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step3/TimeInput.tsx @@ -1,11 +1,9 @@ import { CircleClose, Clock } from "@element-plus/icons-vue" -import { useDebounceFn, useState, useXsState } from "@hooks" -import { getStyle } from "@pages/util/style" +import { useDebounceFn, useState, useXsState } from '@hooks' +import { getStyle } from '@pages/util/style' import { range } from "@util/array" import { - Effect, ElIcon, ElInput, ElPopover, ElScrollbar, - type ScrollbarInstance, - useLocale, useNamespace + Effect, ElIcon, ElInput, ElPopover, ElScrollbar, type ScrollbarInstance, useLocale, useNamespace, } from "element-plus" import { computed, defineComponent, nextTick, onMounted, ref, Transition, watch } from "vue" @@ -144,8 +142,6 @@ type TimeInputProps = { /** * Rewrite - * - * https://github.com/element-plus/element-plus/blob/dev/packages/components/time-picker/src/time-picker-com/panel-time-pick.vue */ const TimeInput = defineComponent<TimeInputProps>(props => { const [popoverVisible, setPopoverVisible] = useState(false) @@ -202,7 +198,7 @@ const TimeInput = defineComponent<TimeInputProps>(props => { prefixIcon={Clock} modelValue={inputText.value} inputStyle={{ cursor: 'pointer' }} - style={{ '--el-date-editor-width': '160px' }} + style={{ '--el-date-editor-width': '170px' }} size={isXs.value ? 'small' : undefined} readonly v-slots={{ diff --git a/src/pages/app/components/Limit/components/Modify/Step3/index.tsx b/src/pages/app/components/Limit/components/Modify/Step3/index.tsx index e8a91bf27..b288cea1c 100644 --- a/src/pages/app/components/Limit/components/Modify/Step3/index.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step3/index.tsx @@ -6,12 +6,12 @@ */ import { useDialogSop } from '@app/components/common/DialogSop/context' -import { t } from "@app/locale" -import { useXsState } from '@hooks/useMediaSize' +import type { ModifyForm } from '@app/components/Limit/types' +import { t } from '@app/locale' +import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" import { ElForm, ElFormItem, ElInputNumber } from "element-plus" import { defineComponent } from "vue" -import { ModifyForm } from '../types' import PeriodInput from "./PeriodInput" import TimeInput from "./TimeInput" @@ -24,7 +24,7 @@ const _default = defineComponent(() => { return () => ( <Flex justify="center"> <ElForm labelWidth={150} labelPosition='left'> - <ElFormItem label={t(msg => msg.limit.item.daily)}> + <ElFormItem label={t(msg => msg.shared.limit.daily)}> <Flex gap={10} column={isXs.value}> <TimeInput modelValue={data.time ?? 0} onChange={v => data.time = v} /> {!isXs.value && t(msg => msg.limit.item.or)} @@ -34,11 +34,11 @@ const _default = defineComponent(() => { modelValue={data.count} onChange={v => data.count = v ?? 0} size={isXs.value ? 'small' : undefined} - v-slots={{ suffix: () => t(msg => msg.limit.item.visits) }} + v-slots={{ suffix: () => t(msg => msg.shared.limit.visits, { n: '' }).trim() }} /> </Flex> </ElFormItem> - <ElFormItem label={t(msg => msg.limit.item.weekly)}> + <ElFormItem label={t(msg => msg.shared.limit.weekly)}> <Flex gap={10} column={isXs.value}> <TimeInput modelValue={data.weekly ?? 0} onChange={v => data.weekly = v} hourMax={MAX_HOUR_WEEKLY} /> {!isXs.value && t(msg => msg.limit.item.or)} @@ -48,14 +48,14 @@ const _default = defineComponent(() => { modelValue={data.weeklyCount} onChange={v => data.weeklyCount = v ?? 0} size={isXs.value ? 'small' : undefined} - v-slots={{ suffix: () => t(msg => msg.limit.item.visits) }} + v-slots={{ suffix: () => t(msg => msg.shared.limit.visits, { n: '' }).trim() }} /> </Flex> </ElFormItem> <ElFormItem label={t(msg => msg.limit.item.visitTime)}> <TimeInput modelValue={data.visitTime ?? 0} onChange={v => data.visitTime = v} /> </ElFormItem> - <ElFormItem label={t(msg => msg.limit.item.period)}> + <ElFormItem label={t(msg => msg.shared.limit.period)}> <PeriodInput modelValue={data.periods ?? []} onChange={v => data.periods = v} /> </ElFormItem> </ElForm> diff --git a/src/pages/app/components/Limit/components/Modify/index.tsx b/src/pages/app/components/Limit/components/Modify/index.tsx index 15ad02be0..70b0c01c7 100644 --- a/src/pages/app/components/Limit/components/Modify/index.tsx +++ b/src/pages/app/components/Limit/components/Modify/index.tsx @@ -5,17 +5,18 @@ * https://opensource.org/licenses/MIT */ +import { addLimit, updateLimits } from '@api/sw/limit' import DialogSop from '@app/components/common/DialogSop' import { initDialogSopContext } from '@app/components/common/DialogSop/context' -import { t } from "@app/locale" -import limitService from "@service/limit-service" +import { cleanCond } from '@app/components/Limit/common' +import { useLimitData } from "@app/components/Limit/context" +import type { ModifyForm, ModifyInstance } from '@app/components/Limit/types' +import { t } from '@app/locale' import { range } from '@util/array' import { computed, defineComponent, ref, toRaw } from "vue" -import { type ModifyInstance, useLimitData } from "../../context" import Step1 from './Step1' import Step2 from './Step2' import Step3 from './Step3' -import { ModifyForm } from './types' type Mode = "create" | "modify" @@ -25,11 +26,11 @@ const STEP_TITLES = [ t(msg => msg.limit.step.rule), ] -const createInitial = (): ModifyForm => ({ +const createInitial = (url?: string): ModifyForm => ({ name: `RULE-${new String(new Date().getTime() % 10000).padStart(4, '0')}`, time: 3600, weekly: 0, - cond: [], + cond: url ? [cleanCond(url)] : [], visitTime: 0, periods: [], enabled: true, @@ -83,7 +84,7 @@ const _default = defineComponent((_, ctx) => { // Object to array periods: periods?.map(i => ([i?.[0], i?.[1]] satisfies Vector<number>)), } satisfies timer.limit.Rule - await limitService.update(saved) + await updateLimits([saved]) } else { const toCreate = { cond, enabled, name, time, weekly, visitTime, weekdays, count, weeklyCount, @@ -91,7 +92,7 @@ const _default = defineComponent((_, ctx) => { periods: periods?.map(i => ([i?.[0], i?.[1]] satisfies Vector<number>)), allowDelay: false, locked: false, } satisfies MakeOptional<timer.limit.Rule, 'id'> - const id = await limitService.create(toCreate) + const id = await addLimit(toCreate) saved = { ...toCreate, id } } refresh?.() @@ -101,8 +102,8 @@ const _default = defineComponent((_, ctx) => { let modifyingItem: timer.limit.Rule | undefined = undefined ctx.expose({ - create() { - open() + create(url?: string) { + open(createInitial(url)) mode.value = 'create' modifyingItem = undefined }, diff --git a/src/pages/app/components/Limit/components/Modify/types.ts b/src/pages/app/components/Limit/components/Modify/types.ts deleted file mode 100644 index ae99a74e3..000000000 --- a/src/pages/app/components/Limit/components/Modify/types.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type ModifyForm = Omit<timer.limit.Rule, 'id'> & { - urlMiss?: boolean -} \ No newline at end of file diff --git a/src/pages/app/components/Limit/components/Table/OperationColumn.tsx b/src/pages/app/components/Limit/components/Table/OperationColumn.tsx index 9f7f528a9..912a123e4 100644 --- a/src/pages/app/components/Limit/components/Table/OperationColumn.tsx +++ b/src/pages/app/components/Limit/components/Table/OperationColumn.tsx @@ -4,13 +4,13 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { verifyCanModify } from "@app/components/Limit/common" +import { useLimitAction, useLimitData } from "@app/components/Limit/context" +import { t } from '@app/locale' import { Delete, Edit } from "@element-plus/icons-vue" import { locale } from "@i18n" import { ElButton, ElTableColumn, type RenderRowData } from "element-plus" import { defineComponent } from "vue" -import { verifyCanModify } from "../../common" -import { useLimitAction, useLimitData } from "../../context" const LOCALE_WIDTH: { [locale in timer.Locale]: number } = { en: 220, diff --git a/src/pages/app/components/Limit/components/Table/Rule.tsx b/src/pages/app/components/Limit/components/Table/Rule.tsx index 6ed4ce8ee..c622753dd 100644 --- a/src/pages/app/components/Limit/components/Table/Rule.tsx +++ b/src/pages/app/components/Limit/components/Table/Rule.tsx @@ -1,4 +1,4 @@ -import { t } from "@app/locale" +import { t } from '@app/locale' import Flex from "@pages/components/Flex" import { period2Str } from '@util/limit' import { formatPeriodCommon, MILL_PER_SECOND } from '@util/time' @@ -14,11 +14,12 @@ type TimeCountPairProps = { } const TimeCountPair: FunctionalComponent<TimeCountPairProps> = ({ time, count, label, type = DAILY_WEEKLY_TAG_TYPE }) => { - if (!time && !count) return null + const content = [ + time && formatPeriodCommon(time * MILL_PER_SECOND, true), + count && t(msg => msg.shared.limit.visits, { n: count }), + ].filter(Boolean).join(` ${t(msg => msg.limit.item.or)} `) - const timeContent = time ? formatPeriodCommon(time * MILL_PER_SECOND, true) : null - const countContent = count ? `${count} ${t(msg => msg.limit.item.visits)}` : null - const content = [timeContent, countContent].filter(str => !!str).join(` ${t(msg => msg.limit.item.or)} `) + if (!content) return null return ( <div> @@ -32,7 +33,7 @@ const PeriodTag: FunctionalComponent<{ periods?: timer.limit.Period[], }> = ({ p return <> <div> - <ElTag size="small" type="info">{t(msg => msg.limit.item.period)}</ElTag> + <ElTag size="small" type="info">{t(msg => msg.shared.limit.period)}</ElTag> </div> <Flex justify="center" gap={4} wrap="wrap"> {periods.map((p, idx) => <ElTag key={idx} size="small" type="info">{period2Str(p)}</ElTag>)} @@ -48,12 +49,12 @@ const Rule = defineComponent<{ value: timer.limit.Item }>(props => { <TimeCountPair time={row.value?.time} count={row.value?.count} - label={t(msg => msg.limit.item.daily)} + label={t(msg => msg.shared.limit.daily)} /> <TimeCountPair time={row.value?.weekly} count={row.value?.weeklyCount} - label={t(msg => msg.limit.item.weekly)} + label={t(msg => msg.shared.limit.weekly)} /> <TimeCountPair time={row.value?.visitTime} diff --git a/src/pages/app/components/Limit/components/Table/Waste.tsx b/src/pages/app/components/Limit/components/Table/Waste.tsx index 226a05ac2..762718945 100644 --- a/src/pages/app/components/Limit/components/Table/Waste.tsx +++ b/src/pages/app/components/Limit/components/Table/Waste.tsx @@ -1,22 +1,20 @@ -import TooltipWrapper from "@app/components/common/TooltipWrapper" -import { t } from "@app/locale" +import { t } from '@app/locale' import Flex from "@pages/components/Flex" +import TooltipWrapper from '@pages/components/TooltipWrapper' import { meetLimit, meetTimeLimit } from "@util/limit" import { formatPeriodCommon } from "@util/time" import { ElTag } from "element-plus" import { computed, defineComponent } from "vue" type Props = { - time?: number - waste: number + time: Parameters<typeof meetTimeLimit>[0] + delay: Parameters<typeof meetTimeLimit>[1] count?: number visit?: number - delayCount?: number - allowDelay?: boolean } const Waste = defineComponent<Props>(props => { - const timeType = computed(() => meetTimeLimit(props.time, props.waste, props.allowDelay, props.delayCount) ? 'danger' : 'info') + const timeType = computed(() => meetTimeLimit(props.time, props.delay) ? 'danger' : 'info') const visitType = computed(() => meetLimit(props.count, props.visit) ? 'danger' : 'info') return () => ( @@ -24,13 +22,13 @@ const Waste = defineComponent<Props>(props => { <div> <TooltipWrapper trigger="hover" - usePopover={props.allowDelay && !!props.time} + usePopover={props.delay.allow && !!props.time} placement="top" v-slots={{ - content: () => `${t(msg => msg.limit.item.delayCount)}: ${props.delayCount ?? 0}`, + content: () => `${t(msg => msg.limit.item.delayCount)}: ${props.delay.count}`, default: () => ( <ElTag size="small" type={timeType.value}> - {formatPeriodCommon(props.waste)} + {formatPeriodCommon(props.time.wasted)} </ElTag> ), }} @@ -38,11 +36,11 @@ const Waste = defineComponent<Props>(props => { </div> <div> <ElTag size="small" type={visitType.value}> - {props.visit ?? 0} {t(msg => msg.limit.item.visits)} + {t(msg => msg.shared.limit.visits, { n: props.visit ?? 0 })} </ElTag> </div> </Flex> ) -}, { props: ['time', 'waste', 'count', 'visit', 'allowDelay', 'delayCount'] }) +}, { props: ['time', 'delay', 'count', 'visit'] }) export default Waste \ No newline at end of file diff --git a/src/pages/app/components/Limit/components/Table/Weekday.tsx b/src/pages/app/components/Limit/components/Table/Weekday.tsx index 33a8804a9..35e91f96e 100644 --- a/src/pages/app/components/Limit/components/Table/Weekday.tsx +++ b/src/pages/app/components/Limit/components/Table/Weekday.tsx @@ -1,5 +1,5 @@ +import { t } from "@app/locale" import Flex from "@pages/components/Flex" -import { t } from "@popup/locale" import { ElTag } from "element-plus" import { computed, defineComponent, toRef, type PropType } from "vue" diff --git a/src/pages/app/components/Limit/components/Table/index.tsx b/src/pages/app/components/Limit/components/Table/index.tsx index 18f806431..489de2dc3 100644 --- a/src/pages/app/components/Limit/components/Table/index.tsx +++ b/src/pages/app/components/Limit/components/Table/index.tsx @@ -4,21 +4,21 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ +import { getWeekStartDay } from "@api/sw/option" import ColumnHeader from "@app/components/common/ColumnHeader" -import { t } from "@app/locale" -import { useLocalStorage, useRequest, useState } from "@hooks" -import weekHelper from "@service/components/week-helper" +import { useDelayDuration, useLimitData } from "@app/components/Limit/context" +import type { LimitInstance } from '@app/components/Limit/types' +import { t } from '@app/locale' +import { useLocalStorage, useRequest, useState } from '@hooks' import { isEffective } from "@util/limit" +import { MILL_PER_SECOND } from "@util/time" import { ElSwitch, ElTable, ElTableColumn, ElTag, type RenderRowData, type Sort, type TableInstance } from "element-plus" import { defineComponent, ref, watch } from "vue" -import { useLimitData, type LimitInstance } from "../../context" import LimitOperationColumn from "./OperationColumn" import Rule from "./Rule" import Waste from "./Waste" import Weekday from "./Weekday" -export type LimitSortProp = keyof Pick<timer.limit.Item, 'name' | 'weekdays' | 'waste' | 'weeklyWaste'> - const DEFAULT_SORT_COL = 'waste' const sortMethodByNumVal = (key: keyof timer.limit.Item & 'waste' | 'weeklyWaste'): (a: timer.limit.Item, b: timer.limit.Item) => number => { @@ -29,12 +29,13 @@ const sortByEffectiveDays = ({ weekdays: a }: timer.limit.Item, { weekdays: b }: const _default = defineComponent((_, ctx) => { const { data: weekStartName } = useRequest(async () => { - const offset = await weekHelper.getRealWeekStart() + const offset = await getWeekStartDay() const name = t(msg => msg.calendar.weekDays)?.split('|')?.[offset] return name || 'NaN' }) const { list, changeEnabled, changeDelay, changeLocked } = useLimitData() + const delayDuration = useDelayDuration() const [cachedSort, setCachedSort] = useLocalStorage<Sort>( '__limit_sort_default__', { prop: DEFAULT_SORT_COL, order: 'descending' } @@ -103,12 +104,10 @@ const _default = defineComponent((_, ctx) => { > {({ row }: RenderRowData<timer.limit.Item>) => isEffective(row.weekdays) ? ( <Waste - waste={row.waste} - time={row.time} - count={row.count} - visit={row.visit} - allowDelay={row.allowDelay} - delayCount={row.delayCount} + time={{ wasted: row.waste, maxLimit: (row.time ?? 0) * MILL_PER_SECOND }} + delay={{ count: row.delayCount, duration: delayDuration.value, allow: !!row.allowDelay }} + count={row.count ?? 0} + visit={row.visit ?? 0} /> ) : ( <ElTag type="info" size="small"> @@ -135,12 +134,10 @@ const _default = defineComponent((_, ctx) => { weeklyDelayCount, allowDelay, } }: RenderRowData<timer.limit.Item>) => ( <Waste - time={weekly} - waste={weeklyWaste} - count={weeklyCount} - visit={weeklyVisit} - allowDelay={allowDelay} - delayCount={weeklyDelayCount} + time={{ wasted: weeklyWaste, maxLimit: (weekly ?? 0) * MILL_PER_SECOND }} + delay={{ count: weeklyDelayCount, duration: delayDuration.value, allow: !!allowDelay }} + count={weeklyCount ?? 0} + visit={weeklyVisit ?? 0} /> ), }} @@ -157,7 +154,7 @@ const _default = defineComponent((_, ctx) => { )} </ElTableColumn> <ElTableColumn - label={t(msg => msg.limit.item.delayAllowed)} + label={t(msg => msg.limit.item.allowDelay)} minWidth={80} align="center" fixed="right" diff --git a/src/pages/app/components/Limit/components/Test.tsx b/src/pages/app/components/Limit/components/Test.tsx index bcd74c0c6..7baddcd2e 100644 --- a/src/pages/app/components/Limit/components/Test.tsx +++ b/src/pages/app/components/Limit/components/Test.tsx @@ -5,21 +5,21 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" -import { useDebounce, useRequest, useState, useSwitch, useXsState } from "@hooks" +import { listLimits } from "@api/sw/limit" +import AlertLines, { type AlertLinesProps } from '@app/components/common/AlertLines' +import { t } from '@app/locale' +import { useDebounce, useRequest, useState, useSwitch, useXsState } from '@hooks' import Flex from '@pages/components/Flex' -import limitService from "@service/limit-service" import { ElDialog, ElInput } from "element-plus" import { defineComponent } from "vue" -import AlertLines, { type AlertLinesProps } from '../../common/AlertLines' -import { type TestInstance } from "../context" +import type { TestInstance } from '../types' async function fetchResult(url: string | undefined): Promise<AlertLinesProps> { if (!url) { return { type: 'warning', title: msg => msg.limit.message.inputTestUrl } } - const matched = await limitService.select({ url, filterDisabled: true }) - if (!matched?.length) { + const matched = await listLimits({ url, enabled: true }) + if (!matched.length) { return { type: 'info', title: msg => msg.limit.message.noRuleMatched } } else { return { diff --git a/src/pages/app/components/Limit/context.ts b/src/pages/app/components/Limit/context.ts index d3b39189f..b8c412f4c 100644 --- a/src/pages/app/components/Limit/context.ts +++ b/src/pages/app/components/Limit/context.ts @@ -1,28 +1,16 @@ -import { t } from "@app/locale" -import { useDocumentVisibility, useManualRequest, useProvide, useProvider, useRequest } from "@hooks" -import limitService from "@service/limit-service" +import { getOption } from '@/api/sw/option' +import { DEFAULT_LIMIT } from '@/util/constant/option' +import { deleteLimits, listLimits, updateLimits } from "@api/sw/limit" +import { t } from '@app/locale' +import { useDocumentVisibility, useManualRequest, useProvide, useProvider, useRequest } from '@hooks' import { ElMessage, ElMessageBox } from "element-plus" -import { computed, Reactive, reactive, ref, toRaw, watch, type Ref } from "vue" -import { useRoute, useRouter } from "vue-router" +import { computed, reactive, ref, toRaw, watch, type ShallowRef } from "vue" import { verifyCanModify } from "./common" -import type { LimitFilterOption } from "./types" - -export type ModifyInstance = { - create(): void - modify(row: timer.limit.Item): void -} - -export type TestInstance = { - show(): void -} - -export type LimitInstance = { - getSelected(): timer.limit.Item[] -} +import type { LimitFilterOption, LimitInstance, ModifyInstance, TestInstance } from "./types" type Context = { - filter: Reactive<LimitFilterOption> - list: Ref<timer.limit.Item[]> + filter: LimitFilterOption + list: ShallowRef<timer.limit.Item[]> refresh: NoArgCallback deleteRow: ArgCallback<timer.limit.Item> batchDelete: NoArgCallback @@ -34,29 +22,29 @@ type Context = { modify: (item: timer.limit.Item) => void create: () => void test: () => void - empty: Ref<boolean> + empty: ShallowRef<boolean> + delayDuration: ShallowRef<number> } const NAMESPACE = 'limit' -const initialUrl = () => { - // Init with url parameter - const urlParam = useRoute().query['url'] as string - useRouter().replace({ query: {} }) - return urlParam ? decodeURIComponent(urlParam) : '' -} -export const useLimitProvider = () => { - const filter = reactive<LimitFilterOption>({ url: initialUrl(), onlyEnabled: false }) +export const useLimitProvider = (initialUrl: string | undefined) => { + const filter = reactive<LimitFilterOption>({ url: initialUrl, effective: false }) const { data: list, refresh, loading } = useRequest( - () => limitService.select({ filterDisabled: filter.onlyEnabled, url: filter.url ?? '' }), + () => listLimits({ url: filter.url, effective: filter.effective }), { defaultValue: [], - deps: [() => filter.url, () => filter.onlyEnabled], + deps: [() => filter.url, () => filter.effective], }, ) + const { data: delayDuration } = useRequest( + () => getOption().then(o => o.limitDelayDuration), + { defaultValue: DEFAULT_LIMIT.limitDelayDuration }, + ) + // Query data if the window become visible const docVisible = useDocumentVisibility() watch(docVisible, () => docVisible.value && refresh()) @@ -65,7 +53,7 @@ export const useLimitProvider = () => { await verifyCanModify(row) const message = t(msg => msg.limit.message.deleteConfirm, { name: row.name }) await ElMessageBox.confirm(message, { type: "warning" }) - await limitService.remove(row) + await deleteLimits([row.id]) }, { onSuccess() { ElMessage.success(t(msg => msg.operation.successMsg)) @@ -93,20 +81,20 @@ export const useLimitProvider = () => { const names = list.map(item => item.name ?? item.id).join(', ') verifyCanModify(...list) .then(() => ElMessageBox.confirm(t(msg => msg.limit.message.deleteConfirm, { name: names }), { type: "warning" })) - .then(() => limitService.remove(...list)) + .then(() => deleteLimits(list.map(item => item.id))) .then(onBatchSuccess) .catch(() => { }) } const handleBatchEnable = (list: timer.limit.Item[]) => { list.forEach(item => item.enabled = true) - limitService.updateEnabled(...list).then(onBatchSuccess).catch(() => { }) + updateLimits(list).then(onBatchSuccess).catch(() => { }) } const handleBatchDisable = (list: timer.limit.Item[]) => verifyCanModify(...list) .then(() => { list.forEach(item => item.enabled = false) - return limitService.updateEnabled(...list) + return updateLimits(list) }) .then(onBatchSuccess) .catch(() => { }) @@ -116,18 +104,18 @@ export const useLimitProvider = () => { try { (row.locked || !enabled) && await verifyCanModify(row) row.enabled = enabled - await limitService.updateEnabled(toRaw(row)) + await updateLimits([toRaw(row)]) } catch (e) { console.warn(e) } } const changeDelay = async (row: timer.limit.Item, newVal: boolean) => { - const delayable = !!newVal + const allowDelay = !!newVal try { - (row.locked || delayable) && await verifyCanModify(row) - row.allowDelay = delayable - await limitService.updateDelay(toRaw(row)) + (row.locked || allowDelay) && await verifyCanModify(row) + row.allowDelay = allowDelay + await updateLimits([toRaw(row)]) } catch (e) { console.warn(e) } @@ -143,7 +131,7 @@ export const useLimitProvider = () => { await verifyCanModify(row) } row.locked = locked - await limitService.updateLocked(toRaw(row)) + await updateLimits([toRaw(row)]) } catch (e) { console.warn(e) } @@ -154,23 +142,20 @@ export const useLimitProvider = () => { const modify = (row: timer.limit.Item) => modifyInst.value?.modify?.(toRaw(row)) const create = () => modifyInst.value?.create?.() const test = () => testInst.value?.show?.() - const empty = computed(() => !loading.value && !list.value.length) + const empty = computed(() => !loading.value && !(list.value?.length)) useProvide<Context>(NAMESPACE, { - filter, - list, empty, refresh, - deleteRow, + filter, list, empty, refresh, delayDuration, + deleteRow, modify, create, test, changeEnabled, changeDelay, changeLocked, batchDelete: () => selectedAndThen(handleBatchDelete), batchEnable: () => selectedAndThen(handleBatchEnable), batchDisable: () => selectedAndThen(handleBatchDisable), - changeEnabled, changeDelay, changeLocked, - modify, create, test, }) return { modifyInst, testInst, inst } } -export const useLimitFilter = (): Reactive<LimitFilterOption> => useProvider<Context, 'filter'>(NAMESPACE, "filter").filter +export const useLimitFilter = (): LimitFilterOption => useProvider<Context, 'filter'>(NAMESPACE, "filter").filter export const useLimitData = () => useProvider<Context, 'list' | 'refresh' | 'deleteRow' | 'changeEnabled' | 'changeDelay' | 'changeLocked'>( NAMESPACE, 'list', 'refresh', 'deleteRow', 'changeEnabled', 'changeDelay', 'changeLocked' @@ -180,4 +165,6 @@ export const useLimitBatch = () => useProvider<Context, 'batchDelete' | 'batchEn NAMESPACE, 'batchDelete', 'batchDisable', 'batchEnable' ) -export const useLimitAction = () => useProvider<Context, 'test' | 'modify' | 'create' | 'empty'>(NAMESPACE, 'modify', 'test', 'create', 'empty') \ No newline at end of file +export const useLimitAction = () => useProvider<Context, 'test' | 'modify' | 'create' | 'empty'>(NAMESPACE, 'modify', 'test', 'create', 'empty') + +export const useDelayDuration = () => useProvider<Context, 'delayDuration'>(NAMESPACE, 'delayDuration').delayDuration \ No newline at end of file diff --git a/src/pages/app/components/Limit/index.tsx b/src/pages/app/components/Limit/index.tsx index 611ca303d..8644ebefb 100644 --- a/src/pages/app/components/Limit/index.tsx +++ b/src/pages/app/components/Limit/index.tsx @@ -5,17 +5,34 @@ * https://opensource.org/licenses/MIT */ +import type { LimitQuery } from '@app/router/constants' import { useXsState } from '@hooks' -import { defineComponent } from "vue" +import { defineComponent, onMounted } from "vue" +import { useRoute, useRouter } from 'vue-router' import ContentCard from '../common/ContentCard' -import ContentContainer from "../common/ContentContainer" +import ContentContainer from '../common/ContentContainer' import { Filter, List, Modify, Table, Test } from "./components" import { useLimitProvider } from "./context" +const initialQuery = () => { + const { url, action } = useRoute().query as LimitQuery + useRouter().replace({ query: {} }) + return { + url: url && decodeURIComponent(url), + action, + } +} + const _default = defineComponent(() => { - const { modifyInst, testInst, inst } = useLimitProvider() + const { url, action } = initialQuery() + const initialUrl = action === 'create' ? undefined : url + const { modifyInst, testInst, inst } = useLimitProvider(initialUrl) const isXs = useXsState() + if (action === 'create') { + onMounted(() => setTimeout(() => modifyInst.value?.create(url))) + } + return () => ( <ContentContainer v-slots={{ filter: () => <Filter />, diff --git a/src/pages/app/components/Limit/types.d.ts b/src/pages/app/components/Limit/types.d.ts index 9e00296ce..033ef138d 100644 --- a/src/pages/app/components/Limit/types.d.ts +++ b/src/pages/app/components/Limit/types.d.ts @@ -1,4 +1,21 @@ export type LimitFilterOption = { url: string | undefined - onlyEnabled: boolean + effective: boolean } + +export type ModifyInstance = { + create(url?: string): void + modify(row: timer.limit.Item): void +} + +export type TestInstance = { + show(): void +} + +export type LimitInstance = { + getSelected(): timer.limit.Item[] +} + +export type ModifyForm = Omit<timer.limit.Rule, 'id'> & { + urlMiss?: boolean +} \ No newline at end of file diff --git a/src/pages/app/components/Option/Select.tsx b/src/pages/app/components/Option/Select.tsx index a4fddbdc7..e3df4eecf 100644 --- a/src/pages/app/components/Option/Select.tsx +++ b/src/pages/app/components/Option/Select.tsx @@ -1,6 +1,6 @@ import { ElCard, ElOption, ElSelect } from "element-plus" import { defineComponent, h, useSlots } from "vue" -import ContentContainer from "../common/ContentContainer" +import ContentContainer from '../common/ContentContainer' import { useCategory } from "./useCategory" const _default = defineComponent(() => { diff --git a/src/pages/app/components/Option/Tabs.tsx b/src/pages/app/components/Option/Tabs.tsx index 892163e49..5335e064f 100644 --- a/src/pages/app/components/Option/Tabs.tsx +++ b/src/pages/app/components/Option/Tabs.tsx @@ -1,10 +1,10 @@ -import { t } from "@app/locale" +import { t } from '@app/locale' import { Download, Refresh, Upload } from "@element-plus/icons-vue" import { css } from '@emotion/css' import Flex from "@pages/components/Flex" -import { ElButton, ElMessage, ElMessageBox, ElTabPane, ElTabs, TabPaneName, useNamespace } from "element-plus" +import { ElButton, ElMessage, ElMessageBox, ElTabPane, ElTabs, useNamespace } from "element-plus" import { defineComponent, h, useSlots } from "vue" -import ContentContainer from "../common/ContentContainer" +import ContentContainer from '../common/ContentContainer' import { createFileInput, exportSettings, importSettings } from "./export-import" import { type OptionCategory, useCategory } from './useCategory' @@ -59,14 +59,7 @@ const useStyle = () => { const _default = defineComponent<Props>(props => { const { category, setCategory, getLabel } = useCategory() - - const handleReset = () => props.onReset?.(category.value) - - const handleBeforeLeave = (activeName: TabPaneName): Promise<boolean> => { - setCategory(activeName) - return Promise.resolve(true) - } - + const handleReset = () => props.onReset(category.value) const cls = useStyle() return () => ( diff --git a/src/pages/app/components/Option/categories/Accessibility.tsx b/src/pages/app/components/Option/categories/Accessibility.tsx index 44a315621..d2d04babb 100644 --- a/src/pages/app/components/Option/categories/Accessibility.tsx +++ b/src/pages/app/components/Option/categories/Accessibility.tsx @@ -1,18 +1,18 @@ -import { defaultAccessibility } from "@util/constant/option" +import { DEFAULT_ACCESSIBILITY } from "@util/constant/option" import { ElSwitch } from "element-plus" import { defineComponent } from "vue" import { OptionItem } from '../components' import { useOption } from "../useOption" import type { CategoryInstance } from './types' -function copy(target: timer.option.AccessibilityOption, source: timer.option.AccessibilityOption) { +function copy(target: timer.option.AccessibilityOption, source: Readonly<timer.option.AccessibilityOption>) { target.chartDecal = source.chartDecal } const _default = defineComponent((_, ctx) => { - const { option } = useOption({ defaultValue: defaultAccessibility, copy }) + const { option } = useOption({ defaultValue: DEFAULT_ACCESSIBILITY, copy }) ctx.expose({ - reset: () => copy(option, defaultAccessibility()) + reset: () => copy(option, DEFAULT_ACCESSIBILITY) } satisfies CategoryInstance) return () => ( <OptionItem label={msg => msg.option.accessibility.chartDecal} defaultValue={false}> diff --git a/src/pages/app/components/Option/categories/Appearance/DarkModeInput.tsx b/src/pages/app/components/Option/categories/Appearance/DarkModeInput.tsx index 12c17f92e..08be9a7aa 100644 --- a/src/pages/app/components/Option/categories/Appearance/DarkModeInput.tsx +++ b/src/pages/app/components/Option/categories/Appearance/DarkModeInput.tsx @@ -4,7 +4,7 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { t } from '@app/locale' import { ElSelect, ElTimePicker } from "element-plus" import { computed, defineComponent, StyleValue } from "vue" diff --git a/src/pages/app/components/Option/categories/Appearance/index.tsx b/src/pages/app/components/Option/categories/Appearance/index.tsx index 74349a509..fd3ec7342 100644 --- a/src/pages/app/components/Option/categories/Appearance/index.tsx +++ b/src/pages/app/components/Option/categories/Appearance/index.tsx @@ -6,19 +6,18 @@ */ import { isSidePanelEnabled, setSidePanelEnabled, SIDE_PANEL_STATE_SUPPORTED_CONTROL } from '@api/chrome/sidePanel' -import { type I18nKey, t, tWith } from "@app/locale" -import { useRequest } from '@hooks/useRequest' +import { OptionItem, OptionLines, OptionTag } from '@app/components/Option/components' +import { useOption } from '@app/components/Option/useOption' +import { type I18nKey, t, tWith } from '@app/locale' +import { useRequest } from '@hooks' import { ALL_LOCALES, localeSameAsBrowser } from "@i18n" import localeMessages from "@i18n/message/common/locale" -import optionService from "@service/option-service" +import { processDarkMode } from '@pages/util/dark-mode' import { IS_ANDROID } from "@util/constant/environment" -import { defaultAppearance } from "@util/constant/option" -import { toggle } from "@util/dark-mode" +import { DEFAULT_APPEARANCE } from "@util/constant/option" import { ElColorPicker, ElMessageBox, ElSelect, ElSlider, ElSwitch, ElTag, type TagProps } from "element-plus" import { computed, defineComponent, type StyleValue } from "vue" -import { OptionItem, OptionLines, OptionTag } from '../../components' -import { useOption } from "../../useOption" -import { CategoryInstance } from '../types' +import type { CategoryInstance } from '../types' import DarkModeInput from "./DarkModeInput" const FOLLOW_BROWSER: I18nKey = msg => msg.option.followBrowser @@ -44,14 +43,13 @@ function copy(target: timer.option.AppearanceOption, source: timer.option.Appear target.chartAnimationDuration = source.chartAnimationDuration } -const DEFAULT_ANIMA_DURATION = defaultAppearance().chartAnimationDuration const DEFAULT_SIDE_PANEL_ENABLED = true const _default = defineComponent((_props, ctx) => { const { option } = useOption<timer.option.AppearanceOption>({ - defaultValue: defaultAppearance, copy, - onChange: async val => optionService.isDarkMode(val).then(toggle) + defaultValue: DEFAULT_APPEARANCE, copy, + onChange: processDarkMode, }) const { data: sidePanelEnabled, refresh: refreshSidePanel } = useRequest(isSidePanelEnabled, { defaultValue: DEFAULT_SIDE_PANEL_ENABLED, @@ -64,7 +62,7 @@ const _default = defineComponent((_props, ctx) => { ctx.expose({ reset() { handleSidePanelChange(DEFAULT_SIDE_PANEL_ENABLED) - copy(option, defaultAppearance()) + copy(option, DEFAULT_APPEARANCE) } } satisfies CategoryInstance) @@ -86,7 +84,7 @@ const _default = defineComponent((_props, ctx) => { const animaDurationTagType = computed<TagProps['type']>(() => { const val = option.chartAnimationDuration if (!val) return 'info' - if (val > DEFAULT_ANIMA_DURATION) return 'warning' + if (val > DEFAULT_APPEARANCE.chartAnimationDuration) return 'warning' return 'primary' }) @@ -178,7 +176,7 @@ const _default = defineComponent((_props, ctx) => { )} <OptionItem label={msg => msg.option.appearance.animationDuration} - defaultValue={`${DEFAULT_ANIMA_DURATION}ms`} + defaultValue={`${DEFAULT_APPEARANCE.chartAnimationDuration}ms`} > <ElSlider modelValue={option.chartAnimationDuration} diff --git a/src/pages/app/components/Option/categories/Backup/AutoInput.tsx b/src/pages/app/components/Option/categories/Backup/AutoInput.tsx index a3f6307c8..0d986e2a8 100644 --- a/src/pages/app/components/Option/categories/Backup/AutoInput.tsx +++ b/src/pages/app/components/Option/categories/Backup/AutoInput.tsx @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { t, tN } from "@app/locale" +import { t, tN } from '@app/locale' import { locale } from "@i18n" import localeMessages from "@i18n/message/common/locale" import { ElInputNumber, ElSwitch } from "element-plus" diff --git a/src/pages/app/components/Option/categories/Backup/Clear/Step2.tsx b/src/pages/app/components/Option/categories/Backup/Clear/Step2.tsx index b326be791..0bb43dd7f 100644 --- a/src/pages/app/components/Option/categories/Backup/Clear/Step2.tsx +++ b/src/pages/app/components/Option/categories/Backup/Clear/Step2.tsx @@ -6,12 +6,12 @@ */ import { useDialogSop } from '@app/components/common/DialogSop/context' -import { t } from "@app/locale" +import { t } from '@app/locale' import { ElAlert } from "element-plus" import { defineComponent, toRaw } from "vue" import type { ClearForm } from './types' -const _default = defineComponent<{}>(props => { +const _default = defineComponent<{}>(() => { const { form } = useDialogSop<ClearForm>() return () => ( <ElAlert type="success" closable={false}> diff --git a/src/pages/app/components/Option/categories/Backup/Clear/index.tsx b/src/pages/app/components/Option/categories/Backup/Clear/index.tsx index 2accb825e..da6683239 100644 --- a/src/pages/app/components/Option/categories/Backup/Clear/index.tsx +++ b/src/pages/app/components/Option/categories/Backup/Clear/index.tsx @@ -5,12 +5,12 @@ * https://opensource.org/licenses/MIT */ +import { clearBackup, queryBackup } from '@api/sw/backup' import DialogSop from "@app/components/common/DialogSop" import { initDialogSopContext } from '@app/components/common/DialogSop/context' -import { t } from "@app/locale" +import { t } from '@app/locale' import { Delete } from "@element-plus/icons-vue" -import processor from '@service/backup/processor' -import { BIRTHDAY, getBirthday, parseTime } from '@util/time' +import { BIRTHDAY, formatTimeYMD } from '@util/time' import { ElButton } from "element-plus" import { defineComponent } from "vue" import ClientTable from '../ClientTable' @@ -24,9 +24,9 @@ const STEP_TITLES = [ async function fetchStatResult(client: timer.backup.Client): Promise<StatResult> { const { id: specCid, maxDate, minDate = BIRTHDAY } = client - const start = parseTime(minDate) ?? getBirthday() - const end = parseTime(maxDate) ?? new Date() - const remoteRows: timer.core.Row[] = await processor.query({ specCid, start, end }) + const start = minDate ?? BIRTHDAY + const end = maxDate ?? formatTimeYMD(Date.now()) + const remoteRows = await queryBackup({ specCid, start, end }) const siteSet: Set<string> = new Set() remoteRows?.forEach(row => { const { host } = row || {} @@ -49,8 +49,8 @@ const _default = defineComponent(() => { onFinish: async ({ form }) => { const cid = form.client?.id if (!cid) throw new Error(t(msg => msg.option.backup.clientTable.notSelected)) - const result = await processor.clear(cid) - if (!result.success) throw new Error(result.errorMsg) + const errMsg = await clearBackup(cid) + if (errMsg) throw new Error(errMsg) }, }) diff --git a/src/pages/app/components/Option/categories/Backup/ClientTable.tsx b/src/pages/app/components/Option/categories/Backup/ClientTable.tsx index 71959765d..067963117 100644 --- a/src/pages/app/components/Option/categories/Backup/ClientTable.tsx +++ b/src/pages/app/components/Option/categories/Backup/ClientTable.tsx @@ -5,18 +5,16 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" -import { cvt2LocaleTime } from "@app/util/time" +import { allBackupClients } from "@api/sw/backup" +import { t } from '@app/locale' +import { cvt2LocaleTime } from '@app/util/time' import { Loading, RefreshRight } from "@element-plus/icons-vue" import { css } from '@emotion/css' -import { useRequest } from "@hooks" -import processor from "@service/backup/processor" -import { getCid } from "@service/meta-service" +import { useRequest } from '@hooks' import { - ElLink, ElMessage, ElRadio, ElTable, ElTableColumn, ElTag, useNamespace, - type RenderRowData, + ElLink, ElMessage, ElRadio, ElTable, ElTableColumn, ElTag, useNamespace, type RenderRowData, } from "element-plus" -import { defineComponent, ref, StyleValue, toRaw } from "vue" +import { defineComponent, ref, toRaw, type StyleValue } from "vue" const useStyle = () => { const radioNs = useNamespace('radio') @@ -36,19 +34,11 @@ const formatTime = (value: timer.backup.Client): string => { } const _default = defineComponent<{ onSelect: ArgCallback<timer.backup.Client> }>(props => { - const { data: list, loading, refresh } = useRequest(async () => { - const { success, data, errorMsg } = await processor.listClients() || {} - if (!success) { - throw new Error(errorMsg) - } - return data - }, { + const { data: list, loading, refresh } = useRequest(allBackupClients, { defaultValue: [], - onError: e => ElMessage.error(typeof e === 'string' ? e : (e as Error).message || 'Unknown error...') + onError: e => ElMessage.error(e instanceof Error ? e.message : String(e ?? 'Unknown error...')) }) - const { data: localCid } = useRequest(getCid) - const selectedCid = ref<string>() const handleRowSelect = (row: timer.backup.Client) => { selectedCid.value = row.id @@ -100,10 +90,10 @@ const _default = defineComponent<{ onSelect: ArgCallback<timer.backup.Client> }> align="center" headerAlign="center" > - {({ row: client }: RenderRowData<timer.backup.Client>) => <> + {({ row: client }: RenderRowData<(timer.backup.Client & { current: boolean })>) => <> {client.name || '-'} <ElTag - v-show={localCid.value === client?.id} + v-show={client.current} size="small" type="danger" style={{ height: '20px', marginInline: '6px 0' } satisfies StyleValue} > diff --git a/src/pages/app/components/Option/categories/Backup/Download/Step2.tsx b/src/pages/app/components/Option/categories/Backup/Download/Step2.tsx index 25503cc36..106ef0491 100644 --- a/src/pages/app/components/Option/categories/Backup/Download/Step2.tsx +++ b/src/pages/app/components/Option/categories/Backup/Download/Step2.tsx @@ -7,7 +7,7 @@ import { useDialogSop } from '@app/components/common/DialogSop/context' import CompareTable from "@app/components/common/imported/CompareTable" import ResolutionRadio from "@app/components/common/imported/ResolutionRadio" -import { t } from "@app/locale" +import { t } from '@app/locale' import Flex from "@pages/components/Flex" import { ElAlert } from "element-plus" import { defineComponent } from "vue" diff --git a/src/pages/app/components/Option/categories/Backup/Download/index.tsx b/src/pages/app/components/Option/categories/Backup/Download/index.tsx index f503f23cb..d56d804a2 100644 --- a/src/pages/app/components/Option/categories/Backup/Download/index.tsx +++ b/src/pages/app/components/Option/categories/Backup/Download/index.tsx @@ -4,13 +4,13 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ +import { previewBackup } from '@api/sw/backup' +import { importOther } from '@api/sw/immigration' import DialogSop from '@app/components/common/DialogSop' import { initDialogSopContext } from '@app/components/common/DialogSop/context' import { t } from "@app/locale" import { Files } from "@element-plus/icons-vue" -import processor from '@service/backup/processor' -import { fillExist, processImportedData } from '@service/components/import-processor' -import { getBirthday, parseTime } from '@util/time' +import { BIRTHDAY, formatTimeYMD } from '@util/time' import { ElButton } from "element-plus" import { defineComponent, toRaw } from "vue" import ClientTable from '../ClientTable' @@ -24,16 +24,9 @@ const STEP_TITLES = [ async function fetchData(client: timer.backup.Client): Promise<timer.imported.Data> { const { id: specCid, maxDate, minDate } = client - const start = parseTime(minDate) ?? getBirthday() - const end = parseTime(maxDate) ?? new Date() - const remoteRows = await processor.query({ specCid, start, end }) - const rows: timer.imported.Row[] = remoteRows.map(rr => ({ - date: rr.date, - host: rr.host, - focus: rr.focus, - time: rr.time, - })) - await fillExist(rows) + const start = minDate ?? BIRTHDAY + const end = maxDate ?? formatTimeYMD(Date.now()) + const rows = await previewBackup({ specCid, start, end }) return { rows, focus: true, time: true } } @@ -53,7 +46,7 @@ const _default = defineComponent(() => { if (!resolution) throw new Error(t(msg => msg.dataManage.importOther.conflictNotSelected)) const data = form.data if (!data) throw new Error(t(msg => msg.option.backup.clientTable.notSelected)) - await processImportedData(toRaw(data), resolution) + await importOther({ resolution, data: toRaw(data) }) }, }) diff --git a/src/pages/app/components/Option/categories/Backup/Footer.tsx b/src/pages/app/components/Option/categories/Backup/Footer.tsx index eb325e535..6cf3ef2fb 100644 --- a/src/pages/app/components/Option/categories/Backup/Footer.tsx +++ b/src/pages/app/components/Option/categories/Backup/Footer.tsx @@ -5,15 +5,14 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { checkAuth, getLastBackUp, syncData } from "@api/sw/backup" +import { t } from '@app/locale' import { Operation, UploadFilled } from "@element-plus/icons-vue" import { css } from '@emotion/css' -import { useManualRequest, useRequest, useState } from "@hooks" +import { useManualRequest, useRequest } from "@hooks" import Flex from "@pages/components/Flex" -import processor from "@service/backup/processor" -import { getLastBackUp } from "@service/meta-service" import { formatTime } from "@util/time" -import { ElButton, ElDivider, ElLoading, ElMessage, ElText, useNamespace } from "element-plus" +import { ElButton, ElDivider, ElMessage, ElText, useNamespace } from "element-plus" import { defineComponent, type StyleValue } from "vue" import Clear from "./Clear" import Download from "./Download" @@ -27,42 +26,28 @@ const useStyle = () => { ` } -async function handleTest() { - const loading = ElLoading.service({ text: "Please wait...." }) - try { - const { errorMsg } = await processor.checkAuth() - if (!errorMsg) { - ElMessage.success("Valid!") - } else { - ElMessage.error(errorMsg) - } - } finally { - loading.close() - } -} - const TIME_FORMAT = t(msg => msg.calendar.timeFormat) const _default = defineComponent<{ type: timer.backup.Type }>(props => { - const [lastTime, setLastTime] = useState<number>() - useRequest(() => getLastBackUp(props.type).then(d => d?.ts), { + const { data: lastTime, refresh: refreshLastTime } = useRequest(() => getLastBackUp(props.type), { deps: () => props.type, - onSuccess: setLastTime, }) - const { refresh: handleBackup } = useManualRequest(() => processor.syncData(), { + const { refresh: handleBackup } = useManualRequest(syncData, { loadingText: "Doing backup....", - onSuccess: ({ success, data, errorMsg }) => { - if (success) { - ElMessage.success('Successfully!') - setLastTime(data ?? Date.now()) - } else { - ElMessage.error(errorMsg ?? 'Unknown error') - } + onSuccess: errorMsg => { + if (errorMsg) return ElMessage.error(errorMsg) + ElMessage.success('Successfully!') + refreshLastTime() }, }) + const { refresh: handleTest } = useManualRequest(checkAuth, { + loadingText: "Please wait....", + onSuccess: err => err ? ElMessage.error(err) : ElMessage.success("Valid!"), + }) + const footerCls = useStyle() return () => <> diff --git a/src/pages/app/components/Option/categories/Backup/index.tsx b/src/pages/app/components/Option/categories/Backup/index.tsx index a963d3576..44ed660d7 100644 --- a/src/pages/app/components/Option/categories/Backup/index.tsx +++ b/src/pages/app/components/Option/categories/Backup/index.tsx @@ -4,14 +4,14 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ +import { OptionItem, OptionLines, OptionTooltip } from '@app/components/Option/components' +import { t } from '@app/locale' import { DEFAULT_VAULT as DEFAULT_OBSIDIAN_BUCKET, DEFAULT_ENDPOINT as DEFAULT_OBSIDIAN_ENDPOINT, } from "@api/obsidian" -import { t } from "@app/locale" import { ElInput, ElSelect } from "element-plus" import { computed, defineComponent } from "vue" -import { OptionItem, OptionLines, OptionTooltip } from '../../components' import type { CategoryInstance } from '../types' import AutoInput from "./AutoInput" import Footer from "./Footer" diff --git a/src/pages/app/components/Option/categories/Backup/useBackup.ts b/src/pages/app/components/Option/categories/Backup/useBackup.ts index 0857290c0..1af57ac90 100644 --- a/src/pages/app/components/Option/categories/Backup/useBackup.ts +++ b/src/pages/app/components/Option/categories/Backup/useBackup.ts @@ -1,7 +1,6 @@ -import { trySendMsg2Runtime } from '@api/chrome/runtime' -import { defaultBackup } from "@util/constant/option" -import { computed, watch } from "vue" -import { useOption } from '../../useOption' +import { useOption } from '@app/components/Option/useOption' +import { DEFAULT_BACKUP } from "@util/constant/option" +import { computed } from "vue" function copy(target: timer.option.BackupOption, source: timer.option.BackupOption) { target.backupType = source.backupType @@ -14,18 +13,12 @@ function copy(target: timer.option.BackupOption, source: timer.option.BackupOpti } export const useBackup = () => { - const { option, loading } = useOption({ defaultValue: defaultBackup, copy }) - - watch([ - () => option.autoBackUp, - () => option.autoBackUpInterval, - ], () => !loading.value && setTimeout(() => trySendMsg2Runtime('resetBackupScheduler'))) + const { option } = useOption<timer.option.BackupOption>({ defaultValue: DEFAULT_BACKUP, copy }) const reset = () => { - const defaultOption = defaultBackup() // Only reset type and auto flag - option.backupType = defaultOption.backupType - option.autoBackUp = defaultOption.autoBackUp + option.backupType = DEFAULT_BACKUP.backupType + option.autoBackUp = DEFAULT_BACKUP.autoBackUp } const auth = computed({ diff --git a/src/pages/app/components/Option/categories/Limit/index.tsx b/src/pages/app/components/Option/categories/Limit/index.tsx index 33bb92ded..a029bb960 100644 --- a/src/pages/app/components/Option/categories/Limit/index.tsx +++ b/src/pages/app/components/Option/categories/Limit/index.tsx @@ -4,16 +4,16 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { OptionItem, OptionLines } from '@app/components/Option/components' +import { useOption } from "@app/components/Option/useOption" +import { t } from '@app/locale' import { processVerification } from "@app/util/limit" import { Edit } from "@element-plus/icons-vue" import { css } from '@emotion/css' import { locale } from '@i18n' -import { defaultLimit } from "@util/constant/option" +import { DEFAULT_LIMIT } from "@util/constant/option" import { ElButton, ElInput, ElInputNumber, ElMessage, ElMessageBox, ElSelect, ElSwitch, useNamespace } from "element-plus" import { defineComponent, type StyleValue } from "vue" -import { OptionItem, OptionLines } from '../../components' -import { useOption } from "../../useOption" import type { CategoryInstance } from '../types' import { usePswEdit } from "./usePswEdit" import { useVerify } from "./useVerify" @@ -50,19 +50,20 @@ const ALL_DIFF: timer.limit.VerificationDifficulty[] = [ 'disgusting', ] -function copy(target: timer.option.LimitOption, source: timer.option.LimitOption) { +function copy(target: timer.option.LimitOption, source: Readonly<timer.option.LimitOption>) { target.limitPrompt = source.limitPrompt target.limitLevel = source.limitLevel target.limitPassword = source.limitPassword target.limitVerifyDifficulty = source.limitVerifyDifficulty target.limitReminder = source.limitReminder target.limitReminderDuration = source.limitReminderDuration + target.limitDelayDuration = source.limitDelayDuration } function reset(target: timer.option.LimitOption) { const defaultValue: MakeOptional< timer.option.LimitOption, 'limitPassword' | 'limitVerifyDifficulty' | 'limitReminderDuration' - > = defaultLimit() + > = structuredClone(DEFAULT_LIMIT) // Not to reset limitPassword delete defaultValue.limitPassword // Not to reset difficulty @@ -83,7 +84,7 @@ const confirm4Strict = async (): Promise<void> => { } const _default = defineComponent((_, ctx) => { - const { option } = useOption<timer.option.LimitOption>({ defaultValue: defaultLimit, copy }) + const { option } = useOption<timer.option.LimitOption>({ defaultValue: DEFAULT_LIMIT, copy }) const { verified, verify } = useVerify(option) const { modifyPsw } = usePswEdit({ reset: () => option.limitPassword }) @@ -91,36 +92,45 @@ const _default = defineComponent((_, ctx) => { reset: () => verify().then(() => reset(option)).catch(() => { }) } satisfies CategoryInstance) - const handleLevelChange = async (val: timer.limit.RestrictionLevel) => { - try { - await verify() + const handleLevelChange = (val: timer.limit.RestrictionLevel) => { + verify().then(async () => { if (val === "strict") { await confirm4Strict() } else if (val === "password") { option.limitPassword = await modifyPsw() } option.limitLevel = val - } catch (e) { - console.warn("Failed to verify", e) - } + }).catch(e => console.warn("Failed to verify", e)) } const handlePswEdit = async () => { - try { - await verify() + verify().then(async () => { option.limitPassword = await modifyPsw() ElMessage.success(t(msg => msg.operation.successMsg)) - } catch (e) { - console.warn("Failed to verify", e) - } + }).catch(e => console.warn("Failed to verify", e)) + } + + const handleDurationChange = (val: number | undefined) => { + if (typeof val !== "number") return + verify() + .then(() => option.limitDelayDuration = val) + .catch(e => console.warn("Failed to verify", e)) } const levelSelectStyle = useLevelSelectStyle() return () => <OptionLines> + <OptionItem label={msg => msg.option.limit.delayDuration} defaultValue={DEFAULT_LIMIT.limitDelayDuration}> + <ElInputNumber + modelValue={option.limitDelayDuration} + size="small" min={1} max={20} + style={{ width: "80px" } satisfies StyleValue} + onChange={handleDurationChange} + /> + </OptionItem> <OptionItem label={msg => msg.option.limit.reminder} - defaultValue={t(msg => msg.option.no)} + defaultValue={false} v-slots={{ default: () => ( <ElSwitch @@ -133,15 +143,15 @@ const _default = defineComponent((_, ctx) => { disabled={!option.limitReminder} modelValue={option.limitReminderDuration} onChange={val => val && (option.limitReminderDuration = val)} - min={1} max={20} - size="small" + min={1} max={20} size="small" + style={{ width: "80px" } satisfies StyleValue} /> ), }} /> <OptionItem label={msg => msg.option.limit.level.label} - defaultValue={t(msg => msg.option.limit.level[defaultLimit().limitLevel])} + defaultValue={t(msg => msg.option.limit.level[DEFAULT_LIMIT.limitLevel])} > <ElSelect modelValue={option.limitLevel} @@ -170,7 +180,7 @@ const _default = defineComponent((_, ctx) => { <OptionItem v-show={option.limitLevel === "verification"} label={msg => msg.option.limit.level.verificationLabel} - defaultValue={t(msg => (msg.option.limit.level as any)[defaultLimit().limitVerifyDifficulty])} + defaultValue={t(msg => (msg.option.limit.level as any)[DEFAULT_LIMIT.limitVerifyDifficulty])} > <ElSelect modelValue={option.limitVerifyDifficulty} diff --git a/src/pages/app/components/Option/categories/Limit/usePswEdit.tsx b/src/pages/app/components/Option/categories/Limit/usePswEdit.tsx index 3cff5f95d..50c28c0b2 100644 --- a/src/pages/app/components/Option/categories/Limit/usePswEdit.tsx +++ b/src/pages/app/components/Option/categories/Limit/usePswEdit.tsx @@ -1,6 +1,6 @@ -import { t } from "@app/locale" +import { useState } from '@hooks' +import { t } from '@app/locale' import { css } from '@emotion/css' -import { useState } from "@hooks" import { ElForm, ElFormItem, ElInput, ElMessage, ElMessageBox, useNamespace } from "element-plus" type Options = { diff --git a/src/pages/app/components/Option/categories/Limit/useVerify.ts b/src/pages/app/components/Option/categories/Limit/useVerify.ts index cd4e98498..0a649ea8a 100644 --- a/src/pages/app/components/Option/categories/Limit/useVerify.ts +++ b/src/pages/app/components/Option/categories/Limit/useVerify.ts @@ -1,5 +1,5 @@ -import { judgeVerificationRequired, processVerification } from "@app/util/limit" -import limitService from "@service/limit-service" +import { listLimits } from "@api/sw/limit" +import { judgeVerificationRequired, processVerification } from "@app/util/limit/index" import { ref } from "vue" export const useVerify = (option: timer.option.LimitOption) => { @@ -7,12 +7,11 @@ export const useVerify = (option: timer.option.LimitOption) => { const verify = async (): Promise<void> => { if (verified.value) return - const items = await limitService.select() - const triggerResults = await Promise.all((items || []).map(judgeVerificationRequired)) - const anyTrigger = triggerResults.some(t => !!t) - if (anyTrigger) { - await processVerification(option) - } + const items = await listLimits() + const delayDuration = option.limitDelayDuration + const triggerResults = await Promise.all(items.map(item => judgeVerificationRequired(item, delayDuration))) + const anyTrigger = triggerResults.some(t => t) + if (anyTrigger) await processVerification(option) verified.value = true } diff --git a/src/pages/app/components/Option/categories/Notification/Footer.tsx b/src/pages/app/components/Option/categories/Notification/Footer.tsx index bdd929603..3c3746a4f 100644 --- a/src/pages/app/components/Option/categories/Notification/Footer.tsx +++ b/src/pages/app/components/Option/categories/Notification/Footer.tsx @@ -1,17 +1,19 @@ +import { sendMsg2Runtime } from '@api/sw/common' import { t } from '@app/locale' import { Operation } from '@element-plus/icons-vue' import Flex from '@pages/components/Flex' -import processor from '@service/notification/processor' import { ElButton, ElDivider, ElMessage } from 'element-plus' import type { FunctionalComponent } from 'vue' const Footer: FunctionalComponent<{}> = () => { const handleTest = async () => { - const result = await processor.doSend() - if (result.success) { + try { + const errMsg = await sendMsg2Runtime('option.testNotification') + if (errMsg) throw new Error(errMsg) ElMessage.success('Valid!') - } else { - ElMessage.error(`${result.errorMsg}`) + } catch (e) { + const msg = e instanceof Error ? e.message : String(e) ?? 'Unknown error' + ElMessage.error(msg) } } diff --git a/src/pages/app/components/Option/categories/Notification/index.tsx b/src/pages/app/components/Option/categories/Notification/index.tsx index 71004e1b4..ae3ba0f46 100644 --- a/src/pages/app/components/Option/categories/Notification/index.tsx +++ b/src/pages/app/components/Option/categories/Notification/index.tsx @@ -1,8 +1,8 @@ +import { OptionItem, OptionLines } from '@app/components/Option/components' import { t } from '@app/locale' import { QuestionFilled } from '@element-plus/icons-vue' import { ElIcon, ElInput, ElLink, ElMessage, ElSelect, ElTimePicker, ElTooltip } from 'element-plus' import { computed, defineComponent, StyleValue } from 'vue' -import { OptionItem, OptionLines } from '../../components' import type { CategoryInstance } from '../types' import Footer from './Footer' import { useNotification } from './useNotification' @@ -95,7 +95,7 @@ const Notification = defineComponent((_, ctx) => { size="small" style={{ width: "400px" } satisfies StyleValue} onInput={val => option.notificationEndpoint = val} - placeholder="https://example.com/api/notification" + placeholder="https://example.com/notification" /> <ElTooltip content='Document' effect='light' placement='top'> <ElLink diff --git a/src/pages/app/components/Option/categories/Notification/useNotification.ts b/src/pages/app/components/Option/categories/Notification/useNotification.ts index 8b60e263d..08d115c94 100644 --- a/src/pages/app/components/Option/categories/Notification/useNotification.ts +++ b/src/pages/app/components/Option/categories/Notification/useNotification.ts @@ -1,9 +1,8 @@ -import { trySendMsg2Runtime } from '@api/chrome/runtime' -import { defaultNotification } from '@util/constant/option' -import { computed, watch } from 'vue' -import { useOption } from '../../useOption' +import { useOption } from '@app/components/Option/useOption' +import { DEFAULT_NOTIFICATION } from '@util/constant/option' +import { computed } from 'vue' -function copy(target: timer.option.NotificationOption, source: timer.option.NotificationOption) { +function copy(target: timer.option.NotificationOption, source: Readonly<timer.option.NotificationOption>) { target.notificationCycle = source.notificationCycle target.notificationOffset = source.notificationOffset target.notificationMethod = source.notificationMethod @@ -14,12 +13,7 @@ function copy(target: timer.option.NotificationOption, source: timer.option.Noti const MIN_PER_DAY = 24 * 60 export const useNotification = () => { - const { option, loading } = useOption<timer.option.NotificationOption>({ defaultValue: defaultNotification, copy }) - - watch([ - () => option.notificationCycle, - () => option.notificationOffset, - ], () => !loading.value && setTimeout(() => trySendMsg2Runtime('resetNotificationScheduler'))) + const { option } = useOption<timer.option.NotificationOption>({ defaultValue: DEFAULT_NOTIFICATION, copy }) const weekday = computed<number | null>({ get() { @@ -61,7 +55,7 @@ export const useNotification = () => { }) const reset = () => { - option.notificationCycle = defaultNotification().notificationCycle + option.notificationCycle = DEFAULT_NOTIFICATION.notificationCycle // other fields needn't be reset } diff --git a/src/pages/app/components/Option/categories/Tracking.tsx b/src/pages/app/components/Option/categories/Tracking.tsx index 2f66b6479..58a002c2c 100644 --- a/src/pages/app/components/Option/categories/Tracking.tsx +++ b/src/pages/app/components/Option/categories/Tracking.tsx @@ -5,14 +5,14 @@ * https://opensource.org/licenses/MIT */ import { hasPerm, requestPerm } from "@api/chrome/permission" -import { isAllowedFileSchemeAccess, sendMsg2Runtime } from "@api/chrome/runtime" -import { t } from "@app/locale" -import { useManualRequest, useRequest } from "@hooks" +import { isAllowedFileSchemeAccess } from "@api/chrome/runtime" +import { sendMsg2Runtime } from '@api/sw/common' +import { t } from '@app/locale' +import { useManualRequest, useRequest } from '@hooks' import { locale } from "@i18n" -import immigration from '@service/components/immigration' import { rotate } from "@util/array" import { IS_ANDROID, IS_FIREFOX } from "@util/constant/environment" -import { defaultTracking } from "@util/constant/option" +import { DEFAULT_TRACKING } from "@util/constant/option" import { MILL_PER_SECOND } from "@util/time" import { ElMessage, ElMessageBox, ElSelect, ElSwitch, ElTimePicker, ElTooltip } from "element-plus" import { computed, defineComponent } from "vue" @@ -25,8 +25,6 @@ const ALL_STORAGES: Record<timer.option.StorageType, string> = { indexed_db: 'IndexedDB', } -const DEFAULT_VALUE = defaultTracking() - const weekStartOptionPairs: [[timer.option.WeekStartOption, string]] = [ ['default', t(msg => msg.option.tracking.weekStartAsNormal)] ] @@ -36,7 +34,7 @@ const allWeekDays = t(msg => msg.calendar.weekDays) rotate(allWeekDays, locale === 'zh_CN' ? 0 : 1, true) allWeekDays.forEach(weekDayInfo => weekStartOptionPairs.push(weekDayInfo)) -function copy(target: timer.option.TrackingOption, source: timer.option.TrackingOption) { +function copy(target: timer.option.TrackingOption, source: Readonly<timer.option.TrackingOption>) { target.countLocalFiles = source.countLocalFiles target.countTabGroup = source.countTabGroup target.weekStart = source.weekStart @@ -46,7 +44,7 @@ function copy(target: timer.option.TrackingOption, source: timer.option.Tracking } const _default = defineComponent((_props, ctx) => { - const { option } = useOption({ defaultValue: defaultTracking, copy }) + const { option } = useOption<timer.option.TrackingOption>({ defaultValue: DEFAULT_TRACKING, copy }) const { data: fileAccess } = useRequest(isAllowedFileSchemeAccess) const reset = () => { // Not to reset these fields @@ -54,16 +52,19 @@ const _default = defineComponent((_props, ctx) => { autoPauseInterval: oldInterval, storage: oldStorage, } = option - copy(option, defaultTracking()) + copy(option, DEFAULT_TRACKING) option.autoPauseInterval = oldInterval option.storage = oldStorage } ctx.expose({ reset } satisfies CategoryInstance) - const { refresh: changeStorageType, loading: storageMigrating } = useManualRequest(async (type: timer.option.StorageType) => { - await immigration.migrateStorage(type) - option.storage = type - }, { loadingText: 'Data migrating...' }) + const { refresh: changeStorageType, loading: storageMigrating } = useManualRequest( + type => sendMsg2Runtime('option.changeStorage', type), + { + loadingText: 'Data migrating...', + onSuccess: (_, type) => option.storage = type, + } + ) const handleChangeStorage = (type: timer.option.StorageType) => { const msg = t(msg => msg.option.tracking.storageConfirm, { type: ALL_STORAGES[type] }) @@ -109,14 +110,13 @@ const _default = defineComponent((_props, ctx) => { } } option.countTabGroup = val - val && sendMsg2Runtime("enableTabGroup") } return () => <OptionLines> {!IS_ANDROID && <> <OptionItem label={msg => msg.option.tracking.autoPauseTrack} - defaultValue={DEFAULT_VALUE.autoPauseTracking} + defaultValue={DEFAULT_TRACKING.autoPauseTracking} v-slots={{ info: () => <OptionTooltip>{t(msg => msg.option.tracking.noActivityInfo)}</OptionTooltip>, maxTime: () => <ElTimePicker @@ -136,7 +136,7 @@ const _default = defineComponent((_props, ctx) => { /> <OptionItem label={msg => msg.option.tracking.countLocalFiles} - defaultValue={DEFAULT_VALUE.countLocalFiles} + defaultValue={DEFAULT_TRACKING.countLocalFiles} v-slots={{ info: () => <OptionTooltip>{t(msg => msg.option.tracking.localFilesInfo)}</OptionTooltip>, localFileTime: () => <OptionTag>{t(msg => msg.option.tracking.localFileTime)}</OptionTag>, diff --git a/src/pages/app/components/Option/components/Item.tsx b/src/pages/app/components/Option/components/Item.tsx index 068f6c381..e636d93c3 100644 --- a/src/pages/app/components/Option/components/Item.tsx +++ b/src/pages/app/components/Option/components/Item.tsx @@ -1,6 +1,6 @@ -import { t, tN, type I18nKey } from "@app/locale" +import { MediaSize, useMediaSize } from '@hooks' +import { t, tN, type I18nKey } from '@app/locale' import { css } from '@emotion/css' -import { MediaSize, useMediaSize } from '@hooks/useMediaSize' import Flex from "@pages/components/Flex" import { colorVariant } from '@pages/util/style' import { ElTag, useNamespace } from "element-plus" diff --git a/src/pages/app/components/Option/export-import.ts b/src/pages/app/components/Option/export-import.ts index de08301eb..a9e1c0da9 100644 --- a/src/pages/app/components/Option/export-import.ts +++ b/src/pages/app/components/Option/export-import.ts @@ -4,11 +4,11 @@ * https://opensource.org/licenses/MIT */ -import optionHolder from "@service/components/option-holder" +import { getOption, setOption } from "@api/sw/option" import { deserialize, exportJson } from "@util/file" import { mergeObject } from '@util/lang' -export interface ExportedSettings { +interface ExportedSettings { version: string timestamp: number settings: timer.option.AllOption @@ -18,9 +18,9 @@ export interface ExportedSettings { * Export all settings to JSON file */ export async function exportSettings(): Promise<void> { - const settings = await optionHolder.get() + const settings = await getOption() const exportData: ExportedSettings = { - version: '1.0', + version: chrome.runtime.getManifest().version, timestamp: Date.now(), settings, } @@ -43,7 +43,7 @@ export async function importSettings(jsonString: string): Promise<void> { const validatedSettings = await validateAndMergeSettings(importData.settings) // Set the imported settings - await optionHolder.set(validatedSettings) + await setOption(validatedSettings as Partial<timer.option.AllOption>) } /** @@ -51,10 +51,10 @@ export async function importSettings(jsonString: string): Promise<void> { */ async function validateAndMergeSettings(importedSettings: Partial<timer.option.AllOption>): Promise<timer.option.AllOption> { // Get current user settings as defaults instead of default options - const defaults = await optionHolder.get() + const current = await getOption() // Delete client name delete importedSettings['clientName'] - return mergeObject(defaults, importedSettings) + return mergeObject(current ?? {}, importedSettings) as timer.option.AllOption } /** diff --git a/src/pages/app/components/Option/index.tsx b/src/pages/app/components/Option/index.tsx index 766b20fbf..184c4a44b 100644 --- a/src/pages/app/components/Option/index.tsx +++ b/src/pages/app/components/Option/index.tsx @@ -4,7 +4,7 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { MediaSize, useMediaSize } from "@hooks" +import { MediaSize, useMediaSize } from '@hooks' import { ElScrollbar } from 'element-plus' import { defineComponent, ref, type Ref, type StyleValue } from "vue" import type { JSX } from "vue/jsx-runtime" diff --git a/src/pages/app/components/Option/useCategory.ts b/src/pages/app/components/Option/useCategory.ts index ebba33dac..641fe8ea7 100644 --- a/src/pages/app/components/Option/useCategory.ts +++ b/src/pages/app/components/Option/useCategory.ts @@ -9,7 +9,7 @@ const isCategory = createStringUnionGuard<OptionCategory>('appearance', 'trackin const CATE_LABELS: Record<OptionCategory, I18nKey> = { appearance: msg => msg.option.appearance.title, tracking: msg => msg.option.tracking.title, - limit: msg => msg.menu.limit, + limit: msg => msg.base.limit, accessibility: msg => msg.option.accessibility.title, backup: msg => msg.option.backup.title, notification: msg => msg.option.notification.title, diff --git a/src/pages/app/components/Option/useOption.ts b/src/pages/app/components/Option/useOption.ts index 2461dae2d..a8aa50621 100644 --- a/src/pages/app/components/Option/useOption.ts +++ b/src/pages/app/components/Option/useOption.ts @@ -1,25 +1,29 @@ -import { useRequest } from '@hooks/useRequest' -import optionHolder from "@service/components/option-holder" +import { getOption, setOption } from "@api/sw/option" +import { useRequest } from '@hooks' import { reactive, toRaw, watch } from "vue" type Options<T> = { - defaultValue: () => T - copy: (target: T, source: T) => void + defaultValue: (() => T) | T + copy: (target: T, source: Readonly<T>) => void onChange?: (newVal: T) => void } +type MutableKeys<T> = { + -readonly [K in keyof T]: T[K] +} + export const useOption = <T extends object = Partial<timer.option.AllOption>>(options: Options<T>) => { const { defaultValue, copy, onChange } = options - const option = reactive<T>(defaultValue?.()) + const option = reactive<MutableKeys<T>>(typeof defaultValue === 'function' ? defaultValue() : structuredClone(defaultValue)) const { loading } = useRequest(async () => { - const currentVal = await optionHolder.get() as T + const currentVal = await getOption() as T copy(option as T, currentVal) }) watch(option, async () => { const newVal = toRaw(option) as T - !loading.value && await optionHolder.set(newVal) + !loading.value && await setOption(newVal) onChange?.(newVal) }) diff --git a/src/pages/app/components/Report/ReportFilter/BatchDelete.tsx b/src/pages/app/components/Report/Filter/BatchDelete.tsx similarity index 78% rename from src/pages/app/components/Report/ReportFilter/BatchDelete.tsx rename to src/pages/app/components/Report/Filter/BatchDelete.tsx index 88caaa9b4..b49a3db83 100644 --- a/src/pages/app/components/Report/ReportFilter/BatchDelete.tsx +++ b/src/pages/app/components/Report/Filter/BatchDelete.tsx @@ -1,16 +1,17 @@ import { getGroup } from "@api/chrome/tabGroups" -import { type I18nKey, t } from "@app/locale" -import statDatabase from "@db/stat-database" +import { + batchDeleteStats, countGroupStatsByIds, countSiteStatsByHosts, deleteSiteStatByGroup, deleteSiteStatByHost, +} from "@api/sw/stat" +import { type I18nKey, t } from '@app/locale' import { DeleteFilled } from "@element-plus/icons-vue" -import { batchDelete, countGroupByIds, countSiteByHosts } from "@service/stat-service" import { isGroup, isNormalSite, isSite } from "@util/stat" -import { formatTime, getBirthday } from "@util/time" +import { cvtDateRange2Str, DateRange, formatTime, formatTimeYMD, getBirthday } from "@util/time" import { ElButton, ElMessage, ElMessageBox } from "element-plus" import { computed, defineComponent } from "vue" import { useReportComponent, useReportFilter } from "../context" import type { DisplayComponent, ReportFilterOption } from "../types" -async function computeBatchDeleteMsg(selected: timer.stat.Row[], mergeDate: boolean, dateRange: [Date?, Date?]): Promise<string> { +async function computeBatchDeleteMsg(selected: timer.stat.Row[], mergeDate: boolean, dateRange: DateRange): Promise<string> { const hosts: string[] = [] const groupIds: number[] = [] selected.forEach(row => { @@ -30,8 +31,10 @@ async function computeBatchDeleteMsg(selected: timer.stat.Row[], mergeDate: bool let count2Delete = selected.length ?? 0 if (mergeDate) { // All the items - const siteCount = hosts.length ? await countSiteByHosts(hosts, dateRange) : 0 - const groupCount = groupIds.length ? await countGroupByIds(groupIds, dateRange) : 0 + const [start, end] = dateRange instanceof Date ? [dateRange,] : dateRange ?? [] + const date: [string?, string?] = [start && formatTimeYMD(start), end && formatTimeYMD(end)] + const siteCount = hosts.length ? await countSiteStatsByHosts(hosts, date) : 0 + const groupCount = groupIds.length ? await countGroupStatsByIds(groupIds, date) : 0 count2Delete = siteCount + groupCount } const i18nParam: Record<string, string | number | undefined> = { @@ -48,7 +51,7 @@ async function computeBatchDeleteMsg(selected: timer.stat.Row[], mergeDate: bool } let key: I18nKey | undefined = undefined - let [startDate, endDate] = dateRange + let [startDate, endDate] = dateRange instanceof Date ? [dateRange,] : dateRange ?? [] if (!startDate && !endDate) { // Delete all key = msg => msg.report.batchDelete.confirmMsgAll @@ -84,7 +87,7 @@ async function handleBatchDelete(displayComp: DisplayComponent | undefined, filt ElMessageBox({ message: await computeBatchDeleteMsg(selected, mergeDate, dateRange), type: "warning", - confirmButtonText: t(msg => msg.button.okey), + confirmButtonText: t(msg => msg.button.okay), showCancelButton: true, cancelButtonText: t(msg => msg.button.dont), // Cant close this on press ESC @@ -101,17 +104,17 @@ async function handleBatchDelete(displayComp: DisplayComponent | undefined, filt }) } -async function deleteBatch(selected: timer.stat.Row[], mergeDate: boolean, dateRange: [Date?, Date?]) { +async function deleteBatch(selected: timer.stat.Row[], mergeDate: boolean, dateRange: DateRange) { if (mergeDate) { // Delete according to the date range - const [start, end] = dateRange ?? [] + const date = cvtDateRange2Str(dateRange) for (const row of selected) { - isNormalSite(row) && await statDatabase.deleteByHost(row.siteKey.host, [start, end]) - isGroup(row) && await statDatabase.deleteByGroup(row.groupKey, [start, end]) + isNormalSite(row) && await deleteSiteStatByHost(row.siteKey.host, date) + isGroup(row) && await deleteSiteStatByGroup(row.groupKey, date) } } else { // If not merge date, batch delete - await batchDelete(selected) + await batchDeleteStats(selected) } } diff --git a/src/pages/app/components/Report/ReportFilter/DownloadFile.tsx b/src/pages/app/components/Report/Filter/DownloadFile.tsx similarity index 97% rename from src/pages/app/components/Report/ReportFilter/DownloadFile.tsx rename to src/pages/app/components/Report/Filter/DownloadFile.tsx index bf2f6d390..4e6e30ee7 100644 --- a/src/pages/app/components/Report/ReportFilter/DownloadFile.tsx +++ b/src/pages/app/components/Report/Filter/DownloadFile.tsx @@ -6,8 +6,8 @@ */ import { useCategory } from "@app/context" +import { useTabGroups } from "@hooks" import { Download } from "@element-plus/icons-vue" -import { useTabGroups } from "@hooks/useTabGroups" import { ElButton, ElDropdown, ElDropdownItem, ElDropdownMenu, ElIcon } from "element-plus" import { defineComponent } from "vue" import { queryAll } from "../common" diff --git a/src/pages/app/components/Report/ReportFilter/MergeFilterItem.tsx b/src/pages/app/components/Report/Filter/MergeFilterItem.tsx similarity index 98% rename from src/pages/app/components/Report/ReportFilter/MergeFilterItem.tsx rename to src/pages/app/components/Report/Filter/MergeFilterItem.tsx index afe295acb..5bf0d477b 100644 --- a/src/pages/app/components/Report/ReportFilter/MergeFilterItem.tsx +++ b/src/pages/app/components/Report/Filter/MergeFilterItem.tsx @@ -1,5 +1,5 @@ import { useCategory } from '@app/context' -import { t } from "@app/locale" +import { t } from '@app/locale' import { Calendar, Collection, Link, Menu } from "@element-plus/icons-vue" import { useSiteMerge } from '@hooks' import Flex from "@pages/components/Flex" @@ -63,7 +63,7 @@ const MergeFilterItem = defineComponent<{}>(() => { </ElCheckboxButton> ))} </ElCheckboxGroup> - </Flex > + </Flex> ) }, { props: ['hideCate'] }) diff --git a/src/pages/app/components/Report/ReportFilter/RemoteClient.tsx b/src/pages/app/components/Report/Filter/RemoteClient.tsx similarity index 84% rename from src/pages/app/components/Report/ReportFilter/RemoteClient.tsx rename to src/pages/app/components/Report/Filter/RemoteClient.tsx index 62ce8c9c3..2b54268ee 100644 --- a/src/pages/app/components/Report/ReportFilter/RemoteClient.tsx +++ b/src/pages/app/components/Report/Filter/RemoteClient.tsx @@ -5,10 +5,10 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { checkAuth } from '@api/sw/backup' +import { t } from '@app/locale' import { UploadFilled } from "@element-plus/icons-vue" -import { useRequest } from "@hooks" -import { canReadRemote } from '@service/stat-service/remote' +import { useRequest } from '@hooks' import { ElButton, ElIcon, ElTooltip } from "element-plus" import { computed, defineComponent } from "vue" import { useReportFilter } from "../context" @@ -17,7 +17,7 @@ import { ICON_BTN_STYLE } from "./common" const _default = defineComponent(() => { const filter = useReportFilter() const content = computed(() => t(msg => msg.report.remoteReading[filter.readRemote ? 'on' : 'off'])) - const { data: visible } = useRequest(() => canReadRemote(), { defaultValue: false }) + const { data: visible } = useRequest(() => checkAuth().then(errMsg => !errMsg), { defaultValue: false }) return () => ( <ElTooltip trigger="hover" placement="bottom-start" effect="dark" content={content.value}> diff --git a/src/pages/app/components/Report/ReportFilter/common.ts b/src/pages/app/components/Report/Filter/common.ts similarity index 100% rename from src/pages/app/components/Report/ReportFilter/common.ts rename to src/pages/app/components/Report/Filter/common.ts diff --git a/src/pages/app/components/Report/ReportFilter/index.tsx b/src/pages/app/components/Report/Filter/index.tsx similarity index 92% rename from src/pages/app/components/Report/ReportFilter/index.tsx rename to src/pages/app/components/Report/Filter/index.tsx index aeffdab75..dd26b59fa 100644 --- a/src/pages/app/components/Report/ReportFilter/index.tsx +++ b/src/pages/app/components/Report/Filter/index.tsx @@ -9,9 +9,9 @@ import CategoryFilter from "@app/components/common/filter/CategoryFilter" import DateRangeFilterItem from "@app/components/common/filter/DateRangeFilterItem" import InputFilterItem from '@app/components/common/filter/InputFilterItem' import TimeFormatFilterItem from "@app/components/common/filter/TimeFormatFilterItem" -import { t } from "@app/locale" +import { t } from '@app/locale' import Flex from "@pages/components/Flex" -import type { ElDatePickerShortcut } from '@pages/types' +import { ElDatePickerShortcut } from '@pages/element-ui/types' import { daysAgo } from "@util/time" import { defineComponent } from "vue" import { useReportFilter } from "../context" @@ -47,7 +47,7 @@ const _default = defineComponent<{}>(() => { endPlaceholder={t(msg => msg.calendar.label.endDate)} disabledDate={(date: Date | number) => new Date(date) > new Date()} shortcuts={dateShortcuts} - modelValue={filter.dateRange} + modelValue={(filter.dateRange instanceof Date ? [filter.dateRange] : (filter.dateRange ?? [])) as [Date?, Date?]} onChange={val => filter.dateRange = val} /> <CategoryFilter @@ -68,6 +68,6 @@ const _default = defineComponent<{}>(() => { </Flex> </Flex> ) -}, { props: ['hideCateFilter'] }) +}) export default _default \ No newline at end of file diff --git a/src/pages/app/components/Report/ReportList/Item.tsx b/src/pages/app/components/Report/List/Item.tsx similarity index 91% rename from src/pages/app/components/Report/ReportList/Item.tsx rename to src/pages/app/components/Report/List/Item.tsx index a367e0765..b6ad6ea54 100644 --- a/src/pages/app/components/Report/ReportList/Item.tsx +++ b/src/pages/app/components/Report/List/Item.tsx @@ -1,18 +1,19 @@ -import HostAlert from "@app/components/common/HostAlert" -import PopupConfirmButton from "@app/components/common/PopupConfirmButton" -import TooltipWrapper from "@app/components/common/TooltipWrapper" -import { cvt2LocaleTime, periodFormatter } from "@app/util/time" + +import HostAlert from '@app/components/common/HostAlert' +import PopupConfirmButton from '@app/components/common/PopupConfirmButton' +import { cvt2LocaleTime, periodFormatter } from '@app/util/time' import { Calendar, Delete, Mouse, QuartzWatch } from "@element-plus/icons-vue" import { css } from '@emotion/css' -import { useTabGroups } from "@hooks/useTabGroups" +import { useTabGroups } from "@hooks" import Flex from '@pages/components/Flex' +import TooltipWrapper from '@pages/components/TooltipWrapper' import { getComposition, isGroup, isNormalSite, isSite } from "@util/stat" import { Effect, ElCheckbox, ElDivider, ElIcon, ElTag, useNamespace } from "element-plus" import { computed, defineComponent, ref, StyleValue, watch } from "vue" import { computeDeleteConfirmMsg, handleDelete } from "../common" -import CompositionTable from "../CompositionTable" +import CompositionTable from "../components/CompositionTable" +import TooltipSiteList from "../components/TooltipSiteList" import { useReportFilter } from "../context" -import TooltipSiteList from "../ReportTable/columns/TooltipSiteList" type Props = { value: timer.stat.Row @@ -70,7 +71,7 @@ const _default = defineComponent<Props>(props => { trigger="click" usePopover={props.value.siteKey.type === 'merged'} v-slots={{ - content: () => <TooltipSiteList modelValue={mergedRows} />, + content: () => <TooltipSiteList modelValue={mergedRows as timer.stat.SiteRow[]} />, }} > <HostAlert @@ -127,7 +128,7 @@ const _default = defineComponent<Props>(props => { </ElTag> </TooltipWrapper> </Flex> - </div > + </div> ) }, { props: ['onDelete', 'onSelectedChange', 'value'] }) diff --git a/src/pages/app/components/Report/ReportList/index.tsx b/src/pages/app/components/Report/List/index.tsx similarity index 89% rename from src/pages/app/components/Report/ReportList/index.tsx rename to src/pages/app/components/Report/List/index.tsx index ceac0f5cf..014f7d618 100644 --- a/src/pages/app/components/Report/ReportList/index.tsx +++ b/src/pages/app/components/Report/List/index.tsx @@ -1,6 +1,6 @@ -import { t } from "@app/locale" +import { t } from '@app/locale' import { css } from '@emotion/css' -import { useScrollRequest } from "@hooks" +import { useScrollRequest } from '@hooks' import { getHost } from "@util/stat" import { ElCard, useNamespace } from "element-plus" import { defineComponent, ref } from "vue" @@ -46,12 +46,12 @@ const _default = defineComponent<{}>((_, ctx) => { const selected = ref<number[]>([]) ctx.expose({ - getSelected: () => selected.value?.map(idx => data.value?.[idx])?.filter(i => !!i) ?? [], + getSelected: () => selected.value.map(idx => data.value[idx]).filter(i => !!i), refresh: reset, } satisfies DisplayComponent) const handleSelectedChange = (val: boolean, idx: number) => { - const newSelected = selected.value?.filter(v => v !== idx) || [] + const newSelected = selected.value.filter(v => v !== idx) val && newSelected.push(idx) return selected.value = newSelected } @@ -64,7 +64,7 @@ const _default = defineComponent<{}>((_, ctx) => { v-infinite-scroll={loadMoreAsync} infinite-scroll-disabled={end.value || loading.value} > - {data.value?.map((row, idx) => ( + {data.value.map((row, idx) => ( <ElCard> <Item key={`row-${getHost(row)}-${idx}`} diff --git a/src/pages/app/components/Report/ReportTable/columns/CateColumn.tsx b/src/pages/app/components/Report/Table/columns/CateColumn.tsx similarity index 73% rename from src/pages/app/components/Report/ReportTable/columns/CateColumn.tsx rename to src/pages/app/components/Report/Table/columns/CateColumn.tsx index fd2b23630..1d871a746 100644 --- a/src/pages/app/components/Report/ReportTable/columns/CateColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/CateColumn.tsx @@ -1,28 +1,19 @@ -import CategoryEditable from "@app/components/common/category/CategoryEditable" -import { useCategory } from "@app/context" -import { t } from "@app/locale" +import CategoryEditable from "@app/components/common/Category/Editable" +import TooltipSiteList from "@app/components/Report/components/TooltipSiteList" +import { useCategory } from '@app/context' +import { t } from '@app/locale' import Flex from "@pages/components/Flex" -import { CATE_NOT_SET_ID, SiteMap } from "@util/site" +import { CATE_NOT_SET_ID } from "@util/site" import { getRelatedCateId, identifyStatKey, isCate, isGroup, isSite } from "@util/stat" import { Effect, ElTableColumn, ElText, ElTooltip, type RenderRowData } from "element-plus" import { defineComponent } from "vue" -import TooltipSiteList from "./TooltipSiteList" const renderMerged = (cateId: number, categories: timer.site.Cate[], merged: timer.stat.SiteRow[]) => { - let cateName: string - let isNotSet = false - const siteMap = new SiteMap<string>() - merged.forEach(row => isSite(row) && siteMap.put(row.siteKey, row.iconUrl)) + const [cateName, isNotSet] = CATE_NOT_SET_ID === cateId + ? [t(msg => msg.shared.cate.notSet), true] + : [categories.find(c => c.id === cateId)?.name, false] - if (cateId === CATE_NOT_SET_ID) { - cateName = t(msg => msg.shared.cate.notSet) - isNotSet = true - } else { - const current = categories?.find(c => c.id === cateId) - if (!current) return null - cateName = current?.name - } - return ( + return cateName ? ( <ElTooltip effect={Effect.LIGHT} offset={10} @@ -37,7 +28,7 @@ const renderMerged = (cateId: number, categories: timer.site.Cate[], merged: tim ), }} /> - ) + ) : null } type Props = { @@ -54,7 +45,7 @@ const CateColumn = defineComponent<Props>(props => { const cateId = getRelatedCateId(row) return ( <Flex key={`${identifyStatKey(row)}_${cateId}`} justify="center"> - {isCate(row) && renderMerged(row.cateKey, cate.all, mergedRows ?? [])} + {isCate(row) && renderMerged(row.cateKey, cate.all, (mergedRows ?? []) as timer.stat.SiteRow[])} {isSite(row) && ( <CategoryEditable siteKey={row.siteKey} diff --git a/src/pages/app/components/Report/ReportTable/columns/DateColumn.tsx b/src/pages/app/components/Report/Table/columns/DateColumn.tsx similarity index 81% rename from src/pages/app/components/Report/ReportTable/columns/DateColumn.tsx rename to src/pages/app/components/Report/Table/columns/DateColumn.tsx index 2b24004c4..97c920705 100644 --- a/src/pages/app/components/Report/ReportTable/columns/DateColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/DateColumn.tsx @@ -5,11 +5,11 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" -import { cvt2LocaleTime } from "@app/util/time" +import type { ReportSort } from '@app/components/Report/types' +import { t } from '@app/locale' +import { cvt2LocaleTime } from '@app/util/time' import { ElTableColumn, RenderRowData } from "element-plus" import { type FunctionalComponent } from "vue" -import type { ReportSort } from "../../types" const DateColumn: FunctionalComponent = () => ( <ElTableColumn diff --git a/src/pages/app/components/Report/ReportTable/columns/GroupColumn.tsx b/src/pages/app/components/Report/Table/columns/GroupColumn.tsx similarity index 88% rename from src/pages/app/components/Report/ReportTable/columns/GroupColumn.tsx rename to src/pages/app/components/Report/Table/columns/GroupColumn.tsx index 149b0fddb..98570dd07 100644 --- a/src/pages/app/components/Report/ReportTable/columns/GroupColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/GroupColumn.tsx @@ -1,6 +1,6 @@ -import { cvtGroupColor } from "@api/chrome/tabGroups" -import { t } from "@app/locale" -import { useTabGroups } from "@hooks/useTabGroups" +import { t } from '@app/locale' +import { useTabGroups } from "@hooks" +import { cvtGroupColor } from '@pages/util/style' import { isGroup } from "@util/stat" import { ElTableColumn, ElTag, type RenderRowData } from "element-plus" import { defineComponent, StyleValue } from "vue" diff --git a/src/pages/app/components/Report/ReportTable/columns/HostColumn.tsx b/src/pages/app/components/Report/Table/columns/HostColumn.tsx similarity index 66% rename from src/pages/app/components/Report/ReportTable/columns/HostColumn.tsx rename to src/pages/app/components/Report/Table/columns/HostColumn.tsx index 52c09cb99..091640142 100644 --- a/src/pages/app/components/Report/ReportTable/columns/HostColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/HostColumn.tsx @@ -5,17 +5,17 @@ * https://opensource.org/licenses/MIT */ -import HostAlert from "@app/components/common/HostAlert" -import TooltipWrapper from "@app/components/common/TooltipWrapper" -import { t } from "@app/locale" +import HostAlert from '@app/components/common/HostAlert' +import TooltipSiteList from '@app/components/Report/components/TooltipSiteList' +import { useReportFilter } from '@app/components/Report/context' +import { ReportSort } from '@app/components/Report/types' +import { t } from '@app/locale' import Flex from "@pages/components/Flex" +import TooltipWrapper from '@pages/components/TooltipWrapper' import { identifySiteKey } from "@util/site" import { isGroup, isSite } from "@util/stat" import { Effect, ElTableColumn, type RenderRowData } from "element-plus" import { defineComponent } from "vue" -import { useReportFilter } from "../../context" -import type { ReportSort } from "../../types" -import TooltipSiteList from "./TooltipSiteList" const _default = defineComponent(() => { const filter = useReportFilter() @@ -35,13 +35,17 @@ const _default = defineComponent(() => { offset={10} placement="left" v-slots={{ - content: () => <TooltipSiteList modelValue={isGroup(row) ? undefined : row.mergedRows} />, + content: () => ( + <TooltipSiteList + modelValue={isGroup(row) ? undefined : (row.mergedRows as timer.stat.SiteRow[] | undefined)} + /> + ), default: () => isSite(row) ? <HostAlert value={row.siteKey} iconUrl={row.iconUrl} /> : '', }} /> </Flex> )} - </ElTableColumn > + </ElTableColumn> ) }) diff --git a/src/pages/app/components/Report/ReportTable/columns/OperationColumn.tsx b/src/pages/app/components/Report/Table/columns/OperationColumn.tsx similarity index 72% rename from src/pages/app/components/Report/ReportTable/columns/OperationColumn.tsx rename to src/pages/app/components/Report/Table/columns/OperationColumn.tsx index 71837898d..2d2be8f4b 100644 --- a/src/pages/app/components/Report/ReportTable/columns/OperationColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/OperationColumn.tsx @@ -4,22 +4,21 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import type { AnalysisQuery } from "@app/components/Analysis/context" -import PopupConfirmButton from "@app/components/common/PopupConfirmButton" -import { t } from "@app/locale" -import { ANALYSIS_ROUTE } from "@app/router/constants" +import { type AppAnalysisQuery } from '@/shared/route' +import { addWhitelist, deleteWhitelist, listWhitelist } from "@api/sw/whitelist" +import PopupConfirmButton from '@app/components/common/PopupConfirmButton' +import { computeDeleteConfirmMsg, handleDelete } from '@app/components/Report/common' +import { useReportFilter } from '@app/components/Report/context' +import { t } from '@app/locale' +import { ANALYSIS_ROUTE } from '@app/router/constants' import { Delete, Open, Plus, Stopwatch } from "@element-plus/icons-vue" -import { useRequest } from "@hooks" -import { useTabGroups } from "@hooks/useTabGroups" +import { useManualRequest, useRequest, useTabGroups } from '@hooks' import { locale } from "@i18n" -import whitelistService from "@service/whitelist/service" import { CATE_NOT_SET_ID } from "@util/site" import { isCate, isGroup, isNormalSite, isSite } from "@util/stat" import { ElButton, ElMessage, ElTableColumn, type RenderRowData } from "element-plus" import { computed, defineComponent } from "vue" import { useRouter } from "vue-router" -import { computeDeleteConfirmMsg, handleDelete } from "../../common" -import { useReportFilter } from "../../context" const LOCALE_WIDTH: { [locale in timer.Locale]: number } = { en: 330, @@ -62,13 +61,16 @@ const _default = defineComponent<Props>(({ onDelete }) => { return !siteMerge || siteMerge === 'group' ? LOCALE_WIDTH[locale] : 110 }) const router = useRouter() - const { - data: whitelist, - refresh: refreshWhitelist, - } = useRequest(() => whitelistService.listAll(), { defaultValue: [] }) + const { data: whitelist, refresh: refreshWhitelist } = useRequest(listWhitelist, { defaultValue: [] }) + const onWhitelistSuccess = () => { + refreshWhitelist() + ElMessage.success(t(msg => msg.operation.successMsg)) + } + const { refresh: onAddWhitelist } = useManualRequest(addWhitelist, { onSuccess: onWhitelistSuccess }) + const { refresh: onRemoveWhitelist } = useManualRequest(deleteWhitelist, { onSuccess: onWhitelistSuccess }) const jump2Analysis = (row: timer.stat.Row) => { - let query: AnalysisQuery + let query: AppAnalysisQuery if (isCate(row)) { query = { cateId: row.cateKey?.toString?.() } } else if (isSite(row)) { @@ -111,33 +113,23 @@ const _default = defineComponent<Props>(({ onDelete }) => { /> )} {/* Add 2 whitelist */} - {isNormalSite(row) && !whitelist.value?.includes(row.siteKey.host) && ( + {isNormalSite(row) && !whitelist.value.includes(row.siteKey.host) && ( <PopupConfirmButton buttonIcon={Plus} buttonType="warning" buttonText={t(msg => msg.item.operation.add2Whitelist)} confirmText={t(msg => msg.whitelist.addConfirmMsg, { url: row.siteKey?.host })} - onConfirm={async () => { - const host = row.siteKey?.host - host && await whitelistService.add(host) - refreshWhitelist() - ElMessage.success(t(msg => msg.operation.successMsg)) - }} + onConfirm={() => onAddWhitelist(row.siteKey.host)} /> )} {/* Remove from whitelist */} - {isNormalSite(row) && whitelist.value?.includes(row.siteKey.host) && ( + {isNormalSite(row) && whitelist.value.includes(row.siteKey.host) && ( <PopupConfirmButton buttonIcon={Open} buttonType="primary" buttonText={t(msg => msg.button.enable)} confirmText={t(msg => msg.whitelist.removeConfirmMsg, { url: row.siteKey?.host })} - onConfirm={async () => { - const host = row.siteKey?.host - host && await whitelistService.remove(host) - refreshWhitelist() - ElMessage.success(t(msg => msg.operation.successMsg)) - }} + onConfirm={() => onRemoveWhitelist(row.siteKey.host)} /> )} </>} diff --git a/src/pages/app/components/Report/ReportTable/columns/TimeColumn.tsx b/src/pages/app/components/Report/Table/columns/TimeColumn.tsx similarity index 79% rename from src/pages/app/components/Report/ReportTable/columns/TimeColumn.tsx rename to src/pages/app/components/Report/Table/columns/TimeColumn.tsx index 6b1fb43a0..e79f74c14 100644 --- a/src/pages/app/components/Report/ReportTable/columns/TimeColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/TimeColumn.tsx @@ -5,15 +5,15 @@ * https://opensource.org/licenses/MIT */ -import TooltipWrapper from "@app/components/common/TooltipWrapper" -import { t } from "@app/locale" -import { periodFormatter } from "@app/util/time" +import CompositionTable from '@app/components/Report/components/CompositionTable' +import { useReportFilter } from "@app/components/Report/context" +import type { ReportSort } from "@app/components/Report/types" +import { t } from '@app/locale' +import { periodFormatter } from '@app/util/time' +import TooltipWrapper from '@pages/components/TooltipWrapper' import { getComposition } from "@util/stat" import { Effect, ElTableColumn, type RenderRowData } from "element-plus" import { defineComponent } from "vue" -import CompositionTable from '../../CompositionTable' -import { useReportFilter } from "../../context" -import type { ReportSort } from "../../types" type Props = { dimension: timer.core.Dimension & 'focus' | 'run' @@ -42,7 +42,7 @@ const TimeColumn = defineComponent<Props>(props => { }} /> )} - </ElTableColumn > + </ElTableColumn> ) }, { props: ['dimension'] }) diff --git a/src/pages/app/components/Report/ReportTable/columns/VisitColumn.tsx b/src/pages/app/components/Report/Table/columns/VisitColumn.tsx similarity index 79% rename from src/pages/app/components/Report/ReportTable/columns/VisitColumn.tsx rename to src/pages/app/components/Report/Table/columns/VisitColumn.tsx index a9aabba06..6a13467bb 100644 --- a/src/pages/app/components/Report/ReportTable/columns/VisitColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/VisitColumn.tsx @@ -5,14 +5,14 @@ * https://opensource.org/licenses/MIT */ -import TooltipWrapper from "@app/components/common/TooltipWrapper" -import { t } from "@app/locale" +import CompositionTable from "@app/components/Report/components/CompositionTable" +import { useReportFilter } from "@app/components/Report/context" +import type { ReportSort } from "@app/components/Report/types" +import { t } from '@app/locale' +import TooltipWrapper from '@pages/components/TooltipWrapper' import { getComposition } from "@util/stat" import { Effect, ElTableColumn, type RenderRowData } from "element-plus" import { defineComponent } from "vue" -import CompositionTable from "../../CompositionTable" -import { useReportFilter } from "../../context" -import type { ReportSort } from "../../types" const VisitColumn = defineComponent(() => { const filter = useReportFilter() diff --git a/src/pages/app/components/Report/ReportTable/index.tsx b/src/pages/app/components/Report/Table/index.tsx similarity index 84% rename from src/pages/app/components/Report/ReportTable/index.tsx rename to src/pages/app/components/Report/Table/index.tsx index cafc9956e..b57f17b0d 100644 --- a/src/pages/app/components/Report/ReportTable/index.tsx +++ b/src/pages/app/components/Report/Table/index.tsx @@ -4,20 +4,21 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import ContentCard from "@app/components/common/ContentCard" -import Editable from "@app/components/common/Editable" -import Pagination from "@app/components/common/Pagination" -import { t } from "@app/locale" -import { periodFormatter } from "@app/util/time" +import { changeSiteAlias } from '@api/sw/site' +import { listCateStats, listGroupStats, listSiteStats } from "@api/sw/stat" +import ContentCard from '@app/components/common/ContentCard' +import Editable from '@app/components/common/Editable' +import Pagination from '@app/components/common/Pagination' +import { t } from '@app/locale' +import { periodFormatter } from '@app/util/time' import { Histogram } from "@element-plus/icons-vue" -import { useDocumentVisibility, useManualRequest, useRequest, useState } from "@hooks" +import { useDocumentVisibility, useManualRequest, useRequest, useState } from '@hooks' import Flex from "@pages/components/Flex" -import { removeAlias, saveAlias } from '@service/site-service' -import { selectCate, selectGroup, selectSite, type SiteQuery } from "@service/stat-service" import { sum } from "@util/array" import { isRtl } from "@util/document" import { siteEqual } from "@util/site" import { getAlias, isSite } from "@util/stat" +import { cvtDateRange2Str } from '@util/time' import { ElLink, ElTable, ElTableColumn, ElText, ElTooltip, type TableInstance } from "element-plus" import { computed, defineComponent, ref, watch } from "vue" import { queryPage } from "../common" @@ -32,15 +33,10 @@ import TimeColumn from "./columns/TimeColumn" import VisitColumn from "./columns/VisitColumn" async function handleAliasChange(key: timer.site.SiteKey, newAlias: string | undefined, data: timer.stat.Row[]) { - newAlias = newAlias?.trim?.() - if (!newAlias) { - await removeAlias(key) - } else { - await saveAlias(key, newAlias) - } - data?.filter(isSite) - ?.filter(item => siteEqual(item.siteKey, key)) - ?.forEach(item => item.alias = newAlias) + newAlias = await changeSiteAlias(key, newAlias) + data.filter(isSite) + .filter(item => siteEqual(item.siteKey, key)) + .forEach(item => item.alias = newAlias) } type ColumnVisible = Record<'index' | 'date' | 'site' | 'cate' | 'group', boolean> @@ -72,25 +68,26 @@ const _default = defineComponent((_, ctx) => { refresh: refreshTotal, loading: totalLoading, } = useManualRequest(async () => { - const { siteMerge, dateRange: date, query, readRemote: inclusiveRemote, cateIds } = filter + const { siteMerge, dateRange, query, readRemote: inclusiveRemote, cateIds } = filter + const date = cvtDateRange2Str(dateRange) let rows: timer.stat.Row[] = [] if (siteMerge === 'group') { - rows = await selectGroup({ date, query }) + rows = await listGroupStats({ date, query }) } else if (siteMerge === 'cate') { - rows = await selectCate({ date, query, cateIds, inclusiveRemote }) + rows = await listCateStats({ date, query, cateIds, inclusiveRemote }) } else { - const param: SiteQuery = { + const param: timer.stat.SiteQuery = { date, query, cateIds, inclusiveRemote, mergeHost: siteMerge === 'domain', } - rows = await selectSite(param) + rows = await listSiteStats(param) } const visit = sum(rows.map(e => e.time)) const focus = sum(rows.map(e => e.focus)) return { visit, focus } }, { defaultValue: { visit: 0, focus: 0 } }) - const runColVisible = computed(() => !!data.value?.list?.find(r => r.run)) + const runColVisible = computed(() => data.value.list.some(r => r.run)) // Query data if document become visible const docVisible = useDocumentVisibility() watch(docVisible, () => docVisible.value && refresh()) @@ -114,7 +111,7 @@ const _default = defineComponent((_, ctx) => { <Flex flex={1} height={0}> <ElTable ref={table} - data={data.value?.list} + data={data.value.list} border fit highlightCurrentRow height="100%" defaultSort={sort.value} @@ -132,7 +129,7 @@ const _default = defineComponent((_, ctx) => { v-slots={({ row }: { row: timer.stat.Row }) => ( <Editable modelValue={getAlias(row)} - onChange={newAlias => 'siteKey' in row && handleAliasChange(row.siteKey, newAlias, data.value?.list ?? [])} + onChange={newAlias => 'siteKey' in row && handleAliasChange(row.siteKey, newAlias, data.value.list)} /> )} /> @@ -165,7 +162,7 @@ const _default = defineComponent((_, ctx) => { <Pagination disabled={loading.value} defaultValue={page.value} - total={data.value?.total || 0} + total={data.value.total} onChange={setPage} /> </Flex> diff --git a/src/pages/app/components/Report/common.ts b/src/pages/app/components/Report/common.ts index fe19bb68f..73e6910eb 100644 --- a/src/pages/app/components/Report/common.ts +++ b/src/pages/app/components/Report/common.ts @@ -1,11 +1,10 @@ -import { t } from "@app/locale" -import statDatabase from "@db/stat-database" import { - selectCate, selectCatePage, selectGroup, selectGroupPage, selectSite, selectSitePage, - type CateQuery, type GroupQuery, type SiteQuery, -} from "@service/stat-service" + deleteSiteStatByGroup, deleteSiteStatByHost, getCateStatPage, getGroupStatPage, getSiteStatPage, listCateStats, + listGroupStats, listSiteStats, +} from "@api/sw/stat" +import { t } from '@app/locale' import { getGroupName, isGroup, isSite } from "@util/stat" -import { formatTime, getBirthday } from "@util/time" +import { cvtDateRange2Str, type DateRange, formatTime, getBirthday } from "@util/time" import type { ReportFilterOption, ReportSort } from "./types" /** @@ -18,8 +17,8 @@ function computeSingleConfirmText(url: string, date: string): string { return t(msg => msg.item.operation.deleteConfirmMsg, { url, date }) } -function computeRangeConfirmText(url: string, dateRange: [Date?, Date?]): string { - let [startDate, endDate] = dateRange +function computeRangeConfirmText(url: string, dateRange: DateRange): string { + let [startDate, endDate] = dateRange instanceof Date ? [dateRange,] : dateRange ?? [] if (!startDate && !endDate) { // Delete all return t(msg => msg.item.operation.deleteConfirmMsgAll, { url }) @@ -55,22 +54,22 @@ export async function handleDelete(row: timer.stat.Row, filterOption: ReportFilt const { mergeDate, dateRange } = filterOption if (!mergeDate) { // Delete one day - isSite(row) && date && await statDatabase.delete({ host: row.siteKey.host, date }) - isGroup(row) && date && await statDatabase.deleteGroup([row.groupKey, date]) + isSite(row) && date && await deleteSiteStatByHost(row.siteKey.host, date) + isGroup(row) && date && await deleteSiteStatByGroup(row.groupKey, date) return } - const start = dateRange?.[0] - const end = dateRange?.[1] + const strRange = cvtDateRange2Str(dateRange) + const [start, end] = strRange ?? [] if (!start && !end) { // Delete all - isSite(row) && await statDatabase.deleteByHost(row.siteKey.host) - isGroup(row) && await statDatabase.deleteByGroup(row.groupKey) + isSite(row) && await deleteSiteStatByHost(row.siteKey.host) + isGroup(row) && await deleteSiteStatByGroup(row.groupKey) return } // Delete by range - isSite(row) && await statDatabase.deleteByHost(row.siteKey.host, [start, end]) - isGroup(row) && await statDatabase.deleteByGroup(row.groupKey, [start, end]) + isSite(row) && await deleteSiteStatByHost(row.siteKey.host, strRange) + isGroup(row) && await deleteSiteStatByGroup(row.groupKey, strRange) } const cvtOrderDir = (order: ReportSort['order']): timer.common.SortDirection | undefined => { @@ -82,8 +81,8 @@ const cvtOrderDir = (order: ReportSort['order']): timer.common.SortDirection | u const cvt2GroupQuery = ( { query, mergeDate, dateRange: date }: ReportFilterOption, { prop, order }: ReportSort, -): GroupQuery => ({ - date, mergeDate, query, +): timer.stat.GroupQuery => ({ + date: cvtDateRange2Str(date), mergeDate, query, sortKey: prop !== 'host' && prop !== 'run' ? prop : undefined, sortDirection: cvtOrderDir(order), }) @@ -91,8 +90,8 @@ const cvt2GroupQuery = ( const cvt2SiteQuery = ( { dateRange: date, mergeDate, siteMerge, query, cateIds, readRemote: inclusiveRemote }: ReportFilterOption, { prop, order }: ReportSort, -): SiteQuery => ({ - date, mergeDate, +): timer.stat.SiteQuery => ({ + date: cvtDateRange2Str(date), mergeDate, mergeHost: siteMerge === 'domain', query, cateIds, inclusiveRemote, virtual: true, @@ -103,30 +102,30 @@ const cvt2SiteQuery = ( const cvt2CateQuery = ( { dateRange: date, mergeDate, query, cateIds, readRemote: inclusiveRemote }: ReportFilterOption, { prop, order }: ReportSort, -): CateQuery => ({ - date, mergeDate, query, cateIds, inclusiveRemote, +): timer.stat.CateQuery => ({ + date: cvtDateRange2Str(date), mergeDate, query, cateIds, inclusiveRemote, sortKey: prop !== 'host' && prop !== 'run' ? prop : undefined, sortDirection: cvtOrderDir(order), }) -export const queryPage = (filter: ReportFilterOption, sort: ReportSort, page: timer.common.PageQuery): Promise<timer.common.PageResult<timer.stat.Row>> => { +export const queryPage = async (filter: ReportFilterOption, sort: ReportSort, page: timer.common.PageQuery): Promise<timer.common.PageResult<timer.stat.Row>> => { const { siteMerge } = filter if (siteMerge === 'group') { - return selectGroupPage(cvt2GroupQuery(filter, sort), page) + return await getGroupStatPage({ ...cvt2GroupQuery(filter, sort), ...page }) } else if (siteMerge === 'cate') { - return selectCatePage(cvt2CateQuery(filter, sort), page) + return await getCateStatPage({ ...cvt2CateQuery(filter, sort), ...page }) } else { - return selectSitePage(cvt2SiteQuery(filter, sort), page) + return await getSiteStatPage({ ...cvt2SiteQuery(filter, sort), ...page }) } } -export const queryAll = (filter: ReportFilterOption, sort: ReportSort): Promise<timer.stat.Row[]> => { +export const queryAll = async (filter: ReportFilterOption, sort: ReportSort): Promise<timer.stat.Row[]> => { const { siteMerge } = filter if (siteMerge === 'group') { - return selectGroup(cvt2GroupQuery(filter, sort)) + return await listGroupStats(cvt2GroupQuery(filter, sort)) } else if (siteMerge === 'cate') { - return selectCate(cvt2CateQuery(filter, sort)) + return await listCateStats(cvt2CateQuery(filter, sort)) } else { - return selectSite(cvt2SiteQuery(filter, sort)) + return await listSiteStats(cvt2SiteQuery(filter, sort)) } } \ No newline at end of file diff --git a/src/pages/app/components/Report/CompositionTable.tsx b/src/pages/app/components/Report/components/CompositionTable.tsx similarity index 98% rename from src/pages/app/components/Report/CompositionTable.tsx rename to src/pages/app/components/Report/components/CompositionTable.tsx index 0ff130be3..3411b7506 100644 --- a/src/pages/app/components/Report/CompositionTable.tsx +++ b/src/pages/app/components/Report/components/CompositionTable.tsx @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { t } from '@app/locale' import { sum } from "@util/array" import { ElTable, ElTableColumn } from "element-plus" import { computed, defineComponent, type PropType } from "vue" diff --git a/src/pages/app/components/Report/ReportTable/columns/TooltipSiteList.tsx b/src/pages/app/components/Report/components/TooltipSiteList.tsx similarity index 87% rename from src/pages/app/components/Report/ReportTable/columns/TooltipSiteList.tsx rename to src/pages/app/components/Report/components/TooltipSiteList.tsx index 18f78e0fa..bd29892e8 100644 --- a/src/pages/app/components/Report/ReportTable/columns/TooltipSiteList.tsx +++ b/src/pages/app/components/Report/components/TooltipSiteList.tsx @@ -5,15 +5,15 @@ import { ElScrollbar } from "element-plus" import { computed, defineComponent, StyleValue, toRefs } from "vue" type Props = { - modelValue?: timer.stat.SiteRow[] | false + modelValue: timer.stat.SiteRow[] | false | undefined clickDisabled?: boolean } const TooltipSiteList = defineComponent<Props>(props => { const { modelValue, clickDisabled: clickable } = toRefs(props) const iconMap = computed(() => { - const siteMap = new SiteMap<string>() - const rows = modelValue?.value + const siteMap = new SiteMap<string | undefined>() + const rows = modelValue.value if (!rows) return siteMap rows.forEach(({ siteKey, iconUrl }) => siteMap.put(siteKey, iconUrl)) return siteMap diff --git a/src/pages/app/components/Report/context.ts b/src/pages/app/components/Report/context.ts index 92fe56a78..aee7c5975 100644 --- a/src/pages/app/components/Report/context.ts +++ b/src/pages/app/components/Report/context.ts @@ -1,12 +1,13 @@ -import { useLocalStorage, useProvide, useProvider } from "@hooks" -import { reactive, type Reactive, ref, type Ref, toRaw, watch } from "vue" +import { type ReportQuery } from '@app/router/constants' +import { useLocalStorage, useProvide, useProvider } from '@hooks' +import { reactive, ref, type ShallowRef, toRaw, watch } from "vue" import { type RouteLocation, type Router, useRoute, useRouter } from "vue-router" -import type { DisplayComponent, ReportFilterOption, ReportQueryParam, ReportSort } from "./types" +import type { DisplayComponent, ReportFilterOption, ReportSort } from "./types" type Context = { - filter: Reactive<ReportFilterOption> - sort: Ref<ReportSort> - comp: Ref<DisplayComponent | undefined> + filter: ReportFilterOption + sort: ShallowRef<ReportSort> + comp: ShallowRef<DisplayComponent | undefined> } const NAMESPACE = 'report' @@ -17,7 +18,7 @@ type QueryPartial = PartialPick<ReportFilterOption, 'query' | 'dateRange' | 'mer * Init the query parameters */ function parseQuery(route: RouteLocation, router: Router): [QueryPartial, ReportSort['prop'] | undefined] { - const routeQuery = route.query as unknown as ReportQueryParam + const routeQuery = route.query as ReportQuery const { q, mm, md, ds, de, sc } = routeQuery const dateStart = ds ? new Date(Number.parseInt(ds)) : undefined const dateEnd = de ? new Date(Number.parseInt(de)) : undefined @@ -55,11 +56,12 @@ const cvtStorage2Filter = (storage: FilterStorageValue | undefined): ReportFilte const cvtFilter2Storage = (filter: ReportFilterOption): FilterStorageValue => { const { query, dateRange, mergeDate, siteMerge, cateIds, timeFormat } = filter + const [dateStart, dateEnd] = dateRange instanceof Date ? [dateRange,] : dateRange ?? [] return { query, mergeDate, siteMerge, - dateStart: dateRange?.[0]?.getTime?.(), - dateEnd: dateRange?.[1]?.getTime?.(), + dateStart: dateStart?.getTime?.(), + dateEnd: dateEnd?.getTime?.(), cateIds, timeFormat, } } @@ -88,8 +90,8 @@ export const initReportContext = () => { return context } -export const useReportFilter = (): Reactive<ReportFilterOption> => useProvider<Context, 'filter'>(NAMESPACE, "filter").filter +export const useReportFilter = (): ReportFilterOption => useProvider<Context, 'filter'>(NAMESPACE, "filter").filter -export const useReportSort = (): Ref<ReportSort> => useProvider<Context, 'sort'>(NAMESPACE, 'sort').sort +export const useReportSort = (): ShallowRef<ReportSort> => useProvider<Context, 'sort'>(NAMESPACE, 'sort').sort export const useReportComponent = () => useProvider<Context, 'comp'>(NAMESPACE, 'comp').comp \ No newline at end of file diff --git a/src/pages/app/components/Report/file-export.ts b/src/pages/app/components/Report/file-export.ts index 70d215f10..f4885d798 100644 --- a/src/pages/app/components/Report/file-export.ts +++ b/src/pages/app/components/Report/file-export.ts @@ -5,8 +5,8 @@ * https://opensource.org/licenses/MIT */ -import { type I18nKey, t } from "@app/locale" -import { periodFormatter } from "@app/util/time" +import { type I18nKey, t } from '@app/locale' +import { periodFormatter } from '@app/util/time' import { exportCsv as exportCsv_, exportJson as exportJson_, @@ -30,7 +30,8 @@ type ExportInfo = { * Compute the name of downloaded file */ function computeFileName(filterParam: ReportFilterOption): string { - const { dateRange: [ds, de], siteMerge, mergeDate, timeFormat } = filterParam + const { dateRange, siteMerge, mergeDate, timeFormat } = filterParam + const [ds, de] = dateRange instanceof Date ? [dateRange,] : dateRange ?? [] const parts = [ t(msg => msg.report.exportFileName), ds && formatTimeYMD(ds), diff --git a/src/pages/app/components/Report/index.tsx b/src/pages/app/components/Report/index.tsx index ead028cb6..0e44aec53 100644 --- a/src/pages/app/components/Report/index.tsx +++ b/src/pages/app/components/Report/index.tsx @@ -5,21 +5,21 @@ * https://opensource.org/licenses/MIT */ -import { useXsState } from "@hooks" +import { useXsState } from '@hooks' import { defineComponent } from "vue" -import ContentContainer from "../common/ContentContainer" +import ContentContainer from '../common/ContentContainer' import { initReportContext } from "./context" -import ReportFilter from "./ReportFilter" -import ReportList from "./ReportList" -import ReportTable from "./ReportTable" +import Filter from "./Filter" +import List from "./List" +import Table from "./Table" const _default = defineComponent(() => { const { comp } = initReportContext() const isXs = useXsState() return () => <ContentContainer v-slots={{ - filter: () => <ReportFilter />, - default: () => isXs.value ? <ReportList ref={comp} /> : <ReportTable ref={comp} /> + filter: () => <Filter />, + default: () => isXs.value ? <List ref={comp} /> : <Table ref={comp} /> }} /> }) diff --git a/src/pages/app/components/Report/types.d.ts b/src/pages/app/components/Report/types.d.ts index 157be51e4..f2589999b 100644 --- a/src/pages/app/components/Report/types.d.ts +++ b/src/pages/app/components/Report/types.d.ts @@ -1,42 +1,13 @@ +import { DateRange } from '@util/time' import type { Sort } from "element-plus" export type ReportSort = Omit<Sort, 'prop'> & { prop: timer.core.Dimension | 'host' | 'date' } -/** -* The query param of report page -*/ -export type ReportQueryParam = { - /** - * Query - */ - q?: string - /** - * Merge method - */ - mm?: Exclude<timer.stat.MergeMethod, 'date'> - /** - * Merge date - */ - md?: string - /** - * Date start - */ - ds?: string - /** - * Date end - */ - de?: string - /** - * Sorted column - */ - sc?: timer.core.Dimension -} - export type ReportFilterOption = { query: string | undefined - dateRange: [Date?, Date?] + dateRange: DateRange mergeDate: boolean siteMerge?: timer.stat.MergeMethod & ('cate' | 'domain' | 'group') cateIds?: number[] diff --git a/src/pages/app/components/RuleMerge/ItemList.tsx b/src/pages/app/components/RuleMerge/ItemList.tsx index 5c809e895..0a35f19cf 100644 --- a/src/pages/app/components/RuleMerge/ItemList.tsx +++ b/src/pages/app/components/RuleMerge/ItemList.tsx @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" -import mergeRuleDatabase from "@db/merge-rule-database" -import { useManualRequest, useRequest } from "@hooks" +import { addMergeRule, deleteMergeRule, listAllMergeRules } from "@api/sw/merge" +import { t } from '@app/locale' +import { useManualRequest, useRequest } from '@hooks' import Flex from "@pages/components/Flex" import { ElMessage, ElMessageBox } from "element-plus" import { defineComponent, ref } from "vue" @@ -15,18 +15,15 @@ import AddButton from './components/AddButton' import Item, { type ItemInstance } from './components/Item' const _default = defineComponent(() => { - const { data: items, refresh } = useRequest(() => mergeRuleDatabase.selectAll()) + const { data: items, refresh } = useRequest(listAllMergeRules, { defaultValue: [] }) const handleSucc = () => { ElMessage.success(t(msg => msg.operation.successMsg)) refresh() } - const { refreshAsync: remove } = useManualRequest( - (origin: string) => mergeRuleDatabase.remove(origin), - { onSuccess: handleSucc }, - ) + const { refreshAsync: remove } = useManualRequest(deleteMergeRule, { onSuccess: handleSucc }) const { refresh: update } = useManualRequest(async (origin: string, merged: string | number) => { - await mergeRuleDatabase.remove(origin) - await mergeRuleDatabase.add({ origin, merged }) + await deleteMergeRule(origin) + await addMergeRule({ origin, merged }) }, { onSuccess: handleSucc }) const itemRefs = ref<ItemInstance[]>([]) @@ -38,7 +35,7 @@ const _default = defineComponent(() => { } async function handleChange(origin: string, merged: string | number, index: number): Promise<void> { - const hasDuplicate = items.value?.find((o, i) => o.origin === origin && i !== index) + const hasDuplicate = items.value.some((o, i) => o.origin === origin && i !== index) if (hasDuplicate) { ElMessage.warning(t(msg => msg.mergeRule.duplicateMsg, { origin })) itemRefs.value?.[index]?.forceEdit?.() @@ -48,12 +45,12 @@ const _default = defineComponent(() => { } const { refresh: add } = useManualRequest( - (rule: timer.merge.Rule) => mergeRuleDatabase.add(rule), + (rule: timer.merge.Rule) => addMergeRule(rule), { onSuccess: handleSucc } ) const handleAdd = async (origin: string, merged: string | number): Promise<boolean> => { - const alreadyExist = !!items.value?.find(item => item.origin === origin) + const alreadyExist = items.value.some(item => item.origin === origin) if (alreadyExist) { ElMessage.warning(t(msg => msg.mergeRule.duplicateMsg, { origin })) return false @@ -71,7 +68,7 @@ const _default = defineComponent(() => { return () => ( <Flex gap={10} wrap justify="space-between"> - {items.value?.map((item, idx) => + {items.value.map((item, idx) => <Item ref={() => itemRefs.value[idx]} origin={item.origin} diff --git a/src/pages/app/components/RuleMerge/components/AddButton.tsx b/src/pages/app/components/RuleMerge/components/AddButton.tsx index c45d46f96..26bd67cff 100644 --- a/src/pages/app/components/RuleMerge/components/AddButton.tsx +++ b/src/pages/app/components/RuleMerge/components/AddButton.tsx @@ -5,8 +5,8 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" -import { useState, useSwitch } from "@hooks" +import { useState, useSwitch } from '@hooks' +import { t } from '@app/locale' import { ElButton } from "element-plus" import { defineComponent, StyleValue } from "vue" import ItemInput from './ItemInput' diff --git a/src/pages/app/components/RuleMerge/components/Item.tsx b/src/pages/app/components/RuleMerge/components/Item.tsx index f192a8cf0..adad1e988 100644 --- a/src/pages/app/components/RuleMerge/components/Item.tsx +++ b/src/pages/app/components/RuleMerge/components/Item.tsx @@ -5,8 +5,8 @@ * https://opensource.org/licenses/MIT */ import EditableTag from "@app/components/common/EditableTag" -import { t } from "@app/locale" -import { useShadow, useSwitch } from "@hooks" +import { t } from '@app/locale' +import { useShadow, useSwitch } from '@hooks' import Flex from "@pages/components/Flex" import { LOCAL_HOST_PATTERN } from "@util/constant/remain-host" import { type TagProps } from "element-plus" diff --git a/src/pages/app/components/RuleMerge/components/ItemInput.tsx b/src/pages/app/components/RuleMerge/components/ItemInput.tsx index 7194c9dc3..423cb75ae 100644 --- a/src/pages/app/components/RuleMerge/components/ItemInput.tsx +++ b/src/pages/app/components/RuleMerge/components/ItemInput.tsx @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { t } from '@app/locale' import { Check, Close } from "@element-plus/icons-vue" -import { useShadow } from "@hooks" +import { useShadow } from '@hooks' import Box from "@pages/components/Box" import { LOCAL_HOST_PATTERN } from "@util/constant/remain-host" import { tryParseInteger } from "@util/number" diff --git a/src/pages/app/components/RuleMerge/index.tsx b/src/pages/app/components/RuleMerge/index.tsx index 0097a322b..7c4e536b6 100644 --- a/src/pages/app/components/RuleMerge/index.tsx +++ b/src/pages/app/components/RuleMerge/index.tsx @@ -5,12 +5,12 @@ * https://opensource.org/licenses/MIT */ -import AlertLines from '@app/components/common/AlertLines' import Flex from "@pages/components/Flex" import { PSL_HOMEPAGE } from '@util/constant/url' import { ElCard } from "element-plus" import type { FunctionalComponent, StyleValue } from "vue" -import ContentContainer from "../common/ContentContainer" +import AlertLines from '../common/AlertLines' +import ContentContainer from '../common/ContentContainer' import ItemList from "./ItemList" const pslStyle: StyleValue = { diff --git a/src/pages/app/components/SiteManage/Modify/HostSelect.tsx b/src/pages/app/components/SiteManage/Modify/HostSelect.tsx new file mode 100644 index 000000000..541b66f25 --- /dev/null +++ b/src/pages/app/components/SiteManage/Modify/HostSelect.tsx @@ -0,0 +1,69 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ +import { searchSite } from '@api/sw/site' +import { t } from '@app/locale' +import { useManualRequest } from '@hooks' +import { identifySiteKey, parseSiteIdentity } from '@util/site' +import { ElOption, ElSelect, ElTag } from "element-plus" +import { computed, defineComponent } from "vue" + +type Props = ModelValue<timer.site.SiteKey | undefined> + +const EXIST_MSG = t(msg => msg.siteManage.msg.existedTag) +const MERGED_MSG = t(msg => msg.siteManage.type.merged?.name)?.toLocaleUpperCase?.() +const VIRTUAL_MSG = t(msg => msg.siteManage.type.virtual?.name)?.toLocaleUpperCase?.() + +/** + * Calculate the label of alias key to display + * + * @returns + * 1. www.google.com + * 2. www.google.com[MERGED] + * 4. www.google.com[EXISTED] + * 5. www.github.com/sheepzh/*[VIRTUAL] + * 5. www.github.com/sheepzh/*[VIRTUAL-EXISTED] + * 3. www.google.com[MERGED-EXISTED] + */ +function labelOf(site: timer.site.SiteInfo): string { + let { host: label, type, alias } = site + const suffix: string[] = [] + type === 'merged' && suffix.push(MERGED_MSG) + type === 'virtual' && suffix.push(VIRTUAL_MSG) + alias && suffix.push(EXIST_MSG) + suffix.length && (label += `[${suffix.join('-')}]`) + return label +} + +const _default = defineComponent<Props>(props => { + const value = computed(() => identifySiteKey(props.modelValue)) + const { data: options, loading: searching, refresh: searchOption } = useManualRequest( + searchSite, { defaultValue: [] } + ) + + return () => ( + <ElSelect + style={{ width: '100%' }} + modelValue={value.value} + filterable + remote + loading={searching.value} + remoteMethod={searchOption} + onChange={val => props.onChange?.(parseSiteIdentity(val) ?? { host: '', type: 'normal' })} + > + {options.value.map(row => ( + <ElOption value={identifySiteKey(row)} disabled={!!row.alias} label={labelOf(row)}> + <span>{row.host}</span> + <ElTag v-show={row.type === 'merged'} size="small">{MERGED_MSG}</ElTag> + <ElTag v-show={row.type === 'virtual'} size="small">{VIRTUAL_MSG}</ElTag> + <ElTag v-show={!!row.alias} size="small" type="info">{EXIST_MSG}</ElTag> + </ElOption> + ))} + </ElSelect> + ) +}, { props: ['modelValue', 'onChange'] }) + +export default _default diff --git a/src/pages/app/components/SiteManage/SiteManageModify/index.tsx b/src/pages/app/components/SiteManage/Modify/index.tsx similarity index 77% rename from src/pages/app/components/SiteManage/SiteManageModify/index.tsx rename to src/pages/app/components/SiteManage/Modify/index.tsx index 0e763d249..4859b867f 100644 --- a/src/pages/app/components/SiteManage/SiteManageModify/index.tsx +++ b/src/pages/app/components/SiteManage/Modify/index.tsx @@ -4,15 +4,14 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" -import siteDatabase from '@db/site-database' +import { sendMsg2Runtime } from '@api/sw/common' +import CategorySelect from '@app/components/common/Category/Select' +import { t } from '@app/locale' import { Check } from "@element-plus/icons-vue" -import { useSwitch } from "@hooks" -import { addSite } from "@service/site-service" +import { useSwitch } from '@hooks' import { supportCategory } from "@util/site" import { ElButton, ElDialog, ElForm, ElFormItem, ElInput, ElMessage, type FormInstance, type FormItemRule } from "element-plus" import { computed, defineComponent, reactive, ref } from "vue" -import CategorySelect from "../../common/category/CategorySelect" import HostSelect from "./HostSelect" export type ModifyInstance = { @@ -50,21 +49,6 @@ function validateForm(form: FormInstance | undefined): Promise<boolean> { }) } -async function handleAdd(siteInfo: timer.site.SiteInfo): Promise<boolean> { - const existed = await siteDatabase.exist(siteInfo) - if (existed) { - ElMessage({ - type: 'warning', - message: t(msg => msg.siteManage.msg.hostExistWarn, { host: siteInfo.host }), - showClose: true, - duration: 1600 - }) - } else { - await addSite(siteInfo) - } - return !existed -} - const initData = (): _FormData => ({ key: undefined, alias: undefined, @@ -83,8 +67,8 @@ const _default = defineComponent<{ onSave: NoArgCallback }>((props, ctx) => { } ctx.expose({ add } satisfies ModifyInstance) - const handleSave = async () => { - const valid: boolean = await validateForm(form.value) + const handleAdd = async () => { + const valid = await validateForm(form.value) if (!valid) return false let { key: siteKey, alias } = formData @@ -92,12 +76,11 @@ const _default = defineComponent<{ onSave: NoArgCallback }>((props, ctx) => { alias = alias?.trim() const siteInfo: timer.site.SiteInfo = { ...siteKey, alias, cate: formData.category } - const saved = await handleAdd(siteInfo) - if (saved) { - close() - ElMessage.success(t(msg => msg.operation.successMsg)) - props.onSave?.() - } + const errMsg = await sendMsg2Runtime('site.add', siteInfo) + if (errMsg) return ElMessage.warning(errMsg) + close() + ElMessage.success(t(msg => msg.operation.successMsg)) + props.onSave?.() } return () => ( @@ -109,13 +92,13 @@ const _default = defineComponent<{ onSave: NoArgCallback }>((props, ctx) => { onClose={close} v-slots={{ footer: () => ( - <ElButton type="primary" icon={Check} onClick={handleSave}> + <ElButton type="primary" icon={Check} onClick={handleAdd}> {t(msg => msg.button.save)} </ElButton> ) }} > - <ElForm model={formData} rules={formRule} ref={form}> + <ElForm model={formData} rules={formRule} ref={form} labelWidth={130}> <ElFormItem prop="key" label={t(msg => msg.item.host)}> <HostSelect modelValue={formData.key} onChange={val => formData.key = val} /> </ElFormItem> @@ -123,7 +106,7 @@ const _default = defineComponent<{ onSave: NoArgCallback }>((props, ctx) => { <ElInput modelValue={formData.alias} onInput={val => formData.alias = val} - onKeydown={ev => (ev as KeyboardEvent).key === "Enter" && handleSave()} + onKeydown={ev => ev instanceof KeyboardEvent && ev.key === "Enter" && handleAdd()} /> </ElFormItem> {showCate.value && ( diff --git a/src/pages/app/components/SiteManage/SiteManageFilter.tsx b/src/pages/app/components/SiteManage/SiteManageFilter.tsx index 2239de5b5..404e6cc08 100644 --- a/src/pages/app/components/SiteManage/SiteManageFilter.tsx +++ b/src/pages/app/components/SiteManage/SiteManageFilter.tsx @@ -4,16 +4,16 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import ButtonFilterItem from "@app/components/common/filter/ButtonFilterItem" -import InputFilterItem from "@app/components/common/filter/InputFilterItem" import { useCategory } from "@app/context" -import { t } from "@app/locale" +import { t } from '@app/locale' import { Connection, Delete, Grid, Plus } from "@element-plus/icons-vue" import Flex from "@pages/components/Flex" import { computed, defineComponent, watch } from "vue" -import DropdownButton, { type DropdownButtonItem } from "../common/DropdownButton" -import CategoryFilter from "../common/filter/CategoryFilter" -import MultiSelectFilterItem from "../common/filter/MultiSelectFilterItem" +import DropdownButton, { type DropdownButtonItem } from '../common/DropdownButton' +import ButtonFilterItem from "../common/filter/ButtonFilterItem" +import CategoryFilter from '../common/filter/CategoryFilter' +import InputFilterItem from "../common/filter/InputFilterItem" +import MultiSelectFilterItem from '../common/filter/MultiSelectFilterItem' import { ALL_TYPES } from "./common" import { useSiteManageFilter } from './useSiteManage' diff --git a/src/pages/app/components/SiteManage/SiteManageModify/HostSelect.tsx b/src/pages/app/components/SiteManage/SiteManageModify/HostSelect.tsx deleted file mode 100644 index eff4d8c71..000000000 --- a/src/pages/app/components/SiteManage/SiteManageModify/HostSelect.tsx +++ /dev/null @@ -1,105 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ -import siteDatabase from '@db/site-database' -import { useManualRequest } from "@hooks" -import { listHosts } from "@service/stat-service" -import { MERGED_HOST, ALL_HOSTS as REMAIN_HOSTS } from "@util/constant/remain-host" -import { isValidVirtualHost, judgeVirtualFast } from "@util/pattern" -import { ElOption, ElSelect, ElTag } from "element-plus" -import { computed, defineComponent } from "vue" -import { cvt2OptionValue, cvt2SiteKey, EXIST_MSG, labelOf, MERGED_MSG, VIRTUAL_MSG } from "../common" - -type _OptionInfo = { - siteKey: timer.site.SiteKey - hasAlias: boolean -} - -function cleanQuery(query: string) { - try { - const url = new URL(query) - const { host, pathname } = url - query = host + pathname - } catch { - } - if (query.endsWith('/')) query += '**' - return query -} - -async function handleRemoteSearch(query: string): Promise<_OptionInfo[]> { - query = cleanQuery(query) - if (!query) return [] - const { normal, merged } = await listHosts(query) - const allAlias: timer.site.SiteKey[] = [ - ...normal.map(host => ({ host, type: 'normal' } satisfies timer.site.SiteKey)), - ...merged.map(host => ({ host, type: 'merged' } satisfies timer.site.SiteKey)), - ] - // Add local files - REMAIN_HOSTS.filter(h => h.includes(query)).forEach(remain => allAlias.push({ host: remain, type: 'normal' })) - MERGED_HOST.includes(query) && allAlias.push({ host: MERGED_HOST, type: 'merged' }) - const existedAliasSet = new Set() - const existedKeys = await siteDatabase.existBatch(allAlias) - existedKeys.forEach(key => existedAliasSet.add(cvt2OptionValue(key))) - const existedOptions: _OptionInfo[] = [] - const notExistedOptions: _OptionInfo[] = [] - allAlias.forEach(siteKey => { - const hasAlias = existedAliasSet.has(cvt2OptionValue(siteKey)) - const props: _OptionInfo = { siteKey, hasAlias } - hasAlias ? existedOptions.push(props) : notExistedOptions.push(props) - }) - - const originalOptions = [...notExistedOptions, ...existedOptions] - - const result: _OptionInfo[] = [] - const existIdx = originalOptions.findIndex(o => o.siteKey?.host === query) - if (existIdx === -1) { - // Not exist host, insert site into the first - const isVirtual = judgeVirtualFast(query) - if (isVirtual) { - isValidVirtualHost(query) && result.push({ siteKey: { host: query, type: 'virtual' }, hasAlias: false }) - } else { - result.push({ siteKey: { host: query, type: 'normal' }, hasAlias: false }) - } - result.push(...originalOptions) - } else { - result.push(originalOptions[existIdx]) - originalOptions.forEach((opt, idx) => idx !== existIdx && result.push(opt)) - } - return result -} - -type Props = { - modelValue: timer.site.SiteKey | undefined - onChange?: (_siteKey: timer.site.SiteKey | undefined) => void -} - -const _default = defineComponent<Props>(props => { - const value = computed(() => cvt2OptionValue(props.modelValue)) - const { data: options, loading: searching, refresh: searchOption } = useManualRequest(handleRemoteSearch) - - return () => ( - <ElSelect - style={{ width: '100%' }} - modelValue={value.value} - filterable - remote - loading={searching.value} - remoteMethod={searchOption} - onChange={val => props.onChange?.(cvt2SiteKey(val))} - > - {options.value?.map(({ siteKey, hasAlias }) => ( - <ElOption value={cvt2OptionValue(siteKey)} disabled={hasAlias} label={labelOf(siteKey, hasAlias)}> - <span>{siteKey.host}</span> - <ElTag v-show={siteKey.type === 'merged'} size="small">{MERGED_MSG}</ElTag> - <ElTag v-show={siteKey.type === 'virtual'} size="small">{VIRTUAL_MSG}</ElTag> - <ElTag v-show={hasAlias} size="small" type="info">{EXIST_MSG}</ElTag> - </ElOption> - ))} - </ElSelect> - ) -}, { props: ['modelValue', 'onChange'] }) - -export default _default diff --git a/src/pages/app/components/SiteManage/SiteManageTable/column/AliasColumn.tsx b/src/pages/app/components/SiteManage/Table/column/AliasColumn.tsx similarity index 50% rename from src/pages/app/components/SiteManage/SiteManageTable/column/AliasColumn.tsx rename to src/pages/app/components/SiteManage/Table/column/AliasColumn.tsx index 2369ab7b3..a9cc67116 100644 --- a/src/pages/app/components/SiteManage/SiteManageTable/column/AliasColumn.tsx +++ b/src/pages/app/components/SiteManage/Table/column/AliasColumn.tsx @@ -5,70 +5,35 @@ * https://opensource.org/licenses/MIT */ +import { changeSiteAlias, fillInitialAlias, getInitialAlias } from "@api/sw/site" import Editable from "@app/components/common/Editable" -import { t } from "@app/locale" +import { useSiteManageTable } from '@app/components/SiteManage/useSiteManage' +import { t } from '@app/locale' import { MagicStick } from "@element-plus/icons-vue" +import { useManualRequest } from '@hooks' import Flex from "@pages/components/Flex" -import { batchGetSites, batchSaveAliasNoRewrite, removeAlias, saveAlias } from "@service/site-service" -import { getSuffix as getPslSuffix } from "@util/psl" -import { identifySiteKey, SiteMap } from "@util/site" +import { identifySiteKey } from "@util/site" import { ElIcon, ElMessage, ElPopconfirm, ElTableColumn, ElText } from "element-plus" -import { toUnicode as punyCode2Unicode } from "punycode" import { defineComponent, type StyleValue } from "vue" -import { useSiteManageTable } from '../../useSiteManage' - -function cvt2Alias(part: string): string { - let decoded = part - try { - decoded = punyCode2Unicode(part) - } catch { - } - return decoded.charAt(0).toUpperCase() + decoded.slice(1) -} - -export function genInitialAlias(site: timer.site.SiteInfo): string | undefined { - const { host, alias, type } = site || {} - if (alias) return - if (type !== 'normal') return - let parts = host.split('.') - if (parts.length < 2) return - - const suffix = getPslSuffix(host) - const prefix = host.replace(`.${suffix}`, '').replace(/^www\./, '') - parts = prefix.split('.') - return parts.reverse().map(cvt2Alias).join(' ') -} const AliasColumn = defineComponent<{}>(() => { const { pagination, refresh } = useSiteManageTable() - - const handleChange = async (newAlias: string | undefined, row: timer.site.SiteInfo) => { - newAlias = newAlias?.trim?.() - row.alias = newAlias - if (newAlias) { - await saveAlias(row, newAlias) - } else { - await removeAlias(row) - } - refresh() - } + const { refresh: doChange } = useManualRequest(changeSiteAlias, { onSuccess: refresh }) const handleBatchGenerate = async () => { - let data = pagination.value?.list - if (!data?.length) { - return ElMessage.info("No data") - } - const toSave = new SiteMap<string>() - const items = await batchGetSites(data) - items.filter(i => !i.alias).forEach(site => { - const newAlias = genInitialAlias(site) - newAlias && toSave.put(site, newAlias) - }) - await batchSaveAliasNoRewrite(toSave) + let { list } = pagination.value + if (!list.length) return ElMessage.info("No data") + await fillInitialAlias(list) refresh() ElMessage.success(t(msg => msg.operation.successMsg)) } + const genInitialAlias = async ({ host, type, alias }: timer.site.SiteInfo) => { + if (alias) return alias + if (type === 'normal') return await getInitialAlias(host) + return undefined + } + return () => ( <ElTableColumn minWidth={160} @@ -95,12 +60,14 @@ const AliasColumn = defineComponent<{}>(() => { /> </Flex> ), - default: ({ row }: { row: timer.site.SiteInfo }) => <Editable - key={`${identifySiteKey(row)}_${row.alias}`} - modelValue={row.alias} - initialValue={genInitialAlias(row)} - onChange={val => handleChange(val, row)} - /> + default: ({ row }: { row: timer.site.SiteInfo }) => ( + <Editable + key={`${identifySiteKey(row)}_${row.alias}`} + modelValue={row.alias} + initialValue={() => genInitialAlias(row)} + onChange={val => doChange(row, val)} + /> + ) }} /> ) diff --git a/src/pages/app/components/SiteManage/SiteManageTable/column/OperationColumn.tsx b/src/pages/app/components/SiteManage/Table/column/OperationColumn.tsx similarity index 69% rename from src/pages/app/components/SiteManage/SiteManageTable/column/OperationColumn.tsx rename to src/pages/app/components/SiteManage/Table/column/OperationColumn.tsx index 4821e56d0..13d696fcf 100644 --- a/src/pages/app/components/SiteManage/SiteManageTable/column/OperationColumn.tsx +++ b/src/pages/app/components/SiteManage/Table/column/OperationColumn.tsx @@ -4,18 +4,18 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import PopupConfirmButton from "@app/components/common/PopupConfirmButton" -import { t } from "@app/locale" +import { deleteSites } from '@api/sw/site' +import PopupConfirmButton from '@app/components/common/PopupConfirmButton' +import { useSiteManageTable } from '@app/components/SiteManage/useSiteManage' +import { t } from '@app/locale' import { Delete } from "@element-plus/icons-vue" -import { useRequest } from '@hooks' -import { removeSites } from "@service/site-service" +import { useManualRequest } from '@hooks' import { ElTableColumn, type RenderRowData } from "element-plus" import { defineComponent } from "vue" -import { useSiteManageTable } from '../../useSiteManage' const OperationColumn = defineComponent<{}>(() => { const { refresh } = useSiteManageTable() - const { refresh: handleConfirm } = useRequest(removeSites, { onSuccess: refresh }) + const { refresh: doDelete } = useManualRequest<[timer.site.SiteKey], void>(deleteSites, { onSuccess: refresh }) return () => ( <ElTableColumn width={150} @@ -28,7 +28,7 @@ const OperationColumn = defineComponent<{}>(() => { buttonType="danger" buttonText={t(msg => msg.button.delete)} confirmText={t(msg => msg.siteManage.deleteConfirmMsg, { host: row.host })} - onConfirm={() => handleConfirm(row)} + onConfirm={() => doDelete(row)} /> )} /> diff --git a/src/pages/app/components/SiteManage/SiteManageTable/column/TypeColumn.tsx b/src/pages/app/components/SiteManage/Table/column/TypeColumn.tsx similarity index 90% rename from src/pages/app/components/SiteManage/SiteManageTable/column/TypeColumn.tsx rename to src/pages/app/components/SiteManage/Table/column/TypeColumn.tsx index f853f70fa..f343a7478 100644 --- a/src/pages/app/components/SiteManage/SiteManageTable/column/TypeColumn.tsx +++ b/src/pages/app/components/SiteManage/Table/column/TypeColumn.tsx @@ -1,9 +1,9 @@ -import ColumnHeader from "@app/components/common/ColumnHeader" -import { t } from "@app/locale" +import ColumnHeader from '@app/components/common/ColumnHeader' +import { ALL_TYPES } from "@app/components/SiteManage/common" +import { t } from '@app/locale' import { ElTableColumn, ElTag, type RenderRowData, type TagProps } from "element-plus" import { type FunctionalComponent } from "vue" import type { JSX } from "vue/jsx-runtime" -import { ALL_TYPES } from "../../common" const computeText = ({ type }: timer.site.SiteInfo): string => t(msg => msg.siteManage.type[type].name) diff --git a/src/pages/app/components/SiteManage/SiteManageTable/index.tsx b/src/pages/app/components/SiteManage/Table/index.tsx similarity index 83% rename from src/pages/app/components/SiteManage/SiteManageTable/index.tsx rename to src/pages/app/components/SiteManage/Table/index.tsx index 50cf8245a..b1381be5d 100644 --- a/src/pages/app/components/SiteManage/SiteManageTable/index.tsx +++ b/src/pages/app/components/SiteManage/Table/index.tsx @@ -4,13 +4,14 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import HostAlert from "@app/components/common/HostAlert" -import { t } from "@app/locale" +import { changeSiteRun, deleteSiteIcon } from '@api/sw/site' +import Category from '@app/components/common/Category' +import HostAlert from '@app/components/common/HostAlert' +import { t } from '@app/locale' import Flex from "@pages/components/Flex" -import { removeIconUrl, saveSiteRunState } from '@service/site-service' +import Img from '@pages/components/Img' import { ElSwitch, ElTable, ElTableColumn, type RenderRowData } from "element-plus" import { defineComponent } from "vue" -import CategoryEditable from "../../common/category/CategoryEditable" import { useSiteManageTable } from '../useSiteManage' import AliasColumn from "./column/AliasColumn" import OperationColumn from "./column/OperationColumn" @@ -20,20 +21,19 @@ const _default = defineComponent<{}>(() => { const { setSelected, refresh, pagination } = useSiteManageTable() const handleIconError = async (row: timer.site.SiteInfo) => { - await removeIconUrl(row) + await deleteSiteIcon(row) row.iconUrl = undefined } const handleRunChange = async (val: boolean, row: timer.site.SiteInfo) => { - // Save - await saveSiteRunState(row, val) + await changeSiteRun(row, val) row.run = val refresh() } return () => ( <ElTable - data={pagination.value?.list} + data={pagination.value.list} height="100%" highlightCurrentRow border fit onSelection-change={setSelected} @@ -59,7 +59,7 @@ const _default = defineComponent<{}>(() => { if (!iconUrl) return '' return ( <Flex align="center" justify="center"> - <img width={12} height={12} src={iconUrl} onError={() => handleIconError(row)} /> + <Img size={12} src={iconUrl} onError={() => handleIconError(row)} /> </Flex> ) }} @@ -70,7 +70,7 @@ const _default = defineComponent<{}>(() => { minWidth={140} align="center" v-slots={({ row }: RenderRowData<timer.site.SiteInfo>) => ( - <CategoryEditable siteKey={row} modelValue={row?.cate} onChange={val => row.cate = val} /> + <Category.Editable siteKey={row} modelValue={row?.cate} onChange={val => row.cate = val} /> )} /> <ElTableColumn diff --git a/src/pages/app/components/SiteManage/common.ts b/src/pages/app/components/SiteManage/common.ts index a3cfed2ca..635e1c12d 100644 --- a/src/pages/app/components/SiteManage/common.ts +++ b/src/pages/app/components/SiteManage/common.ts @@ -5,62 +5,4 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" - -const MERGED_FLAG = 'm' -const VIRTUAL_FLAG = 'v' -const NONE_FLAG = '_' - -/** - * site key => option value - */ -export function cvt2OptionValue(siteKey: timer.site.SiteKey | undefined): string { - if (!siteKey) return '' - const { type } = siteKey - let flag = NONE_FLAG - type === 'merged' && (flag = MERGED_FLAG) - type === 'virtual' && (flag = VIRTUAL_FLAG) - return `${flag}${siteKey.host}` -} - -/** - * option value => site key - */ -export function cvt2SiteKey(optionValue: string): timer.site.SiteKey { - const flag = optionValue.substring(0, 1) - const host = optionValue.substring(1) - if (flag === MERGED_FLAG) { - return { host, type: 'merged' } - } else if (flag === VIRTUAL_FLAG) { - return { host, type: 'virtual' } - } else { - return { host, type: 'normal' } - } -} - -export const EXIST_MSG = t(msg => msg.siteManage.msg.existedTag) -export const MERGED_MSG = t(msg => msg.siteManage.type.merged?.name)?.toLocaleUpperCase?.() -export const VIRTUAL_MSG = t(msg => msg.siteManage.type.virtual?.name)?.toLocaleUpperCase?.() - -/** - * Calculate the label of alias key to display - * - * @returns - * 1. www.google.com - * 2. www.google.com[MERGED] - * 4. www.google.com[EXISTED] - * 5. www.github.com/sheepzh/*[VIRTUAL] - * 5. www.github.com/sheepzh/*[VIRTUAL-EXISTED] - * 3. www.google.com[MERGED-EXISTED] - */ -export function labelOf(siteKey: timer.site.SiteKey, exists?: boolean): string { - let { host: label, type } = siteKey || {} - const suffix: string[] = [] - type === 'merged' && suffix.push(MERGED_MSG) - type === 'virtual' && suffix.push(VIRTUAL_MSG) - exists && suffix.push(EXIST_MSG) - suffix.length && (label += `[${suffix.join('-')}]`) - return label -} - export const ALL_TYPES: timer.site.Type[] = ['normal', 'merged', 'virtual'] \ No newline at end of file diff --git a/src/pages/app/components/SiteManage/index.tsx b/src/pages/app/components/SiteManage/index.tsx index aba58bb31..ec1513d15 100644 --- a/src/pages/app/components/SiteManage/index.tsx +++ b/src/pages/app/components/SiteManage/index.tsx @@ -5,20 +5,20 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { changeSitesCate, deleteSites } from "@api/sw/site" +import { t } from '@app/locale' import { Check, Close, WarnTriangleFilled } from "@element-plus/icons-vue" -import { useState, useSwitch } from "@hooks" +import { useState, useSwitch } from '@hooks' import Flex from "@pages/components/Flex" -import { batchSaveSiteCate, removeSites } from "@service/site-service" import { supportCategory } from "@util/site" import { ElButton, ElDialog, ElForm, ElFormItem, ElMessage, ElMessageBox } from "element-plus" import { computed, defineComponent, markRaw, ref, type VNode } from "vue" -import ContentContainer from "../common/ContentContainer" -import Pagination from "../common/Pagination" -import CategorySelect from "../common/category/CategorySelect" -import SiteManageFilter from "./SiteManageFilter" -import Modify, { type ModifyInstance } from './SiteManageModify' -import SiteManageTable from "./SiteManageTable" +import Category from '../common/Category' +import ContentContainer from '../common/ContentContainer' +import Pagination from '../common/Pagination' +import Modify, { type ModifyInstance } from './Modify' +import Filter from "./SiteManageFilter" +import Table from "./Table" import { initSiteManage } from './useSiteManage' export default defineComponent(() => { @@ -29,15 +29,15 @@ export default defineComponent(() => { } = initSiteManage(() => loadingTarget.value?.el as HTMLElement | undefined) const modify = ref<ModifyInstance>() - const cateSupported = computed(() => selected?.value?.filter(supportCategory) || []) + const cateSupported = computed(() => selected.value.filter(supportCategory)) const [showCateChange, openCateChange, closeCateChange] = useSwitch(false) const [batchCate, setBatchCate] = useState<number>() const handleChangeCate = () => { - if (!selected.value?.length) { + if (!selected.value.length) { return ElMessage.info(t(msg => msg.siteManage.msg.noSelected)) } - if (!cateSupported.value?.length) { + if (!cateSupported.value.length) { return ElMessage.info(t(msg => msg.siteManage.msg.noSupported)) } setBatchCate() @@ -46,17 +46,16 @@ export default defineComponent(() => { const onCateChangeConfirm = async () => { const cateId = batchCate.value - if (!cateId) { - return ElMessage.warning("Category not selected") - } - await batchSaveSiteCate(cateId, cateSupported.value) + if (!cateId) return ElMessage.warning("Category not selected") + + await changeSitesCate(cateId, ...cateSupported.value) ElMessage.success(t(msg => msg.operation.successMsg)) closeCateChange() refresh() } const handleDisassociate = () => { - if (!selected.value?.length) { + if (!selected.value.length) { return ElMessage.info(t(msg => msg.siteManage.msg.noSelected)) } ElMessageBox.confirm( @@ -67,15 +66,15 @@ export default defineComponent(() => { closeOnClickModal: true, } ).then(async () => { - const need2Clear = cateSupported.value?.filter(s => s.cate) - need2Clear?.length && await batchSaveSiteCate(undefined, need2Clear) + const need2Clear = cateSupported.value.filter(s => s.cate) + need2Clear.length && await changeSitesCate(undefined, ...need2Clear) ElMessage.success(t(msg => msg.operation.successMsg)) refresh() }).catch(() => { }) } const handleBatchDelete = () => { - if (!selected.value?.length) { + if (!selected.value.length) { return ElMessage.info(t(msg => msg.siteManage.msg.noSelected)) } ElMessageBox.confirm( @@ -87,7 +86,7 @@ export default defineComponent(() => { icon: markRaw(WarnTriangleFilled), } ).then(async () => { - await removeSites(...(selected.value ?? [])) + await deleteSites(...(selected.value ?? [])) ElMessage.success(t(msg => msg.operation.successMsg)) refresh() }).catch(() => { }) @@ -95,7 +94,7 @@ export default defineComponent(() => { return () => <ContentContainer v-slots={{ filter: () => ( - <SiteManageFilter + <Filter onCreate={() => modify.value?.add?.()} onBatchChangeCate={handleChangeCate} onBatchDelete={handleBatchDelete} @@ -105,13 +104,13 @@ export default defineComponent(() => { content: () => <> <Flex ref={loadingTarget} column width="100%" height="100%" gap={23}> <Flex flex={1} height={0}> - <SiteManageTable /> + <Table /> </Flex> <Flex justify="center"> <Pagination disabled={loading.value} defaultValue={page} - total={pagination.value?.total || 0} + total={pagination.value.total} onChange={val => { page.num = val.num, page.size = val.size }} /> </Flex> @@ -126,7 +125,7 @@ export default defineComponent(() => { default: () => <> <ElForm> <ElFormItem label={t(msg => msg.siteManage.cate.name)} required> - <CategorySelect modelValue={batchCate.value} onChange={setBatchCate} /> + <Category.Select modelValue={batchCate.value} onChange={setBatchCate} /> </ElFormItem> </ElForm> </>, diff --git a/src/pages/app/components/SiteManage/useSiteManage.ts b/src/pages/app/components/SiteManage/useSiteManage.ts index f5dfd57ee..3ba795758 100644 --- a/src/pages/app/components/SiteManage/useSiteManage.ts +++ b/src/pages/app/components/SiteManage/useSiteManage.ts @@ -1,9 +1,6 @@ -import { - type RequestOption, - useLocalStorage, useProvide, useProvider, useRequest, useState -} from '@hooks' -import { selectSitePage, type SiteQueryParam } from '@service/site-service' -import { type Reactive, reactive, type ShallowRef, watch } from 'vue' +import { getSitePage } from '@api/sw/site' +import { type RequestOption, useLocalStorage, useProvide, useProvider, useRequest, useState } from '@hooks' +import { reactive, type ShallowRef, watch } from 'vue' type FilterOption = { query?: string @@ -16,8 +13,8 @@ type CacheValue = { } type Context = { - pagination: ShallowRef<timer.common.PageResult<timer.site.SiteInfo> | undefined> - filter: Reactive<FilterOption> + pagination: ShallowRef<timer.common.PageResult<timer.site.SiteInfo>> + filter: FilterOption selected: ShallowRef<timer.site.SiteInfo[]> setSelected: ArgCallback<timer.site.SiteInfo[]> refresh: NoArgCallback @@ -36,9 +33,12 @@ export const initSiteManage = (loadingTarget: RequestOption<unknown, unknown[]>[ const { data: pagination, refresh, loading } = useRequest(() => { const { query: fuzzyQuery, cateIds, types } = filter - const param: SiteQueryParam = { fuzzyQuery, cateIds, types } - return selectSitePage(param, page) - }, { loadingTarget, deps: [() => filter, () => page] }) + return getSitePage({ fuzzyQuery, cateIds, types }, page) + }, { + defaultValue: { list: [], total: 0 }, + loadingTarget, + deps: [() => filter, () => page], + }) useProvide<Context>(NAMESPACE, { pagination, filter, selected, setSelected, refresh }) diff --git a/src/pages/app/components/Whitelist/WhitePanel/AddButton.tsx b/src/pages/app/components/Whitelist/WhitePanel/AddButton.tsx index 310520863..9a90d1889 100644 --- a/src/pages/app/components/Whitelist/WhitePanel/AddButton.tsx +++ b/src/pages/app/components/Whitelist/WhitePanel/AddButton.tsx @@ -4,8 +4,8 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" -import { useState, useSwitch } from "@hooks" +import { useState, useSwitch } from '@hooks' +import { t } from '@app/locale' import { ElButton } from "element-plus" import { defineComponent, StyleValue } from "vue" import WhiteInput from './WhiteInput' diff --git a/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx b/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx index 70ecdbaba..1fd8a3b1b 100644 --- a/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx +++ b/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx @@ -5,12 +5,12 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { listSites } from '@api/sw/site' +import { t } from '@app/locale' import { Check, CirclePlus, Close } from "@element-plus/icons-vue" -import { useRequest, useShadow } from "@hooks" +import { useRequest, useShadow } from '@hooks' import Box from "@pages/components/Box" import Flex from '@pages/components/Flex' -import { selectAllSites } from '@service/site-service' import { EXCLUDING_PREFIX, isRemainHost } from "@util/constant/remain-host" import { isValidHost, judgeVirtualFast } from "@util/pattern" import { ElButton, ElIcon, ElMessage, ElOption, ElSelect, ElTag } from "element-plus" @@ -28,14 +28,14 @@ async function remoteSearch(query: string): Promise<SearchItem[]> { } if (!query) return [] - let sites: SearchItem[] = await selectAllSites({ fuzzyQuery: query }) + const sites: SearchItem[] = await listSites({ fuzzyQuery: query }) const idx = sites.findIndex(s => s.host === query) - const target = idx >= 0 + const target: SearchItem = idx >= 0 // Move found item to the front ? sites.splice(idx, 1)[0] // Or create a new one if not found - : { host: query, type: judgeVirtualFast(query) ? 'virtual' : 'normal' } satisfies SearchItem + : { host: query, type: judgeVirtualFast(query) ? 'virtual' : 'normal' } const result = [target, ...sites] result.forEach(s => s.exclude = exclude) diff --git a/src/pages/app/components/Whitelist/WhitePanel/WhiteItem.tsx b/src/pages/app/components/Whitelist/WhitePanel/WhiteItem.tsx index 26db89160..3d33ceef4 100644 --- a/src/pages/app/components/Whitelist/WhitePanel/WhiteItem.tsx +++ b/src/pages/app/components/Whitelist/WhitePanel/WhiteItem.tsx @@ -5,8 +5,8 @@ * https://opensource.org/licenses/MIT */ -import EditableTag, { type EditableTagProps } from "@app/components/common/EditableTag" -import { useShadow, useSwitch } from "@hooks" +import EditableTag, { type EditableTagProps } from '@app/components/common/EditableTag' +import { useShadow, useSwitch } from '@hooks' import { EXCLUDING_PREFIX } from '@util/constant/remain-host' import { judgeVirtualFast } from "@util/pattern" import { computed, defineComponent } from "vue" diff --git a/src/pages/app/components/Whitelist/WhitePanel/index.tsx b/src/pages/app/components/Whitelist/WhitePanel/index.tsx index ac665afbf..85888004b 100644 --- a/src/pages/app/components/Whitelist/WhitePanel/index.tsx +++ b/src/pages/app/components/Whitelist/WhitePanel/index.tsx @@ -4,9 +4,9 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" +import { addWhitelist, deleteWhitelist, listWhitelist } from "@api/sw/whitelist" +import { t } from '@app/locale' import Flex from "@pages/components/Flex" -import whitelistService from "@service/whitelist/service" import { ElMessage, ElMessageBox } from "element-plus" import { defineComponent, onBeforeMount, reactive } from "vue" import AddButton from './AddButton' @@ -14,17 +14,17 @@ import WhiteItem from './WhiteItem' const _default = defineComponent(() => { const whitelist = reactive<string[]>([]) - onBeforeMount(() => whitelistService.listAll().then(l => whitelist.push(...l))) + onBeforeMount(() => listWhitelist().then(l => whitelist.push(...l))) const handleChanged = async (val: string, index: number): Promise<boolean> => { - const duplicate = whitelist.find((white, i) => white === val && i !== index) + const duplicate = whitelist.some((white, i) => white === val && i !== index) if (duplicate) { ElMessage.warning(t(msg => msg.whitelist.duplicateMsg)) // Reopen return false } - await whitelistService.remove(whitelist[index]) - await whitelistService.add(val) + await deleteWhitelist(whitelist[index]) + await addWhitelist(val) whitelist[index] = val ElMessage.success(t(msg => msg.operation.successMsg)) return true @@ -41,7 +41,7 @@ const _default = defineComponent(() => { const title = t(msg => msg.operation.confirmTitle) return ElMessageBox.confirm(msg, title, { dangerouslyUseHTMLString: true }) .then(async () => { - await whitelistService.add(val) + await addWhitelist(val) whitelist.push(val) ElMessage.success(t(msg => msg.operation.successMsg)) return true @@ -54,7 +54,7 @@ const _default = defineComponent(() => { const confirmTitle = t(msg => msg.operation.confirmTitle) ElMessageBox .confirm(confirmMsg, confirmTitle, { dangerouslyUseHTMLString: true }) - .then(() => whitelistService.remove(whiteItem)) + .then(() => deleteWhitelist(whiteItem)) .then(() => { ElMessage.success(t(msg => msg.operation.successMsg)) const index = whitelist.indexOf(whiteItem) diff --git a/src/pages/app/components/Whitelist/index.tsx b/src/pages/app/components/Whitelist/index.tsx index ba16cd5d0..867070cdc 100644 --- a/src/pages/app/components/Whitelist/index.tsx +++ b/src/pages/app/components/Whitelist/index.tsx @@ -5,11 +5,11 @@ * https://opensource.org/licenses/MIT */ -import AlertLines from '@app/components/common/AlertLines' import Flex from "@pages/components/Flex" import { ElCard } from "element-plus" import { type FunctionalComponent } from "vue" -import ContentContainer from "../common/ContentContainer" +import AlertLines from '../common/AlertLines' +import ContentContainer from '../common/ContentContainer' import WhitePanel from "./WhitePanel" const Whitelist: FunctionalComponent = () => ( diff --git a/src/pages/app/components/common/category/CategoryEditable.tsx b/src/pages/app/components/common/Category/Editable.tsx similarity index 88% rename from src/pages/app/components/common/category/CategoryEditable.tsx rename to src/pages/app/components/common/Category/Editable.tsx index 6ddcaf686..3938fa3e0 100644 --- a/src/pages/app/components/common/category/CategoryEditable.tsx +++ b/src/pages/app/components/common/Category/Editable.tsx @@ -1,12 +1,12 @@ +import { changeSitesCate } from '@api/sw/site' import { useCategory } from "@app/context" import { Edit } from "@element-plus/icons-vue" import { useManualRequest, useSwitch } from "@hooks" import Flex from "@pages/components/Flex" -import { saveSiteCate } from '@service/site-service' import { supportCategory } from "@util/site" import { ElIcon, ElTag } from "element-plus" import { computed, defineComponent, nextTick, ref } from "vue" -import CategorySelect, { CategorySelectInstance } from "./CategorySelect" +import Select, { type Instance } from "./Select" type Props = ModelValue<number | undefined> & { siteKey: timer.site.SiteKey @@ -25,7 +25,7 @@ const CategoryEditable = defineComponent<Props>(props => { const { refresh: doSave } = useManualRequest(async (cateId: number | string | undefined) => { const realCateId = typeof cateId === 'string' ? parseInt(cateId) : cateId - await saveSiteCate(props.siteKey, realCateId) + await changeSitesCate(realCateId, props.siteKey) return realCateId }, { onSuccess(realCateId) { @@ -34,17 +34,18 @@ const CategoryEditable = defineComponent<Props>(props => { }, }) - const handleEditClick = () => { + const handleEditClick = (ev: MouseEvent) => { + ev.stopImmediatePropagation() openEditing() nextTick(() => selectRef.value?.openOptions?.()) } - const selectRef = ref<CategorySelectInstance>() + const selectRef = ref<Instance>() return () => supportCategory(props.siteKey) ? <Flex width="100%" height="100%" justify="center"> {editing.value ? - <CategorySelect + <Select ref={selectRef} size="small" width="100px" @@ -68,7 +69,7 @@ const CategoryEditable = defineComponent<Props>(props => { <Edit /> </ElIcon> </Flex> - </Flex > + </Flex> } </Flex> : false diff --git a/src/pages/app/components/common/category/CategorySelect/OptionItem.tsx b/src/pages/app/components/common/Category/Select/OptionItem.tsx similarity index 87% rename from src/pages/app/components/common/category/CategorySelect/OptionItem.tsx rename to src/pages/app/components/common/Category/Select/OptionItem.tsx index f075c081f..0e2a0cf42 100644 --- a/src/pages/app/components/common/category/CategorySelect/OptionItem.tsx +++ b/src/pages/app/components/common/Category/Select/OptionItem.tsx @@ -1,10 +1,10 @@ +import { sendMsg2Runtime } from '@api/sw/common' +import { listSites } from "@api/sw/site" import { useCategory } from "@app/context" import { t } from "@app/locale" import { Check, Close, Delete, Edit } from "@element-plus/icons-vue" import { useManualRequest, useRequest, useState, useSwitch } from "@hooks" import Flex from "@pages/components/Flex" -import cateService from "@service/cate-service" -import { selectAllSites } from '@service/site-service' import { stopPropagationAfter } from "@util/document" import { ElButton, ElInput, ElMessage, ElMessageBox } from "element-plus" import { defineComponent, nextTick, ref } from "vue" @@ -16,25 +16,28 @@ const OptionItem = defineComponent<{ value: timer.site.Cate }>(props => { const [editingName, setEditingName] = useState(props.value.name) const inputRef = ref<HTMLInputElement>() - const { refresh: saveCate } = useManualRequest((name: string) => cateService.saveName(props.value.id, name), { - onSuccess: () => { - cate.refresh() - closeEditing() - } - }) + const { refresh: saveCate } = useManualRequest( + (name: string) => sendMsg2Runtime('cate.change', { id: props.value.id, name }), + { + onSuccess: () => { + cate.refresh() + closeEditing() + } + }, + ) const onSaveClick = () => { const name = editingName.value?.trim?.() name ? saveCate(name) : ElMessage.warning("Name is blank") } - const { refresh: removeCate } = useManualRequest(() => cateService.remove(props.value.id), { + const { refresh: doRemoveCate } = useManualRequest(() => sendMsg2Runtime('cate.delete', props.value.id), { onSuccess: () => { cate.refresh() ElMessage.success(t(msg => msg.operation.successMsg)) } }) - const { data: relatedSites, loading: queryingSites } = useRequest(() => selectAllSites({ cateIds: props.value?.id })) + const { data: relatedSites, loading: queryingSites } = useRequest(() => listSites({ cateIds: props.value?.id })) const onRemoveClick = (e: MouseEvent) => { e.stopPropagation() @@ -48,7 +51,7 @@ const OptionItem = defineComponent<{ value: timer.site.Cate }>(props => { message: t(msg => msg.siteManage.cate.removeConfirm, { category: props.value?.name }), type: 'warning', closeOnClickModal: false, - }).then(removeCate).catch(() => { }) + }).then(doRemoveCate).catch(() => { }) } const onEditClick = () => { diff --git a/src/pages/app/components/common/category/CategorySelect/SelectFooter.tsx b/src/pages/app/components/common/Category/Select/SelectFooter.tsx similarity index 96% rename from src/pages/app/components/common/category/CategorySelect/SelectFooter.tsx rename to src/pages/app/components/common/Category/Select/SelectFooter.tsx index 035220366..621a1ea97 100644 --- a/src/pages/app/components/common/category/CategorySelect/SelectFooter.tsx +++ b/src/pages/app/components/common/Category/Select/SelectFooter.tsx @@ -1,9 +1,9 @@ +import { sendMsg2Runtime } from '@api/sw/common' import { useCategory } from "@app/context" import { t } from "@app/locale" import { Check, Close, Plus } from "@element-plus/icons-vue" import { useManualRequest, useState, useSwitch } from "@hooks" import Flex from "@pages/components/Flex" -import cateService from "@service/cate-service" import { stopPropagationAfter } from "@util/document" import { ElButton, ElForm, ElFormItem, ElInput, ElMessage } from "element-plus" import { defineComponent, nextTick, ref } from "vue" @@ -14,7 +14,7 @@ const SelectFooter = defineComponent(() => { const [name, setName] = useState<string>() const { refresh: saveCate, loading } = useManualRequest( - (name: string) => cateService.add(name), + (name: string) => sendMsg2Runtime('cate.add', name), { onSuccess() { cate.refresh() diff --git a/src/pages/app/components/common/category/CategorySelect/index.tsx b/src/pages/app/components/common/Category/Select/index.tsx similarity index 75% rename from src/pages/app/components/common/category/CategorySelect/index.tsx rename to src/pages/app/components/common/Category/Select/index.tsx index bc74edab4..47f2476c2 100644 --- a/src/pages/app/components/common/category/CategorySelect/index.tsx +++ b/src/pages/app/components/common/Category/Select/index.tsx @@ -5,7 +5,7 @@ import { defineComponent, ref } from "vue" import OptionItem from "./OptionItem" import SelectFooter from "./SelectFooter" -export type CategorySelectInstance = { +export type Instance = { openOptions: () => void } @@ -18,29 +18,29 @@ type Props = { onChange?: ArgCallback<number | undefined> } -const CategorySelect = defineComponent<Props>((props, ctx) => { +const Select = defineComponent<Props>((props, ctx) => { const cate = useCategory() const selectRef = ref<SelectInstance>() ctx.expose({ openOptions: () => selectRef.value?.selectOption?.() - } satisfies CategorySelectInstance) + } satisfies Instance) return () => ( <ElSelect ref={selectRef} size={props.size} modelValue={props.modelValue} - onChange={val => ctx.emit('change', val)} - onVisible-change={visible => ctx.emit('visibleChange', visible)} + onChange={val => props.onChange?.(val)} + onVisible-change={visible => props.onVisibleChange?.(visible)} style={{ width: props.width || '100%' }} clearable={props.clearable} - onClear={() => ctx.emit('change', undefined)} + onClear={() => props.onChange?.(undefined)} emptyValues={[CATE_NOT_SET_ID, undefined]} v-slots={{ footer: () => <SelectFooter /> }} > {cate.all.map(c => ( - <ElOption value={c?.id} label={c?.name}> + <ElOption value={c.id} label={c.name}> <OptionItem value={c} /> </ElOption> ))} @@ -48,4 +48,4 @@ const CategorySelect = defineComponent<Props>((props, ctx) => { ) }, { props: ['clearable', 'modelValue', 'size', 'width', 'onVisibleChange', 'onChange'] }) -export default CategorySelect +export default Select diff --git a/src/pages/app/components/common/Category/index.ts b/src/pages/app/components/common/Category/index.ts new file mode 100644 index 000000000..bd0b45662 --- /dev/null +++ b/src/pages/app/components/common/Category/index.ts @@ -0,0 +1,9 @@ +import Editable from './Editable' +import Select from './Select' + +const Category = { + Select: Select, + Editable: Editable, +} + +export default Category diff --git a/src/pages/app/components/common/DialogSop/context.ts b/src/pages/app/components/common/DialogSop/context.ts index 9968d6549..157477cea 100644 --- a/src/pages/app/components/common/DialogSop/context.ts +++ b/src/pages/app/components/common/DialogSop/context.ts @@ -2,7 +2,7 @@ import { t } from '@app/locale' import { useManualRequest, useProvide, useProvider } from "@hooks" import { mergeObject } from '@util/lang' import { ElMessage } from "element-plus" -import { computed, nextTick, Reactive, reactive, ref, ShallowRef, type Ref } from "vue" +import { computed, nextTick, reactive, ref, ShallowRef, type Reactive, type Ref } from "vue" type DialogSopContext<TForm extends Record<string, unknown>> = { visible: ShallowRef<boolean> @@ -24,7 +24,7 @@ type TransmitParam<TForm extends Record<string, unknown>> = { target: number } -export type DialogSopInitOptions<TForm extends Record<string, unknown>> = { +type DialogSopInitOptions<TForm extends Record<string, unknown>> = { stepCount: number init: () => TForm onNext?: (p: TransmitParam<TForm>) => Awaitable<void> diff --git a/src/pages/app/components/common/DialogSop/index.tsx b/src/pages/app/components/common/DialogSop/index.tsx index 01b76c449..7cc34244c 100644 --- a/src/pages/app/components/common/DialogSop/index.tsx +++ b/src/pages/app/components/common/DialogSop/index.tsx @@ -1,7 +1,7 @@ import { t } from "@app/locale" import { Back, Check, Close } from "@element-plus/icons-vue" import { css } from '@emotion/css' -import { useXsState } from '@hooks/index' +import { useXsState } from '@hooks' import Box from "@pages/components/Box" import Flex from "@pages/components/Flex" import { type ButtonProps, DialogProps, ElButton, ElDialog, ElDivider, ElStep, ElSteps, ElText, useNamespace } from "element-plus" diff --git a/src/pages/app/components/common/Editable.tsx b/src/pages/app/components/common/Editable.tsx index 76dcf141d..a3788a246 100644 --- a/src/pages/app/components/common/Editable.tsx +++ b/src/pages/app/components/common/Editable.tsx @@ -13,7 +13,7 @@ import { defineComponent, nextTick, ref, type StyleValue, toRef, useSlots } from type Props = { modelValue: string | undefined - initialValue?: string + initialValue?: string | (() => (Awaitable<string | undefined>)) onChange?: (newVal: string | undefined) => void } @@ -37,10 +37,20 @@ const Editable = defineComponent<Props>(props => { closeEditing() props.onChange?.(inputVal.value?.trim()) } + + const resetWithInitial = async () => { + const initial = props.initialValue + if (typeof initial === 'string') { + setInputVal(initial) + } else if (typeof initial === 'function') { + const initialValue = await initial() + setInputVal(initialValue) + } + } + const handleEdit = () => { openEditing() - const initial = props.initialValue - !input.value && initial && setInputVal(initial) + resetWithInitial() nextTick(() => input.value?.focus?.()) } const { label: labelSlot } = useSlots() diff --git a/src/pages/app/components/common/HostAlert.tsx b/src/pages/app/components/common/HostAlert.tsx index 4f3b9c925..26701e596 100644 --- a/src/pages/app/components/common/HostAlert.tsx +++ b/src/pages/app/components/common/HostAlert.tsx @@ -6,6 +6,7 @@ */ import Flex from "@pages/components/Flex" +import Img from '@pages/components/Img' import { IS_SAFARI } from "@util/constant/environment" import { isRemainHost } from "@util/constant/remain-host" import { ElLink } from "element-plus" @@ -45,14 +46,12 @@ const HostAlert = defineComponent<Props>(props => { > {props.value?.host} </ElLink> - {props.iconUrl && - <Flex align="center"> - <img src={props.iconUrl} width={12} height={12} /> - </Flex> - } + <Flex align="center"> + <Img src={props.iconUrl} size={12} /> + </Flex> </Flex> )} - </div > + </div> }, { props: ['clickable', 'iconUrl', 'value'] }) export default HostAlert \ No newline at end of file diff --git a/src/pages/app/components/common/Pagination.tsx b/src/pages/app/components/common/Pagination.tsx index 762e0dfc8..0afe23b70 100644 --- a/src/pages/app/components/common/Pagination.tsx +++ b/src/pages/app/components/common/Pagination.tsx @@ -22,7 +22,7 @@ const Pagination = defineComponent<Props>(props => { <Flex justify="center" align="center"> <ElPagination disabled={props.disabled} - {...getPaginationIconProps() || {}} + {...getPaginationIconProps()} pageSizes={[10, 20, 50]} defaultCurrentPage={(props.defaultValue as timer.common.PageQuery)?.num} defaultPageSize={(props.defaultValue as timer.common.PageQuery)?.size} diff --git a/src/pages/app/components/common/PopupConfirmButton.tsx b/src/pages/app/components/common/PopupConfirmButton.tsx index 4472449df..5a96f479f 100644 --- a/src/pages/app/components/common/PopupConfirmButton.tsx +++ b/src/pages/app/components/common/PopupConfirmButton.tsx @@ -21,7 +21,7 @@ type Props = { const PopupConfirmButton: FunctionalComponent<Props> = props => ( <ElPopconfirm - confirmButtonText={t(msg => msg.button.okey)} + confirmButtonText={t(msg => msg.button.okay)} cancelButtonText={t(msg => msg.button.dont)} title={props.confirmText} width={300} diff --git a/src/pages/app/components/common/filter/ButtonFilterItem.tsx b/src/pages/app/components/common/filter/ButtonFilterItem.tsx index 9eda1817b..665c63894 100644 --- a/src/pages/app/components/common/filter/ButtonFilterItem.tsx +++ b/src/pages/app/components/common/filter/ButtonFilterItem.tsx @@ -4,8 +4,8 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ +import { useXsState } from '@hooks' import { type I18nKey, t } from '@app/locale' -import { useXsState } from '@hooks/useMediaSize' import { type ButtonProps, ElButton } from "element-plus" import { defineComponent } from "vue" diff --git a/src/pages/app/components/common/filter/DateRangeFilterItem.tsx b/src/pages/app/components/common/filter/DateRangeFilterItem.tsx index 992b22d01..ecea9924b 100644 --- a/src/pages/app/components/common/filter/DateRangeFilterItem.tsx +++ b/src/pages/app/components/common/filter/DateRangeFilterItem.tsx @@ -5,14 +5,14 @@ * https://opensource.org/licenses/MIT */ +import { useXsState } from '@hooks' import { t } from "@app/locale" import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue' import { css } from '@emotion/css' -import { useXsState } from '@hooks/useMediaSize' -import { dateFormat } from "@i18n/element" import Flex from '@pages/components/Flex' -import { getDatePickerIconSlots } from "@pages/element-ui/rtl" -import type { ElDatePickerShortcut } from '@pages/types' +import { getDatePickerIconSlots } from '@pages/element-ui/rtl' +import { ElDatePickerShortcut } from '@pages/element-ui/types' +import { dateFormat } from "@i18n/element" import { isRtl } from '@util/document' import { MILL_PER_DAY } from '@util/time' import { type DatePickerProps, ElButton, ElDatePicker, ElText, useNamespace } from "element-plus" @@ -23,9 +23,7 @@ const clearShortcut = (): ElDatePickerShortcut => ({ value: [new Date(0), new Date(0)], }) -type Value = [Date?, Date?] - -type Props = ModelValue<Value> & { +type Props = ModelValue<[Date?, Date?]> & { disabledDate?: (date: Date) => boolean startPlaceholder?: string endPlaceholder?: string @@ -108,12 +106,12 @@ const DefaultRange = defineComponent<Props>(props => { } = useRange(props) const innerVal = computed(() => { - const [start, end] = props.modelValue + const [start, end] = props.modelValue ?? [] return start && end ? [start, end] : undefined }) const handleUpdate = (innerVal: [Date, Date] | undefined) => { - let value: Value = innerVal ?? [undefined, undefined] + let value = innerVal ?? [undefined, undefined] if (innerVal?.[0].getTime() === 0 && innerVal[1].getTime() === 0) { // clear shortcuts value = [undefined, undefined] diff --git a/src/pages/app/components/common/filter/SelectFilterItem.tsx b/src/pages/app/components/common/filter/SelectFilterItem.tsx index 25ee0e992..aae136bf1 100644 --- a/src/pages/app/components/common/filter/SelectFilterItem.tsx +++ b/src/pages/app/components/common/filter/SelectFilterItem.tsx @@ -6,12 +6,12 @@ */ import { useCached } from "@hooks" -import { ElSelect } from "element-plus" +import { ElSelect, SelectProps } from "element-plus" import { defineComponent, watch } from "vue" import { useRoute } from "vue-router" import { SELECT_WRAPPER_STYLE } from "./common" -type Props = { +type Props = Pick<SelectProps, "placeholder"> & { defaultValue?: string /** * Whether to save the value in the localStorage with {@param historyName} @@ -27,12 +27,13 @@ const SelectFilterItem = defineComponent<Props>(props => { watch(data, val => props.onSelect?.(val)) return () => ( <ElSelect + placeholder={props.placeholder} modelValue={data.value} onChange={setter} style={SELECT_WRAPPER_STYLE} options={Object.entries(props.options).map(([value, label]) => ({ label, value }))} /> ) -}, { props: ['defaultValue', 'historyName', 'options', 'onSelect'] }) +}, { props: ['defaultValue', 'historyName', 'options', 'onSelect', 'placeholder'] }) export default SelectFilterItem \ No newline at end of file diff --git a/src/pages/app/components/common/filter/common.ts b/src/pages/app/components/common/filter/common.ts index db050fa68..492779935 100644 --- a/src/pages/app/components/common/filter/common.ts +++ b/src/pages/app/components/common/filter/common.ts @@ -1,4 +1,4 @@ -import { StyleValue } from "vue" +import type { StyleValue } from "vue" export const SELECT_WRAPPER_STYLE: StyleValue = { width: '200px', diff --git a/src/pages/app/components/common/imported/CompareTable.tsx b/src/pages/app/components/common/imported/CompareTable.tsx index d1af02a0a..6147f8cdc 100644 --- a/src/pages/app/components/common/imported/CompareTable.tsx +++ b/src/pages/app/components/common/imported/CompareTable.tsx @@ -5,13 +5,13 @@ * https://opensource.org/licenses/MIT */ -import HostAlert from "@app/components/common/HostAlert" import { type I18nKey, t } from "@app/locale" -import { cvt2LocaleTime, periodFormatter } from "@app/util/time" +import { cvt2LocaleTime, periodFormatter } from '@app/util/time' import { useState } from "@hooks" import Box from "@pages/components/Box" import { type Column, ElAutoResizer, ElTableV2, type SortBy, TableV2SortOrder } from "element-plus" import { computed, defineComponent, toRef } from "vue" +import HostAlert from "../HostAlert" type SortInfo = SortBy & { key: keyof timer.imported.Row @@ -95,7 +95,7 @@ const BASE_COLUMNS: Column[] = [ const _default = defineComponent<Props>((props) => { const data = toRef(props, 'data') const [sort, setSort] = useState<SortInfo>({ order: TableV2SortOrder.ASC, key: 'date' }) - const list = computed(() => computeList(sort.value, data.value?.rows)) + const list = computed(() => computeList(sort.value, data.value.rows)) const columns = computed(() => { const value = [...BASE_COLUMNS] const { focus, time } = data.value diff --git a/src/pages/app/components/common/imported/ResolutionRadio.tsx b/src/pages/app/components/common/imported/ResolutionRadio.tsx index 7c079072c..b07649b41 100644 --- a/src/pages/app/components/common/imported/ResolutionRadio.tsx +++ b/src/pages/app/components/common/imported/ResolutionRadio.tsx @@ -11,7 +11,7 @@ import Flex from "@pages/components/Flex" import { ElForm, ElFormItem, ElIcon, ElRadio, ElRadioGroup, ElTooltip } from "element-plus" import { defineComponent } from "vue" -export const ALL_RESOLUTIONS: timer.imported.ConflictResolution[] = ['overwrite', 'accumulate'] +const ALL_RESOLUTIONS: timer.imported.ConflictResolution[] = ['overwrite', 'accumulate'] type Value = timer.imported.ConflictResolution | undefined diff --git a/src/pages/app/components/common/kanban/Card.tsx b/src/pages/app/components/common/kanban/Card.tsx index 7b0b70824..77363cfb7 100644 --- a/src/pages/app/components/common/kanban/Card.tsx +++ b/src/pages/app/components/common/kanban/Card.tsx @@ -5,8 +5,8 @@ * https://opensource.org/licenses/MIT */ +import { useXsState } from '@hooks' import { I18nKey, t } from '@app/locale' -import { useXsState } from '@hooks/useMediaSize' import { ElCard } from "element-plus" import { defineComponent, h, useSlots, type StyleValue } from "vue" diff --git a/src/pages/app/components/common/kanban/IndicatorCell.tsx b/src/pages/app/components/common/kanban/IndicatorCell.tsx index 39d6777ac..0e6feade7 100644 --- a/src/pages/app/components/common/kanban/IndicatorCell.tsx +++ b/src/pages/app/components/common/kanban/IndicatorCell.tsx @@ -5,16 +5,16 @@ * https://opensource.org/licenses/MIT */ -import { computeRingText, type RingValue, type ValueFormatter } from "@app/components/Analysis/util" +import { useXsState } from '@hooks' import { tN, type I18nKey } from "@app/locale" import { BottomRight, InfoFilled, TopRight } from "@element-plus/icons-vue" -import { useXsState } from '@hooks/useMediaSize' import Box from "@pages/components/Box" import Flex from "@pages/components/Flex" import { colorVariant, getCssVariable } from '@pages/util/style' import { range } from "@util/array" import { ElIcon, ElTooltip } from "element-plus" import { computed, defineComponent, type CSSProperties } from "vue" +import type { RingValue, ValueFormatter } from './types' const SubVal = defineComponent<{ value: string }>(props => { return () => ( @@ -58,6 +58,26 @@ const ComparisonIcon = defineComponent<{ value: RingValue }>(props => { return () => renderIcons(comp.value) }, { props: ['value'] }) + +/** + * Compute ring text + * + * @param ring ring value + * @param formatter formatter + * @returns text or '-' + */ +function computeRingText(ring: RingValue, formatter?: ValueFormatter): string | undefined { + const [current, last] = ring + if (current === undefined && last === undefined) { + // return undefined if both are undefined + return undefined + } + const delta = (current ?? 0) - (last ?? 0) + let result = formatter ? formatter(delta) : delta?.toString() + delta >= 0 && (result = '+' + result) + return result +} + const RingLine = defineComponent<{ value: RingValue, formatter?: ValueFormatter }>(props => { const text = computed(() => computeRingText(props.value, props.formatter)) return () => text.value ? <> diff --git a/src/pages/app/components/common/kanban/index.ts b/src/pages/app/components/common/kanban/index.ts index 36f80fdc4..fe8d5f4d3 100644 --- a/src/pages/app/components/common/kanban/index.ts +++ b/src/pages/app/components/common/kanban/index.ts @@ -5,8 +5,5 @@ * https://opensource.org/licenses/MIT */ -import Card from "./Card" -import IndicatorCell from "./IndicatorCell" - -export const KanbanIndicatorCell = IndicatorCell -export const KanbanCard = Card \ No newline at end of file +export { default as KanbanCard } from "./Card" +export { default as KanbanIndicatorCell } from "./IndicatorCell" diff --git a/src/pages/app/components/common/kanban/types.d.ts b/src/pages/app/components/common/kanban/types.d.ts new file mode 100644 index 000000000..6b5412c6b --- /dev/null +++ b/src/pages/app/components/common/kanban/types.d.ts @@ -0,0 +1,6 @@ +export type RingValue = [ + current?: number, + last?: number, +] + +export type ValueFormatter = (val: number | undefined) => string \ No newline at end of file diff --git a/src/pages/app/context.ts b/src/pages/app/context.ts index 7782963d4..ea4ddf363 100644 --- a/src/pages/app/context.ts +++ b/src/pages/app/context.ts @@ -1,8 +1,8 @@ +import { listAllCategories } from "@api/sw/cate" import { MediaSize, useMediaSize, useProvide, useProvider, useRequest } from "@hooks" -import cateService from "@service/cate-service" import { toMap } from '@util/array' import { CATE_NOT_SET_ID } from '@util/site' -import { computed, reactive, watch, type Ref } from "vue" +import { computed, reactive, watch } from "vue" import { t } from './locale' type MenuLayout = 'nav' | 'sidebar' @@ -16,13 +16,12 @@ interface CategoryInstance { type AppContextValue = { category: Readonly<CategoryInstance> - layout: Readonly<Ref<MenuLayout>> } const NAMESPACE = '_' export const initAppContext = () => { - const { refresh: refreshCategories } = useRequest(() => cateService.listAll(), { + const { refresh: refreshCategories } = useRequest(listAllCategories, { onSuccess: categories => { category.all = categories const map = toMap(categories, c => c.id, c => c.name) @@ -39,11 +38,9 @@ export const initAppContext = () => { nameMap: {}, refresh: refreshCategories, }) - useProvide<AppContextValue>(NAMESPACE, { category, layout }) + useProvide<AppContextValue>(NAMESPACE, { category }) return { layout } } -export const useCategory = () => useProvider<AppContextValue, "category">(NAMESPACE, "category").category - -export const useLayout = () => useProvider<AppContextValue, "layout">(NAMESPACE, "layout").layout \ No newline at end of file +export const useCategory = () => useProvider<AppContextValue, "category">(NAMESPACE, "category").category \ No newline at end of file diff --git a/src/pages/app/echarts.ts b/src/pages/app/echarts.ts deleted file mode 100644 index adaea40a2..000000000 --- a/src/pages/app/echarts.ts +++ /dev/null @@ -1,20 +0,0 @@ -import { BarChart, CustomChart, EffectScatterChart, HeatmapChart, LineChart, PieChart, ScatterChart } from "echarts/charts" -import { - AriaComponent, - DataZoomComponent, - GridComponent, - LegendComponent, - TitleComponent, - TooltipComponent, - VisualMapComponent, -} from "echarts/components" -import { use } from "echarts/core" -import { CanvasRenderer } from "echarts/renderers" - -export const initEcharts = () => { - use([ - CanvasRenderer, - AriaComponent, GridComponent, TooltipComponent, TitleComponent, VisualMapComponent, LegendComponent, DataZoomComponent, - BarChart, PieChart, LineChart, HeatmapChart, ScatterChart, EffectScatterChart, CustomChart, - ]) -} \ No newline at end of file diff --git a/src/pages/app/index.ts b/src/pages/app/index.ts index fa1eac4ab..b53d83526 100644 --- a/src/pages/app/index.ts +++ b/src/pages/app/index.ts @@ -7,34 +7,26 @@ import { listenMediaSizeChange } from "@hooks" import { initLocale } from "@i18n" -import { initElementLocale } from "@i18n/element" -import optionService from "@service/option-service" -import { init as initTheme, toggle } from "@util/dark-mode" -import { createApp, type App } from "vue" -import '../../common/timer' -import { initEcharts } from "./echarts" +import { createElApp } from "@pages/element-ui/app" +import { initDarkTheme } from "@pages/util/dark-mode" +import { initEcharts } from "../util/echarts" import Main from "./Layout" import installRouter from "./router" import { injectAppCss } from './styles/index' async function main() { injectAppCss() - // Init theme with cache first - initTheme() + initDarkTheme() listenMediaSizeChange() - // Calculate the latest mode - optionService.isDarkMode().then(toggle) - await initLocale() + initLocale() initEcharts() - const app: App = createApp(Main) + const app = await createElApp(Main) installRouter(app) const el = document.createElement('div') document.body.append(el) el.id = 'app' app.mount(el) - - await initElementLocale(app) } main() \ No newline at end of file diff --git a/src/pages/app/router/constants.ts b/src/pages/app/router/constants.ts index ff96e795c..4c3116c23 100644 --- a/src/pages/app/router/constants.ts +++ b/src/pages/app/router/constants.ts @@ -7,23 +7,15 @@ export const DASHBOARD_ROUTE = '/data/dashboard' -export const ANALYSIS_ROUTE = '/data/analysis' - -export const OPTION_ROUTE = '/additional/option' - /** - * Use on the app page and background script - * * @since 0.2.2 */ -export const LIMIT_ROUTE = '/behavior/limit' - -/** - * @since 0.9.1 - */ -export const REPORT_ROUTE = '/data/report' +export { + APP_ANALYSIS_ROUTE as ANALYSIS_ROUTE, APP_LIMIT_ROUTE as LIMIT_ROUTE, APP_OPTION_ROUTE as OPTION_ROUTE, + APP_REPORT_ROUTE as REPORT_ROUTE, type AppLimitQuery as LimitQuery, type AppReportQuery as ReportQuery +} from "@/shared/route" /** * @since 1.8.0 */ -export const MERGE_ROUTE = '/additional/rule-merge' \ No newline at end of file +export const MERGE_ROUTE = '/additional/rule-merge' diff --git a/src/pages/app/router/index.ts b/src/pages/app/router/index.ts index b9d89527c..6366a72f1 100644 --- a/src/pages/app/router/index.ts +++ b/src/pages/app/router/index.ts @@ -5,7 +5,6 @@ * https://opensource.org/licenses/MIT */ -import { increaseApp } from "@service/meta-service" import { type App } from "vue" import { createRouter, createWebHashHistory, type RouteRecordRaw } from "vue-router" import { ANALYSIS_ROUTE, DASHBOARD_ROUTE, LIMIT_ROUTE, MERGE_ROUTE, OPTION_ROUTE, REPORT_ROUTE } from "./constants" @@ -91,17 +90,4 @@ const router = createRouter({ routes, }) -async function handleChange() { - await router.isReady() - const current = router.currentRoute.value.fullPath - current && increaseApp(current) - router.afterEach(async (to, from, failure: Error | void) => { - if (failure || to.fullPath === from.fullPath) return - await increaseApp(to.fullPath) - }) -} - -export default (app: App) => { - app.use(router) - handleChange() -} +export default (app: App) => app.use(router) diff --git a/src/pages/app/styles/index.ts b/src/pages/app/styles/index.ts index b658a4395..0c2075ad2 100644 --- a/src/pages/app/styles/index.ts +++ b/src/pages/app/styles/index.ts @@ -1,7 +1,5 @@ import { injectGlobal } from '@emotion/css' import '@pages/element-ui/dark-theme.css' -import "element-plus/theme-chalk/display.css" -import 'element-plus/theme-chalk/index.css' import { injectEchartsCss } from './echarts' import { injectElementCss } from './element' diff --git a/src/pages/app/util/echarts.ts b/src/pages/app/util/echarts.ts index 090c823d4..2136103d6 100644 --- a/src/pages/app/util/echarts.ts +++ b/src/pages/app/util/echarts.ts @@ -2,6 +2,7 @@ import { getCssVariable } from "@pages/util/style" import { range } from "@util/array" import { addVector, multiTuple, subVector } from "@util/tuple" import { type LinearGradientObject } from "echarts" +import type { TopLevelFormatterParams } from "echarts/types/dist/shared" const splitVectors = (vectorRange: Tuple<Vector<number>, 2>, count: number, gradientFactor?: number): Vector<number>[] => { gradientFactor = gradientFactor ?? 1.3 @@ -103,4 +104,14 @@ export const tooltipSpaceLine = (height?: number): string => { export const getPieBorderColor = (): string | undefined => { return getCssVariable('--echarts-pie-border-color') +} + +export function parseValueOfFormatter(params: TopLevelFormatterParams) { + const param = Array.isArray(params) ? params[0] : params + const { data } = param + if (typeof data === 'object' && data !== null) { + return 'value' in data ? data.value : undefined + } + + return undefined } \ No newline at end of file diff --git a/src/service/limit-service/verification/generator/confession.ts b/src/pages/app/util/limit/generator/confession.ts similarity index 83% rename from src/service/limit-service/verification/generator/confession.ts rename to src/pages/app/util/limit/generator/confession.ts index bc39c5fd9..1715dbb99 100644 --- a/src/service/limit-service/verification/generator/confession.ts +++ b/src/pages/app/util/limit/generator/confession.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { type VerificationContext, type VerificationGenerator, type VerificationPair } from "../common" +import type { VerificationContext, VerificationGenerator, VerificationPair } from "../types" /** * Generator of confession diff --git a/src/service/limit-service/verification/generator/index.ts b/src/pages/app/util/limit/generator/index.ts similarity index 89% rename from src/service/limit-service/verification/generator/index.ts rename to src/pages/app/util/limit/generator/index.ts index a57bb658d..dc3425ce0 100644 --- a/src/service/limit-service/verification/generator/index.ts +++ b/src/pages/app/util/limit/generator/index.ts @@ -4,7 +4,7 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { type VerificationGenerator } from "../common" +import type { VerificationGenerator } from "../types" import ConfessionGenerator from "./confession" import PiGenerator from "./pi" import UglyGenerator from "./ugly" diff --git a/src/service/limit-service/verification/generator/pi.ts b/src/pages/app/util/limit/generator/pi.ts similarity index 91% rename from src/service/limit-service/verification/generator/pi.ts rename to src/pages/app/util/limit/generator/pi.ts index 5c673d7f4..b7b5a67ae 100644 --- a/src/service/limit-service/verification/generator/pi.ts +++ b/src/pages/app/util/limit/generator/pi.ts @@ -6,7 +6,7 @@ */ import { randomIntBetween } from "@util/number" -import { type VerificationContext, type VerificationGenerator, type VerificationPair } from "../common" +import type { VerificationContext, VerificationGenerator, VerificationPair } from "../types" const MIN_START_IDX = 10 const MAX_START_IDX = 25 diff --git a/src/service/limit-service/verification/generator/ugly.ts b/src/pages/app/util/limit/generator/ugly.ts similarity index 90% rename from src/service/limit-service/verification/generator/ugly.ts rename to src/pages/app/util/limit/generator/ugly.ts index 15cb8458d..d4e94ef41 100644 --- a/src/service/limit-service/verification/generator/ugly.ts +++ b/src/pages/app/util/limit/generator/ugly.ts @@ -7,9 +7,9 @@ import { range } from "@util/array" import { randomIntBetween } from "@util/number" -import type { VerificationContext, VerificationGenerator, VerificationPair } from "../common" +import type { VerificationContext, VerificationGenerator, VerificationPair } from "../types" -const BASE = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\`-=[]/.,:\"<>?!@#$%^&*()_+;'".replaceAll(/[01IlLOo]/g, "") +const BASE = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz\`-=[]/.,:\"<>?!@#$%^&*()_+;'/i18n".replaceAll(/[01IlLOo]/g, "") const BASE_LEN = BASE.length class UglyGenerator implements VerificationGenerator { diff --git a/src/service/limit-service/verification/generator/uncommon-chinese.ts b/src/pages/app/util/limit/generator/uncommon-chinese.ts similarity index 97% rename from src/service/limit-service/verification/generator/uncommon-chinese.ts rename to src/pages/app/util/limit/generator/uncommon-chinese.ts index dcf329ec7..bad152cbc 100644 --- a/src/service/limit-service/verification/generator/uncommon-chinese.ts +++ b/src/pages/app/util/limit/generator/uncommon-chinese.ts @@ -6,7 +6,7 @@ */ import { randomIntBetween } from "@util/number" -import type { VerificationContext, VerificationGenerator, VerificationPair } from "../common" +import type { VerificationContext, VerificationGenerator, VerificationPair } from "../types" const UNCOMMON_WORDS = '龘靐齉齾爩鱻麤龗灪吁龖厵滟爨癵籱饢驫鲡鹂鸾麣纞虋讟钃骊郁鸜麷鞻韽韾响顟顠饙饙騳騱饐' const LENGTH = UNCOMMON_WORDS.length diff --git a/src/pages/app/util/limit.tsx b/src/pages/app/util/limit/index.tsx similarity index 62% rename from src/pages/app/util/limit.tsx rename to src/pages/app/util/limit/index.tsx index 4f3d8f264..621b8b8a3 100644 --- a/src/pages/app/util/limit.tsx +++ b/src/pages/app/util/limit/index.tsx @@ -1,25 +1,31 @@ -import { sendMsg2Runtime } from "@api/chrome/runtime" -import { t, tN } from "@app/locale" -import { useCountDown } from '@hooks/useCount' -import { I18nResultItem, locale } from "@i18n" +import { trySendMsg2Runtime } from '@api/sw/common' +import { css } from '@emotion/css' +import { useCountDown } from "@hooks" +import { type I18nKey, type I18nResultItem, locale, t as t_, tN as tN_ } from "@i18n" +import limitMessages, { type LimitMessage } from "@i18n/message/app/limit" +import buttonMessages from "@i18n/message/common/button" import { getCssVariable } from "@pages/util/style" -import verificationProcessor from "@service/limit-service/verification/processor" -import { dateMinute2Idx, hasLimited, isEnabledAndEffective } from "@util/limit" -import { ElMessage, ElMessageBox, type ElMessageBoxOptions, type InputType, useId } from "element-plus" +import { dateMinute2Idx, hasLimited, isEffective } from "@util/limit" +import { ElMessage, ElMessageBox, type InputType, useId, useNamespace } from "element-plus" import { defineComponent, onMounted, ref, type VNode } from "vue" +import verificationProcessor from './processor' + +const t = (key: I18nKey<LimitMessage>, param?: any) => t_(limitMessages, { key, param }) + +const tN = (key: I18nKey<LimitMessage>, param?: any) => tN_<LimitMessage, VNode>(limitMessages, { key, param }) /** * Judge wether verification is required * * @returns T/F */ -export async function judgeVerificationRequired(item: timer.limit.Item): Promise<boolean> { +export async function judgeVerificationRequired(item: timer.limit.Item, delayDuration: number): Promise<boolean> { if (item.locked) return true - if (!isEnabledAndEffective(item)) return false + if (!item.enabled || !isEffective(item.weekdays)) return false const { visitTime, periods } = item // Daily or weekly - if (hasLimited(item)) return true + if (hasLimited(item, delayDuration)) return true // Period if (periods?.length) { const idx = dateMinute2Idx(new Date()) @@ -28,13 +34,8 @@ export async function judgeVerificationRequired(item: timer.limit.Item): Promise } // Visit if (visitTime) { - let hitVisit = false - try { - hitVisit = !!await sendMsg2Runtime("askHitVisit", item) - } catch (e) { - // If error occurs, regarded as not hitting - // ignored - } + // If error occurs, regarded as not hitting + const hitVisit = await trySendMsg2Runtime('limit.hitVisit', item) ?? false if (hitVisit) return true } return false @@ -81,6 +82,18 @@ const AnswerCanvas = defineComponent(((props: { text: string }) => { ) }), { props: ['text'] }) +// Fix some style missing in limit modal with postcss processor +const INPUT_NS = useNamespace('input') +const MODAL_CLS = css` + & .${INPUT_NS.e('inner')} { + background: 0; + } +` +const MSG_CLS = css` + left: 50%; + transform: translate(-50%); +` + /** * NOT TO return Promise.resolve() * @@ -89,16 +102,14 @@ const AnswerCanvas = defineComponent(((props: { text: string }) => { * @returns null if verification not required, * or promise with resolve invoked only if verification code or password correct */ -export function processVerification(option: timer.option.LimitOption, context?: { appendTo: Exclude<ElMessageBoxOptions['appendTo'], string> }): Promise<void> { +export async function processVerification(option: timer.option.LimitOption): Promise<void> { const { limitLevel, limitPassword, limitVerifyDifficulty } = option - const { appendTo } = context || {} if (limitLevel === "strict") { return new Promise(() => ElMessageBox({ - appendTo, boxType: 'alert', type: 'warning', title: '', - message: <div>{t(msg => msg.limit.verification.strictTip)}</div>, + message: <div>{t(msg => msg.verification.strictTip)}</div>, }).catch(() => { })) } let inputType: InputType | undefined @@ -108,22 +119,22 @@ export function processVerification(option: timer.option.LimitOption, context?: let countdown: number | undefined if (limitLevel === 'password' && limitPassword) { answerValue = limitPassword - messageNode = t(msg => msg.limit.verification.pswInputTip) - incorrectMessage = t(msg => msg.limit.verification.incorrectPsw) + messageNode = t(msg => msg.verification.pswInputTip) + incorrectMessage = t(msg => msg.verification.incorrectPsw) inputType = 'password' } else if (limitLevel === 'verification') { const pair = verificationProcessor.generate(limitVerifyDifficulty ?? 'easy', locale) - const { prompt, promptParam, answer, second = 60 } = pair || {} + const { prompt, promptParam, answer, second = 60 } = pair ?? {} countdown = second - answerValue = typeof answer === 'function' ? t(msg => answer(msg.limit.verification)) : answer - incorrectMessage = t(msg => msg.limit.verification.incorrectAnswer) + answerValue = typeof answer === 'function' ? t(msg => (answer as (msg: any) => string)(msg.verification)) : answer + incorrectMessage = t(msg => msg.verification.incorrectAnswer) if (prompt) { const promptTxt = typeof prompt === 'function' - ? t(msg => prompt(msg.limit.verification), { ...promptParam, answer: answerValue }) + ? t(msg => prompt(msg.verification), { ...promptParam, answer: answerValue }) : prompt - messageNode = tN(msg => msg.limit.verification.inputTip, { prompt: <b>{promptTxt}</b>, second }) + messageNode = tN(msg => msg.verification.inputTip, { prompt: <b>{promptTxt}</b>, second }) } else if (answerValue) { - messageNode = tN(msg => msg.limit.verification.inputTip2, { answer: <AnswerCanvas text={answerValue} />, second }) + messageNode = tN(msg => msg.verification.inputTip2, { answer: <AnswerCanvas text={answerValue} />, second }) } } if (!messageNode || !answerValue) return Promise.resolve() @@ -131,9 +142,8 @@ export function processVerification(option: timer.option.LimitOption, context?: const okBtnClz = `limit-confirm-btn-${useId().value}` const btnText = (leftSec: number) => `${okBtnTxt} (${leftSec})` - const okBtnTxt = t(msg => msg.button.okey) + const okBtnTxt = t_(buttonMessages, { key: msg => msg.okay }) const msgData = ElMessageBox({ - appendTo, autofocus: true, boxType: 'prompt', type: 'warning', @@ -146,18 +156,19 @@ export function processVerification(option: timer.option.LimitOption, context?: confirmButtonText: countdown ? btnText(countdown) : okBtnTxt, confirmButtonClass: okBtnClz, buttonSize: "small", + modalClass: MODAL_CLS, }) - let cleanCountdown = countdown ? useCountDown({ + const cleanCountdown = countdown ? useCountDown({ countdown, onComplete: () => { - const btn = (appendTo ?? document).querySelector(`.${okBtnClz}`) + const btn = document.querySelector(`.${okBtnClz}`) if (!btn) return - ElMessage.warning(t(msg => msg.limit.message.timeout)) + ElMessage.warning(t(msg => msg.message.timeout)) btn.remove() }, onTick: (val: number) => { - const btnSpan = (appendTo ?? document).querySelector(`.${okBtnClz} span`) + const btnSpan = document.querySelector(`.${okBtnClz} span`) if (!btnSpan) return btnSpan.textContent = btnText(Math.floor(val / 1000)) }, @@ -166,12 +177,12 @@ export function processVerification(option: timer.option.LimitOption, context?: return new Promise(resolve => { msgData.then(data => { // Double check - const btn = (appendTo ?? document).querySelector(`.${okBtnClz}`) + const btn = document.querySelector(`.${okBtnClz}`) if (!btn) return if (typeof data === 'string') return const { value } = data if (value === answerValue) return resolve() - ElMessage.error({ appendTo, message: incorrectMessage }) + ElMessage.error({ message: incorrectMessage, customClass: MSG_CLS }) }).catch(() => cleanCountdown?.()) }) } diff --git a/src/service/limit-service/verification/processor.ts b/src/pages/app/util/limit/processor.ts similarity index 82% rename from src/service/limit-service/verification/processor.ts rename to src/pages/app/util/limit/processor.ts index d2561e726..2546b110c 100644 --- a/src/service/limit-service/verification/processor.ts +++ b/src/pages/app/util/limit/processor.ts @@ -5,8 +5,8 @@ * https://opensource.org/licenses/MIT */ -import { type VerificationContext, type VerificationGenerator, type VerificationPair } from "./common" import { ALL_GENERATORS } from "./generator" +import type { VerificationContext, VerificationGenerator, VerificationPair } from "./types" class VerificationProcessor { generators: VerificationGenerator[] @@ -31,4 +31,6 @@ class VerificationProcessor { } } -export default new VerificationProcessor() \ No newline at end of file +const verificationProcessor = new VerificationProcessor() + +export default verificationProcessor \ No newline at end of file diff --git a/src/service/limit-service/verification/common.ts b/src/pages/app/util/limit/types.ts similarity index 99% rename from src/service/limit-service/verification/common.ts rename to src/pages/app/util/limit/types.ts index 22cf89669..6b527d549 100644 --- a/src/service/limit-service/verification/common.ts +++ b/src/pages/app/util/limit/types.ts @@ -4,7 +4,6 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ - import { type I18nKey } from "@i18n" import { type LimitMessage } from "@i18n/message/app/limit" diff --git a/src/pages/app/util/time.ts b/src/pages/app/util/time.ts index 2ae73b399..494297fec 100644 --- a/src/pages/app/util/time.ts +++ b/src/pages/app/util/time.ts @@ -5,8 +5,8 @@ * https://opensource.org/licenses/MIT */ -import { t } from "@app/locale" import { formatPeriodCommon, MILL_PER_MINUTE, MILL_PER_SECOND } from "@util/time" +import { t } from "../locale" /** * Convert {yyyy}{mm}{dd} to locale time @@ -59,4 +59,4 @@ export function periodFormatter(milliseconds: number | undefined | null, option? if (hideUnit) return val let unit = UNIT_MAP[format] return val + unit -} +} \ No newline at end of file diff --git a/src/pages/components/Img.tsx b/src/pages/components/Img.tsx new file mode 100644 index 000000000..811509d5d --- /dev/null +++ b/src/pages/components/Img.tsx @@ -0,0 +1,31 @@ +import { useState } from '@hooks' +import { type CSSProperties, defineComponent, toRef } from 'vue' + +type Props = Partial<Pick<HTMLImageElement, 'src' | 'alt' | 'title'>> & { + style?: CSSProperties + onError?: ArgCallback<Event> + size?: number +} + +const Img = defineComponent<Props>(props => { + const src = toRef(props, 'src') + const [imgErr, setImgErr] = useState(false) + const handleError = (event: Event) => { + setImgErr(true) + props?.onError?.(event) + } + + return () => !src.value || imgErr.value ? null : ( + <img + src={src.value} + alt={props.alt} + title={props.title} + onError={handleError} + width={props.size} + height={props.size} + style={props.style} + /> + ) +}, { props: ['src', 'alt', 'size', 'style', 'title', 'onError'] }) + +export default Img \ No newline at end of file diff --git a/src/pages/app/components/common/TooltipWrapper.tsx b/src/pages/components/TooltipWrapper.tsx similarity index 100% rename from src/pages/app/components/common/TooltipWrapper.tsx rename to src/pages/components/TooltipWrapper.tsx diff --git a/src/pages/element-ui/app.ts b/src/pages/element-ui/app.ts new file mode 100644 index 000000000..f33fb7fa0 --- /dev/null +++ b/src/pages/element-ui/app.ts @@ -0,0 +1,12 @@ +import { initElementLocale } from "@i18n/element" +import { ElConfigProvider, ElLoadingDirective } from "element-plus" +import { createApp, h, type App, type Component } from "vue" + +export async function createElApp(root: Component): Promise<App> { + const locale = await initElementLocale() + const app = createApp({ + render: () => h(ElConfigProvider, { locale }, () => h(root)), + }) + app.directive("loading", ElLoadingDirective) + return app +} diff --git a/src/pages/types.ts b/src/pages/element-ui/types.d.ts similarity index 100% rename from src/pages/types.ts rename to src/pages/element-ui/types.d.ts diff --git a/src/pages/hooks/index.ts b/src/pages/hooks/index.ts index d1a257934..058e62ccc 100644 --- a/src/pages/hooks/index.ts +++ b/src/pages/hooks/index.ts @@ -1,6 +1,9 @@ export * from "./useCached" +export * from "./useCount" export * from "./useDebounce" export * from "./useDocumentVisibility" +export * from "./useEcharts" +export * from "./useElementSize" export * from "./useLocalStorage" export * from "./useManualRequest" export * from "./useMediaSize" @@ -11,6 +14,6 @@ export * from "./useShadow" export * from "./useSiteMerge" export * from "./useState" export * from "./useSwitch" -export * from "./useWindowFocus" +export * from "./useTabGroups" +export * from "./useWindowListener" export * from "./useWindowSize" - diff --git a/src/pages/hooks/useCached.ts b/src/pages/hooks/useCached.ts index 7efbe831d..7e8b986bd 100644 --- a/src/pages/hooks/useCached.ts +++ b/src/pages/hooks/useCached.ts @@ -6,7 +6,7 @@ */ import { onBeforeMount, ref, type Ref, watch } from "vue" -import { useState } from "." +import { useState } from "./useState" const getInitialValue = <T>(key: string, defaultValue?: T): T | undefined => { if (!key) return defaultValue diff --git a/src/pages/hooks/useEcharts.ts b/src/pages/hooks/useEcharts.ts index 7a0261b29..aa47a1956 100644 --- a/src/pages/hooks/useEcharts.ts +++ b/src/pages/hooks/useEcharts.ts @@ -5,9 +5,9 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import optionHolder from "@service/components/option-holder" +import { getOption } from "@api/sw/option" import { processAnimation, processAria, processFont, processRtl } from "@util/echarts" -import { type AriaComponentOption, type ComposeOption, SeriesOption, TitleComponentOption } from "echarts" +import type { AriaComponentOption, ComposeOption, SeriesOption, TitleComponentOption } from "echarts" import { type ECharts, init } from "echarts/core" import { ElLoading } from "element-plus" import { type Ref, type WatchSource, isRef, onMounted, ref, watch } from "vue" @@ -30,7 +30,7 @@ export abstract class EchartsWrapper<BizOption, EchartsOption> { * true if need to clear all the before series when setOption */ protected replaceSeries: boolean = false - private lastBizOption: BizOption | undefined + protected lastBizOption: BizOption | undefined /** * Fix the font family * @see https://github.com/sheepzh/time-tracker-4-browser/issues/623 @@ -60,7 +60,7 @@ export abstract class EchartsWrapper<BizOption, EchartsOption> { } protected async postChartOption(option: EchartsOption & BaseEchartsOption) { - const { chartDecal, chartAnimationDuration } = await optionHolder.get() || {} + const { chartDecal, chartAnimationDuration } = await getOption() processAnimation(option, chartAnimationDuration) processAria(option, chartDecal) processRtl(option) diff --git a/src/pages/hooks/useLocalStorage.ts b/src/pages/hooks/useLocalStorage.ts index 2617886a8..49eb8944a 100644 --- a/src/pages/hooks/useLocalStorage.ts +++ b/src/pages/hooks/useLocalStorage.ts @@ -7,34 +7,27 @@ type StorageValue = | StorageArray | StorageObject -export function useLocalStorage<T>(key: string, defaultValue: T): [T, (val: T | undefined) => void] +export function useLocalStorage<T>(key: string, defaultValue: T): [T, ArgCallback<T>] export function useLocalStorage<T>(key: string): [T | undefined, (val: T | undefined) => void] - -export function useLocalStorage<T = StorageValue>(key: string, defaultVal?: T): [data: T | undefined, setter: (val: T | undefined) => void] { - const value = deserialize(localStorage.getItem(key), defaultVal) ?? defaultVal +export function useLocalStorage<T = StorageValue>(key: string, defaultVal?: T): [data: T | undefined, setter: ArgCallback<T | undefined>] { + const value: T | undefined = deserialize(localStorage.getItem(key)) ?? defaultVal const setter = (val: T | undefined) => { if (val === undefined) { - localStorage?.removeItem(key) + localStorage.removeItem(key) } else { - localStorage?.setItem(key, JSON.stringify(val)) + localStorage.setItem(key, JSON.stringify(val)) } } return [value, setter] } -function deserialize<T>(json: string | null, defaultVal?: T): T | undefined { +function deserialize<T>(json: string | null): T | undefined { if (!json) return undefined try { - const stored = JSON.parse(json) || {} - Object.entries(defaultVal || {}).forEach(([k, v]) => { - if (stored[k] === undefined || stored[k] === null) { - stored[k] = v - } - }) - return stored + return JSON.parse(json) as T } catch { return undefined } diff --git a/src/pages/hooks/useMediaSize.ts b/src/pages/hooks/useMediaSize.ts index d659fa971..b1e378270 100644 --- a/src/pages/hooks/useMediaSize.ts +++ b/src/pages/hooks/useMediaSize.ts @@ -1,5 +1,5 @@ -import { useWindowSize } from "@hooks" import { computed } from "vue" +import { useWindowSize } from "./useWindowSize" export enum MediaSize { xs, @@ -48,5 +48,10 @@ export const listenMediaSizeChange = () => { } processMediaSize() window.addEventListener('resize', processMediaSize) - window.addEventListener('unload', () => window.removeEventListener('resize', processMediaSize)) + window.addEventListener('pagehide', () => window.removeEventListener('resize', processMediaSize)) + window.addEventListener('pageshow', () => { + window.removeEventListener('resize', processMediaSize) + window.addEventListener('resize', processMediaSize) + processMediaSize() + }) } \ No newline at end of file diff --git a/src/pages/hooks/useRequest.ts b/src/pages/hooks/useRequest.ts index 7dee0a875..9badac709 100644 --- a/src/pages/hooks/useRequest.ts +++ b/src/pages/hooks/useRequest.ts @@ -11,7 +11,7 @@ export type RequestOption<T, P extends any[]> = { loadingText?: string defaultParam?: P deps?: WatchSource<unknown> | WatchSource<unknown>[] - onSuccess?: (result: T) => void, + onSuccess?: (result: T, ...p: P) => void, onError?: (e: unknown) => void } @@ -73,7 +73,7 @@ export function useRequest<P extends any[], T>( const value = await getter?.(...p) data.value = value ts.value = Date.now() - onSuccess?.(value) + onSuccess?.(value, ...p) } catch (e) { console.warn("Errored when requesting", e) onError?.(e) diff --git a/src/pages/hooks/useSiteMerge.ts b/src/pages/hooks/useSiteMerge.ts index 01576c287..8090c43d6 100644 --- a/src/pages/hooks/useSiteMerge.ts +++ b/src/pages/hooks/useSiteMerge.ts @@ -1,4 +1,4 @@ -import optionHolder from '@service/components/option-holder' +import { getOption } from '@api/sw/option' import { computed } from 'vue' import { useRequest } from './useRequest' @@ -7,7 +7,7 @@ type Options = { } export const useSiteMerge = ({ onGroupDisabled }: Options) => { - const { data: countTabGroup } = useRequest(() => optionHolder.get().then(o => o.countTabGroup), { + const { data: countTabGroup } = useRequest(() => getOption().then(o => o?.countTabGroup ?? false), { defaultValue: false, onSuccess: v => !v && onGroupDisabled?.() }) diff --git a/src/pages/hooks/useTabGroups.ts b/src/pages/hooks/useTabGroups.ts index 8907f250f..a1a0ffad3 100644 --- a/src/pages/hooks/useTabGroups.ts +++ b/src/pages/hooks/useTabGroups.ts @@ -1,6 +1,6 @@ import { listAllGroups, onChanged, removeChangedHandler } from "@api/chrome/tabGroups" import { computed, onMounted } from "vue" -import { useRequest } from "." +import { useRequest } from "./useRequest" export const useTabGroups = () => { const { data: groups, refresh } = useRequest(listAllGroups, { defaultValue: [] }) diff --git a/src/pages/hooks/useWindowFocus.ts b/src/pages/hooks/useWindowFocus.ts deleted file mode 100644 index bbc77d95a..000000000 --- a/src/pages/hooks/useWindowFocus.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { shallowRef, type ShallowRef } from 'vue' -import { useWindowListener } from './useWindowListener' - -export function useWindowFocus(): ShallowRef<boolean> { - if (typeof window === 'undefined') return shallowRef(false) - - const focused = shallowRef(window.document.hasFocus()) - - const options: AddEventListenerOptions = { passive: true } - - useWindowListener('focus', () => focused.value = true, options) - useWindowListener('blur', () => focused.value = false, options) - - return focused -} \ No newline at end of file diff --git a/src/pages/icons.tsx b/src/pages/icons.tsx new file mode 100644 index 000000000..050987570 --- /dev/null +++ b/src/pages/icons.tsx @@ -0,0 +1,52 @@ +import type { FunctionalComponent } from 'vue' + +type Icon = FunctionalComponent<{}> + +export const Coffee: Icon = () => ( + <svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"> + <path d="M742.954667 682.666667H554.666667a42.666667 42.666667 0 0 1 0-85.333334h195.413333l14.208-170.666666H259.712l29.013333 348.416A85.333333 85.333333 0 0 0 373.76 853.333333h276.48a85.333333 85.333333 0 0 0 85.034667-78.250666l7.68-92.416z m67.2-341.333334H853.333333V256h-122.965333a119.850667 119.850667 0 0 1-107.178667-66.261333 34.517333 34.517333 0 0 0-30.890666-19.072h-160.597334a34.517333 34.517333 0 0 0-30.890666 19.072A119.850667 119.850667 0 0 1 293.632 256H170.666667v85.333333H810.154667z m39.765333 85.333334l-29.610667 355.498666A170.666667 170.666667 0 0 1 650.24 938.666667H373.76a170.666667 170.666667 0 0 1-170.069333-156.501334L174.08 426.666667H170.666667a85.333333 85.333333 0 0 1-85.333334-85.333334V256a85.333333 85.333333 0 0 1 85.333334-85.333333h122.965333a34.517333 34.517333 0 0 0 30.890667-19.072A119.850667 119.850667 0 0 1 431.701333 85.333333h160.597334c45.397333 0 86.912 25.642667 107.178666 66.261334a34.517333 34.517333 0 0 0 30.890667 19.072H853.333333a85.333333 85.333333 0 0 1 85.333334 85.333333v85.333333a85.333333 85.333333 0 0 1-85.333334 85.333334h-3.413333z"></path> + </svg> +) + +export const GitHub: Icon = () => ( + <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'> + <path d='M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z' /> + </svg> +) + +export const Heart: Icon = () => ( + <svg viewBox="0 0 1024 1024"> + <path d="M1000 248Q976.992 192 933.984 148.992 849.984 64 732.992 64q-64 0-121.504 28T512 171.008q-42.016-51.008-99.488-79.008T291.008 64Q174.016 64 90.016 150.016 47.008 193.024 24 249.024-0.992 308.032 0 371.04q0.992 68.992 28.992 130.496t79.008 104.512q4.992 4 8.992 8 14.016 12 112.992 102.016 208 191.008 256.992 235.008 11.008 8.992 24.992 8.992t24.992-8.992q32.992-30.016 180.992-164.992 158.016-144 196-179.008 52-43.008 80.992-104.992t28.992-132q0-64-24-122.016z" /> + </svg> +) + +export const RoseChart: Icon = () => ( + <svg viewBox="0 0 1024 1024"> + <path d="M512 256a256 256 0 1 0 256 256 256 256 0 0 0-256-256z m0 384a128 128 0 1 1 128-128 128 128 0 0 1-128 128z" /> + <path d="M768 320l-153.6 115.84A128 128 0 0 1 640 512a128 128 0 0 1-37.44 90.56l135.68 135.68A320 320 0 0 0 768 320z" /> + <path d="M602.56 602.56a128 128 0 0 1-181.12 0l-181.12 181.12a384 384 0 0 0 544 0z" /> + <path d="M384 512a128 128 0 0 1 128-128V64a448 448 0 0 0-316.8 764.8l226.24-226.24A128 128 0 0 1 384 512z" /> + </svg> +) + +export const Trend: Icon = () => ( + <svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg"> + <path d="M99.760075 885.579417c-16.231683 0-29.42824-13.196557-29.42824-29.42824L70.331835 148.761092c0-16.238846 13.196557-29.441543 29.42824-29.441543 16.237823 0 29.440519 13.20372 29.440519 29.441543l0 677.955706 807.702746 0c16.23066 0 29.427216 13.202697 29.427216 29.434379s-13.196557 29.42824-29.427216 29.42824L99.760075 885.579417 99.760075 885.579417zM195.092303 726.586286c-6.382361 0-12.472056-2.029216-17.60803-5.867638-12.993942-9.730619-15.657608-28.214599-5.932106-41.208541l193.054901-257.921257c5.619998-7.498788 14.214746-11.799744 23.594371-11.799744 5.867638 0 11.544941 1.729387 16.413831 5.012154l173.612083 116.910687 305.917388-243.700371c5.266957-4.196579 11.597129-6.41613 18.313088-6.41613 9.025561 0 17.418718 4.040013 23.038716 11.095709 4.901637 6.141884 7.107885 13.829983 6.233981 21.648043-0.88209 7.806803-4.758374 14.809287-10.906397 19.717064l-322.80194 257.149685c-5.175883 4.13825-11.689227 6.40999-18.339694 6.40999-5.879917 0-11.565407-1.729387-16.439414-5.012154l-168.280658-113.320928L218.687698 714.77938C213.074863 722.285331 204.471928 726.586286 195.092303 726.586286L195.092303 726.586286z" /> + </svg> +) + +export const BarChart: Icon = () => ( + <svg viewBox="0 0 1024 1024"> + <g transform="matrix(-1 0 0 -1 1024 1024)"> + <g transform="matrix(0 1 -1 0 1024 -0)"> + <path d="M213.312 213.312v597.376h597.376V213.312H213.312z m0-85.312h597.376A85.312 85.312 0 0 1 896 213.312v597.376A85.376 85.376 0 0 1 810.688 896H213.312A85.376 85.376 0 0 1 128 810.688V213.312A85.312 85.312 0 0 1 213.312 128z m128 170.688h85.376a42.688 42.688 0 0 1 42.624 42.624v341.376a42.688 42.688 0 0 1-42.624 42.624H341.312a42.688 42.688 0 0 1-42.624-42.624V341.312a42.688 42.688 0 0 1 42.624-42.624z m256 0h85.376a42.688 42.688 0 0 1 42.624 42.624V512a42.688 42.688 0 0 1-42.624 42.688H597.312A42.688 42.688 0 0 1 554.688 512V341.312a42.688 42.688 0 0 1 42.624-42.624z" /> + </g> + </g> + </svg> +) + +export const HalfPieChart: Icon = () => ( + <svg viewBox="0 0 1365 1024"> + <path d="M1365.055916 682.737408A682.525185 682.525185 0 1 0 91.549237 1024h1181.962989A679.005914 679.005914 0 0 0 1365.055916 682.737408z" /> + </svg> +) diff --git a/src/pages/popup/common.tsx b/src/pages/popup/common.tsx index 9b7265a7c..e1029e19c 100644 --- a/src/pages/popup/common.tsx +++ b/src/pages/popup/common.tsx @@ -1,22 +1,20 @@ -import type { ReportQueryParam } from "@app/components/Report/types" -import { REPORT_ROUTE } from "@app/router/constants" -import weekHelper from "@service/components/week-helper" -import { selectCate, selectGroup, selectSite } from '@service/stat-service' +import { getWeekStartTime } from "@api/sw/option" +import { listCateStats, listGroupStats, listSiteStats } from '@api/sw/stat' +import { REPORT_ROUTE, type ReportQuery } from "@app/router/constants" +import type { PopupDuration, PopupMenu, PopupQuery } from "@popup/types" import { isRemainHost } from "@util/constant/remain-host" import { getAppPageUrl } from "@util/constant/url" import { isSite } from "@util/stat" -import { getMonthTime, MILL_PER_DAY } from "@util/time" -import { type PopupDuration, type PopupQuery } from "./context" +import { cvtDateRange2Str, getMonthTime, MILL_PER_DAY, type DateRange } from "@util/time" +import { createOptionalGuard, createStringUnionGuard } from 'typescript-guard' -type DateRange = Date | [Date, Date] | undefined - -type DateRangeCalculator = (now: Date, num?: number) => Awaitable<DateRange> +type DateRangeCalculator = (now: Date, num?: number) => Awaitable<[Date, Date] | Date | undefined> const DATE_RANGE_CALCULATORS: { [duration in PopupDuration]: DateRangeCalculator } = { today: now => now, yesterday: now => new Date(now.getTime() - MILL_PER_DAY), thisWeek: async now => { - const [start] = await weekHelper.getWeekDate(now) + const start = await getWeekStartTime(now) return [start, now] }, thisMonth: now => [getMonthTime(now)[0], now], @@ -24,27 +22,28 @@ const DATE_RANGE_CALCULATORS: { [duration in PopupDuration]: DateRangeCalculator allTime: () => undefined, } -export const queryRows = async (param: PopupQuery): Promise<[rows: timer.stat.Row[], date: DateRange]> => { +export const queryRows = async (param: PopupQuery): Promise<[rows: timer.stat.Row[], date: [Date, Date] | Date | undefined]> => { const { duration, durationNum, mergeMethod, dimension: sortKey } = param - const date = await DATE_RANGE_CALCULATORS[duration]?.(new Date(), durationNum) + const dateRange = await DATE_RANGE_CALCULATORS[duration]?.(new Date(), durationNum) + const date = cvtDateRange2Str(dateRange) const sortDirection: timer.common.SortDirection = 'DESC' let rows: timer.stat.Row[] if (mergeMethod === 'cate') { - rows = await selectCate({ date, mergeDate: true, sortKey, sortDirection }) + rows = await listCateStats({ date, mergeDate: true, sortKey, sortDirection }) } else if (mergeMethod === 'group') { - rows = await selectGroup({ date, mergeDate: true, sortKey, sortDirection }) + rows = await listGroupStats({ date, mergeDate: true, sortKey, sortDirection }) } else { - rows = await selectSite({ + rows = await listSiteStats({ date, mergeDate: true, mergeHost: mergeMethod === 'domain', sortKey, sortDirection, }) } - return [rows, date] + return [rows, dateRange] } -function buildReportQuery(siteType: timer.site.Type, date: Date | [Date, Date?] | undefined, type: timer.core.Dimension): ReportQueryParam { - const query: ReportQueryParam = {} +function buildReportQuery(siteType: timer.site.Type, date: DateRange | undefined, type: timer.core.Dimension): ReportQuery { + const query: ReportQuery = {} // Merge host siteType === 'merged' && (query.mm = 'domain') // Date @@ -66,7 +65,11 @@ function buildReportQuery(siteType: timer.site.Type, date: Date | [Date, Date?] return query } -export function calJumpUrl(row: timer.stat.Row | undefined, date: Date | [Date, Date?] | undefined, type: timer.core.Dimension): string | undefined { +export function calJumpUrl( + row: timer.stat.Row | undefined, + date: DateRange | undefined, + type: timer.core.Dimension, +): string | undefined { if (!row) return if (isSite(row)) { const { siteKey: { host, type: siteType } } = row @@ -80,3 +83,5 @@ export function calJumpUrl(row: timer.stat.Row | undefined, date: Date | [Date, return getAppPageUrl(REPORT_ROUTE, query) } } + +export const isMenu = createOptionalGuard(createStringUnionGuard<PopupMenu>('percentage', 'ranking', 'limit')) \ No newline at end of file diff --git a/src/pages/popup/components/Footer/DataToolbar.tsx b/src/pages/popup/components/Footer/DataToolbar.tsx new file mode 100644 index 000000000..fe9a653e8 --- /dev/null +++ b/src/pages/popup/components/Footer/DataToolbar.tsx @@ -0,0 +1,52 @@ +import { useSiteMerge } from '@hooks' +import Flex from '@pages/components/Flex' +import { useQuery } from '@popup/context' +import { t } from '@popup/locale' +import { ALL_DIMENSIONS } from '@util/stat' +import { ElSelect, ElText } from 'element-plus' +import { defineComponent } from 'vue' +import DurationSelect from './DurationSelect' + +const DataToolbar = defineComponent(() => { + const query = useQuery() + + const { mergeItems } = useSiteMerge({ + onGroupDisabled: () => query.mergeMethod === 'group' && (query.mergeMethod = undefined) + }) + + return () => ( + <Flex gap={8}> + <Flex gap={4}> + <ElText>{t(msg => msg.shared.merge.mergeBy)}</ElText> + <ElSelect + modelValue={query.mergeMethod} + onChange={v => query.mergeMethod = v ?? undefined} + placeholder={t(msg => msg.shared.merge.mergeMethod.notMerge)} + popperOptions={{ placement: 'top' }} + style={{ width: '90px' }} + options={[ + { value: '', label: t(msg => msg.shared.merge.mergeMethod.notMerge) }, + ...mergeItems.value.map(value => ({ value, label: t(msg => msg.shared.merge.mergeMethod[value]) })), + ]} + /> + </Flex> + <DurationSelect + reverse + modelValue={[query.duration, query.durationNum]} + onChange={([duration, durationNum]) => { + query.duration = duration + query.durationNum = durationNum + }} + /> + <ElSelect + modelValue={query.dimension} + onChange={v => query.dimension = v} + popperOptions={{ placement: 'top' }} + style={{ width: '120px' }} + options={ALL_DIMENSIONS.map(value => ({ value, label: t(msg => msg.item[value]) }))} + /> + </Flex> + ) +}) + +export default DataToolbar \ No newline at end of file diff --git a/src/pages/popup/components/Footer/DurationSelect.tsx b/src/pages/popup/components/Footer/DurationSelect.tsx index 10d3abcea..5d88bf826 100644 --- a/src/pages/popup/components/Footer/DurationSelect.tsx +++ b/src/pages/popup/components/Footer/DurationSelect.tsx @@ -1,11 +1,11 @@ import { css } from '@emotion/css' import { t } from "@i18n" import calendarMessages from "@i18n/message/common/calendar" -import { type PopupDuration } from '@popup/context' +import { type PopupDuration } from '@popup/types' import { type CascaderNode, type CascaderOption, ElCascader, useNamespace } from "element-plus" import { computed, defineComponent } from "vue" -export const rangeLabel = (duration: PopupDuration, n?: string | number): string => { +const rangeLabel = (duration: PopupDuration, n?: string | number): string => { return t(calendarMessages, { key: msg => msg.range[duration], param: n ? { n } : undefined, @@ -71,7 +71,7 @@ const DurationSelect = defineComponent<Props>(props => { const { label, value, level } = param?.node as CascaderNode || {} return level === 2 ? value : label }} - </ElCascader > + </ElCascader> ) }, { props: ['modelValue', 'onChange', 'reverse'] }) diff --git a/src/pages/popup/components/Footer/LimitToolbar.tsx b/src/pages/popup/components/Footer/LimitToolbar.tsx new file mode 100644 index 000000000..38bd6cd1b --- /dev/null +++ b/src/pages/popup/components/Footer/LimitToolbar.tsx @@ -0,0 +1,52 @@ +import { APP_LIMIT_ROUTE, AppLimitQuery } from '@/shared/route' +import { createTab } from '@api/chrome/tab' +import { Plus } from '@element-plus/icons-vue' +import Flex from '@pages/components/Flex' +import { useLimitSummary } from '@popup/context' +import { t } from '@popup/locale' +import { getAppPageUrl } from '@util/constant/url' +import { isBrowserUrl } from '@util/pattern' +import { ElButton, ElSelect } from 'element-plus' +import { computed, defineComponent, type StyleValue } from 'vue' + +const findHost = (url: string) => { + try { + return new URL(url).host + } catch { + return url + } +} + +const LimitToolbar = defineComponent(() => { + const { limitSummary, selectedLimit } = useLimitSummary() + const items = computed(() => limitSummary.value?.items || []) + + const handleNew = async () => { + let url = limitSummary.value?.url + const query: AppLimitQuery = { action: 'create' } + if (url && !isBrowserUrl(url)) { + const host = findHost(url) + query.url = encodeURIComponent(host) + } + await createTab(getAppPageUrl(APP_LIMIT_ROUTE, query)) + } + + return () => ( + <Flex gap={8} justify='end'> + {items.value.length ? ( + <ElSelect + modelValue={selectedLimit.value} + onChange={val => typeof val === 'number' && (selectedLimit.value = val)} + options={items.value.map(i => ({ value: i.id, label: i.name }))} + style={{ width: '140px' } satisfies StyleValue} + /> + ) : ( + <ElButton type='primary' icon={Plus} onClick={handleNew}> + {t(msg => msg.limit.newOne)} + </ElButton> + )} + </Flex> + ) +}) + +export default LimitToolbar \ No newline at end of file diff --git a/src/pages/popup/components/Footer/Menu.tsx b/src/pages/popup/components/Footer/Menu.tsx index c37e83c22..6cee6d51c 100644 --- a/src/pages/popup/components/Footer/Menu.tsx +++ b/src/pages/popup/components/Footer/Menu.tsx @@ -1,23 +1,42 @@ -import { t } from "@popup/locale" -import { POPUP_ROUTES } from "@popup/router" -import { ElRadioButton, ElRadioGroup } from "element-plus" -import { computed, defineComponent } from "vue" -import { useRoute, useRouter } from "vue-router" +import { Histogram, PieChart, Timer } from '@element-plus/icons-vue' +import { useMenu } from '@popup/context' +import { t } from '@popup/locale' +import type { PopupMenu } from '@popup/types' +import { ElIcon, ElRadioButton, ElRadioGroup, ElTooltip } from "element-plus" +import { type Component, defineComponent, h } from "vue" -const Menu = defineComponent(() => { - const route = useRoute() +type MenuItem = { + icon: Component + route: PopupMenu + label: string +} + +const createItems = (): MenuItem[] => [ + { + route: 'percentage', + label: t(msg => msg.footer.route.percentage), + icon: PieChart, + }, { + route: 'ranking', + label: t(msg => msg.footer.route.ranking), + icon: Histogram, + }, { + route: 'limit', + label: t(msg => msg.base.limit), + icon: Timer, + } +] as const - const current = computed(() => route.path?.substring?.(1)) +const Menu = defineComponent(() => { + const { menu, setMenu } = useMenu() - const router = useRouter() return () => ( - <ElRadioGroup - modelValue={current.value} - onChange={val => router.push('/' + val)} - > - {POPUP_ROUTES.map(route => ( + <ElRadioGroup modelValue={menu.value} onChange={v => setMenu(v as PopupMenu)}> + {createItems().map(({ route, label, icon }) => ( <ElRadioButton value={route}> - {t(msg => msg.footer.route[route])} + <ElTooltip content={label}> + <ElIcon>{h(icon)}</ElIcon> + </ElTooltip> </ElRadioButton> ))} </ElRadioGroup> diff --git a/src/pages/popup/components/Footer/index.tsx b/src/pages/popup/components/Footer/index.tsx index ae137e65f..3803d34c4 100644 --- a/src/pages/popup/components/Footer/index.tsx +++ b/src/pages/popup/components/Footer/index.tsx @@ -1,56 +1,22 @@ -import { useSiteMerge } from '@hooks/useSiteMerge' import Flex from "@pages/components/Flex" -import DurationSelect from "@popup/components/Footer/DurationSelect" -import { useQuery } from "@popup/context" -import { t } from "@popup/locale" -import { ALL_DIMENSIONS } from "@util/stat" -import { ElSelect, ElText } from "element-plus" -import { defineComponent } from "vue" +import { useMenu } from "@popup/context" +import { defineComponent, Transition } from "vue" +import DataToolbar from './DataToolbar' +import LimitToolbar from './LimitToolbar' import Menu from "./Menu" const Footer = defineComponent(() => { - const query = useQuery() - const { mergeItems } = useSiteMerge({ - onGroupDisabled: () => query.mergeMethod === 'group' && (query.mergeMethod = undefined) - }) + const { menu } = useMenu() return () => ( <Flex justify="space-between" width="100%"> <Flex> <Menu /> </Flex> - <Flex gap={8}> - <Flex gap={4}> - <ElText>{t(msg => msg.shared.merge.mergeBy)}</ElText> - <ElSelect - modelValue={query.mergeMethod} - onChange={v => query.mergeMethod = v ?? undefined} - placeholder={t(msg => msg.shared.merge.mergeMethod.notMerge)} - popperOptions={{ placement: 'top' }} - style={{ width: '90px' }} - options={[ - { value: '', label: t(msg => msg.shared.merge.mergeMethod.notMerge) }, - ...mergeItems.value.map(value => ({ value, label: t(msg => msg.shared.merge.mergeMethod[value]) })), - ]} - /> - </Flex> - <DurationSelect - reverse - modelValue={[query.duration, query.durationNum]} - onChange={([duration, durationNum]) => { - query.duration = duration - query.durationNum = durationNum - }} - /> - <ElSelect - modelValue={query.dimension} - onChange={v => query.dimension = v} - popperOptions={{ placement: 'top' }} - style={{ width: '120px' }} - options={ALL_DIMENSIONS.map(value => ({ value, label: t(msg => msg.item[value]) }))} - /> - </Flex> - </Flex > + <Transition name="el-fade-in" mode="out-in"> + {menu.value === 'limit' ? <LimitToolbar /> : <DataToolbar />} + </Transition> + </Flex> ) }) diff --git a/src/pages/popup/components/Header/DarkSwitch.tsx b/src/pages/popup/components/Header/DarkSwitch.tsx index ba9a26733..b0491a580 100644 --- a/src/pages/popup/components/Header/DarkSwitch.tsx +++ b/src/pages/popup/components/Header/DarkSwitch.tsx @@ -1,5 +1,5 @@ -import Flex from "@pages/components/Flex" import { usePopupContext } from "@popup/context" +import Flex from "@pages/components/Flex" import { ElIcon } from "element-plus" import { defineComponent } from "vue" diff --git a/src/pages/popup/components/Header/LangSelect.tsx b/src/pages/popup/components/Header/LangSelect.tsx index 8521f5c26..24686599e 100644 --- a/src/pages/popup/components/Header/LangSelect.tsx +++ b/src/pages/popup/components/Header/LangSelect.tsx @@ -1,14 +1,13 @@ import { createTab } from "@api/chrome/tab" -import { useManualRequest } from "@hooks/useManualRequest" -import { useRequest } from "@hooks/useRequest" +import { sendMsg2Runtime } from '@api/sw/common' +import { getOption } from "@api/sw/option" +import { useManualRequest, useRequest } from "@hooks" import { ALL_LOCALES, handleLocaleOption, localeSameAsBrowser, t } from "@i18n" import optionMessages from "@i18n/message/app/option" import localeMessages from "@i18n/message/common/locale" import Flex from "@pages/components/Flex" import { usePopupContext } from "@popup/context" -import { t as tPopup } from "@popup/locale" -import optionHolder from "@service/components/option-holder" -import optionService from "@service/option-service" +import { t as tPopup } from '@popup/locale' import { CROWDIN_HOMEPAGE } from "@util/constant/url" import { ElDropdown, ElDropdownItem, ElDropdownMenu, ElIcon, ElText } from "element-plus" import { defineComponent, type StyleValue } from "vue" @@ -23,15 +22,15 @@ const SELECTED_STYLES: StyleValue = { const LangSelect = defineComponent(() => { const { data: current } = useRequest(async () => { - const option = await optionHolder.get() + const option = await getOption() return option?.locale }) const { reload: reloadPopup } = usePopupContext() const { refresh: saveLocale } = useManualRequest( - async opt => { - await optionService.setLocale(opt) + async (opt: timer.option.LocaleOption) => { + await sendMsg2Runtime('option.set', { locale: opt }) handleLocaleOption(opt) }, { onSuccess: reloadPopup }, @@ -79,7 +78,7 @@ const LangSelect = defineComponent(() => { onClick={() => createTab(CROWDIN_HOMEPAGE)} divided > - {tPopup(msg => msg.menu.helpUs)} + {tPopup(msg => msg.base.helpUs)} </ElDropdownItem> </ElDropdownMenu> ) diff --git a/src/pages/popup/components/Header/Logo.tsx b/src/pages/popup/components/Header/Logo.tsx index 60b2cd2e7..56e46377b 100644 --- a/src/pages/popup/components/Header/Logo.tsx +++ b/src/pages/popup/components/Header/Logo.tsx @@ -1,14 +1,15 @@ +import packageInfo from "@/package" import { getIconUrl } from '@api/chrome/runtime' import Flex from "@pages/components/Flex" -import { t } from "@popup/locale" -import packageInfo from "@src/package" +import Img from '@pages/components/Img' +import { t } from '@popup/locale' import { ElText } from "element-plus" import type { FunctionalComponent } from "vue" const Logo: FunctionalComponent = () => ( <Flex height={30} gap={10}> <Flex gap={10} height="100%" align="center"> - <img src={getIconUrl()} style={{ height: '100%' }} /> + <Img src={getIconUrl()} style={{ height: '100%' }} /> <ElText size="large" tag="b" style={{ color: 'var(--el-text-color-primary)' }}> {t(msg => msg.meta.name)} </ElText> diff --git a/src/pages/popup/components/Header/MoreInfo.tsx b/src/pages/popup/components/Header/MoreInfo.tsx index 484a21639..61694d9cf 100644 --- a/src/pages/popup/components/Header/MoreInfo.tsx +++ b/src/pages/popup/components/Header/MoreInfo.tsx @@ -2,10 +2,10 @@ import { createTab } from "@api/chrome/tab" import { Collection, MoreFilled } from '@element-plus/icons-vue' import { locale } from '@i18n' import Flex from '@pages/components/Flex' -import { Coffee, GitHub, Heart } from '@pages/util/icon' +import { Coffee, GitHub, Heart } from '@pages/icons' +import { rateClicked } from '@pages/util/rate' import { getColor, type ColorVariant } from '@pages/util/style' import { t } from '@popup/locale' -import { saveFlag } from "@service/meta-service" import { BUY_ME_A_COFFEE_PAGE, CHANGE_LOG_PAGE, DONATION_PAGE, REVIEW_PAGE, SOURCE_CODE_PAGE } from "@util/constant/url" import { ElDropdown, ElDropdownItem, ElDropdownMenu, ElIcon } from "element-plus" import { defineComponent, type StyleValue } from "vue" @@ -28,7 +28,7 @@ const ItemLink = ({ icon, text, iconColor }: ItemLinkProps) => ( const MoreInfo = defineComponent<{}>(() => { const handleCmd = async (cmd: Command) => { if (cmd === 'rate') { - await saveFlag("rateOpen") + rateClicked() createTab(REVIEW_PAGE) } else if (cmd === 'coffee') { createTab(BUY_ME_A_COFFEE_PAGE) @@ -69,10 +69,10 @@ const MoreInfo = defineComponent<{}>(() => { <ItemLink icon={<Coffee />} text="Buy me a coffee" iconColor="warning" /> </ElDropdownItem> )} - </ElDropdownMenu > + </ElDropdownMenu> ) }}> - </ElDropdown > + </ElDropdown> ) }) diff --git a/src/pages/popup/components/Header/Option.tsx b/src/pages/popup/components/Header/Option.tsx index 2ebc77f9b..53d97f53f 100644 --- a/src/pages/popup/components/Header/Option.tsx +++ b/src/pages/popup/components/Header/Option.tsx @@ -1,12 +1,12 @@ +import { APP_LIMIT_ROUTE } from '@/shared/route' import { css } from '@emotion/css' -import { useSwitch } from '@hooks/useSwitch' +import { useSwitch } from '@hooks' import Flex from '@pages/components/Flex' -import { useOption } from '@popup/context' +import { useMenu, useOption } from '@popup/context' import { t, tN } from '@popup/locale' -import { ROUTE_PERCENTAGE } from '@popup/router' -import { ElCheckbox, ElIcon, ElInputNumber, ElPopover, ElText, useNamespace } from "element-plus" +import { getAppPageUrl } from '@util/constant/url' +import { ElCheckbox, ElIcon, ElInputNumber, ElLink, ElPopover, ElText, useNamespace } from "element-plus" import { computed, defineComponent, type StyleValue } from "vue" -import { useRoute } from 'vue-router' const reference = () => ( <ElIcon size="large" style={{ cursor: 'pointer' } satisfies StyleValue}> @@ -18,8 +18,8 @@ const reference = () => ( const Option = defineComponent(() => { const option = useOption() - const route = useRoute() - const isPercentage = computed(() => !!route.path?.endsWith(ROUTE_PERCENTAGE)) + const { menu } = useMenu() + const isPercentage = computed(() => menu.value === 'percentage') const toggleName = () => { option.showName = !option.showName @@ -47,7 +47,15 @@ const Option = defineComponent(() => { } ` - return () => ( + return () => menu.value === 'limit' ? ( + <ElLink + href={getAppPageUrl(APP_LIMIT_ROUTE)} + target='_blank' + style={{ '--el-link-text-color': 'unset', '--el-link-hover-text-color': 'unset' }} + > + {reference()} + </ElLink> + ) : ( <ElPopover visible={visible.value} placement='auto-end' diff --git a/src/pages/popup/components/Header/index.tsx b/src/pages/popup/components/Header/index.tsx index caaf3366a..e894e3191 100644 --- a/src/pages/popup/components/Header/index.tsx +++ b/src/pages/popup/components/Header/index.tsx @@ -1,13 +1,13 @@ -import { createTab, listTabs, updateTab } from "@api/chrome/tab" +import { t } from '@popup/locale' import { View } from "@element-plus/icons-vue" import Flex from "@pages/components/Flex" -import LangSelect from "@popup/components/Header/LangSelect" -import { t } from "@popup/locale" +import { createTab, listTabs, updateTab } from "@api/chrome/tab" import { IS_ANDROID } from "@util/constant/environment" import { getAppPageUrl } from "@util/constant/url" import { ElLink } from "element-plus" import type { FunctionalComponent } from "vue" import DarkSwitch from "./DarkSwitch" +import LangSelect from "./LangSelect" import Logo from "./Logo" import MoreInfo from './MoreInfo' import Option from "./Option" diff --git a/src/pages/popup/components/Limit/Content/Chart.tsx b/src/pages/popup/components/Limit/Content/Chart.tsx new file mode 100644 index 000000000..ac459d5aa --- /dev/null +++ b/src/pages/popup/components/Limit/Content/Chart.tsx @@ -0,0 +1,10 @@ +import { useEcharts } from '@hooks' +import { defineComponent, toRef } from 'vue' +import Wrapper from './Wrapper' + +const Chart = defineComponent<{ item: timer.limit.Item }>(props => { + const { elRef } = useEcharts(Wrapper, toRef(props, 'item')) + return () => <div ref={elRef} style={{ width: '100%', flex: 1 }} /> +}, { props: ['item'] }) + +export default Chart \ No newline at end of file diff --git a/src/pages/popup/components/Limit/Content/Wrapper.ts b/src/pages/popup/components/Limit/Content/Wrapper.ts new file mode 100644 index 000000000..ed158f1ff --- /dev/null +++ b/src/pages/popup/components/Limit/Content/Wrapper.ts @@ -0,0 +1,305 @@ +import { EchartsWrapper } from '@hooks' +import { getColor, getInfoColor, getRegularTextColor, getSecondaryTextColor } from '@pages/util/style' +import { t } from '@popup/locale' +import { clamp } from '@util/number' +import { formatPeriodCommon, MILL_PER_SECOND } from '@util/time' +import type { ComposeOption, GaugeSeriesOption, LegendComponentOption, PieSeriesOption, TitleComponentOption, TooltipComponentOption } from 'echarts' + +type EcOption = ComposeOption< + | LegendComponentOption + | TitleComponentOption + | GaugeSeriesOption + | PieSeriesOption + | TooltipComponentOption +> + +const MINUTES_PER_DAY = 24 * 60 +const GAUGE_BG_COLOR = 'rgba(0, 0, 0, 0.12)' + +const timeGaugeColor = () => getColor('primary') ?? '#409eff' +const visitGaugeColor = () => getColor('success') ?? '#67c23a' +const clockPointerColor = () => getColor('warning') ?? '#e6a23c' +const blockedPeriodColor = () => getColor('danger') ?? '#f56c6c' + +type LimitDimension = 'time' | 'visit' + +type ChartPart = { + titles: TitleComponentOption[] + series: (PieSeriesOption | GaugeSeriesOption)[] +} + +const createTitle = (text: string, left: string): TitleComponentOption => { + const textPrimary = getRegularTextColor() + return { + text, + left, + top: '13%', + textAlign: 'center', + width: 100, + textStyle: { color: textPrimary, fontSize: 16, fontWeight: 'bold' }, + } +} + +const createInfo = (text: string, left: string): TitleComponentOption => { + const textSecondary = getSecondaryTextColor() + return { + text, + left, + bottom: '20%', + textAlign: 'center', + subtext: '', + padding: 0, + itemGap: 0, + textStyle: { color: textSecondary, fontSize: 14, overflow: 'break', width: 120 }, + } +} + +type GaugeOptions = { + name: LimitDimension + center: string + usage: { + used: number + limit: number + } + color: string +} + +const createGauge = ({ name, center, usage: { used, limit }, color }: GaugeOptions): GaugeSeriesOption => { + const percent = limit ? clamp((used * 100) / limit, 0, 100) : 40 + return { + name, + type: 'gauge', + radius: '45%', + center: [center, '50%'], + startAngle: 210, + endAngle: -30, + min: 0, + max: 100, + splitLine: { show: false }, + axisTick: { show: false }, + axisLabel: { show: false }, + pointer: { show: false }, + detail: { + show: true, + offsetCenter: [0, 0], + fontSize: 13, + color: getSecondaryTextColor(), + width: 100, + overflow: 'break', + formatter: limit ? val => `${val.toFixed(1)}%` : () => t(msg => msg.limit.noLimit) + }, + silent: false, + axisLine: { lineStyle: { width: 8, color: [[1, GAUGE_BG_COLOR]] } }, + progress: { show: limit > 0, width: 8, itemStyle: { color, borderCap: 'round' } }, + data: [{ value: percent }], + } +} + +class Wrapper extends EchartsWrapper<timer.limit.Item, EcOption> { + private dimension: LimitDimension = 'time' + protected replaceSeries = true + + init(container: HTMLDivElement): void { + super.init(container) + this.instance?.on('legendselectchanged', params => { + const name = typeof params === 'object' && params && 'name' in params + ? String((params as { name?: string }).name ?? '') + : '' + const next = name === 'visit' ? 'visit' : 'time' + this.switchDimension(next) + }) + } + + async render(biz: timer.limit.Item) { + const firstRender = !this.lastBizOption + if (firstRender) { + const hasTimeLimit = !!biz.time || !!biz.weekly + const hasVisitLimit = !!biz.count || !!biz.weeklyCount + if (!hasTimeLimit && hasVisitLimit) this.dimension = 'visit' + } + await super.render(biz) + } + + private switchDimension(next: LimitDimension) { + if (this.dimension === next) return + this.dimension = next + this.lastBizOption && void this.render(this.lastBizOption) + } + + private builtLimitPart(time: [number, number | undefined], visit: [number, number | undefined], leftPos: string): ChartPart { + const [timeUsed, timeLimit] = time + const timeMax = timeLimit ? timeLimit * MILL_PER_SECOND : 0 + const timeUsedText = formatPeriodCommon(timeUsed, true) + const timeLabel = timeMax ? `${timeUsedText} / ${formatPeriodCommon(timeMax, true)}` : timeUsedText + const timeOpts: GaugeOptions = { + name: 'time', center: leftPos, + usage: { limit: timeMax, used: timeUsed }, + color: timeGaugeColor(), + } + + const [visitUsed, visitLimit] = visit + const visitLabel = t(msg => msg.shared.limit.visits, { n: visitLimit ? `${visitUsed}/${visitLimit}` : visitUsed }) + const visitOpts: GaugeOptions = { + name: 'visit', center: leftPos, + usage: { limit: visitLimit ?? 0, used: visitUsed ?? 0 }, + color: visitGaugeColor(), + } + + return { + titles: [ + createInfo(this.dimension === 'visit' ? visitLabel : timeLabel, leftPos), + ], + series: [createGauge(timeOpts), createGauge(visitOpts)], + } + } + + private buildDailyPart(biz: timer.limit.Item, leftPos: string): ChartPart { + const { time, waste, visit, count } = biz + const basePart = this.builtLimitPart([waste, time], [visit, count], leftPos) + basePart.titles.push(createTitle(t(msg => msg.shared.limit.daily), leftPos)) + return basePart + } + + private buildWeeklyPart(biz: timer.limit.Item, rightPos: string): ChartPart { + const { weekly = 0, weeklyWaste, weeklyVisit: weeklyVisitCount, weeklyCount = 0 } = biz + const basePart = this.builtLimitPart([weeklyWaste, weekly], [weeklyVisitCount, weeklyCount], rightPos) + basePart.titles.push(createTitle(t(msg => msg.shared.limit.weekly), rightPos)) + return basePart + } + + private buildPeriodPart(periods: timer.limit.Period[] | undefined, leftPos: string): ChartPart { + if (!periods?.length) return { titles: [], series: [] } + const now = new Date() + const nowMinutes = now.getHours() * 60 + now.getMinutes() + + const blocked = [...periods] + .map(([s, e]) => ({ start: clamp(s, 0, MINUTES_PER_DAY), end: clamp(e, 0, MINUTES_PER_DAY) })) + .filter(({ start, end }) => start < end) + .sort((a, b) => a.start - b.start) + + const fmt = (m: number) => + `${String(Math.floor(m / 60)).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}` + + // Gauge axisLine color segments (cumulative ratios 0→1) + const gaugeColors: [number, string][] = [] + let last = 0 + for (const { start, end } of blocked) { + if (start > last) gaugeColors.push([start / MINUTES_PER_DAY, GAUGE_BG_COLOR]) + gaugeColors.push([end / MINUTES_PER_DAY, blockedPeriodColor()]) + last = end + } + if (last < MINUTES_PER_DAY) gaugeColors.push([1, GAUGE_BG_COLOR]) + + // Transparent pie as tooltip event layer (invisible, same ring area) + type PieItem = { value: number; name: string; itemStyle: { color: string } } + const firstStart = blocked[0].start + const lastEnd = blocked[blocked.length - 1].end + const startAngle = 90 - (lastEnd / MINUTES_PER_DAY) * 360 + const pieData: PieItem[] = [ + { value: MINUTES_PER_DAY - lastEnd + firstStart, name: '', itemStyle: { color: 'rgba(0,0,0,0)' } }, + ] + for (let i = 0; i < blocked.length; i++) { + const { start, end } = blocked[i] + pieData.push({ value: end - start, name: `${fmt(start)} - ${fmt(end)}`, itemStyle: { color: 'rgba(0,0,0,0)' } }) + const nextStart = blocked[i + 1]?.start + if (nextStart !== undefined && nextStart > end) { + pieData.push({ value: nextStart - end, name: '', itemStyle: { color: 'rgba(0,0,0,0)' } }) + } + } + + const hitPeriod = blocked.find(({ start, end }) => nowMinutes >= start && nowMinutes < end) + const infoText = hitPeriod + ? `${fmt(hitPeriod.start)} - ${fmt(hitPeriod.end)}` + : t(msg => msg.limit.notHit) + + return { + titles: [ + createTitle(t(msg => msg.shared.limit.period), leftPos), + createInfo(infoText, leftPos), + ], + series: [ + { + type: 'gauge', + center: [leftPos, '50%'], + radius: '45%', + startAngle: 90, + endAngle: -270, + min: 0, + max: MINUTES_PER_DAY, + splitNumber: 4, + + axisTick: { + splitNumber: 6, + length: 3, + lineStyle: { color: getSecondaryTextColor(), width: 1 }, + }, + axisLabel: { + distance: 10, + fontSize: 11, + color: getSecondaryTextColor(), + formatter: (val: number) => val === MINUTES_PER_DAY ? '' : String(val / 60), + }, + axisLine: { lineStyle: { width: 8, color: gaugeColors } }, + pointer: { + length: '50%', + width: 2, + itemStyle: { color: clockPointerColor() }, + }, + detail: { show: false }, + data: [{ value: nowMinutes }], + silent: true, + } as GaugeSeriesOption, + { + type: 'pie', + center: [leftPos, '50%'], + radius: ['37%', '45%'], + startAngle, + label: { show: false }, + emphasis: { scale: false }, + itemStyle: { borderWidth: 0 }, + tooltip: { formatter: (params: any) => (params as { name: string }).name || null }, + data: pieData.filter(d => d.value > 0), + } as PieSeriesOption, + ], + } + } + + protected generateOption(biz: timer.limit.Item): Awaitable<EcOption> { + const hasPeriods = !!biz.periods?.length + const leftPos = hasPeriods ? '17.5%' : '32%' + const centerPos = hasPeriods ? '50%' : undefined + const rightPos = hasPeriods ? '82.5%' : '68%' + + const period = this.buildPeriodPart(biz.periods, leftPos) + const daily = this.buildDailyPart(biz, centerPos ?? leftPos) + const weekly = this.buildWeeklyPart(biz, rightPos) + + const inactiveColor = getInfoColor() + + return { + title: [...period.titles, ...daily.titles, ...weekly.titles], + tooltip: { show: true }, + legend: { + orient: 'horizontal', + bottom: '2%', + left: 'center', + icon: 'roundRect', + itemWidth: 20, + itemHeight: 12, + itemGap: 16, + selectedMode: 'single', + inactiveColor, + formatter: () => '', + tooltip: { show: false }, + selected: { time: this.dimension === 'time', visit: this.dimension === 'visit' }, + data: [ + { name: 'time' satisfies LimitDimension, itemStyle: { color: timeGaugeColor() } }, + { name: 'visit' satisfies LimitDimension, itemStyle: { color: visitGaugeColor() } }, + ], + }, + series: [...period.series, ...daily.series, ...weekly.series], + } + } +} + +export default Wrapper \ No newline at end of file diff --git a/src/pages/popup/components/Limit/Content/index.tsx b/src/pages/popup/components/Limit/Content/index.tsx new file mode 100644 index 000000000..bd1f61b25 --- /dev/null +++ b/src/pages/popup/components/Limit/Content/index.tsx @@ -0,0 +1,24 @@ +import Flex from '@pages/components/Flex' +import { useLimitSummary } from '@popup/context' +import { t } from '@popup/locale' +import { ElResult } from 'element-plus' +import { computed, defineComponent, type FunctionalComponent } from 'vue' +import Chart from './Chart' + +const Empty: FunctionalComponent<{}> = () => ( + <Flex column align="center" justify="center" height='100%' gap={20}> + <ElResult + icon='info' + title={t(msg => msg.limit.noData)} + /> + </Flex> +) + +const Content = defineComponent<{}>(() => { + const { limitSummary: summary, selectedLimit } = useLimitSummary() + const item = computed(() => summary.value?.items.find(i => i.id === selectedLimit.value)) + + return () => item.value ? <Chart item={item.value} /> : <Empty /> +}) + +export default Content \ No newline at end of file diff --git a/src/pages/popup/components/Limit/Summary.tsx b/src/pages/popup/components/Limit/Summary.tsx new file mode 100644 index 000000000..227442ceb --- /dev/null +++ b/src/pages/popup/components/Limit/Summary.tsx @@ -0,0 +1,54 @@ +import Flex from '@pages/components/Flex' +import Img from '@pages/components/Img' +import { useLimitSummary } from '@popup/context' +import { ElText } from 'element-plus' +import { computed, defineComponent, StyleValue } from 'vue' + +const TITLE_SIZE = 24 + +const Summary = defineComponent<{}>(() => { + const { limitSummary: summary } = useLimitSummary() + const site = computed(() => { + const site = summary.value?.site + if (!site) return '' + const { alias, host } = site + return alias ? `${alias} (${host})` : host + }) + + const withoutSearch = computed(() => { + const original = summary.value?.url + if (!original) return undefined + try { + const u = new URL(original) + return u.origin + u.pathname + } catch { + return original + } + }) + + return () => ( + <Flex + column justify='center' align='center' gap={10} + style={{ marginTop: '20px', marginBottom: '20px' }} + > + <Flex align='center' justify='center' gap={12}> + <Img src={summary.value?.site.iconUrl} size={TITLE_SIZE} /> + <ElText style={{ fontSize: `${TITLE_SIZE}px` } satisfies StyleValue} > + {site.value} + </ElText> + </Flex> + <ElText + size='large' + style={{ + wordBreak: 'break-all', + textAlign: 'center', + lineHeight: '1.4em', + } satisfies StyleValue} + > + {withoutSearch.value} + </ElText> + </Flex> + ) +}) + +export default Summary \ No newline at end of file diff --git a/src/pages/popup/components/Limit/index.tsx b/src/pages/popup/components/Limit/index.tsx new file mode 100644 index 000000000..ef30f9c8d --- /dev/null +++ b/src/pages/popup/components/Limit/index.tsx @@ -0,0 +1,18 @@ +import Flex from '@pages/components/Flex' +import { ElCard } from 'element-plus' +import type { FunctionalComponent, StyleValue } from 'vue' +import Content from './Content' +import Summary from './Summary' + +const Limit: FunctionalComponent<{}> = () => ( + <ElCard style={{ width: '100%' } satisfies StyleValue}> + <Flex column width='100%' height='100%'> + <Summary /> + <Content /> + </Flex> + </ElCard> +) + +Limit.displayName = 'Limit' + +export default Limit diff --git a/src/pages/popup/components/Percentage/Cate/Wrapper.ts b/src/pages/popup/components/Percentage/Cate/Wrapper.ts index 05cc3cecf..626c07ffe 100644 --- a/src/pages/popup/components/Percentage/Cate/Wrapper.ts +++ b/src/pages/popup/components/Percentage/Cate/Wrapper.ts @@ -1,19 +1,15 @@ -import { EchartsWrapper } from "@hooks/useEcharts" +import { listAllCategories } from "@api/sw/cate" +import { EchartsWrapper } from '@hooks' import { getInfoColor, getPrimaryTextColor } from "@pages/util/style" -import { t } from "@popup/locale" -import cateService from "@service/cate-service" -import { mergeDate } from "@service/stat-service/merge/date" +import { t } from '@popup/locale' import { toMap } from "@util/array" import { CATE_NOT_SET_ID } from "@util/site" import { isCate } from "@util/stat" -import { type PieSeriesOption } from "echarts/charts" -import { - type LegendComponentOption, - type TitleComponentOption, - type ToolboxComponentOption, - type TooltipComponentOption, -} from "echarts/components" -import { type ComposeOption, type ECElementEvent } from "echarts/core" +import type { + ComposeOption, LegendComponentOption, PieSeriesOption, TitleComponentOption, ToolboxComponentOption, + TooltipComponentOption, +} from "echarts" +import type { ECElementEvent } from "echarts/core" import { adaptDonutSeries, formatTooltip, generateSiteSeriesOption, generateTitleOption, generateToolboxOption, handleClick, isOther, type PieSeriesItemOption, @@ -94,7 +90,7 @@ export default class SiteWrapper extends EchartsWrapper<PercentageResult, EcOpti const textColor = getPrimaryTextColor() const inactiveColor = getInfoColor() - const cates = await cateService.listAll() + const cates = await listAllCategories() const cateNameMap = toMap(cates, c => c.id, c => c.name) cateNameMap[CATE_NOT_SET_ID] = t(msg => msg.shared.cate.notSet) @@ -107,7 +103,7 @@ export default class SiteWrapper extends EchartsWrapper<PercentageResult, EcOpti textStyle: { color: textColor }, pageTextStyle: { color: textColor }, inactiveColor, - data: rows.filter(isCate).map(({ cateKey }) => ({ name: cateNameMap[cateKey] ?? `${cateKey}`, cateKey })), + data: rows.filter(isCate).map(({ cateKey }) => ({ name: String(cateNameMap[cateKey] ?? cateKey), cateKey })), } const series: PieSeriesOption[] = [{ @@ -119,7 +115,7 @@ export default class SiteWrapper extends EchartsWrapper<PercentageResult, EcOpti data: rows.filter(isCate).map(row => ({ value: row[dimension], row, selected: row.cateKey === selected?.cateKey, - name: cateNameMap[row.cateKey ?? ''], + name: String(cateNameMap[row.cateKey ?? ''] ?? ''), } satisfies PieSeriesItemOption)), emphasis: { itemStyle: { @@ -139,8 +135,7 @@ export default class SiteWrapper extends EchartsWrapper<PercentageResult, EcOpti ...adaptDonutSeries(donutChart, selected ? '30%' : '55%', selected ? 0.3 : undefined), }] if (selected) { - let mergedRows = (selected?.mergedRows || []).sort((a, b) => (b[dimension] ?? 0) - (a[dimension] ?? 0)) - mergedRows = mergeDate(mergedRows) + let mergedRows = (selected.mergedRows ?? []).sort((a, b) => b[dimension] - a[dimension]) const siteSeries = generateSiteSeriesOption(mergedRows, result, { center: ['60%', '58%'], @@ -163,7 +158,7 @@ export default class SiteWrapper extends EchartsWrapper<PercentageResult, EcOpti series.push(siteSeries) } - const titleSuffix = this.selectedCache && this.selectedCache !== CATE_NOT_SET_ID ? cateNameMap[this.selectedCache] : undefined + const titleSuffix: string | undefined = this.selectedCache && this.selectedCache !== CATE_NOT_SET_ID ? cateNameMap[this.selectedCache] : undefined const option: EcOption = { title: generateTitleOption(result, titleSuffix), legend, diff --git a/src/pages/popup/components/Percentage/Cate/index.tsx b/src/pages/popup/components/Percentage/Cate/index.tsx index cf3e31880..b03a78dc5 100644 --- a/src/pages/popup/components/Percentage/Cate/index.tsx +++ b/src/pages/popup/components/Percentage/Cate/index.tsx @@ -1,5 +1,5 @@ -import { useEcharts } from "@hooks/useEcharts" import { usePopupContext } from "@popup/context" +import { useEcharts } from '@hooks' import { defineComponent, toRef } from "vue" import { type PercentageResult } from "../query" import Wrapper from "./Wrapper" diff --git a/src/pages/popup/components/Percentage/Site/Wrapper.ts b/src/pages/popup/components/Percentage/Site/Wrapper.ts index c7676efed..3d993076e 100644 --- a/src/pages/popup/components/Percentage/Site/Wrapper.ts +++ b/src/pages/popup/components/Percentage/Site/Wrapper.ts @@ -1,13 +1,10 @@ -import { EchartsWrapper } from "@hooks/useEcharts" +import { EchartsWrapper } from '@hooks' import { getInfoColor, getPrimaryTextColor } from "@pages/util/style" -import { type PieSeriesOption } from "echarts/charts" -import { - type LegendComponentOption, - type TitleComponentOption, - type ToolboxComponentOption, - type TooltipComponentOption, -} from "echarts/components" -import { type ComposeOption, type ECElementEvent } from "echarts/core" +import type { + ComposeOption, LegendComponentOption, PieSeriesOption, TitleComponentOption, ToolboxComponentOption, + TooltipComponentOption, +} from "echarts" +import type { ECElementEvent } from "echarts/core" import type { TopLevelFormatterParams } from "echarts/types/dist/shared" import { formatTooltip, generateSiteSeriesOption, generateTitleOption, generateToolboxOption, handleClick, @@ -58,7 +55,7 @@ export default class SiteWrapper extends EchartsWrapper<PercentageResult, EcOpti }) } - protected generateOption(result: PercentageResult): EcOption | Promise<EcOption> { + protected generateOption(result: PercentageResult): Awaitable<EcOption> { this.resultCache = result if (!result) return {} diff --git a/src/pages/popup/components/Percentage/Site/index.tsx b/src/pages/popup/components/Percentage/Site/index.tsx index 565cd9f41..5db032974 100644 --- a/src/pages/popup/components/Percentage/Site/index.tsx +++ b/src/pages/popup/components/Percentage/Site/index.tsx @@ -1,5 +1,5 @@ -import { useEcharts } from "@hooks/useEcharts" import { usePopupContext } from "@popup/context" +import { useEcharts } from '@hooks' import { defineComponent, toRef } from "vue" import { type PercentageResult } from "../query" import Wrapper from "./Wrapper" diff --git a/src/pages/popup/components/Percentage/chart.ts b/src/pages/popup/components/Percentage/chart.ts index c0db8ffd0..6adefaeaa 100644 --- a/src/pages/popup/components/Percentage/chart.ts +++ b/src/pages/popup/components/Percentage/chart.ts @@ -1,16 +1,15 @@ import { createTab } from "@api/chrome/tab" import { getCssVariable, getInfoColor, getPrimaryTextColor, getSecondaryTextColor } from "@pages/util/style" import { calJumpUrl } from "@popup/common" -import { t } from "@popup/locale" +import { t } from '@popup/locale' import { sum, toMap } from "@util/array" import { IS_SAFARI } from "@util/constant/environment" import { isRtl } from "@util/document" import { generateSiteLabel } from "@util/site" import { getGroupName, isGroup, isSite } from "@util/stat" import { formatPeriodCommon, formatTime, parseTime } from "@util/time" -import { type PieSeriesOption } from "echarts/charts" -import { type TitleComponentOption, type ToolboxComponentOption } from "echarts/components" -import { type CallbackDataParams, type TopLevelFormatterParams } from "echarts/types/dist/shared" +import type { PieSeriesOption, TitleComponentOption, ToolboxComponentOption } from "echarts" +import type { CallbackDataParams, TopLevelFormatterParams } from "echarts/types/dist/shared" import { type PercentageResult } from "./query" function combineDate(start: Date, end: Date, format: string): string { diff --git a/src/pages/popup/components/Percentage/index.tsx b/src/pages/popup/components/Percentage/index.tsx index d7b0fa614..c2c8edd3a 100644 --- a/src/pages/popup/components/Percentage/index.tsx +++ b/src/pages/popup/components/Percentage/index.tsx @@ -1,17 +1,11 @@ -import { useRequest } from "@hooks/useRequest" +import { useRequest } from "@hooks" import { useOption, useQuery } from "@popup/context" -import { PieChart } from "echarts/charts" -import { AriaComponent, LegendComponent, TitleComponent, ToolboxComponent, TooltipComponent } from "echarts/components" -import { use } from "echarts/core" -import { CanvasRenderer } from "echarts/renderers" import { ElCard } from "element-plus" import { defineComponent } from "vue" import Cate from "./Cate" import { doQuery } from "./query" import Site from "./Site" -use([CanvasRenderer, PieChart, AriaComponent, LegendComponent, TitleComponent, TooltipComponent, ToolboxComponent]) - const Percentage = defineComponent(() => { const query = useQuery() const option = useOption() diff --git a/src/pages/popup/components/Percentage/query.ts b/src/pages/popup/components/Percentage/query.ts index 2b1a05649..c2ffe612b 100644 --- a/src/pages/popup/components/Percentage/query.ts +++ b/src/pages/popup/components/Percentage/query.ts @@ -1,8 +1,8 @@ -import { listAllGroups } from "@api/chrome/tabGroups" import { queryRows, } from "@popup/common" -import type { PopupOption, PopupQuery } from "@popup/context" -import { t } from "@popup/locale" -import { getDayLength } from "@util/time" +import { t } from '@popup/locale' +import type { PopupOption, PopupQuery } from "@popup/types" +import { listAllGroups } from "@api/chrome/tabGroups" +import { getBirthday, getDayLength } from "@util/time" export type PercentageResult = { query: PopupQuery @@ -59,7 +59,7 @@ export const doQuery = async (query: PopupQuery, option: PopupOption): Promise<P dates.forEach(d => allDatesSet.add(d)) }) const dateLength = allDatesSet.size > 0 ? allDatesSet.size - : (Array.isArray(date) ? getDayLength(date[0], date[1] ?? new Date()) : 1) + : (Array.isArray(date) ? getDayLength(date[0] ?? getBirthday(), date[1] ?? new Date()) : 1) return { query, rows, diff --git a/src/pages/popup/components/Ranking/Item.tsx b/src/pages/popup/components/Ranking/Item.tsx index 3b9f35c08..5993ec277 100644 --- a/src/pages/popup/components/Ranking/Item.tsx +++ b/src/pages/popup/components/Ranking/Item.tsx @@ -1,15 +1,15 @@ import { createTab } from "@api/chrome/tab" -import { cvtGroupColor } from "@api/chrome/tabGroups" -import TooltipWrapper from "@app/components/common/TooltipWrapper" import { Mouse, Timer } from "@element-plus/icons-vue" -import { useTabGroups } from "@hooks/useTabGroups" +import { useTabGroups } from "@hooks" import Flex from "@pages/components/Flex" +import TooltipWrapper from '@pages/components/TooltipWrapper' +import { cvtGroupColor } from '@pages/util/style' import { calJumpUrl } from "@popup/common" import { useCateNameMap, useQuery } from "@popup/context" -import { t } from "@popup/locale" +import { t } from '@popup/locale' import { isRemainHost } from "@util/constant/remain-host" import { getGroupName, getIconUrl, isCate, isGroup, isNormalSite, isSite } from "@util/stat" -import { formatPeriodCommon } from "@util/time" +import { DateRange, formatPeriodCommon } from "@util/time" import { ElAvatar, ElCard, ElIcon, ElLink, ElProgress, ElTag, ElText } from "element-plus" import { computed, defineComponent, type StyleValue } from "vue" @@ -82,7 +82,7 @@ type ItemProps = { value: timer.stat.Row max?: number total?: number - date?: Date | [start: Date, end?: Date] + date?: DateRange displaySiteName?: boolean onJump?: NoArgCallback } @@ -171,7 +171,7 @@ const Item = defineComponent<ItemProps>(props => { </Flex> </Flex> </Flex> - </ElCard > + </ElCard> ) }, { props: ['date', 'displaySiteName', 'max', 'total', 'value', 'onJump'] }) diff --git a/src/pages/popup/components/Ranking/index.tsx b/src/pages/popup/components/Ranking/index.tsx index 6d728aa85..81d734b4e 100644 --- a/src/pages/popup/components/Ranking/index.tsx +++ b/src/pages/popup/components/Ranking/index.tsx @@ -1,5 +1,5 @@ -import { useRequest } from "@hooks/useRequest" import { useOption, useQuery } from "@popup/context" +import { useRequest } from "@hooks" import { ElCol, ElRow, ElScrollbar } from "element-plus" import { defineComponent } from "vue" import Item from "./Item" diff --git a/src/pages/popup/components/Ranking/query.tsx b/src/pages/popup/components/Ranking/query.tsx index a644429b7..afd3c24ac 100644 --- a/src/pages/popup/components/Ranking/query.tsx +++ b/src/pages/popup/components/Ranking/query.tsx @@ -1,13 +1,14 @@ import { queryRows } from "@popup/common" -import type { PopupOption, PopupQuery } from "@popup/context" +import type { PopupOption, PopupQuery } from "@popup/types" import { sum } from "@util/array" +import { type DateRange } from '@util/time' -export type RankingResult = { +type RankingResult = { rows: timer.stat.Row[] max: number total: number displaySiteName: boolean - date: Date | [Date, Date?] | undefined + date: DateRange } export const doQuery = async (query: PopupQuery, option: PopupOption): Promise<RankingResult> => { diff --git a/src/pages/popup/context.ts b/src/pages/popup/context.ts index 70cd3decd..20e0d33cd 100644 --- a/src/pages/popup/context.ts +++ b/src/pages/popup/context.ts @@ -1,64 +1,92 @@ +import { listAllCategories } from '@api/sw/cate' +import { getLimitSummary } from '@api/sw/limit' +import { getOption, setOption } from "@api/sw/option" import { useLocalStorage, useProvide, useProvider, useRequest } from "@hooks" -import cateService from "@service/cate-service" -import optionService from "@service/option-service" +import { isDarkMode, processDarkMode } from "@pages/util/dark-mode" import { toMap } from "@util/array" -import { isDarkMode, toggle } from "@util/dark-mode" import { CATE_NOT_SET_ID } from "@util/site" -import { reactive, type Reactive, ref, type Ref, toRaw, watch } from "vue" +import { computed, reactive, Ref, ref, type ShallowRef, toRaw, watch } from "vue" +import { useRoute, useRouter } from 'vue-router' +import { isMenu } from './common' import { t } from "./locale" - -export type PopupDuration = - | "today" | "yesterday" | "thisWeek" | "thisMonth" - | "lastDays" - | "allTime" - -export type PopupQuery = { - mergeMethod: Exclude<timer.stat.MergeMethod, 'date'> | undefined - duration: PopupDuration - durationNum?: number - dimension: Exclude<timer.core.Dimension, 'run'> -} - -export type PopupOption = { - showName: boolean - topN: number - donutChart: boolean -} +import type { PopupMenu, PopupOption, PopupQuery } from './types' type PopupContextValue = { reload: () => void - darkMode: Ref<boolean> - setDarkMode: (val: boolean) => void - query: Reactive<PopupQuery> - option: Reactive<PopupOption> - cateNameMap: Ref<Record<number, string>> + darkMode: ShallowRef<boolean> + setDarkMode: ArgCallback<boolean> + query: PopupQuery + option: PopupOption + cateNameMap: ShallowRef<Record<number, string>> + menu: ShallowRef<PopupMenu | undefined> + setMenu: ArgCallback<PopupMenu> + limitSummary: ShallowRef<timer.limit.Summary | undefined> + selectedLimit: Ref<number | undefined> +} + +const initMenu = () => { + const [stored, setStored] = useLocalStorage<PopupMenu>('popup_menu', 'percentage') + const route = useRoute() + const router = useRouter() + const myRoute = computed(() => { + const menuMaybe = route.path.substring(1) + return isMenu(menuMaybe) ? menuMaybe : stored + }) + const setMyRoute = (val: PopupMenu) => { + setStored(val) + router.push('/' + val) + } + return [myRoute, setMyRoute] as const } const NAMESPACE = '_' -export const initPopupContext = (): Ref<number> => { +export const initPopupContext = (): ShallowRef<number> => { const appKey = ref(Date.now()) const reload = () => appKey.value = Date.now() - const { data: darkMode, refresh: refreshDarkMode } = useRequest(() => optionService.isDarkMode(), { defaultValue: isDarkMode() }) + const { data: darkMode, refresh: refreshDarkMode } = useRequest(async () => { + const option = await getOption() + return processDarkMode(option) + }, { defaultValue: isDarkMode() }) const setDarkMode = async (val: boolean) => { - const option: timer.option.DarkMode = val ? 'on' : 'off' - await optionService.setDarkMode(option) - toggle(val) + await setOption({ darkMode: val ? 'on' : 'off' }) refreshDarkMode() } const { data: cateNameMap } = useRequest(async () => { - const categories = await cateService.listAll() - const result = toMap(categories ?? [], c => c.id, c => c.name) + const categories = await listAllCategories() + const result = toMap(categories, c => c.id, c => c.name) result[CATE_NOT_SET_ID] = t(msg => msg.shared.cate.notSet) return result }, { defaultValue: {} }) const query = initQuery() const option = initOption() - useProvide<PopupContextValue>(NAMESPACE, { reload, darkMode, setDarkMode, query, option, cateNameMap }) + + const [menu, setMenu] = initMenu() + + const { data: limitSummary } = useRequest( + () => menu.value === 'limit' ? getLimitSummary() : Promise.resolve(undefined), + { + deps: menu, + onSuccess(newVal) { + const newItems = newVal?.items ?? [] + if (!newItems.some(i => i.id === selectedLimit.value)) { + selectedLimit.value = newItems[0]?.id + } + } + }, + ) + const selectedLimit = ref<number>() + + useProvide<PopupContextValue>(NAMESPACE, { + reload, darkMode, setDarkMode, query, option, + cateNameMap, + menu, setMenu, + limitSummary, selectedLimit + }) return appKey } @@ -97,4 +125,8 @@ export const useQuery = () => useProvider<PopupContextValue, 'query'>(NAMESPACE, export const useOption = () => useProvider<PopupContextValue, 'option'>(NAMESPACE, 'option').option -export const useCateNameMap = () => useProvider<PopupContextValue, 'cateNameMap'>(NAMESPACE, 'cateNameMap')?.cateNameMap \ No newline at end of file +export const useCateNameMap = () => useProvider<PopupContextValue, 'cateNameMap'>(NAMESPACE, 'cateNameMap')?.cateNameMap + +export const useMenu = () => useProvider<PopupContextValue, 'menu' | 'setMenu'>(NAMESPACE, 'menu', 'setMenu') + +export const useLimitSummary = () => useProvider<PopupContextValue, 'limitSummary' | 'selectedLimit'>(NAMESPACE, 'limitSummary', 'selectedLimit') \ No newline at end of file diff --git a/src/pages/popup/index.ts b/src/pages/popup/index.ts index 819a66a7d..d0cd73333 100644 --- a/src/pages/popup/index.ts +++ b/src/pages/popup/index.ts @@ -5,14 +5,13 @@ * https://opensource.org/licenses/MIT */ +import { getOption } from "@api/sw/option" import { initLocale } from "@i18n" -import { increasePopup } from "@service/meta-service" -import optionService from "@service/option-service" -import { toggle } from "@util/dark-mode" -import "element-plus/theme-chalk/index.css" +import { processDarkMode } from '@pages/util/dark-mode' +import { initEcharts } from "@pages/util/echarts" +import type { FrameRequest, FrameResponse } from "@popup/types" import { createApp } from "vue" import Main from "./Main" -import { type FrameRequest, type FrameResponse } from "./message" import initRouter from "./router" import { injectGlobalCss } from "./style" @@ -36,11 +35,11 @@ function send2ParentWindow(data: any): Promise<void> { } async function main() { - await initLocale() + initLocale() + initEcharts() injectGlobalCss() - const isDarkMode = await optionService.isDarkMode() - toggle(isDarkMode) + getOption().then(processDarkMode) await send2ParentWindow('themeInitialized') const el = document.createElement('div') @@ -50,8 +49,6 @@ async function main() { const app = createApp(Main) initRouter(app) app.mount(el) - - increasePopup() } main() diff --git a/src/pages/popup/locale.ts b/src/pages/popup/locale.ts index f44286ccb..bc161614e 100644 --- a/src/pages/popup/locale.ts +++ b/src/pages/popup/locale.ts @@ -9,7 +9,7 @@ import { type I18nKey as _I18nKey, t as _t, tN as _tN } from "@i18n" import messages, { type PopupMessage } from "@i18n/message/popup" import type { VNode } from 'vue' -export type I18nKey = _I18nKey<PopupMessage> +type I18nKey = _I18nKey<PopupMessage> export const t = (key: I18nKey, param?: any) => _t<PopupMessage>(messages, { key, param }) diff --git a/src/pages/popup/message.ts b/src/pages/popup/message.ts deleted file mode 100644 index 5c340dade..000000000 --- a/src/pages/popup/message.ts +++ /dev/null @@ -1,8 +0,0 @@ -export type FrameRequest = { - stamp: number - data: any -} - -export type FrameResponse = { - stamp: number -} \ No newline at end of file diff --git a/src/pages/popup/router.ts b/src/pages/popup/router.ts index b4f8bbf68..649a27c7a 100644 --- a/src/pages/popup/router.ts +++ b/src/pages/popup/router.ts @@ -1,31 +1,34 @@ +import { useLocalStorage } from '@hooks' import { type App } from "vue" -import { createRouter, createWebHashHistory, type RouteRecordRaw } from "vue-router" +import { createRouter, createWebHashHistory, RouteRecordRedirect, type RouteRecordSingleView } from "vue-router" +import type { PopupMenu } from './types' -export const ROUTE_PERCENTAGE = 'percentage' -const ROUTE_RANKING = 'ranking' +type Path = `/${PopupMenu}` +type MyRoute = + | (Omit<RouteRecordSingleView, 'path'> & { path: Path }) + | (Omit<RouteRecordRedirect, 'redirect'> & { redirect: Path }) -export const POPUP_ROUTES = [ROUTE_PERCENTAGE, ROUTE_RANKING] -export type PopupRoute = typeof POPUP_ROUTES[number] - -const routes: RouteRecordRaw[] = [ +const createRoutes = (stored: PopupMenu | undefined): MyRoute[] => [ { path: '/', - redirect: '/' + ROUTE_PERCENTAGE, + redirect: stored ? `/${stored}` : '/percentage', }, { - path: '/' + ROUTE_PERCENTAGE, + path: '/percentage', component: () => import('./components/Percentage'), }, { - path: '/' + ROUTE_RANKING, + path: '/ranking', component: () => import('./components/Ranking'), - } + }, { + path: '/limit', + component: () => import('./components/Limit'), + }, ] export default (app: App) => { - const router = createRouter({ - history: createWebHashHistory(), - routes, - }) - + const [stored] = useLocalStorage<PopupMenu>('popup_menu') + const routes = createRoutes(stored) + const history = createWebHashHistory() + const router = createRouter({ routes, history }) app.use(router) } \ No newline at end of file diff --git a/src/pages/popup/skeleton.ts b/src/pages/popup/skeleton.ts index f1338e105..4ea302989 100644 --- a/src/pages/popup/skeleton.ts +++ b/src/pages/popup/skeleton.ts @@ -1,5 +1,5 @@ -import { init as initTheme } from "@util/dark-mode" -import { type FrameRequest, type FrameResponse } from "./message" +import { initDarkTheme } from "@pages/util/dark-mode" +import type { FrameRequest, FrameResponse } from "@popup/types" import { injectSkeletonCss } from './style/skeleton' function injectFrame() { @@ -23,7 +23,7 @@ function injectFrame() { */ async function main() { // Calculate the latest mode - initTheme() + initDarkTheme() injectSkeletonCss() // Resize after init theme document.body.style.width = '766px' diff --git a/src/pages/popup/types.d.ts b/src/pages/popup/types.d.ts new file mode 100644 index 000000000..65436365a --- /dev/null +++ b/src/pages/popup/types.d.ts @@ -0,0 +1,28 @@ +export type FrameRequest = { + stamp: number + data: any +} + +export type FrameResponse = { + stamp: number +} + +export type PopupDuration = + | "today" | "yesterday" | "thisWeek" | "thisMonth" + | "lastDays" + | "allTime" + +export type PopupQuery = { + mergeMethod: Exclude<timer.stat.MergeMethod, 'date'> | undefined + duration: PopupDuration + durationNum?: number + dimension: Exclude<timer.core.Dimension, 'run'> +} + +export type PopupOption = { + showName: boolean + topN: number + donutChart: boolean +} + +export type PopupMenu = 'percentage' | 'ranking' | 'limit' \ No newline at end of file diff --git a/src/pages/side/Layout.tsx b/src/pages/side/Layout.tsx index f31ec8d28..f122c95ce 100644 --- a/src/pages/side/Layout.tsx +++ b/src/pages/side/Layout.tsx @@ -1,9 +1,9 @@ +import { listSiteStats } from "@api/sw/stat" import { useRequest } from "@hooks" import Flex from '@pages/components/Flex' -import { selectSite } from "@service/stat-service" -import { formatTime } from "@util/time" +import { formatTime, formatTimeYMD } from "@util/time" import { ElText } from "element-plus" -import { defineComponent, ref, type StyleValue } from "vue" +import { defineComponent, ref } from "vue" import RowList from "./components/RowList" import Search from "./components/Search" import { t } from "./locale" @@ -12,12 +12,12 @@ const _default = defineComponent<{}>(() => { const date = ref(new Date()) const query = ref('') - const { data, refresh, loading } = useRequest(() => selectSite({ - date: date.value ?? new Date(), + const { data, refresh, loading } = useRequest(() => listSiteStats({ + date: formatTimeYMD(date.value), query: query.value, sortKey: 'focus', sortDirection: 'DESC', - })) + }), { defaultValue: [] }) return () => <Flex column height='100%'> <Search @@ -39,11 +39,7 @@ const _default = defineComponent<{}>(() => { @{formatTime(date.value, t(msg => msg.calendar.dateFormat))} </ElText> </Flex> - <RowList - loading={loading.value} - data={data.value ?? []} - style={{ flex: 1, overflow: "auto" } satisfies StyleValue} - /> + <RowList loading={loading.value} data={data.value} /> </Flex> }) diff --git a/src/pages/side/components/RowList/Item.tsx b/src/pages/side/components/RowList/Item.tsx index cf9063af1..14af89bf5 100644 --- a/src/pages/side/components/RowList/Item.tsx +++ b/src/pages/side/components/RowList/Item.tsx @@ -1,13 +1,12 @@ import { createTab } from "@api/chrome/tab" -import { useShadow } from "@hooks" import Flex from "@pages/components/Flex" import { isRemainHost } from "@util/constant/remain-host" import { getAlias, getHost, isSite } from "@util/stat" import { formatPeriodCommon } from "@util/time" import { ElAvatar, ElCard, ElLink, ElProgress, ElTag, ElText, ElTooltip } from "element-plus" -import { computed, defineComponent, type PropType } from "vue" +import { computed, defineComponent, toRef } from "vue" -const renderTitle = (siteName: string | undefined, host: string | undefined, handleJump: () => void) => { +const renderTitle = (siteName: string | undefined, host: string | undefined, handleJump: NoArgCallback) => { const text = siteName ?? host ?? '' const tooltip = siteName ? host : null const textNode = <ElLink onClick={handleJump}>{text}</ElLink> @@ -15,7 +14,7 @@ const renderTitle = (siteName: string | undefined, host: string | undefined, han return ( <ElTooltip content={tooltip} placement="top" offset={4}> {textNode} - </ElTooltip > + </ElTooltip> ) } @@ -25,83 +24,80 @@ const renderAvatarText = (row: timer.stat.Row) => { return getHost(row)?.substring?.(0, 1)?.toUpperCase?.() } -const _default = defineComponent({ - props: { - value: { - type: Object as PropType<timer.stat.Row>, - required: true, - }, - max: Number, - total: Number, - }, - setup(props) { - const [iconUrl] = useShadow(() => 'iconUrl' in props.value ? props.value?.iconUrl : undefined) - const [host] = useShadow(() => getHost(props.value)) - const [siteName] = useShadow(() => getAlias(props.value)) - const clickable = computed(() => host?.value && !isRemainHost(host.value)) - const [rate] = useShadow(() => { - if (!props.max) return 0 - return (props.value?.focus ?? 0) / props.max * 100 - }, 0) - const [percentage] = useShadow(() => { - if (!props.total) return '0 %' - const val = (props.value?.focus ?? 0) / props.total * 100 - return val.toFixed(2) + ' %' - }) - const handleJump = () => clickable.value && createTab("https://" + host.value) - return () => ( - <ElCard - shadow="hover" - style={{ padding: '0 10px', height: '70px', boxSizing: 'border-box' }} - bodyStyle={{ padding: '5px', display: 'flex', height: 'calc(100% - 10px)' }} +type Props = { + value: timer.stat.Row + max?: number + total?: number +} + +const _default = defineComponent<Props>(props => { + const row = toRef(props, 'value') + const iconUrl = computed(() => 'iconUrl' in row.value ? row.value.iconUrl : undefined) + const host = computed(() => getHost(row.value)) + const siteName = computed(() => getAlias(row.value)) + const clickable = computed(() => host.value && !isRemainHost(host.value)) + const rate = computed(() => { + if (!props.max) return 0 + return (row.value?.focus ?? 0) / props.max * 100 + }) + const percentage = computed(() => { + if (!props.total) return '0 %' + const val = (row.value?.focus ?? 0) / props.total * 100 + return val.toFixed(2) + ' %' + }) + const handleJump = () => clickable.value && createTab("https://" + host.value) + return () => ( + <ElCard + shadow="hover" + style={{ padding: '0 10px', height: '70px', boxSizing: 'border-box' }} + bodyStyle={{ padding: '5px', display: 'flex', height: 'calc(100% - 10px)' }} + > + <Flex + width={50} + align="center" + justify="space-around" + boxSizing="content-box" + cursor={clickable.value ? 'pointer' : undefined} + onClick={handleJump} > - <Flex - width={50} - align="center" - justify="space-around" - boxSizing="content-box" - cursor={clickable.value ? 'pointer' : undefined} - onClick={handleJump} + <ElAvatar + src={iconUrl.value} + shape="square" + fit="fill" + style={{ + backgroundColor: isSite(props.value) && props.value.iconUrl ? "transparent" : null, + padding: '2px', + userSelect: 'none', + fontSize: '22px', + }} > - <ElAvatar - src={iconUrl.value} - shape="square" - fit="fill" - style={{ - backgroundColor: isSite(props.value) && props.value.iconUrl ? "transparent" : null, - padding: '2px', - userSelect: 'none', - fontSize: '22px', - }} + {renderAvatarText(props.value)} + </ElAvatar> + </Flex> + <Flex + column flex={1} + style={{ marginInlineStart: '10px', paddingInlineEnd: '10px' }} + > + <Flex align="center" justify="space-between" height={24}> + {renderTitle(siteName.value, host.value, handleJump)} + <ElTag + size="small" + style={{ fontSize: '10px', padding: '0 3px', height: '16px' }} > - {renderAvatarText(props.value)} - </ElAvatar> + {percentage.value} + </ElTag> </Flex> - <Flex - column flex={1} - style={{ marginInlineStart: '10px', paddingInlineEnd: '10px' }} - > - <Flex align="center" justify="space-between" height={24}> - {renderTitle(siteName.value, host.value, handleJump)} - <ElTag - size="small" - style={{ fontSize: '10px', padding: '0 3px', height: '16px' }} - > - {percentage.value} - </ElTag> - </Flex> - <Flex column justify="space-around" flex={1}> - <Flex justify="end" width="100%"> - <ElText size="small"> - {formatPeriodCommon(props.value?.focus ?? 0)} - </ElText> - </Flex> - <ElProgress percentage={rate.value} showText={false} /> + <Flex column justify="space-around" flex={1}> + <Flex justify="end" width="100%"> + <ElText size="small"> + {formatPeriodCommon(props.value?.focus ?? 0)} + </ElText> </Flex> + <ElProgress percentage={rate.value} showText={false} /> </Flex> - </ElCard > - ) - } -}) + </Flex> + </ElCard> + ) +}, { props: ['value', 'max', 'total'] }) export default _default \ No newline at end of file diff --git a/src/pages/side/components/RowList/index.tsx b/src/pages/side/components/RowList/index.tsx index 26b7d9518..b4cb76e53 100644 --- a/src/pages/side/components/RowList/index.tsx +++ b/src/pages/side/components/RowList/index.tsx @@ -13,8 +13,8 @@ type Props = { const RowList = defineComponent<Props>(props => { const data = toRef(props, 'data') - const maxFocus = computed(() => data.value.map(r => r.focus).reduce((a, b) => a > b ? a : b, 0) ?? 0) - const totalFocus = computed(() => sum(data.value.map(i => i?.focus ?? 0))) + const maxFocus = computed(() => Math.max(0, ...data.value.map(r => r.focus))) + const totalFocus = computed(() => sum(data.value.map(i => i.focus))) const scrollbar = ref<ScrollbarInstance>() watch(data, () => scrollbar.value?.setScrollTop(0)) @@ -29,11 +29,11 @@ const RowList = defineComponent<Props>(props => { } ` return () => ( - <Flex flex={1} style={props.style}> + <Flex flex={1} style={{ overflow: 'hidden' }}> <ElScrollbar v-loading={props.loading} height="100%" ref={scrollbar} style={{ width: '100%' }}> <Flex column gap={8}> - {!data.value?.length && !props.loading && <ElEmpty class={emptyCls} />} - {data.value?.map(item => <Item value={item} max={maxFocus.value} total={totalFocus.value} />)} + {!data.value.length && !props.loading && <ElEmpty class={emptyCls} />} + {data.value.map(item => <Item value={item} max={maxFocus.value} total={totalFocus.value} />)} </Flex> </ElScrollbar> </Flex> diff --git a/src/pages/side/components/Search/index.tsx b/src/pages/side/components/Search/index.tsx index a55447ad2..d80780590 100644 --- a/src/pages/side/components/Search/index.tsx +++ b/src/pages/side/components/Search/index.tsx @@ -1,9 +1,9 @@ +import { useState } from "@hooks" +import { t } from '@side/locale' import { Search } from "@element-plus/icons-vue" import { css } from '@emotion/css' -import { useState } from "@hooks" import Flex from "@pages/components/Flex" import { getDatePickerIconSlots } from '@pages/element-ui/rtl' -import { t } from "@side/locale" import { type DateCell, ElDatePicker, ElInput, useNamespace } from "element-plus" import { defineComponent, h } from "vue" import Cell from './Cell' diff --git a/src/pages/side/components/Search/useDatePicker.ts b/src/pages/side/components/Search/useDatePicker.ts index 0e2551429..a7d7747cf 100644 --- a/src/pages/side/components/Search/useDatePicker.ts +++ b/src/pages/side/components/Search/useDatePicker.ts @@ -1,7 +1,6 @@ -import { useRequest } from '@hooks/useRequest' -import { useState } from '@hooks/useState' -import { selectSite } from '@service/stat-service' -import { getMonthTime, MILL_PER_WEEK } from '@util/time' +import { listSiteStats } from '@api/sw/stat' +import { useRequest, useState } from '@hooks' +import { formatTimeYMD, getMonthTime, MILL_PER_WEEK } from '@util/time' import { watch } from 'vue' export const useDatePicker = (options: { onChange: ArgCallback<Date> }) => { @@ -12,10 +11,10 @@ export const useDatePicker = (options: { onChange: ArgCallback<Date> }) => { const { data: dataDates, refresh: refreshDates } = useRequest(async (dateInMonth: Date) => { const [ms, me] = getMonthTime(dateInMonth) - const start = new Date(ms.getTime() - ms.getDay() * MILL_PER_WEEK) - const end = new Date(me.getTime() + (6 - me.getDay()) * MILL_PER_WEEK) + const start = formatTimeYMD(new Date(ms.getTime() - ms.getDay() * MILL_PER_WEEK)) + const end = formatTimeYMD(new Date(me.getTime() + (6 - me.getDay()) * MILL_PER_WEEK)) - const stats = await selectSite({ date: [start, end] }) + const stats = await listSiteStats({ date: [start, end] }) const dateSet = new Set<string>() stats.forEach(({ date }) => date && dateSet.add(date)) return Array.from(dateSet) diff --git a/src/pages/side/index.ts b/src/pages/side/index.ts index 7237a62a9..e78096549 100644 --- a/src/pages/side/index.ts +++ b/src/pages/side/index.ts @@ -5,26 +5,16 @@ * https://opensource.org/licenses/MIT */ import { initLocale } from "@i18n" -import { initElementLocale } from "@i18n/element" -import optionService from "@service/option-service" -import { injectGlobalCss } from '@side/style' -import { init as initTheme, toggle } from "@util/dark-mode" -import { ElLoadingDirective } from 'element-plus' -import 'element-plus/theme-chalk/index.css' -import { type App, createApp } from "vue" -import '../../common/timer' +import { createElApp } from "@pages/element-ui/app" +import { initDarkTheme } from "@pages/util/dark-mode" import Main from "./Layout" +import { injectGlobalCss } from './style' async function main() { - // Init theme with cache first - initTheme() + initDarkTheme() injectGlobalCss() - // Calculate the latest mode - optionService.isDarkMode().then(toggle) - await initLocale() - const app: App = createApp(Main) - await initElementLocale(app) - app.directive("loading", ElLoadingDirective) + initLocale() + const app = await createElApp(Main) const el = document.createElement('div') document.body.append(el) diff --git a/src/pages/side/locale.ts b/src/pages/side/locale.ts index ebf117f66..77c45c81a 100644 --- a/src/pages/side/locale.ts +++ b/src/pages/side/locale.ts @@ -8,7 +8,7 @@ import { type I18nKey as _I18nKey, t as _t } from "@i18n" import messages, { type SideMessage } from "@i18n/message/side" -export type I18nKey = _I18nKey<SideMessage> +type I18nKey = _I18nKey<SideMessage> export const t = (key: I18nKey, param?: any) => { const props = { key, param } diff --git a/src/pages/util/dark-mode.ts b/src/pages/util/dark-mode.ts new file mode 100644 index 000000000..6d6661052 --- /dev/null +++ b/src/pages/util/dark-mode.ts @@ -0,0 +1,66 @@ +/** + * Copyright (c) 2022-present Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { getOption } from '@api/sw/option' + +const THEME_ATTR = "data-theme" +const DARK_VAL = "dark" +const STORAGE_KEY = "isDark" +const STORAGE_FLAG = "1" + +function toggle0(isDarkMode: boolean, el?: Element) { + el = el || document.getElementsByTagName("html")?.[0] + el.setAttribute(THEME_ATTR, isDarkMode ? DARK_VAL : "") + localStorage.setItem(STORAGE_KEY, isDarkMode ? STORAGE_FLAG : '') +} + +/** + * Init from local storage + */ +export function initDarkTheme(el?: Element) { + const val = isDarkMode() + toggle0(val, el) + // Calculate the latest mode + getOption().then(processDarkMode) +} + +function calcDarkMode(option: timer.option.AppearanceOption): boolean { + const { darkMode, darkModeTimeStart: start, darkModeTimeEnd: end } = option + if (darkMode === "default") { + if (typeof window === 'undefined') return false + return !!window.matchMedia('(prefers-color-scheme: dark)')?.matches + } else if (darkMode === "on") { + return true + } else if (darkMode === "off") { + return false + } else if (darkMode === "timed") { + if (start === undefined || end === undefined) { + return false + } + const now = new Date() + const currentSecs = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds() + if (start > end) { + // Mostly + return start <= currentSecs || currentSecs <= end + } else if (start < end) { + return start <= currentSecs && currentSecs <= end + } else { + return currentSecs === start + } + } + return false +} + +export function processDarkMode(option: timer.option.AppearanceOption): boolean { + const val = calcDarkMode(option) + toggle0(val) + return val +} + +export function isDarkMode() { + return localStorage.getItem(STORAGE_KEY) === STORAGE_FLAG +} \ No newline at end of file diff --git a/src/pages/util/echarts.ts b/src/pages/util/echarts.ts new file mode 100644 index 000000000..6d5125271 --- /dev/null +++ b/src/pages/util/echarts.ts @@ -0,0 +1,18 @@ +import { + BarChart, CustomChart, EffectScatterChart, GaugeChart, LineChart, PieChart, ScatterChart, +} from "echarts/charts" +import { + AriaComponent, DataZoomComponent, GridComponent, LegendComponent, TitleComponent, ToolboxComponent, + TooltipComponent, VisualMapComponent, +} from "echarts/components" +import { use } from "echarts/core" +import { CanvasRenderer } from "echarts/renderers" + +export const initEcharts = () => { + use([ + CanvasRenderer, + ToolboxComponent, AriaComponent, GridComponent, TooltipComponent, TitleComponent, VisualMapComponent, LegendComponent, + DataZoomComponent, + BarChart, PieChart, LineChart, ScatterChart, EffectScatterChart, CustomChart, GaugeChart, + ]) +} \ No newline at end of file diff --git a/src/pages/util/icon.tsx b/src/pages/util/icon.tsx deleted file mode 100644 index 86ad37f90..000000000 --- a/src/pages/util/icon.tsx +++ /dev/null @@ -1,19 +0,0 @@ -import { type FunctionalComponent } from 'vue' - -export const Coffee: FunctionalComponent<{}> = () => ( - <svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"> - <path d="M742.954667 682.666667H554.666667a42.666667 42.666667 0 0 1 0-85.333334h195.413333l14.208-170.666666H259.712l29.013333 348.416A85.333333 85.333333 0 0 0 373.76 853.333333h276.48a85.333333 85.333333 0 0 0 85.034667-78.250666l7.68-92.416z m67.2-341.333334H853.333333V256h-122.965333a119.850667 119.850667 0 0 1-107.178667-66.261333 34.517333 34.517333 0 0 0-30.890666-19.072h-160.597334a34.517333 34.517333 0 0 0-30.890666 19.072A119.850667 119.850667 0 0 1 293.632 256H170.666667v85.333333H810.154667z m39.765333 85.333334l-29.610667 355.498666A170.666667 170.666667 0 0 1 650.24 938.666667H373.76a170.666667 170.666667 0 0 1-170.069333-156.501334L174.08 426.666667H170.666667a85.333333 85.333333 0 0 1-85.333334-85.333334V256a85.333333 85.333333 0 0 1 85.333334-85.333333h122.965333a34.517333 34.517333 0 0 0 30.890667-19.072A119.850667 119.850667 0 0 1 431.701333 85.333333h160.597334c45.397333 0 86.912 25.642667 107.178666 66.261334a34.517333 34.517333 0 0 0 30.890667 19.072H853.333333a85.333333 85.333333 0 0 1 85.333334 85.333333v85.333333a85.333333 85.333333 0 0 1-85.333334 85.333334h-3.413333z"></path> - </svg> -) - -export const GitHub: FunctionalComponent<{}> = () => ( - <svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'> - <path d='M8 0c4.42 0 8 3.58 8 8a8.013 8.013 0 0 1-5.45 7.59c-.4.08-.55-.17-.55-.38 0-.27.01-1.13.01-2.2 0-.75-.25-1.23-.54-1.48 1.78-.2 3.65-.88 3.65-3.95 0-.88-.31-1.59-.82-2.15.08-.2.36-1.02-.08-2.12 0 0-.67-.22-2.2.82-.64-.18-1.32-.27-2-.27-.68 0-1.36.09-2 .27-1.53-1.03-2.2-.82-2.2-.82-.44 1.1-.16 1.92-.08 2.12-.51.56-.82 1.28-.82 2.15 0 3.06 1.86 3.75 3.64 3.95-.23.2-.44.55-.51 1.07-.46.21-1.61.55-2.33-.66-.15-.24-.6-.83-1.23-.82-.67.01-.27.38.01.53.34.19.73.9.82 1.13.16.45.68 1.31 2.69.94 0 .67.01 1.3.01 1.49 0 .21-.15.45-.55.38A7.995 7.995 0 0 1 0 8c0-4.42 3.58-8 8-8Z' /> - </svg> -) - -export const Heart: FunctionalComponent<{}> = () => ( - <svg viewBox="0 0 1024 1024"> - <path d="M1000 248Q976.992 192 933.984 148.992 849.984 64 732.992 64q-64 0-121.504 28T512 171.008q-42.016-51.008-99.488-79.008T291.008 64Q174.016 64 90.016 150.016 47.008 193.024 24 249.024-0.992 308.032 0 371.04q0.992 68.992 28.992 130.496t79.008 104.512q4.992 4 8.992 8 14.016 12 112.992 102.016 208 191.008 256.992 235.008 11.008 8.992 24.992 8.992t24.992-8.992q32.992-30.016 180.992-164.992 158.016-144 196-179.008 52-43.008 80.992-104.992t28.992-132q0-64-24-122.016z" /> - </svg > -) \ No newline at end of file diff --git a/src/pages/util/rate.ts b/src/pages/util/rate.ts new file mode 100644 index 000000000..ec3f519cc --- /dev/null +++ b/src/pages/util/rate.ts @@ -0,0 +1,20 @@ +import { trySendMsg2Runtime } from '@api/sw/common' +import { REVIEW_PAGE } from '@util/constant/url' +import { getDayLength } from '@util/time' + +const INSTALL_DAY_MIN_LIMIT = 14 + +const FLAG = 'rateOpen' + +export async function recommendRate(): Promise<boolean> { + if (!REVIEW_PAGE) return false + const installTime = await trySendMsg2Runtime('meta.installTs') + if (!installTime) return false + const installedDays = getDayLength(new Date(installTime), new Date()) + if (installedDays < INSTALL_DAY_MIN_LIMIT) return false + return !localStorage.getItem(FLAG) +} + +export function rateClicked() { + localStorage.setItem(FLAG, '1') +} \ No newline at end of file diff --git a/src/pages/util/style.ts b/src/pages/util/style.ts index 0db056e1d..f53312a61 100644 --- a/src/pages/util/style.ts +++ b/src/pages/util/style.ts @@ -27,7 +27,7 @@ export const colorVariant = (variant: Variant, effect?: 'dark' | 'light', level? export const colorUsage = (usage: ColorUsage) => `--el-${usage}-color` -export const textColor = (variant: TextVariant) => `--el-text-color-${variant}` +const textColor = (variant: TextVariant) => `--el-text-color-${variant}` export const getStyle = ( element: HTMLElement, @@ -74,4 +74,19 @@ export function getInfoColor(): string | undefined { export function getColor(variant: Variant): string | undefined { return getCssVariable(colorVariant(variant)) -} \ No newline at end of file +} + +export const cvtGroupColor = (color?: `${chrome.tabGroups.Color}`): string => { + switch (color) { + case 'grey': return '#5F6369' + case 'blue': return '#1974E8' + case 'yellow': return '#F9AB03' + case 'red': return '#DA3025' + case 'green': return '#198139' + case 'pink': return '#D01984' + case 'purple': return '#A143F5' + case 'cyan': return '#027B84' + case 'orange': return '#FA913E' + case undefined: return '#000' + } +} diff --git a/src/service/cate-service.ts b/src/service/cate-service.ts deleted file mode 100644 index 569509e58..000000000 --- a/src/service/cate-service.ts +++ /dev/null @@ -1,22 +0,0 @@ -import siteCateDatabase from "@db/site-cate-database" - -class CateService { - - listAll(): Promise<timer.site.Cate[]> { - return siteCateDatabase.listAll() - } - - add(name: string): Promise<timer.site.Cate> { - return siteCateDatabase.add(name) - } - - saveName(id: number, name: string): Promise<void> { - return siteCateDatabase.update(id, name) - } - - remove(id: number): Promise<void> { - return siteCateDatabase.delete(id) - } -} - -export default new CateService() \ No newline at end of file diff --git a/src/service/components/immigration.ts b/src/service/components/immigration.ts deleted file mode 100644 index a6769311c..000000000 --- a/src/service/components/immigration.ts +++ /dev/null @@ -1,67 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import limitDatabase from "@db/limit-database" -import mergeRuleDatabase from "@db/merge-rule-database" -import statDatabase from "@db/stat-database" -import type { BrowserMigratable, StorageMigratable } from '@db/types' -import whitelistDatabase from "@db/whitelist-database" -import packageInfo from "@src/package" - -/** - * Data export/import and storage migration - * - * @since 0.2.5 - */ -class Immigration { - private browserMigratables: BrowserMigratable<string>[] - private storageMigratables: StorageMigratable<unknown>[] - - constructor() { - this.browserMigratables = [ - statDatabase, - limitDatabase, - mergeRuleDatabase, - whitelistDatabase, - ] - this.storageMigratables = [ - statDatabase, - ] - } - - async exportData(): Promise<timer.backup.ExportData> { - const data: timer.backup.ExportData = { - __meta__: { version: packageInfo.version, ts: Date.now() }, - } - for (const migratable of this.browserMigratables) { - const namespace = migratable.namespace - const exportData = await migratable.exportData() - data[namespace] = exportData - } - return data - } - - async importData(data: unknown): Promise<void> { - for (const db of this.browserMigratables) await db.importData(data) - } - - async migrateStorage(type: timer.option.StorageType): Promise<void> { - const dataList: unknown[] = [] - // 1. migrate all the databases firstly - for (const migratable of this.storageMigratables) { - const data = await migratable.migrateStorage(type) - dataList.push(data) - } - // 2. after migration - for (const migratable of this.storageMigratables) { - const [data] = dataList.splice(0, 1) - await migratable.afterStorageMigrated(data) - } - } -} - -export default new Immigration() \ No newline at end of file diff --git a/src/service/components/import-processor.ts b/src/service/components/import-processor.ts deleted file mode 100644 index 3c935bb11..000000000 --- a/src/service/components/import-processor.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import statDatabase from "@db/stat-database" -import { isNotZeroResult } from "@util/stat" - -/** - * Process imported data from other extensions of remote - * - * @since 1.9.2 - */ -export async function processImportedData(data: timer.imported.Data, resolution: timer.imported.ConflictResolution): Promise<void> { - if (resolution === 'overwrite') { - return processOverwrite(data) - } else { - return processAcc(data) - } -} - -async function processOverwrite(data: timer.imported.Data): Promise<void> { - const { rows, focus, time } = data - await Promise.all(rows.map(async row => { - const { host, date } = row - const exist = await statDatabase.get(host, date) - focus && (exist.focus = row.focus || 0) - time && (exist.time = row.time || 0) - await statDatabase.forceUpdate({ ...exist, host, date }) - })) -} - -async function processAcc(data: timer.imported.Data): Promise<void> { - const { rows } = data - await Promise.all(rows.map(async row => { - const { host, date, focus = 0, time = 0 } = row - await statDatabase.accumulate(host, date, { focus, time }) - })) -} - -export async function fillExist(rows: timer.imported.Row[]): Promise<void> { - await Promise.all(rows.map(async row => { - const { host, date } = row - const exist = await statDatabase.get(host, date) - isNotZeroResult(exist) && (row.exist = exist) - })) -} \ No newline at end of file diff --git a/src/service/components/option-holder.ts b/src/service/components/option-holder.ts deleted file mode 100644 index b5e542d8c..000000000 --- a/src/service/components/option-holder.ts +++ /dev/null @@ -1,52 +0,0 @@ -import { onPermRemoved } from "@api/chrome/permission" -import db from "@db/option-database" -import { type DefaultOption, defaultOption } from "@util/constant/option" - -type ChangeListener = (option: timer.option.AllOption) => void - -class OptionHolder { - private option: DefaultOption | undefined - private listeners: ChangeListener[] = [] - - constructor() { - db.addOptionChangeListener(async () => { - const option = await this.reset() - this.listeners.forEach(listener => listener?.(option)) - }) - onPermRemoved(perm => { - perm.permissions?.includes('tabGroups') && this.set({ countTabGroup: false }) - }) - } - - listenPermChange() { - onPermRemoved(perm => { - perm.permissions?.includes('tabGroups') && this.set({ countTabGroup: false }) - }) - } - - private async reset(): Promise<DefaultOption> { - const exist: Partial<timer.option.AllOption> = await db.getOption() - const result = defaultOption() - Object.entries(exist).forEach(([key, val]) => (result as any)[key] = val) - this.option = result - return result - } - - async get(): Promise<DefaultOption> { - return this.option ?? await this.reset() - } - - async set(option: Partial<timer.option.AllOption>): Promise<void> { - const exist: Partial<timer.option.AllOption> = await db.getOption() - const toSet = defaultOption() - Object.entries(exist).forEach(([key, val]) => (toSet as any)[key] = val) - Object.entries(option).forEach(([key, val]) => (toSet as any)[key] = val) - await db.setOption(toSet) - } - - addChangeListener(listener: ChangeListener) { - listener && this.listeners.push(listener) - } -} - -export default new OptionHolder() \ No newline at end of file diff --git a/src/service/components/virtual-site-holder.ts b/src/service/components/virtual-site-holder.ts deleted file mode 100644 index 46a3f7d14..000000000 --- a/src/service/components/virtual-site-holder.ts +++ /dev/null @@ -1,45 +0,0 @@ -import siteDatabase from "@db/site-database" -import { compileAntPattern } from '@util/pattern' - -/** - * The singleton implementation of virtual sites holder - * - * @since 1.6.0 - */ -class VirtualSiteHolder { - hostSiteRegMap: Record<string, RegExp> = {} - - constructor() { - siteDatabase.select().then(sitesInfos => sitesInfos - .filter(s => s.type === 'virtual') - .forEach(site => this.updateRegularExp(site)) - ) - siteDatabase.addChangeListener(oldAndNew => oldAndNew.forEach(([oldVal, newVal]) => { - if (!newVal) { - // deleted - oldVal?.host && delete this.hostSiteRegMap[oldVal.host] - } else { - this.updateRegularExp(newVal) - } - })) - } - - private updateRegularExp(siteInfo: timer.site.SiteInfo) { - const { host } = siteInfo - this.hostSiteRegMap[host] = compileAntPattern(host) - } - - /** - * Find the virtual sites which matches the target url - * - * @param url - * @returns virtual sites - */ - findMatched(url: string): string[] { - return Object.entries(this.hostSiteRegMap) - .filter(([_, reg]) => reg.test(url)) - .map(([k]) => k) - } -} - -export default new VirtualSiteHolder() \ No newline at end of file diff --git a/src/service/limit-service/index.ts b/src/service/limit-service/index.ts deleted file mode 100644 index 09d81b4ce..000000000 --- a/src/service/limit-service/index.ts +++ /dev/null @@ -1,233 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { listTabs, sendMsg2Tab } from "@api/chrome/tab" -import db from "@db/limit-database" -import optionHolder from "@service/components/option-holder" -import weekHelper from "@service/components/week-helper" -import whitelistHolder from "@service/whitelist/holder" -import { sum } from "@util/array" -import { calcTimeState, hasLimited, isEnabledAndEffective, matches } from "@util/limit" -import { formatTimeYMD, MILL_PER_MINUTE } from "@util/time" - -export type QueryParam = { - filterDisabled: boolean - id?: number - url?: string -} - -async function select(cond?: QueryParam): Promise<timer.limit.Item[]> { - const { filterDisabled, url, id } = cond || {} - const now = new Date() - const today = formatTimeYMD(now) - const [startDate, endDate] = await weekHelper.getWeekDateRange(now) - - return (await db.all()) - .filter(item => !filterDisabled || item.enabled) - .filter(item => !id || id === item?.id) - // If use url, then test it - .filter(item => !url || matches(item?.cond, url)) - .map(({ records, ...others }) => { - const todayRec = records[today] - const thisWeekRec = Object.entries(records) - .filter(([k]) => k >= startDate && k <= endDate) - .map(([, v]) => v) - const weeklyWaste = sum(thisWeekRec.map(r => r.mill ?? 0)) - const weeklyDelayCount = sum(thisWeekRec.map(r => r.delay ?? 0)) - const weeklyVisit = sum(thisWeekRec.map(r => r.visit ?? 0)) - return { - ...others, - waste: todayRec?.mill ?? 0, - visit: todayRec?.visit ?? 0, - delayCount: todayRec?.delay ?? 0, - weeklyWaste, - weeklyDelayCount, - weeklyVisit, - } satisfies timer.limit.Item - }) -} - -async function selectEffective(url?: string) { - const enabledItems: timer.limit.Item[] = await select({ filterDisabled: true, url }) - return enabledItems?.filter(isEnabledAndEffective) || [] -} - -/** - * Fired if the item is removed or disabled - * - * @param item - */ -async function noticeLimitChanged() { - const effectiveItems = await selectEffective() - const tabs = await listTabs() - tabs.forEach(({ id, url }) => { - if (!id || !url) return - const limitedItems = effectiveItems.filter(item => matches(item?.cond, url)) - sendMsg2Tab(id, 'limitChanged', limitedItems).catch(err => console.warn(err.message)) - }) -} - -async function updateEnabled(...items: timer.limit.Rule[]): Promise<void> { - if (!items?.length) return - for (const item of items) { - await db.updateEnabled(item.id, !!item.enabled) - } - await noticeLimitChanged() -} - -async function updateLocked(...items: timer.limit.Rule[]): Promise<void> { - if (!items?.length) return - for (const item of items) { - await db.updateLocked(item.id, item.locked) - } -} - -async function updateDelay(...items: timer.limit.Rule[]) { - if (!items?.length) return - for (const item of items) { - await db.updateDelay(item.id, !!item.allowDelay) - } - await noticeLimitChanged() -} - -async function remove(...items: timer.limit.Rule[]): Promise<void> { - if (!items?.length) return - for (const item of items) { - await db.remove(item.id) - } - await noticeLimitChanged() -} - -async function getLimited(url: string): Promise<timer.limit.Item[]> { - const list: timer.limit.Item[] = await getRelated(url) - return list.filter(item => hasLimited(item)) -} - -async function getRelated(url: string): Promise<timer.limit.Item[]> { - const effectiveItems = await selectEffective() - return effectiveItems.filter(item => matches(item?.cond, url)) -} - -type IncreaseResult = { - limited?: timer.limit.Item[] - reminder?: timer.limit.ReminderInfo -} - -/** - * Add time - * - * @param url url - * @param focusTime time, milliseconds - * @returns the rules is limit cause of this operation - */ -async function addFocusTime(host: string, url: string, focusTime: number): Promise<IncreaseResult> { - if (whitelistHolder.contains(host, url)) return {} - - const allEffective = await selectEffective(url) - - const toUpdate: { [cond: string]: number } = {} - const limited: timer.limit.Item[] = [] - const needReminder: timer.limit.Item[] = [] - - const { limitReminder, limitReminderDuration = 0 } = await optionHolder.get() - const durationMill = limitReminder ? limitReminderDuration * MILL_PER_MINUTE : 0 - allEffective.forEach(item => { - const [met, reminder] = addFocusForEach(item, focusTime, durationMill) - met && limited.push(item) - reminder && needReminder.push(item) - toUpdate[item.id] = item.waste - }) - const result: IncreaseResult = { limited } - if (needReminder?.length) { - result.reminder = { - items: needReminder, - duration: limitReminderDuration, - } - } - await db.updateWaste(formatTimeYMD(new Date()), toUpdate) - return result -} - -function addFocusForEach(item: timer.limit.Item, focusTime: number, durationMill: number): [met: boolean, reminder: boolean] { - const before = calcTimeState(item, durationMill) - item.waste += focusTime - // Fast increase - item.weeklyWaste += focusTime - const after = calcTimeState(item, durationMill) - const met = (before.daily !== 'LIMITED' && after.daily === 'LIMITED') || (before.weekly !== 'LIMITED' && after.weekly === 'LIMITED') - const reminder = (before.daily === 'NORMAL' && after.daily === 'REMINDER') || (before.weekly === 'NORMAL' && after.weekly === 'REMINDER') - return [met, reminder] -} - -/** - * Increase visit count - * @returns the rules is limited - */ -async function incVisit(host: string, url: string): Promise<timer.limit.Item[]> { - if (whitelistHolder.contains(host, url)) return [] - - const allEnabled: timer.limit.Item[] = await select({ filterDisabled: true, url }) - const result: timer.limit.Item[] = [] - await db.increaseVisit(formatTimeYMD(new Date()), allEnabled.map(item => item.id)) - allEnabled.forEach(item => { - // Fast increase - item.visit++ - item.weeklyVisit++ - - hasLimited(item) && result.push(item) - }) - return result -} - -/** - * @returns Rules to wake - */ -async function moreMinutes(url: string): Promise<timer.limit.Item[]> { - const rules = (await select({ url: url, filterDisabled: true })) - .filter(item => hasLimited(item) && item.allowDelay) - rules.forEach(rule => { - rule.delayCount = (rule.delayCount ?? 0) + 1 - // Fast increase - rule.weeklyDelayCount = (rule.weeklyDelayCount ?? 0) + 1 - }) - - const date = formatTimeYMD(new Date()) - await db.updateDelayCount(date, rules) - return rules.filter(r => !hasLimited(r)) -} - -async function update(...rules: timer.limit.Rule[]) { - if (!rules?.length) return - for (const rule of rules) { - await db.save(rule, true) - } - await noticeLimitChanged() -} - -async function create(rule: MakeOptional<timer.limit.Rule, 'id'>): Promise<number> { - const id = await db.save(rule, false) - await noticeLimitChanged() - return id -} - -class LimitService { - moreMinutes = moreMinutes - getLimited = getLimited - getRelated = getRelated - updateEnabled = updateEnabled - updateDelay = updateDelay - updateLocked = updateLocked - select = select - remove = remove - update = update - create = create - broadcastRules = noticeLimitChanged - addFocusTime = addFocusTime - incVisit = incVisit -} - -export default new LimitService() diff --git a/src/service/meta-service.ts b/src/service/meta-service.ts deleted file mode 100644 index 00da4fafa..000000000 --- a/src/service/meta-service.ts +++ /dev/null @@ -1,110 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import db from "@db/meta-database" -import { REVIEW_PAGE } from "@util/constant/url" -import { getDayLength } from "@util/time" - -async function getInstallTime() { - const meta = await db.getMeta() - return meta.installTime ? new Date(meta.installTime) : undefined -} - -export async function updateInstallTime(installTime: Date) { - const meta = await db.getMeta() - if (meta.installTime) { - // Must not rewrite - return - } - meta.installTime = installTime.getTime() - await db.update(meta) -} - -export async function increaseApp(routePath: string) { - const meta = await db.getMeta() - const appCounter = meta.appCounter || {} - appCounter[routePath] = (appCounter[routePath] || 0) + 1 - meta.appCounter = appCounter - await db.update(meta) -} - -export async function increasePopup() { - const meta = await db.getMeta() - const popupCounter = meta.popupCounter || {} - popupCounter._total = (popupCounter._total || 0) + 1 - meta.popupCounter = popupCounter - await db.update(meta) -} - -/** - * @since 1.2.0 - */ -export async function getCid(): Promise<string | undefined> { - const meta = await db.getMeta() - return meta.cid -} - -/** - * @since 1.2.0 - */ -export async function updateCid(newCid: string) { - const meta = await db.getMeta() - if (meta.cid) { - return - } - meta.cid = newCid - await db.update(meta) -} - -/** - * @since 1.4.7 - */ -export async function updateBackUpTime(type: timer.backup.Type, time: number) { - const meta = await db.getMeta() - if (!meta.backup) { - meta.backup = {} - } - meta.backup[type] = { ts: time } - await db.update(meta) -} - -/** - * @since 1.4.7 - */ -export async function getLastBackUp(type: timer.backup.Type): Promise<{ ts: number, msg?: string } | undefined> { - const meta = await db.getMeta() - return meta.backup?.[type] -} - -/** - * @since 2.2.0 - */ -export async function saveFlag(flag: timer.ExtensionMetaFlag) { - if (!flag) return - const meta = await db.getMeta() - if (!meta.flag) meta.flag = {} - meta.flag[flag] = true - await db.update(meta) -} - -async function getFlag(flag: timer.ExtensionMetaFlag) { - if (!flag) return false - const meta = await db.getMeta() - return !!meta.flag?.[flag] -} - -const INSTALL_DAY_MIN_LIMIT = 14 - -export async function recommendRate(): Promise<boolean> { - if (!REVIEW_PAGE) return false - const installTime = await getInstallTime() - if (!installTime) return false - const installedDays = getDayLength(installTime, new Date()) - if (installedDays < INSTALL_DAY_MIN_LIMIT) return false - const rateOpen = await getFlag("rateOpen") - return !rateOpen -} diff --git a/src/service/notification/common.ts b/src/service/notification/common.ts deleted file mode 100644 index e90fec1de..000000000 --- a/src/service/notification/common.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { NotificationData } from './types' - -export function formatNotificationData(data: NotificationData): string { - return JSON.stringify(data, null, 2) -} diff --git a/src/service/option-service.ts b/src/service/option-service.ts deleted file mode 100644 index 87595220d..000000000 --- a/src/service/option-service.ts +++ /dev/null @@ -1,94 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import optionHolder from "./components/option-holder" - -async function setLocale(locale: timer.option.LocaleOption): Promise<void> { - const exist: Partial<timer.option.AllOption> = await optionHolder.get() - exist.locale = locale - await optionHolder.set(exist) -} - -async function setBackupOption(option: Partial<timer.option.BackupOption>): Promise<void> { - // Rewrite auths - const existOption = await optionHolder.get() - const existAuths = existOption.backupAuths || {} - const existExts = existOption.backupExts || {} - Object.entries(option.backupAuths || {}).forEach(([key, auth]) => existAuths[key as timer.backup.Type] = auth) - Object.entries(option.backupExts || {}).forEach(([key, ext]) => { - if (!ext) return - const type = key as timer.backup.Type - const existExt = existExts[type] || {} - Object.entries(ext).forEach(([extKey, val]) => existExt[extKey as keyof timer.backup.TypeExt] = val) - existExts[type] = existExt - }) - option.backupAuths = existAuths - option.backupExts = existExts - await optionHolder.set(option) -} - -async function isDarkMode(targetVal?: timer.option.AppearanceOption): Promise<boolean> { - const option = targetVal || await optionHolder.get() - const darkMode = option.darkMode - if (darkMode === "default") { - if (typeof window === 'undefined') return false - return !!window.matchMedia('(prefers-color-scheme: dark)')?.matches - } else if (darkMode === "on") { - return true - } else if (darkMode === "off") { - return false - } else if (darkMode === "timed") { - const start = option.darkModeTimeStart - const end = option.darkModeTimeEnd - if (start === undefined || end === undefined) { - return false - } - const now = new Date() - const currentSecs = now.getHours() * 3600 + now.getMinutes() * 60 + now.getSeconds() - if (start > end) { - // Mostly - return start <= currentSecs || currentSecs <= end - } else if (start < end) { - return start <= currentSecs && currentSecs <= end - } else { - return currentSecs === start - } - } - return false -} - -async function setDarkMode(mode: timer.option.DarkMode, period?: [number, number]): Promise<void> { - const exist: timer.option.AllOption = await optionHolder.get() - exist.darkMode = mode - if (mode === 'timed') { - const [start, end] = period || [] - exist.darkModeTimeStart = start - exist.darkModeTimeEnd = end - } - await optionHolder.set(exist) -} - -class OptionService { - /** - * @since 1.2.0 - */ - setBackupOption = setBackupOption - /** - * @since 3.0.0 - */ - setLocale = setLocale - /** - * @since 1.1.0 - */ - isDarkMode = isDarkMode - /** - * @since 3.0.0 - */ - setDarkMode = setDarkMode -} - -export default new OptionService() diff --git a/src/service/period-service.ts b/src/service/period-service.ts deleted file mode 100644 index b18395a22..000000000 --- a/src/service/period-service.ts +++ /dev/null @@ -1,45 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import periodDatabase from "@db/period-database" -import { after, compare, getDateString } from "@util/period" - -export type PeriodQueryParam = { - /** - * Required - */ - periodRange: timer.period.KeyRange -} - -function dateStrBetween(startDate: timer.period.Key, endDate: timer.period.Key): string[] { - const result: string[] = [] - while (compare(startDate, endDate) <= 0) { - result.push(getDateString(startDate)) - startDate = after(startDate, 1) - } - return result -} - -async function listBetween(param: PeriodQueryParam): Promise<timer.period.Result[]> { - const [start, end] = param.periodRange - const allDates = dateStrBetween(start, end) - return periodDatabase.getBatch(allDates) -} - -async function batchDeleteBetween(param: PeriodQueryParam): Promise<void> { - const [start, end] = param.periodRange - if (!start || !end) return - const allDates = dateStrBetween(start, end) - await periodDatabase.batchDelete(allDates) -} - -class PeriodService { - listBetween = listBetween - batchDeleteBetween = batchDeleteBetween -} - -export default new PeriodService() diff --git a/src/service/site-service.ts b/src/service/site-service.ts deleted file mode 100644 index 3113a9b80..000000000 --- a/src/service/site-service.ts +++ /dev/null @@ -1,138 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { listTabs, sendMsg2Tab } from "@api/chrome/tab" -import siteDatabase, { type SiteCondition } from "@db/site-database" -import { toMap } from "@util/array" -import { identifySiteKey, SiteMap, supportCategory } from "@util/site" -import { slicePageResult } from "./components/page-info" - -export type SiteQueryParam = SiteCondition - -export async function removeAlias(key: timer.site.SiteKey) { - const exist = await siteDatabase.get(key) - if (!exist) return - delete exist.alias - await siteDatabase.save(exist) -} - -export async function saveAlias(key: timer.site.SiteKey, alias: string, noRewrite?: boolean) { - const exist = await siteDatabase.get(key) - let toUpdate: timer.site.SiteInfo - if (exist) { - // Can't overwrite if alias is already existed - if (exist.alias && noRewrite) return - toUpdate = exist - toUpdate.alias = alias - } else { - toUpdate = { ...key, alias } - } - await siteDatabase.save(toUpdate) -} - -export async function batchSaveAliasNoRewrite(siteMap: SiteMap<string>): Promise<void> { - if (!siteMap?.count?.()) return - const allSites = await siteDatabase.getBatch(siteMap.keys()) - const existMap = new SiteMap<timer.site.SiteInfo>() - allSites.forEach(exist => existMap.put(exist, exist)) - - const toSave: timer.site.SiteInfo[] = [] - siteMap.forEach((k, alias) => { - const exist = existMap.get(k) - if (exist?.alias || !alias) return - toSave.push({ ...exist || k, alias }) - }) - await siteDatabase.save(...toSave) -} - -export async function removeIconUrl(key: timer.site.SiteKey) { - const exist = await siteDatabase.get(key) - if (!exist) return - delete exist.iconUrl - await siteDatabase.save(exist) -} - -export async function saveIconUrl(key: timer.site.SiteKey, iconUrl: string) { - const exist = await siteDatabase.get(key) - let toUpdate: timer.site.SiteInfo - if (exist) { - toUpdate = { ...exist } - toUpdate.iconUrl = iconUrl - } else { - toUpdate = { ...key, iconUrl } - } - await siteDatabase.save(toUpdate) -} - -export async function saveSiteRunState(key: timer.site.SiteKey, run: boolean) { - const exist = await siteDatabase.get(key) - if (!exist) return - exist.run = run - await siteDatabase.save(exist) - // send msg to tabs - const tabs = await listTabs() - for (const { id } of tabs) { - try { - id && await sendMsg2Tab(id, 'siteRunChange') - } catch { } - } -} - -export async function addSite(siteInfo: timer.site.SiteInfo): Promise<void> { - if (await siteDatabase.exist(siteInfo)) { - return - } - if (!supportCategory(siteInfo)) siteInfo.cate = undefined - await siteDatabase.save(siteInfo) -} - -export async function selectSitePage(param?: SiteQueryParam, page?: timer.common.PageQuery): Promise<timer.common.PageResult<timer.site.SiteInfo>> { - const origin: timer.site.SiteInfo[] = await siteDatabase.select(param) - const result: timer.common.PageResult<timer.site.SiteInfo> = slicePageResult(origin, page) - return result -} - -export function selectAllSites(param?: SiteQueryParam): Promise<timer.site.SiteInfo[]> { - return siteDatabase.select(param) -} - -export function batchGetSites(keys: timer.site.SiteKey[]): Promise<timer.site.SiteInfo[]> { - return siteDatabase.getBatch(keys) -} - -export function removeSites(...sites: timer.site.SiteKey[]): Promise<void> { - return siteDatabase.remove(...sites) -} - -export async function saveSiteCate(key: timer.site.SiteKey, cateId: number | undefined): Promise<void> { - if (!supportCategory(key)) return - - const exist = await siteDatabase.get(key) - await siteDatabase.save({ ...exist || key, cate: cateId }) -} - -export async function batchSaveSiteCate(cateId: number | undefined, keys: timer.site.SiteKey[]): Promise<void> { - keys = keys?.filter(supportCategory) - if (!keys?.length) return - - const allSites = await siteDatabase.getBatch(keys) - const siteMap = toMap(allSites, identifySiteKey) - - const toSave = keys.map(k => { - const s = siteMap[identifySiteKey(k)] - return ({ ...s || k, cate: cateId }) - }) - await siteDatabase.save(...toSave) -} - -/** -* @since 0.9.0 -*/ -export async function getSite(siteKey: timer.site.SiteKey): Promise<timer.site.SiteInfo> { - const info = await siteDatabase.get(siteKey) - return info ?? siteKey -} diff --git a/src/service/whitelist/holder.ts b/src/service/whitelist/holder.ts deleted file mode 100644 index 3661fbff1..000000000 --- a/src/service/whitelist/holder.ts +++ /dev/null @@ -1,36 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import whitelistDatabase from "@db/whitelist-database" -import WhitelistProcessor from './processor' - -/** - * The singleton implementation of whitelist holder - */ -class WhitelistHolder { - private processor = new WhitelistProcessor() - private postHandlers: NoArgCallback[] - - constructor() { - whitelistDatabase.selectAll().then(list => this.processor.setWhitelist(list)) - whitelistDatabase.addChangeListener(whitelist => { - this.processor.setWhitelist(whitelist) - this.postHandlers.forEach(handler => handler()) - }) - this.postHandlers = [] - } - - addPostHandler(handler: () => void) { - this.postHandlers.push(handler) - } - - contains(host: string, url: string): boolean { - return this.processor.contains(host, url) - } -} - -export default new WhitelistHolder() \ No newline at end of file diff --git a/src/service/whitelist/service.ts b/src/service/whitelist/service.ts deleted file mode 100644 index b9d267378..000000000 --- a/src/service/whitelist/service.ts +++ /dev/null @@ -1,34 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import whitelistDatabase from "@db/whitelist-database" -import { log } from '@src/common/logger' - -/** - * Service of whitelist - * - * @since 0.0.5 - */ -class WhitelistService { - - add(white: string): Promise<void> { - log('add to whitelist: ' + white) - // Just add to the white list, not to delete records since v0.1.1 - return whitelistDatabase.add(white) - } - - listAll(): Promise<string[]> { - return whitelistDatabase.selectAll() - } - - remove(white: string): Promise<void> { - log('remove whitelist: ' + white) - return whitelistDatabase.remove(white) - } -} - -export default new WhitelistService() \ No newline at end of file diff --git a/src/shared/route.ts b/src/shared/route.ts new file mode 100644 index 000000000..0ee3bcc23 --- /dev/null +++ b/src/shared/route.ts @@ -0,0 +1,43 @@ +export const APP_LIMIT_ROUTE = '/behavior/limit' +export type AppLimitQuery = { + url?: string + action?: 'create' +} + +export const APP_ANALYSIS_ROUTE = '/data/analysis' +export type AppAnalysisQuery = Partial<timer.site.SiteKey> & { + cateId?: string + url?: string +} + +export const APP_OPTION_ROUTE = '/additional/option' +export const APP_REPORT_ROUTE = '/data/report' +/** + * The query param of report page + */ +export type AppReportQuery = { + /** + * Query + */ + q?: string + /** + * Merge method + */ + mm?: Exclude<timer.stat.MergeMethod, 'date'> + /** + * Merge date + */ + md?: string + /** + * Date start + */ + ds?: string + /** + * Date end + */ + de?: string + /** + * Sorted column + */ + sc?: timer.core.Dimension +} \ No newline at end of file diff --git a/src/util/array.ts b/src/util/array.ts index 21686c068..1f0688eee 100644 --- a/src/util/array.ts +++ b/src/util/array.ts @@ -5,6 +5,18 @@ * https://opensource.org/licenses/MIT */ +export function groupBy<T, R>( + arr: T[], + keyFunc: (e: T, idx: number) => string | undefined | null, + downstream: (grouped: T[], key: string) => R +): Record<string, R> + +export function groupBy<T, R>( + arr: T[], + keyFunc: (e: T, idx: number) => number, + downstream: (grouped: T[], key: string) => R +): Record<number, R> + /** * Group by * @@ -107,15 +119,6 @@ export function sum(arr: number[]): number { return arr?.reduce?.((a, b) => (a ?? 0) + (b ?? 0), 0) ?? 0 } -/** - * @since 2.1.0 - * @returns null if arr is empty or null - */ -export function average(arr: number[]): number | null { - if (!arr?.length) return null - return sum(arr) / arr.length -} - export function allMatch<T>(arr: T[], predicate: (t: T) => boolean): boolean { return !arr?.filter?.(e => !predicate?.(e))?.length } @@ -131,22 +134,3 @@ export function range(len: number): number[] { } return arr } - -export function containsAny<T>(arr1: T[], arr2: T[]): boolean { - if (!arr1?.length || !arr2?.length) return false - - return !!arr1.find(e => arr2.includes(e)) -} - -export function joinAny<T = any>(arr: T[], separator: T): T[] { - if (!arr.length) return [separator] - - return arr.reduce<T[]>( - (arr, e) => { - arr.length && arr.push(separator) - arr.push(e) - return arr - }, - [], - ) -} \ No newline at end of file diff --git a/src/util/constant/environment.ts b/src/util/constant/environment.ts index 2004a3536..c2461ba53 100644 --- a/src/util/constant/environment.ts +++ b/src/util/constant/environment.ts @@ -41,7 +41,7 @@ export const BROWSER_NAME = browser export const IS_FIREFOX = BROWSER_NAME === 'firefox' -export const IS_EDGE = BROWSER_NAME === 'edge' +const IS_EDGE = BROWSER_NAME === 'edge' export const IS_CHROME = BROWSER_NAME === 'chrome' diff --git a/src/util/constant/option.ts b/src/util/constant/option.ts index 6de315b02..5b67520b6 100644 --- a/src/util/constant/option.ts +++ b/src/util/constant/option.ts @@ -5,90 +5,66 @@ * https://opensource.org/licenses/MIT */ -type AppearanceRequired = MakeRequired<timer.option.AppearanceOption, 'darkModeTimeStart' | 'darkModeTimeEnd'> +export const DEFAULT_APPEARANCE: timer.option.AppearanceRequired = { + displayWhitelistMenu: false, + // Change false to true @since 0.8.4 + displayBadgeText: true, + locale: "default", + printInConsole: true, + darkMode: "default", + // 6 PM - 6 AM + // 18*60*60 + darkModeTimeStart: 64800, + // 6*60*60 + darkModeTimeEnd: 21600, + // 1s + chartAnimationDuration: 1000, +} as const -export function defaultAppearance(): AppearanceRequired { - return { - displayWhitelistMenu: false, - // Change false to true @since 0.8.4 - displayBadgeText: true, - locale: "default", - printInConsole: true, - darkMode: "default", - // 6 PM - 6 AM - // 18*60*60 - darkModeTimeStart: 64800, - // 6*60*60 - darkModeTimeEnd: 21600, - // 1s - chartAnimationDuration: 1000, - } -} +export const DEFAULT_TRACKING: timer.option.TrackingRequired = { + autoPauseTracking: false, + // 10 minutes + autoPauseInterval: 600, + countLocalFiles: false, + countTabGroup: false, + weekStart: 'default', + storage: 'classic', +} as const -type TrackingRequired = MakeRequired<timer.option.TrackingOption, 'weekStart'> +export const DEFAULT_LIMIT: timer.option.LimitRequired = { + limitDelayDuration: 5, + limitLevel: 'nothing', + limitPassword: '', + limitVerifyDifficulty: 'easy', + limitReminder: false, + limitReminderDuration: 5, +} as const -export function defaultTracking(): TrackingRequired { - return { - autoPauseTracking: false, - // 10 minutes - autoPauseInterval: 600, - countLocalFiles: false, - countTabGroup: false, - weekStart: 'default', - storage: 'classic', - } -} +export const DEFAULT_BACKUP: timer.option.BackupOption = { + backupType: 'none', + clientName: 'unknown', + backupAuths: {}, + backupLogin: {}, + backupExts: {}, + autoBackUp: false, + autoBackUpInterval: 30, +} as const -type LimitRequired = MakeRequired<timer.option.LimitOption, 'limitPassword' | 'limitVerifyDifficulty' | 'limitReminderDuration'> +export const DEFAULT_ACCESSIBILITY: timer.option.AccessibilityOption = { + chartDecal: false +} as const -export function defaultLimit(): LimitRequired { - return { - limitLevel: 'nothing', - limitPassword: '', - limitVerifyDifficulty: 'easy', - limitReminder: false, - limitReminderDuration: 5, - } -} +export const DEFAULT_NOTIFICATION: timer.option.NotificationOption = { + notificationCycle: 'none', + notificationMethod: 'browser', + notificationOffset: 0, +} as const -export function defaultBackup(): timer.option.BackupOption { - return { - backupType: 'none', - clientName: 'unknown', - backupAuths: {}, - backupLogin: {}, - backupExts: {}, - autoBackUp: false, - autoBackUpInterval: 30, - } -} - -export function defaultAccessibility(): timer.option.AccessibilityOption { - return { - chartDecal: false, - } -} - -export function defaultNotification(): timer.option.NotificationOption { - return { - notificationCycle: 'none', - notificationMethod: 'browser', - notificationOffset: 0, - } -} - -export type DefaultOption = - & AppearanceRequired & TrackingRequired & LimitRequired - & timer.option.BackupOption & timer.option.AccessibilityOption - & timer.option.NotificationOption - -export function defaultOption(): DefaultOption { - return { - ...defaultAppearance(), - ...defaultTracking(), - ...defaultBackup(), - ...defaultLimit(), - ...defaultAccessibility(), - ...defaultNotification(), - } -} \ No newline at end of file +export const defaultOption = () => structuredClone({ + ...DEFAULT_APPEARANCE, + ...DEFAULT_TRACKING, + ...DEFAULT_BACKUP, + ...DEFAULT_LIMIT, + ...DEFAULT_ACCESSIBILITY, + ...DEFAULT_NOTIFICATION, +}) satisfies timer.option.DefaultOption \ No newline at end of file diff --git a/src/util/constant/url.ts b/src/util/constant/url.ts index 37e256ed4..7af029494 100644 --- a/src/util/constant/url.ts +++ b/src/util/constant/url.ts @@ -28,13 +28,6 @@ export const CHANGE_LOG_PAGE = 'https://github.com/sheepzh/time-tracker-4-browse */ export const GITHUB_ISSUE_ADD = 'https://github.com/sheepzh/time-tracker-4-browser/issues/new/choose' -/** - * Feedback powered by www.wjx.cn - * - * @since 0.1.6 - */ -export const ZH_FEEDBACK_PAGE = 'https://www.wjx.cn/vj/YFWwHUy.aspx' - /** * Feedback powered by support.qq.com * @@ -115,23 +108,16 @@ export const CROWDIN_PROJECT_ID = 516822 */ export const CROWDIN_HOMEPAGE = 'https://crowdin.com/project/timer-chrome-edge-firefox' -const webstorePages: Partial<Record<typeof BROWSER_NAME, [storePage: string, reviewPage: string]>> = { - firefox: [FIREFOX_HOMEPAGE, FIREFOX_HOMEPAGE + "/reviews"], - chrome: [CHROME_HOMEPAGE, CHROME_HOMEPAGE + "/reviews"], - edge: [EDGE_HOMEPAGE, EDGE_HOMEPAGE], +const REVIEW_PAGES: Partial<Record<typeof BROWSER_NAME, string>> = { + firefox: FIREFOX_HOMEPAGE + "/reviews", + chrome: CHROME_HOMEPAGE + "/reviews", + edge: EDGE_HOMEPAGE, } -const [webstorePage, reviewPage] = webstorePages[BROWSER_NAME] ?? [HOMEPAGE, CHROME_HOMEPAGE + "/reviews"] - -/** - * @since 0.0.5 - */ -export const WEBSTORE_PAGE = webstorePage - /** * @since 2.2.4 */ -export const REVIEW_PAGE = reviewPage +export const REVIEW_PAGE = REVIEW_PAGES[BROWSER_NAME] ?? CHROME_HOMEPAGE + "/reviews" /** * @since 3.7.13 diff --git a/src/util/dark-mode.ts b/src/util/dark-mode.ts deleted file mode 100644 index 3f694f446..000000000 --- a/src/util/dark-mode.ts +++ /dev/null @@ -1,40 +0,0 @@ -/** - * Copyright (c) 2022-present Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -/** - * Dark mode - * - * @since 1.1.0 - */ - -const THEME_ATTR = "data-theme" -const DARK_VAL = "dark" -const STORAGE_KEY = "isDark" -const STORAGE_FLAG = "1" - -function toggle0(isDarkMode: boolean, el?: Element) { - el = el || document.getElementsByTagName("html")?.[0] - el.setAttribute(THEME_ATTR, isDarkMode ? DARK_VAL : "") -} - -/** - * Init from local storage - */ -export function init(el?: Element) { - const val = isDarkMode() - toggle0(val, el) - return val -} - -export function toggle(isDarkMode: boolean, el?: Element) { - toggle0(isDarkMode, el) - localStorage.setItem(STORAGE_KEY, isDarkMode ? STORAGE_FLAG : '') -} - -export function isDarkMode() { - return localStorage.getItem(STORAGE_KEY) === STORAGE_FLAG -} \ No newline at end of file diff --git a/src/util/echarts.ts b/src/util/echarts.ts index 1b678ac75..05283368b 100644 --- a/src/util/echarts.ts +++ b/src/util/echarts.ts @@ -1,15 +1,6 @@ import type { - AriaComponentOption, - BarSeriesOption, - ComposeOption, - GridComponentOption, - LegendComponentOption, - LineSeriesOption, - PieSeriesOption, - ScatterSeriesOption, - TitleComponentOption, - ToolboxComponentOption, - VisualMapComponentOption, + AriaComponentOption, BarSeriesOption, ComposeOption, GaugeSeriesOption, GridComponentOption, LegendComponentOption, LineSeriesOption, + PieSeriesOption, ScatterSeriesOption, TitleComponentOption, ToolboxComponentOption, VisualMapComponentOption, } from "echarts" import type { AxisBaseOption } from 'echarts/types/src/coord/axisCommonTypes.js' import { isRtl } from "./document" @@ -65,7 +56,7 @@ const generateAriaOption = (chartDecal: boolean): AriaComponentOption => { } } -type SupportedSeriesOption = PieSeriesOption | LineSeriesOption | BarSeriesOption | ScatterSeriesOption +type SupportedSeriesOption = PieSeriesOption | LineSeriesOption | BarSeriesOption | ScatterSeriesOption | GaugeSeriesOption type GlobalEcOption = ComposeOption< | TitleComponentOption | GridComponentOption | LegendComponentOption | ToolboxComponentOption | VisualMapComponentOption @@ -86,6 +77,7 @@ export const processFont = (toProcess: unknown, elFont: string) => { } }) processArrayLike(series, s => { + if (!('label' in s)) return s.label = { ...s.label, fontFamily: s.label?.fontFamily ?? elFont, @@ -127,6 +119,9 @@ export const processAnimation = (toProcess: unknown, duration: number) => { if (duration > 0) { s.animation = true s.animationDuration = duration + s.animationDurationUpdate = duration + s.animationEasing ??= 'cubicInOut' + s.animationEasingUpdate ??= 'cubicInOut' } else { s.animation = false } diff --git a/src/util/lang.ts b/src/util/lang.ts index 2af7348c4..76cd6a2e6 100644 --- a/src/util/lang.ts +++ b/src/util/lang.ts @@ -1,30 +1,3 @@ -/** - * @since 2.1.7 - */ -export const deepCopy = <T = any | null | undefined>(obj: T): T => { - if (!obj) return obj - if (typeof obj !== 'object') return obj - - let deep: Record<string, any> = {} - Object.entries(obj).forEach(([k, v]) => { - if (typeof v !== "object" || v === null) { - deep[k] = v - } else if (Array.isArray(v)) { - deep[k] = v.map(e => deepCopy(e)) - } else if (v instanceof Set) { - deep[k] = new Set(v) - } else if (v instanceof Map) { - deep[k] = new Map(v) - } else if (v instanceof Date) { - deep[k] = new Date(v.getTime()) - } else { - // Ignored type - deep[k] = deepCopy(v) - } - }) - return deep as T -} - export const mergeObject = <T extends Record<string, any>>(defaults: T, newVal: Partial<T> | undefined): T => { if (newVal === undefined) return defaults diff --git a/src/util/limit.ts b/src/util/limit.ts index 67ff57b46..c25a6d7fb 100644 --- a/src/util/limit.ts +++ b/src/util/limit.ts @@ -1,20 +1,6 @@ import { EXCLUDING_PREFIX } from './constant/remain-host' import { getWeekDay, MILL_PER_MINUTE, MILL_PER_SECOND } from "./time" -export const DELAY_MILL = 5 * MILL_PER_MINUTE - -export const cleanCond = (origin: string | undefined): string | undefined => { - if (!origin) return undefined - - const startIdx = origin?.indexOf('//') - const endIdx = origin?.indexOf('?') - let res = origin.substring(startIdx === -1 ? 0 : startIdx + 2, endIdx === -1 ? undefined : endIdx) - while (res.endsWith('/')) { - res = res.substring(0, res.length - 1) - } - return res || undefined -} - const matchUrl = (cond: string, url: string): boolean => { return new RegExp(`^.*//${cond.split('*').join('.*')}`).test(url) } @@ -60,78 +46,38 @@ export const meetLimit = (limit: number | undefined, value: number | undefined): return !!limit && !!value && value > limit } -export const meetTimeLimit = ( - limitSec: number | undefined, wastedMill: number | undefined, - allowDelay: boolean | undefined, delayCount: number | undefined -): boolean => { - let realLimit = (limitSec ?? 0) * MILL_PER_SECOND - allowDelay && realLimit && (realLimit += DELAY_MILL * (delayCount ?? 0)) - return meetLimit(realLimit, wastedMill) -} - -type TimeLimitState = 'NORMAL' | 'REMINDER' | 'LIMITED' +type LimitInfo = { wasted: number, maxLimit: number | undefined } +type DelayInfo = { count: number, duration: number, allow: boolean } -type LimitStateResult = { - daily: TimeLimitState - weekly: TimeLimitState +export const meetTimeLimit = (limit: LimitInfo, delay: DelayInfo) => { + const { wasted, maxLimit = 0 } = limit + const { count, duration, allow } = delay + const realLimit = allow ? maxLimit + duration * MILL_PER_MINUTE * (count ?? 0) : maxLimit + return meetLimit(realLimit, wasted) } -export function calcTimeState(item: timer.limit.Item, reminderMills: number): LimitStateResult { - let res: LimitStateResult = { - daily: "NORMAL", - weekly: "NORMAL", - } - const { - time, waste, delayCount, - weekly, weeklyWaste, weeklyDelayCount, - allowDelay, - } = item || {} - // 1. daily states - if (meetTimeLimit(time, waste, allowDelay, delayCount)) { - res.daily = 'LIMITED' - } else if (reminderMills && meetTimeLimit(time, waste + reminderMills, allowDelay, delayCount)) { - res.daily = 'REMINDER' - } - - // 2. weekly states - if (meetTimeLimit(weekly, weeklyWaste, allowDelay, weeklyDelayCount)) { - res.weekly = 'LIMITED' - } else if (reminderMills && meetTimeLimit(weekly, weeklyWaste + reminderMills, allowDelay, weeklyDelayCount)) { - res.weekly = 'REMINDER' - } - - return res -} - -export function hasLimited(item: timer.limit.Item): boolean { - return hasDailyLimited(item) || hasWeeklyLimited(item) -} - -export function hasDailyLimited(item: timer.limit.Item): boolean { - const { time, count = 0, waste = 0, visit = 0, delayCount = 0, allowDelay = false } = item || {} - const timeMeet = meetTimeLimit(time, waste, allowDelay, delayCount) - const countMeet = meetLimit(count, visit) - return timeMeet || countMeet +export function hasDailyLimited(item: timer.limit.Item, delayDuration: number): boolean { + const { time, count, waste, visit, delayCount, allowDelay } = item + const delay = { count: delayCount, duration: delayDuration, allow: !!allowDelay } + const limit = { wasted: waste, maxLimit: (time ?? 0) * MILL_PER_SECOND } + return meetTimeLimit(limit, delay) || meetLimit(count, visit) } -export function hasWeeklyLimited(item: timer.limit.Item): boolean { - const { weekly = 0, weeklyCount = 0, weeklyWaste = 0, weeklyVisit = 0, weeklyDelayCount = 0, allowDelay = false } = item || {} - const timeMeet = meetTimeLimit(weekly, weeklyWaste, allowDelay, weeklyDelayCount) - const countMeet = meetLimit(weeklyCount, weeklyVisit) - return timeMeet || countMeet +export function hasWeeklyLimited(item: timer.limit.Item, delayDuration: number): boolean { + const { weekly, weeklyCount, weeklyWaste, weeklyVisit, weeklyDelayCount, allowDelay } = item + const delay = { count: weeklyDelayCount, duration: delayDuration, allow: !!allowDelay } + const limit = { wasted: weeklyWaste, maxLimit: (weekly ?? 0) * MILL_PER_SECOND } + return meetTimeLimit(limit, delay) || meetLimit(weeklyCount, weeklyVisit) } -export function isEnabledAndEffective(rule: timer.limit.Rule): boolean { - return !!rule?.enabled && isEffective(rule.weekdays) +export function hasLimited(item: timer.limit.Item, delayDuration: number): boolean { + return hasDailyLimited(item, delayDuration) || hasWeeklyLimited(item, delayDuration) } -export function isEffective(weekdays: timer.limit.Rule['weekdays']): boolean { +export function isEffective(weekdays: timer.limit.Rule['weekdays'], weekday?: number): boolean { const weekdayLen = weekdays?.length - if (!weekdayLen || weekdayLen <= 0 || weekdayLen >= 7) { - return true - } - const weekday = getWeekDay(new Date()) - return weekdays.includes(weekday) + if (!weekdayLen || weekdayLen <= 0 || weekdayLen >= 7) return true + return weekdays.includes(weekday ?? getWeekDay(new Date())) } const idx2Str = (time: number | undefined): string => { @@ -152,6 +98,6 @@ export const dateMinute2Idx = (date: Date): number => { } export const period2Str = (p: timer.limit.Period | undefined): string => { - const [start, end] = p || [] + const [start, end] = p ?? [] return `${idx2Str(start)}-${idx2Str(end)}` } diff --git a/src/util/merge.ts b/src/util/merge.ts deleted file mode 100644 index 1a05783c0..000000000 --- a/src/util/merge.ts +++ /dev/null @@ -1,20 +0,0 @@ -type Method = timer.stat.MergeMethod - -export const ALL_MERGE_METHODS: Method[] = ['date', 'domain', 'cate', 'group'] - -function judgeAdded(target: Method, newVal: Method[], oldVal: Method[]): boolean { - return newVal?.includes?.(target) && !oldVal?.includes?.(target) -} - -export function processNewMethod(oldVal: Method[] | undefined, newVal: Method[]): Method[] { - oldVal = oldVal || [] - if (judgeAdded('cate', newVal, oldVal)) { - // Add cate, so remove domain - return newVal.filter?.(v => v !== 'domain') - } - if (judgeAdded('domain', newVal, oldVal)) { - // Add domain, so remove cate - return newVal.filter?.(v => v !== 'cate') - } - return newVal -} \ No newline at end of file diff --git a/src/util/number.ts b/src/util/number.ts index 262eec576..8d89ece39 100644 --- a/src/util/number.ts +++ b/src/util/number.ts @@ -5,22 +5,14 @@ * https://opensource.org/licenses/MIT */ -/** - * @since 0.6.0 - * @returns T/F - */ -export function isInteger(str: string): boolean { - return tryParseInteger(str)[0] -} - /** * @since 0.6.0 * @returns [true, intValue] if str is an integer, or [false, str] */ -export function tryParseInteger(str: string): [boolean, number | string] { +export function tryParseInteger(str: string): [true, number] | [false, string] { const num: number = Number.parseInt(str) const isInteger: boolean = !isNaN(num) && num.toString().length === str.length - return [isInteger, isInteger ? num : str] + return isInteger ? [true, num] : [false, str] } /** @@ -28,4 +20,6 @@ export function tryParseInteger(str: string): [boolean, number | string] { */ export function randomIntBetween(lowerInclusive: number, upperExclusive: number): number { return Math.floor(Math.random() * (upperExclusive - lowerInclusive)) + lowerInclusive -} \ No newline at end of file +} + +export const clamp = (v: number, min: number, max: number): number => Math.min(max, Math.max(min, v)) \ No newline at end of file diff --git a/src/util/pattern.ts b/src/util/pattern.ts index 22f39aa0b..d6dec176c 100644 --- a/src/util/pattern.ts +++ b/src/util/pattern.ts @@ -47,10 +47,6 @@ export function isIpAndPort(host: string) { return reg.test(host) } -export function isLocalhost(host: string) { - return host?.startsWith?.('localhost') -} - /** * Test whether the host is a valid host * @@ -109,7 +105,7 @@ export function judgeVirtualFast(host: string): boolean { return host?.includes('/') || host?.includes('*') } -export type HostInfo = { +type HostInfo = { /** * Including port */ @@ -205,4 +201,4 @@ export function compileAntPattern(antPattern: string): RegExp { } return new RegExp("^(.+://)?" + patternStr + "/?([\\?#].*)?$") -} \ No newline at end of file +} diff --git a/src/util/period.ts b/src/util/period.ts index 920000c44..963d5119f 100644 --- a/src/util/period.ts +++ b/src/util/period.ts @@ -5,13 +5,12 @@ * https://opensource.org/licenses/MIT */ -import { groupBy } from "./array" import { MILL_PER_DAY, MILL_PER_MINUTE } from "./time" export const MINUTE_PER_PERIOD = 15 -export const PERIOD_PER_DATE = 24 * 60 / MINUTE_PER_PERIOD +const PERIOD_PER_DATE = 24 * 60 / MINUTE_PER_PERIOD export const MAX_PERIOD_ORDER = PERIOD_PER_DATE - 1 -export const MILL_PER_PERIOD = MINUTE_PER_PERIOD * MILL_PER_MINUTE +const MILL_PER_PERIOD = MINUTE_PER_PERIOD * MILL_PER_MINUTE export function keyOf(time: Date | number, order?: number): timer.period.Key { time = time instanceof Date ? time : new Date(time) @@ -24,11 +23,6 @@ export function keyOf(time: Date | number, order?: number): timer.period.Key { return { year, month, date, order } } -export function copyKeyWith(old: timer.period.Key, newOrder: number): timer.period.Key { - const { year, month, date } = old - return { year, month, date, order: newOrder } -} - export function indexOf(key: timer.period.Key): number { if (!key) return 0 const { year, month, date, order } = key @@ -42,23 +36,6 @@ export function compare(a: timer.period.Key, b: timer.period.Key): number { return indexOf(a) - indexOf(b) } -export function keyBefore(key: timer.period.Key, orderCount: number): timer.period.Key { - let order = key.order - let decomposition = 0 - - while (order < orderCount) { - decomposition++ - order += PERIOD_PER_DATE - } - order = order - orderCount - if (decomposition) { - const newDate = new Date(startOfKey(key).getTime() - MILL_PER_DAY * decomposition) - return keyOf(newDate, order) - } else { - return copyKeyWith(key, order) - } -} - export function after(key: timer.period.Key, orderCount: number): timer.period.Key { const date = new Date(key.year, key.month - 1, key.date, 0, (key.order + orderCount) * MINUTE_PER_PERIOD, 1) return keyOf(date) @@ -68,10 +45,6 @@ export function startOfKey(key: timer.period.Key): Date { return new Date(key.year, key.month - 1, key.date, 0, MINUTE_PER_PERIOD * key.order) } -export function lastKeyOfLastDate(key: timer.period.Key): timer.period.Key { - return keyBefore(key, key.order + 1) -} - export function getDateString(key: timer.period.Key) { return `${key.year}${key.month < 10 ? '0' : ''}${key.month}${key.date < 10 ? '0' : ''}${key.date}` } @@ -81,46 +54,11 @@ export function rowOf(endKey: timer.period.Key, duration?: number, milliseconds? milliseconds = milliseconds || 0 const date = getDateString(endKey) const endStart = startOfKey(endKey) - const endTime = new Date(endStart.getTime() + MILL_PER_PERIOD) - const startTime = duration === 1 ? endStart : new Date(endStart.getTime() - (duration - 1) * MILL_PER_PERIOD) + const endTime = endStart.getTime() + MILL_PER_PERIOD + const startTime = duration === 1 ? endStart.getTime() : endStart.getTime() - (duration - 1) * MILL_PER_PERIOD return { startTime, endTime, milliseconds, date } } -export function startOrderOfRow(row: timer.period.Row): number { - return (row.startTime.getHours() * 60 + row.startTime.getMinutes()) / MINUTE_PER_PERIOD -} - -/** - * @param rows period rows - * @returns [0, 12) - */ -export function calcMostPeriodOf2Hours(rows: timer.period.Result[]): number { - const periodCount = rows?.length ?? 0 - // Order [0, 95] - const averageTimePerPeriod: { [order: number]: number } = groupBy(rows, - p => p.order, - (grouped: timer.period.Result[]) => { - const periodMills = grouped.map(p => p.milliseconds) - if (!periodCount) { - return 0 - } - return Math.floor(periodMills.reduce((a, b) => a + b, 0) / periodCount) - } - ) - // Merged per 2 hours - const averageTimePer2Hours: { [idx: number]: number } = groupBy(Object.entries(averageTimePerPeriod), - ([order]) => Math.floor(parseInt(order) / 8), - averages => averages.map(a => a[1]).reduce((a, b) => a + b, 0) - ) - // The two most frequent online hours - const most2Hour: number = parseInt( - Object.entries(averageTimePer2Hours) - .sort((a, b) => a[1] - b[1]) - .reverse()[0]?.[0] - ) - return most2Hour -} - function generateOrderMap(data: timer.period.Row[], periodSize: number): Map<number, number> { const map: Map<number, number> = new Map() data.forEach(item => { @@ -131,6 +69,11 @@ function generateOrderMap(data: timer.period.Row[], periodSize: number): Map<num return map } +function startOrderOfRow(row: timer.period.Row): number { + const d = new Date(row.startTime) + return (d.getHours() * 60 + d.getMinutes()) / MINUTE_PER_PERIOD +} + function cvt2AverageResult(map: Map<number, number>, periodSize: number, dateNum: number): timer.period.Row[] { const result: timer.period.Row[] = [] let period = keyOf(new Date(), 0) @@ -148,7 +91,7 @@ export function averageByDay(data: timer.period.Row[], periodSize: number): time if (!data?.length) return [] const rangeStart = data[0]?.startTime const rangeEnd = data[data.length - 1]?.endTime - const dateNum = (rangeEnd.getTime() - rangeStart.getTime()) / MILL_PER_DAY + const dateNum = (rangeEnd - rangeStart) / MILL_PER_DAY const map = generateOrderMap(data, periodSize) return cvt2AverageResult(map, periodSize, dateNum) } diff --git a/src/util/site.ts b/src/util/site.ts index a188d5aa4..0cbf4d596 100644 --- a/src/util/site.ts +++ b/src/util/site.ts @@ -90,10 +90,11 @@ export function identifySiteKey(site: timer.site.SiteKey | undefined): string { return (TYPE_PREFIX_MAP[type] ?? ' ') + (host || '') } -export function parseSiteKeyFromIdentity(keyIdentity: string): timer.site.SiteKey | undefined { - const type = PREFIX_TYPE_MAP[keyIdentity?.charAt?.(0) as SiteIdentityPrefix] +export function parseSiteIdentity(identity: string | undefined): timer.site.SiteKey | undefined { + if (!identity) return + const type = PREFIX_TYPE_MAP[identity.charAt(0) as SiteIdentityPrefix] if (!type) return - const host = keyIdentity?.substring(1)?.trim?.() + const host = identity.substring(1).trim() if (!host) return return { type, host } } @@ -115,23 +116,29 @@ export function distinctSites(list: timer.site.SiteKey[]): timer.site.SiteKey[] } export class SiteMap<T> { - private innerMap: Record<string, [timer.site.SiteKey, T | undefined]> + private innerMap: Record<string, [timer.site.SiteKey, T]> constructor() { this.innerMap = {} } - public put(site: timer.site.SiteKey, t: T | undefined): void { + static identify<T extends timer.site.SiteKey>(data: T[]): SiteMap<T> { + const map = new SiteMap<T>() + data.forEach(item => map.put(item, item)) + return map + } + + public put(site: timer.site.SiteKey, t: T): void { const key = identifySiteKey(site) this.innerMap[key] = [site, t] } - public get(site: timer.site.SiteKey): T | undefined { + public get(site: timer.site.SiteKey): T | null { const key = identifySiteKey(site) - return this.innerMap[key]?.[1] + return this.innerMap[key]?.[1] ?? null } - public map<R>(mapper: (key: timer.site.SiteKey, value: T | undefined) => R): R[] { + public map<R>(mapper: (key: timer.site.SiteKey, value: T) => R): R[] { return Object.values(this.innerMap).map(([site, val]) => mapper?.(site, val)) } @@ -143,7 +150,7 @@ export class SiteMap<T> { return Object.values(this.innerMap).map(v => v[0]) } - public forEach(func: (k: timer.site.SiteKey, v: T | undefined, idx: number) => void) { + public forEach(func: (k: timer.site.SiteKey, v: T, idx: number) => void) { if (!func) return Object.values(this.innerMap).forEach(([k, v], idx) => func(k, v, idx)) } diff --git a/src/util/stat.ts b/src/util/stat.ts index 7904d2616..f052653d5 100644 --- a/src/util/stat.ts +++ b/src/util/stat.ts @@ -1,3 +1,4 @@ +import { toMap } from './array' import { identifySiteKey } from "./site" /** @@ -36,19 +37,15 @@ export const isNormalSite = (row: timer.stat.Row): row is timer.stat.SiteRow => return 'siteKey' in row && row.siteKey.type === 'normal' } -export const isMergedSite = (row: timer.stat.Row): row is timer.stat.SiteRow => { - return 'siteKey' in row && row.siteKey.type === 'merged' -} - -export const isGroup = (row: timer.stat.Row): row is timer.stat.GroupRow => { +export const isGroup = (row: timer.stat.StatKey): row is timer.stat.GroupRow => { return 'groupKey' in row } -export const isSite = (row: timer.stat.Row): row is timer.stat.SiteRow => { +export const isSite = (row: timer.stat.StatKey): row is timer.stat.SiteRow => { return 'siteKey' in row } -export const isCate = (row: timer.stat.Row): row is timer.stat.CateRow => { +export const isCate = (row: timer.stat.StatKey): row is timer.stat.CateRow => { return 'cateKey' in row } @@ -78,4 +75,15 @@ export const getGroupName = (groupMap: Record<string, chrome.tabGroups.TabGroup> const { groupKey } = row const title = groupMap[groupKey]?.title return title ?? `ID: ${groupKey}` +} + +export const mergeWith = async <T extends timer.core.RowKey,>( + original: T[], exist: timer.core.Row[], + mergeFunc: (o: T, e: timer.core.Row | undefined) => Awaitable<void>, +) => { + const keyOf = ({ date, host }: timer.core.RowKey) => `${date}${host}` + const existMap = toMap(exist, keyOf) + for (const o of original) { + await mergeFunc(o, existMap[keyOf(o)]) + } } \ No newline at end of file diff --git a/src/util/time.ts b/src/util/time.ts index 9d5dd1372..314ce7ae2 100644 --- a/src/util/time.ts +++ b/src/util/time.ts @@ -47,7 +47,7 @@ export function formatTime(time: Date | number, cFormat?: string) { return timeStr } -export function formatTimeYMD(time: Date | number) { +export function formatTimeYMD(time: Date | number): string { return formatTime(time, '{y}{m}{d}') } @@ -56,7 +56,7 @@ type PeriodMsgFormat = Record<'dayMsg' | 'hourMsg' | 'minuteMsg' | 'secondMsg', /** * Format milliseconds for display */ -export function formatPeriod(milliseconds: number, message: PeriodMsgFormat): string { +function formatPeriod(milliseconds: number, message: PeriodMsgFormat): string { const prefix = milliseconds < 0 ? '-' : '' milliseconds = Math.abs(milliseconds) const { dayMsg, hourMsg, minuteMsg, secondMsg } = message @@ -206,6 +206,7 @@ export function getBirthday(): Date { date.setHours(0, 0, 0, 0) return date } + export const BIRTHDAY = '20220303' /** @@ -260,3 +261,16 @@ export function parseTime(dateStr: string | undefined): Date | undefined { result.setDate(date) return result } + +export type DateRange = Date | [Date?, Date?] | undefined + +export const cvtDateRange2Str = (range: DateRange): [string?, string?] | undefined => { + if (range === undefined) return undefined + if (range instanceof Date) { + // The same day + const date = formatTimeYMD(range) + return [date, date] + } + const [start, end] = range + return [start && formatTimeYMD(start), end && formatTimeYMD(end)] +} \ No newline at end of file diff --git a/src/util/tuple.ts b/src/util/tuple.ts index 7d114d701..ebb9d707d 100644 --- a/src/util/tuple.ts +++ b/src/util/tuple.ts @@ -39,9 +39,3 @@ export const multiTuple = <L extends number>(a: Vector<L>, multiFactor: number): return a?.map(v => (v ?? 0) * (multiFactor ?? 0)) as unknown as Vector<L> } -/** - * Divide tuple - */ -export const divideTuple = <L extends number>(a: Vector<L>, divideFactor: number): Vector<L> => { - return a?.map(v => (v ?? 0) / (divideFactor ?? 0)) as unknown as Vector<L> -} diff --git a/test-e2e/backup/common.ts b/test-e2e/backup/common.ts index d48b1bec4..f9f7ee7b5 100644 --- a/test-e2e/backup/common.ts +++ b/test-e2e/backup/common.ts @@ -1,6 +1,7 @@ import type { ElementHandle, Page } from 'puppeteer' -import { type LaunchContext, sleep } from '../common/base' +import { type LaunchContext } from '../common/base' import { waitForMessage, waitForSuccMessage } from '../common/message' +import { sleep } from '../common/util' const typeNames: Record<timer.backup.Type, string> = { gist: 'gist', diff --git a/test-e2e/backup/gist.test.ts b/test-e2e/backup/gist.test.ts index 1a9fce883..29ea54cdd 100644 --- a/test-e2e/backup/gist.test.ts +++ b/test-e2e/backup/gist.test.ts @@ -1,5 +1,6 @@ -import { launchBrowser, MOCK_URL, sleep, type LaunchContext } from '../common/base' +import { launchBrowser, type LaunchContext } from '../common/base' import { readRecordsOfFirstPage } from '../common/record' +import { MOCK_URL, sleep } from '../common/util' import { BackupOptionWrapper } from './common' const GIST_MOCK_ORIGIN = 'http://127.0.0.1:12347' @@ -7,9 +8,11 @@ const GIST_MOCK_TOKEN = 'github_gist_mock_token' let context: LaunchContext -describe('Backup with gist', () => { +const describeOptional = process.env.GITHUB_ACTIONS ? describe.skip : describe + +describeOptional('Backup with gist', () => { beforeEach(async () => { - context = await launchBrowser({ proxies: [{ host: 'api.github.com', target: GIST_MOCK_ORIGIN }] }) + context = await launchBrowser({ bgProxies: [{ host: 'api.github.com', target: GIST_MOCK_ORIGIN }] }) }) afterEach(() => context.close()) @@ -17,23 +20,28 @@ describe('Backup with gist', () => { test('create and update gist', async () => { // Fill in gist parameters const option = new BackupOptionWrapper(context) - const page = await option.changeType('gist') + await option.changeType('gist') const tokenInput = await option.$('input[name="token"]') expect(tokenInput).toBeTruthy() // Assert test invalid with invalid token await tokenInput!.type('foobar' + Date.now()) + await sleep(.5) await option.assertTestInvalid() // Assert token is valid await tokenInput!.focus() - await page.keyboard.down('Control') - await page.keyboard.press('a') - await page.keyboard.up('Control') + await tokenInput!.evaluate(el => { + if (!(el instanceof HTMLInputElement)) return + el.value = '' + el.dispatchEvent(new Event('input', { bubbles: true })) + }) await tokenInput!.type(GIST_MOCK_TOKEN) + await sleep(.5) await option.assertTestValid() + // Visit site const sitePage = await context.newPageAndWaitCsInjected(MOCK_URL) await sleep(2) diff --git a/test-e2e/common/base.ts b/test-e2e/common/base.ts index 6c881564b..04ac99e12 100644 --- a/test-e2e/common/base.ts +++ b/test-e2e/common/base.ts @@ -1,50 +1,44 @@ -import { type Browser, launch, type Page, type Target } from "puppeteer" +import { type Browser, type CDPSession, launch, type Page, type Target } from "puppeteer" import { E2E_OUTPUT_PATH } from "../../rspack/constant" -import { removeAllWhitelist } from './whitelist.test' +import { removeAllWhitelist } from './whitelist' const USE_HEADLESS_PUPPETEER = !!process.env['USE_HEADLESS_PUPPETEER'] -export interface HostProxy { +type HostProxy = { host: string target: string } -const setupProxies = async (target: Target, proxies: HostProxy[]) => { +async function setupBgProxies(serviceWorker: Target, proxies: HostProxy[]): Promise<CDPSession | undefined> { + let session: CDPSession | undefined try { - const session = await target.createCDPSession() + session = await serviceWorker.createCDPSession() await session.send('Fetch.enable', { - patterns: [{ urlPattern: '*', requestStage: 'Request' }], - }) - session.on('Fetch.requestPaused', async (event: { requestId: string; request: { url: string } }) => { - try { - const requestUrl = new URL(event.request.url) - const proxy = proxies.find(p => requestUrl.hostname === p.host || requestUrl.host === p.host) - if (proxy) { - const targetUrl = new URL(proxy.target) - requestUrl.protocol = targetUrl.protocol - requestUrl.hostname = targetUrl.hostname - requestUrl.port = targetUrl.port - await session.send('Fetch.continueRequest', { - requestId: event.requestId, - url: requestUrl.toString(), - }) - } else { - await session.send('Fetch.continueRequest', { requestId: event.requestId }) - } - } catch { - try { await session.send('Fetch.continueRequest', { requestId: event.requestId }) } catch { /* ignore */ } - } + patterns: proxies.map(p => ({ urlPattern: `*://${p.host}/*`, requestStage: 'Request' as const })), }) } catch { - // not all targets support CDP sessions (e.g. browser target) - } -} - -async function applyProxies(browser: Browser, proxies: HostProxy[]): Promise<void> { - for (const target of browser.targets()) { - await setupProxies(target, proxies) + // Target may have been closed before the session was ready + return } - browser.on('targetcreated', target => setupProxies(target, proxies)) + session.on('Fetch.requestPaused', async event => { + const { requestId, request: { url: originUrl } } = event + try { + const url = new URL(originUrl) + const proxy = proxies.find(p => url.hostname === p.host || url.host === p.host) + if (!proxy) { + await session.send('Fetch.continueRequest', { requestId }) + return + } + const targetUrl = new URL(proxy.target) + url.protocol = targetUrl.protocol + url.hostname = targetUrl.hostname + url.port = targetUrl.port + await session.send('Fetch.continueRequest', { requestId, url: String(url) }) + } catch { + await session.send('Fetch.continueRequest', { requestId }) + } + }) + return session } export interface LaunchContext { @@ -61,16 +55,15 @@ export interface LaunchContext { } class LaunchContextWrapper implements LaunchContext { - browser: Browser - extensionId: string - constructor(browser: Browser, extensionId: string) { - this.browser = browser - this.extensionId = extensionId - } + constructor( + readonly browser: Browser, readonly extensionId: string, + private cdpSession: CDPSession | undefined + ) { } - close(): Promise<void> { - return this.browser.close() + async close(): Promise<void> { + this.cdpSession?.detach().catch(() => { }) + await this.browser.close() } async openAppPage(route: string): Promise<Page> { @@ -82,9 +75,7 @@ class LaunchContextWrapper implements LaunchContext { async newPage(url?: string): Promise<Page> { const page = await this.browser.newPage() - if (url) { - await page.goto(url, { waitUntil: 'domcontentloaded' }) - } + url && await page.goto(url, { waitUntil: 'domcontentloaded' }) return page } @@ -98,50 +89,40 @@ class LaunchContextWrapper implements LaunchContext { type BrowserOptions = { dirPath?: string - proxies?: HostProxy[] + bgProxies?: HostProxy[] } export async function launchBrowser(options?: BrowserOptions): Promise<LaunchContext> { - const { dirPath = E2E_OUTPUT_PATH, proxies } = options ?? {} + const { dirPath = E2E_OUTPUT_PATH, bgProxies } = options ?? {} const args = [ `--disable-extensions-except=${dirPath}`, `--load-extension=${dirPath}`, '--start-maximized', '--no-sandbox', ] + // GitHub-hosted runners use a small /dev/shm; Chrome can crash or hang without this flag. + if (process.env['GITHUB_ACTIONS'] === 'true') { + args.push('--disable-gpu', '--disable-dev-shm-usage') + } // Test with large screen USE_HEADLESS_PUPPETEER && args.push('--window-size=1880,1000') const browser = await launch({ defaultViewport: null, headless: USE_HEADLESS_PUPPETEER, + enableExtensions: true, args, }) - const serviceWorker = await browser.waitForTarget(target => target.type() === 'service_worker') - const url = serviceWorker.url() - let extensionId: string | undefined = url.split('/')[2] - if (!extensionId) { - throw new Error('Failed to detect extension id') - } + const sw = await browser.waitForTarget(target => target.type() === 'service_worker') + const url = sw.url() + let extensionId = url.split('/')[2] + if (!extensionId) throw new Error('Failed to detect extension id') - const context = new LaunchContextWrapper(browser, extensionId) - - proxies?.length && await applyProxies(browser, proxies) + const cdpSession = bgProxies?.length ? await setupBgProxies(sw, bgProxies) : undefined + const context = new LaunchContextWrapper(browser, extensionId, cdpSession) // remove whitelist added by service_worker await removeAllWhitelist(context) return context -} - -export function sleep(seconds: number): Promise<void> { - return new Promise(resolve => setTimeout(resolve, seconds * 1000)) -} - -export const MOCK_HOST = "127.0.0.1:12345" - -export const MOCK_URL = "http://" + MOCK_HOST - -export const MOCK_HOST_2 = "127.0.0.1:12346" - -export const MOCK_URL_2 = "http://" + MOCK_HOST_2 +} \ No newline at end of file diff --git a/test-e2e/common/record.ts b/test-e2e/common/record.ts index 5fbd7469d..c0178b409 100644 --- a/test-e2e/common/record.ts +++ b/test-e2e/common/record.ts @@ -1,4 +1,4 @@ -import { LaunchContext } from "./base" +import type { LaunchContext } from "./base" type RecordRow = { date: string diff --git a/test-e2e/common/util.ts b/test-e2e/common/util.ts new file mode 100644 index 000000000..0911dcf0e --- /dev/null +++ b/test-e2e/common/util.ts @@ -0,0 +1,11 @@ +export function sleep(seconds: number): Promise<void> { + return new Promise(resolve => setTimeout(resolve, seconds * 1000)) +} + +export const MOCK_HOST = "127.0.0.1:12345" + +export const MOCK_URL = "http://" + MOCK_HOST + +const MOCK_HOST_2 = "127.0.0.1:12346" + +export const MOCK_URL_2 = "http://" + MOCK_HOST_2 diff --git a/test-e2e/common/whitelist-skip.test.ts b/test-e2e/common/whitelist-skip.test.ts new file mode 100644 index 000000000..32852fadf --- /dev/null +++ b/test-e2e/common/whitelist-skip.test.ts @@ -0,0 +1,8 @@ +import { launchBrowser } from './base' +import { createWhitelist } from './whitelist' + +// Run to test the function, but skip it in normal test runs +test.skip('create whitelist', async () => { + const context = await launchBrowser() + await createWhitelist(context, 'example.com') +}) \ No newline at end of file diff --git a/test-e2e/common/whitelist.test.ts b/test-e2e/common/whitelist.ts similarity index 79% rename from test-e2e/common/whitelist.test.ts rename to test-e2e/common/whitelist.ts index c15ed8336..a8e6f9d88 100644 --- a/test-e2e/common/whitelist.test.ts +++ b/test-e2e/common/whitelist.ts @@ -1,4 +1,5 @@ -import { launchBrowser, type LaunchContext, sleep } from "./base" +import { type LaunchContext } from "./base" +import { sleep } from './util' export async function createWhitelist(context: LaunchContext, white: string) { const whitePage = await context.openAppPage('/additional/whitelist') @@ -27,10 +28,4 @@ export async function removeAllWhitelist(context: LaunchContext) { await chrome.storage.local.remove('__timer__WHITELIST') }) await whitePage.close() -} - -// Run to test the function, but skip it in normal test runs -test.skip('create whitelist', async () => { - const context = await launchBrowser() - await createWhitelist(context, 'example.com') -}) \ No newline at end of file +} \ No newline at end of file diff --git a/test-e2e/install.test.ts b/test-e2e/install.test.ts deleted file mode 100644 index b52202a50..000000000 --- a/test-e2e/install.test.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { join } from "path" -import { launchBrowser, type LaunchContext } from "./common/base" - -let context: LaunchContext - -describe('After installed', () => { - beforeEach(async () => { - const path = join(__dirname, '..', 'dist_prod') - context = await launchBrowser({ dirPath: path }) - }) - - afterEach(async () => context.close()) - - test('Open the official page', async () => { - const { browser } = context - await browser.waitForTarget(target => target.url().includes('wfhg.cc')) - }, 5000) -}) - diff --git a/test-e2e/limit/common.ts b/test-e2e/limit/common.ts index 18836b199..b28748feb 100644 --- a/test-e2e/limit/common.ts +++ b/test-e2e/limit/common.ts @@ -1,5 +1,21 @@ -import { ElementHandle, type Page } from "puppeteer" -import { sleep } from "../common/base" +import { ElementHandle, type Frame, type Page } from "puppeteer" +import { sleep } from "../common/util" + +export async function waitForLimitFrame(page: Page, timeout = 5000): Promise<Frame> { + return page.waitForFrame(f => f.url().includes('limit.html'), { timeout }) +} + +export async function isLimitModalVisible(page: Page): Promise<boolean> { + await page.waitForSelector('extension-time-tracker-overlay', { timeout: 3000 }) + return await page.evaluate(async () => { + const overlay = document.querySelector('extension-time-tracker-overlay') + if (!overlay) return false + const iframe = overlay.shadowRoot?.firstElementChild + return iframe instanceof HTMLIFrameElement + && iframe.style.visibility !== 'hidden' + && iframe.style.display !== 'none' + }) +} export async function createLimitRule(rule: timer.limit.Rule, page: Page) { const createButton = await page.$('.el-card:first-child .el-button:last-child') @@ -72,8 +88,15 @@ export async function fillTimeLimit(value: number | undefined, input: ElementHan await sleep(.2) } -export async function fillVisitLimit(value: number, input: ElementHandle<HTMLInputElement>, page: Page) { +async function fillVisitLimit(value: number, input: ElementHandle<HTMLInputElement>, page: Page) { await input.focus() await page.keyboard.press('Delete') await page.keyboard.type(`${value ?? 0}`) +} + +export async function clickDelay(testPage: Page) { + const limitFrame = await waitForLimitFrame(testPage) + const moreBtn = await limitFrame.waitForSelector('.el-button--primary') + await moreBtn!.click() + await sleep(.8) } \ No newline at end of file diff --git a/test-e2e/limit/daily-time.test.ts b/test-e2e/limit/daily-time.test.ts index a7d6513ff..6629c89c0 100644 --- a/test-e2e/limit/daily-time.test.ts +++ b/test-e2e/limit/daily-time.test.ts @@ -1,10 +1,11 @@ -import { launchBrowser, type LaunchContext, MOCK_URL, sleep } from "../common/base" -import { createLimitRule, fillTimeLimit } from "./common" +import { launchBrowser, type LaunchContext } from '../common/base' +import { MOCK_URL, sleep } from '../common/util' +import { createLimitRule, fillTimeLimit, isLimitModalVisible, waitForLimitFrame } from "./common" let context: LaunchContext describe('Daily time limit', () => { - beforeEach(async () => context = await launchBrowser()) + beforeEach(async () => { context = await launchBrowser() }) afterEach(() => context.close()) @@ -41,9 +42,13 @@ describe('Daily time limit', () => { await sleep(2.1) // 4. Limited - const { name, time } = await testPage.evaluate(async () => { - const shadow = document.querySelector('extension-time-tracker-overlay') - const descEl = shadow?.shadowRoot?.querySelector('#app .el-descriptions:not([style*="display: none"])') + const limitFrame = await waitForLimitFrame(testPage) + await limitFrame.waitForFunction(() => { + const td = document.querySelector('#app .el-descriptions:not([style*="display: none"]) tr td:nth-child(2)') + return td?.textContent && td.textContent !== '-' + }, { timeout: 5000 }) + const { name, time } = await limitFrame.evaluate(() => { + const descEl = document.querySelector('#app .el-descriptions:not([style*="display: none"])') const trs = descEl?.querySelectorAll('tr') const name = trs?.[0]?.querySelector('td:nth-child(2)')?.textContent const timeStr = trs?.[3]?.querySelector('td:nth-child(2) .el-tag--danger')?.textContent @@ -79,11 +84,7 @@ describe('Daily time limit', () => { // 7. Modal disappear await testPage.bringToFront() await sleep(.5) - const modalExist = await testPage.evaluate(() => { - const shadow = document.querySelector('extension-time-tracker-overlay') - if (!shadow) return false - return !!shadow.shadowRoot!.querySelector('body:not([style*="display: none"])') - }) + const modalExist = await isLimitModalVisible(testPage) expect(modalExist).toBeFalsy() }, 60000) }) \ No newline at end of file diff --git a/test-e2e/limit/daily-visit.test.ts b/test-e2e/limit/daily-visit.test.ts index f7a33b1e1..6c1ce5075 100644 --- a/test-e2e/limit/daily-visit.test.ts +++ b/test-e2e/limit/daily-visit.test.ts @@ -1,10 +1,11 @@ -import { launchBrowser, type LaunchContext, MOCK_URL, sleep } from "../common/base" -import { createLimitRule } from "./common" +import { launchBrowser, type LaunchContext } from '../common/base' +import { MOCK_URL, sleep } from '../common/util' +import { createLimitRule, isLimitModalVisible, waitForLimitFrame } from "./common" let context: LaunchContext describe('Daily time limit', () => { - beforeEach(async () => context = await launchBrowser()) + beforeEach(async () => { context = await launchBrowser() }) afterEach(() => context.close()) @@ -36,13 +37,16 @@ describe('Daily time limit', () => { // Waiting for limit message handling await sleep(2) - const { name, count } = await testPage.evaluate(async () => { - const shadow = document.querySelector('extension-time-tracker-overlay') - if (!shadow) return {} - const descEl = shadow!.shadowRoot!.querySelector('#app .el-descriptions:not([style*="display: none"])') - const trs = descEl!.querySelectorAll('tr') - const name = trs[0].querySelector('td:nth-child(2)')!.textContent - const count = trs[3].querySelector('td:nth-child(2) .el-tag--danger')!.textContent + const limitFrame = await waitForLimitFrame(testPage) + await limitFrame.waitForFunction(() => { + const td = document.querySelector('#app .el-descriptions:not([style*="display: none"]) tr td:nth-child(2)') + return td?.textContent && td.textContent !== '-' + }, { timeout: 5000 }) + const { name, count } = await limitFrame.evaluate(() => { + const descEl = document.querySelector('#app .el-descriptions:not([style*="display: none"])') + const trs = descEl?.querySelectorAll('tr') + const name = trs?.[0]?.querySelector('td:nth-child(2)')?.textContent + const count = trs?.[3]?.querySelector('td:nth-child(2) .el-tag--danger')?.textContent return { name, count } }) @@ -68,11 +72,7 @@ describe('Daily time limit', () => { // 5. The modal disappear await testPage.bringToFront() await sleep(.5) - const modalExist = await testPage.evaluate(() => { - const shadow = document.querySelector('extension-time-tracker-overlay') - if (!shadow) return false - return !!shadow!.shadowRoot!.querySelector('body:not([style*="display: none"])') - }) + const modalExist = await isLimitModalVisible(testPage) expect(modalExist).toBeFalsy() }, 60000) }) \ No newline at end of file diff --git a/test-e2e/limit/delay-duration.test.ts b/test-e2e/limit/delay-duration.test.ts new file mode 100644 index 000000000..bda56bd14 --- /dev/null +++ b/test-e2e/limit/delay-duration.test.ts @@ -0,0 +1,93 @@ +import { formatTimeYMD, MILL_PER_SECOND } from '@util/time' +import type { Page } from 'puppeteer' +import { launchBrowser, type LaunchContext } from '../common/base' +import { MOCK_URL, sleep } from '../common/util' +import { clickDelay, createLimitRule, isLimitModalVisible } from './common' + +async function setDelayDuration(page: Page, value: number) { + const delayInput = await page.waitForSelector('.el-input-number input') + await delayInput!.click({ count: 3 }) + await page.keyboard.press('Backspace') + await page.keyboard.type(`${value}`) + await page.keyboard.press('Enter') + await sleep(.2) +} + +async function findRuleId(page: Page): Promise<number> { + return page.evaluate( + ruleName => new Promise<number>((resolve, reject) => { + chrome.runtime.sendMessage({ code: 'limit.list', data: undefined }, (res: { + code?: string + data?: timer.limit.Item[] + msg?: string + }) => { + if (res?.code !== 'success') return reject(new Error(res?.msg ?? 'limit.list failed')) + const id = res.data?.find(r => r.name === ruleName)?.id + typeof id === 'number' ? resolve(id) : reject(new Error('rule id not found')) + }) + }), + DEMO_RULE.name, + ) +} + +async function setTodayWaste(page: Page, ruleId: number, mill: number) { + const today = formatTimeYMD(new Date()) + await page.evaluate( + async (id, m, date) => { + const key = '__timer__LIMIT' + const bag = await chrome.storage.local.get(key) + const items = (bag[key] ?? {}) as Record<string, { r?: Record<string, { m?: number; c?: number; d?: number }> }> + const row = items[String(id)] + if (!row) throw new Error('limit row missing in storage') + const prev = row.r?.[date] ?? { m: 0, c: 0 } + row.r = row.r ?? {} + row.r[date] = { ...prev, m } + await chrome.storage.local.set({ [key]: items }) + }, + ruleId, mill, today, + ) +} + +const DEMO_RULE = { + id: 1, + name: 'DELAY DURATION', + cond: [MOCK_URL], + time: 1, + enabled: true, + allowDelay: true, + locked: false, +} as const satisfies timer.limit.Rule + +describe('Limit delay duration', () => { + let context: LaunchContext + + beforeEach(async () => { context = await launchBrowser() }) + + // afterEach(() => context.close()) + + test('Delay with customized duration', async () => { + const optionPage = await context.openAppPage('/additional/option?i=limit') + await setDelayDuration(optionPage, 1) + + const limitPage = await context.openAppPage('/behavior/limit') + await createLimitRule(DEMO_RULE, limitPage) + + const ruleId = await findRuleId(limitPage) + // 61 seconds, more than 1 minute delay + await setTodayWaste(limitPage, ruleId, 61 * MILL_PER_SECOND) + + const testPage = await context.newPageAndWaitCsInjected(MOCK_URL) + await sleep(1) + + expect(await isLimitModalVisible(testPage)).toBeTruthy() + + await clickDelay(testPage) + + // Not disappear if only delay once (1 minute delay) + expect(await isLimitModalVisible(testPage)).toBeTruthy() + + // Disappear if delay twice (2 minutes delay) + await clickDelay(testPage) + expect(await isLimitModalVisible(testPage)).toBeFalsy() + }, 45000) +}) diff --git a/test-e2e/limit/visit-limit.test.ts b/test-e2e/limit/visit-limit.test.ts index 8585cce86..018d654f9 100644 --- a/test-e2e/limit/visit-limit.test.ts +++ b/test-e2e/limit/visit-limit.test.ts @@ -1,14 +1,15 @@ -import { launchBrowser, LaunchContext, MOCK_URL, sleep } from '../common/base' -import { createLimitRule } from './common' +import { launchBrowser, type LaunchContext } from '../common/base' +import { MOCK_URL, sleep } from '../common/util' +import { createLimitRule, isLimitModalVisible, waitForLimitFrame } from './common' describe('Time limit per visit', () => { let context: LaunchContext - beforeEach(async () => context = await launchBrowser()) + beforeEach(async () => { context = await launchBrowser() }) afterEach(() => context.close()) - test("More 5 minutes", async () => { + test("Delay", async () => { const limitPage = await context.openAppPage('/behavior/limit') const demoRule: timer.limit.Rule = { id: 1, name: 'TEST DAILY LIMIT', @@ -25,23 +26,15 @@ describe('Time limit per visit', () => { await sleep(2) // 3. Modal exist and then click more 5 minutes - const clicked = await testPage.evaluate(() => { - const shadow = document.querySelector('extension-time-tracker-overlay') - if (!shadow) return false - const button = shadow.shadowRoot?.querySelector<HTMLButtonElement>('.el-button--primary') - button?.click() - return !!button - }) - expect(clicked).toBeTruthy() + const limitFrame = await waitForLimitFrame(testPage) + const button = await limitFrame.waitForSelector('.el-button--primary') + expect(button).toBeTruthy() + await button!.click() // 4. Modal disappear await sleep(.5) - const modalExist = await testPage.evaluate(() => { - const shadow = document.querySelector('extension-time-tracker-overlay') - if (!shadow) return false - return !!shadow.shadowRoot!.querySelector('body:not([style*="display: none"])') - }) + const modalExist = await isLimitModalVisible(testPage) expect(modalExist).toBeFalsy() - }, 10000) + }, 1000000000) }) \ No newline at end of file diff --git a/test-e2e/rstest.config.mts b/test-e2e/rstest.config.mts new file mode 100644 index 000000000..d7ebd599c --- /dev/null +++ b/test-e2e/rstest.config.mts @@ -0,0 +1,13 @@ +import { defineConfig } from '@rstest/core' + +export default defineConfig({ + include: ['test-e2e/**/*.test.ts'], + testEnvironment: 'node', + globals: true, + isolate: false, + hookTimeout: 120_000, + testTimeout: 60_000, + pool: { + maxWorkers: 1, + }, +}) diff --git a/test-e2e/tracker/base.test.ts b/test-e2e/tracker/base.test.ts index 3857b3942..7445c4591 100644 --- a/test-e2e/tracker/base.test.ts +++ b/test-e2e/tracker/base.test.ts @@ -1,11 +1,12 @@ -import { launchBrowser, type LaunchContext, MOCK_HOST, MOCK_URL, MOCK_URL_2, sleep } from "../common/base" +import { launchBrowser, type LaunchContext } from '../common/base' import { readRecordsOfFirstPage } from "../common/record" -import { createWhitelist } from "../common/whitelist.test" +import { MOCK_HOST, MOCK_URL, MOCK_URL_2, sleep } from '../common/util' +import { createWhitelist } from "../common/whitelist" let context: LaunchContext describe('Tracking', () => { - beforeEach(async () => context = await launchBrowser()) + beforeEach(async () => { context = await launchBrowser() }) afterEach(() => context.close()) diff --git a/test-e2e/tracker/run-time.test.ts b/test-e2e/tracker/run-time.test.ts index ce35a29f6..db1e02882 100644 --- a/test-e2e/tracker/run-time.test.ts +++ b/test-e2e/tracker/run-time.test.ts @@ -1,6 +1,7 @@ -import { launchBrowser, MOCK_HOST, MOCK_URL, sleep, type LaunchContext } from "../common/base" +import { launchBrowser, type LaunchContext } from '../common/base' import { parseTime2Sec, readRecordsOfFirstPage } from "../common/record" -import { createWhitelist } from "../common/whitelist.test" +import { MOCK_HOST, MOCK_URL, sleep } from '../common/util' +import { createWhitelist } from "../common/whitelist" let context: LaunchContext @@ -17,7 +18,7 @@ async function clickRunTimeChange(siteHost: string): Promise<void> { } describe('Run time tracking', () => { - beforeEach(async () => context = await launchBrowser()) + beforeEach(async () => { context = await launchBrowser() }) afterEach(() => context.close()) diff --git a/test/__mock__/storage.ts b/test/__mock__/storage.ts index 0a1bfc37c..b715e9432 100644 --- a/test/__mock__/storage.ts +++ b/test/__mock__/storage.ts @@ -1,4 +1,4 @@ -import StoragePromise from "@db/common/storage-promise" +import { rstest } from '@rstest/core' let store: Record<string, any> = {} @@ -28,7 +28,7 @@ function resolveKey(key: string | Object | string[] | null) { } const sync = { - get: jest.fn((...args) => { + get: rstest.fn((...args) => { let id: string | string[] | Object let cb: (result: {}) => void let result: {} = {} @@ -42,18 +42,18 @@ const sync = { } cb?.(result) }), - getBytesInUse: jest.fn(cb => cb && cb(0)), - set: jest.fn((payload, cb) => { + getBytesInUse: rstest.fn(cb => cb && cb(0)), + set: rstest.fn((payload, cb) => { Object.keys(payload).forEach((key) => (store[key] = payload[key])) cb?.() }), - remove: jest.fn((id, cb) => { + remove: rstest.fn((id, cb) => { const idType = typeof id const keys: string[] = idType === 'string' ? [id] : (Array.isArray(id) ? id : Object.keys(id)) keys.forEach((key: string) => delete store[key]) cb?.() }), - clear: jest.fn(cb => { + clear: rstest.fn(cb => { store = {} cb?.() }) @@ -64,9 +64,9 @@ const local = { ...sync, QUOTA_BYTES: 5 * 1024 * 1024 } as chrome.storage.LocalS const managed = sync const onChanged = { - addListener: jest.fn(), - removeListener: jest.fn(), - hasListener: jest.fn() + addListener: rstest.fn(), + removeListener: rstest.fn(), + hasListener: rstest.fn() } as unknown as typeof chrome.storage.onChanged export const mockStorage = () => { @@ -80,6 +80,4 @@ export const mockStorage = () => { }, } } as unknown as typeof global.chrome -} - -export const localPromise = new StoragePromise(local) \ No newline at end of file +} \ No newline at end of file diff --git a/test/background/backup/gist/compressor.test.ts b/test/background/backup/gist/compressor.test.ts index 8346fef0f..2994ea480 100644 --- a/test/background/backup/gist/compressor.test.ts +++ b/test/background/backup/gist/compressor.test.ts @@ -1,4 +1,4 @@ -import { divide2Buckets, GistData, gistData2Rows } from "@service/backup/gist/compressor" +import { divide2Buckets, type GistData, gistData2Rows } from "@service/backup/gist/compressor" test('divide 1', () => { const rows: timer.core.Row[] = [{ diff --git a/test/database/limit-database.test.ts b/test/background/database/limit-database.test.ts similarity index 68% rename from test/database/limit-database.test.ts rename to test/background/database/limit-database.test.ts index e37263cc8..fc8413187 100644 --- a/test/database/limit-database.test.ts +++ b/test/background/database/limit-database.test.ts @@ -1,13 +1,13 @@ import db from "@db/limit-database" import { formatTimeYMD } from "@util/time" -import { mockStorage } from "../__mock__/storage" +import { mockStorage } from "../../__mock__/storage" import { mockLegacyData } from './migratable' describe('limit-database', () => { beforeAll(() => mockStorage()) beforeEach(async () => chrome.storage.local.clear()) - test('test1', async () => { + test('save, all, remove', async () => { const toAdd: timer.limit.Rule = { id: 1, name: "foobar", @@ -17,7 +17,7 @@ describe('limit-database', () => { allowDelay: false, locked: false, } - const id = await db.save(toAdd) + const id = await db.add(toAdd) let all: timer.limit.Rule[] = await db.all() expect(all.length).toEqual(1) let saved = all[0] @@ -26,33 +26,19 @@ describe('limit-database', () => { expect(saved.name).toEqual(toAdd.name) expect(saved.enabled).toEqual(toAdd.enabled) expect(saved.allowDelay).toEqual(toAdd.allowDelay) - const toRewrite = { - id, - name: 'hahah', - cond: ['123'], - time: 21, - enabled: true, - allowDelay: false, - locked: false, - } - // Not rewrite - await db.save(toRewrite) - all = await db.all() - saved = all[0] - expect(saved.cond).toEqual(toAdd.cond) - expect(saved.time).toEqual(toAdd.time) - expect(saved.name).toEqual(toAdd.name) - expect(saved.enabled).toEqual(toAdd.enabled) - expect(saved.allowDelay).toEqual(toAdd.allowDelay) - await db.remove(id) + await db.batchRemove([id + 1]) // Not exist, no error throws + all = await db.all() + expect(all.length).toEqual(1) - expect((await db.all()).length).toEqual(0) + await db.batchRemove([id]) + all = await db.all() + expect(all.length).toEqual(0) }) test("update waste", async () => { const date = formatTimeYMD(new Date()) - const id1 = await db.save({ + const id1 = await db.add({ name: "foobar", cond: ["a.*.com"], time: 21, @@ -60,7 +46,7 @@ describe('limit-database', () => { allowDelay: false, locked: false, }) - await db.save({ + await db.add({ name: "foobar", cond: ["*.b.com"], time: 20, @@ -96,8 +82,8 @@ describe('limit-database', () => { enabled: false, locked: false, } - await db.save(cond1) - await db.save(cond2) + await db.add(cond1) + await db.add(cond2) const data2Import = await db.storage.get() // clear @@ -122,23 +108,4 @@ describe('limit-database', () => { await db.importData(mockLegacyData(importData)) expect(await db.all()).toEqual([]) }) - - test("update delay", async () => { - const data: MakeOptional<timer.limit.Rule, 'id'> = { - name: 'foobar', - cond: ["cond1"], - time: 20, - allowDelay: false, - enabled: true, - locked: false, - } - const id = await db.save(data) - await db.updateDelay(id, true) - await db.updateDelay(Number.MAX_VALUE, true) - const all = await db.all() - expect(all.length).toEqual(1) - const item = all[0] - expect(item.allowDelay).toBeTruthy() - expect(item.cond).toEqual(["cond1"]) - }) }) \ No newline at end of file diff --git a/test/database/merge-rule-database.test.ts b/test/background/database/merge-rule-database.test.ts similarity index 92% rename from test/database/merge-rule-database.test.ts rename to test/background/database/merge-rule-database.test.ts index ed31f6208..8618aedc5 100644 --- a/test/database/merge-rule-database.test.ts +++ b/test/background/database/merge-rule-database.test.ts @@ -1,17 +1,17 @@ import db from "@db/merge-rule-database" -import { mockStorage } from "../__mock__/storage" +import { mockStorage } from "../../__mock__/storage" import { mockLegacyData } from './migratable' function of(origin: string, merged?: string | number): timer.merge.Rule { return { origin, merged: merged || '' } } -describe('merge-rule-database.test', () => { +describe('merge-rule-database', () => { beforeAll(mockStorage) beforeEach(async () => chrome.storage.local.clear()) - test('1', async () => { + test('add, selectAll, remove', async () => { let toAdd: timer.merge.Rule[] = [of('4', 2)] await db.add(...toAdd) expect((await db.selectAll())).toEqual(expect.arrayContaining(toAdd)) diff --git a/test/database/migratable.ts b/test/background/database/migratable.ts similarity index 100% rename from test/database/migratable.ts rename to test/background/database/migratable.ts diff --git a/test/database/period-database.test.ts b/test/background/database/period-database.test.ts similarity index 91% rename from test/database/period-database.test.ts rename to test/background/database/period-database.test.ts index c0b206f0c..84f3e4c8c 100644 --- a/test/database/period-database.test.ts +++ b/test/background/database/period-database.test.ts @@ -1,7 +1,7 @@ import db from "@db/period-database" -import { keyOf } from "@util/period" +import { keyOf } from '@util/period' import { formatTimeYMD } from "@util/time" -import { mockStorage } from "../__mock__/storage" +import { mockStorage } from "../../__mock__/storage" function resultOf(date: Date, orderNum: number, milliseconds: number): timer.period.Result { return { ...keyOf(date, orderNum), milliseconds } @@ -12,7 +12,7 @@ describe('period-database', () => { beforeEach(async () => chrome.storage.local.clear()) - test('1', async () => { + test('get empty, accumulate and get by date', async () => { const date = new Date(2021, 5, 7) const dateStr = formatTimeYMD(date) const yesterday = new Date(2021, 5, 6) diff --git a/test/database/stat-database/classic.test.ts b/test/background/database/stat-database/classic.test.ts similarity index 71% rename from test/database/stat-database/classic.test.ts rename to test/background/database/stat-database/classic.test.ts index a3ab65c08..610e12f5a 100644 --- a/test/database/stat-database/classic.test.ts +++ b/test/background/database/stat-database/classic.test.ts @@ -2,13 +2,13 @@ import type { StatCondition } from '@db/stat-database' import { ClassicStatDatabase, parseImportData } from "@db/stat-database/classic" import { resultOf } from "@util/stat" import { formatTimeYMD, MILL_PER_DAY } from "@util/time" -import { mockStorage } from '../../__mock__/storage' +import { mockStorage } from '../../../__mock__/storage' let db: ClassicStatDatabase -const now = new Date() -const nowStr = formatTimeYMD(now) -const yesterday = new Date(now.getTime() - MILL_PER_DAY) -const beforeYesterday = new Date(now.getTime() - MILL_PER_DAY * 2) +const nowTs = Date.now() +const now = formatTimeYMD(nowTs) +const yesterday = formatTimeYMD(new Date(nowTs - MILL_PER_DAY)) +const beforeYesterday = formatTimeYMD(new Date(nowTs - MILL_PER_DAY * 2)) const baidu = 'www.baidu.com' const google = 'www.google.com.hk' @@ -20,23 +20,23 @@ describe('stat-database/classic', () => { beforeEach(async () => chrome.storage.local.clear()) - test('1', async () => { - await db.accumulate(baidu, nowStr, resultOf(100, 0)) + test('accumulate and get single host result', async () => { + await db.accumulate(baidu, now, resultOf(100, 0)) const data: timer.core.Result = await db.get(baidu, now) expect(data).toMatchObject(resultOf(100, 0)) }) - test('2', async () => { - await db.accumulate(baidu, nowStr, resultOf(200, 0)) - await db.accumulate(baidu, nowStr, resultOf(200, 0)) + test('accumulate merges focus and time', async () => { + 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({ host: baidu, date: nowStr, focus: 400, time: 0 } satisfies timer.core.Row) - await db.accumulate(baidu, nowStr, resultOf(0, 1)) + expect(data).toEqual({ host: baidu, date: now, focus: 400, time: 0 } satisfies timer.core.Row) + await db.accumulate(baidu, now, resultOf(0, 1)) data = await db.get(baidu, now) - expect(data).toEqual({ host: baidu, date: nowStr, focus: 400, time: 1 } satisfies timer.core.Row) + expect(data).toEqual({ host: baidu, date: now, focus: 400, time: 1 } satisfies timer.core.Row) }) - test('3', async () => { + test('batchAccumulate and select with condition/date range', async () => { await db.batchAccumulate( { [google]: resultOf(11, 0), @@ -68,8 +68,8 @@ describe('stat-database/classic', () => { // By date range cond = { date: [now, now] } const expectedResult: timer.core.Row[] = [ - { date: nowStr, focus: 11, host: google, time: 0 }, - { date: nowStr, focus: 1, host: baidu, time: 0 }, + { date: now, focus: 11, host: google, time: 0 }, + { date: now, focus: 1, host: baidu, time: 0 }, ] expect(await db.select(cond)).toEqual(expectedResult) // Only use start @@ -96,19 +96,19 @@ describe('stat-database/classic', () => { expect((await db.select(cond)).length).toEqual(2) }) - test('5', async () => { - await db.accumulate(baidu, nowStr, resultOf(10, 0)) - await db.accumulate(baidu, formatTimeYMD(yesterday), resultOf(12, 0)) + test('accumulate and delete by key/date', async () => { + 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.delete({ host: baidu, date: formatTimeYMD(yesterday) }) + await db.delete({ host: baidu, date: yesterday }) expect((await db.select()).length).toEqual(1) // Delete yesterday's data again, nothing changed - await db.delete({ host: baidu, date: formatTimeYMD(yesterday) }) + await db.delete({ host: baidu, date: yesterday }) expect((await db.get(baidu, now)).focus).toEqual(10) // Add one again, and another - await db.accumulate(baidu, formatTimeYMD(beforeYesterday), resultOf(1, 1)) - await db.accumulate(google, nowStr, resultOf(0, 0)) + 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.deleteByHost(baidu) @@ -120,27 +120,26 @@ describe('stat-database/classic', () => { const list = await db.select(cond) expect(list.length).toEqual(1) // Add one item of baidu again again - await db.accumulate(baidu, nowStr, resultOf(1, 1)) + await db.accumulate(baidu, now, resultOf(1, 1)) // But delete google await db.delete(...list) // Then only one item of baidu expect((await db.select()).length).toEqual(1) }) - test('6', async () => { + test('batchAccumulate empty returns zero and get returns zero result', async () => { await db.batchAccumulate({}, now) expect((await db.select()).length).toEqual(0) - // Return zero instance const result = await db.get(baidu, now) expect([result.focus, result.time]).toEqual([0, 0]) }) - test('7', async () => { + test('delete by key and deleteByHost date range', async () => { const foo = resultOf(1, 1) - await db.accumulate(baidu, nowStr, foo) - await db.accumulate(baidu, formatTimeYMD(yesterday), foo) - await db.accumulate(baidu, formatTimeYMD(beforeYesterday), foo) - await db.delete({ host: baidu, date: formatTimeYMD(now) }) + await db.accumulate(baidu, now, foo) + await db.accumulate(baidu, yesterday, foo) + await db.accumulate(baidu, beforeYesterday, foo) + await db.delete({ host: baidu, date: now }) expect((await db.select()).length).toEqual(2) await db.deleteByHost(baidu, [now, beforeYesterday]) // Invalid @@ -149,7 +148,7 @@ describe('stat-database/classic', () => { test("parseImportData", async () => { const foo = resultOf(1, 1) - await db.accumulate(baidu, nowStr, foo) + await db.accumulate(baidu, now, foo) const data2Import = await db.storage.get() chrome.storage.local.clear() @@ -157,7 +156,7 @@ describe('stat-database/classic', () => { const data = parseImportData(data2Import) expect(data.length).toEqual(1) const item = data[0] - expect(item.date).toEqual(nowStr) + expect(item.date).toEqual(now) expect(item.host).toEqual(baidu) expect(item.focus).toEqual(1) expect(item.time).toEqual(1) diff --git a/test/database/stat-database/idb.test.ts b/test/background/database/stat-database/idb.test.ts similarity index 88% rename from test/database/stat-database/idb.test.ts rename to test/background/database/stat-database/idb.test.ts index b5afe6a31..71f6b5cfa 100644 --- a/test/database/stat-database/idb.test.ts +++ b/test/background/database/stat-database/idb.test.ts @@ -1,7 +1,7 @@ import { zeroResult, zeroRow } from '@db/stat-database/common' import { IDBStatDatabase } from '@db/stat-database/idb' import 'fake-indexeddb/auto' -import { mockRuntime } from '../../__mock__/runtime' +import { mockRuntime } from '../../../__mock__/runtime' const GOOGLE = 'www.google.com' const GITHUB = 'www.github.com' @@ -48,7 +48,7 @@ describe('stat-database/idb', () => { const byGroupId = await db.get(`${GROUP_1}`, '20240601') expect(byGroupId).toMatchObject(zeroResult()) - const groups = await db.selectGroup({ date: new Date(2024, 5, 1) }) + const groups = await db.selectGroup({ date: '20240601' }) expect(groups).toEqual([{ host: `${GROUP_1}`, date: '20240601', focus: 5, time: 10 } satisfies timer.core.Row]) }) @@ -93,29 +93,29 @@ describe('stat-database/idb', () => { await db.accumulateGroup(GROUP_1, '20240602', { focus: 1, time: 1 }) // Query by date index - expect(await db.select({ date: [, new Date(2024, 5, 2)] })).toEqual([ + expect(await db.select({ date: [, '20240602'] })).toEqual([ { host: GITHUB, date: '20240602', focus: 10, time: 20 }, { host: GOOGLE, date: '20240602', focus: 1, time: 0 }, ] satisfies timer.core.Row[]) - expect(await db.select({ date: new Date(2024, 5, 3) })).toEqual([ + expect(await db.select({ date: '20240603' })).toEqual([ { host: GITHUB, date: '20240603', focus: 10, time: 20 }, { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, ] satisfies timer.core.Row[]) - expect(await db.select({ date: [new Date(2024, 5, 2), new Date(2024, 5, 3)] })).toEqual([ + expect(await db.select({ date: ['20240602', '20240603'] })).toEqual([ { host: GITHUB, date: '20240602', focus: 10, time: 20 }, { host: GOOGLE, date: '20240602', focus: 1, time: 0 }, { host: GITHUB, date: '20240603', focus: 10, time: 20 }, { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, ] satisfies timer.core.Row[]) // Same as above, but reversed order - expect(await db.select({ date: [new Date(2024, 5, 3), new Date(2024, 5, 2)] })).toEqual([ + expect(await db.select({ date: ['20240603', '20240602'] })).toEqual([ { host: GITHUB, date: '20240602', focus: 10, time: 20 }, { host: GOOGLE, date: '20240602', focus: 1, time: 0 }, { host: GITHUB, date: '20240603', focus: 10, time: 20 }, { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, ] satisfies timer.core.Row[]) // Including virtual - expect(await db.select({ date: new Date(2024, 5, 3), virtual: true })).toEqual([ + expect(await db.select({ date: '20240603', virtual: true })).toEqual([ { host: GITHUB, date: '20240603', focus: 10, time: 20 }, { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, @@ -136,11 +136,11 @@ describe('stat-database/idb', () => { ] satisfies timer.core.Row[]) // Query by date and host index - expect(await db.select({ date: new Date(2024, 5, 3), keys: GOOGLE })).toEqual([ + expect(await db.select({ date: '20240603', keys: GOOGLE })).toEqual([ { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, ] satisfies timer.core.Row[]) - expect(await db.select({ date: new Date(2024, 5, 3), keys: GITHUB_VIRTUAL })).toEqual([] satisfies timer.core.Row[]) - expect(await db.select({ date: new Date(2024, 5, 3), keys: GITHUB_VIRTUAL, virtual: true })).toEqual([ + expect(await db.select({ date: '20240603', keys: GITHUB_VIRTUAL })).toEqual([] satisfies timer.core.Row[]) + expect(await db.select({ date: '20240603', keys: GITHUB_VIRTUAL, virtual: true })).toEqual([ { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, ] satisfies timer.core.Row[]) @@ -154,7 +154,7 @@ describe('stat-database/idb', () => { { host: GITHUB, date: '20240602', focus: 10, time: 20 }, { host: GITHUB, date: '20240603', focus: 10, time: 20 }, ] satisfies timer.core.Row[]) - expect(await db.select({ timeRange: [10, 20], date: new Date(2024, 5, 3), virtual: true })).toEqual([ + expect(await db.select({ timeRange: [10, 20], date: '20240603', virtual: true })).toEqual([ { host: GITHUB, date: '20240603', focus: 10, time: 20 }, { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, ] satisfies timer.core.Row[]) @@ -169,7 +169,7 @@ describe('stat-database/idb', () => { { host: GITHUB, date: '20240602', focus: 10, time: 20 }, { host: GITHUB, date: '20240603', focus: 10, time: 20 }, ] satisfies timer.core.Row[]) - expect(await db.select({ focusRange: [5, 10], date: new Date(2024, 5, 3), virtual: true })).toEqual([ + expect(await db.select({ focusRange: [5, 10], date: '20240603', virtual: true })).toEqual([ { host: GITHUB, date: '20240603', focus: 10, time: 20 }, { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, ] satisfies timer.core.Row[]) @@ -213,12 +213,12 @@ describe('stat-database/idb', () => { await db.accumulateGroup(GROUP_1, '20240603', { focus: 3, time: 3 }) await db.accumulateGroup(GROUP_2, '20240603', { focus: 4, time: 4 }) - expect(await db.selectGroup({ date: new Date(2024, 5, 3) })).toEqual([ + expect(await db.selectGroup({ date: '20240603' })).toEqual([ { date: '20240603', host: `${GROUP_1}`, focus: 3, time: 3 }, { date: '20240603', host: `${GROUP_2}`, focus: 4, time: 4 }, ] satisfies timer.core.Row[]) - expect(await db.selectGroup({ date: [new Date(2024, 5, 2), new Date(2024, 5, 3)], keys: GROUP_1.toString() })).toEqual([ + expect(await db.selectGroup({ date: ['20240602', '20240603'], keys: GROUP_1.toString() })).toEqual([ { date: '20240602', host: `${GROUP_1}`, focus: 1, time: 1 }, { date: '20240603', host: `${GROUP_1}`, focus: 3, time: 3 }, ] satisfies timer.core.Row[]) diff --git a/test/database/whitelist-database.test.ts b/test/background/database/whitelist-database.test.ts similarity index 85% rename from test/database/whitelist-database.test.ts rename to test/background/database/whitelist-database.test.ts index 13aeb280f..68d2ea1c9 100644 --- a/test/database/whitelist-database.test.ts +++ b/test/background/database/whitelist-database.test.ts @@ -1,12 +1,12 @@ import db from "@db/whitelist-database" -import { mockStorage } from "../__mock__/storage" +import { mockStorage } from "../../__mock__/storage" describe('timer-database', () => { beforeAll(mockStorage) beforeEach(async () => chrome.storage.local.clear()) - test('1', async () => { + test('add, selectAll, exist, remove work correctly', async () => { await db.add('www.baidu.com') await db.add('google.com') const list = await db.selectAll() diff --git a/test/service/components/host-merge-ruler.test.ts b/test/background/service/components/host-merge-ruler.test.ts similarity index 100% rename from test/service/components/host-merge-ruler.test.ts rename to test/background/service/components/host-merge-ruler.test.ts diff --git a/test/service/components/period-calculator.test.ts b/test/background/service/components/period-calculator.test.ts similarity index 63% rename from test/service/components/period-calculator.test.ts rename to test/background/service/components/period-calculator.test.ts index be9a6eeea..8eac85fe3 100644 --- a/test/service/components/period-calculator.test.ts +++ b/test/background/service/components/period-calculator.test.ts @@ -1,7 +1,9 @@ -import { calculate, getMaxDivisiblePeriod, merge } from "@service/components/period-calculator" -import { keyOf, PERIOD_PER_DATE } from "@util/period" +import { calculate, merge } from "@service/components/period-calculator" +import { keyOf, MINUTE_PER_PERIOD } from '@util/period' import { MILL_PER_DAY } from "@util/time" +const PERIOD_PER_DATE = 24 * 60 / MINUTE_PER_PERIOD + function resultOf(date: Date | number, orderNum: number, milliseconds: number): timer.period.Result { return { ...keyOf(date, orderNum), milliseconds } } @@ -52,36 +54,14 @@ test('', () => { expect(result.length).toEqual(2) const last = result[0] - const realLast: timer.period.Result = resultOf(yesterday, 95, 3000 - 2876) + const realLast = resultOf(yesterday, 95, 3000 - 2876) expect(last).toEqual(realLast) const current = result[1] - const realCurrent: timer.period.Result = resultOf(base, 0, 2876) + const realCurrent = resultOf(base, 0, 2876) expect(current).toEqual(realCurrent) }) -test('', () => { - const dateArr = [2020, 6, 6] - - const key = keyOf(new Date(2020, 5, 6)) - key.order = 4 - let result = getMaxDivisiblePeriod(key, 1) - expect([result.year, result.month, result.date, result.order]).toEqual([...dateArr, 4]) - - key.order = 3 - result = getMaxDivisiblePeriod(key, 1) - expect([result.year, result.month, result.date, result.order]).toEqual([...dateArr, 3]) - - key.order = 0 - result = getMaxDivisiblePeriod(key, 2) - expect([result.year, result.month, result.date, result.order]).toEqual([2020, 6, 5, 95]) -}) - -test('merge', () => { - // 20200501 - const start = new Date(2020, 4, 1) - // 20200531 - const end = new Date(2020, 4, 31) - +test.skip('merge', () => { const d20200506 = new Date(2020, 4, 6) const toMerge: timer.period.Result[] = [ resultOf(d20200506, 19, 20), @@ -90,14 +70,14 @@ test('merge', () => { resultOf(new Date(2020, 4, 20), 20, 20), ] - let result = merge(toMerge, { periodSize: 1, start: keyOf(start, 0), end: keyOf(end, 3) }) + let result = merge(toMerge, 1) expect(result.length).toEqual(30 * PERIOD_PER_DATE + 4) expect(result.filter(p => p.date === '20200506' && p.milliseconds > 0).length).toEqual(3) let milliseconds = result.filter(p => p.date === '20200506').map(p => p.milliseconds).reduce((a, b) => a + b, 0) expect(milliseconds).toEqual(60) - result = merge(toMerge, { periodSize: 4, start: keyOf(new Date(2020, 4, 11), 0), end: keyOf(end, 3) }) + result = merge(toMerge, 4) expect(result.length).toEqual(20 * PERIOD_PER_DATE / 4 + 4 / 4) - result = merge(toMerge, { periodSize: 2, start: keyOf(start, 0), end: keyOf(end, 3) }) + result = merge(toMerge, 2) expect(result.length).toEqual(30 * PERIOD_PER_DATE / 2 + 4 / 2) }) \ No newline at end of file diff --git a/test/background/service/limit-service.test.ts b/test/background/service/limit-service.test.ts new file mode 100644 index 000000000..5cdea1aa9 --- /dev/null +++ b/test/background/service/limit-service.test.ts @@ -0,0 +1,67 @@ +import { mockStorage } from "../../__mock__/storage" + +let calcTimeState: (item: timer.limit.Item, reminderMills: number, delayDuration: number) => { + daily: 'NORMAL' | 'REMINDER' | 'LIMITED' + weekly: 'NORMAL' | 'REMINDER' | 'LIMITED' +} + +beforeAll(async () => { + mockStorage() + Object.assign(global.chrome as object, { + runtime: { + id: "test", + getManifest: () => ({ manifest_version: 3 }), + }, + }) + const mod = await import("@/background/service/limit-service") + calcTimeState = mod.calcTimeState +}) + +describe("background/limit-service", () => { + test("calcTimeState", () => { + const item: timer.limit.Item = { + id: 1, + name: "foobar", + cond: [], + time: 10, + weekly: 10, + waste: 0, + visit: 0, + delayCount: 0, + weeklyWaste: 0, + weeklyVisit: 0, + weeklyDelayCount: 0, + enabled: true, + allowDelay: false, + locked: false, + } + const duration = 1000 + + type LimitState = "NORMAL" | "REMINDER" | "LIMITED" + + const assert = (daily: LimitState, weekly: LimitState) => { + const res = calcTimeState(item, duration, 5) + expect(res?.daily).toBe(daily) + expect(res?.weekly).toBe(weekly) + } + + item.waste = 9000 + assert("NORMAL", "NORMAL") + + item.waste = 9001 + assert("REMINDER", "NORMAL") + + item.waste = 10001 + assert("LIMITED", "NORMAL") + + item.allowDelay = true + item.delayCount = 1 + + item.weeklyWaste = 9000 + assert("NORMAL", "NORMAL") + item.weeklyWaste = 9001 + assert("NORMAL", "REMINDER") + item.weeklyWaste = 10001 + assert("NORMAL", "LIMITED") + }) +}) diff --git a/test/service/whitelist/processor.test.ts b/test/background/service/whitelist/processor.test.ts similarity index 96% rename from test/service/whitelist/processor.test.ts rename to test/background/service/whitelist/processor.test.ts index eadc3521f..9cd20b307 100644 --- a/test/service/whitelist/processor.test.ts +++ b/test/background/service/whitelist/processor.test.ts @@ -3,7 +3,7 @@ import WhitelistProcessor from '@service/whitelist/processor' describe('whitelist-holder', () => { let processor: WhitelistProcessor - beforeEach(() => processor = new WhitelistProcessor()) + beforeEach(() => { processor = new WhitelistProcessor() }) const verify = (whitelist: string[], cases: Array<[string, string, boolean]>) => { processor.setWhitelist(whitelist) @@ -14,7 +14,7 @@ describe('whitelist-holder', () => { processor.setWhitelist([]) expect(processor.contains("github.com", "https://github.com/")).toBeFalsy() - processor.setWhitelist(['', 'github.com', '', null as any, undefined as any]) + processor.setWhitelist(['', 'github.com', '']) expect(processor.contains("github.com", "https://github.com/")).toBeTruthy() processor.setWhitelist(['google.com']) diff --git a/test/common/logger.test.ts b/test/common/logger.test.ts deleted file mode 100644 index 3c7da6182..000000000 --- a/test/common/logger.test.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { closeLog, log, openLog } from "@src/common/logger" - -test('test open log', () => { - global.console.log = jest.fn() - openLog() - log("foobar") - expect(console.log).toHaveBeenCalledWith("foobar") -}) - -test('test close log', () => { - global.console.log = jest.fn() - closeLog() - log("foobar") - expect(console.log).toHaveBeenCalledTimes(0) -}) \ No newline at end of file diff --git a/test/jest.setup.ts b/test/jest.setup.ts deleted file mode 100644 index 6124e6ede..000000000 --- a/test/jest.setup.ts +++ /dev/null @@ -1,4 +0,0 @@ -if (typeof global.structuredClone === 'undefined') { - // Used for fake-indexeddb - global.structuredClone = (v) => JSON.parse(JSON.stringify(v)) -} \ No newline at end of file diff --git a/test/rstest.config.mts b/test/rstest.config.mts new file mode 100644 index 000000000..eaf9b53a3 --- /dev/null +++ b/test/rstest.config.mts @@ -0,0 +1,7 @@ +import { defineConfig } from '@rstest/core' + +export default defineConfig({ + include: ['test/**/*.test.ts'], + testEnvironment: 'jsdom', + globals: true, +}) diff --git a/test/util/array.test.ts b/test/util/array.test.ts index 1f7e04f47..7ea5f753f 100644 --- a/test/util/array.test.ts +++ b/test/util/array.test.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { allMatch, anyMatch, average, groupBy, rotate, sum, toMap } from "@util/array" +import { allMatch, anyMatch, groupBy, rotate, sum, toMap } from "@util/array" describe("util/array", () => { @@ -43,16 +43,10 @@ describe("util/array", () => { test("sum", () => { let arr: number[] = [1, 2, 3, 4] - expect(10).toEqual(sum(arr)) + expect(sum(arr)).toEqual(10) arr = [] - expect(0).toEqual(sum(arr)) - }) - - test("average", () => { - expect(average([10, 1])).toEqual(11 / 2) - expect(average([])).toBeNull() - expect(average([0])).toEqual(0) + expect(sum(arr)).toEqual(0) }) test("allMatch", () => { diff --git a/test/util/chrome/compile.test.ts b/test/util/chrome/compile.test.ts index cce0939b3..c5b825645 100644 --- a/test/util/chrome/compile.test.ts +++ b/test/util/chrome/compile.test.ts @@ -1,8 +1,10 @@ -import compile from "@i18n/chrome/compile" +import compile from "../../../src/i18n/chrome/compile" -test('1', () => { - const messages = { app: '123', foo: { bar: '234' } } - const chromeMessages = compile(messages) - expect(chromeMessages.app.message).toEqual('123') - expect(chromeMessages.foo_bar.message).toEqual('234') +describe('i18n/chrome/compile', () => { + test('flattens nested messages to chrome i18n format', () => { + const messages = { app: '123', foo: { bar: '234' } } + const chromeMessages = compile(messages) + expect(chromeMessages.app.message).toEqual('123') + expect(chromeMessages.foo_bar.message).toEqual('234') + }) }) \ No newline at end of file diff --git a/test/util/limit.test.ts b/test/util/limit.test.ts index dda39391e..1df844594 100644 --- a/test/util/limit.test.ts +++ b/test/util/limit.test.ts @@ -1,17 +1,10 @@ +import { rstest } from '@rstest/core' import { - calcTimeState, cleanCond, dateMinute2Idx, hasLimited, hasWeeklyLimited, isEffective, isEnabledAndEffective, - matchCond, matches, meetLimit, meetTimeLimit, period2Str + dateMinute2Idx, hasLimited, hasWeeklyLimited, isEffective, matchCond, matches, meetLimit, meetTimeLimit, + period2Str, } from "@util/limit" describe('util/limit', () => { - test('cleanCond', () => { - expect(cleanCond('https://github.com?a=2')).toEqual('github.com') - expect(cleanCond('https://github.com/?a=2')).toEqual('github.com') - expect(cleanCond('*://github.com/?a=2')).toEqual('github.com') - expect(cleanCond('www.github.com/sheepzh/?a=2')).toEqual('www.github.com/sheepzh') - expect(cleanCond('https://')).toBeUndefined() - }) - test('matches', () => { const cond = ['www.baidu.com', '*.google.com', 'github.com/sheepzh', '+github.com/sheepzh/time-tracker-4-browser', '+www.bilibili.com/cheese', '*.bilibili.com*'] @@ -46,12 +39,14 @@ describe('util/limit', () => { }) test('meetTimeLimit', () => { - expect(meetTimeLimit(undefined, undefined, undefined, undefined)).toBe(false) + const delay5 = { duration: 5, allow: false as boolean, count: 0 } + + expect(meetTimeLimit({ wasted: 0, maxLimit: 0 }, { ...delay5, allow: false })).toBe(false) - expect(meetTimeLimit(1, 1001, undefined, undefined)).toBe(true) - expect(meetTimeLimit(1, 1001, true, undefined)).toBe(true) - expect(meetTimeLimit(1, 1001, true, 1)).toBe(false) - expect(meetTimeLimit(1, (1 + 60 * 5) * 1000 + 1, true, 1)).toBe(true) + expect(meetTimeLimit({ wasted: 1001, maxLimit: 1 }, { ...delay5, allow: false })).toBe(true) + expect(meetTimeLimit({ wasted: 1001, maxLimit: 1 }, { duration: 5, allow: true, count: 0 })).toBe(true) + expect(meetTimeLimit({ wasted: 1001, maxLimit: 1 }, { duration: 5, allow: true, count: 1 })).toBe(false) + expect(meetTimeLimit({ wasted: (1 + 60 * 5) * 1000 + 1, maxLimit: 1 }, { duration: 5, allow: true, count: 1 })).toBe(true) }) test('period2Str', () => { @@ -71,37 +66,16 @@ describe('util/limit', () => { expect(isEffective(undefined)).toBe(true) expect(isEffective([])).toBe(true) - Object.defineProperty(global, 'performance', { writable: true }) - jest.useFakeTimers({ doNotFake: ['performance'] }) + rstest.useFakeTimers({}) const monday = new Date() monday.setFullYear(2025) monday.setMonth(0) monday.setDate(20) - jest.setSystemTime(monday) + rstest.setSystemTime(monday) expect(isEffective([1, 2])).toBe(false) expect(isEffective([0, 1, 2])).toBe(true) - }) - - test('isEffectiveAndEnabled', () => { - Object.defineProperty(global, 'performance', { writable: true }) - jest.useFakeTimers({ doNotFake: ['performance'] }) - const monday = new Date() - monday.setFullYear(2025) - monday.setMonth(0) - monday.setDate(20) - jest.setSystemTime(monday) - - const rule = (weekdays: number[], enabled: boolean): timer.limit.Rule => ({ - id: 1, name: 'foobar', - cond: [], - time: 0, weekdays, - enabled, allowDelay: false, locked: false, - }) - - expect(isEnabledAndEffective(rule([0, 1, 2], true))).toBe(true) - expect(isEnabledAndEffective(rule([0, 1, 2], false))).toBe(false) - expect(isEnabledAndEffective(rule([1, 2], true))).toBe(false) + rstest.useRealTimers() }) test('hasWeeklyLimited', () => { @@ -121,66 +95,19 @@ describe('util/limit', () => { locked: false, } - expect(hasWeeklyLimited(item)).toBe(false) + expect(hasWeeklyLimited(item, 5)).toBe(false) item.weekly = 299 - expect(hasWeeklyLimited(item)).toBe(false) + expect(hasWeeklyLimited(item, 5)).toBe(false) item.weeklyWaste = 299 * 1000 + 1 - expect(hasWeeklyLimited(item)).toBe(true) + expect(hasWeeklyLimited(item, 5)).toBe(true) item.weeklyDelayCount = 1 - expect(hasWeeklyLimited(item)).toBe(true) - - item.allowDelay = true - expect(hasWeeklyLimited(item)).toBe(false) - }) - - test('calcTimeState', () => { - const item: timer.limit.Item = { - id: 1, - name: 'foobar', - cond: [], - time: 10, - weekly: 10, - waste: 0, - visit: 0, - delayCount: 0, - weeklyWaste: 0, - weeklyVisit: 0, - weeklyDelayCount: 0, - enabled: true, - allowDelay: false, - locked: false, - } - const duration = 1000 - - type LimitState = 'NORMAL' | 'REMINDER' | 'LIMITED' - - const assert = (daily: LimitState, weekly: LimitState) => { - const res = calcTimeState(item, duration) - expect(res?.daily).toBe(daily) - expect(res?.weekly).toBe(weekly) - } - - item.waste = 9000 - assert('NORMAL', 'NORMAL') - - item.waste = 9001 - assert('REMINDER', 'NORMAL') - - item.waste = 10001 - assert('LIMITED', 'NORMAL') + expect(hasWeeklyLimited(item, 5)).toBe(true) item.allowDelay = true - item.delayCount = 1 - - item.weeklyWaste = 9000 - assert('NORMAL', 'NORMAL') - item.weeklyWaste = 9001 - assert('NORMAL', 'REMINDER') - item.weeklyWaste = 10001 - assert('NORMAL', 'LIMITED') + expect(hasWeeklyLimited(item, 5)).toBe(false) }) test('hasLimit', () => { @@ -202,7 +129,7 @@ describe('util/limit', () => { locked: false, } setup(item) - expect(hasLimited(item)).toBe(limited) + expect(hasLimited(item, 5)).toBe(limited) } assert(item => item.waste = 1000, false) diff --git a/test/util/period.test.ts b/test/util/period.test.ts index 9785de8ec..0652bbf2f 100644 --- a/test/util/period.test.ts +++ b/test/util/period.test.ts @@ -1,8 +1,10 @@ -import { copyKeyWith, keyOf, compare } from "@util/period" +// import { compare, copyKeyWith, keyOf } from "@util/period" + +import { compare, keyOf } from '@util/period' test('test1', () => { const key1 = keyOf(new Date(), 0) - const key2 = copyKeyWith(key1, 1) + const key2 = keyOf(new Date(), 1) expect(compare(key1, key2) < 0).toBeTruthy() expect(compare(key1, key1) === 0).toBeTruthy() @@ -12,4 +14,4 @@ test('test2', () => { const a = keyOf(new Date(2020, 4, 20), 15) const b = keyOf(new Date(2020, 4, 20), 19) expect(compare(a, b) < 0).toBeTruthy() -}) \ No newline at end of file +}) diff --git a/test/util/time.test.ts b/test/util/time.test.ts index 0b3563ae4..696a985b9 100644 --- a/test/util/time.test.ts +++ b/test/util/time.test.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { daysAgo, formatPeriod, formatPeriodCommon, formatTime, getMonthTime, getStartOfDay, isSameDay } from "@util/time" +import { daysAgo, formatPeriodCommon, formatTime, getMonthTime, getStartOfDay, isSameDay } from "../../src/util/time" test('time', () => { const dateStr = '2020/05/01 00:00:01' @@ -22,14 +22,6 @@ test('time', () => { }) test('format', () => { - const msg = { - dayMsg: '{day}天{hour}时{minute}分{second}秒', - hourMsg: '{hour}时{minute}分{second}秒', - minuteMsg: '{minute}分{second}秒', - secondMsg: '{second}秒' - } - expect(formatPeriod(86400 * 1000, msg)).toEqual('1天0时0分0秒') - expect(formatPeriod(3666 * 1000, msg)).toEqual('1时1分6秒') expect(formatPeriodCommon(86400 * 1000)).toEqual('1d 0h 0m 0s') expect(formatPeriodCommon(3666 * 1000)).toEqual('1h 1m 6s') expect(formatPeriodCommon(1)).toEqual('0s') diff --git a/tsconfig.json b/tsconfig.json index f18ae3200..5893f3325 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,13 +3,13 @@ "module": "esnext", "target": "esnext", "lib": [ - "ES2023", + "ESNext", "DOM" ], "types": [ - "jest", "chrome", - "firefox-webext-browser" + "firefox-webext-browser", + "@rstest/core/globals" ], "jsx": "preserve", "jsxFactory": "h", @@ -18,14 +18,11 @@ "esModuleInterop": true, "sourceMap": true, "strict": true, - "noImplicitAny": true, - "strictNullChecks": true, - "strictFunctionTypes": true, - "strictPropertyInitialization": true, "resolveJsonModule": true, - "importHelpers": true, - "skipLibCheck": true, "moduleResolution": "bundler", + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, "paths": { "@api/*": [ "./src/api/*" @@ -42,9 +39,6 @@ "@side/*": [ "./src/pages/side/*" ], - "@hooks/*": [ - "./src/pages/hooks/*" - ], "@hooks": [ "./src/pages/hooks/index" ], @@ -52,10 +46,13 @@ "./src/pages/*" ], "@db/*": [ - "./src/database/*" + "./src/background/database/*" ], "@service/*": [ - "./src/service/*" + "./src/background/service/*" + ], + "@polyfill": [ + "./src/background/polyfill/index" ], "@util/*": [ "./src/util/*" @@ -66,11 +63,8 @@ "@i18n": [ "./src/i18n/index" ], - "@src/*": [ + "@/*": [ "./src/*" - ], - "*": [ - "./types/*" ] } }, diff --git a/types/chrome.d.ts b/types/chrome.d.ts index 3d12301a6..fb41c7ff4 100644 --- a/types/chrome.d.ts +++ b/types/chrome.d.ts @@ -15,4 +15,5 @@ declare type ChromeAlarm = chrome.alarms.Alarm // chrome.runtime declare type ChromeOnInstalledReason = `${chrome.runtime.OnInstalledReason}` declare type ChromeMessageSender = chrome.runtime.MessageSender -declare type ChromeMessageHandler<T = any, R = any> = (req: timer.mq.Request<T>, sender: ChromeMessageSender) => Promise<timer.mq.Response<R>> \ No newline at end of file +declare type ChromeMessageHandler = (req: timer.mq.Request<timer.mq.ReqCode>, sender: ChromeMessageSender) => Promise<timer.mq.Response<timer.mq.ReqCode>> +declare type ChromeTabMessageHandler = (req: timer.tab.Request<timer.tab.ReqCode>, sender: ChromeMessageSender) => Promise<timer.tab.Response<timer.tab.ReqCode>> \ No newline at end of file diff --git a/types/common.d.ts b/types/common.d.ts index 340a7b306..ce31ee2e4 100644 --- a/types/common.d.ts +++ b/types/common.d.ts @@ -44,6 +44,8 @@ declare type CompareFn<T> = (a: T, b: T) => number declare type Awaitable<T> = T | Promise<T> +declare type Arrayable<T> = T | T[] + declare type Getter<T> = () => T | Promise<T> declare type NoArgCallback = () => void diff --git a/types/timer/backup.d.ts b/types/timer/backup.d.ts index 172e351ce..63e9839dc 100644 --- a/types/timer/backup.d.ts +++ b/types/timer/backup.d.ts @@ -46,7 +46,7 @@ declare namespace timer.backup { * * @param targetCid The client id, default value is the local one in context */ - download(context: timer.backup.CoordinatorContext<Cache>, dateStart: Date, dateEnd: Date, targetCid?: string): Promise<timer.core.Row[]> + download(context: timer.backup.CoordinatorContext<Cache>, start: string, end: string, targetCid?: string): Promise<timer.core.Row[]> /** * Upload fragmented data to cloud * @param rows @@ -88,27 +88,6 @@ declare namespace timer.backup { dirPath?: string } - /** - * Snapshot of last backup - */ - type Snapshot = { - /** - * Timestamp - */ - ts: number - /** - * The date of the ts - */ - date: string - } - - /** - * Snapshot cache - */ - type SnapshotCache = Partial<{ - [type in Type]: Snapshot - }> - type MetaCache = Partial<Record<Type, unknown>> type RowExtend = { @@ -122,6 +101,13 @@ declare namespace timer.backup { cname?: string } + type RemoteQuery = { + start: string + end: string + specCid?: string + excludeLocal?: boolean + } + type Row = core.Row & RowExtend /** @@ -139,7 +125,5 @@ declare namespace timer.backup { __merge__?: timer.merge.Rule[] __whitelist__?: string[] __cate__?: timer.site.Cate[] - // Legacy data before v4.0.0 - [key: string]: unknown } } \ No newline at end of file diff --git a/types/timer/common.d.ts b/types/timer/common.d.ts index 8f593ca8a..0b359ff58 100644 --- a/types/timer/common.d.ts +++ b/types/timer/common.d.ts @@ -1,15 +1,33 @@ declare namespace timer.common { + type Result<T> = { + success: true + data: T + } | { + success: false + errorMsg: string + } + type PageQuery = { num?: number size?: number } + type PageResult<T> = { list: T[] total: number } + type SortDirection = 'ASC' | 'DESC' type SortBy<T extends string> = { sortKey?: T sortDirection?: SortDirection } + + /** + * chrome.storage.local usage (mq memory.getUsedStorage). + */ + type StorageUsage = { + used: number + total: number + } } \ No newline at end of file diff --git a/types/timer/core.d.ts b/types/timer/core.d.ts index f2dadc765..ec57fe16f 100644 --- a/types/timer/core.d.ts +++ b/types/timer/core.d.ts @@ -2,7 +2,6 @@ declare namespace timer.core { type Event = { start: number end: number - url: string ignoreTabCheck: boolean /** * Used for run time tracking diff --git a/types/timer/imported.d.ts b/types/timer/imported.d.ts index bdeb81bf8..8a20d305a 100644 --- a/types/timer/imported.d.ts +++ b/types/timer/imported.d.ts @@ -14,4 +14,9 @@ declare namespace timer.imported { } & { rows: Row[] } + + type ProcessQuery = { + data: Data + resolution: ConflictResolution + } } \ No newline at end of file diff --git a/types/timer/index.d.ts b/types/timer/index.d.ts index 4602115f6..5644aaaae 100644 --- a/types/timer/index.d.ts +++ b/types/timer/index.d.ts @@ -53,14 +53,8 @@ declare namespace timer { | 'mn' | 'hi' - type ExtensionMetaFlag = "rateOpen" - type ExtensionMeta = { installTime?: number - appCounter?: { [routePath: string]: number } - popupCounter?: { - _total?: number - } /** * The id of this client * @@ -80,7 +74,5 @@ declare namespace timer { msg?: string } } - // Flags - flag?: Partial<Record<ExtensionMetaFlag, boolean>> } } \ No newline at end of file diff --git a/types/timer/limit.d.ts b/types/timer/limit.d.ts index 5a227860d..82f3becf1 100644 --- a/types/timer/limit.d.ts +++ b/types/timer/limit.d.ts @@ -136,4 +136,21 @@ declare namespace timer.limit { // Minutes duration: number } + + type Query = { + id?: number + url?: string + // Only enabled rules + enabled?: boolean + // Only effective rules (should be enabled and meet effective conditions) + effective?: boolean + // Only effective and limited rules + limited?: boolean + } + + type Summary = { + url: string + site: site.SiteInfo + items: timer.limit.Item[] + } } diff --git a/types/timer/message.d.ts b/types/timer/message.d.ts new file mode 100644 index 000000000..dcebd36c6 --- /dev/null +++ b/types/timer/message.d.ts @@ -0,0 +1,157 @@ +type _TransmitValue = + | undefined | string | number | boolean | void + | { readonly [key: string]: _TransmitValue } + | readonly _TransmitValue[] + +type _HandlerIO<Input extends _TransmitValue = undefined, Output extends _TransmitValue = undefined> = [Input, Output] +type _MakeRegistry<Codes extends string, Param extends _TransmitValue = undefined, Result extends _TransmitValue = undefined> = Record<Codes, _HandlerIO<Param, Result>> + +type _MqReqData<R, K extends keyof R> = R[K] extends [infer In, unknown] ? In : never +type _MqResData<R, K extends keyof R> = R[K] extends [unknown, infer Out] ? Out : never +type _MqRequest<R, K extends keyof R = keyof R> = { code: K; data: _MqReqData<R, K> } +type _MqSuccess<R, K extends keyof R> = + _MqResData<R, K> extends undefined ? { code: "success"; data?: undefined } : { code: "success"; data: _MqResData<R, K> } +type _MqResponse<R, K extends keyof R = keyof R> = + | { code: "fail"; msg: string } + | { code: "ignore" } + | (K extends keyof R ? _MqSuccess<R, K> : never) +type _MqHandler<R, C extends keyof R> = _MqResData<R, C> extends undefined + ? (data: _MqReqData<R, C>, sender: chrome.runtime.MessageSender) => Awaitable<void | undefined> + : (data: _MqReqData<R, C>, sender: chrome.runtime.MessageSender) => Awaitable<_MqResData<R, C>> + +declare namespace timer.mq { + type _HandlerRegistry = + // Track event + & _MakeRegistry<'track.time' | 'track.runTime', core.Event> + // Content script events + & _MakeRegistry<'cs.injected'> + & _MakeRegistry<'cs.idleChanged', boolean> + // Content script API + & _MakeRegistry<'cs.getAudible', undefined, boolean> + // Statistics + & _MakeRegistry<'stat.today', string, core.Result | undefined> + & _MakeRegistry<'stat.sites', stat.SiteQuery | undefined, stat.SiteRow[]> + & _MakeRegistry<'stat.sitePage', stat.SitePageQuery | undefined, common.PageResult<stat.SiteRow>> + & _MakeRegistry<'stat.deleteSite', stat.SiteDeleteQuery> + & _MakeRegistry<'stat.countSite', stat.SiteQuery | undefined, number> + & _MakeRegistry<'stat.cates', stat.CateQuery | undefined, stat.CateRow[]> + & _MakeRegistry<'stat.catePage', stat.CatePageQuery | undefined, common.PageResult<stat.CateRow>> + & _MakeRegistry<'stat.groups', stat.GroupQuery | undefined, stat.GroupRow[]> + & _MakeRegistry<'stat.groupPage', stat.GroupPageQuery | undefined, common.PageResult<stat.GroupRow>> + & _MakeRegistry<'stat.countGroup', stat.GroupQuery | undefined, number> + & _MakeRegistry<'stat.batchDelete', stat.StatKey[]> + // Items + & _MakeRegistry<'item.batch', core.RowKey[], core.Row[]> + // Category + & _MakeRegistry<'cate.all', undefined, site.Cate[]> + & _MakeRegistry<'cate.add', string, site.Cate> + & _MakeRegistry<'cate.change', site.Cate> + & _MakeRegistry<'cate.delete', number> + // Option + & _MakeRegistry<'option.get', undefined, option.DefaultOption> + & _MakeRegistry<'option.set', Partial<option.AllOption>> + & _MakeRegistry<'option.changeStorage', option.StorageType> + & _MakeRegistry<'option.testNotification', undefined, string | undefined> + & _MakeRegistry<'option.weekStartDay', undefined, number> + & _MakeRegistry<'option.weekStartTime', number, number> + // Meta + & _MakeRegistry<'meta.installTs', undefined, number> + & _MakeRegistry<'meta.usedStorage', undefined, common.StorageUsage> + // Site + & _MakeRegistry<'site.runEnabled', string, boolean> + & _MakeRegistry<'site.list', site.Query | undefined, site.SiteInfo[]> + & _MakeRegistry<'site.page', site.PageQuery | undefined, common.PageResult<site.SiteInfo>> + & _MakeRegistry<'site.add', site.SiteInfo, string | undefined> + & _MakeRegistry<'site.delete', site.SiteKey[]> + & _MakeRegistry<'site.changeCate', site.ChangeCateParam> + & _MakeRegistry<'site.deleteIcon', site.SiteKey> + & _MakeRegistry<'site.changeAlias', site.ChangeAliasParam> + & _MakeRegistry<'site.fillAlias', site.SiteKey[]> + & _MakeRegistry<'site.initialAlias', string, string | undefined> + & _MakeRegistry<'site.changeRun', { key: site.SiteKey; enabled: boolean }> + & _MakeRegistry<'site.search', string | undefined, site.SiteInfo[]> + // Time Limit + & _MakeRegistry<'limit.list', limit.Query | undefined, limit.Item[]> + & _MakeRegistry<'limit.delete', number[]> + & _MakeRegistry<'limit.update', limit.Rule[]> + & _MakeRegistry<'limit.add', Omit<limit.Rule, 'id'>, number> + & _MakeRegistry<'limit.hitVisit', limit.Item, boolean> + & _MakeRegistry<'limit.delay', string> + & _MakeRegistry<'limit.summary', undefined, limit.Summary | undefined> + // Merge + & _MakeRegistry<'merge.all', undefined, merge.Rule[]> + & _MakeRegistry<'merge.delete', string> + & _MakeRegistry<'merge.add', merge.Rule> + // Whitelist + & _MakeRegistry<'whitelist.all', undefined, string[]> + & _MakeRegistry<'whitelist.add' | 'whitelist.delete', string> + & _MakeRegistry<'whitelist.contain', { host: string; url: string }, boolean> + // Backup + & _MakeRegistry<'backup.sync' | 'backup.checkAuth', undefined, string | undefined> + & _MakeRegistry<'backup.clear', string, string | undefined> + & _MakeRegistry<'backup.query', backup.RemoteQuery, backup.Row[]> + & _MakeRegistry<'backup.lastTs', backup.Type, number | undefined> + & _MakeRegistry<'backup.clients', undefined, (backup.Client & { current: boolean })[]> + // Period + & _MakeRegistry<'period.list', period.Query, period.Row[]> + // Timeline + & _MakeRegistry<'timeline.list', timeline.Query, timeline.Activity[]> + & _MakeRegistry<'timeline.tick', timeline.Event> + & _MakeRegistry<'backup.preview', backup.RemoteQuery, imported.Row[]> + & _MakeRegistry<'immigration.importOther', imported.ProcessQuery> + & _MakeRegistry<'immigration.import', any> + & _MakeRegistry<'immigration.export', undefined, backup.ExportData> + + type ReqCode = keyof _HandlerRegistry + + type ReqData<T extends ReqCode> = _MqReqData<_HandlerRegistry, T> + + /** + * @since 0.2.2 + */ + type Request<T extends ReqCode = ReqCode> = _MqRequest<_HandlerRegistry, T> + + type ResData<T extends ReqCode> = _MqResData<_HandlerRegistry, T> + + /** + * When ResData is undefined, success may omit data. + * @since 0.8.4 + */ + type Response<T extends ReqCode = ReqCode> = _MqResponse<_HandlerRegistry, T> + + /** + * @since 1.3.0 + */ + type Handler<C extends ReqCode> = _MqHandler<_HandlerRegistry, C> + /** + * @since 0.8.4 + */ + type Callback<T extends ReqCode = ReqCode> = (result?: Response<T>) => void +} + +/** + * Background → content script via chrome.tabs.sendMessage (see sendMsg2Tab). + */ +declare namespace timer.tab { + type _HandlerRegistry = + & _MakeRegistry<'siteRunChange'> + & _MakeRegistry<'syncAudible', boolean, void> + & _MakeRegistry<'limitTimeMeet' | 'limitChanged', limit.Item[], void> + & _MakeRegistry<'limitReminder', limit.ReminderInfo, void> + & _MakeRegistry<'askVisitHit', number, boolean> + + type ReqCode = keyof _HandlerRegistry + + type ReqData<T extends ReqCode> = _MqReqData<_HandlerRegistry, T> + + type ResData<T extends ReqCode> = _MqResData<_HandlerRegistry, T> + + type Request<T extends ReqCode = ReqCode> = _MqRequest<_HandlerRegistry, T> + + type Response<T extends ReqCode = ReqCode> = _MqResponse<_HandlerRegistry, T> + + /** + * @since 0.8.4 + */ + type Callback<T extends ReqCode = ReqCode> = (result?: Response<T>) => void +} \ No newline at end of file diff --git a/types/timer/mq.d.ts b/types/timer/mq.d.ts deleted file mode 100644 index 804e62982..000000000 --- a/types/timer/mq.d.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** -* Message queue -*/ -declare namespace timer.mq { - type ReqCode = - | 'openLimitPage' - | 'limitTimeMeet' - // @since 0.9.0 - | 'limitWaking' - // @since 1.2.3 - | 'limitChanged' - // @since 3.1.0 - | 'limitReminder' - // @since 2.0.0 - | 'askVisitTime' - | 'askHitVisit' - // @since 3.2.0 - | 'siteRunChange' - // @since 3.5.0 - | "enableTabGroup" - // @since 3.7.3 - | "syncAudible" - | "resetBackupScheduler" - // @since 4.1.0 - | "resetNotificationScheduler" - // Request by content script - // @since 1.3.0 - | "cs.isInWhitelist" - | "cs.incVisitCount" - | "cs.printTodayInfo" - | "cs.getTodayInfo" - | "cs.moreMinutes" - | "cs.getLimitedRules" - | "cs.getRelatedRules" - | "cs.trackTime" - | "cs.trackRunTime" - | "cs.onInjected" - | "cs.openAnalysis" - | "cs.openLimit" - // @since 2.5.5 - | "cs.idleChange" - // @since 3.2.0 - | "cs.getRunSites" - // @since 3.6.1 - | "cs.timelineEv" - // @since 3.7.3 - | "cs.getAudible" - - type ResCode = "success" | "fail" | "ignore" - - /** - * @since 0.2.2 - */ - type Request<T = any> = { - code: ReqCode - data?: T - } - /** - * @since 0.8.4 - */ - type Response<T = any> = { - code: ResCode, - msg?: string - data?: T - } - /** - * @since 1.3.0 - */ - type Handler<Req, Res> = (data: Req, sender: chrome.runtime.MessageSender) => Promise<Res> | Res - /** - * @since 0.8.4 - */ - type Callback<T = any> = (result?: Response<T>) => void -} \ No newline at end of file diff --git a/types/timer/option.d.ts b/types/timer/option.d.ts index a8bcf2fda..386d44b89 100644 --- a/types/timer/option.d.ts +++ b/types/timer/option.d.ts @@ -73,6 +73,8 @@ declare namespace timer.option { chartAnimationDuration: number } + type AppearanceRequired = MakeRequired<timer.option.AppearanceOption, 'darkModeTimeStart' | 'darkModeTimeEnd'> + type TrackingOption = { /** * Whether to pause tracking if no activity detected @@ -108,7 +110,13 @@ declare namespace timer.option { storage: StorageType } + type TrackingRequired = MakeRequired<timer.option.TrackingOption, 'weekStart'> + type LimitOption = { + /** + * Delay duration, minutes + */ + limitDelayDuration: number /** * Motto displayed when restricted */ @@ -139,6 +147,8 @@ declare namespace timer.option { limitReminderDuration?: number } + type LimitRequired = MakeRequired<timer.option.LimitOption, 'limitPassword' | 'limitVerifyDifficulty' | 'limitReminderDuration'> + /** * The options of backup * @@ -207,6 +217,11 @@ declare namespace timer.option { notificationAuthToken?: string } + export type DefaultOption = + & AppearanceRequired & TrackingRequired & LimitRequired + & timer.option.BackupOption & timer.option.AccessibilityOption + & timer.option.NotificationOption + type AllOption = & AppearanceOption & TrackingOption diff --git a/types/timer/period.d.ts b/types/timer/period.d.ts index 9cd8b8293..34d0a31c1 100644 --- a/types/timer/period.d.ts +++ b/types/timer/period.d.ts @@ -10,6 +10,7 @@ declare namespace timer.period { order: number } type KeyRange = [Key, Key] + type Result = Key & { /** * 1~900000 @@ -17,17 +18,25 @@ declare namespace timer.period { */ milliseconds: number } + type Row = { /** * {yyyy}{mm}{dd} */ date: string - startTime: Date - endTime: Date + /** Unix timestamp (ms) of row start */ + startTime: number + /** Unix timestamp (ms) of row end */ + endTime: number /** * 1 - 60000 * ps. 60000 = 60s * 1000ms/s */ milliseconds: number } + + type Query = { + range?: KeyRange + size?: number + } } \ No newline at end of file diff --git a/types/timer/site.d.ts b/types/timer/site.d.ts index 39a7decdf..73a7d054d 100644 --- a/types/timer/site.d.ts +++ b/types/timer/site.d.ts @@ -29,4 +29,24 @@ declare namespace timer.site { id: number name: string } + + type Query = { + fuzzyQuery?: string + cateIds?: Arrayable<number> + types?: Arrayable<timer.site.Type> + } + + type PageQuery = Query & common.PageQuery + + type ChangeCateParam = { + // Undefined means uncategorized + cateId: number | undefined + keys: SiteKey[] + } + + type ChangeAliasParam = { + key: SiteKey + // Undefined means delete alias + alias: string | undefined + } } \ No newline at end of file diff --git a/types/timer/stat.d.ts b/types/timer/stat.d.ts index e0ef2ca30..a98fea564 100644 --- a/types/timer/stat.d.ts +++ b/types/timer/stat.d.ts @@ -9,19 +9,9 @@ declare namespace timer.stat { groupKey: number } type TargetKey = SiteTarget | CateTarget | GroupTarget - type DateKey = { date?: string } + type DateKey = { date: string } type StatKey = TargetKey & DateKey - type SiteMergeExtend = { - /** - * The merged domains - * Can't be empty if merged - * - * @since 0.1.5 - */ - mergedRows?: Omit<timer.stat.SiteRow, 'mergedRows'>[] - } - type DateMergeExtend = { /** * The merged dates @@ -38,30 +28,123 @@ declare namespace timer.stat { composition?: RemoteComposition } - interface SiteRow extends SiteTarget, DateKey, core.Result, backup.RowExtend, SiteMergeExtend, DateMergeExtend, RemoteExtend { - /** - * Icon url - */ - iconUrl?: string - /** - * The alias name of this Site, always is the title of its homepage by detected - */ - alias?: string + /** StatCondition.date fields only (mq / stat-database queries). */ + type _BaseQuery = { + date?: string | [string?, string?] + mergeDate?: boolean + } + + type SiteQuery = + & _BaseQuery + & { + focusRange?: Vector<2> + timeRange?: [number, number?] + virtual?: boolean + } + & timer.common.SortBy<'date' | 'host' | timer.core.Dimension> + & { + query?: string + host?: string | string[] + mergeHost?: boolean + inclusiveRemote?: boolean + cateIds?: number[] + ignoreSite?: boolean + } + + type SiteDeleteQuery = ({ + host: string + } | { + groupId: number + }) & { + date?: [start?: string, end?: string] | string + } + + type SitePageQuery = SiteQuery & timer.common.PageQuery + + type SiteRowFlat = SiteTarget & + DateKey & + core.Result & + backup.RowExtend & + DateMergeExtend & + RemoteExtend & { + /** + * Icon url + */ + iconUrl?: string + /** + * The alias name of this Site, always is the title of its homepage by detected + */ + alias?: string + /** + * @since 3.0.0 + */ + cateId?: number + } + + type SiteMergeExtend = { /** - * @since 3.0.0 + * The merged domains + * Can't be empty if merged + * + * @since 0.1.5 */ - cateId?: number + mergedRows?: SiteRowFlat[] } - interface CateRow extends CateTarget, DateKey, core.Result, backup.RowExtend, SiteMergeExtend, DateMergeExtend, RemoteExtend { - cateName: string | undefined + type SiteRow = SiteRowFlat & SiteMergeExtend + + type CateQuery = _BaseQuery + & timer.common.SortBy<'date' | 'focus' | 'time'> + & { + query?: string + inclusiveRemote?: boolean + cateIds?: number[] + } + + type CatePageQuery = CateQuery & timer.common.PageQuery + + type CateRowFlat = CateTarget & + DateKey & + core.Result & + backup.RowExtend & + DateMergeExtend & + RemoteExtend & { + cateName: string | undefined + } + + type CateMergeExtend = { + mergedRows?: SiteRowFlat[] } - interface GroupRow extends GroupTarget, DateKey, DateMergeExtend, core.Result { - color: `${chrome.tabGroups.Color}` | undefined - title: string | undefined + type CateRow = CateRowFlat & CateMergeExtend + + type GroupRowFlat = GroupTarget & + DateKey & + DateMergeExtend & + core.Result & { + color: `${chrome.tabGroups.Color}` | undefined + title: string | undefined + } + + type GroupQuery = _BaseQuery + & { + focusRange?: Vector<2> + timeRange?: [number, number?] + } + & timer.common.SortBy<'date' | 'title' | 'focus' | 'time'> + & { + query?: string + groupIds?: number[] + } + + type GroupPageQuery = GroupQuery & timer.common.PageQuery + + type GroupMergeExtend = { + mergedRows?: GroupRowFlat[] } + type GroupRow = GroupRowFlat & GroupMergeExtend + /** * Row of each statistics result */ diff --git a/types/timer/timeline.d.ts b/types/timer/timeline.d.ts index e2a0a10de..9b11f05ed 100644 --- a/types/timer/timeline.d.ts +++ b/types/timer/timeline.d.ts @@ -10,4 +10,19 @@ declare namespace timer.timeline { duration: number host: string } -} \ No newline at end of file + + type MergeMethod = 'cate' | 'domain' | 'none' + + type Activity = { + start: number + duration: number + seriesKey: string + seriesName: string | undefined + } + + type Query = { + host?: string + start?: number + merge: MergeMethod + } +} From 2a757eb51598fd905ed436a350812890794151d3 Mon Sep 17 00:00:00 2001 From: sheepie <returnzhy1996@outlook.com> Date: Tue, 28 Apr 2026 10:51:29 +0800 Subject: [PATCH 126/174] ci: skip lib check --- tsconfig.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tsconfig.json b/tsconfig.json index 5893f3325..f18b54796 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,7 @@ "isolatedModules": true, "noUnusedLocals": true, "noUnusedParameters": true, + "skipLibCheck": true, "paths": { "@api/*": [ "./src/api/*" @@ -78,4 +79,4 @@ "tsconfig-paths/register" ] } -} \ No newline at end of file +} From 7beb8bf195761fdc8a56c279dd500b463ab2078a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 28 Apr 2026 11:07:02 +0800 Subject: [PATCH 127/174] i18n(download): download translations by bot (#742) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/limit-resource.json | 1 + src/i18n/message/app/option-resource.json | 28 +++++++++++++++---- .../message/app/site-manage-resource.json | 12 ++++++-- src/i18n/message/common/button-resource.json | 8 ++---- .../message/common/calendar-resource.json | 4 ++- src/i18n/message/common/item-resource.json | 3 ++ src/i18n/message/common/shared-resource.json | 1 - src/i18n/message/popup/content-resource.json | 6 +++- src/i18n/message/popup/header-resource.json | 4 ++- src/i18n/message/popup/limit-resource.json | 5 ++++ 10 files changed, 55 insertions(+), 17 deletions(-) diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index 3c6564eb8..2baf05ba2 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -1,5 +1,6 @@ { "zh_CN": { + "onlyEffective": "仅生效中", "wildcardTip": "您可以使用通配符来匹配子域名或子页面,使用\"+\"作为前缀来排除子页面!", "emptyTips": "点击这里创建一条规则!", "item": { diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index cf434d3fb..78243e465 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -67,7 +67,8 @@ "strictContent": "当您选择这个选项之后,如果某个站点触发了每日限制,除了等到第二天自动解锁以外,不允许您手动解锁。如果规则设置不当,很有可能会阻碍您的日常工作!", "pswFormLabel": "解锁密码", "pswFormAgain": "再次输入" - } + }, + "delayDuration": "每次延迟 {input} 分钟" }, "backup": { "title": "数据备份", @@ -1638,23 +1639,30 @@ "label": "Tryb ciemny {input}", "timed": "Czas na" }, - "animationDuration": "Długość trwania początkowej animacji wykresu {input}" + "animationDuration": "Długość trwania początkowej animacji wykresu {input}", + "sidePanel": "{input} - czy włączyć panel boczny" }, "tracking": { "title": "Monitorowanie", "autoPauseTrack": "{input} Wstrzymaj monitorowanie, jeżeli nie wykryto aktywności {info} przez {maxTime}", "noActivityInfo": "Mysz i klawiatura są nieaktywne, nie są w trybie pełnoekranowym i nie ma dźwięku", + "countLocalFiles": "{input} Śledzenie czasu, kiedy przeglądarka odczytuje {localFileTime} {info}", "localFileTime": "pliki lokalne", + "localFilesInfo": "Obsługuje pliki takich typów jak PDF, obrazy, txt lub JSON.", + "countTabGroup": "{input} Śledzenie czasu w grupach kart {info}", "tabGroupInfo": "Po usunięciu grupy tagów, dane również zostaną usunięte.", "fileAccessDisabled": "Dostęp do adresów URL plików jest obecnie niedozwolony. Proszę najpierw włączyć go na stronie zarządzania", "weekStart": "Pierwszy dzień tygodnia {input}", "weekStartAsNormal": "Normalnie", - "storage": "Przechowuj dane śledzenia w {input}" + "storage": "Przechowuj dane śledzenia w {input}", + "storageConfirm": "Czy chcesz zmienić typ pamięci na {type}?" }, "limit": { + "prompt": "Wyświetlane zapytanie, gdy ograniczono {input}", "reminder": "Przypomnienie {input} {minInput} minut(y) przed limitem czasu", "level": { "label": "Jak odblokować podczas ograniczenia {input}", + "nothing": "Zezwalaj na bezpośrednie odblokowanie na stronie administratora", "password": "Musisz wprowadzić hasło, aby odblokować", "verification": "Musisz wprowadzić kod weryfikacyjny, aby odblokować", "passwordLabel": "Hasło odblokowujące {input}", @@ -1673,24 +1681,31 @@ }, "backup": { "title": "Kopia zapasowa danych", + "type": "Zdalny typ: {input}", "client": "Nazwa klienta {input}", "meta": { + "gist": { + "authInfo": "Wymagany jest przynajmniej jeden token z gist" + }, "obsidian_local_rest_api": { "endpointInfo": "Tylko HTTP jest dostępny, ponieważ CORS nie może być skonfigurowany dla stron rozszerzenia" } }, "label": { "endpoint": "Adres punktu końcowego {info} {input}", + "path": "Ścieżka do folderu {input}", "account": "Nazwa użytkownika {input}", "password": "Hasło {input}" }, "operation": "Kopia zapasowa", "download": { "btn": "Pobierz", - "willDownload": "Do pobrania" + "willDownload": "Do pobrania", + "confirmTip": "{size} części danych z [{clientName}] zostaną pobrane" }, "clear": { - "btn": "Wyczyść" + "btn": "Wyczyść", + "confirmTip": "{rowCount} części danych dla stron {hostCount} śledzonych przez [{clientName}] zostaną usunięte!" }, "confirmStep": "Potwierdź dane", "clientTable": { @@ -1706,7 +1721,8 @@ } }, "accessibility": { - "title": "Ułatwienia dostępu" + "title": "Ułatwienia dostępu", + "chartDecal": "{input} - czy wyświetlić wykres typu decal" }, "notification": { "title": "Powiadomienie", diff --git a/src/i18n/message/app/site-manage-resource.json b/src/i18n/message/app/site-manage-resource.json index b52e1cb6b..66a75141d 100644 --- a/src/i18n/message/app/site-manage-resource.json +++ b/src/i18n/message/app/site-manage-resource.json @@ -525,14 +525,20 @@ "info": "statystyki według nazwy domeny" }, "merged": { - "name": "scalone" + "name": "scalone", + "info": "łączenie statystyk wielu powiązanych nazw stron i reguły łączenia mogą być dostosowywane" + }, + "virtual": { + "name": "wirtualne", + "info": "policz dowolny adres URL w formacie Ant Pattern, możesz dodać niestandardową stronę w prawym górnym rogu" } }, "cate": { "name": "Nazwa", "relatedMsg": "Ta kategoria została przypisana do witryn {siteCount} i nie może zostać usunięta", "removeConfirm": "Potwierdzasz usunięcie kategorii: {category}?", - "batchChange": "Zmień kategorie" + "batchChange": "Zmień kategorie", + "batchDisassociate": "Odłącz kategorie" }, "form": { "emptyAlias": "Wprowadź nazwę witryny", @@ -540,7 +546,9 @@ }, "msg": { "hostExistWarn": "{host} istnieje", + "existedTag": "WYŁĄCZONE", "noSelected": "Nie wybrano żadnej witryny", + "noSupported": "Wybrane witryny nie mogą ustawić kategorii", "disassociatedMsg": "Czy chcesz wyczyścić kategorie wszystkich wybranych witryn?", "batchDeleteMsg": "Czy chcesz usunąć wszystkie wybrane witryny?" } diff --git a/src/i18n/message/common/button-resource.json b/src/i18n/message/common/button-resource.json index 17eccfff2..58349123f 100644 --- a/src/i18n/message/common/button-resource.json +++ b/src/i18n/message/common/button-resource.json @@ -83,7 +83,6 @@ "cancel": "キャンセル", "previous": "戻る", "next": "次へ", - "okay": "OK", "dont": "いいえ", "operation": "操作", "configuration": "設定", @@ -104,7 +103,6 @@ "cancel": "Cancelar", "previous": "Anterior", "next": "Seguinte", - "okay": "OK", "dont": "Não", "operation": "Ações", "configuration": "Configurações", @@ -150,7 +148,6 @@ "cancel": "Cancelar", "previous": "Anterior", "next": "Siguiente", - "okay": "OK", "dont": "No", "operation": "Acciones", "configuration": "Configuración", @@ -174,7 +171,6 @@ "cancel": "Abbrechen", "previous": "Zurück", "next": "Weiter", - "okay": "OK", "dont": "Nein", "operation": "Aktionen", "configuration": "Einstellungen", @@ -198,7 +194,6 @@ "cancel": "Annuler", "previous": "Retour", "next": "Suivant", - "okay": "OK", "dont": "Non", "operation": "Opérations", "configuration": "Paramètres", @@ -291,10 +286,13 @@ "previous": "Wstecz", "next": "Dalej", "okay": "Ok", + "dont": "Nie", "operation": "Działania", "configuration": "Ustawienia", "clear": "Wyczyść", "enable": "Włącz", + "batchEnable": "Włączanie serii", + "batchDisable": "Seria wyłączenia", "collapse": "Zwiń", "expand": "Rozwiń" } diff --git a/src/i18n/message/common/calendar-resource.json b/src/i18n/message/common/calendar-resource.json index cf43b3257..d16c23343 100644 --- a/src/i18n/message/common/calendar-resource.json +++ b/src/i18n/message/common/calendar-resource.json @@ -285,7 +285,9 @@ "pl": { "weekDays": "Pn|Wt|Śr|Cz|Pt|Sb|Nd", "months": "Sty|Lut|Mar|Kwi|Maj|Cze|Lip|Sie|Wrz|Paź|Lis|Gru", - "dateFormat": "{m}/{d}/{y}", + "dateFormat": "{d}/{m}/{y}", + "monthDateFormat": "{d}/{m}", + "timeFormat": "{d}/{m}/{y} {h}:{i}:{s}", "simpleTimeFormat": "{m}/{d} {h}:{i}", "label": { "startDate": "Data rozpoczęcia", diff --git a/src/i18n/message/common/item-resource.json b/src/i18n/message/common/item-resource.json index bde6deb84..a2d6c47ce 100644 --- a/src/i18n/message/common/item-resource.json +++ b/src/i18n/message/common/item-resource.json @@ -224,6 +224,9 @@ "operation": { "add2Whitelist": "Whitelista", "analysis": "Analizuj", + "deleteConfirmMsgAll": "Wszystkie wpisy wizyt dla [{url}] zostaną usunięte", + "deleteConfirmMsgRange": "Sprawdź wpisy dla [{url}] pomiędzy {start} a {end} zostaną usunięte", + "deleteConfirmMsg": "Wpis wizyt dla [{url}] z {date} zostanie usunięty", "exportWholeData": "Eksportuj", "importWholeData": "Importuj", "importOtherData": "Importuj z rozszerzenia" diff --git a/src/i18n/message/common/shared-resource.json b/src/i18n/message/common/shared-resource.json index df7aa3285..745665966 100644 --- a/src/i18n/message/common/shared-resource.json +++ b/src/i18n/message/common/shared-resource.json @@ -37,7 +37,6 @@ "notSet": "未分类" }, "limit": { - "effective": "生效中", "limited": "已受限", "daily": "每日上限", "weekly": "每周上限", diff --git a/src/i18n/message/popup/content-resource.json b/src/i18n/message/popup/content-resource.json index 976fe2047..7b1ffd118 100644 --- a/src/i18n/message/popup/content-resource.json +++ b/src/i18n/message/popup/content-resource.json @@ -262,7 +262,11 @@ "allTime": "Wszystkie dane" }, "saveAsImageTitle": "Podsumowanie", - "totalTime": "Ogółem {totalTime}" + "averageCount": "Średnio {value} razy co dzień", + "averageTime": "Średnio {value} razy dziennie", + "totalTime": "Ogółem {totalTime}", + "totalCount": "Razem: {totalCount}", + "otherLabel": "Pozostałe strony: {count}" }, "ranking": { "includingCount": "Wliczając {siteCount} stron" diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index 1e13d94f0..be5d3e717 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -58,6 +58,8 @@ }, "pl": { "rating": "Prześlij ocenę", - "showSiteName": "Wyświetl nazwę witryny" + "showSiteName": "Wyświetl nazwę witryny", + "showTopN": "Wyświetl górną część {n}", + "donutChart": "Wyświetlane jako wykresy" } } \ No newline at end of file diff --git a/src/i18n/message/popup/limit-resource.json b/src/i18n/message/popup/limit-resource.json index 7db8162bc..f3de52f0e 100644 --- a/src/i18n/message/popup/limit-resource.json +++ b/src/i18n/message/popup/limit-resource.json @@ -9,6 +9,11 @@ "notHit": "Not in effect" }, "zh_CN": { + "noData": "此URL无限制规则", + "newOne": "新建", + "timeUsed": "已使用 {percent}", + "visitUsed": "已使用 {used} 次", + "remain": "剩余 {remaining}", "noLimit": "无限制", "notHit": "未命中" } From 6d45e6360447313428b815eb463b9c3ed53dee2b Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Tue, 28 Apr 2026 13:18:55 +0800 Subject: [PATCH 128/174] v4.2.0 --- CHANGELOG.md | 6 ++++++ package.json | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 55212b9f7..ba00c0f86 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,12 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.2.0] - 2026-04-28 + +- refactored basic architecture +- supported custom delay duration +- added time limit on the popup page + ## [4.1.7] - 2026-04-23 - Dropped support for Firefox versions below 140 diff --git a/package.json b/package.json index 617b0dfa5..2ff0e24f7 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tt4b", - "version": "4.1.7", + "version": "4.2.0", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { From bd948978a597810ffe4b13c8426b55fa4231e647 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Tue, 28 Apr 2026 14:15:49 +0800 Subject: [PATCH 129/174] chore: debug edge --- .github/workflows/publish-edge.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/publish-edge.yml b/.github/workflows/publish-edge.yml index 1fde28733..39892ed1a 100644 --- a/.github/workflows/publish-edge.yml +++ b/.github/workflows/publish-edge.yml @@ -3,6 +3,8 @@ on: [workflow_dispatch] jobs: publish: runs-on: ubuntu-latest + env: + ACTIONS_RUNNER_DEBUG: true steps: - uses: actions/checkout@v4 with: @@ -10,7 +12,7 @@ jobs: - name: Setup NodeJS uses: actions/setup-node@v4 with: - node-version: "v22" + node-version: "v24" - name: Install dependencies run: npm install - name: Build From b216933698e7548836efad35594284afecb2bc2a Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Tue, 28 Apr 2026 22:43:01 +0800 Subject: [PATCH 130/174] ci: min version for publish --- .github/workflows/publish-all.yml | 6 ++++-- .github/workflows/publish-firefox.yml | 11 +++++++---- 2 files changed, 11 insertions(+), 6 deletions(-) diff --git a/.github/workflows/publish-all.yml b/.github/workflows/publish-all.yml index ddfcb8635..eeebda540 100644 --- a/.github/workflows/publish-all.yml +++ b/.github/workflows/publish-all.yml @@ -1,9 +1,10 @@ name: Publish Extension to All Stores -on: [workflow_dispatch] +on: [ workflow_dispatch ] env: MAX_PACKAGE_SIZE: 2621440 # 2.5MB in bytes + FF_MIN_VER: "140.0" jobs: build: @@ -130,6 +131,7 @@ jobs: addon-guid: "{a8cf72f7-09b7-4cd4-9aaa-7a023bf09916}" xpi-path: target.firefox.zip source-file-path: target.src.zip - compatibility: firefox, android + compatibility: "{\"firefox\": {\"min\": \"${{ env.FF_MIN_VER }}\"}, \"android\": + {\"min\": \"${{ env.FF_MIN_VER}}\"}}" jwt-issuer: ${{ secrets.FIREFOX_JWD_ISSUER }} jwt-secret: ${{ secrets.FIREFOX_JWD_SECRET }} diff --git a/.github/workflows/publish-firefox.yml b/.github/workflows/publish-firefox.yml index d666b8793..b41cff429 100644 --- a/.github/workflows/publish-firefox.yml +++ b/.github/workflows/publish-firefox.yml @@ -1,16 +1,18 @@ name: Publish to Firefox Addon Store -on: [workflow_dispatch] +on: [ workflow_dispatch ] jobs: publish: runs-on: ubuntu-latest + env: + FF_MIN_VER: "140.0" steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup NodeJS - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: "v22" + node-version: "v24" - name: Install dependencies run: npm install - name: Build @@ -23,6 +25,7 @@ jobs: addon-guid: "{a8cf72f7-09b7-4cd4-9aaa-7a023bf09916}" xpi-path: market_packages/target.firefox.zip source-file-path: market_packages/target.src.zip - compatibility: firefox, android + compatibility: "{\"firefox\": {\"min\": \"${{ env.FF_MIN_VER }}\"}, \"android\": + {\"min\": \"${{ env.FF_MIN_VER}}\"}}" jwt-issuer: ${{ secrets.FIREFOX_JWD_ISSUER }} jwt-secret: ${{ secrets.FIREFOX_JWD_SECRET }} From 2949b56d4ec64f983b0e6e45ee575c6e217ab784 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Wed, 29 Apr 2026 00:06:59 +0800 Subject: [PATCH 131/174] fix: popup style --- src/i18n/message/popup/limit-resource.json | 2 +- .../components/Modify/Step3/PeriodInput.tsx | 9 +++- src/pages/app/components/Limit/context.ts | 29 ++++++++++- src/pages/app/components/Limit/index.tsx | 21 +------- .../popup/components/Footer/DataToolbar.tsx | 1 - .../components/Footer/DurationSelect.tsx | 51 ++++++++----------- .../popup/components/Footer/LimitToolbar.tsx | 38 +++++++++----- src/pages/popup/components/Footer/index.tsx | 6 +-- .../popup/components/Limit/Content/Wrapper.ts | 33 +++++++++--- .../popup/components/Limit/Content/index.tsx | 4 +- src/pages/popup/components/Limit/Summary.tsx | 2 +- src/pages/popup/components/Limit/index.tsx | 2 +- src/pages/popup/context.ts | 12 +++-- src/shared/route.ts | 3 +- 14 files changed, 128 insertions(+), 85 deletions(-) diff --git a/src/i18n/message/popup/limit-resource.json b/src/i18n/message/popup/limit-resource.json index f3de52f0e..e9287c95a 100644 --- a/src/i18n/message/popup/limit-resource.json +++ b/src/i18n/message/popup/limit-resource.json @@ -5,7 +5,7 @@ "timeUsed": "{percent} used", "visitUsed": "{used} count visited", "remain": "{remaining} remaining", - "noLimit": "No Limit", + "noLimit": "No limit", "notHit": "Not in effect" }, "zh_CN": { diff --git a/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx b/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx index 0249b8c22..2d585afac 100644 --- a/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx @@ -5,10 +5,10 @@ * https://opensource.org/licenses/MIT */ -import { useState, useSwitch, useXsState } from '@hooks' import { t } from '@app/locale' import { Check, Close, Plus } from "@element-plus/icons-vue" import { css } from '@emotion/css' +import { useState, useSwitch, useXsState } from '@hooks' import Flex from "@pages/components/Flex" import { dateMinute2Idx, period2Str } from "@util/limit" import { MILL_PER_HOUR } from "@util/time" @@ -71,6 +71,13 @@ const checkImpact = (p1: timer.limit.Period, p2: timer.limit.Period): boolean => const rangeInitial = (): [Date, Date] => { const now = new Date() + if (now.getHours() >= 23) { + const start = new Date(now) + start.setHours(23, 0, 0, 0) + const end = new Date(now) + end.setHours(23, 59, 0, 0) + return [start, end] + } return [now, new Date(now.getTime() + MILL_PER_HOUR)] } diff --git a/src/pages/app/components/Limit/context.ts b/src/pages/app/components/Limit/context.ts index b8c412f4c..967d6b8f6 100644 --- a/src/pages/app/components/Limit/context.ts +++ b/src/pages/app/components/Limit/context.ts @@ -2,9 +2,12 @@ import { getOption } from '@/api/sw/option' import { DEFAULT_LIMIT } from '@/util/constant/option' import { deleteLimits, listLimits, updateLimits } from "@api/sw/limit" import { t } from '@app/locale' +import type { LimitQuery } from '@app/router/constants' import { useDocumentVisibility, useManualRequest, useProvide, useProvider, useRequest } from '@hooks' +import { tryParseInteger } from '@util/number' import { ElMessage, ElMessageBox } from "element-plus" -import { computed, reactive, ref, toRaw, watch, type ShallowRef } from "vue" +import { computed, onMounted, reactive, ref, toRaw, watch, type ShallowRef } from "vue" +import { useRoute, useRouter } from 'vue-router' import { verifyCanModify } from "./common" import type { LimitFilterOption, LimitInstance, ModifyInstance, TestInstance } from "./types" @@ -28,8 +31,25 @@ type Context = { const NAMESPACE = 'limit' +const initialQuery = () => { + const { url, action, id: idQuery } = useRoute().query as LimitQuery + useRouter().replace({ query: {} }) + const [isNum, idMaybe] = idQuery ? tryParseInteger(idQuery) : [false, undefined] + return { + url: url && decodeURIComponent(url), + action, + id: isNum && !Number.isNaN(idMaybe) && idMaybe ? idMaybe : undefined, + } +} + +export const useLimitProvider = () => { + const { url, action, id } = initialQuery() + const initialUrl = action === 'create' ? undefined : url + + if (action === 'create') { + onMounted(() => setTimeout(() => modifyInst.value?.create(url))) + } -export const useLimitProvider = (initialUrl: string | undefined) => { const filter = reactive<LimitFilterOption>({ url: initialUrl, effective: false }) const { data: list, refresh, loading } = useRequest( @@ -37,6 +57,11 @@ export const useLimitProvider = (initialUrl: string | undefined) => { { defaultValue: [], deps: [() => filter.url, () => filter.effective], + onSuccess: data => { + if (action !== 'modify') return + const target = data.find(i => i.id === id) + target && setTimeout(() => modifyInst.value?.modify(target)) + } }, ) diff --git a/src/pages/app/components/Limit/index.tsx b/src/pages/app/components/Limit/index.tsx index 8644ebefb..dee76c1ac 100644 --- a/src/pages/app/components/Limit/index.tsx +++ b/src/pages/app/components/Limit/index.tsx @@ -5,34 +5,17 @@ * https://opensource.org/licenses/MIT */ -import type { LimitQuery } from '@app/router/constants' import { useXsState } from '@hooks' -import { defineComponent, onMounted } from "vue" -import { useRoute, useRouter } from 'vue-router' +import { defineComponent } from "vue" import ContentCard from '../common/ContentCard' import ContentContainer from '../common/ContentContainer' import { Filter, List, Modify, Table, Test } from "./components" import { useLimitProvider } from "./context" -const initialQuery = () => { - const { url, action } = useRoute().query as LimitQuery - useRouter().replace({ query: {} }) - return { - url: url && decodeURIComponent(url), - action, - } -} - const _default = defineComponent(() => { - const { url, action } = initialQuery() - const initialUrl = action === 'create' ? undefined : url - const { modifyInst, testInst, inst } = useLimitProvider(initialUrl) + const { modifyInst, testInst, inst } = useLimitProvider() const isXs = useXsState() - if (action === 'create') { - onMounted(() => setTimeout(() => modifyInst.value?.create(url))) - } - return () => ( <ContentContainer v-slots={{ filter: () => <Filter />, diff --git a/src/pages/popup/components/Footer/DataToolbar.tsx b/src/pages/popup/components/Footer/DataToolbar.tsx index fe9a653e8..6492a7a74 100644 --- a/src/pages/popup/components/Footer/DataToolbar.tsx +++ b/src/pages/popup/components/Footer/DataToolbar.tsx @@ -31,7 +31,6 @@ const DataToolbar = defineComponent(() => { /> </Flex> <DurationSelect - reverse modelValue={[query.duration, query.durationNum]} onChange={([duration, durationNum]) => { query.duration = duration diff --git a/src/pages/popup/components/Footer/DurationSelect.tsx b/src/pages/popup/components/Footer/DurationSelect.tsx index 5d88bf826..853c6c356 100644 --- a/src/pages/popup/components/Footer/DurationSelect.tsx +++ b/src/pages/popup/components/Footer/DurationSelect.tsx @@ -5,12 +5,10 @@ import { type PopupDuration } from '@popup/types' import { type CascaderNode, type CascaderOption, ElCascader, useNamespace } from "element-plus" import { computed, defineComponent } from "vue" -const rangeLabel = (duration: PopupDuration, n?: string | number): string => { - return t(calendarMessages, { - key: msg => msg.range[duration], - param: n ? { n } : undefined, - }) -} +const rangeLabel = (duration: PopupDuration, n?: string | number): string => t(calendarMessages, { + key: msg => msg.range[duration], + param: n ? { n } : undefined, +}) const BUILTIN_DAY_NUM = [7, 30, 90, 180, 365] @@ -18,33 +16,28 @@ const cvt2Opt = (value: PopupDuration, n?: string | number): CascaderOption => ( value, label: rangeLabel(value, n), }) -const options = (reverse?: boolean): CascaderOption[] => { - const result: CascaderOption[] = [ - ...(['today', 'yesterday', 'thisWeek', 'thisMonth'] satisfies PopupDuration[]).map(cvt2Opt), - { - ...cvt2Opt('lastDays', 'X'), - children: [ - ...BUILTIN_DAY_NUM.map(value => ({ - value, - label: rangeLabel('lastDays', value), - })), - ], - }, - cvt2Opt('allTime'), - ] - return reverse ? result.reverse() : result -} +const options = (): CascaderOption[] => [ + cvt2Opt('allTime'), + { + ...cvt2Opt('lastDays', 'X'), + children: [ + ...BUILTIN_DAY_NUM.map(value => ({ + value, + label: rangeLabel('lastDays', value), + })), + ], + }, + ...(['thisMonth', 'thisWeek', 'yesterday', 'today'] satisfies PopupDuration[]).map(cvt2Opt), +] type DurationValue = [PopupDuration, number?] -type Props = ModelValue<DurationValue> & { - reverse?: boolean -} +type Props = ModelValue<DurationValue> const DurationSelect = defineComponent<Props>(props => { const casVal = computed(() => { - const [type, num] = props.modelValue || [] - return type === 'lastDays' ? num || 30 : type || 'today' + const [type, num] = props.modelValue + return type === 'lastDays' ? num ?? 30 : type }) const cascaderNs = useNamespace('cascader') @@ -62,7 +55,7 @@ const DurationSelect = defineComponent<Props>(props => { <ElCascader modelValue={casVal.value} onChange={val => props.onChange?.(val as [PopupDuration, number?])} - options={options(props.reverse)} + options={options()} show-all-levels={false} style={{ width: '130px' }} popperClass={popoverCls} @@ -73,6 +66,6 @@ const DurationSelect = defineComponent<Props>(props => { }} </ElCascader> ) -}, { props: ['modelValue', 'onChange', 'reverse'] }) +}, { props: ['modelValue', 'onChange'] }) export default DurationSelect \ No newline at end of file diff --git a/src/pages/popup/components/Footer/LimitToolbar.tsx b/src/pages/popup/components/Footer/LimitToolbar.tsx index 38bd6cd1b..0487c7b8c 100644 --- a/src/pages/popup/components/Footer/LimitToolbar.tsx +++ b/src/pages/popup/components/Footer/LimitToolbar.tsx @@ -1,6 +1,6 @@ import { APP_LIMIT_ROUTE, AppLimitQuery } from '@/shared/route' import { createTab } from '@api/chrome/tab' -import { Plus } from '@element-plus/icons-vue' +import { Edit, Plus } from '@element-plus/icons-vue' import Flex from '@pages/components/Flex' import { useLimitSummary } from '@popup/context' import { t } from '@popup/locale' @@ -18,11 +18,11 @@ const findHost = (url: string) => { } const LimitToolbar = defineComponent(() => { - const { limitSummary, selectedLimit } = useLimitSummary() - const items = computed(() => limitSummary.value?.items || []) + const { summary, selected, loading } = useLimitSummary() + const items = computed(() => summary.value?.items || []) const handleNew = async () => { - let url = limitSummary.value?.url + let url = summary.value?.url const query: AppLimitQuery = { action: 'create' } if (url && !isBrowserUrl(url)) { const host = findHost(url) @@ -31,16 +31,30 @@ const LimitToolbar = defineComponent(() => { await createTab(getAppPageUrl(APP_LIMIT_ROUTE, query)) } + const handleEdit = async () => { + if (!selected.value) return + const query: AppLimitQuery = { action: 'modify', id: String(selected.value) } + await createTab(getAppPageUrl(APP_LIMIT_ROUTE, query)) + } + return () => ( <Flex gap={8} justify='end'> - {items.value.length ? ( - <ElSelect - modelValue={selectedLimit.value} - onChange={val => typeof val === 'number' && (selectedLimit.value = val)} - options={items.value.map(i => ({ value: i.id, label: i.name }))} - style={{ width: '140px' } satisfies StyleValue} - /> - ) : ( + {!!items.value.length && ( + <Flex gap={4}> + <ElSelect + modelValue={selected.value} + onChange={val => typeof val === 'number' && (selected.value = val)} + options={items.value.map(i => ({ value: i.id, label: i.name }))} + style={{ width: '140px' } satisfies StyleValue} + /> + <ElButton + icon={Edit} + onClick={handleEdit} + style={{ width: '40px' } satisfies StyleValue} + /> + </Flex> + )} + {!loading.value && !items.value.length && ( <ElButton type='primary' icon={Plus} onClick={handleNew}> {t(msg => msg.limit.newOne)} </ElButton> diff --git a/src/pages/popup/components/Footer/index.tsx b/src/pages/popup/components/Footer/index.tsx index 3803d34c4..36c643b81 100644 --- a/src/pages/popup/components/Footer/index.tsx +++ b/src/pages/popup/components/Footer/index.tsx @@ -9,10 +9,8 @@ const Footer = defineComponent(() => { const { menu } = useMenu() return () => ( - <Flex justify="space-between" width="100%"> - <Flex> - <Menu /> - </Flex> + <Flex justify="space-between" marginBottom={2} marginInline={1}> + <Menu /> <Transition name="el-fade-in" mode="out-in"> {menu.value === 'limit' ? <LimitToolbar /> : <DataToolbar />} </Transition> diff --git a/src/pages/popup/components/Limit/Content/Wrapper.ts b/src/pages/popup/components/Limit/Content/Wrapper.ts index ed158f1ff..7d1ea6a49 100644 --- a/src/pages/popup/components/Limit/Content/Wrapper.ts +++ b/src/pages/popup/components/Limit/Content/Wrapper.ts @@ -50,7 +50,7 @@ const createInfo = (text: string, left: string): TitleComponentOption => { subtext: '', padding: 0, itemGap: 0, - textStyle: { color: textSecondary, fontSize: 14, overflow: 'break', width: 120 }, + textStyle: { color: textSecondary, fontSize: 14, overflow: 'break', width: 140 }, } } @@ -64,8 +64,18 @@ type GaugeOptions = { color: string } -const createGauge = ({ name, center, usage: { used, limit }, color }: GaugeOptions): GaugeSeriesOption => { +const createGauge = (options: GaugeOptions): GaugeSeriesOption => { + const { name, center, usage: { used, limit }, color } = options + const percent = limit ? clamp((used * 100) / limit, 0, 100) : 40 + const percentText = `${percent.toFixed(1)}%` + const usedText = name === 'time' + ? formatPeriodCommon(used, true) + : t(msg => msg.shared.limit.visits, { n: used }) + const progressText = name === 'time' + ? `${usedText}/${formatPeriodCommon(limit, true)}` + : t(msg => msg.shared.limit.visits, { n: `${used}/${limit}` }) + return { name, type: 'gauge', @@ -86,7 +96,10 @@ const createGauge = ({ name, center, usage: { used, limit }, color }: GaugeOptio color: getSecondaryTextColor(), width: 100, overflow: 'break', - formatter: limit ? val => `${val.toFixed(1)}%` : () => t(msg => msg.limit.noLimit) + formatter: () => limit ? percentText : usedText, + }, + tooltip: { + formatter: () => `${percentText}<br/>${progressText}`, }, silent: false, axisLine: { lineStyle: { width: 8, color: [[1, GAUGE_BG_COLOR]] } }, @@ -127,10 +140,13 @@ class Wrapper extends EchartsWrapper<timer.limit.Item, EcOption> { } private builtLimitPart(time: [number, number | undefined], visit: [number, number | undefined], leftPos: string): ChartPart { + const noLimitText = t(msg => msg.limit.noLimit) + const [timeUsed, timeLimit] = time const timeMax = timeLimit ? timeLimit * MILL_PER_SECOND : 0 - const timeUsedText = formatPeriodCommon(timeUsed, true) - const timeLabel = timeMax ? `${timeUsedText} / ${formatPeriodCommon(timeMax, true)}` : timeUsedText + const timeLabel = timeMax + ? t(msg => msg.limit.remain, { remaining: formatPeriodCommon(timeMax - timeUsed, true) }) + : noLimitText const timeOpts: GaugeOptions = { name: 'time', center: leftPos, usage: { limit: timeMax, used: timeUsed }, @@ -138,7 +154,9 @@ class Wrapper extends EchartsWrapper<timer.limit.Item, EcOption> { } const [visitUsed, visitLimit] = visit - const visitLabel = t(msg => msg.shared.limit.visits, { n: visitLimit ? `${visitUsed}/${visitLimit}` : visitUsed }) + const visitLabel = visitLimit + ? t(msg => msg.shared.limit.visits, { n: `${visitUsed}/${visitLimit}` }) + : noLimitText const visitOpts: GaugeOptions = { name: 'visit', center: leftPos, usage: { limit: visitLimit ?? 0, used: visitUsed ?? 0 }, @@ -227,7 +245,6 @@ class Wrapper extends EchartsWrapper<timer.limit.Item, EcOption> { min: 0, max: MINUTES_PER_DAY, splitNumber: 4, - axisTick: { splitNumber: 6, length: 3, @@ -257,7 +274,7 @@ class Wrapper extends EchartsWrapper<timer.limit.Item, EcOption> { label: { show: false }, emphasis: { scale: false }, itemStyle: { borderWidth: 0 }, - tooltip: { formatter: (params: any) => (params as { name: string }).name || null }, + tooltip: { formatter: ({ name }) => name }, data: pieData.filter(d => d.value > 0), } as PieSeriesOption, ], diff --git a/src/pages/popup/components/Limit/Content/index.tsx b/src/pages/popup/components/Limit/Content/index.tsx index bd1f61b25..9e7e52f36 100644 --- a/src/pages/popup/components/Limit/Content/index.tsx +++ b/src/pages/popup/components/Limit/Content/index.tsx @@ -15,8 +15,8 @@ const Empty: FunctionalComponent<{}> = () => ( ) const Content = defineComponent<{}>(() => { - const { limitSummary: summary, selectedLimit } = useLimitSummary() - const item = computed(() => summary.value?.items.find(i => i.id === selectedLimit.value)) + const { summary, selected } = useLimitSummary() + const item = computed(() => summary.value?.items.find(i => i.id === selected.value)) return () => item.value ? <Chart item={item.value} /> : <Empty /> }) diff --git a/src/pages/popup/components/Limit/Summary.tsx b/src/pages/popup/components/Limit/Summary.tsx index 227442ceb..9c3abad3c 100644 --- a/src/pages/popup/components/Limit/Summary.tsx +++ b/src/pages/popup/components/Limit/Summary.tsx @@ -7,7 +7,7 @@ import { computed, defineComponent, StyleValue } from 'vue' const TITLE_SIZE = 24 const Summary = defineComponent<{}>(() => { - const { limitSummary: summary } = useLimitSummary() + const { summary } = useLimitSummary() const site = computed(() => { const site = summary.value?.site if (!site) return '' diff --git a/src/pages/popup/components/Limit/index.tsx b/src/pages/popup/components/Limit/index.tsx index ef30f9c8d..7949e4f2b 100644 --- a/src/pages/popup/components/Limit/index.tsx +++ b/src/pages/popup/components/Limit/index.tsx @@ -5,7 +5,7 @@ import Content from './Content' import Summary from './Summary' const Limit: FunctionalComponent<{}> = () => ( - <ElCard style={{ width: '100%' } satisfies StyleValue}> + <ElCard shadow='never' style={{ width: '100%' } satisfies StyleValue}> <Flex column width='100%' height='100%'> <Summary /> <Content /> diff --git a/src/pages/popup/context.ts b/src/pages/popup/context.ts index 20e0d33cd..c1f5fa554 100644 --- a/src/pages/popup/context.ts +++ b/src/pages/popup/context.ts @@ -21,6 +21,7 @@ type PopupContextValue = { menu: ShallowRef<PopupMenu | undefined> setMenu: ArgCallback<PopupMenu> limitSummary: ShallowRef<timer.limit.Summary | undefined> + limitSummaryLoading: ShallowRef<boolean> selectedLimit: Ref<number | undefined> } @@ -67,7 +68,7 @@ export const initPopupContext = (): ShallowRef<number> => { const [menu, setMenu] = initMenu() - const { data: limitSummary } = useRequest( + const { data: limitSummary, loading: limitSummaryLoading } = useRequest( () => menu.value === 'limit' ? getLimitSummary() : Promise.resolve(undefined), { deps: menu, @@ -85,7 +86,7 @@ export const initPopupContext = (): ShallowRef<number> => { reload, darkMode, setDarkMode, query, option, cateNameMap, menu, setMenu, - limitSummary, selectedLimit + limitSummary, limitSummaryLoading, selectedLimit, }) return appKey @@ -129,4 +130,9 @@ export const useCateNameMap = () => useProvider<PopupContextValue, 'cateNameMap' export const useMenu = () => useProvider<PopupContextValue, 'menu' | 'setMenu'>(NAMESPACE, 'menu', 'setMenu') -export const useLimitSummary = () => useProvider<PopupContextValue, 'limitSummary' | 'selectedLimit'>(NAMESPACE, 'limitSummary', 'selectedLimit') \ No newline at end of file +export const useLimitSummary = () => { + const { limitSummary: summary, limitSummaryLoading: loading, selectedLimit: selected } = useProvider<PopupContextValue, 'limitSummary' | 'limitSummaryLoading' | 'selectedLimit'>( + NAMESPACE, 'limitSummary', 'limitSummaryLoading', 'selectedLimit' + ) + return { summary, loading, selected } +} \ No newline at end of file diff --git a/src/shared/route.ts b/src/shared/route.ts index 0ee3bcc23..307ef5e5d 100644 --- a/src/shared/route.ts +++ b/src/shared/route.ts @@ -1,7 +1,8 @@ export const APP_LIMIT_ROUTE = '/behavior/limit' export type AppLimitQuery = { + action?: 'create' | 'modify' url?: string - action?: 'create' + id?: string } export const APP_ANALYSIS_ROUTE = '/data/analysis' From 5cb82df78c20dbfc6763c5210a2d0050ac2a940d Mon Sep 17 00:00:00 2001 From: sheepie <returnzhy1996@outlook.com> Date: Wed, 29 Apr 2026 10:39:18 +0800 Subject: [PATCH 132/174] fix: limit not hide after enabled (#745) --- src/background/service/limit-service.ts | 5 +---- src/content-script/limit/index.ts | 6 +++--- .../limit/processor/message-adaptor.ts | 8 +++----- .../limit/processor/period-processor.ts | 15 ++++++--------- .../limit/processor/visit-processor.ts | 2 +- src/content-script/limit/types.ts | 2 +- types/timer/message.d.ts | 7 ++++--- 7 files changed, 19 insertions(+), 26 deletions(-) diff --git a/src/background/service/limit-service.ts b/src/background/service/limit-service.ts index 86d0f1bd0..b81b4765f 100644 --- a/src/background/service/limit-service.ts +++ b/src/background/service/limit-service.ts @@ -65,13 +65,10 @@ function cvtRecord2Item({ records, ...others }: LimitRecord, today: string, week * @param item */ export async function noticeLimitChanged(): Promise<void> { - const effectiveItems = await selectLimit({ effective: true }) const tabs = await listTabs() tabs.forEach(({ id, url }) => { if (!id || !url) return - const matched = effectiveItems.filter(({ cond }) => matches(cond, url)) - if (!matched.length) return - sendMsg2Tab(id, 'limitChanged', matched).catch(err => console.warn(err.message)) + sendMsg2Tab(id, 'limitChanged').catch(err => console.info(err?.message)) }) } diff --git a/src/content-script/limit/index.ts b/src/content-script/limit/index.ts index 0391c3171..fabea9e7b 100644 --- a/src/content-script/limit/index.ts +++ b/src/content-script/limit/index.ts @@ -25,9 +25,9 @@ export default async function processLimit(url: string, dispatcher: Dispatcher) const reminder = new Reminder() dispatcher - .register('limitChanged', items => processors.forEach(p => p.onLimitChanged(items))) - .register('limitTimeMeet', items => mesageAdaptor.onLimitTimeMeet(items)) - .register('limitReminder', data => reminder.show(data)) + .register('limitChanged', () => void processors.forEach(p => p.onLimitChanged())) + .register('limitTimeMeet', items => void mesageAdaptor.onLimitTimeMeet(items)) + .register('limitReminder', data => void reminder.show(data)) .register('askVisitHit', ruleId => modal.reasons.some(r => r.type === 'VISIT' && ruleId === r.id)) .registerAudibleChange(visitProcessor.tracker) diff --git a/src/content-script/limit/processor/message-adaptor.ts b/src/content-script/limit/processor/message-adaptor.ts index 7bb318ace..eb7a2dc9c 100644 --- a/src/content-script/limit/processor/message-adaptor.ts +++ b/src/content-script/limit/processor/message-adaptor.ts @@ -13,10 +13,8 @@ const cvtItem2AddReason = (item: timer.limit.Item, delayDuration: number): Limit class MessageAdaptor implements Processor { constructor(private readonly context: ModalContext, private readonly delayDuration: number) { } - onLimitChanged(items: timer.limit.Item[]): void { - this.context.modal.removeReasonsByType("DAILY", "WEEKLY") - items.flatMap(item => cvtItem2AddReason(item, this.delayDuration)) - .forEach(reason => this.context.modal.addReason(reason)) + onLimitChanged(): void { + this.initRules() } onLimitTimeMeet(items: timer.limit.Item[]): void { @@ -33,7 +31,7 @@ class MessageAdaptor implements Processor { async initRules(): Promise<void> { this.context.modal.removeReasonsByType('DAILY', 'WEEKLY') - const limitedRules = await trySendMsg2Runtime('limit.list', { limited: true, url: this.context.url }) + const limitedRules = await trySendMsg2Runtime('limit.list', { limited: true, effective: true, url: this.context.url }) if (!limitedRules?.length) return const reasons = limitedRules.flatMap(item => cvtItem2AddReason(item, this.delayDuration)) diff --git a/src/content-script/limit/processor/period-processor.ts b/src/content-script/limit/processor/period-processor.ts index 9a7475a06..2048c5dd0 100644 --- a/src/content-script/limit/processor/period-processor.ts +++ b/src/content-script/limit/processor/period-processor.ts @@ -28,19 +28,16 @@ class PeriodProcessor implements Processor { constructor(private readonly context: ModalContext) { } - async onLimitChanged(data: timer.limit.Item[]): Promise<void> { - this.timers.forEach(clearTimeout) - await this.init0(data) - } - - init(): Promise<void> { - return this.init0() + async onLimitChanged(): Promise<void> { + await this.init() } - private async init0(rules?: timer.limit.Item[]) { - rules = rules ?? await trySendMsg2Runtime('limit.list', { effective: true, url: this.context.url }) ?? [] + async init(): Promise<void> { // Clear first + this.timers.forEach(clearTimeout) this.context.modal.removeReasonsByType("PERIOD") + + const rules = await trySendMsg2Runtime('limit.list', { effective: true, url: this.context.url }) ?? [] const nowSeconds = date2Idx(new Date()) this.timers = rules.flatMap(r => processRule(r, nowSeconds, this.context)) } diff --git a/src/content-script/limit/processor/visit-processor.ts b/src/content-script/limit/processor/visit-processor.ts index 25c641b0b..f90df40f8 100644 --- a/src/content-script/limit/processor/visit-processor.ts +++ b/src/content-script/limit/processor/visit-processor.ts @@ -15,7 +15,7 @@ class VisitProcessor implements Processor { }) } - onLimitChanged(_: timer.limit.Item[]): void { + onLimitChanged(): void { this.initRules() } diff --git a/src/content-script/limit/types.ts b/src/content-script/limit/types.ts index 707b2ce61..bbe4dbfce 100644 --- a/src/content-script/limit/types.ts +++ b/src/content-script/limit/types.ts @@ -23,5 +23,5 @@ export type ModalContext = { export interface Processor { init(): Awaitable<void> - onLimitChanged(items: timer.limit.Item[]): void + onLimitChanged(): void } \ No newline at end of file diff --git a/types/timer/message.d.ts b/types/timer/message.d.ts index dcebd36c6..6e9125f3a 100644 --- a/types/timer/message.d.ts +++ b/types/timer/message.d.ts @@ -135,9 +135,10 @@ declare namespace timer.mq { declare namespace timer.tab { type _HandlerRegistry = & _MakeRegistry<'siteRunChange'> - & _MakeRegistry<'syncAudible', boolean, void> - & _MakeRegistry<'limitTimeMeet' | 'limitChanged', limit.Item[], void> - & _MakeRegistry<'limitReminder', limit.ReminderInfo, void> + & _MakeRegistry<'syncAudible', boolean> + & _MakeRegistry<'limitTimeMeet', limit.Item[]> + & _MakeRegistry<'limitChanged'> + & _MakeRegistry<'limitReminder', limit.ReminderInfo> & _MakeRegistry<'askVisitHit', number, boolean> type ReqCode = keyof _HandlerRegistry From dc7aaa5340c018b2a6300faf9dce1be78c81157c Mon Sep 17 00:00:00 2001 From: sheepie <returnzhy1996@outlook.com> Date: Wed, 29 Apr 2026 12:47:43 +0800 Subject: [PATCH 133/174] refactor: update ts strictness (#743) --- script/crowdin/client.ts | 5 ++- script/crowdin/common.ts | 6 ++-- script/user-chart/add.ts | 30 ++++++++--------- script/user-chart/render.ts | 5 ++- script/util/process.ts | 2 +- src/background/alarm-manager.ts | 9 ++--- src/background/database/cate-database.ts | 2 +- src/background/database/common/migratable.ts | 6 ++-- .../database/stat-database/classic.ts | 5 +-- src/background/database/stat-database/idb.ts | 18 ++++------ src/background/limit-processor.ts | 4 +-- src/background/psl/index.ts | 1 + .../service/components/host-merge-ruler.ts | 6 ++-- .../service/components/period-calculator.ts | 14 ++++---- src/background/service/site-service.ts | 30 +++++++++-------- .../service/stat-service/merge/date.ts | 33 +++++++++---------- .../AnalysisFilter/TargetSelect.tsx | 6 ++-- .../components/Summary/Calendar/Wrapper.ts | 10 ++---- .../components/Trend/Dimension/Wrapper.ts | 4 +-- .../Analysis/components/Trend/context.ts | 5 +-- .../Dashboard/components/Calendar/Wrapper.ts | 16 ++++----- .../components/MonthOnMonth/Wrapper.ts | 10 +++--- .../components/Timeline/Chart/Wrapper.ts | 18 +++++----- .../Dashboard/components/Timeline/Summary.tsx | 4 +-- .../DataManage/Migration/ImportButton.tsx | 8 ++--- .../Migration/ImportOtherButton/processor.ts | 32 +++++++++--------- .../Habit/Period/Average/Wrapper.tsx | 6 ++-- .../components/Habit/Period/Stack/Wrapper.ts | 4 +-- .../app/components/Habit/Period/Summary.tsx | 2 +- .../components/Habit/Period/Trend/Wrapper.ts | 15 ++++++--- .../app/components/Habit/Period/common.ts | 19 +++++------ .../Habit/Site/DailyTrend/Wrapper.ts | 2 +- .../app/components/Habit/Site/TopK/Wrapper.ts | 32 ++++++++---------- .../components/Limit/components/List/Card.tsx | 12 +++---- .../components/Modify/Step3/PeriodInput.tsx | 11 ++++--- .../Limit/components/Modify/index.tsx | 4 +-- .../Option/categories/Appearance/index.tsx | 2 +- .../Whitelist/WhitePanel/WhiteInput.tsx | 10 +++--- .../components/Whitelist/WhitePanel/index.tsx | 3 +- src/pages/app/util/echarts.ts | 32 +++++++++--------- .../util/limit/generator/uncommon-chinese.ts | 2 +- src/pages/app/util/limit/processor.ts | 18 ++++------ .../popup/components/Limit/Content/Wrapper.ts | 8 +++-- .../components/Percentage/Site/Wrapper.ts | 2 +- .../popup/components/Percentage/chart.ts | 9 +++-- src/pages/util/dark-mode.ts | 4 +-- src/util/base64.ts | 12 +++---- src/util/echarts.ts | 3 +- src/util/limit.ts | 2 ++ src/util/pattern.ts | 14 ++++---- src/util/period.ts | 2 +- src/util/time.ts | 10 +++--- src/util/tuple.ts | 33 +++++++++---------- test-e2e/backup/common.ts | 1 + test-e2e/backup/gist.test.ts | 4 +-- test-e2e/common/record.ts | 16 ++++----- test-e2e/limit/common.ts | 16 ++++----- test-e2e/tracker/base.test.ts | 12 +++---- test-e2e/tracker/run-time.test.ts | 12 +++---- .../background/backup/gist/compressor.test.ts | 14 ++++---- .../database/limit-database.test.ts | 14 ++++---- .../database/stat-database/classic.test.ts | 8 ++--- test/util/array.test.ts | 2 +- tsconfig.json | 4 ++- types/common.d.ts | 9 ++--- 65 files changed, 325 insertions(+), 349 deletions(-) diff --git a/script/crowdin/client.ts b/script/crowdin/client.ts index c07b46f61..b446a48ac 100644 --- a/script/crowdin/client.ts +++ b/script/crowdin/client.ts @@ -229,15 +229,14 @@ export class CrowdinClient { console.log("Content length: " + Object.keys(content).length) for (const [path, value] of Object.entries(content)) { const string = existStringsKeyMap[path] + if (!string) continue const patch: PatchRequest[] = [] string.text !== value && patch.push({ op: 'replace', path: '/text', value: value }) - if (!patch.length) { - continue - } + if (!patch.length) continue console.log('Try to edit string: ' + string.identifier) await this.crowdin.sourceStringsApi.editString(PROJECT_ID, string.id, patch) } diff --git a/script/crowdin/common.ts b/script/crowdin/common.ts index a1148c67d..4bf5a7ca8 100644 --- a/script/crowdin/common.ts +++ b/script/crowdin/common.ts @@ -180,6 +180,7 @@ function checkPlaceholder(translated: string, source: string) { function fillItem(fields: string[], index: number, obj: Record<string, any>, text: string) { const field = fields[index] + if (!field) return if (index === fields.length - 1) { obj[field] = text return @@ -217,8 +218,5 @@ export function transMsg(message: any, prefix?: string): ItemSet { export async function checkMainBranch(client: CrowdinClient): Promise<SourceFilesModel.Branch> { const branch = await client.getMainBranch() - if (!branch) { - exitWith("Main branch is null") - } - return branch! + return branch ?? exitWith("Main branch is null") } diff --git a/script/user-chart/add.ts b/script/user-chart/add.ts index 00006a723..1dc3a5d99 100644 --- a/script/user-chart/add.ts +++ b/script/user-chart/add.ts @@ -34,23 +34,16 @@ function parseArgv(): AddArgv { const argv = process.argv.slice(2) const [a0, a1] = argv - if (!a0) { - exitWith("add.ts [c/e/f] [file_name] OR add.ts auto [dir_path]") - } + if (!a0) return exitWith("add.ts [c/e/f] [file_name] OR add.ts auto [dir_path]") if (a0 === 'auto') { - if (!a1) exitWith("add.ts auto [dir_path]") - return { mode: 'auto', dirPath: a1 } + return a1 ? { mode: 'auto', dirPath: a1 } : exitWith("add.ts auto [dir_path]") } - if (!a1) { - exitWith("add.ts [c/e/f] [file_name] OR add.ts auto [dir_path]") - } + if (!a1) return exitWith("add.ts [c/e/f] [file_name] OR add.ts auto [dir_path]") - const browser: Browser = BROWSER_MAP[a0] - if (!browser) { - exitWith("add.ts [c/e/f] [file_name]") - } + const browser = BROWSER_MAP[a0] + if (!browser) return exitWith("add.ts [c/e/f] [file_name]") return { mode: 'manual', browser, fileName: a1 } } @@ -64,7 +57,9 @@ function detectBrowser(fileName: string): Browser | null { function sortDataByKey(data: UserCount): UserCount { const sorted: UserCount = {} - Object.keys(data).sort().forEach(key => sorted[key] = data[key]) + Object.entries(data) + .sort((a, b) => a[0].localeCompare(b[0])) + .forEach(([key, val]) => sorted[key] = val) return sorted } @@ -91,11 +86,16 @@ async function updateGist(token: string, browser: Browser, data: UserCount, gist const filename = filenameOf(browser) // 1. merge const file = gist.files[filename] - const existData = (await getJsonFileContent<UserCount>(file!)) || {} + if (!file) { + exitWith(`Missing file in gist: ${filename}`) + } + const existData = (await getJsonFileContent<UserCount>(file)) || {} Object.entries(data).forEach(([key, val]) => existData[key] = val) // 2. sort by key const sorted: UserCount = {} - Object.keys(existData).sort().forEach(key => sorted[key] = existData[key]) + Object.entries(existData) + .sort(([key1], [key2]) => key1.localeCompare(key2)) + .forEach(([key, value]) => sorted[key] = value) const files: Record<string, FileForm> = {} files[filename] = { filename: filename, content: JSON.stringify(sorted, null, 2) } const gistForm: GistForm = { public: true, description, files } diff --git a/script/user-chart/render.ts b/script/user-chart/render.ts index 85d405836..ded23a9be 100644 --- a/script/user-chart/render.ts +++ b/script/user-chart/render.ts @@ -113,7 +113,10 @@ function zoom<T>(data: T[], reduction: number): T[] { let i = 0 const newData: T[] = [] while (i < data.length) { - newData.push(data[i]) + const item = data[i] + if (item !== undefined) { + newData.push(item) + } i += reduction } return newData diff --git a/script/util/process.ts b/script/util/process.ts index ce80dc4ce..d017db756 100644 --- a/script/util/process.ts +++ b/script/util/process.ts @@ -2,7 +2,7 @@ /** * @throws Will invoke ```process.exit()``` */ -export function exitWith(msg: string) { +export function exitWith(msg: string): never { console.error(msg) process.exit() } \ No newline at end of file diff --git a/src/background/alarm-manager.ts b/src/background/alarm-manager.ts index 7bf8e1668..7dd45cf8c 100644 --- a/src/background/alarm-manager.ts +++ b/src/background/alarm-manager.ts @@ -42,14 +42,11 @@ class AlarmManager { return } const innerName = getOuterName(name) - const config: _AlarmConfig = this.alarms[innerName] - if (!config) { - // Not registered, or removed - return - } + const config = this.alarms[innerName] + if (!config) return // Handle alarm event try { - config.handler?.(alarm) + config.handler(alarm) } catch (e) { console.info("Failed to handle alarm event", e) } finally { diff --git a/src/background/database/cate-database.ts b/src/background/database/cate-database.ts index 4e4f4dac3..2d98ecfbe 100644 --- a/src/background/database/cate-database.ts +++ b/src/background/database/cate-database.ts @@ -53,7 +53,7 @@ class CateDatabase extends BaseDatabase { } const id = (Object.keys(items || {}).map(k => parseInt(k)).sort().reverse()?.[0] ?? 0) + 1 - items[id] = { n: name || items[id]?.n } + items[id] = { n: name ?? items[id]?.n } await this.saveItems(items) return { name, id } diff --git a/src/background/database/common/migratable.ts b/src/background/database/common/migratable.ts index 2f88092bb..1aa6ab5ae 100644 --- a/src/background/database/common/migratable.ts +++ b/src/background/database/common/migratable.ts @@ -14,9 +14,9 @@ export const isLegacyVersion = (data: unknown): data is timer.backup.ExportData const version = data.__meta__.version const match = version.match(/^(\d+)\.(\d+)\.(\d+)/) - if (!match) return true - - const major = parseInt(match[1]) + const majorStr = match?.[1] + if (!majorStr) return true + const major = parseInt(majorStr) return major < 4 } diff --git a/src/background/database/stat-database/classic.ts b/src/background/database/stat-database/classic.ts index 4799563ac..4dc27fe6d 100644 --- a/src/background/database/stat-database/classic.ts +++ b/src/background/database/stat-database/classic.ts @@ -96,7 +96,8 @@ export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { const afterUpdated: Record<string, timer.core.Result> = {} Object.entries(keys).forEach(([host, key]) => { const item = data[host] - const exist: timer.core.Result = increase(item, items[key] as timer.core.Result) + if (!item) return + const exist = increase(item, items[key] as timer.core.Result | undefined) toUpdate[key] = afterUpdated[host] = exist }) await this.storage.set(toUpdate) @@ -162,7 +163,7 @@ export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { const items = await this.storage.get<Record<string, timer.core.Result>>(storageKeys) return keys.map(({ host, date }, i) => { const sk = storageKeys[i] - const exist = items[sk] + const exist = sk ? items[sk] : undefined const result = exist ?? zeroResult() return { host, date, ...result } }) diff --git a/src/background/database/stat-database/idb.ts b/src/background/database/stat-database/idb.ts index f0dca40f0..ebfd09b76 100644 --- a/src/background/database/stat-database/idb.ts +++ b/src/background/database/stat-database/idb.ts @@ -87,18 +87,12 @@ export class IDBStatDatabase extends BaseIDBStorage<StoredRow> implements StatDa } = cond if (expectGroup) { - if (keys?.length === 1) { - const groupId = parseInt(keys[0]) - if (!isNaN(groupId)) { - return { - cursorReq: this.assertIndexCursor(store, 'groupId', IDBKeyRange.only(groupId)), - coverage: { host: true } - } - } - } - - return { - cursorReq: this.assertIndexCursor(store, 'groupId', IDBKeyRange.lowerBound(0)) + const groupId = keys?.length === 1 ? parseInt(keys[0] ?? 'NaN') : NaN + return isNaN(groupId) ? { + cursorReq: this.assertIndexCursor(store, 'groupId', IDBKeyRange.lowerBound(0)), + } : { + cursorReq: this.assertIndexCursor(store, 'groupId', IDBKeyRange.only(groupId)), + coverage: { host: true } } } diff --git a/src/background/limit-processor.ts b/src/background/limit-processor.ts index 157e66d21..bade3edac 100644 --- a/src/background/limit-processor.ts +++ b/src/background/limit-processor.ts @@ -44,9 +44,7 @@ const processAskHitVisit = async (item: timer.limit.Item) => { async function querySummary(): Promise<timer.limit.Summary | undefined> { const tabs = await listTabs({ currentWindow: true, active: true }) - if (tabs.length !== 1) return undefined - const [tab] = tabs - const url = tab.url + const url = tabs[0]?.url if (!url) return undefined const { host } = extractHostname(url) diff --git a/src/background/psl/index.ts b/src/background/psl/index.ts index 4969343eb..e2b941cc7 100644 --- a/src/background/psl/index.ts +++ b/src/background/psl/index.ts @@ -47,6 +47,7 @@ export const getPsl = (origin: string): string | null => { const get0 = (tree: PslTree, parts: string[], index: number, chains: Chain[]) => { const part = parts[index] + if (!part) return let pslNode = tree[part] if (!pslNode && !tree[`!${part}`]) { pslNode = tree['*'] diff --git a/src/background/service/components/host-merge-ruler.ts b/src/background/service/components/host-merge-ruler.ts index ab8528c45..44cde2276 100644 --- a/src/background/service/components/host-merge-ruler.ts +++ b/src/background/service/components/host-merge-ruler.ts @@ -81,12 +81,10 @@ export default class CustomizedHostMergeRuler { * @returns merged host */ private mergeInner(origin: string): string { - let host = origin + let host: string | undefined = origin if (judgeVirtualFast(origin)) { host = origin.split('/')?.[0] - if (!host) { - return origin - } + if (!host) return origin } // First check the static rules let merged = this.noRegMergeRules[host] diff --git a/src/background/service/components/period-calculator.ts b/src/background/service/components/period-calculator.ts index 68467a174..cf0242aa2 100644 --- a/src/background/service/components/period-calculator.ts +++ b/src/background/service/components/period-calculator.ts @@ -38,18 +38,18 @@ export function calculate(timestamp: number, milliseconds: number): timer.period } export function merge(periods: timer.period.Result[], size: number): timer.period.Row[] { - if (!periods?.length) return [] - - const rows: timer.period.Row[] = [] - periods = periods.sort(compare) - let start: timer.period.Key = periods[0] - const end: timer.period.Key = periods[periods.length - 1] + const first = periods[0] + const last = periods[periods.length - 1] + if (!first || !last) return [] const map: Map<number, number> = new Map() periods.forEach(p => map.set(indexOf(p), p.milliseconds)) + let mills: number[] = [] - for (; compare(start, end) <= 0; start = after(start, 1)) { + let start: timer.period.Key = first + const rows: timer.period.Row[] = [] + for (; compare(start, last) <= 0; start = after(start, 1)) { mills.push(map.get(indexOf(start)) ?? 0) const isEndOfWindow = (start.order % size) === size - 1 if (isEndOfWindow) { diff --git a/src/background/service/site-service.ts b/src/background/service/site-service.ts index ef8b08c5e..53598777f 100644 --- a/src/background/service/site-service.ts +++ b/src/background/service/site-service.ts @@ -87,6 +87,12 @@ export async function getSite(siteKey: timer.site.SiteKey): Promise<timer.site.S return info ?? siteKey } +function moveToFront<T>(arr: T[], idx: number): T[] { + const item = arr[idx] + if (item === undefined) return arr + return [item, ...arr.slice(0, idx), ...arr.slice(idx + 1)] +} + export async function searchSites(query: string | undefined): Promise<timer.site.SiteInfo[]> { query = cleanSearchQuery(query) const filter = query ? (host: string) => host.includes(query) : () => true @@ -105,22 +111,18 @@ export async function searchSites(query: string | undefined): Promise<timer.site const ranked = [...rows.filter(r => !r.alias), ...rows.filter(r => r.alias)] const hitIdx = ranked.findIndex(r => r.host === query) - if (hitIdx >= 0) { - const [same] = ranked.splice(hitIdx, 1) - return [same, ...ranked] - } else if (!query) { - return ranked - } else if (judgeVirtualFast(query) && isValidVirtualHost(query)) { + if (hitIdx >= 0) return moveToFront(ranked, hitIdx) + if (!query) return ranked + + if (judgeVirtualFast(query) && isValidVirtualHost(query)) { return [{ host: query, type: 'virtual' }, ...ranked] - } else { - const { host } = extractHostname(query) - const hostIdx = ranked.findIndex(r => r.host === host) - if (hostIdx >= 0) { - const [same] = ranked.splice(hostIdx, 1) - return [same, ...ranked] - } - return [{ host, type: 'normal' }, ...ranked] } + + const { host } = extractHostname(query) + const hostIdx = ranked.findIndex(r => r.host === host) + if (hostIdx >= 0) return moveToFront(ranked, hostIdx) + + return [{ host, type: 'normal' }, ...ranked] } function cleanSearchQuery(query: string | undefined): string | undefined { diff --git a/src/background/service/stat-service/merge/date.ts b/src/background/service/stat-service/merge/date.ts index 2108f4b47..80b8abb2a 100644 --- a/src/background/service/stat-service/merge/date.ts +++ b/src/background/service/stat-service/merge/date.ts @@ -1,29 +1,26 @@ import { identifyTargetKey, isCate, isGroup, isNormalSite, isSite } from "@util/stat" import { mergeResult } from "./common" +type MergeRow = + | MakeRequired<timer.stat.SiteRow | timer.stat.CateRow, 'mergedDates' | 'mergedRows'> + | MakeRequired<timer.stat.GroupRow, 'mergedDates' | 'mergedRows'> + export function mergeDate<T extends timer.stat.Row>(origin: T[]): T[] { - const map: Record< - string, - | MakeRequired<timer.stat.SiteRow | timer.stat.CateRow, 'mergedDates' | 'mergedRows'> - | MakeRequired<timer.stat.GroupRow, 'mergedDates' | 'mergedRows'> - > = {} + const map: Record<string, MergeRow> = {} origin.forEach(ele => { const { date } = ele const key = identifyTargetKey(ele) - let exist = map[key] - if (!exist) { - exist = map[key] = { - ...ele, - focus: 0, - time: 0, - mergedRows: [], - mergedDates: [], - composition: { focus: [], time: [], run: [] }, - } satisfies timer.stat.Row - } + const exist: MergeRow = map[key] ?? (map[key] = { + ...ele, + focus: 0, + time: 0, + mergedRows: [], + mergedDates: [], + composition: { focus: [], time: [], run: [] }, + }) mergeResult(exist, ele) - isSite(ele) && isSite(exist) && exist.mergedRows.push(...ele.mergedRows ?? []) - isCate(ele) && isCate(exist) && exist.mergedRows.push(...ele.mergedRows ?? []) + isSite(ele) && isSite(exist) && exist.mergedRows.push(...(ele.mergedRows ?? [])) + isCate(ele) && isCate(exist) && exist.mergedRows.push(...(ele.mergedRows ?? [])) isGroup(ele) && isGroup(exist) && exist.mergedRows.push(...(ele.mergedRows ?? [])) date && exist.mergedDates.push(date) if (isNormalSite(ele) && !isGroup(exist)) { diff --git a/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx b/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx index 1c0d7eab3..0393addef 100644 --- a/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx +++ b/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx @@ -118,10 +118,8 @@ const TargetSelect = defineComponent(() => { label: t(msg => msg.analysis.target.site), options: siteItems.map(item => ({ value: cvtTarget2Key(item), label: item.label, data: item })), }) - if (res.length === 1) { - // Single content, not use group - res = res[0].options - } + // Single content, not use group + if (res.length === 1) res = res[0]?.options ?? [] return res }) diff --git a/src/pages/app/components/Analysis/components/Summary/Calendar/Wrapper.ts b/src/pages/app/components/Analysis/components/Summary/Calendar/Wrapper.ts index 7e6a5f219..3a69c9b08 100644 --- a/src/pages/app/components/Analysis/components/Summary/Calendar/Wrapper.ts +++ b/src/pages/app/components/Analysis/components/Summary/Calendar/Wrapper.ts @@ -63,17 +63,13 @@ function getXAxisLabelMap(data: _Value[]): { [x: string]: string } { const xAndMonthMap = groupBy(data, e => e[0], grouped => new Set(grouped.map(a => a[3].substring(4, 6)))) let lastMonth: string | undefined = undefined Object.entries(xAndMonthMap).forEach(([x, monthSet]) => { - if (monthSet.size != 1) { - return - } + if (monthSet.size != 1) return const currentMonth = Array.from(monthSet)[0] - if (currentMonth === lastMonth) { - return - } + if (!currentMonth || currentMonth === lastMonth) return lastMonth = currentMonth const monthNum = parseInt(currentMonth) const label = allMonthLabel[monthNum - 1] - result[x] = label + label && (result[x] = label) }) return result } diff --git a/src/pages/app/components/Analysis/components/Trend/Dimension/Wrapper.ts b/src/pages/app/components/Analysis/components/Trend/Dimension/Wrapper.ts index 17a2ee85e..990377ac9 100644 --- a/src/pages/app/components/Analysis/components/Trend/Dimension/Wrapper.ts +++ b/src/pages/app/components/Analysis/components/Trend/Dimension/Wrapper.ts @@ -100,7 +100,7 @@ const generateOption = ({ entries, preEntries, title, valueFormatter }: BizOptio showSymbol: false, smooth: true, lineStyle: { width: prevExistData ? 0 : 1 }, - color: PREV_COLOR.colorStops[0].color, + color: PREV_COLOR.colorStops?.[0]?.color, areaStyle: { opacity: .5, color: PREV_COLOR }, emphasis: { focus: "self" }, }, { @@ -109,7 +109,7 @@ const generateOption = ({ entries, preEntries, title, valueFormatter }: BizOptio showSymbol: false, smooth: true, lineStyle: { width: thisExistData ? 0 : 1 }, - color: THIS_COLOR.colorStops[0].color, + color: THIS_COLOR.colorStops[0]?.color, areaStyle: { color: THIS_COLOR }, emphasis: { focus: "series" }, }] diff --git a/src/pages/app/components/Analysis/components/Trend/context.ts b/src/pages/app/components/Analysis/components/Trend/context.ts index aac7972da..4c9963fab 100644 --- a/src/pages/app/components/Analysis/components/Trend/context.ts +++ b/src/pages/app/components/Analysis/components/Trend/context.ts @@ -7,8 +7,8 @@ import { useAnalysisRows } from '@app/components/Analysis/context' import type { DimensionData, DimensionEntry } from '@app/components/Analysis/types' -import { useProvide, useProvider } from '@hooks' import { cvt2LocaleTime } from '@app/util/time' +import { useProvide, useProvider } from '@hooks' import { toMap } from "@util/array" import { daysAgo, getAllDatesBetween, getDayLength, MILL_PER_DAY } from "@util/time" import { computed, onMounted, ref, watch, type Ref } from "vue" @@ -70,7 +70,8 @@ function computeIndicatorSet( const fullPeriodRow: Record<string, timer.stat.Row> = {} allDates.forEach(date => { const row = periodRowMap[date] - if (!(fullPeriodRow[date] = row)) return + if (!row) return + fullPeriodRow[date] = row const { focus, time: visit } = row focus > (focusMax.value ?? Number.MIN_SAFE_INTEGER) && (focusMax = { value: focus, date }) visit > (visitMax.value ?? Number.MIN_SAFE_INTEGER) && (visitMax = { value: visit, date }) diff --git a/src/pages/app/components/Dashboard/components/Calendar/Wrapper.ts b/src/pages/app/components/Dashboard/components/Calendar/Wrapper.ts index 5f24bdc34..46462517f 100644 --- a/src/pages/app/components/Dashboard/components/Calendar/Wrapper.ts +++ b/src/pages/app/components/Dashboard/components/Calendar/Wrapper.ts @@ -49,19 +49,15 @@ function getXAxisLabelMap(data: ChartValue[]): { [x: string]: string } { const result: Record<string, string> = {} // {[ x:string ]: Set<string> } const xAndMonthMap = groupBy(data, e => e[0], grouped => new Set(grouped.map(a => a[3].substring(4, 6)))) - let lastMonth: string + let lastMonth: string | undefined Object.entries(xAndMonthMap).forEach(([x, monthSet]) => { - if (monthSet.size != 1) { - return - } + if (monthSet.size != 1) return const currentMonth = Array.from(monthSet)[0] - if (currentMonth === lastMonth) { - return - } + if (!currentMonth || currentMonth === lastMonth) return lastMonth = currentMonth const monthNum = parseInt(currentMonth) const label = allMonthLabel[monthNum - 1] - result[x] = label + label && (result[x] = label) }) return result } @@ -123,8 +119,8 @@ function optionOf(data: ChartValue[], weekDays: string[], dom: HTMLElement): EcO borderWidth: 0, formatter: (params: TopLevelFormatterParams) => { const param = Array.isArray(params) ? params[0] : params - const { data } = param - const { value } = data as any + const { data } = param ?? {} + const { value } = data as any ?? {} const [_1, _2, mills, date] = value return mills ? formatTooltip(mills as number, date) : '' }, diff --git a/src/pages/app/components/Dashboard/components/MonthOnMonth/Wrapper.ts b/src/pages/app/components/Dashboard/components/MonthOnMonth/Wrapper.ts index 6fcba4ad8..1bb685ffb 100644 --- a/src/pages/app/components/Dashboard/components/MonthOnMonth/Wrapper.ts +++ b/src/pages/app/components/Dashboard/components/MonthOnMonth/Wrapper.ts @@ -34,9 +34,9 @@ function optionOf(lastPeriodItems: Row[], thisPeriodItems: Row[], domWidth: numb const [thisColor, lastColor] = params.map(v => v.color) const { date: thisDate, total: thisVal } = thisItem || {} const { date: lastDate, total: lastVal } = lastItem || {} - const lastStr = `${tooltipDot(lastColor as string)} ${cvt2LocaleTime(lastDate)} <b>${formatPeriodCommon(lastVal)}</b>` - let thisStr = `${tooltipDot(thisColor as string)} ${cvt2LocaleTime(thisDate)} <b>${formatPeriodCommon(thisVal)}</b>` - if (lastVal) { + const lastStr = `${tooltipDot(lastColor as string)} ${cvt2LocaleTime(lastDate)} <b>${formatPeriodCommon(lastVal ?? 0)}</b>` + let thisStr = `${tooltipDot(thisColor as string)} ${cvt2LocaleTime(thisDate)} <b>${formatPeriodCommon(thisVal ?? 0)}</b>` + if (lastVal && thisVal) { const delta = (thisVal - lastVal) / lastVal * 100 let deltaStr = delta.toFixed(1) + '%' if (delta >= 0) deltaStr = '+' + deltaStr @@ -74,7 +74,7 @@ function optionOf(lastPeriodItems: Row[], thisPeriodItems: Row[], domWidth: numb barCategoryGap: `${domWidth < 500 ? 30 : 55}%`, itemStyle: { color: color1 }, data: thisPeriodItems.map((row, idx) => { - const otherIsEmpty = lastPeriodItems[idx].total === 0 + const otherIsEmpty = !lastPeriodItems[idx]?.total return { value: row.total, row, itemStyle: { borderRadius: otherIsEmpty ? 10 : [10, 10, 0, 0] }, @@ -86,7 +86,7 @@ function optionOf(lastPeriodItems: Row[], thisPeriodItems: Row[], domWidth: numb type: 'bar', itemStyle: { color: color2 }, data: lastPeriodItems.map((row, idx) => { - const otherIsEmpty = thisPeriodItems[idx].total === 0 + const otherIsEmpty = !thisPeriodItems[idx]?.total return { value: -row.total, row, itemStyle: { borderRadius: otherIsEmpty ? 10 : [0, 0, 10, 10] }, diff --git a/src/pages/app/components/Dashboard/components/Timeline/Chart/Wrapper.ts b/src/pages/app/components/Dashboard/components/Timeline/Chart/Wrapper.ts index eb20b1f54..99e24ec63 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/Chart/Wrapper.ts +++ b/src/pages/app/components/Dashboard/components/Timeline/Chart/Wrapper.ts @@ -83,14 +83,14 @@ const collectLegends = (activities: timer.timeline.Activity[], merge: timer.time displayName: merge === 'cate' && key === String(CATE_NOT_SET_ID) ? t(msg => msg.shared.cate.notSet) : keyNameMap[key], - color: colors[idx % colorLen], - })) + color: colors[idx % colorLen] ?? '#000', + } satisfies LegendInfo)) } const renderItem: CustomSeriesRenderItem = (params, api) => { const categoryIndex = api.value(0) - const start = api.coord([api.value(1), categoryIndex]) - const end = api.coord([api.value(2), categoryIndex]) + const [sX = 0, sY = 0] = api.coord([api.value(1), categoryIndex]) + const [eX = 0] = api.coord([api.value(2), categoryIndex]) const size = api.size?.([0, 1]) const height = ((Array.isArray(size) ? size[1] : size) ?? 0) * 0.6 @@ -98,10 +98,10 @@ const renderItem: CustomSeriesRenderItem = (params, api) => { const coordSys = params.coordSys as unknown as Cartesian2DCoordSys var rectShape = graphic.clipRectByRect({ - x: start[0], - y: start[1] - height / 2, - width: end[0] - start[0], - height: height + x: sX, + y: sY - height / 2, + width: eX - sX, + height }, { x: coordSys.x, y: coordSys.y, @@ -226,7 +226,7 @@ class Wrapper extends EchartsWrapper<BizData, EcOption> { formatter: params => { const color = (params as any)?.color ?? "#000" const param = Array.isArray(params) ? params[0] : params - const { value, seriesName } = param + const { value, seriesName } = param ?? {} const [_1, start, _2, duration] = value as MyValue const startStr = formatStart(start) const durStr = formatDuration(duration) diff --git a/src/pages/app/components/Dashboard/components/Timeline/Summary.tsx b/src/pages/app/components/Dashboard/components/Timeline/Summary.tsx index fd7236655..366a21562 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/Summary.tsx +++ b/src/pages/app/components/Dashboard/components/Timeline/Summary.tsx @@ -20,12 +20,12 @@ const computeSessionScore = (activities: timer.timeline.Activity[], hourCount: n let currentSession: timer.timeline.Activity[] = [] activities.sort((a, b) => a.start - b.start).forEach(current => { - if (!currentSession.length) { + const prev = currentSession[currentSession.length - 1] + if (!prev) { currentSession.push(current) return } - const prev = currentSession[currentSession.length - 1] const gap = current.start - (prev.start + prev.duration) if (gap <= MILL_PER_MINUTE * 3) { diff --git a/src/pages/app/components/DataManage/Migration/ImportButton.tsx b/src/pages/app/components/DataManage/Migration/ImportButton.tsx index f4885c7b2..40baf693d 100644 --- a/src/pages/app/components/DataManage/Migration/ImportButton.tsx +++ b/src/pages/app/components/DataManage/Migration/ImportButton.tsx @@ -14,12 +14,10 @@ import { defineComponent, ref } from "vue" import { useDataMemory } from "../context" async function handleFileSelected(fileInput: HTMLInputElement | undefined, callback: () => void) { - const files = fileInput?.files - if (!files?.length) { - return - } + const file = fileInput?.files?.[0] + if (!file) return + const loading = ElLoading.service({ fullscreen: true }) - const file: File = files[0] const fileText = await file.text() const data = deserialize(fileText) if (!data) return ElMessage.error(t(msg => msg.dataManage.importError)) diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts b/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts index 001824e8d..08a13e296 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts @@ -13,7 +13,9 @@ import { mergeWith } from '@util/stat' import { formatTimeYMD, MILL_PER_SECOND } from "@util/time" import type { OtherExtension } from './types' -const throwError = () => { throw new Error("Failed to parse, please check your file or contact the author via " + AUTHOR_EMAIL) } +function throwError<T>(): T { + throw new Error("Failed to parse, please check your file or contact the author via " + AUTHOR_EMAIL) +} export async function parseFile(ext: OtherExtension, file: File): Promise<timer.imported.Data> { let rows: timer.imported.Row[] = [] @@ -38,15 +40,13 @@ async function parseWebActivityTimeTracker(file: File): Promise<[timer.imported. const text = await file.text() if (isCsvFile(file)) { const lines = text.split('\n').map(line => line.trim()).filter(line => !!line).splice(1) - const rows = lines.map(line => { + const rows: timer.imported.Row[] = lines.map(line => { const [host, date, seconds] = line.split(',').map(cell => cell.trim()) - if (!host || !date || (!seconds && seconds !== '0')) { - throwError() - } - const [year, month, day] = date.split('/') - !year || !month || !day && throwError() + if (!host || !date || (!seconds && seconds !== '0')) return throwError() + const [year, month, day] = date?.split('/') ?? [] + if (!year || !month || !day) return throwError() const realDate = `${year}${month.length == 2 ? month : '0' + month}${day.length == 2 ? day : '0' + day}` - return { host, date: realDate, focus: parseInt(seconds) * MILL_PER_SECOND, time: 0 } satisfies timer.imported.Row + return { host, date: realDate, focus: parseInt(seconds) * MILL_PER_SECOND, time: 0 } }) return [rows, false] } else if (isJsonFile(file)) { @@ -133,15 +133,15 @@ async function parseWebtimeTracker(file: File): Promise<timer.imported.Row[]> { return rows } else if (isCsvFile(file)) { const lines = text.split('\n').map(line => line.trim()).filter(line => !!line) - const colHeaders = lines[0].split(',') + const colHeaders = lines[0]?.split(',') ?? [] const rows: timer.imported.Row[] = [] lines.slice(1).forEach(line => { const cells = line.split(',') const host = cells[0] if (!host) return for (let i = 1; i < colHeaders?.length; i++) { - const seconds = Number.parseInt(cells[i]) - const date = cvtWebtimeTrackerDate(colHeaders[i]) + const seconds = Number.parseInt(cells[i] ?? '') + const date = cvtWebtimeTrackerDate(colHeaders[i] ?? '') seconds && date && rows.push({ host, date, focus: seconds * MILL_PER_SECOND, time: 0 }) } }) @@ -153,9 +153,9 @@ async function parseWebtimeTracker(file: File): Promise<timer.imported.Row[]> { function parseHistoryTrendsUnlimitedLine(line: string, data: { [dateAndHost: string]: number }) { const cells = line.split('\t') const url = cells[0] - if (isBrowserUrl(url)) return + if (!url || isBrowserUrl(url)) return const tsMaybe = cells?.[1]?.trim?.() - if (/^U\d{13,}(\.\d*)?$/.test(tsMaybe)) { + if (tsMaybe && /^U\d{13,}(\.\d*)?$/.test(tsMaybe)) { // Backup data let date: string try { @@ -170,9 +170,9 @@ function parseHistoryTrendsUnlimitedLine(line: string, data: { [dateAndHost: str data[key] = (data[key] ?? 0) + 1 } else { // Analyze data - const host = cells[1] - const dateStr = cells[4] - const date = cvtWebtimeTrackerDate(dateStr?.substring(0, 10)) + const host = cells[1] ?? '' + const dateStr = cells[4] ?? '' + const date = cvtWebtimeTrackerDate(dateStr.substring(0, 10)) if (!host || !date) return const key = date + host data[key] = (data[key] ?? 0) + 1 diff --git a/src/pages/app/components/Habit/Period/Average/Wrapper.tsx b/src/pages/app/components/Habit/Period/Average/Wrapper.tsx index e271030e5..10ee2650a 100644 --- a/src/pages/app/components/Habit/Period/Average/Wrapper.tsx +++ b/src/pages/app/components/Habit/Period/Average/Wrapper.tsx @@ -69,9 +69,9 @@ const formatValueLine = (mill: number, range: timer.period.KeyRange, color: stri const formatTooltip = (params: TopLevelFormatterParams, biz: BizOption): string => { const { periodSize, prevRange, currRange } = biz if (!Array.isArray(params)) return '' - const [curr, prev] = params || [] + const [curr, prev] = params ?? [] - const idx = curr.dataIndex + const idx = curr?.dataIndex ?? 0 const start = formatXAxis(idx, periodSize) const end = formatXAxis(idx + 1, periodSize) @@ -83,7 +83,7 @@ const formatTooltip = (params: TopLevelFormatterParams, biz: BizOption): string periodStr, ) - const currLine = formatValueLine(curr.value as number, currRange, CURR_COLOR) + const currLine = formatValueLine(curr?.value as number ?? 0, currRange, CURR_COLOR) const prevLine = formatValueLine(-(prev?.value ?? 0 as number), prevRange, PREV_COLOR) return `${timeLine}${tooltipSpaceLine()}${currLine}${prevLine}` diff --git a/src/pages/app/components/Habit/Period/Stack/Wrapper.ts b/src/pages/app/components/Habit/Period/Stack/Wrapper.ts index 395ffec4c..f3ece34b9 100644 --- a/src/pages/app/components/Habit/Period/Stack/Wrapper.ts +++ b/src/pages/app/components/Habit/Period/Stack/Wrapper.ts @@ -22,9 +22,9 @@ const [COLOR] = getLineSeriesPalette() const formatTooltip = (params: TopLevelFormatterParams, timeFormat: timer.app.TimeFormat) => { const param = Array.isArray(params) ? params[0] : params - const [, total, , end] = param.data as number[] + const [, total, , end] = param?.data as number[] return ` - <div>${formatTime(end, t(msg => msg.calendar.timeFormat))}</div> + <div>${end ? formatTime(end, t(msg => msg.calendar.timeFormat)) : ''}</div> <div><b>${periodFormatter(total, { format: timeFormat })}</b></div> ` } diff --git a/src/pages/app/components/Habit/Period/Summary.tsx b/src/pages/app/components/Habit/Period/Summary.tsx index 6d4ec66e1..69a03ffa9 100644 --- a/src/pages/app/components/Habit/Period/Summary.tsx +++ b/src/pages/app/components/Habit/Period/Summary.tsx @@ -64,7 +64,7 @@ const computeSummary = (rows: timer.period.Row[], periodSize: number): Result => return { favorite: { period: favoritePeriod, - average: favoriteRow?.milliseconds, + average: favoriteRow?.milliseconds ?? 0, }, longestIdle: { length: idleLength, diff --git a/src/pages/app/components/Habit/Period/Trend/Wrapper.ts b/src/pages/app/components/Habit/Period/Trend/Wrapper.ts index ef1c1f21c..59006c26e 100644 --- a/src/pages/app/components/Habit/Period/Trend/Wrapper.ts +++ b/src/pages/app/components/Habit/Period/Trend/Wrapper.ts @@ -27,10 +27,17 @@ export type BizOption = { function formatTimeOfEcharts(params: TopLevelFormatterParams, timeFormat: timer.app.TimeFormat): string { const format = Array.isArray(params) ? params[0] : params - const value = format.value as number[] - const milliseconds = value?.[1] ?? 0 - const start = formatTime(value?.[2], '{m}-{d} {h}:{i}') - const end = formatTime(value?.[3], '{h}:{i}') + if (!format) return 'NaN' + const { value } = format + if (!Array.isArray(value)) return 'NaN' + const time = value?.[1] ?? 0 + const startTs = value?.[2] + const endTs = value?.[3] + const start = typeof startTs === 'number' ? formatTime(startTs, '{m}-{d} {h}:{i}') : 'NaN' + const end = typeof endTs === 'number' ? formatTime(endTs, '{h}:{i}') : 'NaN' + const milliseconds = time instanceof Date + ? time.getTime() + : (typeof time === 'number' ? time : Number.parseInt(time)) return ` <div>${start}-${end}</div> <div> diff --git a/src/pages/app/components/Habit/Period/common.ts b/src/pages/app/components/Habit/Period/common.ts index fc307fea7..3dd58cf2e 100644 --- a/src/pages/app/components/Habit/Period/common.ts +++ b/src/pages/app/components/Habit/Period/common.ts @@ -9,14 +9,12 @@ import { t } from '@app/locale' import { formatTime } from "@util/time" import { type GridComponentOption } from "echarts/components" -export const generateGridOption = (): GridComponentOption => { - return { - top: 30, - bottom: 40, - left: 40, - right: 20, - } -} +export const generateGridOption = () => ({ + top: 30, + bottom: 40, + left: 40, + right: 20, +} satisfies GridComponentOption) const MONTHS = t(msg => msg.calendar.months).split('|') @@ -26,9 +24,10 @@ export const formatXAxisTime = (time: number, idx: number): string => { const isStartOfMonth = dateStr === '01000000' const isStartOfDate = dateStr.endsWith('000000') if (idx === 0 || isStartOfMonth) { - return MONTHS[date.getMonth()] + const monthIdx = date.getMonth() + return MONTHS[monthIdx] ?? `${monthIdx + 1}` } else if (isStartOfDate) { - return date.getDate()?.toString?.()?.padStart(2, '0') + return date.getDate().toString().padStart(2, '0') } else { return formatTime(date, "{h}:{i}") } diff --git a/src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts b/src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts index c1009c008..6a9414921 100644 --- a/src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts +++ b/src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts @@ -63,7 +63,7 @@ const formatTimeTooltip = (params: TopLevelFormatterParams, format: timer.app.Ti ? periodFormatter(value as number, { format }) : (value as number) return tooltipFlexLine( - `<b>${tooltipDot(color?.colorStops?.[0]?.color)} ${valueStr}</b>`, + `<b>${tooltipDot(color?.colorStops?.[0]?.color ?? '#000')} ${valueStr}</b>`, seriesName, ) }).join('') diff --git a/src/pages/app/components/Habit/Site/TopK/Wrapper.ts b/src/pages/app/components/Habit/Site/TopK/Wrapper.ts index d889fe032..ef6b48a24 100644 --- a/src/pages/app/components/Habit/Site/TopK/Wrapper.ts +++ b/src/pages/app/components/Habit/Site/TopK/Wrapper.ts @@ -50,27 +50,23 @@ const formatFocusTooltip = (params: TopLevelFormatterParams, format: timer.app.T </div> ` } +type MergeRow = + | MakeRequired<timer.stat.SiteRow | timer.stat.CateRow, 'mergedDates' | 'mergedRows'> + | MakeRequired<timer.stat.GroupRow, 'mergedDates' | 'mergedRows'> function mergeDate(origin: timer.stat.Row[]): timer.stat.Row[] { - const map: Record< - string, - | MakeRequired<timer.stat.SiteRow | timer.stat.CateRow, 'mergedDates' | 'mergedRows'> - | MakeRequired<timer.stat.GroupRow, 'mergedDates' | 'mergedRows'> - > = {} + const map: Record<string, MergeRow> = {} origin.forEach(ele => { - const { date = '', focus, time } = ele + const { date, focus, time } = ele const key = identifyTargetKey(ele) - let exist = map[key] - if (!exist) { - exist = map[key] = { - ...ele, - focus: 0, - time: 0, - mergedRows: [], - mergedDates: [], - composition: { focus: [], time: [], run: [] }, - } - } + const exist: MergeRow = map[key] ?? (map[key] = { + ...ele, + focus: 0, + time: 0, + mergedRows: [], + mergedDates: [], + composition: { focus: [], time: [], run: [] }, + }) exist.focus += focus ?? 0 exist.time += time ?? 0 exist.mergedDates.push(date) @@ -150,7 +146,7 @@ async function generateOption(rows: timer.stat.Row[] = [], timeFormat: timer.app padding: [0, 4, 0, 0], formatter: (params: TopLevelFormatterParams) => { const param = Array.isArray(params) ? params[0] : params - const { row } = (param.data || {}) as SeriesDataItem + const { row } = (param?.data ?? {}) as SeriesDataItem if (!isSite(row)) return '' const { siteKey, alias } = row return alias ?? siteKey.host diff --git a/src/pages/app/components/Limit/components/List/Card.tsx b/src/pages/app/components/Limit/components/List/Card.tsx index 55864924a..129e69e92 100644 --- a/src/pages/app/components/Limit/components/List/Card.tsx +++ b/src/pages/app/components/Limit/components/List/Card.tsx @@ -39,17 +39,17 @@ const Divider: FunctionalComponent<{}> = () => { } const EffectiveDays: FunctionalComponent<{ weekdays?: number[] }> = ({ weekdays = [] }) => { - const weekdayNum = weekdays?.length + const weekdayNum = weekdays.length + const first = weekdays[0] let text: string = '' let type: TagProps['type'] | undefined = undefined if (!weekdayNum || weekdayNum === 7) { text = t(msg => msg.calendar.range.everyday) type = 'success' - } else if (weekdayNum === 1) { - text = ALL_WEEKDAYS[weekdays[0]] - } else { - const firstDay = ALL_WEEKDAYS[weekdays[0]] - text = `${firstDay}...${weekdayNum}` + } else if (first !== undefined && weekdayNum === 1) { + text = ALL_WEEKDAYS[first] ?? '' + } else if (first !== undefined) { + text = `${ALL_WEEKDAYS[first] ?? ''}...${weekdayNum}` } return <ElTag size="small" type={type}>{text}</ElTag> diff --git a/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx b/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx index 2d585afac..133e0d9dd 100644 --- a/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx @@ -37,16 +37,17 @@ const insertPeriods = (periods: timer.limit.Period[], toInsert: timer.limit.Peri } for (let i = 0; i < len; i++) { const pre = periods[i] + if (!pre) continue const next = periods[i + 1] if (checkImpact(pre, toInsert)) { mergePeriod(pre, toInsert) - if (checkImpact(pre, next)) { + if (next && checkImpact(pre, next)) { mergePeriod(pre, next) periods.splice(i + 1, 1) } return } - if (checkImpact(toInsert, next)) { + if (next && checkImpact(toInsert, next)) { mergePeriod(next, toInsert) return } @@ -111,15 +112,15 @@ const PeriodInput = defineComponent<ModelValue<timer.limit.Period[]>>(props => { const handleSave = () => { const val = range2Period(editingRange.value) - const oldPeriods = props.modelValue?.map(p => ([p?.[0], p?.[1]] satisfies Vector<number>)) || [] + const oldPeriods = props.modelValue.map(p => [p[0], p[1]] satisfies timer.limit.Period) insertPeriods(oldPeriods, val) props.onChange?.(oldPeriods) closeEditing() } const handleDelete = (idx: number) => { - const newPeriods = props.modelValue?.filter((_, i) => i !== idx) - ?.map(p => ([p?.[0], p?.[1]] satisfies Vector<number>)) || [] + const newPeriods = props.modelValue.filter((_, i) => i !== idx) + .map(p => [p[0], p[1]] satisfies timer.limit.Period) props.onChange?.(newPeriods) } diff --git a/src/pages/app/components/Limit/components/Modify/index.tsx b/src/pages/app/components/Limit/components/Modify/index.tsx index 70b0c01c7..72781710f 100644 --- a/src/pages/app/components/Limit/components/Modify/index.tsx +++ b/src/pages/app/components/Limit/components/Modify/index.tsx @@ -82,14 +82,14 @@ const _default = defineComponent((_, ctx) => { ...modifyingItem, cond, enabled, name, time, weekly, visitTime, weekdays, count, weeklyCount, // Object to array - periods: periods?.map(i => ([i?.[0], i?.[1]] satisfies Vector<number>)), + periods: periods?.map(i => [i[0], i[1]] satisfies timer.limit.Period), } satisfies timer.limit.Rule await updateLimits([saved]) } else { const toCreate = { cond, enabled, name, time, weekly, visitTime, weekdays, count, weeklyCount, // Object to array - periods: periods?.map(i => ([i?.[0], i?.[1]] satisfies Vector<number>)), + periods: periods?.map(i => [i[0], i[1]] satisfies timer.limit.Period), allowDelay: false, locked: false, } satisfies MakeOptional<timer.limit.Rule, 'id'> const id = await addLimit(toCreate) diff --git a/src/pages/app/components/Option/categories/Appearance/index.tsx b/src/pages/app/components/Option/categories/Appearance/index.tsx index fd3ec7342..0bdf9832f 100644 --- a/src/pages/app/components/Option/categories/Appearance/index.tsx +++ b/src/pages/app/components/Option/categories/Appearance/index.tsx @@ -186,7 +186,7 @@ const _default = defineComponent((_props, ctx) => { persistent={false} style={{ width: '250px', display: 'inline-flex', marginInlineStart: '10px' } satisfies StyleValue} formatTooltip={val => `${val}ms`} - onUpdate:modelValue={val => option.chartAnimationDuration = Array.isArray(val) ? val[0] : val} + onUpdate:modelValue={val => option.chartAnimationDuration = Array.isArray(val) ? (val[0] ?? 0) : val} /> <ElTag size="small" diff --git a/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx b/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx index 1fd8a3b1b..ae4451850 100644 --- a/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx +++ b/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx @@ -24,18 +24,16 @@ async function remoteSearch(query: string): Promise<SearchItem[]> { let exclude = false if (query?.startsWith(EXCLUDING_PREFIX)) { exclude = true - query = query.slice(1) + query = query.slice(EXCLUDING_PREFIX.length) } if (!query) return [] const sites: SearchItem[] = await listSites({ fuzzyQuery: query }) const idx = sites.findIndex(s => s.host === query) - const target: SearchItem = idx >= 0 - // Move found item to the front - ? sites.splice(idx, 1)[0] - // Or create a new one if not found - : { host: query, type: judgeVirtualFast(query) ? 'virtual' : 'normal' } + const target: SearchItem = (idx >= 0 ? sites.splice(idx, 1)[0] : null) + ?? { host: query, type: judgeVirtualFast(query) ? 'virtual' : 'normal' } + const result = [target, ...sites] result.forEach(s => s.exclude = exclude) diff --git a/src/pages/app/components/Whitelist/WhitePanel/index.tsx b/src/pages/app/components/Whitelist/WhitePanel/index.tsx index 85888004b..5ab5099f1 100644 --- a/src/pages/app/components/Whitelist/WhitePanel/index.tsx +++ b/src/pages/app/components/Whitelist/WhitePanel/index.tsx @@ -23,7 +23,8 @@ const _default = defineComponent(() => { // Reopen return false } - await deleteWhitelist(whitelist[index]) + const exists = whitelist[index] + if (exists) await deleteWhitelist(exists) await addWhitelist(val) whitelist[index] = val ElMessage.success(t(msg => msg.operation.successMsg)) diff --git a/src/pages/app/util/echarts.ts b/src/pages/app/util/echarts.ts index 2136103d6..d1b08dddf 100644 --- a/src/pages/app/util/echarts.ts +++ b/src/pages/app/util/echarts.ts @@ -4,32 +4,32 @@ import { addVector, multiTuple, subVector } from "@util/tuple" import { type LinearGradientObject } from "echarts" import type { TopLevelFormatterParams } from "echarts/types/dist/shared" -const splitVectors = (vectorRange: Tuple<Vector<number>, 2>, count: number, gradientFactor?: number): Vector<number>[] => { - gradientFactor = gradientFactor ?? 1.3 - const segmentCount = count - 1 +const splitColorVectors = (vectorRange: Tuple<Vector<3>, 2>, count: number, gradientFactor?: number): Vector<3>[] => { + gradientFactor ??= 1.3 const [v1, v2] = vectorRange + if (count === 1) return [v1] + const delta = subVector(v2, v1) - const allVectors = range(segmentCount).map(idx => { - const growth = Math.pow(idx / count, gradientFactor) - return addVector(v1, multiTuple(delta, growth)) + const last = count - 1 + + return range(count).map(i => { + const t = Math.pow(i / last, gradientFactor) + return addVector(v1, multiTuple(delta, t)) }) - allVectors.push(v2) - return allVectors } export const getStepColors = (count: number, gradientFactor?: number): string[] => { const p1 = getCssVariable('--echarts-step-color-1') ?? '' const p2 = getCssVariable('--echarts-step-color-2') ?? '' - if (!p1 || !p2) return [p1, p2].filter(s => !!s) + if (!p1 || !p2) return [p1, p2].filter(Boolean) if (count <= 0) return [] - if (count === 1) return [p1] - if (count === 2) return [p1, p2] const c1 = cvtColor2Vector(p1) const c2 = cvtColor2Vector(p2) - const allVectors = splitVectors([c1, c2], count, gradientFactor) - return allVectors.map(([r, g, b]) => `rgb(${r.toFixed(1)}, ${g.toFixed(1)}, ${b.toFixed(1)})`) + + return splitColorVectors([c1, c2], count, gradientFactor) + .map(v => `rgb(${v[0].toFixed(1)}, ${v[1].toFixed(1)}, ${v[2].toFixed(1)})`) } /** @@ -108,10 +108,10 @@ export const getPieBorderColor = (): string | undefined => { export function parseValueOfFormatter(params: TopLevelFormatterParams) { const param = Array.isArray(params) ? params[0] : params + if (!param) return undefined const { data } = param - if (typeof data === 'object' && data !== null) { - return 'value' in data ? data.value : undefined + if (typeof data === 'object' && data !== null && 'value' in data) { + return data.value } - return undefined } \ No newline at end of file diff --git a/src/pages/app/util/limit/generator/uncommon-chinese.ts b/src/pages/app/util/limit/generator/uncommon-chinese.ts index bad152cbc..066353ee7 100644 --- a/src/pages/app/util/limit/generator/uncommon-chinese.ts +++ b/src/pages/app/util/limit/generator/uncommon-chinese.ts @@ -21,7 +21,7 @@ class UncommonChinese implements VerificationGenerator { while (answer.length < 3) { const idx = randomIntBetween(0, LENGTH) const ch = UNCOMMON_WORDS[idx] - if (!answer.includes(ch)) { + if (ch && !answer.includes(ch)) { answer += ch } } diff --git a/src/pages/app/util/limit/processor.ts b/src/pages/app/util/limit/processor.ts index 2546b110c..d33d409c5 100644 --- a/src/pages/app/util/limit/processor.ts +++ b/src/pages/app/util/limit/processor.ts @@ -5,6 +5,7 @@ * https://opensource.org/licenses/MIT */ +import { randomIntBetween } from "@util/number" import { ALL_GENERATORS } from "./generator" import type { VerificationContext, VerificationGenerator, VerificationPair } from "./types" @@ -15,19 +16,14 @@ class VerificationProcessor { this.generators = ALL_GENERATORS } - generate(difficulty: timer.limit.VerificationDifficulty, locale: timer.Locale): VerificationPair | null { + generate(difficulty: timer.limit.VerificationDifficulty, locale: timer.Locale): VerificationPair | undefined { const context: VerificationContext = { difficulty, locale } const supported = this.generators.filter(g => g.supports(context)) - const len = supported?.length - if (!len) { - return null - } - let generator = supported[0] - if (len > 1) { - const idx = Math.floor(Math.random() * supported.length) - generator = supported[idx] - } - return generator.generate(context) + + if (!supported.length) return undefined + + const generator = supported[randomIntBetween(0, supported.length)] + return generator?.generate(context) } } diff --git a/src/pages/popup/components/Limit/Content/Wrapper.ts b/src/pages/popup/components/Limit/Content/Wrapper.ts index 7d1ea6a49..f6aae2135 100644 --- a/src/pages/popup/components/Limit/Content/Wrapper.ts +++ b/src/pages/popup/components/Limit/Content/Wrapper.ts @@ -210,14 +210,16 @@ class Wrapper extends EchartsWrapper<timer.limit.Item, EcOption> { // Transparent pie as tooltip event layer (invisible, same ring area) type PieItem = { value: number; name: string; itemStyle: { color: string } } - const firstStart = blocked[0].start - const lastEnd = blocked[blocked.length - 1].end + const firstStart = blocked[0]?.start ?? 0 + const lastEnd = blocked[blocked.length - 1]?.end ?? 0 const startAngle = 90 - (lastEnd / MINUTES_PER_DAY) * 360 const pieData: PieItem[] = [ { value: MINUTES_PER_DAY - lastEnd + firstStart, name: '', itemStyle: { color: 'rgba(0,0,0,0)' } }, ] for (let i = 0; i < blocked.length; i++) { - const { start, end } = blocked[i] + const item = blocked[i] + if (!item) continue + const { start, end } = item pieData.push({ value: end - start, name: `${fmt(start)} - ${fmt(end)}`, itemStyle: { color: 'rgba(0,0,0,0)' } }) const nextStart = blocked[i + 1]?.start if (nextStart !== undefined && nextStart > end) { diff --git a/src/pages/popup/components/Percentage/Site/Wrapper.ts b/src/pages/popup/components/Percentage/Site/Wrapper.ts index 3d993076e..fa4c49506 100644 --- a/src/pages/popup/components/Percentage/Site/Wrapper.ts +++ b/src/pages/popup/components/Percentage/Site/Wrapper.ts @@ -23,7 +23,7 @@ type EcOption = ComposeOption< const maxWidth = 750 function calcPositionOfTooltip(container: HTMLElement, point: (number | string)[]) { - let p: number | string = point[0] + const p = point[0] ?? 0 const pN: number = typeof p === 'number' ? p : Number.parseFloat(p) const tooltip = container.children.item(1) as HTMLDivElement let tooltipWidth = 0 diff --git a/src/pages/popup/components/Percentage/chart.ts b/src/pages/popup/components/Percentage/chart.ts index 6adefaeaa..9b27af1ea 100644 --- a/src/pages/popup/components/Percentage/chart.ts +++ b/src/pages/popup/components/Percentage/chart.ts @@ -143,11 +143,10 @@ type ChartRow = timer.stat.Row | OtherRow export const isOther = (row: ChartRow): row is OtherRow => 'other' in row function cvt2ChartRows(rows: timer.stat.Row[], dimension: Exclude<timer.core.Dimension, 'run'>, itemCount: number): ChartRow[] { - rows = rows.filter(item => !!item[dimension]).sort((a, b) => (b[dimension] ?? 0) - (a[dimension] ?? 0)) + const sorted = rows.filter(item => !!item[dimension]).sort((a, b) => (b[dimension] ?? 0) - (a[dimension] ?? 0)) const popupRows: ChartRow[] = [] const other: OtherRow = { focus: 0, time: 0, count: 0, other: true } - for (let i = 0; i < rows.length; i++) { - const row = rows[i] + sorted.forEach((row, i) => { if (i < itemCount) { popupRows.push(row) } else { @@ -155,8 +154,8 @@ function cvt2ChartRows(rows: timer.stat.Row[], dimension: Exclude<timer.core.Dim other.time += row.time other.count++ } - } - other.count && popupRows.push(other) + }) + if (other.count) popupRows.push(other) return popupRows } diff --git a/src/pages/util/dark-mode.ts b/src/pages/util/dark-mode.ts index 6d6661052..ef777dfce 100644 --- a/src/pages/util/dark-mode.ts +++ b/src/pages/util/dark-mode.ts @@ -13,8 +13,8 @@ const STORAGE_KEY = "isDark" const STORAGE_FLAG = "1" function toggle0(isDarkMode: boolean, el?: Element) { - el = el || document.getElementsByTagName("html")?.[0] - el.setAttribute(THEME_ATTR, isDarkMode ? DARK_VAL : "") + el ??= document.getElementsByTagName("html")?.[0] + el?.setAttribute(THEME_ATTR, isDarkMode ? DARK_VAL : "") localStorage.setItem(STORAGE_KEY, isDarkMode ? STORAGE_FLAG : '') } diff --git a/src/util/base64.ts b/src/util/base64.ts index ab65f3f51..84a223186 100644 --- a/src/util/base64.ts +++ b/src/util/base64.ts @@ -5,18 +5,18 @@ export function encode(str: string): string { let result = '' for (let i = 0; i < bytes.length; i += 3) { - const byte1 = bytes[i] - const byte2 = bytes[i + 1] || 0 - const byte3 = bytes[i + 2] || 0 + const byte1 = bytes[i] ?? 0 + const byte2 = bytes[i + 1] ?? 0 + const byte3 = bytes[i + 2] ?? 0 const char1 = byte1 >> 2 const char2 = ((byte1 & 0x03) << 4) | (byte2 >> 4) const char3 = ((byte2 & 0x0F) << 2) | (byte3 >> 6) const char4 = byte3 & 0x3F - result += BASE64_CHARS[char1] + BASE64_CHARS[char2] + - (i + 1 < bytes.length ? BASE64_CHARS[char3] : '=') + - (i + 2 < bytes.length ? BASE64_CHARS[char4] : '=') + result += BASE64_CHARS.charAt(char1) + BASE64_CHARS.charAt(char2) + + (i + 1 < bytes.length ? BASE64_CHARS.charAt(char3) : '=') + + (i + 2 < bytes.length ? BASE64_CHARS.charAt(char4) : '=') } return result diff --git a/src/util/echarts.ts b/src/util/echarts.ts index 05283368b..110d01bf5 100644 --- a/src/util/echarts.ts +++ b/src/util/echarts.ts @@ -4,6 +4,7 @@ import type { } from "echarts" import type { AxisBaseOption } from 'echarts/types/src/coord/axisCommonTypes.js' import { isRtl } from "./document" +import { isTuple } from './tuple' export const processAria = (option: ComposeOption<AriaComponentOption>, chartDecal: boolean) => { if (!option) return @@ -231,7 +232,7 @@ const swapPosition = (option: { left?: string | number, right?: string | number } const swapBorderRadius = (option: number[]) => { - if (option?.length !== 4) return + if (!isTuple(option, 4)) return let t = option[0] option[0] = option[1] diff --git a/src/util/limit.ts b/src/util/limit.ts index c25a6d7fb..24411648d 100644 --- a/src/util/limit.ts +++ b/src/util/limit.ts @@ -14,6 +14,7 @@ export function matches(cond: timer.limit.Item['cond'], url: string): boolean { let hit = false for (let i = cond.length - 1; i >= 0; i--) { const rule = cond[i] + if (rule === undefined) continue if (rule.startsWith(EXCLUDING_PREFIX)) { if (matchUrl(rule.slice(1), url)) return false } else { @@ -32,6 +33,7 @@ export function matchCond(cond: timer.limit.Item['cond'], url: string): string[] const matchedNormalRules: string[] = [] for (let i = cond.length - 1; i >= 0; i--) { const rule = cond[i] + if (rule === undefined) continue if (rule.startsWith(EXCLUDING_PREFIX)) { // Immediately return an empty array if an exclusion rule is hit if (matchUrl(rule.slice(1), url)) return [] diff --git a/src/util/pattern.ts b/src/util/pattern.ts index d6dec176c..d8f0e1c23 100644 --- a/src/util/pattern.ts +++ b/src/util/pattern.ts @@ -91,8 +91,8 @@ export function isValidVirtualHost(host: string) { const segments = host.split('/') // Can't be normal host if (segments.length === 1) return false - if (!isValidHost(segments[0])) return false - return true + const [seg] = segments + return seg && isValidHost(seg) } /** @@ -119,21 +119,21 @@ export function extractHostname(url: string): HostInfo { return { host: fileHost, protocol: 'file' } } - let host: string - let protocol: string + let host: string | undefined + let protocol: string | undefined const indexOfDoubleSlashes = url.indexOf("//") if (indexOfDoubleSlashes > -1) { const splits = url.split('/') host = splits[2] protocol = splits[0] - protocol = protocol.substring(0, protocol.length - 1) + protocol = protocol?.substring(0, protocol.length - 1) } else { host = url.split('/')[0] protocol = '' } - host = host.split('?')[0] + host = host?.split('?')[0] - return { host, protocol } + return { host: host ?? '', protocol: protocol ?? '' } } const FILE_PREFIX = "file://" diff --git a/src/util/period.ts b/src/util/period.ts index 963d5119f..4b1fbbfbf 100644 --- a/src/util/period.ts +++ b/src/util/period.ts @@ -24,7 +24,6 @@ export function keyOf(time: Date | number, order?: number): timer.period.Key { } export function indexOf(key: timer.period.Key): number { - if (!key) return 0 const { year, month, date, order } = key return (year << 18) | (month << 14) @@ -91,6 +90,7 @@ export function averageByDay(data: timer.period.Row[], periodSize: number): time if (!data?.length) return [] const rangeStart = data[0]?.startTime const rangeEnd = data[data.length - 1]?.endTime + if (!rangeStart || !rangeEnd) return [] const dateNum = (rangeEnd - rangeStart) / MILL_PER_DAY const map = generateOrderMap(data, periodSize) return cvt2AverageResult(map, periodSize, dateNum) diff --git a/src/util/time.ts b/src/util/time.ts index 314ce7ae2..13656e862 100644 --- a/src/util/time.ts +++ b/src/util/time.ts @@ -18,16 +18,13 @@ import { isRtl } from "./document" * * Parse the time to string */ -export function formatTime(time: Date | number, cFormat?: string) { - const format = cFormat || '{y}-{m}-{d} {h}:{i}:{s}' +export function formatTime(time: Date | number, format?: string) { + format ??= '{y}-{m}-{d} {h}:{i}:{s}' let date: Date if (time instanceof Date) { date = time } else { - if ((typeof time === 'number') && (time.toString().length === 10)) { - time = time * 1000 - } - date = new Date(time) + date = new Date(time.toString().length === 10 ? time * 1000 : time) } const formatObj: Record<string, number> = { y: date.getFullYear(), @@ -40,6 +37,7 @@ export function formatTime(time: Date | number, cFormat?: string) { } const timeStr = format.replace(/{([ymdhisa])+}/g, (_result, key) => { const value = formatObj[key] + if (value === undefined) return key // Note: getDay() returns 0 on Sunday if (key === 'a') { return ['日', '一', '二', '三', '四', '五', '六'][value] } return value.toString().padStart(2, '0') diff --git a/src/util/tuple.ts b/src/util/tuple.ts index ebb9d707d..a6d190b76 100644 --- a/src/util/tuple.ts +++ b/src/util/tuple.ts @@ -1,22 +1,19 @@ -import { range } from "./array" -const isTuple = (arg: unknown): arg is Tuple<any, never> => { - if (Array.isArray(arg)) return true - if (!(arg as Object).hasOwnProperty?.("get")) return false - const predicate = arg as Tuple<any, never> - const len = predicate?.length - return typeof len === 'number' && !isNaN(len) && isFinite(len) && len >= 0 && Number.isInteger(len) +export function isTuple<T, N extends number>(arr: T[], len: N): arr is Tuple<T, N> { + return arr.length === len } /** * Add tuple */ export const addVector = <L extends number>(a: Vector<L>, toAdd: Vector<L> | number): Vector<L> => { - const l: L = a.length ?? 0 as L - if (isTuple(toAdd)) { - return range(l).map(idx => (a?.[idx] ?? 0) + (toAdd?.[idx] ?? 0)) as unknown as Vector<L> + const arr = a as number[] + + if (typeof toAdd === 'number') { + return arr.map(v => v + toAdd) as Vector<L> } else { - return a?.map(v => (v ?? 0) + ((toAdd as number) ?? 0)) as unknown as Vector<L> + const b = toAdd as number[] + return arr.map((v, i) => v + (b[i] ?? 0)) as Vector<L> } } @@ -24,11 +21,13 @@ export const addVector = <L extends number>(a: Vector<L>, toAdd: Vector<L> | num * Subtract tuple */ export const subVector = <L extends number>(a: Vector<L>, toSub: Vector<L> | number): Vector<L> => { - const l: L = a.length ?? 0 as L - if (isTuple(toSub)) { - return range(l).map(idx => (a?.[idx] ?? 0) - (toSub?.[idx] ?? 0)) as unknown as Vector<L> + const arr = a as number[] + + if (typeof toSub === 'number') { + return arr.map(v => v - toSub) as Vector<L> } else { - return a?.map(v => (v ?? 0) + ((toSub as number) ?? 0)) as unknown as Vector<L> + const b = toSub as number[] + return arr.map((v, i) => v - (b[i] ?? 0)) as Vector<L> } } @@ -36,6 +35,6 @@ export const subVector = <L extends number>(a: Vector<L>, toSub: Vector<L> | num * Multiple tuple */ export const multiTuple = <L extends number>(a: Vector<L>, multiFactor: number): Vector<L> => { - return a?.map(v => (v ?? 0) * (multiFactor ?? 0)) as unknown as Vector<L> + const arr = a as number[] + return arr.map(v => v * multiFactor) as Vector<L> } - diff --git a/test-e2e/backup/common.ts b/test-e2e/backup/common.ts index f9f7ee7b5..ea809883f 100644 --- a/test-e2e/backup/common.ts +++ b/test-e2e/backup/common.ts @@ -140,6 +140,7 @@ export class BackupOptionWrapper { const groups = matchRes.groups if (!groups) continue const { M, d, y, h, m, s } = groups + if (!y || !M || !d || !h || !m || !s) continue return new Date( Number.parseInt(y), Number.parseInt(M) - 1, Number.parseInt(d), Number.parseInt(h), Number.parseInt(m), Number.parseInt(s), diff --git a/test-e2e/backup/gist.test.ts b/test-e2e/backup/gist.test.ts index 29ea54cdd..43c1741f9 100644 --- a/test-e2e/backup/gist.test.ts +++ b/test-e2e/backup/gist.test.ts @@ -60,8 +60,8 @@ describeOptional('Backup with gist', () => { const twiceRecords = await readRecordsOfFirstPage(context) expect(twiceRecords.length).toEqual(1) const after = twiceRecords[0] - expect(after.url).toEqual(original.url) - expect(after.visit).toEqual('2') + expect(after?.url).toEqual(original?.url) + expect(after?.visit).toEqual('2') // Clear data await option.clearData() diff --git a/test-e2e/common/record.ts b/test-e2e/common/record.ts index c0178b409..e78319e2b 100644 --- a/test-e2e/common/record.ts +++ b/test-e2e/common/record.ts @@ -14,18 +14,18 @@ function readRecords(): RecordRow[] { const rows = document.querySelectorAll('.el-table .el-table__body-wrapper table tbody tr') return Array.from(rows).map(row => { const cells = row.querySelectorAll('td') - const date = cells[1].textContent ?? '' - const url = cells[2].textContent ?? '' - const name = cells[3].textContent ?? '' - const category = cells[4].textContent ?? '' - const time = cells[5].textContent ?? '' + const date = cells[1]?.textContent ?? '' + const url = cells[2]?.textContent ?? '' + const name = cells[3]?.textContent ?? '' + const category = cells[4]?.textContent ?? '' + const time = cells[5]?.textContent ?? '' let runTime: string | undefined = undefined, visit = '' if (cells?.length === 9) { // Including run time - runTime = cells[6].textContent ?? undefined - visit = cells[7].textContent ?? '' + runTime = cells[6]?.textContent ?? undefined + visit = cells[7]?.textContent ?? '' } else { - visit = cells[6].textContent ?? '' + visit = cells[6]?.textContent ?? '' } return { date, url, name, category, time, runTime, visit } }) diff --git a/test-e2e/limit/common.ts b/test-e2e/limit/common.ts index b28748feb..f181711ab 100644 --- a/test-e2e/limit/common.ts +++ b/test-e2e/limit/common.ts @@ -1,4 +1,4 @@ -import { ElementHandle, type Frame, type Page } from "puppeteer" +import type { ElementHandle, Frame, Page } from "puppeteer" import { sleep } from "../common/util" export async function waitForLimitFrame(page: Page, timeout = 5000): Promise<Frame> { @@ -44,13 +44,13 @@ export async function createLimitRule(rule: timer.limit.Rule, page: Page) { // 3. Fill the rule await sleep(.1) const { time, weekly, visitTime, count, weeklyCount } = rule || {} - const timeInputs = await page.$$('.el-dialog .el-date-editor input') - await fillTimeLimit(time, timeInputs[0], page) - await fillTimeLimit(weekly, timeInputs[1], page) - await fillTimeLimit(visitTime, timeInputs[2], page) - const visitInputs = await page.$$('.el-dialog .el-input-number input') - await fillVisitLimit(count!, visitInputs[0], page) - await fillVisitLimit(weeklyCount!, visitInputs[1], page) + const [fstTime, secTime, trdTime] = await page.$$('.el-dialog .el-date-editor input') + fstTime && await fillTimeLimit(time, fstTime, page) + secTime && await fillTimeLimit(weekly, secTime, page) + trdTime && await fillTimeLimit(visitTime, trdTime, page) + const [fstVisit, secVisit] = await page.$$('.el-dialog .el-input-number input') + fstVisit && await fillVisitLimit(count!, fstVisit, page) + secVisit && await fillVisitLimit(weeklyCount!, secVisit, page) // 4. Save await sleep(.3) diff --git a/test-e2e/tracker/base.test.ts b/test-e2e/tracker/base.test.ts index 7445c4591..2be47352e 100644 --- a/test-e2e/tracker/base.test.ts +++ b/test-e2e/tracker/base.test.ts @@ -16,11 +16,11 @@ describe('Tracking', () => { let records = await readRecordsOfFirstPage(context) expect(records.length).toEqual(1) - const { visit: visitStr, time: timeStr } = records[0] + const { visit: visitStr, time: timeStr } = records[0] ?? {} // 1 visit expect(visitStr).toEqual("1") // >= 2 s - const time = parseInt(timeStr.replace('s', '').trim()) + const time = timeStr ? parseInt(timeStr.replace('s', '').trim()) : NaN expect(time >= 2) // Another page @@ -39,11 +39,11 @@ describe('Tracking', () => { let records = await readRecordsOfFirstPage(context) expect(records.length).toEqual(1) - const { visit: visitStr, time: timeStr } = records[0] + const { visit: visitStr, time: timeStr } = records[0] ?? {} // 1 visit expect(visitStr).toEqual("1") // >= 2 s - const time = parseInt(timeStr.replace('s', '').trim()) + const time = timeStr ? parseInt(timeStr.replace('s', '').trim()) : NaN expect(time >= 2) await createWhitelist(context, MOCK_HOST) @@ -52,7 +52,7 @@ describe('Tracking', () => { await sleep(2) records = await readRecordsOfFirstPage(context) expect(records.length).toEqual(1) - expect(records[0].time).toEqual(timeStr) - expect(records[0].visit).toEqual("1") + expect(records[0]?.time).toEqual(timeStr) + expect(records[0]?.visit).toEqual("1") }, 60000) }) \ No newline at end of file diff --git a/test-e2e/tracker/run-time.test.ts b/test-e2e/tracker/run-time.test.ts index db1e02882..1f219cac1 100644 --- a/test-e2e/tracker/run-time.test.ts +++ b/test-e2e/tracker/run-time.test.ts @@ -27,8 +27,8 @@ describe('Run time tracking', () => { await sleep(1.1) let records = await readRecordsOfFirstPage(context) let record = records[0] - expect(parseTime2Sec(record.time)).toBeGreaterThanOrEqual(1) - expect(record.runTime).toBeFalsy() + expect(parseTime2Sec(record?.time)).toBeGreaterThanOrEqual(1) + expect(record?.runTime).toBeFalsy() // 1. Enable run time tracking const enableTs = Date.now() @@ -49,7 +49,7 @@ describe('Run time tracking', () => { await sleep(1) records = await readRecordsOfFirstPage(context) - const runTime2 = parseTime2Sec(records[0].runTime) + const runTime2 = parseTime2Sec(records[0]?.runTime) expect(runTime2).toBeGreaterThanOrEqual(runTime1 + 1) expect(runTime2).toBeLessThan((Date.now() - enableTs) / 1000) @@ -59,7 +59,7 @@ describe('Run time tracking', () => { await emptyPage.bringToFront() await sleep(4) records = await readRecordsOfFirstPage(context) - const runTime3 = parseTime2Sec(records[0].runTime) + const runTime3 = parseTime2Sec(records[0]?.runTime) expect(runTime3).toBeLessThanOrEqual(Math.round((disableTs - enableTs) / 1000)) }, 60000) @@ -72,7 +72,7 @@ describe('Run time tracking', () => { await sleep(4) let records = await readRecordsOfFirstPage(context) - const runTime = parseTime2Sec(records[0].runTime) + const runTime = parseTime2Sec(records[0]?.runTime) expect(runTime).toBeTruthy() expect(runTime).toBeLessThanOrEqual((Date.now() - enableTs) / 1000 + 1) @@ -83,7 +83,7 @@ describe('Run time tracking', () => { await sleep(2) records = await readRecordsOfFirstPage(context) - const runTime1 = parseTime2Sec(records[0].runTime) + const runTime1 = parseTime2Sec(records[0]?.runTime) expect(runTime1).toBeLessThan((disableTs - enableTs) / 1000 + 1) }, 60000) }) diff --git a/test/background/backup/gist/compressor.test.ts b/test/background/backup/gist/compressor.test.ts index 2994ea480..7d2a90435 100644 --- a/test/background/backup/gist/compressor.test.ts +++ b/test/background/backup/gist/compressor.test.ts @@ -15,7 +15,7 @@ test('divide 1', () => { }] const divided = divide2Buckets(rows) expect(divided.length).toEqual(1) - const [bucket, gistData] = divided[0] + const [bucket, gistData] = divided[0] ?? [] expect(bucket).toEqual('202208') const expectData: GistData = { "01": { @@ -39,11 +39,11 @@ test('gistData2Rows', () => { rows.sort((a, b) => a.date > b.date ? 1 : -1) const row0 = rows[0] const row1 = rows[1] - expect(row0.date).toEqual('20220901') - expect(row0.time).toEqual(0) - expect(row0.focus).toEqual(1) + expect(row0?.date).toEqual('20220901') + expect(row0?.time).toEqual(0) + expect(row0?.focus).toEqual(1) - expect(row1.date).toEqual('20220908') - expect(row1.time).toEqual(1) - expect(row1.focus).toEqual(1) + expect(row1?.date).toEqual('20220908') + expect(row1?.time).toEqual(1) + expect(row1?.focus).toEqual(1) }) \ No newline at end of file diff --git a/test/background/database/limit-database.test.ts b/test/background/database/limit-database.test.ts index fc8413187..d50dd6f67 100644 --- a/test/background/database/limit-database.test.ts +++ b/test/background/database/limit-database.test.ts @@ -20,12 +20,12 @@ describe('limit-database', () => { const id = await db.add(toAdd) let all: timer.limit.Rule[] = await db.all() expect(all.length).toEqual(1) - let saved = all[0] - expect(saved.cond).toEqual(toAdd.cond) - expect(saved.time).toEqual(toAdd.time) - expect(saved.name).toEqual(toAdd.name) - expect(saved.enabled).toEqual(toAdd.enabled) - expect(saved.allowDelay).toEqual(toAdd.allowDelay) + let { cond, time, name, enabled, allowDelay } = all[0] ?? {} + expect(cond).toEqual(toAdd.cond) + expect(time).toEqual(toAdd.time) + expect(name).toEqual(toAdd.name) + expect(enabled).toEqual(toAdd.enabled) + expect(allowDelay).toEqual(toAdd.allowDelay) await db.batchRemove([id + 1]) // Not exist, no error throws all = await db.all() @@ -62,7 +62,7 @@ describe('limit-database', () => { const all = await db.all() const used = all.find(a => a.cond?.includes("a.*.com")) expect(used?.records?.[date]).toBeTruthy() - expect(used?.records?.[date].mill).toEqual(10) + expect(used?.records?.[date]?.mill).toEqual(10) }) test("import data", async () => { diff --git a/test/background/database/stat-database/classic.test.ts b/test/background/database/stat-database/classic.test.ts index 610e12f5a..78965c955 100644 --- a/test/background/database/stat-database/classic.test.ts +++ b/test/background/database/stat-database/classic.test.ts @@ -156,10 +156,10 @@ describe('stat-database/classic', () => { const data = parseImportData(data2Import) expect(data.length).toEqual(1) const item = data[0] - expect(item.date).toEqual(now) - expect(item.host).toEqual(baidu) - expect(item.focus).toEqual(1) - expect(item.time).toEqual(1) + expect(item?.date).toEqual(now) + expect(item?.host).toEqual(baidu) + expect(item?.focus).toEqual(1) + expect(item?.time).toEqual(1) }) test("parseImportData2", async () => { diff --git a/test/util/array.test.ts b/test/util/array.test.ts index 7ea5f753f..28f69589a 100644 --- a/test/util/array.test.ts +++ b/test/util/array.test.ts @@ -10,7 +10,7 @@ import { allMatch, anyMatch, groupBy, rotate, sum, toMap } from "@util/array" describe("util/array", () => { test('group by', () => { - const arr = [ + const arr: [number, number][] = [ [1, 2], [1, 3], [2, 3], diff --git a/tsconfig.json b/tsconfig.json index f18b54796..12b21186d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -23,6 +23,8 @@ "isolatedModules": true, "noUnusedLocals": true, "noUnusedParameters": true, + "noImplicitAny": true, + "noUncheckedIndexedAccess": true, "skipLibCheck": true, "paths": { "@api/*": [ @@ -79,4 +81,4 @@ "tsconfig-paths/register" ] } -} +} \ No newline at end of file diff --git a/types/common.d.ts b/types/common.d.ts index ce31ee2e4..804190fc5 100644 --- a/types/common.d.ts +++ b/types/common.d.ts @@ -26,12 +26,9 @@ type RequiredPick<T, K extends keyof T> = Required<Pick<T, K>> * @param E element * @param L length of tuple */ -declare type Tuple<E, L extends number, Arr = [E, ...Array<E>]> = - Pick<Arr, Exclude<keyof Arr, 'splice' | 'push' | 'pop' | 'shift' | 'unshift'>> - & { - readonly length: L - [I: number]: E - } +declare type Tuple<E, L extends number, _Acc extends E[] = []> = + & (_Acc['length'] extends L ? _Acc : Tuple<E, L, [..._Acc, E]>) + & { readonly length: L } /** * Vector From e4f60f557119fbbca75999546aa0852fb50a632c Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Wed, 29 Apr 2026 13:06:13 +0800 Subject: [PATCH 134/174] v4.2.1 --- CHANGELOG.md | 5 +++++ package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ba00c0f86..a610403bc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.2.1] - 2026-04-30 + +- Fixed some bugs for time limit +- Optimized the style of popup page + ## [4.2.0] - 2026-04-28 - refactored basic architecture diff --git a/package.json b/package.json index 2ff0e24f7..ae37715de 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tt4b", - "version": "4.2.0", + "version": "4.2.1", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { From ed11610c5a656e5300d7c542c60c54438d7a2e13 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Thu, 30 Apr 2026 22:57:38 +0800 Subject: [PATCH 135/174] fix: action behavior on FF Mobile (#747) --- package.json | 12 ++++++------ .../browser-action.ts => action.ts} | 13 ++++++++++--- src/background/index.ts | 7 +++++++ src/background/install-handler/index.ts | 16 +--------------- 4 files changed, 24 insertions(+), 24 deletions(-) rename src/background/{install-handler/browser-action.ts => action.ts} (90%) diff --git a/package.json b/package.json index ae37715de..e1f97be48 100644 --- a/package.json +++ b/package.json @@ -32,9 +32,9 @@ "@crowdin/crowdin-api-client": "^1.55.1", "@emotion/babel-plugin": "^11.13.5", "@rsdoctor/rspack-plugin": "^1.5.9", - "@rspack/cli": "^2.0.0", - "@rspack/core": "^2.0.0", - "@rstest/core": "^0.9.9", + "@rspack/cli": "^2.0.1", + "@rspack/core": "^2.0.1", + "@rstest/core": "^0.9.10", "@rstest/coverage-istanbul": "^0.3.2", "@types/chrome": "0.1.40", "@types/decompress": "^4.2.7", @@ -42,15 +42,15 @@ "@types/node": "^25.6.0", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.1.1", - "commitlint": "^20.5.2", + "commitlint": "^20.5.3", "css-loader": "^7.1.4", "decompress": "^4.2.1", "fake-indexeddb": "^6.2.5", "fork-ts-checker-webpack-plugin": "^9.1.0", "husky": "^9.1.7", - "jsdom": "^29.0.2", + "jsdom": "^29.1.1", "jszip": "^3.10.1", - "knip": "^6.7.0", + "knip": "^6.9.0", "postcss": "^8.5.12", "postcss-loader": "^8.2.1", "postcss-rtlcss": "^6.0.0", diff --git a/src/background/install-handler/browser-action.ts b/src/background/action.ts similarity index 90% rename from src/background/install-handler/browser-action.ts rename to src/background/action.ts index 61f71705a..5ea65c59c 100644 --- a/src/background/install-handler/browser-action.ts +++ b/src/background/action.ts @@ -1,4 +1,4 @@ -/** +/** * Copyright (c) 2021-present Hengyang Zhang * * This software is released under the MIT License. @@ -78,7 +78,7 @@ const changeLogProps: ChromeContextMenuCreateProps = { ...baseProps } -function initBrowserAction() { +export function initBrowserAction() { createContextMenu(allFunctionProps) createContextMenu(optionPageProps) createContextMenu(repoPageProps) @@ -92,4 +92,11 @@ function initBrowserAction() { } } -export default initBrowserAction +export function initSidePanel() { + if (!IS_MV3) return + const sidePanel = chrome.sidePanel + // sidePanel not supported for Firefox + // Avoid `chrome.sidePanel.setOptions` to skip web-ext lint + if (!sidePanel?.setOptions) return + sidePanel.setOptions({ path: "/static/side.html" }) +} diff --git a/src/background/index.ts b/src/background/index.ts index 7eead8c05..c717dbaa1 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -6,6 +6,7 @@ */ import { trySendMsg2Tab } from "@api/chrome/tab" +import { initBrowserAction, initSidePanel } from './action' import badgeTextManager from "./badge-manager" import initCsHandler from "./content-script-handler" import initDataCleaner from "./data-cleaner" @@ -19,6 +20,12 @@ import initWhitelistMenuManager from "./whitelist-menu-manager" initAfterInstalled() +// Initialize side panel +initSidePanel() + +// Initialize context menu and icon action +initBrowserAction() + // Init data cleaner initDataCleaner() diff --git a/src/background/install-handler/index.ts b/src/background/install-handler/index.ts index 3303ca238..68bdc7433 100644 --- a/src/background/install-handler/index.ts +++ b/src/background/install-handler/index.ts @@ -3,10 +3,9 @@ import { onInstalled, setUninstallURL } from "@api/chrome/runtime" import { executeScript } from "@api/chrome/script" import { createTabAfterCurrent, listTabs } from "@api/chrome/tab" import { updateInstallTime } from "@service/meta-service" -import { IS_E2E, IS_FROM_STORE, IS_MV3 } from "@util/constant/environment" +import { IS_E2E, IS_FROM_STORE } from "@util/constant/environment" import { getGuidePageUrl, UNINSTALL_QUESTIONNAIRE } from "@util/constant/url" import { isBrowserUrl } from "@util/pattern" -import initBrowserAction from './browser-action' import versionManager from './version' async function onFirstInstall() { @@ -30,15 +29,6 @@ function initQuestionnaire() { } } -function initSidePanel() { - if (!IS_MV3) return - const sidePanel = chrome.sidePanel - // sidePanel not supported for Firefox - // Avoid `chrome.sidePanel.setOptions` to skip web-ext lint - if (!sidePanel?.setOptions) return - sidePanel.setOptions({ path: "/static/side.html" }) -} - export function initAfterInstalled() { onInstalled(async reason => { reason === "install" && await onFirstInstall() @@ -46,10 +36,6 @@ export function initAfterInstalled() { initQuestionnaire() // Reload content-script IS_FROM_STORE && await reloadContentScript() - // Initialize side panel - initSidePanel() - // Initialize context menu - initBrowserAction() // Initialize with version versionManager.handle(reason) }) From 0e67690d3da1c1d0c7805eb664414550f8bd1489 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Thu, 30 Apr 2026 22:59:53 +0800 Subject: [PATCH 136/174] v4.2.2 --- CHANGELOG.md | 5 ++++- package.json | 2 +- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a610403bc..4c91c98c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.2.2] - 2026-05-01 [For FF Mobile] + +- Fixed behavior on FF Mobile + ## [4.2.1] - 2026-04-30 - Fixed some bugs for time limit @@ -20,7 +24,6 @@ It is worth mentioning that the release time of each change refers to the time w - Dropped support for Firefox versions below 140 - Fixed some issues - ## [4.1.6] - 2026-04-09 - Optimized some UI diff --git a/package.json b/package.json index e1f97be48..b13170815 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tt4b", - "version": "4.2.1", + "version": "4.2.2", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { From 320e4f6f5d1764905f23ad1450bc8bb8d9f1d72a Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Thu, 30 Apr 2026 23:15:27 +0800 Subject: [PATCH 137/174] fix: ts checks --- src/content-script/dispatcher.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/content-script/dispatcher.ts b/src/content-script/dispatcher.ts index 58c017f6f..96f8a767f 100644 --- a/src/content-script/dispatcher.ts +++ b/src/content-script/dispatcher.ts @@ -29,7 +29,7 @@ class Dispatcher { return true }) - this.register('syncAudible', audible => this.audibleChangeHandlers.forEach(h => h.onAudibleChange(audible))) + this.register('syncAudible', audible => void this.audibleChangeHandlers.forEach(h => h.onAudibleChange(audible))) } register<Code extends timer.tab.ReqCode>(code: Code, handler: Handler<Code>): Dispatcher { From 3d153d72204e00f55dae4ac920953c9741236a8a Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Thu, 30 Apr 2026 23:22:09 +0800 Subject: [PATCH 138/174] ci: remove ts checks --- .github/workflows/crowdin-export.yml | 4 ---- 1 file changed, 4 deletions(-) diff --git a/.github/workflows/crowdin-export.yml b/.github/workflows/crowdin-export.yml index e0f993a53..0ae69d015 100644 --- a/.github/workflows/crowdin-export.yml +++ b/.github/workflows/crowdin-export.yml @@ -39,10 +39,6 @@ jobs: echo "No errors found in export logs" fi - - name: Test typescript - if: success() - run: npx tsc --noEmit - - name: Create Pull Request if: success() uses: peter-evans/create-pull-request@v8 From 4349c6017f512211d9f6de1ff4c6f51f82956537 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 3 May 2026 10:50:44 +0800 Subject: [PATCH 139/174] chore(psl): update PSL list by bot (#749) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/background/psl/rules.json | 148 +++++++++++++++++++++++++--------- 1 file changed, 111 insertions(+), 37 deletions(-) diff --git a/src/background/psl/rules.json b/src/background/psl/rules.json index 58110d7d9..482160e4a 100644 --- a/src/background/psl/rules.json +++ b/src/background/psl/rules.json @@ -263,6 +263,7 @@ }, "l": 1 }, + "claude": 1, "clerk": 1, "clerkstage": 1, "cloudflare": 1, @@ -339,6 +340,7 @@ "on-fleek": 1, "ondigitalocean": 1, "onhercules": 1, + "pplx": 1, "railway": { "c": { "up": 1 @@ -534,7 +536,7 @@ "associates": 1, "at": { "c": { - "4": 1, + "0.0.0.4": 1, "123webseite": 1, "12hp": 1, "2ix": 1, @@ -1383,16 +1385,16 @@ }, "bg": { "c": { - "0": 1, - "1": 1, - "2": 1, - "3": 1, - "4": 1, - "5": 1, - "6": 1, - "7": 1, - "8": 1, - "9": 1, + "0.0.0.0": 1, + "0.0.0.1": 1, + "0.0.0.2": 1, + "0.0.0.3": 1, + "0.0.0.4": 1, + "0.0.0.5": 1, + "0.0.0.6": 1, + "0.0.0.7": 1, + "0.0.0.8": 1, + "0.0.0.9": 1, "a": 1, "b": 1, "barsy": 1, @@ -1998,6 +2000,7 @@ "instances": 1 } }, + "sryze": 1, "twmail": 1, "uk": 1, "us": 1 @@ -2183,6 +2186,27 @@ }, "jote": 1, "jotelulu": 1, + "k2": { + "c": { + "elastic": 1, + "ru-msk": { + "c": { + "lb": 1, + "s3": 1, + "website": 1 + } + }, + "ru-spb": { + "c": { + "lb": 1, + "s3": 1, + "website": 1 + } + }, + "s3": 1, + "website": 1 + } + }, "keliweb": { "c": { "cs": 1 @@ -4138,6 +4162,21 @@ }, "l": 1 }, + "atlassian-3p": { + "c": { + "*": 1 + } + }, + "atlassian-3p-us-gov-mod": { + "c": { + "*": 1 + } + }, + "atlassian-isolated-3p": { + "c": { + "*": 1 + } + }, "atmeta": 1, "auiusercontent": { "c": { @@ -4242,6 +4281,7 @@ "ddnsguru": 1, "ddnsking": 1, "de": 1, + "deployagent": 1, "deus-canvas": 1, "dev-myqnapcloud": 1, "devinapps": { @@ -4568,6 +4608,11 @@ } }, "messwithdns": 1, + "metaaiusercontent": { + "c": { + "*": 1 + } + }, "meteorapp": { "c": { "eu": 1 @@ -4655,7 +4700,6 @@ "outsystemscloud": 1, "ownprovider": 1, "pagespeedmobilizer": 1, - "pagexl": 1, "paywhirl": { "c": { "*": 1 @@ -4729,7 +4773,7 @@ "c": { "test": { "c": { - "001": { + "0.0.0.1": { "c": { "*": 1 } @@ -4815,6 +4859,7 @@ "us": 1, "us1-plenit": 1, "vipsinaapp": 1, + "vivenushop": 1, "vultrobjects": { "c": { "*": 1 @@ -5382,6 +5427,7 @@ "ngrok-free": 1, "pages": 1, "panel": 1, + "payload": 1, "platter-app": 1, "r2": 1, "replit": { @@ -5427,6 +5473,7 @@ } }, "vercel": 1, + "vivenushop": 1, "webhare": { "c": { "*": 1 @@ -5440,17 +5487,7 @@ "dhl": 1, "diamonds": 1, "diet": 1, - "digital": { - "c": { - "cloudapps": { - "c": { - "london": 1 - }, - "l": 1 - } - }, - "l": 1 - }, + "digital": 1, "direct": { "c": { "libp2p": 1 @@ -5945,7 +5982,10 @@ "fujitsu": 1, "fun": { "c": { - "ms": 1 + "ms": 1, + "vicp": 1, + "yicp": 1, + "zicp": 1 }, "l": 1 }, @@ -6346,7 +6386,7 @@ }, "hu": { "c": { - "2000": 1, + "0.0.7.208": 1, "agrar": 1, "bolt": 1, "casino": 1, @@ -6561,7 +6601,7 @@ "investments": 1, "io": { "c": { - "2038": 1, + "0.0.7.246": 1, "apigee": 1, "azurecontainer": { "c": { @@ -9973,6 +10013,11 @@ "filegear": 1, "filegear-sg": 1, "gov": 1, + "hooc": { + "c": { + "seprox": 1 + } + }, "hopto": 1, "i234": 1, "its": 1, @@ -10316,13 +10361,13 @@ "azurefd": 1, "azurestaticapps": { "c": { - "1": 1, - "2": 1, - "3": 1, - "4": 1, - "5": 1, - "6": 1, - "7": 1, + "0.0.0.1": 1, + "0.0.0.2": 1, + "0.0.0.3": 1, + "0.0.0.4": 1, + "0.0.0.5": 1, + "0.0.0.6": 1, + "0.0.0.7": 1, "centralus": 1, "eastasia": 1, "eastus2": 1, @@ -11935,6 +11980,7 @@ "freeddns": 1, "freedesktop": 1, "from-me": 1, + "fspages": 1, "game-host": 1, "gotdns": 1, "hatenadiary": 1, @@ -12135,7 +12181,7 @@ "pictet": 1, "pictures": { "c": { - "1337": 1 + "0.0.5.57": 1 }, "l": 1 }, @@ -13102,6 +13148,7 @@ "novecore": 1, "omniwe": 1, "opensocial": 1, + "piebox": 1, "platformsh": { "c": { "*": 1 @@ -13185,6 +13232,7 @@ "space": { "c": { "app-ionos": 1, + "deployagent": 1, "heiyu": 1, "hf": { "c": { @@ -13509,7 +13557,7 @@ }, "to": { "c": { - "611": 1, + "0.0.2.99": 1, "com": 1, "edu": 1, "gov": 1, @@ -14321,9 +14369,34 @@ }, "wa": { "c": { + "aberdeen": 1, + "bainbridge-isl": 1, + "bellevue": 1, + "bremerton": 1, "cc": 1, + "centralia": 1, + "chehalis": 1, + "forks": 1, + "gig-harbor": 1, + "hoquiam": 1, "k12": 1, - "lib": 1 + "keyport": 1, + "kingston": 1, + "lib": 1, + "olympia": 1, + "port-angeles": 1, + "port-ludlow": 1, + "port-orchard": 1, + "port-townsend": 1, + "poulsbo": 1, + "redmond": 1, + "renton": 1, + "sea": 1, + "seattle": 1, + "sequim": 1, + "shelton": 1, + "silverdale": 1, + "yarrow-point": 1 }, "l": 1 }, @@ -14859,6 +14932,7 @@ "botdash": 1, "caffeine": 1, "exe": 1, + "opentunnel": 1, "telebit": { "c": { "*": 1 From d7d83f4555406fc29f6c3541de40fdf83bfcb2c5 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Tue, 5 May 2026 23:03:09 +0800 Subject: [PATCH 140/174] fix: limit matching rules (#750) --- package.json | 6 +++--- src/util/limit.ts | 9 ++++++++- test/util/limit.test.ts | 10 +++++++++- 3 files changed, 20 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index b13170815..8b253ccf6 100644 --- a/package.json +++ b/package.json @@ -50,8 +50,8 @@ "husky": "^9.1.7", "jsdom": "^29.1.1", "jszip": "^3.10.1", - "knip": "^6.9.0", - "postcss": "^8.5.12", + "knip": "^6.11.0", + "postcss": "^8.5.14", "postcss-loader": "^8.2.1", "postcss-rtlcss": "^6.0.0", "puppeteer": "^24.42.0", @@ -73,4 +73,4 @@ "engines": { "node": ">=22" } -} +} \ No newline at end of file diff --git a/src/util/limit.ts b/src/util/limit.ts index 24411648d..55d7c5fb3 100644 --- a/src/util/limit.ts +++ b/src/util/limit.ts @@ -1,8 +1,15 @@ import { EXCLUDING_PREFIX } from './constant/remain-host' import { getWeekDay, MILL_PER_MINUTE, MILL_PER_SECOND } from "./time" +const GLOBSTAR_TOKEN = `__GLOBSTAR${Math.random().toString(36).slice(2, 6)}__` + const matchUrl = (cond: string, url: string): boolean => { - return new RegExp(`^.*//${cond.split('*').join('.*')}`).test(url) + const pattern = cond + .replace(/\*\*/g, GLOBSTAR_TOKEN) + .split('*') + .join('.*') + .replaceAll(GLOBSTAR_TOKEN, '.+') + return new RegExp(`^.*//${pattern}`).test(url) } /** diff --git a/test/util/limit.test.ts b/test/util/limit.test.ts index 1df844594..24f045c70 100644 --- a/test/util/limit.test.ts +++ b/test/util/limit.test.ts @@ -6,9 +6,17 @@ import { describe('util/limit', () => { test('matches', () => { - const cond = ['www.baidu.com', '*.google.com', 'github.com/sheepzh', '+github.com/sheepzh/time-tracker-4-browser', '+www.bilibili.com/cheese', '*.bilibili.com*'] + const cond = [ + 'www.baidu.com', '+www.baidu.com/**', + '*.google.com', + 'github.com/sheepzh', + '+github.com/sheepzh/time-tracker-4-browser', + '+www.bilibili.com/cheese', + '*.bilibili.com*', + ] expect(matches(cond, 'https://www.baidu.com')).toBe(true) + expect(matches(cond, 'http://www.baidu.com/')).toBe(true) expect(matches(cond, 'http://hk.google.com')).toBe(true) expect(matches(cond, 'http://github.com/sheepzh/poetry')).toBe(true) expect(matches(cond, 'http://github.com/sheepzh/time-tracker-4-browser')).toBe(false) From e0b7957587f02ecafbd020d33e85e70166747201 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Wed, 6 May 2026 21:31:53 +0800 Subject: [PATCH 141/174] i18n(download): download translations by bot (#754) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/dashboard-resource.json | 9 ++++++--- src/i18n/message/app/data-manage-resource.json | 2 +- src/i18n/message/app/limit-resource.json | 5 ++++- src/i18n/message/app/option-resource.json | 5 ++++- src/i18n/message/app/report-resource.json | 1 + src/i18n/message/common/button-resource.json | 7 ++++++- src/i18n/message/common/calendar-resource.json | 1 + src/i18n/message/common/item-resource.json | 1 + src/i18n/message/common/shared-resource.json | 5 ++++- src/i18n/message/popup/header-resource.json | 4 +++- src/i18n/message/popup/limit-resource.json | 9 +++++++++ 11 files changed, 40 insertions(+), 9 deletions(-) diff --git a/src/i18n/message/app/dashboard-resource.json b/src/i18n/message/app/dashboard-resource.json index 00638668e..b529abf78 100644 --- a/src/i18n/message/app/dashboard-resource.json +++ b/src/i18n/message/app/dashboard-resource.json @@ -76,20 +76,23 @@ }, "ja": { "heatMap": { - "title0": "過去1年間に {hour} 時間以上オンラインで過ごした", - "title1": "過去 1 年間にオンラインで費やした時間は 1 時間未満" + "title0": "去年は {hour} 時間以上閲覧した", + "title1": "過去1年間の閲覧時間は1時間未満です" }, "topK": { "title": "過去 {day} 日間に最も拜訪された TOP {k}" }, "indicator": { - "installedDays": "使用 {number} 日", + "installedDays": "{number}日間使用しました", "visitCount": "{site} つのサイトへの合計 {visit} 回の拜訪", "browsingTime": "{minute} 分以上ウェブを閲覧する", "mostUse": "{start}:00 から {end}:00 までのお気に入りのインターネットアクセス" }, "monthOnMonth": { "title": "閲覧時間の月間推移" + }, + "timeline": { + "focusScore": "フォーカス度" } }, "pt_PT": { diff --git a/src/i18n/message/app/data-manage-resource.json b/src/i18n/message/app/data-manage-resource.json index 39f4828a7..0fdd8d2b5 100644 --- a/src/i18n/message/app/data-manage-resource.json +++ b/src/i18n/message/app/data-manage-resource.json @@ -108,7 +108,7 @@ "conflictTip": "インポートされたデータがローカルデータと競合する場合の対処方法", "overwrite": "上書き", "accumulate": "累積する", - "imported": "輸入された", + "imported": "インポート済み", "local": "ローカル", "fileNotSelected": "ファイルが選択されていません", "conflictNotSelected": "競合する解像度が選択されていません" diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index 2baf05ba2..1d959494e 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -144,6 +144,7 @@ }, "ja": { "wildcardTip": "ワイルドカードを使用してサブドメインまたはサブページに一致させることができ、「+」をプレフィックスとしてサブページを除外することができます!", + "emptyTips": "ここをクリックしてルールを一つ作成!", "item": { "name": "規則名", "condition": "制限 URL", @@ -152,6 +153,7 @@ "detail": "規則明細", "visitTime": "訪問ごとの制限", "enabled": "有效", + "locked": "ロック済み", "effectiveDay": "発効日", "or": "や", "notEffective": "効果がない" @@ -170,7 +172,8 @@ "deleteConfirm": "ルール [{name}] を削除しますか?", "inputTestUrl": "最初にテストする URL リンクを入力してください", "noRuleMatched": "URL がどのルールとも一致しません", - "rulesMatched": "URL は次のルールに一致します。" + "rulesMatched": "URL は次のルールに一致します。", + "timeout": "時間切れです! XD" }, "verification": { "inputTip": "ルールがトリガーされたかロックされました。続行するには、次の質問に対する回答を {second} 秒以内に入力してください: {prompt}", diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 78243e465..b19fcd21b 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -546,7 +546,10 @@ }, "method": { "label": "通知方法{input}", - "browser": "ブラウザ" + "browser": "ブラウザ", + "callback": { + "label": "HTTP Callback" + } } }, "resetButton": "リセット", diff --git a/src/i18n/message/app/report-resource.json b/src/i18n/message/app/report-resource.json index b0309cec1..7a0629d18 100644 --- a/src/i18n/message/app/report-resource.json +++ b/src/i18n/message/app/report-resource.json @@ -64,6 +64,7 @@ }, "ja": { "exportFileName": "私のウェブ時間データ", + "total": "合計訪問回数: {visit} 回、合計時間: {focus}", "batchDelete": { "noSelectedMsg": "最初にテーブルで削除する行にチェックマークを付けてください", "confirmMsg": "{date} の {example} のようなサイトの {count} レコードは削除されます!", diff --git a/src/i18n/message/common/button-resource.json b/src/i18n/message/common/button-resource.json index 58349123f..37a80f8e1 100644 --- a/src/i18n/message/common/button-resource.json +++ b/src/i18n/message/common/button-resource.json @@ -73,6 +73,7 @@ }, "ja": { "create": "新規", + "add": "追加", "delete": "削除", "batchDelete": "一括削除", "modify": "編集", @@ -83,12 +84,16 @@ "cancel": "キャンセル", "previous": "戻る", "next": "次へ", + "okay": "OK", "dont": "いいえ", "operation": "操作", "configuration": "設定", "clear": "クリア", + "enable": "有効化", "batchEnable": "一括有効", - "batchDisable": "一括無効" + "batchDisable": "一括無効", + "collapse": "折りたたむ", + "expand": "展開" }, "pt_PT": { "create": "Criar", diff --git a/src/i18n/message/common/calendar-resource.json b/src/i18n/message/common/calendar-resource.json index d16c23343..0ba01c53b 100644 --- a/src/i18n/message/common/calendar-resource.json +++ b/src/i18n/message/common/calendar-resource.json @@ -88,6 +88,7 @@ "everyday": "毎日", "thisWeek": "今週", "thisMonth": "今月", + "lastWeek": "先週", "lastDays": "過去{n}日間", "tillYesterday": "昨日まで", "tillDaysAgo": "{n}日前まで", diff --git a/src/i18n/message/common/item-resource.json b/src/i18n/message/common/item-resource.json index a2d6c47ce..f6d311e5a 100644 --- a/src/i18n/message/common/item-resource.json +++ b/src/i18n/message/common/item-resource.json @@ -56,6 +56,7 @@ "ja": { "date": "日付", "host": "ドメイン", + "group": "タブグループ", "focus": "閲覧時間", "run": "実行時間", "time": "訪問回数", diff --git a/src/i18n/message/common/shared-resource.json b/src/i18n/message/common/shared-resource.json index 745665966..97d8c1d9f 100644 --- a/src/i18n/message/common/shared-resource.json +++ b/src/i18n/message/common/shared-resource.json @@ -51,6 +51,7 @@ "date": "日期", "domain": "URL", "cate": "カテゴリー", + "group": "タブグループ", "notMerge": "不合并" } }, @@ -58,11 +59,13 @@ "notSet": "未設定" }, "limit": { + "limited": "制限あり", "daily": "1日の限度", "weekly": "週間の限度", "period": "許可されない期間", "visits": "{n}訪問" - } + }, + "all": "すべて" }, "zh_TW": { "merge": { diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index be5d3e717..c21f2d268 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -18,8 +18,10 @@ "donutChart": "以圓餅圖顯示" }, "ja": { + "rating": "評価を送信", "showSiteName": "サイト名を表示", - "showTopN": "トップ {n} を表示" + "showTopN": "トップ {n} を表示", + "donutChart": "ドーナツグラフとして表示" }, "pt_PT": { "showSiteName": "Mostrar nome do site", diff --git a/src/i18n/message/popup/limit-resource.json b/src/i18n/message/popup/limit-resource.json index e9287c95a..0d9761c91 100644 --- a/src/i18n/message/popup/limit-resource.json +++ b/src/i18n/message/popup/limit-resource.json @@ -16,5 +16,14 @@ "remain": "剩余 {remaining}", "noLimit": "无限制", "notHit": "未命中" + }, + "ja": { + "noData": "このURLには制限が適用されません", + "newOne": "追加", + "timeUsed": "{percent} 使用済み", + "visitUsed": "訪問回数{used}回", + "remain": "残り {remaining}", + "noLimit": "制限なし", + "notHit": "無効です" } } \ No newline at end of file From 1fd1620817b3461088666f7a676700de06571332 Mon Sep 17 00:00:00 2001 From: sheepie <returnzhy1996@outlook.com> Date: Wed, 6 May 2026 22:22:07 +0800 Subject: [PATCH 142/174] fix: file tracking for FF (#752) --- package.json | 4 ++-- src/background/track-server/file-tracker.ts | 2 +- src/background/track-server/index.ts | 8 +++++++- src/background/track-server/normal.ts | 10 ++++------ 4 files changed, 14 insertions(+), 10 deletions(-) diff --git a/package.json b/package.json index 8b253ccf6..91217fe52 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "postcss": "^8.5.14", "postcss-loader": "^8.2.1", "postcss-rtlcss": "^6.0.0", - "puppeteer": "^24.42.0", + "puppeteer": "^24.43.0", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "6.0.3", @@ -67,7 +67,7 @@ "element-plus": "2.13.7", "hash.js": "^1.1.7", "typescript-guard": "0.2.4", - "vue": "^3.5.33", + "vue": "^3.5.34", "vue-router": "^5.0.6" }, "engines": { diff --git a/src/background/track-server/file-tracker.ts b/src/background/track-server/file-tracker.ts index 0f4c3cbaa..0492cd888 100644 --- a/src/background/track-server/file-tracker.ts +++ b/src/background/track-server/file-tracker.ts @@ -50,7 +50,7 @@ class FileTracker { this.enabled && handleTrackTimeEvent({ host, start, end, ignoreTabCheck: false, - }, tab.url, tab) + }, tab) this.current.start = end } } diff --git a/src/background/track-server/index.ts b/src/background/track-server/index.ts index 88b6addea..2301afbc0 100644 --- a/src/background/track-server/index.ts +++ b/src/background/track-server/index.ts @@ -1,4 +1,5 @@ import { IS_ANDROID, IS_FIREFOX } from '@util/constant/environment' +import { isFileUrl } from '@util/pattern' import type MessageDispatcher from "../message-dispatcher" import FileTracker from './file-tracker' import { initTabGroup } from './group' @@ -7,7 +8,12 @@ import { handleTrackRunTimeEvent } from './runtime' export default function initTrackServer(messageDispatcher: MessageDispatcher) { messageDispatcher - .register('track.time', (ev, { tab, url }) => handleTrackTimeEvent(ev, url, tab)) + .register('track.time', async (ev, { tab }) => { + const url = tab?.url + // Not to process event from FF file tab + if (IS_FIREFOX && url && isFileUrl(url)) return + await handleTrackTimeEvent(ev, tab) + }) .register('track.runTime', (ev, { url }) => handleTrackRunTimeEvent(ev, url)) initTabGroup() diff --git a/src/background/track-server/normal.ts b/src/background/track-server/normal.ts index d823f7e64..773a3cfda 100644 --- a/src/background/track-server/normal.ts +++ b/src/background/track-server/normal.ts @@ -9,8 +9,8 @@ import { import { addLimitFocusTime, incLimitVisit } from '@service/limit-service' import periodThrottler from '@service/throttler/period-throttler' import whitelistHolder from "@service/whitelist/holder" -import { IS_ANDROID, IS_FIREFOX } from "@util/constant/environment" -import { extractHostname, isFileUrl } from "@util/pattern" +import { IS_ANDROID } from "@util/constant/environment" +import { extractHostname } from "@util/pattern" import badgeManager from "../badge-manager" async function handleTime(context: ItemIncContext, timeRange: [number, number], tabId: number | undefined): Promise<number> { @@ -30,13 +30,11 @@ async function handleTime(context: ItemIncContext, timeRange: [number, number], return focusTime } -export async function handleTrackTimeEvent(event: timer.core.Event, url: string | undefined, tab: ChromeTab | undefined): Promise<void> { +export async function handleTrackTimeEvent(event: timer.core.Event, tab: ChromeTab | undefined): Promise<void> { + const { id: tabId, windowId, groupId, url } = tab ?? {} if (!url) return - // not to process cs events from local files for FF - if (IS_FIREFOX && isFileUrl(url)) return const { start, end, ignoreTabCheck } = event - const { id: tabId, windowId, groupId } = tab ?? {} if (!ignoreTabCheck) { if (await windowNotFocused(windowId)) return if (await tabNotActive(tabId)) return From 0388892208d1c215eeb3b8a4912a571c30d69557 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Thu, 7 May 2026 23:00:56 +0800 Subject: [PATCH 143/174] v4.2.3 --- CHANGELOG.md | 5 +++++ package.json | 2 +- 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4c91c98c5..9a4f43b53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.2.3] - 2026-05-08 + +- Fixed some bugs +- Added some translations + ## [4.2.2] - 2026-05-01 [For FF Mobile] - Fixed behavior on FF Mobile diff --git a/package.json b/package.json index 91217fe52..3ae403262 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tt4b", - "version": "4.2.2", + "version": "4.2.3", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { From 367f66f3e16a908474cf565dd90a5d957965611d Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Thu, 7 May 2026 23:09:47 +0800 Subject: [PATCH 144/174] i18n: remove unused keys --- src/i18n/message/common/shared-resource.json | 3 +-- src/i18n/message/common/shared.ts | 1 - 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/src/i18n/message/common/shared-resource.json b/src/i18n/message/common/shared-resource.json index 97d8c1d9f..29ee51159 100644 --- a/src/i18n/message/common/shared-resource.json +++ b/src/i18n/message/common/shared-resource.json @@ -19,8 +19,7 @@ "weekly": "Weekly limit", "period": "Blocked periods", "visits": "{n} visits" - }, - "all": "All" + } }, "zh_CN": { "merge": { diff --git a/src/i18n/message/common/shared.ts b/src/i18n/message/common/shared.ts index fc6e09aab..a7678c2bf 100644 --- a/src/i18n/message/common/shared.ts +++ b/src/i18n/message/common/shared.ts @@ -15,7 +15,6 @@ export type SharedMessage = { period: string visits: string } - all: string } const sharedMessages = resource satisfies Messages<SharedMessage> From 9afc203594f86a13ad1b6266ea14bb251c72316f Mon Sep 17 00:00:00 2001 From: sheepie <returnzhy1996@outlook.com> Date: Thu, 7 May 2026 23:14:14 +0800 Subject: [PATCH 145/174] perf: optimize window (#757) --- src/api/chrome/window.ts | 104 +++++++++++++++++++------- src/background/badge-manager.ts | 4 +- src/background/track-server/normal.ts | 27 +++---- 3 files changed, 89 insertions(+), 46 deletions(-) diff --git a/src/api/chrome/window.ts b/src/api/chrome/window.ts index dd80521d7..cb7233600 100644 --- a/src/api/chrome/window.ts +++ b/src/api/chrome/window.ts @@ -1,45 +1,95 @@ import { IS_ANDROID } from "@util/constant/environment" import { handleError } from "./common" -export function isNoneWindowId(windowId: number | undefined) { - return windowId === undefined || windowId === chrome.windows.WINDOW_ID_NONE +type WindowSimple = { + id: number + focused?: boolean } -export function getFocusedNormalWindowId(): Promise<number | undefined> { - if (IS_ANDROID) { - return Promise.resolve(undefined) +class WindowHolder { + private windows: Map<number, WindowSimple> = new Map() + private lastFocusedId: number | undefined = undefined + + async init(): Promise<void> { + if (IS_ANDROID) return + + this.windows.clear() + this.lastFocusedId = undefined + + const windows = await listAllWindow() + windows.forEach(({ id, focused }) => id !== undefined && this.windows.set(id, ({ id, focused }))) + this.lastFocusedId = await getLastFocusedId() + + chrome.windows.onCreated.addListener(({ id, focused }) => { + if (id === undefined) return + + this.windows.set(id, { id, focused }) + focused && (this.lastFocusedId = id) + }) + + chrome.windows.onRemoved.addListener(id => this.windows.delete(id)) + + chrome.windows.onFocusChanged.addListener(async winId => { + for (const [id, info] of this.windows) { + // WINDOW_ID_NONE === winId means all the window change to non-focused + info.focused = id === winId + } + + if (winId !== chrome.windows.WINDOW_ID_NONE) { + const window = await getWindow(winId) + window?.type === 'normal' && (this.lastFocusedId = winId) + } + }) + } + + get(windowId: number): WindowSimple | undefined { + return this.windows.get(windowId) + } + + getLatestFocusedId(): number | undefined { + return this.lastFocusedId } +} + +function listAllWindow(): Promise<chrome.windows.Window[]> { + if (IS_ANDROID) return Promise.resolve([]) + return new Promise(resolve => chrome.windows.getAll( + { windowTypes: ['normal'] }, + list => { + handleError('listAllWindow') + resolve(list) + }, + )) +} + +function getLastFocusedId(): Promise<number | undefined> { + if (IS_ANDROID) return Promise.resolve(undefined) return new Promise(resolve => chrome.windows.getLastFocused( { windowTypes: ['normal'] }, - window => { - handleError('getFocusedNormalWindow') - if (!window) { - resolve(undefined) - return - } - const { focused, id } = window - if (!focused || !id || isNoneWindowId(id)) { - resolve(undefined) - } else { - resolve(id) - } + ({ id }) => { + handleError('getLastFocusedId') + resolve(id) }, )) } -export async function getWindow(id: number): Promise<chrome.windows.Window | undefined> { - if (IS_ANDROID) { - return - } - return new Promise(resolve => chrome.windows.get(id, win => resolve(win))) +function getWindow(id: number): Promise<ChromeWindow | undefined> { + if (IS_ANDROID) return Promise.resolve(undefined) + return new Promise(resolve => chrome.windows.get(id, window => { + handleError('getWindow') + resolve(window) + })) } -type _Handler = (windowId: number) => void +export const windowHolder = new WindowHolder() +windowHolder.init().catch(err => console.error('windowHolder.init failed', err)) -export function onWindowFocusChanged(handler: _Handler) { - if (IS_ANDROID) { - return - } +export function isNoneWindowId(windowId: number | undefined) { + return windowId === undefined || windowId === chrome.windows.WINDOW_ID_NONE +} + +export function onWindowFocusChanged(handler: ArgCallback<number>) { + if (IS_ANDROID) return chrome.windows.onFocusChanged.addListener(windowId => { handleError('onWindowFocusChanged') handler(windowId) diff --git a/src/background/badge-manager.ts b/src/background/badge-manager.ts index 810f23352..5e06a96dc 100644 --- a/src/background/badge-manager.ts +++ b/src/background/badge-manager.ts @@ -7,7 +7,7 @@ import { setBadgeBgColor, setBadgeText } from "@api/chrome/action" import { listTabs } from "@api/chrome/tab" -import { getFocusedNormalWindowId, isNoneWindowId, onWindowFocusChanged } from "@api/chrome/window" +import { isNoneWindowId, onWindowFocusChanged, windowHolder } from "@api/chrome/window" import { IS_ANDROID } from "@util/constant/environment" import { extractHostname, isBrowserUrl } from "@util/pattern" import { MILL_PER_HOUR, MILL_PER_MINUTE, MILL_PER_SECOND } from "@util/time" @@ -41,7 +41,7 @@ function mill2Str(milliseconds: number) { } async function findActiveTab(windowId?: number): Promise<BadgeLocation | undefined> { - windowId ??= await getFocusedNormalWindowId() + windowId ??= windowHolder.getLatestFocusedId() if (isNoneWindowId(windowId)) return undefined const tabs = await listTabs({ windowId, active: true }) // Fix #131 — Edge can return two active tabs (e.g. edge://newtab/). diff --git a/src/background/track-server/normal.ts b/src/background/track-server/normal.ts index 773a3cfda..b7e6b1448 100644 --- a/src/background/track-server/normal.ts +++ b/src/background/track-server/normal.ts @@ -1,10 +1,8 @@ -import { getTab, listTabs, sendMsg2Tab } from "@api/chrome/tab" -import { getWindow } from "@api/chrome/window" +import { listTabs, sendMsg2Tab } from "@api/chrome/tab" +import { windowHolder } from '@api/chrome/window' import optionHolder from "@service/components/option-holder" import { - addFocusTime as addItemFocusTime, - increaseVisit as increaseItemVisit, - type ItemIncContext + addFocusTime as addItemFocusTime, increaseVisit as increaseItemVisit, type ItemIncContext, } from "@service/item-service" import { addLimitFocusTime, incLimitVisit } from '@service/limit-service' import periodThrottler from '@service/throttler/period-throttler' @@ -31,13 +29,14 @@ async function handleTime(context: ItemIncContext, timeRange: [number, number], } export async function handleTrackTimeEvent(event: timer.core.Event, tab: ChromeTab | undefined): Promise<void> { - const { id: tabId, windowId, groupId, url } = tab ?? {} + if (!tab) return + const { id: tabId, windowId, groupId, url, active } = tab if (!url) return const { start, end, ignoreTabCheck } = event if (!ignoreTabCheck) { - if (await windowNotFocused(windowId)) return - if (await tabNotActive(tabId)) return + if (windowNotFocused(windowId)) return + if (!active) return } const { protocol, host } = extractHostname(url) const option = await optionHolder.get() @@ -56,22 +55,16 @@ export async function handleTrackTimeEvent(event: timer.core.Event, tab: ChromeT } } -async function windowNotFocused(winId: number | undefined): Promise<boolean> { +function windowNotFocused(winId: number | undefined): boolean { if (IS_ANDROID) return false if (!winId) return true - const window = await getWindow(winId) + const window = windowHolder.get(winId) return !window?.focused } -async function tabNotActive(tabId: number | undefined): Promise<boolean> { - if (!tabId) return true - const tab = await getTab(tabId) - return !tab?.active -} - async function sendLimitedMessage(items: timer.limit.Item[]) { const tabs = await listTabs() - if (!tabs?.length) return + if (!tabs.length) return for (const tab of tabs) { try { const { id } = tab From 0cfccd183696cfd212f4642ecf5e6954caa207b1 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Sat, 9 May 2026 00:56:17 +0800 Subject: [PATCH 146/174] feat: add a donation icon to the header of popup --- src/pages/icons.tsx | 2 +- .../popup/components/Header/Donation.tsx | 25 +++++++++++++++++++ .../popup/components/Header/MoreInfo.tsx | 16 +++--------- src/pages/popup/components/Header/index.tsx | 6 +++-- tsconfig.json | 3 --- 5 files changed, 33 insertions(+), 19 deletions(-) create mode 100644 src/pages/popup/components/Header/Donation.tsx diff --git a/src/pages/icons.tsx b/src/pages/icons.tsx index 050987570..dc4bf046b 100644 --- a/src/pages/icons.tsx +++ b/src/pages/icons.tsx @@ -4,7 +4,7 @@ type Icon = FunctionalComponent<{}> export const Coffee: Icon = () => ( <svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"> - <path d="M742.954667 682.666667H554.666667a42.666667 42.666667 0 0 1 0-85.333334h195.413333l14.208-170.666666H259.712l29.013333 348.416A85.333333 85.333333 0 0 0 373.76 853.333333h276.48a85.333333 85.333333 0 0 0 85.034667-78.250666l7.68-92.416z m67.2-341.333334H853.333333V256h-122.965333a119.850667 119.850667 0 0 1-107.178667-66.261333 34.517333 34.517333 0 0 0-30.890666-19.072h-160.597334a34.517333 34.517333 0 0 0-30.890666 19.072A119.850667 119.850667 0 0 1 293.632 256H170.666667v85.333333H810.154667z m39.765333 85.333334l-29.610667 355.498666A170.666667 170.666667 0 0 1 650.24 938.666667H373.76a170.666667 170.666667 0 0 1-170.069333-156.501334L174.08 426.666667H170.666667a85.333333 85.333333 0 0 1-85.333334-85.333334V256a85.333333 85.333333 0 0 1 85.333334-85.333333h122.965333a34.517333 34.517333 0 0 0 30.890667-19.072A119.850667 119.850667 0 0 1 431.701333 85.333333h160.597334c45.397333 0 86.912 25.642667 107.178666 66.261334a34.517333 34.517333 0 0 0 30.890667 19.072H853.333333a85.333333 85.333333 0 0 1 85.333334 85.333333v85.333333a85.333333 85.333333 0 0 1-85.333334 85.333334h-3.413333z"></path> + <path d="M247.68 933.12a66.88 66.88 0 0 0 64 58.88h399.68a66.88 66.88 0 0 0 64-58.88L864 320H160zM512 480a128 128 0 1 1-128 128 128 128 0 0 1 128-128zM923.2 160a64 64 0 0 1-56.64-32l-32-58.88A65.92 65.92 0 0 0 777.6 32H246.72a64 64 0 0 0-57.28 35.52L160 126.4A68.16 68.16 0 0 1 100.8 160 36.48 36.48 0 0 0 64 192v32c0 17.6 23.68 32 41.28 32h813.44C936.32 256 960 241.6 960 224V192a36.48 36.48 0 0 0-36.8-32z" /> </svg> ) diff --git a/src/pages/popup/components/Header/Donation.tsx b/src/pages/popup/components/Header/Donation.tsx new file mode 100644 index 000000000..796b481be --- /dev/null +++ b/src/pages/popup/components/Header/Donation.tsx @@ -0,0 +1,25 @@ +import { createTab } from '@api/chrome/tab' +import { locale } from '@i18n' +import Flex from '@pages/components/Flex' +import { Coffee } from '@pages/icons' +import { BUY_ME_A_COFFEE_PAGE, DONATION_PAGE } from '@util/constant/url' +import { ElIcon, ElTooltip } from 'element-plus' +import { FunctionalComponent } from 'vue' + +const Donation: FunctionalComponent<{}> = () => { + const [content, url] = locale === 'zh_CN' + ? ['请他喝杯咖啡~', DONATION_PAGE] + : ['Buy me a coffee', BUY_ME_A_COFFEE_PAGE] + + return ( + <ElTooltip content={content} placement="bottom"> + <Flex onClick={() => createTab(url)} cursor='pointer'> + <ElIcon size="large" color="var(--el-text-color-primary)"> + <Coffee /> + </ElIcon> + </Flex> + </ElTooltip> + ) +} + +export default Donation \ No newline at end of file diff --git a/src/pages/popup/components/Header/MoreInfo.tsx b/src/pages/popup/components/Header/MoreInfo.tsx index 61694d9cf..8828036ab 100644 --- a/src/pages/popup/components/Header/MoreInfo.tsx +++ b/src/pages/popup/components/Header/MoreInfo.tsx @@ -1,14 +1,13 @@ import { createTab } from "@api/chrome/tab" import { Collection, MoreFilled } from '@element-plus/icons-vue' -import { locale } from '@i18n' import Flex from '@pages/components/Flex' -import { Coffee, GitHub, Heart } from '@pages/icons' +import { GitHub, Heart } from '@pages/icons' import { rateClicked } from '@pages/util/rate' import { getColor, type ColorVariant } from '@pages/util/style' import { t } from '@popup/locale' import { BUY_ME_A_COFFEE_PAGE, CHANGE_LOG_PAGE, DONATION_PAGE, REVIEW_PAGE, SOURCE_CODE_PAGE } from "@util/constant/url" import { ElDropdown, ElDropdownItem, ElDropdownMenu, ElIcon } from "element-plus" -import { defineComponent, type StyleValue } from "vue" +import { defineComponent, type FunctionalComponent, type StyleValue } from "vue" import { type JSX } from 'vue/jsx-runtime' type Command = 'rate' | 'coffee' | 'donation' | 'github' | 'changelog' @@ -18,7 +17,7 @@ type ItemLinkProps = { text: string iconColor?: ColorVariant } -const ItemLink = ({ icon, text, iconColor }: ItemLinkProps) => ( +const ItemLink: FunctionalComponent<ItemLinkProps> = ({ icon, text, iconColor }) => ( <Flex gap={2} align='center'> <ElIcon color={iconColor ? getColor(iconColor) : undefined}>{icon}</ElIcon> {text} @@ -60,15 +59,6 @@ const MoreInfo = defineComponent<{}>(() => { <ElDropdownItem command={'rate' satisfies Command} divided> <ItemLink icon={<Heart />} text={t(msg => msg.header.rating)} iconColor="danger" /> </ElDropdownItem> - {locale === 'zh_CN' ? ( - <ElDropdownItem command={'donation' satisfies Command}> - <ItemLink icon={<Coffee />} text="请他喝杯咖啡~" iconColor="warning" /> - </ElDropdownItem> - ) : ( - <ElDropdownItem command={'coffee' satisfies Command}> - <ItemLink icon={<Coffee />} text="Buy me a coffee" iconColor="warning" /> - </ElDropdownItem> - )} </ElDropdownMenu> ) }}> diff --git a/src/pages/popup/components/Header/index.tsx b/src/pages/popup/components/Header/index.tsx index e894e3191..49cc14f46 100644 --- a/src/pages/popup/components/Header/index.tsx +++ b/src/pages/popup/components/Header/index.tsx @@ -1,12 +1,13 @@ -import { t } from '@popup/locale' +import { createTab, listTabs, updateTab } from '@api/chrome/tab' import { View } from "@element-plus/icons-vue" import Flex from "@pages/components/Flex" -import { createTab, listTabs, updateTab } from "@api/chrome/tab" +import { t } from '@popup/locale' import { IS_ANDROID } from "@util/constant/environment" import { getAppPageUrl } from "@util/constant/url" import { ElLink } from "element-plus" import type { FunctionalComponent } from "vue" import DarkSwitch from "./DarkSwitch" +import Donation from './Donation' import LangSelect from "./LangSelect" import Logo from "./Logo" import MoreInfo from './MoreInfo' @@ -40,6 +41,7 @@ const Header: FunctionalComponent<{}> = () => ( <LangSelect /> <DarkSwitch /> <Option /> + <Donation /> <MoreInfo /> </Flex> </Flex> diff --git a/tsconfig.json b/tsconfig.json index 12b21186d..b829590bd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -54,9 +54,6 @@ "@service/*": [ "./src/background/service/*" ], - "@polyfill": [ - "./src/background/polyfill/index" - ], "@util/*": [ "./src/util/*" ], From a858b71015f016e1996b03f19e513c73fcd6199a Mon Sep 17 00:00:00 2001 From: sheepie <returnzhy1996@outlook.com> Date: Sat, 9 May 2026 01:05:16 +0800 Subject: [PATCH 147/174] feat: 2fa for limit verification (#744) --- .vscode/settings.json | 3 + package.json | 11 +- src/api/web-dav.ts | 4 +- src/background/message-dispatcher.ts | 3 + src/background/service/2fa-service.ts | 146 ++++++++++++++++++ .../limit/modal/components/Footer.tsx | 2 +- src/i18n/message/app/limit-resource.json | 2 + src/i18n/message/app/limit.ts | 2 + src/i18n/message/app/option-resource.json | 7 +- src/i18n/message/app/option.ts | 4 + src/i18n/message/common/button-resource.json | 3 +- src/i18n/message/common/button.ts | 1 + .../Option/categories/Limit/index.tsx | 28 +++- .../categories/Limit/use2faSetup/Form.tsx | 111 +++++++++++++ .../categories/Limit/use2faSetup/index.tsx | 41 +++++ .../Option/categories/Limit/usePswEdit.tsx | 2 +- src/pages/app/util/limit/index.tsx | 42 ++++- src/util/base64.ts | 23 --- src/util/encode.ts | 89 +++++++++++ types/qrcode-generator.d.ts | 15 ++ types/timer/index.d.ts | 10 ++ types/timer/limit.d.ts | 2 + types/timer/message.d.ts | 2 + 23 files changed, 505 insertions(+), 48 deletions(-) create mode 100644 src/background/service/2fa-service.ts create mode 100644 src/pages/app/components/Option/categories/Limit/use2faSetup/Form.tsx create mode 100644 src/pages/app/components/Option/categories/Limit/use2faSetup/index.tsx delete mode 100644 src/util/base64.ts create mode 100644 src/util/encode.ts create mode 100644 types/qrcode-generator.d.ts diff --git a/.vscode/settings.json b/.vscode/settings.json index 2454221db..50906233d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -30,9 +30,11 @@ "knip", "MKCOL", "newtab", + "otpauth", "Popconfirm", "PROPFIND", "Qihu", + "qrcode", "Rsdoctor", "rspack", "rstest", @@ -40,6 +42,7 @@ "selectchanged", "sheepzh", "subpages", + "Totp", "vueuse", "webext", "webstore", diff --git a/package.json b/package.json index 3ae403262..d30c26c9a 100644 --- a/package.json +++ b/package.json @@ -32,14 +32,14 @@ "@crowdin/crowdin-api-client": "^1.55.1", "@emotion/babel-plugin": "^11.13.5", "@rsdoctor/rspack-plugin": "^1.5.9", - "@rspack/cli": "^2.0.1", - "@rspack/core": "^2.0.1", + "@rspack/cli": "^2.0.2", + "@rspack/core": "^2.0.2", "@rstest/core": "^0.9.10", "@rstest/coverage-istanbul": "^0.3.2", - "@types/chrome": "0.1.40", + "@types/chrome": "0.1.42", "@types/decompress": "^4.2.7", "@types/firefox-webext-browser": "^143.0.0", - "@types/node": "^25.6.0", + "@types/node": "^25.6.2", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.1.1", "commitlint": "^20.5.3", @@ -50,7 +50,7 @@ "husky": "^9.1.7", "jsdom": "^29.1.1", "jszip": "^3.10.1", - "knip": "^6.11.0", + "knip": "^6.12.1", "postcss": "^8.5.14", "postcss-loader": "^8.2.1", "postcss-rtlcss": "^6.0.0", @@ -66,6 +66,7 @@ "echarts": "^6.0.0", "element-plus": "2.13.7", "hash.js": "^1.1.7", + "qrcode-generator": "^2.0.4", "typescript-guard": "0.2.4", "vue": "^3.5.34", "vue-router": "^5.0.6" diff --git a/src/api/web-dav.ts b/src/api/web-dav.ts index b536c1895..d8cb042d3 100644 --- a/src/api/web-dav.ts +++ b/src/api/web-dav.ts @@ -3,7 +3,7 @@ * * Testing with server implemented by https://github.com/svtslv/webdav-cli */ -import { encode } from '../util/base64' +import { encodeBase64 } from '../util/encode' import { fetchDelete, fetchGet } from './http' // Only support password for now @@ -22,7 +22,7 @@ const authHeaders = (auth: WebDAVAuth): Headers => { const type = auth?.type const headers = new Headers() if (type === 'password') { - headers.set('Authorization', `Basic ${encode(`${auth?.username}:${auth?.password}`)}`) + headers.set('Authorization', `Basic ${encodeBase64(`${auth?.username}:${auth?.password}`)}`) } return headers } diff --git a/src/background/message-dispatcher.ts b/src/background/message-dispatcher.ts index ceb5e19cd..9744050e5 100644 --- a/src/background/message-dispatcher.ts +++ b/src/background/message-dispatcher.ts @@ -12,6 +12,7 @@ import { getUsedStorage } from './database/memory-detector' import mergeRuleDatabase from "./database/merge-rule-database" import siteDatabase from './database/site-database' import statDatabase from './database/stat-database' +import { check2faCode, prepare2fa } from "./service/2fa-service" import backupProcessor from "./service/backup/processor" import { exportData, importData, migrateStorage } from "./service/components/immigration" import { importOther, previewBackup } from "./service/components/import-processor" @@ -105,6 +106,8 @@ class MessageDispatcher { // Meta information .register('meta.installTs', getInstallTime) .register('meta.usedStorage', getUsedStorage) + .register('meta.prepare2fa', prepare2fa) + .register('meta.check2fa', check2faCode) // Whitelist & Merge Rule .register('whitelist.contain', ({ host, url }) => whitelistHolder.contains(host, url)) .register('whitelist.all', () => whitelistHolder.all()) diff --git a/src/background/service/2fa-service.ts b/src/background/service/2fa-service.ts new file mode 100644 index 000000000..5900b060d --- /dev/null +++ b/src/background/service/2fa-service.ts @@ -0,0 +1,146 @@ +/** + * 2FA service implementation + * https://datatracker.ietf.org/doc/html/rfc6238 + * + * Copyright (c) 2026 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { getRuntimeName } from '@api/chrome/runtime' +import db from '@db/meta-database' +import { decodeBase32, decodeBase64, encodeBase32, encodeBase64 } from '@util/encode' +import { getCid } from './meta-service' + +/** + * Generate TOTP material and save to the storage + * + * @return otpauth URI + */ +export async function prepare2fa(): Promise<string> { + const secret = generateSecret() + const issuer = getRuntimeName() + const accountName = await getCid() + const uri = buildTotpUri({ issuer, accountName, secret }) + await saveTwoFa(secret) + return uri +} + +function generateSecret(): string { + const randomBytes = new Uint8Array(20) + crypto.getRandomValues(randomBytes) + return encodeBase32(randomBytes).toLowerCase() +} + +function buildTotpUri(params: { issuer: string; accountName: string; secret: string }): string { + const { issuer, accountName, secret } = params + const label = `${issuer}:${accountName}` + const sec = secret.toUpperCase().replace(/\s/g, '') + return `otpauth://totp/${encodeURIComponent(label)}?secret=${sec}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30` +} + +async function saveTwoFa(secret: string): Promise<void> { + const cid = await getCid() + const meta = await db.getMeta() + meta.twoFa = await encrypt(secret, cid) + await db.update(meta) +} + +async function encrypt(plaintext: string, cid: string): Promise<timer.TwoFactorAuth> { + const encoder = new TextEncoder() + const dataBuffer = encoder.encode(plaintext) + + const salt = crypto.getRandomValues(new Uint8Array(16)) + const iv = crypto.getRandomValues(new Uint8Array(12)) + + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(cid), + { name: 'PBKDF2' }, + false, + ['deriveKey'] + ) + + const key = await crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ) + + const cipherText = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv }, + key, + dataBuffer + ) + + return { + salt: encodeBase64(salt), + iv: encodeBase64(iv), + secret: encodeBase64(new Uint8Array(cipherText)), + } +} + +export async function check2faCode(code: string): Promise<boolean> { + const { twoFa } = await db.getMeta() + if (!twoFa) return false + + const cid = await getCid() + const secret = await decryptSecret(twoFa, cid) + return verifyTotp(secret, code) +} + +async function decryptSecret(twoFa: timer.TwoFactorAuth, cid: string): Promise<string> { + const encoder = new TextEncoder() + const keyMaterial = await crypto.subtle.importKey( + 'raw', encoder.encode(cid), { name: 'PBKDF2' }, false, ['deriveKey'] + ) + const key = await crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: decodeBase64(twoFa.salt), iterations: 100000, hash: 'SHA-256' }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['decrypt'] + ) + const plain = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: decodeBase64(twoFa.iv) }, + key, + decodeBase64(twoFa.secret), + ) + return new TextDecoder().decode(plain) +} + +async function verifyTotp(secretBase32: string, code: string): Promise<boolean> { + const normalized = code.replace(/\s/g, '') + if (!/^\d{6}$/.test(normalized)) return false + const step = Math.floor(Date.now() / 1000 / 30) + for (const delta of [0, -1, 1]) { + const candidate = await genTotp(secretBase32, step + delta) + if (candidate === normalized) return true + } + return false +} + +async function genTotp(secretBase32: string, step: number): Promise<string> { + const key = decodeBase32(secretBase32) + const msg = new Uint8Array(8) + let t = step + for (let i = 7; i >= 0; i--) { + msg[i] = t & 0xff + t >>>= 8 + } + + const hmacKey = await crypto.subtle.importKey( + 'raw', key.slice(), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign'] + ) + const sigBuffer = await crypto.subtle.sign('HMAC', hmacKey, msg) + const sig = new Uint8Array(sigBuffer) + const offset = sig[sig.length - 1]! & 0x0f + const otp = ((sig[offset]! & 0x7f) << 24 + | sig[offset + 1]! << 16 + | sig[offset + 2]! << 8 + | sig[offset + 3]!) % 1_000_000 + return otp.toString().padStart(6, '0') +} diff --git a/src/content-script/limit/modal/components/Footer.tsx b/src/content-script/limit/modal/components/Footer.tsx index eb9361687..a1c212e16 100644 --- a/src/content-script/limit/modal/components/Footer.tsx +++ b/src/content-script/limit/modal/components/Footer.tsx @@ -1,4 +1,4 @@ -import { APP_ANALYSIS_ROUTE, APP_LIMIT_ROUTE, AppLimitQuery, type AppAnalysisQuery } from '@/shared/route' +import { APP_ANALYSIS_ROUTE, APP_LIMIT_ROUTE, type AppAnalysisQuery, type AppLimitQuery } from '@/shared/route' import { trySendMsg2Runtime } from '@api/sw/common' import { processVerification } from '@app/util/limit' import { t } from "@cs/locale" diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index 1d959494e..364bf528a 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -137,6 +137,8 @@ "strictTip": "Triggered, no operation is allowed before release!", "incorrectPsw": "Incorrect password", "incorrectAnswer": "Incorrect answer", + "twoFaInputTip": "The rule has been triggered or locked. Enter the 6-digit code from your authenticator app to continue.", + "incorrect2fa": "Incorrect 2FA code", "pi": "{digitCount} digits from {startIndex} to {endIndex} of the decimal part of π", "confession": "Time is fleeting" }, diff --git a/src/i18n/message/app/limit.ts b/src/i18n/message/app/limit.ts index 0d94b1997..92b06fcbd 100644 --- a/src/i18n/message/app/limit.ts +++ b/src/i18n/message/app/limit.ts @@ -50,6 +50,8 @@ export type LimitMessage = { strictTip: string incorrectPsw: string incorrectAnswer: string + twoFaInputTip: string + incorrect2fa: string pi: string confession: string } diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index b19fcd21b..92fc6eb31 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -351,7 +351,12 @@ "strictTitle": "Operation confirm", "strictContent": "When you select this option, if a site triggers daily limit, you will not be allowed to manually unblock it other than waiting until the next day. If rules are not set up properly, they can very well hinder your routines!", "pswFormLabel": "Password", - "pswFormAgain": "Re-enter" + "pswFormAgain": "Re-enter", + "2fa": "Must use 2FA code to unlock", + "twoFaTitle": "Enable 2FA", + "twoFaScanHint": "Scan the QR code with an authenticator app or import the setup link into a password manager that supports TOTP.", + "twoFaCopyLink": "Copy link", + "twoFaVerifyLabel": "Enter the 6-digit code to verify" }, "delayDuration": "Delay for {input} minutes per time" }, diff --git a/src/i18n/message/app/option.ts b/src/i18n/message/app/option.ts index 4c50bbcbb..842616ced 100644 --- a/src/i18n/message/app/option.ts +++ b/src/i18n/message/app/option.ts @@ -72,6 +72,10 @@ export type OptionMessage = { strictContent: string pswFormLabel: string pswFormAgain: string + twoFaTitle: string + twoFaScanHint: string + twoFaCopyLink: string + twoFaVerifyLabel: string } delayDuration: string } diff --git a/src/i18n/message/common/button-resource.json b/src/i18n/message/common/button-resource.json index 37a80f8e1..e62153415 100644 --- a/src/i18n/message/common/button-resource.json +++ b/src/i18n/message/common/button-resource.json @@ -21,7 +21,8 @@ "batchEnable": "Batch Enable", "batchDisable": "Batch Disable", "collapse": "Collapse", - "expand": "Expand" + "expand": "Expand", + "copy": "Copy" }, "zh_CN": { "create": "新建", diff --git a/src/i18n/message/common/button.ts b/src/i18n/message/common/button.ts index 288200b37..054b834a5 100644 --- a/src/i18n/message/common/button.ts +++ b/src/i18n/message/common/button.ts @@ -29,6 +29,7 @@ export type ButtonMessage = { batchDisable: string collapse: string expand: string + copy: string } const _default: Messages<ButtonMessage> = resource diff --git a/src/pages/app/components/Option/categories/Limit/index.tsx b/src/pages/app/components/Option/categories/Limit/index.tsx index a029bb960..3d9492378 100644 --- a/src/pages/app/components/Option/categories/Limit/index.tsx +++ b/src/pages/app/components/Option/categories/Limit/index.tsx @@ -13,8 +13,9 @@ import { css } from '@emotion/css' import { locale } from '@i18n' import { DEFAULT_LIMIT } from "@util/constant/option" import { ElButton, ElInput, ElInputNumber, ElMessage, ElMessageBox, ElSelect, ElSwitch, useNamespace } from "element-plus" -import { defineComponent, type StyleValue } from "vue" +import { defineComponent, FunctionalComponent, type StyleValue } from "vue" import type { CategoryInstance } from '../types' +import { use2faSetup } from './use2faSetup' import { usePswEdit } from "./usePswEdit" import { useVerify } from "./useVerify" @@ -41,6 +42,7 @@ const ALL_LEVEL: timer.limit.RestrictionLevel[] = [ 'nothing', 'verification', 'password', + '2fa', 'strict', ] @@ -83,10 +85,21 @@ const confirm4Strict = async (): Promise<void> => { }) } +const TestButton: FunctionalComponent<{ onClick: NoArgCallback }> = props => ( + <ElButton + size="small" + style={{ height: '28px', marginInlineStart: '5px' } satisfies StyleValue} + onClick={props.onClick} + > + {t(msg => msg.button.test)} + </ElButton> +) + const _default = defineComponent((_, ctx) => { const { option } = useOption<timer.option.LimitOption>({ defaultValue: DEFAULT_LIMIT, copy }) const { verified, verify } = useVerify(option) const { modifyPsw } = usePswEdit({ reset: () => option.limitPassword }) + const { setup2fa } = use2faSetup() ctx.expose({ reset: () => verify().then(() => reset(option)).catch(() => { }) @@ -96,6 +109,8 @@ const _default = defineComponent((_, ctx) => { verify().then(async () => { if (val === "strict") { await confirm4Strict() + } else if (val === '2fa') { + await setup2fa() } else if (val === "password") { option.limitPassword = await modifyPsw() } @@ -117,6 +132,8 @@ const _default = defineComponent((_, ctx) => { .catch(e => console.warn("Failed to verify", e)) } + const handleTest = () => processVerification(option).then(() => ElMessage.success('Valid!')).catch(console.warn) + const levelSelectStyle = useLevelSelectStyle() return () => <OptionLines> @@ -161,6 +178,7 @@ const _default = defineComponent((_, ctx) => { onChange={handleLevelChange} options={ALL_LEVEL.map(value => ({ value, label: t(msg => msg.option.limit.level[value]) }))} /> + {option.limitLevel === '2fa' && <TestButton onClick={handleTest} />} </OptionItem> <OptionItem v-show={option.limitLevel === "password"} @@ -191,13 +209,7 @@ const _default = defineComponent((_, ctx) => { } options={ALL_DIFF.map(value => ({ value, label: t(msg => msg.option.limit.level.verificationDifficulty[value]) }))} /> - <ElButton - size="small" - style={{ height: '28px', marginInlineStart: '5px' } satisfies StyleValue} - onClick={() => processVerification(option)} - > - {t(msg => msg.button.test)} - </ElButton> + <TestButton onClick={handleTest} /> </OptionItem> <OptionItem label={msg => msg.option.limit.prompt}> <ElInput diff --git a/src/pages/app/components/Option/categories/Limit/use2faSetup/Form.tsx b/src/pages/app/components/Option/categories/Limit/use2faSetup/Form.tsx new file mode 100644 index 000000000..e30f8f79a --- /dev/null +++ b/src/pages/app/components/Option/categories/Limit/use2faSetup/Form.tsx @@ -0,0 +1,111 @@ +import { t } from '@app/locale' +import { CopyDocument } from '@element-plus/icons-vue' +import { useRequest, useState } from '@hooks' +import Flex from '@pages/components/Flex' +import Img from '@pages/components/Img' +import { ElButton, ElForm, ElFormItem, ElInput, ElMessage, ElText } from 'element-plus' +import qrcode from 'qrcode-generator' +import { computed, defineComponent, toRef, watch } from 'vue' + +function generateQrDataUrl(text: string): string { + const qr = qrcode(0, 'M') + qr.addData(text) + qr.make() + + const margin = 1 + const targetWidth = 200 + + const moduleCount = qr.getModuleCount() + const totalModules = moduleCount + margin * 2 + const scale = Math.max(1, Math.floor(targetWidth / totalModules)) + const canvasSize = totalModules * scale + + const canvas = document.createElement('canvas') + canvas.width = canvasSize + canvas.height = canvasSize + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('Failed to create QR canvas context') + + ctx.fillStyle = '#fff' + ctx.fillRect(0, 0, canvasSize, canvasSize) + ctx.fillStyle = '#000' + + for (let row = 0; row < moduleCount; row++) { + for (let col = 0; col < moduleCount; col++) { + if (!qr.isDark(row, col)) continue + ctx.fillRect((col + margin) * scale, (row + margin) * scale, scale, scale) + } + } + + return canvas.toDataURL('image/png') +} + +function extractSecret(otpauth: string): string { + const raw = new URL(otpauth).searchParams.get('secret') + return raw ?? 'Secret is unknown' +} + +async function copy(text: string) { + try { + await navigator.clipboard.writeText(text) + ElMessage.success('Copied') + } catch (e) { + const errMsg = e instanceof Error ? e.message : e ?? 'Unknown error' + ElMessage.error(`Copy failed: ${errMsg}`) + } +} + +export type FormInstance = { + getVerifyCode: () => string +} + +const _default = defineComponent<{ otpauth: string }>((props, ctx) => { + const otpauth = toRef(props, 'otpauth') + + const [code, setCode] = useState('') + watch(otpauth, () => setCode(''), { immediate: true }) + + const { data: qrData } = useRequest(() => generateQrDataUrl(otpauth.value), { deps: otpauth }) + ctx.expose({ + getVerifyCode: () => code.value, + } satisfies FormInstance) + + const secret = computed(() => extractSecret(otpauth.value)) + + return () => ( + <ElForm labelPosition="top"> + <Flex column marginBottom={12} marginTop={12}> + <ElText style={{ lineHeight: 1.5 }}> + {t(msg => msg.option.limit.level.twoFaScanHint)} + </ElText> + </Flex> + <Flex column align="center" marginBottom={12}> + <Img src={qrData.value} size={200} /> + </Flex> + <ElFormItem label='2FA Secret'> + <ElInput + size='small' + modelValue={secret.value} + readonly v-slots={{ + append: () => <ElButton size="small" icon={CopyDocument} onClick={() => copy(secret.value)} />, + }} + /> + <Flex marginTop={6}> + <ElButton size="small" icon={CopyDocument} onClick={() => copy(otpauth.value)}> + {t(msg => msg.option.limit.level.twoFaCopyLink)} + </ElButton> + </Flex> + </ElFormItem> + <ElFormItem required label={t(msg => msg.option.limit.level.twoFaVerifyLabel)}> + <ElInput + size='small' + modelValue={code.value} + onUpdate:modelValue={val => setCode(val.trim())} + maxlength={6} + /> + </ElFormItem> + </ElForm > + ) +}, { props: ['otpauth'] }) + +export default _default diff --git a/src/pages/app/components/Option/categories/Limit/use2faSetup/index.tsx b/src/pages/app/components/Option/categories/Limit/use2faSetup/index.tsx new file mode 100644 index 000000000..794af3486 --- /dev/null +++ b/src/pages/app/components/Option/categories/Limit/use2faSetup/index.tsx @@ -0,0 +1,41 @@ +import { sendMsg2Runtime } from '@api/sw/common' +import { t } from '@app/locale' +import { ElMessage, ElMessageBox } from 'element-plus' +import { ref } from 'vue' +import Form, { type FormInstance } from './Form' + +export const use2faSetup = () => { + const form = ref<FormInstance>() + + const setup2fa = async () => { + const otpauth = await sendMsg2Runtime('meta.prepare2fa') + + const action = await ElMessageBox({ + title: t(msg => msg.option.limit.level.twoFaTitle), + message: () => <Form ref={form} otpauth={otpauth} />, + confirmButtonText: t(msg => msg.button.confirm), + showCancelButton: true, + closeOnClickModal: false, + showClose: false, + customStyle: { '--el-messagebox-padding-primary': '12px 20px' }, + beforeClose: (act, instance, done) => { + if (act !== 'confirm') return done() + + const code = form.value?.getVerifyCode().replace(/\s/g, '') + if (!code) return ElMessage.error('Verify code is empty') + if (!/^\d{6}$/.test(code)) return ElMessage.error('Invalid verify code') + + sendMsg2Runtime('meta.check2fa', code).then(ok => { + if (!ok) return ElMessage.error("Incorrect code") + instance.action = act + instance.inputValue = code + done() + }).catch(() => ElMessage.error("Failed to check code")) + }, + }) + + if (action !== 'confirm') throw new Error('User cancelled') + } + + return { setup2fa } +} diff --git a/src/pages/app/components/Option/categories/Limit/usePswEdit.tsx b/src/pages/app/components/Option/categories/Limit/usePswEdit.tsx index 50c28c0b2..0a3769892 100644 --- a/src/pages/app/components/Option/categories/Limit/usePswEdit.tsx +++ b/src/pages/app/components/Option/categories/Limit/usePswEdit.tsx @@ -1,6 +1,6 @@ -import { useState } from '@hooks' import { t } from '@app/locale' import { css } from '@emotion/css' +import { useState } from '@hooks' import { ElForm, ElFormItem, ElInput, ElMessage, ElMessageBox, useNamespace } from "element-plus" type Options = { diff --git a/src/pages/app/util/limit/index.tsx b/src/pages/app/util/limit/index.tsx index 621b8b8a3..c2d3f2fe6 100644 --- a/src/pages/app/util/limit/index.tsx +++ b/src/pages/app/util/limit/index.tsx @@ -1,4 +1,4 @@ -import { trySendMsg2Runtime } from '@api/sw/common' +import { sendMsg2Runtime, trySendMsg2Runtime } from '@api/sw/common' import { css } from '@emotion/css' import { useCountDown } from "@hooks" import { type I18nKey, type I18nResultItem, locale, t as t_, tN as tN_ } from "@i18n" @@ -93,6 +93,9 @@ const MSG_CLS = css` left: 50%; transform: translate(-50%); ` +const okBtnTxt = t_(buttonMessages, { key: msg => msg.okay }) + +const errMsg = (message: string) => ElMessage.error({ message, customClass: MSG_CLS }) /** * NOT TO return Promise.resolve() @@ -102,15 +105,17 @@ const MSG_CLS = css` * @returns null if verification not required, * or promise with resolve invoked only if verification code or password correct */ -export async function processVerification(option: timer.option.LimitOption): Promise<void> { +export function processVerification(option: timer.option.LimitOption): Promise<void> { const { limitLevel, limitPassword, limitVerifyDifficulty } = option - if (limitLevel === "strict") { + if (limitLevel === 'strict') { return new Promise(() => ElMessageBox({ boxType: 'alert', type: 'warning', title: '', message: <div>{t(msg => msg.verification.strictTip)}</div>, }).catch(() => { })) + } else if (limitLevel === '2fa') { + return process2faVerification() } let inputType: InputType | undefined let answerValue: string | undefined @@ -142,12 +147,10 @@ export async function processVerification(option: timer.option.LimitOption): Pro const okBtnClz = `limit-confirm-btn-${useId().value}` const btnText = (leftSec: number) => `${okBtnTxt} (${leftSec})` - const okBtnTxt = t_(buttonMessages, { key: msg => msg.okay }) const msgData = ElMessageBox({ autofocus: true, boxType: 'prompt', type: 'warning', - title: '', message: <div style={{ userSelect: 'none' }}>{messageNode}</div>, showInput: true, inputType, @@ -182,7 +185,34 @@ export async function processVerification(option: timer.option.LimitOption): Pro if (typeof data === 'string') return const { value } = data if (value === answerValue) return resolve() - ElMessage.error({ message: incorrectMessage, customClass: MSG_CLS }) + errMsg(incorrectMessage) }).catch(() => cleanCountdown?.()) }) } + +function process2faVerification(): Promise<void> { + return new Promise(resolve => ElMessageBox({ + autofocus: true, + boxType: 'prompt', + type: 'warning', + message: t(msg => msg.verification.twoFaInputTip), + showInput: true, + showCancelButton: true, + showClose: false, + confirmButtonText: okBtnTxt, + buttonSize: 'small', + modalClass: MODAL_CLS, + beforeClose: (act, instance, done) => { + if (act !== 'confirm') return done() + const code = instance.inputValue.replace(/\s/g, '') + if (!/^\d{6}$/.test(code)) return errMsg('Invalid code format') + sendMsg2Runtime('meta.check2fa', code) + .then(ok => { if (!ok) throw new Error('Incorrect code') }) + .then(() => { + resolve() + done() + }) + .catch(e => errMsg(e instanceof Error ? e.message : e ?? 'Unknown error')) + }, + })) +} diff --git a/src/util/base64.ts b/src/util/base64.ts deleted file mode 100644 index 84a223186..000000000 --- a/src/util/base64.ts +++ /dev/null @@ -1,23 +0,0 @@ -const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' - -export function encode(str: string): string { - const bytes = new TextEncoder().encode(str) - let result = '' - - for (let i = 0; i < bytes.length; i += 3) { - const byte1 = bytes[i] ?? 0 - const byte2 = bytes[i + 1] ?? 0 - const byte3 = bytes[i + 2] ?? 0 - - const char1 = byte1 >> 2 - const char2 = ((byte1 & 0x03) << 4) | (byte2 >> 4) - const char3 = ((byte2 & 0x0F) << 2) | (byte3 >> 6) - const char4 = byte3 & 0x3F - - result += BASE64_CHARS.charAt(char1) + BASE64_CHARS.charAt(char2) + - (i + 1 < bytes.length ? BASE64_CHARS.charAt(char3) : '=') + - (i + 2 < bytes.length ? BASE64_CHARS.charAt(char4) : '=') - } - - return result -} \ No newline at end of file diff --git a/src/util/encode.ts b/src/util/encode.ts new file mode 100644 index 000000000..f43fb5f70 --- /dev/null +++ b/src/util/encode.ts @@ -0,0 +1,89 @@ +const BASE64_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/' +const BASE32_CHARS = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567' + +export function encodeBase64(str: string | Uint8Array<ArrayBuffer>): string { + const bytes = typeof str === 'string' ? new TextEncoder().encode(str) : str + let result = '' + + for (let i = 0; i < bytes.length; i += 3) { + const byte1 = bytes[i] ?? 0 + const byte2 = bytes[i + 1] ?? 0 + const byte3 = bytes[i + 2] ?? 0 + + const char1 = byte1 >> 2 + const char2 = ((byte1 & 0x03) << 4) | (byte2 >> 4) + const char3 = ((byte2 & 0x0F) << 2) | (byte3 >> 6) + const char4 = byte3 & 0x3F + + result += BASE64_CHARS.charAt(char1) + BASE64_CHARS.charAt(char2) + + (i + 1 < bytes.length ? BASE64_CHARS.charAt(char3) : '=') + + (i + 2 < bytes.length ? BASE64_CHARS.charAt(char4) : '=') + } + + return result +} + +export function decodeBase64(base64: string): Uint8Array<ArrayBuffer> { + const chars = BASE64_CHARS + const len = base64.length + + let validLen = len + while (validLen > 0 && base64[validLen - 1] === '=') validLen-- + + const bytesLength = Math.floor(validLen * 3 / 4) + const bytes = new Uint8Array(bytesLength) + let byteIdx = 0 + let buffer = 0 + let bitsCollected = 0 + + for (let i = 0; i < len; i++) { + const c = base64[i] + if (c === '=' || !c) break + const idx = chars.indexOf(c) + if (idx === -1) throw new Error('Invalid Base64 character') + + buffer = (buffer << 6) | idx + bitsCollected += 6 + + if (bitsCollected >= 8) { + bitsCollected -= 8 + bytes[byteIdx++] = (buffer >> bitsCollected) & 0xff + } + } + return bytes +} + +export function encodeBase32(buffer: Uint8Array<ArrayBuffer>) { + let result = '' + let bits = 0 + let value = 0 + + for (const byte of buffer) { + value = (value << 8) | byte + bits += 8 + while (bits >= 5) { + bits -= 5 + result += BASE32_CHARS[(value >>> bits) & 0x1f] + } + } + + if (bits > 0) { + result += BASE32_CHARS[(value << (5 - bits)) & 0x1f] + } + + return result +} + +export function decodeBase32(base32: string): Uint8Array<ArrayBuffer> { + const str = base32.toUpperCase().replace(/=+$/, '') + const bytes = new Uint8Array(Math.floor(str.length * 5 / 8)) + let buf = 0, bits = 0, idx = 0 + for (const c of str) { + const v = BASE32_CHARS.indexOf(c) + if (v === -1) continue + buf = (buf << 5) | v + bits += 5 + if (bits >= 8) { bits -= 8; bytes[idx++] = (buf >> bits) & 0xff } + } + return bytes +} \ No newline at end of file diff --git a/types/qrcode-generator.d.ts b/types/qrcode-generator.d.ts new file mode 100644 index 000000000..ecd36a506 --- /dev/null +++ b/types/qrcode-generator.d.ts @@ -0,0 +1,15 @@ +declare module 'qrcode-generator' { + type ErrorCorrectionLevel = 'L' | 'M' | 'Q' | 'H' + + type QrCode = { + addData(data: string): void + make(): void + getModuleCount(): number + isDark(row: number, col: number): boolean + } + + type QrCodeGenerator = (typeNumber: number, errorCorrectionLevel: ErrorCorrectionLevel) => QrCode + + const qrcode: QrCodeGenerator + export default qrcode +} diff --git a/types/timer/index.d.ts b/types/timer/index.d.ts index 5644aaaae..389d654b4 100644 --- a/types/timer/index.d.ts +++ b/types/timer/index.d.ts @@ -74,5 +74,15 @@ declare namespace timer { msg?: string } } + /** + * Two-factor auth + */ + twoFa?: timer.TwoFactorAuth + } + + type TwoFactorAuth = { + secret: string + iv: string + salt: string } } \ No newline at end of file diff --git a/types/timer/limit.d.ts b/types/timer/limit.d.ts index 82f3becf1..07197553d 100644 --- a/types/timer/limit.d.ts +++ b/types/timer/limit.d.ts @@ -111,6 +111,8 @@ declare namespace timer.limit { | 'verification' // Not allowed to unlock manually | 'strict' + // Unlock with 2FA code + | '2fa' /** * @since 1.9.0 */ diff --git a/types/timer/message.d.ts b/types/timer/message.d.ts index 6e9125f3a..d5a86623c 100644 --- a/types/timer/message.d.ts +++ b/types/timer/message.d.ts @@ -57,6 +57,8 @@ declare namespace timer.mq { // Meta & _MakeRegistry<'meta.installTs', undefined, number> & _MakeRegistry<'meta.usedStorage', undefined, common.StorageUsage> + & _MakeRegistry<'meta.check2fa', string, boolean> + & _MakeRegistry<'meta.prepare2fa', undefined, string> // Site & _MakeRegistry<'site.runEnabled', string, boolean> & _MakeRegistry<'site.list', site.Query | undefined, site.SiteInfo[]> From 50868cebbda5848ff3a42dd29b0abea592d29c35 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Sat, 9 May 2026 15:35:46 +0800 Subject: [PATCH 148/174] chore: upgrade deps --- package.json | 8 ++++---- src/pages/app/Layout/menu/Nav.tsx | 12 ++++-------- src/pages/app/Layout/menu/Side.tsx | 11 ++++++----- src/pages/app/Layout/menu/item.ts | 16 ++++++++++------ src/pages/app/Layout/menu/route.ts | 19 +++++++++---------- 5 files changed, 33 insertions(+), 33 deletions(-) diff --git a/package.json b/package.json index d30c26c9a..d8ae1061c 100644 --- a/package.json +++ b/package.json @@ -28,10 +28,10 @@ }, "license": "MIT", "devDependencies": { - "@commitlint/types": "^20.5.0", + "@commitlint/types": "^21.0.0", "@crowdin/crowdin-api-client": "^1.55.1", "@emotion/babel-plugin": "^11.13.5", - "@rsdoctor/rspack-plugin": "^1.5.9", + "@rsdoctor/rspack-plugin": "^1.5.10", "@rspack/cli": "^2.0.2", "@rspack/core": "^2.0.2", "@rstest/core": "^0.9.10", @@ -42,7 +42,7 @@ "@types/node": "^25.6.2", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.1.1", - "commitlint": "^20.5.3", + "commitlint": "^21.0.0", "css-loader": "^7.1.4", "decompress": "^4.2.1", "fake-indexeddb": "^6.2.5", @@ -64,7 +64,7 @@ "@element-plus/icons-vue": "^2.3.2", "@emotion/css": "^11.13.5", "echarts": "^6.0.0", - "element-plus": "2.13.7", + "element-plus": "2.14.0", "hash.js": "^1.1.7", "qrcode-generator": "^2.0.4", "typescript-guard": "0.2.4", diff --git a/src/pages/app/Layout/menu/Nav.tsx b/src/pages/app/Layout/menu/Nav.tsx index 4da29c298..c143e5e38 100644 --- a/src/pages/app/Layout/menu/Nav.tsx +++ b/src/pages/app/Layout/menu/Nav.tsx @@ -7,7 +7,7 @@ import { getIconUrl } from "@api/chrome/runtime" import { t } from '@app/locale' -import { CloseBold, Link, Menu } from "@element-plus/icons-vue" +import { CloseBold, Menu } from "@element-plus/icons-vue" import { css } from '@emotion/css' import { useSwitch } from '@hooks' import Flex from '@pages/components/Flex' @@ -15,7 +15,7 @@ import Img from '@pages/components/Img' import { ElBreadcrumb, ElBreadcrumbItem, ElIcon, ElMenu, ElMenuItem, useNamespace } from "element-plus" import { defineComponent, h, onBeforeMount, ref, watch } from "vue" import { useRouter } from "vue-router" -import { type MenuItem, navMenus } from "./item" +import { indexOfItem, type MenuItem, navMenus } from "./item" import { handleClick, initTitle } from "./route" import { colorMenu } from './style' @@ -44,7 +44,7 @@ const useStyle = () => { } const findTitle = (routePath: string, menus: MenuItem[]): string => { - const title = menus.find(v => routePath === v.route)?.title + const title = menus.find(v => 'route' in v && routePath === v.route)?.title return title ? t(title) : '' } @@ -89,13 +89,9 @@ const _default = defineComponent<{}>(() => { <div class={menuWrapperCls} v-show={showMenu.value}> <ElMenu> {menus.map(item => ( - <ElMenuItem - index={item.index ?? item.route ?? item.href} - onClick={() => handleItemClick(item)} - > + <ElMenuItem index={indexOfItem(item)} onClick={() => handleItemClick(item)}> <ElIcon>{h(item.icon)}</ElIcon> <span>{t(item.title)}</span> - {!!item.href && <ElIcon size={12}><Link /></ElIcon>} </ElMenuItem> ))} </ElMenu> diff --git a/src/pages/app/Layout/menu/Side.tsx b/src/pages/app/Layout/menu/Side.tsx index 086ee8794..4db8fd09f 100644 --- a/src/pages/app/Layout/menu/Side.tsx +++ b/src/pages/app/Layout/menu/Side.tsx @@ -15,7 +15,7 @@ import { colorVariant } from '@pages/util/style' import { ElCollapseTransition, ElIcon, ElMenu, ElMenuItem, ElMenuItemGroup, ElScrollbar, ElText, ElTooltip, useNamespace } from "element-plus" import { defineComponent, h, nextTick, onMounted, type Ref, ref, type StyleValue, watch } from "vue" import { type Router, useRouter } from "vue-router" -import { menuGroups, type MenuItem } from "./item" +import { indexOfItem, menuGroups, type MenuItem } from "./item" import { handleClick, initTitle } from "./route" import { colorMenu } from './style' @@ -57,7 +57,7 @@ const useStyle = () => { const renderItem = (item: MenuItem, router: Router, curr: Ref<string | undefined>) => ( <ElMenuItem - index={item.index ?? item.route ?? item.href} + index={indexOfItem(item)} onClick={() => handleClick(item, router, curr)} v-slots={{ default: () => <ElIcon size={18}>{h(item.icon)}</ElIcon>, @@ -102,9 +102,10 @@ const _default = defineComponent(() => { > {collapsed.value ? menus.flatMap(g => g.children).map(item => renderItem(item, router, curr)) - // Not export type of ElMenuItemGroup, so use h() directly - : menus.map(({ children, title }) => h(ElMenuItemGroup, { title: t(title) }, - () => children.map(item => renderItem(item, router, curr)) + : menus.map(({ children, title }) => ( + <ElMenuItemGroup title={t(title)}> + {children.map(item => renderItem(item, router, curr))} + </ElMenuItemGroup> ))} </ElMenu> </ElScrollbar> diff --git a/src/pages/app/Layout/menu/item.ts b/src/pages/app/Layout/menu/item.ts index dd228d6eb..5f723ee7e 100644 --- a/src/pages/app/Layout/menu/item.ts +++ b/src/pages/app/Layout/menu/item.ts @@ -18,12 +18,9 @@ import Table from "../icons/Table" import Website from "../icons/Website" import Whitelist from "../icons/Whitelist" -export type MenuItem = { +type MenuBase = { title: I18nKey icon: Component | string - route?: string - href?: string - index?: string /** * Whether to support mobile * @@ -32,10 +29,18 @@ export type MenuItem = { mobile?: boolean } -type MenuGroup = Omit<MenuItem, 'href' | 'route'> & { +export type MenuItem = MenuBase & ( + | { route: string } + | { href: string } +) + +type MenuGroup = MenuBase & { + index: string children: MenuItem[] } +export const indexOfItem = (item: MenuItem) => 'route' in item ? item.route : item.href + /** * Menu items */ @@ -105,7 +110,6 @@ export const menuGroups = (): MenuGroup[] => [{ title: msg => msg.base.guidePage, href: getGuidePageUrl(), icon: Memo, - index: '_guide', mobile: false, }, { title: msg => msg.base.helpUs, diff --git a/src/pages/app/Layout/menu/route.ts b/src/pages/app/Layout/menu/route.ts index 54a96bc75..6d6d10913 100644 --- a/src/pages/app/Layout/menu/route.ts +++ b/src/pages/app/Layout/menu/route.ts @@ -1,5 +1,5 @@ -import { type I18nKey, t } from '@app/locale' import { createTabAfterCurrent } from "@api/chrome/tab" +import { type I18nKey, t } from '@app/locale' import { type Ref } from "vue" import { type Router } from "vue-router" import { type MenuItem, menuGroups } from "./item" @@ -15,11 +15,11 @@ function openMenu(route: string, title: I18nKey, router: Router) { const openHref = (href: string) => createTabAfterCurrent(href) export function handleClick(menuItem: MenuItem, router: Router, currentActive?: Ref<string | undefined>) { - const { route, title, href } = menuItem - if (route) { - openMenu(route, title, router) - } else if (href) { - openHref(href) + const { title } = menuItem + if ('route' in menuItem) { + openMenu(menuItem.route, title, router) + } else { + openHref(menuItem.href) currentActive && (currentActive.value = router.currentRoute?.value?.path) } } @@ -28,10 +28,9 @@ export async function initTitle(router: Router) { await router.isReady() const currentPath = router.currentRoute.value.path for (const group of menuGroups()) { - for (const { route, title } of group.children) { - const docTitle = route === currentPath && t(title) - if (docTitle) { - document.title = docTitle + for (const child of group.children) { + if ('route' in child && child.route === currentPath) { + document.title = t(child.title) return } } From 347e9de6171e434707fd565cc91a0fd9e3860bd4 Mon Sep 17 00:00:00 2001 From: sheepie <returnzhy1996@outlook.com> Date: Sun, 10 May 2026 01:34:25 +0800 Subject: [PATCH 149/174] feat: optimize limit (#719, #759) --- src/pages/app/Layout/menu/Side.tsx | 4 +- .../app/components/Habit/Period/Filter.tsx | 4 +- src/pages/app/components/Limit/common.ts | 24 ----- .../components/Limit/components/List/Card.tsx | 22 +---- .../components/Table/OperationColumn.tsx | 57 ----------- .../Limit/components/Table/index.tsx | 95 +++++++++++++------ src/pages/app/components/Limit/context.ts | 60 ++++++++---- src/pages/app/components/Report/context.ts | 27 +++--- .../app/components/Report/file-export.ts | 5 +- src/pages/app/components/Report/types.d.ts | 2 +- .../SiteManage/SiteManageFilter.tsx | 2 +- .../common/filter/InputFilterItem.tsx | 36 ++++--- .../common/filter/MultiSelectFilterItem.tsx | 22 +---- .../common/filter/SelectFilterItem.tsx | 21 ++-- .../common/filter/SwitchFilterItem.tsx | 39 +++----- .../common/filter/TimeFormatFilterItem.tsx | 9 +- .../app/components/common/filter/common.ts | 23 ++++- src/pages/app/util/limit/index.tsx | 1 - src/pages/hooks/useCached.ts | 35 ++----- src/pages/hooks/useRequest.ts | 2 +- src/pages/icons.tsx | 6 ++ tsconfig.json | 6 +- 22 files changed, 220 insertions(+), 282 deletions(-) delete mode 100644 src/pages/app/components/Limit/components/Table/OperationColumn.tsx diff --git a/src/pages/app/Layout/menu/Side.tsx b/src/pages/app/Layout/menu/Side.tsx index 4db8fd09f..dfd082a99 100644 --- a/src/pages/app/Layout/menu/Side.tsx +++ b/src/pages/app/Layout/menu/Side.tsx @@ -20,12 +20,12 @@ import { handleClick, initTitle } from "./route" import { colorMenu } from './style' const useCollapseState = () => { - const { data: collapsed } = useCached('menu-collapsed', false) + const [collapsed, setCollapsed] = useCached('menu-collapsed', false) const [tooltipVisible, setTooltipVisible] = useState(false) const toggle = () => { setTooltipVisible(false) - nextTick(() => collapsed.value = !collapsed.value) + nextTick(() => setCollapsed(!collapsed.value)) } return { diff --git a/src/pages/app/components/Habit/Period/Filter.tsx b/src/pages/app/components/Habit/Period/Filter.tsx index fcc366155..a5111cb65 100644 --- a/src/pages/app/components/Habit/Period/Filter.tsx +++ b/src/pages/app/components/Habit/Period/Filter.tsx @@ -7,8 +7,8 @@ import SelectFilterItem from '@app/components/common/filter/SelectFilterItem' import { t } from '@app/locale' -import Flex from '@pages/components/Flex' import { type HabitMessage } from '@i18n/message/app/habit' +import Flex from '@pages/components/Flex' import { ElRadioButton, ElRadioGroup } from 'element-plus' import { defineComponent } from 'vue' import { usePeriodFilter } from './context' @@ -46,7 +46,7 @@ const _default = defineComponent(() => { historyName='periodSize' defaultValue={filter.periodSize?.toString?.()} options={allOptions()} - onSelect={val => { + onChange={val => { if (!val) return const newPeriodSize = parseInt(val) if (isNaN(newPeriodSize)) return diff --git a/src/pages/app/components/Limit/common.ts b/src/pages/app/components/Limit/common.ts index 4dc987ebe..230d7ef0a 100644 --- a/src/pages/app/components/Limit/common.ts +++ b/src/pages/app/components/Limit/common.ts @@ -1,27 +1,3 @@ -import { getOption } from "@api/sw/option" -import { judgeVerificationRequired, processVerification } from "@app/util/limit/index" - -const batchJudge = async (items: timer.limit.Item[]): Promise<boolean> => { - if (!items?.length) return false - const { limitDelayDuration } = await getOption() - for (const item of items) { - if (!item) continue - const needVerify = await judgeVerificationRequired(item, limitDelayDuration) - if (needVerify) return true - } - return false -} - -export const verifyCanModify = async (...items: timer.limit.Item[]) => { - const needVerify = await batchJudge(items) - if (!needVerify) return - - // Open delay for limited rules, so verification is required - const option = await getOption() - if (!option) return - await processVerification(option) -} - export function cleanCond(origin: string): string export function cleanCond(origin: undefined): undefined export function cleanCond(origin: string | undefined): string | undefined { diff --git a/src/pages/app/components/Limit/components/List/Card.tsx b/src/pages/app/components/Limit/components/List/Card.tsx index 129e69e92..372f8925c 100644 --- a/src/pages/app/components/Limit/components/List/Card.tsx +++ b/src/pages/app/components/Limit/components/List/Card.tsx @@ -4,13 +4,12 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { verifyCanModify } from "@app/components/Limit/common" -import { useLimitAction, useLimitData } from "@app/components/Limit/context" +import { useLimitAction } from "@app/components/Limit/context" import { t } from '@app/locale' import { Delete, EditPen } from "@element-plus/icons-vue" import { css } from '@emotion/css' import Flex from "@pages/components/Flex" -import { ElButton, ElCard, ElDivider, ElMessageBox, ElTag, type TagProps, useNamespace } from "element-plus" +import { ElButton, ElCard, ElDivider, ElTag, type TagProps, useNamespace } from "element-plus" import { defineComponent, type FunctionalComponent, type StyleValue } from "vue" import Rule from "./Rule" @@ -56,18 +55,7 @@ const EffectiveDays: FunctionalComponent<{ weekdays?: number[] }> = ({ weekdays } const _default = defineComponent<Props>(props => { - const { deleteRow } = useLimitData() - const { modify } = useLimitAction() - - const handleModify = () => verifyCanModify(props.value) - .then(() => modify(props.value)) - .catch(() => {/** Do nothing */ }) - - const handleDelete = () => verifyCanModify(props.value) - .then(() => ElMessageBox.confirm(t(msg => msg.limit.message.deleteConfirm, { name: props.value.name }))) - .then(() => deleteRow(props.value)) - .catch(() => {/** Do nothing */ }) - + const { modify, remove } = useLimitAction() const clz = useStyle() return () => ( @@ -85,7 +73,7 @@ const _default = defineComponent<Props>(props => { type='danger' text icon={Delete} - onClick={handleDelete} + onClick={() => remove(props.value)} /> </Flex> <Divider /> @@ -99,7 +87,7 @@ const _default = defineComponent<Props>(props => { <Divider /> {/* Footer Button */} <Flex justify='end'> - <ElButton text icon={EditPen} onClick={handleModify} size='small'> + <ElButton text icon={EditPen} onClick={() => modify(props.value)} size='small'> {t(msg => msg.button.modify)} </ElButton> </Flex> diff --git a/src/pages/app/components/Limit/components/Table/OperationColumn.tsx b/src/pages/app/components/Limit/components/Table/OperationColumn.tsx deleted file mode 100644 index 912a123e4..000000000 --- a/src/pages/app/components/Limit/components/Table/OperationColumn.tsx +++ /dev/null @@ -1,57 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ -import { verifyCanModify } from "@app/components/Limit/common" -import { useLimitAction, useLimitData } from "@app/components/Limit/context" -import { t } from '@app/locale' -import { Delete, Edit } from "@element-plus/icons-vue" -import { locale } from "@i18n" -import { ElButton, ElTableColumn, type RenderRowData } from "element-plus" -import { defineComponent } from "vue" - -const LOCALE_WIDTH: { [locale in timer.Locale]: number } = { - en: 220, - zh_CN: 200, - ja: 200, - zh_TW: 200, - pt_PT: 250, - uk: 260, - es: 240, - de: 250, - fr: 230, - ru: 240, - ar: 220, - tr: 220, - pl: 220, - it: 220, -} - -const _default = defineComponent<{}>(() => { - const { deleteRow } = useLimitData() - const { modify } = useLimitAction() - - const handleModify = (row: timer.limit.Item) => verifyCanModify(row) - .then(() => modify(row)) - .catch(() => {/** Do nothing */ }) - - return () => <ElTableColumn - prop="operations" - label={t(msg => msg.button.operation)} - width={LOCALE_WIDTH[locale]} - align="center" - fixed="right" - v-slots={({ row }: RenderRowData<timer.limit.Item>) => <> - <ElButton type="danger" size="small" icon={Delete} onClick={() => deleteRow(row)}> - {t(msg => msg.button.delete)} - </ElButton> - <ElButton type="primary" size="small" icon={Edit} onClick={() => handleModify(row)}> - {t(msg => msg.button.modify)} - </ElButton> - </>} - /> -}) - -export default _default diff --git a/src/pages/app/components/Limit/components/Table/index.tsx b/src/pages/app/components/Limit/components/Table/index.tsx index 489de2dc3..e2d3ff088 100644 --- a/src/pages/app/components/Limit/components/Table/index.tsx +++ b/src/pages/app/components/Limit/components/Table/index.tsx @@ -4,28 +4,50 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { getWeekStartDay } from "@api/sw/option" +import { getOption, getWeekStartDay } from "@api/sw/option" import ColumnHeader from "@app/components/common/ColumnHeader" -import { useDelayDuration, useLimitData } from "@app/components/Limit/context" +import { useDelayDuration, useLimitAction, useLimitData } from "@app/components/Limit/context" import type { LimitInstance } from '@app/components/Limit/types' import { t } from '@app/locale' -import { useLocalStorage, useRequest, useState } from '@hooks' +import { Delete, Edit } from '@element-plus/icons-vue' +import { useCached, useRequest } from '@hooks' +import { locale } from '@i18n' import { isEffective } from "@util/limit" import { MILL_PER_SECOND } from "@util/time" -import { ElSwitch, ElTable, ElTableColumn, ElTag, type RenderRowData, type Sort, type TableInstance } from "element-plus" -import { defineComponent, ref, watch } from "vue" -import LimitOperationColumn from "./OperationColumn" +import { + ElButton, ElSwitch, ElTable, ElTableColumn, ElTag, type RenderRowData, type Sort, type TableInstance, +} from "element-plus" +import { defineComponent, ref } from "vue" import Rule from "./Rule" import Waste from "./Waste" import Weekday from "./Weekday" +const ACTION_WIDTH: { [locale in timer.Locale]: number } = { + en: 220, + zh_CN: 200, + ja: 200, + zh_TW: 200, + pt_PT: 250, + uk: 260, + es: 240, + de: 250, + fr: 230, + ru: 240, + ar: 220, + tr: 220, + pl: 220, + it: 220, +} + const DEFAULT_SORT_COL = 'waste' -const sortMethodByNumVal = (key: keyof timer.limit.Item & 'waste' | 'weeklyWaste'): (a: timer.limit.Item, b: timer.limit.Item) => number => { - return ({ [key]: a }: timer.limit.Item, { [key]: b }: timer.limit.Item) => (a ?? 0) - (b ?? 0) +function createSorter(key: 'waste' | 'weeklyWaste') { + return (a: timer.limit.Item, b: timer.limit.Item) => a[key] - b[key] } -const sortByEffectiveDays = ({ weekdays: a }: timer.limit.Item, { weekdays: b }: timer.limit.Item) => (a?.length ?? 0) - (b?.length ?? 0) +function sortByEffectiveDays(a: timer.limit.Item, b: timer.limit.Item) { + return (a.weekdays?.length ?? 0) - (b.weekdays?.length ?? 0) +} const _default = defineComponent((_, ctx) => { const { data: weekStartName } = useRequest(async () => { @@ -35,17 +57,17 @@ const _default = defineComponent((_, ctx) => { }) const { list, changeEnabled, changeDelay, changeLocked } = useLimitData() + const { modify, remove } = useLimitAction() const delayDuration = useDelayDuration() - const [cachedSort, setCachedSort] = useLocalStorage<Sort>( - '__limit_sort_default__', { prop: DEFAULT_SORT_COL, order: 'descending' } - ) - - const [sort, setSort] = useState(cachedSort) - watch(sort, () => setCachedSort(sort.value)) - + const [sort, setSort] = useCached<Sort>('__limit_sort_default__', { prop: DEFAULT_SORT_COL, order: 'descending' }) const table = ref<TableInstance>() + const { data: lockVisible } = useRequest(async () => { + const option = await getOption() + return option.limitLevel !== 'nothing' + }, { defaultValue: false }) + ctx.expose({ getSelected: () => table.value?.getSelectionRows?.() ?? [] } satisfies LimitInstance) @@ -97,7 +119,7 @@ const _default = defineComponent((_, ctx) => { <ElTableColumn prop={DEFAULT_SORT_COL} sortable - sortMethod={sortMethodByNumVal('waste')} + sortMethod={createSorter('waste')} label={t(msg => msg.calendar.range.today)} minWidth={90} align="center" @@ -120,7 +142,7 @@ const _default = defineComponent((_, ctx) => { minWidth={110} align="center" sortable - sortMethod={sortMethodByNumVal('weeklyWaste')} + sortMethod={createSorter('weeklyWaste')} v-slots={{ header: () => ( <ColumnHeader @@ -163,18 +185,33 @@ const _default = defineComponent((_, ctx) => { <ElSwitch size="small" modelValue={row.allowDelay} onChange={v => changeDelay(row, !!v)} /> )} </ElTableColumn> - <ElTableColumn - label={t(msg => msg.limit.item.locked)} - minWidth={80} - align="center" - fixed="right" - > - {({ row }: RenderRowData<timer.limit.Item>) => ( - <ElSwitch size="small" modelValue={row.locked} onChange={v => changeLocked(row, !!v)} /> - )} - </ElTableColumn> + {lockVisible.value && ( + <ElTableColumn + label={t(msg => msg.limit.item.locked)} + minWidth={80} + align="center" + fixed="right" + > + {({ row }: RenderRowData<timer.limit.Item>) => ( + <ElSwitch size="small" modelValue={row.locked} onChange={v => changeLocked(row, !!v)} /> + )} + </ElTableColumn> + )} </ElTableColumn> - <LimitOperationColumn /> + <ElTableColumn + label={t(msg => msg.button.operation)} + width={ACTION_WIDTH[locale]} + align="center" + fixed="right" + v-slots={({ row }: RenderRowData<timer.limit.Item>) => <> + <ElButton type="danger" size="small" icon={Delete} onClick={() => remove(row)}> + {t(msg => msg.button.delete)} + </ElButton> + <ElButton type="primary" size="small" icon={Edit} onClick={() => modify(row)}> + {t(msg => msg.button.modify)} + </ElButton> + </>} + /> </ElTable> ) }) diff --git a/src/pages/app/components/Limit/context.ts b/src/pages/app/components/Limit/context.ts index 967d6b8f6..c2a417cf7 100644 --- a/src/pages/app/components/Limit/context.ts +++ b/src/pages/app/components/Limit/context.ts @@ -3,26 +3,26 @@ import { DEFAULT_LIMIT } from '@/util/constant/option' import { deleteLimits, listLimits, updateLimits } from "@api/sw/limit" import { t } from '@app/locale' import type { LimitQuery } from '@app/router/constants' +import { judgeVerificationRequired, processVerification } from '@app/util/limit' import { useDocumentVisibility, useManualRequest, useProvide, useProvider, useRequest } from '@hooks' import { tryParseInteger } from '@util/number' import { ElMessage, ElMessageBox } from "element-plus" import { computed, onMounted, reactive, ref, toRaw, watch, type ShallowRef } from "vue" import { useRoute, useRouter } from 'vue-router' -import { verifyCanModify } from "./common" import type { LimitFilterOption, LimitInstance, ModifyInstance, TestInstance } from "./types" type Context = { filter: LimitFilterOption list: ShallowRef<timer.limit.Item[]> refresh: NoArgCallback - deleteRow: ArgCallback<timer.limit.Item> batchDelete: NoArgCallback batchEnable: NoArgCallback batchDisable: NoArgCallback changeEnabled: (item: timer.limit.Item, val: boolean) => Promise<void> changeDelay: (item: timer.limit.Item, val: boolean) => Promise<void> changeLocked: (item: timer.limit.Item, val: boolean) => Promise<void> - modify: (item: timer.limit.Item) => void + modify: ArgCallback<timer.limit.Item> + remove: ArgCallback<timer.limit.Item> create: () => void test: () => void empty: ShallowRef<boolean> @@ -42,6 +42,29 @@ const initialQuery = () => { } } +const batchJudge = async (items: timer.limit.Item[]): Promise<boolean> => { + if (!items?.length) return false + const { limitDelayDuration, limitLevel } = await getOption() + for (const item of items) { + if (!item) continue + let needVerify = await judgeVerificationRequired(item, limitDelayDuration) + // If locked and the level is not strict, verification is also required to modify the rule + if (limitLevel !== 'strict') needVerify ||= item.locked + if (needVerify) return true + } + return false +} + +const verifyCanModify = async (...items: timer.limit.Item[]) => { + const needVerify = await batchJudge(items) + if (!needVerify) return + + // Open delay for limited rules, so verification is required + const option = await getOption() + if (!option) return + await processVerification(option) +} + export const useLimitProvider = () => { const { url, action, id } = initialQuery() const initialUrl = action === 'create' ? undefined : url @@ -74,10 +97,10 @@ export const useLimitProvider = () => { const docVisible = useDocumentVisibility() watch(docVisible, () => docVisible.value && refresh()) - const { refresh: deleteRow } = useManualRequest(async (row: timer.limit.Item) => { + const { refresh: remove } = useManualRequest(async (row: timer.limit.Item) => { await verifyCanModify(row) const message = t(msg => msg.limit.message.deleteConfirm, { name: row.name }) - await ElMessageBox.confirm(message, { type: "warning" }) + await ElMessageBox.confirm(message) await deleteLimits([row.id]) }, { onSuccess() { @@ -125,21 +148,20 @@ export const useLimitProvider = () => { .catch(() => { }) const changeEnabled = async (row: timer.limit.Item, newVal: boolean) => { - const enabled = !!newVal try { - (row.locked || !enabled) && await verifyCanModify(row) - row.enabled = enabled + // Only verify when disabling, ignore lock state + !newVal && await verifyCanModify(row) + row.enabled = newVal await updateLimits([toRaw(row)]) } catch (e) { - console.warn(e) + console.info(e) } } const changeDelay = async (row: timer.limit.Item, newVal: boolean) => { - const allowDelay = !!newVal try { - (row.locked || allowDelay) && await verifyCanModify(row) - row.allowDelay = allowDelay + (row.locked || newVal) && await verifyCanModify(row) + row.allowDelay = newVal await updateLimits([toRaw(row)]) } catch (e) { console.warn(e) @@ -164,14 +186,16 @@ export const useLimitProvider = () => { const modifyInst = ref<ModifyInstance>() const testInst = ref<TestInstance>() - const modify = (row: timer.limit.Item) => modifyInst.value?.modify?.(toRaw(row)) + const modify = (row: timer.limit.Item) => verifyCanModify(row) + .then(() => modifyInst.value?.modify?.(toRaw(row))) + .catch(() => {/** Do nothing */ }) const create = () => modifyInst.value?.create?.() const test = () => testInst.value?.show?.() const empty = computed(() => !loading.value && !(list.value?.length)) useProvide<Context>(NAMESPACE, { filter, list, empty, refresh, delayDuration, - deleteRow, modify, create, test, changeEnabled, changeDelay, changeLocked, + remove, modify, create, test, changeEnabled, changeDelay, changeLocked, batchDelete: () => selectedAndThen(handleBatchDelete), batchEnable: () => selectedAndThen(handleBatchEnable), batchDisable: () => selectedAndThen(handleBatchDisable), @@ -182,14 +206,14 @@ export const useLimitProvider = () => { export const useLimitFilter = (): LimitFilterOption => useProvider<Context, 'filter'>(NAMESPACE, "filter").filter -export const useLimitData = () => useProvider<Context, 'list' | 'refresh' | 'deleteRow' | 'changeEnabled' | 'changeDelay' | 'changeLocked'>( - NAMESPACE, 'list', 'refresh', 'deleteRow', 'changeEnabled', 'changeDelay', 'changeLocked' +export const useLimitData = () => useProvider<Context, 'list' | 'refresh' | 'changeEnabled' | 'changeDelay' | 'changeLocked'>( + NAMESPACE, 'list', 'refresh', 'changeEnabled', 'changeDelay', 'changeLocked' ) export const useLimitBatch = () => useProvider<Context, 'batchDelete' | 'batchEnable' | 'batchDisable'>( - NAMESPACE, 'batchDelete', 'batchDisable', 'batchEnable' + NAMESPACE, 'batchDelete', 'batchEnable', 'batchDisable' ) -export const useLimitAction = () => useProvider<Context, 'test' | 'modify' | 'create' | 'empty'>(NAMESPACE, 'modify', 'test', 'create', 'empty') +export const useLimitAction = () => useProvider<Context, 'test' | 'remove' | 'modify' | 'create' | 'empty'>(NAMESPACE, 'remove', 'modify', 'test', 'create', 'empty') export const useDelayDuration = () => useProvider<Context, 'delayDuration'>(NAMESPACE, 'delayDuration').delayDuration \ No newline at end of file diff --git a/src/pages/app/components/Report/context.ts b/src/pages/app/components/Report/context.ts index aee7c5975..e33e0ffb6 100644 --- a/src/pages/app/components/Report/context.ts +++ b/src/pages/app/components/Report/context.ts @@ -1,5 +1,5 @@ -import { type ReportQuery } from '@app/router/constants' import { useLocalStorage, useProvide, useProvider } from '@hooks' +import { createStringUnionGuard, isString } from 'typescript-guard' import { reactive, ref, type ShallowRef, toRaw, watch } from "vue" import { type RouteLocation, type Router, useRoute, useRouter } from "vue-router" import type { DisplayComponent, ReportFilterOption, ReportSort } from "./types" @@ -14,25 +14,30 @@ const NAMESPACE = 'report' type QueryPartial = PartialPick<ReportFilterOption, 'query' | 'dateRange' | 'mergeDate' | 'siteMerge'> +const isSortProp = createStringUnionGuard<ReportSort['prop']>('date', 'host', 'focus', 'run', 'time') +const isSiteMerge = createStringUnionGuard<Exclude<ReportFilterOption['siteMerge'], undefined>>( + 'cate', 'domain', 'group', +) + /** * Init the query parameters */ function parseQuery(route: RouteLocation, router: Router): [QueryPartial, ReportSort['prop'] | undefined] { - const routeQuery = route.query as ReportQuery + const routeQuery = route.query const { q, mm, md, ds, de, sc } = routeQuery - const dateStart = ds ? new Date(Number.parseInt(ds)) : undefined - const dateEnd = de ? new Date(Number.parseInt(de)) : undefined + const dateStart = isString(ds) ? new Date(Number.parseInt(ds)) : undefined + const dateEnd = isString(de) ? new Date(Number.parseInt(de)) : undefined // Remove queries router.replace({ query: {} }) const now = new Date() const partial: QueryPartial = { - ...(q && { query: q }), + ...(isString(q) && { query: q }), ...((md === 'true' || md === '1') && { mergeDate: true }), ...((dateStart ?? dateEnd) && { dateRange: [dateStart ?? now, dateEnd ?? now] }), - ...(mm && { siteMerge: mm }) + ...(isSiteMerge(mm) && { siteMerge: mm }) } - return [partial, sc ? sc satisfies ReportSort['prop'] : undefined] + return [partial, isSortProp(sc) ? sc : undefined] } type FilterStorageValue = Omit<ReportFilterOption, 'dateRange' | 'readRemote'> & { @@ -41,7 +46,7 @@ type FilterStorageValue = Omit<ReportFilterOption, 'dateRange' | 'readRemote'> & } const cvtStorage2Filter = (storage: FilterStorageValue | undefined): ReportFilterOption => { - const { query, dateStart, dateEnd, mergeDate, siteMerge, cateIds, timeFormat } = storage || {} + const { query, dateStart, dateEnd, mergeDate, siteMerge, cateIds, timeFormat } = storage ?? {} const now = new Date() return { query, @@ -72,14 +77,12 @@ export const initReportContext = () => { const [queryFilter, querySort] = parseQuery(route, router) const [cachedFilter, setCachedFilter] = useLocalStorage<FilterStorageValue>('report_filter') - - const initialFilter: ReportFilterOption = { ...cvtStorage2Filter(cachedFilter), ...queryFilter } - const filter = reactive(initialFilter) + const filter = reactive<ReportFilterOption>({ ...cvtStorage2Filter(cachedFilter), ...queryFilter }) watch(() => filter, v => setCachedFilter(cvtFilter2Storage(toRaw(v))), { deep: true }) const sort = ref<ReportSort>({ order: 'descending', - prop: querySort || 'focus' + prop: querySort ?? 'focus' }) const comp = ref<DisplayComponent>() diff --git a/src/pages/app/components/Report/file-export.ts b/src/pages/app/components/Report/file-export.ts index f4885d798..69bfabb32 100644 --- a/src/pages/app/components/Report/file-export.ts +++ b/src/pages/app/components/Report/file-export.ts @@ -7,10 +7,7 @@ import { type I18nKey, t } from '@app/locale' import { periodFormatter } from '@app/util/time' -import { - exportCsv as exportCsv_, - exportJson as exportJson_, -} from "@util/file" +import { exportCsv as exportCsv_, exportJson as exportJson_ } from "@util/file" import { CATE_NOT_SET_ID } from "@util/site" import { getAlias, getGroupName, getHost, getRelatedCateId, isGroup } from "@util/stat" import { formatTimeYMD } from "@util/time" diff --git a/src/pages/app/components/Report/types.d.ts b/src/pages/app/components/Report/types.d.ts index f2589999b..1f3ca928d 100644 --- a/src/pages/app/components/Report/types.d.ts +++ b/src/pages/app/components/Report/types.d.ts @@ -9,7 +9,7 @@ export type ReportFilterOption = { query: string | undefined dateRange: DateRange mergeDate: boolean - siteMerge?: timer.stat.MergeMethod & ('cate' | 'domain' | 'group') + siteMerge?: Exclude<timer.stat.MergeMethod, 'date'> cateIds?: number[] /** * @since 1.1.7 diff --git a/src/pages/app/components/SiteManage/SiteManageFilter.tsx b/src/pages/app/components/SiteManage/SiteManageFilter.tsx index 404e6cc08..b6c14a9dc 100644 --- a/src/pages/app/components/SiteManage/SiteManageFilter.tsx +++ b/src/pages/app/components/SiteManage/SiteManageFilter.tsx @@ -69,7 +69,7 @@ const _default = defineComponent<{ <MultiSelectFilterItem placeholder={t(msg => msg.siteManage.column.type)} options={ALL_TYPES.map(type => ({ value: type, label: t(msg => msg.siteManage.type[type].name) }))} - defaultValue={filter.types} + defaultValue={filter.types ?? []} onChange={val => filter.types = val as timer.site.Type[]} /> <CategoryFilter diff --git a/src/pages/app/components/common/filter/InputFilterItem.tsx b/src/pages/app/components/common/filter/InputFilterItem.tsx index 14d2003af..6ef8cb36e 100644 --- a/src/pages/app/components/common/filter/InputFilterItem.tsx +++ b/src/pages/app/components/common/filter/InputFilterItem.tsx @@ -7,30 +7,19 @@ import { Search } from "@element-plus/icons-vue" import { useState } from "@hooks" +import { Enter } from '@pages/icons' import { ElIcon, ElInput } from "element-plus" -import { computed, defineComponent, ref, toRef, type StyleValue } from "vue" - -const EnterIcon = defineComponent<{ focused?: boolean }>(props => { - return () => ( - <ElIcon color={props.focused ? 'var(--el-color-primary)' : undefined}> - <svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="5645" width="200" height="200"> - <path d="M417 1c-48.602 0-88 39.399-88 88v346H89c-48.6 0-88 39.399-88 88v412c0 48.6 39.4 88 88 88h314.69c4.341 0.658 8.786 1 13.31 1h517c24.555 0 46.761-10.057 62.724-26.277C1012.943 981.761 1023 959.555 1023 935V523c0-4.524-0.341-8.968-1-13.31V89c0-48.601-39.398-88-88-88H417z m250.036 645.739V389.131c0-27.134 21.977-49.131 49.087-49.131 27.11 0 49.088 21.997 49.088 49.131V745H453.699l31.657 31.657c19.174 19.173 19.167 50.263-0.012 69.441-19.178 19.179-50.268 19.185-69.441 0.013L266 696.207l149.956-149.955c19.179-19.179 50.269-19.185 69.441-0.013 19.172 19.173 19.167 50.263-0.012 69.441l-31.059 31.059h212.71z" /> - </svg> - </ElIcon> - ) -}, { props: ['focused'] }) +import { computed, defineComponent, ref, type StyleValue } from "vue" type Props = { defaultValue?: string placeholder?: string - enter?: boolean width?: number | string onSearch?: ArgCallback<string> } const InputFilterItem = defineComponent<Props>(props => { const modelValue = ref(props.defaultValue ?? '') - const enter = toRef(props, 'enter', true) const width = computed(() => { const w = props.width @@ -43,21 +32,30 @@ const InputFilterItem = defineComponent<Props>(props => { setFocused(false) props.onSearch?.(modelValue.value) } + + const handleKeydown = (ev: Event | KeyboardEvent) => { + if (ev instanceof KeyboardEvent && ev.key === 'Enter') { + props.onSearch?.(modelValue.value) + } + } + return () => ( <ElInput modelValue={modelValue.value} placeholder={props.placeholder} - clearable={!enter.value} - onClear={() => props.onSearch?.(modelValue.value = '')} onInput={val => modelValue.value = val.trim()} - onKeydown={ev => (ev as KeyboardEvent).key === 'Enter' && enter.value && props.onSearch?.(modelValue.value)} - onBlur={() => handleBlur()} + onKeydown={handleKeydown} + onBlur={handleBlur} onFocus={() => setFocused(true)} style={{ width: width.value } satisfies StyleValue} - suffixIcon={enter.value ? <EnterIcon focused={focused.value} /> : undefined} + suffixIcon={( + <ElIcon color={focused.value ? 'var(--el-color-primary)' : undefined}> + <Enter /> + </ElIcon> + )} prefixIcon={Search} /> ) -}, { props: ['defaultValue', 'enter', 'placeholder', 'width', 'onSearch'] }) +}, { props: ['defaultValue', 'placeholder', 'width', 'onSearch'] }) export default InputFilterItem \ No newline at end of file diff --git a/src/pages/app/components/common/filter/MultiSelectFilterItem.tsx b/src/pages/app/components/common/filter/MultiSelectFilterItem.tsx index f943eaa65..57028d6ee 100644 --- a/src/pages/app/components/common/filter/MultiSelectFilterItem.tsx +++ b/src/pages/app/components/common/filter/MultiSelectFilterItem.tsx @@ -1,27 +1,16 @@ -import { useCached } from "@hooks" import { ElSelect } from "element-plus" -import { defineComponent, watch } from "vue" -import { useRoute } from "vue-router" -import { SELECT_WRAPPER_STYLE } from "./common" +import { defineComponent } from "vue" +import { ALL_BASE_FILTER_PROPS, type BaseFilterProps, SELECT_WRAPPER_STYLE, useFilterState } from "./common" type Data = string | number -type Props = { - defaultValue?: Data[] - /** - * Whether to save the value in the localStorage with {@param historyName} - */ - historyName?: string +type Props = BaseFilterProps<Data[]> & { placeholder?: string - disabled?: boolean options?: { value: Data, label?: string }[] - onChange?: (val: Data[]) => void } const MultiSelectFilterItem = defineComponent<Props>(props => { - const cacheKey = props.historyName && `__filter_item_multi_select_${useRoute().path}_${props.historyName}` - const { data, setter } = useCached<Data[]>(cacheKey, props.defaultValue) - watch(data, val => props.onChange?.(val ?? [])) + const [data, setter] = useFilterState('multi_select', props) return () => ( <ElSelect @@ -30,13 +19,12 @@ const MultiSelectFilterItem = defineComponent<Props>(props => { multiple clearable collapseTags - disabled={props.disabled} onClear={() => setter([])} placeholder={props.placeholder} style={SELECT_WRAPPER_STYLE} options={props.options} /> ) -}, { props: ['defaultValue', 'historyName', 'placeholder', 'disabled', 'options'] }) +}, { props: [...ALL_BASE_FILTER_PROPS, 'placeholder', 'options'] }) export default MultiSelectFilterItem \ No newline at end of file diff --git a/src/pages/app/components/common/filter/SelectFilterItem.tsx b/src/pages/app/components/common/filter/SelectFilterItem.tsx index aae136bf1..a1d9c1669 100644 --- a/src/pages/app/components/common/filter/SelectFilterItem.tsx +++ b/src/pages/app/components/common/filter/SelectFilterItem.tsx @@ -5,26 +5,17 @@ * https://opensource.org/licenses/MIT */ -import { useCached } from "@hooks" import { ElSelect, SelectProps } from "element-plus" -import { defineComponent, watch } from "vue" -import { useRoute } from "vue-router" -import { SELECT_WRAPPER_STYLE } from "./common" +import { defineComponent } from "vue" +import { ALL_BASE_FILTER_PROPS, type BaseFilterProps, SELECT_WRAPPER_STYLE, useFilterState } from "./common" -type Props = Pick<SelectProps, "placeholder"> & { - defaultValue?: string - /** - * Whether to save the value in the localStorage with {@param historyName} - */ - historyName?: string +type Props = BaseFilterProps<string | undefined> & Pick<SelectProps, "placeholder"> & { options: Record<string, string> - onSelect?: (val: string | undefined) => void } const SelectFilterItem = defineComponent<Props>(props => { - const cacheKey = props.historyName && `__filter_item_select_${useRoute().path}_${props.historyName}` - const { data, setter } = useCached(cacheKey, props.defaultValue) - watch(data, val => props.onSelect?.(val)) + const [data, setter] = useFilterState('select', props) + return () => ( <ElSelect placeholder={props.placeholder} @@ -34,6 +25,6 @@ const SelectFilterItem = defineComponent<Props>(props => { options={Object.entries(props.options).map(([value, label]) => ({ label, value }))} /> ) -}, { props: ['defaultValue', 'historyName', 'options', 'onSelect', 'placeholder'] }) +}, { props: [...ALL_BASE_FILTER_PROPS, 'options', 'placeholder'] }) export default SelectFilterItem \ No newline at end of file diff --git a/src/pages/app/components/common/filter/SwitchFilterItem.tsx b/src/pages/app/components/common/filter/SwitchFilterItem.tsx index b59a7a9c6..f1b46e7bc 100644 --- a/src/pages/app/components/common/filter/SwitchFilterItem.tsx +++ b/src/pages/app/components/common/filter/SwitchFilterItem.tsx @@ -5,33 +5,24 @@ * https://opensource.org/licenses/MIT */ -import { useCached } from "@hooks" import Flex from "@pages/components/Flex" import { ElSwitch, ElText } from "element-plus" -import { defineComponent, watch } from "vue" -import { useRoute } from "vue-router" +import { defineComponent } from "vue" +import { ALL_BASE_FILTER_PROPS, type BaseFilterProps, useFilterState } from './common' -const _default = defineComponent({ - emits: { - change: (_val: boolean) => true - }, - props: { - label: String, - defaultValue: Boolean, - historyName: String, - }, - setup(props, ctx) { - const cacheKey = props.historyName ? `__filter_item_switch_${useRoute().path}_${props.historyName}` : undefined - const { data, setter } = useCached(cacheKey, props.defaultValue) - watch(data, () => ctx.emit("change", !!data.value)) +type Props = BaseFilterProps<boolean> & { + label: string +} - return () => ( - <Flex gap={5} align="center"> - <ElText tag="b" type="info">{props.label}</ElText> - <ElSwitch modelValue={data.value} onChange={val => setter(val as boolean)} /> - </Flex> - ) - } -}) +const _default = defineComponent<Props>(props => { + const [data, setter] = useFilterState('switch', props) + + return () => ( + <Flex gap={5} align="center"> + <ElText tag="b" type="info">{props.label}</ElText> + <ElSwitch modelValue={data.value} onChange={val => setter(val as boolean)} /> + </Flex> + ) +}, { props: [...ALL_BASE_FILTER_PROPS, 'label'] }) export default _default \ No newline at end of file diff --git a/src/pages/app/components/common/filter/TimeFormatFilterItem.tsx b/src/pages/app/components/common/filter/TimeFormatFilterItem.tsx index c690e142d..b50fc7759 100644 --- a/src/pages/app/components/common/filter/TimeFormatFilterItem.tsx +++ b/src/pages/app/components/common/filter/TimeFormatFilterItem.tsx @@ -16,18 +16,13 @@ const TIME_FORMAT_LABELS: { [key in timer.app.TimeFormat]: string } = { hour: t(msg => msg.timeFormat.hour) } -type Props = { - modelValue: timer.app.TimeFormat - onChange: (val: timer.app.TimeFormat) => void -} - -const _default = defineComponent<Props>(props => { +const _default = defineComponent<ModelValue<timer.app.TimeFormat>>(props => { return () => ( <SelectFilterItem historyName="timeFormat" defaultValue={props.modelValue} options={TIME_FORMAT_LABELS} - onSelect={val => props.onChange(val as timer.app.TimeFormat)} + onChange={val => val && props.onChange?.(val as timer.app.TimeFormat)} /> ) }, { props: ['modelValue', 'onChange'] }) diff --git a/src/pages/app/components/common/filter/common.ts b/src/pages/app/components/common/filter/common.ts index 492779935..c8bcbf430 100644 --- a/src/pages/app/components/common/filter/common.ts +++ b/src/pages/app/components/common/filter/common.ts @@ -1,5 +1,24 @@ -import type { StyleValue } from "vue" +import { useCached, useState } from '@hooks' +import { watch, type StyleValue } from "vue" +import { useRoute } from 'vue-router' export const SELECT_WRAPPER_STYLE: StyleValue = { width: '200px', -} \ No newline at end of file +} + +export type BaseFilterProps<T> = { + defaultValue: T + historyName?: string + onChange?: ArgCallback<T> +} + +export const useFilterState = <T>(item: string, props: BaseFilterProps<T>) => { + const [data, setter] = props.historyName + ? useCached(`__filter_item_${item}_${useRoute().path}_${props.historyName}`, props.defaultValue) + : useState(props.defaultValue) + props.onChange && watch(data, props.onChange, { immediate: true }) + + return [data, setter] as const +} + +export const ALL_BASE_FILTER_PROPS: readonly (keyof BaseFilterProps<unknown>)[] = ['defaultValue', 'historyName', 'onChange'] \ No newline at end of file diff --git a/src/pages/app/util/limit/index.tsx b/src/pages/app/util/limit/index.tsx index c2d3f2fe6..573c68792 100644 --- a/src/pages/app/util/limit/index.tsx +++ b/src/pages/app/util/limit/index.tsx @@ -20,7 +20,6 @@ const tN = (key: I18nKey<LimitMessage>, param?: any) => tN_<LimitMessage, VNode> * @returns T/F */ export async function judgeVerificationRequired(item: timer.limit.Item, delayDuration: number): Promise<boolean> { - if (item.locked) return true if (!item.enabled || !isEffective(item.weekdays)) return false const { visitTime, periods } = item diff --git a/src/pages/hooks/useCached.ts b/src/pages/hooks/useCached.ts index 7e8b986bd..6c40d2fc7 100644 --- a/src/pages/hooks/useCached.ts +++ b/src/pages/hooks/useCached.ts @@ -5,8 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { onBeforeMount, ref, type Ref, watch } from "vue" -import { useState } from "./useState" +import { ref, type Ref, watch } from "vue" const getInitialValue = <T>(key: string, defaultValue?: T): T | undefined => { if (!key) return defaultValue @@ -28,29 +27,13 @@ const saveCache = <T>(key: string, val: T) => { } } -export function useCached<T>(key: string, defaultValue: T, defaultFirst?: boolean): { data: Ref<T>, setter: (val: T) => void } -export function useCached<T>( - key: string | undefined, - defaultValue?: T, - defaultFirst?: boolean, -): { data: Ref<T | undefined>, setter: (val: T | undefined) => void } - -export function useCached<T>( - key: string | undefined, - defaultValue?: T, - defaultFirst?: boolean, -) { - if (!key) { - const [data, setter] = useState(defaultValue) - return { data, setter } - } - const data: Ref<T | undefined> = ref<T>() +export function useCached<T>(key: string, defaultValue: T): [data: Ref<T>, setter: ArgCallback<T>] +export function useCached<T>(key: string, defaultValue?: T): [data: Ref<T | undefined>, setter: ArgCallback<T | undefined>] +export function useCached<T>(key: string, defaultValue?: T) { + let cachedValue = getInitialValue(key, defaultValue) + let initial = cachedValue ?? defaultValue + const data = initial === undefined ? ref<T>() : ref<T>(initial) const setter = (val: T | undefined) => data.value = val - onBeforeMount(() => { - let cachedValue = getInitialValue(key, defaultValue) - let initial = defaultFirst ? defaultValue || cachedValue : cachedValue - setter(initial) - }) - watch(data, () => saveCache(key, data.value)) - return { data, setter } + watch(data, () => saveCache(key, data.value), { immediate: true }) + return [data, setter] } diff --git a/src/pages/hooks/useRequest.ts b/src/pages/hooks/useRequest.ts index 9badac709..de8294839 100644 --- a/src/pages/hooks/useRequest.ts +++ b/src/pages/hooks/useRequest.ts @@ -75,7 +75,7 @@ export function useRequest<P extends any[], T>( ts.value = Date.now() onSuccess?.(value, ...p) } catch (e) { - console.warn("Errored when requesting", e) + console.log("Errored when requesting", e) onError?.(e) } finally { loading.value = false diff --git a/src/pages/icons.tsx b/src/pages/icons.tsx index dc4bf046b..b92c719ec 100644 --- a/src/pages/icons.tsx +++ b/src/pages/icons.tsx @@ -50,3 +50,9 @@ export const HalfPieChart: Icon = () => ( <path d="M1365.055916 682.737408A682.525185 682.525185 0 1 0 91.549237 1024h1181.962989A679.005914 679.005914 0 0 0 1365.055916 682.737408z" /> </svg> ) + +export const Enter: Icon = () => ( + <svg viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"> + <path d="M417 1c-48.602 0-88 39.399-88 88v346H89c-48.6 0-88 39.399-88 88v412c0 48.6 39.4 88 88 88h314.69c4.341 0.658 8.786 1 13.31 1h517c24.555 0 46.761-10.057 62.724-26.277C1012.943 981.761 1023 959.555 1023 935V523c0-4.524-0.341-8.968-1-13.31V89c0-48.601-39.398-88-88-88H417z m250.036 645.739V389.131c0-27.134 21.977-49.131 49.087-49.131 27.11 0 49.088 21.997 49.088 49.131V745H453.699l31.657 31.657c19.174 19.173 19.167 50.263-0.012 69.441-19.178 19.179-50.268 19.185-69.441 0.013L266 696.207l149.956-149.955c19.179-19.179 50.269-19.185 69.441-0.013 19.172 19.173 19.167 50.263-0.012 69.441l-31.059 31.059h212.71z" /> + </svg> +) \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index b829590bd..f2323fc2d 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -57,12 +57,12 @@ "@util/*": [ "./src/util/*" ], - "@i18n/*": [ - "./src/i18n/*" - ], "@i18n": [ "./src/i18n/index" ], + "@i18n/*": [ + "./src/i18n/*" + ], "@/*": [ "./src/*" ] From b3143100debf377ac6d1879fe84d60eae0d3e012 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sun, 10 May 2026 13:35:48 +0800 Subject: [PATCH 150/174] i18n(download): download translations by bot (#762) Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com> --- src/i18n/message/app/about-resource.json | 12 ++++++------ src/i18n/message/app/habit-resource.json | 10 +++++----- src/i18n/message/app/limit-resource.json | 2 ++ src/i18n/message/app/option-resource.json | 11 ++++++++--- src/i18n/message/common/button-resource.json | 3 ++- src/i18n/message/common/shared-resource.json | 3 +-- src/i18n/message/popup/content-resource.json | 4 ++-- src/i18n/message/side/list-resource.json | 2 +- 8 files changed, 27 insertions(+), 20 deletions(-) diff --git a/src/i18n/message/app/about-resource.json b/src/i18n/message/app/about-resource.json index 9d2e58c7f..dfdbe444f 100644 --- a/src/i18n/message/app/about-resource.json +++ b/src/i18n/message/app/about-resource.json @@ -69,19 +69,19 @@ }, "de": { "label": { - "name": "Namen", + "name": "Titel", "version": "Aktuelle Version", "website": "Offizielle Website", "installation": "Installation", - "thanks": "Dank", + "thanks": "Credits", "privacy": "Datenschutz", "license": "Lizenz", - "support": "Kundendienst" + "support": "Support" }, "text": { - "greet": "Gefällt Ihnen die Erweiterung?", - "rate": "Mit 5 Sternen bewerten!", - "feedback": "Feedback willkommen!" + "greet": "Gefällt dir diese Erweiterung?", + "rate": "Bewerte uns mit 5 Sternen!", + "feedback": "Sag uns deine Meinung!" } }, "ja": { diff --git a/src/i18n/message/app/habit-resource.json b/src/i18n/message/app/habit-resource.json index 4b186b063..54b156163 100644 --- a/src/i18n/message/app/habit-resource.json +++ b/src/i18n/message/app/habit-resource.json @@ -270,18 +270,18 @@ "focusAverage": "Tagesdurchschnitt {value}" }, "period": { - "title": "Gewohnheiten jeden Augenblick", - "busiest": "Die geschäftigste Zeit des Tages", - "idle": "Längste Leerlaufzeit", + "title": "Nutzung nach Tageszeit", + "busiest": "Aktivste Tageszeit", + "idle": "Längste Ruhezeit", "chartType": { - "average": "Täglicher Durchschnitt", + "average": "Tagesdurchschnitt", "trend": "Trend", "stack": "Stapel" }, "sizes": { "fifteen": "Pro 15 Minuten", "halfHour": "Pro halbe Stunde", - "hour": "Pro eine Stunde", + "hour": "Pro Stunde", "twoHour": "Pro zwei Stunden" } }, diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index 364bf528a..5ae7cf149 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -42,6 +42,8 @@ "strictTip": "时限规则已触发,不允许手动解锁!", "incorrectPsw": "密码错误", "incorrectAnswer": "回答错误", + "twoFaInputTip": "规则已被触发或锁定。请从您的身份验证程序中输入6位数字代码以继续。", + "incorrect2fa": "2FA 验证码错误", "pi": "圆周率 π 的小数部分第 {startIndex} 位到第 {endIndex} 位的共 {digitCount} 位数字", "confession": "一寸光阴一寸金,寸金难买寸光阴" }, diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 92fc6eb31..27235751d 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -66,7 +66,12 @@ "strictTitle": "危险操作", "strictContent": "当您选择这个选项之后,如果某个站点触发了每日限制,除了等到第二天自动解锁以外,不允许您手动解锁。如果规则设置不当,很有可能会阻碍您的日常工作!", "pswFormLabel": "解锁密码", - "pswFormAgain": "再次输入" + "pswFormAgain": "再次输入", + "2fa": "必须使用 2FA 解锁", + "twoFaTitle": "启用 2FA 验证", + "twoFaScanHint": "使用身份验证器应用扫描二维码或将设置链接导入支持TOTP的密码管理器。", + "twoFaCopyLink": "复制链接", + "twoFaVerifyLabel": "请输入您的身份程序里的 6 位编码以验证正确导入" }, "delayDuration": "每次延迟 {input} 分钟" }, @@ -950,8 +955,8 @@ "contextMenu": "Kontextmenü", "displayBadgeText": "{input} {timeInfo} in {icon} anzeigen", "badgeBgColor": "Die Hintergrundfarbe des Textes auf dem Symbol {input}", - "icon": "das Symbol der Erweiterung", - "badgeTextContent": "die Besuchszeit der aktuellen Webseite", + "icon": "Icon der Erweiterung", + "badgeTextContent": "Besuchszeit der aktuellen Webseite", "locale": { "label": "Sprache {input}", "changeConfirm": "Die Sprache wurde erfolgreich geändert. Bitte lade diese Seite neu!", diff --git a/src/i18n/message/common/button-resource.json b/src/i18n/message/common/button-resource.json index e62153415..e31709c73 100644 --- a/src/i18n/message/common/button-resource.json +++ b/src/i18n/message/common/button-resource.json @@ -46,7 +46,8 @@ "batchEnable": "批量启用", "batchDisable": "批量禁用", "collapse": "折叠", - "expand": "展开" + "expand": "展开", + "copy": "复制" }, "zh_TW": { "create": "新增", diff --git a/src/i18n/message/common/shared-resource.json b/src/i18n/message/common/shared-resource.json index 29ee51159..5afaa4717 100644 --- a/src/i18n/message/common/shared-resource.json +++ b/src/i18n/message/common/shared-resource.json @@ -63,8 +63,7 @@ "weekly": "週間の限度", "period": "許可されない期間", "visits": "{n}訪問" - }, - "all": "すべて" + } }, "zh_TW": { "merge": { diff --git a/src/i18n/message/popup/content-resource.json b/src/i18n/message/popup/content-resource.json index 7b1ffd118..f08d66281 100644 --- a/src/i18n/message/popup/content-resource.json +++ b/src/i18n/message/popup/content-resource.json @@ -159,8 +159,8 @@ "saveAsImageTitle": "Bildschirmfoto", "averageCount": "{value} mal pro Tag", "averageTime": "{value} pro Tag im Durchschnitt", - "totalTime": "Gesamt {totalTime}", - "totalCount": "Gesamt {totalCount} mal", + "totalTime": "Insgesamt {totalTime}", + "totalCount": "Insgesamt {totalCount} mal", "otherLabel": "{count} andere Websites" }, "ranking": { diff --git a/src/i18n/message/side/list-resource.json b/src/i18n/message/side/list-resource.json index 592cf21ca..f452bfaca 100644 --- a/src/i18n/message/side/list-resource.json +++ b/src/i18n/message/side/list-resource.json @@ -9,7 +9,7 @@ }, "de": { "searchPlaceholder": "Domain oder URL eingeben", - "title": "Browser-Analyse" + "title": "Nutzungsanalyse" }, "ja": { "searchPlaceholder": "ドメインまたはURLを入力", From 286796879554fed9f9c1719fe4334e8e300cc284 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Sun, 10 May 2026 14:53:48 +0800 Subject: [PATCH 151/174] v4.3.0 --- CHANGELOG.md | 5 +++++ package.json | 2 +- src/i18n/message/common/meta-resource.json | 6 +++--- 3 files changed, 9 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9a4f43b53..85271f8e3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.2.4] - 2026-05-10 + +- Added two-factor authentication (2FA) for time limit verification +- Optimized the behavior of time limit verification + ## [4.2.3] - 2026-05-08 - Fixed some bugs diff --git a/package.json b/package.json index d8ae1061c..c03e1fd9a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tt4b", - "version": "4.2.3", + "version": "4.3.0", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { diff --git a/src/i18n/message/common/meta-resource.json b/src/i18n/message/common/meta-resource.json index 31eea7b3f..8a2c5d558 100644 --- a/src/i18n/message/common/meta-resource.json +++ b/src/i18n/message/common/meta-resource.json @@ -1,12 +1,12 @@ { "zh_CN": { "name": "网费很贵", - "marketName": "网费很贵 - 上网时间统计", + "marketName": "网费很贵 - 屏幕习惯分析 & 成瘾网站拦截", "description": "追踪网页时间,分析浏览习惯,防止网站沉迷,提高工作效率" }, "zh_TW": { "name": "網費很貴", - "marketName": "網費很貴 - 上網時間統計", + "marketName": "網費很貴 - 螢幕習慣分析 & 成癮網站攔截", "description": "追蹤上網時間,分析瀏覽習慣,避免沉迷網站,提升工作效率" }, "ja": { @@ -32,7 +32,7 @@ "es": { "name": "Rastreador de tiempo web", "marketName": "Rastreador de tiempo web - Creador de hábitos web", - "description": "Realice un seguimiento, analice sus hábitos y bloquee sitios adictivos" + "description": "Realice un seguimiento del tiempo, analice sus hábitos y bloquee sitios adictivos" }, "de": { "name": "Zeiterfassung", From 117c9832bbb776720aa2f6d69eca6949274d6466 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Sun, 10 May 2026 18:31:39 +0800 Subject: [PATCH 152/174] refactor: remove unused codes --- src/i18n/message/cs/index.ts | 2 +- src/i18n/message/meta.ts | 1 - src/i18n/message/popup/index.ts | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) delete mode 100644 src/i18n/message/meta.ts diff --git a/src/i18n/message/cs/index.ts b/src/i18n/message/cs/index.ts index 28ff8cead..5fdfbfa88 100644 --- a/src/i18n/message/cs/index.ts +++ b/src/i18n/message/cs/index.ts @@ -1,9 +1,9 @@ import limitMessages, { type LimitMessage } from "../app/limit" import menuMessages, { type MenuMessage } from "../app/menu" import calendarMessages, { type CalendarMessage } from "../calendar" +import metaMessages, { type MetaMessage } from "../common/meta" import sharedMessages, { type SharedMessage } from '../common/shared' import { merge, type MessageRoot } from "../merge" -import metaMessages, { type MetaMessage } from "../meta" import consoleMessages, { type ConsoleMessage } from "./console" import modalMessages, { type ModalMessage } from "./modal" diff --git a/src/i18n/message/meta.ts b/src/i18n/message/meta.ts deleted file mode 100644 index e011144db..000000000 --- a/src/i18n/message/meta.ts +++ /dev/null @@ -1 +0,0 @@ -export { default, type MetaMessage } from "./common/meta" diff --git a/src/i18n/message/popup/index.ts b/src/i18n/message/popup/index.ts index dc5a1ee0e..dbb909a0c 100644 --- a/src/i18n/message/popup/index.ts +++ b/src/i18n/message/popup/index.ts @@ -7,10 +7,10 @@ import baseMessages, { type BaseMessage } from "../common/base" import calendarMessages, { type CalendarMessage } from "../common/calendar" +import metaMessages, { type MetaMessage } from "../common/meta" import sharedMessages, { type SharedMessage } from "../common/shared" import itemMessages, { type ItemMessage } from "../item" import { merge, type MessageRoot } from "../merge" -import metaMessages, { type MetaMessage } from "../meta" import contentMessages, { type ContentMessage } from "./content" import footerMessages, { type FooterMessage } from "./footer" import headerMessages, { type HeaderMessage } from "./header" From 68001247bffe1a890c306c1bd7e04be3d0441fe8 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Sun, 10 May 2026 18:34:51 +0800 Subject: [PATCH 153/174] chore: add Brave --- .github/ISSUE_TEMPLATE/bug-report---bug---.yaml | 1 + .github/ISSUE_TEMPLATE/feature-request.yaml | 1 + 2 files changed, 2 insertions(+) diff --git a/.github/ISSUE_TEMPLATE/bug-report---bug---.yaml b/.github/ISSUE_TEMPLATE/bug-report---bug---.yaml index f4e861146..610e65a1d 100644 --- a/.github/ISSUE_TEMPLATE/bug-report---bug---.yaml +++ b/.github/ISSUE_TEMPLATE/bug-report---bug---.yaml @@ -40,6 +40,7 @@ body: - Chrome - Firefox - Edge + - Brave - Other default: 0 - type: input diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml index 889ce99bd..ef5ec5286 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/feature-request.yaml @@ -19,6 +19,7 @@ body: - Chrome - Firefox - Edge + - Brave - Other default: 0 - type: dropdown From 1a0eccf66dac494e113075971f57a5452d66e3a1 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Sun, 10 May 2026 20:06:21 +0800 Subject: [PATCH 154/174] chore: add badges for the installation --- README-zh.md | 6 ++++++ README.md | 6 ++++++ 2 files changed, 12 insertions(+) diff --git a/README-zh.md b/README-zh.md index 40d009a7c..551b6cc6e 100644 --- a/README-zh.md +++ b/README-zh.md @@ -4,6 +4,12 @@ [![](https://img.shields.io/badge/license-Anti%20996-blue)](https://github.com/996icu/996.ICU) [![](https://img.shields.io/github/v/release/sheepzh/time-tracker-4-browser)](https://github.com/sheepzh/time-tracker-4-browser/releases) +<p> + <a href="https://chromewebstore.google.com/detail/dkdhhcbjijekmneelocdllcldcpmekmm/reviews"><img src="https://developer.chrome.com/static/docs/webstore/branding/image/206x58-chrome-web-bcb82d15b2486.png" alt="Chrome Web Store" height="48"></a> + <a href="https://microsoftedge.microsoft.com/addons/detail/timer-running-browsin/fepjgblalcnepokjblgbgmapmlkgfahc"><img src="https://developer.microsoft.com/store/badges/images/English_get-it-from-MS.png" alt="Microsoft Store" height="48"></a> + <a href="https://addons.mozilla.org/zh-CN/firefox/addon/web%E6%99%82%E9%96%93%E7%B5%B1%E8%A8%88/reviews/"><img src="https://blog.mozilla.org/addons/files/2020/04/get-the-addon-fx-apr-2020.svg" alt="Firefox 附加组件" height="48"></a> +</p> + \[ 简体中文 | [English](./README.md) \] 网费很贵是一款用于上网时间统计的浏览器插件,使用 rspack,TypeScript 和 Element-plus 进行开发。你可以在 Firefox,Chrome 和 Edge 中安装并使用它。 diff --git a/README.md b/README.md index 9da6906bf..cfdafd23f 100644 --- a/README.md +++ b/README.md @@ -4,6 +4,12 @@ [![](https://img.shields.io/badge/license-Anti%20996-blue)](https://github.com/996icu/996.ICU) [![Crowdin](https://badges.crowdin.net/timer-chrome-edge-firefox/localized.svg)](https://crowdin.com/project/timer-chrome-edge-firefox) +<p> + <a href="https://chromewebstore.google.com/detail/dkdhhcbjijekmneelocdllcldcpmekmm/reviews"><img src="https://developer.chrome.com/static/docs/webstore/branding/image/206x58-chrome-web-bcb82d15b2486.png" alt="Available in the Chrome Web Store" height="48"></a> + <a href="https://microsoftedge.microsoft.com/addons/detail/time-tracker-web-habit-/fepjgblalcnepokjblgbgmapmlkgfahc"><img src="https://developer.microsoft.com/store/badges/images/English_get-it-from-MS.png" alt="Get it from Microsoft" height="48"></a> + <a href="https://addons.mozilla.org/firefox/addon/besttimetracker/reviews/"><img src="https://blog.mozilla.org/addons/files/2020/04/get-the-addon-fx-apr-2020.svg" alt="Get the add-on" height="48"></a> +</p> + \[ English | [简体中文](./README-zh.md) \] Time Tracker is a browser extension to track the time you spent on all websites. It's built by rspack, TypeScript and Element-plus. And you can install it for Firefox, Chrome and Edge. From 1e6c221559e8c1bf33434d14618b2902a0b589b2 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Mon, 11 May 2026 00:56:16 +0800 Subject: [PATCH 155/174] fix: version number --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 85271f8e3..fadf39217 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. -## [4.2.4] - 2026-05-10 +## [4.3.0] - 2026-05-11 - Added two-factor authentication (2FA) for time limit verification - Optimized the behavior of time limit verification From daedb8ac7fadcfb544aed0543cb561dbdc26a75b Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Mon, 11 May 2026 22:24:23 +0800 Subject: [PATCH 156/174] feat: enable tab group tracking by default --- .../app/components/Option/categories/Appearance/index.tsx | 6 +++--- src/pages/app/components/Option/categories/Tracking.tsx | 2 +- src/pages/app/components/Option/components/Item.tsx | 4 ++-- src/util/constant/option.ts | 2 +- 4 files changed, 7 insertions(+), 7 deletions(-) diff --git a/src/pages/app/components/Option/categories/Appearance/index.tsx b/src/pages/app/components/Option/categories/Appearance/index.tsx index 0bdf9832f..467ab5c81 100644 --- a/src/pages/app/components/Option/categories/Appearance/index.tsx +++ b/src/pages/app/components/Option/categories/Appearance/index.tsx @@ -115,7 +115,7 @@ const _default = defineComponent((_props, ctx) => { {!IS_ANDROID && <> <OptionItem label={msg => msg.option.appearance.displayWhitelist} - defaultValue={t(msg => msg.option.yes)} + defaultValue={DEFAULT_APPEARANCE.displayWhitelistMenu} v-slots={{ whitelist: () => <OptionTag>{t(msg => msg.option.appearance.whitelistItem)}</OptionTag>, contextMenu: () => <OptionTag>{t(msg => msg.option.appearance.contextMenu)}</OptionTag>, @@ -128,7 +128,7 @@ const _default = defineComponent((_props, ctx) => { </OptionItem> <OptionItem label={msg => msg.option.appearance.displayBadgeText} - defaultValue={t(msg => msg.option.yes)} + defaultValue={DEFAULT_APPEARANCE.displayBadgeText} v-slots={{ timeInfo: () => <OptionTag>{t(msg => msg.option.appearance.badgeTextContent)}</OptionTag>, icon: () => <OptionTag>{t(msg => msg.option.appearance.icon)}</OptionTag>, @@ -151,7 +151,7 @@ const _default = defineComponent((_props, ctx) => { </OptionItem> <OptionItem label={msg => msg.option.appearance.printInConsole.label} - defaultValue={t(msg => msg.option.yes)} + defaultValue={DEFAULT_APPEARANCE.printInConsole} v-slots={{ console: () => <OptionTag>{t(msg => msg.option.appearance.printInConsole.console)}</OptionTag>, info: () => <OptionTag>{t(msg => msg.option.appearance.printInConsole.info)}</OptionTag>, diff --git a/src/pages/app/components/Option/categories/Tracking.tsx b/src/pages/app/components/Option/categories/Tracking.tsx index 58a002c2c..c0446e672 100644 --- a/src/pages/app/components/Option/categories/Tracking.tsx +++ b/src/pages/app/components/Option/categories/Tracking.tsx @@ -153,7 +153,7 @@ const _default = defineComponent((_props, ctx) => { /> <OptionItem label={msg => msg.option.tracking.countTabGroup} - defaultValue={t(msg => msg.option.no)} + defaultValue={DEFAULT_TRACKING.countTabGroup} v-slots={{ info: () => <OptionTooltip>{t(msg => msg.option.tracking.tabGroupInfo)}</OptionTooltip>, default: () => <ElSwitch modelValue={option.countTabGroup} onChange={val => handleTabGroupChange(!!val)} /> diff --git a/src/pages/app/components/Option/components/Item.tsx b/src/pages/app/components/Option/components/Item.tsx index e636d93c3..2033f4e7a 100644 --- a/src/pages/app/components/Option/components/Item.tsx +++ b/src/pages/app/components/Option/components/Item.tsx @@ -1,6 +1,6 @@ -import { MediaSize, useMediaSize } from '@hooks' import { t, tN, type I18nKey } from '@app/locale' import { css } from '@emotion/css' +import { MediaSize, useMediaSize } from '@hooks' import Flex from "@pages/components/Flex" import { colorVariant } from '@pages/util/style' import { ElTag, useNamespace } from "element-plus" @@ -19,7 +19,7 @@ const computedDefValText = (defVal: Props['defaultValue']): string | number | un case 'undefined': return undefined case 'string': case 'number': return defVal - case 'boolean': return t(defVal ? msg => msg.option.yes : msg => msg.option.no) + case 'boolean': return t(msg => msg.option[defVal ? 'yes' : 'no']) default: return t(defVal) } } diff --git a/src/util/constant/option.ts b/src/util/constant/option.ts index 5b67520b6..a404161f0 100644 --- a/src/util/constant/option.ts +++ b/src/util/constant/option.ts @@ -26,7 +26,7 @@ export const DEFAULT_TRACKING: timer.option.TrackingRequired = { // 10 minutes autoPauseInterval: 600, countLocalFiles: false, - countTabGroup: false, + countTabGroup: true, weekStart: 'default', storage: 'classic', } as const From 67afa9dca07b158f53603876dbfed8ed4ced6673 Mon Sep 17 00:00:00 2001 From: sheepie <returnzhy1996@outlook.com> Date: Tue, 12 May 2026 15:31:01 +0800 Subject: [PATCH 157/174] feat: optimize data migration (#765) --- .../message/app/data-manage-resource.json | 5 +- src/i18n/message/app/data-manage.ts | 3 + src/i18n/message/common/item-resource.json | 65 +++--------- src/i18n/message/common/item.ts | 3 - .../ClearPanel/ClearFilter/DateFilter.tsx | 92 +++++++--------- .../ClearPanel/ClearFilter/NumberFilter.tsx | 55 ++++------ .../ClearPanel/ClearFilter/index.tsx | 64 +++++------ .../components/DataManage/DataManageAlert.tsx | 23 ++-- .../app/components/DataManage/MemoryInfo.tsx | 10 +- .../DataManage/Migration/ImportButton.tsx | 54 ---------- .../Migration/ImportOtherButton/Step1.tsx | 19 +--- .../Migration/ImportOtherButton/detector.ts | 23 ++++ .../Migration/ImportOtherButton/index.tsx | 100 +++++++++++++++--- .../components/DataManage/Migration/index.tsx | 94 +++++++++++----- src/pages/app/components/DataManage/index.tsx | 10 +- src/pages/hooks/useRequest.ts | 31 ++++-- src/util/constant/environment.ts | 2 +- 17 files changed, 328 insertions(+), 325 deletions(-) delete mode 100644 src/pages/app/components/DataManage/Migration/ImportButton.tsx create mode 100644 src/pages/app/components/DataManage/Migration/ImportOtherButton/detector.ts diff --git a/src/i18n/message/app/data-manage-resource.json b/src/i18n/message/app/data-manage-resource.json index 0fdd8d2b5..d75832029 100644 --- a/src/i18n/message/app/data-manage-resource.json +++ b/src/i18n/message/app/data-manage-resource.json @@ -87,7 +87,10 @@ "paramError": "The parameter is wrong, please check!", "deleteConfirm": "A total of {count} records have been filtered out. Do you want to delete them all?", "migrationAlert": "Migrate data between browsers using import and export", - "importError": "Wrong file extension" + "importError": "Wrong file extension", + "exportData": "Export data", + "restoreData": "Restore data", + "restoreFromOther": "Restore from {ext}" }, "ja": { "totalMemoryAlert": "ブラウザは、データを保存するために各拡張機能に {size}MB のメモリを提供します", diff --git a/src/i18n/message/app/data-manage.ts b/src/i18n/message/app/data-manage.ts index 912514735..b1526dafa 100644 --- a/src/i18n/message/app/data-manage.ts +++ b/src/i18n/message/app/data-manage.ts @@ -36,6 +36,9 @@ export type DataManageMessage = { } & { [resolution in timer.imported.ConflictResolution]: string } + exportData: string + restoreData: string + restoreFromOther: string } const _default: Messages<DataManageMessage> = resource diff --git a/src/i18n/message/common/item-resource.json b/src/i18n/message/common/item-resource.json index f6d311e5a..b4f5b092c 100644 --- a/src/i18n/message/common/item-resource.json +++ b/src/i18n/message/common/item-resource.json @@ -11,10 +11,7 @@ "analysis": "分析", "deleteConfirmMsgAll": "将删除 {url} 的所有访问记录", "deleteConfirmMsgRange": "将删除 {url} 在 {start} 至 {end} 期间的访问记录", - "deleteConfirmMsg": "将删除 {url} 在 {date} 的访问记录", - "exportWholeData": "导出数据", - "importWholeData": "导入数据", - "importOtherData": "导入其他插件数据" + "deleteConfirmMsg": "将删除 {url} 在 {date} 的访问记录" } }, "zh_TW": { @@ -29,10 +26,7 @@ "analysis": "分析", "deleteConfirmMsgAll": "將刪除 {url} 的所有瀏覽紀錄", "deleteConfirmMsgRange": "將刪除 {url} 在 {start} 至 {end} 期間的瀏覽紀錄", - "deleteConfirmMsg": "將刪除 {url} 在 {date} 的瀏覽紀錄", - "exportWholeData": "匯出資料", - "importWholeData": "匯入資料", - "importOtherData": "匯入其他擴充功能資料" + "deleteConfirmMsg": "將刪除 {url} 在 {date} 的瀏覽紀錄" } }, "en": { @@ -47,10 +41,7 @@ "analysis": "Analyze", "deleteConfirmMsgAll": "All visit records for [{url}] will be deleted", "deleteConfirmMsgRange": "Visit records for [{url}] between {start} and {end} will be deleted", - "deleteConfirmMsg": "Visit record for [{url}] of {date} will be deleted", - "exportWholeData": "Export", - "importWholeData": "Import", - "importOtherData": "Import from Extensions" + "deleteConfirmMsg": "Visit record for [{url}] of {date} will be deleted" } }, "ja": { @@ -65,10 +56,7 @@ "analysis": "分析", "deleteConfirmMsgAll": "{url} の全ての訪問記録を削除します", "deleteConfirmMsgRange": "{url} の {start} から {end} までの訪問記録を削除します", - "deleteConfirmMsg": "{url} の {date} の訪問記録を削除します", - "exportWholeData": "エクスポート", - "importWholeData": "インポート", - "importOtherData": "拡張機能からインポート" + "deleteConfirmMsg": "{url} の {date} の訪問記録を削除します" } }, "pt_PT": { @@ -83,10 +71,7 @@ "analysis": "Analisar", "deleteConfirmMsgAll": "Todos os registos de visitas de {url} serão eliminados", "deleteConfirmMsgRange": "Os registos de visitas de {url} entre {start} e {end} serão eliminados", - "deleteConfirmMsg": "O registo de visita de {url} de {date} será eliminado", - "exportWholeData": "Exportar", - "importWholeData": "Importar", - "importOtherData": "Importar de Extensões" + "deleteConfirmMsg": "O registo de visita de {url} de {date} será eliminado" } }, "uk": { @@ -101,10 +86,7 @@ "analysis": "Аналіз", "deleteConfirmMsgAll": "Усі записи для {url} будуть видалені!", "deleteConfirmMsgRange": "Усі записи для {url} між {start} і {end} будуть видалені!", - "deleteConfirmMsg": "Запис для {url} {date} буде видалено!", - "exportWholeData": "Експортувати дані", - "importWholeData": "Імпортувати дані", - "importOtherData": "Імпорт з інших розширень" + "deleteConfirmMsg": "Запис для {url} {date} буде видалено!" } }, "es": { @@ -119,10 +101,7 @@ "analysis": "Analizar", "deleteConfirmMsgAll": "Se eliminarán todos los registros de {url}", "deleteConfirmMsgRange": "Se eliminarán los registros de {url} entre {start} y {end}", - "deleteConfirmMsg": "Se eliminará el registro de {url} del {date}", - "exportWholeData": "Exportar", - "importWholeData": "Importar", - "importOtherData": "Importar de extensiones" + "deleteConfirmMsg": "Se eliminará el registro de {url} del {date}" } }, "de": { @@ -137,10 +116,7 @@ "analysis": "Analyse", "deleteConfirmMsgAll": "Alle Datensätze von {url} werden gelöscht", "deleteConfirmMsgRange": "Alle Einträge von {url} zwischen {start} und {end} werden gelöscht", - "deleteConfirmMsg": "Der Eintrag von {url} am {date} wird gelöscht", - "exportWholeData": "Exportieren", - "importWholeData": "Importieren", - "importOtherData": "Aus Erweiterungen importieren" + "deleteConfirmMsg": "Der Eintrag von {url} am {date} wird gelöscht" } }, "fr": { @@ -155,10 +131,7 @@ "analysis": "Analyser", "deleteConfirmMsgAll": "Tous les enregistrements de {url} seront supprimés", "deleteConfirmMsgRange": "Les enregistrements de {url} entre {start} et {end} seront supprimés", - "deleteConfirmMsg": "L'enregistrement des visites pour [{url}] du [{date}] seront supprimés", - "exportWholeData": "Exporter", - "importWholeData": "Importer", - "importOtherData": "Depuis d'autres extensions" + "deleteConfirmMsg": "L'enregistrement des visites pour [{url}] du [{date}] seront supprimés" } }, "ru": { @@ -173,10 +146,7 @@ "analysis": "Анализ", "deleteConfirmMsgAll": "Все записи {url} будут удалены", "deleteConfirmMsgRange": "Записи {url} с {start} по {end} будут удалены", - "deleteConfirmMsg": "Запись {url} за {date} будет удалена", - "exportWholeData": "Экспорт", - "importWholeData": "Импорт", - "importOtherData": "Импорт из дополнений" + "deleteConfirmMsg": "Запись {url} за {date} будет удалена" } }, "ar": { @@ -191,10 +161,7 @@ "analysis": "تحليل", "deleteConfirmMsgAll": "سيتم حذف جميع سجلات {url}", "deleteConfirmMsgRange": "سيتم حذف سجلات {url} بين {start} و{end}", - "deleteConfirmMsg": "سيتم حذف سجل {url} بتاريخ {date}", - "exportWholeData": "تصدير", - "importWholeData": "استيراد", - "importOtherData": "استيراد من إضافات أخرى" + "deleteConfirmMsg": "سيتم حذف سجل {url} بتاريخ {date}" } }, "tr": { @@ -209,10 +176,7 @@ "analysis": "Analiz et", "deleteConfirmMsgAll": "[{url}] adresine ait tüm ziyaret kayıtları silinecektir", "deleteConfirmMsgRange": "{start} ile {end} arasındaki [{url}] ziyaret kayıtları silinecektir", - "deleteConfirmMsg": "{date} tarihindeki [{url}] için ziyaret kaydı silinecektir", - "exportWholeData": "Dışa Aktar", - "importWholeData": "İçe aktar", - "importOtherData": "Uzantılardan içe aktar" + "deleteConfirmMsg": "{date} tarihindeki [{url}] için ziyaret kaydı silinecektir" } }, "pl": { @@ -227,10 +191,7 @@ "analysis": "Analizuj", "deleteConfirmMsgAll": "Wszystkie wpisy wizyt dla [{url}] zostaną usunięte", "deleteConfirmMsgRange": "Sprawdź wpisy dla [{url}] pomiędzy {start} a {end} zostaną usunięte", - "deleteConfirmMsg": "Wpis wizyt dla [{url}] z {date} zostanie usunięty", - "exportWholeData": "Eksportuj", - "importWholeData": "Importuj", - "importOtherData": "Importuj z rozszerzenia" + "deleteConfirmMsg": "Wpis wizyt dla [{url}] z {date} zostanie usunięty" } } } \ No newline at end of file diff --git a/src/i18n/message/common/item.ts b/src/i18n/message/common/item.ts index 0ed7cf70b..f01c084f8 100644 --- a/src/i18n/message/common/item.ts +++ b/src/i18n/message/common/item.ts @@ -17,9 +17,6 @@ export type ItemMessage = { deleteConfirmMsgRange: string deleteConfirmMsg: string analysis: string - exportWholeData: string - importWholeData: string - importOtherData: string } } & { [dimension in timer.core.Dimension]: string diff --git a/src/pages/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx b/src/pages/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx index 7e203e5be..65faecd51 100644 --- a/src/pages/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx +++ b/src/pages/app/components/DataManage/ClearPanel/ClearFilter/DateFilter.tsx @@ -5,63 +5,51 @@ * https://opensource.org/licenses/MIT */ import { t, tN } from "@app/locale" +import { dateFormat as elDateFormat } from "@i18n/element" import { getDatePickerIconSlots } from '@pages/element-ui/rtl' import type { ElDatePickerShortcut } from '@pages/element-ui/types' -import { dateFormat as elDateFormat } from "@i18n/element" import { formatTime, getBirthday, MILL_PER_DAY } from "@util/time" import { ElDatePicker } from "element-plus" -import { defineComponent, type PropType, type StyleValue } from "vue" - -const _default = defineComponent({ - emits: { - change: (_date: [Date, Date]) => true - }, - props: { - dateRange: Object as PropType<[Date, Date]> - }, - setup(props, ctx) { - const yesterday = new Date().getTime() - MILL_PER_DAY - const daysBefore = (days: number) => new Date(new Date().getTime() - days * MILL_PER_DAY) +import { defineComponent, type StyleValue } from "vue" - const birthday = getBirthday() - const pickerShortcuts: ElDatePickerShortcut[] = [ - { - text: t(msg => msg.calendar.range.tillYesterday), - value: [birthday, daysBefore(1)], - }, { - text: t(msg => msg.calendar.range.tillDaysAgo, { n: 7 }), - value: [birthday, daysBefore(7)], - }, { - text: t(msg => msg.calendar.range.tillDaysAgo, { n: 30 }), - value: [birthday, daysBefore(30)], - } - ] - const dateFormat = '{y}-{m}-{d}' - const startPlaceholder = formatTime(birthday, dateFormat) - const endPlaceholder = formatTime(yesterday, dateFormat) +type Props = ModelValue<[Date, Date] | undefined> +const _default = defineComponent<Props>(props => { + const birthday = getBirthday() + const yesterday = Date.now() - MILL_PER_DAY + const daysBefore = (days: number) => new Date(Date.now() - days * MILL_PER_DAY) + const dateFormat = t(msg => msg.calendar.dateFormat) + const shortcuts: ElDatePickerShortcut[] = [{ + text: t(msg => msg.calendar.range.tillYesterday), + value: [birthday, daysBefore(1)], + }, { + text: t(msg => msg.calendar.range.tillDaysAgo, { n: 7 }), + value: [birthday, daysBefore(7)], + }, { + text: t(msg => msg.calendar.range.tillDaysAgo, { n: 30 }), + value: [birthday, daysBefore(30)], + }] - return () => ( - <p> - <a style={{ marginInlineEnd: '10px' }}>1.</a> - {tN(msg => msg.dataManage.filterDate, { - picker: <ElDatePicker - modelValue={props.dateRange} - onUpdate:modelValue={date => ctx.emit("change", date)} - size="small" - style={{ width: "250px" } satisfies StyleValue} - startPlaceholder={startPlaceholder} - endPlaceholder={endPlaceholder} - dateFormat={elDateFormat()} - type="daterange" - disabledDate={(date: Date) => date.getTime() > yesterday} - shortcuts={pickerShortcuts} - rangeSeparator="-" - v-slots={getDatePickerIconSlots()} - /> - })} - </p> - ) - } -}) + return () => ( + <p> + <a style={{ marginInlineEnd: '10px' }}>1.</a> + {tN(msg => msg.dataManage.filterDate, { + picker: <ElDatePicker + modelValue={props.modelValue} + onUpdate:modelValue={props.onChange} + size="small" + style={{ width: "250px" } satisfies StyleValue} + startPlaceholder={formatTime(birthday, dateFormat)} + endPlaceholder={formatTime(yesterday, dateFormat)} + dateFormat={elDateFormat()} + type="daterange" + disabledDate={(date: Date) => date.getTime() > yesterday} + shortcuts={shortcuts} + rangeSeparator="-" + v-slots={getDatePickerIconSlots()} + /> + })} + </p> + ) +}, { props: ['modelValue', 'onChange'] }) export default _default diff --git a/src/pages/app/components/DataManage/ClearPanel/ClearFilter/NumberFilter.tsx b/src/pages/app/components/DataManage/ClearPanel/ClearFilter/NumberFilter.tsx index 185e61a6a..573123f5f 100644 --- a/src/pages/app/components/DataManage/ClearPanel/ClearFilter/NumberFilter.tsx +++ b/src/pages/app/components/DataManage/ClearPanel/ClearFilter/NumberFilter.tsx @@ -8,50 +8,35 @@ import { tN } from "@app/locale" import { type DataManageMessage } from "@i18n/message/app/data-manage" import { ElInput } from "element-plus" -import { defineComponent, type PropType, type Ref, ref, type StyleValue, watch } from "vue" +import { defineComponent, type StyleValue } from "vue" -const elInput = (ref: Ref<string | undefined>, placeholder: string) => ( +const elInput = (value: string | undefined, onChange: ArgCallback<string | undefined>, placeholder: string) => ( <ElInput placeholder={placeholder} clearable size="small" - modelValue={ref.value} - onInput={val => ref.value = val?.trim()} - onClear={() => ref.value = undefined} + modelValue={value} + onInput={onChange} + onClear={() => onChange(undefined)} style={{ width: '60px' } satisfies StyleValue} /> ) -const _default = defineComponent({ - props: { - translateKey: { - type: String as PropType<keyof DataManageMessage>, - required: true, - }, - defaultValue: { - type: Object as PropType<[string?, string?]>, - required: true, - }, - lineNo: Number, - }, - emits: { - change: (_val: [string?, string?]) => true, - }, - setup(props, ctx) { - const start = ref(props.defaultValue[0]) - const end = ref(props.defaultValue[1]) - watch([start, end], () => ctx.emit("change", [start.value, end.value])) +type Props = ModelValue<[string?, string?]> & { + i18nKey: keyof DataManageMessage + lineNo: number +} - return () => ( - <p> - <a style={{ marginInlineEnd: '10px' }}>{props.lineNo}.</a> - {tN(msg => msg.dataManage[props.translateKey], { - start: elInput(start, '0'), - end: elInput(end, '∞'), - })} - </p> - ) - } -}) +const _default = defineComponent<Props>(props => { + return () => ( + <p> + <a style={{ marginInlineEnd: '10px' }}>{props.lineNo}.</a> + {tN(msg => msg.dataManage[props.i18nKey], { + start: elInput(props.modelValue[0], val => props.onChange?.([val, props.modelValue[1]]), '0'), + end: elInput(props.modelValue[1], val => props.onChange?.([props.modelValue[0], val]), '∞'), + })} + </p> + ) +}, { props: ['modelValue', 'onChange', 'lineNo', 'i18nKey'] }) export default _default \ No newline at end of file diff --git a/src/pages/app/components/DataManage/ClearPanel/ClearFilter/index.tsx b/src/pages/app/components/DataManage/ClearPanel/ClearFilter/index.tsx index cda33e85e..930fe3927 100644 --- a/src/pages/app/components/DataManage/ClearPanel/ClearFilter/index.tsx +++ b/src/pages/app/components/DataManage/ClearPanel/ClearFilter/index.tsx @@ -4,52 +4,38 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { useState } from "@hooks" import { t } from "@app/locale" import { Delete } from "@element-plus/icons-vue" +import { useState } from "@hooks" +import Box from '@pages/components/Box' import { ElButton } from "element-plus" import { defineComponent } from "vue" import DateFilter from "./DateFilter" import NumberFilter from "./NumberFilter" -const _default = defineComponent({ - emits: { - delete: (_date: [Date, Date] | undefined, _focus: [string?, string?], _time: [string?, string?]) => true - }, - setup(_, ctx) { - const [date, setDate] = useState<[Date, Date]>() - const [focus, setFocus] = useState<[string?, string?]>(['0', '2']) - const [time, setTime] = useState<[string?, string?]>(['0',]) +type Props = { + onDelete?: (date: [Date, Date] | undefined, focus: [string?, string?], time: [string?, string?]) => void +} + +const _default = defineComponent<Props>(props => { + const [date, setDate] = useState<[Date, Date]>() + const [focus, setFocus] = useState<[string?, string?]>(['0', '2']) + const [time, setTime] = useState<[string?, string?]>(['0',]) + const handleDelete = () => props.onDelete?.(date.value, focus.value, time.value) - return () => ( - <div style={{ paddingInlineStart: '30px', paddingTop: '40px' }}> - <h3>{t(msg => msg.dataManage.filterItems)}</h3> - <DateFilter dateRange={date.value} onChange={setDate} /> - <NumberFilter - translateKey="filterFocus" - defaultValue={focus.value} - lineNo={2} - onChange={setFocus} - /> - <NumberFilter - translateKey="filterTime" - defaultValue={time.value} - lineNo={3} - onChange={setTime} - /> - <div style={{ paddingTop: '40px' }}> - <ElButton - icon={Delete} - type="danger" - size="small" - onClick={() => ctx.emit("delete", date.value, focus.value, time.value)} - > - {t(msg => msg.button.delete)} - </ElButton> - </div> - </div> - ) - } -}) + return () => ( + <div style={{ paddingInlineStart: '30px', paddingTop: '40px' }}> + <h3>{t(msg => msg.dataManage.filterItems)}</h3> + <DateFilter modelValue={date.value} onChange={setDate} /> + <NumberFilter i18nKey="filterFocus" lineNo={2} modelValue={focus.value} onChange={setFocus} /> + <NumberFilter i18nKey="filterTime" lineNo={3} modelValue={time.value} onChange={setTime} /> + <Box marginTop={40}> + <ElButton icon={Delete} type="danger" onClick={handleDelete}> + {t(msg => msg.button.delete)} + </ElButton> + </Box> + </div> + ) +}, { props: ['onDelete'] }) export default _default \ No newline at end of file diff --git a/src/pages/app/components/DataManage/DataManageAlert.tsx b/src/pages/app/components/DataManage/DataManageAlert.tsx index 51498ee48..2dab72417 100644 --- a/src/pages/app/components/DataManage/DataManageAlert.tsx +++ b/src/pages/app/components/DataManage/DataManageAlert.tsx @@ -8,15 +8,24 @@ type Props = { } const DataManageAlert = defineComponent<Props>(props => { - const text = computed(() => { - const text = props.text - return typeof text === 'string' ? text : t(text) - }) + const message = computed(() => + typeof props.text === 'string' ? props.text : t(props.text)) return () => ( - <ElAlert type={props.type ?? 'info'} closable={false} center> - {text.value} - </ElAlert> + <div + style={{ + flexShrink: 0, + width: '100%', + minHeight: 52, + display: 'flex', + alignItems: 'center', + boxSizing: 'border-box', + }} + > + <ElAlert type={props.type ?? 'info'} closable={false} center style={{ width: '100%' }}> + {message.value} + </ElAlert> + </div> ) }, { props: ['text', 'type'] }) diff --git a/src/pages/app/components/DataManage/MemoryInfo.tsx b/src/pages/app/components/DataManage/MemoryInfo.tsx index 8212d981c..8252c03fc 100644 --- a/src/pages/app/components/DataManage/MemoryInfo.tsx +++ b/src/pages/app/components/DataManage/MemoryInfo.tsx @@ -52,22 +52,18 @@ const _default = defineComponent(() => { }) return () => ( - <ElCard - style={{ width: '100%' } satisfies StyleValue} - bodyStyle={{ height: '100%', boxSizing: 'border-box' }} - > - <Flex column height='100%' align="center"> + <ElCard style={{ width: '100%' } satisfies StyleValue}> + <Flex column width='100%' align="center" gap={16}> <DataManageAlert type={totalMb.value ? "info" : "warning"} text={totalTitle(totalMb.value)} /> - <Flex flex={1} height={0}> + <Flex justify='center' align='center'> <ElProgress strokeWidth={10} percentage={percentage.value} type="circle" color={color.value} - style={{ display: 'flex', marginTop: '30px' } satisfies StyleValue} /> </Flex> <Flex justify='center' column gap={10}> diff --git a/src/pages/app/components/DataManage/Migration/ImportButton.tsx b/src/pages/app/components/DataManage/Migration/ImportButton.tsx deleted file mode 100644 index 40baf693d..000000000 --- a/src/pages/app/components/DataManage/Migration/ImportButton.tsx +++ /dev/null @@ -1,54 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { sendMsg2Runtime } from '@api/sw/common' -import { t } from '@app/locale' -import { Upload } from "@element-plus/icons-vue" -import { deserialize } from "@util/file" -import { ElButton, ElLoading, ElMessage } from "element-plus" -import { defineComponent, ref } from "vue" -import { useDataMemory } from "../context" - -async function handleFileSelected(fileInput: HTMLInputElement | undefined, callback: () => void) { - const file = fileInput?.files?.[0] - if (!file) return - - const loading = ElLoading.service({ fullscreen: true }) - const fileText = await file.text() - const data = deserialize(fileText) - if (!data) return ElMessage.error(t(msg => msg.dataManage.importError)) - await sendMsg2Runtime('immigration.import', data) - loading.close() - callback?.() - ElMessage.success(t(msg => msg.operation.successMsg)) -} - -const _default = defineComponent(() => { - const { refreshMemory } = useDataMemory() - const fileInput = ref<HTMLInputElement>() - - return () => ( - <ElButton - size="large" - type="primary" - icon={Upload} - onClick={() => fileInput.value?.click()} - style={{ margin: 0, flex: 1 }} - > - {t(msg => msg.item.operation.importWholeData)} - <input - ref={fileInput} - type="file" - accept=".json" - style={{ display: "none" }} - onChange={() => handleFileSelected(fileInput.value, refreshMemory)} - /> - </ElButton> - ) -}) - -export default _default diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step1.tsx b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step1.tsx index 72ca3c1d2..021f48826 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step1.tsx +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/Step1.tsx @@ -9,41 +9,28 @@ import { useDialogSop } from '@app/components/common/DialogSop/context' import { t } from '@app/locale' import { Document } from "@element-plus/icons-vue" import Flex from "@pages/components/Flex" -import { ElButton, ElForm, ElFormItem, ElSelect } from "element-plus" +import { ElButton, ElForm, ElFormItem } from "element-plus" import { defineComponent, ref } from "vue" import type { ImportForm, OtherExtension } from './types' -const OTHER_NAMES: { [ext in OtherExtension]: string } = { - webtime_tracker: "Webtime Tracker", - web_activity_time_tracker: "Web Activity Time Tracker", - history_trends_unlimited: "History Trends Unlimited", -} - -const OTHER_FILE_FORMAT: { [ext in OtherExtension]: string } = { +const OTHER_FILE_FORMAT: Record<OtherExtension, string> = { webtime_tracker: '.csv,.json', web_activity_time_tracker: '.csv,.json', history_trends_unlimited: '.tsv', } -const ALL_TYPES: OtherExtension[] = Object.keys(OTHER_NAMES) as OtherExtension[] - const _default = defineComponent<{}>(() => { const { form } = useDialogSop<ImportForm>() const fileInput = ref<HTMLInputElement>() return () => ( <ElForm labelWidth={100} labelPosition="left" style={{ width: '500px' }}> - <ElFormItem label={t(msg => msg.dataManage.importOther.dataSource)} required> - <ElSelect - modelValue={form.ext} onChange={v => form.ext = v} - options={ALL_TYPES.map(value => ({ value, label: OTHER_NAMES[value] }))} - /> - </ElFormItem> <ElFormItem label={t(msg => msg.dataManage.importOther.file)} required> <Flex gap={10}> <ElButton icon={Document} onClick={() => fileInput.value?.click?.()}> {t(msg => msg.dataManage.importOther.selectFileBtn)} <input + key={form.ext} ref={fileInput} type="file" accept={OTHER_FILE_FORMAT[form.ext]} diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/detector.ts b/src/pages/app/components/DataManage/Migration/ImportOtherButton/detector.ts new file mode 100644 index 000000000..7e49e119c --- /dev/null +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/detector.ts @@ -0,0 +1,23 @@ +import { IS_CHROME, IS_EDGE } from '@util/constant/environment' + +const WATT_CHROME_ID = 'hhfnghjdeddcfegfekjeihfmbjenlomm' +const WATT_EDGE_ID = 'eepmlmdenlkkjieghjmedjahpofieogf' +const WATT_RES_URL = '/assets/pomodoro-sounds/1.mp3' +export async function detectWatt(): Promise<boolean> { + if (IS_CHROME) { + return await fetchChrome(WATT_CHROME_ID, WATT_RES_URL) + } else if (IS_EDGE) { + return await fetchChrome(WATT_CHROME_ID, WATT_RES_URL) || await fetchChrome(WATT_EDGE_ID, WATT_RES_URL) + } + return false +} + +async function fetchChrome(id: string, uri: string) { + const url = `chrome-extension://${id}${uri}` + try { + const resp = await fetch(url, { method: 'HEAD' }) + return resp.status === 200 + } catch { + return false + } +} \ No newline at end of file diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/index.tsx b/src/pages/app/components/DataManage/Migration/ImportOtherButton/index.tsx index cbd6e5762..005b5a23f 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/index.tsx +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/index.tsx @@ -10,22 +10,78 @@ import DialogSop from '@app/components/common/DialogSop' import { initDialogSopContext } from '@app/components/common/DialogSop/context' import { useDataMemory } from '@app/components/DataManage/context' import { t } from "@app/locale" -import { Upload } from "@element-plus/icons-vue" +import { css } from '@emotion/css' +import { useRequest, useState } from '@hooks' import Flex from '@pages/components/Flex' -import { ElButton } from "element-plus" -import { defineComponent, toRaw } from "vue" +import { ElDropdown, ElDropdownItem, ElDropdownMenu, ElIcon, ElText } from "element-plus" +import { computed, defineComponent, type FunctionalComponent, h, toRaw } from "vue" +import { detectWatt } from './detector' import { parseFile } from './processor' import Step1 from './Step1' import Step2 from './Step2' -import type { ImportForm } from './types' +import type { ImportForm, OtherExtension } from './types' + +type Config = { + name: string + Icon: FunctionalComponent<{}> +} + +const EXTENSION_CONFIGS: Record<OtherExtension, Config> = { + webtime_tracker: { + name: "Webtime Tracker", + Icon: () => ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120"> + <g><path style="opacity:1" fill="#3896fa" d="M 52.5,-0.5 C 54.1667,-0.5 55.8333,-0.5 57.5,-0.5C 58.8309,8.13683 58.8309,16.8035 57.5,25.5C 53.6019,26.975 49.6019,28.1417 45.5,29C 41.6563,31.5153 37.823,34.0153 34,36.5C 27.7055,32.376 21.5388,28.0426 15.5,23.5C 14.3691,21.9255 14.2025,20.2588 15,18.5C 25.3653,7.73449 37.8653,1.40116 52.5,-0.5 Z" /></g> + <g><path style="opacity:1" fill="#f9645c" d="M 61.5,-0.5 C 63.1667,-0.5 64.8333,-0.5 66.5,-0.5C 96.5,4.83333 114.167,22.5 119.5,52.5C 119.5,57.1667 119.5,61.8333 119.5,66.5C 114.167,96.5 96.5,114.167 66.5,119.5C 62.5,119.5 58.5,119.5 54.5,119.5C 41.2903,118.898 29.957,113.898 20.5,104.5C 26.0177,98.8165 31.351,92.9832 36.5,87C 43.7447,89.8929 51.4114,91.7263 59.5,92.5C 73.6465,92.5144 83.8132,86.181 90,73.5C 97.1507,47.4534 87.6507,31.4534 61.5,25.5C 60.1691,16.8035 60.1691,8.13683 61.5,-0.5 Z" /></g> + <g><path style="opacity:1" fill="#51c15c" d="M -0.5,66.5 C -0.5,61.8333 -0.5,57.1667 -0.5,52.5C 0.666576,42.9739 3.99991,34.1406 9.5,26C 10.9078,25.6848 12.2411,26.0181 13.5,27C 18.5,30.6667 23.5,34.3333 28.5,38C 29.8045,38.804 30.4712,39.9707 30.5,41.5C 24.1818,56.0341 25.5151,69.7007 34.5,82.5C 29.4427,88.737 24.1093,94.9037 18.5,101C 17.5,101.667 16.5,101.667 15.5,101C 6.46176,91.0972 1.12842,79.5972 -0.5,66.5 Z" /></g> + </svg> + ), + }, + web_activity_time_tracker: { + name: "Web Activity Time Tracker", + Icon: () => ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120"> + <g><path style="opacity:1" fill="#023750" d="M 45.5,-0.5 C 54.8333,-0.5 64.1667,-0.5 73.5,-0.5C 74.7785,2.00241 74.7785,4.50241 73.5,7C 70.1832,7.49834 66.8499,7.66501 63.5,7.5C 63.5,9.83333 63.5,12.1667 63.5,14.5C 74.5659,14.5351 84.2325,18.2017 92.5,25.5C 94.3586,23.8076 96.3586,22.3076 98.5,21C 105.333,19.8333 108.167,22.6667 107,29.5C 105.692,31.6414 104.192,33.6414 102.5,35.5C 120.15,66.7322 114.483,92.8989 85.5,114C 80.285,116.571 74.9516,118.404 69.5,119.5C 62.8333,119.5 56.1667,119.5 49.5,119.5C 26.929,113.761 12.7623,99.4278 7,76.5C 4.65865,61.5335 7.82532,47.8669 16.5,35.5C 14.4346,32.9573 13.1012,30.1239 12.5,27C 13.0621,21.4471 16.0621,19.4471 21.5,21C 23.5852,22.0414 25.2519,23.5414 26.5,25.5C 34.7685,18.2011 44.4352,14.5344 55.5,14.5C 55.5,12.1667 55.5,9.83333 55.5,7.5C 52.1501,7.66501 48.8168,7.49834 45.5,7C 44.2215,4.50241 44.2215,2.00241 45.5,-0.5 Z" /></g> + <g><path style="opacity:1" fill="#eff2f0" d="M 52.5,21.5 C 71.0342,20.1879 85.8675,26.8546 97,41.5C 108.799,63.1963 106.299,83.0297 89.5,101C 64.3901,118.657 41.8901,115.824 22,92.5C 11.2242,73.4867 12.2242,55.1534 25,37.5C 32.8691,29.7679 42.0357,24.4345 52.5,21.5 Z" /></g> + <g><path style="opacity:1" fill="#6aba57" d="M 53.5,29.5 C 54.8333,29.5 56.1667,29.5 57.5,29.5C 57.3354,32.5184 57.502,35.5184 58,38.5C 59,39.8333 60,39.8333 61,38.5C 61.3333,35.5 61.6667,32.5 62,29.5C 82.4616,32.4608 93.9616,44.1274 96.5,64.5C 93.5525,64.2229 90.7191,64.5562 88,65.5C 87.3162,66.7839 87.4829,67.9505 88.5,69C 91.1667,69.3333 93.8333,69.6667 96.5,70C 93.7002,90.1331 82.2002,101.633 62,104.5C 61.502,101.518 61.3354,98.5184 61.5,95.5C 60.1667,95.5 58.8333,95.5 57.5,95.5C 57.6646,98.5184 57.498,101.518 57,104.5C 36.7998,101.633 25.2998,90.1331 22.5,70C 25.1667,69.6667 27.8333,69.3333 30.5,69C 31.5171,67.9505 31.6838,66.7839 31,65.5C 28.2809,64.5562 25.4475,64.2229 22.5,64.5C 24.6824,45.6491 35.0158,33.9824 53.5,29.5 Z" /></g> + <g><path style="opacity:1" fill="#033751" d="M 79.5,41.5 C 84.1225,40.4082 85.9558,42.0748 85,46.5C 76.4048,54.4232 68.5715,62.9232 61.5,72C 55.7247,72.2045 53.5581,69.3712 55,63.5C 64.2013,57.4732 72.3679,50.1399 79.5,41.5 Z" /></g> + </svg> + ), + }, + history_trends_unlimited: { + name: "History Trends Unlimited", + Icon: () => ( + <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 120 120"> + <g><path style="opacity:1" fill="#e6e000" d="M 59.5,5.5 C 59.5,23.8333 59.5,42.1667 59.5,60.5C 42.4539,65.6783 25.4539,71.0117 8.5,76.5C 1.16551,47.0244 10.8322,24.8577 37.5,10C 44.4758,6.7715 51.8091,5.2715 59.5,5.5 Z" /></g> + <g><path style="opacity:1" fill="#446fa1" d="M 59.5,5.5 C 87.1275,6.95499 104.961,20.955 113,47.5C 115.24,57.3463 114.74,67.013 111.5,76.5C 94.1807,71.1767 76.8474,65.8433 59.5,60.5C 59.5,42.1667 59.5,23.8333 59.5,5.5 Z" /></g> + <g><path style="opacity:1" fill="#a7433d" d="M 59.5,60.5 C 76.8474,65.8433 94.1807,71.1767 111.5,76.5C 105.384,93.7824 93.7178,105.616 76.5,112C 52.7659,118.31 32.9326,112.144 17,93.5C 13.0956,88.3579 10.2623,82.6912 8.5,76.5C 25.4539,71.0117 42.4539,65.6783 59.5,60.5 Z" /></g> + </svg> + ), + }, +} const STEP_TITLES = [ t(msg => msg.dataManage.importOther.step1), t(msg => msg.dataManage.importOther.step2), ] +const dropdownCls = css` + width: 100%; + & .el-button-group { + width: 100%; + display: flex; + } + & .el-button-group > .el-button:first-child { + flex: 1; + min-width: 0; + } +` + const _default = defineComponent(() => { const { refreshMemory } = useDataMemory() + const [ext, setExt] = useState<OtherExtension>('webtime_tracker') + useRequest(detectWatt, { onSuccess: v => v && setExt('web_activity_time_tracker') }) + const buttonText = computed(() => t(msg => msg.dataManage.restoreFromOther, { ext: EXTENSION_CONFIGS[ext.value].name })) const { step, open } = initDialogSopContext<ImportForm>({ stepCount: 2, @@ -49,20 +105,32 @@ const _default = defineComponent(() => { }) return () => <> - <ElButton + <ElDropdown + splitButton + type='primary' size="large" - type="warning" - icon={Upload} - onClick={() => open()} - style={{ margin: 0, flex: 1, width: '100%', textWrap: 'wrap', lineHeight: '1.4em' }} - > - {t(msg => msg.item.operation.importOtherData)} - </ElButton> - <DialogSop - title={t(msg => msg.item.operation.importOtherData)} - stepTitles={STEP_TITLES} - width='80%' top="10vh" + class={dropdownCls} + style={{ width: '100%' }} + onCommand={(ext: OtherExtension) => setExt(ext)} + onClick={() => open({ ext: ext.value, data: { rows: [], focus: true, time: true } })} + v-slots={{ + dropdown: () => ( + <ElDropdownMenu> + {Object.entries(EXTENSION_CONFIGS).map(([ext, config]) => ( + <ElDropdownItem key={ext} command={ext}> + {config.name} + </ElDropdownItem> + ))} + </ElDropdownMenu> + ), + }} > + <Flex gap={2} align='center'> + <ElIcon size='1.4em'>{h(EXTENSION_CONFIGS[ext.value].Icon)}</ElIcon> + <ElText truncated style={{ minWidth: 0, color: 'inherit', fontSize: 'inherit', lineHeight: 'inherit' }}>{buttonText.value}</ElText> + </Flex> + </ElDropdown> + <DialogSop title={buttonText.value} stepTitles={STEP_TITLES} width='80%' top="10vh"> <Flex width="100%" justify="center"> <Step1 v-show={step.value === 0} /> <Step2 v-show={step.value === 1} /> diff --git a/src/pages/app/components/DataManage/Migration/index.tsx b/src/pages/app/components/DataManage/Migration/index.tsx index 9fb79d45f..256340807 100644 --- a/src/pages/app/components/DataManage/Migration/index.tsx +++ b/src/pages/app/components/DataManage/Migration/index.tsx @@ -7,41 +7,79 @@ import { sendMsg2Runtime } from '@api/sw/common' import { t } from '@app/locale' -import { Download } from "@element-plus/icons-vue" +import { Download, Upload } from "@element-plus/icons-vue" +import { useManualRequest } from '@hooks' import Flex from "@pages/components/Flex" -import { exportJson } from "@util/file" +import { deserialize, exportJson } from "@util/file" import { formatTime } from "@util/time" -import { ElButton, ElCard } from "element-plus" -import { type FunctionalComponent, type StyleValue } from "vue" +import { ElButton, ElCard, ElMessage } from "element-plus" +import { defineComponent, ref, type StyleValue } from "vue" +import { useDataMemory } from '../context' import DataManageAlert from '../DataManageAlert' -import ImportButton from "./ImportButton" import ImportOtherButton from "./ImportOtherButton" -async function handleExport() { - const data = await sendMsg2Runtime('immigration.export') - const timestamp = formatTime(new Date(), '{y}{m}{d}_{h}{i}{s}') - exportJson(data, `timer_backup_${timestamp}`) +const BUTTON_STYLE: StyleValue = { + width: '100%', + margin: 0, } -const Migration: FunctionalComponent = () => ( - <ElCard style={{ width: '100%' } satisfies StyleValue}> - <Flex column gap={20} justify="center" height="100%" align="center"> - <DataManageAlert text={msg => msg.dataManage.migrationAlert} /> - <Flex column gap={20} maxWidth={350} flex={1}> - <ElButton - size="large" - type="success" - icon={Download} - onClick={handleExport} - style={{ flex: 1 } satisfies StyleValue} - > - {t(msg => msg.item.operation.exportWholeData)} - </ElButton> - <ImportButton /> - <ImportOtherButton /> +const Migration = defineComponent(() => { + const { refreshMemory } = useDataMemory() + const fileInput = ref<HTMLInputElement>() + + const { refresh: handleExport } = useManualRequest(async () => { + const data = await sendMsg2Runtime('immigration.export') + const timestamp = formatTime(new Date(), '{y}{m}{d}_{h}{i}{s}') + exportJson(data, `timer_backup_${timestamp}`) + }, { + loadingOptions: { fullscreen: true }, + onSuccess: () => ElMessage.success(t(msg => msg.operation.successMsg)), + }) + + const { refresh: onFileSelected } = useManualRequest(async () => { + const input = fileInput.value + try { + const file = input?.files?.[0] + if (!file) throw new Error(t(msg => msg.dataManage.importOther.fileNotSelected)) + const fileText = await file.text() + const data = deserialize(fileText) + if (!data) throw new Error(t(msg => msg.dataManage.importError)) + await sendMsg2Runtime('immigration.import', data) + } finally { + if (input) input.value = '' + } + }, { + loadingOptions: { fullscreen: true }, + onSuccess: () => { + refreshMemory() + ElMessage.success(t(msg => msg.operation.successMsg)) + }, + onError: e => ElMessage.error(e instanceof Error ? e.message : String(e)), + }) + + return () => ( + <ElCard style={{ width: '100%' } satisfies StyleValue}> + <Flex column width='100%' align='center' gap={16}> + <DataManageAlert text={msg => msg.dataManage.migrationAlert} /> + <Flex column gap={20} maxWidth={350} width='100%' align='stretch'> + <ImportOtherButton /> + <ElButton size="large" icon={Download} onClick={handleExport} style={BUTTON_STYLE}> + {t(msg => msg.dataManage.exportData)} + </ElButton> + <ElButton size="large" icon={Upload} onClick={() => fileInput.value?.click()} style={BUTTON_STYLE}> + {t(msg => msg.dataManage.restoreData)} + <input + ref={fileInput} + type="file" + accept=".json" + style={{ display: "none" }} + onChange={onFileSelected} + /> + </ElButton> + </Flex> </Flex> - </Flex> - </ElCard> -) + </ElCard> + ) +}) export default Migration diff --git a/src/pages/app/components/DataManage/index.tsx b/src/pages/app/components/DataManage/index.tsx index 8e8c21013..50ca96be0 100644 --- a/src/pages/app/components/DataManage/index.tsx +++ b/src/pages/app/components/DataManage/index.tsx @@ -24,13 +24,13 @@ export default defineComponent(() => { <ElScrollbar height="100%" style={{ width: '100%' } satisfies StyleValue}> <ContentContainer> <Flex column gap={22}> - <Flex gap={22} height={ltSm.value ? undefined : 300} column={ltSm.value}> - <Flex height='100%' flex={5}> - <MemoryInfo /> - </Flex> - <Flex height='100%' flex={5}> + <Flex gap={22} align='start' column={ltSm.value}> + <Flex flex={5} width='100%' style={{ minWidth: 0 }}> <Migration /> </Flex> + <Flex flex={5} width='100%' style={{ minWidth: 0 }}> + <MemoryInfo /> + </Flex> </Flex> <ClearPanel /> </Flex> diff --git a/src/pages/hooks/useRequest.ts b/src/pages/hooks/useRequest.ts index de8294839..c36c81ecd 100644 --- a/src/pages/hooks/useRequest.ts +++ b/src/pages/hooks/useRequest.ts @@ -1,4 +1,4 @@ -import { ElLoadingService } from "element-plus" +import { ElLoadingService, type LoadingOptions } from "element-plus" import { onBeforeMount, onMounted, ref, shallowRef, watch, type Ref, type ShallowRef, type WatchSource, @@ -9,6 +9,7 @@ export type RequestOption<T, P extends any[]> = { defaultValue?: T loadingTarget?: string | Ref<HTMLElement | undefined> | Getter<HTMLElement | undefined> loadingText?: string + loadingOptions?: LoadingOptions defaultParam?: P deps?: WatchSource<unknown> | WatchSource<unknown>[] onSuccess?: (result: T, ...p: P) => void, @@ -45,7 +46,6 @@ export function useRequest<P extends any[], T>( getter: (...p: P) => Awaitable<T | undefined>, option?: RequestOption<T, P>, ): RequestResult<T | undefined, P> - export function useRequest<P extends any[], T>( getter: (...p: P) => Promise<T> | T, option?: RequestOption<T, P>, @@ -53,21 +53,19 @@ export function useRequest<P extends any[], T>( const { manual = false, defaultValue, defaultParam = ([] as any[] as P), - loadingTarget, loadingText, deps, onSuccess, onError, - } = option || {} + } = option ?? {} const data = shallowRef(defaultValue) as ShallowRef<T> const loading = ref(false) const param = ref<P>() const ts = ref<number>(Date.now()) + const createLoading = useLoading(option) + const refreshAsync = async (...p: P) => { loading.value = true - let loadingEl = await findLoadingEl(loadingTarget) - // fallback use document - !loadingEl && loadingText && (loadingEl = document.body) - const loadingInstance = loadingEl ? ElLoadingService({ target: loadingEl, text: loadingText }) : null + const loadingInstance = await createLoading?.() try { param.value = p const value = await getter?.(...p) @@ -85,7 +83,7 @@ export function useRequest<P extends any[], T>( const refresh = (...p: P) => { refreshAsync(...p) } if (!manual) { // If loading target specified, do first query after mounted - const hook = loadingTarget ? onMounted : onBeforeMount + const hook = option?.loadingTarget ? onMounted : onBeforeMount hook(() => refresh(...defaultParam)) } if (deps && (!Array.isArray(deps) || deps?.length)) { @@ -93,4 +91,19 @@ export function useRequest<P extends any[], T>( } const refreshAgain = () => param.value && refresh(...param.value) return { data, ts, refresh, refreshAsync, refreshAgain, loading, param } +} + +const useLoading = <T, P extends any[]>(option?: RequestOption<T, P>) => { + const { loadingTarget, loadingText, loadingOptions } = option ?? {} + + if (loadingOptions) return () => ElLoadingService(loadingOptions) + if (loadingTarget || loadingText) { + return async () => { + let loadingEl = await findLoadingEl(loadingTarget) + // fallback use document + !loadingEl && loadingText && (loadingEl = document.body) + return loadingEl ? ElLoadingService({ target: loadingEl, text: loadingText }) : null + } + } + return null } \ No newline at end of file diff --git a/src/util/constant/environment.ts b/src/util/constant/environment.ts index c2461ba53..2004a3536 100644 --- a/src/util/constant/environment.ts +++ b/src/util/constant/environment.ts @@ -41,7 +41,7 @@ export const BROWSER_NAME = browser export const IS_FIREFOX = BROWSER_NAME === 'firefox' -const IS_EDGE = BROWSER_NAME === 'edge' +export const IS_EDGE = BROWSER_NAME === 'edge' export const IS_CHROME = BROWSER_NAME === 'chrome' From 2826ab5e61a6cde41ee2594642c7bc0b94cc8b32 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Tue, 12 May 2026 19:11:52 +0800 Subject: [PATCH 158/174] feat: share on the popup page --- src/i18n/message/popup/content-resource.json | 43 ++-- src/i18n/message/popup/content.ts | 12 +- src/i18n/message/popup/index.ts | 4 +- src/i18n/message/popup/limit-resource.json | 29 --- src/i18n/message/popup/limit.ts | 13 -- .../categories/Limit/use2faSetup/Form.tsx | 40 +--- .../popup/components/Footer/LimitToolbar.tsx | 2 +- .../popup/components/Limit/Content/Wrapper.ts | 6 +- .../popup/components/Limit/Content/index.tsx | 2 +- .../components/Percentage/Cate/Wrapper.ts | 4 +- .../components/Percentage/Site/Wrapper.ts | 4 +- .../popup/components/Percentage/chart.ts | 202 ++++++++++++++---- src/pages/util/qrcode.ts | 43 ++++ src/util/constant/url.ts | 1 + 14 files changed, 264 insertions(+), 141 deletions(-) delete mode 100644 src/i18n/message/popup/limit-resource.json delete mode 100644 src/i18n/message/popup/limit.ts create mode 100644 src/pages/util/qrcode.ts diff --git a/src/i18n/message/popup/content-resource.json b/src/i18n/message/popup/content-resource.json index f08d66281..20d4b6025 100644 --- a/src/i18n/message/popup/content-resource.json +++ b/src/i18n/message/popup/content-resource.json @@ -9,7 +9,6 @@ "lastDays": "近 {n} 天数据", "allTime": "全部数据" }, - "saveAsImageTitle": "保存", "averageCount": "平均每天 {value} 次", "averageTime": "平均每天 {value}", "totalTime": "共 {totalTime}", @@ -18,6 +17,15 @@ }, "ranking": { "includingCount": "含 {siteCount} 个网站" + }, + "limit": { + "noData": "此URL无限制规则", + "newOne": "新建", + "timeUsed": "已使用 {percent}", + "visitUsed": "已使用 {used} 次", + "remain": "剩余 {remaining}", + "noLimit": "无限制", + "notHit": "未命中" } }, "zh_TW": { @@ -30,7 +38,6 @@ "lastDays": "近{n}天紀錄", "allTime": "全部紀錄" }, - "saveAsImageTitle": "儲存圖片", "averageCount": "平均每日 {value} 次", "averageTime": "平均每日 {value}", "totalTime": "總計 {totalTime}", @@ -51,7 +58,8 @@ "lastDays": "Last {n} days' data", "allTime": "All data" }, - "saveAsImageTitle": "Snapshot", + "shareTitle": "Share", + "installTip": "Scan to install", "averageCount": "{value} times per day on average", "averageTime": "{value} per day on average", "totalTime": "Total {totalTime}", @@ -60,6 +68,15 @@ }, "ranking": { "includingCount": "Including {siteCount} sites" + }, + "limit": { + "noData": "No limit rule effective for this URL", + "newOne": "New One", + "timeUsed": "{percent} used", + "visitUsed": "{used} count visited", + "remain": "{remaining} remaining", + "noLimit": "No limit", + "notHit": "Not in effect" } }, "ja": { @@ -72,7 +89,6 @@ "lastDays": "過去 {n} 日間のデータ", "allTime": "すべてのデータ" }, - "saveAsImageTitle": "スクリーンショット", "averageCount": "1日平均 {value} 回", "averageTime": "1日平均 {value}", "totalTime": "合計 {totalTime}", @@ -81,6 +97,15 @@ }, "ranking": { "includingCount": "{siteCount} サイトを含む" + }, + "limit": { + "noData": "このURLには制限が適用されません", + "newOne": "追加", + "timeUsed": "{percent} 使用済み", + "visitUsed": "訪問回数{used}回", + "remain": "残り {remaining}", + "noLimit": "制限なし", + "notHit": "無効です" } }, "pt_PT": { @@ -93,7 +118,6 @@ "lastDays": "Dados dos últimos {n} dias", "allTime": "Todos os dados" }, - "saveAsImageTitle": "Captura", "averageCount": "Média de {value} vezes por dia", "averageTime": "Média de {value} por dia", "totalTime": "Total: {totalTime}", @@ -114,7 +138,6 @@ "lastDays": "Дані за минулі {n} днів", "allTime": "Всі дані" }, - "saveAsImageTitle": "Знімок", "averageCount": "{value} разів на день у середньому", "averageTime": "{value} на день у середньому", "totalTime": "Всього {totalTime}", @@ -135,7 +158,6 @@ "lastDays": "Datos de los últimos {n} días", "allTime": "Todos los datos" }, - "saveAsImageTitle": "Captura de pantalla", "averageCount": "{value} veces al día en promedio", "averageTime": "{value} al día en promedio", "totalTime": "Total {totalTime}", @@ -156,7 +178,6 @@ "lastDays": "Daten der letzten {n} Tage", "allTime": "Alle Daten" }, - "saveAsImageTitle": "Bildschirmfoto", "averageCount": "{value} mal pro Tag", "averageTime": "{value} pro Tag im Durchschnitt", "totalTime": "Insgesamt {totalTime}", @@ -177,7 +198,6 @@ "lastDays": "Les données de ces {n} derniers jours", "allTime": "Toutes les données" }, - "saveAsImageTitle": "Capture d'écran", "averageCount": "{value} fois par jour en moyenne", "averageTime": "{value} par jour en moyenne", "totalTime": "{totalTime} au total", @@ -198,7 +218,6 @@ "lastDays": "Данные за последние {n} дней", "allTime": "Все данные" }, - "saveAsImageTitle": "Снимок", "averageCount": "{value} раз в день в среднем", "averageTime": "{value} в день в среднем", "totalTime": "Всего {totalTime}", @@ -219,7 +238,6 @@ "lastDays": "بيانات آخر {n} يوم", "allTime": "كل البيانات" }, - "saveAsImageTitle": "لقطة", "averageCount": "متوسط {value} زيارة يومياً", "averageTime": "متوسط {value} يومياً", "totalTime": "الإجمالي {totalTime}", @@ -240,7 +258,6 @@ "lastDays": "Son {n} günün verileri", "allTime": "Tüm Veriler" }, - "saveAsImageTitle": "Grafiği İndir", "averageCount": "Günde ortalama {value} kez", "averageTime": "Günde ortalama {value}", "totalTime": "Toplam Zaman {totalTime}", @@ -261,7 +278,6 @@ "lastDays": "Ostatnie dane z {n} dnia/i", "allTime": "Wszystkie dane" }, - "saveAsImageTitle": "Podsumowanie", "averageCount": "Średnio {value} razy co dzień", "averageTime": "Średnio {value} razy dziennie", "totalTime": "Ogółem {totalTime}", @@ -282,7 +298,6 @@ "lastDays": "Ultimo {n} giorni dati", "allTime": "Tutti i dati" }, - "saveAsImageTitle": "Snapshot", "averageTime": "{value} al giorno in media", "totalTime": "Totale {totalTime}", "totalCount": "{totalCount} volte Totale" diff --git a/src/i18n/message/popup/content.ts b/src/i18n/message/popup/content.ts index f00805ce3..1e1fa85e8 100644 --- a/src/i18n/message/popup/content.ts +++ b/src/i18n/message/popup/content.ts @@ -15,7 +15,8 @@ type PopupDuration = export type ContentMessage = { percentage: { title: { [key in PopupDuration]: string } - saveAsImageTitle: string + shareTitle: string + installTip: string averageTime: string averageCount: string totalTime: string @@ -25,6 +26,15 @@ export type ContentMessage = { ranking: { includingCount: string } + limit: { + noData: string + newOne: string + timeUsed: string + visitUsed: string + remain: string + noLimit: string + notHit: string + } } const contentMessages = resource as Messages<ContentMessage> diff --git a/src/i18n/message/popup/index.ts b/src/i18n/message/popup/index.ts index dbb909a0c..3b220b708 100644 --- a/src/i18n/message/popup/index.ts +++ b/src/i18n/message/popup/index.ts @@ -14,11 +14,10 @@ import { merge, type MessageRoot } from "../merge" import contentMessages, { type ContentMessage } from "./content" import footerMessages, { type FooterMessage } from "./footer" import headerMessages, { type HeaderMessage } from "./header" -import limitMessages, { type LimitMessage } from './limit' + export type PopupMessage = { content: ContentMessage - limit: LimitMessage item: ItemMessage meta: MetaMessage base: BaseMessage @@ -30,7 +29,6 @@ export type PopupMessage = { const MESSAGE_ROOT: MessageRoot<PopupMessage> = { content: contentMessages, - limit: limitMessages, item: itemMessages, meta: metaMessages, base: baseMessages, diff --git a/src/i18n/message/popup/limit-resource.json b/src/i18n/message/popup/limit-resource.json deleted file mode 100644 index 0d9761c91..000000000 --- a/src/i18n/message/popup/limit-resource.json +++ /dev/null @@ -1,29 +0,0 @@ -{ - "en": { - "noData": "No limit rule effective for this URL", - "newOne": "New One", - "timeUsed": "{percent} used", - "visitUsed": "{used} count visited", - "remain": "{remaining} remaining", - "noLimit": "No limit", - "notHit": "Not in effect" - }, - "zh_CN": { - "noData": "此URL无限制规则", - "newOne": "新建", - "timeUsed": "已使用 {percent}", - "visitUsed": "已使用 {used} 次", - "remain": "剩余 {remaining}", - "noLimit": "无限制", - "notHit": "未命中" - }, - "ja": { - "noData": "このURLには制限が適用されません", - "newOne": "追加", - "timeUsed": "{percent} 使用済み", - "visitUsed": "訪問回数{used}回", - "remain": "残り {remaining}", - "noLimit": "制限なし", - "notHit": "無効です" - } -} \ No newline at end of file diff --git a/src/i18n/message/popup/limit.ts b/src/i18n/message/popup/limit.ts deleted file mode 100644 index 167044c96..000000000 --- a/src/i18n/message/popup/limit.ts +++ /dev/null @@ -1,13 +0,0 @@ -import resources from "./limit-resource.json" - -export type LimitMessage = { - noData: string - newOne: string - timeUsed: string - visitUsed: string - remain: string - noLimit: string - notHit: string -} - -export default resources satisfies Messages<LimitMessage> \ No newline at end of file diff --git a/src/pages/app/components/Option/categories/Limit/use2faSetup/Form.tsx b/src/pages/app/components/Option/categories/Limit/use2faSetup/Form.tsx index e30f8f79a..b12885a20 100644 --- a/src/pages/app/components/Option/categories/Limit/use2faSetup/Form.tsx +++ b/src/pages/app/components/Option/categories/Limit/use2faSetup/Form.tsx @@ -3,43 +3,10 @@ import { CopyDocument } from '@element-plus/icons-vue' import { useRequest, useState } from '@hooks' import Flex from '@pages/components/Flex' import Img from '@pages/components/Img' +import { generateQrDataUrl } from '@pages/util/qrcode' import { ElButton, ElForm, ElFormItem, ElInput, ElMessage, ElText } from 'element-plus' -import qrcode from 'qrcode-generator' import { computed, defineComponent, toRef, watch } from 'vue' -function generateQrDataUrl(text: string): string { - const qr = qrcode(0, 'M') - qr.addData(text) - qr.make() - - const margin = 1 - const targetWidth = 200 - - const moduleCount = qr.getModuleCount() - const totalModules = moduleCount + margin * 2 - const scale = Math.max(1, Math.floor(targetWidth / totalModules)) - const canvasSize = totalModules * scale - - const canvas = document.createElement('canvas') - canvas.width = canvasSize - canvas.height = canvasSize - const ctx = canvas.getContext('2d') - if (!ctx) throw new Error('Failed to create QR canvas context') - - ctx.fillStyle = '#fff' - ctx.fillRect(0, 0, canvasSize, canvasSize) - ctx.fillStyle = '#000' - - for (let row = 0; row < moduleCount; row++) { - for (let col = 0; col < moduleCount; col++) { - if (!qr.isDark(row, col)) continue - ctx.fillRect((col + margin) * scale, (row + margin) * scale, scale, scale) - } - } - - return canvas.toDataURL('image/png') -} - function extractSecret(otpauth: string): string { const raw = new URL(otpauth).searchParams.get('secret') return raw ?? 'Secret is unknown' @@ -65,7 +32,10 @@ const _default = defineComponent<{ otpauth: string }>((props, ctx) => { const [code, setCode] = useState('') watch(otpauth, () => setCode(''), { immediate: true }) - const { data: qrData } = useRequest(() => generateQrDataUrl(otpauth.value), { deps: otpauth }) + const { data: qrData } = useRequest( + () => generateQrDataUrl({ text: otpauth.value, size: 200 }), + { deps: otpauth }, + ) ctx.expose({ getVerifyCode: () => code.value, } satisfies FormInstance) diff --git a/src/pages/popup/components/Footer/LimitToolbar.tsx b/src/pages/popup/components/Footer/LimitToolbar.tsx index 0487c7b8c..653796af5 100644 --- a/src/pages/popup/components/Footer/LimitToolbar.tsx +++ b/src/pages/popup/components/Footer/LimitToolbar.tsx @@ -56,7 +56,7 @@ const LimitToolbar = defineComponent(() => { )} {!loading.value && !items.value.length && ( <ElButton type='primary' icon={Plus} onClick={handleNew}> - {t(msg => msg.limit.newOne)} + {t(msg => msg.content.limit.newOne)} </ElButton> )} </Flex> diff --git a/src/pages/popup/components/Limit/Content/Wrapper.ts b/src/pages/popup/components/Limit/Content/Wrapper.ts index f6aae2135..56a8b786d 100644 --- a/src/pages/popup/components/Limit/Content/Wrapper.ts +++ b/src/pages/popup/components/Limit/Content/Wrapper.ts @@ -140,12 +140,12 @@ class Wrapper extends EchartsWrapper<timer.limit.Item, EcOption> { } private builtLimitPart(time: [number, number | undefined], visit: [number, number | undefined], leftPos: string): ChartPart { - const noLimitText = t(msg => msg.limit.noLimit) + const noLimitText = t(msg => msg.content.limit.noLimit) const [timeUsed, timeLimit] = time const timeMax = timeLimit ? timeLimit * MILL_PER_SECOND : 0 const timeLabel = timeMax - ? t(msg => msg.limit.remain, { remaining: formatPeriodCommon(timeMax - timeUsed, true) }) + ? t(msg => msg.content.limit.remain, { remaining: formatPeriodCommon(timeMax - timeUsed, true) }) : noLimitText const timeOpts: GaugeOptions = { name: 'time', center: leftPos, @@ -230,7 +230,7 @@ class Wrapper extends EchartsWrapper<timer.limit.Item, EcOption> { const hitPeriod = blocked.find(({ start, end }) => nowMinutes >= start && nowMinutes < end) const infoText = hitPeriod ? `${fmt(hitPeriod.start)} - ${fmt(hitPeriod.end)}` - : t(msg => msg.limit.notHit) + : t(msg => msg.content.limit.notHit) return { titles: [ diff --git a/src/pages/popup/components/Limit/Content/index.tsx b/src/pages/popup/components/Limit/Content/index.tsx index 9e7e52f36..3667d82e0 100644 --- a/src/pages/popup/components/Limit/Content/index.tsx +++ b/src/pages/popup/components/Limit/Content/index.tsx @@ -9,7 +9,7 @@ const Empty: FunctionalComponent<{}> = () => ( <Flex column align="center" justify="center" height='100%' gap={20}> <ElResult icon='info' - title={t(msg => msg.limit.noData)} + title={t(msg => msg.content.limit.noData)} /> </Flex> ) diff --git a/src/pages/popup/components/Percentage/Cate/Wrapper.ts b/src/pages/popup/components/Percentage/Cate/Wrapper.ts index 626c07ffe..36f9010b3 100644 --- a/src/pages/popup/components/Percentage/Cate/Wrapper.ts +++ b/src/pages/popup/components/Percentage/Cate/Wrapper.ts @@ -11,7 +11,7 @@ import type { } from "echarts" import type { ECElementEvent } from "echarts/core" import { - adaptDonutSeries, formatTooltip, generateSiteSeriesOption, generateTitleOption, generateToolboxOption, + adaptDonutSeries, formatTooltip, generateSiteSeriesOption, generateTitleOption, generateToolbox, handleClick, isOther, type PieSeriesItemOption, } from "../chart" import { type PercentageResult } from "../query" @@ -167,7 +167,7 @@ export default class SiteWrapper extends EchartsWrapper<PercentageResult, EcOpti formatter: (params: any) => formatTooltip(result, params), }, series, - toolbox: generateToolboxOption(), + toolbox: generateToolbox(() => this.instance), } return option } diff --git a/src/pages/popup/components/Percentage/Site/Wrapper.ts b/src/pages/popup/components/Percentage/Site/Wrapper.ts index fa4c49506..7a20a246c 100644 --- a/src/pages/popup/components/Percentage/Site/Wrapper.ts +++ b/src/pages/popup/components/Percentage/Site/Wrapper.ts @@ -7,7 +7,7 @@ import type { import type { ECElementEvent } from "echarts/core" import type { TopLevelFormatterParams } from "echarts/types/dist/shared" import { - formatTooltip, generateSiteSeriesOption, generateTitleOption, generateToolboxOption, handleClick, + formatTooltip, generateSiteSeriesOption, generateTitleOption, generateToolbox, handleClick, type PieSeriesItemOption, } from "../chart" import { type PercentageResult } from "../query" @@ -89,7 +89,7 @@ export default class SiteWrapper extends EchartsWrapper<PercentageResult, EcOpti inactiveColor, }, series, - toolbox: generateToolboxOption(), + toolbox: generateToolbox(() => this.instance), } return options } diff --git a/src/pages/popup/components/Percentage/chart.ts b/src/pages/popup/components/Percentage/chart.ts index 9b27af1ea..d95e58ba3 100644 --- a/src/pages/popup/components/Percentage/chart.ts +++ b/src/pages/popup/components/Percentage/chart.ts @@ -1,15 +1,20 @@ +import { getIconUrl, getRuntimeName } from "@api/chrome/runtime" import { createTab } from "@api/chrome/tab" +import { generateQrCanvas } from "@pages/util/qrcode" import { getCssVariable, getInfoColor, getPrimaryTextColor, getSecondaryTextColor } from "@pages/util/style" import { calJumpUrl } from "@popup/common" import { t } from '@popup/locale' import { sum, toMap } from "@util/array" import { IS_SAFARI } from "@util/constant/environment" +import { INSTALL_PAGE } from "@util/constant/url" import { isRtl } from "@util/document" import { generateSiteLabel } from "@util/site" import { getGroupName, isGroup, isSite } from "@util/stat" import { formatPeriodCommon, formatTime, parseTime } from "@util/time" import type { PieSeriesOption, TitleComponentOption, ToolboxComponentOption } from "echarts" +import type { ECharts } from "echarts/core" import type { CallbackDataParams, TopLevelFormatterParams } from "echarts/types/dist/shared" +import { ElMessage } from "element-plus" import { type PercentageResult } from "./query" function combineDate(start: Date, end: Date, format: string): string { @@ -22,10 +27,7 @@ function combineDate(start: Date, end: Date, format: string): string { const sy = start.getFullYear() const ey = end.getFullYear() - if (sy !== ey) { - // Different years - return normalStr - } + if (sy !== ey) return normalStr // The same years const execRes = /({d}|{m})[^{}]*({d}|{m})/.exec(format) @@ -46,13 +48,13 @@ function formatDateStr(date: Date | [Date, Date?] | undefined, dataDate: [string const format = t(msg => msg.calendar.dateFormat) if (!date) { - date = dataDate?.map(parseTime) as [Date, Date] + const start = parseTime(dataDate[0]) + const end = parseTime(dataDate[1]) + date = start ? [start, end] : undefined } if (!date) return '' - if (!Array.isArray(date)) { - // Single day - return formatTime(date, format) - } + // Single day + if (!Array.isArray(date)) return formatTime(date, format) const [start, end] = date return end ? combineDate(start, end, format) : formatTime(start, format) @@ -84,7 +86,7 @@ function calculateSubTitleText(result: PercentageResult): string { // Don't show averages for single-day durations (today/yesterday) const isSingleDay = duration === 'today' || duration === 'yesterday' - if (dateLength && dateLength > 0 && !isSingleDay) { // Changed: removed dimension check + if (dateLength && dateLength > 0 && !isSingleDay) { if (dimension === 'focus') { // Average time per day const total = sum(rows.map(r => r?.focus ?? 0)) @@ -114,22 +116,151 @@ export function generateTitleOption(result: PercentageResult, suffix?: string): } } -export function generateToolboxOption(): ToolboxComponentOption { +function snapshotChart(chart: ECharts, backgroundColor: string, connectedBackgroundColor: string): string { + const pixelRatio = 7 + const isSvg = chart.getZr().painter.getType() === 'svg' + return chart.getConnectedDataURL({ + type: isSvg ? 'svg' : 'png', + pixelRatio, + backgroundColor, + connectedBackgroundColor, + excludeComponents: ['toolbox'], + }) +} + +async function getIconDataUrl(): Promise<string> { + const res = await fetch(getIconUrl()) + if (!res.ok) throw new Error(`Failed to fetch extension icon: ${res.status}`) + const blob = await res.blob() + const type = blob.type || 'image/png' + const bytes = new Uint8Array(await blob.arrayBuffer()) + let binary = '' + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]!) + return `data:${type};base64,${btoa(binary)}` +} + +async function saveWithWatermark(instance: ECharts) { + const bgColor = getCssVariable('--el-card-bg-color', '.el-card')?.trim() ?? '#fff' + const footerBgColor = getCssVariable('--el-fill-color-light')?.trim() ?? bgColor + const textColor = getPrimaryTextColor()?.trim() ?? '#000' + const subTextColor = getSecondaryTextColor()?.trim() ?? '#888' + + const chartDataUrl = snapshotChart(instance, bgColor, footerBgColor) + + const chartImg = await new Promise<HTMLImageElement>((resolve, reject) => { + const img = new Image() + img.onload = () => resolve(img) + img.onerror = reject + img.src = chartDataUrl + }) + + const w = chartImg.width + const footerHeight = Math.round(w * 0.14) + const padding = Math.round(footerHeight * 0.15) + const qrSize = footerHeight - padding * 2 + const nameFontSize = Math.round(footerHeight * 0.28) + const subFontSize = Math.round(footerHeight * 0.18) + + const canvas = document.createElement('canvas') + canvas.width = w + canvas.height = chartImg.height + footerHeight + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('2D canvas context unavailable') + + ctx.fillStyle = bgColor + ctx.fillRect(0, 0, canvas.width, canvas.height) + ctx.drawImage(chartImg, 0, 0) + + ctx.fillStyle = footerBgColor + ctx.fillRect(0, chartImg.height, w, footerHeight) + + const footerTop = chartImg.height + + const qrCanvas = generateQrCanvas({ text: INSTALL_PAGE, size: qrSize }) + const qrX = w - padding - qrCanvas.width + const qrY = footerTop + Math.round((footerHeight - qrCanvas.height) / 2) + ctx.drawImage(qrCanvas, qrX, qrY) + + const iconSize = Math.round(footerHeight * 0.5) + const iconY = footerTop + Math.round((footerHeight - iconSize) / 2) + const iconSrc = await getIconDataUrl() + const iconImg = await new Promise<HTMLImageElement>((resolve, reject) => { + const img = new Image() + img.onload = () => resolve(img) + img.onerror = reject + img.src = iconSrc + }) + ctx.drawImage(iconImg, padding, iconY, iconSize, iconSize) + + const textX = padding + iconSize + Math.round(footerHeight * 0.1) + ctx.textBaseline = 'middle' + ctx.fillStyle = textColor + ctx.font = `bold ${nameFontSize}px sans-serif` + ctx.fillText(getRuntimeName(), textX, footerTop + Math.round(footerHeight * 0.35)) + + ctx.font = `${subFontSize}px sans-serif` + ctx.fillStyle = subTextColor + const tipText = t(msg => msg.content.percentage.installTip) + ctx.fillText(tipText, textX, footerTop + Math.round(footerHeight * 0.68)) + + const link = document.createElement('a') + link.hidden = true + link.download = 'Time_Tracker_Percentage.png' + link.href = canvas.toDataURL('image/png') + document.body.appendChild(link) + link.click() + document.body.removeChild(link) +} + +const MY_SAVE_ICON = ` + path://M812.333229 702.996063c-46.845072 0-89.466408 18.430848-119.288544 51.132804 + L319.564027 536.79845a103.801512 103.801512 0 0 0 0-51.132804l373.480658-215.858509 + c29.822136 32.63796 72.443472 52.540716 119.288544 52.540716 90.87432-1.407912 + 159.094057-69.563652 160.501969-160.437972C971.491282 71.03556 903.271546 1.407912 + 812.333229 0c-90.87432 1.407912-160.437973 71.03556-161.90988 161.909881 0 9.91938 + 1.471908 19.83876 2.87982 29.822136L282.638335 404.774702A160.629961 160.629961 0 0 0 + 161.941879 350.698081C71.067558 352.169989 1.43991 420.453722 0.031998 511.328042 + c1.407912 90.87432 71.03556 159.030061 161.909881 160.501969 46.845072 0 90.87432 + -21.310668 120.696456-54.012625l370.664834 214.450597c-1.407912 9.983376-2.815824 + 19.83876-2.815824 31.230048 1.407912 90.87432 71.03556 159.094057 161.90988 160.501969 + 90.87432-1.407912 159.030061-69.563652 160.437973-160.501969-1.407912-90.87432 + -69.563652-159.030061-160.501969-160.437972z +`.replace(/\s+/g, ' ').trim() + +export function generateToolbox(getInstance: () => ECharts | undefined): ToolboxComponentOption { + const toolboxIconColor = getPrimaryTextColor()?.trim() ?? '#5c6b7a' + return { show: true, top: 5, right: 5, + iconStyle: { + color: toolboxIconColor, + borderColor: toolboxIconColor, + borderWidth: 0, + }, + emphasis: { + iconStyle: { + color: toolboxIconColor, + borderColor: toolboxIconColor, + borderWidth: 0, + }, + }, feature: { - saveAsImage: { + mySave: { show: true, - title: t(msg => msg.content.percentage.saveAsImageTitle), - // file name - name: 'Time_Tracker_Percentage', - excludeComponents: ['toolbox'], - pixelRatio: 7, - backgroundColor: getCssVariable('--el-card-bg-color', '.el-card'), + title: t(msg => msg.content.percentage.shareTitle), + icon: MY_SAVE_ICON, + onclick: () => { + const inst = getInstance() + if (!inst) return + inst && void saveWithWatermark(inst).catch(err => { + console.info(err) + ElMessage.error('Could not save the image.') + }) + }, }, - } + }, } } @@ -184,17 +315,9 @@ const legend2LabelStyle = (legend: string): string => { return code.join('') } -type CallbackFormat = { - name: string - value: number - data: PieSeriesItemOption - percent: number -} - function formatLabel(params: CallbackDataParams, groupMap: Record<number, chrome.tabGroups.TabGroup>): string { - const format = (Array.isArray(params) ? params[0] : params) as CallbackFormat - const { name, data } = format || {} - const { row } = (data as PieSeriesItemOption) || {} + const { name, data } = params + const { row } = data as PieSeriesItemOption ?? {} if (!row) return 'NaN' if (isOther(row)) { @@ -218,7 +341,11 @@ type CustomOption = Pick< 'center' | 'radius' | 'selectedMode' | 'minShowLabelAngle' > -export function generateSiteSeriesOption(rows: timer.stat.Row[], result: PercentageResult, customOption: CustomOption): PieSeriesOption { +export function generateSiteSeriesOption( + rows: timer.stat.Row[], + result: PercentageResult, + customOption: CustomOption, +): PieSeriesOption { const { displaySiteName, query: { dimension }, itemCount, groups, donutChart } = result const groupMap = toMap(groups, g => g.id) @@ -229,12 +356,14 @@ export function generateSiteSeriesOption(rows: timer.stat.Row[], result: Percent if (isOther(row)) { item.itemStyle = { color: getInfoColor() } item.name = t(msg => msg.content.percentage.otherLabel, { count: row.count }) - } else if (isSite(row)) { - const { siteKey, alias, iconUrl } = row - const { host } = siteKey || {} + } else if (!isOther(row) && isSite(row as timer.stat.StatKey)) { + const { siteKey, alias, iconUrl } = row as timer.stat.SiteRow + const { host, type } = siteKey ?? {} const name = item.name = (displaySiteName ? (alias ?? host) : host) ?? '' const richValue: PieLabelRichValueOption = { ...BASE_LABEL_RICH_VALUE } - iconUrl && (richValue.backgroundColor = { image: iconUrl }) + if (type === 'normal' && iconUrl && !IS_SAFARI) { + richValue.backgroundColor = { image: iconUrl } + } iconRich[legend2LabelStyle(name)] = richValue } else if (isGroup(row)) { item.name = getGroupName(groupMap, row) @@ -299,7 +428,6 @@ function calcRealRadius( if (!radius) return radius if (Array.isArray(radius)) return radius if (typeof radius === 'number') return [radius * donutRadiusRatio, radius] - // String try { const percent = parseFloat(radius.replace('%', '')) const inner = percent * donutRadiusRatio @@ -326,7 +454,7 @@ export function formatTooltip({ query, dateLength }: PercentageResult, params: T const { name, value, percent, data } = format ?? {} const { row } = data as PieSeriesItemOption const { dimension, duration } = query - const itemValue = typeof value === 'number' ? value as number : 0 + const itemValue = typeof value === 'number' ? value : 0 let valueLine = dimension === 'time' ? itemValue : formatPeriodCommon(itemValue) // Display percent only when query focus time dimension === 'focus' && (valueLine += ` (${percent}%)`) @@ -340,7 +468,7 @@ export function formatTooltip({ query, dateLength }: PercentageResult, params: T // Don't show averages for single-day durations (today/yesterday) const isSingleDay = duration === 'today' || duration === 'yesterday' - if (dateLength && dateLength > 1 && !isSingleDay) { // Changed: simplified condition + if (dateLength && dateLength > 1 && !isSingleDay) { averageLine = calculateAverageText(dimension, itemValue / dateLength) } } diff --git a/src/pages/util/qrcode.ts b/src/pages/util/qrcode.ts new file mode 100644 index 000000000..4c5492fdf --- /dev/null +++ b/src/pages/util/qrcode.ts @@ -0,0 +1,43 @@ +import qrcode from 'qrcode-generator' + +type GenerateQrCanvasOption = { + text: string + size: number + margin?: number + errorCorrectionLevel?: 'L' | 'M' | 'Q' | 'H' +} + +export function generateQrCanvas(option: GenerateQrCanvasOption): HTMLCanvasElement { + const { text, size, margin = 1, errorCorrectionLevel = 'M' } = option + const qr = qrcode(0, errorCorrectionLevel) + qr.addData(text) + qr.make() + + const moduleCount = qr.getModuleCount() + const totalModules = moduleCount + margin * 2 + const scale = Math.max(1, Math.floor(size / totalModules)) + const canvasSize = totalModules * scale + + const canvas = document.createElement('canvas') + canvas.width = canvasSize + canvas.height = canvasSize + const ctx = canvas.getContext('2d') + if (!ctx) throw new Error('Failed to create QR canvas context') + + ctx.fillStyle = '#fff' + ctx.fillRect(0, 0, canvasSize, canvasSize) + ctx.fillStyle = '#000' + + for (let row = 0; row < moduleCount; row++) { + for (let col = 0; col < moduleCount; col++) { + if (!qr.isDark(row, col)) continue + ctx.fillRect((col + margin) * scale, (row + margin) * scale, scale, scale) + } + } + + return canvas +} + +export function generateQrDataUrl(option: GenerateQrCanvasOption): string { + return generateQrCanvas(option).toDataURL('image/png') +} diff --git a/src/util/constant/url.ts b/src/util/constant/url.ts index 7af029494..b684ff7ee 100644 --- a/src/util/constant/url.ts +++ b/src/util/constant/url.ts @@ -12,6 +12,7 @@ import { BROWSER_MAJOR_VERSION, BROWSER_NAME } from "./environment" export const FIREFOX_HOMEPAGE = 'https://addons.mozilla.org/firefox/addon/besttimetracker' export const CHROME_HOMEPAGE = 'https://chromewebstore.google.com/detail/time-tracker/dkdhhcbjijekmneelocdllcldcpmekmm' export const EDGE_HOMEPAGE = 'https://microsoftedge.microsoft.com/addons/detail/timer-the-web-time-is-e/fepjgblalcnepokjblgbgmapmlkgfahc' +export const INSTALL_PAGE = 'https://www.wfhg.cc/en/install' /** * @since 0.4.0 From a132a99554075d8038b557a58092b97cafc23be8 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Fri, 15 May 2026 11:37:06 +0800 Subject: [PATCH 159/174] fix: file tracking on FF (#752) --- src/api/chrome/action.ts | 4 +- src/background/badge-manager.ts | 58 ++++++++++------- src/background/service/limit-service.ts | 4 +- src/background/track-server/file-tracker.ts | 70 +++++++++++++++------ src/background/track-server/normal.ts | 24 ++++--- 5 files changed, 105 insertions(+), 55 deletions(-) diff --git a/src/api/chrome/action.ts b/src/api/chrome/action.ts index d32d1fb54..bb52a3c7c 100644 --- a/src/api/chrome/action.ts +++ b/src/api/chrome/action.ts @@ -1,5 +1,5 @@ -import { IS_FIREFOX, IS_MV3 } from "@util/constant/environment"; -import { handleError } from "./common"; +import { IS_FIREFOX, IS_MV3 } from "@util/constant/environment" +import { handleError } from "./common" const action = IS_MV3 ? chrome.action : chrome.browserAction diff --git a/src/background/badge-manager.ts b/src/background/badge-manager.ts index 5e06a96dc..c78465813 100644 --- a/src/background/badge-manager.ts +++ b/src/background/badge-manager.ts @@ -6,7 +6,7 @@ */ import { setBadgeBgColor, setBadgeText } from "@api/chrome/action" -import { listTabs } from "@api/chrome/tab" +import { listTabs, onTabUpdated } from "@api/chrome/tab" import { isNoneWindowId, onWindowFocusChanged, windowHolder } from "@api/chrome/window" import { IS_ANDROID } from "@util/constant/environment" import { extractHostname, isBrowserUrl } from "@util/pattern" @@ -59,17 +59,18 @@ async function clearAllBadge(): Promise<void> { } class BadgeManager { - private pausedTabId: number | undefined - private current: BadgeLocation | undefined - private visible = false + #pausedTabId: number | undefined + #current: BadgeLocation | undefined + #visible = false + #countLocalFiles = false async init(messageDispatcher: MessageDispatcher) { if (IS_ANDROID) return // do nothing on Android, since badge text is not supported const option = await optionHolder.get() await this.processOption(option) - optionHolder.addChangeListener(opt => { void this.processOption(opt) }) - whitelistHolder.addPostHandler(() => { void this.render() }) + optionHolder.addChangeListener(opt => this.processOption(opt)) + whitelistHolder.addPostHandler(() => this.render()) messageDispatcher.register('cs.idleChanged', (isIdle, sender) => { const tabId = sender?.tab?.id void (isIdle ? this.pause(tabId) : this.resume(tabId)) @@ -78,48 +79,59 @@ class BadgeManager { const target = await findActiveTab(windowId) await this.updateFocus(target) }) + onTabUpdated(async (tabId, { url }, { active }) => { + if (!active || !url) return + await this.updateFocus({ url, tabId }) + }) await this.updateFocus() } private async pause(tabId?: number) { if (typeof tabId !== 'number') return - this.pausedTabId = tabId + this.#pausedTabId = tabId await this.render() } private async resume(tabId?: number) { - if (typeof this.pausedTabId !== 'number') return - if (typeof tabId !== 'number' || this.pausedTabId !== tabId) return - this.pausedTabId = undefined + if (typeof this.#pausedTabId !== 'number') return + if (typeof tabId !== 'number' || this.#pausedTabId !== tabId) return + this.#pausedTabId = undefined await this.render() } async updateFocus(target?: BadgeLocation) { - this.current = target ?? await findActiveTab() + this.#current = target ?? await findActiveTab() await this.render() } - private async processOption(option: timer.option.AppearanceOption) { - const { displayBadgeText, badgeBgColor } = option ?? {} - const before = this.visible - this.visible = !!displayBadgeText - if (!this.visible && before) await clearAllBadge() - await setBadgeBgColor(badgeBgColor) - if (before !== this.visible) await this.render() + private async processOption(option: timer.option.DefaultOption) { + const { displayBadgeText, badgeBgColor, countLocalFiles } = option + + const changed = this.#visible !== displayBadgeText || this.#countLocalFiles !== countLocalFiles + this.#countLocalFiles = countLocalFiles + this.#visible = displayBadgeText + + if (!this.#visible) { + await clearAllBadge() + } else { + await setBadgeBgColor(badgeBgColor) + if (changed) await this.render() + } } private async render(): Promise<void> { const badgeText = await this.resolveBadgeText() - await setBadgeText(badgeText, this.current?.tabId) + await setBadgeText(badgeText, this.#current?.tabId) } private async resolveBadgeText(): Promise<string> { - if (!this.current || !this.visible) return '' - const { url, tabId } = this.current + if (!this.#current || !this.#visible) return '' + const { url, tabId } = this.#current if (isBrowserUrl(url)) return '∅' - const { host } = extractHostname(url) + const { host, protocol } = extractHostname(url) + if (protocol === 'file' && !this.#countLocalFiles) return '∅' if (whitelistHolder.contains(host, url)) return 'W' - if (this.pausedTabId === tabId) return 'P' + if (this.#pausedTabId === tabId) return 'P' const { focus } = await statDatabase.get(host, new Date()) return mill2Str(focus) } diff --git a/src/background/service/limit-service.ts b/src/background/service/limit-service.ts index b81b4765f..c538c478f 100644 --- a/src/background/service/limit-service.ts +++ b/src/background/service/limit-service.ts @@ -79,7 +79,7 @@ export async function removeLimitRules(ids: number[]): Promise<void> { } type IncreaseResult = { - limited?: timer.limit.Item[] + limited: timer.limit.Item[] reminder?: timer.limit.ReminderInfo } @@ -91,7 +91,7 @@ type IncreaseResult = { * @returns the rules is limit cause of this operation */ export async function addLimitFocusTime(host: string, url: string, focusTime: number): Promise<IncreaseResult> { - if (whitelistHolder.contains(host, url)) return {} + if (whitelistHolder.contains(host, url)) return { limited: [] } const allEffective = await selectLimit({ url, effective: true }) diff --git a/src/background/track-server/file-tracker.ts b/src/background/track-server/file-tracker.ts index 0492cd888..5f352f817 100644 --- a/src/background/track-server/file-tracker.ts +++ b/src/background/track-server/file-tracker.ts @@ -1,4 +1,5 @@ -import { getTab, onTabActivated } from '@api/chrome/tab' +import { getTab, onTabActivated, onTabUpdated } from '@api/chrome/tab' +import { onWindowFocusChanged } from '@api/chrome/window' import optionHolder from '@service/components/option-holder' import { extractFileHost } from '@util/pattern' import { handleTrackTimeEvent } from './normal' @@ -10,8 +11,8 @@ type Context = { start: number } -async function convertContext(tabId: number): Promise<Context | null> { - const tab = await getTab(tabId) +async function convertContext(tabOrId: number | ChromeTab): Promise<Context | null> { + const tab = typeof tabOrId === 'number' ? await getTab(tabOrId) : tabOrId if (!tab) return null const { active, url } = tab if (!active || !url) return null @@ -27,31 +28,64 @@ async function convertContext(tabId: number): Promise<Context | null> { * Local file tracker for firefox */ class FileTracker { - private enabled = false - private current: Context | null = null + #enabled = false + #current: Context | null = null + // Context saved when window loses focus, restored when focus returns + #suspended: Context | null = null + #windowFocused = true async init() { - optionHolder.get().then(v => this.enabled = v.countLocalFiles) - optionHolder.addChangeListener(v => this.enabled = v.countLocalFiles) + optionHolder.get() + .then(v => this.#enabled = v.countLocalFiles) + .catch(e => console.info("Failed to get countLocalFiles:", e)) + optionHolder.addChangeListener(v => this.#enabled = v.countLocalFiles) onTabActivated(async tabId => { - this.tick() - this.current = await convertContext(tabId) + this.#tick() + this.#current = await convertContext(tabId) + this.#suspended = null + }) + + onTabUpdated(async (_tabId, changeInfo, tab) => { + if (!changeInfo.url || !tab.active) return + const newContext = await convertContext(tab) + if (this.#current?.host !== newContext?.host) { + // File host changed or navigated away from file URL + this.#tick() + this.#current = newContext + this.#suspended = null + } + }) + + onWindowFocusChanged(async windowId => { + if (windowId === chrome.windows.WINDOW_ID_NONE) { + this.#tick() + this.#suspended = this.#current + this.#current = null + this.#windowFocused = false + } else if (!this.#windowFocused) { + this.#windowFocused = true + if (this.#suspended) { + // Re-validate: tab may have been closed or navigated away during blur + const suspendedTabId = this.#suspended.tab.id + this.#suspended = null + this.#current = suspendedTabId ? await convertContext(suspendedTabId) : null + } + } }) // NOTE: if migrate to MV3, this line won't work expectedly - setInterval(() => this.tick(), 1000) + setInterval(() => this.#tick(), 1000) } - private tick() { - if (!this.current) return - const { host, tab, start } = this.current + #tick() { + if (!this.#current) return + const { host, tab, start } = this.#current const end = Date.now() - this.enabled && handleTrackTimeEvent({ - host, start, end, - ignoreTabCheck: false, - }, tab) - this.current.start = end + if (this.#enabled) { + void handleTrackTimeEvent({ host, start, end, ignoreTabCheck: true }, tab) + } + this.#current.start = end } } diff --git a/src/background/track-server/normal.ts b/src/background/track-server/normal.ts index b7e6b1448..ed2d86958 100644 --- a/src/background/track-server/normal.ts +++ b/src/background/track-server/normal.ts @@ -20,9 +20,9 @@ async function handleTime(context: ItemIncContext, timeRange: [number, number], // 2. Process limit const { limited, reminder } = await addLimitFocusTime(host, url, focusTime) // If time limited after this operation, send messages - limited?.length && sendLimitedMessage(limited) + limited.length && void sendLimitedMessage(limited) // If need to reminder, send messages - reminder?.items?.length && tabId && sendMsg2Tab(tabId, 'limitReminder', reminder) + reminder?.items?.length && tabId && void sendMsg2Tab(tabId, 'limitReminder', reminder) // 3. Add period time periodThrottler.add(start, focusTime) return focusTime @@ -39,19 +39,23 @@ export async function handleTrackTimeEvent(event: timer.core.Event, tab: ChromeT if (!active) return } const { protocol, host } = extractHostname(url) - const option = await optionHolder.get() - if (protocol === "file" && !option?.countLocalFiles) return + const { countLocalFiles } = await optionHolder.get() + if (protocol === "file" && !countLocalFiles) return + if (whitelistHolder.contains(host, url)) return await handleTime({ host, url, groupId }, [start, end], tabId) if (tabId) { - const winTabs = await listTabs({ active: true, windowId }) - const firstActiveTab = winTabs[0] - // Cause there is no way to determine whether this tab is selected in screen-split mode - // So only show badge for first tab for screen-split mode - // @see #246 - firstActiveTab?.id === tabId && badgeManager.updateFocus({ tabId, url }) + if (!ignoreTabCheck) { + // Cause there is no way to determine whether this tab is selected in screen-split mode + // So only show badge for first tab for screen-split mode + // @see #246 + const winTabs = await listTabs({ active: true, windowId }) + const firstActiveTab = winTabs[0] + if (firstActiveTab?.id !== tabId) return + } + void badgeManager.updateFocus({ tabId, url }) } } From f66f4822531a2a78f1b81af37a034fcd151f7d39 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Fri, 15 May 2026 15:29:16 +0800 Subject: [PATCH 160/174] v4.3.1 --- CHANGELOG.md | 7 +++++++ package.json | 4 ++-- 2 files changed, 9 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fadf39217..7b420cac9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,13 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.3.1] - 2026-05-15 + +- Added share button on the popup page +- Enabled tab group tracking by default +- Optimized data migration from other extensions +- Fixed some bugs + ## [4.3.0] - 2026-05-11 - Added two-factor authentication (2FA) for time limit verification diff --git a/package.json b/package.json index c03e1fd9a..1207407b9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tt4b", - "version": "4.3.0", + "version": "4.3.1", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { @@ -74,4 +74,4 @@ "engines": { "node": ">=22" } -} \ No newline at end of file +} From 4209559997ea004adf546cb2e58599dddeeef83d Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 16 May 2026 00:15:06 +0000 Subject: [PATCH 161/174] i18n(download): download translations by bot --- src/i18n/message/app/about-resource.json | 6 +- .../message/app/data-manage-resource.json | 15 ++- src/i18n/message/app/help-us-resource.json | 2 + src/i18n/message/app/limit-resource.json | 25 +++- src/i18n/message/app/menu-resource.json | 3 +- src/i18n/message/app/merge-rule-resource.json | 5 +- src/i18n/message/app/operation-resource.json | 4 + src/i18n/message/app/option-resource.json | 118 ++++++++++++++++-- src/i18n/message/app/report-resource.json | 3 +- .../message/app/site-manage-resource.json | 13 +- .../message/app/time-format-resource.json | 6 + src/i18n/message/app/whitelist-resource.json | 2 + src/i18n/message/common/base-resource.json | 6 + src/i18n/message/common/button-resource.json | 29 ++++- .../message/common/calendar-resource.json | 24 ++++ .../common/context-menus-resource.json | 5 + src/i18n/message/common/initial-resource.json | 8 ++ src/i18n/message/common/item-resource.json | 15 +++ src/i18n/message/common/shared-resource.json | 17 ++- src/i18n/message/cs/console-resource.json | 4 + src/i18n/message/cs/modal-resource.json | 6 + src/i18n/message/popup/content-resource.json | 28 ++++- src/i18n/message/popup/footer-resource.json | 6 + src/i18n/message/popup/header-resource.json | 6 + src/i18n/message/side/list-resource.json | 1 + 25 files changed, 327 insertions(+), 30 deletions(-) diff --git a/src/i18n/message/app/about-resource.json b/src/i18n/message/app/about-resource.json index dfdbe444f..5de54704c 100644 --- a/src/i18n/message/app/about-resource.json +++ b/src/i18n/message/app/about-resource.json @@ -70,17 +70,17 @@ "de": { "label": { "name": "Titel", - "version": "Aktuelle Version", + "version": "Version", "website": "Offizielle Website", "installation": "Installation", - "thanks": "Credits", + "thanks": "Danksagungen", "privacy": "Datenschutz", "license": "Lizenz", "support": "Support" }, "text": { "greet": "Gefällt dir diese Erweiterung?", - "rate": "Bewerte uns mit 5 Sternen!", + "rate": "Bewerte uns mit 5 Sternen, um uns zu unterstützen!", "feedback": "Sag uns deine Meinung!" } }, diff --git a/src/i18n/message/app/data-manage-resource.json b/src/i18n/message/app/data-manage-resource.json index d75832029..447360876 100644 --- a/src/i18n/message/app/data-manage-resource.json +++ b/src/i18n/message/app/data-manage-resource.json @@ -27,7 +27,10 @@ "paramError": "参数错误,请检查!", "deleteConfirm": "共筛选出 {count} 条数据,是否全部删除?", "migrationAlert": "使用导入/导出在不同浏览器之间迁移数据", - "importError": "文件格式错误" + "importError": "文件格式错误", + "exportData": "导出数据", + "restoreData": "导入数据", + "restoreFromOther": "导入 {ext} 数据" }, "zh_TW": { "totalMemoryAlert": "瀏覽器為每個擴充功能提供 {size}MB 本地儲存空間", @@ -236,7 +239,10 @@ "paramError": "Parameterfehler, bitte überprüfen!", "deleteConfirm": "Insgesamt wurden {count} Datensätze herausgefiltert. Möchten Sie sie alle löschen?", "migrationAlert": "Migrieren Sie Daten zwischen Browsern mithilfe von Import und Export", - "importError": "Falsche Dateierweiterung" + "importError": "Falsche Dateierweiterung", + "exportData": "Daten exportieren", + "restoreData": "Daten wiederherstellen", + "restoreFromOther": "Wiederherstellen aus {ext}" }, "fr": { "totalMemoryAlert": "Le navigateur fournit {size}Mo pour stocker des données locales pour chaque extension", @@ -415,6 +421,9 @@ "paramError": "Il parametro è errato, per favore controlla!", "deleteConfirm": "Un totale di {count} record sono stati filtrati. Vuoi eliminarli tutti?", "migrationAlert": "Migrare i dati tra browser utilizzando importazione ed esportazione", - "importError": "Estensione del file sbagliata" + "importError": "Estensione del file sbagliata", + "exportData": "Esporta dati", + "restoreData": "Ripristino dati", + "restoreFromOther": "Ripristina da {ext}" } } \ No newline at end of file diff --git a/src/i18n/message/app/help-us-resource.json b/src/i18n/message/app/help-us-resource.json index 0496a171b..10a32cced 100644 --- a/src/i18n/message/app/help-us-resource.json +++ b/src/i18n/message/app/help-us-resource.json @@ -146,8 +146,10 @@ "title": "Sentitevi liberi di contribuire a migliorare le traduzioni di localizzazione dell'estensione!", "alert": { "l1": "Al fine di fornire una migliore esperienza utente, ospito le attività di traduzione su Crowdin.", + "l2": "Se trovate questa estensione utile per voi e siete disposti a migliorare la sua traduzione, è possibile fare clic sul pulsante qui sotto per andare alla home page del progetto su Crowdin.", "l3": "Quando il progresso della traduzione di una lingua raggiunge il 50 per cento, considererò di sostenerla in questa estensione." }, + "button": "Vai A Crowdin", "loading": "Controllo avanzamento traduzione...", "contributors": "Lista Contributori" } diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index 5ae7cf149..39b657505 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -264,7 +264,7 @@ "message": { "noUrl": "Не заповнена обмежена URL-адреса", "noRule": "Не заповнено жодного правила", - "deleteConfirm": "Ви дійсно хочете видалити правило {cond}?", + "deleteConfirm": "Ви дійсно хочете видалити правило {name}?", "lockConfirm": "Якщо заблоковано, всі операції потребують перевірки, навіть якщо правило не спрацьовує.", "inputTestUrl": "Спочатку введіть URL-адресу посилання для перевірки", "noRuleMatched": "URL не відповідає жодному правилу", @@ -309,7 +309,7 @@ "message": { "noUrl": "URL restringida sin completar", "noRule": "No hay reglas llenadas", - "deleteConfirm": "¿Deseas eliminar la regla de {cond}?", + "deleteConfirm": "¿Deseas eliminar la regla dé {name}?", "lockConfirm": "Si está bloqueado, todas las operaciones requerirán verificación incluso si no se activa la regla.", "inputTestUrl": "Por favor, introduce primero el enlace URL a ser probado", "noRuleMatched": "La URL no sigue ninguna regla", @@ -329,7 +329,9 @@ "reminder": "¡Menos de {min} minutos hasta el límite de tiempo!" }, "de": { + "onlyEffective": "Nur wirksam", "wildcardTip": "Sie können Platzhalter verwenden, um Subdomains oder Unterseiten zuzuordnen, und \"+\" als Präfix verwenden, um Unterseiten auszuschließen!", + "emptyTips": "Klicke hier, um eine Regel zu erstellen!", "item": { "name": "Regelname", "condition": "Eingeschränkte URL", @@ -340,6 +342,7 @@ "enabled": "Aktiviert", "locked": "Gesperrt", "effectiveDay": "Wirksam auf", + "allowDelay": "Verzögerung zulassen", "or": "oder", "notEffective": "Nicht wirksam" }, @@ -368,6 +371,8 @@ "strictTip": "Die Limit-Regel wurde bereits ausgelöst und eine manuelle Entsperrung ist nicht zulässig!", "incorrectPsw": "Falsches Passwort", "incorrectAnswer": "Falsche Antwort", + "twoFaInputTip": "Die Regel wurde ausgelöst oder gesperrt. Geben Sie den 6-stelligen Code aus Ihrer Authentifizierungs-App ein, um fortzufahren.", + "incorrect2fa": "Falscher 2FA-Code", "pi": "{digitCount} Ziffern von {startIndex} bis {endIndex} des Dezimalteils von π", "confession": "Zeit vergeht" }, @@ -421,6 +426,7 @@ "reminder": "Moins de {min} minutes jusqu'à la limite de temps !" }, "ru": { + "onlyEffective": "Только эффективный режим", "wildcardTip": "Вы можете использовать шаблоны для совпадения с поддоменами или подстраницами, а также использовать «+» в качестве префикса, чтобы исключить подстраницы!", "emptyTips": "Нажмите здесь, чтобы создать новое правило!", "item": { @@ -433,6 +439,7 @@ "enabled": "Включено", "locked": "Заблокировано", "effectiveDay": "Эффективный", + "allowDelay": "Разрешить задержку", "or": "или", "notEffective": "Не активировано" }, @@ -461,6 +468,8 @@ "strictTip": "Ограниченное правило уже было вызвано и разблокировка вручную запрещена!", "incorrectPsw": "Неправильный пароль", "incorrectAnswer": "Неправильный ответ", + "twoFaInputTip": "Правило было вызвано или заблокировано. Введите 6-значный код из вашего приложения-аутентификатора, чтобы продолжить.", + "incorrect2fa": "Неверный код двухфакторной авторизации", "pi": "{digitCount} цифр от {startIndex} до {endIndex} десятичной части числа π", "confession": "Время быстротечно" }, @@ -606,10 +615,12 @@ "reminder": "Mniej niż {min} minut do osiągnięcia limitu!" }, "it": { + "onlyEffective": "Solo Efficace", + "wildcardTip": "Puoi usare caratteri jolly per abbinare sotto-domini o sotto-pagine, e usare \"+\" come prefisso per escludere le sotto-pagine!", "emptyTips": "Clicca qui per creare una regola!", "item": { "name": "Nome della regola", - "condition": "Url Ristretto", + "condition": "URL Ristretto", "weekStartInfo": "Il primo giorno di ogni settimana è {weekStart}, puoi cambiare questo valore nelle opzioni di tracciamento", "delayCount": "Ritardo conteggio", "detail": "Dettagli regola", @@ -641,11 +652,15 @@ }, "verification": { "inputTip": "La regola è stata attivata o bloccata. Per continuare, inserisci la risposta alla seguente domanda entro {second} secondi: {prompt}", - "inputTip2": "La regola è stata attivata o bloccata. Per continuare, inseriscila così com'è entro {second} secondi: {answer}", + "inputTip2": "La regola è stata attivata o bloccata. Per continuare, inseriscila così com'è entro {second} secondi: {answer}", + "pswInputTip": "La regola è stata attivata o bloccata. Per continuare, inserisci la password", "strictTip": "Attivato, nessuna operazione è consentita prima del rilascio!", "incorrectPsw": "Password non e corretta", "incorrectAnswer": "Risposta sbagliata", - "pi": "{digitCount} cifre da {startIndex} a {endIndex} della parte decimale di π" + "twoFaInputTip": "La regola è stata attivata o bloccata. Inserisci il codice a 6 cifre dall'app di autenticazione per continuare.", + "incorrect2fa": "Codice 2FA non valido", + "pi": "{digitCount} cifre da {startIndex} a {endIndex} della parte decimale di π", + "confession": "Il tempo è fugace" }, "reminder": "Meno di {min} minuti fino al limite!" } diff --git a/src/i18n/message/app/menu-resource.json b/src/i18n/message/app/menu-resource.json index 3cfe592e6..5924e2ef5 100644 --- a/src/i18n/message/app/menu-resource.json +++ b/src/i18n/message/app/menu-resource.json @@ -198,13 +198,14 @@ "dashboard": "Dashboard", "data": "Miei Dati", "dataReport": "Record", - "siteAnalysis": "Analiso Sito", + "siteAnalysis": "Analisi Sito", "dataClear": "Memoria", "behavior": "Comportamento Dell'Utente", "habit": "Abitudini", "additional": "Funzionalità aggiuntive", "siteManage": "Gestione del sito", "whitelist": "Whitelist", + "mergeRule": "Raggruppa regole dei siti", "other": "Altre funzioni", "about": "Info su" } diff --git a/src/i18n/message/app/merge-rule-resource.json b/src/i18n/message/app/merge-rule-resource.json index e569a69a9..418b50957 100644 --- a/src/i18n/message/app/merge-rule-resource.json +++ b/src/i18n/message/app/merge-rule-resource.json @@ -253,9 +253,10 @@ "errorOrigin": "Il formato del sito originale non è valido.", "duplicateMsg": "La regola esiste già: {origin}", "addConfirmMsg": "Verranno impostate regole di unione personalizzate per {origin}", - "infoAlert0": "Fai clic sul pulsante [Nuovo]; verranno visualizzati i campi d'immissione relativi al sito di origine e al sito di unione; compila i campi e salva la regola.", + "infoAlertTitle": "le regole di unione quando si contano i siti su questa pagina", + "infoAlert0": "Fai clic sul pulsante [Nuovo]; verranno visualizzati i campi d'immissione relativi al sito di origine e al sito di unione; compila i campi e salva la regola", "infoAlert1": "Il sito originale può essere compilato con un sito specifico o un'espressione regolare, ad esempio www.baidu.com, *.baidu.com, *.google.com*, per determinare quali siti saranno interessati da questa regola durante l'unione", - "infoAlert2": "Il sito unito può contenere il nome di un sito specifico, un numero o essere lasciato vuoto.", + "infoAlert2": "Il sito unito può contenere il nome di un sito specifico, un numero o essere lasciato vuoto", "infoAlert3": "Un numero indica il livello del sito a cui viene unito. Ad esempio, se esiste una regola del tipo '*.*.edu.cn >>> 3', allora 'www.hust.edu.cn' verrà unito a 'hust.edu.cn'", "infoAlert4": "Vuoto significa che il sito originale non verrà unito", "infoAlert5": "Se non viene individuata alcuna regola corrispondente, verrà utilizzato per impostazione predefinita il livello precedente {psl}", diff --git a/src/i18n/message/app/operation-resource.json b/src/i18n/message/app/operation-resource.json index 2b6c0b8c7..ff1eed3c6 100644 --- a/src/i18n/message/app/operation-resource.json +++ b/src/i18n/message/app/operation-resource.json @@ -50,5 +50,9 @@ "pl": { "confirmTitle": "Potwierdzenie", "successMsg": "Pomyślnie!" + }, + "it": { + "confirmTitle": "Conferma", + "successMsg": "Con successo!" } } \ No newline at end of file diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 27235751d..8aaade8c5 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -968,7 +968,8 @@ "info": "Die Anzahl der Besuche der aktuellen Website heute" }, "darkMode": { - "label": "Dunkler Modus {input}" + "label": "Dunkler Modus {input}", + "timed": "Zeitsteuerung aktiv" }, "animationDuration": "Die Dauer der ersten Animation des Diagramms {input}", "sidePanel": "{input} Ob das Seitenpanel aktiviert wird" @@ -1007,8 +1008,14 @@ "strictTitle": "Operation bestätigen", "strictContent": "Wenn Sie diese Option wählen, wenn eine Site Tageslimit auslöst, Sie dürfen die Blockierung nur bis zum nächsten Tag manuell entsperren. Wenn Regeln nicht richtig eingerichtet sind, können sie sehr gut Ihre Routinen behindern!", "pswFormLabel": "Passwort", - "pswFormAgain": "Erneut eingeben" - } + "pswFormAgain": "Erneut eingeben", + "2fa": "Zum Entsperren ist ein 2FA‑Code erforderlich", + "twoFaTitle": "Zwei Faktor Authentifikation - 2FA nutzen", + "twoFaScanHint": "Scanne den QR‑Code mit einer Authentifikation‑App oder importiere den Einrichtungslink in einen TOTP‑fähigen Passwortmanager.", + "twoFaCopyLink": "Link kopieren", + "twoFaVerifyLabel": "Zur Bestätigung den 6‑stelligen Code eingeben" + }, + "delayDuration": "Verzögerung für {input} Minuten pro Vorgang" }, "backup": { "title": "Datensicherung", @@ -1767,41 +1774,112 @@ "it": { "yes": "Si", "no": "No", + "on": "Sempre acceso", + "off": "Sempre spento", + "followBrowser": "Segui browser", + "permGrantConfirm": "Questa funzione richiede autorizzazioni rilevanti", "appearance": { "title": "Apparenza", "displayWhitelist": "{input} Se visualizzare {whitelist} in {contextMenu}", + "whitelistItem": "scorciatoie correlate alla whitelist", + "contextMenu": "il menu contestuale", + "displayBadgeText": "{input} Se visualizzare {timeInfo} in {icon}", + "badgeBgColor": "Il colore di sfondo del testo sull'icona {input}", + "icon": "l'icona dell'estensione", + "badgeTextContent": "il tempo di navigazione del sito web corrente", "locale": { "label": "Linguaggio {input}", "changeConfirm": "La lingua è stata modificata con successo, si prega di aggiorna questa pagina!", "reloadButton": "Aggiorna" }, "printInConsole": { - "console": "console" + "label": "{input} Se stampare {info} in {console}", + "console": "console", + "info": "il conteggio delle visite del sito attuale oggi" }, "darkMode": { - "label": "Modalità oscura {input}" - } + "label": "Modalità oscura {input}", + "timed": "Sincronizzato" + }, + "animationDuration": "La durata dell'animazione iniziale del grafico {input}", + "sidePanel": "{input} Se abilitare il pannello laterale" }, "tracking": { + "title": "Tracciamento", + "autoPauseTrack": "{input} Pausa tracciamento se nessuna attività rilevata {info} per {maxTime}", + "noActivityInfo": "Il mouse e la tastiera sono inattivi, non in modalità a schermo intero, e non c'è suono in riproduzione", + "countLocalFiles": "{input} Se tenere traccia del tempo in cui il browser legge {localFileTime} {info}", + "localFileTime": "file locali", + "localFilesInfo": "Supporta file di tipi come PDF, immagine, txt e json.", + "countTabGroup": "{input} Se tenere traccia dell'orario dei gruppi di schede {info}", + "tabGroupInfo": "Quando si elimina un gruppo di schede, anche i dati verranno eliminati.", + "fileAccessDisabled": "L'accesso agli URL dei file non è attualmente consentito. Si prega di attivarlo prima nella pagina di gestione", + "weekStart": "Il primo giorno di ogni settimana {input}", + "weekStartAsNormal": "Come Normale", + "storage": "Memorizza i dati di tracciamento in {input}", "storageConfirm": "Vuoi cambiare il tipo di storage in {type}?" }, + "limit": { + "prompt": "Prompt mostrato quando riservato {input}", + "reminder": "{input} Promemoria {minInput} minuti prima che il tempo sia scaduto", + "level": { + "label": "Come sbloccare durante la limitazione {input}", + "nothing": "Consenti lo sblocco diretto nella pagina di amministrazione", + "password": "È necessario inserire la password per sbloccare", + "verification": "È necessario inserire il codice di verifica per sbloccare", + "passwordLabel": "Password per sbloccare {input}", + "verificationLabel": "La difficoltà del codice di verifica {input}", + "verificationDifficulty": { + "easy": "Facile", + "hard": "Difficile", + "disgusting": "Disgustoso" + }, + "strict": "Non consentire comunque lo sblocco", + "strictTitle": "Conferma operazione", + "strictContent": "Quando si seleziona questa opzione, se un sito attiva il limite giornaliero, non ti sarà permesso di sbloccare manualmente se non aspettare fino al giorno successivo. Se le regole non sono impostate correttamente, possono molto bene ostacolare le tue routine!", + "pswFormLabel": "Password", + "pswFormAgain": "Conferma", + "2fa": "È necessario utilizzare il codice 2FA per sbloccare", + "twoFaTitle": "Abilita 2FA", + "twoFaScanHint": "Scansiona il codice QR con un'app di autenticazione o importa il link di configurazione in un gestore di password che supporta TOTP.", + "twoFaCopyLink": "Copia link", + "twoFaVerifyLabel": "Inserisci il codice a 6 cifre da verificare" + }, + "delayDuration": "Ritardo per {input} minuti per volta" + }, "backup": { + "title": "Backup Dati", + "type": "Tipo remoto {input}", "client": "Nome dell' Cliente {input}", + "meta": { + "gist": { + "authInfo": "È richiesto un token con almeno il permesso di gist" + }, + "obsidian_local_rest_api": { + "endpointInfo": "Solo HTTP è disponibile, poiché CORS non può essere configurato per le pagine di estensione" + } + }, "label": { + "endpoint": "Indirizzo endpoint {info} {input}", + "path": "Il percorso della cartella {input}", "account": "Nome Utente {input}", "password": "Password {input}" }, "operation": "Backup", "download": { "btn": "Scarica", - "willDownload": "Da scaricare" + "willDownload": "Da scaricare", + "confirmTip": "{size} pezzi di dati da [{clientName}] verranno scaricati" }, "clear": { - "btn": "Cancella" + "btn": "Cancella", + "confirmTip": "{rowCount} pezzi di dati per i siti {hostCount} tracciati da [{clientName}] verranno eliminati!" }, "confirmStep": "Conferma i dati", "clientTable": { "selectTip": "Seleziona cliente", + "dataRange": "Intervallo di dati", + "notSelected": "Client non selezionato", "current": "Attuale" }, "lastTimeTip": "L' Ultimo tempo di backup: {lastTime}", @@ -1811,12 +1889,34 @@ } }, "accessibility": { - "title": "Accessibilità" + "title": "Accessibilità", + "chartDecal": "{input} Indica se visualizzare la decal del grafico" + }, + "notification": { + "title": "Notifiche", + "cycle": { + "label": "Ciclo di notifica {input}", + "daily": "Giornaliero", + "weekly": "Settimanale" + }, + "method": { + "label": "Metodo di notifica {input}", + "browser": "Browser", + "callback": { + "label": "HTTP Callback", + "url": "Callback URL {input}" + } + } }, "resetButton": "Reset", "resetSuccess": "Reset completato!", "exportButton": "Impostazioni di esportazione", "importButton": "Impostazioni di importazione", + "exportSuccess": "Impostazioni esportate con successo", + "importSuccess": "Impostazioni importate con successo", + "importError": "Importazione non riuscita: file delle impostazioni non valido", + "importConfirm": "Impostazioni importate con successo, aggiorna la pagina per applicare le modifiche!", + "reloadButton": "Aggiorna", "defaultValue": "Default: {default}" } } \ No newline at end of file diff --git a/src/i18n/message/app/report-resource.json b/src/i18n/message/app/report-resource.json index 7a0629d18..1141f68f7 100644 --- a/src/i18n/message/app/report-resource.json +++ b/src/i18n/message/app/report-resource.json @@ -277,7 +277,8 @@ "batchDelete": { "noSelectedMsg": "Si prega di selezionare la riga che si desidera eliminare nella prima tabella", "confirmMsg": "{count} record come [{example}] di {date} saranno eliminati!", - "confirmMsgAll": "{count} record come [{example}], saranno eliminati!" + "confirmMsgAll": "{count} record come [{example}], saranno eliminati!", + "confirmMsgRange": "{count} record come [{example}] tra {start} ed {end} saranno eliminati!" }, "remoteReading": { "on": "Lettura dei dati di backup remoti", diff --git a/src/i18n/message/app/site-manage-resource.json b/src/i18n/message/app/site-manage-resource.json index 66a75141d..745564ff2 100644 --- a/src/i18n/message/app/site-manage-resource.json +++ b/src/i18n/message/app/site-manage-resource.json @@ -555,6 +555,7 @@ }, "it": { "deleteConfirmMsg": "{host} sarà eliminato", + "genAliasConfirmMsg": "Indica se completare automaticamente i nomi dei siti nei batch?", "column": { "type": "Site Type", "alias": "Nome del sito", @@ -567,16 +568,20 @@ "info": "statistiche in base al nome dominio" }, "merged": { - "name": "uniti" + "name": "uniti", + "info": "fonde le statistiche di più nomi di dominio correlati, e le regole di fusione possono essere personalizzate" }, "virtual": { - "name": "virtuale" + "name": "virtuale", + "info": "conta qualsiasi URL in formato Ant Pattern, è possibile aggiungere un sito personalizzato nell'angolo in alto a destra" } }, "cate": { "name": "Nome", + "relatedMsg": "Questa categoria è stata associata ai siti {siteCount} e non può essere eliminata", "removeConfirm": "Confermi di voler eliminare la categoria: {category}?", - "batchChange": "Cambia categorie" + "batchChange": "Cambia categorie", + "batchDisassociate": "Dissocia categorie" }, "form": { "emptyAlias": "Inserisci il nome del sito", @@ -584,7 +589,9 @@ }, "msg": { "hostExistWarn": "{host} esiste", + "existedTag": "ESISTEVA", "noSelected": "Nessun sito selezionato", + "noSupported": "I siti selezionati non possono impostare categorie", "disassociatedMsg": "Vuoi cancellare le categorie di tutti i siti selezionati?", "batchDeleteMsg": "Vuoi eliminare tutti i siti selezionati?" } diff --git a/src/i18n/message/app/time-format-resource.json b/src/i18n/message/app/time-format-resource.json index 1057a1c37..5be1d0fed 100644 --- a/src/i18n/message/app/time-format-resource.json +++ b/src/i18n/message/app/time-format-resource.json @@ -76,5 +76,11 @@ "hour": "Wyświetl w godzinach", "minute": "Wyświetl w minutach", "second": "Wyświetl w sekundach" + }, + "it": { + "default": "Formato orario predefinito", + "hour": "Visualizza in ore", + "minute": "Visualizza in minuti", + "second": "Visualizza in secondi" } } \ No newline at end of file diff --git a/src/i18n/message/app/whitelist-resource.json b/src/i18n/message/app/whitelist-resource.json index dfd8f85cb..b574ce547 100644 --- a/src/i18n/message/app/whitelist-resource.json +++ b/src/i18n/message/app/whitelist-resource.json @@ -130,8 +130,10 @@ "addConfirmMsg": "{url} sarà aggiunto alla whitelist.", "removeConfirmMsg": "{url} sarà rimosso dalla whitelist.", "duplicateMsg": "Duplicato", + "infoAlertTitle": "È possibile aggiungere alla whitelist questa pagina", "infoAlert0": "I siti in whitelist non saranno conteggiati", "infoAlert1": "I siti in whitelist non saranno conteggiati", + "infoAlert2": "È possibile utilizzare un jolly(*) per abbinare più siti, come *.example.com/**, e utilizzare + come prefisso per escludere i siti, come +need.example.com/**", "errorInput": "URL del sito non valido" } } \ No newline at end of file diff --git a/src/i18n/message/common/base-resource.json b/src/i18n/message/common/base-resource.json index d18a68c0d..43190cda2 100644 --- a/src/i18n/message/common/base-resource.json +++ b/src/i18n/message/common/base-resource.json @@ -130,6 +130,12 @@ "helpUs": "Pomóż w tłumaczeniu" }, "it": { + "sidebar": "Barra laterale", + "allFunction": "Tutte le funzionalità", + "guidePage": "Guida per l'utente", + "option": "Opzioni", + "sourceCode": "Codice sorgente", + "changeLog": "Note di Rilascio", "limit": "Limite di tempo", "helpUs": "Aiuto Traduzione" } diff --git a/src/i18n/message/common/button-resource.json b/src/i18n/message/common/button-resource.json index e31709c73..8cd1e0656 100644 --- a/src/i18n/message/common/button-resource.json +++ b/src/i18n/message/common/button-resource.json @@ -178,6 +178,7 @@ "cancel": "Abbrechen", "previous": "Zurück", "next": "Weiter", + "okay": "OK", "dont": "Nein", "operation": "Aktionen", "configuration": "Einstellungen", @@ -186,7 +187,8 @@ "batchEnable": "Stapelaktivierung", "batchDisable": "Stapeldeaktivierung", "collapse": "Schließen", - "expand": "Aufklappen" + "expand": "Aufklappen", + "copy": "Kopieren" }, "fr": { "create": "Nouveau", @@ -302,5 +304,30 @@ "batchDisable": "Seria wyłączenia", "collapse": "Zwiń", "expand": "Rozwiń" + }, + "it": { + "create": "Nuovo", + "add": "Aggiungi", + "delete": "Elimina", + "batchDelete": "Elimina batch", + "modify": "Modifica", + "save": "Salva", + "test": "Test", + "paste": "Incolla", + "confirm": "Conferma", + "cancel": "Cancella", + "previous": "Indietro", + "next": "Avanti", + "okay": "OK", + "dont": "No", + "operation": "Azioni", + "configuration": "Impostazioni", + "clear": "Cancella", + "enable": "Abilita", + "batchEnable": "Attiva Batch", + "batchDisable": "Disabilita Batch", + "collapse": "Riduci", + "expand": "Espandi", + "copy": "Copia" } } \ No newline at end of file diff --git a/src/i18n/message/common/calendar-resource.json b/src/i18n/message/common/calendar-resource.json index 0ba01c53b..057201a00 100644 --- a/src/i18n/message/common/calendar-resource.json +++ b/src/i18n/message/common/calendar-resource.json @@ -306,5 +306,29 @@ "tillDaysAgo": "Przez {n} dni temu", "allTime": "Od początku" } + }, + "it": { + "weekDays": "Lun|Mar|Mer|Gio|Ven|Sab|Dom", + "months": "Gen|Feb|Mar|Apr|Mag|Giu|Lug|Ago|Set|Ott|Nov|Dic", + "dateFormat": "{d}/{m}/{y}", + "monthDateFormat": "{d}/{m}", + "timeFormat": "{d}/{m}/{y} {h}:{i}:{s}", + "simpleTimeFormat": "{d}/{m} {h}:{i}", + "label": { + "startDate": "Data Inizio", + "endDate": "Data di fine" + }, + "range": { + "today": "Oggi", + "yesterday": "Ieri", + "everyday": "Tutti i giorni", + "thisWeek": "Questa settimana", + "thisMonth": "Questo mese", + "lastWeek": "Ultima settimana", + "lastDays": "Ultimi {n} giorni", + "tillYesterday": "Fino a ieri", + "tillDaysAgo": "Fino a {n} giorni fa", + "allTime": "Sempre" + } } } \ No newline at end of file diff --git a/src/i18n/message/common/context-menus-resource.json b/src/i18n/message/common/context-menus-resource.json index 78e116567..3c1f9813b 100644 --- a/src/i18n/message/common/context-menus-resource.json +++ b/src/i18n/message/common/context-menus-resource.json @@ -63,5 +63,10 @@ "add2Whitelist": "Dodaj {host} do Whitelisty", "removeFromWhitelist": "Usuń {host} z Whitelisty", "feedbackPage": "Zgłoszenia" + }, + "it": { + "add2Whitelist": "Aggiungi {host} alla whitelist", + "removeFromWhitelist": "Rimuovi {host} dalla whitelist", + "feedbackPage": "Problemi" } } \ No newline at end of file diff --git a/src/i18n/message/common/initial-resource.json b/src/i18n/message/common/initial-resource.json index 5369869ad..ebecb809a 100644 --- a/src/i18n/message/common/initial-resource.json +++ b/src/i18n/message/common/initial-resource.json @@ -102,5 +102,13 @@ "pic": "Zdjęcia", "txt": "Tekst" } + }, + "it": { + "localFile": { + "json": "JSON", + "pdf": "PDF", + "pic": "Immagini", + "txt": "Testo" + } } } \ No newline at end of file diff --git a/src/i18n/message/common/item-resource.json b/src/i18n/message/common/item-resource.json index b4f5b092c..e44637776 100644 --- a/src/i18n/message/common/item-resource.json +++ b/src/i18n/message/common/item-resource.json @@ -193,5 +193,20 @@ "deleteConfirmMsgRange": "Sprawdź wpisy dla [{url}] pomiędzy {start} a {end} zostaną usunięte", "deleteConfirmMsg": "Wpis wizyt dla [{url}] z {date} zostanie usunięty" } + }, + "it": { + "date": "Data", + "host": "Dominio", + "group": "Gruppo Di Schede", + "focus": "Durata", + "run": "Tempo Di Esecuzione", + "time": "Visite", + "operation": { + "add2Whitelist": "Whitelist", + "analysis": "Analizza", + "deleteConfirmMsgAll": "Tutti i record di visita per [{url}] verranno eliminati", + "deleteConfirmMsgRange": "Record di visite per [{url}] tra {start} ed {end} saranno eliminati", + "deleteConfirmMsg": "Visita record per [{url}] di {date} sarà eliminato" + } } } \ No newline at end of file diff --git a/src/i18n/message/common/shared-resource.json b/src/i18n/message/common/shared-resource.json index 5afaa4717..d8bf773bd 100644 --- a/src/i18n/message/common/shared-resource.json +++ b/src/i18n/message/common/shared-resource.json @@ -129,7 +129,21 @@ } }, "it": { + "merge": { + "mergeBy": "Unisci per", + "mergeMethod": { + "date": "Data", + "domain": "URL", + "cate": "Categoria", + "group": "Gruppo Di Schede", + "notMerge": "Non Unire" + } + }, + "cate": { + "notSet": "Non impostato" + }, "limit": { + "limited": "Limitata", "daily": "Limite giornaliero", "weekly": "Limite settimanale", "period": "Periodi bloccati", @@ -159,7 +173,7 @@ }, "de": { "merge": { - "mergeBy": "Merge nach", + "mergeBy": "Zusammenführen durch", "mergeMethod": { "date": "Datum", "domain": "URL", @@ -172,6 +186,7 @@ "notSet": "Nicht festgelegt" }, "limit": { + "limited": "Begrenzt", "daily": "Tägliches Limit", "weekly": "Wöchentliches Limit", "period": "Unzulässiger Zeitraum", diff --git a/src/i18n/message/cs/console-resource.json b/src/i18n/message/cs/console-resource.json index 4545bb79d..73ee5c041 100644 --- a/src/i18n/message/cs/console-resource.json +++ b/src/i18n/message/cs/console-resource.json @@ -50,5 +50,9 @@ "pl": { "consoleLog": "Dziś otworzyłeś {host} {time} raz(y) i spędziłeś {focus} przeglądając go.", "closeAlert": "Możesz wyłączyć te powiadomienia w ustawieniach [{appName}]!" + }, + "it": { + "consoleLog": "Oggi hai aperto {host} {time} volta(e), spendendo {focus} navigandolo.", + "closeAlert": "Puoi disabilitare queste notifiche nelle impostazioni di [{appName}]!" } } \ No newline at end of file diff --git a/src/i18n/message/cs/modal-resource.json b/src/i18n/message/cs/modal-resource.json index 6b655304b..2615fb8e7 100644 --- a/src/i18n/message/cs/modal-resource.json +++ b/src/i18n/message/cs/modal-resource.json @@ -76,5 +76,11 @@ "delay": "jeszcze {n} minut", "browsingTime": "Czas przeglądania", "ruleDetail": "Szczegóły zasad" + }, + "it": { + "defaultPrompt": "Questa pagina è stata bloccata!", + "delay": "{n} altri minuti", + "browsingTime": "Tempo di navigazione", + "ruleDetail": "Dettagli regola" } } \ No newline at end of file diff --git a/src/i18n/message/popup/content-resource.json b/src/i18n/message/popup/content-resource.json index 20d4b6025..8610b09c5 100644 --- a/src/i18n/message/popup/content-resource.json +++ b/src/i18n/message/popup/content-resource.json @@ -9,6 +9,8 @@ "lastDays": "近 {n} 天数据", "allTime": "全部数据" }, + "shareTitle": "分享", + "installTip": "扫描安装 👉", "averageCount": "平均每天 {value} 次", "averageTime": "平均每天 {value}", "totalTime": "共 {totalTime}", @@ -178,6 +180,8 @@ "lastDays": "Daten der letzten {n} Tage", "allTime": "Alle Daten" }, + "shareTitle": "Teilen", + "installTip": "Zum Installieren scannen", "averageCount": "{value} mal pro Tag", "averageTime": "{value} pro Tag im Durchschnitt", "totalTime": "Insgesamt {totalTime}", @@ -186,6 +190,15 @@ }, "ranking": { "includingCount": "Einschließlich {siteCount} Websites" + }, + "limit": { + "noData": "Für diese URL ist keine Regel aktiv", + "newOne": "Neu", + "timeUsed": "{percent} verwendet", + "visitUsed": "{used} mal besucht", + "remain": "{remaining} verbleibend", + "noLimit": "Kein Limit", + "notHit": "Nicht aktiv" } }, "fr": { @@ -298,12 +311,25 @@ "lastDays": "Ultimo {n} giorni dati", "allTime": "Tutti i dati" }, + "shareTitle": "Condividi", + "installTip": "Scansiona per installare", + "averageCount": "{value} al giorno in media", "averageTime": "{value} al giorno in media", "totalTime": "Totale {totalTime}", - "totalCount": "{totalCount} volte Totale" + "totalCount": "{totalCount} volte Totale", + "otherLabel": "Altri siti {count}" }, "ranking": { "includingCount": "Inclusi siti {siteCount}" + }, + "limit": { + "noData": "Nessuna regola di limite effettiva per questo URL", + "newOne": "Nuova", + "timeUsed": "{percent} usato", + "visitUsed": "Conteggio visite {used}", + "remain": "{remaining} rimanente", + "noLimit": "Nessun limite", + "notHit": "Non funzione" } } } \ No newline at end of file diff --git a/src/i18n/message/popup/footer-resource.json b/src/i18n/message/popup/footer-resource.json index de5ab42c8..2de89ae5e 100644 --- a/src/i18n/message/popup/footer-resource.json +++ b/src/i18n/message/popup/footer-resource.json @@ -70,5 +70,11 @@ "percentage": "Rozkład", "ranking": "Ranking" } + }, + "it": { + "route": { + "percentage": "Distribuzione", + "ranking": "Classifica" + } } } \ No newline at end of file diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index c21f2d268..d134485f3 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -63,5 +63,11 @@ "showSiteName": "Wyświetl nazwę witryny", "showTopN": "Wyświetl górną część {n}", "donutChart": "Wyświetlane jako wykresy" + }, + "it": { + "rating": "Invia il tuo voto", + "showSiteName": "Visualizza nome del sito", + "showTopN": "Mostra top {n}", + "donutChart": "Visualizzati come grafici di ciambella" } } \ No newline at end of file diff --git a/src/i18n/message/side/list-resource.json b/src/i18n/message/side/list-resource.json index f452bfaca..e09528796 100644 --- a/src/i18n/message/side/list-resource.json +++ b/src/i18n/message/side/list-resource.json @@ -52,6 +52,7 @@ "title": "Analiza przeglądania " }, "it": { + "searchPlaceholder": "Inserisci dominio o URL", "title": "Analisi Di Esplorazione" } } \ No newline at end of file From 32b42754be16ea3c7a3344aa18fd47f4933a61a1 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Sat, 16 May 2026 15:27:32 +0800 Subject: [PATCH 162/174] feat: increase backup timeout (#772) --- src/api/gist.ts | 5 ++++- src/api/sw/backup.ts | 6 +++--- src/api/sw/immigration.ts | 2 +- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/src/api/gist.ts b/src/api/gist.ts index 9f5933aff..463773d28 100644 --- a/src/api/gist.ts +++ b/src/api/gist.ts @@ -161,5 +161,8 @@ export async function testToken(token: string): Promise<string | undefined> { } }) const { status, statusText } = response || {} - return status === 200 ? undefined : statusText || ("ERROR " + status) + if (status === 200) return undefined + if (status === 401) return '[401] Invalid token or no permission to access gist' + if (status === 403) return '[403] Access forbidden, possibly due to rate limit exceeded' + return statusText || `ERROR ${status}` } diff --git a/src/api/sw/backup.ts b/src/api/sw/backup.ts index 4a46ff1ce..fb7f35875 100644 --- a/src/api/sw/backup.ts +++ b/src/api/sw/backup.ts @@ -4,11 +4,11 @@ export const syncData = () => sendMsg2Runtime('backup.sync', undefined, 120_000) export const checkAuth = () => sendMsg2Runtime('backup.checkAuth') -export const clearBackup = (cid: string) => sendMsg2Runtime('backup.clear', cid) +export const clearBackup = (cid: string) => sendMsg2Runtime('backup.clear', cid, 60_000) -export const queryBackup = (param: timer.backup.RemoteQuery) => sendMsg2Runtime('backup.query', param) +export const queryBackup = (param: timer.backup.RemoteQuery) => sendMsg2Runtime('backup.query', param, 120_000) -export const previewBackup = (param: timer.backup.RemoteQuery) => sendMsg2Runtime('backup.preview', param) +export const previewBackup = (param: timer.backup.RemoteQuery) => sendMsg2Runtime('backup.preview', param, 120_000) export const getLastBackUp = (type: timer.backup.Type) => sendMsg2Runtime('backup.lastTs', type) diff --git a/src/api/sw/immigration.ts b/src/api/sw/immigration.ts index 5486a27d9..568b1a40c 100644 --- a/src/api/sw/immigration.ts +++ b/src/api/sw/immigration.ts @@ -1,5 +1,5 @@ import { sendMsg2Runtime } from "./common" export function importOther(query: timer.imported.ProcessQuery) { - return sendMsg2Runtime('immigration.importOther', query) + return sendMsg2Runtime('immigration.importOther', query, 60_000) } From acdccf7622e2a0d4fa5006fbba887781db580540 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Sat, 16 May 2026 15:29:27 +0800 Subject: [PATCH 163/174] fix: time tracking (#771, #768) --- package.json | 22 ++++---- src/api/chrome/window.ts | 74 +++------------------------ src/background/badge-manager.ts | 11 ++-- src/background/track-server/normal.ts | 8 +-- 4 files changed, 28 insertions(+), 87 deletions(-) diff --git a/package.json b/package.json index 1207407b9..f8b8c8a64 100644 --- a/package.json +++ b/package.json @@ -28,21 +28,21 @@ }, "license": "MIT", "devDependencies": { - "@commitlint/types": "^21.0.0", + "@commitlint/types": "^21.0.1", "@crowdin/crowdin-api-client": "^1.55.1", "@emotion/babel-plugin": "^11.13.5", - "@rsdoctor/rspack-plugin": "^1.5.10", - "@rspack/cli": "^2.0.2", - "@rspack/core": "^2.0.2", - "@rstest/core": "^0.9.10", - "@rstest/coverage-istanbul": "^0.3.2", + "@rsdoctor/rspack-plugin": "^1.5.11", + "@rspack/cli": "^2.0.3", + "@rspack/core": "^2.0.3", + "@rstest/core": "^0.10.0", + "@rstest/coverage-istanbul": "^0.10.0", "@types/chrome": "0.1.42", "@types/decompress": "^4.2.7", "@types/firefox-webext-browser": "^143.0.0", - "@types/node": "^25.6.2", + "@types/node": "^25.8.0", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.1.1", - "commitlint": "^21.0.0", + "commitlint": "^21.0.1", "css-loader": "^7.1.4", "decompress": "^4.2.1", "fake-indexeddb": "^6.2.5", @@ -50,11 +50,11 @@ "husky": "^9.1.7", "jsdom": "^29.1.1", "jszip": "^3.10.1", - "knip": "^6.12.1", + "knip": "^6.14.0", "postcss": "^8.5.14", "postcss-loader": "^8.2.1", "postcss-rtlcss": "^6.0.0", - "puppeteer": "^24.43.0", + "puppeteer": "^25.0.2", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "6.0.3", @@ -69,7 +69,7 @@ "qrcode-generator": "^2.0.4", "typescript-guard": "0.2.4", "vue": "^3.5.34", - "vue-router": "^5.0.6" + "vue-router": "^5.0.7" }, "engines": { "node": ">=22" diff --git a/src/api/chrome/window.ts b/src/api/chrome/window.ts index cb7233600..3b669d6b9 100644 --- a/src/api/chrome/window.ts +++ b/src/api/chrome/window.ts @@ -1,69 +1,12 @@ -import { IS_ANDROID } from "@util/constant/environment" +import { IS_ANDROID, IS_MV3 } from "@util/constant/environment" import { handleError } from "./common" -type WindowSimple = { - id: number - focused?: boolean -} - -class WindowHolder { - private windows: Map<number, WindowSimple> = new Map() - private lastFocusedId: number | undefined = undefined - - async init(): Promise<void> { - if (IS_ANDROID) return - - this.windows.clear() - this.lastFocusedId = undefined - - const windows = await listAllWindow() - windows.forEach(({ id, focused }) => id !== undefined && this.windows.set(id, ({ id, focused }))) - this.lastFocusedId = await getLastFocusedId() - - chrome.windows.onCreated.addListener(({ id, focused }) => { - if (id === undefined) return - - this.windows.set(id, { id, focused }) - focused && (this.lastFocusedId = id) - }) - - chrome.windows.onRemoved.addListener(id => this.windows.delete(id)) - - chrome.windows.onFocusChanged.addListener(async winId => { - for (const [id, info] of this.windows) { - // WINDOW_ID_NONE === winId means all the window change to non-focused - info.focused = id === winId - } - - if (winId !== chrome.windows.WINDOW_ID_NONE) { - const window = await getWindow(winId) - window?.type === 'normal' && (this.lastFocusedId = winId) - } - }) - } - - get(windowId: number): WindowSimple | undefined { - return this.windows.get(windowId) - } - - getLatestFocusedId(): number | undefined { - return this.lastFocusedId - } -} - -function listAllWindow(): Promise<chrome.windows.Window[]> { - if (IS_ANDROID) return Promise.resolve([]) - return new Promise(resolve => chrome.windows.getAll( - { windowTypes: ['normal'] }, - list => { - handleError('listAllWindow') - resolve(list) - }, - )) -} - -function getLastFocusedId(): Promise<number | undefined> { +export async function getLastFocusedId(): Promise<number | undefined> { if (IS_ANDROID) return Promise.resolve(undefined) + if (IS_MV3) { + const window = await chrome.windows.getLastFocused({ windowTypes: ['normal'] }) + return window.id + } return new Promise(resolve => chrome.windows.getLastFocused( { windowTypes: ['normal'] }, ({ id }) => { @@ -73,7 +16,7 @@ function getLastFocusedId(): Promise<number | undefined> { )) } -function getWindow(id: number): Promise<ChromeWindow | undefined> { +export function getWindow(id: number): Promise<ChromeWindow | undefined> { if (IS_ANDROID) return Promise.resolve(undefined) return new Promise(resolve => chrome.windows.get(id, window => { handleError('getWindow') @@ -81,9 +24,6 @@ function getWindow(id: number): Promise<ChromeWindow | undefined> { })) } -export const windowHolder = new WindowHolder() -windowHolder.init().catch(err => console.error('windowHolder.init failed', err)) - export function isNoneWindowId(windowId: number | undefined) { return windowId === undefined || windowId === chrome.windows.WINDOW_ID_NONE } diff --git a/src/background/badge-manager.ts b/src/background/badge-manager.ts index c78465813..846106d9d 100644 --- a/src/background/badge-manager.ts +++ b/src/background/badge-manager.ts @@ -7,7 +7,7 @@ import { setBadgeBgColor, setBadgeText } from "@api/chrome/action" import { listTabs, onTabUpdated } from "@api/chrome/tab" -import { isNoneWindowId, onWindowFocusChanged, windowHolder } from "@api/chrome/window" +import { getLastFocusedId, isNoneWindowId, onWindowFocusChanged } from "@api/chrome/window" import { IS_ANDROID } from "@util/constant/environment" import { extractHostname, isBrowserUrl } from "@util/pattern" import { MILL_PER_HOUR, MILL_PER_MINUTE, MILL_PER_SECOND } from "@util/time" @@ -41,7 +41,7 @@ function mill2Str(milliseconds: number) { } async function findActiveTab(windowId?: number): Promise<BadgeLocation | undefined> { - windowId ??= windowHolder.getLatestFocusedId() + windowId ??= await getLastFocusedId() if (isNoneWindowId(windowId)) return undefined const tabs = await listTabs({ windowId, active: true }) // Fix #131 — Edge can return two active tabs (e.g. edge://newtab/). @@ -76,12 +76,13 @@ class BadgeManager { void (isIdle ? this.pause(tabId) : this.resume(tabId)) }) onWindowFocusChanged(async windowId => { - const target = await findActiveTab(windowId) - await this.updateFocus(target) + this.#current = await findActiveTab(windowId) + await this.render() }) onTabUpdated(async (tabId, { url }, { active }) => { if (!active || !url) return - await this.updateFocus({ url, tabId }) + this.#current = { tabId, url } + await this.render() }) await this.updateFocus() } diff --git a/src/background/track-server/normal.ts b/src/background/track-server/normal.ts index ed2d86958..239bb14e2 100644 --- a/src/background/track-server/normal.ts +++ b/src/background/track-server/normal.ts @@ -1,5 +1,5 @@ import { listTabs, sendMsg2Tab } from "@api/chrome/tab" -import { windowHolder } from '@api/chrome/window' +import { getWindow } from '@api/chrome/window' import optionHolder from "@service/components/option-holder" import { addFocusTime as addItemFocusTime, increaseVisit as increaseItemVisit, type ItemIncContext, @@ -35,7 +35,7 @@ export async function handleTrackTimeEvent(event: timer.core.Event, tab: ChromeT const { start, end, ignoreTabCheck } = event if (!ignoreTabCheck) { - if (windowNotFocused(windowId)) return + if (await windowNotFocused(windowId)) return if (!active) return } const { protocol, host } = extractHostname(url) @@ -59,10 +59,10 @@ export async function handleTrackTimeEvent(event: timer.core.Event, tab: ChromeT } } -function windowNotFocused(winId: number | undefined): boolean { +async function windowNotFocused(winId: number | undefined): Promise<boolean> { if (IS_ANDROID) return false if (!winId) return true - const window = windowHolder.get(winId) + const window = await getWindow(winId) return !window?.focused } From 4fee220a43b2c33874f216e78878d27a462dd7c0 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Sat, 16 May 2026 15:42:38 +0800 Subject: [PATCH 164/174] v4.3.2 --- CHANGELOG.md | 4 ++++ package.json | 4 ++-- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7b420cac9..e825c2e98 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ All notable changes to Time Tracker will be documented in this file. It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. +## [4.3.2] - 2026-05-16 + +- Urgently fixed some bugs + ## [4.3.1] - 2026-05-15 - Added share button on the popup page diff --git a/package.json b/package.json index f8b8c8a64..60b73098b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tt4b", - "version": "4.3.1", + "version": "4.3.2", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { @@ -74,4 +74,4 @@ "engines": { "node": ">=22" } -} +} \ No newline at end of file From f7898ddf1756542a47f9b7257eb6edb471f7ff60 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Sat, 16 May 2026 15:46:57 +0800 Subject: [PATCH 165/174] ci: only allowed to build on main branch --- .github/workflows/publish-all.yml | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/.github/workflows/publish-all.yml b/.github/workflows/publish-all.yml index eeebda540..ae4c03f4f 100644 --- a/.github/workflows/publish-all.yml +++ b/.github/workflows/publish-all.yml @@ -1,6 +1,6 @@ name: Publish Extension to All Stores -on: [ workflow_dispatch ] +on: [workflow_dispatch] env: MAX_PACKAGE_SIZE: 2621440 # 2.5MB in bytes @@ -15,6 +15,23 @@ jobs: with: fetch-depth: 0 + - name: Verify branch + run: | + if [[ "$GITHUB_REF" == refs/heads/main ]]; then + echo "Running on main branch ✅" + elif [[ "$GITHUB_REF" == refs/tags/* ]]; then + git fetch origin main + if git merge-base --is-ancestor HEAD origin/main; then + echo "Tag is on main branch ✅" + else + echo "❌ Tag is not on main branch" + exit 1 + fi + else + echo "❌ Not on main branch or a tag on main" + exit 1 + fi + - name: Setup Node.js uses: actions/setup-node@v6 with: @@ -131,7 +148,8 @@ jobs: addon-guid: "{a8cf72f7-09b7-4cd4-9aaa-7a023bf09916}" xpi-path: target.firefox.zip source-file-path: target.src.zip - compatibility: "{\"firefox\": {\"min\": \"${{ env.FF_MIN_VER }}\"}, \"android\": - {\"min\": \"${{ env.FF_MIN_VER}}\"}}" + compatibility: + '{"firefox": {"min": "${{ env.FF_MIN_VER }}"}, "android": + {"min": "${{ env.FF_MIN_VER}}"}}' jwt-issuer: ${{ secrets.FIREFOX_JWD_ISSUER }} jwt-secret: ${{ secrets.FIREFOX_JWD_SECRET }} From 4376ec96fff47bacd8660d20acd4dca6edf42020 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Mon, 18 May 2026 00:59:47 +0800 Subject: [PATCH 166/174] refactor: add IconRadioGroup --- .../components/Timeline/Chart/index.tsx | 32 ++++++-------- .../components/TopKVisit/Title/index.tsx | 35 ++++------------ src/pages/components/IconRadioGroup.tsx | 42 +++++++++++++++++++ 3 files changed, 62 insertions(+), 47 deletions(-) create mode 100644 src/pages/components/IconRadioGroup.tsx diff --git a/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx b/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx index b9e4877c7..c4f1bc178 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx +++ b/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx @@ -10,17 +10,16 @@ import { t } from '@app/locale' import { Collection, Files, Link } from '@element-plus/icons-vue' import { useEcharts } from '@hooks' import Flex from "@pages/components/Flex" +import IconRadioGroup from '@pages/components/IconRadioGroup' import { type ECElementEvent, type ECharts } from "echarts/core" -import { ElIcon, ElRadioButton, ElRadioGroup } from 'element-plus' -import { computed, defineComponent } from "vue" -import { type JSX } from 'vue/jsx-runtime' +import { type Component, computed, defineComponent } from "vue" import { TIMELINE_DAY_COUNT, useTimelineContext } from '../context' import Wrapper, { BizData, EcOption } from './Wrapper' -const CHART_CONFIG: Record<timer.timeline.MergeMethod, JSX.Element | string> = { - none: <Files />, - domain: <Link />, - cate: <Collection />, +const CHART_CONFIG: Record<timer.timeline.MergeMethod, Component> = { + none: Files, + domain: Link, + cate: Collection, } const extractLegendSelected = (legends: EcOption['legend']): Record<string, boolean> => { @@ -72,19 +71,12 @@ const TimelineChart = defineComponent<{}>(() => { <Flex align="center"> {t(msg => msg.dashboard.timeline.title, { n: TIMELINE_DAY_COUNT })} </Flex> - <Flex align='center'> - <ElRadioGroup - size="small" - modelValue={merge.value} - onChange={val => setMerge(val as timer.timeline.MergeMethod)} - > - {Object.entries(CHART_CONFIG).map(([k, v]) => ( - <ElRadioButton value={k}> - <ElIcon size={15}>{v}</ElIcon> - </ElRadioButton> - ))} - </ElRadioGroup> - </Flex> + <IconRadioGroup + size="small" + modelValue={merge.value} + onChange={val => setMerge(val as timer.timeline.MergeMethod)} + options={Object.entries(CHART_CONFIG).map(([value, icon]) => ({ value, icon }))} + /> </Flex> </ChartTitle> <div ref={elRef} style={{ flex: 1 }} /> diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/Title/index.tsx b/src/pages/app/components/Dashboard/components/TopKVisit/Title/index.tsx index d47a05a69..48f95fef7 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/Title/index.tsx +++ b/src/pages/app/components/Dashboard/components/TopKVisit/Title/index.tsx @@ -1,10 +1,9 @@ import { tN } from "@app/locale" -import { css } from '@emotion/css' import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" +import IconRadioGroup from '@pages/components/IconRadioGroup' import { BarChart, HalfPieChart, RoseChart } from '@pages/icons' -import { ElIcon, ElRadioButton, ElRadioGroup, useNamespace } from "element-plus" -import { type Component, defineComponent, h } from "vue" +import { type Component, defineComponent } from "vue" import { type TopKChartType, useTopKFilter } from "../context" import TitleSelect from "./TitleSelect" @@ -14,19 +13,9 @@ const CHART_CONFIG: { [type in TopKChartType]: Component } = { halfPie: HalfPieChart, } -const useRadioStyle = () => { - const radioNs = useNamespace('radio') - return css` - & .${radioNs.be('button', 'inner')} { - padding: 3px 5px; - } - ` -} - const Title = defineComponent(() => { const filter = useTopKFilter() const isXs = useXsState() - const radioCls = useRadioStyle() return () => ( <Flex align="center" justify="space-between"> @@ -36,20 +25,12 @@ const Title = defineComponent(() => { day: <TitleSelect field="dayNum" values={[7, 30, 90, 180]} />, })} </Flex> - <Flex width={90} justify='end'> - <ElRadioGroup - v-show={!isXs.value} - size="small" - modelValue={filter.topKChartType} - onChange={val => filter.topKChartType = val as TopKChartType} - > - {Object.entries(CHART_CONFIG).map(([k, v]) => ( - <ElRadioButton value={k} class={radioCls}> - <ElIcon size={15}>{h(v)}</ElIcon> - </ElRadioButton> - ))} - </ElRadioGroup> - </Flex> + <IconRadioGroup + v-show={!isXs.value} + modelValue={filter.topKChartType} + onChange={val => filter.topKChartType = val as TopKChartType} + options={Object.entries(CHART_CONFIG).map(([value, icon]) => ({ value, icon }))} + /> </Flex> ) }) diff --git a/src/pages/components/IconRadioGroup.tsx b/src/pages/components/IconRadioGroup.tsx new file mode 100644 index 000000000..488998bb7 --- /dev/null +++ b/src/pages/components/IconRadioGroup.tsx @@ -0,0 +1,42 @@ +import { css } from '@emotion/css' +import { ElIcon, ElRadioButton, ElRadioGroup, type RadioGroupProps, useNamespace } from 'element-plus' +import { type Component, type FunctionalComponent, h } from 'vue' + +const useRadioStyle = () => { + const radioNs = useNamespace('radio') + return css` + & .${radioNs.be('button', 'inner')} { + padding: 3px 5px; + } + ` +} + +const RADIO_CLS = useRadioStyle() + +type Option = { + value: string + icon: Component +} + +type Props = ModelValue<string> & Pick<RadioGroupProps, 'size'> & { + iconSize?: number + options: Option[] +} + +const IconRadioGroup: FunctionalComponent<Props> = ({ size, modelValue, onChange, options, iconSize = 15 }) => ( + <ElRadioGroup + size={size} + modelValue={modelValue} + onChange={val => onChange?.(val as string)} + > + {options.map(({ value, icon }) => ( + <ElRadioButton value={value} class={RADIO_CLS} > + <ElIcon size={iconSize}>{h(icon)}</ElIcon> + </ElRadioButton> + ))} + </ElRadioGroup> +) + +IconRadioGroup.displayName = 'IconRadioGroup' + +export default IconRadioGroup \ No newline at end of file From 5c96fc75fce53694a5993eb2c643d3c71899f463 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 18 May 2026 00:16:54 +0000 Subject: [PATCH 167/174] i18n(download): download translations by bot --- src/i18n/message/app/data-manage-resource.json | 6 +++++- src/i18n/message/app/help-us-resource.json | 6 +++--- src/i18n/message/app/limit-resource.json | 13 +++++++++---- src/i18n/message/app/option-resource.json | 4 ++-- src/i18n/message/popup/content-resource.json | 11 +++++++++++ 5 files changed, 30 insertions(+), 10 deletions(-) diff --git a/src/i18n/message/app/data-manage-resource.json b/src/i18n/message/app/data-manage-resource.json index 447360876..30aa75db5 100644 --- a/src/i18n/message/app/data-manage-resource.json +++ b/src/i18n/message/app/data-manage-resource.json @@ -157,6 +157,7 @@ "totalMemoryAlert": "Браузер надає кожному розширенню {size} МБ для зберігання локальних даних", "totalMemoryAlert1": "Не вдалося визначити максимальну кількість доступної пам'яті браузера", "usedMemoryAlert": "Використано {size} МБ", + "idbAlert": "Перемістити дані відстеження до IndexedDB для зменшення використання сховища", "operationAlert": "Ви можете видалити ці неважливі дані, щоб зменшити використання пам'яті", "filterItems": "Фільтрувати дані", "filterFocus": "Час перегляду для дня – між {start} секунд і {end} секунд", @@ -180,7 +181,10 @@ "paramError": "Хибний параметр. Будь ласка, перевірте!", "deleteConfirm": "Всього відфільтровано {count} записів. Ви хочете видалити їх усі?", "migrationAlert": "Переносьте дані між браузерами за допомогою імпорту й експорту", - "importError": "Неправильне розширення файлу" + "importError": "Неправильне розширення файлу", + "exportData": "Експортувати дані", + "restoreData": "Відновити дані", + "restoreFromOther": "Відновити з {ext}" }, "es": { "totalMemoryAlert": "El navegador proporciona {size}MB para almacenar datos locales para cada extensión", diff --git a/src/i18n/message/app/help-us-resource.json b/src/i18n/message/app/help-us-resource.json index 10a32cced..c417ed569 100644 --- a/src/i18n/message/app/help-us-resource.json +++ b/src/i18n/message/app/help-us-resource.json @@ -35,9 +35,9 @@ "uk": { "title": "Допоможіть поліпшити переклад розширення!", "alert": { - "l1": "Щоб забезпечити кращий досвід користувача, я розміщую завдання з перекладу на Crowdin.", - "l2": "Якщо вам корисне це розширення і ви бажаєте покращити його переклад, ви можете натиснути кнопку нижче, щоб перейти на домашню сторінку проєкту на Crowdin.", - "l3": "Коли прогрес перекладу мови досягне 50%, я розгляну можливість її підтримки в цьому розширенні." + "l1": "Для забезпечення доступності для ширшого кола користувачів я пропоную можливість перекладу на Crowdin.", + "l2": "Якщо вам корисне це розширення і ви бажаєте покращити його переклад, натисніть кнопку нижче, щоб перейти на домашню сторінку проєкту на Crowdin.", + "l3": "Коли прогрес перекладу мови досягне 50%, я розгляну можливість її активації в цьому розширенні." }, "button": "Перейти на Crowdin", "loading": "Перевірка перекладу...", diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index 39b657505..7db540cc8 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -239,7 +239,9 @@ "reminder": "Menos de {min} minutos até ao limite!" }, "uk": { + "onlyEffective": "Лише дійсні", "wildcardTip": "Можна використовувати символи підставлення для пошуку піддоменів або підсторінок, а \"+\" як префікс для виключення підсторінки!", + "emptyTips": "Натисніть тут, щоб створити одне правило!", "item": { "name": "Назва правила", "condition": "Обмежена URL-адреса", @@ -250,8 +252,9 @@ "enabled": "Увімкнено", "locked": "Заблоковано", "effectiveDay": "Діє", + "allowDelay": "Дозволити затримку", "or": "або", - "notEffective": "Не застосовується" + "notEffective": "Не діє" }, "step": { "base": "Загальна інформація", @@ -264,7 +267,7 @@ "message": { "noUrl": "Не заповнена обмежена URL-адреса", "noRule": "Не заповнено жодного правила", - "deleteConfirm": "Ви дійсно хочете видалити правило {name}?", + "deleteConfirm": "Ви хочете видалити правило {name}?", "lockConfirm": "Якщо заблоковано, всі операції потребують перевірки, навіть якщо правило не спрацьовує.", "inputTestUrl": "Спочатку введіть URL-адресу посилання для перевірки", "noRuleMatched": "URL не відповідає жодному правилу", @@ -272,12 +275,14 @@ "timeout": "Час вийшов! XD" }, "verification": { - "inputTip": "Правило було запущено або заблоковано. Щоб продовжити, введіть відповідь на таке запитання протягом {second} секунд: {prompt}", - "inputTip2": "Правило було запущено або заблоковано. Щоб продовжити, введіть його таким, яким він є, протягом {second} секунд: {answer}", + "inputTip": "Правило було ініційовано або заблоковано. Щоб продовжити, введіть відповідь на зазначене запитання протягом {second} секунд: {prompt}", + "inputTip2": "Правило було ініційовано або заблоковано. Щоб продовжити, введіть зазначене протягом {second} секунд: {answer}", "pswInputTip": "Правило обмеження вже було запущено. Щоб продовжити, введіть пароль", "strictTip": "Правило обмеження вже активовано і ручне розблокування не дозволено!", "incorrectPsw": "Неправильний пароль", "incorrectAnswer": "Неправильна відповідь", + "twoFaInputTip": "Правило було ініційовано або заблоковано. Щоб продовжити, введіть код із 6 цифр зі своєї програми автентифікації.", + "incorrect2fa": "Неправильний код 2FA", "pi": "{digitCount} цифр від {startIndex} до {endIndex} десяткової частини числа π", "confession": "Час спливає" }, diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 8aaade8c5..cb1d2d639 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -700,8 +700,8 @@ "uk": { "yes": "Так", "no": "Ні", - "on": "Увімкнено", - "off": "Вимкнено", + "on": "Завжди ввімкнено", + "off": "Завжди вимкнено", "followBrowser": "Як у браузері", "permGrantConfirm": "Ця функція потребує відповідних дозволів", "appearance": { diff --git a/src/i18n/message/popup/content-resource.json b/src/i18n/message/popup/content-resource.json index 8610b09c5..63d98d367 100644 --- a/src/i18n/message/popup/content-resource.json +++ b/src/i18n/message/popup/content-resource.json @@ -140,6 +140,8 @@ "lastDays": "Дані за минулі {n} днів", "allTime": "Всі дані" }, + "shareTitle": "Поділитися", + "installTip": "Сканувати для встановлення", "averageCount": "{value} разів на день у середньому", "averageTime": "{value} на день у середньому", "totalTime": "Всього {totalTime}", @@ -148,6 +150,15 @@ }, "ranking": { "includingCount": "Включаючи {siteCount} сайтів" + }, + "limit": { + "noData": "Для цієї URL-адреси не призначене правило обмеження", + "newOne": "Створити", + "timeUsed": "{percent} використано", + "visitUsed": "{used} кількість відвіданих", + "remain": "{remaining} залишилося", + "noLimit": "Без обмежень", + "notHit": "Не задіяно" } }, "es": { From a29e0359668dfc531a97e14e3d73573685158983 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Mon, 18 May 2026 14:42:17 +0800 Subject: [PATCH 168/174] refactor: refactor type definitions --- script/crowdin/common.ts | 14 +- script/crowdin/export-translation.ts | 2 +- src/api/chrome/runtime.ts | 4 +- src/api/chrome/tab.ts | 14 +- src/api/sw/backup.ts | 6 +- src/api/sw/common.ts | 20 +- src/api/sw/immigration.ts | 2 +- src/api/sw/limit.ts | 6 +- src/api/sw/merge.ts | 2 +- src/api/sw/option.ts | 2 +- src/api/sw/period.ts | 2 +- src/api/sw/site.ts | 16 +- src/api/sw/stat.ts | 14 +- src/background/badge-manager.ts | 2 +- src/background/content-script-handler.ts | 4 +- src/background/database/backup-database.ts | 6 +- src/background/database/cate-database.ts | 6 +- src/background/database/common/migratable.ts | 4 +- .../database/common/storage-holder.ts | 8 +- src/background/database/limit-database.ts | 16 +- src/background/database/memory-detector.ts | 2 +- .../database/merge-rule-database.ts | 10 +- src/background/database/meta-database.ts | 6 +- src/background/database/option-database.ts | 8 +- src/background/database/period-database.ts | 12 +- src/background/database/site-database.ts | 24 +- .../database/stat-database/classic.ts | 58 +- .../database/stat-database/common.ts | 8 +- src/background/database/stat-database/idb.ts | 32 +- .../database/stat-database/index.ts | 36 +- .../database/stat-database/types.ts | 20 +- .../database/timeline-database/classic.ts | 8 +- .../database/timeline-database/idb.ts | 24 +- .../database/timeline-database/index.ts | 4 +- .../database/timeline-database/types.ts | 4 +- src/background/database/types.d.ts | 6 +- src/background/database/whitelist-database.ts | 2 +- .../version/cate-initializer.ts | 2 +- src/background/limit-processor.ts | 4 +- src/background/message-dispatcher.ts | 10 +- src/background/scheduler.ts | 6 +- src/background/service/2fa-service.ts | 4 +- .../service/backup/gist/compressor.ts | 12 +- .../service/backup/gist/coordinator.ts | 26 +- src/background/service/backup/markdown.ts | 8 +- .../service/backup/obsidian/coordinator.ts | 20 +- src/background/service/backup/processor.ts | 52 +- .../service/backup/web-dav/coordinator.ts | 22 +- .../service/components/host-merge-ruler.ts | 4 +- .../service/components/immigration.ts | 6 +- .../service/components/import-processor.ts | 10 +- .../service/components/option-holder.ts | 10 +- .../service/components/page-info.ts | 2 +- .../service/components/period-calculator.ts | 10 +- .../service/components/virtual-site-holder.ts | 4 +- .../service/components/week-helper.ts | 6 +- src/background/service/item-service.ts | 2 +- src/background/service/limit-service.ts | 22 +- src/background/service/meta-service.ts | 4 +- .../service/notification/processor.ts | 2 +- src/background/service/notification/types.ts | 14 +- src/background/service/period-service.ts | 6 +- src/background/service/site-service.ts | 26 +- src/background/service/stat-service/common.ts | 2 +- src/background/service/stat-service/index.ts | 34 +- .../service/stat-service/merge/cate.ts | 6 +- .../service/stat-service/merge/common.ts | 8 +- .../service/stat-service/merge/date.ts | 6 +- .../service/stat-service/merge/host.ts | 8 +- src/background/service/stat-service/remote.ts | 10 +- .../service/throttler/period-throttler.ts | 4 +- .../service/throttler/timeline-throttler.ts | 8 +- src/background/service/timeline-service.ts | 14 +- src/background/track-server/group.ts | 2 +- src/background/track-server/normal.ts | 4 +- src/background/track-server/runtime.ts | 2 +- src/content-script/dispatcher.ts | 14 +- .../limit/modal/components/Reason.tsx | 2 +- src/content-script/limit/modal/context.ts | 2 +- src/content-script/limit/modal/instance.ts | 4 +- .../limit/processor/message-adaptor.ts | 4 +- .../limit/processor/period-processor.ts | 2 +- .../limit/processor/visit-processor.ts | 6 +- .../limit/reminder/component.ts | 6 +- src/content-script/limit/reminder/index.ts | 2 +- src/content-script/limit/types.ts | 8 +- src/content-script/tracker/normal/index.ts | 4 +- src/content-script/tracker/run-time.ts | 2 +- src/i18n/element.ts | 2 +- src/i18n/i18n.d.ts | 4 +- src/i18n/index.ts | 28 +- src/i18n/message/app/data-manage.ts | 2 +- src/i18n/message/app/option.ts | 8 +- src/i18n/message/app/site-manage.ts | 2 +- src/i18n/message/app/time-format.ts | 2 +- src/i18n/message/common/item.ts | 2 +- src/i18n/message/common/locale.ts | 4 +- src/i18n/message/common/shared.ts | 2 +- src/i18n/message/merge.ts | 10 +- .../AnalysisFilter/TargetSelect.tsx | 4 +- .../components/Summary/Calendar/Wrapper.ts | 6 +- .../components/Summary/TargetInfo.tsx | 2 +- .../Analysis/components/Summary/index.tsx | 2 +- .../Analysis/components/Trend/context.ts | 8 +- src/pages/app/components/Analysis/context.ts | 8 +- src/pages/app/components/Analysis/types.d.ts | 2 +- src/pages/app/components/Analysis/util.ts | 2 +- .../Dashboard/components/Indicator.tsx | 2 +- .../components/MonthOnMonth/index.tsx | 2 +- .../components/Timeline/Chart/Wrapper.ts | 15 +- .../components/Timeline/Chart/index.tsx | 4 +- .../Dashboard/components/Timeline/Summary.tsx | 6 +- .../Dashboard/components/Timeline/context.ts | 8 +- .../Dashboard/components/TopKVisit/context.ts | 2 +- .../DataManage/ClearPanel/index.tsx | 2 +- .../Migration/ImportOtherButton/processor.ts | 22 +- .../Migration/ImportOtherButton/types.ts | 4 +- .../app/components/DataManage/context.ts | 2 +- .../Habit/Period/Average/Wrapper.tsx | 18 +- .../components/Habit/Period/Stack/Wrapper.ts | 6 +- .../app/components/Habit/Period/Summary.tsx | 8 +- .../components/Habit/Period/Trend/Wrapper.ts | 8 +- .../app/components/Habit/Period/context.ts | 8 +- .../Habit/Site/DailyTrend/Wrapper.ts | 6 +- .../Habit/Site/Distribution/Wrapper.ts | 15 +- .../app/components/Habit/Site/Summary.tsx | 2 +- .../app/components/Habit/Site/TopK/Wrapper.ts | 16 +- src/pages/app/components/Habit/Site/common.ts | 2 +- .../app/components/Habit/Site/context.ts | 8 +- src/pages/app/components/Habit/context.ts | 2 +- .../app/components/HelpUs/ProgressList.tsx | 4 +- .../components/Limit/components/List/Card.tsx | 2 +- .../components/Limit/components/List/Rule.tsx | 2 +- .../components/Modify/Step3/PeriodInput.tsx | 12 +- .../Limit/components/Modify/index.tsx | 14 +- .../Limit/components/Table/Rule.tsx | 4 +- .../Limit/components/Table/index.tsx | 28 +- src/pages/app/components/Limit/context.ts | 34 +- src/pages/app/components/Limit/types.d.ts | 6 +- .../Option/categories/Accessibility.tsx | 2 +- .../categories/Appearance/DarkModeInput.tsx | 10 +- .../Option/categories/Appearance/index.tsx | 14 +- .../Option/categories/Backup/Clear/index.tsx | 2 +- .../Option/categories/Backup/Clear/types.ts | 2 +- .../Option/categories/Backup/ClientTable.tsx | 14 +- .../categories/Backup/Download/index.tsx | 2 +- .../categories/Backup/Download/types.ts | 6 +- .../Option/categories/Backup/Footer.tsx | 2 +- .../Option/categories/Backup/index.tsx | 10 +- .../Option/categories/Backup/useBackup.ts | 10 +- .../Option/categories/Limit/index.tsx | 18 +- .../Option/categories/Limit/useVerify.ts | 2 +- .../Option/categories/Notification/index.tsx | 16 +- .../Notification/useNotification.ts | 4 +- .../categories/Notification/usePermission.tsx | 6 +- .../components/Option/categories/Tracking.tsx | 14 +- .../app/components/Option/export-import.ts | 8 +- src/pages/app/components/Option/useOption.ts | 2 +- .../components/Report/Filter/BatchDelete.tsx | 4 +- .../Report/Filter/MergeFilterItem.tsx | 8 +- src/pages/app/components/Report/List/Item.tsx | 6 +- .../Report/Table/columns/CateColumn.tsx | 8 +- .../Report/Table/columns/DateColumn.tsx | 2 +- .../Report/Table/columns/GroupColumn.tsx | 2 +- .../Report/Table/columns/HostColumn.tsx | 4 +- .../Report/Table/columns/OperationColumn.tsx | 12 +- .../Report/Table/columns/TimeColumn.tsx | 4 +- .../Report/Table/columns/VisitColumn.tsx | 2 +- .../app/components/Report/Table/index.tsx | 12 +- src/pages/app/components/Report/common.ts | 16 +- .../Report/components/CompositionTable.tsx | 4 +- .../Report/components/TooltipSiteList.tsx | 2 +- .../app/components/Report/file-export.ts | 8 +- src/pages/app/components/Report/types.d.ts | 8 +- .../app/components/RuleMerge/ItemList.tsx | 2 +- .../SiteManage/Modify/HostSelect.tsx | 4 +- .../components/SiteManage/Modify/index.tsx | 4 +- .../SiteManage/SiteManageFilter.tsx | 2 +- .../SiteManage/Table/column/AliasColumn.tsx | 4 +- .../Table/column/OperationColumn.tsx | 4 +- .../SiteManage/Table/column/TypeColumn.tsx | 6 +- .../app/components/SiteManage/Table/index.tsx | 12 +- src/pages/app/components/SiteManage/common.ts | 2 +- .../components/SiteManage/useSiteManage.ts | 12 +- .../Whitelist/WhitePanel/WhiteInput.tsx | 2 +- .../components/common/Category/Editable.tsx | 2 +- .../common/Category/Select/OptionItem.tsx | 2 +- src/pages/app/components/common/HostAlert.tsx | 2 +- .../app/components/common/Pagination.tsx | 8 +- .../common/filter/TimeFormatFilterItem.tsx | 6 +- .../common/imported/CompareTable.tsx | 20 +- .../common/imported/ResolutionRadio.tsx | 4 +- src/pages/app/context.ts | 2 +- src/pages/app/locale.ts | 2 +- src/pages/app/util/limit/index.tsx | 4 +- src/pages/app/util/limit/processor.ts | 2 +- src/pages/app/util/limit/types.ts | 4 +- src/pages/app/util/time.ts | 4 +- src/pages/hooks/useSiteMerge.ts | 2 +- src/pages/popup/common.tsx | 12 +- .../popup/components/Header/LangSelect.tsx | 4 +- .../popup/components/Limit/Content/Chart.tsx | 2 +- .../popup/components/Limit/Content/Wrapper.ts | 12 +- .../components/Percentage/Cate/Wrapper.ts | 2 +- .../popup/components/Percentage/chart.ts | 18 +- .../popup/components/Percentage/query.ts | 8 +- src/pages/popup/components/Ranking/Item.tsx | 4 +- src/pages/popup/components/Ranking/query.tsx | 2 +- src/pages/popup/context.ts | 2 +- src/pages/popup/types.d.ts | 4 +- src/pages/side/components/RowList/Item.tsx | 4 +- src/pages/side/components/RowList/index.tsx | 2 +- src/pages/util/dark-mode.ts | 4 +- src/shared/route.ts | 6 +- src/util/constant/option.ts | 14 +- src/util/constant/url.ts | 8 +- src/util/document.ts | 2 +- src/util/limit.ts | 14 +- src/util/period.ts | 24 +- src/util/site.ts | 32 +- src/util/stat.ts | 38 +- test-e2e/backup/common.ts | 4 +- test-e2e/limit/common.ts | 2 +- test-e2e/limit/daily-time.test.ts | 2 +- test-e2e/limit/daily-visit.test.ts | 2 +- test-e2e/limit/delay-duration.test.ts | 4 +- test-e2e/limit/visit-limit.test.ts | 2 +- .../background/backup/gist/compressor.test.ts | 2 +- .../database/limit-database.test.ts | 8 +- .../database/merge-rule-database.test.ts | 6 +- test/background/database/migratable.ts | 4 +- .../database/period-database.test.ts | 6 +- .../database/stat-database/classic.test.ts | 8 +- .../database/stat-database/idb.test.ts | 60 +- .../components/period-calculator.test.ts | 8 +- test/background/service/limit-service.test.ts | 4 +- test/util/limit.test.ts | 6 +- types/chrome.d.ts | 4 +- types/timer/app.d.ts | 10 - types/timer/backup.d.ts | 129 -- types/timer/common.d.ts | 33 - types/timer/core.d.ts | 39 - types/timer/echarts-extend.d.ts | 7 - types/timer/imported.d.ts | 22 - types/timer/index.d.ts | 88 -- types/timer/limit.d.ts | 158 --- types/timer/merge.d.ts | 15 - types/timer/message.d.ts | 160 --- types/timer/notification.d.ts | 4 - types/timer/option.d.ts | 242 ---- types/timer/period.d.ts | 42 - types/timer/site.d.ts | 52 - types/timer/stat.d.ts | 178 --- types/timer/timeline.d.ts | 28 - types/tt4b.d.ts | 1213 +++++++++++++++++ 255 files changed, 2192 insertions(+), 2188 deletions(-) delete mode 100644 types/timer/app.d.ts delete mode 100644 types/timer/backup.d.ts delete mode 100644 types/timer/common.d.ts delete mode 100644 types/timer/core.d.ts delete mode 100644 types/timer/echarts-extend.d.ts delete mode 100644 types/timer/imported.d.ts delete mode 100644 types/timer/index.d.ts delete mode 100644 types/timer/limit.d.ts delete mode 100644 types/timer/merge.d.ts delete mode 100644 types/timer/message.d.ts delete mode 100644 types/timer/notification.d.ts delete mode 100644 types/timer/option.d.ts delete mode 100644 types/timer/period.d.ts delete mode 100644 types/timer/site.d.ts delete mode 100644 types/timer/stat.d.ts delete mode 100644 types/timer/timeline.d.ts create mode 100644 types/tt4b.d.ts diff --git a/script/crowdin/common.ts b/script/crowdin/common.ts index 4bf5a7ca8..5e21c7e8c 100644 --- a/script/crowdin/common.ts +++ b/script/crowdin/common.ts @@ -32,9 +32,9 @@ export const ALL_CROWDIN_LANGUAGES = [ */ export type CrowdinLanguage = typeof ALL_CROWDIN_LANGUAGES[number] -export const SOURCE_LOCALE: timer.RequiredLocale = 'en' +export const SOURCE_LOCALE: tt4b.RequiredLocale = 'en' -const OPTIONAL_PLACEHOLDER: Record<timer.OptionalLocale, 0> = { +const OPTIONAL_PLACEHOLDER: Record<tt4b.OptionalLocale, 0> = { ja: 0, uk: 0, de: 0, @@ -50,9 +50,9 @@ const OPTIONAL_PLACEHOLDER: Record<timer.OptionalLocale, 0> = { it: 0, } -export const ALL_TRANS_LOCALES = Object.keys(OPTIONAL_PLACEHOLDER) as timer.OptionalLocale[] +export const ALL_TRANS_LOCALES = Object.keys(OPTIONAL_PLACEHOLDER) as tt4b.OptionalLocale[] -const I18N_CROWDIN_MAP: Record<timer.OptionalLocale, CrowdinLanguage> = { +const I18N_CROWDIN_MAP: Record<tt4b.OptionalLocale, CrowdinLanguage> = { zh_CN: 'zh-CN', ja: 'ja', zh_TW: 'zh-TW', @@ -68,7 +68,7 @@ const I18N_CROWDIN_MAP: Record<timer.OptionalLocale, CrowdinLanguage> = { it: 'it', } -export const crowdinLangOf = (locale: timer.OptionalLocale): CrowdinLanguage => I18N_CROWDIN_MAP[locale] +export const crowdinLangOf = (locale: tt4b.OptionalLocale): CrowdinLanguage => I18N_CROWDIN_MAP[locale] const IGNORED_FILE: Partial<{ [dir in Dir]: string[] }> = { common: [ @@ -119,7 +119,7 @@ export async function readAllMessages(dir: Dir): Promise<Record<string, Messages export async function mergeMessage( dir: Dir, filename: string, - messages: Partial<Record<timer.Locale, ItemSet>> + messages: Partial<Record<tt4b.Locale, ItemSet>> ): Promise<void> { const dirPath = path.join(MSG_BASE, dir) const filePath = path.join(dirPath, filename) @@ -147,7 +147,7 @@ export async function mergeMessage( const pathSeg = path.split('.') fillItem(pathSeg, 0, newMessage, text) }) - Object.entries(newMessage).length && (existMessages[locale as timer.Locale] = newMessage) + Object.entries(newMessage).length && (existMessages[locale as tt4b.Locale] = newMessage) }) const newFileContent = JSON.stringify(existMessages, null, 4) diff --git a/script/crowdin/export-translation.ts b/script/crowdin/export-translation.ts index eca038ee3..115f437f7 100644 --- a/script/crowdin/export-translation.ts +++ b/script/crowdin/export-translation.ts @@ -17,7 +17,7 @@ const TEMP_FILE_NAME = join(process.cwd(), ".crowdin-temp.zip") const TEMP_DIR = join(process.cwd(), ".crowdin-temp") async function processDir(dir: Dir): Promise<void> { - const fileSets: Record<string, Partial<Record<timer.Locale, ItemSet>>> = {} + const fileSets: Record<string, Partial<Record<tt4b.Locale, ItemSet>>> = {} for (const locale of ALL_TRANS_LOCALES) { const crowdinLang = crowdinLangOf(locale) const dirPath = join(TEMP_DIR, crowdinLang, dir) diff --git a/src/api/chrome/runtime.ts b/src/api/chrome/runtime.ts index 837ba2130..8f70371f6 100644 --- a/src/api/chrome/runtime.ts +++ b/src/api/chrome/runtime.ts @@ -14,9 +14,9 @@ export function getIconUrl(): string { export function onRuntimeMessage(handler: ChromeMessageHandler): void { // Be careful!!! // Can't use await/async in callback parameter - chrome.runtime.onMessage.addListener((message: timer.mq.Request<timer.mq.ReqCode>, sender: chrome.runtime.MessageSender, sendResponse: timer.mq.Callback<timer.mq.ReqCode>) => { + chrome.runtime.onMessage.addListener((message: tt4b.mq.Request<tt4b.mq.ReqCode>, sender: chrome.runtime.MessageSender, sendResponse: tt4b.mq.Callback<tt4b.mq.ReqCode>) => { void handler(message, sender) - .then((response: timer.mq.Response<timer.mq.ReqCode>) => { + .then((response: tt4b.mq.Response<tt4b.mq.ReqCode>) => { sendResponse(response) }) .catch((err: unknown) => { diff --git a/src/api/chrome/tab.ts b/src/api/chrome/tab.ts index 68dadd083..79c9e6b27 100644 --- a/src/api/chrome/tab.ts +++ b/src/api/chrome/tab.ts @@ -63,15 +63,15 @@ export function listTabs(query?: chrome.tabs.QueryInfo): Promise<ChromeTab[]> { })) } -export function sendMsg2Tab<C extends timer.tab.ReqCode>(tabId: number, code: C, data?: timer.tab.ReqData<C>): Promise<timer.tab.ResData<C> | undefined> { - const request: timer.tab.Request<C> = { code, data: data as timer.tab.ReqData<C> } +export function sendMsg2Tab<C extends tt4b.tab.ReqCode>(tabId: number, code: C, data?: tt4b.tab.ReqData<C>): Promise<tt4b.tab.ResData<C> | undefined> { + const request: tt4b.tab.Request<C> = { code, data: data as tt4b.tab.ReqData<C> } return new Promise((resolve, reject) => { const timeout = setTimeout(() => reject('sendMsg2Tab timeout'), 2000) - chrome.tabs.sendMessage<timer.tab.Request<C>, timer.tab.Response<C>>(tabId, request, response => { + chrome.tabs.sendMessage<tt4b.tab.Request<C>, tt4b.tab.Response<C>>(tabId, request, response => { const sendError = handleError('sendMsg2Tab') clearTimeout(timeout) if (response?.code === 'success') { - resolve(response.data as timer.tab.ResData<C> | undefined) + resolve(response.data as tt4b.tab.ResData<C> | undefined) return } if (response?.code === 'fail') { @@ -83,11 +83,11 @@ export function sendMsg2Tab<C extends timer.tab.ReqCode>(tabId: number, code: C, }) } -export async function trySendMsg2Tab<C extends timer.tab.ReqCode>( +export async function trySendMsg2Tab<C extends tt4b.tab.ReqCode>( tabId: number, code: C, - data?: timer.tab.ReqData<C> -): Promise<timer.tab.ResData<C> | undefined> { + data?: tt4b.tab.ReqData<C> +): Promise<tt4b.tab.ResData<C> | undefined> { try { return await sendMsg2Tab(tabId, code, data) } catch (e) { diff --git a/src/api/sw/backup.ts b/src/api/sw/backup.ts index fb7f35875..1a9bdd177 100644 --- a/src/api/sw/backup.ts +++ b/src/api/sw/backup.ts @@ -6,10 +6,10 @@ export const checkAuth = () => sendMsg2Runtime('backup.checkAuth') export const clearBackup = (cid: string) => sendMsg2Runtime('backup.clear', cid, 60_000) -export const queryBackup = (param: timer.backup.RemoteQuery) => sendMsg2Runtime('backup.query', param, 120_000) +export const queryBackup = (param: tt4b.backup.RemoteQuery) => sendMsg2Runtime('backup.query', param, 120_000) -export const previewBackup = (param: timer.backup.RemoteQuery) => sendMsg2Runtime('backup.preview', param, 120_000) +export const previewBackup = (param: tt4b.backup.RemoteQuery) => sendMsg2Runtime('backup.preview', param, 120_000) -export const getLastBackUp = (type: timer.backup.Type) => sendMsg2Runtime('backup.lastTs', type) +export const getLastBackUp = (type: tt4b.backup.Type) => sendMsg2Runtime('backup.lastTs', type) export const allBackupClients = () => sendMsg2Runtime('backup.clients') diff --git a/src/api/sw/common.ts b/src/api/sw/common.ts index 907a614c7..d674e93ea 100644 --- a/src/api/sw/common.ts +++ b/src/api/sw/common.ts @@ -13,23 +13,23 @@ function cloneData<T = any>(data: T | undefined): T | undefined { } } -type RuntimeMsgArgs<C extends timer.mq.ReqCode> = [timer.mq.ReqData<C>] extends [undefined] - ? [data?: timer.mq.ReqData<C>, timeout_ms?: number] - : [data: timer.mq.ReqData<C>, timeout_ms?: number] +type RuntimeMsgArgs<C extends tt4b.mq.ReqCode> = [tt4b.mq.ReqData<C>] extends [undefined] + ? [data?: tt4b.mq.ReqData<C>, timeout_ms?: number] + : [data: tt4b.mq.ReqData<C>, timeout_ms?: number] -export function sendMsg2Runtime<C extends timer.mq.ReqCode>( +export function sendMsg2Runtime<C extends tt4b.mq.ReqCode>( code: C, ...args: RuntimeMsgArgs<C> -): Promise<timer.mq.ResData<C>> { +): Promise<tt4b.mq.ResData<C>> { const [data, timeout_ms] = args - const request: timer.mq.Request<C> = { code, data: cloneData(data) as timer.mq.ReqData<C> } + const request: tt4b.mq.Request<C> = { code, data: cloneData(data) as tt4b.mq.ReqData<C> } return new Promise((resolve, reject) => { const timeout = setTimeout(() => { // timeout: no response from runtime reject('sendMsg2Runtime timeout') }, timeout_ms ?? 10_000) try { - chrome.runtime.sendMessage(request, (response: timer.mq.Response<C>) => { + chrome.runtime.sendMessage(request, (response: tt4b.mq.Response<C>) => { clearTimeout(timeout) handleError('sendMsg2Runtime') const resCode = response?.code @@ -37,7 +37,7 @@ export function sendMsg2Runtime<C extends timer.mq.ReqCode>( console.warn("Error occurred when querying service-worker", code, data, response?.msg) return reject(new Error(response?.msg || 'Unknown error')) } - resCode === 'success' && resolve(response.data as timer.mq.ResData<C>) + resCode === 'success' && resolve(response.data as tt4b.mq.ResData<C>) }) } catch (e) { clearTimeout(timeout) @@ -51,10 +51,10 @@ export function sendMsg2Runtime<C extends timer.mq.ReqCode>( * Wrap for hooks, after the extension reloaded or upgraded, the context of current content script will be invalid * And sending messages to the runtime will be failed */ -export async function trySendMsg2Runtime<C extends timer.mq.ReqCode>( +export async function trySendMsg2Runtime<C extends tt4b.mq.ReqCode>( code: C, ...args: RuntimeMsgArgs<C> -): Promise<timer.mq.ResData<C> | undefined> { +): Promise<tt4b.mq.ResData<C> | undefined> { try { return await sendMsg2Runtime(code, ...args) } catch { diff --git a/src/api/sw/immigration.ts b/src/api/sw/immigration.ts index 568b1a40c..05c10e155 100644 --- a/src/api/sw/immigration.ts +++ b/src/api/sw/immigration.ts @@ -1,5 +1,5 @@ import { sendMsg2Runtime } from "./common" -export function importOther(query: timer.imported.ProcessQuery) { +export function importOther(query: tt4b.imported.ProcessQuery) { return sendMsg2Runtime('immigration.importOther', query, 60_000) } diff --git a/src/api/sw/limit.ts b/src/api/sw/limit.ts index 37685e05f..052e32d84 100644 --- a/src/api/sw/limit.ts +++ b/src/api/sw/limit.ts @@ -1,11 +1,11 @@ import { sendMsg2Runtime } from "./common" -export const listLimits = (query?: timer.limit.Query) => sendMsg2Runtime('limit.list', query) +export const listLimits = (query?: tt4b.limit.Query) => sendMsg2Runtime('limit.list', query) export const getLimitSummary = () => sendMsg2Runtime('limit.summary') export const deleteLimits = (ids: number[]) => sendMsg2Runtime('limit.delete', ids) -export const updateLimits = (rules: timer.limit.Rule[]) => sendMsg2Runtime('limit.update', rules) +export const updateLimits = (rules: tt4b.limit.Rule[]) => sendMsg2Runtime('limit.update', rules) -export const addLimit = (rule: Omit<timer.limit.Rule, 'id'>) => sendMsg2Runtime('limit.add', rule) +export const addLimit = (rule: Omit<tt4b.limit.Rule, 'id'>) => sendMsg2Runtime('limit.add', rule) diff --git a/src/api/sw/merge.ts b/src/api/sw/merge.ts index 19ab325f1..6e3f06516 100644 --- a/src/api/sw/merge.ts +++ b/src/api/sw/merge.ts @@ -4,4 +4,4 @@ export const listAllMergeRules = () => sendMsg2Runtime('merge.all') export const deleteMergeRule = (origin: string) => sendMsg2Runtime('merge.delete', origin) -export const addMergeRule = (rule: timer.merge.Rule) => sendMsg2Runtime('merge.add', rule) \ No newline at end of file +export const addMergeRule = (rule: tt4b.merge.Rule) => sendMsg2Runtime('merge.add', rule) \ No newline at end of file diff --git a/src/api/sw/option.ts b/src/api/sw/option.ts index 3cd2e20a7..d8735a45c 100644 --- a/src/api/sw/option.ts +++ b/src/api/sw/option.ts @@ -2,7 +2,7 @@ import { sendMsg2Runtime } from "@api/sw/common" export const getOption = () => sendMsg2Runtime('option.get') -export const setOption = (option: Partial<timer.option.AllOption>) => sendMsg2Runtime('option.set', option) +export const setOption = (option: Partial<tt4b.option.AllOption>) => sendMsg2Runtime('option.set', option) export const getWeekStartTime = async (now?: number | Date): Promise<Date> => { let nowTs = typeof now === 'number' ? now : now?.getTime() diff --git a/src/api/sw/period.ts b/src/api/sw/period.ts index 8d028488a..18b55c9d0 100644 --- a/src/api/sw/period.ts +++ b/src/api/sw/period.ts @@ -1,3 +1,3 @@ import { sendMsg2Runtime } from './common' -export const listPeriods = (param: timer.period.Query) => sendMsg2Runtime('period.list', param) \ No newline at end of file +export const listPeriods = (param: tt4b.period.Query) => sendMsg2Runtime('period.list', param) \ No newline at end of file diff --git a/src/api/sw/site.ts b/src/api/sw/site.ts index 1a146d4de..0109e51ae 100644 --- a/src/api/sw/site.ts +++ b/src/api/sw/site.ts @@ -1,30 +1,30 @@ import { sendMsg2Runtime } from "./common" -export const listSites = (param?: timer.site.Query) => sendMsg2Runtime('site.list', param) +export const listSites = (param?: tt4b.site.Query) => sendMsg2Runtime('site.list', param) -export function getSitePage(param?: timer.site.Query, page?: timer.common.PageQuery) { +export function getSitePage(param?: tt4b.site.Query, page?: tt4b.common.PageQuery) { return sendMsg2Runtime('site.page', { ...param, ...page }) } -export const deleteSites = (...keys: timer.site.SiteKey[]) => sendMsg2Runtime('site.delete', keys) +export const deleteSites = (...keys: tt4b.site.SiteKey[]) => sendMsg2Runtime('site.delete', keys) -export function changeSitesCate(cateId: number | undefined, ...keys: timer.site.SiteKey[]) { +export function changeSitesCate(cateId: number | undefined, ...keys: tt4b.site.SiteKey[]) { return sendMsg2Runtime('site.changeCate', { keys, cateId }) } -export const deleteSiteIcon = (key: timer.site.SiteKey) => sendMsg2Runtime('site.deleteIcon', key) +export const deleteSiteIcon = (key: tt4b.site.SiteKey) => sendMsg2Runtime('site.deleteIcon', key) -export async function changeSiteAlias(key: timer.site.SiteKey, alias: string | undefined): Promise<string | undefined> { +export async function changeSiteAlias(key: tt4b.site.SiteKey, alias: string | undefined): Promise<string | undefined> { const trimmed = alias?.trim() || undefined await sendMsg2Runtime('site.changeAlias', { key, alias: trimmed }) return trimmed } -export const fillInitialAlias = (keys: timer.site.SiteKey[]) => sendMsg2Runtime('site.fillAlias', keys) +export const fillInitialAlias = (keys: tt4b.site.SiteKey[]) => sendMsg2Runtime('site.fillAlias', keys) export const getInitialAlias = (host: string) => sendMsg2Runtime('site.initialAlias', host) -export function changeSiteRun(key: timer.site.SiteKey, enabled: boolean) { +export function changeSiteRun(key: tt4b.site.SiteKey, enabled: boolean) { return sendMsg2Runtime('site.changeRun', { key, enabled }) } diff --git a/src/api/sw/stat.ts b/src/api/sw/stat.ts index 3acc35f26..534b02f35 100644 --- a/src/api/sw/stat.ts +++ b/src/api/sw/stat.ts @@ -1,8 +1,8 @@ import { sendMsg2Runtime } from "./common" -export const listSiteStats = (param?: timer.stat.SiteQuery) => sendMsg2Runtime('stat.sites', param) +export const listSiteStats = (param?: tt4b.stat.SiteQuery) => sendMsg2Runtime('stat.sites', param) -export const getSiteStatPage = (param?: timer.stat.SitePageQuery) => sendMsg2Runtime('stat.sitePage', param) +export const getSiteStatPage = (param?: tt4b.stat.SitePageQuery) => sendMsg2Runtime('stat.sitePage', param) export function deleteSiteStatByHost(host: string, date?: [string?, string?] | string) { return sendMsg2Runtime('stat.deleteSite', { host, date }) @@ -12,15 +12,15 @@ export function deleteSiteStatByGroup(groupId: number, date?: [string?, string?] return sendMsg2Runtime('stat.deleteSite', { groupId, date }) } -export const listCateStats = (param?: timer.stat.CateQuery) => sendMsg2Runtime('stat.cates', param) +export const listCateStats = (param?: tt4b.stat.CateQuery) => sendMsg2Runtime('stat.cates', param) -export const getCateStatPage = (param?: timer.stat.CatePageQuery) => sendMsg2Runtime('stat.catePage', param) +export const getCateStatPage = (param?: tt4b.stat.CatePageQuery) => sendMsg2Runtime('stat.catePage', param) -export const listGroupStats = (param?: timer.stat.GroupQuery) => sendMsg2Runtime('stat.groups', param) +export const listGroupStats = (param?: tt4b.stat.GroupQuery) => sendMsg2Runtime('stat.groups', param) -export const getGroupStatPage = (param?: timer.stat.GroupPageQuery) => sendMsg2Runtime('stat.groupPage', param) +export const getGroupStatPage = (param?: tt4b.stat.GroupPageQuery) => sendMsg2Runtime('stat.groupPage', param) -export const batchDeleteStats = (targets: timer.stat.Row[]) => sendMsg2Runtime('stat.batchDelete', targets) +export const batchDeleteStats = (targets: tt4b.stat.Row[]) => sendMsg2Runtime('stat.batchDelete', targets) export function countGroupStatsByIds(groupIds: number[], date: string | [string?, string?]) { return sendMsg2Runtime('stat.countGroup', { groupIds, date }) diff --git a/src/background/badge-manager.ts b/src/background/badge-manager.ts index 846106d9d..84618f279 100644 --- a/src/background/badge-manager.ts +++ b/src/background/badge-manager.ts @@ -105,7 +105,7 @@ class BadgeManager { await this.render() } - private async processOption(option: timer.option.DefaultOption) { + private async processOption(option: tt4b.option.DefaultOption) { const { displayBadgeText, badgeBgColor, countLocalFiles } = option const changed = this.#visible !== displayBadgeText || this.#countLocalFiles !== countLocalFiles diff --git a/src/background/content-script-handler.ts b/src/background/content-script-handler.ts index 4f17af544..3ad13fe4c 100644 --- a/src/background/content-script-handler.ts +++ b/src/background/content-script-handler.ts @@ -17,7 +17,7 @@ function isUrl(title: string) { return title.startsWith('https://') || title.startsWith('http://') || title.startsWith('ftp://') } -async function collectAlias(key: timer.site.SiteKey, tabTitle: string) { +async function collectAlias(key: tt4b.site.SiteKey, tabTitle: string) { if (!tabTitle) return if (isUrl(tabTitle)) return const siteName = extractSiteName(tabTitle, key.host) @@ -36,7 +36,7 @@ async function processTabInfo(tab: ChromeTab): Promise<void> { if (!host) return // localhost hosts with Chrome use cache, so keep the favIcon url undefined IS_CHROME && /^localhost(:.+)?/.test(host) && (favIconUrl = undefined) - const siteKey: timer.site.SiteKey = { host, type: 'normal' } + const siteKey: tt4b.site.SiteKey = { host, type: 'normal' } favIconUrl && await saveIconUrl(siteKey, favIconUrl) !IS_ANDROID && !isBrowserUrl(url) diff --git a/src/background/database/backup-database.ts b/src/background/database/backup-database.ts index c6c74e254..c0129cd5e 100644 --- a/src/background/database/backup-database.ts +++ b/src/background/database/backup-database.ts @@ -11,17 +11,17 @@ import { REMAIN_WORD_PREFIX } from "./common/constant" const PREFIX = REMAIN_WORD_PREFIX + "backup" const CACHE_KEY = PREFIX + "_cache" -function cacheKeyOf(type: timer.backup.Type) { +function cacheKeyOf(type: tt4b.backup.Type) { return CACHE_KEY + "_" + type } class BackupDatabase extends BaseDatabase { - async getCache(type: timer.backup.Type): Promise<unknown> { + async getCache(type: tt4b.backup.Type): Promise<unknown> { return (await this.storage.getOne(cacheKeyOf(type))) || {} } - async updateCache(type: timer.backup.Type, newVal: unknown): Promise<void> { + async updateCache(type: tt4b.backup.Type, newVal: unknown): Promise<void> { return this.storage.put(cacheKeyOf(type), newVal as Object) } } diff --git a/src/background/database/cate-database.ts b/src/background/database/cate-database.ts index 2d98ecfbe..9c0b69336 100644 --- a/src/background/database/cate-database.ts +++ b/src/background/database/cate-database.ts @@ -34,17 +34,17 @@ class CateDatabase extends BaseDatabase { await this.storage.put(KEY, items || {}) } - async listAll(): Promise<timer.site.Cate[]> { + async listAll(): Promise<tt4b.site.Cate[]> { const items = await this.getItems() return Object.entries(items).map(([id, { n = '' } = {}]) => { return { id: parseInt(id), name: n, - } satisfies timer.site.Cate + } satisfies tt4b.site.Cate }) } - async add(name: string): Promise<timer.site.Cate> { + async add(name: string): Promise<tt4b.site.Cate> { const items = await this.getItems() const existId = Object.entries(items).find(([_, v]) => v.n === name)?.[0] if (existId) { diff --git a/src/background/database/common/migratable.ts b/src/background/database/common/migratable.ts index 1aa6ab5ae..323b9be49 100644 --- a/src/background/database/common/migratable.ts +++ b/src/background/database/common/migratable.ts @@ -2,14 +2,14 @@ import { isRecord } from '@util/guard' import { createObjectGuard, isInt, isString, TypeGuard } from 'typescript-guard' import type { BrowserMigratableNamespace } from '../types' -export const isExportData = createObjectGuard<Pick<timer.backup.ExportData, '__meta__'>>({ +export const isExportData = createObjectGuard<Pick<tt4b.backup.ExportData, '__meta__'>>({ __meta__: createObjectGuard({ version: isString, ts: isInt, }), }) -export const isLegacyVersion = (data: unknown): data is timer.backup.ExportData => { +export const isLegacyVersion = (data: unknown): data is tt4b.backup.ExportData => { if (!isExportData(data)) return false const version = data.__meta__.version diff --git a/src/background/database/common/storage-holder.ts b/src/background/database/common/storage-holder.ts index 646ae413b..fdc2194c1 100644 --- a/src/background/database/common/storage-holder.ts +++ b/src/background/database/common/storage-holder.ts @@ -2,21 +2,21 @@ import optionHolder from '@service/components/option-holder' export class StorageHolder<Database> { current: Database - delegates: Record<timer.option.StorageType, Database> + delegates: Record<tt4b.option.StorageType, Database> - constructor(delegates: Record<timer.option.StorageType, Database>) { + constructor(delegates: Record<tt4b.option.StorageType, Database>) { this.delegates = delegates this.current = delegates.classic optionHolder.get().then(val => this.handleOption(val)) optionHolder.addChangeListener(val => this.handleOption(val)) } - private handleOption(option: timer.option.TrackingOption) { + private handleOption(option: tt4b.option.TrackingOption) { const delegate = this.delegates[option.storage] delegate && (this.current = delegate) } - get(type: timer.option.StorageType): Database | null { + get(type: tt4b.option.StorageType): Database | null { return this.delegates[type] ?? null } } \ No newline at end of file diff --git a/src/background/database/limit-database.ts b/src/background/database/limit-database.ts index 97ef85730..b2a3911d8 100644 --- a/src/background/database/limit-database.ts +++ b/src/background/database/limit-database.ts @@ -23,11 +23,11 @@ type DateRecords = { } } -export type LimitRecord = timer.limit.Rule & { +export type LimitRecord = tt4b.limit.Rule & { records: DateRecords } -type PartialRule = MakeRequired<Partial<timer.limit.Rule>, 'name' | 'cond'> +type PartialRule = MakeRequired<Partial<tt4b.limit.Rule>, 'name' | 'cond'> const isValidRow = createObjectGuard<PartialRule>({ id: isOptionalInt, @@ -159,7 +159,7 @@ function migrate(exist: Items, toMigrate: unknown) { }) } -function cvtRule2Item(rule: timer.limit.Rule): ItemValue { +function cvtRule2Item(rule: tt4b.limit.Rule): ItemValue { const { id, name, cond, time, count, weekly, weeklyCount, visitTime, periods, enabled, locked, allowDelay, weekdays } = rule return { i: id, n: name, c: cond, t: time, ct: count, wt: weekly, wct: weeklyCount, @@ -200,7 +200,7 @@ class LimitDatabase extends BaseDatabase implements BrowserMigratable<'__limit__ return Object.values(items).map(cvtItem2Rec) } - async batchUpdate(rules: timer.limit.Rule[]): Promise<void> { + async batchUpdate(rules: tt4b.limit.Rule[]): Promise<void> { if (!rules.length) return const items = await this.getItems() rules.forEach(rule => { @@ -212,7 +212,7 @@ class LimitDatabase extends BaseDatabase implements BrowserMigratable<'__limit__ await this.update(items) } - async add(data: Omit<timer.limit.Rule, 'id'>): Promise<number> { + async add(data: Omit<tt4b.limit.Rule, 'id'>): Promise<number> { const items = await this.getItems() const lastId = Object.values(items) @@ -257,7 +257,7 @@ class LimitDatabase extends BaseDatabase implements BrowserMigratable<'__limit__ await this.update(items) } - async updateDelayCount(date: string, toUpdate: timer.limit.Item[]): Promise<void> { + async updateDelayCount(date: string, toUpdate: tt4b.limit.Item[]): Promise<void> { const items = await this.getItems() toUpdate?.forEach(({ id, delayCount }) => { const entry = items[id] @@ -277,7 +277,7 @@ class LimitDatabase extends BaseDatabase implements BrowserMigratable<'__limit__ const rows = extractNamespace(data, this.namespace, isValidImportRows) ?? [] for (const row of rows) { - const toImport: Omit<timer.limit.Rule, 'id'> = { + const toImport: Omit<tt4b.limit.Rule, 'id'> = { name: row.name, cond: row.cond, time: row.time, @@ -306,7 +306,7 @@ class LimitDatabase extends BaseDatabase implements BrowserMigratable<'__limit__ this.setByKey(KEY, exists) } - exportData(): Promise<timer.limit.Rule[]> { + exportData(): Promise<tt4b.limit.Rule[]> { return this.all() } } diff --git a/src/background/database/memory-detector.ts b/src/background/database/memory-detector.ts index f973f49bc..e4fc35799 100644 --- a/src/background/database/memory-detector.ts +++ b/src/background/database/memory-detector.ts @@ -17,7 +17,7 @@ const total: number = chrome.storage.local.QUOTA_BYTES || 0 * * @since 0.0.9 */ -export async function getUsedStorage(): Promise<timer.common.StorageUsage> { +export async function getUsedStorage(): Promise<tt4b.common.StorageUsage> { const used = await new StoragePromise(chrome.storage.local).getUsedMemory() return { used, total } } \ No newline at end of file diff --git a/src/background/database/merge-rule-database.ts b/src/background/database/merge-rule-database.ts index aa4f1a502..7fe275851 100644 --- a/src/background/database/merge-rule-database.ts +++ b/src/background/database/merge-rule-database.ts @@ -20,7 +20,7 @@ const isMergeValue = createUnionGuard(isString, isInt) const isMergeRuleSet = createRecordGuard(isMergeValue) -const isMergeRule = createObjectGuard<timer.merge.Rule>({ +const isMergeRule = createObjectGuard<tt4b.merge.Rule>({ origin: isString, merged: isMergeValue, }) @@ -42,10 +42,10 @@ class MergeRuleDatabase extends BaseDatabase implements BrowserMigratable<'__mer return this.setByKey(DB_KEY, data) } - async selectAll(): Promise<timer.merge.Rule[]> { + async selectAll(): Promise<tt4b.merge.Rule[]> { const set = await this.refresh() return Object.entries(set) - .map(([origin, merged]) => ({ origin, merged } satisfies timer.merge.Rule)) + .map(([origin, merged]) => ({ origin, merged } satisfies tt4b.merge.Rule)) } async remove(origin: string): Promise<void> { @@ -57,7 +57,7 @@ class MergeRuleDatabase extends BaseDatabase implements BrowserMigratable<'__mer /** * Add to the db */ - async add(...toAdd: timer.merge.Rule[]): Promise<void> { + async add(...toAdd: tt4b.merge.Rule[]): Promise<void> { const set = await this.refresh() // Not rewrite toAdd.forEach(({ origin, merged }) => set[origin] = set[origin] ?? merged) @@ -87,7 +87,7 @@ class MergeRuleDatabase extends BaseDatabase implements BrowserMigratable<'__mer await this.update(exist) } - exportData(): Promise<timer.merge.Rule[]> { + exportData(): Promise<tt4b.merge.Rule[]> { return this.selectAll() } } diff --git a/src/background/database/meta-database.ts b/src/background/database/meta-database.ts index a4ee18283..1b4910566 100644 --- a/src/background/database/meta-database.ts +++ b/src/background/database/meta-database.ts @@ -12,12 +12,12 @@ import { META_KEY } from "./common/constant" * @since 0.6.0 */ class MetaDatabase extends BaseDatabase { - async getMeta(): Promise<timer.ExtensionMeta> { - const meta = await this.storage.getOne<timer.ExtensionMeta>(META_KEY) + async getMeta(): Promise<tt4b.ExtensionMeta> { + const meta = await this.storage.getOne<tt4b.ExtensionMeta>(META_KEY) return meta || {} } - async update(existMeta: timer.ExtensionMeta): Promise<void> { + async update(existMeta: tt4b.ExtensionMeta): Promise<void> { await this.storage.put(META_KEY, existMeta) } } diff --git a/src/background/database/option-database.ts b/src/background/database/option-database.ts index 1fde3a263..ec93264c9 100644 --- a/src/background/database/option-database.ts +++ b/src/background/database/option-database.ts @@ -19,12 +19,12 @@ const DB_KEY = REMAIN_WORD_PREFIX + 'OPTION' */ class OptionDatabase extends BaseDatabase { - async getOption(): Promise<timer.option.AllOption> { - const option = await this.storage.getOne<timer.option.AllOption>(DB_KEY) - return mergeObject<timer.option.AllOption>(defaultOption(), option) + async getOption(): Promise<tt4b.option.AllOption> { + const option = await this.storage.getOne<tt4b.option.AllOption>(DB_KEY) + return mergeObject<tt4b.option.AllOption>(defaultOption(), option) } - async setOption(option: timer.option.AllOption): Promise<void> { + async setOption(option: tt4b.option.AllOption): Promise<void> { option && await this.setByKey(DB_KEY, option) } } diff --git a/src/background/database/period-database.ts b/src/background/database/period-database.ts index 3efd5612d..e99fca321 100644 --- a/src/background/database/period-database.ts +++ b/src/background/database/period-database.ts @@ -18,7 +18,7 @@ const KEY_PREFIX = REMAIN_WORD_PREFIX + 'PERIOD' const KEY_PREFIX_LENGTH = KEY_PREFIX.length const generateKey = (date: string) => KEY_PREFIX + date -function merge(exists: { [dateKey: string]: DailyResult }, toMerge: timer.period.Result[]) { +function merge(exists: { [dateKey: string]: DailyResult }, toMerge: tt4b.period.Result[]) { toMerge.forEach(period => { const { order, milliseconds } = period const key = generateKey(getDateString(period)) @@ -29,8 +29,8 @@ function merge(exists: { [dateKey: string]: DailyResult }, toMerge: timer.period }) } -function db2PeriodInfos(data: { [dateKey: string]: DailyResult }): timer.period.Result[] { - const result: timer.period.Result[] = [] +function db2PeriodInfos(data: { [dateKey: string]: DailyResult }): tt4b.period.Result[] { + const result: tt4b.period.Result[] = [] Object.entries(data).forEach((([dateKey, val]) => { const dateStr = dateKey.substring(KEY_PREFIX_LENGTH) const date = new Date( @@ -59,7 +59,7 @@ class PeriodDatabase extends BaseDatabase { return result || {} } - async accumulate(items: timer.period.Result[]): Promise<void> { + async accumulate(items: tt4b.period.Result[]): Promise<void> { const dates = Array.from(new Set(items.map(getDateString))) const exists = await this.getBatch0(dates) merge(exists, items) @@ -78,7 +78,7 @@ class PeriodDatabase extends BaseDatabase { return this.storage.get(keys) } - async getBatch(dates: string[]): Promise<timer.period.Result[]> { + async getBatch(dates: string[]): Promise<tt4b.period.Result[]> { const data = await this.getBatch0(dates) return db2PeriodInfos(data) } @@ -87,7 +87,7 @@ class PeriodDatabase extends BaseDatabase { * @since 1.0.0 * @returns all period items */ - async getAll(): Promise<timer.period.Result[]> { + async getAll(): Promise<tt4b.period.Result[]> { const allItems = await this.storage.get() const periodItems: { [dateKey: string]: DailyResult } = {} Object.entries(allItems) diff --git a/src/background/database/site-database.ts b/src/background/database/site-database.ts index 2429d1adf..0bdaf4d1a 100644 --- a/src/background/database/site-database.ts +++ b/src/background/database/site-database.ts @@ -34,7 +34,7 @@ const HOST_KEY_PREFIX = DB_KEY_PREFIX + 'h' const VIRTUAL_KEY_PREFIX = DB_KEY_PREFIX + 'v' const MERGED_FLAG = 'm' -function cvt2Key({ host, type }: timer.site.SiteKey): string { +function cvt2Key({ host, type }: tt4b.site.SiteKey): string { switch (type) { case 'virtual': return VIRTUAL_KEY_PREFIX + host case 'merged': return HOST_KEY_PREFIX + MERGED_FLAG + host @@ -42,7 +42,7 @@ function cvt2Key({ host, type }: timer.site.SiteKey): string { } } -function cvt2SiteKey(key: string): timer.site.SiteKey { +function cvt2SiteKey(key: string): tt4b.site.SiteKey { if (key.startsWith(VIRTUAL_KEY_PREFIX)) { return { host: key.substring(VIRTUAL_KEY_PREFIX.length), @@ -59,7 +59,7 @@ function cvt2SiteKey(key: string): timer.site.SiteKey { } } -function cvt2Entry({ alias, iconUrl, cate, run }: timer.site.SiteInfo): _Entry { +function cvt2Entry({ alias, iconUrl, cate, run }: tt4b.site.SiteInfo): _Entry { const entry: _Entry = { i: iconUrl } alias && (entry.a = alias) cate && (entry.c = cate) @@ -68,9 +68,9 @@ function cvt2Entry({ alias, iconUrl, cate, run }: timer.site.SiteInfo): _Entry { return entry } -function cvt2SiteInfo(key: timer.site.SiteKey, entry: _Entry | undefined): timer.site.SiteInfo { +function cvt2SiteInfo(key: tt4b.site.SiteKey, entry: _Entry | undefined): tt4b.site.SiteInfo { const { a, i, c, r } = entry ?? {} - const siteInfo: timer.site.SiteInfo = { ...key } + const siteInfo: tt4b.site.SiteInfo = { ...key } siteInfo.alias = a siteInfo.cate = c ?? CATE_NOT_SET_ID siteInfo.iconUrl = i @@ -78,7 +78,7 @@ function cvt2SiteInfo(key: timer.site.SiteKey, entry: _Entry | undefined): timer return siteInfo } -function buildFilter(condition?: timer.site.Query): (site: timer.site.SiteInfo) => boolean { +function buildFilter(condition?: tt4b.site.Query): (site: tt4b.site.SiteInfo) => boolean { const { fuzzyQuery, cateIds, types } = condition || {} let cateFilter = typeof cateIds === 'number' ? [cateIds] : (cateIds?.length ? cateIds : undefined) let typeFilter = typeof types === 'string' ? [types] : (types?.length ? types : undefined) @@ -92,7 +92,7 @@ function buildFilter(condition?: timer.site.Query): (site: timer.site.SiteInfo) } class SiteDatabase extends BaseDatabase { - async select(condition?: timer.site.Query): Promise<timer.site.SiteInfo[]> { + async select(condition?: tt4b.site.Query): Promise<tt4b.site.SiteInfo[]> { const filter = buildFilter(condition) const data = await this.storage.get() return Object.entries(data) @@ -106,30 +106,30 @@ class SiteDatabase extends BaseDatabase { * * @returns site info, or undefined */ - async get(key: timer.site.SiteKey): Promise<timer.site.SiteInfo | undefined> { + async get(key: tt4b.site.SiteKey): Promise<tt4b.site.SiteInfo | undefined> { const entry = await this.storage.getOne<_Entry>(cvt2Key(key)) return entry && cvt2SiteInfo(key, entry) } - async getBatch(keys: timer.site.SiteKey[]): Promise<timer.site.SiteInfo[]> { + async getBatch(keys: tt4b.site.SiteKey[]): Promise<tt4b.site.SiteInfo[]> { const result = await this.storage.get(keys.map(cvt2Key)) return Object.entries(result) .map(([key, value]) => cvt2SiteInfo(cvt2SiteKey(key), value as _Entry)) } - async save(...sites: timer.site.SiteInfo[]): Promise<void> { + async save(...sites: tt4b.site.SiteInfo[]): Promise<void> { if (!sites.length) return const toSet = toMap(sites, cvt2Key, cvt2Entry) await this.storage.set(toSet) } - async remove(siteKeys: timer.site.SiteKey[]): Promise<void> { + async remove(siteKeys: tt4b.site.SiteKey[]): Promise<void> { if (!siteKeys.length) return const keys = siteKeys.map(cvt2Key) await this.storage.remove(keys) } - async exist(siteKey: timer.site.SiteKey): Promise<boolean> { + async exist(siteKey: tt4b.site.SiteKey): Promise<boolean> { const key = cvt2Key(siteKey) const entry = await this.storage.getOne<_Entry>(key) return !!entry diff --git a/src/background/database/stat-database/classic.ts b/src/background/database/stat-database/classic.ts index 4dc27fe6d..4b6403d89 100644 --- a/src/background/database/stat-database/classic.ts +++ b/src/background/database/stat-database/classic.ts @@ -21,13 +21,13 @@ const generateHostReg = (host: string): RegExp => RegExp(`^\\d{8}${escapeRegExp( const generateGroupKey = (groupId: number, date: Date | string) => formatDateStr(date) + cvtGroupId2Host(groupId) const generateGroupReg = (groupId: number): RegExp => RegExp(`^\\d{8}${escapeRegExp(cvtGroupId2Host(groupId))}$`) -const isPartialResult = createObjectGuard<Partial<timer.core.Result>>({ +const isPartialResult = createObjectGuard<Partial<tt4b.core.Result>>({ focus: isOptionalInt, time: isOptionalInt, run: isOptionalInt, }) -function filterRow(row: timer.core.Row, condition: ProcessedCondition): boolean { +function filterRow(row: tt4b.core.Row, condition: ProcessedCondition): boolean { const { host, date, focus, time } = row const { timeStart, timeEnd, focusStart, focusEnd, keys, virtual } = condition @@ -44,7 +44,7 @@ export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { async refresh(): Promise<{ [key: string]: unknown }> { const result = await this.storage.get() - const items: Record<string, timer.core.Result> = {} + const items: Record<string, tt4b.core.Result> = {} Object.entries(result) .filter(([key]) => !key.startsWith(REMAIN_WORD_PREFIX)) .forEach(([key, value]) => items[key] = value) @@ -55,7 +55,7 @@ export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { * @param host host * @since 0.1.3 */ - accumulate(host: string, date: Date | string, item: timer.core.Result): Promise<timer.core.Result> { + accumulate(host: string, date: Date | string, item: tt4b.core.Result): Promise<tt4b.core.Result> { const key = generateKey(host, date) return this.accumulateInner(key, item) } @@ -64,13 +64,13 @@ export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { * @param host host * @since 0.1.3 */ - accumulateGroup(groupId: number, date: Date | string, item: timer.core.Result): Promise<timer.core.Result> { + accumulateGroup(groupId: number, date: Date | string, item: tt4b.core.Result): Promise<tt4b.core.Result> { const key = generateGroupKey(groupId, date) return this.accumulateInner(key, item) } - private async accumulateInner(key: string, item: timer.core.Result): Promise<timer.core.Result> { - const exist = await this.storage.getOne<timer.core.Result>(key) + private async accumulateInner(key: string, item: tt4b.core.Result): Promise<tt4b.core.Result> { + const exist = await this.storage.getOne<tt4b.core.Result>(key) const value = increase(item, exist) await this.setByKey(key, value) return value @@ -83,7 +83,7 @@ export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { * @param date date * @since 0.1.8 */ - async batchAccumulate(data: Record<string, timer.core.Result>, date: Date | string): Promise<Record<string, timer.core.Result>> { + async batchAccumulate(data: Record<string, tt4b.core.Result>, date: Date | string): Promise<Record<string, tt4b.core.Result>> { const hosts = Object.keys(data) if (!hosts.length) return {} const dateStr = formatDateStr(date) @@ -92,12 +92,12 @@ export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { const items = await this.storage.get(Object.values(keys)) - const toUpdate: Record<string, timer.core.Result> = {} - const afterUpdated: Record<string, timer.core.Result> = {} + const toUpdate: Record<string, tt4b.core.Result> = {} + const afterUpdated: Record<string, tt4b.core.Result> = {} Object.entries(keys).forEach(([host, key]) => { const item = data[host] if (!item) return - const exist = increase(item, items[key] as timer.core.Result | undefined) + const exist = increase(item, items[key] as tt4b.core.Result | undefined) toUpdate[key] = afterUpdated[host] = exist }) await this.storage.set(toUpdate) @@ -107,10 +107,10 @@ export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { /** * Filter by query parameters */ - private async filter(condition?: StatCondition, onlyGroup?: boolean): Promise<timer.core.Row[]> { + private async filter(condition?: StatCondition, onlyGroup?: boolean): Promise<tt4b.core.Row[]> { const cond = processCondition(condition ?? {}) const items = await this.refresh() - const result: timer.core.Row[] = [] + const result: tt4b.core.Row[] = [] Object.entries(items).forEach(([key, value]) => { const date = key.substring(0, 8) let host = key.substring(8) @@ -123,8 +123,8 @@ export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { } else if (host.startsWith(GROUP_PREFIX)) { return } - const { focus, time, run } = value as timer.core.Result - const row: timer.core.Row = { host, date, focus, time } + const { focus, time, run } = value as tt4b.core.Result + const row: tt4b.core.Row = { host, date, focus, time } run !== undefined && (row.run = run) filterRow(row, cond) && result.push(row) }) @@ -136,12 +136,12 @@ export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { * * @param condition condition */ - async select(condition?: StatCondition): Promise<timer.core.Row[]> { + async select(condition?: StatCondition): Promise<tt4b.core.Row[]> { log("select:{condition}", condition) return this.filter(condition) } - async selectGroup(condition?: StatCondition): Promise<timer.core.Row[]> { + async selectGroup(condition?: StatCondition): Promise<tt4b.core.Row[]> { return this.filter(condition, true) } @@ -150,17 +150,17 @@ export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { * * @since 0.0.5 */ - async get(host: string, date: Date | string): Promise<timer.core.Row> { + async get(host: string, date: Date | string): Promise<tt4b.core.Row> { const key = generateKey(host, date) - const exist = await this.storage.getOne<timer.core.Result>(key) + const exist = await this.storage.getOne<tt4b.core.Result>(key) const result = exist ?? zeroResult() return { host, date: formatDateStr(date), ...result } } - async batchSelect(keys: timer.core.RowKey[]): Promise<timer.core.Row[]> { + async batchSelect(keys: tt4b.core.RowKey[]): Promise<tt4b.core.Row[]> { if (!keys.length) return [] const storageKeys = keys.map(({ host, date }) => generateKey(host, date)) - const items = await this.storage.get<Record<string, timer.core.Result>>(storageKeys) + const items = await this.storage.get<Record<string, tt4b.core.Result>>(storageKeys) return keys.map(({ host, date }, i) => { const sk = storageKeys[i] const exist = sk ? items[sk] : undefined @@ -175,7 +175,7 @@ export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { * @param rows site rows, the host and date mustn't be null * @since 0.0.9 */ - async delete(...rows: timer.core.RowKey[]): Promise<void> { + async delete(...rows: tt4b.core.RowKey[]): Promise<void> { const keys: string[] = rows.map(({ host, date }) => generateKey(host, date)) return this.storage.remove(keys) } @@ -190,10 +190,10 @@ export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { * * @since 1.4.3 */ - forceUpdate(...rows: timer.core.Row[]): Promise<void> { + forceUpdate(...rows: tt4b.core.Row[]): Promise<void> { const toSet = Object.fromEntries(rows.map(({ host, date, time, focus, run }) => { const key = generateKey(host, date) - const result: timer.core.Result = { time, focus } + const result: tt4b.core.Result = { time, focus } run && (result.run = run) return [key, result] })) @@ -201,10 +201,10 @@ export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { return this.storage.set(toSet) } - forceUpdateGroup(...rows: timer.core.Row[]): Promise<void> { + forceUpdateGroup(...rows: tt4b.core.Row[]): Promise<void> { const toSet = Object.fromEntries(rows.map(({ host, date, time, focus, run }) => { const key = generateGroupKey(Number(host), date) - const result: timer.core.Result = { time, focus } + const result: tt4b.core.Result = { time, focus } run && (result.run = run) return [key, result] })) @@ -257,9 +257,9 @@ export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { * * @deprecated since 4.0.0, legacy data is not supported for export, this method will be removed in future versions */ -export function parseImportData(data: unknown): timer.core.Row[] { +export function parseImportData(data: unknown): tt4b.core.Row[] { if (typeof data !== "object" || data === null) return [] - const rows: timer.core.Row[] = [] + const rows: tt4b.core.Row[] = [] Object.entries(data) .filter(([key]) => /^20\d{2}[01]\d[0-3]\d.*/.test(key) && !key.substring(8).startsWith(GROUP_PREFIX)) .forEach(([key, value]) => { @@ -267,7 +267,7 @@ export function parseImportData(data: unknown): timer.core.Row[] { if (!isPartialResult(value)) return const date = key.substring(0, 8) const host = key.substring(8) - const row: timer.core.Row = { host, date, focus: value.focus ?? 0, time: value.time ?? 0 } + const row: tt4b.core.Row = { host, date, focus: value.focus ?? 0, time: value.time ?? 0 } isNotZeroResult(row) && rows.push(row) }) return rows diff --git a/src/background/database/stat-database/common.ts b/src/background/database/stat-database/common.ts index 6a94d3649..deea68a82 100644 --- a/src/background/database/stat-database/common.ts +++ b/src/background/database/stat-database/common.ts @@ -9,12 +9,12 @@ export const formatDateStr = (date: string | Date): string => { return formatTimeYMD(date) } -export const zeroResult = (): timer.core.Result => ({ focus: 0, time: 0 }) +export const zeroResult = (): tt4b.core.Result => ({ focus: 0, time: 0 }) -export const zeroRow = (host: string, date: string): timer.core.Row => ({ host, date, focus: 0, time: 0 }) +export const zeroRow = (host: string, date: string): tt4b.core.Row => ({ host, date, focus: 0, time: 0 }) -export const increase = (a: timer.core.Result, b: timer.core.Result | undefined) => { - const res: timer.core.Result = { +export const increase = (a: tt4b.core.Result, b: tt4b.core.Result | undefined) => { + const res: tt4b.core.Result = { focus: a.focus + (b?.focus ?? 0), time: a.time + (b?.time ?? 0), } diff --git a/src/background/database/stat-database/idb.ts b/src/background/database/stat-database/idb.ts index ebfd09b76..882a68b9b 100644 --- a/src/background/database/stat-database/idb.ts +++ b/src/background/database/stat-database/idb.ts @@ -3,7 +3,7 @@ import { cvtGroupId2Host, formatDateStr, increase, zeroRow } from './common' import { filterDate, filterHost, filterNumberRange, processCondition, type ProcessedCondition } from './condition' import type { StatCondition, StatDatabase } from './types' -type StoredRow = timer.core.Row & { +type StoredRow = tt4b.core.Row & { // If present, this is a group row groupId?: number } @@ -54,7 +54,7 @@ export class IDBStatDatabase extends BaseIDBStorage<StoredRow> implements StatDa key: StatIndex = ['date', 'host'] indexes: StatIndex[] = INDEXES - get(host: string, date: Date | string): Promise<timer.core.Row> { + get(host: string, date: Date | string): Promise<tt4b.core.Row> { return this.withStore(async store => { const index = super.assertIndex(store, ['date', 'host']) const dateStr = formatDateStr(date) @@ -63,11 +63,11 @@ export class IDBStatDatabase extends BaseIDBStorage<StoredRow> implements StatDa }, 'readonly') } - batchSelect(keys: timer.core.RowKey[]): Promise<timer.core.Row[]> { + batchSelect(keys: tt4b.core.RowKey[]): Promise<tt4b.core.Row[]> { if (!keys.length) return Promise.resolve([]) return this.withStore(async store => { const index = super.assertIndex(store, ['date', 'host']) - const out: timer.core.Row[] = [] + const out: tt4b.core.Row[] = [] for (const { host, date } of keys) { const req = index.get([date, host]) const row = await req2Promise<StoredRow>(req) ?? zeroRow(host, date) @@ -135,8 +135,8 @@ export class IDBStatDatabase extends BaseIDBStorage<StoredRow> implements StatDa } } - private async selectInternal(store: IDBObjectStore, cond: ProcessedCondition, expectGroup: boolean): Promise<timer.core.Row[]> { - const allRows: timer.core.Row[] = [] + private async selectInternal(store: IDBObjectStore, cond: ProcessedCondition, expectGroup: boolean): Promise<tt4b.core.Row[]> { + const allRows: tt4b.core.Row[] = [] const { cursorReq, coverage = {} } = this.judgeIndex(store, cond, expectGroup) const filter = buildFilter(cond, coverage) @@ -161,14 +161,14 @@ export class IDBStatDatabase extends BaseIDBStorage<StoredRow> implements StatDa return allRows } - select(condition?: StatCondition): Promise<timer.core.Row[]> { + select(condition?: StatCondition): Promise<tt4b.core.Row[]> { return this.withStore(async store => { const cond = processCondition(condition) return this.selectInternal(store, cond, false) }, 'readonly') } - accumulate(host: string, date: Date | string, item: timer.core.Result): Promise<timer.core.Result> { + accumulate(host: string, date: Date | string, item: tt4b.core.Result): Promise<tt4b.core.Result> { return this.withStore(async store => { const index = super.assertIndex(store, ['date', 'host']) const dateStr = formatDateStr(date) @@ -181,11 +181,11 @@ export class IDBStatDatabase extends BaseIDBStorage<StoredRow> implements StatDa }, 'readwrite') } - batchAccumulate(data: Record<string, timer.core.Result>, date: Date | string): Promise<Record<string, timer.core.Row>> { + batchAccumulate(data: Record<string, tt4b.core.Result>, date: Date | string): Promise<Record<string, tt4b.core.Row>> { return this.withStore(async store => { const dateStr = formatDateStr(date) const cursorReq = super.assertIndexCursor(store, 'date', IDBKeyRange.only(dateStr)) - const toUpdate: Record<string, timer.core.Row> = {} + const toUpdate: Record<string, tt4b.core.Row> = {} await iterateCursor(cursorReq, cursor => { const stored = cursor.value as StoredRow | undefined @@ -195,7 +195,7 @@ export class IDBStatDatabase extends BaseIDBStorage<StoredRow> implements StatDa for (const [host, result] of Object.entries(data)) { const existing = toUpdate[host] - const newValue: timer.core.Row = { host, date: dateStr, ...increase(result, existing) } + const newValue: tt4b.core.Row = { host, date: dateStr, ...increase(result, existing) } toUpdate[host] = newValue store.put(newValue) } @@ -203,7 +203,7 @@ export class IDBStatDatabase extends BaseIDBStorage<StoredRow> implements StatDa }, 'readwrite') } - delete(...rows: timer.core.RowKey[]): Promise<void> { + delete(...rows: tt4b.core.RowKey[]): Promise<void> { return this.withStore(async store => { const index = super.assertIndex(store, ['date', 'host']) for (const { host, date } of rows) { @@ -279,7 +279,7 @@ export class IDBStatDatabase extends BaseIDBStorage<StoredRow> implements StatDa }, 'readwrite') } - accumulateGroup(groupId: number, date: Date | string, item: timer.core.Result): Promise<timer.core.Result> { + accumulateGroup(groupId: number, date: Date | string, item: tt4b.core.Result): Promise<tt4b.core.Result> { return this.withStore(async store => { const index = super.assertIndex(store, ['date', 'host']) const dateStr = formatDateStr(date) @@ -293,7 +293,7 @@ export class IDBStatDatabase extends BaseIDBStorage<StoredRow> implements StatDa }, 'readwrite') } - selectGroup(condition?: StatCondition): Promise<timer.core.Row[]> { + selectGroup(condition?: StatCondition): Promise<tt4b.core.Row[]> { return this.withStore(async store => { const cond = processCondition(condition) return this.selectInternal(store, cond, true) @@ -315,11 +315,11 @@ export class IDBStatDatabase extends BaseIDBStorage<StoredRow> implements StatDa }, 'readwrite') } - forceUpdate(...rows: timer.core.Row[]): Promise<void> { + forceUpdate(...rows: tt4b.core.Row[]): Promise<void> { return this.withStore(store => rows.forEach(row => store.put(row)), 'readwrite') } - forceUpdateGroup(...rows: timer.core.Row[]): Promise<void> { + forceUpdateGroup(...rows: tt4b.core.Row[]): Promise<void> { return this.withStore(store => { for (const row of rows) { const { host, date, time, focus, run } = row diff --git a/src/background/database/stat-database/index.ts b/src/background/database/stat-database/index.ts index 04df14e34..e39db8d98 100644 --- a/src/background/database/stat-database/index.ts +++ b/src/background/database/stat-database/index.ts @@ -17,11 +17,11 @@ import type { StatCondition, StatDatabase } from './types' type StateDatabaseComposite = & StatDatabase - & StorageMigratable<[tabs: timer.core.Row[], groups: timer.core.Row[]]> + & StorageMigratable<[tabs: tt4b.core.Row[], groups: tt4b.core.Row[]]> & BrowserMigratable<'__stat__'> // Only `date` and `host` are required for import, other fields are optional, and will be set to default if not provided -type ValidImportRow = MakeRequired<Partial<timer.core.Row>, 'date' | 'host'> +type ValidImportRow = MakeRequired<Partial<tt4b.core.Row>, 'date' | 'host'> const isValidImportRow = createObjectGuard<ValidImportRow>({ focus: isOptionalInt, @@ -41,31 +41,31 @@ class StatDatabaseWrapper implements StateDatabaseComposite { }) private current = () => this.holder.current - get(host: string, date: Date): Promise<timer.core.Row> { + get(host: string, date: Date): Promise<tt4b.core.Row> { return this.current().get(host, date) } - batchSelect(keys: timer.core.RowKey[]): Promise<timer.core.Row[]> { + batchSelect(keys: tt4b.core.RowKey[]): Promise<tt4b.core.Row[]> { return this.current().batchSelect(keys) } - select(condition?: StatCondition): Promise<timer.core.Row[]> { + select(condition?: StatCondition): Promise<tt4b.core.Row[]> { return this.current().select(condition) } - accumulate(host: string, date: Date | string, item: timer.core.Result): Promise<timer.core.Result> { + accumulate(host: string, date: Date | string, item: tt4b.core.Result): Promise<tt4b.core.Result> { return this.current().accumulate(host, date, item) } - batchAccumulate(data: Record<string, timer.core.Result>, date: Date | string): Promise<Record<string, timer.core.Result>> { + batchAccumulate(data: Record<string, tt4b.core.Result>, date: Date | string): Promise<Record<string, tt4b.core.Result>> { return this.current().batchAccumulate(data, date) } - accumulateGroup(groupId: number, date: Date | string, item: timer.core.Result): Promise<timer.core.Result> { + accumulateGroup(groupId: number, date: Date | string, item: tt4b.core.Result): Promise<tt4b.core.Result> { return this.current().accumulateGroup(groupId, date, item) } - delete(...rows: timer.core.RowKey[]): Promise<void> { + delete(...rows: tt4b.core.RowKey[]): Promise<void> { return this.current().delete(...rows) } @@ -77,7 +77,7 @@ class StatDatabaseWrapper implements StateDatabaseComposite { return this.current().deleteByGroup(groupId, range) } - selectGroup(condition?: StatCondition): Promise<timer.core.Row[]> { + selectGroup(condition?: StatCondition): Promise<tt4b.core.Row[]> { return this.current().selectGroup(condition) } @@ -85,15 +85,15 @@ class StatDatabaseWrapper implements StateDatabaseComposite { return this.current().deleteGroup(...rows) } - forceUpdate(...rows: timer.core.Row[]): Promise<void> { + forceUpdate(...rows: tt4b.core.Row[]): Promise<void> { return this.current().forceUpdate(...rows) } - forceUpdateGroup(...rows: timer.core.Row[]): Promise<void> { + forceUpdateGroup(...rows: tt4b.core.Row[]): Promise<void> { return this.current().forceUpdateGroup(...rows) } - async migrateStorage(type: timer.option.StorageType): Promise<[timer.core.Row[], timer.core.Row[]]> { + async migrateStorage(type: tt4b.option.StorageType): Promise<[tt4b.core.Row[], tt4b.core.Row[]]> { const target = this.holder.get(type) if (!target) return [[], []] const tabs = await this.select({ virtual: true }) @@ -103,7 +103,7 @@ class StatDatabaseWrapper implements StateDatabaseComposite { return [tabs, groups] } - async afterStorageMigrated([tabs, groups]: [timer.core.Row[], timer.core.Row[]]): Promise<void> { + async afterStorageMigrated([tabs, groups]: [tt4b.core.Row[], tt4b.core.Row[]]): Promise<void> { await this.current().delete(...tabs) const groupKeys = groups.map(({ host, date }) => [parseInt(host), date] satisfies [number, string]) await this.current().deleteGroup(...groupKeys) @@ -114,11 +114,11 @@ class StatDatabaseWrapper implements StateDatabaseComposite { await this.forceUpdate(...rows) } - async exportData(): Promise<timer.core.Row[]> { + async exportData(): Promise<tt4b.core.Row[]> { return this.select({ virtual: true }) } - private parseImportRows(data: unknown): timer.core.Row[] { + private parseImportRows(data: unknown): tt4b.core.Row[] { if (!isExportData(data)) return [] if (isLegacyVersion(data)) { return parseImportData(data) ?? [] @@ -127,9 +127,9 @@ class StatDatabaseWrapper implements StateDatabaseComposite { if (!(this.namespace in data)) return [] const nsData = extractNamespace(data, this.namespace, isValidImportRows) ?? [] - const rows: timer.core.Row[] = [] + const rows: tt4b.core.Row[] = [] for (const item of nsData) { - const row: timer.core.Row = { + const row: tt4b.core.Row = { host: item.host, date: item.date, time: item.time ?? 0, diff --git a/src/background/database/stat-database/types.ts b/src/background/database/stat-database/types.ts index 8de20377a..a032c138f 100644 --- a/src/background/database/stat-database/types.ts +++ b/src/background/database/stat-database/types.ts @@ -30,15 +30,15 @@ export type StatCondition = { } export interface StatDatabase { - get(host: string, date: Date): Promise<timer.core.Row> - batchSelect(keys: timer.core.RowKey[]): Promise<timer.core.Row[]> - select(condition?: StatCondition): Promise<timer.core.Row[]> + get(host: string, date: Date): Promise<tt4b.core.Row> + batchSelect(keys: tt4b.core.RowKey[]): Promise<tt4b.core.Row[]> + select(condition?: StatCondition): Promise<tt4b.core.Row[]> /** * Accumulate data */ - accumulate(host: string, date: Date | string, item: timer.core.Result): Promise<timer.core.Result> - batchAccumulate(data: Record<string, timer.core.Result>, date: Date | string): Promise<Record<string, timer.core.Result>> - delete(...rows: timer.core.RowKey[]): Promise<void> + accumulate(host: string, date: Date | string, item: tt4b.core.Result): Promise<tt4b.core.Result> + batchAccumulate(data: Record<string, tt4b.core.Result>, date: Date | string): Promise<Record<string, tt4b.core.Result>> + delete(...rows: tt4b.core.RowKey[]): Promise<void> /** * Delete by host * @@ -58,16 +58,16 @@ export interface StatDatabase { /** * Accumulate data for tab group */ - accumulateGroup(groupId: number, date: Date | string, item: timer.core.Result): Promise<timer.core.Result> - selectGroup(condition?: StatCondition): Promise<timer.core.Row[]> + accumulateGroup(groupId: number, date: Date | string, item: tt4b.core.Result): Promise<tt4b.core.Result> + selectGroup(condition?: StatCondition): Promise<tt4b.core.Row[]> deleteGroup(...rows: [groupId: number, date: string][]): Promise<void> /** * Force update data with overwriting */ - forceUpdate(...rows: timer.core.Row[]): Promise<void> + forceUpdate(...rows: tt4b.core.Row[]): Promise<void> /** * Force update group data with overwriting */ - forceUpdateGroup(...rows: timer.core.Row[]): Promise<void> + forceUpdateGroup(...rows: tt4b.core.Row[]): Promise<void> } \ No newline at end of file diff --git a/src/background/database/timeline-database/classic.ts b/src/background/database/timeline-database/classic.ts index 669e27ba1..56e0877c9 100644 --- a/src/background/database/timeline-database/classic.ts +++ b/src/background/database/timeline-database/classic.ts @@ -17,7 +17,7 @@ type TimelineData = { } } -function filter(ticks: timer.timeline.Tick[], cond?: TimelineCondition): timer.timeline.Tick[] { +function filter(ticks: tt4b.timeline.Tick[], cond?: TimelineCondition): tt4b.timeline.Tick[] { if (!cond) { return ticks } @@ -44,14 +44,14 @@ export default class ClassicTimelineDatabase extends BaseDatabase implements Tim return data ?? {} } - async batchSave(_ticks: timer.timeline.Tick[]): Promise<void> { + async batchSave(_ticks: tt4b.timeline.Tick[]): Promise<void> { console.warn("ClassicTimelineDatabase is deprecated, data will not be saved to it. This invoking is not expected") return } - async select(cond?: TimelineCondition): Promise<timer.timeline.Tick[]> { + async select(cond?: TimelineCondition): Promise<tt4b.timeline.Tick[]> { const data = await this.getData() - const ticks: timer.timeline.Tick[] = [] + const ticks: tt4b.timeline.Tick[] = [] Object.values(data).forEach(hostData => { Object.entries(hostData).forEach(([host, items]) => { items.forEach(({ s: start, d: duration }) => ticks.push({ host, start, duration })) diff --git a/src/background/database/timeline-database/idb.ts b/src/background/database/timeline-database/idb.ts index 130bb3752..a99bbfb16 100644 --- a/src/background/database/timeline-database/idb.ts +++ b/src/background/database/timeline-database/idb.ts @@ -10,13 +10,13 @@ const TIME_LIFE_CYCLE = MILL_PER_DAY * 366 // If two tick with the same host is near 1 sec, then merge them to one const MERGE_THRESHOLD = MILL_PER_SECOND -const canMerge = (exist: timer.timeline.Tick, tick: timer.timeline.Tick) => { +const canMerge = (exist: tt4b.timeline.Tick, tick: tt4b.timeline.Tick) => { const { start: existStart, host: existHost } = exist const { start, host } = tick return existHost === host && start >= existStart && start <= existStart + MERGE_THRESHOLD } -const isConflict = (item: timer.timeline.Tick, tick: timer.timeline.Tick) => { +const isConflict = (item: tt4b.timeline.Tick, tick: tt4b.timeline.Tick) => { const { start: itemStart, duration: itemDuration } = item const { start } = tick return itemStart <= start && start < itemStart + itemDuration @@ -40,29 +40,29 @@ class CleanThrottle { } } -export default class IDBTimelineDatabase extends BaseIDBStorage<timer.timeline.Tick> implements TimelineDatabase { - indexes: Index<timer.timeline.Tick>[] = [ +export default class IDBTimelineDatabase extends BaseIDBStorage<tt4b.timeline.Tick> implements TimelineDatabase { + indexes: Index<tt4b.timeline.Tick>[] = [ 'host', 'start', ] - key: Key<timer.timeline.Tick> | Key<timer.timeline.Tick>[] = ['host', 'start'] + key: Key<tt4b.timeline.Tick> | Key<tt4b.timeline.Tick>[] = ['host', 'start'] table: Table = 'timeline' private cleanThrottle = new CleanThrottle() - batchSave(ticks: timer.timeline.Tick[]): Promise<void> { + batchSave(ticks: tt4b.timeline.Tick[]): Promise<void> { return this.withStore(async store => { const index = this.assertIndex(store, 'host') const hosts = Array.from(new Set(ticks.map(tick => tick.host))) // Fetch existing records for all hosts - const existByHost = new Map<string, timer.timeline.Tick[]>() + const existByHost = new Map<string, tt4b.timeline.Tick[]>() await Promise.all(hosts.map(async host => { const req = index.getAll(IDBKeyRange.only(host)) - const exist = await req2Promise<timer.timeline.Tick[]>(req) + const exist = await req2Promise<tt4b.timeline.Tick[]>(req) exist && existByHost.set(host, exist) })) - const toSave: timer.timeline.Tick[] = [] - const toDelete: timer.timeline.Tick[] = [] + const toSave: tt4b.timeline.Tick[] = [] + const toDelete: tt4b.timeline.Tick[] = [] ticks.forEach(tick => { const existForHost = existByHost.get(tick.host) ?? [] @@ -89,10 +89,10 @@ export default class IDBTimelineDatabase extends BaseIDBStorage<timer.timeline.T }, 'readwrite') } - async select(cond?: TimelineCondition): Promise<timer.timeline.Tick[]> { + async select(cond?: TimelineCondition): Promise<tt4b.timeline.Tick[]> { const rows = await this.withStore(async store => { const { cursorReq, coverage = {} } = this.judgeIndex(store, cond) - const rows = await iterateCursor<timer.timeline.Tick>(cursorReq) + const rows = await iterateCursor<tt4b.timeline.Tick>(cursorReq) const { start: cs, host: ch } = cond ?? {} return rows.filter(tick => { const { host, start } = tick diff --git a/src/background/database/timeline-database/index.ts b/src/background/database/timeline-database/index.ts index 5dfb589a5..e7b43a8c0 100644 --- a/src/background/database/timeline-database/index.ts +++ b/src/background/database/timeline-database/index.ts @@ -6,11 +6,11 @@ class TimelineDatabaseWrapper implements TimelineDatabase { private classic = new ClassicTimelineDatabase() private idb = new IDBTimelineDatabase() - batchSave(ticks: timer.timeline.Tick[]): Promise<void> { + batchSave(ticks: tt4b.timeline.Tick[]): Promise<void> { return this.idb.batchSave(ticks) } - select(cond?: TimelineCondition): Promise<timer.timeline.Tick[]> { + select(cond?: TimelineCondition): Promise<tt4b.timeline.Tick[]> { return this.idb.select(cond) } diff --git a/src/background/database/timeline-database/types.ts b/src/background/database/timeline-database/types.ts index 8ab103884..6b770e24c 100644 --- a/src/background/database/timeline-database/types.ts +++ b/src/background/database/timeline-database/types.ts @@ -7,6 +7,6 @@ export type TimelineCondition = { } export interface TimelineDatabase { - batchSave(ticks: timer.timeline.Tick[]): Promise<void> - select(cond?: TimelineCondition): Promise<timer.timeline.Tick[]> + batchSave(ticks: tt4b.timeline.Tick[]): Promise<void> + select(cond?: TimelineCondition): Promise<tt4b.timeline.Tick[]> } \ No newline at end of file diff --git a/src/background/database/types.d.ts b/src/background/database/types.d.ts index ed81c3ac3..5abf2b8c6 100644 --- a/src/background/database/types.d.ts +++ b/src/background/database/types.d.ts @@ -11,7 +11,7 @@ export interface StorageMigratable<AllData> { * * @param type the type of target storage */ - migrateStorage(type: timer.option.StorageType): Promise<AllData> + migrateStorage(type: tt4b.option.StorageType): Promise<AllData> /** * Handler after migration finished. Clean the old data here * @@ -20,7 +20,7 @@ export interface StorageMigratable<AllData> { afterStorageMigrated(allData: AllData): Promise<void> } -export type BrowserMigratableNamespace = keyof Omit<timer.backup.ExportData, '__meta__'> +export type BrowserMigratableNamespace = keyof Omit<tt4b.backup.ExportData, '__meta__'> /** * Migrate data among browsers (export / import) @@ -30,6 +30,6 @@ export interface BrowserMigratable<N extends BrowserMigratableNamespace = Browse * The name space for migration */ namespace: N - exportData(): Promise<Required<timer.backup.ExportData>[N]> + exportData(): Promise<Required<tt4b.backup.ExportData>[N]> importData(data: unknown): Promise<void> } \ No newline at end of file diff --git a/src/background/database/whitelist-database.ts b/src/background/database/whitelist-database.ts index 3b43d203b..47c1ccd2d 100644 --- a/src/background/database/whitelist-database.ts +++ b/src/background/database/whitelist-database.ts @@ -55,7 +55,7 @@ class WhitelistDatabase extends BaseDatabase implements BrowserMigratable<'__whi /** * @deprecated Only for legacy data, will be removed in future version */ - private parseLegacyData(data: timer.backup.ExportData): string[] { + private parseLegacyData(data: tt4b.backup.ExportData): string[] { const toMigrate = (data as any)[WHITELIST_KEY] return isStringArray(toMigrate) ? toMigrate : [] } diff --git a/src/background/install-handler/version/cate-initializer.ts b/src/background/install-handler/version/cate-initializer.ts index 09ee420e3..d7f25be2f 100644 --- a/src/background/install-handler/version/cate-initializer.ts +++ b/src/background/install-handler/version/cate-initializer.ts @@ -33,7 +33,7 @@ async function initItem(item: InitialCate) { const { name, hosts } = item const cate = await cateDatabase.add(name) const cateId = cate.id - const siteKeys = hosts.map(host => ({ host, type: 'normal' } satisfies timer.site.SiteKey)) + const siteKeys = hosts.map(host => ({ host, type: 'normal' } satisfies tt4b.site.SiteKey)) await batchChangeCate(cateId, siteKeys) } diff --git a/src/background/limit-processor.ts b/src/background/limit-processor.ts index bade3edac..bb8c72b55 100644 --- a/src/background/limit-processor.ts +++ b/src/background/limit-processor.ts @@ -26,7 +26,7 @@ function initDailyBroadcast() { ) } -const processAskHitVisit = async (item: timer.limit.Item) => { +const processAskHitVisit = async (item: tt4b.limit.Item) => { let tabs = await listTabs() const { cond } = item for (const { id, url } of tabs) { @@ -42,7 +42,7 @@ const processAskHitVisit = async (item: timer.limit.Item) => { return false } -async function querySummary(): Promise<timer.limit.Summary | undefined> { +async function querySummary(): Promise<tt4b.limit.Summary | undefined> { const tabs = await listTabs({ currentWindow: true, active: true }) const url = tabs[0]?.url if (!url) return undefined diff --git a/src/background/message-dispatcher.ts b/src/background/message-dispatcher.ts index 9744050e5..f248607fd 100644 --- a/src/background/message-dispatcher.ts +++ b/src/background/message-dispatcher.ts @@ -47,17 +47,17 @@ function processParam(param: unknown): unknown { } class MessageDispatcher { - private handlers: Partial<Record<timer.mq.ReqCode, timer.mq.Handler<timer.mq.ReqCode>>> = {} + private handlers: Partial<Record<tt4b.mq.ReqCode, tt4b.mq.Handler<tt4b.mq.ReqCode>>> = {} constructor() { this.initServiceHandlers() } - register<C extends timer.mq.ReqCode>(code: C, handler: timer.mq.Handler<C>): MessageDispatcher { + register<C extends tt4b.mq.ReqCode>(code: C, handler: tt4b.mq.Handler<C>): MessageDispatcher { if (this.handlers[code]) { throw new Error(`Duplicate handler: code=${code}`) } - this.handlers[code] = handler as unknown as timer.mq.Handler<timer.mq.ReqCode> + this.handlers[code] = handler as unknown as tt4b.mq.Handler<tt4b.mq.ReqCode> return this } @@ -135,7 +135,7 @@ class MessageDispatcher { .register('immigration.importOther', importOther) } - private async handle(message: timer.mq.Request<timer.mq.ReqCode>, sender: ChromeMessageSender): Promise<timer.mq.Response<timer.mq.ReqCode>> { + private async handle(message: tt4b.mq.Request<tt4b.mq.ReqCode>, sender: ChromeMessageSender): Promise<tt4b.mq.Response<tt4b.mq.ReqCode>> { const code = message?.code if (!code) { return { code: 'ignore' } @@ -149,7 +149,7 @@ class MessageDispatcher { try { const data = processParam(message.data) const result = await handler(data, sender) - return { code: 'success', data: result } as timer.mq.Response<typeof code> + return { code: 'success', data: result } as tt4b.mq.Response<typeof code> } catch (error) { const msg = error instanceof Error ? error.message : (error?.toString?.() ?? 'Unknown error') return { code: 'fail', msg } diff --git a/src/background/scheduler.ts b/src/background/scheduler.ts index 593290629..8504ad578 100644 --- a/src/background/scheduler.ts +++ b/src/background/scheduler.ts @@ -7,10 +7,10 @@ import notificationProcessor from "./service/notification/processor" const BACKUP_ALARM_NAME = 'auto-backup-data' const NOTIFICATION_ALARM_NAME = 'notification-data' -const needResetBackup = (newVal: timer.option.AllOption, oldVal: timer.option.AllOption): boolean => +const needResetBackup = (newVal: tt4b.option.AllOption, oldVal: tt4b.option.AllOption): boolean => newVal.autoBackUp !== oldVal.autoBackUp || newVal.autoBackUpInterval !== oldVal.autoBackUpInterval -const needResetNotification = (newVal: timer.option.AllOption, oldVal: timer.option.AllOption): boolean => +const needResetNotification = (newVal: tt4b.option.AllOption, oldVal: tt4b.option.AllOption): boolean => newVal.notificationCycle !== oldVal.notificationCycle || newVal.notificationOffset !== oldVal.notificationOffset export async function initScheduler(): Promise<void> { @@ -45,7 +45,7 @@ async function resetBackup(): Promise<void> { } type OffsetHandler = (offsetMin: number) => number -const OFFSET_HANDLERS: Record<Exclude<timer.notification.Cycle, 'none'>, OffsetHandler> = { +const OFFSET_HANDLERS: Record<Exclude<tt4b.notification.Cycle, 'none'>, OffsetHandler> = { daily: offset => { const next = new Date() next.setHours(0, offset, 0, 0) diff --git a/src/background/service/2fa-service.ts b/src/background/service/2fa-service.ts index 5900b060d..1bd9ad889 100644 --- a/src/background/service/2fa-service.ts +++ b/src/background/service/2fa-service.ts @@ -47,7 +47,7 @@ async function saveTwoFa(secret: string): Promise<void> { await db.update(meta) } -async function encrypt(plaintext: string, cid: string): Promise<timer.TwoFactorAuth> { +async function encrypt(plaintext: string, cid: string): Promise<tt4b.TwoFactorAuth> { const encoder = new TextEncoder() const dataBuffer = encoder.encode(plaintext) @@ -92,7 +92,7 @@ export async function check2faCode(code: string): Promise<boolean> { return verifyTotp(secret, code) } -async function decryptSecret(twoFa: timer.TwoFactorAuth, cid: string): Promise<string> { +async function decryptSecret(twoFa: tt4b.TwoFactorAuth, cid: string): Promise<string> { const encoder = new TextEncoder() const keyMaterial = await crypto.subtle.importKey( 'raw', encoder.encode(cid), { name: 'PBKDF2' }, false, ['deriveKey'] diff --git a/src/background/service/backup/gist/compressor.ts b/src/background/service/backup/gist/compressor.ts index 3c2ee23df..f70702255 100644 --- a/src/background/service/backup/gist/compressor.ts +++ b/src/background/service/backup/gist/compressor.ts @@ -28,7 +28,7 @@ type GistRow = { ] } -function calcGroupKey(row: timer.core.Row): string | undefined { +function calcGroupKey(row: tt4b.core.Row): string | undefined { const date = row.date if (!date) { return undefined @@ -41,7 +41,7 @@ function calcGroupKey(row: timer.core.Row): string | undefined { * * @param rows row array */ -function compress(rows: timer.core.Row[]): GistData { +function compress(rows: tt4b.core.Row[]): GistData { const result: GistData = groupBy( rows, row => row.date.substring(6), @@ -59,7 +59,7 @@ function compress(rows: timer.core.Row[]): GistData { * * @returns [bucket, data][] */ -export function divide2Buckets(rows: timer.core.Row[]): [string, GistData][] { +export function divide2Buckets(rows: tt4b.core.Row[]): [string, GistData][] { const grouped: { [yearAndPart: string]: GistData } = groupBy(rows.filter(r => !!r), calcGroupKey, compress) return Object.entries(grouped) } @@ -88,13 +88,13 @@ export function calcAllBuckets(startDate: string | undefined, endDate: string | * @param gistData gistData * @returns rows */ -export function gistData2Rows(yearMonth: string, gistData: GistData): timer.core.Row[] { - const result: timer.core.Row[] = [] +export function gistData2Rows(yearMonth: string, gistData: GistData): tt4b.core.Row[] { + const result: tt4b.core.Row[] = [] Object.entries(gistData).forEach(([dateOfMonth, gistRow]) => { const date = yearMonth + dateOfMonth Object.entries(gistRow).forEach(([host, val]) => { const [time, focus] = val - const row: timer.core.Row = { + const row: tt4b.core.Row = { date, host, time, diff --git a/src/background/service/backup/gist/coordinator.ts b/src/background/service/backup/gist/coordinator.ts index 39e89b6cc..05aa0fefd 100644 --- a/src/background/service/backup/gist/coordinator.ts +++ b/src/background/service/backup/gist/coordinator.ts @@ -48,7 +48,7 @@ function bucket2filename(bucket: string, cid: string) { return `${bucket}_${cid}.json` } -function filterDate(row: timer.core.Row, start: string, end: string) { +function filterDate(row: tt4b.core.Row, start: string, end: string) { const { date } = row if (!date) return false if (start && date < start) return false @@ -56,7 +56,7 @@ function filterDate(row: timer.core.Row, start: string, end: string) { return true } -function checkTokenExist(context: timer.backup.CoordinatorContext<Cache>): string { +function checkTokenExist(context: tt4b.backup.CoordinatorContext<Cache>): string { const token = context.auth?.token if (!token) { throw new Error("Token must not be empty. This can't happen, please contact to the developer") @@ -64,10 +64,10 @@ function checkTokenExist(context: timer.backup.CoordinatorContext<Cache>): strin return token } -export default class GistCoordinator implements timer.backup.Coordinator<Cache> { +export default class GistCoordinator implements tt4b.backup.Coordinator<Cache> { async updateClients( - context: timer.backup.CoordinatorContext<Cache>, - clients: timer.backup.Client[], + context: tt4b.backup.CoordinatorContext<Cache>, + clients: tt4b.backup.Client[], ): Promise<void> { const gist = await this.getMetaGist(context) if (!gist) { @@ -82,7 +82,7 @@ export default class GistCoordinator implements timer.backup.Coordinator<Cache> await updateGist(checkTokenExist(context), gist.id, { description: gist.description, public: false, files }) } - async listAllClients(context: timer.backup.CoordinatorContext<Cache>): Promise<timer.backup.Client[]> { + async listAllClients(context: tt4b.backup.CoordinatorContext<Cache>): Promise<tt4b.backup.Client[]> { const gist = await this.getMetaGist(context) if (!gist) { return [] @@ -91,11 +91,11 @@ export default class GistCoordinator implements timer.backup.Coordinator<Cache> return file ? await getJsonFileContent(file) ?? [] : [] } - async download(context: timer.backup.CoordinatorContext<Cache>, start: string, end: string, targetCid?: string): Promise<timer.core.Row[]> { + async download(context: tt4b.backup.CoordinatorContext<Cache>, start: string, end: string, targetCid?: string): Promise<tt4b.core.Row[]> { const startTime = parseTime(start) ?? getBirthday() const endTime = parseTime(end) ?? new Date() const allYearMonth = new MonthIterator(startTime, endTime).toArray() - const result: timer.core.Row[] = [] + const result: tt4b.core.Row[] = [] await Promise.all(allYearMonth.map(async yearMonth => { const filename = bucket2filename(yearMonth, targetCid || context.cid) const gist: Gist = await this.getStatGist(context) @@ -110,7 +110,7 @@ export default class GistCoordinator implements timer.backup.Coordinator<Cache> return result } - async upload(context: timer.backup.CoordinatorContext<Cache>, rows: timer.core.Row[]): Promise<void> { + async upload(context: tt4b.backup.CoordinatorContext<Cache>, rows: tt4b.core.Row[]): Promise<void> { const cid = context.cid const buckets = divide2Buckets(rows) const gist = await this.getStatGist(context) @@ -137,7 +137,7 @@ export default class GistCoordinator implements timer.backup.Coordinator<Cache> return gist.description === TIMER_DATA_GIST_DESC } - private async getMetaGist(context: timer.backup.CoordinatorContext<Cache>): Promise<Gist> { + private async getMetaGist(context: tt4b.backup.CoordinatorContext<Cache>): Promise<Gist> { const gistId = context.cache.metaGistId const token = checkTokenExist(context) // 1. Find by id @@ -165,7 +165,7 @@ export default class GistCoordinator implements timer.backup.Coordinator<Cache> return created } - private async getStatGist(context: timer.backup.CoordinatorContext<Cache>): Promise<Gist> { + private async getStatGist(context: tt4b.backup.CoordinatorContext<Cache>): Promise<Gist> { const gistId = context.cache.statGistId const token = checkTokenExist(context) // 1. Find by id @@ -192,13 +192,13 @@ export default class GistCoordinator implements timer.backup.Coordinator<Cache> return created } - async testAuth(auth: timer.backup.Auth): Promise<string | undefined> { + async testAuth(auth: tt4b.backup.Auth): Promise<string | undefined> { const { token } = auth if (!token) return 'Token is empty' return testToken(token) } - async clear(context: timer.backup.CoordinatorContext<Cache>, client: timer.backup.Client): Promise<void> { + async clear(context: tt4b.backup.CoordinatorContext<Cache>, client: tt4b.backup.Client): Promise<void> { // 1. Find the names of file to delete const { minDate, maxDate, id: cid } = client || {} const allBuckets = calcAllBuckets(minDate, maxDate) diff --git a/src/background/service/backup/markdown.ts b/src/background/service/backup/markdown.ts index 2f3c9a961..0165f50eb 100644 --- a/src/background/service/backup/markdown.ts +++ b/src/background/service/backup/markdown.ts @@ -10,7 +10,7 @@ import { formatPeriodCommon } from "@util/time" export const CLIENT_FILE_NAME = "clients_no_modify.md" -const CLIENT_FIELDS: MarkdownTableField<timer.backup.Client>[] = [ +const CLIENT_FIELDS: MarkdownTableField<tt4b.backup.Client>[] = [ { name: "Client Id", formatter: r => r.id, @@ -42,7 +42,7 @@ function genJsonLine(data: any): string { return `<!-- ${JSON.stringify(data)} -->` } -export function convertClients2Markdown(clients: timer.backup.Client[]): string { +export function convertClients2Markdown(clients: tt4b.backup.Client[]): string { return genMarkdownTable(clients, CLIENT_FIELDS) } @@ -82,7 +82,7 @@ function genMarkdownTable<T>(list: T[], fields: MarkdownTableField<T>[]): string return lines.join('\n') } -const ROW_FIELDS: MarkdownTableField<timer.core.Row>[] = [ +const ROW_FIELDS: MarkdownTableField<tt4b.core.Row>[] = [ { name: "Date", formatter: r => r.date, @@ -98,7 +98,7 @@ const ROW_FIELDS: MarkdownTableField<timer.core.Row>[] = [ }, ] -export function divideByDate(rows: timer.core.Row[]): { [date: string]: string } { +export function divideByDate(rows: tt4b.core.Row[]): { [date: string]: string } { return groupBy(rows, row => row.date, list => genMarkdownTable(list, ROW_FIELDS)) } diff --git a/src/background/service/backup/obsidian/coordinator.ts b/src/background/service/backup/obsidian/coordinator.ts index 26068f958..56263f75f 100644 --- a/src/background/service/backup/obsidian/coordinator.ts +++ b/src/background/service/backup/obsidian/coordinator.ts @@ -13,7 +13,7 @@ import { getBirthday, parseTime } from '@util/time' import { processDir } from "../common" import { CLIENT_FILE_NAME, convertClients2Markdown, divideByDate, parseData } from "../markdown" -function prepareContext(context: timer.backup.CoordinatorContext<never>) { +function prepareContext(context: tt4b.backup.CoordinatorContext<never>) { const { auth, ext, cid } = context const { token } = auth || {} if (!token) { @@ -25,16 +25,16 @@ function prepareContext(context: timer.backup.CoordinatorContext<never>) { return { ctx, dirPath, cid } } -export default class ObsidianCoordinator implements timer.backup.Coordinator<never> { +export default class ObsidianCoordinator implements tt4b.backup.Coordinator<never> { - async updateClients(context: timer.backup.CoordinatorContext<never>, clients: timer.backup.Client[]): Promise<void> { + async updateClients(context: tt4b.backup.CoordinatorContext<never>, clients: tt4b.backup.Client[]): Promise<void> { const { ctx, dirPath } = prepareContext(context) const clientFilePath = `${dirPath}${CLIENT_FILE_NAME}` const content = convertClients2Markdown(clients) await updateFile(ctx, clientFilePath, content) } - async listAllClients(context: timer.backup.CoordinatorContext<never>): Promise<timer.backup.Client[]> { + async listAllClients(context: tt4b.backup.CoordinatorContext<never>): Promise<tt4b.backup.Client[]> { const { ctx, dirPath } = prepareContext(context) const clientFilePath = `${dirPath}${CLIENT_FILE_NAME}` try { @@ -46,23 +46,23 @@ export default class ObsidianCoordinator implements timer.backup.Coordinator<nev } } - async download(context: timer.backup.CoordinatorContext<never>, start: string, end: string, targetCid?: string): Promise<timer.core.Row[]> { + async download(context: tt4b.backup.CoordinatorContext<never>, start: string, end: string, targetCid?: string): Promise<tt4b.core.Row[]> { const { ctx, dirPath, cid } = prepareContext(context) const startTime = parseTime(start) ?? getBirthday() const endTime = parseTime(end) ?? new Date() const dateIterator = new DateIterator(startTime, endTime) - const result: timer.core.Row[] = [] + const result: tt4b.core.Row[] = [] await Promise.all(dateIterator.toArray().map(async date => { const filePath = `${dirPath}${targetCid || cid}/${date}.md` const fileContent = await getFileContent(ctx, filePath) - const rows = parseData<timer.core.Row[]>(fileContent) + const rows = parseData<tt4b.core.Row[]>(fileContent) rows?.forEach?.(row => result.push(row)) })) return result } - async upload(context: timer.backup.CoordinatorContext<never>, rows: timer.core.Row[]): Promise<void> { + async upload(context: tt4b.backup.CoordinatorContext<never>, rows: tt4b.core.Row[]): Promise<void> { const { ctx, dirPath, cid } = prepareContext(context) const dateAndContents = divideByDate(rows) @@ -74,7 +74,7 @@ export default class ObsidianCoordinator implements timer.backup.Coordinator<nev ) } - async testAuth(authInfo: timer.backup.Auth, ext: timer.backup.TypeExt): Promise<string | undefined> { + async testAuth(authInfo: tt4b.backup.Auth, ext: tt4b.backup.TypeExt): Promise<string | undefined> { let { endpoint, dirPath, bucket } = ext || {} let { token: auth } = authInfo || {} dirPath = processDir(dirPath) @@ -105,7 +105,7 @@ export default class ObsidianCoordinator implements timer.backup.Coordinator<nev } } - async clear(context: timer.backup.CoordinatorContext<never>, client: timer.backup.Client): Promise<void> { + async clear(context: tt4b.backup.CoordinatorContext<never>, client: tt4b.backup.Client): Promise<void> { const cid = client.id const { ctx, dirPath } = prepareContext(context) const clientDirPath = `${dirPath}${cid}/` diff --git a/src/background/service/backup/processor.ts b/src/background/service/backup/processor.ts index 639e8fb7c..23fae06d0 100644 --- a/src/background/service/backup/processor.ts +++ b/src/background/service/backup/processor.ts @@ -14,29 +14,29 @@ import ObsidianCoordinator from "./obsidian/coordinator" import WebDAVCoordinator from "./web-dav/coordinator" type AuthCheckResult = { - option: timer.option.BackupOption - auth: timer.backup.Auth - ext: timer.backup.TypeExt - type: timer.backup.Type - coordinator: timer.backup.Coordinator<unknown> + option: tt4b.option.BackupOption + auth: tt4b.backup.Auth + ext: tt4b.backup.TypeExt + type: tt4b.backup.Type + coordinator: tt4b.backup.Coordinator<unknown> errorMsg?: string } -class CoordinatorContextWrapper<Cache> implements timer.backup.CoordinatorContext<Cache> { - auth: timer.backup.Auth - ext?: timer.backup.TypeExt +class CoordinatorContextWrapper<Cache> implements tt4b.backup.CoordinatorContext<Cache> { + auth: tt4b.backup.Auth + ext?: tt4b.backup.TypeExt cache: Cache = {} as unknown as Cache - type: timer.backup.Type + type: tt4b.backup.Type cid: string - constructor(cid: string, auth: timer.backup.Auth, ext: timer.backup.TypeExt, type: timer.backup.Type) { + constructor(cid: string, auth: tt4b.backup.Auth, ext: tt4b.backup.TypeExt, type: tt4b.backup.Type) { this.cid = cid this.auth = auth this.ext = ext this.type = type } - async init(): Promise<timer.backup.CoordinatorContext<Cache>> { + async init(): Promise<tt4b.backup.CoordinatorContext<Cache>> { this.cache = await syncDb.getCache(this.type) as Cache return this } @@ -47,9 +47,9 @@ class CoordinatorContextWrapper<Cache> implements timer.backup.CoordinatorContex } async function syncFull( - context: timer.backup.CoordinatorContext<unknown>, - coordinator: timer.backup.Coordinator<unknown>, - client: timer.backup.Client + context: tt4b.backup.CoordinatorContext<unknown>, + coordinator: tt4b.backup.Coordinator<unknown>, + client: tt4b.backup.Client ): Promise<void> { // 1. select rows const rows = await statDb.select() @@ -60,7 +60,7 @@ async function syncFull( await coordinator.upload(context, rows) } -function filterClient(c: timer.backup.Client, excludeLocal: boolean, localClientId: string, start?: string, end?: string) { +function filterClient(c: tt4b.backup.Client, excludeLocal: boolean, localClientId: string, start?: string, end?: string) { // Exclude local client if (excludeLocal && c.id === localClientId) return false // Judge range @@ -69,7 +69,7 @@ function filterClient(c: timer.backup.Client, excludeLocal: boolean, localClient return true } -function prepareAuth(option: timer.option.BackupOption): timer.backup.Auth { +function prepareAuth(option: tt4b.option.BackupOption): tt4b.backup.Auth { const type = option?.backupType || 'none' const token = option?.backupAuths?.[type] const login = option.backupLogin?.[type] @@ -78,12 +78,12 @@ function prepareAuth(option: timer.option.BackupOption): timer.backup.Auth { class Processor { coordinators: { - [type in timer.backup.Type]: timer.backup.Coordinator<unknown> + [type in tt4b.backup.Type]: tt4b.backup.Coordinator<unknown> } constructor() { this.coordinators = { - none: null as unknown as timer.backup.Coordinator<never>, + none: null as unknown as tt4b.backup.Coordinator<never>, gist: new GistCoordinator(), obsidian_local_rest_api: new ObsidianCoordinator(), web_dav: new WebDAVCoordinator(), @@ -95,8 +95,8 @@ class Processor { if (errorMsg) return errorMsg const cid = await getCid() - const context: timer.backup.CoordinatorContext<unknown> = await new CoordinatorContextWrapper<unknown>(cid, auth, ext, type).init() - const client: timer.backup.Client = { + const context: tt4b.backup.CoordinatorContext<unknown> = await new CoordinatorContextWrapper<unknown>(cid, auth, ext, type).init() + const client: tt4b.backup.Client = { id: cid, name: option.clientName, minDate: undefined, @@ -115,7 +115,7 @@ class Processor { } } - async listClients(): Promise<(timer.backup.Client & { current: boolean })[]> { + async listClients(): Promise<(tt4b.backup.Client & { current: boolean })[]> { const { auth, ext, type, coordinator, errorMsg } = await this.checkAuth() if (errorMsg) throw new Error(errorMsg) const cid = await getCid() @@ -130,7 +130,7 @@ class Processor { const ext = backupExts?.[type] ?? {} const auth = prepareAuth(option) - const coordinator: timer.backup.Coordinator<unknown> = type && this.coordinators[type] + const coordinator: tt4b.backup.Coordinator<unknown> = type && this.coordinators[type] if (!coordinator) { // no coordinator, do nothing return { option, auth, ext, type, coordinator, errorMsg: "Invalid type" } @@ -144,7 +144,7 @@ class Processor { return { option, auth, ext, type, coordinator, errorMsg } } - async query(param: timer.backup.RemoteQuery): Promise<timer.backup.Row[]> { + async query(param: tt4b.backup.RemoteQuery): Promise<tt4b.backup.Row[]> { const { type, coordinator, auth, ext, errorMsg } = await this.checkAuth() if (errorMsg || !coordinator) { return [] @@ -153,13 +153,13 @@ class Processor { const { start, end, specCid, excludeLocal } = param let localCid = await getCid() // 1. init context - const context: timer.backup.CoordinatorContext<unknown> = await new CoordinatorContextWrapper<unknown>(localCid, auth, ext, type).init() + const context: tt4b.backup.CoordinatorContext<unknown> = await new CoordinatorContextWrapper<unknown>(localCid, auth, ext, type).init() // 2. query all clients, and filter them const allClients = (await coordinator.listAllClients(context)) .filter(c => filterClient(c, !!excludeLocal, localCid, start, end)) .filter(c => !specCid || c.id === specCid) // 3. iterate clients - const result: timer.backup.Row[] = [] + const result: tt4b.backup.Row[] = [] await Promise.all( allClients.map(async client => { const { id, name } = client @@ -179,7 +179,7 @@ class Processor { const { auth, ext, type, coordinator, errorMsg } = await this.checkAuth() if (errorMsg) return errorMsg let localCid = await getCid() - const context: timer.backup.CoordinatorContext<unknown> = await new CoordinatorContextWrapper<unknown>(localCid, auth, ext, type).init() + const context: tt4b.backup.CoordinatorContext<unknown> = await new CoordinatorContextWrapper<unknown>(localCid, auth, ext, type).init() // 1. Find the client const allClients = await coordinator.listAllClients(context) const client = allClients?.filter(c => c?.id === cid)?.[0] diff --git a/src/background/service/backup/web-dav/coordinator.ts b/src/background/service/backup/web-dav/coordinator.ts index d766240c1..07c116c04 100644 --- a/src/background/service/backup/web-dav/coordinator.ts +++ b/src/background/service/backup/web-dav/coordinator.ts @@ -7,7 +7,7 @@ import { getBirthday, parseTime } from '@util/time' import { processDir } from "../common" import { CLIENT_FILE_NAME, convertClients2Markdown, divideByDate, parseData } from "../markdown" -function getEndpoint(ext: timer.backup.TypeExt | undefined): string | undefined { +function getEndpoint(ext: tt4b.backup.TypeExt | undefined): string | undefined { let { endpoint } = ext || {} if (endpoint?.endsWith('/')) { endpoint = endpoint.substring(0, endpoint.length - 1) @@ -15,7 +15,7 @@ function getEndpoint(ext: timer.backup.TypeExt | undefined): string | undefined return endpoint } -function prepareContext(context: timer.backup.CoordinatorContext<never>): WebDAVContext { +function prepareContext(context: tt4b.backup.CoordinatorContext<never>): WebDAVContext { const { auth, ext } = context const endpoint = getEndpoint(ext) if (!endpoint) { @@ -29,8 +29,8 @@ function prepareContext(context: timer.backup.CoordinatorContext<never>): WebDAV return { auth: webDavAuth, endpoint } } -export default class WebDAVCoordinator implements timer.backup.Coordinator<never> { - async updateClients(context: timer.backup.CoordinatorContext<never>, clients: timer.backup.Client[]): Promise<void> { +export default class WebDAVCoordinator implements tt4b.backup.Coordinator<never> { + async updateClients(context: tt4b.backup.CoordinatorContext<never>, clients: tt4b.backup.Client[]): Promise<void> { const dirPath = processDir(context?.ext?.dirPath) const clientFilePath = `${dirPath}${CLIENT_FILE_NAME}` const content = convertClients2Markdown(clients) @@ -38,7 +38,7 @@ export default class WebDAVCoordinator implements timer.backup.Coordinator<never await writeFile(davContext, clientFilePath, content) } - async listAllClients(context: timer.backup.CoordinatorContext<never>): Promise<timer.backup.Client[]> { + async listAllClients(context: tt4b.backup.CoordinatorContext<never>): Promise<tt4b.backup.Client[]> { const dirPath = processDir(context?.ext?.dirPath) const clientFilePath = `${dirPath}${CLIENT_FILE_NAME}` const davContext = prepareContext(context) @@ -51,7 +51,7 @@ export default class WebDAVCoordinator implements timer.backup.Coordinator<never } } - async download(context: timer.backup.CoordinatorContext<never>, start: string, end: string, targetCid?: string): Promise<timer.core.Row[]> { + async download(context: tt4b.backup.CoordinatorContext<never>, start: string, end: string, targetCid?: string): Promise<tt4b.core.Row[]> { const dirPath = processDir(context?.ext?.dirPath) const davContext = prepareContext(context) targetCid = targetCid || context?.cid @@ -59,17 +59,17 @@ export default class WebDAVCoordinator implements timer.backup.Coordinator<never const dateStart = parseTime(start) ?? getBirthday() const dateEnd = parseTime(end) ?? new Date() const dateIterator = new DateIterator(dateStart, dateEnd) - const result: timer.core.Row[] = [] + const result: tt4b.core.Row[] = [] await Promise.all(dateIterator.toArray().map(async date => { const filePath = `${dirPath}${targetCid}/${date}.md` const fileContent = await readFile(davContext, filePath) - const rows = parseData<timer.core.Row[]>(fileContent) + const rows = parseData<tt4b.core.Row[]>(fileContent) rows?.forEach?.(row => result.push(row)) })) return result } - async upload(context: timer.backup.CoordinatorContext<never>, rows: timer.core.Row[]): Promise<void> { + async upload(context: tt4b.backup.CoordinatorContext<never>, rows: tt4b.core.Row[]): Promise<void> { const dateAndContents = divideByDate(rows) const dirPath = processDir(context?.ext?.dirPath) const cid = context?.cid @@ -91,7 +91,7 @@ export default class WebDAVCoordinator implements timer.backup.Coordinator<never return clientDirPath } - async testAuth(auth: timer.backup.Auth, ext: timer.backup.TypeExt): Promise<string | undefined> { + async testAuth(auth: tt4b.backup.Auth, ext: tt4b.backup.TypeExt): Promise<string | undefined> { const endpoint = getEndpoint(ext) if (!endpoint) { return "The endpoint is blank" @@ -121,7 +121,7 @@ export default class WebDAVCoordinator implements timer.backup.Coordinator<never } } - async clear(context: timer.backup.CoordinatorContext<never>, client: timer.backup.Client): Promise<void> { + async clear(context: tt4b.backup.CoordinatorContext<never>, client: tt4b.backup.Client): Promise<void> { const cid = client.id const dirPath = processDir(context.ext?.dirPath) const davContext = prepareContext(context) diff --git a/src/background/service/components/host-merge-ruler.ts b/src/background/service/components/host-merge-ruler.ts index 44cde2276..1c527b775 100644 --- a/src/background/service/components/host-merge-ruler.ts +++ b/src/background/service/components/host-merge-ruler.ts @@ -43,7 +43,7 @@ const processRegStr = (regStr: string) => regStr .split('**').join('.+') .split('*').join('[^\\.]+') -function convert(dbItem: timer.merge.Rule): RegRuleItem | [string, string | number] { +function convert(dbItem: tt4b.merge.Rule): RegRuleItem | [string, string | number] { const { origin, merged } = dbItem if (origin.includes('*')) { const regStr = processRegStr(origin) @@ -61,7 +61,7 @@ export default class CustomizedHostMergeRuler { private cache: FIFOCache<string> = new FIFOCache(500) - constructor(rules: timer.merge.Rule[]) { + constructor(rules: tt4b.merge.Rule[]) { rules.map(item => convert(item)) .forEach(rule => Array.isArray(rule) ? (this.noRegMergeRules[rule[0]] = rule[1] || rule[0]) diff --git a/src/background/service/components/immigration.ts b/src/background/service/components/immigration.ts index d24c40171..b1cf58c17 100644 --- a/src/background/service/components/immigration.ts +++ b/src/background/service/components/immigration.ts @@ -23,8 +23,8 @@ const STORAGE_MIGRATABLES: StorageMigratable<unknown>[] = [ statDatabase, ] -export async function exportData(): Promise<timer.backup.ExportData> { - const data: timer.backup.ExportData = { +export async function exportData(): Promise<tt4b.backup.ExportData> { + const data: tt4b.backup.ExportData = { __meta__: { version: packageInfo.version, ts: Date.now() }, } for (const migratable of BROWSER_MIGRATABLES) { @@ -39,7 +39,7 @@ export async function importData(data: unknown): Promise<void> { for (const db of BROWSER_MIGRATABLES) await db.importData(data) } -export async function migrateStorage(type: timer.option.StorageType): Promise<void> { +export async function migrateStorage(type: tt4b.option.StorageType): Promise<void> { const dataList: unknown[] = [] // 1. migrate all the databases firstly for (const migratable of STORAGE_MIGRATABLES) { diff --git a/src/background/service/components/import-processor.ts b/src/background/service/components/import-processor.ts index d414fd4ad..52551a1da 100644 --- a/src/background/service/components/import-processor.ts +++ b/src/background/service/components/import-processor.ts @@ -9,7 +9,7 @@ import statDatabase from "@db/stat-database" import { mergeWith } from '@util/stat' import backupProcessor from "../backup/processor" -export async function importOther(query: timer.imported.ProcessQuery): Promise<void> { +export async function importOther(query: tt4b.imported.ProcessQuery): Promise<void> { const { data, resolution } = query if (resolution === 'overwrite') { return processOverwrite(data) @@ -17,7 +17,7 @@ export async function importOther(query: timer.imported.ProcessQuery): Promise<v return processAcc(data) } -async function processOverwrite(data: timer.imported.Data): Promise<void> { +async function processOverwrite(data: tt4b.imported.Data): Promise<void> { const { rows, focus, time } = data const exist = await statDatabase.batchSelect(rows) await mergeWith(rows, exist, async (row, exist) => { @@ -27,7 +27,7 @@ async function processOverwrite(data: timer.imported.Data): Promise<void> { }) } -async function processAcc(data: timer.imported.Data): Promise<void> { +async function processAcc(data: tt4b.imported.Data): Promise<void> { const { rows } = data await Promise.all(rows.map(async row => { const { host, date, focus = 0, time = 0 } = row @@ -36,9 +36,9 @@ async function processAcc(data: timer.imported.Data): Promise<void> { } -export async function previewBackup(param: timer.backup.RemoteQuery): Promise<timer.imported.Row[]> { +export async function previewBackup(param: tt4b.backup.RemoteQuery): Promise<tt4b.imported.Row[]> { const remoteRows = await backupProcessor.query(param) - const rows: timer.imported.Row[] = remoteRows.map(rr => ({ + const rows: tt4b.imported.Row[] = remoteRows.map(rr => ({ date: rr.date, host: rr.host, focus: rr.focus, diff --git a/src/background/service/components/option-holder.ts b/src/background/service/components/option-holder.ts index b9119ddce..5a9a8d966 100644 --- a/src/background/service/components/option-holder.ts +++ b/src/background/service/components/option-holder.ts @@ -2,10 +2,10 @@ import { onPermRemoved } from "@api/chrome/permission" import db from "@db/option-database" import { defaultOption } from '@util/constant/option' -type ChangeListener = (newVal: timer.option.DefaultOption, oldVal: timer.option.DefaultOption) => void +type ChangeListener = (newVal: tt4b.option.DefaultOption, oldVal: tt4b.option.DefaultOption) => void class OptionHolder { - private value: timer.option.DefaultOption | undefined + private value: tt4b.option.DefaultOption | undefined private listeners: ChangeListener[] = [] constructor() { @@ -14,13 +14,13 @@ class OptionHolder { }) } - private async reset(): Promise<timer.option.DefaultOption> { + private async reset(): Promise<tt4b.option.DefaultOption> { const latest = Object.assign(defaultOption(), await db.getOption()) this.value = latest return latest } - async get(): Promise<timer.option.DefaultOption> { + async get(): Promise<tt4b.option.DefaultOption> { return this.value ?? await this.reset() } @@ -28,7 +28,7 @@ class OptionHolder { listener && this.listeners.push(listener) } - async set(option: Partial<timer.option.AllOption>): Promise<void> { + async set(option: Partial<tt4b.option.AllOption>): Promise<void> { const exist = await this.get() const toSet = Object.assign(defaultOption(), exist, option) await db.setOption(toSet) diff --git a/src/background/service/components/page-info.ts b/src/background/service/components/page-info.ts index a36fb7621..ccaf0e3f7 100644 --- a/src/background/service/components/page-info.ts +++ b/src/background/service/components/page-info.ts @@ -11,7 +11,7 @@ const DEFAULT_PAGE_SIZE = 10 /** * Slice the origin list to page */ -export function slicePageResult<T>(originList: T[], pageQuery?: timer.common.PageQuery): timer.common.PageResult<T> { +export function slicePageResult<T>(originList: T[], pageQuery?: tt4b.common.PageQuery): tt4b.common.PageResult<T> { let { num: pageNum = DEFAULT_PAGE_NUM, size: pageSize = DEFAULT_PAGE_SIZE } = pageQuery || {} pageNum < 1 && (pageNum = DEFAULT_PAGE_NUM) pageSize < 1 && (pageSize = DEFAULT_PAGE_SIZE) diff --git a/src/background/service/components/period-calculator.ts b/src/background/service/components/period-calculator.ts index cf0242aa2..5bed6ccd4 100644 --- a/src/background/service/components/period-calculator.ts +++ b/src/background/service/components/period-calculator.ts @@ -13,7 +13,7 @@ import { after, compare, indexOf, keyOf, rowOf, startOfKey } from "@util/period" * @param milliseconds milliseconds * @returns results, can't be empty if milliseconds is positive */ -export function calculate(timestamp: number, milliseconds: number): timer.period.Result[] { +export function calculate(timestamp: number, milliseconds: number): tt4b.period.Result[] { if (milliseconds <= 0) return [] const key = keyOf(timestamp) @@ -21,7 +21,7 @@ export function calculate(timestamp: number, milliseconds: number): timer.period const currentResult = { ...key, milliseconds: 0 } const extraMill = timestamp - start - const result: timer.period.Result[] = [] + const result: tt4b.period.Result[] = [] if (extraMill < milliseconds) { // milliseconds including before period // 1st. add before ones @@ -37,7 +37,7 @@ export function calculate(timestamp: number, milliseconds: number): timer.period return result } -export function merge(periods: timer.period.Result[], size: number): timer.period.Row[] { +export function merge(periods: tt4b.period.Result[], size: number): tt4b.period.Row[] { periods = periods.sort(compare) const first = periods[0] const last = periods[periods.length - 1] @@ -47,8 +47,8 @@ export function merge(periods: timer.period.Result[], size: number): timer.perio periods.forEach(p => map.set(indexOf(p), p.milliseconds)) let mills: number[] = [] - let start: timer.period.Key = first - const rows: timer.period.Row[] = [] + let start: tt4b.period.Key = first + const rows: tt4b.period.Row[] = [] for (; compare(start, last) <= 0; start = after(start, 1)) { mills.push(map.get(indexOf(start)) ?? 0) const isEndOfWindow = (start.order % size) === size - 1 diff --git a/src/background/service/components/virtual-site-holder.ts b/src/background/service/components/virtual-site-holder.ts index 920d25b76..def14a533 100644 --- a/src/background/service/components/virtual-site-holder.ts +++ b/src/background/service/components/virtual-site-holder.ts @@ -13,12 +13,12 @@ class VirtualSiteHolder { db.select().then(keys => keys.forEach(key => this.buildWith(key))) } - buildWith({ host, type }: timer.site.SiteKey) { + buildWith({ host, type }: tt4b.site.SiteKey) { if (type !== 'virtual') return this.hostRegMap[host] = compileAntPattern(host) } - onDeleted({ host, type }: timer.site.SiteKey) { + onDeleted({ host, type }: tt4b.site.SiteKey) { if (type !== 'virtual') return delete this.hostRegMap[host] } diff --git a/src/background/service/components/week-helper.ts b/src/background/service/components/week-helper.ts index 452ab93f8..aba6627e8 100644 --- a/src/background/service/components/week-helper.ts +++ b/src/background/service/components/week-helper.ts @@ -2,7 +2,7 @@ import { locale } from "@i18n" import { getStartOfDay, getWeekDay, MILL_PER_DAY } from "@util/time" import optionHolder from './option-holder' -function getDefaultWeekStart(localeOpt: timer.option.LocaleOption): number { +function getDefaultWeekStart(localeOpt: tt4b.option.LocaleOption): number { const parts = navigator.language.split(/[-_]/) const region = parts[parts.length - 1]?.toLowerCase() ?? '' switch (locale) { @@ -13,7 +13,7 @@ function getDefaultWeekStart(localeOpt: timer.option.LocaleOption): number { // Other countries or fallbacked to English use Monday as the first day of week case 'en': if (['us', 'ca', 'in', 'za', 'jm', 'ph'].includes(region)) { - // US, Canaca, India, South Africa, Jamaica, Philippines use Sunday as the first day of week + // US, Canaca, India, South Africa, Jamaica, Philippines use Sunday as the first day of week return 6 } else if (['gb', 'au', 'nz'].includes(region)) { // UK, Australia and New Zealand use Monday as the first day of week @@ -52,7 +52,7 @@ export async function getWeekStartDay(): Promise<number> { /** * Get the start time and end time of this week - * + * * @param now the specific time to calculate * @returns start time with milliseconds * diff --git a/src/background/service/item-service.ts b/src/background/service/item-service.ts index df8853527..9650025ba 100644 --- a/src/background/service/item-service.ts +++ b/src/background/service/item-service.ts @@ -13,7 +13,7 @@ export type ItemIncContext = { export async function addFocusTime(context: ItemIncContext, focusTime: number): Promise<void> { const { host, url, groupId } = context - const resultSet: Record<string, timer.core.Result> = { [host]: resultOf(focusTime, 0) } + const resultSet: Record<string, tt4b.core.Result> = { [host]: resultOf(focusTime, 0) } const virtualHosts = virtualSiteHolder.findMatched(url) virtualHosts.forEach(virtualHost => resultSet[virtualHost] = resultOf(focusTime, 0)) diff --git a/src/background/service/limit-service.ts b/src/background/service/limit-service.ts index c538c478f..3dcc6dadc 100644 --- a/src/background/service/limit-service.ts +++ b/src/background/service/limit-service.ts @@ -14,7 +14,7 @@ import optionHolder from "./components/option-holder" import { getWeekStartTime } from './components/week-helper' import whitelistHolder from "./whitelist/holder" -export async function selectLimit(param?: timer.limit.Query): Promise<timer.limit.Item[]> { +export async function selectLimit(param?: tt4b.limit.Query): Promise<tt4b.limit.Item[]> { const { enabled, url, id, limited, effective } = param ?? {} const now = new Date() const today = formatTimeYMD(now) @@ -79,8 +79,8 @@ export async function removeLimitRules(ids: number[]): Promise<void> { } type IncreaseResult = { - limited: timer.limit.Item[] - reminder?: timer.limit.ReminderInfo + limited: tt4b.limit.Item[] + reminder?: tt4b.limit.ReminderInfo } /** @@ -96,8 +96,8 @@ export async function addLimitFocusTime(host: string, url: string, focusTime: nu const allEffective = await selectLimit({ url, effective: true }) const toUpdate: { [cond: string]: number } = {} - const limited: timer.limit.Item[] = [] - const needReminder: timer.limit.Item[] = [] + const limited: tt4b.limit.Item[] = [] + const needReminder: tt4b.limit.Item[] = [] const { limitReminder, limitReminderDuration = 0, limitDelayDuration } = await optionHolder.get() const durationMill = limitReminder ? limitReminderDuration * MILL_PER_MINUTE : 0 @@ -125,7 +125,7 @@ type LimitTimeStateResult = { weekly: TimeLimitState } -export function calcTimeState(item: timer.limit.Item, reminderMills: number, delayDuration: number): LimitTimeStateResult { +export function calcTimeState(item: tt4b.limit.Item, reminderMills: number, delayDuration: number): LimitTimeStateResult { const res: LimitTimeStateResult = { daily: 'NORMAL', weekly: 'NORMAL' } const { time, waste, delayCount, @@ -143,7 +143,7 @@ export function calcTimeState(item: timer.limit.Item, reminderMills: number, del return res } -function addFocusForEach(item: timer.limit.Item, focusTime: number, durationMill: number, delayDuration: number): [met: boolean, reminder: boolean] { +function addFocusForEach(item: tt4b.limit.Item, focusTime: number, durationMill: number, delayDuration: number): [met: boolean, reminder: boolean] { const before = calcTimeState(item, durationMill, delayDuration) item.waste += focusTime // Fast increase @@ -158,12 +158,12 @@ function addFocusForEach(item: timer.limit.Item, focusTime: number, durationMill * Increase visit count * @returns the rules is limited */ -export async function incLimitVisit(host: string, url: string): Promise<timer.limit.Item[]> { +export async function incLimitVisit(host: string, url: string): Promise<tt4b.limit.Item[]> { if (whitelistHolder.contains(host, url)) return [] const allEnabled = await selectLimit({ enabled: true, url }) const { limitDelayDuration: delayDuration } = await optionHolder.get() - const result: timer.limit.Item[] = [] + const result: tt4b.limit.Item[] = [] await db.increaseVisit(formatTimeYMD(new Date()), allEnabled.map(item => item.id)) allEnabled.forEach(item => { // Fast increase @@ -189,12 +189,12 @@ export async function delayLimit(url: string): Promise<void> { await noticeLimitChanged() } -export async function updateLimitRules(rules: timer.limit.Rule[]): Promise<void> { +export async function updateLimitRules(rules: tt4b.limit.Rule[]): Promise<void> { await db.batchUpdate(rules) await noticeLimitChanged() } -export async function createLimitRule(rule: Omit<timer.limit.Rule, 'id'>): Promise<number> { +export async function createLimitRule(rule: Omit<tt4b.limit.Rule, 'id'>): Promise<number> { const id = await db.add(rule) await noticeLimitChanged() return id diff --git a/src/background/service/meta-service.ts b/src/background/service/meta-service.ts index 4ceab8350..5572abfce 100644 --- a/src/background/service/meta-service.ts +++ b/src/background/service/meta-service.ts @@ -64,7 +64,7 @@ function getBrand() { /** * @since 1.4.7 */ -export async function updateBackUpTime(type: timer.backup.Type, time: number) { +export async function updateBackUpTime(type: tt4b.backup.Type, time: number) { const meta = await db.getMeta() if (!meta.backup) { meta.backup = {} @@ -76,7 +76,7 @@ export async function updateBackUpTime(type: timer.backup.Type, time: number) { /** * @since 1.4.7 */ -export async function getLastBackUp(type: timer.backup.Type): Promise<number | undefined> { +export async function getLastBackUp(type: tt4b.backup.Type): Promise<number | undefined> { const meta = await db.getMeta() return meta.backup?.[type]?.ts } \ No newline at end of file diff --git a/src/background/service/notification/processor.ts b/src/background/service/notification/processor.ts index 5117ab535..95db875b4 100644 --- a/src/background/service/notification/processor.ts +++ b/src/background/service/notification/processor.ts @@ -14,7 +14,7 @@ const DATE_RANGE_CALCULATORS: Record<NotificationRequest['cycle'], (now: number) class Processor { private notifiers: { - [method in timer.notification.Method]: Notifier + [method in tt4b.notification.Method]: Notifier } constructor() { diff --git a/src/background/service/notification/types.ts b/src/background/service/notification/types.ts index 87b8f1c6b..780367131 100644 --- a/src/background/service/notification/types.ts +++ b/src/background/service/notification/types.ts @@ -7,16 +7,16 @@ type Summary = { } export type NotificationMeta = { - locale: timer.Locale + locale: tt4b.Locale version: string ts: number } export type NotificationRequest = { - cycle: Exclude<timer.notification.Cycle, 'none'> - method: timer.notification.Method - endpoint?: timer.option.NotificationOption['notificationEndpoint'] - authToken?: timer.option.NotificationOption['notificationAuthToken'] + cycle: Exclude<tt4b.notification.Cycle, 'none'> + method: tt4b.notification.Method + endpoint?: tt4b.option.NotificationOption['notificationEndpoint'] + authToken?: tt4b.option.NotificationOption['notificationAuthToken'] } /** @@ -24,9 +24,9 @@ export type NotificationRequest = { */ export type NotificationData = { meta: NotificationMeta - cycle: Exclude<timer.notification.Cycle, 'none'> + cycle: Exclude<tt4b.notification.Cycle, 'none'> summary: Summary - row: timer.core.Row[] + row: tt4b.core.Row[] } /** diff --git a/src/background/service/period-service.ts b/src/background/service/period-service.ts index a5273f456..84797989b 100644 --- a/src/background/service/period-service.ts +++ b/src/background/service/period-service.ts @@ -9,7 +9,7 @@ import db from "@db/period-database" import { after, compare, getDateString } from "@util/period" import { merge } from "./components/period-calculator" -function dateStrBetween(startDate: timer.period.Key, endDate: timer.period.Key): string[] { +function dateStrBetween(startDate: tt4b.period.Key, endDate: tt4b.period.Key): string[] { const result: string[] = [] while (compare(startDate, endDate) <= 0) { result.push(getDateString(startDate)) @@ -18,7 +18,7 @@ function dateStrBetween(startDate: timer.period.Key, endDate: timer.period.Key): return result } -export async function selectPeriods(param: timer.period.Query): Promise<timer.period.Row[]> { +export async function selectPeriods(param: tt4b.period.Query): Promise<tt4b.period.Row[]> { let { range, size = 1 } = param if (!Number.isInteger(size) || size <= 1) size = 1 @@ -32,7 +32,7 @@ export async function selectPeriods(param: timer.period.Query): Promise<timer.pe return merge(results, size) } -export async function batchDeletePeriods(start: timer.period.Key, end: timer.period.Key): Promise<void> { +export async function batchDeletePeriods(start: tt4b.period.Key, end: tt4b.period.Key): Promise<void> { const allDates = dateStrBetween(start, end) await db.batchDelete(allDates) } diff --git a/src/background/service/site-service.ts b/src/background/service/site-service.ts index 53598777f..e5fb102a1 100644 --- a/src/background/service/site-service.ts +++ b/src/background/service/site-service.ts @@ -18,25 +18,25 @@ import CustomizedHostMergeRuler from './components/host-merge-ruler' import { slicePageResult } from "./components/page-info" import virtualSiteHolder from './components/virtual-site-holder' -export async function saveAlias(key: timer.site.SiteKey, alias: string | undefined, noRewrite?: boolean) { +export async function saveAlias(key: tt4b.site.SiteKey, alias: string | undefined, noRewrite?: boolean) { const exist = await siteDatabase.get(key) if (exist && noRewrite) return await siteDatabase.save({ ...exist, ...key, alias }) } -export async function removeIconUrl(key: timer.site.SiteKey) { +export async function removeIconUrl(key: tt4b.site.SiteKey) { const exist = await siteDatabase.get(key) if (!exist) return delete exist.iconUrl await siteDatabase.save(exist) } -export async function saveIconUrl(key: timer.site.SiteKey, iconUrl: string) { +export async function saveIconUrl(key: tt4b.site.SiteKey, iconUrl: string) { const exist = await siteDatabase.get(key) await siteDatabase.save({ ...exist, ...key, iconUrl }) } -export async function saveSiteRunState(key: timer.site.SiteKey, enabled: boolean) { +export async function saveSiteRunState(key: tt4b.site.SiteKey, enabled: boolean) { const exist = await siteDatabase.get(key) if (!exist) return exist.run = enabled @@ -50,7 +50,7 @@ export async function saveSiteRunState(key: timer.site.SiteKey, enabled: boolean } } -export async function addSite(siteInfo: timer.site.SiteInfo): Promise<string | undefined> { +export async function addSite(siteInfo: tt4b.site.SiteInfo): Promise<string | undefined> { if (await siteDatabase.exist(siteInfo)) { return 'Site already exists' } @@ -59,17 +59,17 @@ export async function addSite(siteInfo: timer.site.SiteInfo): Promise<string | u virtualSiteHolder.buildWith(siteInfo) } -export async function removeSites(keys: timer.site.SiteKey[]): Promise<void> { +export async function removeSites(keys: tt4b.site.SiteKey[]): Promise<void> { await siteDatabase.remove(keys) keys.forEach(key => virtualSiteHolder.onDeleted(key)) } -export async function selectSitePage(param?: timer.site.PageQuery): Promise<timer.common.PageResult<timer.site.SiteInfo>> { +export async function selectSitePage(param?: tt4b.site.PageQuery): Promise<tt4b.common.PageResult<tt4b.site.SiteInfo>> { const origin = await siteDatabase.select(param) return slicePageResult(origin, param) } -export async function batchChangeCate(cateId: number | undefined, keys: timer.site.SiteKey[]): Promise<void> { +export async function batchChangeCate(cateId: number | undefined, keys: tt4b.site.SiteKey[]): Promise<void> { keys = keys?.filter(supportCategory) if (!keys?.length) return @@ -82,7 +82,7 @@ export async function batchChangeCate(cateId: number | undefined, keys: timer.si /** * @since 0.9.0 */ -export async function getSite(siteKey: timer.site.SiteKey): Promise<timer.site.SiteInfo> { +export async function getSite(siteKey: tt4b.site.SiteKey): Promise<tt4b.site.SiteInfo> { const info = await siteDatabase.get(siteKey) return info ?? siteKey } @@ -93,12 +93,12 @@ function moveToFront<T>(arr: T[], idx: number): T[] { return [item, ...arr.slice(0, idx), ...arr.slice(idx + 1)] } -export async function searchSites(query: string | undefined): Promise<timer.site.SiteInfo[]> { +export async function searchSites(query: string | undefined): Promise<tt4b.site.SiteInfo[]> { query = cleanSearchQuery(query) const filter = query ? (host: string) => host.includes(query) : () => true const [normal, merged] = await listHosts(filter) - const keys: timer.site.SiteKey[] = [] + const keys: tt4b.site.SiteKey[] = [] normal.forEach(host => keys.push({ host, type: 'normal' })) merged.forEach(host => keys.push({ host, type: 'merged' })) @@ -162,7 +162,7 @@ async function listHosts(filter: (host: string) => boolean): Promise<[normal: st return [Array.from(normal), Array.from(merged)] } -export async function fillInitialAlias(keys: timer.site.SiteKey[]) { +export async function fillInitialAlias(keys: tt4b.site.SiteKey[]) { const sites = await siteDatabase.getBatch(keys) const toSave = new SiteMap<string>() sites.forEach(site => { @@ -196,7 +196,7 @@ async function batchSaveAlias(siteMap: SiteMap<string>): Promise<void> { const allSites = await siteDatabase.getBatch(siteMap.keys()) const existMap = SiteMap.identify(allSites) - const toSave: timer.site.SiteInfo[] = [] + const toSave: tt4b.site.SiteInfo[] = [] siteMap.forEach((k, alias) => { const exist = existMap.get(k) if (exist?.alias) return diff --git a/src/background/service/stat-service/common.ts b/src/background/service/stat-service/common.ts index 6720dafaf..8b3125a8c 100644 --- a/src/background/service/stat-service/common.ts +++ b/src/background/service/stat-service/common.ts @@ -1,6 +1,6 @@ import { judgeVirtualFast } from "@util/pattern" -export function cvt2SiteRow(rowBase: timer.core.Row): timer.stat.SiteRow { +export function cvt2SiteRow(rowBase: tt4b.core.Row): tt4b.stat.SiteRow { const { host, ...otherFields } = rowBase return { siteKey: { host, type: judgeVirtualFast(host) ? 'virtual' : 'normal' }, diff --git a/src/background/service/stat-service/index.ts b/src/background/service/stat-service/index.ts index 3044ba802..ce3101f40 100644 --- a/src/background/service/stat-service/index.ts +++ b/src/background/service/stat-service/index.ts @@ -19,7 +19,7 @@ import { mergeDate } from "./merge/date" import { mergeHost } from "./merge/host" import { processRemote } from "./remote" -function extractAllSiteKeys(rows: timer.stat.SiteRow[], container: timer.site.SiteKey[]) { +function extractAllSiteKeys(rows: tt4b.stat.SiteRow[], container: tt4b.site.SiteKey[]) { rows.forEach(row => { const { mergedRows } = row container.push(row.siteKey) @@ -27,7 +27,7 @@ function extractAllSiteKeys(rows: timer.stat.SiteRow[], container: timer.site.Si }) } -function fillRowWithSiteInfo(row: timer.stat.SiteRow, siteMap: SiteMap<timer.site.SiteInfo>): void { +function fillRowWithSiteInfo(row: tt4b.stat.SiteRow, siteMap: SiteMap<tt4b.site.SiteInfo>): void { if (!isSite(row)) return const { siteKey, mergedRows } = row @@ -41,7 +41,7 @@ function fillRowWithSiteInfo(row: timer.stat.SiteRow, siteMap: SiteMap<timer.sit } } -function compareSortVal(a: string | number, b: string | number, direction?: timer.common.SortDirection): number { +function compareSortVal(a: string | number, b: string | number, direction?: tt4b.common.SortDirection): number { if (a === b) return 0 const val = a > b ? 1 : -1 return direction === 'DESC' ? -val : val @@ -52,12 +52,12 @@ function filterByCateId(itemCateId: number | undefined, cateIds: number[] | unde return cateIds.includes(itemCateId ?? CATE_NOT_SET_ID) } -export async function countSite(param?: timer.stat.SiteQuery): Promise<number> { +export async function countSite(param?: tt4b.stat.SiteQuery): Promise<number> { const rows = await statDatabase.select(param) return rows.length } -export async function selectSite(param?: timer.stat.SiteQuery): Promise<timer.stat.SiteRow[]> { +export async function selectSite(param?: tt4b.stat.SiteQuery): Promise<tt4b.stat.SiteRow[]> { const { mergeHost: needMerge, mergeDate: needMergeDate, date, query, host, cateIds, @@ -87,18 +87,18 @@ export async function selectSite(param?: timer.stat.SiteQuery): Promise<timer.st needMergeDate && (siteRows = mergeDate(siteRows)) // Sort if (sortKey) { - const sortVal = (a: timer.stat.SiteRow) => sortKey === 'host' ? a.siteKey.host : a[sortKey] ?? 0 + const sortVal = (a: tt4b.stat.SiteRow) => sortKey === 'host' ? a.siteKey.host : a[sortKey] ?? 0 siteRows.sort((a, b) => compareSortVal(sortVal(a), sortVal(b), sortDirection)) } return siteRows } -export async function selectSitePage(param?: timer.stat.SitePageQuery): Promise<timer.common.PageResult<timer.stat.SiteRow>> { +export async function selectSitePage(param?: tt4b.stat.SitePageQuery): Promise<tt4b.common.PageResult<tt4b.stat.SiteRow>> { const rows = await selectSite(param) return slicePageResult(rows, param) } -export async function selectCate(param?: timer.stat.CateQuery): Promise<timer.stat.CateRow[]> { +export async function selectCate(param?: tt4b.stat.CateQuery): Promise<tt4b.stat.CateRow[]> { const { mergeDate: needMergeDate, date, query, cateIds, @@ -132,13 +132,13 @@ export async function selectCate(param?: timer.stat.CateQuery): Promise<timer.st return cateRows } -export async function selectCatePage(query?: timer.stat.CatePageQuery): Promise<timer.common.PageResult<timer.stat.CateRow>> { +export async function selectCatePage(query?: tt4b.stat.CatePageQuery): Promise<tt4b.common.PageResult<tt4b.stat.CateRow>> { const rows = await selectCate(query) return slicePageResult(rows, query) } -async function fillSite(rows: timer.stat.SiteRow[]): Promise<true> { - let keys: timer.site.SiteKey[] = [] +async function fillSite(rows: tt4b.stat.SiteRow[]): Promise<true> { + let keys: tt4b.site.SiteKey[] = [] extractAllSiteKeys(rows, keys) keys = distinctSites(keys) @@ -149,7 +149,7 @@ async function fillSite(rows: timer.stat.SiteRow[]): Promise<true> { return true } -export async function selectGroup(param?: timer.stat.GroupQuery): Promise<timer.stat.GroupRow[]> { +export async function selectGroup(param?: tt4b.stat.GroupQuery): Promise<tt4b.stat.GroupRow[]> { const { date, query, mergeDate: needMergeDate, focusRange, timeRange, @@ -158,7 +158,7 @@ export async function selectGroup(param?: timer.stat.GroupQuery): Promise<timer. const list = await statDatabase.selectGroup({ date, focusRange, timeRange }) const groups = await listAllGroups() const groupMap = toMap(groups, g => g.id) - let rows: timer.stat.GroupRow[] = list.map(({ date, time, focus, run, host }) => { + let rows: tt4b.stat.GroupRow[] = list.map(({ date, time, focus, run, host }) => { const groupKey = parseInt(host) const { title, color } = groupMap[groupKey] ?? {} return ({ date, groupKey, title, color, run, focus, time }) @@ -171,21 +171,21 @@ export async function selectGroup(param?: timer.stat.GroupQuery): Promise<timer. return rows } -export async function selectGroupPage(param?: timer.stat.GroupPageQuery) { +export async function selectGroupPage(param?: tt4b.stat.GroupPageQuery) { const rows = await selectGroup(param) return slicePageResult(rows, param) } -export async function countGroup(param?: timer.stat.GroupQuery): Promise<number> { +export async function countGroup(param?: tt4b.stat.GroupQuery): Promise<number> { const { groupIds, date } = param ?? {} const keys = groupIds?.map(gid => `${gid}`) const rows = await statDatabase.selectGroup({ keys, date }) return rows.length } -export async function batchDelete(targets: timer.stat.StatKey[]) { +export async function batchDelete(targets: tt4b.stat.StatKey[]) { if (!targets?.length) return - const siteKeys: timer.core.RowKey[] = [] + const siteKeys: tt4b.core.RowKey[] = [] const groupKeys: [groupId: number, date: string][] = [] targets.forEach(row => { const { date } = row diff --git a/src/background/service/stat-service/merge/cate.ts b/src/background/service/stat-service/merge/cate.ts index a6dfbd727..c57dcff3f 100644 --- a/src/background/service/stat-service/merge/cate.ts +++ b/src/background/service/stat-service/merge/cate.ts @@ -2,9 +2,9 @@ import { toMap } from "@util/array" import { CATE_NOT_SET_ID } from "@util/site" import { mergeResult } from "./common" -export function mergeCate(origin: timer.stat.SiteRow[], cates: timer.site.Cate[]): timer.stat.CateRow[] { +export function mergeCate(origin: tt4b.stat.SiteRow[], cates: tt4b.site.Cate[]): tt4b.stat.CateRow[] { const cateNameMap = toMap(cates, c => c.id, c => c.name) - const rowMap: Record<string, MakeRequired<timer.stat.CateRow, 'mergedRows'>> = {} + const rowMap: Record<string, MakeRequired<tt4b.stat.CateRow, 'mergedRows'>> = {} origin.forEach(ele => { if (ele.siteKey.type !== 'normal') return let { date = '', cateId = CATE_NOT_SET_ID } = ele @@ -17,7 +17,7 @@ export function mergeCate(origin: timer.stat.SiteRow[], cates: timer.site.Cate[] time: 0, mergedRows: [], composition: { focus: [], time: [], run: [] }, - } satisfies timer.stat.CateRow + } satisfies tt4b.stat.CateRow } mergeResult(exist, ele) exist.mergedRows.push(ele) diff --git a/src/background/service/stat-service/merge/common.ts b/src/background/service/stat-service/merge/common.ts index 7339f48a1..67ab41994 100644 --- a/src/background/service/stat-service/merge/common.ts +++ b/src/background/service/stat-service/merge/common.ts @@ -7,9 +7,9 @@ import { isGroup } from "@util/stat" -type _RemoteCompositionMap = Record<'_' | string, timer.stat.RemoteCompositionVal> +type _RemoteCompositionMap = Record<'_' | string, tt4b.stat.RemoteCompositionVal> -function mergeComposition(c1: timer.stat.RemoteComposition | undefined, c2: timer.stat.RemoteComposition | undefined): timer.stat.RemoteComposition { +function mergeComposition(c1: tt4b.stat.RemoteComposition | undefined, c2: tt4b.stat.RemoteComposition | undefined): tt4b.stat.RemoteComposition { const focusMap: _RemoteCompositionMap = {} const timeMap: _RemoteCompositionMap = {} const runMap: _RemoteCompositionMap = {} @@ -28,7 +28,7 @@ function mergeComposition(c1: timer.stat.RemoteComposition | undefined, c2: time return result } -function accCompositionValue(map: _RemoteCompositionMap, value: timer.stat.RemoteCompositionVal) { +function accCompositionValue(map: _RemoteCompositionMap, value: tt4b.stat.RemoteCompositionVal) { if (typeof value === 'number') { const cid = '_' const existVal = map[cid] @@ -48,7 +48,7 @@ function accCompositionValue(map: _RemoteCompositionMap, value: timer.stat.Remot } } -export function mergeResult(target: timer.stat.Row, delta: timer.stat.Row) { +export function mergeResult(target: tt4b.stat.Row, delta: tt4b.stat.Row) { const { focus, time } = delta target.focus += focus ?? 0 target.time += time ?? 0 diff --git a/src/background/service/stat-service/merge/date.ts b/src/background/service/stat-service/merge/date.ts index 80b8abb2a..055a53665 100644 --- a/src/background/service/stat-service/merge/date.ts +++ b/src/background/service/stat-service/merge/date.ts @@ -2,10 +2,10 @@ import { identifyTargetKey, isCate, isGroup, isNormalSite, isSite } from "@util/ import { mergeResult } from "./common" type MergeRow = - | MakeRequired<timer.stat.SiteRow | timer.stat.CateRow, 'mergedDates' | 'mergedRows'> - | MakeRequired<timer.stat.GroupRow, 'mergedDates' | 'mergedRows'> + | MakeRequired<tt4b.stat.SiteRow | tt4b.stat.CateRow, 'mergedDates' | 'mergedRows'> + | MakeRequired<tt4b.stat.GroupRow, 'mergedDates' | 'mergedRows'> -export function mergeDate<T extends timer.stat.Row>(origin: T[]): T[] { +export function mergeDate<T extends tt4b.stat.Row>(origin: T[]): T[] { const map: Record<string, MergeRow> = {} origin.forEach(ele => { const { date } = ele diff --git a/src/background/service/stat-service/merge/host.ts b/src/background/service/stat-service/merge/host.ts index 14ad481ed..3a2c6f4d2 100644 --- a/src/background/service/stat-service/merge/host.ts +++ b/src/background/service/stat-service/merge/host.ts @@ -3,11 +3,11 @@ import CustomizedHostMergeRuler from "@service/components/host-merge-ruler" import { isNormalSite } from "@util/stat" import { mergeResult } from "./common" -export async function mergeHost(origin: timer.stat.SiteRow[]): Promise<timer.stat.SiteRow[]> { - const map: Record<string, MakeRequired<timer.stat.SiteRow, 'mergedRows'>> = {} +export async function mergeHost(origin: tt4b.stat.SiteRow[]): Promise<tt4b.stat.SiteRow[]> { + const map: Record<string, MakeRequired<tt4b.stat.SiteRow, 'mergedRows'>> = {} // Generate ruler - const mergeRuleItems: timer.merge.Rule[] = await mergeRuleDatabase.selectAll() + const mergeRuleItems: tt4b.merge.Rule[] = await mergeRuleDatabase.selectAll() const mergeRuler = new CustomizedHostMergeRuler(mergeRuleItems) origin.forEach(ele => { @@ -25,7 +25,7 @@ export async function mergeHost(origin: timer.stat.SiteRow[]): Promise<timer.sta time: 0, mergedRows: [], composition: { focus: [], time: [], run: [] }, - } satisfies timer.stat.Row + } satisfies tt4b.stat.Row } mergeResult(exist, ele) exist.mergedRows.push(ele) diff --git a/src/background/service/stat-service/remote.ts b/src/background/service/stat-service/remote.ts index 51b871729..c17ae2a39 100644 --- a/src/background/service/stat-service/remote.ts +++ b/src/background/service/stat-service/remote.ts @@ -11,9 +11,9 @@ import { BIRTHDAY, formatTimeYMD } from "@util/time" import processor from "../backup/processor" import { cvt2SiteRow } from "./common" -export async function processRemote(origin: timer.stat.SiteRow[], param?: StatCondition): Promise<timer.stat.SiteRow[]> { +export async function processRemote(origin: tt4b.stat.SiteRow[], param?: StatCondition): Promise<tt4b.stat.SiteRow[]> { // Map to merge - const originMap: Record<string, MakeRequired<timer.stat.SiteRow, 'composition'>> = {} + const originMap: Record<string, MakeRequired<tt4b.stat.SiteRow, 'composition'>> = {} origin.forEach(row => originMap[identifyStatKey(row)] = { ...row, composition: { @@ -26,7 +26,7 @@ export async function processRemote(origin: timer.stat.SiteRow[], param?: StatCo const { keys, date } = param ?? {} const keyArr = typeof keys === 'string' ? [keys] : keys const predicate = keyArr?.length - ? ({ host }: timer.core.Row) => keyArr.includes(host) + ? ({ host }: tt4b.core.Row) => keyArr.includes(host) : () => true // 1. query remote @@ -43,7 +43,7 @@ export async function processRemote(origin: timer.stat.SiteRow[], param?: StatCo return Object.values(originMap) } -function processRemoteRow(rowMap: Record<string, MakeRequired<timer.stat.SiteRow, 'composition'>>, remoteBase: timer.core.Row) { +function processRemoteRow(rowMap: Record<string, MakeRequired<tt4b.stat.SiteRow, 'composition'>>, remoteBase: tt4b.core.Row) { const row = cvt2SiteRow(remoteBase) const key = identifyStatKey(row) let exist = rowMap[key] @@ -57,7 +57,7 @@ function processRemoteRow(rowMap: Record<string, MakeRequired<timer.stat.SiteRow time: [], run: [], }, - } satisfies MakeRequired<timer.stat.SiteRow, 'composition'>) + } satisfies MakeRequired<tt4b.stat.SiteRow, 'composition'>) const { focus = 0, time = 0, run = 0, cid = '', cname } = row diff --git a/src/background/service/throttler/period-throttler.ts b/src/background/service/throttler/period-throttler.ts index 9fa74fb21..aeedd63c7 100644 --- a/src/background/service/throttler/period-throttler.ts +++ b/src/background/service/throttler/period-throttler.ts @@ -2,13 +2,13 @@ import periodDatabase from '@db/period-database' import { calculate } from '../components/period-calculator' import { FirefoxThrottler } from './firefox-throttler' -class PeriodThrottler extends FirefoxThrottler<timer.period.Result> { +class PeriodThrottler extends FirefoxThrottler<tt4b.period.Result> { public add(timestamp: number, milliseconds: number): void { const results = calculate(timestamp, milliseconds) this.save(results) } - protected doStore(data: timer.period.Result[]): void { + protected doStore(data: tt4b.period.Result[]): void { periodDatabase.accumulate(data) } } diff --git a/src/background/service/throttler/timeline-throttler.ts b/src/background/service/throttler/timeline-throttler.ts index d816ffa51..c92d98f3e 100644 --- a/src/background/service/throttler/timeline-throttler.ts +++ b/src/background/service/throttler/timeline-throttler.ts @@ -2,18 +2,18 @@ import timelineDatabase from '@db/timeline-database' import { extractHostname } from '@util/pattern' import { FirefoxThrottler } from './firefox-throttler' -class TimelineThrottler extends FirefoxThrottler<timer.timeline.Tick> { - public saveEvent(ev: timer.timeline.Event) { +class TimelineThrottler extends FirefoxThrottler<tt4b.timeline.Tick> { + public saveEvent(ev: tt4b.timeline.Event) { const { start, end, url } = ev const { host } = extractHostname(url) if (!host) return const durations = split2Durations(start, end) - const ticks: timer.timeline.Tick[] = durations.map(([start, duration]) => ({ start, duration, host })) + const ticks: tt4b.timeline.Tick[] = durations.map(([start, duration]) => ({ start, duration, host })) this.save(ticks) } - protected doStore(data: timer.timeline.Tick[]): void { + protected doStore(data: tt4b.timeline.Tick[]): void { timelineDatabase.batchSave(data) } } diff --git a/src/background/service/timeline-service.ts b/src/background/service/timeline-service.ts index 17087d644..50b941cc5 100644 --- a/src/background/service/timeline-service.ts +++ b/src/background/service/timeline-service.ts @@ -13,7 +13,7 @@ import { toMap } from '@util/array' import { CATE_NOT_SET_ID } from '@util/site' import CustomizedHostMergeRuler from './components/host-merge-ruler' -export async function listTimeline(query: timer.timeline.Query): Promise<timer.timeline.Activity[]> { +export async function listTimeline(query: tt4b.timeline.Query): Promise<tt4b.timeline.Activity[]> { const ticks = await db.select(query) const { merge } = query if (merge === 'domain') { @@ -25,14 +25,14 @@ export async function listTimeline(query: timer.timeline.Query): Promise<timer.t } } -async function mergeByDomain(ticks: timer.timeline.Tick[]): Promise<timer.timeline.Activity[]> { +async function mergeByDomain(ticks: tt4b.timeline.Tick[]): Promise<tt4b.timeline.Activity[]> { const mergeRules = await mergeDb.selectAll() const merger = new CustomizedHostMergeRuler(mergeRules) const allHosts = Array.from(new Set(ticks.map(t => t.host))) const mergedMap = toMap(allHosts, h => h, h => merger.merge(h)) const allSiteKeys = Array.from(new Set(Object.values(mergedMap))) - .map((mergedHost) => ({ type: 'merged', host: mergedHost } satisfies timer.site.SiteKey)) + .map((mergedHost) => ({ type: 'merged', host: mergedHost } satisfies tt4b.site.SiteKey)) const allSites = await siteDb.getBatch(allSiteKeys) const nameMap = toMap(allSites, s => s.host, s => s.alias) @@ -45,11 +45,11 @@ async function mergeByDomain(ticks: timer.timeline.Tick[]): Promise<timer.timeli }) } -async function mergeByCate(ticks: timer.timeline.Tick[]): Promise<timer.timeline.Activity[]> { +async function mergeByCate(ticks: tt4b.timeline.Tick[]): Promise<tt4b.timeline.Activity[]> { const cates = await cateDb.listAll() const cateNameMap = toMap(cates, c => c.id, c => c.name) const allSiteKeys = Array.from(new Set(ticks.map(t => t.host))) - .map(host => ({ type: 'normal', host } satisfies timer.site.SiteKey)) + .map(host => ({ type: 'normal', host } satisfies tt4b.site.SiteKey)) const allSites = await siteDb.getBatch(allSiteKeys) const siteCateMap = toMap(allSites, s => s.host, s => s.cate) @@ -63,9 +63,9 @@ async function mergeByCate(ticks: timer.timeline.Tick[]): Promise<timer.timeline }) } -async function fillSiteName(ticks: timer.timeline.Tick[]): Promise<timer.timeline.Activity[]> { +async function fillSiteName(ticks: tt4b.timeline.Tick[]): Promise<tt4b.timeline.Activity[]> { const allSiteKeys = Array.from(new Set(ticks.map(t => t.host))) - .map(host => ({ type: 'normal', host } satisfies timer.site.SiteKey)) + .map(host => ({ type: 'normal', host } satisfies tt4b.site.SiteKey)) const allSites = await siteDb.getBatch(allSiteKeys) const nameMap = toMap(allSites, s => s.host, s => s.alias) diff --git a/src/background/track-server/group.ts b/src/background/track-server/group.ts index 071ebec80..b8648eeac 100644 --- a/src/background/track-server/group.ts +++ b/src/background/track-server/group.ts @@ -3,7 +3,7 @@ import optionHolder from '../service/components/option-holder' const handleRemove = (group: chrome.tabGroups.TabGroup) => db.deleteByGroup(group.id) -function handleTabGroupsEnabled(option: timer.option.TrackingOption) { +function handleTabGroupsEnabled(option: tt4b.option.TrackingOption) { // Do nothing if not enabled if (!option.countTabGroup) return try { diff --git a/src/background/track-server/normal.ts b/src/background/track-server/normal.ts index 239bb14e2..0ace38c9c 100644 --- a/src/background/track-server/normal.ts +++ b/src/background/track-server/normal.ts @@ -28,7 +28,7 @@ async function handleTime(context: ItemIncContext, timeRange: [number, number], return focusTime } -export async function handleTrackTimeEvent(event: timer.core.Event, tab: ChromeTab | undefined): Promise<void> { +export async function handleTrackTimeEvent(event: tt4b.core.Event, tab: ChromeTab | undefined): Promise<void> { if (!tab) return const { id: tabId, windowId, groupId, url, active } = tab if (!url) return @@ -66,7 +66,7 @@ async function windowNotFocused(winId: number | undefined): Promise<boolean> { return !window?.focused } -async function sendLimitedMessage(items: timer.limit.Item[]) { +async function sendLimitedMessage(items: tt4b.limit.Item[]) { const tabs = await listTabs() if (!tabs.length) return for (const tab of tabs) { diff --git a/src/background/track-server/runtime.ts b/src/background/track-server/runtime.ts index 5f0b0da8b..506e203bc 100644 --- a/src/background/track-server/runtime.ts +++ b/src/background/track-server/runtime.ts @@ -17,7 +17,7 @@ function splitRunTime(start: number, end: number): Record<string, number> { const RUN_TIME_END_CACHE = new FIFOCache<number>(500) -export async function handleTrackRunTimeEvent(event: timer.core.Event, url: string | undefined): Promise<void> { +export async function handleTrackRunTimeEvent(event: tt4b.core.Event, url: string | undefined): Promise<void> { const { start, end, host } = event if (!host || !start || !end || !url) return if (whitelistHolder.contains(host, url)) return diff --git a/src/content-script/dispatcher.ts b/src/content-script/dispatcher.ts index 96f8a767f..bb19f920d 100644 --- a/src/content-script/dispatcher.ts +++ b/src/content-script/dispatcher.ts @@ -7,16 +7,16 @@ import type { AudibleChangeHandler } from './types' -type Handler<Code extends timer.tab.ReqCode> = (data: timer.tab.ReqData<Code>) => timer.tab.ResData<Code> +type Handler<Code extends tt4b.tab.ReqCode> = (data: tt4b.tab.ReqData<Code>) => tt4b.tab.ResData<Code> class Dispatcher { - private handlers: Partial<Record<timer.tab.ReqCode, Handler<timer.tab.ReqCode>>> = {} + private handlers: Partial<Record<tt4b.tab.ReqCode, Handler<tt4b.tab.ReqCode>>> = {} private audibleChangeHandlers: AudibleChangeHandler[] = [] constructor() { // Be careful!!! // Can't use await/async in callback parameter - chrome.runtime.onMessage.addListener((message: timer.tab.Request<timer.tab.ReqCode>, _, sendResponse: timer.tab.Callback<timer.tab.ReqCode>) => { + chrome.runtime.onMessage.addListener((message: tt4b.tab.Request<tt4b.tab.ReqCode>, _, sendResponse: tt4b.tab.Callback<tt4b.tab.ReqCode>) => { this.handle(message) .then(sendResponse) .catch((err: unknown) => { @@ -32,7 +32,7 @@ class Dispatcher { this.register('syncAudible', audible => void this.audibleChangeHandlers.forEach(h => h.onAudibleChange(audible))) } - register<Code extends timer.tab.ReqCode>(code: Code, handler: Handler<Code>): Dispatcher { + register<Code extends tt4b.tab.ReqCode>(code: Code, handler: Handler<Code>): Dispatcher { this.handlers[code] = handler return this } @@ -42,7 +42,7 @@ class Dispatcher { return this } - private async handle(message: timer.tab.Request<timer.tab.ReqCode>): Promise<timer.tab.Response<timer.tab.ReqCode>> { + private async handle(message: tt4b.tab.Request<tt4b.tab.ReqCode>): Promise<tt4b.tab.Response<tt4b.tab.ReqCode>> { const code = message?.code if (!code) { return { code: 'ignore' } @@ -50,8 +50,8 @@ class Dispatcher { const handler = this.handlers[code] if (!handler) return { code: 'ignore' } try { - const res = handler(message.data as timer.tab.ReqData<timer.tab.ReqCode>) - return { code: "success", data: res as timer.tab.ResData<typeof code> } + const res = handler(message.data as tt4b.tab.ReqData<tt4b.tab.ReqCode>) + return { code: "success", data: res as tt4b.tab.ResData<typeof code> } } catch (error) { const msg = error instanceof Error ? error.message : (error?.toString?.() ?? 'Unknown error') return { code: 'fail', msg } diff --git a/src/content-script/limit/modal/components/Reason.tsx b/src/content-script/limit/modal/components/Reason.tsx index 9405f3cd5..b658c16d0 100644 --- a/src/content-script/limit/modal/components/Reason.tsx +++ b/src/content-script/limit/modal/components/Reason.tsx @@ -16,7 +16,7 @@ const useDescriptions = () => { return { style, size } } -const renderBaseItems = (rule: timer.limit.Rule | undefined, url: string) => <> +const renderBaseItems = (rule: tt4b.limit.Rule | undefined, url: string) => <> <ElDescriptionsItem label={t(msg => msg.limit.item.name)} labelAlign="right"> {rule?.name ?? '-'} </ElDescriptionsItem> diff --git a/src/content-script/limit/modal/context.ts b/src/content-script/limit/modal/context.ts index ad3e0f50e..bf2fc47fa 100644 --- a/src/content-script/limit/modal/context.ts +++ b/src/content-script/limit/modal/context.ts @@ -62,4 +62,4 @@ export const provideRule = () => { provide(RULE_KEY, rule) } -export const useRule = () => inject<ShallowRef<timer.limit.Item | undefined>>(RULE_KEY) as ShallowRef<timer.limit.Item | undefined> \ No newline at end of file +export const useRule = () => inject<ShallowRef<tt4b.limit.Item | undefined>>(RULE_KEY) as ShallowRef<tt4b.limit.Item | undefined> \ No newline at end of file diff --git a/src/content-script/limit/modal/instance.ts b/src/content-script/limit/modal/instance.ts index 8ba7072ad..9ebee3115 100644 --- a/src/content-script/limit/modal/instance.ts +++ b/src/content-script/limit/modal/instance.ts @@ -29,7 +29,7 @@ function pauseAllAudio(): void { }) } -const TYPE_SORT: { [reason in timer.limit.ReasonType]: number } = { +const TYPE_SORT: { [reason in tt4b.limit.ReasonType]: number } = { PERIOD: 0, VISIT: 1, DAILY: 2, @@ -102,7 +102,7 @@ class ModalInstance implements MaskModal { this.refresh() } - removeReasonsByType(...types: timer.limit.ReasonType[]): void { + removeReasonsByType(...types: tt4b.limit.ReasonType[]): void { if (!types.length) return this.reasons = this.reasons.filter(r => !types.includes(r.type)) this.refresh() diff --git a/src/content-script/limit/processor/message-adaptor.ts b/src/content-script/limit/processor/message-adaptor.ts index eb7a2dc9c..e64d33484 100644 --- a/src/content-script/limit/processor/message-adaptor.ts +++ b/src/content-script/limit/processor/message-adaptor.ts @@ -2,7 +2,7 @@ import { trySendMsg2Runtime } from '@api/sw/common' import { hasDailyLimited, hasWeeklyLimited, matches } from "@util/limit" import type { LimitReason, ModalContext, Processor } from '../types' -const cvtItem2AddReason = (item: timer.limit.Item, delayDuration: number): LimitReason[] => { +const cvtItem2AddReason = (item: tt4b.limit.Item, delayDuration: number): LimitReason[] => { const { cond, allowDelay, id, delayCount, weeklyDelayCount } = item const reasons2Add: LimitReason[] = [] hasDailyLimited(item, delayDuration) && reasons2Add.push({ type: "DAILY", cond, allowDelay, id, delayCount }) @@ -17,7 +17,7 @@ class MessageAdaptor implements Processor { this.initRules() } - onLimitTimeMeet(items: timer.limit.Item[]): void { + onLimitTimeMeet(items: tt4b.limit.Item[]): void { if (!items.length) return items.filter(({ cond }) => matches(cond, this.context.url)) .flatMap(item => cvtItem2AddReason(item, this.delayDuration)) diff --git a/src/content-script/limit/processor/period-processor.ts b/src/content-script/limit/processor/period-processor.ts index 2048c5dd0..fe0a84bab 100644 --- a/src/content-script/limit/processor/period-processor.ts +++ b/src/content-script/limit/processor/period-processor.ts @@ -3,7 +3,7 @@ import { date2Idx } from "@util/limit" import { MILL_PER_SECOND } from "@util/time" import type { LimitReason, ModalContext, Processor } from '../types' -function processRule(rule: timer.limit.Rule, nowSeconds: number, context: ModalContext): ReturnType<typeof setTimeout>[] { +function processRule(rule: tt4b.limit.Rule, nowSeconds: number, context: ModalContext): ReturnType<typeof setTimeout>[] { const { cond, periods, id } = rule if (!periods?.length) return [] return periods.flatMap(p => { diff --git a/src/content-script/limit/processor/visit-processor.ts b/src/content-script/limit/processor/visit-processor.ts index f90df40f8..80637ccd4 100644 --- a/src/content-script/limit/processor/visit-processor.ts +++ b/src/content-script/limit/processor/visit-processor.ts @@ -5,7 +5,7 @@ import type { ModalContext, Processor } from '../types' class VisitProcessor implements Processor { private focusTime: number = 0 - private rules: timer.limit.Rule[] = [] + private rules: tt4b.limit.Rule[] = [] tracker: NormalTracker private delayCount: number = 0 @@ -19,14 +19,14 @@ class VisitProcessor implements Processor { this.initRules() } - private hasLimited(rule: timer.limit.Rule): boolean { + private hasLimited(rule: tt4b.limit.Rule): boolean { const { visitTime } = rule if (!visitTime) return false const afterDelayed = visitTime * MILL_PER_SECOND + this.delayCount * this.delayDuration * MILL_PER_MINUTE return afterDelayed < this.focusTime } - private async handleTracker({ start, end }: timer.core.Event) { + private async handleTracker({ start, end }: tt4b.core.Event) { const diff = end - start this.focusTime += diff this.rules.forEach(rule => { diff --git a/src/content-script/limit/reminder/component.ts b/src/content-script/limit/reminder/component.ts index 15244c09f..f7e36c367 100644 --- a/src/content-script/limit/reminder/component.ts +++ b/src/content-script/limit/reminder/component.ts @@ -1,5 +1,5 @@ -import { t } from "@cs/locale" import { getIconUrl } from "@api/chrome/runtime" +import { t } from "@cs/locale" const containerStyle = (dark: boolean): Partial<CSSStyleDeclaration> => ({ position: 'fixed', @@ -91,7 +91,7 @@ function createCloseBtn(dark: boolean, onClose: () => void): HTMLElement { return btn } -function createGroup(dark: boolean, data: timer.limit.ReminderInfo, onClose: () => void): HTMLDivElement { +function createGroup(dark: boolean, data: tt4b.limit.ReminderInfo, onClose: () => void): HTMLDivElement { const group = document.createElement('div') mountStyle(group, GROUP_STYLE) @@ -115,7 +115,7 @@ function createGroup(dark: boolean, data: timer.limit.ReminderInfo, onClose: () return group } -export function createComponent(dark: boolean, data: timer.limit.ReminderInfo, onClose: () => void) { +export function createComponent(dark: boolean, data: tt4b.limit.ReminderInfo, onClose: () => void) { const el = document.createElement('div') mountStyle(el, containerStyle(dark)) diff --git a/src/content-script/limit/reminder/index.ts b/src/content-script/limit/reminder/index.ts index 706bafe06..f5ac439e5 100644 --- a/src/content-script/limit/reminder/index.ts +++ b/src/content-script/limit/reminder/index.ts @@ -9,7 +9,7 @@ class Reminder { private el: HTMLElement | undefined private darkMode: boolean = false - public async show(data: timer.limit.ReminderInfo) { + public async show(data: tt4b.limit.ReminderInfo) { if (!document?.body || this.el) return await exitFullscreen() diff --git a/src/content-script/limit/types.ts b/src/content-script/limit/types.ts index bbe4dbfce..cddfcb183 100644 --- a/src/content-script/limit/types.ts +++ b/src/content-script/limit/types.ts @@ -1,9 +1,9 @@ export type LimitReason = - & RequiredPick<timer.limit.Rule, 'id' | 'cond'> - & PartialPick<timer.limit.Item, 'delayCount' | 'allowDelay'> + & RequiredPick<tt4b.limit.Rule, 'id' | 'cond'> + & PartialPick<tt4b.limit.Item, 'delayCount' | 'allowDelay'> & { - type: timer.limit.ReasonType + type: tt4b.limit.ReasonType getVisitTime?: () => number } @@ -12,7 +12,7 @@ export interface MaskModal { addReason(...reasons: LimitReason[]): void removeReason(...reasons: LimitReason[]): void - removeReasonsByType(...types: timer.limit.ReasonType[]): void + removeReasonsByType(...types: tt4b.limit.ReasonType[]): void addDelayHandler(handler: NoArgCallback): void } diff --git a/src/content-script/tracker/normal/index.ts b/src/content-script/tracker/normal/index.ts index d70640d1f..bf529f4ee 100644 --- a/src/content-script/tracker/normal/index.ts +++ b/src/content-script/tracker/normal/index.ts @@ -38,7 +38,7 @@ class TrackContext { } type NormalTrackerOption = { - onReport: (ev: timer.core.Event) => Promise<void> + onReport: (ev: tt4b.core.Event) => Promise<void> onResume?: (reason: StateChangeReason) => void onPause?: (reason: StateChangeReason) => void } @@ -79,7 +79,7 @@ export default class NormalTracker implements AudibleChangeHandler { return } - const data: timer.core.Event = { + const data: tt4b.core.Event = { start: lastTime, end: now, ignoreTabCheck: !!ignoreTabCheck diff --git a/src/content-script/tracker/run-time.ts b/src/content-script/tracker/run-time.ts index 854f0ca1c..fbbe42ec0 100644 --- a/src/content-script/tracker/run-time.ts +++ b/src/content-script/tracker/run-time.ts @@ -29,7 +29,7 @@ class RunTimeTracker { try { if (this.host) { - const event: timer.core.Event = { + const event: tt4b.core.Event = { start: lastTime, end: now, ignoreTabCheck: false, diff --git a/src/i18n/element.ts b/src/i18n/element.ts index a67f485be..036c778d2 100644 --- a/src/i18n/element.ts +++ b/src/i18n/element.ts @@ -2,7 +2,7 @@ import { type Language } from "element-plus/es/locale" import { locale, t } from "." import calendarMessages from "./message/common/calendar" -const LOCALES: Record<timer.Locale, () => Promise<{ default: Language }>> = { +const LOCALES: Record<tt4b.Locale, () => Promise<{ default: Language }>> = { zh_CN: () => import('element-plus/es/locale/lang/zh-cn'), zh_TW: () => import('element-plus/es/locale/lang/zh-tw'), en: () => import('element-plus/es/locale/lang/en'), diff --git a/src/i18n/i18n.d.ts b/src/i18n/i18n.d.ts index cd2e6cbd6..f16415931 100644 --- a/src/i18n/i18n.d.ts +++ b/src/i18n/i18n.d.ts @@ -1,9 +1,9 @@ type RequiredMessages<M> = { - [locale in timer.RequiredLocale]: M + [locale in tt4b.RequiredLocale]: M } type OptionalMessages<M> = { - [locale in timer.OptionalLocale]?: EmbeddedPartial<M> + [locale in tt4b.OptionalLocale]?: EmbeddedPartial<M> } type Messages<M> = RequiredMessages<M> & OptionalMessages<M> diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 9999e1435..02b60b0a3 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -13,17 +13,17 @@ import { ALL_LOCALES as _ALL_LOCALES } from "./message/merge" /** * Not to import this one if not necessary */ -export type FakedLocale = timer.Locale +export type FakedLocale = tt4b.Locale /** * @since 0.2.2 */ -const FEEDBACK_LOCALE: timer.Locale = "en" +const FEEDBACK_LOCALE: tt4b.Locale = "en" -export const ALL_LOCALES: timer.Locale[] = _ALL_LOCALES +export const ALL_LOCALES: tt4b.Locale[] = _ALL_LOCALES // Standardize the locale code according to the Chrome locale code -const chrome2I18n: { [key: string]: timer.Locale } = { +const chrome2I18n: { [key: string]: tt4b.Locale } = { 'zh': 'zh_CN', 'zh-CN': "zh_CN", 'zh-TW': "zh_TW", @@ -44,7 +44,7 @@ const chrome2I18n: { [key: string]: timer.Locale } = { 'pl': 'pl', } -const translationChrome2I18n: { [key: string]: timer.TranslatingLocale } = { +const translationChrome2I18n: { [key: string]: tt4b.TranslatingLocale } = { ko: 'ko', it: 'it', sv: 'sv', @@ -68,18 +68,18 @@ const translationChrome2I18n: { [key: string]: timer.TranslatingLocale } = { * * They are different, so translate */ -function chromeLocale2ExtensionLocale(chromeLocale: string): timer.Locale { +function chromeLocale2ExtensionLocale(chromeLocale: string): tt4b.Locale { if (!chromeLocale) return FEEDBACK_LOCALE const code2 = chromeLocale.substring(0, 2) return chrome2I18n[chromeLocale] ?? chrome2I18n[code2] ?? FEEDBACK_LOCALE } -const browserUiLocale: timer.Locale = chromeLocale2ExtensionLocale(getUILanguage()) +const browserUiLocale: tt4b.Locale = chromeLocale2ExtensionLocale(getUILanguage()) /** * @since 0.9.0 */ -export const localeSameAsBrowser: timer.Locale = browserUiLocale +export const localeSameAsBrowser: tt4b.Locale = browserUiLocale /** * @since 1.5.0 @@ -95,16 +95,16 @@ export function isTranslatingLocale(): boolean { /** * Real locale with locale option */ -export let locale: timer.Locale = browserUiLocale +export let locale: tt4b.Locale = browserUiLocale -export function cvtOption2Locale(option: timer.option.LocaleOption): timer.Locale { +export function cvtOption2Locale(option: tt4b.option.LocaleOption): tt4b.Locale { if (!option || option === 'default') { return chromeLocale2ExtensionLocale(getUILanguage()) } return option } -export function handleLocaleOption(option: timer.option.LocaleOption) { +export function handleLocaleOption(option: tt4b.option.LocaleOption) { locale = cvtOption2Locale(option) setLocale(locale) @@ -123,7 +123,7 @@ export async function initLocale() { function tryGetOriginalI18nVal<MessageType>( messages: Messages<MessageType>, keyPath: I18nKey<MessageType>, - specLocale?: timer.Locale + specLocale?: tt4b.Locale ) { try { return keyPath(messages[specLocale || locale] as MessageType) @@ -135,7 +135,7 @@ function tryGetOriginalI18nVal<MessageType>( function getI18nVal<MessageType>( messages: Messages<MessageType>, keyPath: I18nKey<MessageType>, - specLocale?: timer.Locale + specLocale?: tt4b.Locale ): string { const result = tryGetOriginalI18nVal(messages, keyPath, specLocale) || keyPath(messages[FEEDBACK_LOCALE] as MessageType) @@ -158,7 +158,7 @@ function fillWithParam(result: string, param: { [key: string]: string | number } return result } -export function t<MessageType>(messages: Messages<MessageType>, props: TranslateProps<MessageType>, specLocale?: timer.Locale): string { +export function t<MessageType>(messages: Messages<MessageType>, props: TranslateProps<MessageType>, specLocale?: tt4b.Locale): string { const { key, param } = props const result: string = getI18nVal(messages, key, specLocale) return param ? fillWithParam(result, param) : result diff --git a/src/i18n/message/app/data-manage.ts b/src/i18n/message/app/data-manage.ts index b1526dafa..c1abbec0c 100644 --- a/src/i18n/message/app/data-manage.ts +++ b/src/i18n/message/app/data-manage.ts @@ -34,7 +34,7 @@ export type DataManageMessage = { fileNotSelected: string conflictNotSelected: string } & { - [resolution in timer.imported.ConflictResolution]: string + [resolution in tt4b.imported.ConflictResolution]: string } exportData: string restoreData: string diff --git a/src/i18n/message/app/option.ts b/src/i18n/message/app/option.ts index 842616ced..898d193af 100644 --- a/src/i18n/message/app/option.ts +++ b/src/i18n/message/app/option.ts @@ -60,13 +60,13 @@ export type OptionMessage = { prompt: string reminder: string level: { - [level in timer.limit.RestrictionLevel]: string + [level in tt4b.limit.RestrictionLevel]: string } & { label: string passwordLabel: string verificationLabel: string verificationDifficulty: { - [diff in timer.limit.VerificationDifficulty]: string + [diff in tt4b.limit.VerificationDifficulty]: string } strictTitle: string strictContent: string @@ -84,11 +84,11 @@ export type OptionMessage = { type: string client: string meta: { - [type in timer.backup.Type]: { + [type in tt4b.backup.Type]: { authInfo?: string } } & { - [type in Extract<timer.backup.Type, 'obsidian_local_rest_api'>]: { + [type in Extract<tt4b.backup.Type, 'obsidian_local_rest_api'>]: { endpointInfo: string } } diff --git a/src/i18n/message/app/site-manage.ts b/src/i18n/message/app/site-manage.ts index 3b4ba75c0..7f274756d 100644 --- a/src/i18n/message/app/site-manage.ts +++ b/src/i18n/message/app/site-manage.ts @@ -16,7 +16,7 @@ export type SiteManageMessage = { cate: string icon: string } - type: Record<timer.site.Type, Record<'name' | 'info', string>> + type: Record<tt4b.site.Type, Record<'name' | 'info', string>> cate: { name: string relatedMsg: string diff --git a/src/i18n/message/app/time-format.ts b/src/i18n/message/app/time-format.ts index 9a8c456e7..f726bea85 100644 --- a/src/i18n/message/app/time-format.ts +++ b/src/i18n/message/app/time-format.ts @@ -7,7 +7,7 @@ import resource from './time-format-resource.json' -export type TimeFormatMessage = { [key in timer.app.TimeFormat]: string } +export type TimeFormatMessage = { [key in tt4b.app.TimeFormat]: string } const _default: Messages<TimeFormatMessage> = resource diff --git a/src/i18n/message/common/item.ts b/src/i18n/message/common/item.ts index f01c084f8..755c0ac6b 100644 --- a/src/i18n/message/common/item.ts +++ b/src/i18n/message/common/item.ts @@ -19,7 +19,7 @@ export type ItemMessage = { analysis: string } } & { - [dimension in timer.core.Dimension]: string + [dimension in tt4b.core.Dimension]: string } const _default: Messages<ItemMessage> = resource diff --git a/src/i18n/message/common/locale.ts b/src/i18n/message/common/locale.ts index 7a60c889e..012af62f6 100644 --- a/src/i18n/message/common/locale.ts +++ b/src/i18n/message/common/locale.ts @@ -22,9 +22,9 @@ type Meta = MetaBase & { */ type LocaleMessages = { - [locale in timer.Locale]: Meta + [locale in tt4b.Locale]: Meta } & { - [translatingLocale in timer.TranslatingLocale]: MetaBase + [translatingLocale in tt4b.TranslatingLocale]: MetaBase } const _default: LocaleMessages = resource diff --git a/src/i18n/message/common/shared.ts b/src/i18n/message/common/shared.ts index a7678c2bf..2d19e3169 100644 --- a/src/i18n/message/common/shared.ts +++ b/src/i18n/message/common/shared.ts @@ -3,7 +3,7 @@ import resource from "./shared-resource.json" export type SharedMessage = { merge: { mergeBy: string - mergeMethod: Record<timer.stat.MergeMethod, string> & { notMerge: string } + mergeMethod: Record<tt4b.stat.MergeMethod, string> & { notMerge: string } } cate: { notSet: string diff --git a/src/i18n/message/merge.ts b/src/i18n/message/merge.ts index 5ce41cec3..92157272f 100644 --- a/src/i18n/message/merge.ts +++ b/src/i18n/message/merge.ts @@ -1,4 +1,4 @@ -const ALL_LOCALE_VALIDATOR: { [locale in timer.Locale]: 0 } = { +const ALL_LOCALE_VALIDATOR: { [locale in tt4b.Locale]: 0 } = { en: 0, zh_CN: 0, ja: 0, @@ -15,12 +15,12 @@ const ALL_LOCALE_VALIDATOR: { [locale in timer.Locale]: 0 } = { it: 0, } -export const ALL_LOCALES: timer.Locale[] = Object.keys(ALL_LOCALE_VALIDATOR) as timer.Locale[] +export const ALL_LOCALES: tt4b.Locale[] = Object.keys(ALL_LOCALE_VALIDATOR) as tt4b.Locale[] export type MessageRoot<T = any> = { [key in keyof T]: Messages<T[key]> } export function merge<T>(messageRoot: MessageRoot<T>): Required<Messages<T>> { - const result: Partial<Record<timer.Locale, T & EmbeddedPartial<T>>> = {} + const result: Partial<Record<tt4b.Locale, T & EmbeddedPartial<T>>> = {} ALL_LOCALES.forEach(locale => { const message = messageOfRoot(locale, messageRoot) result[locale] = message as T & EmbeddedPartial<T> @@ -28,8 +28,8 @@ export function merge<T>(messageRoot: MessageRoot<T>): Required<Messages<T>> { return result as Required<Messages<T>> } -function messageOfRoot<T>(locale: timer.Locale, messageRoot: MessageRoot<T>): T { - const entries: [string, any][] = Object.entries(messageRoot).map(([key, val]) => ([key, (val as Record<timer.Locale, T & EmbeddedPartial<T>>)[locale]])) +function messageOfRoot<T>(locale: tt4b.Locale, messageRoot: MessageRoot<T>): T { + const entries: [string, any][] = Object.entries(messageRoot).map(([key, val]) => ([key, (val as Record<tt4b.Locale, T & EmbeddedPartial<T>>)[locale]])) const result = Object.fromEntries(entries) as T return result } \ No newline at end of file diff --git a/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx b/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx index 0393addef..10c39fa19 100644 --- a/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx +++ b/src/pages/app/components/Analysis/components/AnalysisFilter/TargetSelect.tsx @@ -44,7 +44,7 @@ type TargetItem = AnalysisTarget & { label: string } -const fetchItems = async (categories: timer.site.Cate[]): Promise<[siteItems: TargetItem[], cateItems: TargetItem[]]> => { +const fetchItems = async (categories: tt4b.site.Cate[]): Promise<[siteItems: TargetItem[], cateItems: TargetItem[]]> => { // 1. query categories const cateItems: TargetItem[] = categories.map(({ id, name }) => ({ type: 'cate', key: id, label: name })) @@ -62,7 +62,7 @@ const SiteTypeTag: FunctionalComponent<{ text: string }> = ({ text }) => ( </span> ) -const SiteOption = defineComponent<{ value: timer.site.SiteInfo }>(props => { +const SiteOption = defineComponent<{ value: tt4b.site.SiteInfo }>(props => { const alias = computed(() => props.value.alias) const type = computed(() => props.value.type) const mergedText = t(msg => msg.analysis.common.merged) diff --git a/src/pages/app/components/Analysis/components/Summary/Calendar/Wrapper.ts b/src/pages/app/components/Analysis/components/Summary/Calendar/Wrapper.ts index 3a69c9b08..7ae74b138 100644 --- a/src/pages/app/components/Analysis/components/Summary/Calendar/Wrapper.ts +++ b/src/pages/app/components/Analysis/components/Summary/Calendar/Wrapper.ts @@ -76,7 +76,7 @@ function getXAxisLabelMap(data: _Value[]): { [x: string]: string } { function optionOf( data: _Value[], weekDays: string[], - format: timer.app.TimeFormat, domWidth: number, + format: tt4b.app.TimeFormat, domWidth: number, ): EcOption { const xAxisLabelMap = getXAxisLabelMap(data) const axisTextColor = getSecondaryTextColor() @@ -139,8 +139,8 @@ function optionOf( } export type BizOption = { - rows: timer.stat.Row[] - timeFormat: timer.app.TimeFormat + rows: tt4b.stat.Row[] + timeFormat: tt4b.app.TimeFormat } class Wrapper extends EchartsWrapper<BizOption, EcOption> { diff --git a/src/pages/app/components/Analysis/components/Summary/TargetInfo.tsx b/src/pages/app/components/Analysis/components/Summary/TargetInfo.tsx index 9565f8639..c9b33c7da 100644 --- a/src/pages/app/components/Analysis/components/Summary/TargetInfo.tsx +++ b/src/pages/app/components/Analysis/components/Summary/TargetInfo.tsx @@ -33,7 +33,7 @@ const SUBTITLE_STYLE: StyleValue = { margin: 0, } -const SiteInfo: FunctionalComponent<{ site: timer.site.SiteInfo }> = ({ site }) => { +const SiteInfo: FunctionalComponent<{ site: tt4b.site.SiteInfo }> = ({ site }) => { const { iconUrl, alias } = site const label = labelOfHostInfo(site) const [title, subtitle] = alias ? [alias, label] : [label] diff --git a/src/pages/app/components/Analysis/components/Summary/index.tsx b/src/pages/app/components/Analysis/components/Summary/index.tsx index dfa555b71..5e1aa7034 100644 --- a/src/pages/app/components/Analysis/components/Summary/index.tsx +++ b/src/pages/app/components/Analysis/components/Summary/index.tsx @@ -22,7 +22,7 @@ type Summary = { firstDay?: string } -function computeSummary(target: AnalysisTarget | undefined, rows: timer.stat.Row[]): Summary | undefined { +function computeSummary(target: AnalysisTarget | undefined, rows: tt4b.stat.Row[]): Summary | undefined { if (!target) return undefined const summary: Summary = { focus: 0, visit: 0, day: 0 } diff --git a/src/pages/app/components/Analysis/components/Trend/context.ts b/src/pages/app/components/Analysis/components/Trend/context.ts index 4c9963fab..33b79937b 100644 --- a/src/pages/app/components/Analysis/components/Trend/context.ts +++ b/src/pages/app/components/Analysis/components/Trend/context.ts @@ -37,7 +37,7 @@ type IndicatorSet = Record<DimensionType, { type SourceParam = { dateRange: [Date, Date] - rows: timer.stat.Row[] + rows: tt4b.stat.Row[] } type EffectParam = { @@ -48,9 +48,9 @@ type EffectParam = { } function computeIndicatorSet( - rows: timer.stat.Row[], + rows: tt4b.stat.Row[], dateRange: [Date, Date] | undefined, -): [IndicatorSet | undefined, Record<string, timer.stat.Row | undefined>] { +): [IndicatorSet | undefined, Record<string, tt4b.stat.Row | undefined>] { const [start, end] = dateRange || [] const allDates = start && end ? getAllDatesBetween(start, end) : [] if (!rows) { @@ -67,7 +67,7 @@ function computeIndicatorSet( focusMax = visitMax = { date: undefined, value: undefined } activeDay = focusTotal = visitTotal = 0 - const fullPeriodRow: Record<string, timer.stat.Row> = {} + const fullPeriodRow: Record<string, tt4b.stat.Row> = {} allDates.forEach(date => { const row = periodRowMap[date] if (!row) return diff --git a/src/pages/app/components/Analysis/context.ts b/src/pages/app/components/Analysis/context.ts index e6385ae98..a9bdebcce 100644 --- a/src/pages/app/components/Analysis/context.ts +++ b/src/pages/app/components/Analysis/context.ts @@ -15,8 +15,8 @@ import type { AnalysisTarget } from "./types" type Context = { target: Ref<AnalysisTarget | undefined> - timeFormat: Ref<timer.app.TimeFormat> - rows: Ref<timer.stat.Row[]> + timeFormat: Ref<tt4b.app.TimeFormat> + rows: Ref<tt4b.stat.Row[]> } function parseQuery(): AnalysisTarget | undefined { @@ -30,7 +30,7 @@ function parseQuery(): AnalysisTarget | undefined { return undefined } -async function queryRows(target: AnalysisTarget | undefined): Promise<(timer.stat.CateRow | timer.stat.SiteRow)[]> { +async function queryRows(target: AnalysisTarget | undefined): Promise<(tt4b.stat.CateRow | tt4b.stat.SiteRow)[]> { const { key, type } = target ?? {} if (!key) return [] @@ -50,7 +50,7 @@ const NAMESPACE = 'siteAnalysis' export const initAnalysis = () => { const target = ref(parseQuery()) - const [cachedFormat, setFormatCache] = useLocalStorage<timer.app.TimeFormat>('analysis_timeFormat') + const [cachedFormat, setFormatCache] = useLocalStorage<tt4b.app.TimeFormat>('analysis_timeFormat') const timeFormat = ref(cachedFormat ?? 'default') watch(timeFormat, setFormatCache) diff --git a/src/pages/app/components/Analysis/types.d.ts b/src/pages/app/components/Analysis/types.d.ts index 72e828e4b..56fbcd80b 100644 --- a/src/pages/app/components/Analysis/types.d.ts +++ b/src/pages/app/components/Analysis/types.d.ts @@ -1,6 +1,6 @@ export type AnalysisTarget = { type: 'site' - key: timer.site.SiteInfo + key: tt4b.site.SiteInfo } | { type: 'cate' key: number diff --git a/src/pages/app/components/Analysis/util.ts b/src/pages/app/components/Analysis/util.ts index 9c8b7e0f6..28ca91b89 100644 --- a/src/pages/app/components/Analysis/util.ts +++ b/src/pages/app/components/Analysis/util.ts @@ -11,7 +11,7 @@ import type { ValueFormatter } from '../common/kanban/types' /** * Transfer host info to label */ -export function labelOfHostInfo(site: timer.site.SiteKey | undefined): string { +export function labelOfHostInfo(site: tt4b.site.SiteKey | undefined): string { if (!site) return '' const { host, type } = site if (!host) return '' diff --git a/src/pages/app/components/Dashboard/components/Indicator.tsx b/src/pages/app/components/Dashboard/components/Indicator.tsx index f8f7fb126..5f42fca1c 100644 --- a/src/pages/app/components/Dashboard/components/Indicator.tsx +++ b/src/pages/app/components/Dashboard/components/Indicator.tsx @@ -33,7 +33,7 @@ function calculateInstallDays(installTime: Date, now: Date): number { return Math.round(deltaMills / MILL_PER_DAY) } -function calcBusiestClock(rows: timer.period.Row[]): number | undefined { +function calcBusiestClock(rows: tt4b.period.Row[]): number | undefined { const map = groupBy(rows, ({ startTime }) => startTime - getStartOfDay(startTime), list => sum(list.map(e => e.milliseconds)) diff --git a/src/pages/app/components/Dashboard/components/MonthOnMonth/index.tsx b/src/pages/app/components/Dashboard/components/MonthOnMonth/index.tsx index 57ed4c3c7..5cc854b16 100644 --- a/src/pages/app/components/Dashboard/components/MonthOnMonth/index.tsx +++ b/src/pages/app/components/Dashboard/components/MonthOnMonth/index.tsx @@ -17,7 +17,7 @@ type Row = { total: number } -const cvtRow = (rows: timer.stat.Row[], start: Date, end: Date): Row[] => { +const cvtRow = (rows: tt4b.stat.Row[], start: Date, end: Date): Row[] => { const groupByDate = groupBy(rows, r => r.date, l => sum(l.map(e => e.focus ?? 0))) const iterator = new DateIterator(start, end) const result: Row[] = [] diff --git a/src/pages/app/components/Dashboard/components/Timeline/Chart/Wrapper.ts b/src/pages/app/components/Dashboard/components/Timeline/Chart/Wrapper.ts index 99e24ec63..a3bb3ccb4 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/Chart/Wrapper.ts +++ b/src/pages/app/components/Dashboard/components/Timeline/Chart/Wrapper.ts @@ -15,8 +15,8 @@ import { graphic } from "echarts/core" import { formatYAxis } from '../common' export type BizData = { - activities: timer.timeline.Activity[] - merge: timer.timeline.MergeMethod + activities: tt4b.timeline.Activity[] + merge: tt4b.timeline.MergeMethod dates: string[] } @@ -69,7 +69,7 @@ const formatDuration = (duration: number): string => { const LEGEND_WIDTH = 180 -const collectLegends = (activities: timer.timeline.Activity[], merge: timer.timeline.MergeMethod): LegendInfo[] => { +const collectLegends = (activities: tt4b.timeline.Activity[], merge: tt4b.timeline.MergeMethod): LegendInfo[] => { const colors = getSeriesPalette() const colorLen = colors.length || 1 @@ -87,6 +87,13 @@ const collectLegends = (activities: timer.timeline.Activity[], merge: timer.time } satisfies LegendInfo)) } +type Cartesian2DCoordSys = { + x: number + y: number + width: number + height: number +} + const renderItem: CustomSeriesRenderItem = (params, api) => { const categoryIndex = api.value(0) const [sX = 0, sY = 0] = api.coord([api.value(1), categoryIndex]) @@ -150,7 +157,7 @@ const generateSeries = (biz: BizData, legendColors: Record<string, string>): EcO }) } -const calcDataZoomDefaultRange = (activities: timer.timeline.Activity[]): [start: number | undefined, end: number | undefined] => { +const calcDataZoomDefaultRange = (activities: tt4b.timeline.Activity[]): [start: number | undefined, end: number | undefined] => { if (!activities.length) return [undefined, undefined] let min = activities.map(a => a.start).reduce((a, b) => b < a ? b : a) let max = activities.map(({ start, duration }) => start + duration).reduce((a, b) => b > a ? b : a) diff --git a/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx b/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx index c4f1bc178..a259ff9db 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx +++ b/src/pages/app/components/Dashboard/components/Timeline/Chart/index.tsx @@ -16,7 +16,7 @@ import { type Component, computed, defineComponent } from "vue" import { TIMELINE_DAY_COUNT, useTimelineContext } from '../context' import Wrapper, { BizData, EcOption } from './Wrapper' -const CHART_CONFIG: Record<timer.timeline.MergeMethod, Component> = { +const CHART_CONFIG: Record<tt4b.timeline.MergeMethod, Component> = { none: Files, domain: Link, cate: Collection, @@ -74,7 +74,7 @@ const TimelineChart = defineComponent<{}>(() => { <IconRadioGroup size="small" modelValue={merge.value} - onChange={val => setMerge(val as timer.timeline.MergeMethod)} + onChange={val => setMerge(val as tt4b.timeline.MergeMethod)} options={Object.entries(CHART_CONFIG).map(([value, icon]) => ({ value, icon }))} /> </Flex> diff --git a/src/pages/app/components/Dashboard/components/Timeline/Summary.tsx b/src/pages/app/components/Dashboard/components/Timeline/Summary.tsx index 366a21562..2831e44bc 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/Summary.tsx +++ b/src/pages/app/components/Dashboard/components/Timeline/Summary.tsx @@ -15,9 +15,9 @@ const defaultResult = (): { busy: number, focus: number } => ({ focus: 1, }) -const computeSessionScore = (activities: timer.timeline.Activity[], hourCount: number) => { +const computeSessionScore = (activities: tt4b.timeline.Activity[], hourCount: number) => { let continuousSessions = 0 - let currentSession: timer.timeline.Activity[] = [] + let currentSession: tt4b.timeline.Activity[] = [] activities.sort((a, b) => a.start - b.start).forEach(current => { const prev = currentSession[currentSession.length - 1] @@ -42,7 +42,7 @@ const computeSessionScore = (activities: timer.timeline.Activity[], hourCount: n return Math.min(sessionDensity, MAX_SCORE) } -const analyze = (activities: timer.timeline.Activity[]): { busy: number, focus: number } => { +const analyze = (activities: tt4b.timeline.Activity[]): { busy: number, focus: number } => { if (!activities.length) return defaultResult() const minTime = activities.map(t => t.start).sort((a, b) => a - b)[0]! diff --git a/src/pages/app/components/Dashboard/components/Timeline/context.ts b/src/pages/app/components/Dashboard/components/Timeline/context.ts index 638027f40..66f6bd458 100644 --- a/src/pages/app/components/Dashboard/components/Timeline/context.ts +++ b/src/pages/app/components/Dashboard/components/Timeline/context.ts @@ -13,15 +13,15 @@ const NAMESPACE = 'dashboard-timeline' type ContextValue = { dates: string[] - activities: ShallowRef<timer.timeline.Activity[]> - merge: ShallowRef<timer.timeline.MergeMethod> - setMerge: ArgCallback<timer.timeline.MergeMethod> + activities: ShallowRef<tt4b.timeline.Activity[]> + merge: ShallowRef<tt4b.timeline.MergeMethod> + setMerge: ArgCallback<tt4b.timeline.MergeMethod> } export const initTimelineContext = () => { const start = getStartOfDay(Date.now() - MILL_PER_DAY * (TIMELINE_DAY_COUNT - 1)) const dates = getAllDatesBetween(new Date(start), new Date(), formatYAxis) - const [merge, setMerge] = useState<timer.timeline.MergeMethod>('none') + const [merge, setMerge] = useState<tt4b.timeline.MergeMethod>('none') const { data: activities } = useRequest( () => sendMsg2Runtime('timeline.list', { start, merge: merge.value }), { diff --git a/src/pages/app/components/Dashboard/components/TopKVisit/context.ts b/src/pages/app/components/Dashboard/components/TopKVisit/context.ts index d1def2c17..7291f59ca 100644 --- a/src/pages/app/components/Dashboard/components/TopKVisit/context.ts +++ b/src/pages/app/components/Dashboard/components/TopKVisit/context.ts @@ -35,7 +35,7 @@ export const initProvider = () => { const { data: value } = useRequest(async () => { const now = new Date() const startTime: Date = new Date(now.getTime() - MILL_PER_DAY * filter.dayNum) - const query: timer.stat.SiteQuery = { + const query: tt4b.stat.SiteQuery = { date: cvtDateRange2Str([startTime, now]), sortKey: "time", sortDirection: 'DESC', diff --git a/src/pages/app/components/DataManage/ClearPanel/index.tsx b/src/pages/app/components/DataManage/ClearPanel/index.tsx index f157f7408..d025f4b35 100644 --- a/src/pages/app/components/DataManage/ClearPanel/index.tsx +++ b/src/pages/app/components/DataManage/ClearPanel/index.tsx @@ -25,7 +25,7 @@ type ClearFilterRanges = { timeRange: [number, number?] } -function buildClearStatQuery(option: FilterOption): (timer.stat.SiteQuery & timer.stat.GroupQuery) | undefined { +function buildClearStatQuery(option: FilterOption): (tt4b.stat.SiteQuery & tt4b.stat.GroupQuery) | undefined { const param = checkParam(option) if (!param) return undefined const { date } = option diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts b/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts index 08a13e296..87846e579 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/processor.ts @@ -17,8 +17,8 @@ function throwError<T>(): T { throw new Error("Failed to parse, please check your file or contact the author via " + AUTHOR_EMAIL) } -export async function parseFile(ext: OtherExtension, file: File): Promise<timer.imported.Data> { - let rows: timer.imported.Row[] = [] +export async function parseFile(ext: OtherExtension, file: File): Promise<tt4b.imported.Data> { + let rows: tt4b.imported.Row[] = [] let focus = false let time = false if (ext === 'web_activity_time_tracker') { @@ -36,11 +36,11 @@ export async function parseFile(ext: OtherExtension, file: File): Promise<timer. return { rows, focus, time } } -async function parseWebActivityTimeTracker(file: File): Promise<[timer.imported.Row[], time: boolean]> { +async function parseWebActivityTimeTracker(file: File): Promise<[tt4b.imported.Row[], time: boolean]> { const text = await file.text() if (isCsvFile(file)) { const lines = text.split('\n').map(line => line.trim()).filter(line => !!line).splice(1) - const rows: timer.imported.Row[] = lines.map(line => { + const rows: tt4b.imported.Row[] = lines.map(line => { const [host, date, seconds] = line.split(',').map(cell => cell.trim()) if (!host || !date || (!seconds && seconds !== '0')) return throwError() const [year, month, day] = date?.split('/') ?? [] @@ -73,7 +73,7 @@ type WattJsonItem = { } const parseWattJsonFile = (fileContent: string) => { - const rows: timer.imported.Row[] = [] + const rows: tt4b.imported.Row[] = [] const data = JSON.parse(fileContent) as WattJsonItem[] data.forEach(({ url: host, days }) => { if (!host) throw new Error("Invalid item without url") @@ -113,13 +113,13 @@ type WebtimeTrackerBackup = { const WEBTIME_TRACKER_DATE_REG = /(\d{2})-(\d{2})-\d{2}/ const cvtWebtimeTrackerDate = (date: string): string | undefined => WEBTIME_TRACKER_DATE_REG.test(date) ? date.split('-').join('') : undefined -async function parseWebtimeTracker(file: File): Promise<timer.imported.Row[]> { +async function parseWebtimeTracker(file: File): Promise<tt4b.imported.Row[]> { const text = await file.text() if (isJsonFile(file)) { // JSON file by backup const data = JSON.parse(text) as WebtimeTrackerBackup const domains = data?.content?.domains || {} - const rows: timer.imported.Row[] = Object.entries(domains) + const rows: tt4b.imported.Row[] = Object.entries(domains) .flatMap( ([host, value]) => Object.entries(value?.days || {}) .map(([date, item]) => [host, cvtWebtimeTrackerDate(date), item?.seconds] as [string, string, number]) @@ -129,12 +129,12 @@ async function parseWebtimeTracker(file: File): Promise<timer.imported.Row[]> { host, date, focus: seconds * MILL_PER_SECOND - } as timer.imported.Row)) + } as tt4b.imported.Row)) return rows } else if (isCsvFile(file)) { const lines = text.split('\n').map(line => line.trim()).filter(line => !!line) const colHeaders = lines[0]?.split(',') ?? [] - const rows: timer.imported.Row[] = [] + const rows: tt4b.imported.Row[] = [] lines.slice(1).forEach(line => { const cells = line.split(',') const host = cells[0] @@ -179,7 +179,7 @@ function parseHistoryTrendsUnlimitedLine(line: string, data: { [dateAndHost: str } } -async function parseHistoryTrendsUnlimited(file: File): Promise<timer.imported.Row[]> { +async function parseHistoryTrendsUnlimited(file: File): Promise<tt4b.imported.Row[]> { const text = await file.text() if (isTsvFile(file)) { const lines = text.split('\n').map(line => line.trim()).filter(line => !!line) @@ -188,7 +188,7 @@ async function parseHistoryTrendsUnlimited(file: File): Promise<timer.imported.R return Object.entries(dailyVisits).map(([dateAndHost, time]) => { const date = dateAndHost.substring(0, 8) const host = dateAndHost.substring(8) - return { date, host, time, focus: 0 } satisfies timer.imported.Row + return { date, host, time, focus: 0 } satisfies tt4b.imported.Row }) } throw new Error("Invalid file format") diff --git a/src/pages/app/components/DataManage/Migration/ImportOtherButton/types.ts b/src/pages/app/components/DataManage/Migration/ImportOtherButton/types.ts index 2c31cae13..d1abbd52c 100644 --- a/src/pages/app/components/DataManage/Migration/ImportOtherButton/types.ts +++ b/src/pages/app/components/DataManage/Migration/ImportOtherButton/types.ts @@ -1,8 +1,8 @@ export type ImportForm = { ext: OtherExtension file?: File - data: timer.imported.Data - resolution?: timer.imported.ConflictResolution + data: tt4b.imported.Data + resolution?: tt4b.imported.ConflictResolution } export type OtherExtension = diff --git a/src/pages/app/components/DataManage/context.ts b/src/pages/app/components/DataManage/context.ts index 910869dc8..dffe0eb29 100644 --- a/src/pages/app/components/DataManage/context.ts +++ b/src/pages/app/components/DataManage/context.ts @@ -3,7 +3,7 @@ import { useProvide, useProvider, useRequest } from '@hooks' import { type ShallowRef } from "vue" type Context = { - memory: ShallowRef<timer.common.StorageUsage> + memory: ShallowRef<tt4b.common.StorageUsage> refreshMemory: () => void } diff --git a/src/pages/app/components/Habit/Period/Average/Wrapper.tsx b/src/pages/app/components/Habit/Period/Average/Wrapper.tsx index 10ee2650a..38a79f674 100644 --- a/src/pages/app/components/Habit/Period/Average/Wrapper.tsx +++ b/src/pages/app/components/Habit/Period/Average/Wrapper.tsx @@ -21,16 +21,16 @@ type EcOption = ComposeOption< > export type BizOption = { - currRange: timer.period.KeyRange - prevRange: timer.period.KeyRange - curr: timer.period.Row[] - prev: timer.period.Row[] + currRange: tt4b.period.KeyRange + prevRange: tt4b.period.KeyRange + curr: tt4b.period.Row[] + prev: tt4b.period.Row[] periodSize: number } const [CURR_COLOR = '', PREV_COLOR = ''] = getCompareColor() -const cvt2Item = (row: timer.period.Row): number => { +const cvt2Item = (row: tt4b.period.Row): number => { const milliseconds = row.milliseconds return milliseconds } @@ -42,24 +42,24 @@ const formatXAxis = (idx: number, periodSize: number) => { return hour.toString().padStart(2, '0') + ':' + min.toString().padStart(2, '0') } -const key2Str = (key: timer.period.Key) => { +const key2Str = (key: tt4b.period.Key) => { const { month, date } = key return `${month?.toString?.()?.padStart(2, '0')}/${date?.toString?.()?.padStart(2, '0')}` } -const isSameDay = (keyRange: timer.period.KeyRange): boolean => { +const isSameDay = (keyRange: tt4b.period.KeyRange): boolean => { const [start, end] = keyRange || [] return start?.year === end?.year && start?.month === end?.month && start?.date === end?.date } -const range2Str = (keyRange: timer.period.KeyRange) => { +const range2Str = (keyRange: tt4b.period.KeyRange) => { const [start, end] = keyRange return isSameDay(keyRange) ? key2Str(start) : `${key2Str(start)}-${key2Str(end)}` } -const formatValueLine = (mill: number, range: timer.period.KeyRange, color: string): string => { +const formatValueLine = (mill: number, range: tt4b.period.KeyRange, color: string): string => { return tooltipFlexLine( `${tooltipDot(color)} <b>${formatPeriodCommon(mill ?? 0)}</b>`, range2Str(range), diff --git a/src/pages/app/components/Habit/Period/Stack/Wrapper.ts b/src/pages/app/components/Habit/Period/Stack/Wrapper.ts index f3ece34b9..0d15c76b5 100644 --- a/src/pages/app/components/Habit/Period/Stack/Wrapper.ts +++ b/src/pages/app/components/Habit/Period/Stack/Wrapper.ts @@ -14,13 +14,13 @@ type EcOption = ComposeOption< > export type BizOption = { - data: timer.period.Row[] - timeFormat: timer.app.TimeFormat + data: tt4b.period.Row[] + timeFormat: tt4b.app.TimeFormat } const [COLOR] = getLineSeriesPalette() -const formatTooltip = (params: TopLevelFormatterParams, timeFormat: timer.app.TimeFormat) => { +const formatTooltip = (params: TopLevelFormatterParams, timeFormat: tt4b.app.TimeFormat) => { const param = Array.isArray(params) ? params[0] : params const [, total, , end] = param?.data as number[] return ` diff --git a/src/pages/app/components/Habit/Period/Summary.tsx b/src/pages/app/components/Habit/Period/Summary.tsx index 69a03ffa9..449c76b38 100644 --- a/src/pages/app/components/Habit/Period/Summary.tsx +++ b/src/pages/app/components/Habit/Period/Summary.tsx @@ -21,7 +21,7 @@ type Result = { } } -const computeSummary = (rows: timer.period.Row[], periodSize: number): Result => { +const computeSummary = (rows: tt4b.period.Row[], periodSize: number): Result => { const averaged = averageByDay(rows, periodSize) const favoriteRow = averaged.sort((b, a) => a.milliseconds - b.milliseconds)[0] let favoritePeriod = '-' @@ -31,10 +31,10 @@ const computeSummary = (rows: timer.period.Row[], periodSize: number): Result => favoritePeriod = `${formatTime(start, "{h}:{i}")}-${formatTime(end, "{h}:{i}")}` } - let maxIdle: [timer.period.Row | undefined, timer.period.Row | undefined, number] = [, , 0] + let maxIdle: [tt4b.period.Row | undefined, tt4b.period.Row | undefined, number] = [, , 0] - let idleStart: timer.period.Row | undefined - let idleEnd: timer.period.Row | undefined + let idleStart: tt4b.period.Row | undefined + let idleEnd: tt4b.period.Row | undefined rows.forEach(r => { if (r.milliseconds) { if (!idleStart || !idleEnd) return diff --git a/src/pages/app/components/Habit/Period/Trend/Wrapper.ts b/src/pages/app/components/Habit/Period/Trend/Wrapper.ts index 59006c26e..fa2d96421 100644 --- a/src/pages/app/components/Habit/Period/Trend/Wrapper.ts +++ b/src/pages/app/components/Habit/Period/Trend/Wrapper.ts @@ -20,12 +20,12 @@ type EcOption = ComposeOption< > export type BizOption = { - data: timer.period.Row[] - timeFormat: timer.app.TimeFormat + data: tt4b.period.Row[] + timeFormat: tt4b.app.TimeFormat } -function formatTimeOfEcharts(params: TopLevelFormatterParams, timeFormat: timer.app.TimeFormat): string { +function formatTimeOfEcharts(params: TopLevelFormatterParams, timeFormat: tt4b.app.TimeFormat): string { const format = Array.isArray(params) ? params[0] : params if (!format) return 'NaN' const { value } = format @@ -50,7 +50,7 @@ function formatTimeOfEcharts(params: TopLevelFormatterParams, timeFormat: timer. type BarItem = Exclude<BarSeriesOption["data"], undefined>[number] -const cvt2Item = (row: timer.period.Row): BarItem => { +const cvt2Item = (row: tt4b.period.Row): BarItem => { const startTime = row.startTime const endTime = row.endTime const time = (startTime + endTime) / 2 diff --git a/src/pages/app/components/Habit/Period/context.ts b/src/pages/app/components/Habit/Period/context.ts index e9cdbec3b..91c2eb864 100644 --- a/src/pages/app/components/Habit/Period/context.ts +++ b/src/pages/app/components/Habit/Period/context.ts @@ -14,13 +14,13 @@ import { useHabitFilter } from "../context" import type { FilterOption } from "./types" type Value = { - curr: timer.period.Row[] - prev: timer.period.Row[] + curr: tt4b.period.Row[] + prev: tt4b.period.Row[] } type PeriodRange = { - curr: timer.period.KeyRange - prev: timer.period.KeyRange + curr: tt4b.period.KeyRange + prev: tt4b.period.KeyRange } type Context = { diff --git a/src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts b/src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts index 6a9414921..1084b998d 100644 --- a/src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts +++ b/src/pages/app/components/Habit/Site/DailyTrend/Wrapper.ts @@ -34,9 +34,9 @@ const LEGEND_COLOR_MAP = { const TITLE = t(msg => msg.habit.site.trend.title) export type BizOption = { - rows: timer.stat.Row[] + rows: tt4b.stat.Row[] dateRange: [Date, Date] - timeFormat: timer.app.TimeFormat + timeFormat: tt4b.app.TimeFormat } const valueYAxis = (): YAXisOption => ({ @@ -47,7 +47,7 @@ const valueYAxis = (): YAXisOption => ({ splitLine: { show: false }, }) -const formatTimeTooltip = (params: TopLevelFormatterParams, format: timer.app.TimeFormat) => { +const formatTimeTooltip = (params: TopLevelFormatterParams, format: tt4b.app.TimeFormat) => { if (!Array.isArray(params)) return '' const date = params?.[0]?.name if (!date) return '' diff --git a/src/pages/app/components/Habit/Site/Distribution/Wrapper.ts b/src/pages/app/components/Habit/Site/Distribution/Wrapper.ts index 339dba860..606893114 100644 --- a/src/pages/app/components/Habit/Site/Distribution/Wrapper.ts +++ b/src/pages/app/components/Habit/Site/Distribution/Wrapper.ts @@ -5,21 +5,12 @@ import { getPrimaryTextColor, getRegularTextColor } from '@pages/util/style' import { groupBy, sum } from "@util/array" import { getHost } from "@util/stat" import { MILL_PER_HOUR, MILL_PER_MINUTE, MILL_PER_SECOND } from "@util/time" -import { - type ComposeOption, - type LegendComponentOption, - type PieSeriesOption, - type TitleComponentOption -} from "echarts" -import { - type GridOption, - type TooltipOption, - type TopLevelFormatterParams -} from "echarts/types/dist/shared" +import type { ComposeOption, LegendComponentOption, PieSeriesOption, TitleComponentOption } from "echarts" +import type { GridOption, TooltipOption, TopLevelFormatterParams } from "echarts/types/dist/shared" import { computeAverageLen, generateTitleOption } from "../common" export type BizOption = { - rows: timer.stat.Row[] + rows: tt4b.stat.Row[] dateRange: [Date, Date] } diff --git a/src/pages/app/components/Habit/Site/Summary.tsx b/src/pages/app/components/Habit/Site/Summary.tsx index 87dbfd3d5..b30564f62 100644 --- a/src/pages/app/components/Habit/Site/Summary.tsx +++ b/src/pages/app/components/Habit/Site/Summary.tsx @@ -24,7 +24,7 @@ type Result = { exclusiveToday4Average: boolean } -const computeSummary = (rows: timer.stat.Row[] = [], filter: FilterOption): Result => { +const computeSummary = (rows: tt4b.stat.Row[] = [], filter: FilterOption): Result => { const [averageLen, exclusiveToday4Average, exclusiveDate] = computeAverageLen(filter?.dateRange) const totalFocus = sum(rows.map(r => r.focus)) const totalFocus4Average = exclusiveDate ? sum(rows.filter(r => r.date !== exclusiveDate).map(r => r.focus)) : totalFocus diff --git a/src/pages/app/components/Habit/Site/TopK/Wrapper.ts b/src/pages/app/components/Habit/Site/TopK/Wrapper.ts index ef6b48a24..62448f342 100644 --- a/src/pages/app/components/Habit/Site/TopK/Wrapper.ts +++ b/src/pages/app/components/Habit/Site/TopK/Wrapper.ts @@ -25,8 +25,8 @@ type EcOption = ComposeOption< > type BizOption = { - rows: timer.stat.Row[] - timeFormat: timer.app.TimeFormat + rows: tt4b.stat.Row[] + timeFormat: tt4b.app.TimeFormat } const TOP_NUM = 8 @@ -34,10 +34,10 @@ const TOP_NUM = 8 const MARGIN_LEFT_P = 8 const MARGIN_RIGHT_P = 8 -const formatFocusTooltip = (params: TopLevelFormatterParams, format: timer.app.TimeFormat): string => { +const formatFocusTooltip = (params: TopLevelFormatterParams, format: tt4b.app.TimeFormat): string => { const param = Array.isArray(params) ? params[0] : params const { data } = param || {} - const row = (data as any)?.row as timer.stat.Row + const row = (data as any)?.row as tt4b.stat.Row if (!isSite(row)) return '' @@ -51,10 +51,10 @@ const formatFocusTooltip = (params: TopLevelFormatterParams, format: timer.app.T ` } type MergeRow = - | MakeRequired<timer.stat.SiteRow | timer.stat.CateRow, 'mergedDates' | 'mergedRows'> - | MakeRequired<timer.stat.GroupRow, 'mergedDates' | 'mergedRows'> + | MakeRequired<tt4b.stat.SiteRow | tt4b.stat.CateRow, 'mergedDates' | 'mergedRows'> + | MakeRequired<tt4b.stat.GroupRow, 'mergedDates' | 'mergedRows'> -function mergeDate(origin: timer.stat.Row[]): timer.stat.Row[] { +function mergeDate(origin: tt4b.stat.Row[]): tt4b.stat.Row[] { const map: Record<string, MergeRow> = {} origin.forEach(ele => { const { date, focus, time } = ele @@ -75,7 +75,7 @@ function mergeDate(origin: timer.stat.Row[]): timer.stat.Row[] { return newRows } -async function generateOption(rows: timer.stat.Row[] = [], timeFormat: timer.app.TimeFormat, dom: HTMLElement): Promise<EcOption> { +async function generateOption(rows: tt4b.stat.Row[] = [], timeFormat: tt4b.app.TimeFormat, dom: HTMLElement): Promise<EcOption> { const merged = mergeDate(rows) const topList = merged.sort((a, b) => b.focus - a.focus).splice(0, TOP_NUM).reverse() const max = topList[topList.length - 1]?.focus ?? 0 diff --git a/src/pages/app/components/Habit/Site/common.ts b/src/pages/app/components/Habit/Site/common.ts index b2fbe6154..4a63023fc 100644 --- a/src/pages/app/components/Habit/Site/common.ts +++ b/src/pages/app/components/Habit/Site/common.ts @@ -25,7 +25,7 @@ export const generateTitleOption = (text: string): TitleComponentOption => { export type SeriesDataItem = { value: number - row: timer.stat.Row + row: tt4b.stat.Row } /** diff --git a/src/pages/app/components/Habit/Site/context.ts b/src/pages/app/components/Habit/Site/context.ts index 46cb42eed..b23117231 100644 --- a/src/pages/app/components/Habit/Site/context.ts +++ b/src/pages/app/components/Habit/Site/context.ts @@ -12,8 +12,8 @@ import { computed, type ShallowRef } from "vue" import { useHabitFilter } from "../context" type Context = { - rows: ShallowRef<timer.stat.Row[]> - dateMergedRows: ShallowRef<timer.stat.Row[]> + rows: ShallowRef<tt4b.stat.Row[]> + dateMergedRows: ShallowRef<tt4b.stat.Row[]> } const NAMESPACE = 'habitSite' @@ -37,6 +37,6 @@ export const initProvider = () => { return dateRangeLength } -export const useRows = (): ShallowRef<timer.stat.Row[]> => useProvider<Context, 'rows'>(NAMESPACE, "rows").rows +export const useRows = (): ShallowRef<tt4b.stat.Row[]> => useProvider<Context, 'rows'>(NAMESPACE, "rows").rows -export const useDateMergedRows = (): ShallowRef<timer.stat.Row[]> => useProvider<Context, 'dateMergedRows'>(NAMESPACE, 'dateMergedRows').dateMergedRows \ No newline at end of file +export const useDateMergedRows = (): ShallowRef<tt4b.stat.Row[]> => useProvider<Context, 'dateMergedRows'>(NAMESPACE, 'dateMergedRows').dateMergedRows \ No newline at end of file diff --git a/src/pages/app/components/Habit/context.ts b/src/pages/app/components/Habit/context.ts index d217664c0..84db196f1 100644 --- a/src/pages/app/components/Habit/context.ts +++ b/src/pages/app/components/Habit/context.ts @@ -10,7 +10,7 @@ import { daysAgo } from "@util/time" import { reactive, Reactive } from "vue" export type FilterOption = { - timeFormat: timer.app.TimeFormat + timeFormat: tt4b.app.TimeFormat dateRange: [Date, Date] } diff --git a/src/pages/app/components/HelpUs/ProgressList.tsx b/src/pages/app/components/HelpUs/ProgressList.tsx index a42444f9a..7078c4d5d 100644 --- a/src/pages/app/components/HelpUs/ProgressList.tsx +++ b/src/pages/app/components/HelpUs/ProgressList.tsx @@ -13,7 +13,7 @@ import Flex from "@pages/components/Flex" import { ElProgress, type ProgressProps } from "element-plus" import { defineComponent, ref, type StyleValue } from "vue" -type SupportedLocale = timer.Locale | timer.TranslatingLocale +type SupportedLocale = tt4b.Locale | tt4b.TranslatingLocale const localeCrowdMap: { [locale in SupportedLocale]: string } = { en: "en", @@ -108,7 +108,7 @@ const _default = defineComponent(() => { > <Flex width={70} gap={10} fontSize={12} column align="end" color="var(--el-text-color-regular)"> <span>{`${progress}%`}</span> - <span>{localeMessages[locale as timer.Locale]?.name ?? locale}</span> + <span>{localeMessages[locale as tt4b.Locale]?.name ?? locale}</span> </Flex> </ElProgress> ))} diff --git a/src/pages/app/components/Limit/components/List/Card.tsx b/src/pages/app/components/Limit/components/List/Card.tsx index 372f8925c..b83c63240 100644 --- a/src/pages/app/components/Limit/components/List/Card.tsx +++ b/src/pages/app/components/Limit/components/List/Card.tsx @@ -14,7 +14,7 @@ import { defineComponent, type FunctionalComponent, type StyleValue } from "vue" import Rule from "./Rule" type Props = { - value: timer.limit.Item + value: tt4b.limit.Item } const CARD_PADDING = 10 diff --git a/src/pages/app/components/Limit/components/List/Rule.tsx b/src/pages/app/components/Limit/components/List/Rule.tsx index cb52edb74..c54f34b3a 100644 --- a/src/pages/app/components/Limit/components/List/Rule.tsx +++ b/src/pages/app/components/Limit/components/List/Rule.tsx @@ -8,7 +8,7 @@ import { defineComponent, type FunctionalComponent, toRefs } from 'vue' import { DAILY_WEEKLY_TAG_TYPE, PERIOD_TAG_TYPE, VISIT_TAG_TYPE } from '../style' type Props = { - value: timer.limit.Item + value: tt4b.limit.Item } const TimeCountPair: FunctionalComponent<{ time?: number, count?: number }> = ({ time, count }) => { diff --git a/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx b/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx index 133e0d9dd..742a75548 100644 --- a/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx +++ b/src/pages/app/components/Limit/components/Modify/Step3/PeriodInput.tsx @@ -28,7 +28,7 @@ const range2Period = (range: [Date, Date]): [number, number] => { return [Math.min(startIdx, endIdx), Math.max(startIdx, endIdx)] } -const insertPeriods = (periods: timer.limit.Period[], toInsert: timer.limit.Period) => { +const insertPeriods = (periods: tt4b.limit.Period[], toInsert: tt4b.limit.Period) => { if (!toInsert || !periods) return let len = periods.length if (!len) { @@ -57,13 +57,13 @@ const insertPeriods = (periods: timer.limit.Period[], toInsert: timer.limit.Peri periods.sort((a, b) => a[0] - b[0]) } -const mergePeriod = (target: timer.limit.Period, toMerge: timer.limit.Period) => { +const mergePeriod = (target: tt4b.limit.Period, toMerge: tt4b.limit.Period) => { if (!target || !toMerge) return target[0] = Math.min(target[0], toMerge[0]) target[1] = Math.max(target[1], toMerge[1]) } -const checkImpact = (p1: timer.limit.Period, p2: timer.limit.Period): boolean => { +const checkImpact = (p1: tt4b.limit.Period, p2: tt4b.limit.Period): boolean => { if (!p1 || !p2) return false const [s1, e1] = p1 const [s2, e2] = p2 @@ -100,7 +100,7 @@ const usePickerStyle = () => { ` } -const PeriodInput = defineComponent<ModelValue<timer.limit.Period[]>>(props => { +const PeriodInput = defineComponent<ModelValue<tt4b.limit.Period[]>>(props => { const [editing, openEditing, closeEditing] = useSwitch(false) const [editingRange, setEditingRange] = useState(rangeInitial()) const isXs = useXsState() @@ -112,7 +112,7 @@ const PeriodInput = defineComponent<ModelValue<timer.limit.Period[]>>(props => { const handleSave = () => { const val = range2Period(editingRange.value) - const oldPeriods = props.modelValue.map(p => [p[0], p[1]] satisfies timer.limit.Period) + const oldPeriods = props.modelValue.map(p => [p[0], p[1]] satisfies tt4b.limit.Period) insertPeriods(oldPeriods, val) props.onChange?.(oldPeriods) closeEditing() @@ -120,7 +120,7 @@ const PeriodInput = defineComponent<ModelValue<timer.limit.Period[]>>(props => { const handleDelete = (idx: number) => { const newPeriods = props.modelValue.filter((_, i) => i !== idx) - .map(p => [p[0], p[1]] satisfies timer.limit.Period) + .map(p => [p[0], p[1]] satisfies tt4b.limit.Period) props.onChange?.(newPeriods) } diff --git a/src/pages/app/components/Limit/components/Modify/index.tsx b/src/pages/app/components/Limit/components/Modify/index.tsx index 72781710f..574be120d 100644 --- a/src/pages/app/components/Limit/components/Modify/index.tsx +++ b/src/pages/app/components/Limit/components/Modify/index.tsx @@ -75,23 +75,23 @@ const _default = defineComponent((_, ctx) => { ) { throw new Error(t(msg => msg.limit.message.noRule)) } - let saved: timer.limit.Rule + let saved: tt4b.limit.Rule if (mode.value === 'modify') { if (!modifyingItem) return saved = { ...modifyingItem, cond, enabled, name, time, weekly, visitTime, weekdays, count, weeklyCount, // Object to array - periods: periods?.map(i => [i[0], i[1]] satisfies timer.limit.Period), - } satisfies timer.limit.Rule + periods: periods?.map(i => [i[0], i[1]] satisfies tt4b.limit.Period), + } satisfies tt4b.limit.Rule await updateLimits([saved]) } else { const toCreate = { cond, enabled, name, time, weekly, visitTime, weekdays, count, weeklyCount, // Object to array - periods: periods?.map(i => [i[0], i[1]] satisfies timer.limit.Period), + periods: periods?.map(i => [i[0], i[1]] satisfies tt4b.limit.Period), allowDelay: false, locked: false, - } satisfies MakeOptional<timer.limit.Rule, 'id'> + } satisfies MakeOptional<tt4b.limit.Rule, 'id'> const id = await addLimit(toCreate) saved = { ...toCreate, id } } @@ -99,7 +99,7 @@ const _default = defineComponent((_, ctx) => { } }) // Cache - let modifyingItem: timer.limit.Rule | undefined = undefined + let modifyingItem: tt4b.limit.Rule | undefined = undefined ctx.expose({ create(url?: string) { @@ -107,7 +107,7 @@ const _default = defineComponent((_, ctx) => { mode.value = 'create' modifyingItem = undefined }, - modify(row: timer.limit.Item) { + modify(row: tt4b.limit.Item) { open(toRaw(row)) mode.value = 'modify' modifyingItem = { ...row } diff --git a/src/pages/app/components/Limit/components/Table/Rule.tsx b/src/pages/app/components/Limit/components/Table/Rule.tsx index c622753dd..dfc6683bd 100644 --- a/src/pages/app/components/Limit/components/Table/Rule.tsx +++ b/src/pages/app/components/Limit/components/Table/Rule.tsx @@ -28,7 +28,7 @@ const TimeCountPair: FunctionalComponent<TimeCountPairProps> = ({ time, count, l ) } -const PeriodTag: FunctionalComponent<{ periods?: timer.limit.Period[], }> = ({ periods }) => { +const PeriodTag: FunctionalComponent<{ periods?: tt4b.limit.Period[], }> = ({ periods }) => { if (!periods?.length) return null return <> @@ -41,7 +41,7 @@ const PeriodTag: FunctionalComponent<{ periods?: timer.limit.Period[], }> = ({ p </> } -const Rule = defineComponent<{ value: timer.limit.Item }>(props => { +const Rule = defineComponent<{ value: tt4b.limit.Item }>(props => { const row = toRef(props, 'value') return () => ( diff --git a/src/pages/app/components/Limit/components/Table/index.tsx b/src/pages/app/components/Limit/components/Table/index.tsx index e2d3ff088..1a6fe0870 100644 --- a/src/pages/app/components/Limit/components/Table/index.tsx +++ b/src/pages/app/components/Limit/components/Table/index.tsx @@ -22,7 +22,7 @@ import Rule from "./Rule" import Waste from "./Waste" import Weekday from "./Weekday" -const ACTION_WIDTH: { [locale in timer.Locale]: number } = { +const ACTION_WIDTH: { [locale in tt4b.Locale]: number } = { en: 220, zh_CN: 200, ja: 200, @@ -42,10 +42,10 @@ const ACTION_WIDTH: { [locale in timer.Locale]: number } = { const DEFAULT_SORT_COL = 'waste' function createSorter(key: 'waste' | 'weeklyWaste') { - return (a: timer.limit.Item, b: timer.limit.Item) => a[key] - b[key] + return (a: tt4b.limit.Item, b: tt4b.limit.Item) => a[key] - b[key] } -function sortByEffectiveDays(a: timer.limit.Item, b: timer.limit.Item) { +function sortByEffectiveDays(a: tt4b.limit.Item, b: tt4b.limit.Item) { return (a.weekdays?.length ?? 0) - (b.weekdays?.length ?? 0) } @@ -88,23 +88,23 @@ const _default = defineComponent((_, ctx) => { label={t(msg => msg.limit.item.name)} minWidth={120} align="center" - formatter={({ name }: timer.limit.Item) => name || '-'} + formatter={({ name }: tt4b.limit.Item) => name || '-'} fixed sortable - sortBy={(row: timer.limit.Item) => row.name} + sortBy={(row: tt4b.limit.Item) => row.name} /> <ElTableColumn label={t(msg => msg.limit.item.condition)} minWidth={180} align="center" - formatter={({ cond }: timer.limit.Item) => <>{cond?.map?.(c => <span style={{ display: "block" }}>{c}</span>) || ''}</>} + formatter={({ cond }: tt4b.limit.Item) => <>{cond?.map?.(c => <span style={{ display: "block" }}>{c}</span>) || ''}</>} /> <ElTableColumn label={t(msg => msg.limit.item.detail)} minWidth={200} align="center" > - {({ row }: RenderRowData<timer.limit.Item>) => <Rule value={row} />} + {({ row }: RenderRowData<tt4b.limit.Item>) => <Rule value={row} />} </ElTableColumn> <ElTableColumn prop='effectiveDays' @@ -114,7 +114,7 @@ const _default = defineComponent((_, ctx) => { sortable sortMethod={sortByEffectiveDays} > - {({ row: { weekdays } }: RenderRowData<timer.limit.Item>) => <Weekday value={weekdays} />} + {({ row: { weekdays } }: RenderRowData<tt4b.limit.Item>) => <Weekday value={weekdays} />} </ElTableColumn> <ElTableColumn prop={DEFAULT_SORT_COL} @@ -124,7 +124,7 @@ const _default = defineComponent((_, ctx) => { minWidth={90} align="center" > - {({ row }: RenderRowData<timer.limit.Item>) => isEffective(row.weekdays) ? ( + {({ row }: RenderRowData<tt4b.limit.Item>) => isEffective(row.weekdays) ? ( <Waste time={{ wasted: row.waste, maxLimit: (row.time ?? 0) * MILL_PER_SECOND }} delay={{ count: row.delayCount, duration: delayDuration.value, allow: !!row.allowDelay }} @@ -154,7 +154,7 @@ const _default = defineComponent((_, ctx) => { weeklyWaste, weekly, weeklyVisit, weeklyCount, weeklyDelayCount, allowDelay, - } }: RenderRowData<timer.limit.Item>) => ( + } }: RenderRowData<tt4b.limit.Item>) => ( <Waste time={{ wasted: weeklyWaste, maxLimit: (weekly ?? 0) * MILL_PER_SECOND }} delay={{ count: weeklyDelayCount, duration: delayDuration.value, allow: !!allowDelay }} @@ -171,7 +171,7 @@ const _default = defineComponent((_, ctx) => { align="center" fixed="right" > - {({ row }: RenderRowData<timer.limit.Item>) => ( + {({ row }: RenderRowData<tt4b.limit.Item>) => ( <ElSwitch size="small" modelValue={row.enabled} onChange={v => changeEnabled(row, !!v)} /> )} </ElTableColumn> @@ -181,7 +181,7 @@ const _default = defineComponent((_, ctx) => { align="center" fixed="right" > - {({ row }: RenderRowData<timer.limit.Item>) => ( + {({ row }: RenderRowData<tt4b.limit.Item>) => ( <ElSwitch size="small" modelValue={row.allowDelay} onChange={v => changeDelay(row, !!v)} /> )} </ElTableColumn> @@ -192,7 +192,7 @@ const _default = defineComponent((_, ctx) => { align="center" fixed="right" > - {({ row }: RenderRowData<timer.limit.Item>) => ( + {({ row }: RenderRowData<tt4b.limit.Item>) => ( <ElSwitch size="small" modelValue={row.locked} onChange={v => changeLocked(row, !!v)} /> )} </ElTableColumn> @@ -203,7 +203,7 @@ const _default = defineComponent((_, ctx) => { width={ACTION_WIDTH[locale]} align="center" fixed="right" - v-slots={({ row }: RenderRowData<timer.limit.Item>) => <> + v-slots={({ row }: RenderRowData<tt4b.limit.Item>) => <> <ElButton type="danger" size="small" icon={Delete} onClick={() => remove(row)}> {t(msg => msg.button.delete)} </ElButton> diff --git a/src/pages/app/components/Limit/context.ts b/src/pages/app/components/Limit/context.ts index c2a417cf7..9c4bb508a 100644 --- a/src/pages/app/components/Limit/context.ts +++ b/src/pages/app/components/Limit/context.ts @@ -13,16 +13,16 @@ import type { LimitFilterOption, LimitInstance, ModifyInstance, TestInstance } f type Context = { filter: LimitFilterOption - list: ShallowRef<timer.limit.Item[]> + list: ShallowRef<tt4b.limit.Item[]> refresh: NoArgCallback batchDelete: NoArgCallback batchEnable: NoArgCallback batchDisable: NoArgCallback - changeEnabled: (item: timer.limit.Item, val: boolean) => Promise<void> - changeDelay: (item: timer.limit.Item, val: boolean) => Promise<void> - changeLocked: (item: timer.limit.Item, val: boolean) => Promise<void> - modify: ArgCallback<timer.limit.Item> - remove: ArgCallback<timer.limit.Item> + changeEnabled: (item: tt4b.limit.Item, val: boolean) => Promise<void> + changeDelay: (item: tt4b.limit.Item, val: boolean) => Promise<void> + changeLocked: (item: tt4b.limit.Item, val: boolean) => Promise<void> + modify: ArgCallback<tt4b.limit.Item> + remove: ArgCallback<tt4b.limit.Item> create: () => void test: () => void empty: ShallowRef<boolean> @@ -42,7 +42,7 @@ const initialQuery = () => { } } -const batchJudge = async (items: timer.limit.Item[]): Promise<boolean> => { +const batchJudge = async (items: tt4b.limit.Item[]): Promise<boolean> => { if (!items?.length) return false const { limitDelayDuration, limitLevel } = await getOption() for (const item of items) { @@ -55,7 +55,7 @@ const batchJudge = async (items: timer.limit.Item[]): Promise<boolean> => { return false } -const verifyCanModify = async (...items: timer.limit.Item[]) => { +const verifyCanModify = async (...items: tt4b.limit.Item[]) => { const needVerify = await batchJudge(items) if (!needVerify) return @@ -97,7 +97,7 @@ export const useLimitProvider = () => { const docVisible = useDocumentVisibility() watch(docVisible, () => docVisible.value && refresh()) - const { refresh: remove } = useManualRequest(async (row: timer.limit.Item) => { + const { refresh: remove } = useManualRequest(async (row: tt4b.limit.Item) => { await verifyCanModify(row) const message = t(msg => msg.limit.message.deleteConfirm, { name: row.name }) await ElMessageBox.confirm(message) @@ -111,7 +111,7 @@ export const useLimitProvider = () => { const inst = ref<LimitInstance>() - const selectedAndThen = (then: (list: timer.limit.Item[]) => void): void => { + const selectedAndThen = (then: (list: tt4b.limit.Item[]) => void): void => { const list = inst.value?.getSelected?.() if (!list?.length) { ElMessage.info('No limit rule selected') @@ -125,7 +125,7 @@ export const useLimitProvider = () => { refresh() } - const handleBatchDelete = (list: timer.limit.Item[]) => { + const handleBatchDelete = (list: tt4b.limit.Item[]) => { const names = list.map(item => item.name ?? item.id).join(', ') verifyCanModify(...list) .then(() => ElMessageBox.confirm(t(msg => msg.limit.message.deleteConfirm, { name: names }), { type: "warning" })) @@ -134,12 +134,12 @@ export const useLimitProvider = () => { .catch(() => { }) } - const handleBatchEnable = (list: timer.limit.Item[]) => { + const handleBatchEnable = (list: tt4b.limit.Item[]) => { list.forEach(item => item.enabled = true) updateLimits(list).then(onBatchSuccess).catch(() => { }) } - const handleBatchDisable = (list: timer.limit.Item[]) => verifyCanModify(...list) + const handleBatchDisable = (list: tt4b.limit.Item[]) => verifyCanModify(...list) .then(() => { list.forEach(item => item.enabled = false) return updateLimits(list) @@ -147,7 +147,7 @@ export const useLimitProvider = () => { .then(onBatchSuccess) .catch(() => { }) - const changeEnabled = async (row: timer.limit.Item, newVal: boolean) => { + const changeEnabled = async (row: tt4b.limit.Item, newVal: boolean) => { try { // Only verify when disabling, ignore lock state !newVal && await verifyCanModify(row) @@ -158,7 +158,7 @@ export const useLimitProvider = () => { } } - const changeDelay = async (row: timer.limit.Item, newVal: boolean) => { + const changeDelay = async (row: tt4b.limit.Item, newVal: boolean) => { try { (row.locked || newVal) && await verifyCanModify(row) row.allowDelay = newVal @@ -168,7 +168,7 @@ export const useLimitProvider = () => { } } - const changeLocked = async (row: timer.limit.Item, newVal: boolean) => { + const changeLocked = async (row: tt4b.limit.Item, newVal: boolean) => { const locked = !!newVal try { if (locked) { @@ -186,7 +186,7 @@ export const useLimitProvider = () => { const modifyInst = ref<ModifyInstance>() const testInst = ref<TestInstance>() - const modify = (row: timer.limit.Item) => verifyCanModify(row) + const modify = (row: tt4b.limit.Item) => verifyCanModify(row) .then(() => modifyInst.value?.modify?.(toRaw(row))) .catch(() => {/** Do nothing */ }) const create = () => modifyInst.value?.create?.() diff --git a/src/pages/app/components/Limit/types.d.ts b/src/pages/app/components/Limit/types.d.ts index 033ef138d..f2c2c8ccc 100644 --- a/src/pages/app/components/Limit/types.d.ts +++ b/src/pages/app/components/Limit/types.d.ts @@ -5,7 +5,7 @@ export type LimitFilterOption = { export type ModifyInstance = { create(url?: string): void - modify(row: timer.limit.Item): void + modify(row: tt4b.limit.Item): void } export type TestInstance = { @@ -13,9 +13,9 @@ export type TestInstance = { } export type LimitInstance = { - getSelected(): timer.limit.Item[] + getSelected(): tt4b.limit.Item[] } -export type ModifyForm = Omit<timer.limit.Rule, 'id'> & { +export type ModifyForm = Omit<tt4b.limit.Rule, 'id'> & { urlMiss?: boolean } \ No newline at end of file diff --git a/src/pages/app/components/Option/categories/Accessibility.tsx b/src/pages/app/components/Option/categories/Accessibility.tsx index d2d04babb..02d4585e1 100644 --- a/src/pages/app/components/Option/categories/Accessibility.tsx +++ b/src/pages/app/components/Option/categories/Accessibility.tsx @@ -5,7 +5,7 @@ import { OptionItem } from '../components' import { useOption } from "../useOption" import type { CategoryInstance } from './types' -function copy(target: timer.option.AccessibilityOption, source: Readonly<timer.option.AccessibilityOption>) { +function copy(target: tt4b.option.AccessibilityOption, source: Readonly<tt4b.option.AccessibilityOption>) { target.chartDecal = source.chartDecal } diff --git a/src/pages/app/components/Option/categories/Appearance/DarkModeInput.tsx b/src/pages/app/components/Option/categories/Appearance/DarkModeInput.tsx index 08be9a7aa..f9f9d1917 100644 --- a/src/pages/app/components/Option/categories/Appearance/DarkModeInput.tsx +++ b/src/pages/app/components/Option/categories/Appearance/DarkModeInput.tsx @@ -8,8 +8,8 @@ import { t } from '@app/locale' import { ElSelect, ElTimePicker } from "element-plus" import { computed, defineComponent, StyleValue } from "vue" -const ALL_MODES: timer.option.DarkMode[] = ["default", "on", "off", "timed"] -const MODE_LABELS: Record<timer.option.DarkMode, string> = { +const ALL_MODES: tt4b.option.DarkMode[] = ["default", "on", "off", "timed"] +const MODE_LABELS: Record<tt4b.option.DarkMode, string> = { default: t(msg => msg.option.followBrowser), on: t(msg => msg.option.on), off: t(msg => msg.option.off), @@ -36,10 +36,10 @@ function computeDateToSecond(date: Date) { } type Props = { - modelValue: timer.option.DarkMode + modelValue: tt4b.option.DarkMode startSecond?: number endSecond?: number - onChange?: (darkMode: timer.option.DarkMode, [startSecond, endSecond]: [number?, number?]) => void + onChange?: (darkMode: tt4b.option.DarkMode, [startSecond, endSecond]: [number?, number?]) => void } const _default = defineComponent<Props>(props => { @@ -51,7 +51,7 @@ const _default = defineComponent<Props>(props => { modelValue={props.modelValue} size="small" style={{ width: "120px" }} - onChange={val => props.onChange?.(val as timer.option.DarkMode, [props.startSecond, props.endSecond])} + onChange={val => props.onChange?.(val as tt4b.option.DarkMode, [props.startSecond, props.endSecond])} options={ALL_MODES.map(value => ({ value, label: MODE_LABELS[value] }))} /> {props.modelValue === "timed" && <> diff --git a/src/pages/app/components/Option/categories/Appearance/index.tsx b/src/pages/app/components/Option/categories/Appearance/index.tsx index 467ab5c81..2268d5c0d 100644 --- a/src/pages/app/components/Option/categories/Appearance/index.tsx +++ b/src/pages/app/components/Option/categories/Appearance/index.tsx @@ -22,16 +22,16 @@ import DarkModeInput from "./DarkModeInput" const FOLLOW_BROWSER: I18nKey = msg => msg.option.followBrowser -const SORTED_LOCALES: timer.Locale[] = ALL_LOCALES +const SORTED_LOCALES: tt4b.Locale[] = ALL_LOCALES // Keep the locale same as this browser first position .sort((a, _b) => a === localeSameAsBrowser ? -1 : 0) -const allLocaleOptions = (["default", ...SORTED_LOCALES] satisfies timer.option.LocaleOption[]).map(locale => ({ +const allLocaleOptions = (["default", ...SORTED_LOCALES] satisfies tt4b.option.LocaleOption[]).map(locale => ({ value: locale, label: locale === "default" ? t(FOLLOW_BROWSER) : localeMessages[locale].name })) -function copy(target: timer.option.AppearanceOption, source: timer.option.AppearanceOption) { +function copy(target: tt4b.option.AppearanceOption, source: tt4b.option.AppearanceOption) { target.displayWhitelistMenu = source.displayWhitelistMenu target.displayBadgeText = source.displayBadgeText target.badgeBgColor = source.badgeBgColor @@ -47,7 +47,7 @@ const DEFAULT_SIDE_PANEL_ENABLED = true const _default = defineComponent((_props, ctx) => { - const { option } = useOption<timer.option.AppearanceOption>({ + const { option } = useOption<tt4b.option.AppearanceOption>({ defaultValue: DEFAULT_APPEARANCE, copy, onChange: processDarkMode, }) @@ -66,11 +66,11 @@ const _default = defineComponent((_props, ctx) => { } } satisfies CategoryInstance) - const handleLocaleChange = (newVal: timer.option.LocaleOption) => { + const handleLocaleChange = (newVal: tt4b.option.LocaleOption) => { option.locale = newVal // await maybe not work in Firefox, so calculate the real locale again // GG Firefox - const realLocale: timer.Locale = newVal === "default" + const realLocale: tt4b.Locale = newVal === "default" ? localeSameAsBrowser : newVal ElMessageBox({ @@ -107,7 +107,7 @@ const _default = defineComponent((_props, ctx) => { modelValue={option.locale} size="small" style={{ width: "120px" }} - onChange={(newVal: timer.option.LocaleOption) => handleLocaleChange(newVal)} + onChange={(newVal: tt4b.option.LocaleOption) => handleLocaleChange(newVal)} filterable options={allLocaleOptions} /> diff --git a/src/pages/app/components/Option/categories/Backup/Clear/index.tsx b/src/pages/app/components/Option/categories/Backup/Clear/index.tsx index da6683239..902db37ce 100644 --- a/src/pages/app/components/Option/categories/Backup/Clear/index.tsx +++ b/src/pages/app/components/Option/categories/Backup/Clear/index.tsx @@ -22,7 +22,7 @@ const STEP_TITLES = [ t(msg => msg.option.backup.confirmStep), ] -async function fetchStatResult(client: timer.backup.Client): Promise<StatResult> { +async function fetchStatResult(client: tt4b.backup.Client): Promise<StatResult> { const { id: specCid, maxDate, minDate = BIRTHDAY } = client const start = minDate ?? BIRTHDAY const end = maxDate ?? formatTimeYMD(Date.now()) diff --git a/src/pages/app/components/Option/categories/Backup/Clear/types.ts b/src/pages/app/components/Option/categories/Backup/Clear/types.ts index 4bd4a7b09..32fdcc5a3 100644 --- a/src/pages/app/components/Option/categories/Backup/Clear/types.ts +++ b/src/pages/app/components/Option/categories/Backup/Clear/types.ts @@ -1,5 +1,5 @@ export type ClearForm = { - client?: timer.backup.Client + client?: tt4b.backup.Client result?: StatResult } diff --git a/src/pages/app/components/Option/categories/Backup/ClientTable.tsx b/src/pages/app/components/Option/categories/Backup/ClientTable.tsx index 067963117..a5544daf3 100644 --- a/src/pages/app/components/Option/categories/Backup/ClientTable.tsx +++ b/src/pages/app/components/Option/categories/Backup/ClientTable.tsx @@ -26,21 +26,21 @@ const useStyle = () => { return { radioCls } } -const formatTime = (value: timer.backup.Client): string => { +const formatTime = (value: tt4b.backup.Client): string => { const { minDate, maxDate } = value || {} const min = minDate ? cvt2LocaleTime(minDate) : '' const max = maxDate ? cvt2LocaleTime(maxDate) : '' return `${min} - ${max}` } -const _default = defineComponent<{ onSelect: ArgCallback<timer.backup.Client> }>(props => { +const _default = defineComponent<{ onSelect: ArgCallback<tt4b.backup.Client> }>(props => { const { data: list, loading, refresh } = useRequest(allBackupClients, { defaultValue: [], onError: e => ElMessage.error(e instanceof Error ? e.message : String(e ?? 'Unknown error...')) }) const selectedCid = ref<string>() - const handleRowSelect = (row: timer.backup.Client) => { + const handleRowSelect = (row: tt4b.backup.Client) => { selectedCid.value = row.id props.onSelect?.(toRaw(row)) } @@ -53,7 +53,7 @@ const _default = defineComponent<{ onSelect: ArgCallback<timer.backup.Client> }> maxHeight="40vh" class="backup-client-table" highlightCurrentRow - onCurrent-change={(row: timer.backup.Client) => handleRowSelect(row)} + onCurrent-change={(row: tt4b.backup.Client) => handleRowSelect(row)} emptyText={loading.value ? 'Loading data ...' : 'Empty data'} > <ElTableColumn @@ -68,7 +68,7 @@ const _default = defineComponent<{ onSelect: ArgCallback<timer.backup.Client> }> underline="never" /> ), - default: ({ row }: RenderRowData<timer.backup.Client>) => ( + default: ({ row }: RenderRowData<tt4b.backup.Client>) => ( <ElRadio class={radioCls} value={row.id} @@ -83,14 +83,14 @@ const _default = defineComponent<{ onSelect: ArgCallback<timer.backup.Client> }> align="center" headerAlign="center" width={320} - formatter={(client: timer.backup.Client) => client.id || '-'} + formatter={(client: tt4b.backup.Client) => client.id || '-'} /> <ElTableColumn label={t(msg => msg.option.backup.client, { input: '' })} align="center" headerAlign="center" > - {({ row: client }: RenderRowData<(timer.backup.Client & { current: boolean })>) => <> + {({ row: client }: RenderRowData<(tt4b.backup.Client & { current: boolean })>) => <> {client.name || '-'} <ElTag v-show={client.current} diff --git a/src/pages/app/components/Option/categories/Backup/Download/index.tsx b/src/pages/app/components/Option/categories/Backup/Download/index.tsx index d56d804a2..ac0d3544c 100644 --- a/src/pages/app/components/Option/categories/Backup/Download/index.tsx +++ b/src/pages/app/components/Option/categories/Backup/Download/index.tsx @@ -22,7 +22,7 @@ const STEP_TITLES = [ t(msg => msg.option.backup.confirmStep), ] -async function fetchData(client: timer.backup.Client): Promise<timer.imported.Data> { +async function fetchData(client: tt4b.backup.Client): Promise<tt4b.imported.Data> { const { id: specCid, maxDate, minDate } = client const start = minDate ?? BIRTHDAY const end = maxDate ?? formatTimeYMD(Date.now()) diff --git a/src/pages/app/components/Option/categories/Backup/Download/types.ts b/src/pages/app/components/Option/categories/Backup/Download/types.ts index 215cc3c9a..6a60bdf1a 100644 --- a/src/pages/app/components/Option/categories/Backup/Download/types.ts +++ b/src/pages/app/components/Option/categories/Backup/Download/types.ts @@ -1,5 +1,5 @@ export type DownloadForm = { - client?: timer.backup.Client - resolution?: timer.imported.ConflictResolution - data: timer.imported.Data + client?: tt4b.backup.Client + resolution?: tt4b.imported.ConflictResolution + data: tt4b.imported.Data } \ No newline at end of file diff --git a/src/pages/app/components/Option/categories/Backup/Footer.tsx b/src/pages/app/components/Option/categories/Backup/Footer.tsx index 6cf3ef2fb..5e94910e2 100644 --- a/src/pages/app/components/Option/categories/Backup/Footer.tsx +++ b/src/pages/app/components/Option/categories/Backup/Footer.tsx @@ -28,7 +28,7 @@ const useStyle = () => { const TIME_FORMAT = t(msg => msg.calendar.timeFormat) -const _default = defineComponent<{ type: timer.backup.Type }>(props => { +const _default = defineComponent<{ type: tt4b.backup.Type }>(props => { const { data: lastTime, refresh: refreshLastTime } = useRequest(() => getLastBackUp(props.type), { deps: () => props.type, diff --git a/src/pages/app/components/Option/categories/Backup/index.tsx b/src/pages/app/components/Option/categories/Backup/index.tsx index 44ed660d7..df0f1333f 100644 --- a/src/pages/app/components/Option/categories/Backup/index.tsx +++ b/src/pages/app/components/Option/categories/Backup/index.tsx @@ -4,12 +4,12 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { OptionItem, OptionLines, OptionTooltip } from '@app/components/Option/components' -import { t } from '@app/locale' import { DEFAULT_VAULT as DEFAULT_OBSIDIAN_BUCKET, DEFAULT_ENDPOINT as DEFAULT_OBSIDIAN_ENDPOINT, } from "@api/obsidian" +import { OptionItem, OptionLines, OptionTooltip } from '@app/components/Option/components' +import { t } from '@app/locale' import { ElInput, ElSelect } from "element-plus" import { computed, defineComponent } from "vue" import type { CategoryInstance } from '../types' @@ -17,14 +17,14 @@ import AutoInput from "./AutoInput" import Footer from "./Footer" import { useBackup } from "./useBackup" -const ALL_TYPES: timer.backup.Type[] = [ +const ALL_TYPES: tt4b.backup.Type[] = [ 'none', 'gist', 'web_dav', 'obsidian_local_rest_api', ] -const TYPE_NAMES: Record<timer.backup.Type, string> = { +const TYPE_NAMES: Record<tt4b.backup.Type, string> = { none: t(msg => msg.option.off), gist: 'GitHub Gist', obsidian_local_rest_api: 'Obsidian - Local REST API', @@ -48,7 +48,7 @@ const _default = defineComponent((_, ctx) => { <ElSelect modelValue={option.backupType} size="small" - onChange={(val: timer.backup.Type) => option.backupType = val} + onChange={(val: tt4b.backup.Type) => option.backupType = val} options={ALL_TYPES.map(value => ({ value, label: TYPE_NAMES[value] }))} /> </OptionItem> diff --git a/src/pages/app/components/Option/categories/Backup/useBackup.ts b/src/pages/app/components/Option/categories/Backup/useBackup.ts index 1af57ac90..9b2e6e7de 100644 --- a/src/pages/app/components/Option/categories/Backup/useBackup.ts +++ b/src/pages/app/components/Option/categories/Backup/useBackup.ts @@ -2,7 +2,7 @@ import { useOption } from '@app/components/Option/useOption' import { DEFAULT_BACKUP } from "@util/constant/option" import { computed } from "vue" -function copy(target: timer.option.BackupOption, source: timer.option.BackupOption) { +function copy(target: tt4b.option.BackupOption, source: tt4b.option.BackupOption) { target.backupType = source.backupType target.autoBackUp = source.autoBackUp target.autoBackUpInterval = source.autoBackUpInterval @@ -13,7 +13,7 @@ function copy(target: timer.option.BackupOption, source: timer.option.BackupOpti } export const useBackup = () => { - const { option } = useOption<timer.option.BackupOption>({ defaultValue: DEFAULT_BACKUP, copy }) + const { option } = useOption<tt4b.option.BackupOption>({ defaultValue: DEFAULT_BACKUP, copy }) const reset = () => { // Only reset type and auto flag @@ -34,7 +34,7 @@ export const useBackup = () => { } }) - const ext = computed<timer.backup.TypeExt | undefined>({ + const ext = computed<tt4b.backup.TypeExt | undefined>({ get: () => option.backupExts?.[option.backupType], set: val => { const typeVal = option.backupType @@ -47,12 +47,12 @@ export const useBackup = () => { }, }) - const setExtField = (field: keyof timer.backup.TypeExt, val: string) => { + const setExtField = (field: keyof tt4b.backup.TypeExt, val: string) => { const newVal = { ...(ext.value || {}), [field]: val?.trim?.() } ext.value = newVal } - const setLoginField = (field: keyof timer.backup.LoginInfo, val: string) => { + const setLoginField = (field: keyof tt4b.backup.LoginInfo, val: string) => { const typeVal = option.backupType if (!typeVal) return const newLogin = { diff --git a/src/pages/app/components/Option/categories/Limit/index.tsx b/src/pages/app/components/Option/categories/Limit/index.tsx index 3d9492378..f94bab970 100644 --- a/src/pages/app/components/Option/categories/Limit/index.tsx +++ b/src/pages/app/components/Option/categories/Limit/index.tsx @@ -21,7 +21,7 @@ import { useVerify } from "./useVerify" const useLevelSelectStyle = () => { const selectNs = useNamespace('select') - const localWidth: Partial<Record<timer.Locale, number>> = { + const localWidth: Partial<Record<tt4b.Locale, number>> = { en: 330, uk: 330, zh_CN: 210, @@ -38,7 +38,7 @@ const useLevelSelectStyle = () => { return { width, cls } } -const ALL_LEVEL: timer.limit.RestrictionLevel[] = [ +const ALL_LEVEL: tt4b.limit.RestrictionLevel[] = [ 'nothing', 'verification', 'password', @@ -46,13 +46,13 @@ const ALL_LEVEL: timer.limit.RestrictionLevel[] = [ 'strict', ] -const ALL_DIFF: timer.limit.VerificationDifficulty[] = [ +const ALL_DIFF: tt4b.limit.VerificationDifficulty[] = [ 'easy', 'hard', 'disgusting', ] -function copy(target: timer.option.LimitOption, source: Readonly<timer.option.LimitOption>) { +function copy(target: tt4b.option.LimitOption, source: Readonly<tt4b.option.LimitOption>) { target.limitPrompt = source.limitPrompt target.limitLevel = source.limitLevel target.limitPassword = source.limitPassword @@ -62,9 +62,9 @@ function copy(target: timer.option.LimitOption, source: Readonly<timer.option.Li target.limitDelayDuration = source.limitDelayDuration } -function reset(target: timer.option.LimitOption) { +function reset(target: tt4b.option.LimitOption) { const defaultValue: MakeOptional< - timer.option.LimitOption, 'limitPassword' | 'limitVerifyDifficulty' | 'limitReminderDuration' + tt4b.option.LimitOption, 'limitPassword' | 'limitVerifyDifficulty' | 'limitReminderDuration' > = structuredClone(DEFAULT_LIMIT) // Not to reset limitPassword delete defaultValue.limitPassword @@ -96,7 +96,7 @@ const TestButton: FunctionalComponent<{ onClick: NoArgCallback }> = props => ( ) const _default = defineComponent((_, ctx) => { - const { option } = useOption<timer.option.LimitOption>({ defaultValue: DEFAULT_LIMIT, copy }) + const { option } = useOption<tt4b.option.LimitOption>({ defaultValue: DEFAULT_LIMIT, copy }) const { verified, verify } = useVerify(option) const { modifyPsw } = usePswEdit({ reset: () => option.limitPassword }) const { setup2fa } = use2faSetup() @@ -105,7 +105,7 @@ const _default = defineComponent((_, ctx) => { reset: () => verify().then(() => reset(option)).catch(() => { }) } satisfies CategoryInstance) - const handleLevelChange = (val: timer.limit.RestrictionLevel) => { + const handleLevelChange = (val: tt4b.limit.RestrictionLevel) => { verify().then(async () => { if (val === "strict") { await confirm4Strict() @@ -203,7 +203,7 @@ const _default = defineComponent((_, ctx) => { <ElSelect modelValue={option.limitVerifyDifficulty} size="small" - onChange={(val: timer.limit.VerificationDifficulty) => verify() + onChange={(val: tt4b.limit.VerificationDifficulty) => verify() .then(() => option.limitVerifyDifficulty = val) .catch(console.warn) } diff --git a/src/pages/app/components/Option/categories/Limit/useVerify.ts b/src/pages/app/components/Option/categories/Limit/useVerify.ts index 0a649ea8a..d69992c41 100644 --- a/src/pages/app/components/Option/categories/Limit/useVerify.ts +++ b/src/pages/app/components/Option/categories/Limit/useVerify.ts @@ -2,7 +2,7 @@ import { listLimits } from "@api/sw/limit" import { judgeVerificationRequired, processVerification } from "@app/util/limit/index" import { ref } from "vue" -export const useVerify = (option: timer.option.LimitOption) => { +export const useVerify = (option: tt4b.option.LimitOption) => { const verified = ref(false) const verify = async (): Promise<void> => { diff --git a/src/pages/app/components/Option/categories/Notification/index.tsx b/src/pages/app/components/Option/categories/Notification/index.tsx index ae3ba0f46..cc649c48a 100644 --- a/src/pages/app/components/Option/categories/Notification/index.tsx +++ b/src/pages/app/components/Option/categories/Notification/index.tsx @@ -8,13 +8,13 @@ import Footer from './Footer' import { useNotification } from './useNotification' import usePermission from './usePermission' -const CYCLE_LABELS: Record<timer.notification.Cycle, string> = { +const CYCLE_LABELS: Record<tt4b.notification.Cycle, string> = { none: t(msg => msg.option.off), daily: t(msg => msg.option.notification.cycle.daily), weekly: t(msg => msg.option.notification.cycle.weekly), } -const METHOD_LABELS: Record<timer.notification.Method, string> = { +const METHOD_LABELS: Record<tt4b.notification.Method, string> = { browser: t(msg => msg.option.notification.method.browser), callback: t(msg => msg.option.notification.method.callback.label), } @@ -29,7 +29,7 @@ const Notification = defineComponent((_, ctx) => { const { checkRequest } = usePermission() - const onCycleChange = async (val: timer.notification.Cycle) => { + const onCycleChange = async (val: tt4b.notification.Cycle) => { if (val === 'none') { option.notificationCycle = val return @@ -38,7 +38,7 @@ const Notification = defineComponent((_, ctx) => { result ? option.notificationCycle = val : ElMessage.info('Denied by user') } - const onMethodChange = async (val: timer.notification.Method) => { + const onMethodChange = async (val: tt4b.notification.Method) => { const result = await checkRequest(val) result ? option.notificationMethod = val : ElMessage.info('Denied by user') } @@ -54,8 +54,8 @@ const Notification = defineComponent((_, ctx) => { modelValue={option.notificationCycle} size="small" style={{ width: "120px" } satisfies StyleValue} - onChange={val => onCycleChange(val as timer.notification.Cycle)} - options={Object.entries(CYCLE_LABELS).map(([value, label]) => ({ value: value as timer.notification.Cycle, label }))} + onChange={val => onCycleChange(val as tt4b.notification.Cycle)} + options={Object.entries(CYCLE_LABELS).map(([value, label]) => ({ value: value as tt4b.notification.Cycle, label }))} /> {option.notificationCycle === 'weekly' && <> <ElSelect @@ -83,8 +83,8 @@ const Notification = defineComponent((_, ctx) => { modelValue={option.notificationMethod} size="small" style={{ width: "150px" } satisfies StyleValue} - onChange={val => onMethodChange(val as timer.notification.Method)} - options={Object.entries(METHOD_LABELS).map(([value, label]) => ({ value: value as timer.notification.Method, label }))} + onChange={val => onMethodChange(val as tt4b.notification.Method)} + options={Object.entries(METHOD_LABELS).map(([value, label]) => ({ value: value as tt4b.notification.Method, label }))} /> </OptionItem> {isNotNone.value && option.notificationMethod === 'callback' && ( diff --git a/src/pages/app/components/Option/categories/Notification/useNotification.ts b/src/pages/app/components/Option/categories/Notification/useNotification.ts index 08d115c94..1f6c4aa4d 100644 --- a/src/pages/app/components/Option/categories/Notification/useNotification.ts +++ b/src/pages/app/components/Option/categories/Notification/useNotification.ts @@ -2,7 +2,7 @@ import { useOption } from '@app/components/Option/useOption' import { DEFAULT_NOTIFICATION } from '@util/constant/option' import { computed } from 'vue' -function copy(target: timer.option.NotificationOption, source: Readonly<timer.option.NotificationOption>) { +function copy(target: tt4b.option.NotificationOption, source: Readonly<tt4b.option.NotificationOption>) { target.notificationCycle = source.notificationCycle target.notificationOffset = source.notificationOffset target.notificationMethod = source.notificationMethod @@ -13,7 +13,7 @@ function copy(target: timer.option.NotificationOption, source: Readonly<timer.op const MIN_PER_DAY = 24 * 60 export const useNotification = () => { - const { option } = useOption<timer.option.NotificationOption>({ defaultValue: DEFAULT_NOTIFICATION, copy }) + const { option } = useOption<tt4b.option.NotificationOption>({ defaultValue: DEFAULT_NOTIFICATION, copy }) const weekday = computed<number | null>({ get() { diff --git a/src/pages/app/components/Option/categories/Notification/usePermission.tsx b/src/pages/app/components/Option/categories/Notification/usePermission.tsx index 3789ba09f..d296b9d56 100644 --- a/src/pages/app/components/Option/categories/Notification/usePermission.tsx +++ b/src/pages/app/components/Option/categories/Notification/usePermission.tsx @@ -11,7 +11,7 @@ const judgeDataPerm = async () => { return !!perm?.data_collection?.includes?.(DATA_PERM) } -const doRequestPerm = async (method: timer.notification.Method | undefined): Promise<boolean> => { +const doRequestPerm = async (method: tt4b.notification.Method | undefined): Promise<boolean> => { if (!method) return true if (method === 'browser') { return await requestPerm(BASE_PERM) @@ -23,7 +23,7 @@ const doRequestPerm = async (method: timer.notification.Method | undefined): Pro } const usePermission = () => { - const granted = reactive<Record<timer.notification.Method, boolean>>({ + const granted = reactive<Record<tt4b.notification.Method, boolean>>({ browser: false, callback: false, }) @@ -33,7 +33,7 @@ const usePermission = () => { granted.browser = await hasPerm(BASE_PERM) }) - const checkRequest = async (method: timer.notification.Method) => { + const checkRequest = async (method: tt4b.notification.Method) => { // Invalid method to check if (granted[method]) return true diff --git a/src/pages/app/components/Option/categories/Tracking.tsx b/src/pages/app/components/Option/categories/Tracking.tsx index c0446e672..7faebc33d 100644 --- a/src/pages/app/components/Option/categories/Tracking.tsx +++ b/src/pages/app/components/Option/categories/Tracking.tsx @@ -20,21 +20,21 @@ import { OptionItem, OptionLines, OptionTag, OptionTooltip } from '../components import { useOption } from "../useOption" import type { CategoryInstance } from './types' -const ALL_STORAGES: Record<timer.option.StorageType, string> = { +const ALL_STORAGES: Record<tt4b.option.StorageType, string> = { classic: 'chrome.storage.local', indexed_db: 'IndexedDB', } -const weekStartOptionPairs: [[timer.option.WeekStartOption, string]] = [ +const weekStartOptionPairs: [[tt4b.option.WeekStartOption, string]] = [ ['default', t(msg => msg.option.tracking.weekStartAsNormal)] ] const allWeekDays = t(msg => msg.calendar.weekDays) .split('|') - .map((weekDay, idx) => [idx + 1, weekDay] as [timer.option.WeekStartOption, string]) + .map((weekDay, idx) => [idx + 1, weekDay] as [tt4b.option.WeekStartOption, string]) rotate(allWeekDays, locale === 'zh_CN' ? 0 : 1, true) allWeekDays.forEach(weekDayInfo => weekStartOptionPairs.push(weekDayInfo)) -function copy(target: timer.option.TrackingOption, source: Readonly<timer.option.TrackingOption>) { +function copy(target: tt4b.option.TrackingOption, source: Readonly<tt4b.option.TrackingOption>) { target.countLocalFiles = source.countLocalFiles target.countTabGroup = source.countTabGroup target.weekStart = source.weekStart @@ -44,7 +44,7 @@ function copy(target: timer.option.TrackingOption, source: Readonly<timer.option } const _default = defineComponent((_props, ctx) => { - const { option } = useOption<timer.option.TrackingOption>({ defaultValue: DEFAULT_TRACKING, copy }) + const { option } = useOption<tt4b.option.TrackingOption>({ defaultValue: DEFAULT_TRACKING, copy }) const { data: fileAccess } = useRequest(isAllowedFileSchemeAccess) const reset = () => { // Not to reset these fields @@ -66,7 +66,7 @@ const _default = defineComponent((_props, ctx) => { } ) - const handleChangeStorage = (type: timer.option.StorageType) => { + const handleChangeStorage = (type: tt4b.option.StorageType) => { const msg = t(msg => msg.option.tracking.storageConfirm, { type: ALL_STORAGES[type] }) ElMessageBox.confirm(msg, { type: 'warning' }) .then(() => changeStorageType(type)) @@ -168,7 +168,7 @@ const _default = defineComponent((_props, ctx) => { modelValue={option.weekStart} size="small" style={{ width: '120px' }} - onChange={(val: timer.option.WeekStartOption) => option.weekStart = val} + onChange={(val: tt4b.option.WeekStartOption) => option.weekStart = val} options={weekStartOptionPairs.map(([value, label]) => ({ value, label }))} /> </OptionItem> diff --git a/src/pages/app/components/Option/export-import.ts b/src/pages/app/components/Option/export-import.ts index a9e1c0da9..6938f3f42 100644 --- a/src/pages/app/components/Option/export-import.ts +++ b/src/pages/app/components/Option/export-import.ts @@ -11,7 +11,7 @@ import { mergeObject } from '@util/lang' interface ExportedSettings { version: string timestamp: number - settings: timer.option.AllOption + settings: tt4b.option.AllOption } /** @@ -43,18 +43,18 @@ export async function importSettings(jsonString: string): Promise<void> { const validatedSettings = await validateAndMergeSettings(importData.settings) // Set the imported settings - await setOption(validatedSettings as Partial<timer.option.AllOption>) + await setOption(validatedSettings as Partial<tt4b.option.AllOption>) } /** * Validate imported settings and merge with defaults to ensure all required fields exist */ -async function validateAndMergeSettings(importedSettings: Partial<timer.option.AllOption>): Promise<timer.option.AllOption> { +async function validateAndMergeSettings(importedSettings: Partial<tt4b.option.AllOption>): Promise<tt4b.option.AllOption> { // Get current user settings as defaults instead of default options const current = await getOption() // Delete client name delete importedSettings['clientName'] - return mergeObject(current ?? {}, importedSettings) as timer.option.AllOption + return mergeObject(current ?? {}, importedSettings) as tt4b.option.AllOption } /** diff --git a/src/pages/app/components/Option/useOption.ts b/src/pages/app/components/Option/useOption.ts index a8aa50621..438337c74 100644 --- a/src/pages/app/components/Option/useOption.ts +++ b/src/pages/app/components/Option/useOption.ts @@ -12,7 +12,7 @@ type MutableKeys<T> = { -readonly [K in keyof T]: T[K] } -export const useOption = <T extends object = Partial<timer.option.AllOption>>(options: Options<T>) => { +export const useOption = <T extends object = Partial<tt4b.option.AllOption>>(options: Options<T>) => { const { defaultValue, copy, onChange } = options const option = reactive<MutableKeys<T>>(typeof defaultValue === 'function' ? defaultValue() : structuredClone(defaultValue)) diff --git a/src/pages/app/components/Report/Filter/BatchDelete.tsx b/src/pages/app/components/Report/Filter/BatchDelete.tsx index b49a3db83..caad0b7c4 100644 --- a/src/pages/app/components/Report/Filter/BatchDelete.tsx +++ b/src/pages/app/components/Report/Filter/BatchDelete.tsx @@ -11,7 +11,7 @@ import { computed, defineComponent } from "vue" import { useReportComponent, useReportFilter } from "../context" import type { DisplayComponent, ReportFilterOption } from "../types" -async function computeBatchDeleteMsg(selected: timer.stat.Row[], mergeDate: boolean, dateRange: DateRange): Promise<string> { +async function computeBatchDeleteMsg(selected: tt4b.stat.Row[], mergeDate: boolean, dateRange: DateRange): Promise<string> { const hosts: string[] = [] const groupIds: number[] = [] selected.forEach(row => { @@ -104,7 +104,7 @@ async function handleBatchDelete(displayComp: DisplayComponent | undefined, filt }) } -async function deleteBatch(selected: timer.stat.Row[], mergeDate: boolean, dateRange: DateRange) { +async function deleteBatch(selected: tt4b.stat.Row[], mergeDate: boolean, dateRange: DateRange) { if (mergeDate) { // Delete according to the date range const date = cvtDateRange2Str(dateRange) diff --git a/src/pages/app/components/Report/Filter/MergeFilterItem.tsx b/src/pages/app/components/Report/Filter/MergeFilterItem.tsx index 5bf0d477b..0171f6989 100644 --- a/src/pages/app/components/Report/Filter/MergeFilterItem.tsx +++ b/src/pages/app/components/Report/Filter/MergeFilterItem.tsx @@ -8,7 +8,7 @@ import { computed, defineComponent, StyleValue } from "vue" import { type JSX } from "vue/jsx-runtime" import { useReportFilter } from "../context" -const METHOD_ICONS: Record<timer.stat.MergeMethod, JSX.Element> = { +const METHOD_ICONS: Record<tt4b.stat.MergeMethod, JSX.Element> = { cate: <Collection />, date: <Calendar />, domain: <Link />, @@ -22,13 +22,13 @@ const MergeFilterItem = defineComponent<{}>(() => { onGroupDisabled: () => mergeMethod.value.filter(v => v !== 'group') }) const mergeItems = computed(() => { - const res = ['date', ...siteMergeItems.value] satisfies timer.stat.MergeMethod[] + const res = ['date', ...siteMergeItems.value] satisfies tt4b.stat.MergeMethod[] return cate.enabled ? res : res.filter(m => m !== 'cate') }) const mergeMethod = computed({ get: () => { const { mergeDate, siteMerge } = filter - const res: timer.stat.MergeMethod[] = [] + const res: tt4b.stat.MergeMethod[] = [] mergeDate && (res.push('date')) siteMerge && (res.push(siteMerge)) return res @@ -51,7 +51,7 @@ const MergeFilterItem = defineComponent<{}>(() => { </ElText> <ElCheckboxGroup modelValue={mergeMethod.value} - onChange={val => mergeMethod.value = val as timer.stat.MergeMethod[]} + onChange={val => mergeMethod.value = val as tt4b.stat.MergeMethod[]} > {mergeItems.value.map(method => ( <ElCheckboxButton value={method}> diff --git a/src/pages/app/components/Report/List/Item.tsx b/src/pages/app/components/Report/List/Item.tsx index b6ad6ea54..2561035be 100644 --- a/src/pages/app/components/Report/List/Item.tsx +++ b/src/pages/app/components/Report/List/Item.tsx @@ -16,9 +16,9 @@ import TooltipSiteList from "../components/TooltipSiteList" import { useReportFilter } from "../context" type Props = { - value: timer.stat.Row + value: tt4b.stat.Row onSelectedChange: ArgCallback<boolean> - onDelete?: ArgCallback<timer.stat.Row> + onDelete?: ArgCallback<tt4b.stat.Row> } const useContentStyle = () => { @@ -71,7 +71,7 @@ const _default = defineComponent<Props>(props => { trigger="click" usePopover={props.value.siteKey.type === 'merged'} v-slots={{ - content: () => <TooltipSiteList modelValue={mergedRows as timer.stat.SiteRow[]} />, + content: () => <TooltipSiteList modelValue={mergedRows as tt4b.stat.SiteRow[]} />, }} > <HostAlert diff --git a/src/pages/app/components/Report/Table/columns/CateColumn.tsx b/src/pages/app/components/Report/Table/columns/CateColumn.tsx index 1d871a746..48498b325 100644 --- a/src/pages/app/components/Report/Table/columns/CateColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/CateColumn.tsx @@ -8,7 +8,7 @@ import { getRelatedCateId, identifyStatKey, isCate, isGroup, isSite } from "@uti import { Effect, ElTableColumn, ElText, ElTooltip, type RenderRowData } from "element-plus" import { defineComponent } from "vue" -const renderMerged = (cateId: number, categories: timer.site.Cate[], merged: timer.stat.SiteRow[]) => { +const renderMerged = (cateId: number, categories: tt4b.site.Cate[], merged: tt4b.stat.SiteRow[]) => { const [cateName, isNotSet] = CATE_NOT_SET_ID === cateId ? [t(msg => msg.shared.cate.notSet), true] : [categories.find(c => c.id === cateId)?.name, false] @@ -32,20 +32,20 @@ const renderMerged = (cateId: number, categories: timer.site.Cate[], merged: tim } type Props = { - onChange: (key: timer.site.SiteKey, newCate: number | undefined) => void, + onChange: (key: tt4b.site.SiteKey, newCate: number | undefined) => void, } const CateColumn = defineComponent<Props>(props => { const cate = useCategory() return () => cate.enabled ? ( <ElTableColumn label={t(msg => msg.siteManage.column.cate)} minWidth={140}> - {({ row }: RenderRowData<timer.stat.Row>) => { + {({ row }: RenderRowData<tt4b.stat.Row>) => { if (!row || isGroup(row)) return const { mergedRows } = row const cateId = getRelatedCateId(row) return ( <Flex key={`${identifyStatKey(row)}_${cateId}`} justify="center"> - {isCate(row) && renderMerged(row.cateKey, cate.all, (mergedRows ?? []) as timer.stat.SiteRow[])} + {isCate(row) && renderMerged(row.cateKey, cate.all, (mergedRows ?? []) as tt4b.stat.SiteRow[])} {isSite(row) && ( <CategoryEditable siteKey={row.siteKey} diff --git a/src/pages/app/components/Report/Table/columns/DateColumn.tsx b/src/pages/app/components/Report/Table/columns/DateColumn.tsx index 97c920705..3f341d755 100644 --- a/src/pages/app/components/Report/Table/columns/DateColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/DateColumn.tsx @@ -19,7 +19,7 @@ const DateColumn: FunctionalComponent = () => ( align="center" sortable="custom" > - {({ row }: RenderRowData<timer.stat.Row>) => <span>{cvt2LocaleTime(row.date)}</span>} + {({ row }: RenderRowData<tt4b.stat.Row>) => <span>{cvt2LocaleTime(row.date)}</span>} </ElTableColumn> ) diff --git a/src/pages/app/components/Report/Table/columns/GroupColumn.tsx b/src/pages/app/components/Report/Table/columns/GroupColumn.tsx index 98570dd07..fc30e3664 100644 --- a/src/pages/app/components/Report/Table/columns/GroupColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/GroupColumn.tsx @@ -13,7 +13,7 @@ const GroupColumn = defineComponent(() => { align="center" label={t(msg => msg.item.group)} width={140} - v-slots={({ row }: RenderRowData<timer.stat.Row>) => { + v-slots={({ row }: RenderRowData<tt4b.stat.Row>) => { if (!isGroup(row)) return const { groupKey } = row const { color, title } = groupMap.value[groupKey] ?? {} diff --git a/src/pages/app/components/Report/Table/columns/HostColumn.tsx b/src/pages/app/components/Report/Table/columns/HostColumn.tsx index 091640142..d31761191 100644 --- a/src/pages/app/components/Report/Table/columns/HostColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/HostColumn.tsx @@ -27,7 +27,7 @@ const _default = defineComponent(() => { sortable="custom" align="center" > - {({ row }: RenderRowData<timer.stat.Row>) => ( + {({ row }: RenderRowData<tt4b.stat.Row>) => ( <Flex key={isSite(row) ? identifySiteKey(row.siteKey) : ''} justify="center"> <TooltipWrapper usePopover={filter?.siteMerge === 'domain'} @@ -37,7 +37,7 @@ const _default = defineComponent(() => { v-slots={{ content: () => ( <TooltipSiteList - modelValue={isGroup(row) ? undefined : (row.mergedRows as timer.stat.SiteRow[] | undefined)} + modelValue={isGroup(row) ? undefined : (row.mergedRows as tt4b.stat.SiteRow[] | undefined)} /> ), default: () => isSite(row) ? <HostAlert value={row.siteKey} iconUrl={row.iconUrl} /> : '', diff --git a/src/pages/app/components/Report/Table/columns/OperationColumn.tsx b/src/pages/app/components/Report/Table/columns/OperationColumn.tsx index 2d2be8f4b..2f29ed29b 100644 --- a/src/pages/app/components/Report/Table/columns/OperationColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/OperationColumn.tsx @@ -20,7 +20,7 @@ import { ElButton, ElMessage, ElTableColumn, type RenderRowData } from "element- import { computed, defineComponent } from "vue" import { useRouter } from "vue-router" -const LOCALE_WIDTH: { [locale in timer.Locale]: number } = { +const LOCALE_WIDTH: { [locale in tt4b.Locale]: number } = { en: 330, zh_CN: 290, ja: 360, @@ -38,16 +38,16 @@ const LOCALE_WIDTH: { [locale in timer.Locale]: number } = { } type Props = { - onDelete?: ArgCallback<timer.stat.Row> + onDelete?: ArgCallback<tt4b.stat.Row> } -const analysisVisible = (row: timer.stat.Row) => { +const analysisVisible = (row: tt4b.stat.Row) => { if (isGroup(row)) return false if (isCate(row)) return row.cateKey !== CATE_NOT_SET_ID return true } -const deleteVisible = (row: timer.stat.Row) => { +const deleteVisible = (row: tt4b.stat.Row) => { if (isCate(row)) return false if (isSite(row) && row.siteKey.type === 'merged') return false return true @@ -69,7 +69,7 @@ const _default = defineComponent<Props>(({ onDelete }) => { const { refresh: onAddWhitelist } = useManualRequest(addWhitelist, { onSuccess: onWhitelistSuccess }) const { refresh: onRemoveWhitelist } = useManualRequest(deleteWhitelist, { onSuccess: onWhitelistSuccess }) - const jump2Analysis = (row: timer.stat.Row) => { + const jump2Analysis = (row: tt4b.stat.Row) => { let query: AppAnalysisQuery if (isCate(row)) { query = { cateId: row.cateKey?.toString?.() } @@ -87,7 +87,7 @@ const _default = defineComponent<Props>(({ onDelete }) => { label={t(msg => msg.button.operation)} align="center" > - {({ row }: RenderRowData<timer.stat.Row>) => <> + {({ row }: RenderRowData<tt4b.stat.Row>) => <> {/* Analysis */} {analysisVisible(row) && ( <ElButton diff --git a/src/pages/app/components/Report/Table/columns/TimeColumn.tsx b/src/pages/app/components/Report/Table/columns/TimeColumn.tsx index e79f74c14..5843ec336 100644 --- a/src/pages/app/components/Report/Table/columns/TimeColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/TimeColumn.tsx @@ -16,7 +16,7 @@ import { Effect, ElTableColumn, type RenderRowData } from "element-plus" import { defineComponent } from "vue" type Props = { - dimension: timer.core.Dimension & 'focus' | 'run' + dimension: tt4b.core.Dimension & 'focus' | 'run' } const TimeColumn = defineComponent<Props>(props => { @@ -30,7 +30,7 @@ const TimeColumn = defineComponent<Props>(props => { align="center" sortable="custom" > - {({ row }: RenderRowData<timer.stat.Row>) => ( + {({ row }: RenderRowData<tt4b.stat.Row>) => ( <TooltipWrapper usePopover={filter.readRemote} placement="top" diff --git a/src/pages/app/components/Report/Table/columns/VisitColumn.tsx b/src/pages/app/components/Report/Table/columns/VisitColumn.tsx index 6a13467bb..232a52b50 100644 --- a/src/pages/app/components/Report/Table/columns/VisitColumn.tsx +++ b/src/pages/app/components/Report/Table/columns/VisitColumn.tsx @@ -24,7 +24,7 @@ const VisitColumn = defineComponent(() => { align="center" sortable="custom" > - {({ row }: RenderRowData<timer.stat.Row>) => ( + {({ row }: RenderRowData<tt4b.stat.Row>) => ( <TooltipWrapper usePopover={filter.readRemote} placement="top" diff --git a/src/pages/app/components/Report/Table/index.tsx b/src/pages/app/components/Report/Table/index.tsx index b57f17b0d..03937fcd1 100644 --- a/src/pages/app/components/Report/Table/index.tsx +++ b/src/pages/app/components/Report/Table/index.tsx @@ -32,7 +32,7 @@ import OperationColumn from "./columns/OperationColumn" import TimeColumn from "./columns/TimeColumn" import VisitColumn from "./columns/VisitColumn" -async function handleAliasChange(key: timer.site.SiteKey, newAlias: string | undefined, data: timer.stat.Row[]) { +async function handleAliasChange(key: tt4b.site.SiteKey, newAlias: string | undefined, data: tt4b.stat.Row[]) { newAlias = await changeSiteAlias(key, newAlias) data.filter(isSite) .filter(item => siteEqual(item.siteKey, key)) @@ -54,7 +54,7 @@ const computeVisible = (filter: ReportFilterOption): ColumnVisible => { const _default = defineComponent((_, ctx) => { const rtl = isRtl() - const [page, setPage] = useState<timer.common.PageQuery>({ size: 20, num: 1 }) + const [page, setPage] = useState<tt4b.common.PageQuery>({ size: 20, num: 1 }) const sort = useReportSort() const filter = useReportFilter() const visible = computed(() => computeVisible(filter)) @@ -70,13 +70,13 @@ const _default = defineComponent((_, ctx) => { } = useManualRequest(async () => { const { siteMerge, dateRange, query, readRemote: inclusiveRemote, cateIds } = filter const date = cvtDateRange2Str(dateRange) - let rows: timer.stat.Row[] = [] + let rows: tt4b.stat.Row[] = [] if (siteMerge === 'group') { rows = await listGroupStats({ date, query }) } else if (siteMerge === 'cate') { rows = await listCateStats({ date, query, cateIds, inclusiveRemote }) } else { - const param: timer.stat.SiteQuery = { + const param: tt4b.stat.SiteQuery = { date, query, cateIds, inclusiveRemote, mergeHost: siteMerge === 'domain', } @@ -92,7 +92,7 @@ const _default = defineComponent((_, ctx) => { const docVisible = useDocumentVisibility() watch(docVisible, () => docVisible.value && refresh()) - const [selection, setSelection] = useState<timer.stat.Row[]>([]) + const [selection, setSelection] = useState<tt4b.stat.Row[]>([]) ctx.expose({ getSelected: () => selection.value, refresh, @@ -126,7 +126,7 @@ const _default = defineComponent((_, ctx) => { label={t(msg => msg.siteManage.column.alias)} minWidth={140} align="center" - v-slots={({ row }: { row: timer.stat.Row }) => ( + v-slots={({ row }: { row: tt4b.stat.Row }) => ( <Editable modelValue={getAlias(row)} onChange={newAlias => 'siteKey' in row && handleAliasChange(row.siteKey, newAlias, data.value.list)} diff --git a/src/pages/app/components/Report/common.ts b/src/pages/app/components/Report/common.ts index 73e6910eb..d6319bf0a 100644 --- a/src/pages/app/components/Report/common.ts +++ b/src/pages/app/components/Report/common.ts @@ -34,7 +34,7 @@ function computeRangeConfirmText(url: string, dateRange: DateRange): string { : t(msg => msg.item.operation.deleteConfirmMsgRange, { url, start, end }) } -export function computeDeleteConfirmMsg(row: timer.stat.Row, filterOption: ReportFilterOption, groupMap: Record<number, chrome.tabGroups.TabGroup>): string { +export function computeDeleteConfirmMsg(row: tt4b.stat.Row, filterOption: ReportFilterOption, groupMap: Record<number, chrome.tabGroups.TabGroup>): string { let name: string | undefined if (isGroup(row)) { name = getGroupName(groupMap, row) @@ -49,7 +49,7 @@ export function computeDeleteConfirmMsg(row: timer.stat.Row, filterOption: Repor : computeSingleConfirmText(name, date ?? '') } -export async function handleDelete(row: timer.stat.Row, filterOption: ReportFilterOption) { +export async function handleDelete(row: tt4b.stat.Row, filterOption: ReportFilterOption) { const { date } = row const { mergeDate, dateRange } = filterOption if (!mergeDate) { @@ -72,7 +72,7 @@ export async function handleDelete(row: timer.stat.Row, filterOption: ReportFilt isGroup(row) && await deleteSiteStatByGroup(row.groupKey, strRange) } -const cvtOrderDir = (order: ReportSort['order']): timer.common.SortDirection | undefined => { +const cvtOrderDir = (order: ReportSort['order']): tt4b.common.SortDirection | undefined => { if (order === 'ascending') return 'ASC' else if (order === 'descending') return 'DESC' else return undefined @@ -81,7 +81,7 @@ const cvtOrderDir = (order: ReportSort['order']): timer.common.SortDirection | u const cvt2GroupQuery = ( { query, mergeDate, dateRange: date }: ReportFilterOption, { prop, order }: ReportSort, -): timer.stat.GroupQuery => ({ +): tt4b.stat.GroupQuery => ({ date: cvtDateRange2Str(date), mergeDate, query, sortKey: prop !== 'host' && prop !== 'run' ? prop : undefined, sortDirection: cvtOrderDir(order), @@ -90,7 +90,7 @@ const cvt2GroupQuery = ( const cvt2SiteQuery = ( { dateRange: date, mergeDate, siteMerge, query, cateIds, readRemote: inclusiveRemote }: ReportFilterOption, { prop, order }: ReportSort, -): timer.stat.SiteQuery => ({ +): tt4b.stat.SiteQuery => ({ date: cvtDateRange2Str(date), mergeDate, mergeHost: siteMerge === 'domain', query, cateIds, inclusiveRemote, @@ -102,13 +102,13 @@ const cvt2SiteQuery = ( const cvt2CateQuery = ( { dateRange: date, mergeDate, query, cateIds, readRemote: inclusiveRemote }: ReportFilterOption, { prop, order }: ReportSort, -): timer.stat.CateQuery => ({ +): tt4b.stat.CateQuery => ({ date: cvtDateRange2Str(date), mergeDate, query, cateIds, inclusiveRemote, sortKey: prop !== 'host' && prop !== 'run' ? prop : undefined, sortDirection: cvtOrderDir(order), }) -export const queryPage = async (filter: ReportFilterOption, sort: ReportSort, page: timer.common.PageQuery): Promise<timer.common.PageResult<timer.stat.Row>> => { +export const queryPage = async (filter: ReportFilterOption, sort: ReportSort, page: tt4b.common.PageQuery): Promise<tt4b.common.PageResult<tt4b.stat.Row>> => { const { siteMerge } = filter if (siteMerge === 'group') { return await getGroupStatPage({ ...cvt2GroupQuery(filter, sort), ...page }) @@ -119,7 +119,7 @@ export const queryPage = async (filter: ReportFilterOption, sort: ReportSort, pa } } -export const queryAll = async (filter: ReportFilterOption, sort: ReportSort): Promise<timer.stat.Row[]> => { +export const queryAll = async (filter: ReportFilterOption, sort: ReportSort): Promise<tt4b.stat.Row[]> => { const { siteMerge } = filter if (siteMerge === 'group') { return await listGroupStats(cvt2GroupQuery(filter, sort)) diff --git a/src/pages/app/components/Report/components/CompositionTable.tsx b/src/pages/app/components/Report/components/CompositionTable.tsx index 3411b7506..5d0afa0f5 100644 --- a/src/pages/app/components/Report/components/CompositionTable.tsx +++ b/src/pages/app/components/Report/components/CompositionTable.tsx @@ -23,7 +23,7 @@ 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[] { +function computeRows(data: tt4b.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 } @@ -44,7 +44,7 @@ const formatValue = (value: number, valueFormatter?: ValueFormatter) => { const _default = defineComponent({ props: { data: { - type: Array as PropType<timer.stat.RemoteCompositionVal[]>, + type: Array as PropType<tt4b.stat.RemoteCompositionVal[]>, required: true, }, valueFormatter: Function as PropType<ValueFormatter>, diff --git a/src/pages/app/components/Report/components/TooltipSiteList.tsx b/src/pages/app/components/Report/components/TooltipSiteList.tsx index bd29892e8..f9e7c336e 100644 --- a/src/pages/app/components/Report/components/TooltipSiteList.tsx +++ b/src/pages/app/components/Report/components/TooltipSiteList.tsx @@ -5,7 +5,7 @@ import { ElScrollbar } from "element-plus" import { computed, defineComponent, StyleValue, toRefs } from "vue" type Props = { - modelValue: timer.stat.SiteRow[] | false | undefined + modelValue: tt4b.stat.SiteRow[] | false | undefined clickDisabled?: boolean } diff --git a/src/pages/app/components/Report/file-export.ts b/src/pages/app/components/Report/file-export.ts index 69bfabb32..818cc5e9b 100644 --- a/src/pages/app/components/Report/file-export.ts +++ b/src/pages/app/components/Report/file-export.ts @@ -50,7 +50,7 @@ const generateJsonData = ({ rows, categories, groupMap }: ExportParam): ExportIn time: row.time })) -const getCateName = (row: timer.stat.Row, categories: timer.site.Cate[]): string | undefined => { +const getCateName = (row: tt4b.stat.Row, categories: tt4b.site.Cate[]): string | undefined => { const cateId = getRelatedCateId(row) let cate: string | undefined = undefined if (cateId === CATE_NOT_SET_ID) { @@ -63,9 +63,9 @@ const getCateName = (row: timer.stat.Row, categories: timer.site.Cate[]): string } export type ExportParam = { - rows: timer.stat.Row[] + rows: tt4b.stat.Row[] filter: ReportFilterOption - categories: timer.site.Cate[] + categories: tt4b.site.Cate[] groupMap: Record<number, chrome.tabGroups.TabGroup> } @@ -86,7 +86,7 @@ type CsvColumn = keyof ExportInfo type CsvColumnConfig = { visible: (mergeDate: boolean, siteMerge: ReportFilterOption['siteMerge']) => boolean i18n: I18nKey - formatter: (row: timer.stat.Row, categories: timer.site.Cate[], groupMap: Record<number, chrome.tabGroups.TabGroup>) => string + formatter: (row: tt4b.stat.Row, categories: tt4b.site.Cate[], groupMap: Record<number, chrome.tabGroups.TabGroup>) => string } const CSV_COLUMN_CONFIGS: Record<CsvColumn, CsvColumnConfig> = { diff --git a/src/pages/app/components/Report/types.d.ts b/src/pages/app/components/Report/types.d.ts index 1f3ca928d..5e5f5e99a 100644 --- a/src/pages/app/components/Report/types.d.ts +++ b/src/pages/app/components/Report/types.d.ts @@ -2,23 +2,23 @@ import { DateRange } from '@util/time' import type { Sort } from "element-plus" export type ReportSort = Omit<Sort, 'prop'> & { - prop: timer.core.Dimension | 'host' | 'date' + prop: tt4b.core.Dimension | 'host' | 'date' } export type ReportFilterOption = { query: string | undefined dateRange: DateRange mergeDate: boolean - siteMerge?: Exclude<timer.stat.MergeMethod, 'date'> + siteMerge?: Exclude<tt4b.stat.MergeMethod, 'date'> cateIds?: number[] /** * @since 1.1.7 */ - timeFormat: timer.app.TimeFormat + timeFormat: tt4b.app.TimeFormat readRemote?: boolean } export interface DisplayComponent { - getSelected(): timer.stat.Row[] + getSelected(): tt4b.stat.Row[] refresh(): Promise<void> | void } \ No newline at end of file diff --git a/src/pages/app/components/RuleMerge/ItemList.tsx b/src/pages/app/components/RuleMerge/ItemList.tsx index 0a35f19cf..1581e3db1 100644 --- a/src/pages/app/components/RuleMerge/ItemList.tsx +++ b/src/pages/app/components/RuleMerge/ItemList.tsx @@ -45,7 +45,7 @@ const _default = defineComponent(() => { } const { refresh: add } = useManualRequest( - (rule: timer.merge.Rule) => addMergeRule(rule), + (rule: tt4b.merge.Rule) => addMergeRule(rule), { onSuccess: handleSucc } ) diff --git a/src/pages/app/components/SiteManage/Modify/HostSelect.tsx b/src/pages/app/components/SiteManage/Modify/HostSelect.tsx index 541b66f25..3ad646b05 100644 --- a/src/pages/app/components/SiteManage/Modify/HostSelect.tsx +++ b/src/pages/app/components/SiteManage/Modify/HostSelect.tsx @@ -11,7 +11,7 @@ import { identifySiteKey, parseSiteIdentity } from '@util/site' import { ElOption, ElSelect, ElTag } from "element-plus" import { computed, defineComponent } from "vue" -type Props = ModelValue<timer.site.SiteKey | undefined> +type Props = ModelValue<tt4b.site.SiteKey | undefined> const EXIST_MSG = t(msg => msg.siteManage.msg.existedTag) const MERGED_MSG = t(msg => msg.siteManage.type.merged?.name)?.toLocaleUpperCase?.() @@ -28,7 +28,7 @@ const VIRTUAL_MSG = t(msg => msg.siteManage.type.virtual?.name)?.toLocaleUpperCa * 5. www.github.com/sheepzh/*[VIRTUAL-EXISTED] * 3. www.google.com[MERGED-EXISTED] */ -function labelOf(site: timer.site.SiteInfo): string { +function labelOf(site: tt4b.site.SiteInfo): string { let { host: label, type, alias } = site const suffix: string[] = [] type === 'merged' && suffix.push(MERGED_MSG) diff --git a/src/pages/app/components/SiteManage/Modify/index.tsx b/src/pages/app/components/SiteManage/Modify/index.tsx index 4859b867f..1949a4026 100644 --- a/src/pages/app/components/SiteManage/Modify/index.tsx +++ b/src/pages/app/components/SiteManage/Modify/index.tsx @@ -22,7 +22,7 @@ type _FormData = { /** * Value of alias key */ - key: timer.site.SiteKey | undefined + key: tt4b.site.SiteKey | undefined alias: string | undefined category: number | undefined } @@ -75,7 +75,7 @@ const _default = defineComponent<{ onSave: NoArgCallback }>((props, ctx) => { if (!siteKey) return false alias = alias?.trim() - const siteInfo: timer.site.SiteInfo = { ...siteKey, alias, cate: formData.category } + const siteInfo: tt4b.site.SiteInfo = { ...siteKey, alias, cate: formData.category } const errMsg = await sendMsg2Runtime('site.add', siteInfo) if (errMsg) return ElMessage.warning(errMsg) close() diff --git a/src/pages/app/components/SiteManage/SiteManageFilter.tsx b/src/pages/app/components/SiteManage/SiteManageFilter.tsx index b6c14a9dc..bd81bfbee 100644 --- a/src/pages/app/components/SiteManage/SiteManageFilter.tsx +++ b/src/pages/app/components/SiteManage/SiteManageFilter.tsx @@ -70,7 +70,7 @@ const _default = defineComponent<{ placeholder={t(msg => msg.siteManage.column.type)} options={ALL_TYPES.map(type => ({ value: type, label: t(msg => msg.siteManage.type[type].name) }))} defaultValue={filter.types ?? []} - onChange={val => filter.types = val as timer.site.Type[]} + onChange={val => filter.types = val as tt4b.site.Type[]} /> <CategoryFilter disabled={cateDisabled.value} diff --git a/src/pages/app/components/SiteManage/Table/column/AliasColumn.tsx b/src/pages/app/components/SiteManage/Table/column/AliasColumn.tsx index a9cc67116..2f2395ce1 100644 --- a/src/pages/app/components/SiteManage/Table/column/AliasColumn.tsx +++ b/src/pages/app/components/SiteManage/Table/column/AliasColumn.tsx @@ -28,7 +28,7 @@ const AliasColumn = defineComponent<{}>(() => { ElMessage.success(t(msg => msg.operation.successMsg)) } - const genInitialAlias = async ({ host, type, alias }: timer.site.SiteInfo) => { + const genInitialAlias = async ({ host, type, alias }: tt4b.site.SiteInfo) => { if (alias) return alias if (type === 'normal') return await getInitialAlias(host) return undefined @@ -60,7 +60,7 @@ const AliasColumn = defineComponent<{}>(() => { /> </Flex> ), - default: ({ row }: { row: timer.site.SiteInfo }) => ( + default: ({ row }: { row: tt4b.site.SiteInfo }) => ( <Editable key={`${identifySiteKey(row)}_${row.alias}`} modelValue={row.alias} diff --git a/src/pages/app/components/SiteManage/Table/column/OperationColumn.tsx b/src/pages/app/components/SiteManage/Table/column/OperationColumn.tsx index 13d696fcf..5be59cbcd 100644 --- a/src/pages/app/components/SiteManage/Table/column/OperationColumn.tsx +++ b/src/pages/app/components/SiteManage/Table/column/OperationColumn.tsx @@ -15,14 +15,14 @@ import { defineComponent } from "vue" const OperationColumn = defineComponent<{}>(() => { const { refresh } = useSiteManageTable() - const { refresh: doDelete } = useManualRequest<[timer.site.SiteKey], void>(deleteSites, { onSuccess: refresh }) + const { refresh: doDelete } = useManualRequest<[tt4b.site.SiteKey], void>(deleteSites, { onSuccess: refresh }) return () => ( <ElTableColumn width={150} label={t(msg => msg.button.operation)} align="center" v-slots={ - ({ row }: RenderRowData<timer.site.SiteInfo>) => ( + ({ row }: RenderRowData<tt4b.site.SiteInfo>) => ( <PopupConfirmButton buttonIcon={Delete} buttonType="danger" diff --git a/src/pages/app/components/SiteManage/Table/column/TypeColumn.tsx b/src/pages/app/components/SiteManage/Table/column/TypeColumn.tsx index f343a7478..9f66a66e8 100644 --- a/src/pages/app/components/SiteManage/Table/column/TypeColumn.tsx +++ b/src/pages/app/components/SiteManage/Table/column/TypeColumn.tsx @@ -5,9 +5,9 @@ import { ElTableColumn, ElTag, type RenderRowData, type TagProps } from "element import { type FunctionalComponent } from "vue" import type { JSX } from "vue/jsx-runtime" -const computeText = ({ type }: timer.site.SiteInfo): string => t(msg => msg.siteManage.type[type].name) +const computeText = ({ type }: tt4b.site.SiteInfo): string => t(msg => msg.siteManage.type[type].name) -function computeType({ type }: timer.site.SiteInfo): TagProps["type"] | undefined { +function computeType({ type }: tt4b.site.SiteInfo): TagProps["type"] | undefined { switch (type) { case 'merged': return 'info' case 'virtual': return 'success' @@ -34,7 +34,7 @@ const TypeColumn: FunctionalComponent = () => ( }} /> ), - default: ({ row }: RenderRowData<timer.site.SiteInfo>) => ( + default: ({ row }: RenderRowData<tt4b.site.SiteInfo>) => ( <ElTag size="small" type={computeType(row)}> {computeText(row)} </ElTag> diff --git a/src/pages/app/components/SiteManage/Table/index.tsx b/src/pages/app/components/SiteManage/Table/index.tsx index b1381be5d..c8a9933e5 100644 --- a/src/pages/app/components/SiteManage/Table/index.tsx +++ b/src/pages/app/components/SiteManage/Table/index.tsx @@ -20,12 +20,12 @@ import TypeColumn from "./column/TypeColumn" const _default = defineComponent<{}>(() => { const { setSelected, refresh, pagination } = useSiteManageTable() - const handleIconError = async (row: timer.site.SiteInfo) => { + const handleIconError = async (row: tt4b.site.SiteInfo) => { await deleteSiteIcon(row) row.iconUrl = undefined } - const handleRunChange = async (val: boolean, row: timer.site.SiteInfo) => { + const handleRunChange = async (val: boolean, row: tt4b.site.SiteInfo) => { await changeSiteRun(row, val) row.run = val refresh() @@ -43,7 +43,7 @@ const _default = defineComponent<{}>(() => { label={t(msg => msg.item.host)} minWidth={220} align="center" - v-slots={({ row }: RenderRowData<timer.site.SiteInfo>) => ( + v-slots={({ row }: RenderRowData<tt4b.site.SiteInfo>) => ( <div style={{ margin: 'auto', width: 'fit-content' }}> <HostAlert value={row} /> </div> @@ -54,7 +54,7 @@ const _default = defineComponent<{}>(() => { label={t(msg => msg.siteManage.column.icon)} minWidth={100} align="center" - v-slots={({ row }: RenderRowData<timer.site.SiteInfo>) => { + v-slots={({ row }: RenderRowData<tt4b.site.SiteInfo>) => { const { iconUrl } = row || {} if (!iconUrl) return '' return ( @@ -69,7 +69,7 @@ const _default = defineComponent<{}>(() => { label={t(msg => msg.siteManage.column.cate)} minWidth={140} align="center" - v-slots={({ row }: RenderRowData<timer.site.SiteInfo>) => ( + v-slots={({ row }: RenderRowData<tt4b.site.SiteInfo>) => ( <Category.Editable siteKey={row} modelValue={row?.cate} onChange={val => row.cate = val} /> )} /> @@ -78,7 +78,7 @@ const _default = defineComponent<{}>(() => { width={100} align="center" > - {({ row }: RenderRowData<timer.site.SiteInfo>) => row.type === 'normal' && ( + {({ row }: RenderRowData<tt4b.site.SiteInfo>) => row.type === 'normal' && ( <ElSwitch size="small" modelValue={row.run} diff --git a/src/pages/app/components/SiteManage/common.ts b/src/pages/app/components/SiteManage/common.ts index 635e1c12d..f9575e095 100644 --- a/src/pages/app/components/SiteManage/common.ts +++ b/src/pages/app/components/SiteManage/common.ts @@ -5,4 +5,4 @@ * https://opensource.org/licenses/MIT */ -export const ALL_TYPES: timer.site.Type[] = ['normal', 'merged', 'virtual'] \ No newline at end of file +export const ALL_TYPES: tt4b.site.Type[] = ['normal', 'merged', 'virtual'] \ No newline at end of file diff --git a/src/pages/app/components/SiteManage/useSiteManage.ts b/src/pages/app/components/SiteManage/useSiteManage.ts index 3ba795758..4d497dd5d 100644 --- a/src/pages/app/components/SiteManage/useSiteManage.ts +++ b/src/pages/app/components/SiteManage/useSiteManage.ts @@ -4,7 +4,7 @@ import { reactive, type ShallowRef, watch } from 'vue' type FilterOption = { query?: string - types?: timer.site.Type[] + types?: tt4b.site.Type[] cateIds?: number[] } @@ -13,10 +13,10 @@ type CacheValue = { } type Context = { - pagination: ShallowRef<timer.common.PageResult<timer.site.SiteInfo>> + pagination: ShallowRef<tt4b.common.PageResult<tt4b.site.SiteInfo>> filter: FilterOption - selected: ShallowRef<timer.site.SiteInfo[]> - setSelected: ArgCallback<timer.site.SiteInfo[]> + selected: ShallowRef<tt4b.site.SiteInfo[]> + setSelected: ArgCallback<tt4b.site.SiteInfo[]> refresh: NoArgCallback } @@ -28,8 +28,8 @@ export const initSiteManage = (loadingTarget: RequestOption<unknown, unknown[]>[ const filter = reactive<FilterOption>({ cateIds: cache?.cateIds }) watch(() => filter.cateIds, cateIds => setCache({ cateIds })) - const page = reactive<timer.common.PageQuery>({ num: 1, size: 20 }) - const [selected, setSelected] = useState<timer.site.SiteInfo[]>([]) + const page = reactive<tt4b.common.PageQuery>({ num: 1, size: 20 }) + const [selected, setSelected] = useState<tt4b.site.SiteInfo[]>([]) const { data: pagination, refresh, loading } = useRequest(() => { const { query: fuzzyQuery, cateIds, types } = filter diff --git a/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx b/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx index ae4451850..58f94ab07 100644 --- a/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx +++ b/src/pages/app/components/Whitelist/WhitePanel/WhiteInput.tsx @@ -16,7 +16,7 @@ import { isValidHost, judgeVirtualFast } from "@util/pattern" import { ElButton, ElIcon, ElMessage, ElOption, ElSelect, ElTag } from "element-plus" import { defineComponent, StyleValue } from "vue" -type SearchItem = timer.site.SiteKey & { +type SearchItem = tt4b.site.SiteKey & { exclude?: boolean } diff --git a/src/pages/app/components/common/Category/Editable.tsx b/src/pages/app/components/common/Category/Editable.tsx index 3938fa3e0..e1fc31ccd 100644 --- a/src/pages/app/components/common/Category/Editable.tsx +++ b/src/pages/app/components/common/Category/Editable.tsx @@ -9,7 +9,7 @@ import { computed, defineComponent, nextTick, ref } from "vue" import Select, { type Instance } from "./Select" type Props = ModelValue<number | undefined> & { - siteKey: timer.site.SiteKey + siteKey: tt4b.site.SiteKey } const CategoryEditable = defineComponent<Props>(props => { diff --git a/src/pages/app/components/common/Category/Select/OptionItem.tsx b/src/pages/app/components/common/Category/Select/OptionItem.tsx index 0e2a0cf42..eb8f95916 100644 --- a/src/pages/app/components/common/Category/Select/OptionItem.tsx +++ b/src/pages/app/components/common/Category/Select/OptionItem.tsx @@ -9,7 +9,7 @@ import { stopPropagationAfter } from "@util/document" import { ElButton, ElInput, ElMessage, ElMessageBox } from "element-plus" import { defineComponent, nextTick, ref } from "vue" -const OptionItem = defineComponent<{ value: timer.site.Cate }>(props => { +const OptionItem = defineComponent<{ value: tt4b.site.Cate }>(props => { const cate = useCategory() const [editing, openEditing, closeEditing] = useSwitch(false) diff --git a/src/pages/app/components/common/HostAlert.tsx b/src/pages/app/components/common/HostAlert.tsx index 26701e596..a1c7e2146 100644 --- a/src/pages/app/components/common/HostAlert.tsx +++ b/src/pages/app/components/common/HostAlert.tsx @@ -13,7 +13,7 @@ import { ElLink } from "element-plus" import { computed, defineComponent, type StyleValue } from "vue" type Props = { - value: timer.site.SiteKey + value: tt4b.site.SiteKey iconUrl?: string clickable?: boolean } diff --git a/src/pages/app/components/common/Pagination.tsx b/src/pages/app/components/common/Pagination.tsx index 0afe23b70..6c5647129 100644 --- a/src/pages/app/components/common/Pagination.tsx +++ b/src/pages/app/components/common/Pagination.tsx @@ -12,9 +12,9 @@ import { defineComponent } from "vue" type Props = { disabled?: boolean - defaultValue?: timer.common.PageQuery + defaultValue?: tt4b.common.PageQuery total?: number - onChange?: (val: timer.common.PageQuery) => void + onChange?: (val: tt4b.common.PageQuery) => void } const Pagination = defineComponent<Props>(props => { @@ -24,8 +24,8 @@ const Pagination = defineComponent<Props>(props => { disabled={props.disabled} {...getPaginationIconProps()} pageSizes={[10, 20, 50]} - defaultCurrentPage={(props.defaultValue as timer.common.PageQuery)?.num} - defaultPageSize={(props.defaultValue as timer.common.PageQuery)?.size} + defaultCurrentPage={(props.defaultValue as tt4b.common.PageQuery)?.num} + defaultPageSize={(props.defaultValue as tt4b.common.PageQuery)?.size} layout="total, sizes, prev, pager, next, jumper" total={props.total} onChange={(currentPage, pageSize) => props.onChange?.({ num: currentPage, size: pageSize })} diff --git a/src/pages/app/components/common/filter/TimeFormatFilterItem.tsx b/src/pages/app/components/common/filter/TimeFormatFilterItem.tsx index b50fc7759..83ce00475 100644 --- a/src/pages/app/components/common/filter/TimeFormatFilterItem.tsx +++ b/src/pages/app/components/common/filter/TimeFormatFilterItem.tsx @@ -9,20 +9,20 @@ import { t } from "@app/locale" import { defineComponent } from "vue" import SelectFilterItem from "./SelectFilterItem" -const TIME_FORMAT_LABELS: { [key in timer.app.TimeFormat]: string } = { +const TIME_FORMAT_LABELS: { [key in tt4b.app.TimeFormat]: string } = { default: t(msg => msg.timeFormat.default), second: t(msg => msg.timeFormat.second), minute: t(msg => msg.timeFormat.minute), hour: t(msg => msg.timeFormat.hour) } -const _default = defineComponent<ModelValue<timer.app.TimeFormat>>(props => { +const _default = defineComponent<ModelValue<tt4b.app.TimeFormat>>(props => { return () => ( <SelectFilterItem historyName="timeFormat" defaultValue={props.modelValue} options={TIME_FORMAT_LABELS} - onChange={val => val && props.onChange?.(val as timer.app.TimeFormat)} + onChange={val => val && props.onChange?.(val as tt4b.app.TimeFormat)} /> ) }, { props: ['modelValue', 'onChange'] }) diff --git a/src/pages/app/components/common/imported/CompareTable.tsx b/src/pages/app/components/common/imported/CompareTable.tsx index 6147f8cdc..8c2b90efc 100644 --- a/src/pages/app/components/common/imported/CompareTable.tsx +++ b/src/pages/app/components/common/imported/CompareTable.tsx @@ -14,15 +14,15 @@ import { computed, defineComponent, toRef } from "vue" import HostAlert from "../HostAlert" type SortInfo = SortBy & { - key: keyof timer.imported.Row + key: keyof tt4b.imported.Row } -function computeList(sort: SortInfo, originRows: timer.imported.Row[]): timer.imported.Row[] { +function computeList(sort: SortInfo, originRows: tt4b.imported.Row[]): tt4b.imported.Row[] { const { key, order } = sort if (!key) { return originRows } - const comparator = (a: timer.imported.Row, b: timer.imported.Row): number => { + const comparator = (a: tt4b.imported.Row, b: tt4b.imported.Row): number => { const av = a[key] ?? 0, bv = b[key] ?? 0 if (av == bv) return 0 if (order === TableV2SortOrder.DESC) { @@ -39,12 +39,12 @@ const focusCol = (comparedCol: I18nKey): Column[] => [ width: 150, align: 'center', title: t(comparedCol), - cellRenderer: ({ rowData }) => <span>{periodFormatter((rowData as timer.imported.Row).focus)}</span>, + cellRenderer: ({ rowData }) => <span>{periodFormatter((rowData as tt4b.imported.Row).focus)}</span>, }, { width: 150, align: 'center', title: t(msg => msg.dataManage.importOther.local), - cellRenderer: ({ rowData }) => <span>{periodFormatter((rowData as timer.imported.Row).exist?.focus)}</span>, + cellRenderer: ({ rowData }) => <span>{periodFormatter((rowData as tt4b.imported.Row).exist?.focus)}</span>, } ] @@ -53,17 +53,17 @@ const timeCol = (comparedCol: I18nKey): Column[] => [ width: 150, align: 'center', title: t(comparedCol), - cellRenderer: ({ rowData }) => <span>{(rowData as timer.imported.Row).time ?? 0}</span>, + cellRenderer: ({ rowData }) => <span>{(rowData as tt4b.imported.Row).time ?? 0}</span>, }, { width: 150, align: 'center', title: t(msg => msg.dataManage.importOther.local), - cellRenderer: ({ rowData }) => <span>{(rowData as timer.imported.Row).exist?.time ?? '-'}</span>, + cellRenderer: ({ rowData }) => <span>{(rowData as tt4b.imported.Row).exist?.time ?? '-'}</span>, } ] type Props = { - data: timer.imported.Data + data: tt4b.imported.Data comparedCol: I18nKey } @@ -78,7 +78,7 @@ const BASE_COLUMNS: Column[] = [ sortable: true, align: 'center', title: t(msg => msg.item.date), - cellRenderer: ({ rowData }) => <span>{cvt2LocaleTime((rowData as timer.imported.Row).date)}</span>, + cellRenderer: ({ rowData }) => <span>{cvt2LocaleTime((rowData as tt4b.imported.Row).date)}</span>, }, { width: 300, dataKey: 'host' satisfies SortInfo['key'], @@ -86,7 +86,7 @@ const BASE_COLUMNS: Column[] = [ align: 'center', title: t(msg => msg.item.host), cellRenderer: ({ rowData }) => { - const { host } = rowData as timer.imported.Row + const { host } = rowData as tt4b.imported.Row return host ? <HostAlert value={{ host, type: 'normal' }} clickable={false} /> : <></> }, } diff --git a/src/pages/app/components/common/imported/ResolutionRadio.tsx b/src/pages/app/components/common/imported/ResolutionRadio.tsx index b07649b41..61a5d5f05 100644 --- a/src/pages/app/components/common/imported/ResolutionRadio.tsx +++ b/src/pages/app/components/common/imported/ResolutionRadio.tsx @@ -11,9 +11,9 @@ import Flex from "@pages/components/Flex" import { ElForm, ElFormItem, ElIcon, ElRadio, ElRadioGroup, ElTooltip } from "element-plus" import { defineComponent } from "vue" -const ALL_RESOLUTIONS: timer.imported.ConflictResolution[] = ['overwrite', 'accumulate'] +const ALL_RESOLUTIONS: tt4b.imported.ConflictResolution[] = ['overwrite', 'accumulate'] -type Value = timer.imported.ConflictResolution | undefined +type Value = tt4b.imported.ConflictResolution | undefined type Props = { modelValue: Value diff --git a/src/pages/app/context.ts b/src/pages/app/context.ts index ea4ddf363..a724a678a 100644 --- a/src/pages/app/context.ts +++ b/src/pages/app/context.ts @@ -9,7 +9,7 @@ type MenuLayout = 'nav' | 'sidebar' interface CategoryInstance { enabled: boolean - all: timer.site.Cate[] + all: tt4b.site.Cate[] nameMap: Record<number, string> refresh(): void } diff --git a/src/pages/app/locale.ts b/src/pages/app/locale.ts index 64a5d7f16..e5dbedb70 100644 --- a/src/pages/app/locale.ts +++ b/src/pages/app/locale.ts @@ -19,7 +19,7 @@ export function t(key: I18nKey, param?: any) { /** * @since 0.8.8 */ -export function tWith(key: I18nKey, specLocale: timer.Locale, param?: any) { +export function tWith(key: I18nKey, specLocale: tt4b.Locale, param?: any) { const props = { key, param } return _t<AppMessage>(messages, props, specLocale) } diff --git a/src/pages/app/util/limit/index.tsx b/src/pages/app/util/limit/index.tsx index 573c68792..42e51a511 100644 --- a/src/pages/app/util/limit/index.tsx +++ b/src/pages/app/util/limit/index.tsx @@ -19,7 +19,7 @@ const tN = (key: I18nKey<LimitMessage>, param?: any) => tN_<LimitMessage, VNode> * * @returns T/F */ -export async function judgeVerificationRequired(item: timer.limit.Item, delayDuration: number): Promise<boolean> { +export async function judgeVerificationRequired(item: tt4b.limit.Item, delayDuration: number): Promise<boolean> { if (!item.enabled || !isEffective(item.weekdays)) return false const { visitTime, periods } = item @@ -104,7 +104,7 @@ const errMsg = (message: string) => ElMessage.error({ message, customClass: MSG_ * @returns null if verification not required, * or promise with resolve invoked only if verification code or password correct */ -export function processVerification(option: timer.option.LimitOption): Promise<void> { +export function processVerification(option: tt4b.option.LimitOption): Promise<void> { const { limitLevel, limitPassword, limitVerifyDifficulty } = option if (limitLevel === 'strict') { return new Promise(() => ElMessageBox({ diff --git a/src/pages/app/util/limit/processor.ts b/src/pages/app/util/limit/processor.ts index d33d409c5..dcfe49ad7 100644 --- a/src/pages/app/util/limit/processor.ts +++ b/src/pages/app/util/limit/processor.ts @@ -16,7 +16,7 @@ class VerificationProcessor { this.generators = ALL_GENERATORS } - generate(difficulty: timer.limit.VerificationDifficulty, locale: timer.Locale): VerificationPair | undefined { + generate(difficulty: tt4b.limit.VerificationDifficulty, locale: tt4b.Locale): VerificationPair | undefined { const context: VerificationContext = { difficulty, locale } const supported = this.generators.filter(g => g.supports(context)) diff --git a/src/pages/app/util/limit/types.ts b/src/pages/app/util/limit/types.ts index 6b527d549..e60c007fd 100644 --- a/src/pages/app/util/limit/types.ts +++ b/src/pages/app/util/limit/types.ts @@ -10,8 +10,8 @@ import { type LimitMessage } from "@i18n/message/app/limit" type LimitVerificationMessage = LimitMessage['verification'] export type VerificationContext = { - difficulty: timer.limit.VerificationDifficulty - locale: timer.Locale + difficulty: tt4b.limit.VerificationDifficulty + locale: tt4b.Locale } export type VerificationPair = { diff --git a/src/pages/app/util/time.ts b/src/pages/app/util/time.ts index 494297fec..e50e55b78 100644 --- a/src/pages/app/util/time.ts +++ b/src/pages/app/util/time.ts @@ -25,11 +25,11 @@ export function cvt2LocaleTime(date: string | undefined): string { } type PeriodFormatOption = { - format?: timer.app.TimeFormat + format?: tt4b.app.TimeFormat hideUnit?: boolean } -const UNIT_MAP: { [unit in Exclude<timer.app.TimeFormat, 'default'>]: string } = { +const UNIT_MAP: { [unit in Exclude<tt4b.app.TimeFormat, 'default'>]: string } = { second: 's', minute: 'm', hour: 'h', diff --git a/src/pages/hooks/useSiteMerge.ts b/src/pages/hooks/useSiteMerge.ts index 8090c43d6..d3b799e0c 100644 --- a/src/pages/hooks/useSiteMerge.ts +++ b/src/pages/hooks/useSiteMerge.ts @@ -13,7 +13,7 @@ export const useSiteMerge = ({ onGroupDisabled }: Options) => { }) const mergeItems = computed(() => { - const res: (Exclude<timer.stat.MergeMethod, 'date'>)[] = ['cate', 'domain'] + const res: (Exclude<tt4b.stat.MergeMethod, 'date'>)[] = ['cate', 'domain'] countTabGroup.value && res.push('group') return res }) diff --git a/src/pages/popup/common.tsx b/src/pages/popup/common.tsx index e1029e19c..760855891 100644 --- a/src/pages/popup/common.tsx +++ b/src/pages/popup/common.tsx @@ -22,12 +22,12 @@ const DATE_RANGE_CALCULATORS: { [duration in PopupDuration]: DateRangeCalculator allTime: () => undefined, } -export const queryRows = async (param: PopupQuery): Promise<[rows: timer.stat.Row[], date: [Date, Date] | Date | undefined]> => { +export const queryRows = async (param: PopupQuery): Promise<[rows: tt4b.stat.Row[], date: [Date, Date] | Date | undefined]> => { const { duration, durationNum, mergeMethod, dimension: sortKey } = param const dateRange = await DATE_RANGE_CALCULATORS[duration]?.(new Date(), durationNum) const date = cvtDateRange2Str(dateRange) - const sortDirection: timer.common.SortDirection = 'DESC' - let rows: timer.stat.Row[] + const sortDirection: tt4b.common.SortDirection = 'DESC' + let rows: tt4b.stat.Row[] if (mergeMethod === 'cate') { rows = await listCateStats({ date, mergeDate: true, sortKey, sortDirection }) } else if (mergeMethod === 'group') { @@ -42,7 +42,7 @@ export const queryRows = async (param: PopupQuery): Promise<[rows: timer.stat.Ro return [rows, dateRange] } -function buildReportQuery(siteType: timer.site.Type, date: DateRange | undefined, type: timer.core.Dimension): ReportQuery { +function buildReportQuery(siteType: tt4b.site.Type, date: DateRange | undefined, type: tt4b.core.Dimension): ReportQuery { const query: ReportQuery = {} // Merge host siteType === 'merged' && (query.mm = 'domain') @@ -66,9 +66,9 @@ function buildReportQuery(siteType: timer.site.Type, date: DateRange | undefined } export function calJumpUrl( - row: timer.stat.Row | undefined, + row: tt4b.stat.Row | undefined, date: DateRange | undefined, - type: timer.core.Dimension, + type: tt4b.core.Dimension, ): string | undefined { if (!row) return if (isSite(row)) { diff --git a/src/pages/popup/components/Header/LangSelect.tsx b/src/pages/popup/components/Header/LangSelect.tsx index 24686599e..38642f5cf 100644 --- a/src/pages/popup/components/Header/LangSelect.tsx +++ b/src/pages/popup/components/Header/LangSelect.tsx @@ -13,7 +13,7 @@ import { ElDropdown, ElDropdownItem, ElDropdownMenu, ElIcon, ElText } from "elem import { defineComponent, type StyleValue } from "vue" // Keep the locale same as this browser first position -const SORTED_LOCALES: timer.Locale[] = ALL_LOCALES.sort((a, _b) => a === localeSameAsBrowser ? -1 : 0) +const SORTED_LOCALES: tt4b.Locale[] = ALL_LOCALES.sort((a, _b) => a === localeSameAsBrowser ? -1 : 0) const SELECTED_STYLES: StyleValue = { color: 'var(--el-color-primary)', @@ -29,7 +29,7 @@ const LangSelect = defineComponent(() => { const { reload: reloadPopup } = usePopupContext() const { refresh: saveLocale } = useManualRequest( - async (opt: timer.option.LocaleOption) => { + async (opt: tt4b.option.LocaleOption) => { await sendMsg2Runtime('option.set', { locale: opt }) handleLocaleOption(opt) }, diff --git a/src/pages/popup/components/Limit/Content/Chart.tsx b/src/pages/popup/components/Limit/Content/Chart.tsx index ac459d5aa..3d562d580 100644 --- a/src/pages/popup/components/Limit/Content/Chart.tsx +++ b/src/pages/popup/components/Limit/Content/Chart.tsx @@ -2,7 +2,7 @@ import { useEcharts } from '@hooks' import { defineComponent, toRef } from 'vue' import Wrapper from './Wrapper' -const Chart = defineComponent<{ item: timer.limit.Item }>(props => { +const Chart = defineComponent<{ item: tt4b.limit.Item }>(props => { const { elRef } = useEcharts(Wrapper, toRef(props, 'item')) return () => <div ref={elRef} style={{ width: '100%', flex: 1 }} /> }, { props: ['item'] }) diff --git a/src/pages/popup/components/Limit/Content/Wrapper.ts b/src/pages/popup/components/Limit/Content/Wrapper.ts index 56a8b786d..a515e2426 100644 --- a/src/pages/popup/components/Limit/Content/Wrapper.ts +++ b/src/pages/popup/components/Limit/Content/Wrapper.ts @@ -108,7 +108,7 @@ const createGauge = (options: GaugeOptions): GaugeSeriesOption => { } } -class Wrapper extends EchartsWrapper<timer.limit.Item, EcOption> { +class Wrapper extends EchartsWrapper<tt4b.limit.Item, EcOption> { private dimension: LimitDimension = 'time' protected replaceSeries = true @@ -123,7 +123,7 @@ class Wrapper extends EchartsWrapper<timer.limit.Item, EcOption> { }) } - async render(biz: timer.limit.Item) { + async render(biz: tt4b.limit.Item) { const firstRender = !this.lastBizOption if (firstRender) { const hasTimeLimit = !!biz.time || !!biz.weekly @@ -171,21 +171,21 @@ class Wrapper extends EchartsWrapper<timer.limit.Item, EcOption> { } } - private buildDailyPart(biz: timer.limit.Item, leftPos: string): ChartPart { + private buildDailyPart(biz: tt4b.limit.Item, leftPos: string): ChartPart { const { time, waste, visit, count } = biz const basePart = this.builtLimitPart([waste, time], [visit, count], leftPos) basePart.titles.push(createTitle(t(msg => msg.shared.limit.daily), leftPos)) return basePart } - private buildWeeklyPart(biz: timer.limit.Item, rightPos: string): ChartPart { + private buildWeeklyPart(biz: tt4b.limit.Item, rightPos: string): ChartPart { const { weekly = 0, weeklyWaste, weeklyVisit: weeklyVisitCount, weeklyCount = 0 } = biz const basePart = this.builtLimitPart([weeklyWaste, weekly], [weeklyVisitCount, weeklyCount], rightPos) basePart.titles.push(createTitle(t(msg => msg.shared.limit.weekly), rightPos)) return basePart } - private buildPeriodPart(periods: timer.limit.Period[] | undefined, leftPos: string): ChartPart { + private buildPeriodPart(periods: tt4b.limit.Period[] | undefined, leftPos: string): ChartPart { if (!periods?.length) return { titles: [], series: [] } const now = new Date() const nowMinutes = now.getHours() * 60 + now.getMinutes() @@ -283,7 +283,7 @@ class Wrapper extends EchartsWrapper<timer.limit.Item, EcOption> { } } - protected generateOption(biz: timer.limit.Item): Awaitable<EcOption> { + protected generateOption(biz: tt4b.limit.Item): Awaitable<EcOption> { const hasPeriods = !!biz.periods?.length const leftPos = hasPeriods ? '17.5%' : '32%' const centerPos = hasPeriods ? '50%' : undefined diff --git a/src/pages/popup/components/Percentage/Cate/Wrapper.ts b/src/pages/popup/components/Percentage/Cate/Wrapper.ts index 36f9010b3..989ca941c 100644 --- a/src/pages/popup/components/Percentage/Cate/Wrapper.ts +++ b/src/pages/popup/components/Percentage/Cate/Wrapper.ts @@ -83,7 +83,7 @@ export default class SiteWrapper extends EchartsWrapper<PercentageResult, EcOpti const { rows, query, donutChart } = result const { dimension } = query - const selected: timer.stat.Row | undefined = this.selectedCache + const selected: tt4b.stat.Row | undefined = this.selectedCache ? rows.filter(isCate).filter(r => r.cateKey === this.selectedCache)[0] : undefined diff --git a/src/pages/popup/components/Percentage/chart.ts b/src/pages/popup/components/Percentage/chart.ts index d95e58ba3..d845451d5 100644 --- a/src/pages/popup/components/Percentage/chart.ts +++ b/src/pages/popup/components/Percentage/chart.ts @@ -60,7 +60,7 @@ function formatDateStr(date: Date | [Date, Date?] | undefined, dataDate: [string return end ? combineDate(start, end, format) : formatTime(start, format) } -function formatTotalStr(rows: timer.stat.Row[], type: timer.core.Dimension | undefined): string { +function formatTotalStr(rows: tt4b.stat.Row[], type: tt4b.core.Dimension | undefined): string { if (type === 'focus') { const total = sum(rows.map(r => r?.focus ?? 0)) const totalTime = formatPeriodCommon(total) @@ -264,16 +264,16 @@ export function generateToolbox(getInstance: () => ECharts | undefined): Toolbox } } -type OtherRow = Record<Exclude<timer.core.Dimension, 'run'>, number> & { +type OtherRow = Record<Exclude<tt4b.core.Dimension, 'run'>, number> & { other: true count: number } -type ChartRow = timer.stat.Row | OtherRow +type ChartRow = tt4b.stat.Row | OtherRow export const isOther = (row: ChartRow): row is OtherRow => 'other' in row -function cvt2ChartRows(rows: timer.stat.Row[], dimension: Exclude<timer.core.Dimension, 'run'>, itemCount: number): ChartRow[] { +function cvt2ChartRows(rows: tt4b.stat.Row[], dimension: Exclude<tt4b.core.Dimension, 'run'>, itemCount: number): ChartRow[] { const sorted = rows.filter(item => !!item[dimension]).sort((a, b) => (b[dimension] ?? 0) - (a[dimension] ?? 0)) const popupRows: ChartRow[] = [] const other: OtherRow = { focus: 0, time: 0, count: 0, other: true } @@ -342,7 +342,7 @@ type CustomOption = Pick< > export function generateSiteSeriesOption( - rows: timer.stat.Row[], + rows: tt4b.stat.Row[], result: PercentageResult, customOption: CustomOption, ): PieSeriesOption { @@ -356,8 +356,8 @@ export function generateSiteSeriesOption( if (isOther(row)) { item.itemStyle = { color: getInfoColor() } item.name = t(msg => msg.content.percentage.otherLabel, { count: row.count }) - } else if (!isOther(row) && isSite(row as timer.stat.StatKey)) { - const { siteKey, alias, iconUrl } = row as timer.stat.SiteRow + } else if (!isOther(row) && isSite(row as tt4b.stat.StatKey)) { + const { siteKey, alias, iconUrl } = row as tt4b.stat.SiteRow const { host, type } = siteKey ?? {} const name = item.name = (displaySiteName ? (alias ?? host) : host) ?? '' const richValue: PieLabelRichValueOption = { ...BASE_LABEL_RICH_VALUE } @@ -437,7 +437,7 @@ function calcRealRadius( } } -function calculateAverageText(type: timer.core.Dimension, averageValue: number): string | undefined { +function calculateAverageText(type: tt4b.core.Dimension, averageValue: number): string | undefined { if (type === 'focus') { return t(msg => msg.content.percentage.averageTime, { value: formatPeriodCommon(parseInt(averageValue.toFixed(0))) }) } else if (type === 'time') { @@ -478,7 +478,7 @@ export function formatTooltip({ query, dateLength }: PercentageResult, params: T /** * Handle click */ -export function handleClick(data: PieSeriesItemOption, date: PercentageResult['date'], type: timer.core.Dimension): void { +export function handleClick(data: PieSeriesItemOption, date: PercentageResult['date'], type: tt4b.core.Dimension): void { const { row } = data if (isOther(row)) { return diff --git a/src/pages/popup/components/Percentage/query.ts b/src/pages/popup/components/Percentage/query.ts index c2ffe612b..e7ad43f48 100644 --- a/src/pages/popup/components/Percentage/query.ts +++ b/src/pages/popup/components/Percentage/query.ts @@ -1,12 +1,12 @@ +import { listAllGroups } from "@api/chrome/tabGroups" import { queryRows, } from "@popup/common" import { t } from '@popup/locale' import type { PopupOption, PopupQuery } from "@popup/types" -import { listAllGroups } from "@api/chrome/tabGroups" import { getBirthday, getDayLength } from "@util/time" export type PercentageResult = { query: PopupQuery - rows: timer.stat.Row[] + rows: tt4b.stat.Row[] // Actually date range according to duration date: Date | [Date, Date?] | undefined displaySiteName: boolean @@ -18,7 +18,7 @@ export type PercentageResult = { donutChart: boolean } -const findAllDates = (row: timer.stat.Row): Set<string> => { +const findAllDates = (row: tt4b.stat.Row): Set<string> => { const set = new Set<string>() const { date, mergedDates } = row date && set.add(date) @@ -30,7 +30,7 @@ const findAllDates = (row: timer.stat.Row): Set<string> => { return set } -const findDateRange = (rows: timer.stat.Row[]): [string, string] | undefined => { +const findDateRange = (rows: tt4b.stat.Row[]): [string, string] | undefined => { const set = new Set<string>() rows?.forEach(row => { const dates = findAllDates(row) diff --git a/src/pages/popup/components/Ranking/Item.tsx b/src/pages/popup/components/Ranking/Item.tsx index 5993ec277..d4c4e7a87 100644 --- a/src/pages/popup/components/Ranking/Item.tsx +++ b/src/pages/popup/components/Ranking/Item.tsx @@ -65,7 +65,7 @@ const Title = defineComponent<TitleProps>(props => { ) }, { props: ['value', 'date', 'displaySiteName'] }) -const renderAvatarText = (row: timer.stat.Row, cateNameMap: Record<number, string>, groupMap: Record<number, chrome.tabGroups.TabGroup>) => { +const renderAvatarText = (row: tt4b.stat.Row, cateNameMap: Record<number, string>, groupMap: Record<number, chrome.tabGroups.TabGroup>) => { let name: string | undefined = undefined if (isGroup(row)) { name = getGroupName(groupMap, row) @@ -79,7 +79,7 @@ const renderAvatarText = (row: timer.stat.Row, cateNameMap: Record<number, strin } type ItemProps = { - value: timer.stat.Row + value: tt4b.stat.Row max?: number total?: number date?: DateRange diff --git a/src/pages/popup/components/Ranking/query.tsx b/src/pages/popup/components/Ranking/query.tsx index afd3c24ac..3a43ca1f0 100644 --- a/src/pages/popup/components/Ranking/query.tsx +++ b/src/pages/popup/components/Ranking/query.tsx @@ -4,7 +4,7 @@ import { sum } from "@util/array" import { type DateRange } from '@util/time' type RankingResult = { - rows: timer.stat.Row[] + rows: tt4b.stat.Row[] max: number total: number displaySiteName: boolean diff --git a/src/pages/popup/context.ts b/src/pages/popup/context.ts index c1f5fa554..4df4cc3c7 100644 --- a/src/pages/popup/context.ts +++ b/src/pages/popup/context.ts @@ -20,7 +20,7 @@ type PopupContextValue = { cateNameMap: ShallowRef<Record<number, string>> menu: ShallowRef<PopupMenu | undefined> setMenu: ArgCallback<PopupMenu> - limitSummary: ShallowRef<timer.limit.Summary | undefined> + limitSummary: ShallowRef<tt4b.limit.Summary | undefined> limitSummaryLoading: ShallowRef<boolean> selectedLimit: Ref<number | undefined> } diff --git a/src/pages/popup/types.d.ts b/src/pages/popup/types.d.ts index 65436365a..96a0c0b95 100644 --- a/src/pages/popup/types.d.ts +++ b/src/pages/popup/types.d.ts @@ -13,10 +13,10 @@ export type PopupDuration = | "allTime" export type PopupQuery = { - mergeMethod: Exclude<timer.stat.MergeMethod, 'date'> | undefined + mergeMethod: Exclude<tt4b.stat.MergeMethod, 'date'> | undefined duration: PopupDuration durationNum?: number - dimension: Exclude<timer.core.Dimension, 'run'> + dimension: Exclude<tt4b.core.Dimension, 'run'> } export type PopupOption = { diff --git a/src/pages/side/components/RowList/Item.tsx b/src/pages/side/components/RowList/Item.tsx index 14af89bf5..1aea3e6be 100644 --- a/src/pages/side/components/RowList/Item.tsx +++ b/src/pages/side/components/RowList/Item.tsx @@ -18,14 +18,14 @@ const renderTitle = (siteName: string | undefined, host: string | undefined, han ) } -const renderAvatarText = (row: timer.stat.Row) => { +const renderAvatarText = (row: tt4b.stat.Row) => { const alias = getAlias(row) if (alias) return alias.substring(0, 1)?.toUpperCase?.() return getHost(row)?.substring?.(0, 1)?.toUpperCase?.() } type Props = { - value: timer.stat.Row + value: tt4b.stat.Row max?: number total?: number } diff --git a/src/pages/side/components/RowList/index.tsx b/src/pages/side/components/RowList/index.tsx index b4cb76e53..f0a409277 100644 --- a/src/pages/side/components/RowList/index.tsx +++ b/src/pages/side/components/RowList/index.tsx @@ -6,7 +6,7 @@ import { computed, type CSSProperties, defineComponent, ref, toRef, watch } from import Item from "./Item" type Props = { - data: timer.stat.Row[] + data: tt4b.stat.Row[] loading?: boolean style?: CSSProperties } diff --git a/src/pages/util/dark-mode.ts b/src/pages/util/dark-mode.ts index ef777dfce..ccbb8b55f 100644 --- a/src/pages/util/dark-mode.ts +++ b/src/pages/util/dark-mode.ts @@ -28,7 +28,7 @@ export function initDarkTheme(el?: Element) { getOption().then(processDarkMode) } -function calcDarkMode(option: timer.option.AppearanceOption): boolean { +function calcDarkMode(option: tt4b.option.AppearanceOption): boolean { const { darkMode, darkModeTimeStart: start, darkModeTimeEnd: end } = option if (darkMode === "default") { if (typeof window === 'undefined') return false @@ -55,7 +55,7 @@ function calcDarkMode(option: timer.option.AppearanceOption): boolean { return false } -export function processDarkMode(option: timer.option.AppearanceOption): boolean { +export function processDarkMode(option: tt4b.option.AppearanceOption): boolean { const val = calcDarkMode(option) toggle0(val) return val diff --git a/src/shared/route.ts b/src/shared/route.ts index 307ef5e5d..2c48c3261 100644 --- a/src/shared/route.ts +++ b/src/shared/route.ts @@ -6,7 +6,7 @@ export type AppLimitQuery = { } export const APP_ANALYSIS_ROUTE = '/data/analysis' -export type AppAnalysisQuery = Partial<timer.site.SiteKey> & { +export type AppAnalysisQuery = Partial<tt4b.site.SiteKey> & { cateId?: string url?: string } @@ -24,7 +24,7 @@ export type AppReportQuery = { /** * Merge method */ - mm?: Exclude<timer.stat.MergeMethod, 'date'> + mm?: Exclude<tt4b.stat.MergeMethod, 'date'> /** * Merge date */ @@ -40,5 +40,5 @@ export type AppReportQuery = { /** * Sorted column */ - sc?: timer.core.Dimension + sc?: tt4b.core.Dimension } \ No newline at end of file diff --git a/src/util/constant/option.ts b/src/util/constant/option.ts index a404161f0..a1bb64688 100644 --- a/src/util/constant/option.ts +++ b/src/util/constant/option.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -export const DEFAULT_APPEARANCE: timer.option.AppearanceRequired = { +export const DEFAULT_APPEARANCE: tt4b.option.AppearanceRequired = { displayWhitelistMenu: false, // Change false to true @since 0.8.4 displayBadgeText: true, @@ -21,7 +21,7 @@ export const DEFAULT_APPEARANCE: timer.option.AppearanceRequired = { chartAnimationDuration: 1000, } as const -export const DEFAULT_TRACKING: timer.option.TrackingRequired = { +export const DEFAULT_TRACKING: tt4b.option.TrackingRequired = { autoPauseTracking: false, // 10 minutes autoPauseInterval: 600, @@ -31,7 +31,7 @@ export const DEFAULT_TRACKING: timer.option.TrackingRequired = { storage: 'classic', } as const -export const DEFAULT_LIMIT: timer.option.LimitRequired = { +export const DEFAULT_LIMIT: tt4b.option.LimitRequired = { limitDelayDuration: 5, limitLevel: 'nothing', limitPassword: '', @@ -40,7 +40,7 @@ export const DEFAULT_LIMIT: timer.option.LimitRequired = { limitReminderDuration: 5, } as const -export const DEFAULT_BACKUP: timer.option.BackupOption = { +export const DEFAULT_BACKUP: tt4b.option.BackupOption = { backupType: 'none', clientName: 'unknown', backupAuths: {}, @@ -50,11 +50,11 @@ export const DEFAULT_BACKUP: timer.option.BackupOption = { autoBackUpInterval: 30, } as const -export const DEFAULT_ACCESSIBILITY: timer.option.AccessibilityOption = { +export const DEFAULT_ACCESSIBILITY: tt4b.option.AccessibilityOption = { chartDecal: false } as const -export const DEFAULT_NOTIFICATION: timer.option.NotificationOption = { +export const DEFAULT_NOTIFICATION: tt4b.option.NotificationOption = { notificationCycle: 'none', notificationMethod: 'browser', notificationOffset: 0, @@ -67,4 +67,4 @@ export const defaultOption = () => structuredClone({ ...DEFAULT_LIMIT, ...DEFAULT_ACCESSIBILITY, ...DEFAULT_NOTIFICATION, -}) satisfies timer.option.DefaultOption \ No newline at end of file +}) satisfies tt4b.option.DefaultOption \ No newline at end of file diff --git a/src/util/constant/url.ts b/src/util/constant/url.ts index b684ff7ee..bd8083612 100644 --- a/src/util/constant/url.ts +++ b/src/util/constant/url.ts @@ -43,7 +43,7 @@ export const LICENSE_PAGE = 'https://github.com/sheepzh/time-tracker-4-browser/b /** * @since 0.9.6 */ -export const FEEDBACK_QUESTIONNAIRE: Record<timer.RequiredLocale, string> & Partial<Record<timer.OptionalLocale, string>> = { +export const FEEDBACK_QUESTIONNAIRE: Record<tt4b.RequiredLocale, string> & Partial<Record<tt4b.OptionalLocale, string>> = { zh_CN: TU_CAO_PAGE, zh_TW: 'https://docs.google.com/forms/d/e/1FAIpQLSdfvG6ExLj331YOLZIKO3x98k3kMxpkkLW1RgFuRGmUnZCGRQ/viewform?usp=sf_link', en: 'https://docs.google.com/forms/d/e/1FAIpQLSdNq4gnSY7uxYkyqOPqyYF3Bqlc3ZnWCLDi5DI5xGjPeVCNiw/viewform?usp=sf_link', @@ -53,7 +53,7 @@ const UNINSTALL_QUESTIONNAIRE_EN = 'https://docs.google.com/forms/d/e/1FAIpQLSfl /** * @since 0.9.6 */ -export const UNINSTALL_QUESTIONNAIRE: { [locale in timer.RequiredLocale]: string } & { [locale in timer.OptionalLocale]?: string } = { +export const UNINSTALL_QUESTIONNAIRE: { [locale in tt4b.RequiredLocale]: string } & { [locale in tt4b.OptionalLocale]?: string } = { zh_CN: 'https://www.wjx.cn/vj/YDgY9Yz.aspx', zh_TW: 'https://docs.google.com/forms/d/e/1FAIpQLSdK93q-548dK-2naoS3DaArdc7tEGoUY9JQvaXP5Kpov8h6-A/viewform?usp=sf_link', ja: 'https://docs.google.com/forms/d/e/1FAIpQLSdsB3onZuleNf6j7KJJLbcote647WV6yeUr-9m7Db5QXakfpg/viewform?usp=sf_link', @@ -72,14 +72,14 @@ export function getAppPageUrl(route?: string, query?: any): string { } export const HOMEPAGE = "https://www.wfhg.cc" -const HOMEPAGE_LOCALES: timer.Locale[] = [ +const HOMEPAGE_LOCALES: tt4b.Locale[] = [ "zh_CN", "zh_TW", "en", "ja", "de", "ru", "fr", "es", "pt_PT", ] export function getHomepageWithLocale(): string { - const homepageLocale: timer.Locale = HOMEPAGE_LOCALES.includes(locale) ? locale : "en" + const homepageLocale: tt4b.Locale = HOMEPAGE_LOCALES.includes(locale) ? locale : "en" return `${HOMEPAGE}/${homepageLocale}/` } diff --git a/src/util/document.ts b/src/util/document.ts index 35afd48a4..7ec75f374 100644 --- a/src/util/document.ts +++ b/src/util/document.ts @@ -4,7 +4,7 @@ export const setDir = (direction: 'ltr' | 'rtl') => { htmlEl?.setAttribute('dir', direction) } -export const setLocale = (locale: timer.Locale) => { +export const setLocale = (locale: tt4b.Locale) => { if (isNotExtensionPage()) return const htmlEl = document.getElementsByTagName("html")?.[0] htmlEl?.setAttribute('data-locale', locale) diff --git a/src/util/limit.ts b/src/util/limit.ts index 55d7c5fb3..726938a4e 100644 --- a/src/util/limit.ts +++ b/src/util/limit.ts @@ -17,7 +17,7 @@ const matchUrl = (cond: string, url: string): boolean => { * @param cond * @param url */ -export function matches(cond: timer.limit.Item['cond'], url: string): boolean { +export function matches(cond: tt4b.limit.Item['cond'], url: string): boolean { let hit = false for (let i = cond.length - 1; i >= 0; i--) { const rule = cond[i] @@ -36,7 +36,7 @@ export function matches(cond: timer.limit.Item['cond'], url: string): boolean { * @param cond * @param url */ -export function matchCond(cond: timer.limit.Item['cond'], url: string): string[] { +export function matchCond(cond: tt4b.limit.Item['cond'], url: string): string[] { const matchedNormalRules: string[] = [] for (let i = cond.length - 1; i >= 0; i--) { const rule = cond[i] @@ -65,25 +65,25 @@ export const meetTimeLimit = (limit: LimitInfo, delay: DelayInfo) => { return meetLimit(realLimit, wasted) } -export function hasDailyLimited(item: timer.limit.Item, delayDuration: number): boolean { +export function hasDailyLimited(item: tt4b.limit.Item, delayDuration: number): boolean { const { time, count, waste, visit, delayCount, allowDelay } = item const delay = { count: delayCount, duration: delayDuration, allow: !!allowDelay } const limit = { wasted: waste, maxLimit: (time ?? 0) * MILL_PER_SECOND } return meetTimeLimit(limit, delay) || meetLimit(count, visit) } -export function hasWeeklyLimited(item: timer.limit.Item, delayDuration: number): boolean { +export function hasWeeklyLimited(item: tt4b.limit.Item, delayDuration: number): boolean { const { weekly, weeklyCount, weeklyWaste, weeklyVisit, weeklyDelayCount, allowDelay } = item const delay = { count: weeklyDelayCount, duration: delayDuration, allow: !!allowDelay } const limit = { wasted: weeklyWaste, maxLimit: (weekly ?? 0) * MILL_PER_SECOND } return meetTimeLimit(limit, delay) || meetLimit(weeklyCount, weeklyVisit) } -export function hasLimited(item: timer.limit.Item, delayDuration: number): boolean { +export function hasLimited(item: tt4b.limit.Item, delayDuration: number): boolean { return hasDailyLimited(item, delayDuration) || hasWeeklyLimited(item, delayDuration) } -export function isEffective(weekdays: timer.limit.Rule['weekdays'], weekday?: number): boolean { +export function isEffective(weekdays: tt4b.limit.Rule['weekdays'], weekday?: number): boolean { const weekdayLen = weekdays?.length if (!weekdayLen || weekdayLen <= 0 || weekdayLen >= 7) return true return weekdays.includes(weekday ?? getWeekDay(new Date())) @@ -106,7 +106,7 @@ export const dateMinute2Idx = (date: Date): number => { return hour * 60 + min } -export const period2Str = (p: timer.limit.Period | undefined): string => { +export const period2Str = (p: tt4b.limit.Period | undefined): string => { const [start, end] = p ?? [] return `${idx2Str(start)}-${idx2Str(end)}` } diff --git a/src/util/period.ts b/src/util/period.ts index 4b1fbbfbf..47b246182 100644 --- a/src/util/period.ts +++ b/src/util/period.ts @@ -12,7 +12,7 @@ const PERIOD_PER_DATE = 24 * 60 / MINUTE_PER_PERIOD export const MAX_PERIOD_ORDER = PERIOD_PER_DATE - 1 const MILL_PER_PERIOD = MINUTE_PER_PERIOD * MILL_PER_MINUTE -export function keyOf(time: Date | number, order?: number): timer.period.Key { +export function keyOf(time: Date | number, order?: number): tt4b.period.Key { time = time instanceof Date ? time : new Date(time) const year = time.getFullYear() const month = time.getMonth() + 1 @@ -23,7 +23,7 @@ export function keyOf(time: Date | number, order?: number): timer.period.Key { return { year, month, date, order } } -export function indexOf(key: timer.period.Key): number { +export function indexOf(key: tt4b.period.Key): number { const { year, month, date, order } = key return (year << 18) | (month << 14) @@ -31,24 +31,24 @@ export function indexOf(key: timer.period.Key): number { | order } -export function compare(a: timer.period.Key, b: timer.period.Key): number { +export function compare(a: tt4b.period.Key, b: tt4b.period.Key): number { return indexOf(a) - indexOf(b) } -export function after(key: timer.period.Key, orderCount: number): timer.period.Key { +export function after(key: tt4b.period.Key, orderCount: number): tt4b.period.Key { const date = new Date(key.year, key.month - 1, key.date, 0, (key.order + orderCount) * MINUTE_PER_PERIOD, 1) return keyOf(date) } -export function startOfKey(key: timer.period.Key): Date { +export function startOfKey(key: tt4b.period.Key): Date { return new Date(key.year, key.month - 1, key.date, 0, MINUTE_PER_PERIOD * key.order) } -export function getDateString(key: timer.period.Key) { +export function getDateString(key: tt4b.period.Key) { return `${key.year}${key.month < 10 ? '0' : ''}${key.month}${key.date < 10 ? '0' : ''}${key.date}` } -export function rowOf(endKey: timer.period.Key, duration?: number, milliseconds?: number): timer.period.Row { +export function rowOf(endKey: tt4b.period.Key, duration?: number, milliseconds?: number): tt4b.period.Row { duration = duration || 1 milliseconds = milliseconds || 0 const date = getDateString(endKey) @@ -58,7 +58,7 @@ export function rowOf(endKey: timer.period.Key, duration?: number, milliseconds? return { startTime, endTime, milliseconds, date } } -function generateOrderMap(data: timer.period.Row[], periodSize: number): Map<number, number> { +function generateOrderMap(data: tt4b.period.Row[], periodSize: number): Map<number, number> { const map: Map<number, number> = new Map() data.forEach(item => { const key = Math.floor(startOrderOfRow(item) / periodSize) @@ -68,13 +68,13 @@ function generateOrderMap(data: timer.period.Row[], periodSize: number): Map<num return map } -function startOrderOfRow(row: timer.period.Row): number { +function startOrderOfRow(row: tt4b.period.Row): number { const d = new Date(row.startTime) return (d.getHours() * 60 + d.getMinutes()) / MINUTE_PER_PERIOD } -function cvt2AverageResult(map: Map<number, number>, periodSize: number, dateNum: number): timer.period.Row[] { - const result: timer.period.Row[] = [] +function cvt2AverageResult(map: Map<number, number>, periodSize: number, dateNum: number): tt4b.period.Row[] { + const result: tt4b.period.Row[] = [] let period = keyOf(new Date(), 0) for (let i = 0; i < PERIOD_PER_DATE / periodSize; i++) { const key = period.order / periodSize @@ -86,7 +86,7 @@ function cvt2AverageResult(map: Map<number, number>, periodSize: number, dateNum return result } -export function averageByDay(data: timer.period.Row[], periodSize: number): timer.period.Row[] { +export function averageByDay(data: tt4b.period.Row[], periodSize: number): tt4b.period.Row[] { if (!data?.length) return [] const rangeStart = data[0]?.startTime const rangeEnd = data[data.length - 1]?.endTime diff --git a/src/util/site.ts b/src/util/site.ts index 0cbf4d596..ac7cb7e78 100644 --- a/src/util/site.ts +++ b/src/util/site.ts @@ -54,12 +54,12 @@ export function generateSiteLabel(host: string, name?: string): string { * * @since 3.0.0 */ -export function supportCategory(siteKey: timer.site.SiteKey | undefined): boolean { +export function supportCategory(siteKey: tt4b.site.SiteKey | undefined): boolean { const { type } = siteKey || {} return type === 'normal' } -export function siteEqual(a: timer.site.SiteKey | undefined, b: timer.site.SiteKey | undefined) { +export function siteEqual(a: tt4b.site.SiteKey | undefined, b: tt4b.site.SiteKey | undefined) { if (!a && !b) return true if (a === b) return true return a?.host === b?.host && a?.type === b?.type @@ -72,25 +72,25 @@ export const CATE_NOT_SET_ID = -1 type SiteIdentityPrefix = 'n' | 'm' | 'v' -const TYPE_PREFIX_MAP: { [type in timer.site.Type]: SiteIdentityPrefix } = { +const TYPE_PREFIX_MAP: { [type in tt4b.site.Type]: SiteIdentityPrefix } = { normal: "n", merged: "m", virtual: "v", } -const PREFIX_TYPE_MAP: { [prefix in SiteIdentityPrefix]: timer.site.Type } = { +const PREFIX_TYPE_MAP: { [prefix in SiteIdentityPrefix]: tt4b.site.Type } = { n: 'normal', m: 'merged', v: 'virtual', } -export function identifySiteKey(site: timer.site.SiteKey | undefined): string { +export function identifySiteKey(site: tt4b.site.SiteKey | undefined): string { if (!site) return '' const { host, type } = site || {} return (TYPE_PREFIX_MAP[type] ?? ' ') + (host || '') } -export function parseSiteIdentity(identity: string | undefined): timer.site.SiteKey | undefined { +export function parseSiteIdentity(identity: string | undefined): tt4b.site.SiteKey | undefined { if (!identity) return const type = PREFIX_TYPE_MAP[identity.charAt(0) as SiteIdentityPrefix] if (!type) return @@ -99,13 +99,13 @@ export function parseSiteIdentity(identity: string | undefined): timer.site.Site return { type, host } } -function cloneSiteKey(origin: timer.site.SiteKey | undefined): timer.site.SiteKey | undefined { +function cloneSiteKey(origin: tt4b.site.SiteKey | undefined): tt4b.site.SiteKey | undefined { if (!origin) return return { host: origin.host, type: origin.type } } -export function distinctSites(list: timer.site.SiteKey[]): timer.site.SiteKey[] { - const map: Record<string, timer.site.SiteKey> = {} +export function distinctSites(list: tt4b.site.SiteKey[]): tt4b.site.SiteKey[] { + const map: Record<string, tt4b.site.SiteKey> = {} list?.forEach(ele => { const key = identifySiteKey(ele) if (map[key]) return @@ -116,29 +116,29 @@ export function distinctSites(list: timer.site.SiteKey[]): timer.site.SiteKey[] } export class SiteMap<T> { - private innerMap: Record<string, [timer.site.SiteKey, T]> + private innerMap: Record<string, [tt4b.site.SiteKey, T]> constructor() { this.innerMap = {} } - static identify<T extends timer.site.SiteKey>(data: T[]): SiteMap<T> { + static identify<T extends tt4b.site.SiteKey>(data: T[]): SiteMap<T> { const map = new SiteMap<T>() data.forEach(item => map.put(item, item)) return map } - public put(site: timer.site.SiteKey, t: T): void { + public put(site: tt4b.site.SiteKey, t: T): void { const key = identifySiteKey(site) this.innerMap[key] = [site, t] } - public get(site: timer.site.SiteKey): T | null { + public get(site: tt4b.site.SiteKey): T | null { const key = identifySiteKey(site) return this.innerMap[key]?.[1] ?? null } - public map<R>(mapper: (key: timer.site.SiteKey, value: T) => R): R[] { + public map<R>(mapper: (key: tt4b.site.SiteKey, value: T) => R): R[] { return Object.values(this.innerMap).map(([site, val]) => mapper?.(site, val)) } @@ -146,11 +146,11 @@ export class SiteMap<T> { return Object.keys(this.innerMap).length } - public keys(): timer.site.SiteKey[] { + public keys(): tt4b.site.SiteKey[] { return Object.values(this.innerMap).map(v => v[0]) } - public forEach(func: (k: timer.site.SiteKey, v: T, idx: number) => void) { + public forEach(func: (k: tt4b.site.SiteKey, v: T, idx: number) => void) { if (!func) return Object.values(this.innerMap).forEach(([k, v], idx) => func(k, v, idx)) } diff --git a/src/util/stat.ts b/src/util/stat.ts index f052653d5..1fa609773 100644 --- a/src/util/stat.ts +++ b/src/util/stat.ts @@ -7,17 +7,17 @@ import { identifySiteKey } from "./site" * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -export function isNotZeroResult(target: timer.core.Result): boolean { +export function isNotZeroResult(target: tt4b.core.Result): boolean { return !!target.focus || !!target.time } -export function resultOf(focus: number, time: number): timer.core.Result { +export function resultOf(focus: number, time: number): tt4b.core.Result { return { focus, time } } -export const ALL_DIMENSIONS: timer.core.Dimension[] = ['focus', 'time'] +export const ALL_DIMENSIONS: tt4b.core.Dimension[] = ['focus', 'time'] -export function identifyTargetKey(targetKey: timer.stat.TargetKey): string { +export function identifyTargetKey(targetKey: tt4b.stat.TargetKey): string { if ('cateKey' in targetKey) { return `cate_${targetKey.cateKey}` } else if ('siteKey' in targetKey) { @@ -27,61 +27,61 @@ export function identifyTargetKey(targetKey: timer.stat.TargetKey): string { } } -export function identifyStatKey(rowKey: timer.stat.StatKey) { +export function identifyStatKey(rowKey: tt4b.stat.StatKey) { const { date } = rowKey || {} return [identifyTargetKey(rowKey), date ?? ''].join('_') } -export const isNormalSite = (row: timer.stat.Row): row is timer.stat.SiteRow => { +export const isNormalSite = (row: tt4b.stat.Row): row is tt4b.stat.SiteRow => { return 'siteKey' in row && row.siteKey.type === 'normal' } -export const isGroup = (row: timer.stat.StatKey): row is timer.stat.GroupRow => { +export const isGroup = (row: tt4b.stat.StatKey): row is tt4b.stat.GroupRow => { return 'groupKey' in row } -export const isSite = (row: timer.stat.StatKey): row is timer.stat.SiteRow => { +export const isSite = (row: tt4b.stat.StatKey): row is tt4b.stat.SiteRow => { return 'siteKey' in row } -export const isCate = (row: timer.stat.StatKey): row is timer.stat.CateRow => { +export const isCate = (row: tt4b.stat.StatKey): row is tt4b.stat.CateRow => { return 'cateKey' in row } -export const getHost = (row: timer.stat.Row): string | undefined => { +export const getHost = (row: tt4b.stat.Row): string | undefined => { return 'siteKey' in row ? row.siteKey.host : undefined } -export const getAlias = (row: timer.stat.Row): string | undefined => { +export const getAlias = (row: tt4b.stat.Row): string | undefined => { return 'alias' in row ? row.alias : undefined } -export const getIconUrl = (row: timer.stat.Row): string | undefined => { +export const getIconUrl = (row: tt4b.stat.Row): string | undefined => { return 'iconUrl' in row ? row.iconUrl : undefined } -export const getRelatedCateId = (row: timer.stat.Row): number | undefined => { +export const getRelatedCateId = (row: tt4b.stat.Row): number | undefined => { if ('cateId' in row) return row.cateId if ('cateKey' in row) return row.cateKey return undefined } -export const getComposition = (row: timer.stat.Row, dimension: timer.core.Dimension): timer.stat.RemoteCompositionVal[] => { +export const getComposition = (row: tt4b.stat.Row, dimension: tt4b.core.Dimension): tt4b.stat.RemoteCompositionVal[] => { return 'composition' in row ? row.composition?.[dimension] ?? [] : [] } -export const getGroupName = (groupMap: Record<string, chrome.tabGroups.TabGroup>, row: timer.stat.GroupRow): string => { +export const getGroupName = (groupMap: Record<string, chrome.tabGroups.TabGroup>, row: tt4b.stat.GroupRow): string => { const { groupKey } = row const title = groupMap[groupKey]?.title return title ?? `ID: ${groupKey}` } -export const mergeWith = async <T extends timer.core.RowKey,>( - original: T[], exist: timer.core.Row[], - mergeFunc: (o: T, e: timer.core.Row | undefined) => Awaitable<void>, +export const mergeWith = async <T extends tt4b.core.RowKey,>( + original: T[], exist: tt4b.core.Row[], + mergeFunc: (o: T, e: tt4b.core.Row | undefined) => Awaitable<void>, ) => { - const keyOf = ({ date, host }: timer.core.RowKey) => `${date}${host}` + const keyOf = ({ date, host }: tt4b.core.RowKey) => `${date}${host}` const existMap = toMap(exist, keyOf) for (const o of original) { await mergeFunc(o, existMap[keyOf(o)]) diff --git a/test-e2e/backup/common.ts b/test-e2e/backup/common.ts index ea809883f..5a64e932f 100644 --- a/test-e2e/backup/common.ts +++ b/test-e2e/backup/common.ts @@ -3,7 +3,7 @@ import { type LaunchContext } from '../common/base' import { waitForMessage, waitForSuccMessage } from '../common/message' import { sleep } from '../common/util' -const typeNames: Record<timer.backup.Type, string> = { +const typeNames: Record<tt4b.backup.Type, string> = { gist: 'gist', web_dav: 'dav', obsidian_local_rest_api: 'obsidian', @@ -73,7 +73,7 @@ export class BackupOptionWrapper { return await page.$(`#${OPTION_TAB_ID} ${selector}`) } - async changeType(type: timer.backup.Type) { + async changeType(type: tt4b.backup.Type) { const page = await this.page() const pane = await page.$(`#${OPTION_TAB_ID} .el-select`) await pane?.click() diff --git a/test-e2e/limit/common.ts b/test-e2e/limit/common.ts index f181711ab..bf242d03d 100644 --- a/test-e2e/limit/common.ts +++ b/test-e2e/limit/common.ts @@ -17,7 +17,7 @@ export async function isLimitModalVisible(page: Page): Promise<boolean> { }) } -export async function createLimitRule(rule: timer.limit.Rule, page: Page) { +export async function createLimitRule(rule: tt4b.limit.Rule, page: Page) { const createButton = await page.$('.el-card:first-child .el-button:last-child') await createButton!.click() // 1 Fill the name diff --git a/test-e2e/limit/daily-time.test.ts b/test-e2e/limit/daily-time.test.ts index 6629c89c0..695af8be3 100644 --- a/test-e2e/limit/daily-time.test.ts +++ b/test-e2e/limit/daily-time.test.ts @@ -12,7 +12,7 @@ describe('Daily time limit', () => { test('basic', async () => { const limitTime = 2 const limitPage = await context.openAppPage('/behavior/limit') - const demoRule: timer.limit.Rule = { + const demoRule: tt4b.limit.Rule = { id: 1, name: 'TEST DAILY LIMIT', cond: [MOCK_URL], time: limitTime, diff --git a/test-e2e/limit/daily-visit.test.ts b/test-e2e/limit/daily-visit.test.ts index 6c1ce5075..3298c0298 100644 --- a/test-e2e/limit/daily-visit.test.ts +++ b/test-e2e/limit/daily-visit.test.ts @@ -11,7 +11,7 @@ describe('Daily time limit', () => { test("Daily visit limit", async () => { const limitPage = await context.openAppPage('/behavior/limit') - const demoRule: timer.limit.Rule = { + const demoRule: tt4b.limit.Rule = { id: 1, name: 'TEST DAILY LIMIT', cond: [MOCK_URL], time: 0, count: 1, diff --git a/test-e2e/limit/delay-duration.test.ts b/test-e2e/limit/delay-duration.test.ts index bda56bd14..c7822d4e3 100644 --- a/test-e2e/limit/delay-duration.test.ts +++ b/test-e2e/limit/delay-duration.test.ts @@ -18,7 +18,7 @@ async function findRuleId(page: Page): Promise<number> { ruleName => new Promise<number>((resolve, reject) => { chrome.runtime.sendMessage({ code: 'limit.list', data: undefined }, (res: { code?: string - data?: timer.limit.Item[] + data?: tt4b.limit.Item[] msg?: string }) => { if (res?.code !== 'success') return reject(new Error(res?.msg ?? 'limit.list failed')) @@ -56,7 +56,7 @@ const DEMO_RULE = { enabled: true, allowDelay: true, locked: false, -} as const satisfies timer.limit.Rule +} as const satisfies tt4b.limit.Rule describe('Limit delay duration', () => { let context: LaunchContext diff --git a/test-e2e/limit/visit-limit.test.ts b/test-e2e/limit/visit-limit.test.ts index 018d654f9..8871f9336 100644 --- a/test-e2e/limit/visit-limit.test.ts +++ b/test-e2e/limit/visit-limit.test.ts @@ -11,7 +11,7 @@ describe('Time limit per visit', () => { test("Delay", async () => { const limitPage = await context.openAppPage('/behavior/limit') - const demoRule: timer.limit.Rule = { + const demoRule: tt4b.limit.Rule = { id: 1, name: 'TEST DAILY LIMIT', cond: [MOCK_URL], visitTime: 1, diff --git a/test/background/backup/gist/compressor.test.ts b/test/background/backup/gist/compressor.test.ts index 7d2a90435..cb2d32d4c 100644 --- a/test/background/backup/gist/compressor.test.ts +++ b/test/background/backup/gist/compressor.test.ts @@ -1,7 +1,7 @@ import { divide2Buckets, type GistData, gistData2Rows } from "@service/backup/gist/compressor" test('divide 1', () => { - const rows: timer.core.Row[] = [{ + const rows: tt4b.core.Row[] = [{ host: 'www.baidu.com', date: '20220801', focus: 0, diff --git a/test/background/database/limit-database.test.ts b/test/background/database/limit-database.test.ts index d50dd6f67..9c4a651b3 100644 --- a/test/background/database/limit-database.test.ts +++ b/test/background/database/limit-database.test.ts @@ -8,7 +8,7 @@ describe('limit-database', () => { beforeEach(async () => chrome.storage.local.clear()) test('save, all, remove', async () => { - const toAdd: timer.limit.Rule = { + const toAdd: tt4b.limit.Rule = { id: 1, name: "foobar", cond: ['123'], @@ -18,7 +18,7 @@ describe('limit-database', () => { locked: false, } const id = await db.add(toAdd) - let all: timer.limit.Rule[] = await db.all() + let all: tt4b.limit.Rule[] = await db.all() expect(all.length).toEqual(1) let { cond, time, name, enabled, allowDelay } = all[0] ?? {} expect(cond).toEqual(toAdd.cond) @@ -66,7 +66,7 @@ describe('limit-database', () => { }) test("import data", async () => { - const cond1: MakeOptional<timer.limit.Rule, 'id'> = { + const cond1: MakeOptional<tt4b.limit.Rule, 'id'> = { name: 'foobar1', cond: ["cond1"], time: 20, @@ -74,7 +74,7 @@ describe('limit-database', () => { enabled: true, locked: false, } - const cond2: MakeOptional<timer.limit.Rule, 'id'> = { + const cond2: MakeOptional<tt4b.limit.Rule, 'id'> = { name: 'foobar2', cond: ["cond2"], time: 20, diff --git a/test/background/database/merge-rule-database.test.ts b/test/background/database/merge-rule-database.test.ts index 8618aedc5..009a6fd10 100644 --- a/test/background/database/merge-rule-database.test.ts +++ b/test/background/database/merge-rule-database.test.ts @@ -2,7 +2,7 @@ import db from "@db/merge-rule-database" import { mockStorage } from "../../__mock__/storage" import { mockLegacyData } from './migratable' -function of(origin: string, merged?: string | number): timer.merge.Rule { +function of(origin: string, merged?: string | number): tt4b.merge.Rule { return { origin, merged: merged || '' } } @@ -12,7 +12,7 @@ describe('merge-rule-database', () => { beforeEach(async () => chrome.storage.local.clear()) test('add, selectAll, remove', async () => { - let toAdd: timer.merge.Rule[] = [of('4', 2)] + let toAdd: tt4b.merge.Rule[] = [of('4', 2)] await db.add(...toAdd) expect((await db.selectAll())).toEqual(expect.arrayContaining(toAdd)) toAdd = [ @@ -42,7 +42,7 @@ describe('merge-rule-database', () => { expect(await db.selectAll()).toEqual([]) await db.importData(mockLegacyData(data2Import)) - const imported: timer.merge.Rule[] = await db.selectAll() + const imported: tt4b.merge.Rule[] = await db.selectAll() expect(imported).toEqual([ { origin: "www.baidu.com", merged: 2 }, { origin: "www.google.com", merged: "google.com" } diff --git a/test/background/database/migratable.ts b/test/background/database/migratable.ts index ce2e3692a..0f944250f 100644 --- a/test/background/database/migratable.ts +++ b/test/background/database/migratable.ts @@ -1,5 +1,5 @@ -export function mockLegacyData(data: Record<string, unknown>): timer.backup.ExportData { - const withMeta: timer.backup.ExportData = { +export function mockLegacyData(data: Record<string, unknown>): tt4b.backup.ExportData { + const withMeta: tt4b.backup.ExportData = { ...data, __meta__: { version: "3.8.15", diff --git a/test/background/database/period-database.test.ts b/test/background/database/period-database.test.ts index 84f3e4c8c..e4edff40d 100644 --- a/test/background/database/period-database.test.ts +++ b/test/background/database/period-database.test.ts @@ -3,7 +3,7 @@ import { keyOf } from '@util/period' import { formatTimeYMD } from "@util/time" import { mockStorage } from "../../__mock__/storage" -function resultOf(date: Date, orderNum: number, milliseconds: number): timer.period.Result { +function resultOf(date: Date, orderNum: number, milliseconds: number): tt4b.period.Result { return { ...keyOf(date, orderNum), milliseconds } } @@ -19,7 +19,7 @@ describe('period-database', () => { expect((await db.get(dateStr))).toEqual({}) - const toAdd: timer.period.Result[] = [ + const toAdd: tt4b.period.Result[] = [ resultOf(date, 0, 56999), resultOf(date, 1, 2), resultOf(yesterday, 95, 2) @@ -38,7 +38,7 @@ describe('period-database', () => { test('getBatch', async () => { const date = new Date(2021, 5, 7) const yesterday = new Date(2021, 5, 6) - const toAdd: timer.period.Result[] = [ + const toAdd: tt4b.period.Result[] = [ resultOf(date, 0, 56999), resultOf(date, 1, 2), resultOf(yesterday, 95, 2) diff --git a/test/background/database/stat-database/classic.test.ts b/test/background/database/stat-database/classic.test.ts index 78965c955..3c47b92e9 100644 --- a/test/background/database/stat-database/classic.test.ts +++ b/test/background/database/stat-database/classic.test.ts @@ -22,7 +22,7 @@ describe('stat-database/classic', () => { test('accumulate and get single host result', async () => { await db.accumulate(baidu, now, resultOf(100, 0)) - const data: timer.core.Result = await db.get(baidu, now) + const data: tt4b.core.Result = await db.get(baidu, now) expect(data).toMatchObject(resultOf(100, 0)) }) @@ -30,10 +30,10 @@ describe('stat-database/classic', () => { 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({ host: baidu, date: now, focus: 400, time: 0 } satisfies timer.core.Row) + expect(data).toEqual({ host: baidu, date: now, focus: 400, time: 0 } satisfies tt4b.core.Row) await db.accumulate(baidu, now, resultOf(0, 1)) data = await db.get(baidu, now) - expect(data).toEqual({ host: baidu, date: now, focus: 400, time: 1 } satisfies timer.core.Row) + expect(data).toEqual({ host: baidu, date: now, focus: 400, time: 1 } satisfies tt4b.core.Row) }) test('batchAccumulate and select with condition/date range', async () => { @@ -67,7 +67,7 @@ describe('stat-database/classic', () => { // By date range cond = { date: [now, now] } - const expectedResult: timer.core.Row[] = [ + const expectedResult: tt4b.core.Row[] = [ { date: now, focus: 11, host: google, time: 0 }, { date: now, focus: 1, host: baidu, time: 0 }, ] diff --git a/test/background/database/stat-database/idb.test.ts b/test/background/database/stat-database/idb.test.ts index 71f6b5cfa..8ed585dc6 100644 --- a/test/background/database/stat-database/idb.test.ts +++ b/test/background/database/stat-database/idb.test.ts @@ -28,10 +28,10 @@ describe('stat-database/idb', () => { // Hosts const github = await db.get(GITHUB, '20240601') - expect(github).toEqual({ host: GITHUB, date: '20240601', focus: 10, time: 20 } satisfies timer.core.Row) + expect(github).toEqual({ host: GITHUB, date: '20240601', focus: 10, time: 20 } satisfies tt4b.core.Row) const google = await db.get(GOOGLE, new Date(2025, 10, 1)) - expect(google).toEqual({ host: GOOGLE, date: '20251101', focus: 1, time: 0 } satisfies timer.core.Row) + expect(google).toEqual({ host: GOOGLE, date: '20251101', focus: 1, time: 0 } satisfies tt4b.core.Row) // Date not exist const notExist = await db.get(GOOGLE, '20240601') @@ -42,14 +42,14 @@ describe('stat-database/idb', () => { expect(list).toEqual([ { host: GITHUB, date: '20240601', focus: 10, time: 20 }, { host: GOOGLE, date: '20251101', focus: 1, time: 0 }, - ] satisfies timer.core.Row[]) + ] satisfies tt4b.core.Row[]) // Groups const byGroupId = await db.get(`${GROUP_1}`, '20240601') expect(byGroupId).toMatchObject(zeroResult()) const groups = await db.selectGroup({ date: '20240601' }) - expect(groups).toEqual([{ host: `${GROUP_1}`, date: '20240601', focus: 5, time: 10 } satisfies timer.core.Row]) + expect(groups).toEqual([{ host: `${GROUP_1}`, date: '20240601', focus: 5, time: 10 } satisfies tt4b.core.Row]) }) test('batchAccumulate', async () => { @@ -63,15 +63,15 @@ describe('stat-database/idb', () => { }, '20240602') expect(await db.get(GITHUB, '20240602')) - .toEqual({ host: GITHUB, date: '20240602', focus: 10, time: 20 } satisfies timer.core.Row) + .toEqual({ host: GITHUB, date: '20240602', focus: 10, time: 20 } satisfies tt4b.core.Row) expect(await db.get(GOOGLE, '20240602')) - .toEqual({ host: GOOGLE, date: '20240602', focus: 1, time: 0 } satisfies timer.core.Row) + .toEqual({ host: GOOGLE, date: '20240602', focus: 1, time: 0 } satisfies tt4b.core.Row) expect(await db.get(MAYBE_GROUP_1, '20240602')) - .toEqual({ host: MAYBE_GROUP_1, date: '20240602', focus: 5, time: 10 } satisfies timer.core.Row) + .toEqual({ host: MAYBE_GROUP_1, date: '20240602', focus: 5, time: 10 } satisfies tt4b.core.Row) await db.batchAccumulate({ [GOOGLE]: { focus: 0, time: 1 } }, '20240602') - expect(await db.get(GOOGLE, '20240602')).toEqual({ host: GOOGLE, date: '20240602', focus: 1, time: 1 } satisfies timer.core.Row) + expect(await db.get(GOOGLE, '20240602')).toEqual({ host: GOOGLE, date: '20240602', focus: 1, time: 1 } satisfies tt4b.core.Row) }) test('multiple indexes', async () => { @@ -96,83 +96,83 @@ describe('stat-database/idb', () => { expect(await db.select({ date: [, '20240602'] })).toEqual([ { host: GITHUB, date: '20240602', focus: 10, time: 20 }, { host: GOOGLE, date: '20240602', focus: 1, time: 0 }, - ] satisfies timer.core.Row[]) + ] satisfies tt4b.core.Row[]) expect(await db.select({ date: '20240603' })).toEqual([ { host: GITHUB, date: '20240603', focus: 10, time: 20 }, { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, - ] satisfies timer.core.Row[]) + ] satisfies tt4b.core.Row[]) expect(await db.select({ date: ['20240602', '20240603'] })).toEqual([ { host: GITHUB, date: '20240602', focus: 10, time: 20 }, { host: GOOGLE, date: '20240602', focus: 1, time: 0 }, { host: GITHUB, date: '20240603', focus: 10, time: 20 }, { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, - ] satisfies timer.core.Row[]) + ] satisfies tt4b.core.Row[]) // Same as above, but reversed order expect(await db.select({ date: ['20240603', '20240602'] })).toEqual([ { host: GITHUB, date: '20240602', focus: 10, time: 20 }, { host: GOOGLE, date: '20240602', focus: 1, time: 0 }, { host: GITHUB, date: '20240603', focus: 10, time: 20 }, { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, - ] satisfies timer.core.Row[]) + ] satisfies tt4b.core.Row[]) // Including virtual expect(await db.select({ date: '20240603', virtual: true })).toEqual([ { host: GITHUB, date: '20240603', focus: 10, time: 20 }, { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, - ] satisfies timer.core.Row[]) + ] satisfies tt4b.core.Row[]) // Query by host expect(await db.select({ keys: GOOGLE })).toEqual([ { host: GOOGLE, date: '20240602', focus: 1, time: 0 }, { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, - ] satisfies timer.core.Row[]) + ] satisfies tt4b.core.Row[]) expect(await db.select({ keys: GITHUB })).toEqual([ { host: GITHUB, date: '20240602', focus: 10, time: 20 }, { host: GITHUB, date: '20240603', focus: 10, time: 20 }, - ] satisfies timer.core.Row[]) - expect(await db.select({ keys: GITHUB_VIRTUAL })).toEqual([] satisfies timer.core.Row[]) + ] satisfies tt4b.core.Row[]) + expect(await db.select({ keys: GITHUB_VIRTUAL })).toEqual([] satisfies tt4b.core.Row[]) expect(await db.select({ keys: GITHUB_VIRTUAL, virtual: true })).toEqual([ { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, - ] satisfies timer.core.Row[]) + ] satisfies tt4b.core.Row[]) // Query by date and host index expect(await db.select({ date: '20240603', keys: GOOGLE })).toEqual([ { host: GOOGLE, date: '20240603', focus: 1, time: 0 }, - ] satisfies timer.core.Row[]) - expect(await db.select({ date: '20240603', keys: GITHUB_VIRTUAL })).toEqual([] satisfies timer.core.Row[]) + ] satisfies tt4b.core.Row[]) + expect(await db.select({ date: '20240603', keys: GITHUB_VIRTUAL })).toEqual([] satisfies tt4b.core.Row[]) expect(await db.select({ date: '20240603', keys: GITHUB_VIRTUAL, virtual: true })).toEqual([ { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, - ] satisfies timer.core.Row[]) + ] satisfies tt4b.core.Row[]) // Query by time index expect(await db.select({ timeRange: [10, 20] })).toEqual([ { host: GITHUB, date: '20240602', focus: 10, time: 20 }, { host: GITHUB, date: '20240603', focus: 10, time: 20 }, - ] satisfies timer.core.Row[]) + ] satisfies tt4b.core.Row[]) expect(await db.select({ timeRange: [10, 20], virtual: true })).toEqual([ { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, { host: GITHUB, date: '20240602', focus: 10, time: 20 }, { host: GITHUB, date: '20240603', focus: 10, time: 20 }, - ] satisfies timer.core.Row[]) + ] satisfies tt4b.core.Row[]) expect(await db.select({ timeRange: [10, 20], date: '20240603', virtual: true })).toEqual([ { host: GITHUB, date: '20240603', focus: 10, time: 20 }, { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, - ] satisfies timer.core.Row[]) + ] satisfies tt4b.core.Row[]) // Query by focus index expect(await db.select({ focusRange: [5, 10] })).toEqual([ { host: GITHUB, date: '20240602', focus: 10, time: 20 }, { host: GITHUB, date: '20240603', focus: 10, time: 20 }, - ] satisfies timer.core.Row[]) + ] satisfies tt4b.core.Row[]) expect(await db.select({ focusRange: [5, 10], virtual: true })).toEqual([ { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, { host: GITHUB, date: '20240602', focus: 10, time: 20 }, { host: GITHUB, date: '20240603', focus: 10, time: 20 }, - ] satisfies timer.core.Row[]) + ] satisfies tt4b.core.Row[]) expect(await db.select({ focusRange: [5, 10], date: '20240603', virtual: true })).toEqual([ { host: GITHUB, date: '20240603', focus: 10, time: 20 }, { host: GITHUB_VIRTUAL, date: '20240603', focus: 5, time: 10 }, - ] satisfies timer.core.Row[]) + ] satisfies tt4b.core.Row[]) }) test('delete', async () => { @@ -193,10 +193,10 @@ describe('stat-database/idb', () => { await db.deleteByGroup(GROUP_1) expect(await db.get(MAYBE_GROUP_1, '20240603')) - .toEqual({ host: MAYBE_GROUP_1, date: '20240603', focus: 222, time: 222 } satisfies timer.core.Row) + .toEqual({ host: MAYBE_GROUP_1, date: '20240603', focus: 222, time: 222 } satisfies tt4b.core.Row) await db.delete({ host: MAYBE_GROUP_1, date: '20240603' }, { host: GITHUB_VIRTUAL, date: '20240603' }) - expect(await db.select({ virtual: true })).toEqual([] satisfies timer.core.Row[]) + expect(await db.select({ virtual: true })).toEqual([] satisfies tt4b.core.Row[]) }) test('multiple select groups', async () => { @@ -216,11 +216,11 @@ describe('stat-database/idb', () => { expect(await db.selectGroup({ date: '20240603' })).toEqual([ { date: '20240603', host: `${GROUP_1}`, focus: 3, time: 3 }, { date: '20240603', host: `${GROUP_2}`, focus: 4, time: 4 }, - ] satisfies timer.core.Row[]) + ] satisfies tt4b.core.Row[]) expect(await db.selectGroup({ date: ['20240602', '20240603'], keys: GROUP_1.toString() })).toEqual([ { date: '20240602', host: `${GROUP_1}`, focus: 1, time: 1 }, { date: '20240603', host: `${GROUP_1}`, focus: 3, time: 3 }, - ] satisfies timer.core.Row[]) + ] satisfies tt4b.core.Row[]) }) }) \ No newline at end of file diff --git a/test/background/service/components/period-calculator.test.ts b/test/background/service/components/period-calculator.test.ts index 8eac85fe3..267c20cb6 100644 --- a/test/background/service/components/period-calculator.test.ts +++ b/test/background/service/components/period-calculator.test.ts @@ -4,7 +4,7 @@ import { MILL_PER_DAY } from "@util/time" const PERIOD_PER_DATE = 24 * 60 / MINUTE_PER_PERIOD -function resultOf(date: Date | number, orderNum: number, milliseconds: number): timer.period.Result { +function resultOf(date: Date | number, orderNum: number, milliseconds: number): tt4b.period.Result { return { ...keyOf(date, orderNum), milliseconds } } @@ -27,13 +27,13 @@ test('', () => { let result = calculate(ts1, 877) expect(result.length).toEqual(1) let current = result[0] - let realCurrent: timer.period.Result = resultOf(base, 1 + 13 * 4, 877) + let realCurrent: tt4b.period.Result = resultOf(base, 1 + 13 * 4, 877) expect(current).toEqual(realCurrent) result = calculate(ts1, (7 * 60 + 56) * 1000 + 877) expect(result.length).toEqual(2) const last = result[0] - const realLast: timer.period.Result = resultOf(base, 13 * 4, 1) + const realLast: tt4b.period.Result = resultOf(base, 13 * 4, 1) expect(last).toEqual(realLast) current = result[1] realCurrent.milliseconds = (7 * 60 + 56) * 1000 + 876 @@ -63,7 +63,7 @@ test('', () => { test.skip('merge', () => { const d20200506 = new Date(2020, 4, 6) - const toMerge: timer.period.Result[] = [ + const toMerge: tt4b.period.Result[] = [ resultOf(d20200506, 19, 20), resultOf(d20200506, 18, 20), resultOf(d20200506, 20, 20), diff --git a/test/background/service/limit-service.test.ts b/test/background/service/limit-service.test.ts index 5cdea1aa9..d7cb49a0c 100644 --- a/test/background/service/limit-service.test.ts +++ b/test/background/service/limit-service.test.ts @@ -1,6 +1,6 @@ import { mockStorage } from "../../__mock__/storage" -let calcTimeState: (item: timer.limit.Item, reminderMills: number, delayDuration: number) => { +let calcTimeState: (item: tt4b.limit.Item, reminderMills: number, delayDuration: number) => { daily: 'NORMAL' | 'REMINDER' | 'LIMITED' weekly: 'NORMAL' | 'REMINDER' | 'LIMITED' } @@ -19,7 +19,7 @@ beforeAll(async () => { describe("background/limit-service", () => { test("calcTimeState", () => { - const item: timer.limit.Item = { + const item: tt4b.limit.Item = { id: 1, name: "foobar", cond: [], diff --git a/test/util/limit.test.ts b/test/util/limit.test.ts index 24f045c70..209ab07cd 100644 --- a/test/util/limit.test.ts +++ b/test/util/limit.test.ts @@ -87,7 +87,7 @@ describe('util/limit', () => { }) test('hasWeeklyLimited', () => { - const item: timer.limit.Item = { + const item: tt4b.limit.Item = { id: 1, name: 'foobar', cond: [], @@ -119,8 +119,8 @@ describe('util/limit', () => { }) test('hasLimit', () => { - const assert = (setup: (item: timer.limit.Item) => void, limited: boolean) => { - const item: timer.limit.Item = { + const assert = (setup: (item: tt4b.limit.Item) => void, limited: boolean) => { + const item: tt4b.limit.Item = { id: 1, name: 'foobar', cond: [], diff --git a/types/chrome.d.ts b/types/chrome.d.ts index fb41c7ff4..215a1a055 100644 --- a/types/chrome.d.ts +++ b/types/chrome.d.ts @@ -15,5 +15,5 @@ declare type ChromeAlarm = chrome.alarms.Alarm // chrome.runtime declare type ChromeOnInstalledReason = `${chrome.runtime.OnInstalledReason}` declare type ChromeMessageSender = chrome.runtime.MessageSender -declare type ChromeMessageHandler = (req: timer.mq.Request<timer.mq.ReqCode>, sender: ChromeMessageSender) => Promise<timer.mq.Response<timer.mq.ReqCode>> -declare type ChromeTabMessageHandler = (req: timer.tab.Request<timer.tab.ReqCode>, sender: ChromeMessageSender) => Promise<timer.tab.Response<timer.tab.ReqCode>> \ No newline at end of file +declare type ChromeMessageHandler = (req: tt4b.mq.Request<tt4b.mq.ReqCode>, sender: ChromeMessageSender) => Promise<tt4b.mq.Response<tt4b.mq.ReqCode>> +declare type ChromeTabMessageHandler = (req: tt4b.tab.Request<tt4b.tab.ReqCode>, sender: ChromeMessageSender) => Promise<tt4b.tab.Response<tt4b.tab.ReqCode>> \ No newline at end of file diff --git a/types/timer/app.d.ts b/types/timer/app.d.ts deleted file mode 100644 index f7b85c721..000000000 --- a/types/timer/app.d.ts +++ /dev/null @@ -1,10 +0,0 @@ -declare namespace timer.app { - /** - * @since 1.1.7 - */ - type TimeFormat = - | "default" - | "second" - | "minute" - | "hour" -} \ No newline at end of file diff --git a/types/timer/backup.d.ts b/types/timer/backup.d.ts deleted file mode 100644 index 63e9839dc..000000000 --- a/types/timer/backup.d.ts +++ /dev/null @@ -1,129 +0,0 @@ -/** - * @since 1.2.0 - */ -declare namespace timer.backup { - - type Client = { - id: string - name: string - minDate?: string - maxDate?: string - } - - type LoginInfo = { - acc?: string - psw?: string - } - - type Auth = { - token?: string - login?: LoginInfo - } - - interface CoordinatorContext<Cache> { - cid: string - auth?: Auth - login?: LoginInfo - ext?: TypeExt - cache: Cache - handleCacheChanged: () => Promise<void> - } - - /** - * timer.backup.Coordinator of data synchronizer - */ - interface Coordinator<Cache> { - /** - * Register for client - */ - updateClients(context: timer.backup.CoordinatorContext<Cache>, clients: Client[]): Promise<void> - /** - * List all clients - */ - listAllClients(context: timer.backup.CoordinatorContext<Cache>): Promise<Client[]> - /** - * Download fragmented data from cloud - * - * @param targetCid The client id, default value is the local one in context - */ - download(context: timer.backup.CoordinatorContext<Cache>, start: string, end: string, targetCid?: string): Promise<timer.core.Row[]> - /** - * Upload fragmented data to cloud - * @param rows - */ - upload(context: timer.backup.CoordinatorContext<Cache>, rows: timer.core.Row[]): Promise<void> - /** - * Test auth - * - * @returns errorMsg or null/undefined - */ - testAuth(auth: Auth, ext: timer.backup.TypeExt): Promise<string | undefined> - /** - * Clear data - */ - clear(context: timer.backup.CoordinatorContext<Cache>, client: timer.backup.Client): Promise<void> - } - - type Type = - | 'none' - | 'gist' - // Sync into Obsidian via its plugin Local REST API - // @since 1.9.4 - | 'obsidian_local_rest_api' - // @since 2.4.5 - | 'web_dav' - - type AuthType = - | 'token' - | 'password' - - type TypeExt = { - /** - * The vault of obsidian - * - * @since 2.4.4 - */ - bucket?: string - endpoint?: string - dirPath?: string - } - - type MetaCache = Partial<Record<Type, unknown>> - - type RowExtend = { - /** - * The id of client where the remote data is stored - */ - cid?: string - /** - * The name of client where the remote data is stored - */ - cname?: string - } - - type RemoteQuery = { - start: string - end: string - specCid?: string - excludeLocal?: boolean - } - - type Row = core.Row & RowExtend - - /** - * The data format for export and import - */ - type ExportMeta = { - version: string - ts: number - } - - type ExportData = { - __meta__: ExportMeta - __stat__?: timer.core.Row[] - __limit__?: timer.limit.Rule[] - __merge__?: timer.merge.Rule[] - __whitelist__?: string[] - __cate__?: timer.site.Cate[] - } -} \ No newline at end of file diff --git a/types/timer/common.d.ts b/types/timer/common.d.ts deleted file mode 100644 index 0b359ff58..000000000 --- a/types/timer/common.d.ts +++ /dev/null @@ -1,33 +0,0 @@ -declare namespace timer.common { - type Result<T> = { - success: true - data: T - } | { - success: false - errorMsg: string - } - - type PageQuery = { - num?: number - size?: number - } - - type PageResult<T> = { - list: T[] - total: number - } - - type SortDirection = 'ASC' | 'DESC' - type SortBy<T extends string> = { - sortKey?: T - sortDirection?: SortDirection - } - - /** - * chrome.storage.local usage (mq memory.getUsedStorage). - */ - type StorageUsage = { - used: number - total: number - } -} \ No newline at end of file diff --git a/types/timer/core.d.ts b/types/timer/core.d.ts deleted file mode 100644 index ec57fe16f..000000000 --- a/types/timer/core.d.ts +++ /dev/null @@ -1,39 +0,0 @@ -declare namespace timer.core { - type Event = { - start: number - end: number - ignoreTabCheck: boolean - /** - * Used for run time tracking - */ - host?: string - } - - /** - * The dimension to statistics - */ - type Dimension = - // Focus time - | 'focus' - // Visit count - | 'time' - // Run time - | 'run' - - /** - * The stat result of host - * - * @since 0.0.1 - */ - type Result = MakeOptional<{ [item in Dimension]: number }, 'run'> - - /** - * The unique key of each data row - */ - type RowKey = { - host: string - date: string - } - - type Row = RowKey & Result -} \ No newline at end of file diff --git a/types/timer/echarts-extend.d.ts b/types/timer/echarts-extend.d.ts deleted file mode 100644 index f606a2138..000000000 --- a/types/timer/echarts-extend.d.ts +++ /dev/null @@ -1,7 +0,0 @@ -interface Cartesian2DCoordSys { - type: 'cartesian2d' - x: number - y: number - width: number - height: number -} \ No newline at end of file diff --git a/types/timer/imported.d.ts b/types/timer/imported.d.ts deleted file mode 100644 index 8a20d305a..000000000 --- a/types/timer/imported.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -/** - * @since 1.9.2 - */ -declare namespace timer.imported { - type ConflictResolution = 'overwrite' | 'accumulate' - - type Row = Required<timer.core.RowKey> & timer.core.Result & { - exist?: timer.core.Result - } - - type Data = { - // Whether there is data for this dimension - [dimension in timer.core.Dimension]?: boolean - } & { - rows: Row[] - } - - type ProcessQuery = { - data: Data - resolution: ConflictResolution - } -} \ No newline at end of file diff --git a/types/timer/index.d.ts b/types/timer/index.d.ts deleted file mode 100644 index 389d654b4..000000000 --- a/types/timer/index.d.ts +++ /dev/null @@ -1,88 +0,0 @@ -declare namespace timer { - type RequiredLocale = 'en' - type OptionalLocale = - | 'zh_CN' - | 'ja' - // @since 0.9.0 - | 'zh_TW' - // @since 1.8.2 - | 'pt_PT' - // @since 2.1.0 - | 'uk' - // @since 2.1.4 - | 'es' - // @since 2.2.7 - | 'de' - // @since 2.3.6 - | 'fr' - // @since 2.4.6 - | 'ru' - // @since 2.5.0 - | 'ar' - // @since 3.7.3 - | 'tr' - // @since 3.7.3 - | 'pl' - // @since 4.1.2 - | 'it' - - /** - * @since 0.8.0 - */ - type Locale = RequiredLocale | OptionalLocale - - /** - * Translating locales - * - * @since 1.4.0 - */ - type TranslatingLocale = - | 'ko' - | 'pl' - | 'it' - | 'sv' - | 'fi' - | 'da' - | 'hr' - | 'id' - | 'cs' - | 'ro' - | 'nl' - | 'vi' - | 'sk' - | 'mn' - | 'hi' - - type ExtensionMeta = { - installTime?: number - /** - * The id of this client - * - * @since 1.2.0 - */ - cid?: string - backup?: { - [key in timer.backup.Type]?: { - ts: number - msg?: string - } - } - notification?: { - [key in timer.notification.Method]?: { - ts: number - endDate: string - msg?: string - } - } - /** - * Two-factor auth - */ - twoFa?: timer.TwoFactorAuth - } - - type TwoFactorAuth = { - secret: string - iv: string - salt: string - } -} \ No newline at end of file diff --git a/types/timer/limit.d.ts b/types/timer/limit.d.ts deleted file mode 100644 index 07197553d..000000000 --- a/types/timer/limit.d.ts +++ /dev/null @@ -1,158 +0,0 @@ -declare namespace timer.limit { - /** - * Restricted periods - * [0, 1] means from 00:00 to 00:01 - * [0, 120] means from 00:00 to 02:00 - * @since 2.0.0 - */ - type Period = Vector<2> - /** - * Limit rule in runtime - * - * @since 0.8.4 - */ - type Item = Rule & { - /** - * Waste today, milliseconds - */ - waste: number - /** - * Visit count today - * - * @since 3.1.0 - */ - visit: number - /** - * Number of delays today - */ - delayCount: number - /** - * Waste this week, milliseconds - */ - weeklyWaste: number - /** - * Visit count this week - * - * @since 3.1.0 - */ - weeklyVisit: number - /** - * Delay count of this week - */ - weeklyDelayCount: number - } - type Rule = { - /** - * Id - */ - id: number - /** - * Name - */ - name: string - /** - * Condition, can be regular expression with star signs - */ - cond: string[] - /** - * Time limit per day, seconds - */ - time?: number - /** - * Visit count per day - * - * @since 3.1.0 - */ - count?: number - /** - * Time limit per week, seconds - * - * @since 2.4.1 - */ - weekly?: number - /** - * Visit count per week - * - * @since 3.1.0 - */ - weeklyCount?: number - /** - * Time limit per visit, seconds - * - * @since 2.0.0 - */ - visitTime?: number - enabled: boolean - /** - * Locked - * - * @since 3.4.0 - */ - locked: boolean - /** - * @since 2.3.4 - */ - weekdays?: number[] - /** - * Allow to delay 5 minutes if time over - */ - allowDelay: boolean - periods?: Period[] - } - /** - * @since 1.9.0 - */ - type RestrictionLevel = - // No additional action required to lock - | 'nothing' - // Password required to lock or modify restricted rule - | 'password' - // Verification code input required to lock or modify restricted rule - | 'verification' - // Not allowed to unlock manually - | 'strict' - // Unlock with 2FA code - | '2fa' - /** - * @since 1.9.0 - */ - type VerificationDifficulty = - // Easy - | 'easy' - // Need some operations - | 'hard' - // Disgusting - | 'disgusting' - - type ReasonType = - | "DAILY" - | "WEEKLY" - | "VISIT" - | "PERIOD" - - /** - * @since 3.1.0 - */ - type ReminderInfo = { - items: timer.limit.Item[] - // Minutes - duration: number - } - - type Query = { - id?: number - url?: string - // Only enabled rules - enabled?: boolean - // Only effective rules (should be enabled and meet effective conditions) - effective?: boolean - // Only effective and limited rules - limited?: boolean - } - - type Summary = { - url: string - site: site.SiteInfo - items: timer.limit.Item[] - } -} diff --git a/types/timer/merge.d.ts b/types/timer/merge.d.ts deleted file mode 100644 index 2e800e6c2..000000000 --- a/types/timer/merge.d.ts +++ /dev/null @@ -1,15 +0,0 @@ -declare namespace timer.merge { - type Rule = { - /** - * Origin host, can be regular expression with star signs - */ - origin: string - /** - * The merge result - * - * + Empty string means equals to the origin host - * + Number means the count of kept dots, must be natural number (int & >=0) - */ - merged: string | number - } -} diff --git a/types/timer/message.d.ts b/types/timer/message.d.ts deleted file mode 100644 index d5a86623c..000000000 --- a/types/timer/message.d.ts +++ /dev/null @@ -1,160 +0,0 @@ -type _TransmitValue = - | undefined | string | number | boolean | void - | { readonly [key: string]: _TransmitValue } - | readonly _TransmitValue[] - -type _HandlerIO<Input extends _TransmitValue = undefined, Output extends _TransmitValue = undefined> = [Input, Output] -type _MakeRegistry<Codes extends string, Param extends _TransmitValue = undefined, Result extends _TransmitValue = undefined> = Record<Codes, _HandlerIO<Param, Result>> - -type _MqReqData<R, K extends keyof R> = R[K] extends [infer In, unknown] ? In : never -type _MqResData<R, K extends keyof R> = R[K] extends [unknown, infer Out] ? Out : never -type _MqRequest<R, K extends keyof R = keyof R> = { code: K; data: _MqReqData<R, K> } -type _MqSuccess<R, K extends keyof R> = - _MqResData<R, K> extends undefined ? { code: "success"; data?: undefined } : { code: "success"; data: _MqResData<R, K> } -type _MqResponse<R, K extends keyof R = keyof R> = - | { code: "fail"; msg: string } - | { code: "ignore" } - | (K extends keyof R ? _MqSuccess<R, K> : never) -type _MqHandler<R, C extends keyof R> = _MqResData<R, C> extends undefined - ? (data: _MqReqData<R, C>, sender: chrome.runtime.MessageSender) => Awaitable<void | undefined> - : (data: _MqReqData<R, C>, sender: chrome.runtime.MessageSender) => Awaitable<_MqResData<R, C>> - -declare namespace timer.mq { - type _HandlerRegistry = - // Track event - & _MakeRegistry<'track.time' | 'track.runTime', core.Event> - // Content script events - & _MakeRegistry<'cs.injected'> - & _MakeRegistry<'cs.idleChanged', boolean> - // Content script API - & _MakeRegistry<'cs.getAudible', undefined, boolean> - // Statistics - & _MakeRegistry<'stat.today', string, core.Result | undefined> - & _MakeRegistry<'stat.sites', stat.SiteQuery | undefined, stat.SiteRow[]> - & _MakeRegistry<'stat.sitePage', stat.SitePageQuery | undefined, common.PageResult<stat.SiteRow>> - & _MakeRegistry<'stat.deleteSite', stat.SiteDeleteQuery> - & _MakeRegistry<'stat.countSite', stat.SiteQuery | undefined, number> - & _MakeRegistry<'stat.cates', stat.CateQuery | undefined, stat.CateRow[]> - & _MakeRegistry<'stat.catePage', stat.CatePageQuery | undefined, common.PageResult<stat.CateRow>> - & _MakeRegistry<'stat.groups', stat.GroupQuery | undefined, stat.GroupRow[]> - & _MakeRegistry<'stat.groupPage', stat.GroupPageQuery | undefined, common.PageResult<stat.GroupRow>> - & _MakeRegistry<'stat.countGroup', stat.GroupQuery | undefined, number> - & _MakeRegistry<'stat.batchDelete', stat.StatKey[]> - // Items - & _MakeRegistry<'item.batch', core.RowKey[], core.Row[]> - // Category - & _MakeRegistry<'cate.all', undefined, site.Cate[]> - & _MakeRegistry<'cate.add', string, site.Cate> - & _MakeRegistry<'cate.change', site.Cate> - & _MakeRegistry<'cate.delete', number> - // Option - & _MakeRegistry<'option.get', undefined, option.DefaultOption> - & _MakeRegistry<'option.set', Partial<option.AllOption>> - & _MakeRegistry<'option.changeStorage', option.StorageType> - & _MakeRegistry<'option.testNotification', undefined, string | undefined> - & _MakeRegistry<'option.weekStartDay', undefined, number> - & _MakeRegistry<'option.weekStartTime', number, number> - // Meta - & _MakeRegistry<'meta.installTs', undefined, number> - & _MakeRegistry<'meta.usedStorage', undefined, common.StorageUsage> - & _MakeRegistry<'meta.check2fa', string, boolean> - & _MakeRegistry<'meta.prepare2fa', undefined, string> - // Site - & _MakeRegistry<'site.runEnabled', string, boolean> - & _MakeRegistry<'site.list', site.Query | undefined, site.SiteInfo[]> - & _MakeRegistry<'site.page', site.PageQuery | undefined, common.PageResult<site.SiteInfo>> - & _MakeRegistry<'site.add', site.SiteInfo, string | undefined> - & _MakeRegistry<'site.delete', site.SiteKey[]> - & _MakeRegistry<'site.changeCate', site.ChangeCateParam> - & _MakeRegistry<'site.deleteIcon', site.SiteKey> - & _MakeRegistry<'site.changeAlias', site.ChangeAliasParam> - & _MakeRegistry<'site.fillAlias', site.SiteKey[]> - & _MakeRegistry<'site.initialAlias', string, string | undefined> - & _MakeRegistry<'site.changeRun', { key: site.SiteKey; enabled: boolean }> - & _MakeRegistry<'site.search', string | undefined, site.SiteInfo[]> - // Time Limit - & _MakeRegistry<'limit.list', limit.Query | undefined, limit.Item[]> - & _MakeRegistry<'limit.delete', number[]> - & _MakeRegistry<'limit.update', limit.Rule[]> - & _MakeRegistry<'limit.add', Omit<limit.Rule, 'id'>, number> - & _MakeRegistry<'limit.hitVisit', limit.Item, boolean> - & _MakeRegistry<'limit.delay', string> - & _MakeRegistry<'limit.summary', undefined, limit.Summary | undefined> - // Merge - & _MakeRegistry<'merge.all', undefined, merge.Rule[]> - & _MakeRegistry<'merge.delete', string> - & _MakeRegistry<'merge.add', merge.Rule> - // Whitelist - & _MakeRegistry<'whitelist.all', undefined, string[]> - & _MakeRegistry<'whitelist.add' | 'whitelist.delete', string> - & _MakeRegistry<'whitelist.contain', { host: string; url: string }, boolean> - // Backup - & _MakeRegistry<'backup.sync' | 'backup.checkAuth', undefined, string | undefined> - & _MakeRegistry<'backup.clear', string, string | undefined> - & _MakeRegistry<'backup.query', backup.RemoteQuery, backup.Row[]> - & _MakeRegistry<'backup.lastTs', backup.Type, number | undefined> - & _MakeRegistry<'backup.clients', undefined, (backup.Client & { current: boolean })[]> - // Period - & _MakeRegistry<'period.list', period.Query, period.Row[]> - // Timeline - & _MakeRegistry<'timeline.list', timeline.Query, timeline.Activity[]> - & _MakeRegistry<'timeline.tick', timeline.Event> - & _MakeRegistry<'backup.preview', backup.RemoteQuery, imported.Row[]> - & _MakeRegistry<'immigration.importOther', imported.ProcessQuery> - & _MakeRegistry<'immigration.import', any> - & _MakeRegistry<'immigration.export', undefined, backup.ExportData> - - type ReqCode = keyof _HandlerRegistry - - type ReqData<T extends ReqCode> = _MqReqData<_HandlerRegistry, T> - - /** - * @since 0.2.2 - */ - type Request<T extends ReqCode = ReqCode> = _MqRequest<_HandlerRegistry, T> - - type ResData<T extends ReqCode> = _MqResData<_HandlerRegistry, T> - - /** - * When ResData is undefined, success may omit data. - * @since 0.8.4 - */ - type Response<T extends ReqCode = ReqCode> = _MqResponse<_HandlerRegistry, T> - - /** - * @since 1.3.0 - */ - type Handler<C extends ReqCode> = _MqHandler<_HandlerRegistry, C> - /** - * @since 0.8.4 - */ - type Callback<T extends ReqCode = ReqCode> = (result?: Response<T>) => void -} - -/** - * Background → content script via chrome.tabs.sendMessage (see sendMsg2Tab). - */ -declare namespace timer.tab { - type _HandlerRegistry = - & _MakeRegistry<'siteRunChange'> - & _MakeRegistry<'syncAudible', boolean> - & _MakeRegistry<'limitTimeMeet', limit.Item[]> - & _MakeRegistry<'limitChanged'> - & _MakeRegistry<'limitReminder', limit.ReminderInfo> - & _MakeRegistry<'askVisitHit', number, boolean> - - type ReqCode = keyof _HandlerRegistry - - type ReqData<T extends ReqCode> = _MqReqData<_HandlerRegistry, T> - - type ResData<T extends ReqCode> = _MqResData<_HandlerRegistry, T> - - type Request<T extends ReqCode = ReqCode> = _MqRequest<_HandlerRegistry, T> - - type Response<T extends ReqCode = ReqCode> = _MqResponse<_HandlerRegistry, T> - - /** - * @since 0.8.4 - */ - type Callback<T extends ReqCode = ReqCode> = (result?: Response<T>) => void -} \ No newline at end of file diff --git a/types/timer/notification.d.ts b/types/timer/notification.d.ts deleted file mode 100644 index 010cd7388..000000000 --- a/types/timer/notification.d.ts +++ /dev/null @@ -1,4 +0,0 @@ -declare namespace timer.notification { - type Method = 'browser' | 'callback' - type Cycle = 'none' | 'daily' | 'weekly' -} \ No newline at end of file diff --git a/types/timer/option.d.ts b/types/timer/option.d.ts deleted file mode 100644 index 386d44b89..000000000 --- a/types/timer/option.d.ts +++ /dev/null @@ -1,242 +0,0 @@ -/** - * The options - * - * @since 0.3.0 - */ -declare namespace timer.option { - /** - * @since 1.2.5 - */ - type WeekStartOption = - | 'default' - | number // Weekday, From 1 to 7 - - type DarkMode = - // Follow the OS, @since 1.3.3 - | "default" - // Always on - | "on" - // Always off - | "off" - // Timed on - | "timed" - - type AppearanceOption = { - /** - * Whether to display the whitelist button in the context menu - * - * @since 0.3.2 - */ - displayWhitelistMenu: boolean - /** - * Whether to display the badge text of focus time - * - * @since 0.3.3 - */ - displayBadgeText: boolean - /** - * The background color of badge text - * - * @since 2.3.0 - */ - badgeBgColor?: string - /** - * The language of this extension - * - * @since 0.8.0 - */ - locale: LocaleOption - /** - * Whether to print the info in the console - * - * @since 0.8.6 - */ - printInConsole: boolean - /** - * The state of dark mode - * - * @since 1.1.0 - */ - darkMode: DarkMode - /** - * The range of seconds to turn on dark mode. Required if {@param darkMode} is 'timed' - * - * @since 1.1.0 - */ - darkModeTimeStart?: number - darkModeTimeEnd?: number - /** - * The animation of charts - * - * @since 3.2.2 - */ - chartAnimationDuration: number - } - - type AppearanceRequired = MakeRequired<timer.option.AppearanceOption, 'darkModeTimeStart' | 'darkModeTimeEnd'> - - type TrackingOption = { - /** - * Whether to pause tracking if no activity detected - * - * @since 2.5.4 - */ - autoPauseTracking: boolean - /** - * Check interval of auto pausing, seconds - * - * @since 2.5.4 - */ - autoPauseInterval: number - /** - * Whether to count the local files - * @since 0.7.0 - */ - countLocalFiles: boolean - /** - * Whether to count the tile of tab group - */ - countTabGroup: boolean - /** - * The start of one week - * @since 2.4.1 - */ - weekStart?: WeekStartOption - /** - * Where to store the tracking data - * - * @since 4.0.0 - */ - storage: StorageType - } - - type TrackingRequired = MakeRequired<timer.option.TrackingOption, 'weekStart'> - - type LimitOption = { - /** - * Delay duration, minutes - */ - limitDelayDuration: number - /** - * Motto displayed when restricted - */ - limitPrompt?: string - /** - * restriction level - */ - limitLevel: limit.RestrictionLevel - /** - * The password to unlock - */ - limitPassword?: string - /** - * The difficulty of verification - */ - limitVerifyDifficulty?: limit.VerificationDifficulty - /** - * Whether to reminder before time will meet - * - * @since 3.1.0 - */ - limitReminder: boolean - /** - * Minutes - * - * @since 3.1.0 - */ - limitReminderDuration?: number - } - - type LimitRequired = MakeRequired<timer.option.LimitOption, 'limitPassword' | 'limitVerifyDifficulty' | 'limitReminderDuration'> - - /** - * The options of backup - * - * @since 1.2.0 - */ - type BackupOption = { - /** - * The type 2 backup - */ - backupType: backup.Type - /** - * The auth of types, maybe ak/sk or static token - */ - backupAuths: { [type in backup.Type]?: string } - /** - * Login info of types - */ - backupLogin: { [type in backup.Type]?: backup.LoginInfo } - /** - * The extended information of types, including url, file path, and so on - */ - backupExts?: { - [type in backup.Type]?: backup.TypeExt - } - /** - * The name of this client - */ - clientName: string - /** - * Whether to auto-backup data - */ - autoBackUp: boolean - /** - * Interval to auto-backup data, minutes - */ - autoBackUpInterval: number - } - - type AccessibilityOption = { - /** - * Show decals for charts - */ - chartDecal: boolean - } - - type NotificationOption = { - /** - * Notification cycle: none, daily, or weekly - */ - notificationCycle: timer.notification.Cycle - /** - * Offset time in minutes relative to the start of the cycle - */ - notificationOffset: number - /** - * Notification method: browser or callback - */ - notificationMethod: timer.notification.Method - /** - * HTTP callback endpoint URL - */ - notificationEndpoint?: string - /** - * Auth token for HTTP callback (optional) - */ - notificationAuthToken?: string - } - - export type DefaultOption = - & AppearanceRequired & TrackingRequired & LimitRequired - & timer.option.BackupOption & timer.option.AccessibilityOption - & timer.option.NotificationOption - - type AllOption = - & AppearanceOption - & TrackingOption - & LimitOption - & AccessibilityOption - & BackupOption - & NotificationOption - - /** - * @since 0.8.0 - */ - type LocaleOption = Locale | "default" - - /** - * @since 4.0.0 - */ - type StorageType = 'classic' | 'indexed_db' -} diff --git a/types/timer/period.d.ts b/types/timer/period.d.ts deleted file mode 100644 index 34d0a31c1..000000000 --- a/types/timer/period.d.ts +++ /dev/null @@ -1,42 +0,0 @@ -declare namespace timer.period { - type Key = { - year: number - month: number - date: number - /** - * 0~95 - * ps. 95 = 60 / 15 * 24 - 1 - */ - order: number - } - type KeyRange = [Key, Key] - - type Result = Key & { - /** - * 1~900000 - * ps. 900000 = 15min * 60s/min * 1000ms/s - */ - milliseconds: number - } - - type Row = { - /** - * {yyyy}{mm}{dd} - */ - date: string - /** Unix timestamp (ms) of row start */ - startTime: number - /** Unix timestamp (ms) of row end */ - endTime: number - /** - * 1 - 60000 - * ps. 60000 = 60s * 1000ms/s - */ - milliseconds: number - } - - type Query = { - range?: KeyRange - size?: number - } -} \ No newline at end of file diff --git a/types/timer/site.d.ts b/types/timer/site.d.ts deleted file mode 100644 index 73a7d054d..000000000 --- a/types/timer/site.d.ts +++ /dev/null @@ -1,52 +0,0 @@ -declare namespace timer.site { - type SiteKey = { - host: string - type: timer.site.Type - } - type SiteInfo = SiteKey & { - alias?: string - iconUrl?: string - /** - * Category ID - * - * @since 3.0.0 - */ - cate?: number - /** - * Whether to count the running time - * - * @since 3.2.0 - */ - run?: boolean - } - type Type = 'normal' | 'merged' | 'virtual' - /** - * Site tag - * - * @since 3.0.0 - */ - type Cate = { - id: number - name: string - } - - type Query = { - fuzzyQuery?: string - cateIds?: Arrayable<number> - types?: Arrayable<timer.site.Type> - } - - type PageQuery = Query & common.PageQuery - - type ChangeCateParam = { - // Undefined means uncategorized - cateId: number | undefined - keys: SiteKey[] - } - - type ChangeAliasParam = { - key: SiteKey - // Undefined means delete alias - alias: string | undefined - } -} \ No newline at end of file diff --git a/types/timer/stat.d.ts b/types/timer/stat.d.ts deleted file mode 100644 index a98fea564..000000000 --- a/types/timer/stat.d.ts +++ /dev/null @@ -1,178 +0,0 @@ -declare namespace timer.stat { - type SiteTarget = { - siteKey: timer.site.SiteKey - } - type CateTarget = { - cateKey: number - } - type GroupTarget = { - groupKey: number - } - type TargetKey = SiteTarget | CateTarget | GroupTarget - type DateKey = { date: string } - type StatKey = TargetKey & DateKey - - type DateMergeExtend = { - /** - * The merged dates - * - * @since 2.4.7 - */ - mergedDates?: string[] - } - - type RemoteExtend = { - /** - * The composition of data when querying remote - */ - composition?: RemoteComposition - } - - /** StatCondition.date fields only (mq / stat-database queries). */ - type _BaseQuery = { - date?: string | [string?, string?] - mergeDate?: boolean - } - - type SiteQuery = - & _BaseQuery - & { - focusRange?: Vector<2> - timeRange?: [number, number?] - virtual?: boolean - } - & timer.common.SortBy<'date' | 'host' | timer.core.Dimension> - & { - query?: string - host?: string | string[] - mergeHost?: boolean - inclusiveRemote?: boolean - cateIds?: number[] - ignoreSite?: boolean - } - - type SiteDeleteQuery = ({ - host: string - } | { - groupId: number - }) & { - date?: [start?: string, end?: string] | string - } - - type SitePageQuery = SiteQuery & timer.common.PageQuery - - type SiteRowFlat = SiteTarget & - DateKey & - core.Result & - backup.RowExtend & - DateMergeExtend & - RemoteExtend & { - /** - * Icon url - */ - iconUrl?: string - /** - * The alias name of this Site, always is the title of its homepage by detected - */ - alias?: string - /** - * @since 3.0.0 - */ - cateId?: number - } - - type SiteMergeExtend = { - /** - * The merged domains - * Can't be empty if merged - * - * @since 0.1.5 - */ - mergedRows?: SiteRowFlat[] - } - - type SiteRow = SiteRowFlat & SiteMergeExtend - - type CateQuery = _BaseQuery - & timer.common.SortBy<'date' | 'focus' | 'time'> - & { - query?: string - inclusiveRemote?: boolean - cateIds?: number[] - } - - type CatePageQuery = CateQuery & timer.common.PageQuery - - type CateRowFlat = CateTarget & - DateKey & - core.Result & - backup.RowExtend & - DateMergeExtend & - RemoteExtend & { - cateName: string | undefined - } - - type CateMergeExtend = { - mergedRows?: SiteRowFlat[] - } - - type CateRow = CateRowFlat & CateMergeExtend - - type GroupRowFlat = GroupTarget & - DateKey & - DateMergeExtend & - core.Result & { - color: `${chrome.tabGroups.Color}` | undefined - title: string | undefined - } - - type GroupQuery = _BaseQuery - & { - focusRange?: Vector<2> - timeRange?: [number, number?] - } - & timer.common.SortBy<'date' | 'title' | 'focus' | 'time'> - & { - query?: string - groupIds?: number[] - } - - type GroupPageQuery = GroupQuery & timer.common.PageQuery - - type GroupMergeExtend = { - mergedRows?: GroupRowFlat[] - } - - type GroupRow = GroupRowFlat & GroupMergeExtend - - /** - * Row of each statistics result - */ - type Row = SiteRow | CateRow | GroupRow - - 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 core.Dimension]: RemoteCompositionVal[] - } - - /** - * @since 3.0.0 - */ - type MergeMethod = 'cate' | 'date' | 'domain' | 'group' -} \ No newline at end of file diff --git a/types/timer/timeline.d.ts b/types/timer/timeline.d.ts deleted file mode 100644 index 9b11f05ed..000000000 --- a/types/timer/timeline.d.ts +++ /dev/null @@ -1,28 +0,0 @@ -declare namespace timer.timeline { - type Event = { - start: number - end: number - url: string - } - - type Tick = { - start: number - duration: number - host: string - } - - type MergeMethod = 'cate' | 'domain' | 'none' - - type Activity = { - start: number - duration: number - seriesKey: string - seriesName: string | undefined - } - - type Query = { - host?: string - start?: number - merge: MergeMethod - } -} diff --git a/types/tt4b.d.ts b/types/tt4b.d.ts new file mode 100644 index 000000000..6a15e5d59 --- /dev/null +++ b/types/tt4b.d.ts @@ -0,0 +1,1213 @@ +declare namespace tt4b { + type RequiredLocale = 'en' + type OptionalLocale = + | 'zh_CN' + | 'ja' + // @since 0.9.0 + | 'zh_TW' + // @since 1.8.2 + | 'pt_PT' + // @since 2.1.0 + | 'uk' + // @since 2.1.4 + | 'es' + // @since 2.2.7 + | 'de' + // @since 2.3.6 + | 'fr' + // @since 2.4.6 + | 'ru' + // @since 2.5.0 + | 'ar' + // @since 3.7.3 + | 'tr' + // @since 3.7.3 + | 'pl' + // @since 4.1.2 + | 'it' + + /** + * @since 0.8.0 + */ + type Locale = RequiredLocale | OptionalLocale + + /** + * Translating locales + * + * @since 1.4.0 + */ + type TranslatingLocale = + | 'ko' + | 'pl' + | 'it' + | 'sv' + | 'fi' + | 'da' + | 'hr' + | 'id' + | 'cs' + | 'ro' + | 'nl' + | 'vi' + | 'sk' + | 'mn' + | 'hi' + + type ExtensionMeta = { + installTime?: number + /** + * The id of this client + * + * @since 1.2.0 + */ + cid?: string + backup?: { + [key in tt4b.backup.Type]?: { + ts: number + msg?: string + } + } + notification?: { + [key in tt4b.notification.Method]?: { + ts: number + endDate: string + msg?: string + } + } + /** + * Two-factor auth + */ + twoFa?: tt4b.TwoFactorAuth + } + + type TwoFactorAuth = { + secret: string + iv: string + salt: string + } + + namespace app { + /** + * @since 1.1.7 + */ + type TimeFormat = + | "default" + | "second" + | "minute" + | "hour" + } + + namespace common { + type Result<T> = { + success: true + data: T + } | { + success: false + errorMsg: string + } + + type PageQuery = { + num?: number + size?: number + } + + type PageResult<T> = { + list: T[] + total: number + } + + type SortDirection = 'ASC' | 'DESC' + type SortBy<T extends string> = { + sortKey?: T + sortDirection?: SortDirection + } + + /** + * chrome.storage.local usage (mq memory.getUsedStorage). + */ + type StorageUsage = { + used: number + total: number + } + } + + namespace notification { + type Method = 'browser' | 'callback' + type Cycle = 'none' | 'daily' | 'weekly' + } + + namespace core { + type Event = { + start: number + end: number + ignoreTabCheck: boolean + /** + * Used for run time tracking + */ + host?: string + } + + /** + * The dimension to statistics + */ + type Dimension = + // Focus time + | 'focus' + // Visit count + | 'time' + // Run time + | 'run' + + /** + * The stat result of host + * + * @since 0.0.1 + */ + type Result = MakeOptional<{ [item in Dimension]: number }, 'run'> + + /** + * The unique key of each data row + */ + type RowKey = { + host: string + date: string + } + + type Row = RowKey & Result + } + + namespace site { + type SiteKey = { + host: string + type: tt4b.site.Type + } + type SiteInfo = SiteKey & { + alias?: string + iconUrl?: string + /** + * Category ID + * + * @since 3.0.0 + */ + cate?: number + /** + * Whether to count the running time + * + * @since 3.2.0 + */ + run?: boolean + } + type Type = 'normal' | 'merged' | 'virtual' + /** + * Site tag + * + * @since 3.0.0 + */ + type Cate = { + id: number + name: string + } + + type Query = { + fuzzyQuery?: string + cateIds?: Arrayable<number> + types?: Arrayable<tt4b.site.Type> + } + + type PageQuery = Query & common.PageQuery + + type ChangeCateParam = { + // Undefined means uncategorized + cateId: number | undefined + keys: SiteKey[] + } + + type ChangeAliasParam = { + key: SiteKey + // Undefined means delete alias + alias: string | undefined + } + } + + namespace merge { + type Rule = { + /** + * Origin host, can be regular expression with star signs + */ + origin: string + /** + * The merge result + * + * + Empty string means equals to the origin host + * + Number means the count of kept dots, must be natural number (int & >=0) + */ + merged: string | number + } + } + + namespace limit { + /** + * Restricted periods + * [0, 1] means from 00:00 to 00:01 + * [0, 120] means from 00:00 to 02:00 + * @since 2.0.0 + */ + type Period = Vector<2> + /** + * Limit rule in runtime + * + * @since 0.8.4 + */ + type Item = Rule & { + /** + * Waste today, milliseconds + */ + waste: number + /** + * Visit count today + * + * @since 3.1.0 + */ + visit: number + /** + * Number of delays today + */ + delayCount: number + /** + * Waste this week, milliseconds + */ + weeklyWaste: number + /** + * Visit count this week + * + * @since 3.1.0 + */ + weeklyVisit: number + /** + * Delay count of this week + */ + weeklyDelayCount: number + } + type Rule = { + /** + * Id + */ + id: number + /** + * Name + */ + name: string + /** + * Condition, can be regular expression with star signs + */ + cond: string[] + /** + * Time limit per day, seconds + */ + time?: number + /** + * Visit count per day + * + * @since 3.1.0 + */ + count?: number + /** + * Time limit per week, seconds + * + * @since 2.4.1 + */ + weekly?: number + /** + * Visit count per week + * + * @since 3.1.0 + */ + weeklyCount?: number + /** + * Time limit per visit, seconds + * + * @since 2.0.0 + */ + visitTime?: number + enabled: boolean + /** + * Locked + * + * @since 3.4.0 + */ + locked: boolean + /** + * @since 2.3.4 + */ + weekdays?: number[] + /** + * Allow to delay 5 minutes if time over + */ + allowDelay: boolean + periods?: Period[] + } + /** + * @since 1.9.0 + */ + type RestrictionLevel = + // No additional action required to lock + | 'nothing' + // Password required to lock or modify restricted rule + | 'password' + // Verification code input required to lock or modify restricted rule + | 'verification' + // Not allowed to unlock manually + | 'strict' + // Unlock with 2FA code + | '2fa' + /** + * @since 1.9.0 + */ + type VerificationDifficulty = + // Easy + | 'easy' + // Need some operations + | 'hard' + // Disgusting + | 'disgusting' + + type ReasonType = + | "DAILY" + | "WEEKLY" + | "VISIT" + | "PERIOD" + + /** + * @since 3.1.0 + */ + type ReminderInfo = { + items: tt4b.limit.Item[] + // Minutes + duration: number + } + + type Query = { + id?: number + url?: string + // Only enabled rules + enabled?: boolean + // Only effective rules (should be enabled and meet effective conditions) + effective?: boolean + // Only effective and limited rules + limited?: boolean + } + + type Summary = { + url: string + site: site.SiteInfo + items: tt4b.limit.Item[] + } + } + + namespace period { + type Key = { + year: number + month: number + date: number + /** + * 0~95 + * ps. 95 = 60 / 15 * 24 - 1 + */ + order: number + } + type KeyRange = [Key, Key] + + type Result = Key & { + /** + * 1~900000 + * ps. 900000 = 15min * 60s/min * 1000ms/s + */ + milliseconds: number + } + + type Row = { + /** + * {yyyy}{mm}{dd} + */ + date: string + /** Unix timestamp (ms) of row start */ + startTime: number + /** Unix timestamp (ms) of row end */ + endTime: number + /** + * 1 - 60000 + * ps. 60000 = 60s * 1000ms/s + */ + milliseconds: number + } + + type Query = { + range?: KeyRange + size?: number + } + } + + namespace timeline { + type Event = { + start: number + end: number + url: string + } + + type Tick = { + start: number + duration: number + host: string + } + + type MergeMethod = 'cate' | 'domain' | 'none' + + type Activity = { + start: number + duration: number + seriesKey: string + seriesName: string | undefined + } + + type Query = { + host?: string + start?: number + merge: MergeMethod + } + } + + /** + * @since 1.2.0 + */ + namespace backup { + type Client = { + id: string + name: string + minDate?: string + maxDate?: string + } + + type LoginInfo = { + acc?: string + psw?: string + } + + type Auth = { + token?: string + login?: LoginInfo + } + + interface CoordinatorContext<Cache> { + cid: string + auth?: Auth + login?: LoginInfo + ext?: TypeExt + cache: Cache + handleCacheChanged: () => Promise<void> + } + + /** + * tt4b.backup.Coordinator of data synchronizer + */ + interface Coordinator<Cache> { + /** + * Register for client + */ + updateClients(context: tt4b.backup.CoordinatorContext<Cache>, clients: Client[]): Promise<void> + /** + * List all clients + */ + listAllClients(context: tt4b.backup.CoordinatorContext<Cache>): Promise<Client[]> + /** + * Download fragmented data from cloud + * + * @param targetCid The client id, default value is the local one in context + */ + download(context: tt4b.backup.CoordinatorContext<Cache>, start: string, end: string, targetCid?: string): Promise<tt4b.core.Row[]> + /** + * Upload fragmented data to cloud + * @param rows + */ + upload(context: tt4b.backup.CoordinatorContext<Cache>, rows: tt4b.core.Row[]): Promise<void> + /** + * Test auth + * + * @returns errorMsg or null/undefined + */ + testAuth(auth: Auth, ext: tt4b.backup.TypeExt): Promise<string | undefined> + /** + * Clear data + */ + clear(context: tt4b.backup.CoordinatorContext<Cache>, client: tt4b.backup.Client): Promise<void> + } + + type Type = + | 'none' + | 'gist' + // Sync into Obsidian via its plugin Local REST API + // @since 1.9.4 + | 'obsidian_local_rest_api' + // @since 2 .4.5 + | 'web_dav' + + type AuthType = + | 'token' + | 'password' + + type TypeExt = { + /** + * The vault of obsidian + * + * @since 2.4.4 + */ + bucket?: string + endpoint?: string + dirPath?: string + } + + type MetaCache = Partial<Record<Type, unknown>> + + type RowExtend = { + /** + * The id of client where the remote data is stored + */ + cid?: string + /** + * The name of client where the remote data is stored + */ + cname?: string + } + + type RemoteQuery = { + start: string + end: string + specCid?: string + excludeLocal?: boolean + } + + type Row = core.Row & RowExtend + + /** + * The data format for export and import + */ + type ExportMeta = { + version: string + ts: number + } + + type ExportData = { + __meta__: ExportMeta + __stat__?: tt4b.core.Row[] + __limit__?: tt4b.limit.Rule[] + __merge__?: tt4b.merge.Rule[] + __whitelist__?: string[] + __cate__?: tt4b.site.Cate[] + } + } + + /** + * @since 1.9.2 + */ + namespace imported { + type ConflictResolution = 'overwrite' | 'accumulate' + + type Row = Required<tt4b.core.RowKey> & tt4b.core.Result & { + exist?: tt4b.core.Result + } + + type Data = { + // Whether there is data for this dimension + [dimension in tt4b.core.Dimension]?: boolean + } & { + rows: Row[] + } + + type ProcessQuery = { + data: Data + resolution: ConflictResolution + } + } + + namespace stat { + type SiteTarget = { + siteKey: tt4b.site.SiteKey + } + type CateTarget = { + cateKey: number + } + type GroupTarget = { + groupKey: number + } + type TargetKey = SiteTarget | CateTarget | GroupTarget + type DateKey = { date: string } + type StatKey = TargetKey & DateKey + + type DateMergeExtend = { + /** + * The merged dates + * + * @since 2.4.7 + */ + mergedDates?: string[] + } + + type RemoteExtend = { + /** + * The composition of data when querying remote + */ + composition?: RemoteComposition + } + + /** StatCondition.date fields only (mq / stat-database queries). */ + type _BaseQuery = { + date?: string | [string?, string?] + mergeDate?: boolean + } + + type SiteQuery = + & _BaseQuery + & { + focusRange?: Vector<2> + timeRange?: [number, number?] + virtual?: boolean + } + & tt4b.common.SortBy<'date' | 'host' | tt4b.core.Dimension> + & { + query?: string + host?: string | string[] + mergeHost?: boolean + inclusiveRemote?: boolean + cateIds?: number[] + ignoreSite?: boolean + } + + type SiteDeleteQuery = ({ + host: string + } | { + groupId: number + }) & { + date?: [start?: string, end?: string] | string + } + + type SitePageQuery = SiteQuery & tt4b.common.PageQuery + + type SiteRowFlat = SiteTarget & + DateKey & + core.Result & + backup.RowExtend & + DateMergeExtend & + RemoteExtend & { + /** + * Icon url + */ + iconUrl?: string + /** + * The alias name of this Site, always is the title of its homepage by detected + */ + alias?: string + /** + * @since 3.0.0 + */ + cateId?: number + } + + type SiteMergeExtend = { + /** + * The merged domains + * Can't be empty if merged + * + * @since 0.1.5 + */ + mergedRows?: SiteRowFlat[] + } + + type SiteRow = SiteRowFlat & SiteMergeExtend + + type CateQuery = _BaseQuery + & tt4b.common.SortBy<'date' | 'focus' | 'time'> + & { + query?: string + inclusiveRemote?: boolean + cateIds?: number[] + } + + type CatePageQuery = CateQuery & tt4b.common.PageQuery + + type CateRowFlat = CateTarget & + DateKey & + core.Result & + backup.RowExtend & + DateMergeExtend & + RemoteExtend & { + cateName: string | undefined + } + + type CateMergeExtend = { + mergedRows?: SiteRowFlat[] + } + + type CateRow = CateRowFlat & CateMergeExtend + + type GroupRowFlat = GroupTarget & + DateKey & + DateMergeExtend & + core.Result & { + color: `${chrome.tabGroups.Color}` | undefined + title: string | undefined + } + + type GroupQuery = _BaseQuery + & { + focusRange?: Vector<2> + timeRange?: [number, number?] + } + & tt4b.common.SortBy<'date' | 'title' | 'focus' | 'time'> + & { + query?: string + groupIds?: number[] + } + + type GroupPageQuery = GroupQuery & tt4b.common.PageQuery + + type GroupMergeExtend = { + mergedRows?: GroupRowFlat[] + } + + type GroupRow = GroupRowFlat & GroupMergeExtend + + /** + * Row of each statistics result + */ + type Row = SiteRow | CateRow | GroupRow + + 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 core.Dimension]: RemoteCompositionVal[] + } + + /** + * @since 3.0.0 + */ + type MergeMethod = 'cate' | 'date' | 'domain' | 'group' + } + + /** + * The options + * + * @since 0.3.0 + */ + namespace option { + /** + * @since 1.2.5 + */ + type WeekStartOption = + | 'default' + | number // Weekday, From 1 to 7 + + type DarkMode = + // Follow the OS, @since 1.3.3 + | "default" + // Always on + | "on" + // Always off + | "off" + // Timed on + | "timed" + + type AppearanceOption = { + /** + * Whether to display the whitelist button in the context menu + * + * @since 0.3.2 + */ + displayWhitelistMenu: boolean + /** + * Whether to display the badge text of focus time + * + * @since 0.3.3 + */ + displayBadgeText: boolean + /** + * The background color of badge text + * + * @since 2.3.0 + */ + badgeBgColor?: string + /** + * The language of this extension + * + * @since 0.8.0 + */ + locale: LocaleOption + /** + * Whether to print the info in the console + * + * @since 0.8.6 + */ + printInConsole: boolean + /** + * The state of dark mode + * + * @since 1.1.0 + */ + darkMode: DarkMode + /** + * The range of seconds to turn on dark mode. Required if {@param darkMode} is 'timed' + * + * @since 1.1.0 + */ + darkModeTimeStart?: number + darkModeTimeEnd?: number + /** + * The animation of charts + * + * @since 3.2.2 + */ + chartAnimationDuration: number + } + + type AppearanceRequired = MakeRequired<tt4b.option.AppearanceOption, 'darkModeTimeStart' | 'darkModeTimeEnd'> + + type TrackingOption = { + /** + * Whether to pause tracking if no activity detected + * + * @since 2.5.4 + */ + autoPauseTracking: boolean + /** + * Check interval of auto pausing, seconds + * + * @since 2.5.4 + */ + autoPauseInterval: number + /** + * Whether to count the local files + * @since 0.7.0 + */ + countLocalFiles: boolean + /** + * Whether to count the tile of tab group + */ + countTabGroup: boolean + /** + * The start of one week + * @since 2.4.1 + */ + weekStart?: WeekStartOption + /** + * Where to store the tracking data + * + * @since 4.0.0 + */ + storage: StorageType + } + + type TrackingRequired = MakeRequired<tt4b.option.TrackingOption, 'weekStart'> + + type LimitOption = { + /** + * Delay duration, minutes + */ + limitDelayDuration: number + /** + * Motto displayed when restricted + */ + limitPrompt?: string + /** + * restriction level + */ + limitLevel: limit.RestrictionLevel + /** + * The password to unlock + */ + limitPassword?: string + /** + * The difficulty of verification + */ + limitVerifyDifficulty?: limit.VerificationDifficulty + /** + * Whether to reminder before time will meet + * + * @since 3.1.0 + */ + limitReminder: boolean + /** + * Minutes + * + * @since 3.1.0 + */ + limitReminderDuration?: number + } + + type LimitRequired = MakeRequired<tt4b.option.LimitOption, 'limitPassword' | 'limitVerifyDifficulty' | 'limitReminderDuration'> + + /** + * The options of backup + * + * @since 1.2.0 + */ + type BackupOption = { + /** + * The type 2 backup + */ + backupType: backup.Type + /** + * The auth of types, maybe ak/sk or static token + */ + backupAuths: { [type in backup.Type]?: string } + /** + * Login info of types + */ + backupLogin: { [type in backup.Type]?: backup.LoginInfo } + /** + * The extended information of types, including url, file path, and so on + */ + backupExts?: { + [type in backup.Type]?: backup.TypeExt + } + /** + * The name of this client + */ + clientName: string + /** + * Whether to auto-backup data + */ + autoBackUp: boolean + /** + * Interval to auto-backup data, minutes + */ + autoBackUpInterval: number + } + + type AccessibilityOption = { + /** + * Show decals for charts + */ + chartDecal: boolean + } + + type NotificationOption = { + /** + * Notification cycle: none, daily, or weekly + */ + notificationCycle: tt4b.notification.Cycle + /** + * Offset time in minutes relative to the start of the cycle + */ + notificationOffset: number + /** + * Notification method: browser or callback + */ + notificationMethod: tt4b.notification.Method + /** + * HTTP callback endpoint URL + */ + notificationEndpoint?: string + /** + * Auth token for HTTP callback (optional) + */ + notificationAuthToken?: string + } + + export type DefaultOption = + & AppearanceRequired & TrackingRequired & LimitRequired + & tt4b.option.BackupOption & tt4b.option.AccessibilityOption + & tt4b.option.NotificationOption + + type AllOption = + & AppearanceOption + & TrackingOption + & LimitOption + & AccessibilityOption + & BackupOption + & NotificationOption + + /** + * @since 0.8.0 + */ + type LocaleOption = Locale | "default" + + /** + * @since 4.0.0 + */ + type StorageType = 'classic' | 'indexed_db' + } + + namespace mq { + type _TransmitValue = + | undefined | string | number | boolean | void + | { readonly [key: string]: _TransmitValue } + | readonly _TransmitValue[] + + type _HandlerIO<Input extends _TransmitValue = undefined, Output extends _TransmitValue = undefined> = [Input, Output] + type _MakeRegistry<Codes extends string, Param extends _TransmitValue = undefined, Result extends _TransmitValue = undefined> = Record<Codes, _HandlerIO<Param, Result>> + + type _MqReqData<R, K extends keyof R> = R[K] extends [infer In, unknown] ? In : never + type _MqResData<R, K extends keyof R> = R[K] extends [unknown, infer Out] ? Out : never + type _MqRequest<R, K extends keyof R = keyof R> = { code: K; data: _MqReqData<R, K> } + type _MqSuccess<R, K extends keyof R> = + _MqResData<R, K> extends undefined ? { code: "success"; data?: undefined } : { code: "success"; data: _MqResData<R, K> } + type _MqResponse<R, K extends keyof R = keyof R> = + | { code: "fail"; msg: string } + | { code: "ignore" } + | (K extends keyof R ? _MqSuccess<R, K> : never) + type _MqHandler<R, C extends keyof R> = _MqResData<R, C> extends undefined + ? (data: _MqReqData<R, C>, sender: chrome.runtime.MessageSender) => Awaitable<void | undefined> + : (data: _MqReqData<R, C>, sender: chrome.runtime.MessageSender) => Awaitable<_MqResData<R, C>> + + type _HandlerRegistry = + // Track event + & _MakeRegistry<'track.time' | 'track.runTime', core.Event> + // Content script events + & _MakeRegistry<'cs.injected'> + & _MakeRegistry<'cs.idleChanged', boolean> + // Content script API + & _MakeRegistry<'cs.getAudible', undefined, boolean> + // Statistics + & _MakeRegistry<'stat.today', string, core.Result | undefined> + & _MakeRegistry<'stat.sites', stat.SiteQuery | undefined, stat.SiteRow[]> + & _MakeRegistry<'stat.sitePage', stat.SitePageQuery | undefined, common.PageResult<stat.SiteRow>> + & _MakeRegistry<'stat.deleteSite', stat.SiteDeleteQuery> + & _MakeRegistry<'stat.countSite', stat.SiteQuery | undefined, number> + & _MakeRegistry<'stat.cates', stat.CateQuery | undefined, stat.CateRow[]> + & _MakeRegistry<'stat.catePage', stat.CatePageQuery | undefined, common.PageResult<stat.CateRow>> + & _MakeRegistry<'stat.groups', stat.GroupQuery | undefined, stat.GroupRow[]> + & _MakeRegistry<'stat.groupPage', stat.GroupPageQuery | undefined, common.PageResult<stat.GroupRow>> + & _MakeRegistry<'stat.countGroup', stat.GroupQuery | undefined, number> + & _MakeRegistry<'stat.batchDelete', stat.StatKey[]> + // Items + & _MakeRegistry<'item.batch', core.RowKey[], core.Row[]> + // Category + & _MakeRegistry<'cate.all', undefined, site.Cate[]> + & _MakeRegistry<'cate.add', string, site.Cate> + & _MakeRegistry<'cate.change', site.Cate> + & _MakeRegistry<'cate.delete', number> + // Option + & _MakeRegistry<'option.get', undefined, option.DefaultOption> + & _MakeRegistry<'option.set', Partial<option.AllOption>> + & _MakeRegistry<'option.changeStorage', option.StorageType> + & _MakeRegistry<'option.testNotification', undefined, string | undefined> + & _MakeRegistry<'option.weekStartDay', undefined, number> + & _MakeRegistry<'option.weekStartTime', number, number> + // Meta + & _MakeRegistry<'meta.installTs', undefined, number> + & _MakeRegistry<'meta.usedStorage', undefined, common.StorageUsage> + & _MakeRegistry<'meta.check2fa', string, boolean> + & _MakeRegistry<'meta.prepare2fa', undefined, string> + // Site + & _MakeRegistry<'site.runEnabled', string, boolean> + & _MakeRegistry<'site.list', site.Query | undefined, site.SiteInfo[]> + & _MakeRegistry<'site.page', site.PageQuery | undefined, common.PageResult<site.SiteInfo>> + & _MakeRegistry<'site.add', site.SiteInfo, string | undefined> + & _MakeRegistry<'site.delete', site.SiteKey[]> + & _MakeRegistry<'site.changeCate', site.ChangeCateParam> + & _MakeRegistry<'site.deleteIcon', site.SiteKey> + & _MakeRegistry<'site.changeAlias', site.ChangeAliasParam> + & _MakeRegistry<'site.fillAlias', site.SiteKey[]> + & _MakeRegistry<'site.initialAlias', string, string | undefined> + & _MakeRegistry<'site.changeRun', { key: site.SiteKey; enabled: boolean }> + & _MakeRegistry<'site.search', string | undefined, site.SiteInfo[]> + // Time Limit + & _MakeRegistry<'limit.list', limit.Query | undefined, limit.Item[]> + & _MakeRegistry<'limit.delete', number[]> + & _MakeRegistry<'limit.update', limit.Rule[]> + & _MakeRegistry<'limit.add', Omit<limit.Rule, 'id'>, number> + & _MakeRegistry<'limit.hitVisit', limit.Item, boolean> + & _MakeRegistry<'limit.delay', string> + & _MakeRegistry<'limit.summary', undefined, limit.Summary | undefined> + // Merge + & _MakeRegistry<'merge.all', undefined, merge.Rule[]> + & _MakeRegistry<'merge.delete', string> + & _MakeRegistry<'merge.add', merge.Rule> + // Whitelist + & _MakeRegistry<'whitelist.all', undefined, string[]> + & _MakeRegistry<'whitelist.add' | 'whitelist.delete', string> + & _MakeRegistry<'whitelist.contain', { host: string; url: string }, boolean> + // Backup + & _MakeRegistry<'backup.sync' | 'backup.checkAuth', undefined, string | undefined> + & _MakeRegistry<'backup.clear', string, string | undefined> + & _MakeRegistry<'backup.query', backup.RemoteQuery, backup.Row[]> + & _MakeRegistry<'backup.lastTs', backup.Type, number | undefined> + & _MakeRegistry<'backup.clients', undefined, (backup.Client & { current: boolean })[]> + // Period + & _MakeRegistry<'period.list', period.Query, period.Row[]> + // Timeline + & _MakeRegistry<'timeline.list', timeline.Query, timeline.Activity[]> + & _MakeRegistry<'timeline.tick', timeline.Event> + & _MakeRegistry<'backup.preview', backup.RemoteQuery, imported.Row[]> + & _MakeRegistry<'immigration.importOther', imported.ProcessQuery> + & _MakeRegistry<'immigration.import', any> + & _MakeRegistry<'immigration.export', undefined, backup.ExportData> + + type ReqCode = keyof _HandlerRegistry + + type ReqData<T extends ReqCode> = _MqReqData<_HandlerRegistry, T> + + /** + * @since 0.2.2 + */ + type Request<T extends ReqCode = ReqCode> = _MqRequest<_HandlerRegistry, T> + + type ResData<T extends ReqCode> = _MqResData<_HandlerRegistry, T> + + /** + * When ResData is undefined, success may omit data. + * @since 0.8.4 + */ + type Response<T extends ReqCode = ReqCode> = _MqResponse<_HandlerRegistry, T> + + /** + * @since 1.3.0 + */ + type Handler<C extends ReqCode> = _MqHandler<_HandlerRegistry, C> + /** + * @since 0.8.4 + */ + type Callback<T extends ReqCode = ReqCode> = (result?: Response<T>) => void + } + + /** + * Background → content script via chrome.tabs.sendMessage (see sendMsg2Tab). + */ + namespace tab { + type _HandlerRegistry = + & mq._MakeRegistry<'siteRunChange'> + & mq._MakeRegistry<'syncAudible', boolean> + & mq._MakeRegistry<'limitTimeMeet', limit.Item[]> + & mq._MakeRegistry<'limitChanged'> + & mq._MakeRegistry<'limitReminder', limit.ReminderInfo> + & mq._MakeRegistry<'askVisitHit', number, boolean> + + type ReqCode = keyof _HandlerRegistry + + type ReqData<T extends ReqCode> = mq._MqReqData<_HandlerRegistry, T> + + type ResData<T extends ReqCode> = mq._MqResData<_HandlerRegistry, T> + + type Request<T extends ReqCode = ReqCode> = mq._MqRequest<_HandlerRegistry, T> + + type Response<T extends ReqCode = ReqCode> = mq._MqResponse<_HandlerRegistry, T> + + /** + * @since 0.8.4 + */ + type Callback<T extends ReqCode = ReqCode> = (result?: Response<T>) => void + } +} From db5862ebc14108fd8c5b7ffd59339628abbec33a Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Fri, 15 May 2026 14:25:29 +0800 Subject: [PATCH 169/174] ci: optimize crowdin --- script/crowdin/client.ts | 42 ++++---- script/crowdin/common.ts | 20 ++-- script/crowdin/download.ts | 49 +++++++++ script/crowdin/export-translation.ts | 57 ++--------- script/crowdin/sync-source.ts | 33 ++++-- script/crowdin/sync-translation.ts | 144 ++++++++++++++++++--------- 6 files changed, 216 insertions(+), 129 deletions(-) create mode 100644 script/crowdin/download.ts diff --git a/script/crowdin/client.ts b/script/crowdin/client.ts index b446a48ac..80fc7474f 100644 --- a/script/crowdin/client.ts +++ b/script/crowdin/client.ts @@ -19,14 +19,12 @@ const MAIN_BRANCH_NAME = 'main' */ class PaginationIterator<T> { private offset = 0 - private limit = 25 + private limit = 500 private isEnd = false private buf: T[] = [] private cursor = 0 - private query: (pagination: Pagination) => Promise<ResponseList<T>> - constructor(query: (pagination: Pagination) => Promise<ResponseList<T>>) { - this.query = query + constructor(private query: (pagination: Pagination) => Promise<ResponseList<T>>) { } reset(): void { @@ -169,17 +167,6 @@ export class CrowdinClient { return response.data } - async restoreFile(storage: UploadStorageModel.Storage, existFile: SourceFilesModel.File): Promise<SourceFilesModel.File> { - const response = await this.crowdin.sourceFilesApi.updateOrRestoreFile(PROJECT_ID, existFile.id, { storageId: storage.id }) - return response.data - } - - getFileByName(param: NameKey): Promise<SourceFilesModel.File | undefined> { - return new PaginationIterator( - p => this.crowdin.sourceFilesApi.listProjectFiles(PROJECT_ID, { ...p, branchId: param.branchId }) - ).findFirst(t => t.name === param.name) - } - getDirByName(param: NameKey): Promise<SourceFilesModel.Directory | undefined> { return new PaginationIterator( p => this.crowdin.sourceFilesApi.listProjectDirectories(PROJECT_ID, { ...p, branchId: param.branchId }) @@ -200,6 +187,12 @@ export class CrowdinClient { ).findAll() } + async downloadSourceFile(fileId: number): Promise<Record<string, unknown>> { + const res = await this.crowdin.sourceFilesApi.downloadFile(PROJECT_ID, fileId) + const response = await fetch(res.data.url) + return await response.json() as Record<string, unknown> + } + listStringsByFile(fileId: number): Promise<SourceStringsModel.String[]> { return new PaginationIterator( p => this.crowdin.sourceStringsApi.listProjectStrings(PROJECT_ID, { ...p, fileId: fileId }) @@ -280,11 +273,24 @@ export class CrowdinClient { skipUntranslatedStrings: true, }) const buildId = buildRes.data.id + const maxRetries = 120 + let retryCount = 0 while (true) { - // Wait finished - const res = await this.crowdin.translationsApi.downloadTranslations(PROJECT_ID, buildId) - return res.data.url + if (retryCount >= maxRetries) { + throw new Error(`Build timed out after ${maxRetries} retries: buildId=${buildId}`) + } + const statusRes = await this.crowdin.translationsApi.checkBuildStatus(PROJECT_ID, buildId) + const { status, progress } = statusRes.data + console.log(`Build status: ${status}, progress: ${progress}%`) + if (status === 'finished') break + if (status === 'canceled' || status === 'failed') { + throw new Error(`Build ${status}: buildId=${buildId}`) + } + retryCount++ + await new Promise(resolve => setTimeout(resolve, 1000)) } + const res = await this.crowdin.translationsApi.downloadTranslations(PROJECT_ID, buildId) + return res.data.url } } diff --git a/script/crowdin/common.ts b/script/crowdin/common.ts index 5e21c7e8c..090f1baa4 100644 --- a/script/crowdin/common.ts +++ b/script/crowdin/common.ts @@ -92,7 +92,7 @@ export const RSC_FILE_SUFFIX = "-resource.json" * @param dir the directory of messages * @returns */ -export async function readAllMessages(dir: Dir): Promise<Record<string, Messages<any>>> { +export async function readAllMessages(dir: Dir): Promise<Record<string, Messages<Record<string, unknown>>>> { const dirPath = path.join(MSG_BASE, dir) const files = fs.readdirSync(dirPath) @@ -101,7 +101,7 @@ export async function readAllMessages(dir: Dir): Promise<Record<string, Messages if (!file.endsWith(RSC_FILE_SUFFIX)) { return } - const message = (await import(`@i18n/message/${dir}/${file}`))?.default as Messages<any> + const message = (await import(`@i18n/message/${dir}/${file}`))?.default as Messages<Record<string, unknown>> const name = file.replace(RSC_FILE_SUFFIX, '') message && (result[name] = message) return @@ -110,10 +110,10 @@ export async function readAllMessages(dir: Dir): Promise<Record<string, Messages } /** - * Merge crowdin message into locale resource json files + * Merge crowdin message into locale resource JSON files * * @param dir dir - * @param filename the name of json file + * @param filename the name of JSON file * @param messages crowdin messages */ export async function mergeMessage( @@ -123,7 +123,7 @@ export async function mergeMessage( ): Promise<void> { const dirPath = path.join(MSG_BASE, dir) const filePath = path.join(dirPath, filename) - const existMessages = (await import(`@i18n/message/${dir}/${filename}`))?.default as Messages<any> + const existMessages = (await import(`@i18n/message/${dir}/${filename}`))?.default as Messages<Record<string, unknown>> if (!existMessages) { logError(`Failed to find local code: dir=${dir}, filename=${filename}`) return @@ -155,22 +155,22 @@ export async function mergeMessage( } function logError(msg: string) { - console.error(`[CROWDIN-ERROR] ${msg}`) + console.error(`\x1b[31m[CROWDIN-ERROR]\x1b[0m ${msg}`) } function checkPlaceholder(translated: string, source: string) { const allSourcePlaceholders = - Array.from(source.matchAll(/\{(.*?)\}/g)) + Array.from(source.matchAll(/\{(.*?)}/g)) .map(matched => matched[1]) .sort() const allTranslatedPlaceholders = - Array.from(translated.matchAll(/\{(.*?)\}/g)) + Array.from(translated.matchAll(/\{(.*?)}/g)) .map(matched => matched[1]) .sort() if (allSourcePlaceholders.length != allTranslatedPlaceholders.length) { return false } - for (let i = 0; i++; i < allSourcePlaceholders.length) { + for (let i = 0; i < allSourcePlaceholders.length; i++) { if (allSourcePlaceholders[i] !== allTranslatedPlaceholders[i]) { return false } @@ -220,3 +220,5 @@ export async function checkMainBranch(client: CrowdinClient): Promise<SourceFile const branch = await client.getMainBranch() return branch ?? exitWith("Main branch is null") } + + diff --git a/script/crowdin/download.ts b/script/crowdin/download.ts new file mode 100644 index 000000000..4059af6ca --- /dev/null +++ b/script/crowdin/download.ts @@ -0,0 +1,49 @@ +import decompress from "decompress" +import { existsSync, readFileSync } from "fs" +import { rm, writeFile } from "fs/promises" +import { join } from "path" +import { type Dir, type ItemSet, transMsg } from "./common" + +const TEMP_FILE_NAME = join(process.cwd(), ".crowdin-temp.zip") +const TEMP_DIR = join(process.cwd(), ".crowdin-temp") + +export async function downloadProjectZip(url: string): Promise<string> { + const res = await fetch(url) + const blob = await res.blob() + const buffer = Buffer.from(await blob.arrayBuffer()) + await writeFile(TEMP_FILE_NAME, buffer) + console.log("Downloaded project zip file") + if (existsSync(TEMP_DIR)) { + await rm(TEMP_DIR, { recursive: true, force: true }) + } + await decompress(TEMP_FILE_NAME, TEMP_DIR) + console.log("Decompressed zip file") + return TEMP_DIR +} + +export async function clearTempFile() { + await rm(TEMP_FILE_NAME, { force: true }) + await rm(TEMP_DIR, { recursive: true, force: true }) +} + +/** + * Read a single file from the unzipped Crowdin project translation. + * + * @param tmpDir temp directory containing unzipped translations + * @param langDir language subdirectory (e.g. 'zh-CN' or 'en') + * @param dir message directory + * @param crowdinFileName file name with .json extension + */ +export function readCrowdinZipFile(tmpDir: string, langDir: string, dir: Dir, crowdinFileName: string): ItemSet { + const filePath = join(tmpDir, langDir, dir, crowdinFileName) + if (!existsSync(filePath)) { + return {} + } + try { + const json = readFileSync(filePath).toString() + return transMsg(JSON.parse(json)) + } catch (error) { + console.warn(`Failed to parse crowdin zip file: ${filePath}`, error) + return {} + } +} diff --git a/script/crowdin/export-translation.ts b/script/crowdin/export-translation.ts index 115f437f7..bd12e5e59 100644 --- a/script/crowdin/export-translation.ts +++ b/script/crowdin/export-translation.ts @@ -1,26 +1,17 @@ -import decompress from "decompress" -import { existsSync, readdirSync, readFileSync, rm, writeFile } from "fs" +import { readdirSync, readFileSync } from "fs" import { join } from "path" import { getClientFromEnv } from "./client" import { - ALL_DIRS, ALL_TRANS_LOCALES, - checkMainBranch, - crowdinLangOf, - type Dir, - type ItemSet, - mergeMessage, - RSC_FILE_SUFFIX, + ALL_DIRS, ALL_TRANS_LOCALES, checkMainBranch, crowdinLangOf, type Dir, type ItemSet, mergeMessage, RSC_FILE_SUFFIX, transMsg } from "./common" +import { clearTempFile, downloadProjectZip } from "./download" -const TEMP_FILE_NAME = join(process.cwd(), ".crowdin-temp.zip") -const TEMP_DIR = join(process.cwd(), ".crowdin-temp") - -async function processDir(dir: Dir): Promise<void> { +async function processDir(tmpDir: string, dir: Dir): Promise<void> { const fileSets: Record<string, Partial<Record<tt4b.Locale, ItemSet>>> = {} for (const locale of ALL_TRANS_LOCALES) { const crowdinLang = crowdinLangOf(locale) - const dirPath = join(TEMP_DIR, crowdinLang, dir) + const dirPath = join(tmpDir, crowdinLang, dir) const files = readdirSync(dirPath) for (const fileName of files) { const json = readFileSync(join(dirPath, fileName)).toString() @@ -34,44 +25,18 @@ async function processDir(dir: Dir): Promise<void> { } } -async function downloadProjectZip(url: string): Promise<void> { - const res = await fetch(url) - const blob = await res.blob() - const buffer = Buffer.from(await blob.arrayBuffer()) - await new Promise(resolve => writeFile(TEMP_FILE_NAME, buffer, resolve)) -} - -async function compressProjectZip(): Promise<void> { - if (existsSync(TEMP_DIR)) { - await new Promise(resolve => rm(TEMP_DIR, { recursive: true }, resolve)) - } - await decompress(TEMP_FILE_NAME, TEMP_DIR) -} - -async function clearTempFile() { - await new Promise(resolve => rm(TEMP_FILE_NAME, resolve)) - await new Promise(resolve => rm(TEMP_DIR, { recursive: true }, resolve)) -} - async function main() { const client = getClientFromEnv() const branch = await checkMainBranch(client) const zipUrl = await client.buildProjectTranslation(branch.id) console.log("Built project translations") console.log(zipUrl) - await downloadProjectZip(zipUrl) - console.log("Downloaded project zip file") - try { - await compressProjectZip() - console.log("Compressed zip file") - for (const dir of ALL_DIRS) { - await processDir(dir) - console.log("Processed dir: " + dir) - } - } finally { - clearTempFile() - console.log("Cleaned temp files") + const tmpDir = await downloadProjectZip(zipUrl) + + for (const dir of ALL_DIRS) { + await processDir(tmpDir, dir) + console.log("Processed dir: " + dir) } } -main() \ No newline at end of file +main().finally(clearTempFile) \ No newline at end of file diff --git a/script/crowdin/sync-source.ts b/script/crowdin/sync-source.ts index a5f3f62ce..8fd494c30 100644 --- a/script/crowdin/sync-source.ts +++ b/script/crowdin/sync-source.ts @@ -1,15 +1,20 @@ -import { type SourceFilesModel, type SourceStringsModel } from "@crowdin/crowdin-api-client" +import type { SourceFilesModel, SourceStringsModel } from "@crowdin/crowdin-api-client" import { toMap } from "@util/array" import { type CrowdinClient, getClientFromEnv, type NameKey } from "./client" -import { - ALL_DIRS, - Dir, - isIgnored, - ItemSet, - readAllMessages, - SOURCE_LOCALE, - transMsg -} from "./common" +import { ALL_DIRS, type Dir, isIgnored, type ItemSet, readAllMessages, SOURCE_LOCALE, transMsg } from "./common" + +/** + * Check if local source content is the same as Crowdin source content + */ +function isSourceUnchanged(localContent: ItemSet, crowdinContent: ItemSet): boolean { + const localKeys = Object.keys(localContent).filter(k => !!localContent[k]) + const crowdinKeys = Object.keys(crowdinContent) + if (localKeys.length !== crowdinKeys.length) return false + for (const key of localKeys) { + if (localContent[key] !== crowdinContent[key]) return false + } + return true +} async function initBranch(client: CrowdinClient): Promise<SourceFilesModel.Branch> { const branch = await client.getOrCreateMainBranch() @@ -85,6 +90,14 @@ async function processByDir(client: CrowdinClient, dir: Dir, branch: SourceFiles const storage = await client.createStorage(crowdinFilename, fileContent) existFile = await client.createFile(directory.id, storage, crowdinFilename) console.log(`Created new file: dir=${dir}, fileName=${crowdinFilename}, id=${existFile.id}`) + } else { + // Download source file from Crowdin and compare + const crowdinJson = await client.downloadSourceFile(existFile.id) + const crowdinContent = transMsg(crowdinJson) + if (isSourceUnchanged(fileContent, crowdinContent)) { + console.log(`No source diff for ${dir}/${crowdinFilename}, skipped`) + continue + } } // Process by strings await processStrings(client, existFile, fileContent) diff --git a/script/crowdin/sync-translation.ts b/script/crowdin/sync-translation.ts index 56294533e..1510bc9b3 100644 --- a/script/crowdin/sync-translation.ts +++ b/script/crowdin/sync-translation.ts @@ -1,62 +1,118 @@ - -import { type SourceFilesModel } from "@crowdin/crowdin-api-client" +import type { SourceFilesModel, SourceStringsModel } from "@crowdin/crowdin-api-client" import { toMap } from "@util/array" import { exitWith } from "../util/process" import { type CrowdinClient, getClientFromEnv } from "./client" import { - ALL_DIRS, ALL_TRANS_LOCALES, - type CrowdinLanguage, type Dir, type ItemSet, - checkMainBranch, crowdinLangOf, isIgnored, readAllMessages, transMsg, + ALL_DIRS, ALL_TRANS_LOCALES, checkMainBranch, crowdinLangOf, type CrowdinLanguage, type Dir, isIgnored, + readAllMessages, transMsg, } from "./common" +import { clearTempFile, downloadProjectZip, readCrowdinZipFile } from "./download" const CROWDIN_USER_ID_OF_OWNER = 15266594 -async function processDirMessage(client: CrowdinClient, file: SourceFilesModel.File, message: ItemSet, lang: CrowdinLanguage): Promise<void> { - console.log(`Start to process dir message: fileName=${file.name}, lang=${lang}`) - const strings = await client.listStringsByFile(file.id) - const stringMap = toMap(strings, s => s.identifier) - for (const [identifier, text] of Object.entries(message)) { - const string = stringMap[identifier] - if (!string) { - console.log(`Can't found string of identifier: ${identifier}, file: ${file.path}`) +/** + * Sync translation for a single string. + * Keeps the same behavior as the original processDirMessage: + * 1. Delete all owner's translations that differ from current text + * 2. Create new translation if no matching one exists + */ +async function syncStringTranslation( + client: CrowdinClient, + stringId: number, + text: string, + lang: CrowdinLanguage, +): Promise<void> { + const existList = await client.listTranslationByStringAndLang({ stringId, lang }) + // Delete old translations by owner that differ from current + const oldByOwner = existList.filter(t => t.user.id === CROWDIN_USER_ID_OF_OWNER && t.text !== text) + for (const toDelete of oldByOwner) { + await client.deleteTranslation(toDelete.id) + console.log(`Deleted translation by owner: stringId=${stringId}, lang=${lang}, text=${toDelete.text}`) + } + if (!existList.some(t => t.text === text)) { + await client.createTranslation({ stringId, lang }, text) + console.log(`Created trans: stringId=${stringId}, lang=${lang}, text=${text}`) + } +} +/** + * Process translations for a single file across all locales + */ +async function processFile(client: CrowdinClient, options: { + dir: Dir + fileName: string + message: Messages<Record<string, unknown>> + crowdinFile: SourceFilesModel.File + tmpDir: string +}): Promise<void> { + const { dir, fileName, message, crowdinFile, tmpDir } = options + const crowdinFileName = fileName + '.json' + let stringMap: Record<string, SourceStringsModel.String> = {} + let stringMapLoaded = false + + for (const locale of ALL_TRANS_LOCALES) { + const translated = message[locale] + if (!translated || !Object.keys(translated).length) { continue } - if (text === string.text) { - // The same as original text - console.log(`Translation same as origin text of ${string.identifier} in ${file.path}`) + + const crowdinLang = crowdinLangOf(locale) + const strings = transMsg(translated) + const crowdinStrings = readCrowdinZipFile(tmpDir, crowdinLang, dir, crowdinFileName) + + // Compare locally first — find strings that actually differ + const diffKeys = Object.entries(strings).filter(([identifier, text]) => { + if (!text) return false + return crowdinStrings[identifier] !== text + }) + + if (!diffKeys.length) { + console.log(`No diff for ${dir}/${fileName} [${crowdinLang}], skipped`) continue } - const existList = await client.listTranslationByStringAndLang({ stringId: string.id, lang }) - // Deleted old translations different from current - const oldByOwner = existList.filter(t => t.user.id === CROWDIN_USER_ID_OF_OWNER && t.text !== text) - for (const toDelete of oldByOwner || []) { - await client.deleteTranslation(toDelete.id) - console.log(`Deleted translation by owner: stringId=${string.id}, lang=${lang}, text=${toDelete.text}`) + + console.log(`Found ${diffKeys.length} diff(s) for ${dir}/${fileName} [${crowdinLang}]`) + + if (!stringMapLoaded) { + const existStrings = await client.listStringsByFile(crowdinFile.id) + stringMap = toMap(existStrings, s => s.identifier) + stringMapLoaded = true } - if (!existList.some(t => t.text === text)) { - // Create new translation - await client.createTranslation({ stringId: string.id, lang }, text) - console.log(`Created trans: stringId=${string.id}, lang=${lang}, text=${text}`) + + for (const [identifier, text] of diffKeys) { + const string = stringMap[identifier] + if (!string) { + console.log(`Can't find string of identifier: ${identifier}, file: ${crowdinFile.path}`) + continue + } + if (text === string.text) { + console.log(`Translation same as origin text of ${string.identifier} in ${crowdinFile.path}`) + continue + } + await syncStringTranslation(client, string.id, text, crowdinLang) } } } -async function processDir(client: CrowdinClient, dir: Dir, branch: SourceFilesModel.Branch): Promise<void> { +/** + * Compare local translations with Crowdin zip, only sync differences + */ +async function processDir( + client: CrowdinClient, + dir: Dir, + branch: SourceFilesModel.Branch, + tmpDir: string, +): Promise<void> { const messages = await readAllMessages(dir) - const directory = await client.getDirByName({ - name: dir, - branchId: branch.id, - }) + const directory = await client.getDirByName({ name: dir, branchId: branch.id }) if (!directory) { exitWith("Directory not found: " + dir) } - const files = await client.listFilesByDirectory(directory!.id) + const files = await client.listFilesByDirectory(directory.id) console.log(`find ${files.length} files of ${dir}`) const fileMap = toMap(files, f => f.name) for (const [fileName, message] of Object.entries(messages)) { - console.log(`Start to sync translations of ${dir}/${fileName}`) if (isIgnored(dir, fileName)) { - console.log("Ignored file: " + fileName) + console.log(`Ignored file: ${dir}/${fileName}`) continue } const crowdinFileName = fileName + '.json' @@ -65,16 +121,7 @@ async function processDir(client: CrowdinClient, dir: Dir, branch: SourceFilesMo console.log(`Failed to find file: dir=${dir}, filename=${fileName}`) continue } - - for (const locale of ALL_TRANS_LOCALES) { - const translated = message[locale] - if (!translated || !Object.keys(translated).length) { - continue - } - const strings = transMsg(message[locale]) - const crowdinLang = crowdinLangOf(locale) - await processDirMessage(client, crowdinFile, strings, crowdinLang) - } + await processFile(client, { dir, fileName, message, crowdinFile, tmpDir }) } } @@ -82,9 +129,14 @@ async function main() { const client = getClientFromEnv() const branch = await checkMainBranch(client) + // Download all translations as zip for local comparison + const zipUrl = await client.buildProjectTranslation(branch.id) + const tmpDir = await downloadProjectZip(zipUrl) + console.log("Downloaded project zip to: " + tmpDir) + for (const dir of ALL_DIRS) { - await processDir(client, dir, branch) + await processDir(client, dir, branch, tmpDir) } } -main() \ No newline at end of file +main().finally(clearTempFile) \ No newline at end of file From 6731ec82cc522382ee368df8e09decf8269133ec Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Tue, 19 May 2026 15:39:44 +0000 Subject: [PATCH 170/174] i18n(download): download translations by bot --- src/i18n/message/app/dashboard-resource.json | 2 +- src/i18n/message/app/data-manage-resource.json | 5 ++++- src/i18n/message/app/limit-resource.json | 3 +++ src/i18n/message/app/option-resource.json | 10 ++++++++-- src/i18n/message/common/button-resource.json | 4 +++- src/i18n/message/common/shared-resource.json | 1 + src/i18n/message/popup/content-resource.json | 11 +++++++++++ 7 files changed, 31 insertions(+), 5 deletions(-) diff --git a/src/i18n/message/app/dashboard-resource.json b/src/i18n/message/app/dashboard-resource.json index b529abf78..37206c034 100644 --- a/src/i18n/message/app/dashboard-resource.json +++ b/src/i18n/message/app/dashboard-resource.json @@ -197,7 +197,7 @@ }, "fr": { "heatMap": { - "title0": "Plus de {hour} heures parcourues au cours de l'année dernière", + "title0": "Visité pendant plus de {hour} heures durant l'année dernière", "title1": "Parcouru moins d'une heure l'année dernière" }, "topK": { diff --git a/src/i18n/message/app/data-manage-resource.json b/src/i18n/message/app/data-manage-resource.json index 30aa75db5..e4ccbd5b2 100644 --- a/src/i18n/message/app/data-manage-resource.json +++ b/src/i18n/message/app/data-manage-resource.json @@ -276,7 +276,10 @@ "paramError": "Le paramètre est incorrect, veuillez vérifier!", "deleteConfirm": "Un total de {count} enregistrements a été filtré. Souhaitez-vous tous les supprimer ?", "migrationAlert": "Migrer les données entre les navigateurs à l'aide de l'importation et de l'exportation", - "importError": "Mauvaise extension de fichier" + "importError": "Mauvaise extension de fichier", + "exportData": "Exporter les données", + "restoreData": "Restaurer les données", + "restoreFromOther": "Restaurer depuis {ext}" }, "ru": { "totalMemoryAlert": "Браузер предоставляет {size}MB для хранения локальных данных по каждому расширению", diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index 7db540cc8..84fb6f4dd 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -384,6 +384,7 @@ "reminder": "Weniger als {min} Minuten bis zum Zeitlimit!" }, "fr": { + "onlyEffective": "Seulement en vigueur", "wildcardTip": "Vous pouvez utiliser des caractères génériques pour cibler des sous-domaines ou des sous-pages, et le préfixe \"+\" pour en exclure !", "emptyTips": "Cliquez ici pour créer une règle !", "item": { @@ -425,6 +426,8 @@ "strictTip": "La règle de limite a déjà été déclenchée et le déverrouillage manuel n'est pas autorisé !", "incorrectPsw": "Mot de passe incorrect", "incorrectAnswer": "Réponse incorrecte", + "twoFaInputTip": "La règle a été déclenchée ou verrouillée. Entrez le code à 6 chiffres de votre application d'authentification pour continuer.", + "incorrect2fa": "Code d’authentification à deux facteurs incorrect", "pi": "{digitCount} chiffres de {startIndex} à {endIndex} de la partie décimale de π", "confession": "Le temps, c'est de l'argent" }, diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index cb1d2d639..4e231e4a7 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -1156,8 +1156,14 @@ "strictTitle": "Confirmation de l'opération", "strictContent": "Lorsque vous sélectionnez cette option, si un site déclenche une limite quotidienne, vous ne serez pas autorisé à le débloquer manuellement sauf en attendant le lendemain. Si les règles ne sont pas correctement configurées, elles peuvent très bien gêner vos routines !", "pswFormLabel": "Mot de passe", - "pswFormAgain": "Retapez" - } + "pswFormAgain": "Retapez", + "2fa": "Utilisez le code 2FA pour déverrouiller", + "twoFaTitle": "Activer l'authentification à deux facteurs (A2F)", + "twoFaScanHint": "Scannez le code QR avec une application d'authentification ou importez le lien d'installation dans un gestionnaire de mots de passe qui prend en charge TOTP.", + "twoFaCopyLink": "Copier le lien", + "twoFaVerifyLabel": "Entrez le code à 6 chiffres pour verifier" + }, + "delayDuration": "Délai pour {input} minutes chaque fois" }, "backup": { "title": "Sauvegarde des données", diff --git a/src/i18n/message/common/button-resource.json b/src/i18n/message/common/button-resource.json index 8cd1e0656..991cae1c1 100644 --- a/src/i18n/message/common/button-resource.json +++ b/src/i18n/message/common/button-resource.json @@ -203,6 +203,7 @@ "cancel": "Annuler", "previous": "Retour", "next": "Suivant", + "okay": "OK", "dont": "Non", "operation": "Opérations", "configuration": "Paramètres", @@ -211,7 +212,8 @@ "batchEnable": "Activation par Lot", "batchDisable": "Désactivation par Lot", "collapse": "Réduire", - "expand": "Développer" + "expand": "Développer", + "copy": "Copier" }, "ru": { "create": "Создать", diff --git a/src/i18n/message/common/shared-resource.json b/src/i18n/message/common/shared-resource.json index d8bf773bd..cfee40f22 100644 --- a/src/i18n/message/common/shared-resource.json +++ b/src/i18n/message/common/shared-resource.json @@ -208,6 +208,7 @@ "notSet": "Non Défini" }, "limit": { + "limited": "Limité", "daily": "Limite quotidienne", "weekly": "Limite hebdomadaire", "period": "Périodes bloquées", diff --git a/src/i18n/message/popup/content-resource.json b/src/i18n/message/popup/content-resource.json index 63d98d367..9b98da656 100644 --- a/src/i18n/message/popup/content-resource.json +++ b/src/i18n/message/popup/content-resource.json @@ -222,6 +222,8 @@ "lastDays": "Les données de ces {n} derniers jours", "allTime": "Toutes les données" }, + "shareTitle": "Partager", + "installTip": "Scanner pour installer", "averageCount": "{value} fois par jour en moyenne", "averageTime": "{value} par jour en moyenne", "totalTime": "{totalTime} au total", @@ -230,6 +232,15 @@ }, "ranking": { "includingCount": "Y compris {siteCount} sites" + }, + "limit": { + "noData": "Pas de règle de limite en vigueur pour cette URL", + "newOne": "Créer Nouvelle", + "timeUsed": "{percent} utilisé", + "visitUsed": "{used} visites", + "remain": "{remaining} restantes", + "noLimit": "Aucune limite", + "notHit": "Pas en vigueur" } }, "ru": { From 9b9636303f28d05c1177cb7507ab8bdb6e8105a3 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Wed, 20 May 2026 01:01:30 +0800 Subject: [PATCH 171/174] chore: add Discord link --- README-zh.md | 19 +++++++-- README.md | 23 ++++++++--- src/i18n/message/popup/header-resource.json | 1 + src/i18n/message/popup/header.ts | 1 + src/pages/icons.tsx | 6 +++ .../popup/components/Header/MoreInfo.tsx | 40 ++++++++++--------- 6 files changed, 63 insertions(+), 27 deletions(-) diff --git a/README-zh.md b/README-zh.md index 551b6cc6e..9b49e936d 100644 --- a/README-zh.md +++ b/README-zh.md @@ -3,6 +3,7 @@ [![codecov](https://codecov.io/gh/sheepzh/time-tracker-4-browser/branch/main/graph/badge.svg?token=S98QSBSKCR&style=flat-square)](https://codecov.io/gh/sheepzh/time-tracker-4-browser) [![](https://img.shields.io/badge/license-Anti%20996-blue)](https://github.com/996icu/996.ICU) [![](https://img.shields.io/github/v/release/sheepzh/time-tracker-4-browser)](https://github.com/sheepzh/time-tracker-4-browser/releases) +[![Discord](https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/yXCngD8pKS) <p> <a href="https://chromewebstore.google.com/detail/dkdhhcbjijekmneelocdllcldcpmekmm/reviews"><img src="https://developer.chrome.com/static/docs/webstore/branding/image/206x58-chrome-web-bcb82d15b2486.png" alt="Chrome Web Store" height="48"></a> @@ -54,13 +55,25 @@ <img src="./doc/screenshot/app.png" width="100%"> </div> -## 贡献指南 +## 如何反馈 -如果你想参与到该项目的开源建设,可以考虑以下几种方式 +如果你在使用过程中遇到了问题,或有任何想法和建议,欢迎通过以下方式告诉我们: #### 提交 Issue -如果您有一些好的想法,或者 bug 反馈,可以新建一条 [issue](https://github.com/sheepzh/time-tracker-4-browser/issues) 。作者会在第一时间进行回复。 +在 [GitHub Issues](https://github.com/sheepzh/time-tracker-4-browser/issues) 中描述你遇到的问题或功能建议,我们会尽快回复。 + +#### 加入 Discord + +加入我们的 [Discord 社区](https://discord.gg/yXCngD8pKS),与开发者和其他用户直接交流。 + +#### 创建 Discussion + +在 [GitHub Discussions](https://github.com/sheepzh/time-tracker-4-browser/discussions) 中发起话题,适合分享使用经验或进行开放性讨论。 + +## 贡献指南 + +如果你想参与到该项目的开源建设,可以考虑以下几种方式 #### 参与开发 diff --git a/README.md b/README.md index cfdafd23f..acdd1f9dd 100644 --- a/README.md +++ b/README.md @@ -3,6 +3,7 @@ [![codecov](https://codecov.io/gh/sheepzh/time-tracker-4-browser/branch/main/graph/badge.svg?token=S98QSBSKCR&style=flat-square)](https://codecov.io/gh/sheepzh/time-tracker-4-browser) [![](https://img.shields.io/badge/license-Anti%20996-blue)](https://github.com/996icu/996.ICU) [![Crowdin](https://badges.crowdin.net/timer-chrome-edge-firefox/localized.svg)](https://crowdin.com/project/timer-chrome-edge-firefox) +[![Discord](https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/yXCngD8pKS) <p> <a href="https://chromewebstore.google.com/detail/dkdhhcbjijekmneelocdllcldcpmekmm/reviews"><img src="https://developer.chrome.com/static/docs/webstore/branding/image/206x58-chrome-web-bcb82d15b2486.png" alt="Available in the Chrome Web Store" height="48"></a> @@ -53,15 +54,27 @@ Time Tracker is a browser extension to track the time you spent on all websites. <p>Page Blocking</p> </div> -## Contribution +## Feedback -There are some things you can do to contribute to this software. +If you encounter any issues or have suggestions during use, feel free to reach out through the following channels: + +#### 1. Submit an Issue + +Describe your problem or feature request in [GitHub Issues](https://github.com/sheepzh/time-tracker-4-browser/issues), and we'll get back to you as soon as possible. + +#### 2. Join Discord -#### 1. Submit issues +Join our [Discord community](https://discord.gg/yXCngD8pKS) to chat directly with the developer and other users. -You can [submit one issue](https://github.com/sheepzh/time-tracker-4-browser/issues) to us if you have some suggestions, feature requests, or feedback of bugs. And we will reply it as soon as possible. +#### 3. Create a Discussion + +Start a topic in [GitHub Discussions](https://github.com/sheepzh/time-tracker-4-browser/discussions) — great for sharing experiences or open-ended conversations. + +## Contribution + +There are some things you can do to contribute to this software. -#### 2. Participate in development +#### 1. Participate in development If you know how to develop browser extensions and are familiar with the project's technology stack (TypeScript + Vue3 + Element Plus + Echarts), you can also contribute code diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index d134485f3..360b3b0b3 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -1,6 +1,7 @@ { "en": { "rating": "Submit rating", + "discord": "Join Discord", "showSiteName": "Display site name", "showTopN": "Display top {n}", "donutChart": "Displayed as donut charts" diff --git a/src/i18n/message/popup/header.ts b/src/i18n/message/popup/header.ts index 0f403d837..ba3017ebe 100644 --- a/src/i18n/message/popup/header.ts +++ b/src/i18n/message/popup/header.ts @@ -9,6 +9,7 @@ import resource from './header-resource.json' export type HeaderMessage = { rating: string + discord: string donutChart: string showSiteName: string showTopN: string diff --git a/src/pages/icons.tsx b/src/pages/icons.tsx index b92c719ec..bc8a42bc8 100644 --- a/src/pages/icons.tsx +++ b/src/pages/icons.tsx @@ -14,6 +14,12 @@ export const GitHub: Icon = () => ( </svg> ) +export const Discord: Icon = () => ( + <svg viewBox="0 0 1280 1024" version="1.1" xmlns="http://www.w3.org/2000/svg"> + <path d="M1049.062 139.672a3 3 0 0 0-1.528-1.4A970.13 970.13 0 0 0 808.162 64.06a3.632 3.632 0 0 0-3.846 1.82 674.922 674.922 0 0 0-29.8 61.2 895.696 895.696 0 0 0-268.852 0 619.082 619.082 0 0 0-30.27-61.2 3.78 3.78 0 0 0-3.848-1.82 967.378 967.378 0 0 0-239.376 74.214 3.424 3.424 0 0 0-1.576 1.352C78.136 367.302 36.372 589.38 56.86 808.708a4.032 4.032 0 0 0 1.53 2.75 975.332 975.332 0 0 0 293.65 148.378 3.8 3.8 0 0 0 4.126-1.352A696.4 696.4 0 0 0 416.24 860.8a3.72 3.72 0 0 0-2.038-5.176 642.346 642.346 0 0 1-91.736-43.706 3.77 3.77 0 0 1-0.37-6.252 502.094 502.094 0 0 0 18.218-14.274 3.638 3.638 0 0 1 3.8-0.512c192.458 87.834 400.82 87.834 591 0a3.624 3.624 0 0 1 3.848 0.466 469.066 469.066 0 0 0 18.264 14.32 3.768 3.768 0 0 1-0.324 6.252 602.814 602.814 0 0 1-91.78 43.66 3.75 3.75 0 0 0-2 5.222 782.11 782.11 0 0 0 60.028 97.63 3.728 3.728 0 0 0 4.126 1.4A972.096 972.096 0 0 0 1221.4 811.458a3.764 3.764 0 0 0 1.53-2.704c24.528-253.566-41.064-473.824-173.868-669.082zM444.982 675.16c-57.944 0-105.688-53.174-105.688-118.478s46.818-118.482 105.688-118.482c59.33 0 106.612 53.64 105.686 118.478 0 65.308-46.82 118.482-105.686 118.482z m390.76 0c-57.942 0-105.686-53.174-105.686-118.478s46.818-118.482 105.686-118.482c59.334 0 106.614 53.64 105.688 118.478 0 65.308-46.354 118.482-105.688 118.482z" /> + </svg> +) + export const Heart: Icon = () => ( <svg viewBox="0 0 1024 1024"> <path d="M1000 248Q976.992 192 933.984 148.992 849.984 64 732.992 64q-64 0-121.504 28T512 171.008q-42.016-51.008-99.488-79.008T291.008 64Q174.016 64 90.016 150.016 47.008 193.024 24 249.024-0.992 308.032 0 371.04q0.992 68.992 28.992 130.496t79.008 104.512q4.992 4 8.992 8 14.016 12 112.992 102.016 208 191.008 256.992 235.008 11.008 8.992 24.992 8.992t24.992-8.992q32.992-30.016 180.992-164.992 158.016-144 196-179.008 52-43.008 80.992-104.992t28.992-132q0-64-24-122.016z" /> diff --git a/src/pages/popup/components/Header/MoreInfo.tsx b/src/pages/popup/components/Header/MoreInfo.tsx index 8828036ab..7bacaa96d 100644 --- a/src/pages/popup/components/Header/MoreInfo.tsx +++ b/src/pages/popup/components/Header/MoreInfo.tsx @@ -1,16 +1,19 @@ import { createTab } from "@api/chrome/tab" import { Collection, MoreFilled } from '@element-plus/icons-vue' import Flex from '@pages/components/Flex' -import { GitHub, Heart } from '@pages/icons' +import { Discord, GitHub, Heart } from '@pages/icons' import { rateClicked } from '@pages/util/rate' import { getColor, type ColorVariant } from '@pages/util/style' import { t } from '@popup/locale' -import { BUY_ME_A_COFFEE_PAGE, CHANGE_LOG_PAGE, DONATION_PAGE, REVIEW_PAGE, SOURCE_CODE_PAGE } from "@util/constant/url" +import { CHANGE_LOG_PAGE, REVIEW_PAGE, SOURCE_CODE_PAGE } from "@util/constant/url" import { ElDropdown, ElDropdownItem, ElDropdownMenu, ElIcon } from "element-plus" +import { createStringUnionGuard } from 'typescript-guard' import { defineComponent, type FunctionalComponent, type StyleValue } from "vue" import { type JSX } from 'vue/jsx-runtime' -type Command = 'rate' | 'coffee' | 'donation' | 'github' | 'changelog' +type Command = 'github' | 'changelog' | 'rate' | 'discord' + +const isCommand = createStringUnionGuard<Command>('github', 'changelog', 'rate', 'discord') type ItemLinkProps = { icon: JSX.Element @@ -24,27 +27,23 @@ const ItemLink: FunctionalComponent<ItemLinkProps> = ({ icon, text, iconColor }) </Flex> ) +const HANDLERS: Record<Command, NoArgCallback> = { + github: () => createTab(SOURCE_CODE_PAGE), + changelog: () => createTab(CHANGE_LOG_PAGE), + rate: () => { + rateClicked() + createTab(REVIEW_PAGE) + }, + discord: () => createTab('https://discord.gg/yXCngD8pKS'), +} + const MoreInfo = defineComponent<{}>(() => { - const handleCmd = async (cmd: Command) => { - if (cmd === 'rate') { - rateClicked() - createTab(REVIEW_PAGE) - } else if (cmd === 'coffee') { - createTab(BUY_ME_A_COFFEE_PAGE) - } else if (cmd === 'github') { - createTab(SOURCE_CODE_PAGE) - } else if (cmd === 'changelog') { - createTab(CHANGE_LOG_PAGE) - } else if (cmd === 'donation') { - createTab(DONATION_PAGE) - } - } return () => ( <ElDropdown size='small' trigger='click' - onCommand={handleCmd} + onCommand={val => isCommand(val) && HANDLERS[val]()} style={{ cursor: 'pointer' } satisfies StyleValue} v-slots={{ default: () => <ElIcon><MoreFilled /></ElIcon>, @@ -56,7 +55,10 @@ const MoreInfo = defineComponent<{}>(() => { <ElDropdownItem command={'changelog' satisfies Command}> <ItemLink icon={<Collection />} text={t(msg => msg.base.changeLog)} /> </ElDropdownItem> - <ElDropdownItem command={'rate' satisfies Command} divided> + <ElDropdownItem command={'discord' satisfies Command} divided> + <ItemLink icon={<Discord />} text={t(msg => msg.header.discord)} /> + </ElDropdownItem> + <ElDropdownItem command={'rate' satisfies Command}> <ItemLink icon={<Heart />} text={t(msg => msg.header.rating)} iconColor="danger" /> </ElDropdownItem> </ElDropdownMenu> From 33de7b00e9b5954ce8a4e68263e7b324828d3582 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Wed, 20 May 2026 01:34:36 +0800 Subject: [PATCH 172/174] i18n: add Norwegian Bokmal, Hungarian, Indonesian to translate --- src/i18n/message/common/locale-resource.json | 6 ++++++ src/pages/app/components/HelpUs/ProgressList.tsx | 4 +++- types/tt4b.d.ts | 2 ++ 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/i18n/message/common/locale-resource.json b/src/i18n/message/common/locale-resource.json index 382d71596..d2c129694 100644 --- a/src/i18n/message/common/locale-resource.json +++ b/src/i18n/message/common/locale-resource.json @@ -93,5 +93,11 @@ }, "hi": { "name": "हिन्दी" + }, + "nb": { + "name": "norsk bokmål" + }, + "hu": { + "name": "magyar" } } \ No newline at end of file diff --git a/src/pages/app/components/HelpUs/ProgressList.tsx b/src/pages/app/components/HelpUs/ProgressList.tsx index 7078c4d5d..fc4508575 100644 --- a/src/pages/app/components/HelpUs/ProgressList.tsx +++ b/src/pages/app/components/HelpUs/ProgressList.tsx @@ -43,6 +43,8 @@ const localeCrowdMap: { [locale in SupportedLocale]: string } = { mn: "mn", ar: "ar", hi: "hi", + hu: "hu", + nb: "nb", } const crowdLocaleMap: { [locale: string]: SupportedLocale } = {} @@ -106,7 +108,7 @@ const _default = defineComponent(() => { status={computeType(progress)} style={{ fontSize: '16px' } satisfies StyleValue} > - <Flex width={70} gap={10} fontSize={12} column align="end" color="var(--el-text-color-regular)"> + <Flex width={70} gap={10} fontSize={12} column align="end" style={{ textAlign: 'end' }} color="var(--el-text-color-regular)"> <span>{`${progress}%`}</span> <span>{localeMessages[locale as tt4b.Locale]?.name ?? locale}</span> </Flex> diff --git a/types/tt4b.d.ts b/types/tt4b.d.ts index 6a15e5d59..5c4488713 100644 --- a/types/tt4b.d.ts +++ b/types/tt4b.d.ts @@ -52,6 +52,8 @@ declare namespace tt4b { | 'sk' | 'mn' | 'hi' + | 'hu' + | 'nb' type ExtensionMeta = { installTime?: number From ce8c7fd04a43671775140ff326242bff847d51e7 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Wed, 20 May 2026 21:12:55 +0800 Subject: [PATCH 173/174] chore: fix rendering user chart --- package.json | 20 ++++++++++---------- script/user-chart/add.ts | 8 +++++--- script/user-chart/render.ts | 27 ++++----------------------- 3 files changed, 19 insertions(+), 36 deletions(-) diff --git a/package.json b/package.json index 60b73098b..c20c16056 100644 --- a/package.json +++ b/package.json @@ -29,17 +29,17 @@ "license": "MIT", "devDependencies": { "@commitlint/types": "^21.0.1", - "@crowdin/crowdin-api-client": "^1.55.1", + "@crowdin/crowdin-api-client": "^1.55.2", "@emotion/babel-plugin": "^11.13.5", "@rsdoctor/rspack-plugin": "^1.5.11", - "@rspack/cli": "^2.0.3", - "@rspack/core": "^2.0.3", - "@rstest/core": "^0.10.0", - "@rstest/coverage-istanbul": "^0.10.0", + "@rspack/cli": "^2.0.4", + "@rspack/core": "^2.0.4", + "@rstest/core": "^0.10.1", + "@rstest/coverage-istanbul": "^0.10.1", "@types/chrome": "0.1.42", "@types/decompress": "^4.2.7", "@types/firefox-webext-browser": "^143.0.0", - "@types/node": "^25.8.0", + "@types/node": "^25.9.1", "@vue/babel-plugin-jsx": "^2.0.1", "babel-loader": "^10.1.1", "commitlint": "^21.0.1", @@ -50,11 +50,11 @@ "husky": "^9.1.7", "jsdom": "^29.1.1", "jszip": "^3.10.1", - "knip": "^6.14.0", - "postcss": "^8.5.14", + "knip": "^6.14.1", + "postcss": "^8.5.15", "postcss-loader": "^8.2.1", "postcss-rtlcss": "^6.0.0", - "puppeteer": "^25.0.2", + "puppeteer": "^25.0.4", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", "typescript": "6.0.3", @@ -63,7 +63,7 @@ "dependencies": { "@element-plus/icons-vue": "^2.3.2", "@emotion/css": "^11.13.5", - "echarts": "^6.0.0", + "echarts": "^6.1.0", "element-plus": "2.14.0", "hash.js": "^1.1.7", "qrcode-generator": "^2.0.4", diff --git a/script/user-chart/add.ts b/script/user-chart/add.ts index 1dc3a5d99..b7c632fc8 100644 --- a/script/user-chart/add.ts +++ b/script/user-chart/add.ts @@ -113,10 +113,12 @@ function parseChrome(content: string): UserCount { if (!dateStr || !numberStr) { return } - // Replace '/' to '-', then rjust month and date - const date = dateStr.split('/').map(str => rjust(str, 2, '0')).join('-') + // Chrome CSV format: YYYY/M/D + const [, y, m, d] = /^(\d{4})\/(\d{1,2})\/(\d{1,2})$/.exec(dateStr.trim()) ?? [] + if (!y || !m || !d) return + const date = `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}` const number = parseInt(numberStr) - date && number && (result[date] = number) + number && (result[date] = number) }) return result } diff --git a/script/user-chart/render.ts b/script/user-chart/render.ts index ded23a9be..709db982c 100644 --- a/script/user-chart/render.ts +++ b/script/user-chart/render.ts @@ -16,8 +16,6 @@ type EcOption = ComposeOption< | GridComponentOption> const ALL_BROWSERS: Browser[] = ['firefox', 'chrome', 'edge'] -const POINT_COUNT = 500 - type OriginData = { [browser in Browser]: UserCount } @@ -29,11 +27,13 @@ type ChartData = { } } +const VALID_DATE_RE = /^\d{4}-\d{2}-\d{2}$/ + function preProcess(originData: OriginData): ChartData { // 1. sort dates const dateSet = new Set<string>() Object.values(originData).forEach(ud => Object.keys(ud).forEach(date => dateSet.add(date))) - let allDates = Array.from(dateSet).sort() + let allDates = Array.from(dateSet).filter(d => VALID_DATE_RE.test(d)).sort() // 2. smooth the count const ctx: { [browser in Browser]: SmoothContext } = { @@ -45,7 +45,7 @@ function preProcess(originData: OriginData): ChartData { allDates.forEach( date => ALL_BROWSERS.forEach(b => ctx[b].process(originData[b][date])) ) - const result: ChartData = { + return { xAxis: allDates, yAxises: { chrome: ctx.chrome.end(), @@ -53,12 +53,6 @@ function preProcess(originData: OriginData): ChartData { edge: ctx.edge.end(), } } - - // 3. zoom - const reduction = Math.floor(Object.keys(allDates).length / POINT_COUNT) - result.xAxis = zoom(result.xAxis, reduction) - ALL_BROWSERS.forEach(b => result.yAxises[b] = zoom(result.yAxises[b], reduction)) - return result } class SmoothContext { @@ -109,19 +103,6 @@ class SmoothContext { } } -function zoom<T>(data: T[], reduction: number): T[] { - let i = 0 - const newData: T[] = [] - while (i < data.length) { - const item = data[i] - if (item !== undefined) { - newData.push(item) - } - i += reduction - } - return newData -} - function render2Svg(chartData: ChartData): string { const { xAxis, yAxises } = chartData const chart: EChartsType = init(null, null, { From cc6130aaebc9bc919a2a5be6ea168e06b54eced7 Mon Sep 17 00:00:00 2001 From: sheepzh <returnzhy1996@outlook.com> Date: Thu, 21 May 2026 00:29:31 +0800 Subject: [PATCH 174/174] feat: add mark line for habit average chart (#778) --- .../Habit/Period/Average/Wrapper.tsx | 173 +++++++++++++++++- src/pages/util/echarts.ts | 4 +- 2 files changed, 166 insertions(+), 11 deletions(-) diff --git a/src/pages/app/components/Habit/Period/Average/Wrapper.tsx b/src/pages/app/components/Habit/Period/Average/Wrapper.tsx index 38a79f674..069225005 100644 --- a/src/pages/app/components/Habit/Period/Average/Wrapper.tsx +++ b/src/pages/app/components/Habit/Period/Average/Wrapper.tsx @@ -7,11 +7,14 @@ import { t } from "@app/locale" import { getCompareColor, tooltipDot, tooltipFlexLine, tooltipSpaceLine } from "@app/util/echarts" import { EchartsWrapper } from "@hooks" -import { getPrimaryTextColor } from '@pages/util/style' +import { getPrimaryTextColor, getSecondaryTextColor } from '@pages/util/style' import { averageByDay, MINUTE_PER_PERIOD } from "@util/period" import { formatPeriodCommon, MILL_PER_MINUTE } from "@util/time" -import type { BarSeriesOption, ComposeOption, GridComponentOption, TooltipComponentOption } from "echarts" -import { type TopLevelFormatterParams } from "echarts/types/dist/shared" +import type { + BarSeriesOption, ComposeOption, EChartsType, ElementEvent, GridComponentOption, MarkLineComponentOption, + TooltipComponentOption, +} from "echarts" +import type { TopLevelFormatterParams } from "echarts/types/dist/shared" import { generateGridOption } from "../common" type EcOption = ComposeOption< @@ -89,6 +92,9 @@ const formatTooltip = (params: TopLevelFormatterParams, biz: BizOption): string return `${timeLine}${tooltipSpaceLine()}${currLine}${prevLine}` } +const MARK_LINE_COLOR = getSecondaryTextColor() +const LABEL_COLOR = getPrimaryTextColor() + const generateOption = (biz: BizOption): EcOption => { let { curr, prev, periodSize } = biz @@ -97,20 +103,21 @@ const generateOption = (biz: BizOption): EcOption => { const currData = curr.map(r => cvt2Item(r)) const prevData = prev.map(r => cvt2Item(({ ...r, milliseconds: -r.milliseconds }))) - - const textColor = getPrimaryTextColor() const borderRadius = 5 * periodSize return { tooltip: { trigger: 'axis', formatter: (params: TopLevelFormatterParams) => formatTooltip(params, biz), + axisPointer: { + lineStyle: { color: MARK_LINE_COLOR }, + } }, - grid: generateGridOption(), + grid: { ...generateGridOption(), right: 30 }, xAxis: { type: 'category', axisLabel: { - color: textColor, + color: LABEL_COLOR, interval: (16 / periodSize - 1), formatter: (_, index) => formatXAxis(index, periodSize), }, @@ -132,7 +139,6 @@ const generateOption = (biz: BizOption): EcOption => { { type: "bar", stack: 'one', - large: true, data: currData.map((value, idx) => ({ value, itemStyle: { borderRadius: prevData[idx] ? [borderRadius, borderRadius, 0, 0] : borderRadius }, @@ -142,7 +148,6 @@ const generateOption = (biz: BizOption): EcOption => { }, { type: "bar", stack: 'one', - large: true, data: prevData.map((value, idx) => ({ value, itemStyle: { borderRadius: currData[idx] ? [0, 0, borderRadius, borderRadius] : borderRadius }, @@ -153,6 +158,156 @@ const generateOption = (biz: BizOption): EcOption => { } } +class Markline { + #locked = false + #lockedValue: number | null = null + #currentYValue: number | null = null + #rafId: number | null = null + + constructor( + private chart: EChartsType, + private getPeriodSize: () => number | undefined, + ) { + } + + get #segWidth(): number { + const periodSize = this.getPeriodSize() + if (!periodSize || periodSize <= 1) return MILL_PER_MINUTE * 1 + if (periodSize <= 4) return MILL_PER_MINUTE * 5 + return MILL_PER_MINUTE * 10 + } + + init() { + this.chart.getZr().on('mousemove', params => this.#handleMouseMove(params)) + this.chart.getZr().on('click', params => this.#handleClick(params)) + this.chart.getZr().on('globalout', () => this.#handleGlobalout()) + } + + #handleClick(params: ElementEvent): boolean | void { + const pointInPixel = [params.offsetX, params.offsetY] + + if (!this.chart.containPixel('grid', pointInPixel)) { + // Click outside grid area, ignore + return + } + + if (this.#locked) { + this.#unlock() + return + } + + if (this.#currentYValue !== null && !isNaN(this.#currentYValue)) { + this.#locked = true + this.#lockedValue = this.#currentYValue + this.#render() + } + } + + #handleMouseMove(params: ElementEvent) { + if (this.#rafId) return + + this.#rafId = requestAnimationFrame(() => { + const pointInPixel = [params.offsetX, params.offsetY] + + if (this.chart.containPixel('grid', pointInPixel)) { + // Move in the grid area: update current Y value + const [, yVal] = this.chart.convertFromPixel({ seriesIndex: 0 }, pointInPixel) + if (yVal !== undefined && !isNaN(yVal)) { + const segWidth = this.#segWidth + this.#currentYValue = Math.round(yVal / segWidth) * segWidth + !this.#locked && this.#render() + } + } else { + this.#handleGlobalout() + } + this.#rafId = null + }) + } + + #render() { + const val = this.#locked ? this.#lockedValue : this.#currentYValue + + const markLine: MarkLineComponentOption = val === null || isNaN(val) + ? { data: [] } + : { + animation: false, + symbol: 'none', + lineStyle: { + width: 1, + type: 'dashed', + opacity: .6, + color: MARK_LINE_COLOR, + }, + label: { + show: true, + position: 'end', + formatter: `${(val / MILL_PER_MINUTE).toFixed(0)}m`, + fontSize: 11, + borderRadius: 3, + color: LABEL_COLOR, + opacity: 1, + }, + data: [{ yAxis: val }], + emphasis: { + lineStyle: { + width: 1, + type: 'dashed', + opacity: 1, + color: MARK_LINE_COLOR, + } + } + } + + this.chart.setOption( + { series: [{ markLine }] }, + // Use layzUpdate to avoid triggering animation which may conflict with series data animations + { notMerge: false, lazyUpdate: true }, + ) + } + + /** + * Do nothing if locked, or clear the mark line + */ + #handleGlobalout() { + if (this.#locked) return + this.#currentYValue = null + this.#render() + } + + #unlock() { + this.#locked = false + this.#lockedValue = null + this.#render() + } + + forceLock(ms: number) { + const segWidth = this.#segWidth + this.#locked = true + this.#lockedValue = Math.round(ms / segWidth) * segWidth + this.#render() + } +} + export default class Wrapper extends EchartsWrapper<BizOption, EcOption> { + #markline: Markline | undefined + generateOption = generateOption + + override init(container: HTMLDivElement): void { + super.init(container) + + if (!this.instance) return + + this.#markline = new Markline(this.instance, () => this.lastBizOption?.periodSize) + this.#markline.init() + } + + override async render(biz: BizOption): Promise<void> { + await super.render(biz) + const { curr } = biz + const len = curr.length + if (!len) return + const average = curr.reduce((sum, r) => sum + r.milliseconds, 0) / len + this.#markline?.forceLock(average) + } } \ No newline at end of file diff --git a/src/pages/util/echarts.ts b/src/pages/util/echarts.ts index 6d5125271..f25218690 100644 --- a/src/pages/util/echarts.ts +++ b/src/pages/util/echarts.ts @@ -2,7 +2,7 @@ import { BarChart, CustomChart, EffectScatterChart, GaugeChart, LineChart, PieChart, ScatterChart, } from "echarts/charts" import { - AriaComponent, DataZoomComponent, GridComponent, LegendComponent, TitleComponent, ToolboxComponent, + AriaComponent, DataZoomComponent, GridComponent, LegendComponent, MarkLineComponent, TitleComponent, ToolboxComponent, TooltipComponent, VisualMapComponent, } from "echarts/components" import { use } from "echarts/core" @@ -12,7 +12,7 @@ export const initEcharts = () => { use([ CanvasRenderer, ToolboxComponent, AriaComponent, GridComponent, TooltipComponent, TitleComponent, VisualMapComponent, LegendComponent, - DataZoomComponent, + DataZoomComponent, MarkLineComponent, BarChart, PieChart, LineChart, ScatterChart, EffectScatterChart, CustomChart, GaugeChart, ]) } \ No newline at end of file