diff --git a/.github/ISSUE_TEMPLATE/bug-report---bug---.yaml b/.github/ISSUE_TEMPLATE/bug-report.yaml similarity index 79% rename from .github/ISSUE_TEMPLATE/bug-report---bug---.yaml rename to .github/ISSUE_TEMPLATE/bug-report.yaml index 610e65a1d..eabb4bf2b 100644 --- a/.github/ISSUE_TEMPLATE/bug-report---bug---.yaml +++ b/.github/ISSUE_TEMPLATE/bug-report.yaml @@ -15,34 +15,22 @@ body: label: Version description: Which version of the extension/addon are you using? placeholder: ex. v1.0.0 - - type: dropdown + - type: input id: os validations: required: true attributes: label: OS description: Which operating system are you using? (Mac/Linux/Win) - options: - - Win - - Mac - - Linux - - Android - - Other - default: 0 - - type: dropdown + placeholder: ex. Linux + - type: input id: browser validations: required: true attributes: label: Browser description: Which browser are you using? (Chrome/Firefox/Edge/Other) - options: - - Chrome - - Firefox - - Edge - - Brave - - Other - default: 0 + placeholder: ex. Chrome - type: input id: browser_version validations: diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml index ef5ec5286..566df9f59 100644 --- a/.github/ISSUE_TEMPLATE/feature-request.yaml +++ b/.github/ISSUE_TEMPLATE/feature-request.yaml @@ -7,35 +7,22 @@ body: attributes: value: | Tell us your wants~ - - type: dropdown + - type: input id: browser validations: required: true attributes: label: Browser - multiple: true description: Which browsers are you using? (Chrome/Firefox/Edge/Other) - options: - - Chrome - - Firefox - - Edge - - Brave - - Other - default: 0 - - type: dropdown + placeholder: ex. Chrome + - type: input 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 + placeholder: ex. Linux - type: textarea id: description validations: diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 000000000..919e86feb --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1 @@ +When performing a code review, follow [code-review.md](./instructions/code-review.md) for guidance. diff --git a/.github/instructions/code-review.md b/.github/instructions/code-review.md new file mode 100644 index 000000000..49a8eb81e --- /dev/null +++ b/.github/instructions/code-review.md @@ -0,0 +1,4 @@ +# Core Rules + +# Constraints +- Do not check for missing non-English translations. diff --git a/.github/workflows/contributor.yml b/.github/workflows/contributor.yml new file mode 100644 index 000000000..dcb4a8c07 --- /dev/null +++ b/.github/workflows/contributor.yml @@ -0,0 +1,28 @@ +name: Update Contributors + +on: + schedule: + - cron: "0 23 * * 2" + workflow_dispatch: + +jobs: + update-contributors: + runs-on: ubuntu-latest + + steps: + - name: Checkout Repository + uses: actions/checkout@v6 + + - name: Set up Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + + - name: Install Dependencies + run: npm install + + - name: Run Script + run: npx tsx --tsconfig tsconfig.node.json ./script/contributor.ts + env: + TIMER_USER_COUNT_GIST_TOKEN: ${{ secrets.GIST_PAT }} + TIMER_CROWDIN_AUTH: ${{ secrets.TIMER_CROWDIN_AUTH }} diff --git a/.github/workflows/crowdin-export.yml b/.github/workflows/crowdin-export.yml index 0ae69d015..ad9861994 100644 --- a/.github/workflows/crowdin-export.yml +++ b/.github/workflows/crowdin-export.yml @@ -25,7 +25,7 @@ jobs: - name: Export translations run: | - npx ts-node --project ./tsconfig.node.json ./script/crowdin/export-translation.ts 2>&1 | tee /tmp/export-output.log + npx tsx --tsconfig ./tsconfig.node.json ./script/crowdin/export-translation.ts 2>&1 | tee /tmp/export-output.log exit ${PIPESTATUS[0]} continue-on-error: true diff --git a/.github/workflows/crowdin-sync.yml b/.github/workflows/crowdin-sync.yml index ff42e5b46..1081b5114 100644 --- a/.github/workflows/crowdin-sync.yml +++ b/.github/workflows/crowdin-sync.yml @@ -6,16 +6,16 @@ jobs: env: TIMER_CROWDIN_AUTH: ${{ secrets.TIMER_CROWDIN_AUTH }} steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v7 + with: + fetch-depth: 1 - 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: "v24" - name: Install dependencies run: npm install - name: Sync source - run: ts-node --project ./tsconfig.node.json ./script/crowdin/sync-source.ts + run: npx tsx --tsconfig ./tsconfig.node.json ./script/crowdin/sync-source.ts - name: Sync translations - run: ts-node --project ./tsconfig.node.json ./script/crowdin/sync-translation.ts + run: npx tsx --tsconfig ./tsconfig.node.json ./script/crowdin/sync-translation.ts diff --git a/.github/workflows/psl-update.yml b/.github/workflows/psl-update.yml index cfde5fd37..e2ef6e3c1 100644 --- a/.github/workflows/psl-update.yml +++ b/.github/workflows/psl-update.yml @@ -8,20 +8,18 @@ jobs: runs-on: ubuntu-latest steps: - name: Prepare branch - uses: actions/checkout@v4 + uses: actions/checkout@v7 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: "v24" - name: Install dependencies run: npm install - name: Update psl - run: ts-node -P tsconfig.node.json ./script/psl.ts + run: npx tsx --tsconfig ./tsconfig.node.json ./script/psl.ts - name: Create Pull Request uses: peter-evans/create-pull-request@v7 with: diff --git a/.github/workflows/publish-edge.yml b/.github/workflows/publish-edge.yml index 39892ed1a..6cd541152 100644 --- a/.github/workflows/publish-edge.yml +++ b/.github/workflows/publish-edge.yml @@ -4,7 +4,7 @@ jobs: publish: runs-on: ubuntu-latest env: - ACTIONS_RUNNER_DEBUG: true + ACTIONS_RUNNER_DEBUG: true steps: - uses: actions/checkout@v4 with: diff --git a/.github/workflows/publish-firefox.yml b/.github/workflows/publish-firefox.yml index b41cff429..22e9c6096 100644 --- a/.github/workflows/publish-firefox.yml +++ b/.github/workflows/publish-firefox.yml @@ -1,5 +1,5 @@ name: Publish to Firefox Addon Store -on: [ workflow_dispatch ] +on: [workflow_dispatch] jobs: publish: runs-on: ubuntu-latest @@ -25,7 +25,8 @@ 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\": {\"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 }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 4fb4dfe1c..7abb62d23 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -4,11 +4,11 @@ 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: 24 - run: npm install - run: npm run test-c - name: Tests ✅ @@ -31,6 +31,6 @@ jobs: } - name: Upload coverage report if: ${{ !cancelled() }} - uses: codecov/test-results-action@v1 + uses: codecov/codecov-action@v6 with: token: ${{ secrets.CODECOV_TOKEN }} diff --git a/.gitignore b/.gitignore index 86537e1bd..071808f26 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,5 @@ package-lock.json aaa user-chart.svg +contributors.svg test.log diff --git a/.vscode/settings.json b/.vscode/settings.json index 50906233d..719844d44 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -31,6 +31,7 @@ "MKCOL", "newtab", "otpauth", + "Pomodoro", "Popconfirm", "PROPFIND", "Qihu", diff --git a/CHANGELOG.md b/CHANGELOG.md index e825c2e98..22c6e2c73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,55 @@ 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.4.4] - 2026-07-24 + +- Supported unblocking when the period limit triggers + +## [4.4.3] - 2026-07-13 + +- Fixed a bug + +## [4.4.2] - 2026-07-12 + +- Supported block periods to span cross days +- Categories is sorted alphabetically now +- Added range filter of record page +- Fixed some bugs + +## [4.4.1] - 2026-07-06 + +- Added the view of current page on the popup page +- Improved the performance when adding a site +- Added option syncing with Google/Edge/Firefox account + +## [4.4.0] - 2026-06-29 + +- Added Focus & Pomodoro +- Refactored the background page +- Fixed some bugs + +## [4.3.7] - 2026-06-15 + +- Supported custom icons for websites +- Fixed some bugs for Mobile + +## [4.3.6] - 2026-06-14 + +- Fixed some bugs + +## [4.3.5] - 2026-06-05 + +- Fixed some bugs (#788, #790) + +## [4.3.4] - 2026-06-01 + +- Fixed some bugs + +## [4.3.3] - 2026-05-23 + +- Added Norwegian Bokmal, Hungarian, Indonesian to translate +- Added mark line for habit average chart (#778) + ## [4.3.2] - 2026-05-16 - Urgently fixed some bugs @@ -57,7 +106,6 @@ It is worth mentioning that the release time of each change refers to the time w - Fixed some issue for Gist - ## [4.1.3] - 2026-03-24 - Add data collection permission for Firefox diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index d4a9b6945..426163e47 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -290,7 +290,7 @@ Please use the code formatting tools that come with VSCode. Please **disable - No semicolon at the end of the line - Please use LF (\n). In Windows, you need to execute the following command to turn off the warning: -``` +```shell git config core.autocrlf false ``` @@ -310,17 +310,17 @@ Crowdin is a collaborative translation platform that allows native speakers to h 1. Upload English text and other language text in code -``` +```shell # Upload original English text -ts-node ./script/crowdin/sync-source.ts +npx tsx --tsconfig tsconfig.node.json ./script/crowdin/sync-source.ts # Upload texts in other languages ​​in local code -ts-node ./script/crowdin/sync-translation.ts +npx tsx --tsconfig tsconfig.node.json ./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/time-tracker-4-browser/actions/workflows/crowdin-sync.yml) 2. Export translations from Crowdin -``` -ts-node ./script/crowdin/export-translation.ts +```shell +npx tsx --tsconfig tsconfig.node.json ./script/crowdin/export-translation.ts ``` diff --git a/README-zh.md b/README-zh.md index 9b49e936d..da628f466 100644 --- a/README-zh.md +++ b/README-zh.md @@ -91,6 +91,6 @@ 至于最简单粗暴的贡献方式,当然是在 [Firefox](https://addons.mozilla.org/zh-CN/firefox/addon/web%E6%99%82%E9%96%93%E7%B5%B1%E8%A8%88/) / [Chrome](https://chrome.google.com/webstore/detail/%E7%BD%91%E8%B4%B9%E5%BE%88%E8%B4%B5-%E4%B8%8A%E7%BD%91%E6%97%B6%E9%97%B4%E7%BB%9F%E8%AE%A1/dkdhhcbjijekmneelocdllcldcpmekmm) / [Edge](https://microsoftedge.microsoft.com/addons/detail/timer-the-web-time-is-e/fepjgblalcnepokjblgbgmapmlkgfahc) 好评三连啦 XXD -## 致谢 +## ❤️ 谢谢大家! -Timer - Count your browsing time and visits on every sites | Product Hunt +![Thanks To](https://gist.githubusercontent.com/sheepzh/cb33b8b1a1e21b533bf650483b125af5/raw/contributors.svg) diff --git a/README.md b/README.md index acdd1f9dd..afe7cac83 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,6 @@ Most of the software's localization relies on machine translation. You can also It's simple and much helpful! -## Thanks +## ❤️ Thanks To -Timer (relaunch) - Timer is one browser extension to stat site visits and time. | Product Hunt +![Thanks To](https://gist.githubusercontent.com/sheepzh/cb33b8b1a1e21b533bf650483b125af5/raw/contributors.svg) \ No newline at end of file diff --git a/examples/gist/mock-server.ts b/examples/gist/mock-server.ts index 1e0888633..324ebff83 100644 --- a/examples/gist/mock-server.ts +++ b/examples/gist/mock-server.ts @@ -137,7 +137,6 @@ function updateExistingGist(origin: string, gist: Gist, form: GistForm): Gist { return next } - class Handler { private readonly req: IncomingMessage private readonly res: ServerResponse diff --git a/examples/host/home/index.html b/examples/host/home/index.html new file mode 100644 index 000000000..438757300 --- /dev/null +++ b/examples/host/home/index.html @@ -0,0 +1,8 @@ + + + + +
Time Tracker test page — home
+ + + diff --git a/examples/package.json b/examples/package.json index fdd09e3c0..e557ec210 100644 --- a/examples/package.json +++ b/examples/package.json @@ -4,15 +4,15 @@ "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" + "start:gist": "npx tsx gist/mock-server.ts", + "start:notification": "npx tsx 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" + "@types/node": "^26.1.1", + "tsx": "^4.23.0", + "typescript": "7.0.2" } } \ No newline at end of file diff --git a/knip.ts b/knip.ts index fc9fdf434..f66f8b63d 100644 --- a/knip.ts +++ b/knip.ts @@ -13,7 +13,6 @@ const config: KnipConfig = { ], ignoreDependencies: [ "@rstest/coverage-istanbul", - "tsconfig-paths", ], rspack: { config: ["rspack/rspack.{dev,prod,e2e,analyze}*.ts"], diff --git a/package.json b/package.json index c20c16056..14388fd88 100644 --- a/package.json +++ b/package.json @@ -1,8 +1,9 @@ { "name": "tt4b", - "version": "4.3.2", + "version": "4.4.4", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", + "type": "module", "scripts": { "pure-install": "npm install --include=optional --ignore-scripts", "dev": "rspack --config=rspack/rspack.dev.ts --watch", @@ -28,48 +29,47 @@ }, "license": "MIT", "devDependencies": { - "@commitlint/types": "^21.0.1", - "@crowdin/crowdin-api-client": "^1.55.2", + "@commitlint/types": "^21.2.0", + "@crowdin/crowdin-api-client": "^1.56.1", "@emotion/babel-plugin": "^11.13.5", - "@rsdoctor/rspack-plugin": "^1.5.11", - "@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", + "@rsdoctor/rspack-plugin": "^1.6.1", + "@rspack/cli": "^2.1.5", + "@rspack/core": "^2.1.5", + "@rstest/core": "^0.11.3", + "@rstest/coverage-istanbul": "^0.11.3", + "@types/chrome": "0.2.2", "@types/decompress": "^4.2.7", "@types/firefox-webext-browser": "^143.0.0", - "@types/node": "^25.9.1", - "@vue/babel-plugin-jsx": "^2.0.1", + "@types/node": "^26.1.1", + "@vue/babel-plugin-jsx": "^3.0.0", "babel-loader": "^10.1.1", - "commitlint": "^21.0.1", + "commitlint": "^21.2.1", "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.1.1", "jszip": "^3.10.1", - "knip": "^6.14.1", - "postcss": "^8.5.15", + "knip": "^6.29.0", + "postcss": "^8.5.22", "postcss-loader": "^8.2.1", "postcss-rtlcss": "^6.0.0", - "puppeteer": "^25.0.4", - "ts-node": "^10.9.2", - "tsconfig-paths": "^4.2.0", - "typescript": "6.0.3", + "puppeteer": "^25.3.0", + "ts-checker-rspack-plugin": "^1.5.2", + "tsx": "^4.23.1", + "typescript": "7.0.2", "unplugin-element-plus": "^0.11.2" }, "dependencies": { "@element-plus/icons-vue": "^2.3.2", "@emotion/css": "^11.13.5", "echarts": "^6.1.0", - "element-plus": "2.14.0", + "element-plus": "2.14.3", "hash.js": "^1.1.7", "qrcode-generator": "^2.0.4", - "typescript-guard": "0.2.4", - "vue": "^3.5.34", - "vue-router": "^5.0.7" + "typescript-guard": "0.2.6", + "vue": "^3.5.40", + "vue-router": "^5.2.0" }, "engines": { "node": ">=22" diff --git a/rspack/rspack.common.ts b/rspack/rspack.common.ts index 6f2bde349..792beaf2b 100644 --- a/rspack/rspack.common.ts +++ b/rspack/rspack.common.ts @@ -5,6 +5,7 @@ import { import { default as VueBabelPluginJsx } from "@vue/babel-plugin-jsx" import path, { join } from "path" import postcssRTLCSS from 'postcss-rtlcss' +import { TsCheckerRspackPlugin } from "ts-checker-rspack-plugin" import ElementPlus from 'unplugin-element-plus/rspack' import i18nChrome from "../src/i18n/chrome" import { compilerOptions } from "../tsconfig.json" @@ -207,6 +208,15 @@ const generateOption = ({ outputPath, manifest, mode }: Option) => { const plugins = [ ...generateJsonPlugins, ElementPlus({}), + new TsCheckerRspackPlugin({ + issue: { + exclude: [ + { file: 'test/**' }, + { file: 'test-e2e/**' }, + { file: 'script/**' }, + ], + }, + }), new GenerateJsonPlugin(MANIFEST_JSON_NAME, manifest), new ImportCheckerPlugin(), // copy static resources diff --git a/rspack/rspack.dev.ts b/rspack/rspack.dev.ts index 5cf59f91e..18ce606d4 100644 --- a/rspack/rspack.dev.ts +++ b/rspack/rspack.dev.ts @@ -1,4 +1,3 @@ -import ForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin" import path from "path" import manifest from "../src/manifest" import generateOption from "./rspack.common" @@ -11,27 +10,4 @@ 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.e2e.ts b/rspack/rspack.e2e.ts index 633fbfa1d..2b83682b9 100644 --- a/rspack/rspack.e2e.ts +++ b/rspack/rspack.e2e.ts @@ -4,6 +4,10 @@ import { E2E_OUTPUT_PATH } from "./constant" import generateOption from "./rspack.common" manifest.name = E2E_NAME +// Grant all permissions as required for e2e testing +const permissions = manifest.permissions ??= [] +permissions.push(...manifest.optional_permissions ?? []) +manifest.optional_permissions = [] const options = generateOption({ outputPath: E2E_OUTPUT_PATH, diff --git a/rspack/rspack.prod.firefox.ts b/rspack/rspack.prod.firefox.ts index b76b4c28f..85d7faf9f 100644 --- a/rspack/rspack.prod.firefox.ts +++ b/rspack/rspack.prod.firefox.ts @@ -1,11 +1,10 @@ import path from "path" +import { name, version } from '../package.json' 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')) - const outputPath = path.resolve(__dirname, '..', 'dist_prod_firefox') const marketPkgPath = path.resolve(__dirname, '..', 'market_packages') diff --git a/rspack/rspack.prod.safari.ts b/rspack/rspack.prod.safari.ts index e4f53201e..3c9ee4cd3 100644 --- a/rspack/rspack.prod.safari.ts +++ b/rspack/rspack.prod.safari.ts @@ -1,10 +1,9 @@ import path from "path" +import { name, version } from '../package.json' import manifest from "../src/manifest" import { FileManagerPlugin } from "./plugins/file-manager" import generateOption from "./rspack.common" -const { name, version } = require(path.join(__dirname, '..', 'package.json')) - const outputPath = path.join(__dirname, '..', 'dist_prod_safari') const normalZipFilePath = path.resolve(__dirname, '..', 'market_packages', `${name}-${version}-safari.zip`) diff --git a/rspack/rspack.prod.ts b/rspack/rspack.prod.ts index 6ef6db0c1..660492af4 100644 --- a/rspack/rspack.prod.ts +++ b/rspack/rspack.prod.ts @@ -1,11 +1,10 @@ import path from "path" +import { name, version } from "../package.json" import manifest from "../src/manifest" import { FileManagerPlugin } from "./plugins/file-manager" import optionGenerator from "./rspack.common" import { enhancePluginWith } from './util' -const { name, version } = require(path.join(__dirname, '..', 'package.json')) - const outputPath = path.resolve(__dirname, '..', 'dist_prod') const marketPkgPath = path.resolve(__dirname, '..', 'market_packages') diff --git a/rspack/util.ts b/rspack/util.ts index 6df6c9490..f05e1b3fa 100644 --- a/rspack/util.ts +++ b/rspack/util.ts @@ -1,6 +1,6 @@ -import type { RspackOptions, RspackPluginInstance } from '@rspack/core' +import type { Plugin, RspackOptions } from '@rspack/core' -export function enhancePluginWith(option: RspackOptions, ...toPush: RspackPluginInstance[]) { +export function enhancePluginWith(option: RspackOptions, ...toPush: Plugin[]) { const { plugins = [] } = option plugins.push(...toPush) option.plugins = plugins diff --git a/script/contributor.ts b/script/contributor.ts new file mode 100644 index 000000000..bc081c553 --- /dev/null +++ b/script/contributor.ts @@ -0,0 +1,232 @@ +import { createGist, findTarget, updateGist, type FileForm, type GistForm } from '@api/gist' +import { writeFileSync } from 'fs' +import { createArrayGuard, createObjectGuard, isInt, isString } from 'typescript-guard' +import { getClientFromEnv, TopMember } from './crowdin/client' +import { validateTokenFromEnv } from './util/gist' +import { exitWith } from './util/process' + +type GithubContributor = { + login: string + avatar_url: string + contributions: number + type: string +} + +const isGithubContributors = createArrayGuard( + createObjectGuard({ + login: isString, + avatar_url: isString, + contributions: isInt, + type: isString, + }) +) + +async function fetchGithubContributors(token: string): Promise { + const result: GithubContributor[] = [] + let page = 1 + while (true) { + const res = await fetch( + `https://api.github.com/repos/sheepzh/time-tracker-4-browser/contributors?per_page=100&page=${page}`, + { headers: { Accept: 'application/vnd.github+json', Authorization: `token ${token}` } } + ) + if (!res.ok) throw new Error(`GitHub contributors API error: ${res.status}`) + const data = await res.json() + if (!isGithubContributors(data)) exitWith(`Invalid data from GitHub API: ${JSON.stringify(data)}`) + if (!data.length) break + result.push(...data) + page++ + } + return result +} + +const AVATAR_R = 20 +const CARD_W = 64 +const CARD_H = 76 +const COLS = 12 +const H_PADDING = 20 +const V_PADDING = 16 +const SECTION_GAP = 28 +const SECTION_LABEL_H = 24 + +async function getBase64Avatar(url: string): Promise { + try { + const response = await fetch(url) + if (!response.ok) throw new Error(`HTTP error! status: ${response.status}`) + + const arrayBuffer = await response.arrayBuffer() + const buffer = Buffer.from(arrayBuffer) + + const contentType = response.headers.get('content-type') ?? 'image/jpeg' + const base64 = buffer.toString('base64') + + return `data:${contentType};base64,${base64}` + } catch (error) { + // Fallback to original URL if error occurs + console.error(`[Avatar Fetch Failed] ${url}:`, error) + return url + } +} + +type Contributor = { + name: string + avatarUrl: string + sub?: string +} + +function renderCard(x: number, y: number, contributor: Contributor): string { + const { avatarUrl, name, sub = '' } = contributor + const cx = x + CARD_W / 2 + const ay = y + AVATAR_R + 4 + + const shortName = name.length > 8 ? name.slice(0, 7) + '…' : name + const shortSub = sub.length > 10 ? sub.slice(0, 9) + '…' : sub + const safeAvatarUrl = avatarUrl.replace(/&/g, '&') + + return ` + + + + + + ${shortName} + + + ${shortSub} + + ` +} + +function renderSection(label: string, items: Contributor[], yOffset: number): { svg: string; height: number } { + const rows = Math.ceil(items.length / COLS) + const height = SECTION_LABEL_H + rows * CARD_H + let svg = ` + + ${label} + + ` + + items.forEach((item, i) => { + const x = H_PADDING + (i % COLS) * CARD_W + const y = yOffset + SECTION_LABEL_H + Math.floor(i / COLS) * CARD_H + svg += renderCard(x, y, item) + }) + return { svg, height } +} + +async function renderSvg(coders: Contributor[], translators: Contributor[]): Promise { + const totalWidth = H_PADDING * 2 + COLS * CARD_W + let y = V_PADDING + const sections: string[] = [] + + // Code Contributors + for (const coder of coders) { + coder.avatarUrl = await getBase64Avatar(coder.avatarUrl) + } + const { height: coderHeight, svg: coderSvg } = renderSection(`Code Contributors [${coders.length}]`, coders, y) + sections.push(coderSvg) + y += coderHeight + SECTION_GAP + + // Translation Contributors + for (const translator of translators) { + translator.avatarUrl = await getBase64Avatar(translator.avatarUrl) + } + const cdSection = renderSection(`Translation Contributors [${translators.length}]`, translators, y) + sections.push(cdSection.svg) + y += cdSection.height + V_PADDING + + const totalHeight = y + + return ` + + + ${sections.join('\n')} + + ` +} + +const GIST_DESC = 'Timer contributor list, auto-generated' +const GIST_FILENAME = 'contributors.svg' + +async function uploadToGist(token: string, svg: string): Promise { + const files: Record = { + [GIST_FILENAME]: { filename: GIST_FILENAME, content: svg }, + } + const form: GistForm = { public: true, description: GIST_DESC, files } + const existing = await findTarget(token, g => g.description === GIST_DESC) + if (existing) { + await updateGist(token, existing.id, form) + console.log('Updated gist:', existing.id) + } else { + const created = await createGist(token, form) + console.log('Created gist:', created.id) + } +} + +async function fetchCoders(token: string): Promise { + console.log('Fetching GitHub contributors...') + const github = await fetchGithubContributors(token) + const users = github.filter(c => c.type === 'User') + console.log(`GitHub: ${users.length} users (${github.length} contributors)`) + + return users.map(g => ({ + name: g.login, + avatarUrl: g.avatar_url, + })) +} + +const crowdinScore = ({ approved, translated }: TopMember): number => translated + approved * .4 + +const LANGUAGE_MAP: Record = { + "Chinese Simplified": "简体中文", + "Chinese Traditional": "正體中文", +} + +const fetchTranslators = async (): Promise => { + console.log('Fetching Crowdin contributors...') + const members = await getClientFromEnv().fetchTopMembers() + console.log(`Crowdin: ${members.length} contributors`) + + return members + .filter(c => c.username !== 'sheepzh') + .sort((a, b) => crowdinScore(b) - crowdinScore(a)) + .map(c => ({ + name: c.username, + avatarUrl: c.avatarUrl, + sub: c.languages.map(l => LANGUAGE_MAP[l] ?? l).slice(0, 1).join(', ') + })) +} + +async function main(): Promise { + const gistToken = validateTokenFromEnv() + + const coders = await fetchCoders(gistToken) + const translators = await fetchTranslators() + + console.log('Rendering SVG...') + const svg = await renderSvg(coders, translators) + writeFileSync('contributors.svg', svg, 'utf-8') + + console.log('Uploading to Gist...') + await uploadToGist(gistToken, svg) + console.log('Done!') +} + +main() + diff --git a/script/crowdin/client.ts b/script/crowdin/client.ts index 80fc7474f..f0bf9d3cb 100644 --- a/script/crowdin/client.ts +++ b/script/crowdin/client.ts @@ -1,4 +1,5 @@ -import Crowdin, { +import { + Client, type Credentials, type Pagination, type PatchRequest, @@ -8,12 +9,52 @@ import Crowdin, { type StringTranslationsModel, type UploadStorageModel, } from '@crowdin/crowdin-api-client' +import { createArrayGuard, createObjectGuard, isInt, isString } from 'typescript-guard' +import { exitWith } from '../util/process' import { ALL_CROWDIN_LANGUAGES, type CrowdinLanguage, type Dir, type ItemSet } from './common' const PROJECT_ID = 516822 +export type TopMember = { + username: string + avatarUrl: string + translated: number + approved: number + languages: string[] +} + const MAIN_BRANCH_NAME = 'main' +type TopMemberRow = { + user: { + username: string + avatarUrl: string + } + languages: { name: string }[] + translated: number + approved: number +} + +const isTopMemberRow = createObjectGuard({ + user: createObjectGuard({ + username: isString, + avatarUrl: isString, + }), + languages: createArrayGuard(createObjectGuard({ + name: isString, + })), + translated: isInt, + approved: isInt, +}) + +type TopMemberData = { + data: TopMemberRow[] +} + +const isTopMemberData = createObjectGuard({ + data: createArrayGuard(isTopMemberRow) +}) + /** * The iterator of response */ @@ -105,13 +146,13 @@ type TranslationKey = { * The wrapper of client with auth */ export class CrowdinClient { - crowdin: Crowdin + crowdin: Client constructor(token: string) { const credentials: Credentials = { token: token } - this.crowdin = new Crowdin(credentials) + this.crowdin = new Client(credentials) console.info("Initialized client successfully") } @@ -273,25 +314,67 @@ export class CrowdinClient { skipUntranslatedStrings: true, }) const buildId = buildRes.data.id - const maxRetries = 120 - let retryCount = 0 - while (true) { - if (retryCount >= maxRetries) { - throw new Error(`Build timed out after ${maxRetries} retries: buildId=${buildId}`) - } + await this.#retryWith(async () => { 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 === 'finished') return true 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 } + + async fetchTopMembers(): Promise { + const reportUrl = await this.#buildMemberReport() + const result = await fetch(reportUrl) + const json = await result.json() + if (!isTopMemberData(json)) { + exitWith(`Unexpected report data format: ${JSON.stringify(json)}`) + } + return json.data.map(r => ({ + username: r.user.username, + avatarUrl: r.user.avatarUrl, + translated: r.translated, + approved: r.approved, + languages: r.languages.map(l => l.name), + })) + } + + async #buildMemberReport(): Promise { + const { data: { identifier: reportId } } = await this.crowdin.reportsApi.generateReport(PROJECT_ID, { + name: 'top-members', + schema: { unit: 'words', format: 'json' }, + }) + + await this.#retryWith(async () => { + const status = await this.crowdin.reportsApi.checkReportStatus(PROJECT_ID, reportId) + const { status: s, progress } = status.data + console.log(`Crowdin report: ${s} ${progress}%`) + if (s === 'finished') return true + if (s === 'canceled' || s === 'failed') throw new Error(`Report ${s}`) + }) + + const report = await this.crowdin.reportsApi.downloadReport(PROJECT_ID, reportId) + return report.data.url + } + + async #retryWith(predicate: () => Promise) { + const maxRetries = 120 + let retryCount = 0 + while (true) { + if (retryCount >= maxRetries) { + throw new Error(`Build timed out after ${maxRetries} retries`) + } + const pass = await predicate() + if (pass) break + retryCount++ + await new Promise(r => setTimeout(r, 1000)) + } + } } /** diff --git a/script/crowdin/common.ts b/script/crowdin/common.ts index 090f1baa4..2af4d48f5 100644 --- a/script/crowdin/common.ts +++ b/script/crowdin/common.ts @@ -1,10 +1,11 @@ import { type SourceFilesModel } from '@crowdin/crowdin-api-client' import fs from 'fs' import path from 'path' +import { fileURLToPath } from 'url' import { exitWith } from '../util/process' import { type CrowdinClient } from './client' -export const ALL_DIRS = ['app', 'common', 'popup', 'side', 'cs'] as const +export const ALL_DIRS = ['app', 'common', 'popup', 'side', 'cs', 'bg'] as const /** * The directory of messages @@ -83,7 +84,8 @@ export function isIgnored(dir: Dir, fileName: string) { return !!IGNORED_FILE[dir]?.includes(fileName) } -const MSG_BASE = path.join(__dirname, '..', '..', 'src', 'i18n', 'message') +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)) +const MSG_BASE = path.join(SCRIPT_DIR, '..', '..', 'src', 'i18n', 'message') export const RSC_FILE_SUFFIX = "-resource.json" /** @@ -123,8 +125,11 @@ export async function mergeMessage( ): Promise { const dirPath = path.join(MSG_BASE, dir) const filePath = path.join(dirPath, filename) - const existMessages = (await import(`@i18n/message/${dir}/${filename}`))?.default as Messages> - if (!existMessages) { + let existMessages: Messages> + try { + const module = await import(`@i18n/message/${dir}/${filename}`) + existMessages = module.default + } catch { logError(`Failed to find local code: dir=${dir}, filename=${filename}`) return } @@ -220,5 +225,3 @@ export async function checkMainBranch(client: CrowdinClient): Promise s.id)) } - async function processByDir(client: CrowdinClient, dir: Dir, branch: SourceFilesModel.Branch): Promise { // 1. init directory const dirKey: NameKey = { name: dir, branchId: branch.id } diff --git a/script/psl.ts b/script/psl.ts index fc33d526f..792947732 100644 --- a/script/psl.ts +++ b/script/psl.ts @@ -1,13 +1,15 @@ /** * Build psl tree */ -import { type PslTree } from '@/background/psl' import { fetchGet } from '@api/http' +import { type PslTree } from '@bg/psl' import { writeFileSync } from 'fs' import path from 'path' +import { fileURLToPath } from 'url' const LIST_URL = "https://publicsuffix.org/list/effective_tld_names.dat" -const JSON_PATH = path.join(__dirname, "..", "src", "background", "psl", "rules.json") +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)) +const JSON_PATH = path.join(SCRIPT_DIR, "..", "src", "background", "psl", "rules.json") const downloadList = async (): Promise => { const response = await fetchGet(LIST_URL) diff --git a/script/setup-e2e.sh b/script/setup-e2e.sh index 09b1da86e..519972bca 100755 --- a/script/setup-e2e.sh +++ b/script/setup-e2e.sh @@ -126,7 +126,7 @@ install_e2e_dependencies() { 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" + log_error "Failed to download browser for puppeteer. Retry manually: npx puppeteer browsers install" exit 1 fi else diff --git a/script/user-chart/add.ts b/script/user-chart/add.ts index b7c632fc8..a276ccb06 100644 --- a/script/user-chart/add.ts +++ b/script/user-chart/add.ts @@ -8,8 +8,9 @@ import { } from "@api/gist" import { CHROME_ID } from "@util/constant/meta" import fs from "fs" +import { validateTokenFromEnv } from '../util/gist' import { exitWith } from "../util/process" -import { type Browser, descriptionOf, filenameOf, getExistGist, type UserCount, validateTokenFromEnv } from "./common" +import { type Browser, descriptionOf, filenameOf, getExistGist, type UserCount } from "./common" type AutoMode = { mode: 'auto' diff --git a/script/user-chart/common.ts b/script/user-chart/common.ts index a3a30cfc5..62fc0587e 100644 --- a/script/user-chart/common.ts +++ b/script/user-chart/common.ts @@ -1,5 +1,4 @@ import { findTarget, type Gist } from "@api/gist" -import { exitWith } from '../util/process' export type Browser = | 'chrome' @@ -8,17 +7,6 @@ export type Browser = export type UserCount = Record -/** - * Validate the token from environment variables - */ -export function validateTokenFromEnv(): string { - const token = process.env.TIMER_USER_COUNT_GIST_TOKEN - if (!token) { - exitWith("Can't find token from env variable [TIMER_USER_COUNT_GIST_TOKEN]") - } - return token! -} - /** * Calculate the gist description of target browser */ diff --git a/script/user-chart/render.ts b/script/user-chart/render.ts index 709db982c..be9dcf5b8 100644 --- a/script/user-chart/render.ts +++ b/script/user-chart/render.ts @@ -1,34 +1,55 @@ import { - createGist, findTarget, getJsonFileContent, updateGist, - type FileForm, type GistForm, + createGist, findTarget, getJsonFileContent, updateGist, type FileForm, type GistForm, } from "@api/gist" import { - init, - type ComposeOption, type EChartsType, type GridComponentOption, type LineSeriesOption, type TitleComponentOption, + init, type ComposeOption, type GridComponentOption, type LineSeriesOption, type TitleComponentOption } from "echarts" import { writeFileSync } from "fs" import { exit } from 'process' -import { filenameOf, getExistGist, validateTokenFromEnv, type Browser, type UserCount } from "./common" +import { validateTokenFromEnv } from '../util/gist' +import { filenameOf, getExistGist, type Browser, type UserCount } from "./common" type EcOption = ComposeOption< | LineSeriesOption | TitleComponentOption - | GridComponentOption> -const ALL_BROWSERS: Browser[] = ['firefox', 'chrome', 'edge'] + | GridComponentOption +> +const ALL_BROWSERS: Browser[] = ['edge', 'chrome', 'firefox'] -type OriginData = { - [browser in Browser]: UserCount -} +type OriginData = Record type ChartData = { xAxis: string[] - yAxises: { - [browser in Browser]: number[] - } + yAxises: Record } const VALID_DATE_RE = /^\d{4}-\d{2}-\d{2}$/ +const calcAvg = (arr: number[]): number => { + const l = arr.length + if (l === 0) return 0 + return arr.reduce((acc, val) => acc + val, 0) / l +} + +const computeGrowth = (totals: number[], dayCount: number): string => { + const current = calcAvg(totals.slice(-dayCount)) + const base = calcAvg(totals.slice(-2 * dayCount, -dayCount)) + if (!base) return '0' + const growth = ((current - base) / base) * 100 + return growth > 0 ? `+${growth.toFixed(2)}` : growth.toFixed(2) +} + +const computeSummary = ({ xAxis, yAxises }: ChartData) => { + const totals = xAxis.map((_, idx) => ALL_BROWSERS.reduce((sum, browser) => sum + (yAxises[browser][idx] ?? 0), 0)) + const total = totals[totals.length - 1] ?? 0 + const latest = xAxis[xAxis.length - 1] + if (!latest) return { total, yoy: '0', month: '0' } + + const yoy = computeGrowth(totals, 365) + const month = computeGrowth(totals, 30) + return { total, yoy, month } +} + function preProcess(originData: OriginData): ChartData { // 1. sort dates const dateSet = new Set() @@ -36,7 +57,7 @@ function preProcess(originData: OriginData): ChartData { let allDates = Array.from(dateSet).filter(d => VALID_DATE_RE.test(d)).sort() // 2. smooth the count - const ctx: { [browser in Browser]: SmoothContext } = { + const ctx: Record = { chrome: new SmoothContext(), firefox: new SmoothContext(), edge: new SmoothContext(), @@ -73,7 +94,7 @@ class SmoothContext { if (newVal) { this.smooth(newVal) } else { - this.increaseStep() + this.step += 1 } } @@ -82,42 +103,36 @@ class SmoothContext { return } const unitVal = (currentValue - this.lastVal) / (this.step + 1) - Object.keys(Array.from(new Array(this.step))) - .map(key => parseInt(key)) - .map(i => Math.floor(unitVal * (i + 1) + this.lastVal)) - .forEach(smoothedVal => this.data.push(smoothedVal)) + + const smoothedValues = Array.from({ length: this.step }, (_, i) => Math.floor(unitVal * (i + 1) + this.lastVal)) + this.data.push(...smoothedValues) this.data.push(currentValue) // Reset this.lastVal = currentValue this.step = 0 } - increaseStep(): void { - this.step += 1 - } - end(): number[] { - Object.keys(Array.from(new Array(this.step))) - .forEach(() => this.data.push(this.lastVal)) + Array.from({ length: this.step }).forEach(() => this.data.push(this.lastVal)) return this.data } } function render2Svg(chartData: ChartData): string { const { xAxis, yAxises } = chartData - const chart: EChartsType = init(null, null, { + const chart = init(null, null, { renderer: 'svg', ssr: true, width: 960, height: 640 }) - const totalUserCount = Object.values(yAxises) - .map(v => v[v.length - 1] || 0) - .reduce((a, b) => a + b) + const { total, yoy, month } = computeSummary(chartData) + const ds = xAxis[0] + const de = xAxis[xAxis.length - 1] const option: EcOption = { title: { - text: 'Total Active User Count', - subtext: `${xAxis[0]} to ${xAxis[xAxis.length - 1]} | currently ${totalUserCount} ` + text: 'Weekly Active Users', + subtext: `${ds} to ${de} | currently ${total} | YoY ${yoy}% | MoM ${month}%` }, legend: { data: ALL_BROWSERS }, grid: { diff --git a/script/util/gist.ts b/script/util/gist.ts new file mode 100644 index 000000000..114549d8b --- /dev/null +++ b/script/util/gist.ts @@ -0,0 +1,12 @@ +import { exitWith } from './process' + +/** + * Validate the token from environment variables + */ +export function validateTokenFromEnv(): string { + const token = process.env.TIMER_USER_COUNT_GIST_TOKEN + if (!token) { + exitWith("Can't find token from env variable [TIMER_USER_COUNT_GIST_TOKEN]") + } + return token +} \ No newline at end of file diff --git a/script/zip.sh b/script/zip.sh deleted file mode 100755 index b36efda56..000000000 --- a/script/zip.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/bin/bash - -FOLDER=$( - cd "$(dirname "$0")/.." - pwd -) -TARGET_PATH="${FOLDER}/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} ./ diff --git a/src/api/chrome/i18n.ts b/src/api/chrome/i18n.ts index 0a7aee6c3..7610c6c60 100644 --- a/src/api/chrome/i18n.ts +++ b/src/api/chrome/i18n.ts @@ -1,10 +1,5 @@ // Bug of chrome: // chrome.i18n.getUILanguage may not work in background export function getUILanguage(): string { - return chrome?.i18n?.getUILanguage?.() + return globalThis.chrome?.i18n?.getUILanguage?.() } - -// Bug of chrome: -// chrome.i18n.getMessage may not work in background -// @see https://stackoverflow.com/questions/6089707/calling-chrome-i18n-getmessage-from-a-content-script -export const getMessage: (key: string) => string = chrome?.i18n?.getMessage diff --git a/src/api/chrome/notifications.ts b/src/api/chrome/notifications.ts index 598d04697..9002e3c5b 100644 --- a/src/api/chrome/notifications.ts +++ b/src/api/chrome/notifications.ts @@ -1,17 +1,18 @@ import { IS_MV3 } from "@util/constant/environment" import { handleError } from "./common" +import { getIconUrl } from './runtime' -type NotificationTopic = 'time' +type Topic = 'time' | 'focus' +type ChromeOptions = chrome.notifications.NotificationCreateOptions +type Options = Omit -export async function createNotification( - topic: NotificationTopic, - options: MakeRequired -): Promise { +export async function createNotification(topic: Topic, options: Options): Promise { + const param = { ...options, iconUrl: getIconUrl() } if (IS_MV3) { - return await chrome.notifications.create(topic, options) + return await chrome.notifications.create(topic, param) } else { return new Promise((resolve, reject) => { - chrome.notifications.create(topic, options, (id: string) => { + chrome.notifications.create(topic, param, (id: string) => { const error = handleError('createNotification') if (error) { reject(new Error(error)) diff --git a/src/api/chrome/tab.ts b/src/api/chrome/tab.ts index 79c9e6b27..917e4f7b3 100644 --- a/src/api/chrome/tab.ts +++ b/src/api/chrome/tab.ts @@ -56,7 +56,8 @@ export async function createTabAfterCurrent(url: string, currentTab?: ChromeTab) } export function listTabs(query?: chrome.tabs.QueryInfo): Promise { - query = query || {} + query ??= {} + if (IS_MV3) return chrome.tabs.query(query) return new Promise(resolve => chrome.tabs.query(query, tabs => { handleError("listTabs") resolve(tabs || []) @@ -91,7 +92,7 @@ export async function trySendMsg2Tab( try { 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) + console.info(`Errored to send message to tab: tabId=${tabId}, code=${code}, data=${JSON.stringify(data)}`, e) return Promise.resolve(undefined) } } diff --git a/src/api/crowdin.ts b/src/api/crowdin.ts index f70f76586..dab5b6b93 100644 --- a/src/api/crowdin.ts +++ b/src/api/crowdin.ts @@ -58,7 +58,7 @@ export async function getTranslationStatus(): Promise { export async function getMembers(): Promise { const result: MemberInfo[] = [] - const limit = 10 + const limit = 500 let offset = 0 while (true) { const url = `https://api.crowdin.com/api/v2/projects/${CROWDIN_PROJECT_ID}/members?limit=${limit}&offset=${offset}` diff --git a/src/api/sw/focus.ts b/src/api/sw/focus.ts new file mode 100644 index 000000000..8c834103f --- /dev/null +++ b/src/api/sw/focus.ts @@ -0,0 +1,15 @@ +import { sendMsg2Runtime } from './common' + +export const listFocusPresets = () => sendMsg2Runtime('focus.allPresets') + +export const getFocusPreset = (id: number) => sendMsg2Runtime('focus.getPreset', id) + +export const addFocusPreset = (preset: Omit) => sendMsg2Runtime('focus.addPreset', preset) + +export const saveFocusPreset = (preset: tt4b.focus.Preset) => sendMsg2Runtime('focus.savePreset', preset) + +export const deleteFocusPreset = (id: number) => sendMsg2Runtime('focus.deletePreset', id) + +export const getCurrentSession = () => sendMsg2Runtime('focus.current') + +export const focusAction = (request: tt4b.focus.ActionRequest) => sendMsg2Runtime('focus.action', request) diff --git a/src/api/sw/site.ts b/src/api/sw/site.ts index 0109e51ae..6e5b3b947 100644 --- a/src/api/sw/site.ts +++ b/src/api/sw/site.ts @@ -1,5 +1,7 @@ import { sendMsg2Runtime } from "./common" +export const getCurrentSite = () => sendMsg2Runtime('site.current') + export const listSites = (param?: tt4b.site.Query) => sendMsg2Runtime('site.list', param) export function getSitePage(param?: tt4b.site.Query, page?: tt4b.common.PageQuery) { @@ -12,13 +14,9 @@ export function changeSitesCate(cateId: number | undefined, ...keys: tt4b.site.S return sendMsg2Runtime('site.changeCate', { keys, cateId }) } -export const deleteSiteIcon = (key: tt4b.site.SiteKey) => sendMsg2Runtime('site.deleteIcon', key) +export const addSite = (siteInfo: tt4b.site.SiteInfo) => sendMsg2Runtime('site.add', siteInfo) -export async function changeSiteAlias(key: tt4b.site.SiteKey, alias: string | undefined): Promise { - const trimmed = alias?.trim() || undefined - await sendMsg2Runtime('site.changeAlias', { key, alias: trimmed }) - return trimmed -} +export const modifySite = (param: tt4b.site.ModifyParam) => sendMsg2Runtime('site.modify', param) export const fillInitialAlias = (keys: tt4b.site.SiteKey[]) => sendMsg2Runtime('site.fillAlias', keys) @@ -28,4 +26,4 @@ export function changeSiteRun(key: tt4b.site.SiteKey, enabled: boolean) { return sendMsg2Runtime('site.changeRun', { key, enabled }) } -export const searchSite = (query?: string) => sendMsg2Runtime('site.search', query) +export const detectSites = () => sendMsg2Runtime('site.detect') diff --git a/src/api/sw/whitelist.ts b/src/api/sw/whitelist.ts index 14e3744df..3218b7198 100644 --- a/src/api/sw/whitelist.ts +++ b/src/api/sw/whitelist.ts @@ -5,3 +5,5 @@ export const listWhitelist = () => sendMsg2Runtime('whitelist.all') export const addWhitelist = (white: string) => sendMsg2Runtime('whitelist.add', white) export const deleteWhitelist = (white: string) => sendMsg2Runtime('whitelist.delete', white) + +export const saveWhitelist = (whitelist: string[]) => sendMsg2Runtime('whitelist.save', whitelist) \ No newline at end of file diff --git a/src/api/web-dav.ts b/src/api/web-dav.ts index d8cb042d3..4ce66530e 100644 --- a/src/api/web-dav.ts +++ b/src/api/web-dav.ts @@ -34,7 +34,7 @@ export async function judgeDirExist(context: WebDAVContext, dirPath: string): Pr const method = 'PROPFIND' headers.append('Accept', 'text/plain,application/xml') headers.append('Depth', '1') - const response = await fetch(url, { method, headers }) + const response = await fetch(url, { method, headers, credentials: 'omit' }) const status = response?.status if (status == 207) { return true @@ -62,7 +62,7 @@ export async function makeDirs(context: WebDAVContext, dirPath: string) { if (!exists) { const url = `${endpoint}/${currentPath}` const headers = authHeaders(auth) - const response = await fetch(url, { method: 'MKCOL', headers }) + const response = await fetch(url, { method: 'MKCOL', headers, credentials: 'omit' }) handleWriteResponse(response) } } @@ -72,12 +72,12 @@ export async function deleteDir(context: WebDAVContext, dirPath: string) { const { auth, endpoint } = context || {} const url = `${endpoint}/${dirPath}` const headers = authHeaders(auth) - const response = await fetchDelete(url, { headers }) + const response = await fetchDelete(url, { headers, credentials: 'omit' }) const status = response.status if (status === 403) { throw new Error("Unauthorized to delete directory") } - if (status !== 201 && status !== 200) { + if (![201, 200, 204].includes(status)) { throw new Error("Failed to delete directory: " + status) } } @@ -87,7 +87,7 @@ export async function writeFile(context: WebDAVContext, filePath: string, conten const headers = authHeaders(auth) headers.set("Content-Type", "application/octet-stream") const url = `${endpoint}/${filePath}` - const response = await fetch(url, { headers, method: 'put', body: content }) + const response = await fetch(url, { headers, method: 'put', body: content, credentials: 'omit' }) handleWriteResponse(response) } @@ -96,7 +96,7 @@ function handleWriteResponse(response: Response) { if (status === 403) { throw new Error("Unauthorized to write file or create directory") } - if (status !== 201 && status !== 200) { + if (![201, 200, 204].includes(status)) { throw new Error("Failed to write file or create directory: " + status) } } @@ -106,7 +106,7 @@ export async function readFile(context: WebDAVContext, filePath: string): Promis const headers = authHeaders(auth) const url = `${endpoint}/${filePath}` try { - const response = await fetchGet(url, { headers }) + const response = await fetchGet(url, { headers, credentials: 'omit' }) const status = response?.status if (status === 200) { return response.text() diff --git a/src/background/action.ts b/src/background/action.ts index 5ea65c59c..0cc2bb154 100644 --- a/src/background/action.ts +++ b/src/background/action.ts @@ -4,18 +4,14 @@ * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ -import { APP_OPTION_ROUTE, APP_REPORT_ROUTE } from "@/shared/route" +import { APP_OPTION_ROUTE, APP_RECORD_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_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 { CHANGE_LOG_PAGE, SOURCE_CODE_PAGE, getAppPageUrl, getGuidePageUrl } from "@util/constant/url" +import { t } from './i18n' const APP_PAGE_URL = getAppPageUrl() @@ -38,42 +34,35 @@ function titleOf(prefixEmoji: string, title: string) { const allFunctionProps: ChromeContextMenuCreateProps = { id: getRuntimeId() + '_timer_menu_item_app_link', - title: titleOf('🏷️', t2Chrome(msg => msg.base.allFunction)), + title: titleOf('🏷️', t(msg => msg.base.allFunction)), onclick: () => createTab(APP_PAGE_URL), ...baseProps } const optionPageProps: ChromeContextMenuCreateProps = { id: getRuntimeId() + '_timer_menu_item_option_link', - title: titleOf('🥰', t2Chrome(msg => msg.base.option)), + title: titleOf('🥰', t(msg => msg.base.option)), onclick: () => createTab(getAppPageUrl(APP_OPTION_ROUTE)), ...baseProps } const repoPageProps: ChromeContextMenuCreateProps = { id: getRuntimeId() + '_timer_menu_item_repo_link', - title: titleOf('🍻', t2Chrome(msg => msg.base.sourceCode)), + title: titleOf('🍻', t(msg => msg.base.sourceCode)), onclick: () => createTab(SOURCE_CODE_PAGE), ...baseProps } -const feedbackPageProps: ChromeContextMenuCreateProps = { - id: getRuntimeId() + '_timer_menu_item_feedback_link', - title: titleOf('😿', t2Chrome(msg => msg.contextMenus.feedbackPage)), - onclick: () => createTab(locale === 'zh_CN' ? TU_CAO_PAGE : GITHUB_ISSUE_ADD), - ...baseProps -} - const guidePageProps: ChromeContextMenuCreateProps = { id: getRuntimeId() + '_timer_menu_item_guide_link', - title: titleOf('📖', t2Chrome(msg => msg.base.guidePage)), + title: titleOf('📖', t(msg => msg.base.guidePage)), onclick: () => createTab(getGuidePageUrl()), ...baseProps } const changeLogProps: ChromeContextMenuCreateProps = { id: getRuntimeId() + '_timer_menu_item_changelog', - title: titleOf('📆', t2Chrome(msg => msg.base.changeLog)), + title: titleOf('📆', t(msg => msg.base.changeLog)), onclick: () => createTab(CHANGE_LOG_PAGE), ...baseProps } @@ -82,13 +71,12 @@ export function initBrowserAction() { createContextMenu(allFunctionProps) createContextMenu(optionPageProps) createContextMenu(repoPageProps) - createContextMenu(feedbackPageProps) createContextMenu(guidePageProps) createContextMenu(changeLogProps) if (IS_ANDROID) { // Forbidden popup page - onIconClick(() => createTab({ url: getAppPageUrl(APP_REPORT_ROUTE) })) + onIconClick(() => createTab({ url: getAppPageUrl(APP_RECORD_ROUTE) })) } } diff --git a/src/background/alarm-manager.ts b/src/background/alarm-manager.ts index 7dd45cf8c..d33a1e0bf 100644 --- a/src/background/alarm-manager.ts +++ b/src/background/alarm-manager.ts @@ -7,7 +7,7 @@ type _AlarmConfig = { when?: () => number | null, } -type _Handler = (alarm: ChromeAlarm) => void +type _Handler = (alarm: ChromeAlarm) => Awaitable const ALARM_PREFIX = 'timer-alarm-' + getRuntimeId() + '-' const ALARM_PREFIX_LENGTH = ALARM_PREFIX.length @@ -46,7 +46,7 @@ class AlarmManager { if (!config) return // Handle alarm event try { - config.handler(alarm) + await config.handler(alarm) } catch (e) { console.info("Failed to handle alarm event", e) } finally { diff --git a/src/background/badge-manager.ts b/src/background/badge-manager.ts index 84618f279..e046ab8ce 100644 --- a/src/background/badge-manager.ts +++ b/src/background/badge-manager.ts @@ -8,8 +8,9 @@ import { setBadgeBgColor, setBadgeText } from "@api/chrome/action" import { listTabs, onTabUpdated } from "@api/chrome/tab" import { getLastFocusedId, isNoneWindowId, onWindowFocusChanged } from "@api/chrome/window" -import { IS_ANDROID } from "@util/constant/environment" -import { extractHostname, isBrowserUrl } from "@util/pattern" +import focusHolder from '@service/focus/holder' +import { IS_ANDROID, isNotTrackable } from "@util/constant/environment" +import { extractHostname } from "@util/pattern" import { MILL_PER_HOUR, MILL_PER_MINUTE, MILL_PER_SECOND } from "@util/time" import statDatabase from "./database/stat-database" import type MessageDispatcher from './message-dispatcher' @@ -46,7 +47,7 @@ async function findActiveTab(windowId?: number): Promise { + async render(): Promise { + const focusBadge = focusHolder.badge + if (focusBadge) { + // Set focus badge with for all tabs + return await setBadgeText(focusBadge) + } const badgeText = await this.resolveBadgeText() await setBadgeText(badgeText, this.#current?.tabId) } @@ -128,7 +134,7 @@ class BadgeManager { private async resolveBadgeText(): Promise { if (!this.#current || !this.#visible) return '' const { url, tabId } = this.#current - if (isBrowserUrl(url)) return '∅' + if (isNotTrackable(url)) return '∅' const { host, protocol } = extractHostname(url) if (protocol === 'file' && !this.#countLocalFiles) return '∅' if (whitelistHolder.contains(host, url)) return 'W' @@ -138,6 +144,6 @@ class BadgeManager { } } -const badgeTextManager = new BadgeManager() +const badgeManager = new BadgeManager() -export default badgeTextManager +export default badgeManager diff --git a/src/background/content-script-handler.ts b/src/background/content-script-handler.ts index 3ad13fe4c..5e422d41a 100644 --- a/src/background/content-script-handler.ts +++ b/src/background/content-script-handler.ts @@ -5,43 +5,32 @@ * https://opensource.org/licenses/MIT */ -import { IS_ANDROID, IS_CHROME, IS_SAFARI } from "@util/constant/environment" -import { extractHostname, isBrowserUrl, isHomepage } from "@util/pattern" +import { getTab } from '@api/chrome/tab' +import { saveSite } from '@service/site-service' +import { IS_ANDROID, IS_CHROME, IS_FIREFOX, IS_SAFARI, isNotTrackable } from "@util/constant/environment" +import { extractHostname, isHomepage } from "@util/pattern" import { extractSiteName } from "@util/site" import badgeManager from "./badge-manager" import MessageDispatcher from "./message-dispatcher" -import { saveAlias, saveIconUrl } from "./service/site-service" import { incVisitCount } from './track-server/normal' -function isUrl(title: string) { - return title.startsWith('https://') || title.startsWith('http://') || title.startsWith('ftp://') -} - -async function collectAlias(key: tt4b.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 + // Not support to modify site info on Android, so skip it + if (IS_ANDROID) return + let { favIconUrl: iconUrl, url, title } = tab if (!url || !title) return - if (isBrowserUrl(url)) return + if (isNotTrackable(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: tt4b.site.SiteKey = { host, type: 'normal' } - favIconUrl && await saveIconUrl(siteKey, favIconUrl) - !IS_ANDROID - && !isBrowserUrl(url) - && isHomepage(url) - && await collectAlias(siteKey, title) + IS_CHROME && /^localhost(:.+)?/.test(host) && (iconUrl = undefined) + // Only collect site name for homepage + const alias = isHomepage(url) ? extractSiteName(title) : undefined + await saveSite({ host, type: 'normal', alias, iconUrl }, false) } /** @@ -49,6 +38,8 @@ async function processTabInfo(tab: ChromeTab): Promise { */ const collectIconAndAlias = async (tab: ChromeTab) => { if (IS_SAFARI || IS_ANDROID) return + // Tab from sender does not contain favIconUrl for FF + if (IS_FIREFOX) tab = (tab.id ? await getTab(tab.id) : undefined) ?? tab processTabInfo(tab) } diff --git a/src/background/database/cate-database.ts b/src/background/database/cate-database.ts index 9c0b69336..da866003d 100644 --- a/src/background/database/cate-database.ts +++ b/src/background/database/cate-database.ts @@ -52,8 +52,9 @@ class CateDatabase extends BaseDatabase { return { id: parseInt(existId), name } } - const id = (Object.keys(items || {}).map(k => parseInt(k)).sort().reverse()?.[0] ?? 0) + 1 - items[id] = { n: name ?? items[id]?.n } + const ids = Object.keys(items).map(Number).filter(Number.isFinite) + const id = (ids.length ? Math.max(...ids) : 0) + 1 + items[id] = {n: name} await this.saveItems(items) return { name, id } diff --git a/src/background/database/common/indexed-storage.ts b/src/background/database/common/indexed-storage.ts index 531e0ea76..0468fc554 100644 --- a/src/background/database/common/indexed-storage.ts +++ b/src/background/database/common/indexed-storage.ts @@ -1,4 +1,4 @@ -const ALL_TABLES = ['stat', 'timeline'] as const +const ALL_TABLES = ['stat', 'timeline', 'focus_preset', 'focus_record'] as const export type Table = typeof ALL_TABLES[number] @@ -33,7 +33,7 @@ export function req2Promise(req: IDBRequest): Promise( req: IDBRequest ): Promise -export async function iterateCursor( +export async function iterateCursor( req: IDBRequest, processor: (cursor: IDBCursorWithValue) => void | Promise ): Promise @@ -103,59 +103,56 @@ export type IndexResult = { } export abstract class BaseIDBStorage> { - private DB_NAME = `tt4b_${chrome.runtime.id}` as const - - private db: IDBDatabase | undefined - private static initPromises = new Map>() + #DB_NAME = `tt4b_${chrome.runtime.id}` as const + #db: IDBDatabase | undefined + static #initPromises = new Map>() abstract indexes: Index[] abstract key: Key | Key[] abstract table: Table protected async initDb(): Promise { - if (this.db) return this.db + if (this.#db) return this.#db - let initPromise = BaseIDBStorage.initPromises.get(this.table) + let initPromise = BaseIDBStorage.#initPromises.get(this.table) if (!initPromise) { - initPromise = this.doInitDb() - BaseIDBStorage.initPromises.set(this.table, initPromise) + initPromise = this.#doInitDb() + BaseIDBStorage.#initPromises.set(this.table, initPromise) } try { - this.db = await initPromise - this.setupDbCloseHandler(this.db) - return this.db + this.#db = await initPromise + this.#setupDbCloseHandler(this.#db) + return this.#db } catch (error) { - BaseIDBStorage.initPromises.delete(this.table) + BaseIDBStorage.#initPromises.delete(this.table) throw error } } - private setupDbCloseHandler(db: IDBDatabase): void { + #setupDbCloseHandler(db: IDBDatabase): void { db.onversionchange = () => db.close() db.onclose = () => { - if (this.db !== db) return + if (this.#db !== db) return - this.db = undefined - BaseIDBStorage.initPromises.delete(this.table) + this.#db = undefined + BaseIDBStorage.#initPromises.delete(this.table) } } - private async doInitDb(): Promise { + async #doInitDb(): Promise { const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB - const checkDb = await new Promise((resolve, reject) => { - const checkRequest = factory.open(this.DB_NAME) + return 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")) }) - - return checkDb } // Only used for testing, be careful when using in production - public async clear(): Promise { + async clear(): Promise { await this.withStore(store => store.clear(), 'readwrite') } @@ -163,16 +160,16 @@ export abstract class BaseIDBStorage> { const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB const checkDb = await new Promise((resolve, reject) => { - const checkRequest = factory.open(this.DB_NAME) + 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`) + 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) + const needUpgrade = !storeExisted || this.#needUpgradeIndexes(checkDb) if (!needUpgrade) { checkDb.close() @@ -183,7 +180,7 @@ export abstract class BaseIDBStorage> { checkDb.close() return new Promise((resolve, reject) => { - const upgradeRequest = factory.open(this.DB_NAME, currentVersion + 1) + const upgradeRequest = factory.open(this.#DB_NAME, currentVersion + 1) upgradeRequest.onupgradeneeded = () => { try { @@ -205,7 +202,7 @@ export abstract class BaseIDBStorage> { let store = upgradeDb.objectStoreNames.contains(this.table) ? transaction.objectStore(this.table) : upgradeDb.createObjectStore(this.table, { keyPath: this.key as string | string[] }) - this.createIndexes(store) + this.#createIndexes(store) } catch (error) { console.error("Failed to upgrade database in onupgradeneeded", error) upgradeRequest.transaction?.abort() @@ -225,10 +222,10 @@ export abstract class BaseIDBStorage> { } upgradeRequest.onblocked = () => { - const blockingTables = Array.from(BaseIDBStorage.initPromises.keys()) + 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}), ` + + `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'}` ) @@ -236,7 +233,7 @@ export abstract class BaseIDBStorage> { }) } - private needUpgradeIndexes(db: IDBDatabase): boolean { + #needUpgradeIndexes(db: IDBDatabase): boolean { try { const transaction = db.transaction(this.table, 'readonly') const store = transaction.objectStore(this.table) @@ -256,7 +253,7 @@ export abstract class BaseIDBStorage> { } } - private createIndexes(store: IDBObjectStore) { + #createIndexes(store: IDBObjectStore) { const existingIndexes = store.indexNames for (const index of this.indexes) { @@ -298,12 +295,12 @@ export abstract class BaseIDBStorage> { } if (errorType === 'StoreNotFound') { - this.db?.close() + this.#db?.close() await this.upgrade() } - this.db = undefined - BaseIDBStorage.initPromises.delete(this.table) + this.#db = undefined + BaseIDBStorage.#initPromises.delete(this.table) db = await this.initDb() } } diff --git a/src/background/database/common/storage-promise.ts b/src/background/database/common/storage-promise.ts index 14c47dc97..1df35b03f 100644 --- a/src/background/database/common/storage-promise.ts +++ b/src/background/database/common/storage-promise.ts @@ -5,28 +5,23 @@ * https://opensource.org/licenses/MIT */ -/** - * Copy from chrome.storage - */ -type NoInferX = T[][T extends any ? 0 : never] +type StorageArea = chrome.storage.StorageArea /** * Wrap the storage with promise */ export default class StoragePromise { - private storage: chrome.storage.StorageArea | undefined + private storage: StorageArea | undefined - constructor(storage?: chrome.storage.StorageArea) { + constructor(storage?: StorageArea) { this.storage = storage } - private getStorage(): chrome.storage.StorageArea { + private getStorage(): StorageArea { return this.storage ?? chrome.storage.local } - get( - keys?: NoInferX | Array> | Partial> | null, - ): Promise { + get(keys?: Parameters[0]): Promise { return new Promise(resolve => this.getStorage().get(keys ?? null, resolve)) } diff --git a/src/background/database/focus-preset-database.ts b/src/background/database/focus-preset-database.ts new file mode 100644 index 000000000..13e664ab0 --- /dev/null +++ b/src/background/database/focus-preset-database.ts @@ -0,0 +1,42 @@ +import { BaseIDBStorage, iterateCursor, req2Promise, type Index, type Key, type Table } from './common/indexed-storage' + +class FocusPresetDatabase extends BaseIDBStorage { + indexes: Index[] = [] + key: Key = 'id' + table: Table = 'focus_preset' + + async add(preset: Omit): Promise { + return this.withStore(async store => { + const id = Date.now() + const req = store.add({ ...preset, id } satisfies tt4b.focus.Preset) + await req2Promise(req) + return id + }) + } + + async update(preset: tt4b.focus.Preset): Promise { + return this.withStore(async store => void store.put(preset)) + } + + async remove(id: number): Promise { + return this.withStore(async store => void store.delete(id)) + } + + async getById(id: number): Promise { + return this.withStore(async store => { + const req = store.get(id) + return await req2Promise(req) + }, 'readonly') + } + + async listAll(): Promise { + return this.withStore(async store => { + const req = store.openCursor() + return await iterateCursor(req) as tt4b.focus.Preset[] + }, 'readonly') + } +} + +const focusPresetDatabase = new FocusPresetDatabase() + +export default focusPresetDatabase diff --git a/src/background/database/focus-record-database.ts b/src/background/database/focus-record-database.ts new file mode 100644 index 000000000..e32bcefc4 --- /dev/null +++ b/src/background/database/focus-record-database.ts @@ -0,0 +1,62 @@ +import { + BaseIDBStorage, closedRangeKey, iterateCursor, req2Promise, type Index, type Key, type Table, +} from './common/indexed-storage' + +type Condition = { + state?: Arrayable + start?: number + end?: number +} + +class FocusRecordDatabase extends BaseIDBStorage { + indexes: Index[] = ['end', 'state'] + key: Key = 'start' + table: Table = 'focus_record' + + async add(record: tt4b.focus.Session): Promise { + return this.withStore(async store => { + const req = store.add(record) + await req2Promise(req) + }) + } + + async save(record: tt4b.focus.Session): Promise { + return this.withStore(async store => void store.put(record)) + } + + async list(condition?: Condition): Promise { + return this.withStore(async store => { + const { state, start, end } = condition ?? {} + const states = state ? (Array.isArray(state) ? state : [state]) : undefined + + // If only state filter, use state index + if (states && !start && !end) { + const index = this.assertIndex(store, 'state') + if (states.length === 1) { + const req = index.openCursor(IDBKeyRange.only(states[0])) + return await iterateCursor(req) as tt4b.focus.Session[] + } + // Multiple states: iterate all and filter + const req = index.openCursor() + const rows = await iterateCursor(req) as tt4b.focus.Session[] + return rows.filter(row => states.includes(row.state)) + } + + // If time range filter, use start index + if (start || end) { + const range = closedRangeKey(start, end) + const req = store.openCursor(range) + const rows = await iterateCursor(req) as tt4b.focus.Session[] + return states ? rows.filter(row => states.includes(row.state)) : rows + } + + // No condition, return all + const req = store.openCursor() + return await iterateCursor(req) as tt4b.focus.Session[] + }, 'readonly') + } +} + +const focusRecordDatabase = new FocusRecordDatabase() + +export default focusRecordDatabase diff --git a/src/background/database/limit-database.ts b/src/background/database/limit-database.ts index b2a3911d8..9ca91d0ce 100644 --- a/src/background/database/limit-database.ts +++ b/src/background/database/limit-database.ts @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { isOptionalInt, isRecord, isVector2 } from '@util/guard' +import { 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 { createArrayGuard, createGuard, createObjectGuard, createOptionalGuard, isInt, isOptionalBoolean, isOptionalInt, isString } from 'typescript-guard' import BaseDatabase from "./common/base-database" import { REMAIN_WORD_PREFIX } from "./common/constant" import { extractNamespace, isExportData, isLegacyVersion } from './common/migratable' @@ -38,10 +38,10 @@ const isValidRow = createObjectGuard({ weekly: isOptionalInt, weeklyCount: isOptionalInt, visitTime: isOptionalInt, - enabled: createOptionalGuard(isBoolean), - locked: createOptionalGuard(isBoolean), + enabled: isOptionalBoolean, + locked: isOptionalBoolean, weekdays: createOptionalGuard(createArrayGuard(createGuard(val => isInt(val) && val >= 0 && val <= 6))), - allowDelay: createOptionalGuard(isBoolean), + allowDelay: isOptionalBoolean, periods: createOptionalGuard(createArrayGuard(isVector2)), }) @@ -147,7 +147,8 @@ type Items = Record function migrate(exist: Items, toMigrate: unknown) { if (!isRecord(toMigrate)) return - const idBase = Object.keys(exist).map(parseInt).sort().reverse()?.[0] ?? 0 + 1 + const ids = Object.keys(exist).map(Number).filter(Number.isFinite) + const idBase = (ids.length ? Math.max(...ids) : 0) + 1 Object.values(toMigrate).forEach((value, idx) => { const id = idBase + idx const itemValue: ItemValue = value as ItemValue diff --git a/src/background/database/option-database.ts b/src/background/database/option-database.ts index ec93264c9..5c1de3146 100644 --- a/src/background/database/option-database.ts +++ b/src/background/database/option-database.ts @@ -9,6 +9,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" +import StoragePromise from './common/storage-promise' const DB_KEY = REMAIN_WORD_PREFIX + 'OPTION' @@ -18,15 +19,29 @@ const DB_KEY = REMAIN_WORD_PREFIX + 'OPTION' * @since 0.3.0 */ class OptionDatabase extends BaseDatabase { + #sync: StoragePromise = new StoragePromise(chrome.storage.sync) - async getOption(): Promise { + async getOption(): Promise { const option = await this.storage.getOne(DB_KEY) - return mergeObject(defaultOption(), option) + return mergeObject(defaultOption(), option) } async setOption(option: tt4b.option.AllOption): Promise { option && await this.setByKey(DB_KEY, option) } + + async sync(): Promise { + await this.#copy(this.storage, this.#sync) + } + + async download(): Promise { + await this.#copy(this.#sync, this.storage) + } + + async #copy(from: StoragePromise, to: StoragePromise): Promise { + const value = await from.getOne(DB_KEY) + return value ? to.put(DB_KEY, value) : to.remove(DB_KEY) + } } const optionDatabase = new OptionDatabase() diff --git a/src/background/database/stat-database/classic.ts b/src/background/database/stat-database/classic.ts index 4b6403d89..f29112e59 100644 --- a/src/background/database/stat-database/classic.ts +++ b/src/background/database/stat-database/classic.ts @@ -1,12 +1,11 @@ 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 { createObjectGuard, isOptionalInt } 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 { filterDate, filterHost, processCondition, type ProcessedCondition } from './condition' import type { StatCondition, StatDatabase } from './types' /** @@ -27,14 +26,9 @@ const isPartialResult = createObjectGuard>({ run: isOptionalInt, }) -function filterRow(row: tt4b.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]) +function filterRow({ host, date }: tt4b.core.Row, condition: ProcessedCondition): boolean { + const { keys, virtual } = condition + return filterHost(host, keys, virtual) && filterDate(date, condition) } /** diff --git a/src/background/database/stat-database/condition.ts b/src/background/database/stat-database/condition.ts index 19e70f918..d8757eef9 100644 --- a/src/background/database/stat-database/condition.ts +++ b/src/background/database/stat-database/condition.ts @@ -6,10 +6,6 @@ export type ProcessedCondition = StatCondition & { 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 { @@ -31,38 +27,17 @@ export function filterDate( 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 (typeof paramDate === 'string') { - result.useExactDate = true - result.exactDateStr = paramDate - } else { - const [startDate, endDate] = paramDate - result.useExactDate = false - result.startDateStr = startDate - result.endDateStr = 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]) + if (typeof paramDate === 'string') { + result.useExactDate = true + result.exactDateStr = paramDate + } else if (paramDate) { + result.startDateStr = paramDate[0] + result.endDateStr = paramDate[1] } return result diff --git a/src/background/database/stat-database/idb.ts b/src/background/database/stat-database/idb.ts index 882a68b9b..6d0b6ebad 100644 --- a/src/background/database/stat-database/idb.ts +++ b/src/background/database/stat-database/idb.ts @@ -1,6 +1,6 @@ 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 { filterDate, filterHost, processCondition, type ProcessedCondition } from './condition' import type { StatCondition, StatDatabase } from './types' type StoredRow = tt4b.core.Row & { @@ -19,31 +19,17 @@ 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 + return filterHost(row.host, keys, cond.virtual) } } @@ -79,15 +65,10 @@ export class IDBStatDatabase extends BaseIDBStorage implements StatDa 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 + const { useExactDate, exactDateStr, startDateStr, endDateStr } = cond if (expectGroup) { - const groupId = keys?.length === 1 ? parseInt(keys[0] ?? 'NaN') : NaN + const groupId = parseInt(keys?.[0] ?? 'NaN') return isNaN(groupId) ? { cursorReq: this.assertIndexCursor(store, 'groupId', IDBKeyRange.lowerBound(0)), } : { @@ -113,22 +94,6 @@ export class IDBStatDatabase extends BaseIDBStorage implements StatDa } } - 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: {} @@ -207,8 +172,7 @@ export class IDBStatDatabase extends BaseIDBStorage implements StatDa 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 req = index.getKey([date, host]) const key = await req2Promise(req) if (key) { await req2Promise(store.delete(key)) diff --git a/src/background/database/stat-database/index.ts b/src/background/database/stat-database/index.ts index e39db8d98..98a67b0f9 100644 --- a/src/background/database/stat-database/index.ts +++ b/src/background/database/stat-database/index.ts @@ -5,9 +5,8 @@ * https://opensource.org/licenses/MIT */ -import { isOptionalInt } from '@util/guard' import { isNotZeroResult } from '@util/stat' -import { createArrayGuard, createObjectGuard, isString } from 'typescript-guard' +import { createArrayGuard, createObjectGuard, isOptionalInt, isString } from 'typescript-guard' import { extractNamespace, isExportData, isLegacyVersion } from '../common/migratable' import { StorageHolder } from '../common/storage-holder' import type { BrowserMigratable, StorageMigratable } from '../types' @@ -39,58 +38,61 @@ class StatDatabaseWrapper implements StateDatabaseComposite { classic: new ClassicStatDatabase(), indexed_db: new IDBStatDatabase(), }) - private current = () => this.holder.current + + get #current() { + return this.holder.current + } get(host: string, date: Date): Promise { - return this.current().get(host, date) + return this.#current.get(host, date) } batchSelect(keys: tt4b.core.RowKey[]): Promise { - return this.current().batchSelect(keys) + return this.#current.batchSelect(keys) } select(condition?: StatCondition): Promise { - return this.current().select(condition) + return this.#current.select(condition) } accumulate(host: string, date: Date | string, item: tt4b.core.Result): Promise { - return this.current().accumulate(host, date, item) + return this.#current.accumulate(host, date, item) } batchAccumulate(data: Record, date: Date | string): Promise> { - return this.current().batchAccumulate(data, date) + return this.#current.batchAccumulate(data, date) } accumulateGroup(groupId: number, date: Date | string, item: tt4b.core.Result): Promise { - return this.current().accumulateGroup(groupId, date, item) + return this.#current.accumulateGroup(groupId, date, item) } delete(...rows: tt4b.core.RowKey[]): Promise { - return this.current().delete(...rows) + return this.#current.delete(...rows) } deleteByHost(host: string, range?: string | [string, string]): Promise { - return this.current().deleteByHost(host, range) + return this.#current.deleteByHost(host, range) } deleteByGroup(groupId: number, range?: string | [string, string]): Promise { - return this.current().deleteByGroup(groupId, range) + return this.#current.deleteByGroup(groupId, range) } selectGroup(condition?: StatCondition): Promise { - return this.current().selectGroup(condition) + return this.#current.selectGroup(condition) } deleteGroup(...rows: [groupId: number, date: string][]): Promise { - return this.current().deleteGroup(...rows) + return this.#current.deleteGroup(...rows) } forceUpdate(...rows: tt4b.core.Row[]): Promise { - return this.current().forceUpdate(...rows) + return this.#current.forceUpdate(...rows) } forceUpdateGroup(...rows: tt4b.core.Row[]): Promise { - return this.current().forceUpdateGroup(...rows) + return this.#current.forceUpdateGroup(...rows) } async migrateStorage(type: tt4b.option.StorageType): Promise<[tt4b.core.Row[], tt4b.core.Row[]]> { @@ -104,18 +106,18 @@ class StatDatabaseWrapper implements StateDatabaseComposite { } async afterStorageMigrated([tabs, groups]: [tt4b.core.Row[], tt4b.core.Row[]]): Promise { - await this.current().delete(...tabs) + await this.#current.delete(...tabs) const groupKeys = groups.map(({ host, date }) => [parseInt(host), date] satisfies [number, string]) - await this.current().deleteGroup(...groupKeys) + await this.#current.deleteGroup(...groupKeys) } async importData(data: unknown): Promise { const rows = this.parseImportRows(data) - await this.forceUpdate(...rows) + await this.#current.forceUpdate(...rows) } async exportData(): Promise { - return this.select({ virtual: true }) + return this.#current.select({ virtual: true }) } private parseImportRows(data: unknown): tt4b.core.Row[] { diff --git a/src/background/database/stat-database/types.ts b/src/background/database/stat-database/types.ts index a032c138f..5264f6805 100644 --- a/src/background/database/stat-database/types.ts +++ b/src/background/database/stat-database/types.ts @@ -5,18 +5,6 @@ export type StatCondition = { * {y}{m}{d} */ date?: string | [string?, string?] - /** - * Focus range, milliseconds - * - * @since 0.0.9 - */ - focusRange?: Vector<2> - /** - * Time range - * - * @since 0.0.9 - */ - timeRange?: [number, number?] /** * Whether to include virtual sites * diff --git a/src/background/database/timeline-database/idb.ts b/src/background/database/timeline-database.ts similarity index 91% rename from src/background/database/timeline-database/idb.ts rename to src/background/database/timeline-database.ts index a99bbfb16..23536ef47 100644 --- a/src/background/database/timeline-database/idb.ts +++ b/src/background/database/timeline-database.ts @@ -2,8 +2,15 @@ import { MILL_PER_DAY, MILL_PER_SECOND } from '@util/time' import { BaseIDBStorage, iterateCursor, req2Promise, type Index, type IndexResult, type Key, type Table, -} from '../common/indexed-storage' -import type { TimelineCondition, TimelineDatabase } from './types' +} from './common/indexed-storage' + +type TimelineCondition = { + host?: string + /** + * Start time in milliseconds, inclusive + */ + start?: number +} const TIME_LIFE_CYCLE = MILL_PER_DAY * 366 @@ -40,7 +47,7 @@ class CleanThrottle { } } -export default class IDBTimelineDatabase extends BaseIDBStorage implements TimelineDatabase { +class TimelineDatabase extends BaseIDBStorage { indexes: Index[] = [ 'host', 'start', ] @@ -91,7 +98,7 @@ export default class IDBTimelineDatabase extends BaseIDBStorage { const rows = await this.withStore(async store => { - const { cursorReq, coverage = {} } = this.judgeIndex(store, cond) + const { cursorReq, coverage = {} } = this.#judgeIndex(store, cond) const rows = await iterateCursor(cursorReq) const { start: cs, host: ch } = cond ?? {} return rows.filter(tick => { @@ -111,7 +118,7 @@ export default class IDBTimelineDatabase extends BaseIDBStorage { + #judgeIndex(store: IDBObjectStore, cond?: TimelineCondition): IndexResult { const { host, start } = cond ?? {} if (host) { return { @@ -127,4 +134,8 @@ export default class IDBTimelineDatabase extends BaseIDBStorage { - 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: tt4b.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: 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 })) - }) - }) - return filter(ticks, cond) - } - - async clear(): Promise { - await this.storage.remove(DB_KEY) - } -} diff --git a/src/background/database/timeline-database/index.ts b/src/background/database/timeline-database/index.ts deleted file mode 100644 index e7b43a8c0..000000000 --- a/src/background/database/timeline-database/index.ts +++ /dev/null @@ -1,26 +0,0 @@ -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: tt4b.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/background/database/timeline-database/types.ts b/src/background/database/timeline-database/types.ts deleted file mode 100644 index 6b770e24c..000000000 --- a/src/background/database/timeline-database/types.ts +++ /dev/null @@ -1,12 +0,0 @@ -export type TimelineCondition = { - host?: string - /** - * Start time in milliseconds, inclusive - */ - start?: number -} - -export interface TimelineDatabase { - batchSave(ticks: tt4b.timeline.Tick[]): Promise - select(cond?: TimelineCondition): Promise -} \ No newline at end of file diff --git a/src/background/database/whitelist-database.ts b/src/background/database/whitelist-database.ts index 47c1ccd2d..afc61f656 100644 --- a/src/background/database/whitelist-database.ts +++ b/src/background/database/whitelist-database.ts @@ -23,6 +23,10 @@ class WhitelistDatabase extends BaseDatabase implements BrowserMigratable<'__whi return exist || [] } + async saveAll(toSave: string[]): Promise { + await this.update(toSave) + } + async add(white: string): Promise { const exist = await this.selectAll() if (exist.includes(white)) return diff --git a/src/background/i18n.ts b/src/background/i18n.ts new file mode 100644 index 000000000..4bd21c6ad --- /dev/null +++ b/src/background/i18n.ts @@ -0,0 +1,9 @@ +import { t as _t, type I18nKey as _I18nKey } from "@i18n" +import messages, { type BgMessage } from "@i18n/message/bg" + +export type I18nKey = _I18nKey + +export function t(key: I18nKey, param?: any) { + const props = { key, param } + return _t(messages, props) +} diff --git a/src/background/index.ts b/src/background/index.ts index c717dbaa1..8aa6060e7 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -7,7 +7,7 @@ import { trySendMsg2Tab } from "@api/chrome/tab" import { initBrowserAction, initSidePanel } from './action' -import badgeTextManager from "./badge-manager" +import badgeManager from "./badge-manager" import initCsHandler from "./content-script-handler" import initDataCleaner from "./data-cleaner" import { initAfterInstalled } from './install-handler' @@ -47,11 +47,11 @@ initScheduler() initWhitelistMenuManager() // Badge manager -badgeTextManager.init(messageDispatcher) +badgeManager.init(messageDispatcher) // Listen to tab changed new TabListener() - .onActivated(({ url, tabId }) => badgeTextManager.updateFocus({ url, tabId })) + .onActivated(({ url, tabId }) => badgeManager.updateFocus({ url, tabId })) .onUpdated((tabId, { audible }) => audible !== undefined && trySendMsg2Tab(tabId, 'syncAudible', audible)) .start() diff --git a/src/background/install-handler/index.ts b/src/background/install-handler/index.ts index 68bdc7433..904ea10d3 100644 --- a/src/background/install-handler/index.ts +++ b/src/background/install-handler/index.ts @@ -3,9 +3,8 @@ 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 { IS_E2E, IS_FROM_STORE, isNotTrackable } from "@util/constant/environment" import { getGuidePageUrl, UNINSTALL_QUESTIONNAIRE } from "@util/constant/url" -import { isBrowserUrl } from "@util/pattern" import versionManager from './version' async function onFirstInstall() { @@ -17,7 +16,7 @@ async function reloadContentScript() { const files = chrome.runtime.getManifest().content_scripts?.[0]?.js if (!files?.length) return const tabs = await listTabs() - tabs.filter(({ url }) => url && !isBrowserUrl(url)) + tabs.filter(({ url }) => url && !isNotTrackable(url)) .forEach(({ id: tabId }) => tabId && executeScript(tabId, files)) } diff --git a/src/background/install-handler/version/index.ts b/src/background/install-handler/version/index.ts index 3c6767dc2..daf126b15 100644 --- a/src/background/install-handler/version/index.ts +++ b/src/background/install-handler/version/index.ts @@ -8,7 +8,6 @@ import { getVersion } from "@api/chrome/runtime" import CateInitializer from "./cate-initializer" import HostMergeInitializer from "./host-merge-initializer" -import IndexedDBMigrator from './indexed-migrator' import LocalFileInitializer from "./local-file-initializer" import type { Migrator } from "./types" import WhitelistInitializer from "./whitelist-initializer" @@ -27,7 +26,6 @@ class VersionManager { new LocalFileInitializer(), new WhitelistInitializer(), new CateInitializer(), - new IndexedDBMigrator(), ) } diff --git a/src/background/install-handler/version/indexed-migrator.ts b/src/background/install-handler/version/indexed-migrator.ts deleted file mode 100644 index 63ee0169b..000000000 --- a/src/background/install-handler/version/indexed-migrator.ts +++ /dev/null @@ -1,32 +0,0 @@ -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 { - } - - 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)) - } -} - -export default IndexedMigrator \ No newline at end of file diff --git a/src/background/install-handler/version/local-file-initializer.ts b/src/background/install-handler/version/local-file-initializer.ts index 4a19e57fa..356942a8e 100644 --- a/src/background/install-handler/version/local-file-initializer.ts +++ b/src/background/install-handler/version/local-file-initializer.ts @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ +import { t } from '@bg/i18n' import mergeRuleDatabase from "@db/merge-rule-database" -import { t2Chrome } from "@i18n/chrome/t" -import { saveAlias } from '@service/site-service' +import { saveSite } 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 "./types" @@ -27,21 +27,14 @@ export default class LocalFileInitializer implements Migrator { merged: MERGED_HOST, }).then(() => console.log('Local file merge rules initialized')) // Add site name - saveAlias( - { host: PDF_HOST, type: 'normal' }, - t2Chrome(msg => msg.initial.localFile.pdf), - ) - saveAlias( - { host: JSON_HOST, type: 'normal' }, - t2Chrome(msg => msg.initial.localFile.json), - ) - saveAlias( - { host: PIC_HOST, type: 'normal' }, - t2Chrome(msg => msg.initial.localFile.pic), - ) - saveAlias( - { host: TXT_HOST, type: 'normal' }, - t2Chrome(msg => msg.initial.localFile.txt), - ) + const hostAlias = { + [PDF_HOST]: t(msg => msg.initial.localFile.pdf), + [JSON_HOST]: t(msg => msg.initial.localFile.json), + [PIC_HOST]: t(msg => msg.initial.localFile.pic), + [TXT_HOST]: t(msg => msg.initial.localFile.txt), + } + for (const [host, alias] of Object.entries(hostAlias)) { + void saveSite({ host, type: 'normal', alias, iconUrl: undefined }, true) + } } } \ No newline at end of file diff --git a/src/background/limit-processor.ts b/src/background/limit-processor.ts index bb8c72b55..ff3641d89 100644 --- a/src/background/limit-processor.ts +++ b/src/background/limit-processor.ts @@ -16,7 +16,6 @@ import { createLimitRule, delayLimit, noticeLimitChanged, removeLimitRules, selectLimit, updateLimitRules, } from "./service/limit-service" - function initDailyBroadcast() { // Broadcast rules at the start of each day alarmManager.setWhen( diff --git a/src/background/message-dispatcher.ts b/src/background/message-dispatcher.ts index f248607fd..22d76e515 100644 --- a/src/background/message-dispatcher.ts +++ b/src/background/message-dispatcher.ts @@ -7,6 +7,8 @@ import { log } from '@/common/logger' import { onRuntimeMessage } from "@api/chrome/runtime" +import focusPresetDatabase from "@db/focus-preset-database" +import focusHolder from '@service/focus/holder' import cateDatabase from './database/cate-database' import { getUsedStorage } from './database/memory-detector' import mergeRuleDatabase from "./database/merge-rule-database" @@ -18,13 +20,14 @@ import { exportData, importData, migrateStorage } from "./service/components/imm import { importOther, previewBackup } from "./service/components/import-processor" import optionHolder from "./service/components/option-holder" import { getWeekStartDay, getWeekStartTime } from "./service/components/week-helper" +import { handleAction, saveLastPopup } from "./service/focus" 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, + addSite, batchChangeCate, detectSites, fillInitialAlias, getCurrentSite, getInitialAlias, getSite, removeSites, + saveSite, saveSiteRunState, selectSitePage, } from "./service/site-service" import { batchDelete, countGroup, countSite, selectCate, selectCatePage, selectGroup, selectGroupPage, selectSite, @@ -79,21 +82,23 @@ class MessageDispatcher { .register('stat.today', getTodayResult) .register('item.batch', keys => statDatabase.batchSelect(keys)) // Site management + .register('site.current', getCurrentSite) .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.modify', param => saveSite(param, true)) .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) + .register('site.detect', detectSites) // Options .register('option.get', () => optionHolder.get()) .register('option.set', val => optionHolder.set(val)) + .register('option.sync', () => optionHolder.sync()) + .register('option.download', () => optionHolder.download()) .register('option.changeStorage', migrateStorage) .register('option.testNotification', () => notificationProcessor.doSend()) .register('option.weekStartDay', getWeekStartDay) @@ -108,18 +113,20 @@ class MessageDispatcher { .register('meta.usedStorage', getUsedStorage) .register('meta.prepare2fa', prepare2fa) .register('meta.check2fa', check2faCode) + .register('meta.popup', saveLastPopup) // 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)) + .register('whitelist.save', list => whitelistHolder.saveAll(list)) // 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.checkAuth', () => backupProcessor.checkAuth().then(res => typeof res === 'string' ? res : undefined)) .register('backup.clear', cid => backupProcessor.clear(cid)) .register('backup.query', param => backupProcessor.query(param)) .register('backup.lastTs', getLastBackUp) @@ -129,6 +136,14 @@ class MessageDispatcher { .register('period.list', selectPeriods) .register('timeline.list', listTimeline) .register('timeline.tick', ev => timelineThrottler.saveEvent(ev)) + // Focus + .register('focus.allPresets', () => focusPresetDatabase.listAll()) + .register('focus.getPreset', id => focusPresetDatabase.getById(id)) + .register('focus.addPreset', data => focusPresetDatabase.add(data)) + .register('focus.savePreset', data => focusPresetDatabase.update(data)) + .register('focus.deletePreset', id => focusPresetDatabase.remove(id)) + .register('focus.action', handleAction) + .register('focus.current', () => focusHolder.current) // Data immigration .register('immigration.import', importData) .register('immigration.export', exportData) @@ -161,4 +176,4 @@ class MessageDispatcher { } } -export default MessageDispatcher \ No newline at end of file +export default MessageDispatcher diff --git a/src/background/psl/rules.json b/src/background/psl/rules.json index 482160e4a..a19623105 100644 --- a/src/background/psl/rules.json +++ b/src/background/psl/rules.json @@ -251,7 +251,6 @@ "*": 1 } }, - "bookonline": 1, "botdash": 1, "brave": { "c": { @@ -341,6 +340,7 @@ "ondigitalocean": 1, "onhercules": 1, "pplx": 1, + "puter": 1, "railway": { "c": { "up": 1 @@ -549,6 +549,7 @@ }, "biz": 1, "co": 1, + "dnshome": 1, "funkfeuer": { "c": { "wien": 1 @@ -1289,7 +1290,12 @@ "banamex": 1, "band": 1, "bank": 1, - "bar": 1, + "bar": { + "c": { + "resolve": 1 + }, + "l": 1 + }, "barcelona": 1, "barclaycard": 1, "barclays": 1, @@ -1373,7 +1379,12 @@ "beats": 1, "beauty": 1, "beer": 1, - "berlin": 1, + "berlin": { + "c": { + "ddns": 1 + }, + "l": 1 + }, "best": 1, "bestbuy": 1, "bet": 1, @@ -2157,6 +2168,7 @@ "l": 1 }, "diadem": 1, + "dnshome": 1, "elementor": 1, "emergent": 1, "encoway": { @@ -2164,6 +2176,7 @@ "eu": 1 } }, + "hstgr": 1, "jelastic": { "c": { "vip": 1 @@ -2542,6 +2555,7 @@ "jl": 1, "js": 1, "jx": 1, + "khsj": 1, "ln": 1, "mil": 1, "mo": 1, @@ -4757,7 +4771,6 @@ } }, "reservd": 1, - "reserve-online": 1, "rhcloud": 1, "rice-labs": 1, "routingthecloud": 1, @@ -5121,6 +5134,7 @@ }, "l": 1 }, + "ddnssec": 1, "diskussionsbereich": 1, "dnshome": 1, "dnsupdater": 1, @@ -5128,6 +5142,7 @@ "dyn-ip24": 1, "dynamisches-dns": 1, "dyndns1": 1, + "dyndnssec": 1, "firewall-gateway": 1, "frusky": { "c": { @@ -5137,6 +5152,7 @@ "fuettertdasnetz": 1, "git-repos": 1, "goip": 1, + "heimdns": 1, "home-webserver": { "c": { "dyn": 1 @@ -5196,6 +5212,7 @@ } }, "square7": 1, + "srvdns": 1, "svn-repos": 1, "taifun-dns": 1, "test-iserv": 1, @@ -5472,6 +5489,16 @@ "*": 1 } }, + "storage": { + "c": { + "t3": 1 + } + }, + "storageapi": { + "c": { + "t3": 1 + } + }, "vercel": 1, "vivenushop": 1, "webhare": { @@ -5687,11 +5714,6 @@ }, "email": { "c": { - "crisp": { - "c": { - "on": 1 - } - }, "intouch": 1, "tawk": { "c": { @@ -5779,6 +5801,7 @@ "cloudns": 1, "deuxfleurs": 1, "directwp": 1, + "dnshome": 1, "dogado": { "c": { "jelastic": 1 @@ -5827,7 +5850,12 @@ "fairwinds": 1, "faith": 1, "family": 1, - "fan": 1, + "fan": { + "c": { + "mkm": 1 + }, + "l": 1 + }, "fans": 1, "farm": { "c": { @@ -6430,6 +6458,7 @@ "id": { "c": { "ac": 1, + "ai": 1, "biz": 1, "co": 1, "desa": 1, @@ -6974,6 +7003,7 @@ "cz": 1, "dell-ogliastra": 1, "dellogliastra": 1, + "dnshome": 1, "edu": 1, "emilia-romagna": 1, "emiliaromagna": 1, @@ -9803,6 +9833,7 @@ "lincoln": 1, "link": { "c": { + "canva": 1, "cyon": 1, "dweb": { "c": { @@ -11736,7 +11767,12 @@ }, "nokia": 1, "norton": 1, - "now": 1, + "now": { + "c": { + "dyn": 1 + }, + "l": 1 + }, "nowruz": 1, "nowtv": 1, "np": { @@ -11831,8 +11867,10 @@ "online": { "c": { "barsy": 1, + "book": 1, "eero": 1, "eero-stage": 1, + "heimdns": 1, "leapcell": 1, "websitebuilder": 1 }, @@ -13121,6 +13159,7 @@ "my": 1 } }, + "chatgpt": 1, "cloudera": { "c": { "*": 1 @@ -13155,6 +13194,7 @@ } }, "preview": 1, + "puter": 1, "sol": 1, "sourcecraft": 1, "square": 1, @@ -13243,6 +13283,7 @@ "myfast": 1, "project": 1, "uber": 1, + "vibehost": 1, "xs4all": 1 }, "l": 1 @@ -14189,13 +14230,7 @@ }, "l": 1 }, - "nd": { - "c": { - "cc": 1, - "lib": 1 - }, - "l": 1 - }, + "nd": 1, "ne": { "c": { "cc": 1, @@ -14683,7 +14718,8 @@ "woodside": 1, "work": { "c": { - "imagine-proxy": 1 + "imagine-proxy": 1, + "puter": 1 }, "l": 1 }, @@ -14709,7 +14745,12 @@ "l": 1 }, "wtc": 1, - "wtf": 1, + "wtf": { + "c": { + "ddns": 1 + }, + "l": 1 + }, "xbox": 1, "xerox": 1, "xihuan": 1, @@ -15013,11 +15054,17 @@ "zone": { "c": { "lima": 1, + "prg1-zerops": 1, "stackit": 1, "triton": { "c": { "*": 1 } + }, + "zerops": { + "c": { + "*": 1 + } } }, "l": 1 diff --git a/src/background/service/backup/processor.ts b/src/background/service/backup/processor.ts index 23fae06d0..b1f2a28c4 100644 --- a/src/background/service/backup/processor.ts +++ b/src/background/service/backup/processor.ts @@ -19,8 +19,7 @@ type AuthCheckResult = { ext: tt4b.backup.TypeExt type: tt4b.backup.Type coordinator: tt4b.backup.Coordinator - errorMsg?: string -} +} | string class CoordinatorContextWrapper implements tt4b.backup.CoordinatorContext { auth: tt4b.backup.Auth @@ -76,26 +75,20 @@ function prepareAuth(option: tt4b.option.BackupOption): tt4b.backup.Auth { return { token, login } } -class Processor { - coordinators: { - [type in tt4b.backup.Type]: tt4b.backup.Coordinator - } - - constructor() { - this.coordinators = { - none: null as unknown as tt4b.backup.Coordinator, - gist: new GistCoordinator(), - obsidian_local_rest_api: new ObsidianCoordinator(), - web_dav: new WebDAVCoordinator(), - } - } +const COORDINATORS: Record, tt4b.backup.Coordinator> = { + gist: new GistCoordinator(), + obsidian_local_rest_api: new ObsidianCoordinator(), + web_dav: new WebDAVCoordinator(), +} +class Processor { async syncData(): Promise { - const { option, auth, ext, type, coordinator, errorMsg } = await this.checkAuth() - if (errorMsg) return errorMsg + const authRes = await this.checkAuth() + if (typeof authRes === 'string') return authRes + const { option, auth, ext, type, coordinator } = authRes const cid = await getCid() - const context: tt4b.backup.CoordinatorContext = await new CoordinatorContextWrapper(cid, auth, ext, type).init() + const context = await new CoordinatorContextWrapper(cid, auth, ext, type).init() const client: tt4b.backup.Client = { id: cid, name: option.clientName, @@ -116,8 +109,9 @@ class Processor { } async listClients(): Promise<(tt4b.backup.Client & { current: boolean })[]> { - const { auth, ext, type, coordinator, errorMsg } = await this.checkAuth() - if (errorMsg) throw new Error(errorMsg) + const authRes = await this.checkAuth() + if (typeof authRes === 'string') throw new Error(authRes) + const { auth, ext, type, coordinator } = authRes const cid = await getCid() const context = await new CoordinatorContextWrapper(cid, auth, ext, type).init() const clients = await coordinator.listAllClients(context) @@ -127,33 +121,28 @@ class Processor { async checkAuth(): Promise { const option = await optionHolder.get() const { backupType: type, backupExts } = option + if (type === 'none') return "Invalid type" const ext = backupExts?.[type] ?? {} const auth = prepareAuth(option) - const coordinator: tt4b.backup.Coordinator = type && this.coordinators[type] - if (!coordinator) { - // no coordinator, do nothing - return { option, auth, ext, type, coordinator, errorMsg: "Invalid type" } - } - let errorMsg + const coordinator = COORDINATORS[type] try { - errorMsg = await coordinator.testAuth(auth, ext) + const errorMsg = await coordinator.testAuth(auth, ext) + return errorMsg ?? { option, auth, ext, type, coordinator } } catch (e) { - errorMsg = (e as Error)?.message || 'Unknown error' + return e instanceof Error ? e.message : String(e ?? 'Unknown Error') } - return { option, auth, ext, type, coordinator, errorMsg } } async query(param: tt4b.backup.RemoteQuery): Promise { - const { type, coordinator, auth, ext, errorMsg } = await this.checkAuth() - if (errorMsg || !coordinator) { - return [] - } + const authRes = await this.checkAuth() + if (typeof authRes === 'string') return [] + const { auth, ext, type, coordinator } = authRes const { start, end, specCid, excludeLocal } = param let localCid = await getCid() // 1. init context - const context: tt4b.backup.CoordinatorContext = await new CoordinatorContextWrapper(localCid, auth, ext, type).init() + const context = await new CoordinatorContextWrapper(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)) @@ -176,10 +165,11 @@ class Processor { } async clear(cid: string): Promise { - const { auth, ext, type, coordinator, errorMsg } = await this.checkAuth() - if (errorMsg) return errorMsg + const authRes = await this.checkAuth() + if (typeof authRes === 'string') return authRes + const { auth, ext, type, coordinator } = authRes let localCid = await getCid() - const context: tt4b.backup.CoordinatorContext = await new CoordinatorContextWrapper(localCid, auth, ext, type).init() + const context = 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] diff --git a/src/background/service/components/host-merge-ruler.ts b/src/background/service/components/host-merge-ruler.ts index 1c527b775..96d2e94f2 100644 --- a/src/background/service/components/host-merge-ruler.ts +++ b/src/background/service/components/host-merge-ruler.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { getPsl } from '@/background/psl' +import { getPsl } from '@bg/psl' import FIFOCache from '@util/fifo-cache' import { isIpAndPort, judgeVirtualFast } from "@util/pattern" diff --git a/src/background/service/components/import-processor.ts b/src/background/service/components/import-processor.ts index 52551a1da..4c641a579 100644 --- a/src/background/service/components/import-processor.ts +++ b/src/background/service/components/import-processor.ts @@ -35,7 +35,6 @@ async function processAcc(data: tt4b.imported.Data): Promise { })) } - export async function previewBackup(param: tt4b.backup.RemoteQuery): Promise { const remoteRows = await backupProcessor.query(param) const rows: tt4b.imported.Row[] = remoteRows.map(rr => ({ diff --git a/src/background/service/components/option-holder.ts b/src/background/service/components/option-holder.ts index 5a9a8d966..ae1101276 100644 --- a/src/background/service/components/option-holder.ts +++ b/src/background/service/components/option-holder.ts @@ -5,8 +5,8 @@ import { defaultOption } from '@util/constant/option' type ChangeListener = (newVal: tt4b.option.DefaultOption, oldVal: tt4b.option.DefaultOption) => void class OptionHolder { - private value: tt4b.option.DefaultOption | undefined - private listeners: ChangeListener[] = [] + #value: tt4b.option.DefaultOption | undefined + #listeners: ChangeListener[] = [] constructor() { onPermRemoved(perm => { @@ -14,26 +14,34 @@ class OptionHolder { }) } - private async reset(): Promise { - const latest = Object.assign(defaultOption(), await db.getOption()) - this.value = latest - return latest + async #reset(): Promise { + this.#value = await db.getOption() + return this.#value } async get(): Promise { - return this.value ?? await this.reset() + return this.#value ?? await this.#reset() } addChangeListener(listener: ChangeListener) { - listener && this.listeners.push(listener) + 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)) + this.#value = toSet + this.#listeners.forEach(listener => listener(toSet, exist)) + } + + async sync(): Promise { + return db.sync() + } + + async download(): Promise { + await db.download() + await this.#reset() } } diff --git a/src/background/service/components/virtual-site-holder.ts b/src/background/service/components/virtual-site-holder.ts index def14a533..63040cbd1 100644 --- a/src/background/service/components/virtual-site-holder.ts +++ b/src/background/service/components/virtual-site-holder.ts @@ -1,21 +1,28 @@ import db from "@db/site-database" import { compileAntPattern } from '@util/pattern' +type DataNode = { + reg: RegExp + site: tt4b.site.SiteInfo +} + /** * The singleton implementation of virtual sites holder * * @since 1.6.0 */ class VirtualSiteHolder { - hostRegMap: Record = {} + hostRegMap: Record = {} constructor() { db.select().then(keys => keys.forEach(key => this.buildWith(key))) } - buildWith({ host, type }: tt4b.site.SiteKey) { + buildWith(site: tt4b.site.SiteInfo) { + const { host, type } = site if (type !== 'virtual') return - this.hostRegMap[host] = compileAntPattern(host) + const reg = compileAntPattern(host) + this.hostRegMap[host] = { reg, site } } onDeleted({ host, type }: tt4b.site.SiteKey) { @@ -29,10 +36,10 @@ class VirtualSiteHolder { * @param url * @returns virtual sites */ - findMatched(url: string): string[] { + findMatched(url: string): tt4b.site.SiteInfo[] { return Object.entries(this.hostRegMap) - .filter(([_, reg]) => reg.test(url)) - .map(([k]) => k) + .filter(([_, { reg }]) => reg.test(url)) + .map(([_, { site }]) => site) } } diff --git a/src/background/service/focus/holder.ts b/src/background/service/focus/holder.ts new file mode 100644 index 000000000..1681a520f --- /dev/null +++ b/src/background/service/focus/holder.ts @@ -0,0 +1,246 @@ +import alarmManager from '@bg/alarm-manager' +import db from "@db/focus-record-database" +import metaDatabase from '@db/meta-database' +import { isAlive } from '@util/focus' +import { MILL_PER_SECOND } from '@util/time' + +async function stop(record: tt4b.focus.Session): Promise { + const now = Date.now() + const { state, phase } = record + if (state === 'running') { + await increaseTime(record, now) + } else if (state !== 'paused') { + return + } + record.state = 'stopped' + record.end = now + record.logs.push({ action: 'stop', ts: now, phase }) + record.totalFocus = calcTotalFocus(record.logs) + await db.save(record) +} + +async function increaseTime(record: tt4b.focus.Session, now: number) { + const checkpoint = [...record.logs] + .sort((a, b) => b.ts - a.ts) + .find(({ action }) => action === 'resume' || action === 'start') + if (!checkpoint) { + console.info("[WARNING] NO FOCUS CHECKPOINT FOUND") + return + } + const thisDuration = now - checkpoint.ts + record.currentDuration += thisDuration +} + +function calcTotalFocus(logs: tt4b.focus.Session['logs']) { + let total = 0 + let openTs: number | undefined = undefined + for (const { action, ts, phase } of logs) { + if (phase !== 'focus') { + openTs = undefined + } else if (action === 'start' || action === 'resume') { + openTs = ts + } else if (action === 'finish' || action === 'pause' || action === 'stop') { + if (openTs === undefined) continue + total += ts - openTs + openTs = undefined + } + } + return total +} + +const ALARM_NAME = 'focus-session' +const calcAlarmWhen = (session: tt4b.focus.Session | undefined): number | null => { + if (!session) return null + const { state, duration, currentDuration, break: breakDur, phase } = session + if (state !== 'running') return null + let remaining: number + if (phase === 'focus') { + if (!duration) return null + remaining = duration * MILL_PER_SECOND - currentDuration + } else if (phase === 'break') { + if (!breakDur) return null // Never happen + remaining = breakDur * MILL_PER_SECOND - currentDuration + } else { + return null + } + return Date.now() + remaining +} + +type OnTick = (session: tt4b.focus.Session) => Promise + +class FocusHolder { + #session: tt4b.focus.Session | undefined = undefined + #popup: tt4b.ui.PopupMenu | undefined = undefined + #initialized: boolean = false + #onTick: OnTick | undefined = undefined + + constructor() { + void this.#init() + } + + async #init() { + const records = await db.list({ state: ['running', 'paused'] }) + const [latest, ...others] = records.sort((a, b) => b.end - a.end) + for (const other of others) { + await stop(other) + } + this.#session = latest + + if (this.#session && this.#session.state === 'running') { + await increaseTime(this.#session, Date.now()) + await db.save(this.#session) + await this.#startNewAlarm() + } + + const meta = await metaDatabase.getMeta() + this.#popup = meta.popup + + this.#initialized = true + } + + async #handleAlarmTick(): Promise { + const session = this.#session + if (!session || session.state !== 'running') return + const now = Date.now() + await increaseTime(session, now) + + const { method, phase, duration, break: breakDur, currentDuration } = session + + if (method === 'focus') { + if (!duration) return // Never happen + const remaining = duration * MILL_PER_SECOND - currentDuration + if (remaining >= 0) return // Not yet time to switch phase cause of some reason + session.state = 'done' + session.end = now + session.logs.push({ action: 'finish', ts: now, phase: 'focus' }) + session.totalFocus = calcTotalFocus(session.logs) + await db.save(session) + await alarmManager.remove(ALARM_NAME) + } else if (method === 'pomodoro') { + const realDuration = phase === 'focus' ? duration : breakDur + if (realDuration && currentDuration < realDuration * MILL_PER_SECOND) { + return + } + const prevPhase = phase + const nextPhase: tt4b.focus.Phase = phase === 'focus' ? 'break' : 'focus' + session.logs.push({ action: 'finish', ts: now, phase: prevPhase }) + session.phase = nextPhase + session.currentDuration = 0 + session.logs.push({ action: 'start', ts: now, phase: nextPhase }) + await db.save(session) + } + + await this.#onTick?.(session) + } + + async #startNewAlarm() { + await alarmManager.setWhen( + ALARM_NAME, + () => this.#session ? calcAlarmWhen(this.#session) : null, + () => this.#handleAlarmTick(), + ) + } + + async start(config: tt4b.focus.Config, presetId?: number): Promise { + if (!this.#initialized) throw new Error("Not initialized yet") + if (this.#session) { + const { state } = this.#session + if (state === 'running' || state === 'paused') return + } + + const now = Date.now() + this.#session = { + ...config, + presetId, + start: now, + end: now, + totalFocus: 0, + currentDuration: 0, + phase: 'focus', + state: 'running', + logs: [{ action: 'start', ts: now, phase: 'focus' }], + } + await db.add(this.#session) + + await alarmManager.remove(ALARM_NAME) + await this.#startNewAlarm() + } + + async pause(): Promise { + if (!this.#session) return + if (this.#session.state !== 'running') return + + const now = Date.now() + await increaseTime(this.#session, now) + this.#session.end = now + this.#session.state = 'paused' + this.#session.logs.push({ action: 'pause', ts: now, phase: this.#session.phase }) + await db.save(this.#session) + + await alarmManager.remove(ALARM_NAME) + } + + async resume(): Promise { + if (!this.#session) return + if (this.#session.state !== 'paused') return + + const now = Date.now() + this.#session.state = 'running' + this.#session.logs.push({ action: 'resume', ts: now, phase: this.#session.phase }) + await db.save(this.#session) + + await this.#startNewAlarm() + } + + async stop(): Promise { + if (!this.#session) return + await stop(this.#session) + + await alarmManager.remove(ALARM_NAME) + } + + async delay(): Promise { + if (!this.#session) return + if (this.#session.method !== 'focus') return + if (!this.#session.duration) return + this.#session.duration += 60 * 5 // Add 5 minutes + await db.save(this.#session) + } + + async dismiss(): Promise { + if (this.#session && isAlive(this.#session)) return + this.#session = undefined + } + + set onTick(handler: OnTick | undefined) { + this.#onTick = handler + } + + get current(): tt4b.focus.Session | undefined { + return this.#session + } + + get badge(): string | null { + const session = this.current + if (this.#popup !== 'focus') return null + if (!session) return null + const { state, phase } = session + if (state === 'running') return phase === 'focus' ? '🎯' : '😌' + if (state === 'paused') return '⏸️' + return null + } + + get popup(): tt4b.ui.PopupMenu | undefined { + return this.#popup + } + + set popup(val: tt4b.ui.PopupMenu | undefined) { + this.#popup = val + void metaDatabase.getMeta() + .then(meta => metaDatabase.update({ ...meta, popup: val })) + } +} + +const focusHolder = new FocusHolder() + +export default focusHolder diff --git a/src/background/service/focus/index.ts b/src/background/service/focus/index.ts new file mode 100644 index 000000000..c9ad0f7f8 --- /dev/null +++ b/src/background/service/focus/index.ts @@ -0,0 +1,58 @@ +import { createNotification } from '@api/chrome/notifications' +import { listTabs, trySendMsg2Tab } from '@api/chrome/tab' +import badgeManager from '@bg/badge-manager' +import { t } from '@bg/i18n' +import focusHolder from './holder' + +focusHolder.onTick = async session => { + await badgeManager.render() + await broadcastFocusChanged() + const [title, message] = calcNotification(session) + title && await createNotification('focus', { type: 'basic', title, message }) +} + +function calcNotification(session: tt4b.focus.Session): [title: string, message: string] | [null, null] { + const { state, method, phase } = session + if (state === 'done') { + return [t(msg => msg.notification.focus.completedTitle), t(msg => msg.notification.focus.completedMsg)] + } else if (method === 'pomodoro') { + return phase === 'break' + ? [t(msg => msg.focus.break), t(msg => msg.notification.focus.breakStartMsg)] + : [t(msg => msg.focus.duration), t(msg => msg.notification.focus.focusResumeMsg)] + } + return [null, null] +} + +async function broadcastFocusChanged(): Promise { + const tabs = await listTabs() + const session = focusHolder.current + for (const { id: tabId } of tabs) { + if (!tabId) continue + void trySendMsg2Tab(tabId, 'focusChanged', session) + } +} + +export async function handleAction(request: tt4b.focus.ActionRequest): Promise { + await dispatchAction(request) + await badgeManager.render() + await broadcastFocusChanged() +} + +function dispatchAction(action: tt4b.focus.ActionRequest): Promise { + if (typeof action === 'object') { + return focusHolder.start(action.config, action.presetId) + } + switch (action) { + case 'pause': return focusHolder.pause() + case 'resume': return focusHolder.resume() + case 'stop': return focusHolder.stop() + case 'delay': return focusHolder.delay() + case 'dismiss': return focusHolder.dismiss() + default: return Promise.resolve() + } +} + +export async function saveLastPopup(popup: tt4b.ui.PopupMenu | undefined): Promise { + focusHolder.popup = popup + await badgeManager.render() +} \ No newline at end of file diff --git a/src/background/service/item-service.ts b/src/background/service/item-service.ts index 9650025ba..a68a073d3 100644 --- a/src/background/service/item-service.ts +++ b/src/background/service/item-service.ts @@ -14,8 +14,8 @@ export async function addFocusTime(context: ItemIncContext, focusTime: number): const { host, url, groupId } = context const resultSet: Record = { [host]: resultOf(focusTime, 0) } - const virtualHosts = virtualSiteHolder.findMatched(url) - virtualHosts.forEach(virtualHost => resultSet[virtualHost] = resultOf(focusTime, 0)) + const virtualSites = virtualSiteHolder.findMatched(url) + virtualSites.forEach(({ host }) => resultSet[host] = resultOf(focusTime, 0)) const now = new Date() @@ -34,7 +34,7 @@ export async function addRunTime(host: string, dateTime: Record) 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)) + virtualSiteHolder.findMatched(url).forEach(({ host }) => resultSet[host] = resultOf(0, 1)) const now = new Date() diff --git a/src/background/service/notification/browser/notifier.ts b/src/background/service/notification/browser/notifier.ts index 8075ac771..be31a71b0 100644 --- a/src/background/service/notification/browser/notifier.ts +++ b/src/background/service/notification/browser/notifier.ts @@ -1,9 +1,6 @@ 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 { t } from '@bg/i18n' import { formatPeriodCommon } from '@util/time' import type { NotificationData, NotificationRequest, Notifier } from '../types' @@ -38,24 +35,14 @@ export default class BrowserNotifier implements Notifier { const errMsg = await this.assertPerm() if (errMsg) return errMsg - const { - cycle, - meta: { locale }, - summary: { focus, visit, siteCount }, - } = data + const { cycle, 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 appName = t(msg => msg.meta.name) + const calendar = t(msg => msg.calendar.range[cycle === 'daily' ? 'yesterday' : 'lastWeek']) const title = `${appName} - ${calendar}` const focusStr = formatPeriodCommon(focus, true) + const message = t(msg => msg.notification.dailySummary, { focus: focusStr, visit, siteCount }) - const message = `Focus time: ${focusStr}, Visits: ${visit}, Sites: ${siteCount}` - - await createNotification('time', { - type: 'basic', - iconUrl: getIconUrl(), - title, - message, - }) + await createNotification('time', { type: 'basic', title, message }) } } diff --git a/src/background/service/site-service.ts b/src/background/service/site-service.ts index e5fb102a1..d9d815773 100644 --- a/src/background/service/site-service.ts +++ b/src/background/service/site-service.ts @@ -8,7 +8,7 @@ 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 { extractHostname } from "@util/pattern" import { SiteMap, supportCategory } from "@util/site" import { toUnicode as punyCode2Unicode } from "punycode" import mergeRuleDatabase from '../database/merge-rule-database' @@ -18,22 +18,22 @@ 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: 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: tt4b.site.SiteKey) { - const exist = await siteDatabase.get(key) - if (!exist) return - delete exist.iconUrl - await siteDatabase.save(exist) -} - -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 saveSite(param: tt4b.site.ModifyParam, overwrite: boolean): Promise { + const exist = await siteDatabase.get(param) + const alias = overwrite ? param.alias : exist?.alias ?? param.alias + const iconUrl = param.type === 'normal' + ? (overwrite ? param.iconUrl : exist?.iconUrl ?? param.iconUrl) + : undefined + + // Avoid unnecessary chrome.storage writes + if (!exist) { + if (alias === undefined && iconUrl === undefined) return + } else if (exist.alias === alias && exist.iconUrl === iconUrl) { + return + } + const toSave = { ...exist, ...param, alias, iconUrl } + await siteDatabase.save(toSave) + virtualSiteHolder.buildWith(toSave) } export async function saveSiteRunState(key: tt4b.site.SiteKey, enabled: boolean) { @@ -87,77 +87,43 @@ export async function getSite(siteKey: tt4b.site.SiteKey): Promise(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 { - query = cleanSearchQuery(query) - const filter = query ? (host: string) => host.includes(query) : () => true - const [normal, merged] = await listHosts(filter) +/** + * Detect all sites from stat and site database + */ +export async function detectSites(): Promise { + const [normal, merged] = await listHostsOfStat() const keys: tt4b.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' }) + ALL_FILE_HOSTS.forEach(fileHost => keys.push({ host: fileHost, type: 'normal' })) + 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) return moveToFront(ranked, hitIdx) - if (!query) return ranked - - if (judgeVirtualFast(query) && isValidVirtualHost(query)) { - return [{ host: query, type: 'virtual' }, ...ranked] - } - - const { host } = extractHostname(query) - const hostIdx = ranked.findIndex(r => r.host === host) - if (hostIdx >= 0) return moveToFront(ranked, hostIdx) + const rows = keys.map(k => ({ ...siteMap.remove(k), ...k })) - return [{ host, type: 'normal' }, ...ranked] -} + // Append sites only in site database + siteMap.forEach((_, v) => rows.push(v)) -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 + return rows } /** * 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[]]> { +async function listHostsOfStat(): Promise<[normal: string[], merged: string[]]> { const rows = await statDatabase.select({ virtual: false }) - const hosts = new Set(rows.map(row => row.host)) + const normal = 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) - }) + normal.forEach(host => merged.add(mergeRuler.merge(host))) return [Array.from(normal), Array.from(merged)] } @@ -203,4 +169,20 @@ async function batchSaveAlias(siteMap: SiteMap): Promise { toSave.push({ ...exist ?? k, alias }) }) await siteDatabase.save(...toSave) +} + +export async function getCurrentSite(): Promise { + const tabs = await listTabs({ currentWindow: true, active: true }) + const url = tabs[0]?.url + if (!url) return undefined + const { host } = extractHostname(url) + const normal = await getSite({ host, type: 'normal' }) + + const others = virtualSiteHolder.findMatched(url) + const mergedRules = await mergeRuleDatabase.selectAll() + const mergeRuler = new CustomizedHostMergeRuler(mergedRules) + const merged = mergeRuler.merge(host) + const mergedSite = await getSite({ host: merged, type: 'merged' }) + others.push(mergedSite) + return { url, normal, others } } \ No newline at end of file diff --git a/src/background/service/stat-service/index.ts b/src/background/service/stat-service/index.ts index ce3101f40..6e5c0d661 100644 --- a/src/background/service/stat-service/index.ts +++ b/src/background/service/stat-service/index.ts @@ -10,7 +10,7 @@ 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 { CATE_NOT_SET_ID, distinctSites, SiteMap } from "@util/site" +import { CATE_NOT_SET_ID, SiteMap } from "@util/site" import { isGroup, isSite } from "@util/stat" import { slicePageResult } from "../components/page-info" import { cvt2SiteRow } from "./common" @@ -19,26 +19,23 @@ import { mergeDate } from "./merge/date" import { mergeHost } from "./merge/host" import { processRemote } from "./remote" -function extractAllSiteKeys(rows: tt4b.stat.SiteRow[], container: tt4b.site.SiteKey[]) { - rows.forEach(row => { - const { mergedRows } = row - container.push(row.siteKey) +function extractAllSiteKeys(rows: tt4b.stat.SiteRow[], container: SiteMap) { + rows.forEach(({ mergedRows, siteKey }) => { + container.put(siteKey, siteKey) mergedRows?.length && extractAllSiteKeys(mergedRows, container) }) } function fillRowWithSiteInfo(row: tt4b.stat.SiteRow, siteMap: SiteMap): void { - if (!isSite(row)) return const { siteKey, mergedRows } = row mergedRows?.map(m => fillRowWithSiteInfo(m, siteMap)) const siteInfo = siteMap.get(siteKey) - if (siteInfo) { - const { cate, iconUrl, alias } = siteInfo - row.cateId = cate - row.alias = alias - row.iconUrl = iconUrl - } + if (!siteInfo) return + const { cate, iconUrl, alias } = siteInfo + row.cateId = cate + row.alias = alias + row.iconUrl = iconUrl } function compareSortVal(a: string | number, b: string | number, direction?: tt4b.common.SortDirection): number { @@ -52,6 +49,22 @@ function filterByCateId(itemCateId: number | undefined, cateIds: number[] | unde return cateIds.includes(itemCateId ?? CATE_NOT_SET_ID) } +function filterByValue>( + rows: T[], + param?: Pick, +): T[] { + const { timeRange, focusRange } = param ?? {} + const [fs, fe] = focusRange ?? [] + const [ts, te] = timeRange ?? [] + if ((fs ?? fe ?? ts ?? te) === undefined) return rows + return rows.filter(({ focus, time }) => { + return (fs === undefined || focus >= fs) + && (fe === undefined || focus <= fe) + && (ts === undefined || time >= ts) + && (te === undefined || time <= te) + }) +} + export async function countSite(param?: tt4b.stat.SiteQuery): Promise { const rows = await statDatabase.select(param) return rows.length @@ -61,15 +74,11 @@ export async function selectSite(param?: tt4b.stat.SiteQuery): Promise !host || host === siteHost) + .filter(({ siteKey: { host } }) => !hosts || hosts.includes(host)) .filter(({ siteKey: { host: siteHost }, alias }) => !query || siteHost.includes(query) || !!alias?.includes(query)) .filter(({ cateId }) => filterByCateId(cateId, cateIds)) // Merge by date needMergeDate && (siteRows = mergeDate(siteRows)) + // Value filter + siteRows = filterByValue(siteRows, param) // Sort if (sortKey) { const sortVal = (a: tt4b.stat.SiteRow) => sortKey === 'host' ? a.siteKey.host : a[sortKey] ?? 0 @@ -118,13 +130,14 @@ export async function selectCate(param?: tt4b.stat.CateQuery): Promise !cateIds?.length || cateIds.includes(cateKey)) .filter(({ cateName }) => !query || cateName?.includes(query)) // Merge cates by date again if (needMergeDate) cateRows = mergeDate(cateRows) - + // Value filter + cateRows = filterByValue(cateRows, param) // Sort if (sortKey) { cateRows.sort((a, b) => compareSortVal(a[sortKey] ?? 0, b[sortKey] ?? 0, sortDirection)) @@ -138,11 +151,10 @@ export async function selectCatePage(query?: tt4b.stat.CatePageQuery): Promise { - let keys: tt4b.site.SiteKey[] = [] + const keys = new SiteMap() extractAllSiteKeys(rows, keys) - keys = distinctSites(keys) - const sites = await siteDatabase.getBatch(keys) + const sites = await siteDatabase.getBatch(keys.keys()) const siteMap = SiteMap.identify(sites) rows.forEach(item => fillRowWithSiteInfo(item, siteMap)) @@ -152,10 +164,9 @@ async function fillSite(rows: tt4b.stat.SiteRow[]): Promise { export async function selectGroup(param?: tt4b.stat.GroupQuery): Promise { const { date, query, mergeDate: needMergeDate, - focusRange, timeRange, - sortKey, sortDirection, + sortKey, sortDirection, groupIds } = param ?? {} - const list = await statDatabase.selectGroup({ date, focusRange, timeRange }) + const list = await statDatabase.selectGroup({ date, keys: groupIds?.map(String) }) const groups = await listAllGroups() const groupMap = toMap(groups, g => g.id) let rows: tt4b.stat.GroupRow[] = list.map(({ date, time, focus, run, host }) => { @@ -165,6 +176,7 @@ export async function selectGroup(param?: tt4b.stat.GroupQuery): Promise !query || title?.includes(query)) needMergeDate && (rows = mergeDate(rows)) + rows = filterByValue(rows, param) if (sortKey) { rows.sort((a, b) => compareSortVal(a[sortKey] ?? 0, b[sortKey] ?? 0, sortDirection)) } @@ -177,22 +189,18 @@ export async function selectGroupPage(param?: tt4b.stat.GroupPageQuery) { } export async function countGroup(param?: tt4b.stat.GroupQuery): Promise { - const { groupIds, date } = param ?? {} - const keys = groupIds?.map(gid => `${gid}`) - const rows = await statDatabase.selectGroup({ keys, date }) + const rows = await selectGroup(param) return rows.length } export async function batchDelete(targets: tt4b.stat.StatKey[]) { - if (!targets?.length) return const siteKeys: tt4b.core.RowKey[] = [] const groupKeys: [groupId: number, date: string][] = [] targets.forEach(row => { const { date } = row - if (!date) return isSite(row) && siteKeys.push({ host: row.siteKey.host, date }) isGroup(row) && groupKeys.push([row.groupKey, date]) }) await statDatabase.delete(...siteKeys) await statDatabase.deleteGroup(...groupKeys) -} \ No newline at end of file +} diff --git a/src/background/service/whitelist/holder.ts b/src/background/service/whitelist/holder.ts index c974138c6..3280de659 100644 --- a/src/background/service/whitelist/holder.ts +++ b/src/background/service/whitelist/holder.ts @@ -19,8 +19,8 @@ class WhitelistHolder { this.rebuild() } - private async rebuild() { - const whitelist = await db.selectAll() + private async rebuild(whitelist?: string[]) { + whitelist ??= await db.selectAll() this.processor.setWhitelist(whitelist) this.postHandlers.forEach(handler => handler(whitelist)) } @@ -38,6 +38,11 @@ class WhitelistHolder { return db.selectAll() } + async saveAll(toSave: string[]): Promise { + await db.saveAll(toSave) + await this.rebuild(toSave) + } + async remove(white: string): Promise { await db.remove(white) await this.rebuild() diff --git a/src/background/track-server/group.ts b/src/background/track-server/group.ts index b8648eeac..f72a14e7c 100644 --- a/src/background/track-server/group.ts +++ b/src/background/track-server/group.ts @@ -10,7 +10,7 @@ function handleTabGroupsEnabled(option: tt4b.option.TrackingOption) { chrome.tabGroups.onRemoved.removeListener(handleRemove) chrome.tabGroups.onRemoved.addListener(handleRemove) } catch (e) { - console.warn('failed to handle event: enableTabGroup', e) + console.info('failed to handle event: enableTabGroup', e) } } diff --git a/src/background/whitelist-menu-manager.ts b/src/background/whitelist-menu-manager.ts index a9b058d03..c11166e49 100644 --- a/src/background/whitelist-menu-manager.ts +++ b/src/background/whitelist-menu-manager.ts @@ -8,9 +8,9 @@ import { createContextMenu, updateContextMenu } from "@api/chrome/context-menu" import { getRuntimeId } from "@api/chrome/runtime" import { getTab, onTabActivated, onTabUpdated } from "@api/chrome/tab" -import { t2Chrome } from "@i18n/chrome/t" -import { IS_ANDROID } from "@util/constant/environment" -import { extractHostname, isBrowserUrl } from "@util/pattern" +import { IS_ANDROID, isNotTrackable } from "@util/constant/environment" +import { extractHostname } from "@util/pattern" +import { t } from './i18n' import optionHolder from "./service/components/option-holder" import whitelistHolder from './service/whitelist/holder' @@ -29,15 +29,14 @@ async function updateContextMenuInner(param: ChromeTab | number | undefined): Pr const tab = typeof param === 'number' ? await getTab(currentActiveId) : param const { url } = tab ?? {} - const targetHost = url && !isBrowserUrl(url) ? extractHostname(url).host : undefined + const host = url && !isNotTrackable(url) ? extractHostname(url).host : undefined const visible = (await optionHolder.get())?.displayWhitelistMenu const changeProp: ChromeContextMenuUpdateProps = {} - if (targetHost && visible) { - const exist = whitelistHolder.containsHost(targetHost) + if (host && visible) { + const exist = whitelistHolder.containsHost(host) changeProp.visible = visible - changeProp.title = t2Chrome(root => root.contextMenus[exist ? 'removeFromWhitelist' : 'add2Whitelist']) - .replace('{host}', targetHost) - changeProp.onclick = () => exist ? whitelistHolder.remove(targetHost) : whitelistHolder.add(targetHost) + changeProp.title = t(msg => msg.contextMenus[exist ? 'removeFromWhitelist' : 'add2Whitelist'], { host }) + changeProp.onclick = () => exist ? whitelistHolder.remove(host) : whitelistHolder.add(host) } else { // If not a valid host, hide this menu changeProp.visible = false diff --git a/src/content-script/index.ts b/src/content-script/index.ts index bf05214f8..ae40cd1cb 100644 --- a/src/content-script/index.ts +++ b/src/content-script/index.ts @@ -9,40 +9,38 @@ import { trySendMsg2Runtime } from '@api/sw/common' import { initLocale } from "@i18n" import Dispatcher from './dispatcher' import processLimit from "./limit" +import LocationWatcher from './location-watcher' import printInfo from "./printer" import processTimeline from './timeline' import NormalTracker from "./tracker/normal" import RunTimeTracker from "./tracker/run-time" -const host = document?.location?.host -const url = document?.location?.href - const FLAG_ID = '__TIMER_INJECTION_FLAG__' + chrome.runtime.id function getOrSetFlag(): boolean { - const pre = document?.getElementById(FLAG_ID) - if (!pre) { - const flag = document.createElement('span') - flag.style && (flag.style.visibility = 'hidden') - flag && (flag.id = FLAG_ID) + const existed = document?.getElementById(FLAG_ID) + if (existed) return true + + const flag = document.createElement('span') + flag.style && (flag.style.visibility = 'hidden') + flag && (flag.id = FLAG_ID) - if (document.readyState === "complete") { - document?.body?.appendChild(flag) - } else { - const oldListener = document.onreadystatechange - document.onreadystatechange = function (ev) { - oldListener?.call(this, ev) - document.readyState === "complete" && document?.body?.appendChild(flag) - } + if (document.readyState === "complete") { + document?.body?.appendChild(flag) + } else { + const oldListener = document.onreadystatechange + document.onreadystatechange = function (ev) { + oldListener?.call(this, ev) + document.readyState === "complete" && document?.body?.appendChild(flag) } } - return !!pre + return false } async function main() { const dispatcher = new Dispatcher() - // Execute in every injections + // Execute in every injection const normalTracker = new NormalTracker({ onReport: data => trySendMsg2Runtime('track.time', data), onResume: reason => reason === 'idle' && trySendMsg2Runtime('cs.idleChanged', false), @@ -50,23 +48,24 @@ async function main() { }) normalTracker.init() dispatcher.registerAudibleChange(normalTracker) - new RunTimeTracker(url).init(dispatcher) + + const location = new LocationWatcher() + await location.init() + + new RunTimeTracker(location).init(dispatcher) // Execute only one time for each dom if (getOrSetFlag()) return - if (!host) return - - const isWhitelist = await trySendMsg2Runtime('whitelist.contain', { host, url }) - if (isWhitelist) return - initLocale() - printInfo(host) - await processLimit(url, dispatcher) + void initLocale() + await processLimit(location, dispatcher) + if (location.whitelisted) return + void printInfo(location.host) processTimeline() // Increase visit count at the end await trySendMsg2Runtime('cs.injected') } -main() +void main() diff --git a/src/content-script/limit/common.ts b/src/content-script/limit/common.ts index 8f9cc2ecf..47aca642c 100644 --- a/src/content-script/limit/common.ts +++ b/src/content-script/limit/common.ts @@ -1,6 +1,12 @@ -import type { LimitReason } from './types' +import type { Reason } from './types' + +export function isSameReason(a: Reason, b: Reason): boolean { + if (a.type === 'FOCUS' && b.type === 'FOCUS') { + // There is at most one focus reason, so just return true + return true + } + if (a.type === 'FOCUS' || b.type === 'FOCUS') return false -export function isSameReason(a: LimitReason, b: LimitReason): boolean { if (a?.id !== b?.id || a?.type !== b?.type) return false if (a?.type === 'DAILY' || a?.type === 'VISIT') { // Need judge allow delay diff --git a/src/content-script/limit/index.ts b/src/content-script/limit/index.ts index fabea9e7b..7a4796979 100644 --- a/src/content-script/limit/index.ts +++ b/src/content-script/limit/index.ts @@ -1,35 +1,35 @@ 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 LocationWatcher from '../location-watcher' +import ModalManager from './manager' +import DelayCoordinator from './manager/delay-coordinator' +import LimitState from './manager/state' +import { DailyWeeklyProcessor, FocusProcessor, PeriodProcessor, VisitProcessor } from './processor' import Reminder from './reminder' -import type { ModalContext, Processor } from './types' -export default async function processLimit(url: string, dispatcher: Dispatcher) { +export default async function processLimit(location: LocationWatcher, dispatcher: Dispatcher) { const { limitDelayDuration: delayDuration } = await getOption() - const modal = new ModalInstance(url) - const context: ModalContext = { modal, url } + const state = new LimitState() + const delayCoord = new DelayCoordinator() - const mesageAdaptor = new MessageAdaptor(context, delayDuration) - const visitProcessor = new VisitProcessor(context, delayDuration) + const dailyWeeklyPsr = new DailyWeeklyProcessor(state, delayCoord, location, delayDuration) + const visitPsr = new VisitProcessor(state, delayCoord, location, delayDuration) + const focusPsr = new FocusProcessor(state, location) + const periodPsr = new PeriodProcessor(state, delayCoord, location, delayDuration) - const processors: Processor[] = [ - mesageAdaptor, - visitProcessor, - new PeriodProcessor(context), - ] + const processors = [dailyWeeklyPsr, visitPsr, periodPsr, focusPsr] await Promise.all(processors.map(p => p.init())) + location.onChange(() => void processors.forEach(p => void p.reset())) + + new ModalManager(location).init(state, delayCoord, visitPsr) const reminder = new Reminder() dispatcher - .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) - - return visitProcessor.tracker + .register('limitChanged', () => processors.forEach(p => void p.reset())) + .register('limitTimeMeet', items => dailyWeeklyPsr.onTimeMeet(items)) + .register('limitReminder', data => reminder.show(data)) + .register('askVisitHit', ruleId => state.reasons.some(r => r.type === 'VISIT' && ruleId === r.id)) + .register('focusChanged', session => focusPsr.onFocusChanged(session)) + .registerAudibleChange(visitPsr) } diff --git a/src/content-script/limit/manager/delay-coordinator.ts b/src/content-script/limit/manager/delay-coordinator.ts new file mode 100644 index 000000000..d280d7309 --- /dev/null +++ b/src/content-script/limit/manager/delay-coordinator.ts @@ -0,0 +1,20 @@ +import type { LimitReason } from '../types' + +class DelayCoordinator { + #handlers: Map> = new Map() + + process(reason: LimitReason) { + const handlers = this.#handlers.get(reason.type) + handlers?.forEach(h => h()) + } + + register(handler: NoArgCallback, ...types: tt4b.limit.ReasonType[]): void { + types.forEach(type => { + const handlers = this.#handlers.get(type) ?? new Set() + handlers.add(handler) + this.#handlers.set(type, handlers) + }) + } +} + +export default DelayCoordinator \ No newline at end of file diff --git a/src/content-script/limit/manager/index.ts b/src/content-script/limit/manager/index.ts new file mode 100644 index 000000000..203236695 --- /dev/null +++ b/src/content-script/limit/manager/index.ts @@ -0,0 +1,128 @@ +import { getUrl } from '@api/chrome/runtime' +import { trySendMsg2Runtime } from '@api/sw/common' +import LocationWatcher from '@cs/location-watcher' +import { ModalBridge } from '../modal/bridge' +import { VisitProcessor } from '../processor' +import type { Reason } from '../types' +import DelayCoordinator from './delay-coordinator' +import ScreenLocker from './screen-locker' +import LimitState from './state' + +const MODAL_URL = getUrl('static/limit.html') +const MSG_ORIGIN = new URL(MODAL_URL).origin +const TAG_NAME = 'extension-time-tracker-overlay' + +class RootElement extends HTMLElement { + constructor() { + super() + } +} + +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 +} + +class ModalManager { + #el?: RootElement + #iframe?: HTMLIFrameElement + #sl = new ScreenLocker() + #bridge: ModalBridge + + constructor(private location: LocationWatcher) { + this.#bridge = new ModalBridge(MSG_ORIGIN, () => this.#iframe?.contentWindow ?? undefined) + + location.onChange(({ nextUrl }) => this.#notifyUrl(nextUrl)) + } + + init(state: LimitState, delayCoord: DelayCoordinator, visitProcessor: VisitProcessor) { + this.#bridge + .register('delay', reason => delayCoord.process(reason)) + // fixme: refactor this, this action should be handled by the focus processor + .register('stop', () => trySendMsg2Runtime('focus.action', 'stop')) + + this.#notifyUrl(this.location.url) + + visitProcessor.onChange(time => this.#notifyVisitTime(time)) + state.onChange(current => current ? this.#show(current) : this.#hide()) + } + + #notifyUrl(url: string): void { + if (!this.#iframe?.contentWindow) return + this.#bridge.request('url', url).catch(() => { }) + } + + #notifyVisitTime(time: number): void { + if (!this.#iframe?.contentWindow) return + this.#bridge.request('visitTime', time).catch(() => { }) + } + + #notifyReason(reason: Reason | undefined) { + if (!this.#iframe?.contentWindow) return + this.#bridge.request('reason', reason).catch(() => { }) + } + + async #initFrame(): Promise { + const root = await this.prepareRoot() + if (!root) return + const iframe = document.createElement('iframe') + iframe.src = `${MODAL_URL}?url=${encodeURIComponent(this.location.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.#el ?? document.querySelector(TAG_NAME) as RootElement + if (exist) { + this.#el = exist + if (!document.body.contains(exist)) { + document.body.appendChild(exist) + } + return exist.shadowRoot + } + this.#el = createRootElement() + document.body.appendChild(this.#el) + return this.#el.attachShadow({ mode: 'open' }) + } + if (document.body) return inner() + + return new Promise(resolve => { + window.addEventListener('load', () => resolve(inner())) + }) + } + + async #show(reason: Reason) { + if (!this.#el) { + await this.#initFrame() + } else if (!document.body.contains(this.#el)) { + document.body.appendChild(this.#el) + } + + this.#el && (this.#el.style.visibility = 'visible') + await this.#sl.lock() + this.#iframe && (this.#iframe.style.visibility = 'visible') + this.#notifyReason(reason) + } + + #hide() { + this.#el && (this.#el.style.visibility = 'hidden') + this.#sl.unlock() + this.#iframe && (this.#iframe.style.visibility = 'hidden') + this.#notifyReason(undefined) + } +} + +export default ModalManager diff --git a/src/content-script/limit/manager/screen-locker.ts b/src/content-script/limit/manager/screen-locker.ts new file mode 100644 index 000000000..65c7f6404 --- /dev/null +++ b/src/content-script/limit/manager/screen-locker.ts @@ -0,0 +1,56 @@ +import { getRuntimeId } from '@api/chrome/runtime' +import { exitFullscreen } from '../common' + +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 { } + }) +} + +class ScreenLocker { + static #styleId = `time-tracker-style-${getRuntimeId()}` + static #lockedCls = `time-tracker-locked-${getRuntimeId()}` + + async lock() { + await exitFullscreen() + pauseAllVideo() + pauseAllAudio() + + this.insertStyle() + document?.documentElement?.classList?.add?.(ScreenLocker.#lockedCls) + } + + unlock() { + document?.documentElement?.classList?.remove(ScreenLocker.#lockedCls) + } + + private insertStyle() { + if (!document) return + if (document.getElementById(ScreenLocker.#styleId)) return + const style = document.createElement('style') + style.id = ScreenLocker.#styleId + const css = ` + .${ScreenLocker.#lockedCls} { + overflow: hidden !important; + } + ` + style.appendChild(document.createTextNode(css)) + document.head?.appendChild(style) + } +} + +export default ScreenLocker \ No newline at end of file diff --git a/src/content-script/limit/manager/state.ts b/src/content-script/limit/manager/state.ts new file mode 100644 index 000000000..8fcbd120f --- /dev/null +++ b/src/content-script/limit/manager/state.ts @@ -0,0 +1,50 @@ +import { isSameReason } from '../common' +import { Reason, ReasonType } from '../types' + +const TYPE_SORT: Record = { + FOCUS: -1, + PERIOD: 0, + VISIT: 1, + DAILY: 2, + WEEKLY: 3, +} + +class LimitState { + #items: Reason[] = [] + #listener?: ArgCallback + + get reasons(): Readonly { + return this.#items + } + + onChange(listener: ArgCallback) { + this.#listener = listener + this.#notify() + } + + add(...reasons: Reason[]): void { + const filtered = reasons.filter(r => !this.#items.some(item => isSameReason(item, r))) + if (!filtered.length) return + this.#items.push(...filtered) + this.#items.sort((a, b) => TYPE_SORT[a.type] - TYPE_SORT[b.type]) + this.#notify() + } + + remove(...reasons: Reason[]): void { + if (!reasons.length) return + this.#items = this.#items.filter(item => !reasons.some(r => isSameReason(item, r))) + this.#notify() + } + + removeByType(...types: ReasonType[]): void { + if (!types.length) return + this.#items = this.#items.filter(item => !types.includes(item.type)) + this.#notify() + } + + #notify() { + this.#listener?.(this.#items[0]) + } +} + +export default LimitState \ No newline at end of file diff --git a/src/content-script/limit/modal/Main.tsx b/src/content-script/limit/modal/Main.tsx index ba9653a9a..6c3ff8f23 100644 --- a/src/content-script/limit/modal/Main.tsx +++ b/src/content-script/limit/modal/Main.tsx @@ -1,24 +1,20 @@ import "@pages/element-ui/dark-theme.css" import { defineComponent } from "vue" -import Alert from "./components/Alert" -import Footer from "./components/Footer" -import Reason from "./components/Reason" +import FocusView from './components/FocusView' +import LimitView from './components/LimitView' import { provideRule } from './context' import "./style/element-base.css" import "./style/modal.css" const _default = defineComponent(() => { - provideRule() + const reason = provideRule() - return () => ( -
-
- - -
-
-
- ) + return () => { + const val = reason.value + if (!val) return null + const view = val.type === 'FOCUS' ? : + return
{view}
+ } }) export default _default \ No newline at end of file diff --git a/src/content-script/limit/modal/bridge.ts b/src/content-script/limit/modal/bridge.ts index 3934c666e..705a7ba4a 100644 --- a/src/content-script/limit/modal/bridge.ts +++ b/src/content-script/limit/modal/bridge.ts @@ -1,4 +1,23 @@ -import type { BridgeCode, BridgeHandler, BridgeRequest, BridgeResponse } from './types' +import type { LimitReason, Reason } from '../types' + +type Handler = { + req: Input + res: Output +} + +type MakeRegistry = Record> + +type BridgeRegistry = + & MakeRegistry<'reason', Reason | undefined> + & MakeRegistry<'visitTime', number> + & MakeRegistry<'delay', LimitReason> + & MakeRegistry<'url', string> + & MakeRegistry<'stop'> + +type BridgeCode = keyof BridgeRegistry +type BridgeRequest = BridgeRegistry[C]['req'] +type BridgeResponse = BridgeRegistry[C]['res'] +type BridgeHandler = (req: BridgeRequest) => Awaitable> type RpcBase = { code: C @@ -37,12 +56,6 @@ export class ModalBridge { 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 diff --git a/src/content-script/limit/modal/components/Alert.tsx b/src/content-script/limit/modal/components/Alert.tsx index 247398a05..16ff6608c 100644 --- a/src/content-script/limit/modal/components/Alert.tsx +++ b/src/content-script/limit/modal/components/Alert.tsx @@ -1,36 +1,34 @@ import { getIconUrl } from "@api/chrome/runtime" -import { getOption } from "@api/sw/option" import { t } from "@cs/locale" -import { useRequest, useXsState } from "@hooks" +import { useXsState } from "@hooks" import Box from '@pages/components/Box' import Flex from '@pages/components/Flex' 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 getOption() - return option?.limitPrompt ?? defaultPrompt - }, { defaultValue: defaultPrompt }) +type Props = { + prompt?: string +} +const _default = defineComponent((props, ctx) => { const isXs = useXsState() return () => ( - + {t(msg => msg.meta.name)?.toUpperCase()} - {prompt.value} + {ctx.slots.default?.() ?? props.prompt ?? ''} ) -}) +}, { props: ['prompt'] }) export default _default \ No newline at end of file diff --git a/src/content-script/limit/modal/components/FocusView/Reason.tsx b/src/content-script/limit/modal/components/FocusView/Reason.tsx new file mode 100644 index 000000000..2f37da0ab --- /dev/null +++ b/src/content-script/limit/modal/components/FocusView/Reason.tsx @@ -0,0 +1,59 @@ +import type { FocusReason } from '@cs/limit/types' +import { t } from '@cs/locale' +import Flex from '@pages/components/Flex' +import { matchUrl } from '@util/limit' +import { formatPeriodCommon, MILL_PER_SECOND } from '@util/time' +import { ElDescriptions, ElDescriptionsItem, ElTag } from 'element-plus' +import { defineComponent } from 'vue' +import { useApp } from '../../context' +import { useDescriptions } from '../common' + +const Reason = defineComponent<{ value: FocusReason }>(props => { + const { url } = useApp() + const descProps = useDescriptions() + return () => ( + + + msg.focus.presetName)} + labelAlign='right' + > + {props.value.presetName} + + msg.focus.policy[props.value.policy].label)} + labelAlign='right' + > + + {props.value.cond.map(c => ( + + {c} + + ))} + + + msg.focus.duration)} + labelAlign='right' + > + {props.value.duration + ? formatPeriodCommon(props.value.duration * MILL_PER_SECOND) + : t(msg => msg.shared.unlimited)} + + msg.focus.break)} + labelAlign='right' + > + {formatPeriodCommon((props.value.break ?? 0) * MILL_PER_SECOND)} + + + + ) +}, { props: ['value'] }) + +export default Reason \ No newline at end of file diff --git a/src/content-script/limit/modal/components/FocusView/index.tsx b/src/content-script/limit/modal/components/FocusView/index.tsx new file mode 100644 index 000000000..ff86f47cf --- /dev/null +++ b/src/content-script/limit/modal/components/FocusView/index.tsx @@ -0,0 +1,35 @@ +import type { FocusReason } from '@cs/limit/types' +import { t } from '@cs/locale' +import ConfirmButton from '@pages/components/ConfirmButton' +import Flex from '@pages/components/Flex' +import { ElTag } from 'element-plus' +import { defineComponent } from 'vue' +import { useApp } from '../../context' +import Alert from '../Alert' +import Reason from './Reason' + +const FocusView = defineComponent<{ value: FocusReason }>(props => { + const { bridge } = useApp() + + return () => <> + + + {t(msg => msg.focus.method[props.value.method].label)} + + {t(msg => msg.focus.state[props.value.state])} + + + + + + msg.focus.button.stop)} + buttonProps={{ type: 'danger' }} + onConfirm={() => bridge.request('stop', undefined)} + data-testid='stop-btn' + /> + + +}, { props: ['value'] }) + +export default FocusView \ No newline at end of file diff --git a/src/content-script/limit/modal/components/Footer.tsx b/src/content-script/limit/modal/components/LimitView/Footer.tsx similarity index 68% rename from src/content-script/limit/modal/components/Footer.tsx rename to src/content-script/limit/modal/components/LimitView/Footer.tsx index a1c212e16..fde1bf3aa 100644 --- a/src/content-script/limit/modal/components/Footer.tsx +++ b/src/content-script/limit/modal/components/LimitView/Footer.tsx @@ -1,4 +1,6 @@ -import { APP_ANALYSIS_ROUTE, APP_LIMIT_ROUTE, type AppAnalysisQuery, type AppLimitQuery } from '@/shared/route' +import { + APP_LIMIT_ROUTE, APP_SITE_ANALYSIS_ROUTE, type AppLimitQuery, type AppSiteAnalysisQuery, +} from '@/shared/route' import { trySendMsg2Runtime } from '@api/sw/common' import { processVerification } from '@app/util/limit' import { t } from "@cs/locale" @@ -9,21 +11,22 @@ import { getAppPageUrl } from '@util/constant/url' import { meetTimeLimit } from '@util/limit' import { MILL_PER_SECOND } from '@util/time' import { ElButton } from "element-plus" -import { computed, defineComponent } from "vue" -import { useApp, useRule } from '../context' +import { computed, defineComponent, toRaw } from "vue" +import { useApp, useRule } from '../../context' +import { useLimitReason } from './context' const _default = defineComponent(() => { - const { reason, visitTime: currVisitTime, bridge, url, delayDuration } = useApp() + const { visitTime: currVisitTime, bridge, url, delayDuration } = useApp() + const reason = useLimitReason() - const analysisUrl = getAppPageUrl(APP_ANALYSIS_ROUTE, { url } satisfies AppAnalysisQuery) - const ruleUrl = getAppPageUrl(APP_LIMIT_ROUTE, { url: encodeURI(url) } satisfies AppLimitQuery) + const analysisUrl = computed(() => getAppPageUrl(APP_SITE_ANALYSIS_ROUTE, { url: url.value } satisfies AppSiteAnalysisQuery)) + const ruleUrl = computed(() => getAppPageUrl(APP_LIMIT_ROUTE, { url: encodeURI(url.value) } satisfies AppLimitQuery)) const rule = useRule() const showDelay = computed(() => { - const reasonVal = reason.value - if (!reasonVal) return false - const { type, allowDelay, delayCount = 0 } = reasonVal + const { type, allowDelay, delayCount = 0 } = reason.value if (!allowDelay) return false + if (type === 'PERIOD') return true const { time, weekly, visitTime, waste, weeklyWaste } = rule.value ?? {} let maxLimitMs = 0, wasted = 0 @@ -41,7 +44,7 @@ const _default = defineComponent(() => { } return meetTimeLimit( { wasted, maxLimit: maxLimitMs }, - { count: delayCount, duration: delayDuration.value, allow: !!allowDelay }, + { count: delayCount, duration: delayDuration.value, allow: true }, ) }) @@ -49,16 +52,16 @@ const _default = defineComponent(() => { const option = await trySendMsg2Runtime('option.get') try { if (option) await processVerification(option) - await bridge.request('delay', undefined) + await bridge.request('delay', toRaw(reason.value)) } catch { } } return () => ( - + - {t(msg => msg.menu.siteAnalysis)} + {t(msg => msg.menu.analysis)} { > {t(msg => msg.modal.delay, { n: delayDuration.value })} - + {t(msg => msg.modal.ruleDetail)} diff --git a/src/content-script/limit/modal/components/LimitView/Reason.tsx b/src/content-script/limit/modal/components/LimitView/Reason.tsx new file mode 100644 index 000000000..3cc3263b3 --- /dev/null +++ b/src/content-script/limit/modal/components/LimitView/Reason.tsx @@ -0,0 +1,139 @@ +import { t } from "@cs/locale" +import Flex from "@pages/components/Flex" +import { period2Str } from '@pages/util/limit' +import { matchCond, meetLimit, meetTimeLimit } from "@util/limit" +import { formatPeriodCommon, MILL_PER_SECOND } from "@util/time" +import { ElDescriptions, ElDescriptionsItem, ElTag } from 'element-plus' +import { computed, defineComponent } from "vue" +import { useApp, useRule } from '../../context' +import { useDescriptions } from '../common' +import { useLimitReason } from './context' + +const renderBaseItems = (rule: tt4b.limit.Rule | undefined, url: string) => <> + msg.limit.item.name)} labelAlign="right"> + {rule?.name ?? '-'} + + msg.limit.item.condition)} labelAlign='right'> + {matchCond(rule?.cond ?? [], url).join(', ')} + + + +type DescriptionProps = { + time?: number + waste?: number + count?: number + visit?: number + ruleLabel?: string + dataLabel?: string +} + +const TimeDescriptions = defineComponent(props => { + const { url, delayDuration } = useApp() + const reason = useLimitReason() + const rule = useRule() + const descProps = useDescriptions() + + 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 () => ( + + {renderBaseItems(rule.value, url.value)} + + + {formatPeriodCommon((props.time ?? 0) * MILL_PER_SECOND)} + {t(msg => msg.shared.limit.visits, { n: props.count })} + + + + + + {formatPeriodCommon(props.waste ?? 0)} + + + {t(msg => msg.shared.limit.visits, { n: props.visit ?? 0 })} + + + + msg.limit.item.delayCount)} + labelAlign="right" + > + {reason.value?.delayCount ?? 0} + + + ) +}, { props: ['time', 'waste', 'count', 'visit', 'ruleLabel', 'dataLabel'] }) + +const _default = defineComponent(() => { + const { visitTime, url } = useApp() + const reason = useLimitReason() + const type = computed(() => reason.value.type) + const rule = useRule() + + const descProps = useDescriptions() + + return () => ( + + msg.shared.limit.daily)} + dataLabel={t(msg => msg.calendar.range.today)} + /> + msg.shared.limit.weekly)} + dataLabel={t(msg => msg.calendar.range.thisWeek)} + /> + + {renderBaseItems(rule.value, url.value)} + msg.limit.item.visitTime)} labelAlign="right"> + {formatPeriodCommon((rule.value?.visitTime ?? 0) * MILL_PER_SECOND) || '-'} + + msg.modal.browsingTime)} labelAlign="right"> + {visitTime.value ? formatPeriodCommon(visitTime.value) : '-'} + + msg.limit.item.delayCount)} labelAlign="right"> + {reason.value.delayCount ?? 0} + + + + {renderBaseItems(rule.value, url.value)} + msg.shared.limit.period)} labelAlign="right"> + {rule.value?.periods?.length + ?
+ {rule.value.periods.map(p => {period2Str(p)})} +
+ : '-' + } +
+
+
+ ) +}) + +export default _default \ No newline at end of file diff --git a/src/content-script/limit/modal/components/LimitView/context.ts b/src/content-script/limit/modal/components/LimitView/context.ts new file mode 100644 index 000000000..2142f50bb --- /dev/null +++ b/src/content-script/limit/modal/components/LimitView/context.ts @@ -0,0 +1,15 @@ +import type { LimitReason } from '@cs/limit/types' +import { useProvide, useProvider } from '@hooks' +import type { ShallowRef } from 'vue' + +type ContextValue = { + reason: ShallowRef +} + +const NAMESPACE = 'limit-reason' + +export const injectLimitReason = (reason: ShallowRef) => { + useProvide(NAMESPACE, { reason }) +} + +export const useLimitReason = () => useProvider(NAMESPACE, 'reason').reason \ No newline at end of file diff --git a/src/content-script/limit/modal/components/LimitView/index.tsx b/src/content-script/limit/modal/components/LimitView/index.tsx new file mode 100644 index 000000000..f92039bcd --- /dev/null +++ b/src/content-script/limit/modal/components/LimitView/index.tsx @@ -0,0 +1,32 @@ +import { getOption } from '@api/sw/option' +import type { LimitReason } from '@cs/limit/types' +import { t } from '@cs/locale' +import { useRequest } from '@hooks' +import { defineComponent, toRef } from 'vue' +import Alert from '../Alert' +import Footer from './Footer' +import Reason from './Reason' +import { injectLimitReason } from './context' + +const usePrompt = () => { + const defaultPrompt = t(msg => msg.modal.defaultPrompt) + const { data: prompt } = useRequest(async () => { + const option = await getOption() + return option?.limitPrompt ?? defaultPrompt + }, { defaultValue: defaultPrompt }) + return prompt +} + +const LimitView = defineComponent<{ value: LimitReason }>(props => { + const value = toRef(props, 'value') + injectLimitReason(value) + const prompt = usePrompt() + + return () => <> + + +