diff --git a/.commitlintrc.ts b/.commitlintrc.ts index 281aaf6ce..c916b3617 100644 --- a/.commitlintrc.ts +++ b/.commitlintrc.ts @@ -1,30 +1,22 @@ -import { - RuleConfigCondition, - RuleConfigSeverity, - TargetCaseType -} from "@commitlint/types" +import { RuleConfigSeverity, type UserConfig } from "@commitlint/types" -export default { +const config: UserConfig = { rules: { - "body-leading-blank": [RuleConfigSeverity.Error, "always"] as const, - "body-max-line-length": [RuleConfigSeverity.Error, "always", 100] as const, - "footer-leading-blank": [RuleConfigSeverity.Warning, "never"] as const, - "footer-max-line-length": [ - RuleConfigSeverity.Error, - "always", - 100, - ] as const, - "header-max-length": [RuleConfigSeverity.Error, "always", 100] as const, - "header-trim": [RuleConfigSeverity.Error, "always"] as const, + "body-leading-blank": [RuleConfigSeverity.Error, "always"], + "body-max-line-length": [RuleConfigSeverity.Error, "always", 100], + "footer-leading-blank": [RuleConfigSeverity.Warning, "never"], + "footer-max-line-length": [RuleConfigSeverity.Error, "always", 100], + "header-max-length": [RuleConfigSeverity.Error, "always", 100], + "header-trim": [RuleConfigSeverity.Error, "always"], "subject-case": [ RuleConfigSeverity.Error, "never", ["sentence-case", "start-case", "pascal-case", "upper-case"], - ] as [RuleConfigSeverity, RuleConfigCondition, TargetCaseType[]], - "subject-empty": [RuleConfigSeverity.Error, "never"] as const, - "subject-full-stop": [RuleConfigSeverity.Error, "never", "."] as const, - "type-case": [RuleConfigSeverity.Error, "always", "lower-case"] as const, - "type-empty": [RuleConfigSeverity.Error, "never"] as const, + ], + "subject-empty": [RuleConfigSeverity.Error, "never"], + "subject-full-stop": [RuleConfigSeverity.Error, "never", "."], + "type-case": [RuleConfigSeverity.Error, "always", "lower-case"], + "type-empty": [RuleConfigSeverity.Error, "never"], "type-enum": [ RuleConfigSeverity.Error, "always", @@ -42,7 +34,9 @@ export default { "test", "i18n", ], - ] satisfies [RuleConfigSeverity, RuleConfigCondition, string[]], + ] }, prompt: {}, -} \ No newline at end of file +} + +export default config \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/bug-report---bug---.md b/.github/ISSUE_TEMPLATE/bug-report---bug---.md deleted file mode 100644 index 3f8052829..000000000 --- a/.github/ISSUE_TEMPLATE/bug-report---bug---.md +++ /dev/null @@ -1,17 +0,0 @@ ---- -name: Bug report / BUG 提交 -about: Create a report to help us improve / 帮助我们改善扩展功能 -title: '[BUG]' -labels: '' -assignees: '' ---- - -## Tell me the environment / 请先填写版本信息 - -Version: [Extension version / 扩展版本] -OS: [操作系统:Mac/Linux/Win] -Browser: [浏览器名称:Chrome/Edge/Firefox]-[浏览器版本,选填] - -## Detail / Bug 详情 - -[Describe the bug / 描述您使用过程中遇到的问题] diff --git a/.github/ISSUE_TEMPLATE/bug-report---bug---.yaml b/.github/ISSUE_TEMPLATE/bug-report---bug---.yaml new file mode 100644 index 000000000..610e65a1d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug-report---bug---.yaml @@ -0,0 +1,61 @@ +name: Bug report +title: "[BUG] " +labels: bug +description: Create a report to help us improve +body: + - type: markdown + attributes: + value: | + Create a report to help us improve + - type: input + id: version + validations: + required: true + attributes: + label: Version + description: Which version of the extension/addon are you using? + placeholder: ex. v1.0.0 + - type: dropdown + 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 + 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 + - type: input + id: browser_version + validations: + required: false + attributes: + label: Browser Version + description: Which version of the browser? (Optional) + placeholder: ex. 91.0.4472.124 + - type: textarea + id: description + validations: + required: true + attributes: + label: Bug Description + description: Please describe the bug in detail. + placeholder: "Steps to reproduce the behavior, and what you expected to happen." diff --git a/.github/ISSUE_TEMPLATE/feature-request-------.md b/.github/ISSUE_TEMPLATE/feature-request-------.md deleted file mode 100644 index 5db3c2de7..000000000 --- a/.github/ISSUE_TEMPLATE/feature-request-------.md +++ /dev/null @@ -1,10 +0,0 @@ ---- -name: Feature request / 需求提交 -about: Suggest an idea for this project / 说出你宝贵的需求! -title: "[FEATURE]" -labels: '' -assignees: '' - ---- - - diff --git a/.github/ISSUE_TEMPLATE/feature-request.yaml b/.github/ISSUE_TEMPLATE/feature-request.yaml new file mode 100644 index 000000000..ef5ec5286 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature-request.yaml @@ -0,0 +1,46 @@ +name: Feature Request +title: "[FEATURE] " +labels: feature +description: Suggest an idea for this project +body: + - type: markdown + attributes: + value: | + Tell us your wants~ + - type: dropdown + id: browser + validations: + required: true + attributes: + label: Browser + multiple: true + description: Which browsers are you using? (Chrome/Firefox/Edge/Other) + options: + - Chrome + - Firefox + - Edge + - Brave + - Other + default: 0 + - type: dropdown + id: os + validations: + required: false + attributes: + label: OS + description: Which operating system are you using? (Mac/Linux/Win, Optional) + options: + - Win + - Mac + - Linux + - Android + - Other + default: 0 + - type: textarea + id: description + validations: + required: false + attributes: + label: Feature Description + description: Please describe the feature in detail. + placeholder: "1. What you want.\n2. Why you want it.\n3. Any additional context." diff --git a/.github/workflows/crowdin-export.yml b/.github/workflows/crowdin-export.yml index afd0cded5..0ae69d015 100644 --- a/.github/workflows/crowdin-export.yml +++ b/.github/workflows/crowdin-export.yml @@ -10,24 +10,38 @@ jobs: TIMER_CROWDIN_AUTH: ${{ secrets.TIMER_CROWDIN_AUTH }} steps: - name: Prepare branch - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 1 token: ${{secrets.GITHUB_TOKEN}} + - name: Test using Node.js - uses: actions/setup-node@v1 + uses: actions/setup-node@v6 with: - node-version: "v22" - - name: Install ts-node - run: npm i -g ts-node + node-version: "lts/*" + - name: Install dependencies run: npm install + - name: Export translations - run: ts-node --project ./tsconfig.node.json ./script/crowdin/export-translation.ts - - name: Test typescript - uses: icrawl/action-tsc@v1 + run: | + npx ts-node --project ./tsconfig.node.json ./script/crowdin/export-translation.ts 2>&1 | tee /tmp/export-output.log + exit ${PIPESTATUS[0]} + continue-on-error: true + + - name: Check exporting logs + if: always() + run: | + if grep -q -F "[CROWDIN-ERROR]" /tmp/export-output.log; then + echo "::error:: Found errors in export logs" + exit 1 + else + echo "No errors found in export logs" + fi + - name: Create Pull Request - uses: peter-evans/create-pull-request@v7 + if: success() + uses: peter-evans/create-pull-request@v8 with: commit-message: "i18n(download): download translations by bot" branch: crowdin-export/patch diff --git a/.github/workflows/crowdin-sync.yml b/.github/workflows/crowdin-sync.yml index 805105ffb..ff42e5b46 100644 --- a/.github/workflows/crowdin-sync.yml +++ b/.github/workflows/crowdin-sync.yml @@ -10,7 +10,7 @@ jobs: - name: Test using Node.js uses: actions/setup-node@v1 with: - node-version: "v20.11.0" + node-version: "v22" - name: Install ts-node run: npm i -g ts-node - name: Install dependencies diff --git a/.github/workflows/end-to-end-tests.yml b/.github/workflows/end-to-end-tests.yml index 965d682a5..bd5e3ea2a 100644 --- a/.github/workflows/end-to-end-tests.yml +++ b/.github/workflows/end-to-end-tests.yml @@ -1,30 +1,22 @@ name: End-to-end tests CI -on: [pull_request] +on: [pull_request, workflow_dispatch] 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: "v20.11.0" + node-version: "v24" - name: Install dependencies run: npm install - - name: Install http-server - run: npm install -g http-server pm2 - - name: Build e2e outputs - run: npm run dev:e2e - - name: Build production outputs - run: npm run build - - - name: Start up mock server - run: | - pm2 start 'http-server ./test-e2e/example -p 12345' - pm2 start 'http-server ./test-e2e/example -p 12346' + - name: Setup e2e environment + run: bash script/setup-e2e.sh --all - name: Run tests env: + DEBUG: "rstest" USE_HEADLESS_PUPPETEER: true run: npm run test-e2e - name: Tests ✅ @@ -44,4 +36,4 @@ jobs: "state": "failure", "description": "Tests failed", "target_url": "https://github.com/${{ github.repository }}/actions/runs/${{ github.run_id }}" - } + }' diff --git a/.github/workflows/knip.yml b/.github/workflows/knip.yml new file mode 100644 index 000000000..a65a39fac --- /dev/null +++ b/.github/workflows/knip.yml @@ -0,0 +1,19 @@ +name: Knip +on: + pull_request: +concurrency: + group: knip-${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true +jobs: + knip: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + - name: Use Node.js + uses: actions/setup-node@v6 + with: + node-version: "24" + - name: Install dependencies + run: npm install + - name: Run Knip + run: npx knip --treat-config-hints-as-errors diff --git a/.github/workflows/psl-update.yml b/.github/workflows/psl-update.yml index 14d45d0ad..cfde5fd37 100644 --- a/.github/workflows/psl-update.yml +++ b/.github/workflows/psl-update.yml @@ -21,7 +21,7 @@ jobs: - name: Install dependencies run: npm install - name: Update psl - run: ts-node ./script/psl.ts + run: ts-node -P tsconfig.node.json ./script/psl.ts - name: Create Pull Request uses: peter-evans/create-pull-request@v7 with: diff --git a/.github/workflows/publish-all.yml b/.github/workflows/publish-all.yml index 7f08ba52c..ae4c03f4f 100644 --- a/.github/workflows/publish-all.yml +++ b/.github/workflows/publish-all.yml @@ -4,20 +4,38 @@ on: [workflow_dispatch] env: MAX_PACKAGE_SIZE: 2621440 # 2.5MB in bytes + FF_MIN_VER: "140.0" jobs: build: runs-on: ubuntu-latest steps: - name: Checkout repository - uses: actions/checkout@v4 + uses: actions/checkout@v6 with: fetch-depth: 0 + - name: Verify branch + run: | + if [[ "$GITHUB_REF" == refs/heads/main ]]; then + echo "Running on main branch ✅" + elif [[ "$GITHUB_REF" == refs/tags/* ]]; then + git fetch origin main + if git merge-base --is-ancestor HEAD origin/main; then + echo "Tag is on main branch ✅" + else + echo "❌ Tag is not on main branch" + exit 1 + fi + else + echo "❌ Not on main branch or a tag on main" + exit 1 + fi + - name: Setup Node.js - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: "v20.11.0" + node-version: "v24" - name: Install dependencies run: npm install @@ -28,6 +46,9 @@ jobs: - name: Build for Firefox run: npm run build:firefox + - name: Source package + run: bash script/source-archive.sh + - name: Check file sizes run: | # Check Chrome/Edge package size @@ -53,19 +74,19 @@ jobs: fi - name: Upload Chrome/Edge Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: chrome-edge-package path: market_packages/target.zip - name: Upload Firefox XPI Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: firefox-xpi-package path: market_packages/target.firefox.zip - name: Upload Firefox Source Artifact - uses: actions/upload-artifact@v4 + uses: actions/upload-artifact@v7 with: name: firefox-source-package path: market_packages/target.src.zip @@ -75,7 +96,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download Chrome/Edge Artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: chrome-edge-package @@ -94,7 +115,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Download Chrome/Edge Artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: chrome-edge-package @@ -112,21 +133,23 @@ jobs: runs-on: ubuntu-latest steps: - name: Download Firefox XPI Artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: firefox-xpi-package - name: Download Firefox Source Artifact - uses: actions/download-artifact@v4 + uses: actions/download-artifact@v8 with: name: firefox-source-package - name: Upload to Firefox Add-on Store - uses: wdzeng/firefox-addon@v1.2.0-alpha.0 + uses: wdzeng/firefox-addon@v1.2.0 with: addon-guid: "{a8cf72f7-09b7-4cd4-9aaa-7a023bf09916}" xpi-path: target.firefox.zip source-file-path: target.src.zip - compatibility: firefox, android + compatibility: + '{"firefox": {"min": "${{ env.FF_MIN_VER }}"}, "android": + {"min": "${{ env.FF_MIN_VER}}"}}' jwt-issuer: ${{ secrets.FIREFOX_JWD_ISSUER }} jwt-secret: ${{ secrets.FIREFOX_JWD_SECRET }} diff --git a/.github/workflows/publish-chrome.yml b/.github/workflows/publish-chrome.yml index a3b8b12ab..68e3e7bb6 100644 --- a/.github/workflows/publish-chrome.yml +++ b/.github/workflows/publish-chrome.yml @@ -10,7 +10,7 @@ jobs: - name: Setup NodeJS uses: actions/setup-node@v4 with: - node-version: "v20.11.0" + node-version: "v22" - name: Install dependencies run: npm install - name: Build diff --git a/.github/workflows/publish-edge.yml b/.github/workflows/publish-edge.yml index 240e52d8e..6cd541152 100644 --- a/.github/workflows/publish-edge.yml +++ b/.github/workflows/publish-edge.yml @@ -3,6 +3,8 @@ on: [workflow_dispatch] jobs: publish: runs-on: ubuntu-latest + env: + ACTIONS_RUNNER_DEBUG: true steps: - uses: actions/checkout@v4 with: @@ -10,7 +12,7 @@ jobs: - name: Setup NodeJS uses: actions/setup-node@v4 with: - node-version: "v20.11.0" + node-version: "v24" - name: Install dependencies run: npm install - name: Build diff --git a/.github/workflows/publish-firefox.yml b/.github/workflows/publish-firefox.yml index 3bc021f53..22e9c6096 100644 --- a/.github/workflows/publish-firefox.yml +++ b/.github/workflows/publish-firefox.yml @@ -3,24 +3,30 @@ on: [workflow_dispatch] jobs: publish: runs-on: ubuntu-latest + env: + FF_MIN_VER: "140.0" steps: - uses: actions/checkout@v4 with: fetch-depth: 0 - name: Setup NodeJS - uses: actions/setup-node@v4 + uses: actions/setup-node@v6 with: - node-version: "v20.11.0" + node-version: "v24" - name: Install dependencies run: npm install - name: Build run: npm run build:firefox + - name: Source package + run: bash script/source-archive.sh - name: Upload to Firefox addon store uses: wdzeng/firefox-addon@v1.2.0-alpha.0 with: addon-guid: "{a8cf72f7-09b7-4cd4-9aaa-7a023bf09916}" xpi-path: market_packages/target.firefox.zip source-file-path: market_packages/target.src.zip - compatibility: firefox, android + compatibility: + '{"firefox": {"min": "${{ env.FF_MIN_VER }}"}, "android": + {"min": "${{ env.FF_MIN_VER}}"}}' jwt-issuer: ${{ secrets.FIREFOX_JWD_ISSUER }} jwt-secret: ${{ secrets.FIREFOX_JWD_SECRET }} diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1cf191d92..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: "v20.11.0" + 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 ee659ba6c..86537e1bd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ +tsconfig.tsbuildinfo + .DS_Store node_modules diff --git a/.vscode/settings.json b/.vscode/settings.json index 02e9869d3..50906233d 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -8,64 +8,53 @@ }, "editor.foldingImportsByDefault": true, "editor.trimAutoWhitespace": true, - "javascript.format.insertSpaceAfterCommaDelimiter": true, - "javascript.preferences.quoteStyle": "single", - "javascript.format.semicolons": "remove", - "javascript.format.insertSpaceBeforeFunctionParenthesis": false, - "typescript.format.insertSpaceAfterCommaDelimiter": true, - "typescript.preferences.quoteStyle": "single", - "typescript.format.semicolons": "remove", - "typescript.format.insertSpaceBeforeFunctionParenthesis": false, + "js/ts.format.insertSpaceAfterCommaDelimiter": true, + "js/ts.preferences.quoteStyle": "single", + "js/ts.format.semicolons": "remove", + "js/ts.format.insertSpaceBeforeFunctionParenthesis": false, "files.eol": "\n", "cSpell.words": [ "Arrayable", "Auths", "Cascader", - "clbbddpinhgdejpoepalbfnkogbobfdb", "COPYFILE", - "countup", "daterange", - "delayable", "domcontentloaded", "dont", "echarts", "emsp", "ensp", - "filemanager", "Hengyang", + "indexeddb", "Kanban", + "knip", "MKCOL", "newtab", - "nohup", - "okey", - "Openeds", + "otpauth", "Popconfirm", "PROPFIND", "Qihu", + "qrcode", "Rsdoctor", "rspack", + "rstest", "rtlcss", "selectchanged", "sheepzh", "subpages", - "Treemap", - "Vnode", + "Totp", "vueuse", - "webcomponents", + "webext", "webstore", "webtime", "wfhg", - "zcvf", - "Zhang", - "zrender" + "Zhang" ], "cSpell.ignorePaths": [ "package-lock.json", "node_modules", - "vscode-extension", ".git/objects", ".vscode", - ".vscode-insiders", // Ignore i18n resources "src/i18n/message/**/*.json", ], diff --git a/CHANGELOG.md b/CHANGELOG.md index f336f770f..2e2cc95c3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,183 @@ 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 Edge to moderate packages, while only 1-2 days for Chrome and Firefox. +It is worth mentioning that the release time of each change refers to the time when the installation package is submitted to the webstore. It is about one week for Firefox to moderate packages, while only 1-2 days for Chrome and Edge. + +## [4.3.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 + +## [4.3.1] - 2026-05-15 + +- Added share button on the popup page +- Enabled tab group tracking by default +- Optimized data migration from other extensions +- Fixed some bugs + +## [4.3.0] - 2026-05-11 + +- Added two-factor authentication (2FA) for time limit verification +- Optimized the behavior of time limit verification + +## [4.2.3] - 2026-05-08 + +- Fixed some bugs +- Added some translations + +## [4.2.2] - 2026-05-01 [For FF Mobile] + +- Fixed behavior on FF Mobile + +## [4.2.1] - 2026-04-30 + +- Fixed some bugs for time limit +- Optimized the style of popup page + +## [4.2.0] - 2026-04-28 + +- refactored basic architecture +- supported custom delay duration +- added time limit on the popup page + +## [4.1.7] - 2026-04-23 + +- Dropped support for Firefox versions below 140 +- Fixed some issues + +## [4.1.6] - 2026-04-09 + +- Optimized some UI + +## [4.1.5] - 2026-03-31 + +- Fixed some issues + +## [4.1.4] - 2026-03-30 + +- Fixed some issue for Gist + + +## [4.1.3] - 2026-03-24 + +- Add data collection permission for Firefox + +## [4.1.2] - 2026-03-20 + +- Supported Italian + +## [4.1.1] - 2026-03-13 + +- Fixed the issue of timeline + +## [4.1.0] - 2026-03-07 + +- Added notification +- Supported time limits for mobile browsers + +## [4.0.1] - 2026-02-27 + +- Fixed an IndexedDB upgrade bug on Edge + +## [4.0.0] - 2026-02-26 + +- Supported IndexedDB to store the tracking data +- Refactor the header of popup page + +## [3.7.15] - 2026-01-21 + +- Fixed virtual sites' data + +## [3.7.14] - 2026-01-16 + +- Supported more languages for the guide page + +## [3.7.13] - 2026-01-13 + +- Added the donation link. Thank you all guys ^^ + +## [3.7.12] - 2026-01-09 + +- The first version of new year! +- Fixed some bugs + +## [3.7.11] - 2025-12-30 + +- Fixed an issue that allowed bypassing time-limited passwords in certain situations +- Fixed an issue where report tables did not refresh after category changes +- Removed scrollbars from pop-up charts for improved user experience + +## [3.7.10] - 2025-12-23 + +- Fixed a bug on Firefox + +## [3.7.9] - 2025-12-23 + +- Optimized UI +- Supported to disable the side panel for Chrome and Edge + +## [3.7.8] - 2025-12-13 [For Chrome] + +- Same as v3.7.7 + +## [3.7.7] - 2025-12-13 + +- Reverted tracking the time in split-screen mode for Chrome, which caused tracking stopping +- Supported donut chart on the popup pages + +## [3.7.6] - 2025-12-10 + +- Supported tracking the time in split-screen mode for Chrome +- Added the mask layer for date picker on the side page +- Fixed bugs that caused errors when importing data from Web Activity Time Tracker + +## [3.7.5] - 2025-11-28 + +- Supported tracking files for Firefox in background script +- All charts use system fonts +- Optimized the interaction for modifying restriction rules +- Fixed some bugs + +## [3.7.4] - 2025-11-15 + +- Improved the time display on popup page +- Supported exporting and importing data for Android + +## [3.7.3] - 2025-11-11 + +- Supported Polish +- Supported Turkish +- Considered audible tabs as active, and not to pause tracking. Thanks to [mrfragger](https://github.com/mrfragger) +- Fixed a bug when backup via WebDAV +- Reduced some lint warnings from web-ext + +## [3.7.2] - 2025-11-03 + +- Supported to collapse the menu +- Performance on Firefox has been optimized to address a performance issue caused by a leak of structured clone holders resulting from frequent calls to runtime.sendMessage + +## [3.7.1] - 2025-10-28 [For Chrome] + +- Optimized SEO of CWS + +## [3.7.0] - 2025-10-26 + +- Optimized adaptation on mobile devices +- Migrated options of popup page +- Optimized the interaction of limit rules +- Fixed some bugs ## [3.6.8] - 2025-10-21 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 8e503a369..d4a9b6945 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,12 +1,12 @@ # Contributing Guide -## 1. Prerequisites +## Prerequisites The technology stack used is: - [rspack](https://rspack.dev) + [TypeScript](https://github.com/microsoft/TypeScript) - [Vue3 (Composition API + JSX)]() -- [sass](https://github.com/sass/sass) +- [emotion](https://github.com/emotion-js/emotion) - [Element Plus](https://element-plus.gitee.io/) - [Echarts](https://github.com/apache/echarts) @@ -14,130 +14,274 @@ And [Chrome Extension Development Documentation](https://developer.chrome.com/do Some free open source tools are also integrated: -- Testing tool [jest](https://jestjs.io/docs/getting-started) +- Testing tool [Rstest](https://rstest.rs) - End-to-end integration testing [puppeteer](https://developer.chrome.com/docs/extensions/how-to/test/puppeteer) - I18N tool [Crowdin](https://crowdin.com/project/timer-chrome-edge-firefox) -## 2. Steps +## Development Setup -1. Fork your repository -2. Install dependencies and initialize +### 1. Fork and Setup + +1. Fork this repository to your GitHub account +2. Clone your forked repository locally + +### 2. Install Dependencies ```shell npm install npm run prepare ``` -3. Create your own branch -4. Code & run +### 3. Create Development Branch + +Create a new branch for your changes -First execute the command +### 4. Development Commands + +#### Desktop Development ```shell +# Chrome/Edge development npm run dev -# or if you want to run in Firefox + +# Firefox development npm run dev:firefox + +# Safari development +npm run dev:safari ``` -Two folders will be output in the project root directory, `dist_dev` and `dist_dev_firefox` +This will create output directories: -Then import different targets according to the test browser: +- `dist_dev` - Chrome/Edge extension +- `dist_dev_firefox` - Firefox extension +- `dist_dev_safari` - Safari extension -- Import `dist_dev` into Chrome and Edge -- Import the `manifest.json` file in `dist_dev_firefox` for Firefox +#### Mobile Development (Android) -5. Run unit tests +> See: [Developing extensions for Firefox for Android](https://extensionworkshop.com/documentation/develop/developing-extensions-for-firefox-for-android/) + +For Firefox on Android development, use the helper script: ```shell -npm run test +./script/android-firefox.sh ``` -6. Run end-to-end tests +This script will: + +- Check prerequisites (web-ext, adb) +- Detect connected Android devices +- Build the extension automatically +- Install and run on Firefox for Android -First, you need to compile twice: integration compilation and production compilation +#### Production Builds ```shell -npm run dev:e2e +# Build all platforms npm run build + +# Build specific platform +npm run build:firefox +npm run build:safari +``` + +### 5. Debugging + +#### Chrome/Edge + +1. Open `chrome://extensions/` or `edge://extensions/` +2. Enable "Developer mode" +3. Click "Load unpacked" and select the `dist_dev` folder + +#### Firefox + +1. Open `about:debugging` +2. Click "This Firefox" +3. Click "Load Temporary Add-on" +4. Select the `manifest.json` file from `dist_dev_firefox` folder + +#### Firefox for Android + +Use the provided script which handles the entire process automatically. + +## Testing + +### Unit Tests + +```shell +npm run test ``` -Then execute the test +### Coverage Report + +```shell +npm run test-c +``` + +This will generate coverage reports in the `coverage/` directory. + +### End-to-End Tests + +> **Note**: This step is optional! All PRs will automatically run this step. + +#### Setup E2E Testing + +Use the provided setup script to initialize the e2e testing environment: + +```shell +# Initialize e2e environment (install dependencies and build outputs) +bash script/setup-e2e.sh --init --build + +# Start test servers +bash script/setup-e2e.sh --start-servers +``` + +The setup script will: + +- Install/upgrade global dependencies (`http-server`, `pm2`) +- Download browser for Puppeteer (if needed) +- Build e2e test output (`dist_e2e/`) +- Start test servers on ports 12345 and 12346 + +#### Run E2E Tests ```shell npm run test-e2e ``` -If you need to start puppeteer in headless mode, you can set the following environment variables +#### Headless Mode + +For headless Puppeteer testing: ```bash export USE_HEADLESS_PUPPETEER=true +npm run test-e2e ``` -7. Submit a PR - -Please PR to the main branch of this repository. - -## 3. Application architecture design - -> todo - -## 4. Directory Structure - -```plain -project -│ -└───doc # Documents -│ -└───public # Assets -| -└───src -| | manifest.ts # manifest.json for Chrome and Edge -| | manifest-firefox.ts # manifest.json for Firefox -| | -| └───api # API -| | -| └───pages # UI -| | | -| | └───app # Background page -| | | -| | └───popup # Popup page -| | | | -| | | └───skeleton.ts # Skeleton of the popup page -| | | | -| | | └───index.ts # Popup page entrance -| | | -| | └───side # Side page -| | | -| | └───[Other dirs] # Shared code -| | -| └───background # Backend service, responsible for coordinating data interaction, Service Worker -| | -| └───content-script # Script injected into user pages -| | -| └───service # Service Layer -| | -| └───database # Data Access Layer -| | -| └───i18n # Translations -| | -| └───util # Utils -| | -| └───common # Shared -| -└───test # Unit tests -| └───__mock__ -| | -| └───storage.ts # mock chrome.storage -| -└───test-e2e # End-to-end tests -| -└───types # Declarations -| -└───rspack # rspack config - -``` - -## 5. Code format +#### Stop Test Servers + +After testing, stop the servers: + +```shell +pm2 stop all && pm2 delete all +``` + +#### Setup Script Options + +The `setup-e2e.sh` script supports multiple options: + +```shell +# Show help +bash script/setup-e2e.sh --help + +# Initialize only (install dependencies) +bash script/setup-e2e.sh --init + +# Build e2e output only +bash script/setup-e2e.sh --build + +# Initialize and build +bash script/setup-e2e.sh --init --build + +# Run all steps (init + build + build production) +bash script/setup-e2e.sh --all +``` + +## Code Quality + +### Code Formatting + +- Use single quotes whenever possible +- Keep code concise while being grammatically correct +- No semicolons at the end of lines +- Use LF (`\n`) line endings + +For Windows users: + +```shell +git config core.autocrlf false +``` + +### Pre-commit Hooks + +The project uses Husky for pre-commit hooks. These will run automatically when you commit: + +## Submitting Changes + +### 1. Commit Your Changes + +```shell +git add . +git commit -m "feat: add new feature description" +# or +git commit -m "fix: fix bug description" +``` + +Use conventional commit messages: + +- `feat:` for new features +- `fix:` for bug fixes +- `docs:` for documentation changes +- `style:` for formatting changes +- `refactor:` for code refactoring +- `test:` for adding tests +- `chore:` for maintenance tasks +- `i18n:` for internationalization + +### 2. Push and Create PR + +Create a Pull Request to the `main` branch of this repository. + +### 3. PR Requirements + +- [ ] All tests pass +- [ ] Documentation updated if needed + +## Project Structure + +``` +time-tracker-4-browser/ +├── src/ # Source code +│ ├── manifest.ts # Chrome/Edge manifest +│ ├── manifest-firefox.ts # Firefox manifest +│ ├── api/ # API layer +│ ├── background/ # Service Worker +│ ├── content-script/ # Content scripts +│ ├── pages/ # UI pages +│ │ ├── app/ # Main app (background page) +│ │ ├── popup/ # Extension popup +│ │ │ ├── skeleton.ts # Popup skeleton +│ │ │ └── index.ts # Popup entry +│ │ └── side/ # Side panel +│ ├── service/ # Business logic +│ ├── database/ # Data access layer +│ ├── i18n/ # Internationalization +│ ├── util/ # Utilities +│ └── common/ # Shared code +├── test/ # Unit tests +│ └── __mock__/ # Test mocks +├── test-e2e/ # End-to-end tests +├── types/ # TypeScript declarations +├── rspack/ # Build configuration +├── script/ # Build and utility scripts +│ ├── android-firefox.sh # Android development helper +│ └── setup-e2e.sh # E2E test environment setup +├── public/ # Static assets +├── doc/ # Documentation +├── dist_dev/ # Chrome/Edge dev build +├── dist_dev_firefox/ # Firefox dev build +└── dist_dev_safari/ # Safari dev build +``` + +### Key Files + +- **`src/manifest.ts`** - Chrome/Edge extension manifest (Manifest V3) +- **`src/manifest-firefox.ts`** - Firefox extension manifest (Manifest V2) +- **`src/background/`** - Service Worker and background scripts +- **`src/content-script/`** - Scripts injected into web pages +- **`src/pages/`** - Extension UI (popup, side panel, options) + +## Code format Please use the code formatting tools that come with VSCode. Please **disable Prettier Eslint** and other formatting tools @@ -150,35 +294,33 @@ Please use the code formatting tools that come with VSCode. Please **disable git config core.autocrlf false ``` -## 6. How to use i18n +## How to use i18n Except for certain professional terms, the text of the user interface can be in English. For the rest, please use i18n to inject text. See the code directory `src/i18n` ### How to add entries 1. Add new fields in the definition file `xxx.ts` -2. Then add the corresponding text of this field in English (en) and Simplified Chinese (zh_CN) in the corresponding resource file `xxx-resource.json` -3. Call `t(msg=>msg...)` in the code to get the text content +2. Then add the corresponding text of this field in English (en) in the corresponding resource file `xxx-resource.json` +3. Call `t(msg => msg...)` in the code to get the text content, which can make full use of TypeScript to inspect translated texts. ### How to integrate with Crowdin -Crowdin is a collaborative translation platform that allows native speakers to help translate multilingual content. The project's integration with Crowdin is divided into two steps +Crowdin is a collaborative translation platform that allows native speakers to help translate multilingual content. The project's integration with Crowdin is divided into two steps. Collaborators usually need't to perform these steps. 1. Upload English text and other language text in code ``` # Upload original English text ts-node ./script/crowdin/sync-source.ts -# Upload texts in other languages ​​in local code (excluding Simplified Chinese) +# Upload texts in other languages ​​in local code ts-node ./script/crowdin/sync-translation.ts ``` -Because the above two scripts rely on the Crowdin access secret in the environment variable, I integrated them into Github's [Action](https://github.com/sheepzh/timer/actions/workflows/crowdin-sync.yml) +Because the above two scripts rely on the Crowdin access secret in the environment variable, I integrated them into Github's [Action](https://github.com/sheepzh/time-tracker-4-browser/actions/workflows/crowdin-sync.yml) 2. Export translations from Crowdin ``` ts-node ./script/crowdin/export-translation.ts ``` - -You can also directly execute [Action](https://github.com/sheepzh/timer/actions/workflows/crowdin-export.yml). diff --git a/README-zh.md b/README-zh.md index 498162e6b..9b49e936d 100644 --- a/README-zh.md +++ b/README-zh.md @@ -1,8 +1,15 @@ # 网费很贵 -[![codecov](https://codecov.io/gh/sheepzh/timer/branch/main/graph/badge.svg?token=S98QSBSKCR&style=flat-square)](https://codecov.io/gh/sheepzh/timer) +[![codecov](https://codecov.io/gh/sheepzh/time-tracker-4-browser/branch/main/graph/badge.svg?token=S98QSBSKCR&style=flat-square)](https://codecov.io/gh/sheepzh/time-tracker-4-browser) [![](https://img.shields.io/badge/license-Anti%20996-blue)](https://github.com/996icu/996.ICU) -[![](https://img.shields.io/github/v/release/sheepzh/timer)](https://github.com/sheepzh/timer/releases) +[![](https://img.shields.io/github/v/release/sheepzh/time-tracker-4-browser)](https://github.com/sheepzh/time-tracker-4-browser/releases) +[![Discord](https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/yXCngD8pKS) + +

+ Chrome Web Store + Microsoft Store + Firefox 附加组件 +

\[ 简体中文 | [English](./README.md) \] @@ -48,13 +55,25 @@ -## 贡献指南 +## 如何反馈 -如果你想参与到该项目的开源建设,可以考虑以下几种方式 +如果你在使用过程中遇到了问题,或有任何想法和建议,欢迎通过以下方式告诉我们: #### 提交 Issue -如果您有一些好的想法,或者 bug 反馈,可以新建一条 [issue](https://github.com/sheepzh/timer/issues) 。作者会在第一时间进行回复。 +在 [GitHub Issues](https://github.com/sheepzh/time-tracker-4-browser/issues) 中描述你遇到的问题或功能建议,我们会尽快回复。 + +#### 加入 Discord + +加入我们的 [Discord 社区](https://discord.gg/yXCngD8pKS),与开发者和其他用户直接交流。 + +#### 创建 Discussion + +在 [GitHub Discussions](https://github.com/sheepzh/time-tracker-4-browser/discussions) 中发起话题,适合分享使用经验或进行开放性讨论。 + +## 贡献指南 + +如果你想参与到该项目的开源建设,可以考虑以下几种方式 #### 参与开发 diff --git a/README.md b/README.md index da81bed88..7e740cdbc 100644 --- a/README.md +++ b/README.md @@ -1,9 +1,15 @@ # Time Tracker for Browser -[![codecov](https://codecov.io/gh/sheepzh/timer/branch/main/graph/badge.svg?token=S98QSBSKCR&style=flat-square)](https://codecov.io/gh/sheepzh/timer) +[![codecov](https://codecov.io/gh/sheepzh/time-tracker-4-browser/branch/main/graph/badge.svg?token=S98QSBSKCR&style=flat-square)](https://codecov.io/gh/sheepzh/time-tracker-4-browser) [![](https://img.shields.io/badge/license-Anti%20996-blue)](https://github.com/996icu/996.ICU) -[![](https://img.shields.io/github/v/release/sheepzh/timer)](https://github.com/sheepzh/timer/releases) [![Crowdin](https://badges.crowdin.net/timer-chrome-edge-firefox/localized.svg)](https://crowdin.com/project/timer-chrome-edge-firefox) +[![Discord](https://img.shields.io/badge/discord-join-5865F2?logo=discord&logoColor=white)](https://discord.gg/yXCngD8pKS) + +

+ Available in the Chrome Web Store + Get it from Microsoft + Get the add-on +

\[ English | [简体中文](./README-zh.md) \] @@ -48,15 +54,27 @@ Time Tracker is a browser extension to track the time you spent on all websites.

Page Blocking

-## Contribution +## Feedback -There are some things you can do to contribute to this software. +If you encounter any issues or have suggestions during use, feel free to reach out through the following channels: + +#### 1. Submit an Issue + +Describe your problem or feature request in [GitHub Issues](https://github.com/sheepzh/time-tracker-4-browser/issues), and we'll get back to you as soon as possible. + +#### 2. Join Discord -#### 1. Submit issues +Join our [Discord community](https://discord.gg/yXCngD8pKS) to chat directly with the developer and other users. -You can [submit one issue](https://github.com/sheepzh/timer/issues) to us if you have some suggestions, feature requests, or feedback of bugs. And we will reply it as soon as possible. +#### 3. Create a Discussion -#### 2. Participate in development +Start a topic in [GitHub Discussions](https://github.com/sheepzh/time-tracker-4-browser/discussions) — great for sharing experiences or open-ended conversations. + +## Contribution + +There are some things you can do to contribute to this software. + +#### 1. Participate in development If you know how to develop browser extensions and are familiar with the project's technology stack (TypeScript + Vue3 + Element Plus + Echarts), you can also contribute code @@ -64,7 +82,7 @@ See the [Development Guide](./CONTRIBUTING.md) #### 3. Perfect translation -In addition to Simplified Chinese, the other localized languages of this software all rely on machine translation. You can also submit translation suggestions on [Crowdin](https://crowdin.com/project/timer-chrome-edge-firefox). +Most of the software's localization relies on machine translation. You can also submit translation suggestions on [Crowdin](https://crowdin.com/project/timer-chrome-edge-firefox). #### 4. Rate 5 stars @@ -75,3 +93,5 @@ It's simple and much helpful! ## Thanks Timer (relaunch) - Timer is one browser extension to stat site visits and time. | Product Hunt + + diff --git a/doc/for-fire-fox.md b/doc/for-fire-fox.md deleted file mode 100644 index 9f1ce08f4..000000000 --- a/doc/for-fire-fox.md +++ /dev/null @@ -1,14 +0,0 @@ -## How to build for Firefox - -```shell -npm install -npm run build:firefox -``` - -The output directory is `dist_prod_firefox` and you can find a zip file named `target.firefox.zip` in the `market_packages` directory - -## Another - -Also you could visit the sourcecode on GitHub - -[sheepzh/timer](https://github.com/sheepzh/timer) diff --git a/doc/for-firefox.md b/doc/for-firefox.md new file mode 100644 index 000000000..6a5a680b6 --- /dev/null +++ b/doc/for-firefox.md @@ -0,0 +1,24 @@ +## Building for Firefox + +### Prerequisites + ++ Node.js v22 + +### Build Steps + +```shell +npm install +npm run build:firefox +``` + +### Output Files + ++ Build directory: `dist_prod_firefox` + ++ Marketplace package: `market_packages/target.firefox.zip` + +## Source Code + +Visit our GitHub repository for the latest source code: + +[sheepzh/time-tracker-4-browser](https://github.com/sheepzh/time-tracker-4-browser) diff --git a/doc/safari-install.md b/doc/safari-install.md index a3c8c9b79..f08b32824 100644 --- a/doc/safari-install.md +++ b/doc/safari-install.md @@ -6,8 +6,8 @@ So please install it **manually**, GG Safari. ## 0. Download this repository ```shell -git clone https://github.com/sheepzh/timer.git -cd timer +git clone https://github.com/sheepzh/time-tracker-4-browser.git +cd time-tracker-4-browser ``` ## 1. Install tools @@ -30,14 +30,12 @@ npm install npm run build:safari ``` -Then there will be one folder called **Timer**. - -Also, you can download the archived file from [the release page](https://github.com/sheepzh/timer/releases), and unzip it to gain this folder. +Then there will be one folder called **dist_prod_safari**. 2. Convert js bundles to Xcode project ```shell -[YOUR_PATH]/Xcode.app/Contents/Developer/usr/bin/safari-web-extension-converter ./Timer +[YOUR_PATH]/Xcode.app/Contents/Developer/usr/bin/safari-web-extension-converter ./dist_prod_safari ``` 3. Run Xcode project and one extension app will installed on your macOS diff --git a/doc/similar-comparison.md b/doc/similar-comparison.md deleted file mode 100644 index c0b7df02a..000000000 --- a/doc/similar-comparison.md +++ /dev/null @@ -1,25 +0,0 @@ - -|Features|Webtime Tracker|Web Activity Time Tracker|Time Tracker|TimeYourWeb Time Tracker| -|----|----|----|----|----| -|Chrome|[👤 60k+
⭐ 4.79/5](https://chrome.google.com/webstore/detail/webtime-tracker/ppaojnbmmaigjmlpjaldnkgnklhicppk)|[👤 20k+
⭐ 4.71/5](https://chrome.google.com/webstore/detail/web-activity-time-tracker/hhfnghjdeddcfegfekjeihfmbjenlomm)|[👤 2k+
⭐ 4.97/5](https://chrome.google.com/webstore/detail/time-tracker/dkdhhcbjijekmneelocdllcldcpmekmm)|[👤 13k+
⭐ 4.38/5](https://chrome.google.com/webstore/detail/timeyourweb-time-tracker/kfmlkgchpffnaphmlmjnimonlldbcpnh) -|Edge|❌|[👤 4k+
⭐ 4.50/5](https://chrome.google.com/webstore/detail/web-activity-time-tracker/hhfnghjdeddcfegfekjeihfmbjenlomm)|[👤 3k+
⭐ 5.00/5](https://microsoftedge.microsoft.com/addons/detail/timer-running-browsin/fepjgblalcnepokjblgbgmapmlkgfahc)|[👤 400+
⭐ Not rated](https://microsoftedge.microsoft.com/addons/detail/insight-track-and-optim/kkcmfbejfopnopfcmcjgfkpalecbleio)|[👤 1k+
⭐ 5.00/5](https://microsoftedge.microsoft.com/addons/detail/timeyourweb-time-tracker/jodkkcjbahdphacgjhacggclncbaffoe) -|Firefox|[👤 90+
⭐ Not rated](https://addons.mozilla.org/en-US/firefox/addon/webtime-tracker-2/)|❌|[👤 100+
⭐ 5.00/5](https://addons.mozilla.org/en-US/firefox/addon/besttimetracker/)|❌ -|Track by domain|✅|✅|✅|✅| -|Track any URL|❌|❌|✅|❌ -|Track local files|❌|✅|❌|✅| -|Whitelist|❌|✅|✅|✅ -|Data chart screenshot|✅|❌|✅|❌ -|Daily browsing restrictions|❌|✅|✅|❌ -|Daily notifications|❌|✅|❌|❌|❌ -|Pause switch|❌|❌|❌|✅ -|Access calendar|❌|✅|✅|❌ -|Site analysis|❌|❌|✅|❌ -|Active moments per day|❌|❌|✅|✅ -|Beginner's guide|✅|❌|✅|❌ -|Dark mode|❌|❌|✅|❌ -|Export data|✅|✅|✅|❌| -|Sync data across browsers|❌|❌|✅|❌ -|Free|✅|✅|✅|✅| -|Open Source|❌|[GitHub](https://github.com/Stigmatoz/web-activity-time-tracker)|[GitHub](https://github.com/sheepzh/timer)|❌ -|Languages|English|Deutsch, English, русский|English, português (Portugal), 中文 (简体), 中文 (繁體), 日本語|English -|Memory usage with 5 tabs open on Chrome, MacOS
|21+M|19+M|21+M|20+M diff --git a/examples/README.md b/examples/README.md new file mode 100644 index 000000000..df92792ee --- /dev/null +++ b/examples/README.md @@ -0,0 +1,26 @@ +# Examples Package + +The `examples` directory is an independent npm package for local demo and e2e helper servers. + +## Setup + +```bash +cd examples +npm install +``` + +## Available scripts + +- `npm run start:gist` - start Gist mock server +- `npm run start:notification` - start notification demo server ([notification/README.md](notification/README.md)) + +## Environment variables + +### Gist mock server + +- `PORT` (default: `12347`) +- `GIST_TOKEN` (optional auth token; supports `token` or `Bearer` authorization header) + +### Notification demo server + +- `AUTH` (optional signature secret) diff --git a/examples/gist/README.md b/examples/gist/README.md new file mode 100644 index 000000000..412014f3e --- /dev/null +++ b/examples/gist/README.md @@ -0,0 +1,45 @@ +# Gist Mock Server + +This mock server provides an in-memory Gist API for end-to-end testing. +It follows the GitHub Gist REST contract for the endpoints needed by this project. + +## Supported APIs + +- `GET /gists?per_page=100&page=1` +- `GET /gists/:id` +- `POST /gists` (create) +- `PATCH /gists/:id` (official update API) +- `POST /gists/:id` (compat mode for current project call path) +- `DELETE /gists/:id` +- `GET /raw/:id/:filename` + +Response shape is primarily aligned with GitHub Gist REST API, while keeping compatibility with current project call paths (for example `POST /gists/:id`). + +## Run + +```bash +cd examples +npm install +npm run start:gist +``` + +Optional environment variables: + +- `PORT` (default: `12347`) +- `GIST_TOKEN` (if set, requests must contain `Authorization: token ` or `Authorization: Bearer `) + +## Test with curl + +```bash +curl -X POST "http://localhost:12347/gists" \ + -H "Authorization: token demo" \ + -H "Content-Type: application/json" \ + -d '{ + "public": false, + "description": "Used for timer to save meta info. Don'\''t change this description :)", + "files": { + "README.md": {"filename": "README.md", "content": "hello"}, + "clients.json": {"filename": "clients.json", "content": "[]"} + } + }' +``` diff --git a/examples/gist/mock-server.ts b/examples/gist/mock-server.ts new file mode 100644 index 000000000..1e0888633 --- /dev/null +++ b/examples/gist/mock-server.ts @@ -0,0 +1,297 @@ +import { randomUUID } from "crypto" +import { createServer, type IncomingMessage, type ServerResponse } from "http" + +type GistFormFile = { + filename: string + content: string +} + +type GistFile = { + filename: string + type: string + language: string + raw_url: string + size: number + truncated: boolean + content?: string +} + +type GistForm = Partial<{ + public: boolean + description: string + files: { [filename: string]: GistFormFile | null } +}> + +type Gist = { + url: string + forks_url: string + commits_url: string + id: string + node_id: string + git_pull_url: string + git_push_url: string + html_url: string + public: boolean + description: string + files: { [filename: string]: GistFile | null } + comments: number + comments_url: string + owner: null + user: null + truncated: boolean + history: unknown[] + forks: unknown[] + created_at: string + updated_at: string +} + +const PORT = Number(process.env.PORT ?? 12347) +// The same as e2e tests +const REQUIRED_TOKEN = 'github_gist_mock_token' + +const gists = new Map() +const gistOrder: string[] = [] + +function nowIso(): string { + return new Date().toISOString() +} + +function inferFileType(filename: string): string { + if (filename.endsWith(".json")) return "application/json" + if (filename.endsWith(".md")) return "text/markdown" + return "text/plain" +} + +function inferLanguage(filename: string): string { + if (filename.endsWith(".json")) return "JSON" + if (filename.endsWith(".md")) return "Markdown" + return "Text" +} + +function buildGistFile(origin: string, gistId: string, filename: string, content: string): GistFile { + const encodedFilename = encodeURIComponent(filename) + return { + filename, + type: inferFileType(filename), + language: inferLanguage(filename), + raw_url: `${origin}/raw/${gistId}/${encodedFilename}`, + size: Buffer.byteLength(content, "utf-8"), + truncated: false, + content, + } +} + +function buildCreatedGist(origin: string, form: GistForm): Gist { + const id = randomUUID().replaceAll("-", "") + const ts = nowIso() + const files: Record = {} + for (const [filename, file] of Object.entries(form.files || {})) { + if (!file) continue + files[filename] = buildGistFile(origin, id, filename, file.content) + } + const gistUrl = `${origin}/gists/${id}` + return { + url: gistUrl, + forks_url: `${gistUrl}/forks`, + commits_url: `${gistUrl}/commits`, + id, + node_id: `MOCK_${id}`, + git_pull_url: `${origin}/${id}.git`, + git_push_url: `${origin}/${id}.git`, + html_url: `${origin}/mock/gist/${id}`, + public: !!form.public, + description: form.description ?? "", + files, + comments: 0, + comments_url: `${gistUrl}/comments`, + owner: null, + user: null, + truncated: false, + history: [], + forks: [], + created_at: ts, + updated_at: ts, + } +} + +function updateExistingGist(origin: string, gist: Gist, form: GistForm): Gist { + const next: Gist = { + ...gist, + description: form.description ?? gist.description, + public: typeof form.public === "boolean" ? form.public : gist.public, + files: { ...gist.files }, + updated_at: nowIso(), + } + for (const [filename, file] of Object.entries(form.files || {})) { + if (file === null) { + delete next.files[filename] + continue + } + const oldFilename = filename + const newFilename = file.filename || oldFilename + if (newFilename !== oldFilename) { + delete next.files[oldFilename] + } + next.files[newFilename] = buildGistFile(origin, gist.id, newFilename, file.content) + } + return next +} + + +class Handler { + private readonly req: IncomingMessage + private readonly res: ServerResponse + private readonly origin: string + private readonly url: URL + + constructor(req: IncomingMessage, res: ServerResponse) { + this.req = req + this.res = res + this.origin = `http://${req.headers.host ?? `localhost:${PORT}`}` + this.url = new URL(req.url ?? "/", this.origin) + } + + handle() { + try { + const { method, url, headers } = this.req + console.log(`${method} ${url} ${headers.authorization}`) + + if (!this.isAuthValid()) return this.unauthorized() + + if (method === "HEAD") return this.sendNoContent() + const { pathname } = this.url + if (method === "GET" && pathname === "/gists") return this.listGists() + if (method === "POST" && pathname === "/gists") return this.createGist() + + const gistMatch = pathname.match(/^\/gists\/([^/]+)$/) + if (gistMatch?.[1]) { + const gistId = gistMatch[1] + if (method === "GET") return this.getGist(gistId) + if (method === "POST") return this.updateGist(gistId) + if (method === "PATCH") return this.updateGist(gistId) + if (method === "DELETE") return this.deleteGist(gistId) + } + + const rawMatch = pathname.match(/^\/raw\/([^/]+)\/(.+)$/) + if (method === "GET" && rawMatch?.[1] && rawMatch?.[2]) { + const gistId = rawMatch[1] + const filename = decodeURIComponent(rawMatch[2]) + return this.getRawFile(gistId, filename) + } + + return this.notFound() + } catch (e) { + console.error("Mock gist server error:", e) + this.sendJson(400, { message: "Bad Request" }) + } + } + + private isAuthValid(): boolean { + const { method, headers: { authorization } } = this.req + const needAuth = method === "GET" || method === "POST" || method === "PATCH" || method === "DELETE" + if (!needAuth) return true + return !REQUIRED_TOKEN || `token ${REQUIRED_TOKEN}` === authorization + } + + private async readBody(): Promise { + const body = await new Promise((resolve, reject) => { + let data = "" + this.req.on("data", (chunk: Buffer) => { data += chunk.toString() }) + this.req.on("end", () => resolve(data)) + this.req.on("error", reject) + }) + return JSON.parse(body) as T + } + + private listGists() { + const perPageRaw = Number(this.url.searchParams.get("per_page") || "30") + const pageRaw = Number(this.url.searchParams.get("page") || "1") + const perPage = Math.min(100, Math.max(1, Number.isNaN(perPageRaw) ? 30 : perPageRaw)) + const page = Math.max(1, Number.isNaN(pageRaw) ? 1 : pageRaw) + const since = this.url.searchParams.get("since") + const all = gistOrder.map(id => gists.get(id)).filter(Boolean) as Gist[] + const filtered = since + ? all.filter(gist => gist.updated_at > since) + : all + const start = (page - 1) * perPage + const end = start + Math.max(0, perPage) + this.sendJson(200, filtered.slice(start, end)) + } + + private async createGist() { + const form = await this.readBody() + if (!form.files || Object.keys(form.files).length === 0) { + return this.validationFailed("Validation failed: files is required") + } + const gist = buildCreatedGist(this.origin, form) + gists.set(gist.id, gist) + gistOrder.unshift(gist.id) + this.sendJson(201, gist) + } + + private getGist(gistId: string) { + const gist = gists.get(gistId) + if (!gist) return this.notFound() + this.sendJson(200, gist) + } + + private async updateGist(gistId: string) { + const gist = gists.get(gistId) + if (!gist) return this.notFound() + const form = await this.readBody() + if (!form.description && !form.files) { + return this.validationFailed("Validation failed: description or files is required") + } + const updated = updateExistingGist(this.origin, gist, form) + gists.set(gistId, updated) + this.sendJson(200, updated) + } + + private async deleteGist(gistId: string) { + if (!gists.has(gistId)) return this.notFound() + gists.delete(gistId) + const idx = gistOrder.findIndex(id => id === gistId) + if (idx >= 0) gistOrder.splice(idx, 1) + this.sendNoContent() + } + + private getRawFile(gistId: string, filename: string) { + const gist = gists.get(gistId) + const file = gist?.files[filename] + if (!file) return this.notFound() + this.res.writeHead(200, { "Content-Type": file.type || "text/plain" }) + this.res.end(file.content || "") + } + + private sendJson(statusCode: number, payload: unknown) { + this.res.writeHead(statusCode, { "Content-Type": "application/json" }) + this.res.end(JSON.stringify(payload)) + } + + private unauthorized() { + this.sendJson(401, { message: "Bad credentials" }) + } + + private sendNoContent() { + this.res.writeHead(204).end() + } + + private validationFailed(message: string) { + this.sendJson(422, { message }) + } + + private notFound() { + this.sendJson(404, { message: "Not Found" }) + } +} + +function main() { + const server = createServer((req, res) => new Handler(req, res).handle()) + + server.listen(PORT, () => { + console.log(`Gist mock server listening on http://localhost:${PORT}`) + console.log("Set GIST_TOKEN to enable token validation") + }) +} + +main() diff --git a/examples/host/index.html b/examples/host/index.html new file mode 100644 index 000000000..5812731a0 --- /dev/null +++ b/examples/host/index.html @@ -0,0 +1,8 @@ + + + + +
Time Tracker test page
+ + + \ No newline at end of file diff --git a/examples/notification/README.md b/examples/notification/README.md new file mode 100644 index 000000000..3c13280ed --- /dev/null +++ b/examples/notification/README.md @@ -0,0 +1,157 @@ +# HTTP Notification Callback + +The extension can push usage data to your server via an HTTP callback. This page describes: + +1. [Protocol contract](#protocol-contract) — what your HTTP endpoint must satisfy. +2. [Request body (JSON)](#request-body) — every field the extension sends. +3. [Signature verification](#signature-verification) — how to verify `Tt4b-Sign`. +4. [Full example](#full-example) — a complete request you can use for testing. +5. [Local demo server](#local-demo-server) — run a sample receiver locally. + +--- + +## Protocol contract + +Your endpoint **must** satisfy every requirement below. The extension will only ever call the URL the user saved in settings. + +| # | Requirement | +|---|-------------| +| 1 | Accept **`POST`** requests. The extension never uses any other HTTP method for a real callback. | +| 2 | The URL scheme must be **`http://`** or **`https://`**. The extension will not use other URL schemes. | +| 3 | The request carries **`Content-Type: application/json`** with a **UTF-8** JSON body whose structure is defined in [Request body](#request-body). | +| 4 | If the user configured an **auth token**, the request includes a **`Tt4b-Sign`** header (see [Signature verification](#signature-verification)). If no token is configured the header is **omitted** — your server must not require it in that case. | +| 5 | Return a **2xx** status (e.g. `200`) to signal success. The extension only treats the delivery as successful for **2xx** responses. The **response body is not used**. | +| 6 | Return a meaningful **4xx / 5xx** status to signal failure. The user may see the **HTTP status text** of your response, so use a descriptive reason phrase (e.g. `401 Unauthorized`, `400 Bad Request`). | + +**Out of scope:** what you do with the data (store, forward, log, etc.) is entirely up to you — as long as you return **2xx** when you have successfully accepted the payload. + +--- + +## Request body + +The top-level JSON object contains four keys: + +| Field | Type | Description | +|-------|------|-------------| +| `meta` | `object` | Client metadata — see [`meta`](#meta). | +| `cycle` | `string` | `"daily"` or `"weekly"` — the reporting period the user chose. | +| `summary` | `object` | Aggregated totals for the period — see [`summary`](#summary). | +| `row` | `array` | Per-site, per-day breakdown — see [`row`](#row-items). | + +### `meta` + +| Field | Type | Example | Description | +|-------|------|---------|-------------| +| `locale` | `string` | `"en"` | The app's UI locale at the time the payload was built. | +| `version` | `string` | `"1.0.0"` | Extension version string. | +| `ts` | `number` | `1710000000000` | When the payload was built: milliseconds since the Unix epoch. | + +### `summary` + +| Field | Type | Example | Description | +|-------|------|---------|-------------| +| `focus` | `number` | `3600` | Total **focus time in milliseconds** across all sites in the period. Focus time is the accumulated duration a tab was in the foreground and the user was actively interacting. | +| `visit` | `number` | `42` | Total page-visit count across all sites in the period. | +| `siteCount` | `number` | `3` | Number of distinct hosts present in `row`. | +| `dateStart` | `string` | `"2025-01-15"` | First date of the period (`YYYY-MM-DD`). | +| `dateEnd` | `string` | `"2025-01-15"` | Last date of the period (`YYYY-MM-DD`). For a daily cycle `dateStart === dateEnd`. | + +### `row` items + +Each element in the `row` array represents **one host on one day**: + +| Field | Type | Required | Example | Description | +|-------|------|----------|---------|-------------| +| `host` | `string` | yes | `"example.com"` | The site's hostname (no scheme, no path). | +| `date` | `string` | yes | `"2025-01-15"` | The calendar date (`YYYY-MM-DD`). | +| `focus` | `number` | yes | `1200` | Focus time **in milliseconds** for this host on this date. | +| `time` | `number` | yes | `10` | **Visit count** for this host on that calendar day (how many page visits were recorded; **not** a length of time). | +| `run` | `number` | no | `5000` | **Run time** in milliseconds for this host on this date (only when run-time tracking is enabled for the site; not the same as focus time). | + +> **Consistency check:** `summary.focus` equals the sum of all `row[].focus` values, and `summary.visit` equals the sum of all `row[].time` values. + +--- + +## Signature verification + +| Auth token configured? | `Tt4b-Sign` header | +|-------------------------|--------------------| +| No (empty) | **Not sent.** Do not require or check it. | +| Yes | `Tt4b-Sign: <64 lowercase hex characters>` | + +**Algorithm:** HMAC-SHA256. + +- **Key** — the auth-token string (UTF-8). +- **Data** — an **empty string** (`""`). + +> ⚠️ The HMAC is computed over an **empty** input, **not** over the request body. This is by design — the signature proves the caller knows the shared token, not that the body is untampered. If you need body-integrity verification, hash the body separately after validating the token. + +```js +import { createHmac } from 'node:crypto'; + +function verifySign(header, token) { + const expected = createHmac('sha256', token).update('').digest('hex'); + return expected === header; +} +``` + +--- + +## Full example + +**Request:** + +``` +POST /callback HTTP/1.1 +Content-Type: application/json +Tt4b-Sign: <64 hex chars — only if token is set> +``` + +```json +{ + "meta": { + "locale": "en", + "version": "1.0.0", + "ts": 1710000000000 + }, + "cycle": "daily", + "summary": { + "focus": 3600, + "visit": 42, + "siteCount": 3, + "dateStart": "2025-01-15", + "dateEnd": "2025-01-15" + }, + "row": [ + { "host": "example.com", "date": "2025-01-15", "focus": 1200, "time": 10 }, + { "host": "github.com", "date": "2025-01-15", "focus": 1800, "time": 25 }, + { "host": "docs.test.io", "date": "2025-01-15", "focus": 600, "time": 7 } + ] +} +``` + +**Expected response:** `200 OK` (body is ignored). + +> The three rows sum to `focus=3600` and `time=42`, matching `summary.focus` and `summary.visit`. + +--- + +## Local demo server + +From the `examples/` package ([setup](../README.md)): + +```bash +# Optional: set AUTH to the same value as the extension's auth token +export AUTH="my-secret-token" +npm run start:notification +``` + +This starts `demo-server.ts` on **port 3000**. + +| Method | Demo behavior | +|--------|---------------| +| `POST` | Parses JSON, optionally verifies `Tt4b-Sign`, responds `200` / `401` / `400`. | +| `HEAD` | `200` (this demo only; the extension’s callback does not use `HEAD`.) | +| Any other | `405 Method Not Allowed`. **The real callback always uses `POST`.** | + +> The demo only checks `meta` for the signature; it does **not** validate the full body shape. A production service should follow the [protocol contract](#protocol-contract) and field definitions above. \ No newline at end of file diff --git a/examples/notification/demo-server.ts b/examples/notification/demo-server.ts new file mode 100644 index 000000000..b1af9e66c --- /dev/null +++ b/examples/notification/demo-server.ts @@ -0,0 +1,56 @@ +import hash from "hash.js" +import { createServer } from "http" + +const AUTH: string | undefined = process.env.AUTH + +function genSign(meta: any, auth: string): string { + return hash.hmac(hash.sha256 as any, auth).update(meta).digest('hex') +} + +function verifySign(meta: any, receivedSign: string | string[] | undefined): boolean { + if (!AUTH) return true + if (!receivedSign) return false + return genSign(meta, AUTH) === receivedSign +} + +function main() { + const server = createServer(async (req, res) => { + const method = req.method + if (method === 'HEAD') { + res.writeHead(200).end() + return + } + + if (method === 'POST') { + try { + const body = await new Promise((resolve, reject) => { + let data = '' + req.on('data', (chunk: Buffer) => { data += chunk.toString() }) + req.on('end', () => resolve(data)) + req.on('error', reject) + }) + + const { meta } = JSON.parse(body) + const sign = req.headers['tt4b-sign'] + if (!verifySign(meta, sign)) { + res.writeHead(401).end('Unauthorized') + return + } + + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end("Thanks!!") + } catch (e) { + console.error("Failed to parse request", e) + res.writeHead(400).end('Bad Request') + } + return + } + + res.writeHead(405).end('Method Not Allowed') + }) + + const port = 3000 + server.listen(port, () => console.log(`Notification server listening on port ${port}`)) +} + +main() diff --git a/examples/package.json b/examples/package.json new file mode 100644 index 000000000..fdd09e3c0 --- /dev/null +++ b/examples/package.json @@ -0,0 +1,18 @@ +{ + "name": "@tt4b/examples", + "version": "1.0.0", + "private": true, + "description": "Standalone examples package for local/e2e servers", + "scripts": { + "start:gist": "ts-node gist/mock-server.ts", + "start:notification": "ts-node notification/demo-server.ts" + }, + "dependencies": { + "hash.js": "^1.1.7" + }, + "devDependencies": { + "@types/node": "^25.5.0", + "ts-node": "^10.9.2", + "typescript": "6.0.2" + } +} \ No newline at end of file diff --git a/jest.config.ts b/jest.config.ts deleted file mode 100644 index 4b4b1b320..000000000 --- a/jest.config.ts +++ /dev/null @@ -1,46 +0,0 @@ -import { type Config } from "@jest/types" -import { compilerOptions } from './tsconfig.json' - -const { paths } = compilerOptions - -const aliasPattern = /^(@.*)\/\*$/ -const sourcePattern = /^(.*)\/\*$/ - -const moduleNameMapper: { [key: string]: string } = {} - -Object.entries(paths).forEach(([alias, sourceArr]) => { - const aliasMatch = alias.match(aliasPattern) - if (!aliasMatch) { - return - } - if (sourceArr.length !== 1) { - return - } - const sourceMath = sourceArr[0]?.match(sourcePattern) - if (!sourceMath) { - return - } - const prefix = aliasMatch[1] - const pattern = `^${prefix}/(.*)$` - const source = sourceMath[1] - const sourcePath = `/${source}/$1` - moduleNameMapper[pattern] = sourcePath -}) - -console.log("The moduleNameMapper parsed from tsconfig.json: ") -console.log(moduleNameMapper) - -const config: Config.InitialOptions = { - moduleNameMapper, - roots: [ - "/test", - "/test-e2e", - ], - testRegex: '(.+)\\.test\\.(jsx?|tsx?)$', - transform: { - "^.+\\.tsx?$": "@swc/jest" - }, - moduleFileExtensions: ['ts', 'tsx', 'js', 'jsx', 'json', 'node'], -} - -export default config \ No newline at end of file diff --git a/knip.ts b/knip.ts new file mode 100644 index 000000000..fc9fdf434 --- /dev/null +++ b/knip.ts @@ -0,0 +1,34 @@ +import type { KnipConfig } from "knip" +const config: KnipConfig = { + entry: [ + "src/background/index.ts", + "src/pages/app/index.ts", + "src/pages/popup/{index,skeleton}.ts", + "src/pages/side/index.ts", + "src/content-script/index.ts", + "src/content-script/limit/modal/index.ts", + "script/user-chart/{add,render}.ts", + "examples/gist/mock-server.ts", + "examples/notification/demo-server.ts", + ], + ignoreDependencies: [ + "@rstest/coverage-istanbul", + "tsconfig-paths", + ], + rspack: { + config: ["rspack/rspack.{dev,prod,e2e,analyze}*.ts"], + }, + rstest: { + config: [ + "test/rstest.config.mts", + "test-e2e/rstest.config.mts", + ] + }, + commitlint: { + config: [ + ".commitlintrc.ts", + ] + } +} + +export default config \ No newline at end of file diff --git a/package.json b/package.json index 2db08e94b..1a86d99ca 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { - "name": "timer", - "version": "3.6.8", + "name": "tt4b", + "version": "4.3.5", "description": "Time tracker for browser", "homepage": "https://www.wfhg.cc", "scripts": { @@ -10,13 +10,12 @@ "dev:safari": "rspack --config=rspack/rspack.dev.safari.ts --watch", "dev:e2e": "rspack --config=rspack/rspack.e2e.ts", "analyze": "rspack --config=rspack/rspack.analyze.ts", - "android-firefox": "web-ext run -t firefox-android --firefox-apk org.mozilla.fenix -s dist_dev_firefox --adb-device ", "build": "rspack --config=rspack/rspack.prod.ts", "build:firefox": "rspack --config=rspack/rspack.prod.firefox.ts", "build:safari": "rspack --config=rspack/rspack.prod.safari.ts", - "test": "jest --env=jsdom test/", - "test-c": "jest --coverage --reporters=jest-junit --env=jsdom test/", - "test-e2e": "jest test-e2e/ --runInBand", + "test": "rstest --config test/rstest.config.mts", + "test-c": "rstest --config test/rstest.config.mts --coverage --reporter=junit", + "test-e2e": "rstest --config test-e2e/rstest.config.mts", "prepare": "husky" }, "author": { @@ -25,56 +24,54 @@ "url": "https://www.github.com/sheepzh" }, "repository": { - "url": "https://github.com/sheepzh/timer" + "url": "https://github.com/sheepzh/time-tracker-4-browser" }, "license": "MIT", "devDependencies": { - "@crowdin/crowdin-api-client": "^1.48.3", - "@rsdoctor/rspack-plugin": "^1.3.3", - "@rspack/cli": "^1.5.8", - "@rspack/core": "^1.5.8", - "@swc/core": "^1.13.5", - "@swc/jest": "^0.2.39", - "@types/chrome": "0.1.24", + "@commitlint/types": "^21.0.1", + "@crowdin/crowdin-api-client": "^1.55.4", + "@emotion/babel-plugin": "^11.13.5", + "@rsdoctor/rspack-plugin": "^1.5.13", + "@rspack/cli": "^2.0.8", + "@rspack/core": "^2.0.8", + "@rstest/core": "^0.10.3", + "@rstest/coverage-istanbul": "^0.10.3", + "@types/chrome": "0.1.43", "@types/decompress": "^4.2.7", - "@types/jest": "^30.0.0", - "@types/node": "^24.7.2", - "@types/punycode": "^2.1.4", + "@types/firefox-webext-browser": "^143.0.0", + "@types/node": "^25.9.3", "@vue/babel-plugin-jsx": "^2.0.1", - "babel-loader": "^10.0.0", - "commitlint": "^20.1.0", - "css-loader": "^7.1.2", + "babel-loader": "^10.1.1", + "commitlint": "^21.0.2", + "css-loader": "^7.1.4", "decompress": "^4.2.1", + "fake-indexeddb": "^6.2.5", + "fork-ts-checker-webpack-plugin": "^9.1.0", "husky": "^9.1.7", - "jest": "^30.2.0", - "jest-environment-jsdom": "^30.2.0", - "jest-junit": "^16.0.0", + "jsdom": "^29.1.1", "jszip": "^3.10.1", - "postcss": "^8.5.6", - "postcss-loader": "^8.2.0", - "postcss-rtlcss": "^5.7.1", - "puppeteer": "^24.25.0", - "sass": "^1.93.2", - "sass-loader": "^16.0.5", - "ts-loader": "^9.5.4", + "knip": "^6.16.1", + "postcss": "^8.5.15", + "postcss-loader": "^8.2.1", + "postcss-rtlcss": "^6.0.0", + "puppeteer": "^25.1.0", "ts-node": "^10.9.2", "tsconfig-paths": "^4.2.0", - "typescript": "5.9.3" - }, - "optionalDependencies": { - "web-ext": "^9.0.0" + "typescript": "6.0.3", + "unplugin-element-plus": "^0.11.2" }, "dependencies": { "@element-plus/icons-vue": "^2.3.2", - "countup.js": "^2.9.0", - "echarts": "^6.0.0", - "element-plus": "2.11.4", - "js-base64": "^3.7.8", - "punycode": "^2.3.1", - "vue": "^3.5.22", - "vue-router": "^4.6.2" + "@emotion/css": "^11.13.5", + "echarts": "^6.1.0", + "element-plus": "2.14.1", + "hash.js": "^1.1.7", + "qrcode-generator": "^2.0.4", + "typescript-guard": "0.2.6", + "vue": "^3.5.38", + "vue-router": "^5.1.0" }, "engines": { - "node": ">=20" + "node": ">=22" } } diff --git a/public/images/icon-128.png b/public/images/icon-128.png new file mode 100644 index 000000000..7e9537415 Binary files /dev/null and b/public/images/icon-128.png differ diff --git a/public/images/icon-16.png b/public/images/icon-16.png new file mode 100644 index 000000000..b4c5e7a53 Binary files /dev/null and b/public/images/icon-16.png differ diff --git a/public/images/icon-48.png b/public/images/icon-48.png new file mode 100644 index 000000000..0b89d9ed3 Binary files /dev/null and b/public/images/icon-48.png differ diff --git a/rspack/plugins/file-manager.ts b/rspack/plugins/file-manager.ts index 01f442f02..224d99228 100644 --- a/rspack/plugins/file-manager.ts +++ b/rspack/plugins/file-manager.ts @@ -1,4 +1,4 @@ -import type { Compiler } from '@rspack/core' +import type { Compiler, RspackPluginInstance } from '@rspack/core' import fs from 'fs' import JSZip from 'jszip' import path from 'path' @@ -23,7 +23,8 @@ interface FileManagerOptions { context?: string } -export class FileManagerPlugin { +export class FileManagerPlugin implements RspackPluginInstance { + private static readonly NAME = 'FileManagerPlugin' private options: FileManagerOptions private outputPath: string @@ -33,11 +34,11 @@ export class FileManagerPlugin { } apply(compiler: Compiler) { - compiler.hooks.afterEnvironment.tap('FileManagerPlugin', () => { + compiler.hooks.afterEnvironment.tap(FileManagerPlugin.NAME, () => { this.outputPath = compiler.options.output.path || 'dist' }) - compiler.hooks.done.tapPromise('FileManagerPlugin', async () => { + compiler.hooks.done.tapPromise(FileManagerPlugin.NAME, async () => { if (this.options.events.onEnd) { for (const op of this.options.events.onEnd) { await this.processOperation(op) diff --git a/rspack/plugins/generate-json.ts b/rspack/plugins/generate-json.ts index 4682f3cac..d635096a9 100644 --- a/rspack/plugins/generate-json.ts +++ b/rspack/plugins/generate-json.ts @@ -1,38 +1,27 @@ -import { Compilation, Compiler, sources } from '@rspack/core' +import { Compilation, type Compiler, type RspackPluginInstance, sources } from '@rspack/core' -type GenerateJsonPluginOptions = { - data: Record - outputPath: string -} +export class GenerateJsonPlugin implements RspackPluginInstance { + private static readonly NAME = 'GenerateJsonPlugin' -export class GenerateJsonPlugin { - private options: GenerateJsonPluginOptions - - constructor(outputPath: string, data: Record) { + constructor(private outputPath: string, private data: unknown) { if (!data || typeof data !== 'object') { throw new Error('Invalid data option') } - if (!outputPath?.endsWith('.json')) { + if (!outputPath.endsWith('.json')) { throw new Error('outputPath must be .json file') } - - this.options = { outputPath, data } } apply(compiler: Compiler) { - compiler.hooks.thisCompilation.tap('GenerateJsonPlugin', (compilation) => { + compiler.hooks.thisCompilation.tap(GenerateJsonPlugin.NAME, compilation => { compilation.hooks.processAssets.tap({ - name: 'GenerateJsonPlugin', + name: GenerateJsonPlugin.NAME, stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONS, }, () => { try { - const json = JSON.stringify(this.options.data) - - compilation.emitAsset(this.options.outputPath, { - source: () => json, - size: () => json.length - } as sources.Source) - + const json = JSON.stringify(this.data) + const raw = new sources.RawSource(json) + compilation.emitAsset(this.outputPath, raw) } catch (e) { compilation.errors.push(new Error(`[SimpleWriteJson] ${(e as Error)?.message}`)) } diff --git a/rspack/plugins/import-checker.ts b/rspack/plugins/import-checker.ts new file mode 100644 index 000000000..bd094d3df --- /dev/null +++ b/rspack/plugins/import-checker.ts @@ -0,0 +1,97 @@ +import type { Compiler, Module, ModuleGraph, RspackPluginInstance } from '@rspack/core' +import { NormalModule } from '@rspack/core' + +/** + * Can't import content-script & pages for background + * Can't import background for content-script & pages + */ +class ImportCheckerPlugin implements RspackPluginInstance { + static readonly NAME = 'ImportCheckerPlugin' + + apply(compiler: Compiler) { + compiler.hooks.compilation.tap(ImportCheckerPlugin.NAME, compilation => { + const moduleGraph = compilation.moduleGraph + compilation.hooks.finishModules.tap( + ImportCheckerPlugin.NAME, + modules => { + for (const mod of modules) { + processModule(mod, moduleGraph) + } + }, + ) + }) + } +} + +function processModule(mod: Module, moduleGraph: ModuleGraph) { + const resource = moduleFilesystemPath(mod) + if (!resource) return + + const incoming = moduleGraph.getIncomingConnections(mod) + if (!incoming?.length) return + + for (const conn of incoming) { + const originMod = conn.originModule + if (!originMod) continue + const issuer = moduleFilesystemPath(originMod) + if (!issuer) continue + const err = verify(issuer, resource) + if (err) throw err + } +} + +function verify(issuer: string, resource: string): Error | undefined { + const issuerPath = normalizePath(issuer) + const resourcePath = normalizePath(resource) + + const issuerInBg = isBgPath(issuerPath) + const issuerInOthers = isCsOrPagePath(issuerPath) + + const resourceInBg = isBgPath(resourcePath) + const resourceInOthers = isCsOrPagePath(resourcePath) + + if (issuerInBg && resourceInOthers) { + return new Error( + `[${ImportCheckerPlugin.NAME}] background must not import content-script or pages.\n` + + ` From: ${issuer}\n` + + ` To: ${resource}`, + ) + } + + if (issuerInOthers && resourceInBg) { + return new Error( + `[${ImportCheckerPlugin.NAME}] content-script and pages must not import background.\n` + + ` From: ${issuer}\n` + + ` To: ${resource}`, + ) + } +} + +function moduleFilesystemPath(mod: Module): string | undefined { + if (mod instanceof NormalModule) { + const { resource } = mod + if (resource) return resource + } + return mod.nameForCondition() +} + +function normalizePath(p: string): string { + return p.replace(/\\/g, '/') +} + +export function isBgPath(path: string): boolean { + return isUnder(normalizePath(path), 'background') +} + +function isCsOrPagePath(path: string): boolean { + return isUnder(path, 'content-script') || isUnder(path, 'pages') +} + +function isUnder(path: string, segment: string): boolean { + const marker = `/src/${segment}/` + if (path.includes(marker)) return true + const suffix = `/src/${segment}` + return path.endsWith(suffix) +} + +export default ImportCheckerPlugin diff --git a/rspack/rspack.common.ts b/rspack/rspack.common.ts index a941fc410..6f2bde349 100644 --- a/rspack/rspack.common.ts +++ b/rspack/rspack.common.ts @@ -1,21 +1,22 @@ import { CopyRspackPlugin, CssExtractRspackPlugin, DefinePlugin, HtmlRspackPlugin, - type Chunk, type Configuration, - type RspackPluginInstance, - type RuleSetRule + type Chunk, type Configuration, type Module, type RspackPluginInstance, type RuleSetRule } from "@rspack/core" +import { default as VueBabelPluginJsx } from "@vue/babel-plugin-jsx" import path, { join } from "path" import postcssRTLCSS from 'postcss-rtlcss' +import ElementPlus from 'unplugin-element-plus/rspack' import i18nChrome from "../src/i18n/chrome" +import { compilerOptions } from "../tsconfig.json" import { GenerateJsonPlugin } from "./plugins/generate-json" +import ImportCheckerPlugin, { isBgPath } from "./plugins/import-checker" -export const MANIFEST_JSON_NAME = "manifest.json" +const MANIFEST_JSON_NAME = "manifest.json" const generateJsonPlugins: RspackPluginInstance[] = [] const localeJsonFiles = Object.entries(i18nChrome) .map(([locale, message]) => new GenerateJsonPlugin(`_locales/${locale}/messages.json`, message)) - .map(plugin => plugin as unknown as RspackPluginInstance) generateJsonPlugins.push(...localeJsonFiles) type EntryConfig = { @@ -25,7 +26,7 @@ type EntryConfig = { const BACKGROUND = 'background' const CONTENT_SCRIPT = 'content_scripts' -const CONTENT_SCRIPT_SKELETON = 'content_scripts_skeleton' +const CONTENT_SCRIPT_LIMIT = 'content_scripts_limit' const POPUP = 'popup' const entryConfigs: EntryConfig[] = [{ @@ -35,8 +36,8 @@ const entryConfigs: EntryConfig[] = [{ name: CONTENT_SCRIPT, path: './src/content-script', }, { - name: CONTENT_SCRIPT_SKELETON, - path: './src/content-script/skeleton', + name: CONTENT_SCRIPT_LIMIT, + path: './src/content-script/limit/modal', }, { name: POPUP, path: './src/pages/popup', @@ -61,9 +62,11 @@ const POSTCSS_LOADER_CONF: RuleSetRule['use'] = { } const chunkFilter = ({ name }: Chunk) => { - return !name || ![BACKGROUND, CONTENT_SCRIPT, CONTENT_SCRIPT_SKELETON].includes(name) + return !name || ![BACKGROUND, CONTENT_SCRIPT].includes(name) } +const isBackgroundModule = (module: Module) => isBgPath(module.nameForCondition?.() ?? '') + const staticOptions: Configuration = { entry() { const entry: Record = {} @@ -83,16 +86,30 @@ const staticOptions: Configuration = { iterableIsArray: true, }, plugins: [ - "@vue/babel-plugin-jsx", + VueBabelPluginJsx, + "@emotion/babel-plugin", ], }, - }, 'ts-loader'], + }, { + loader: 'builtin:swc-loader', + options: { + jsc: { + parser: { + syntax: 'typescript', + tsx: true, + }, + transform: { + react: { + runtime: 'preserve', + }, + }, + target: compilerOptions.target, + }, + }, + }], }, { test: /\.css$/, use: [CssExtractRspackPlugin.loader, 'css-loader', POSTCSS_LOADER_CONF], - }, { - test: /\.s[ac]ss$/, - use: [CssExtractRspackPlugin.loader, 'css-loader', POSTCSS_LOADER_CONF, 'sass-loader'] }, { test: /\.(jpg|jpeg|png|woff|woff2|eot|ttf|svg)$/, type: 'asset/resource' @@ -100,20 +117,81 @@ const staticOptions: Configuration = { ] }, resolve: { - extensions: ['.ts', '.tsx', '.js', '.css', '.scss', '.sass'], + extensions: ['.ts', '.tsx', '.js', '.css'], tsConfig: join(__dirname, '..', 'tsconfig.json'), + conditionNames: ['import', 'module', 'browser', 'default'], + alias: { + 'element-plus/es/components/loading-service/style/css': 'element-plus/es/components/loading/style/css', + 'element-plus/es/components/loading-directive/style/css': 'element-plus/es/components/loading/style/css', + 'element-plus/es/components/auto-resizer/style/css': 'element-plus/es/components/table-v2/style/css', + }, }, optimization: { splitChunks: { chunks: chunkFilter, + maxInitialRequests: 30, + maxAsyncRequests: 30, cacheGroups: { + echarts: { + test: /[\\/]node_modules[\\/]echarts[\\/]/, + name: 'vendor/echarts', + filename: 'vendor/echarts.js', + priority: 40, + reuseExistingChunk: true, + enforce: true, + }, elementPlus: { - name: 'element-plus', test: /[\\/]node_modules[\\/]element-plus[\\/]/, + name: 'vendor/element-plus', + filename: 'vendor/element-plus.js', + priority: 39, + reuseExistingChunk: true, + enforce: true, + }, + elementIcons: { + test: /[\\/]node_modules[\\/]@element-plus[\\/]icons-vue[\\/]/, + name: 'vendor/el-icons', + filename: 'vendor/el-icons.js', + priority: 38, + reuseExistingChunk: true, + enforce: true, + }, + vue: { + test: /[\\/]node_modules[\\/](vue|@vue|vue-router|@vueuse)[\\/]/, + name: 'vendor/vue', + filename: 'vendor/vue.js', + priority: 37, + reuseExistingChunk: true, + enforce: true, + }, + dayjs: { + test: /[\\/node_modules][\\/]dayjs[\\/]/, + priority: 37, + reuseExistingChunk: true, + enforce: true, + }, + memoizeOne: { + test: /[\\/node_modules][\\/]memoize\\-one[\\/]/, + priority: 37, + reuseExistingChunk: true, + enforce: true, + }, + /** + * Exclude src/background from the default shared chunk group so those files are + * never pulled into vendor/* (merging into entry name: 'background' panics in Rspack). + */ + default: { + minChunks: 2, + priority: -20, + reuseExistingChunk: true, + test: module => !isBackgroundModule(module), }, defaultVendors: { - filename: 'vendor/[name].js' - } + test: /[\\/]node_modules[\\/]/, + filename: 'vendor/[name].js', + priority: -10, + reuseExistingChunk: true, + }, } }, }, @@ -121,14 +199,16 @@ const staticOptions: Configuration = { type Option = { outputPath: string - manifest: chrome.runtime.ManifestV3 | chrome.runtime.ManifestFirefox + manifest: chrome.runtime.ManifestV3 | browser._manifest.WebExtensionManifest mode: Configuration["mode"] } const generateOption = ({ outputPath, manifest, mode }: Option) => { const plugins = [ ...generateJsonPlugins, + ElementPlus({}), new GenerateJsonPlugin(MANIFEST_JSON_NAME, manifest), + new ImportCheckerPlugin(), // copy static resources new CopyRspackPlugin({ patterns: [ @@ -138,7 +218,7 @@ const generateOption = ({ outputPath, manifest, mode }: Option) => { } ] }), - new CssExtractRspackPlugin(), + new CssExtractRspackPlugin({ ignoreOrder: true }), new HtmlRspackPlugin({ filename: path.join('static', 'app.html'), title: 'Loading...', @@ -150,6 +230,17 @@ const generateOption = ({ outputPath, manifest, mode }: Option) => { }, chunks: ['app'], }), + new HtmlRspackPlugin({ + filename: path.join('static', 'limit.html'), + title: 'Loading...', + chunks: [CONTENT_SCRIPT_LIMIT], + meta: { + viewport: { + name: "viewport", + content: 'width=device-width', + }, + } + }), new HtmlRspackPlugin({ filename: path.join('static', 'popup.html'), chunks: ['popup'], @@ -174,6 +265,7 @@ const generateOption = ({ outputPath, manifest, mode }: Option) => { const config: Configuration = { ...staticOptions, output: { + ...staticOptions.output, path: outputPath, filename: '[name].js', }, diff --git a/rspack/rspack.dev.firefox.ts b/rspack/rspack.dev.firefox.ts index 75f713ded..adb49d4e2 100644 --- a/rspack/rspack.dev.firefox.ts +++ b/rspack/rspack.dev.firefox.ts @@ -3,10 +3,14 @@ import manifest from "../src/manifest-firefox" import generateOption from "./rspack.common" manifest.name = "IS DEV" -// Fix the crx id for development mode -manifest.key = "clbbddpinhgdejpoepalbfnkogbobfdb" // The manifest.json is different from Chrome's with add-on ID -manifest.browser_specific_settings = { gecko: { id: 'timer@zhy' } } +manifest.browser_specific_settings = { + ...manifest.browser_specific_settings, + gecko: { + ...manifest.browser_specific_settings?.gecko, + id: 'tt4b@zhy', + } +} const options = generateOption({ outputPath: path.join(__dirname, '..', 'dist_dev_firefox'), diff --git a/rspack/rspack.dev.ts b/rspack/rspack.dev.ts index 18ce606d4..5cf59f91e 100644 --- a/rspack/rspack.dev.ts +++ b/rspack/rspack.dev.ts @@ -1,3 +1,4 @@ +import ForkTsCheckerWebpackPlugin from "fork-ts-checker-webpack-plugin" import path from "path" import manifest from "../src/manifest" import generateOption from "./rspack.common" @@ -10,4 +11,27 @@ const options = generateOption({ mode: "development", }) +const tsCheckerPlugin = new ForkTsCheckerWebpackPlugin({ + typescript: { + configOverwrite: { + compilerOptions: { + skipLibCheck: false, + }, + }, + diagnosticOptions: { + syntactic: true, + semantic: true, + declaration: true, + global: true, + }, + }, + issue: { + exclude: [ + { file: '**/node_modules/**' }, + ], + }, +}) + +options.plugins?.push(tsCheckerPlugin) + export default options diff --git a/rspack/rspack.e2e.ts b/rspack/rspack.e2e.ts index 633fbfa1d..632cf4795 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 36bd6144c..b76b4c28f 100644 --- a/rspack/rspack.prod.firefox.ts +++ b/rspack/rspack.prod.firefox.ts @@ -2,6 +2,7 @@ import path from "path" import manifestFirefox from "../src/manifest-firefox" import { FileManagerPlugin } from "./plugins/file-manager" import optionGenerator from "./rspack.common" +import { enhancePluginWith } from './util' const { name, version } = require(path.join(__dirname, '..', 'package.json')) @@ -10,19 +11,10 @@ const marketPkgPath = path.resolve(__dirname, '..', 'market_packages') const normalZipFilePath = path.resolve(marketPkgPath, `${name}-${version}.firefox.zip`) const targetZipFilePath = path.resolve(marketPkgPath, 'target.firefox.zip') -const normalSourceCodePath = path.resolve(__dirname, '..', 'market_packages', `${name}-${version}-src.zip`) -const targetSourceCodePath = path.resolve(__dirname, '..', 'market_packages', 'target.src.zip') -const readmeForFirefox = path.join(__dirname, '..', 'doc', 'for-fire-fox.md') -// Temporary directory for source code to archive on Firefox -const sourceTempDir = path.resolve(__dirname, '..', 'source_temp') -const srcDir = ['public', 'src', "test", "types", 'package.json', 'tsconfig.json', 'rspack', "jest.config.ts", "script", ".gitignore"] -const copyMapper = srcDir.map(p => { return { source: path.resolve(__dirname, '..', p), destination: path.resolve(sourceTempDir, p) } }) -const filemanagerPlugin = new FileManagerPlugin({ +const fileManagerPlugin = new FileManagerPlugin({ events: { - // Archive at the end onEnd: [ - // Define plugin to archive zip for different markets { delete: [normalZipFilePath], archive: [{ @@ -33,26 +25,12 @@ const filemanagerPlugin = new FileManagerPlugin({ destination: targetZipFilePath, }] }, - // Archive source code for FireFox - { - copy: [ - { source: readmeForFirefox, destination: path.join(sourceTempDir, 'README.md') }, - ...copyMapper - ], - archive: [ - { source: sourceTempDir, destination: normalSourceCodePath }, - { source: sourceTempDir, destination: targetSourceCodePath }, - ], - delete: [sourceTempDir], - }, ] } }) const option = optionGenerator({ outputPath, manifest: manifestFirefox, mode: "production" }) -const { plugins = [] } = option -plugins.push(filemanagerPlugin) -option.plugins = plugins +enhancePluginWith(option, fileManagerPlugin) option.devtool = false -export default option \ No newline at end of file +export default option diff --git a/rspack/rspack.prod.safari.ts b/rspack/rspack.prod.safari.ts index 5a9dd8a36..e4f53201e 100644 --- a/rspack/rspack.prod.safari.ts +++ b/rspack/rspack.prod.safari.ts @@ -10,7 +10,7 @@ const normalZipFilePath = path.resolve(__dirname, '..', 'market_packages', `${na const options = generateOption({ outputPath, manifest, mode: "production" }) -const filemanagerPlugin = new FileManagerPlugin({ +const fileManagerPlugin = new FileManagerPlugin({ events: { // Archive at the end onEnd: [ @@ -25,7 +25,7 @@ const filemanagerPlugin = new FileManagerPlugin({ }) const { plugins = [] } = options -plugins.push(filemanagerPlugin) +plugins.push(fileManagerPlugin) options.plugins = plugins export default options \ No newline at end of file diff --git a/rspack/rspack.prod.ts b/rspack/rspack.prod.ts index 47df7f112..6ef6db0c1 100644 --- a/rspack/rspack.prod.ts +++ b/rspack/rspack.prod.ts @@ -12,7 +12,7 @@ const marketPkgPath = path.resolve(__dirname, '..', 'market_packages') const normalZipFilePath = path.resolve(marketPkgPath, `${name}-${version}.mv3.zip`) const targetZipFilePath = path.resolve(marketPkgPath, `target.zip`) -const filemanagerPlugin = new FileManagerPlugin({ +const fileManagerPlugin = new FileManagerPlugin({ events: { // Archive at the end onEnd: [ @@ -34,7 +34,7 @@ const filemanagerPlugin = new FileManagerPlugin({ const option = optionGenerator({ outputPath, manifest, mode: "production" }) -enhancePluginWith(option, filemanagerPlugin) +enhancePluginWith(option, fileManagerPlugin) option.devtool = false export default option \ No newline at end of file diff --git a/rspack/util.ts b/rspack/util.ts index 65114cee9..f05e1b3fa 100644 --- a/rspack/util.ts +++ b/rspack/util.ts @@ -1,6 +1,6 @@ -import { type RspackOptions, type 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/android-firefox.sh b/script/android-firefox.sh new file mode 100755 index 000000000..d5446ed69 --- /dev/null +++ b/script/android-firefox.sh @@ -0,0 +1,246 @@ +#!/bin/bash + +# Exit immediately on error +set -e + +exec 3>&1 +exec 4>&2 + +# Global variable to store background process PID +NPM_PID="" + +# Cleanup: kill npm watching process +cleanup() { + if [ -n "$NPM_PID" ] && kill -0 "$NPM_PID" 2>/dev/null; then + log_info "Stopping background build process (PID: $NPM_PID)..." + kill "$NPM_PID" 2>/dev/null + wait "$NPM_PID" 2>/dev/null + fi +} + +# Set trap to cleanup on exit +trap cleanup EXIT + +# Source logging functions +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +LOG_FD=4 +source "${SCRIPT_DIR}/lib/log.sh" + +# Check if command exists +check_command() { + if ! command -v "$1" &> /dev/null; then + log_error "Command $1 not found, please install it first" + return 1 + fi + return 0 +} + +# Check web-ext version +check_web_ext_version() { + log_info "Checking web-ext version..." + if check_command web-ext; then + local version + version=$(web-ext --version 2>/dev/null || echo "0.0.0") + log_info "Current web-ext version: $version" + + local major_version + major_version=$(echo "$version" | cut -d. -f1) + if [ "$major_version" -lt 4 ]; then + log_warning "web-ext version might be too old, recommend upgrading to 4.1.0 or later" + log_info "Run: npm install -g web-ext@latest" + fi + else + log_error "web-ext is not installed" + log_info "Please run: npm install -g web-ext@latest" + exit 1 + fi +} + +# Check adb installation +check_adb_installation() { + log_info "Checking adb installation..." + if check_command adb; then + log_success "adb is installed" + else + log_error "adb not found, please install Android Platform Tools" + log_info "You can install via Android Studio SDK Manager or run:" + log_info "sdkmanager 'platform-tools'" + exit 1 + fi +} + +# Select device from connected Android devices +select_device() { + log_info "Scanning for connected Android devices..." + + adb start-server > /dev/null 2>&1 + + local device_array=() + local devices_output + devices_output=$(adb devices 2>/dev/null) + + while IFS= read -r device; do + if [[ -n "$device" && "$device" != "List of devices attached" ]]; then + device_array+=("$device") + fi + done < <(adb devices 2>/dev/null | awk 'NR>1 && /device$/ {print $1}') + + local device_count=${#device_array[@]} + + if [ "$device_count" -eq 0 ]; then + log_error "No Android device or emulator found" + log_info "Please ensure:" + log_info " 1. Device is connected via USB" + log_info " 2. USB debugging is enabled" + log_info " 3. Firefox for Android Nightly is installed" + log_info " 4. 'Remote debugging via USB' is enabled in Firefox" + exit 1 + fi + + if [ "$device_count" -eq 1 ]; then + local device_id="${device_array[0]}" + log_success "Selected device: $device_id" + echo "$device_id" + return 0 + fi + + log_info "Multiple devices found:" + echo >&3 + + for i in "${!device_array[@]}"; do + echo " $((i+1)). ${device_array[$i]}" >&3 + done + + echo >&3 + read -p "Select device (1-${#device_array[@]}): " device_choice <&3 + + if [[ "$device_choice" =~ ^[0-9]+$ ]] && [ "$device_choice" -ge 1 ] && [ "$device_choice" -le "${#device_array[@]}" ]; then + local selected_device="${device_array[$((device_choice-1))]}" + log_success "Selected device: $selected_device" + echo "$selected_device" + else + log_error "Invalid selection" + exit 1 + fi +} + +# Check Firefox Nightly installation on specific device +check_firefox_nightly() { + local device_id="$1" + + log_info "Checking Firefox Nightly installation on device $device_id..." + + local firefox_package="org.mozilla.fenix" + + if adb -s "$device_id" shell pm list packages | grep -q "$firefox_package"; then + log_success "Firefox Nightly is installed on device $device_id" + else + log_warning "Firefox Nightly not detected on device $device_id" + log_info "Package name should be: $firefox_package" + fi +} + +# Build and run extension on specific device +build_and_run_extension() { + local device_id="$1" + local extension_dir="dist_dev_firefox" + # Clear existing extension directory + if [ -d "$extension_dir" ]; then + log_info "Clearing existing extension directory..." + rm -rf "$extension_dir" + fi + # Start npm run dev:firefox in background + log_info "Starting npm run dev:firefox in background..." + npm run dev:firefox > /dev/null 2>&1 & + NPM_PID=$! + # Wait for manifest.json to be created + local max_wait=60 + local wait_count=0 + log_info "Waiting for building finished..." + while [ ! -f "$extension_dir/manifest.json" ] && [ $wait_count -lt $max_wait ]; do + sleep 1 + wait_count=$((wait_count + 1)) + if [ $((wait_count % 5)) -eq 0 ]; then + log_info "Still waiting for building finished... ($wait_count/$max_wait)" + fi + done + + if [ ! -f "$extension_dir/manifest.json" ]; then + log_error "Building not finished yet after ${max_wait}s" + cleanup + exit 1 + fi + + log_success "Extension built successfully, manifest.json found" + log_info "Background build process PID: $NPM_PID" + + log_info "Starting extension development server..." + log_info "Command: web-ext run -t firefox-android --firefox-apk org.mozilla.fenix -s $extension_dir --adb-device $device_id --verbose" + + if web-ext run \ + -t firefox-android \ + --firefox-apk org.mozilla.fenix \ + -s "$extension_dir" \ + --adb-device "$device_id" \ + --verbose; then + log_success "Extension development server started successfully" + else + log_error "Failed to start extension development server" + exit 1 + fi +} + +main() { + echo -e "${BLUE}=== Android Extension Development One-Click Setup Script ===${NC}" >&3 + echo >&3 + + # Check prerequisites + check_web_ext_version + check_adb_installation + + local selected_device + selected_device=$(select_device) + check_firefox_nightly "$selected_device" + + log_success "All prerequisite checks passed!" + log_success "Target device: $selected_device" + + log_info "Do you want to build and run the extension now? (y/n): " + read -n 1 -r <&3 + echo >&3 + if [[ $REPLY =~ ^[Yy]$ ]]; then + build_and_run_extension "$selected_device" + else + log_warning "Gave up to launch the extension" + log_info "You can run the extension manually later:" + log_info "web-ext run -t firefox-android --firefox-apk org.mozilla.fenix -s dist_dev_firefox --adb-device $selected_device" + fi +} + +# Show usage instructions +show_usage() { + echo "Usage: $0" >&3 + echo >&3 + echo "Description:" >&3 + echo " One-click script to build and run Android extension development" >&3 + echo " Extension directory: dist_dev_firefox" >&3 + echo >&3 + echo "Prerequisites:" >&3 + echo " - web-ext 4.1.0 or later" >&3 + echo " - Android Platform Tools (adb)" >&3 + echo " - Connected Android device with Firefox Nightly" >&3 + echo >&3 + echo "The script will:" >&3 + echo " 1. Detect all connected Android devices" >&3 + echo " 2. Let you select target device (if multiple)" >&3 + echo " 3. Build extension if needed (npm run dev:firefox)" >&3 + echo " 4. Run extension on selected device" >&3 +} + +# Handle help parameter +if [ "$1" = "-h" ] || [ "$1" = "--help" ]; then + show_usage + exit 0 +fi + +main \ No newline at end of file diff --git a/script/crowdin/client.ts b/script/crowdin/client.ts index 2b9361f56..80fc7474f 100644 --- a/script/crowdin/client.ts +++ b/script/crowdin/client.ts @@ -19,14 +19,12 @@ const MAIN_BRANCH_NAME = 'main' */ class PaginationIterator { private offset = 0 - private limit = 25 + private limit = 500 private isEnd = false private buf: T[] = [] private cursor = 0 - private query: (pagination: Pagination) => Promise> - constructor(query: (pagination: Pagination) => Promise>) { - this.query = query + constructor(private query: (pagination: Pagination) => Promise>) { } reset(): void { @@ -79,8 +77,8 @@ class PaginationIterator { private async processBuf() { const pagination: Pagination = { offset: this.offset, limit: this.limit } const list = await this.query(pagination) - const data = list?.data - if (!data?.length) { + const data = list.data + if (!data.length) { this.isEnd = true } else { this.buf = data.map(obj => obj.data) @@ -98,7 +96,7 @@ export type NameKey = { branchId: number } -export type TranslationKey = { +type TranslationKey = { stringId: number lang: CrowdinLanguage } @@ -169,17 +167,6 @@ export class CrowdinClient { return response.data } - async restoreFile(storage: UploadStorageModel.Storage, existFile: SourceFilesModel.File): Promise { - const response = await this.crowdin.sourceFilesApi.updateOrRestoreFile(PROJECT_ID, existFile.id, { storageId: storage.id }) - return response.data - } - - getFileByName(param: NameKey): Promise { - return new PaginationIterator( - p => this.crowdin.sourceFilesApi.listProjectFiles(PROJECT_ID, { ...p, branchId: param.branchId }) - ).findFirst(t => t.name === param.name) - } - getDirByName(param: NameKey): Promise { return new PaginationIterator( p => this.crowdin.sourceFilesApi.listProjectDirectories(PROJECT_ID, { ...p, branchId: param.branchId }) @@ -200,6 +187,12 @@ export class CrowdinClient { ).findAll() } + async downloadSourceFile(fileId: number): Promise> { + const res = await this.crowdin.sourceFilesApi.downloadFile(PROJECT_ID, fileId) + const response = await fetch(res.data.url) + return await response.json() as Record + } + listStringsByFile(fileId: number): Promise { return new PaginationIterator( p => this.crowdin.sourceStringsApi.listProjectStrings(PROJECT_ID, { ...p, fileId: fileId }) @@ -229,15 +222,14 @@ export class CrowdinClient { console.log("Content length: " + Object.keys(content).length) for (const [path, value] of Object.entries(content)) { const string = existStringsKeyMap[path] + if (!string) continue const patch: PatchRequest[] = [] - string?.text !== value && patch.push({ + string.text !== value && patch.push({ op: 'replace', path: '/text', value: value }) - if (!patch.length) { - continue - } + if (!patch.length) continue console.log('Try to edit string: ' + string.identifier) await this.crowdin.sourceStringsApi.editString(PROJECT_ID, string.id, patch) } @@ -280,13 +272,25 @@ export class CrowdinClient { targetLanguageIds: [...ALL_CROWDIN_LANGUAGES], skipUntranslatedStrings: true, }) - const buildId = buildRes?.data?.id + const buildId = buildRes.data.id + const maxRetries = 120 + let retryCount = 0 while (true) { - // Wait finished - const res = await this.crowdin.translationsApi.downloadTranslations(PROJECT_ID, buildId) - const url = res?.data?.url - if (url) return url + if (retryCount >= maxRetries) { + throw new Error(`Build timed out after ${maxRetries} retries: buildId=${buildId}`) + } + const statusRes = await this.crowdin.translationsApi.checkBuildStatus(PROJECT_ID, buildId) + const { status, progress } = statusRes.data + console.log(`Build status: ${status}, progress: ${progress}%`) + if (status === 'finished') break + if (status === 'canceled' || status === 'failed') { + throw new Error(`Build ${status}: buildId=${buildId}`) + } + retryCount++ + await new Promise(resolve => setTimeout(resolve, 1000)) } + const res = await this.crowdin.translationsApi.downloadTranslations(PROJECT_ID, buildId) + return res.data.url } } @@ -296,7 +300,7 @@ export class CrowdinClient { * @returns client */ export function getClientFromEnv(): CrowdinClient { - const envVar = process.env?.TIMER_CROWDIN_AUTH + const envVar = process.env.TIMER_CROWDIN_AUTH if (!envVar) { console.error("Failed to get the variable named [TIMER_CROWDIN_AUTH]") process.exit(1) diff --git a/script/crowdin/common.ts b/script/crowdin/common.ts index d02a4399a..090f1baa4 100644 --- a/script/crowdin/common.ts +++ b/script/crowdin/common.ts @@ -18,7 +18,12 @@ export type ItemSet = { [path: string]: string } -export const ALL_CROWDIN_LANGUAGES = ['zh-TW', 'ja', 'pt-PT', 'uk', 'es-ES', 'de', 'fr', 'ru', 'ar'] as const +export const ALL_CROWDIN_LANGUAGES = [ + 'zh-CN', 'zh-TW', 'ja', + 'pt-PT', 'uk', 'es-ES', 'de', 'fr', 'ru', 'pl', + 'ar', 'tr', + 'it', +] as const /** * The language code of crowdin @@ -27,34 +32,28 @@ export const ALL_CROWDIN_LANGUAGES = ['zh-TW', 'ja', 'pt-PT', 'uk', 'es-ES', 'de */ export type CrowdinLanguage = typeof ALL_CROWDIN_LANGUAGES[number] -export const SOURCE_LOCALE: timer.SourceLocale = 'en' - -// Not include en and zh_CN -export const ALL_TRANS_LOCALES: timer.OptionalLocale[] = [ - 'ja', - 'zh_TW', - 'pt_PT', - 'uk', - 'es', - 'de', - 'fr', - 'ru', - 'ar', -] - -const CROWDIN_I18N_MAP: Record = { - ja: 'ja', - 'zh-TW': 'zh_TW', - 'pt-PT': 'pt_PT', - uk: 'uk', - 'es-ES': 'es', - de: 'de', - fr: 'fr', - ru: 'ru', - ar: 'ar', +export const SOURCE_LOCALE: tt4b.RequiredLocale = 'en' + +const OPTIONAL_PLACEHOLDER: Record = { + ja: 0, + uk: 0, + de: 0, + fr: 0, + ru: 0, + pl: 0, + ar: 0, + tr: 0, + zh_CN: 0, + zh_TW: 0, + pt_PT: 0, + es: 0, + it: 0, } -const I18N_CROWDIN_MAP: Record = { +export const ALL_TRANS_LOCALES = Object.keys(OPTIONAL_PLACEHOLDER) as tt4b.OptionalLocale[] + +const I18N_CROWDIN_MAP: Record = { + zh_CN: 'zh-CN', ja: 'ja', zh_TW: 'zh-TW', pt_PT: 'pt-PT', @@ -64,11 +63,12 @@ const I18N_CROWDIN_MAP: Record = { fr: 'fr', ru: 'ru', ar: 'ar', + tr: 'tr', + pl: 'pl', + it: 'it', } -export const crowdinLangOf = (locale: timer.OptionalLocale): CrowdinLanguage => I18N_CROWDIN_MAP[locale] - -export const localeOf = (crowdinLang: CrowdinLanguage) => CROWDIN_I18N_MAP[crowdinLang] +export const crowdinLangOf = (locale: tt4b.OptionalLocale): CrowdinLanguage => I18N_CROWDIN_MAP[locale] const IGNORED_FILE: Partial<{ [dir in Dir]: string[] }> = { common: [ @@ -83,7 +83,7 @@ export function isIgnored(dir: Dir, fileName: string) { return !!IGNORED_FILE[dir]?.includes(fileName) } -export const MSG_BASE = path.join(__dirname, '..', '..', 'src', 'i18n', 'message') +const MSG_BASE = path.join(__dirname, '..', '..', 'src', 'i18n', 'message') export const RSC_FILE_SUFFIX = "-resource.json" /** @@ -92,7 +92,7 @@ export const RSC_FILE_SUFFIX = "-resource.json" * @param dir the directory of messages * @returns */ -export async function readAllMessages(dir: Dir): Promise>> { +export async function readAllMessages(dir: Dir): Promise>>> { const dirPath = path.join(MSG_BASE, dir) const files = fs.readdirSync(dirPath) @@ -101,7 +101,7 @@ export async function readAllMessages(dir: Dir): Promise + const message = (await import(`@i18n/message/${dir}/${file}`))?.default as Messages> const name = file.replace(RSC_FILE_SUFFIX, '') message && (result[name] = message) return @@ -110,22 +110,22 @@ export async function readAllMessages(dir: Dir): Promise> + messages: Partial> ): 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 + const existMessages = (await import(`@i18n/message/${dir}/${filename}`))?.default as Messages> if (!existMessages) { - console.error(`Failed to find local code: dir=${dir}, filename=${filename}`) + logError(`Failed to find local code: dir=${dir}, filename=${filename}`) return } const sourceItemSet = transMsg(existMessages[SOURCE_LOCALE]) @@ -141,32 +141,36 @@ export async function mergeMessage( // Deleted key if (!sourceText) return if (!checkPlaceholder(text, sourceText)) { - console.error(`Invalid placeholder: dir=${dir}, filename=${filename}, path=${path}, source=${sourceText}, translated=${text}`) + logError(`Invalid placeholder: locale=${locale}, dir=${dir}, filename=${filename}, path=${path}, source=${sourceText}, translated=${text}`) return } const pathSeg = path.split('.') fillItem(pathSeg, 0, newMessage, text) }) - Object.entries(newMessage).length && (existMessages[locale as timer.Locale] = newMessage) + Object.entries(newMessage).length && (existMessages[locale as tt4b.Locale] = newMessage) }) const newFileContent = JSON.stringify(existMessages, null, 4) fs.writeFileSync(filePath, newFileContent, { encoding: 'utf-8' }) } +function logError(msg: string) { + console.error(`\x1b[31m[CROWDIN-ERROR]\x1b[0m ${msg}`) +} + function checkPlaceholder(translated: string, source: string) { const allSourcePlaceholders = - Array.from(source.matchAll(/\{(.*?)\}/g)) + Array.from(source.matchAll(/\{(.*?)}/g)) .map(matched => matched[1]) .sort() const allTranslatedPlaceholders = - Array.from(translated.matchAll(/\{(.*?)\}/g)) + Array.from(translated.matchAll(/\{(.*?)}/g)) .map(matched => matched[1]) .sort() if (allSourcePlaceholders.length != allTranslatedPlaceholders.length) { return false } - for (let i = 0; i++; i < allSourcePlaceholders.length) { + for (let i = 0; i < allSourcePlaceholders.length; i++) { if (allSourcePlaceholders[i] !== allTranslatedPlaceholders[i]) { return false } @@ -176,6 +180,7 @@ function checkPlaceholder(translated: string, source: string) { function fillItem(fields: string[], index: number, obj: Record, text: string) { const field = fields[index] + if (!field) return if (index === fields.length - 1) { obj[field] = text return @@ -213,8 +218,7 @@ export function transMsg(message: any, prefix?: string): ItemSet { export async function checkMainBranch(client: CrowdinClient): Promise { const branch = await client.getMainBranch() - if (!branch) { - exitWith("Main branch is null") - } - return branch! + return branch ?? exitWith("Main branch is null") } + + diff --git a/script/crowdin/download.ts b/script/crowdin/download.ts new file mode 100644 index 000000000..4059af6ca --- /dev/null +++ b/script/crowdin/download.ts @@ -0,0 +1,49 @@ +import decompress from "decompress" +import { existsSync, readFileSync } from "fs" +import { rm, writeFile } from "fs/promises" +import { join } from "path" +import { type Dir, type ItemSet, transMsg } from "./common" + +const TEMP_FILE_NAME = join(process.cwd(), ".crowdin-temp.zip") +const TEMP_DIR = join(process.cwd(), ".crowdin-temp") + +export async function downloadProjectZip(url: string): Promise { + const res = await fetch(url) + const blob = await res.blob() + const buffer = Buffer.from(await blob.arrayBuffer()) + await writeFile(TEMP_FILE_NAME, buffer) + console.log("Downloaded project zip file") + if (existsSync(TEMP_DIR)) { + await rm(TEMP_DIR, { recursive: true, force: true }) + } + await decompress(TEMP_FILE_NAME, TEMP_DIR) + console.log("Decompressed zip file") + return TEMP_DIR +} + +export async function clearTempFile() { + await rm(TEMP_FILE_NAME, { force: true }) + await rm(TEMP_DIR, { recursive: true, force: true }) +} + +/** + * Read a single file from the unzipped Crowdin project translation. + * + * @param tmpDir temp directory containing unzipped translations + * @param langDir language subdirectory (e.g. 'zh-CN' or 'en') + * @param dir message directory + * @param crowdinFileName file name with .json extension + */ +export function readCrowdinZipFile(tmpDir: string, langDir: string, dir: Dir, crowdinFileName: string): ItemSet { + const filePath = join(tmpDir, langDir, dir, crowdinFileName) + if (!existsSync(filePath)) { + return {} + } + try { + const json = readFileSync(filePath).toString() + return transMsg(JSON.parse(json)) + } catch (error) { + console.warn(`Failed to parse crowdin zip file: ${filePath}`, error) + return {} + } +} diff --git a/script/crowdin/export-translation.ts b/script/crowdin/export-translation.ts index eca038ee3..bd12e5e59 100644 --- a/script/crowdin/export-translation.ts +++ b/script/crowdin/export-translation.ts @@ -1,26 +1,17 @@ -import decompress from "decompress" -import { existsSync, readdirSync, readFileSync, rm, writeFile } from "fs" +import { readdirSync, readFileSync } from "fs" import { join } from "path" import { getClientFromEnv } from "./client" import { - ALL_DIRS, ALL_TRANS_LOCALES, - checkMainBranch, - crowdinLangOf, - type Dir, - type ItemSet, - mergeMessage, - RSC_FILE_SUFFIX, + ALL_DIRS, ALL_TRANS_LOCALES, checkMainBranch, crowdinLangOf, type Dir, type ItemSet, mergeMessage, RSC_FILE_SUFFIX, transMsg } from "./common" +import { clearTempFile, downloadProjectZip } from "./download" -const TEMP_FILE_NAME = join(process.cwd(), ".crowdin-temp.zip") -const TEMP_DIR = join(process.cwd(), ".crowdin-temp") - -async function processDir(dir: Dir): Promise { - const fileSets: Record>> = {} +async function processDir(tmpDir: string, dir: Dir): Promise { + const fileSets: Record>> = {} for (const locale of ALL_TRANS_LOCALES) { const crowdinLang = crowdinLangOf(locale) - const dirPath = join(TEMP_DIR, crowdinLang, dir) + const dirPath = join(tmpDir, crowdinLang, dir) const files = readdirSync(dirPath) for (const fileName of files) { const json = readFileSync(join(dirPath, fileName)).toString() @@ -34,44 +25,18 @@ async function processDir(dir: Dir): Promise { } } -async function downloadProjectZip(url: string): Promise { - const res = await fetch(url) - const blob = await res.blob() - const buffer = Buffer.from(await blob.arrayBuffer()) - await new Promise(resolve => writeFile(TEMP_FILE_NAME, buffer, resolve)) -} - -async function compressProjectZip(): Promise { - if (existsSync(TEMP_DIR)) { - await new Promise(resolve => rm(TEMP_DIR, { recursive: true }, resolve)) - } - await decompress(TEMP_FILE_NAME, TEMP_DIR) -} - -async function clearTempFile() { - await new Promise(resolve => rm(TEMP_FILE_NAME, resolve)) - await new Promise(resolve => rm(TEMP_DIR, { recursive: true }, resolve)) -} - async function main() { const client = getClientFromEnv() const branch = await checkMainBranch(client) const zipUrl = await client.buildProjectTranslation(branch.id) console.log("Built project translations") console.log(zipUrl) - await downloadProjectZip(zipUrl) - console.log("Downloaded project zip file") - try { - await compressProjectZip() - console.log("Compressed zip file") - for (const dir of ALL_DIRS) { - await processDir(dir) - console.log("Processed dir: " + dir) - } - } finally { - clearTempFile() - console.log("Cleaned temp files") + const tmpDir = await downloadProjectZip(zipUrl) + + for (const dir of ALL_DIRS) { + await processDir(tmpDir, dir) + console.log("Processed dir: " + dir) } } -main() \ No newline at end of file +main().finally(clearTempFile) \ No newline at end of file diff --git a/script/crowdin/sync-source.ts b/script/crowdin/sync-source.ts index a5f3f62ce..8fd494c30 100644 --- a/script/crowdin/sync-source.ts +++ b/script/crowdin/sync-source.ts @@ -1,15 +1,20 @@ -import { type SourceFilesModel, type SourceStringsModel } from "@crowdin/crowdin-api-client" +import type { SourceFilesModel, SourceStringsModel } from "@crowdin/crowdin-api-client" import { toMap } from "@util/array" import { type CrowdinClient, getClientFromEnv, type NameKey } from "./client" -import { - ALL_DIRS, - Dir, - isIgnored, - ItemSet, - readAllMessages, - SOURCE_LOCALE, - transMsg -} from "./common" +import { ALL_DIRS, type Dir, isIgnored, type ItemSet, readAllMessages, SOURCE_LOCALE, transMsg } from "./common" + +/** + * Check if local source content is the same as Crowdin source content + */ +function isSourceUnchanged(localContent: ItemSet, crowdinContent: ItemSet): boolean { + const localKeys = Object.keys(localContent).filter(k => !!localContent[k]) + const crowdinKeys = Object.keys(crowdinContent) + if (localKeys.length !== crowdinKeys.length) return false + for (const key of localKeys) { + if (localContent[key] !== crowdinContent[key]) return false + } + return true +} async function initBranch(client: CrowdinClient): Promise { const branch = await client.getOrCreateMainBranch() @@ -85,6 +90,14 @@ async function processByDir(client: CrowdinClient, dir: Dir, branch: SourceFiles const storage = await client.createStorage(crowdinFilename, fileContent) existFile = await client.createFile(directory.id, storage, crowdinFilename) console.log(`Created new file: dir=${dir}, fileName=${crowdinFilename}, id=${existFile.id}`) + } else { + // Download source file from Crowdin and compare + const crowdinJson = await client.downloadSourceFile(existFile.id) + const crowdinContent = transMsg(crowdinJson) + if (isSourceUnchanged(fileContent, crowdinContent)) { + console.log(`No source diff for ${dir}/${crowdinFilename}, skipped`) + continue + } } // Process by strings await processStrings(client, existFile, fileContent) diff --git a/script/crowdin/sync-translation.ts b/script/crowdin/sync-translation.ts index 184cd3026..1510bc9b3 100644 --- a/script/crowdin/sync-translation.ts +++ b/script/crowdin/sync-translation.ts @@ -1,62 +1,118 @@ - -import { type SourceFilesModel } from "@crowdin/crowdin-api-client" +import type { SourceFilesModel, SourceStringsModel } from "@crowdin/crowdin-api-client" import { toMap } from "@util/array" import { exitWith } from "../util/process" import { type CrowdinClient, getClientFromEnv } from "./client" import { - ALL_DIRS, ALL_TRANS_LOCALES, - type CrowdinLanguage, type Dir, type ItemSet, - checkMainBranch, crowdinLangOf, isIgnored, readAllMessages, transMsg, + ALL_DIRS, ALL_TRANS_LOCALES, checkMainBranch, crowdinLangOf, type CrowdinLanguage, type Dir, isIgnored, + readAllMessages, transMsg, } from "./common" +import { clearTempFile, downloadProjectZip, readCrowdinZipFile } from "./download" const CROWDIN_USER_ID_OF_OWNER = 15266594 -async function processDirMessage(client: CrowdinClient, file: SourceFilesModel.File, message: ItemSet, lang: CrowdinLanguage): Promise { - console.log(`Start to process dir message: fileName=${file.name}, lang=${lang}`) - const strings = await client.listStringsByFile(file.id) - const stringMap = toMap(strings, s => s.identifier) - for (const [identifier, text] of Object.entries(message)) { - const string = stringMap[identifier] - if (!string) { - console.log(`Can't found string of identifier: ${identifier}, file: ${file.path}`) +/** + * Sync translation for a single string. + * Keeps the same behavior as the original processDirMessage: + * 1. Delete all owner's translations that differ from current text + * 2. Create new translation if no matching one exists + */ +async function syncStringTranslation( + client: CrowdinClient, + stringId: number, + text: string, + lang: CrowdinLanguage, +): Promise { + const existList = await client.listTranslationByStringAndLang({ stringId, lang }) + // Delete old translations by owner that differ from current + const oldByOwner = existList.filter(t => t.user.id === CROWDIN_USER_ID_OF_OWNER && t.text !== text) + for (const toDelete of oldByOwner) { + await client.deleteTranslation(toDelete.id) + console.log(`Deleted translation by owner: stringId=${stringId}, lang=${lang}, text=${toDelete.text}`) + } + if (!existList.some(t => t.text === text)) { + await client.createTranslation({ stringId, lang }, text) + console.log(`Created trans: stringId=${stringId}, lang=${lang}, text=${text}`) + } +} +/** + * Process translations for a single file across all locales + */ +async function processFile(client: CrowdinClient, options: { + dir: Dir + fileName: string + message: Messages> + crowdinFile: SourceFilesModel.File + tmpDir: string +}): Promise { + const { dir, fileName, message, crowdinFile, tmpDir } = options + const crowdinFileName = fileName + '.json' + let stringMap: Record = {} + let stringMapLoaded = false + + for (const locale of ALL_TRANS_LOCALES) { + const translated = message[locale] + if (!translated || !Object.keys(translated).length) { continue } - if (text === string.text) { - // The same as original text - console.log(`Translation same as origin text of ${string.identifier} in ${file.path}`) + + const crowdinLang = crowdinLangOf(locale) + const strings = transMsg(translated) + const crowdinStrings = readCrowdinZipFile(tmpDir, crowdinLang, dir, crowdinFileName) + + // Compare locally first — find strings that actually differ + const diffKeys = Object.entries(strings).filter(([identifier, text]) => { + if (!text) return false + return crowdinStrings[identifier] !== text + }) + + if (!diffKeys.length) { + console.log(`No diff for ${dir}/${fileName} [${crowdinLang}], skipped`) continue } - const existList = await client.listTranslationByStringAndLang({ stringId: string.id, lang }) - // Deleted old translations different from current - const oldByOwner = existList?.filter(t => t?.user?.id === CROWDIN_USER_ID_OF_OWNER && t.text !== text) - for (const toDelete of oldByOwner || []) { - await client.deleteTranslation(toDelete.id) - console.log(`Deleted translation by owner: stringId=${string.id}, lang=${lang}, text=${toDelete.text}`) + + console.log(`Found ${diffKeys.length} diff(s) for ${dir}/${fileName} [${crowdinLang}]`) + + if (!stringMapLoaded) { + const existStrings = await client.listStringsByFile(crowdinFile.id) + stringMap = toMap(existStrings, s => s.identifier) + stringMapLoaded = true } - if (!existList?.find(t => t.text === text)) { - // Create new translation - await client.createTranslation({ stringId: string.id, lang }, text) - console.log(`Created trans: stringId=${string.id}, lang=${lang}, text=${text}`) + + for (const [identifier, text] of diffKeys) { + const string = stringMap[identifier] + if (!string) { + console.log(`Can't find string of identifier: ${identifier}, file: ${crowdinFile.path}`) + continue + } + if (text === string.text) { + console.log(`Translation same as origin text of ${string.identifier} in ${crowdinFile.path}`) + continue + } + await syncStringTranslation(client, string.id, text, crowdinLang) } } } -async function processDir(client: CrowdinClient, dir: Dir, branch: SourceFilesModel.Branch): Promise { +/** + * Compare local translations with Crowdin zip, only sync differences + */ +async function processDir( + client: CrowdinClient, + dir: Dir, + branch: SourceFilesModel.Branch, + tmpDir: string, +): Promise { const messages = await readAllMessages(dir) - const directory = await client.getDirByName({ - name: dir, - branchId: branch.id, - }) + const directory = await client.getDirByName({ name: dir, branchId: branch.id }) if (!directory) { exitWith("Directory not found: " + dir) } - const files = await client.listFilesByDirectory(directory!.id) + const files = await client.listFilesByDirectory(directory.id) console.log(`find ${files.length} files of ${dir}`) const fileMap = toMap(files, f => f.name) for (const [fileName, message] of Object.entries(messages)) { - console.log(`Start to sync translations of ${dir}/${fileName}`) if (isIgnored(dir, fileName)) { - console.log("Ignored file: " + fileName) + console.log(`Ignored file: ${dir}/${fileName}`) continue } const crowdinFileName = fileName + '.json' @@ -65,16 +121,7 @@ async function processDir(client: CrowdinClient, dir: Dir, branch: SourceFilesMo console.log(`Failed to find file: dir=${dir}, filename=${fileName}`) continue } - - for (const locale of ALL_TRANS_LOCALES) { - const translated = message[locale] - if (!translated || !Object.keys(translated).length) { - continue - } - const strings = transMsg(message[locale]) - const crowdinLang = crowdinLangOf(locale) - await processDirMessage(client, crowdinFile, strings, crowdinLang) - } + await processFile(client, { dir, fileName, message, crowdinFile, tmpDir }) } } @@ -82,9 +129,14 @@ async function main() { const client = getClientFromEnv() const branch = await checkMainBranch(client) + // Download all translations as zip for local comparison + const zipUrl = await client.buildProjectTranslation(branch.id) + const tmpDir = await downloadProjectZip(zipUrl) + console.log("Downloaded project zip to: " + tmpDir) + for (const dir of ALL_DIRS) { - await processDir(client, dir, branch) + await processDir(client, dir, branch, tmpDir) } } -main() \ No newline at end of file +main().finally(clearTempFile) \ No newline at end of file diff --git a/script/lib/log.sh b/script/lib/log.sh new file mode 100755 index 000000000..c5861f732 --- /dev/null +++ b/script/lib/log.sh @@ -0,0 +1,31 @@ +#!/bin/bash + +# Color definitions +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +# No Color +NC='\033[0m' + +log_info() { + echo -e "${BLUE}[ INFO]${NC} $1" >&${LOG_FD:-1} +} + +log_success() { + echo -e "${GREEN}[ SUCC]${NC} $1" >&${LOG_FD:-1} +} + +log_warning() { + echo -e "${YELLOW}[ WARN]${NC} $1" >&${LOG_FD:-1} +} + +log_error() { + echo -e "${RED}[ERROR]${NC} $1" >&${LOG_FD:-1} +} + +log_step() { + echo -e "${CYAN}[ STEP]${NC} $1" >&${LOG_FD:-1} +} + diff --git a/script/psl.ts b/script/psl.ts index aea4ced6d..9e9a96e32 100644 --- a/script/psl.ts +++ b/script/psl.ts @@ -2,13 +2,12 @@ * Build psl tree */ import { fetchGet } from '@api/http' -import { type PslTree } from '@util/psl' +import { type PslTree } from '@bg/psl' import { writeFileSync } from 'fs' import path from 'path' -import punycode from "punycode" const LIST_URL = "https://publicsuffix.org/list/effective_tld_names.dat" -const JSON_PATH = path.join(__dirname, "..", "src", "util", "psl", "rules.json") +const JSON_PATH = path.join(__dirname, "..", "src", "background", "psl", "rules.json") const downloadList = async (): Promise => { const response = await fetchGet(LIST_URL) @@ -18,7 +17,7 @@ const downloadList = async (): Promise => { const parse = (tree: PslTree, parts: string[], index: number) => { if (index < 0) return const part = parts[index] - const ascii = punycode.toASCII(part) + const ascii = new URL(`http://${part}`).hostname let node = tree[ascii] if (index === 0) { if (!node) tree[ascii] = 1 @@ -58,6 +57,4 @@ async function main() { writeFileSync(JSON_PATH, JSON.stringify(tree, null, 4), { encoding: "utf-8" }) } - - main() \ No newline at end of file diff --git a/script/setup-e2e.sh b/script/setup-e2e.sh new file mode 100755 index 000000000..519972bca --- /dev/null +++ b/script/setup-e2e.sh @@ -0,0 +1,419 @@ +#!/bin/bash + +# Exit immediately on error +set -e + +PROJECT_ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +# Source logging functions +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +source "${SCRIPT_DIR}/lib/log.sh" + +# Detect OS +detect_os() { + case "$(uname -s)" in + Linux*) + echo "linux" + ;; + Darwin*) + echo "macos" + ;; + MINGW*|MSYS*|CYGWIN*) + echo "windows" + ;; + *) + echo "unknown" + ;; + esac +} + +# Check if command exists +check_command() { + if ! command -v "$1" &> /dev/null; then + return 1 + fi + return 0 +} + +# Check Node.js version +check_node_version() { + if ! check_command node; then + log_error "Node.js is not installed" + log_info "Please install Node.js >= 22 from https://nodejs.org/" + exit 1 + fi + + local node_version + node_version=$(node -v | sed 's/v//') + local major_version + major_version=$(echo "$node_version" | cut -d. -f1) + + log_info "Current Node.js version: $node_version" + + if [ "$major_version" -lt 22 ]; then + log_error "Node.js version must be >= 22" + log_info "Current version: $node_version" + log_info "Please upgrade Node.js from https://nodejs.org/" + exit 1 + fi + + log_success "Node.js version check passed" +} + +# Check npm +check_npm() { + if ! check_command npm; then + log_error "npm is not installed" + log_info "npm should come with Node.js, please reinstall Node.js" + exit 1 + fi + + local npm_version + npm_version=$(npm -v) + log_info "Current npm version: $npm_version" + log_success "npm is available" +} + +# Install global npm package (only if not installed) +install_global_package() { + local package_name=$1 + if command -v "$package_name" &> /dev/null; then + log_info "$package_name is already installed" + return 0 + fi + + log_info "$package_name is not installed, installing..." + if npm install -g "${package_name}@latest" &> /dev/null; then + log_success "$package_name installed successfully" + else + log_error "Failed to install $package_name" + exit 1 + fi +} + +# Upgrade global npm package +upgrade_global_package() { + local package_name=$1 + if ! command -v "$package_name" &> /dev/null; then + log_warning "$package_name is not installed, skipping upgrade" + return 0 + fi + + log_info "$package_name is already installed, upgrading..." + if npm install -g "${package_name}@latest" &> /dev/null; then + log_success "$package_name upgraded successfully" + else + log_error "Failed to upgrade $package_name" + exit 1 + fi +} + +# Install e2e dependencies +install_e2e_dependencies() { + log_step "Installing e2e test dependencies..." + + if [ ! -d "$PROJECT_ROOT/node_modules/puppeteer" ]; then + log_error "puppeteer is not installed. Please run 'npm install' first." + exit 1 + fi + + local browser_path + browser_path=$(node -e "try { const p = require('puppeteer'); console.log(p.executablePath()); } catch(e) { process.exit(1); }" 2>/dev/null) + if [ -n "$browser_path" ] && [ -f "$browser_path" ]; then + log_info "Browser is already downloaded" + elif [ -f "$PROJECT_ROOT/node_modules/puppeteer/install.mjs" ]; then + log_info "Downloading browser for puppeteer..." + if node "$PROJECT_ROOT/node_modules/puppeteer/install.mjs" &> /dev/null; then + log_success "Browser downloaded successfully" + else + log_error "Failed to download browser for puppeteer. Retry manually: npx puppeteer browsers install" + exit 1 + fi + else + log_warning "puppeteer install script not found, browser may already be downloaded" + fi + + install_global_package "http-server" + install_global_package "pm2" + + log_success "E2E dependencies installed successfully" +} + +install_mock_server_dependencies() { + if [ -f "$PROJECT_ROOT/examples/package.json" ]; then + log_step "Installing dependencies for mock server..." + if npm install --prefix "$PROJECT_ROOT/examples" &> /dev/null; then + log_success "Mock server dependencies installed successfully" + else + log_error "Failed to install mock server dependencies" + exit 1 + fi + else + log_warning "examples/package.json not found, skipping mock server dependencies installation" + fi +} + +# Upgrade e2e dependencies +upgrade_e2e_dependencies() { + log_step "Upgrading e2e test dependencies..." + + upgrade_global_package "http-server" + upgrade_global_package "pm2" + + log_success "E2E dependencies upgraded successfully" +} + +# Build npm script output +build_npm_script() { + local script_name=$1 + local output_dir=$2 + local is_required=${3:-true} + + log_step "Building $script_name output..." + + cd "$PROJECT_ROOT" || exit 1 + + log_info "Running: npm run $script_name" + local build_output + local build_exit_code + set +e + build_output=$(npm run "$script_name" 2>&1) + build_exit_code=$? + set -e + + if [ $build_exit_code -eq 0 ]; then + if [ -d "$PROJECT_ROOT/$output_dir" ]; then + log_success "$script_name output built successfully in $output_dir/" + else + if [ "$is_required" = true ]; then + log_error "$script_name output directory $output_dir/ not found after build" + exit 1 + else + log_warning "$script_name output directory $output_dir/ not found after build" + fi + fi + else + log_error "Failed to build $script_name output" + echo "$build_output" >&2 + exit 1 + fi +} + +# Build e2e output +build_e2e_output() { + build_npm_script "dev:e2e" "dist_e2e" true +} + +is_port_open() { + local port=$1 + lsof -nP -iTCP:"$port" -sTCP:LISTEN > /dev/null 2>&1 +} + +wait_port_open() { + local port=$1 + local timeout_seconds=${2:-5} + local start_seconds=$SECONDS + while true; do + if is_port_open "$port"; then + return 0 + fi + if [ $((SECONDS - start_seconds)) -ge "$timeout_seconds" ]; then + return 1 + fi + done +} + +remove_pm2_app_if_exists() { + local app_name=$1 + if pm2 describe "$app_name" > /dev/null 2>&1; then + log_info "Removing existing pm2 app: $app_name" + pm2 delete "$app_name" > /dev/null 2>&1 || true + fi +} + +# Start test servers +start_test_servers() { + log_step "Starting test servers..." + + if ! command -v pm2 &> /dev/null || ! command -v http-server &> /dev/null; then + log_error "pm2 or http-server is not installed. Please run setup first." + exit 1 + fi + + cd "$PROJECT_ROOT" || exit 1 + + log_info "Checking and starting e2e-server-1..." + remove_pm2_app_if_exists "e2e-server-1" + if is_port_open 12345; then + log_warning "Port 12345 is already in use before starting e2e-server-1" + fi + pm2 start http-server --name "e2e-server-1" -- ./examples/host -p 12345 + if ! wait_port_open 12345 5; then + log_error "e2e-server-1 failed to start on port 12345" + log_info "Check logs with: pm2 logs e2e-server-1 --lines 50" + exit 1 + fi + + if [ ! -f "$PROJECT_ROOT/examples/package.json" ]; then + log_error "examples package is missing. Please check examples/package.json" + exit 1 + fi + if [ ! -d "$PROJECT_ROOT/examples/node_modules" ]; then + log_error "examples dependencies are not installed. Please run: cd examples && npm install" + exit 1 + fi + + log_info "Checking and starting e2e-server-2..." + remove_pm2_app_if_exists "e2e-server-2" + if is_port_open 12346; then + log_warning "Port 12346 is already in use before starting e2e-server-2" + fi + pm2 start http-server --name "e2e-server-2" -- ./examples/host -p 12346 + if ! wait_port_open 12346 5; then + log_error "e2e-server-2 failed to start on port 12346" + log_info "Check logs with: pm2 logs e2e-server-2 --lines 50" + exit 1 + fi + + log_info "Checking and starting gist-mock-server..." + remove_pm2_app_if_exists "gist-mock-server" + if is_port_open 12347; then + log_warning "Port 12347 is already in use before starting gist-mock-server" + fi + PORT=12347 pm2 start npm --name "gist-mock-server" --cwd "$PROJECT_ROOT/examples" -- run start:gist + if ! wait_port_open 12347 8; then + log_error "gist-mock-server failed to start on port 12347" + log_info "Check logs with: pm2 logs gist-mock-server --lines 50" + exit 1 + fi + + log_success "Test servers started" + log_info "Server 1: http://127.0.0.1:12345" + log_info "Server 2: http://127.0.0.1:12346" + log_info "Gist mock: http://127.0.0.1:12347" + log_info "To stop servers, run: pm2 delete e2e-server-1 e2e-server-2 gist-mock-server" + log_info "To view server logs, run: pm2 logs" +} + +# Show usage +show_usage() { + echo "Usage: $0 [OPTIONS]" + echo "" + echo "E2E Test Environment Setup Script" + echo "" + echo "This script initializes the e2e testing environment and can build e2e outputs." + echo "It installs/upgrades required dependencies and compiles the code for e2e testing." + echo "" + echo "Options:" + echo " --init, -i Initialize e2e environment (install dependencies if not installed)" + echo " --upgrade, -u Upgrade e2e dependencies (http-server, pm2)" + echo " --build, -b Build e2e output (runs 'npm run dev:e2e')" + echo " --start-servers, -s Start test servers (12345/12346) and gist mock server (12347)" + echo " --all, -a Run all steps (init + build + build-prod)" + echo " --help, -h Show this help message" + echo "" + echo "Examples:" + echo " $0 --init # Install e2e dependencies (if not installed)" + echo " $0 --upgrade # Upgrade e2e dependencies" + echo " $0 --build # Build e2e output only" + echo " $0 --init --build # Initialize and build" + echo " $0 --all # Initialize, build e2e and production outputs" + echo " $0 --build --start-servers # Build and start test servers" +} + +# Main function +main() { + local do_init=false + local do_upgrade=false + local do_build=false + local do_start_servers=false + + # Parse arguments + while [[ $# -gt 0 ]]; do + case $1 in + --init|-i) + do_init=true + shift + ;; + --upgrade|-u) + do_upgrade=true + shift + ;; + --build|-b) + do_build=true + shift + ;; + --start-servers|-s) + do_start_servers=true + shift + ;; + --all|-a) + do_init=true + do_build=true + do_start_servers=true + shift + ;; + --help|-h) + show_usage + exit 0 + ;; + *) + log_error "Unknown option: $1" + show_usage + exit 1 + ;; + esac + done + + if [ "$do_init" = false ] && [ "$do_upgrade" = false ] && [ "$do_build" = false ] && [ "$do_start_servers" = false ]; then + show_usage + exit 0 + fi + + if [ ! -f "package.json" ]; then + log_error "Please run this script from the project root." + exit 1 + fi + + echo -e "${CYAN}=== E2E Test Environment Setup ===${NC}" + echo "" + + # Detect OS and show info + local os_type + os_type=$(detect_os) + log_info "Detected OS: $os_type" + + if [ "$os_type" = "windows" ]; then + log_warning "Running on Windows. Make sure you're using Git Bash or WSL." + fi + + # Check prerequisites + check_node_version + check_npm + + # Run requested operations + if [ "$do_init" = true ]; then + install_e2e_dependencies + install_mock_server_dependencies + fi + + if [ "$do_upgrade" = true ]; then + upgrade_e2e_dependencies + fi + + if [ "$do_build" = true ]; then + build_e2e_output + fi + + if [ "$do_start_servers" = true ]; then + start_test_servers + fi + + log_success "All operations completed successfully!" + log_info "Run e2e tests:" + log_info " 1. npm run test-e2e" + log_info " 2. USE_HEADLESS_PUPPETEER=true npm run test-e2e" +} + +# Run main function +main "$@" \ No newline at end of file diff --git a/script/source-archive.sh b/script/source-archive.sh new file mode 100755 index 000000000..fd7bd06d9 --- /dev/null +++ b/script/source-archive.sh @@ -0,0 +1,13 @@ +#!/usr/bin/env bash + +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +cd "$ROOT" +mkdir -p market_packages +git archive --format=zip -o market_packages/target.src.zip HEAD +zip -u -q market_packages/target.src.zip package-lock.json +zip -d -q market_packages/target.src.zip README.md 2>/dev/null || true +TMP=$(mktemp -d) +cp doc/for-firefox.md "$TMP/README.md" +( cd "$TMP" && zip -u -j -q "$ROOT/market_packages/target.src.zip" README.md ) +rm -rf "$TMP" diff --git a/script/user-chart/add.ts b/script/user-chart/add.ts index 5021576f5..b7c632fc8 100644 --- a/script/user-chart/add.ts +++ b/script/user-chart/add.ts @@ -5,48 +5,74 @@ import { type Gist, type GistForm, updateGist as updateGistApi -} from "@src/api/gist" +} from "@api/gist" +import { CHROME_ID } from "@util/constant/meta" import fs from "fs" import { exitWith } from "../util/process" -import { descriptionOf, filenameOf, getExistGist, validateTokenFromEnv } from "./common" +import { type Browser, descriptionOf, filenameOf, getExistGist, type UserCount, validateTokenFromEnv } from "./common" -type AddArgv = { +type AutoMode = { + mode: 'auto' + dirPath: string +} + +type ManualMode = { + mode: 'manual' browser: Browser fileName: string } +type AddArgv = AutoMode | ManualMode + +const BROWSER_MAP: Record = { + c: 'chrome', + e: 'edge', + f: 'firefox', +} + function parseArgv(): AddArgv { const argv = process.argv.slice(2) - const browserArgv = argv[0] - const fileName = argv[1] - if (!browserArgv || !fileName) { - exitWith("add.ts [c/e/f] [file_name]") - } - const browserArgvMap: Record = { - c: 'chrome', - e: 'edge', - f: 'firefox', - } - const browser: Browser = browserArgvMap[browserArgv] - if (!browser) { - exitWith("add.ts [c/e/f] [file_name]") - } - return { - browser, - fileName + const [a0, a1] = argv + + if (!a0) return exitWith("add.ts [c/e/f] [file_name] OR add.ts auto [dir_path]") + + if (a0 === 'auto') { + return a1 ? { mode: 'auto', dirPath: a1 } : exitWith("add.ts auto [dir_path]") } + + if (!a1) return exitWith("add.ts [c/e/f] [file_name] OR add.ts auto [dir_path]") + + const browser = BROWSER_MAP[a0] + if (!browser) return exitWith("add.ts [c/e/f] [file_name]") + + return { mode: 'manual', browser, fileName: a1 } +} + +function detectBrowser(fileName: string): Browser | null { + if (fileName.includes(CHROME_ID)) return 'chrome' + if (fileName.includes('usage-day-')) return 'firefox' + if (fileName.includes('edgeaddon_analytics')) return 'edge' + return null +} + +function sortDataByKey(data: UserCount): UserCount { + const sorted: UserCount = {} + Object.entries(data) + .sort((a, b) => a[0].localeCompare(b[0])) + .forEach(([key, val]) => sorted[key] = val) + return sorted } async function createGist(token: string, browser: Browser, data: UserCount) { const description = descriptionOf(browser) const filename = filenameOf(browser) - - // 1. sort by key - const sorted: UserCount = {} - Object.keys(data).sort().forEach(key => sorted[key] = data[key]) - // 2. create - const files: Record = {} - files[filename] = { filename: filename, content: JSON.stringify(sorted, null, 2) } + const sorted = sortDataByKey(data) + const files: Record = { + [filename]: { + filename, + content: JSON.stringify(sorted, null, 2) + } + } const gistForm: GistForm = { public: true, description, @@ -60,25 +86,26 @@ async function updateGist(token: string, browser: Browser, data: UserCount, gist const filename = filenameOf(browser) // 1. merge const file = gist.files[filename] - const existData = (await getJsonFileContent(file!)) || {} + if (!file) { + exitWith(`Missing file in gist: ${filename}`) + } + const existData = (await getJsonFileContent(file)) || {} Object.entries(data).forEach(([key, val]) => existData[key] = val) // 2. sort by key const sorted: UserCount = {} - Object.keys(existData).sort().forEach(key => sorted[key] = existData[key]) + Object.entries(existData) + .sort(([key1], [key2]) => key1.localeCompare(key2)) + .forEach(([key, value]) => sorted[key] = value) const files: Record = {} files[filename] = { filename: filename, content: JSON.stringify(sorted, null, 2) } - const gistForm: GistForm = { - public: true, - description, - files - } - updateGistApi(token, gist.id, gistForm) + const gistForm: GistForm = { public: true, description, files } + await updateGistApi(token, gist.id, gistForm) } function parseChrome(content: string): UserCount { const lines = content.split('\n') const result: Record = {} - if (!(lines?.length > 2)) { + if (!(lines.length > 2)) { return result } lines.slice(2).forEach(line => { @@ -86,10 +113,12 @@ function parseChrome(content: string): UserCount { if (!dateStr || !numberStr) { return } - // Replace '/' to '-', then rjust month and date - const date = dateStr.split('/').map(str => rjust(str, 2, '0')).join('-') + // Chrome CSV format: YYYY/M/D + const [, y, m, d] = /^(\d{4})\/(\d{1,2})\/(\d{1,2})$/.exec(dateStr.trim()) ?? [] + if (!y || !m || !d) return + const date = `${y}-${m.padStart(2, '0')}-${d.padStart(2, '0')}` const number = parseInt(numberStr) - date && number && (result[date] = number) + number && (result[date] = number) }) return result } @@ -97,7 +126,7 @@ function parseChrome(content: string): UserCount { function parseEdge(content: string): UserCount { const lines = content.split('\n') const result: Record = {} - if (!(lines?.length > 1)) { + if (!(lines.length > 1)) { return result } lines.slice(1).forEach(line => { @@ -118,7 +147,7 @@ function parseEdge(content: string): UserCount { function parseFirefox(content: string): UserCount { const lines = content.split('\n') const result: Record = {} - if (!(lines?.length > 4)) { + if (!(lines.length > 4)) { return result } lines.slice(4).forEach(line => { @@ -142,22 +171,86 @@ function rjust(str: string, num: number, padding: string): string { return Array.from(new Array(num - str.length).keys()).map(_ => padding).join('') + str } +const PARSERS: Record UserCount> = { + chrome: parseChrome, + edge: parseEdge, + firefox: parseFirefox, +} + +async function processFile(browser: Browser, filePath: string): Promise { + const content = fs.readFileSync(filePath, { encoding: 'utf-8' }) + return PARSERS[browser](content) +} + +function groupFilesByBrowser(files: string[]): Record { + const grouped: Record = { chrome: [], edge: [], firefox: [] } + for (const file of files) { + const browser = detectBrowser(file) + if (browser) { + grouped[browser].push(file) + } else { + console.warn(`Unknown file format, skipping: ${file}`) + } + } + return grouped +} + +function mergeData(target: UserCount, source: UserCount): void { + Object.entries(source).forEach(([key, val]) => { + target[key] = target[key] ? Math.max(target[key], val) : val + }) +} + +async function processDirectory(token: string, dirPath: string) { + const files = fs.readdirSync(dirPath).filter(f => f.endsWith('.csv')) + if (files.length === 0) exitWith(`No CSV files found in ${dirPath}`) + + const filesByBrowser = groupFilesByBrowser(files) + + for (const [browser, browserFiles] of Object.entries(filesByBrowser)) { + if (browserFiles.length === 0) continue + + const typedBrowser = browser as Browser + console.log(`Processing ${browser.toUpperCase()}: ${browserFiles.length} file(s)`) + + let mergedData: UserCount = {} + for (const file of browserFiles) { + const filePath = `${dirPath}/${file}` + console.log(`Processing: ${file}`) + const data = await processFile(typedBrowser, filePath) + mergeData(mergedData, data) + } + + if (Object.keys(mergedData).length === 0) { + console.log(`No valid data found for ${browser}`) + continue + } + + console.log(`Merged ${Object.keys(mergedData).length} date entries for ${browser}`) + + const gist = await getExistGist(token, typedBrowser) + if (!gist) { + console.log(`Creating new gist for ${browser}...`) + await createGist(token, typedBrowser, mergedData) + } else { + console.log(`Updating existing gist for ${browser}...`) + await updateGist(token, typedBrowser, mergedData, gist) + } + console.log(`${browser.toUpperCase()} completed successfully!`) + } +} + async function main() { const token = validateTokenFromEnv() - const argv: AddArgv = parseArgv() - const browser = argv.browser - const fileName = argv.fileName - const content = fs.readFileSync(fileName, { encoding: 'utf-8' }) - let newData: UserCount = {} - if (browser === 'chrome') { - newData = parseChrome(content) - } else if (browser === 'edge') { - newData = parseEdge(content) - } else if (browser === 'firefox') { - newData = parseFirefox(content) - } else { - exitWith("Un-supported browser: " + browser) + const argv = parseArgv() + + if (argv.mode === 'auto') { + return await processDirectory(token, argv.dirPath) } + + const { browser, fileName } = argv + + const newData = await processFile(browser, fileName) const gist = await getExistGist(token, browser) if (!gist) { await createGist(token, browser, newData) diff --git a/script/user-chart/common.ts b/script/user-chart/common.ts index 6b69633d4..a3a30cfc5 100644 --- a/script/user-chart/common.ts +++ b/script/user-chart/common.ts @@ -1,5 +1,12 @@ import { findTarget, type Gist } from "@api/gist" -import { exitWith } from "../util/process" +import { exitWith } from '../util/process' + +export type Browser = + | 'chrome' + | 'firefox' + | 'edge' + +export type UserCount = Record /** * Validate the token from environment variables diff --git a/script/user-chart/render.ts b/script/user-chart/render.ts index da1b3d14c..e5a3fb8ce 100644 --- a/script/user-chart/render.ts +++ b/script/user-chart/render.ts @@ -1,39 +1,40 @@ import { - createGist, - type FileForm, - findTarget, - getJsonFileContent, - type GistForm, - updateGist + createGist, findTarget, getJsonFileContent, updateGist, + type FileForm, type GistForm, } from "@api/gist" -import { type EChartsType, init } from "echarts" +import { + init, + type ComposeOption, + type GridComponentOption, type LineSeriesOption, type TitleComponentOption +} from "echarts" import { writeFileSync } from "fs" -import { exit } from "process" -import { filenameOf, getExistGist, validateTokenFromEnv } from "./common" - -const ALL_BROWSERS: Browser[] = ['firefox', 'edge', 'chrome'] +import { exit } from 'process' +import { filenameOf, getExistGist, validateTokenFromEnv, type Browser, type UserCount } from "./common" -const POINT_COUNT = 200 +type EcOption = ComposeOption< + | LineSeriesOption + | TitleComponentOption + | 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}$/ + function preProcess(originData: OriginData): ChartData { // 1. sort dates const dateSet = new Set() Object.values(originData).forEach(ud => Object.keys(ud).forEach(date => dateSet.add(date))) - let allDates = Array.from(dateSet).sort() + let allDates = Array.from(dateSet).filter(d => VALID_DATE_RE.test(d)).sort() // 2. smooth the count - const ctx: { [browser in Browser]: SmoothContext } = { + const ctx: Record = { chrome: new SmoothContext(), firefox: new SmoothContext(), edge: new SmoothContext(), @@ -42,7 +43,7 @@ function preProcess(originData: OriginData): ChartData { allDates.forEach( date => ALL_BROWSERS.forEach(b => ctx[b].process(originData[b][date])) ) - const result: ChartData = { + return { xAxis: allDates, yAxises: { chrome: ctx.chrome.end(), @@ -50,12 +51,6 @@ function preProcess(originData: OriginData): ChartData { edge: ctx.edge.end(), } } - - // 3. zoom - const reduction = Math.floor(Object.keys(allDates).length / POINT_COUNT) - result.xAxis = zoom(result.xAxis, reduction) - ALL_BROWSERS.forEach(b => result.yAxises[b] = zoom(result.yAxises[b], reduction)) - return result } class SmoothContext { @@ -76,7 +71,7 @@ class SmoothContext { if (newVal) { this.smooth(newVal) } else { - this.increaseStep() + this.step += 1 } } @@ -85,49 +80,33 @@ 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 zoom(data: T[], reduction: number): T[] { - let i = 0 - const newData: T[] = [] - while (i < data.length) { - newData.push(data[i]) - i += reduction - } - return newData -} - 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) + .map(v => v[v.length - 1] ?? 0) .reduce((a, b) => a + b) - chart.setOption({ + const option: EcOption = { title: { text: 'Total Active User Count', subtext: `${xAxis[0]} to ${xAxis[xAxis.length - 1]} | currently ${totalUserCount} ` @@ -139,23 +118,30 @@ function render2Svg(chartData: ChartData): string { bottom: '8%', containLabel: true }, - xAxis: [{ - type: 'category', - boundaryGap: false, - data: xAxis - }], - yAxis: [ - { type: 'value' } - ], + xAxis: { type: 'time' }, + yAxis: { + type: 'value', + minInterval: 100, + axisLabel: { + formatter(value) { + const text = value.toString() + const textLen = text.length + return textLen < 4 ? text : text.substring(0, textLen - 3) + 'K' + }, + }, + }, series: ALL_BROWSERS.map(b => ({ name: b, type: 'line', stack: 'Total', // Fill the area areaStyle: {}, - data: yAxises[b] + lineStyle: { width: 0 }, + showSymbol: false, + data: yAxises[b].map((val, idx) => [xAxis[idx], val]), })) - }) + } + chart.setOption(option) return chart.renderToSVGString() } @@ -163,10 +149,15 @@ const USER_COUNT_GIST_DESC = "User count of timer, auto-generated" const USER_COUNT_SVG_FILE_NAME = "user_count.svg" async function getOriginData(token: string): Promise { - const [firefox, edge, chrome]: UserCount[] = await Promise.all( - ALL_BROWSERS.map(b => getDataFromGist(token, b)) - ) - return { chrome, firefox, edge } + const result: OriginData = { + chrome: {}, + firefox: {}, + edge: {}, + } + for (const b of ALL_BROWSERS) { + result[b] = await getDataFromGist(token, b) + } + return result } /** diff --git a/script/user-chart/user-chart.d.ts b/script/user-chart/user-chart.d.ts deleted file mode 100644 index 56d0300c8..000000000 --- a/script/user-chart/user-chart.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -type Browser = - | 'chrome' - | 'firefox' - | 'edge' - -type UserCount = Record \ No newline at end of file diff --git a/script/util/process.ts b/script/util/process.ts index ce80dc4ce..d017db756 100644 --- a/script/util/process.ts +++ b/script/util/process.ts @@ -2,7 +2,7 @@ /** * @throws Will invoke ```process.exit()``` */ -export function exitWith(msg: string) { +export function exitWith(msg: string): never { console.error(msg) process.exit() } \ No newline at end of file diff --git a/script/zip.sh b/script/zip.sh deleted file mode 100755 index 2491e975c..000000000 --- a/script/zip.sh +++ /dev/null @@ -1,17 +0,0 @@ -#!/bin/bash - -FOLDER=$( - cd "$(dirname "$0")/.." - pwd -) -TARGET_PATH="${FOLDER}/aaa" - -COPYFILE_DISABLE=1 tar -zcvf ${TARGET_PATH} \ - --exclude=dist*/ \ - --exclude=.git/ \ - --exclude=package-lock.json \ - --exclude=node_modules \ - --exclude=firefox_dev*/ \ - --exclude=market_packages \ - --exclude=aaa \ - ./ diff --git a/src/api/chrome/action.ts b/src/api/chrome/action.ts index 17aa26a54..bb52a3c7c 100644 --- a/src/api/chrome/action.ts +++ b/src/api/chrome/action.ts @@ -3,8 +3,10 @@ import { handleError } from "./common" const action = IS_MV3 ? chrome.action : chrome.browserAction -export function setBadgeText(text: string, tabId: number | undefined): Promise { - return new Promise(resolve => action?.setBadgeText({ text, tabId }, () => { +export function setBadgeText(text: string, tabId?: number): Promise { + const details: { text: string; tabId?: number } = + tabId === undefined ? { text } : { text, tabId } + return new Promise(resolve => action?.setBadgeText(details, () => { handleError('setBadgeText') resolve() })) diff --git a/src/api/chrome/alarm.ts b/src/api/chrome/alarm.ts index 0b4c2c807..bb136462c 100644 --- a/src/api/chrome/alarm.ts +++ b/src/api/chrome/alarm.ts @@ -1,3 +1,4 @@ +import { IS_MV3 } from '@util/constant/environment' import { handleError } from "./common" type AlarmHandler = (alarm: ChromeAlarm) => PromiseLike | void @@ -6,13 +7,33 @@ export function onAlarm(handler: AlarmHandler) { chrome.alarms.onAlarm.addListener(handler) } -export function clearAlarm(name: string): Promise { - return new Promise(resolve => chrome.alarms.clear(name, () => { - handleError('clearAlarm') +export async function clearAlarm(name: string): Promise { + if (IS_MV3) { + return chrome.alarms.clear(name) + } else { + return new Promise(resolve => chrome.alarms.clear(name, removed => { + handleError('clearAlarm') + resolve(removed) + })) + } +} + +export function createAlarm(name: string, when: number): Promise { + if (IS_MV3) { + return chrome.alarms.create(name, { when }) + } + return new Promise(resolve => chrome.alarms.create(name, { when }, () => { + handleError('createAlarm') resolve() })) } -export function createAlarm(name: string, when: number): void { - chrome.alarms.create(name, { when }) +export async function getAlarm(name: string): Promise { + if (IS_MV3) { + return chrome.alarms.get(name) + } + return new Promise(resolve => chrome.alarms.get(name, alarm => { + handleError('getAlarm') + resolve(alarm) + })) } \ No newline at end of file diff --git a/src/api/chrome/notifications.ts b/src/api/chrome/notifications.ts new file mode 100644 index 000000000..0612bc096 --- /dev/null +++ b/src/api/chrome/notifications.ts @@ -0,0 +1,26 @@ +import { IS_MV3 } from "@util/constant/environment" +import { handleError } from "./common" +import { getIconUrl } from './runtime' + +type Topic = 'time' +type ChromeOptions = chrome.notifications.NotificationCreateOptions +type Options = Omit + +export async function createNotification(topic: Topic, options: Options): Promise { + const param = { ...options, iconUrl: getIconUrl() } + if (IS_MV3) { + return await chrome.notifications.create(topic, param) + } else { + return new Promise((resolve, reject) => { + chrome.notifications.create(topic, param, (id: string) => { + const error = handleError('createNotification') + if (error) { + reject(new Error(error)) + } else { + resolve(id) + } + }) + }) + } +} + diff --git a/src/api/chrome/permission.ts b/src/api/chrome/permission.ts index c98423958..1817d1413 100644 --- a/src/api/chrome/permission.ts +++ b/src/api/chrome/permission.ts @@ -1,7 +1,7 @@ import { IS_MV3 } from "@util/constant/environment" import { handleError } from "./common" -export async function hasPerm(perm: chrome.runtime.ManifestPermissions): Promise { +export async function hasPerm(perm: chrome.runtime.ManifestPermission): Promise { if (IS_MV3) { try { return await chrome.permissions.contains({ permissions: [perm] }) @@ -18,7 +18,7 @@ export async function hasPerm(perm: chrome.runtime.ManifestPermissions): Promise } } -export async function requestPerm(perm: chrome.runtime.ManifestPermissions): Promise { +export async function requestPerm(perm: chrome.runtime.ManifestPermission): Promise { if (IS_MV3) { try { return await chrome.permissions.request({ permissions: [perm] }) diff --git a/src/api/chrome/runtime.ts b/src/api/chrome/runtime.ts index f4d8c66e8..8f70371f6 100644 --- a/src/api/chrome/runtime.ts +++ b/src/api/chrome/runtime.ts @@ -1,4 +1,3 @@ -import { handleError } from "./common" export function getRuntimeId(): string { return chrome.runtime.id @@ -8,34 +7,23 @@ export function getRuntimeName(): string { return chrome.runtime.getManifest().name } -export function sendMsg2Runtime(code: timer.mq.ReqCode, data?: T): Promise { - // Fix proxy data failed to serialized in Firefox - if (data !== undefined) { - data = JSON.parse(JSON.stringify(data)) - } - const request: timer.mq.Request = { code, data } - return new Promise((resolve, reject) => { - try { - chrome.runtime.sendMessage(request, (response: timer.mq.Response) => { - handleError('sendMsg2Runtime') - const resCode = response?.code - resCode === 'fail' && reject(new Error(response?.msg || 'Unknown error')) - resCode === 'success' && resolve(response.data) - }) - } catch (e) { - reject('Failed to send message: ' + (e as Error)?.message || 'Unknown error') - } - }) +export function getIconUrl(): string { + return getUrl('static/images/icon.png') } -export function onRuntimeMessage(handler: ChromeMessageHandler): void { +export function onRuntimeMessage(handler: ChromeMessageHandler): void { // Be careful!!! // Can't use await/async in callback parameter - chrome.runtime.onMessage.addListener((message: timer.mq.Request, sender: chrome.runtime.MessageSender, sendResponse: timer.mq.Callback) => { - handler(message, sender).then((response: timer.mq.Response) => { - if (response.code === 'ignore') return - sendResponse(response) - }) + chrome.runtime.onMessage.addListener((message: tt4b.mq.Request, sender: chrome.runtime.MessageSender, sendResponse: tt4b.mq.Callback) => { + void handler(message, sender) + .then((response: tt4b.mq.Response) => { + sendResponse(response) + }) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err) + console.error('onRuntimeMessage handler error', err) + sendResponse({ code: 'fail', msg }) + }) // 'return true' will force chrome to wait for the response processed in the above promise. // @see https://github.com/mozilla/webextension-polyfill/issues/130 return true diff --git a/src/api/chrome/sidePanel.ts b/src/api/chrome/sidePanel.ts new file mode 100644 index 000000000..74cf7961f --- /dev/null +++ b/src/api/chrome/sidePanel.ts @@ -0,0 +1,33 @@ +import { IS_ANDROID, IS_MV3 } from '@util/constant/environment' +import { handleError } from './common' + +// NOT SUPPORTED in Firefox +// Keep noticing at chrome.sidebarAction for Firefox +export const SIDE_PANEL_STATE_SUPPORTED_CONTROL = !!chrome.sidePanel?.setOptions + +export async function isSidePanelEnabled(): Promise { + if (IS_ANDROID || !SIDE_PANEL_STATE_SUPPORTED_CONTROL) return false + + if (IS_MV3) { + const result = await chrome.sidePanel.getOptions({}) + return result.enabled ?? true + } else { + return new Promise(resolve => chrome.sidePanel.getOptions({}, options => { + handleError('isSidePanelEnabled') + resolve(options.enabled ?? true) + })) + } +} + +export async function setSidePanelEnabled(enabled: boolean): Promise { + if (IS_ANDROID || !SIDE_PANEL_STATE_SUPPORTED_CONTROL) return + + if (IS_MV3) { + await chrome.sidePanel.setOptions({ enabled }) + } else { + return new Promise(resolve => chrome.sidePanel.setOptions({ enabled }, () => { + handleError('setSidePanelEnabled') + resolve() + })) + } +} \ No newline at end of file diff --git a/src/api/chrome/tab.ts b/src/api/chrome/tab.ts index 41bc1e1a6..acf91692c 100644 --- a/src/api/chrome/tab.ts +++ b/src/api/chrome/tab.ts @@ -5,35 +5,20 @@ * https://opensource.org/licenses/MIT */ +import { IS_MV3 } from '@util/constant/environment' import { handleError } from "./common" -export function getTab(id: number): Promise { +export function getTab(id: number): Promise { + if (id < 0) { + return Promise.resolve(undefined) + } return new Promise(resolve => chrome.tabs.get(id, tab => { handleError("getTab") resolve(tab) })) } -export function resetTabUrl(tabId: number, url: string): Promise { - return new Promise(resolve => chrome.tabs.update(tabId, { - url: url, - highlighted: true, - }, () => resolve())) -} - -export async function getRightOf(target: ChromeTab): Promise { - if (!target) return - const { windowId, index } = target - return new Promise(resolve => chrome.tabs.query({ windowId }, tabs => { - const rightTab = tabs - ?.sort?.((a, b) => (a?.index ?? -1) - (b?.index ?? -1)) - ?.filter?.(t => t.index > index) - ?.[0] - resolve(rightTab) - })) -} - -export function getCurrentTab(): Promise { +function getCurrentTab(): Promise { return new Promise(resolve => chrome.tabs.getCurrent(tab => { handleError("getCurrentTab") resolve(tab) @@ -71,37 +56,71 @@ 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 || []) })) } -export function sendMsg2Tab(tabId: number, code: timer.mq.ReqCode, data?: T): Promise { - const request: timer.mq.Request = { code, data } +export function sendMsg2Tab(tabId: number, code: C, data?: tt4b.tab.ReqData): Promise | undefined> { + const request: tt4b.tab.Request = { code, data: data as tt4b.tab.ReqData } return new Promise((resolve, reject) => { - chrome.tabs.sendMessage, timer.mq.Response>(tabId, request, response => { + const timeout = setTimeout(() => reject('sendMsg2Tab timeout'), 2000) + chrome.tabs.sendMessage, tt4b.tab.Response>(tabId, request, response => { const sendError = handleError('sendMsg2Tab') - const resCode = response?.code - resCode === 'success' && resolve(response.data) - reject(new Error(response?.msg ?? sendError ?? 'Unknown error')) + clearTimeout(timeout) + if (response?.code === 'success') { + resolve(response.data as tt4b.tab.ResData | undefined) + return + } + if (response?.code === 'fail') { + reject(new Error(response.msg ?? sendError ?? 'Unknown error')) + return + } + reject(new Error(sendError ?? 'Unknown error')) }) }) } -type TabHandler = (tabId: number, ev: Event, tab?: ChromeTab) => void +export async function trySendMsg2Tab( + tabId: number, + code: C, + data?: tt4b.tab.ReqData +): Promise | undefined> { + 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) + return Promise.resolve(undefined) + } +} + +type TabHandler = (tabId: number, ev: Event, tab: ChromeTab) => void -export function onTabActivated(handler: TabHandler): void { - chrome.tabs.onActivated.addListener((activeInfo: chrome.tabs.OnActivatedInfo) => { +export function onTabActivated(handler: (tabId: number, info: ChromeTabActiveInfo) => void): void { + chrome.tabs.onActivated.addListener(activeInfo => { handleError("tabActivated") - handler(activeInfo?.tabId, activeInfo) + handler(activeInfo.tabId, activeInfo) }) } -export function onTabUpdated(handler: TabHandler): void { - chrome.tabs.onUpdated.addListener((tabId: number, changeInfo: ChromeTabChangeInfo, tab: ChromeTab) => { +export function onTabUpdated(handler: TabHandler): void { + chrome.tabs.onUpdated.addListener((tabId: number, changeInfo: ChromeTabUpdatedInfo, tab: ChromeTab) => { handleError("tabUpdated") handler(tabId, changeInfo, tab) }) } + +export function updateTab(tabId: number, updateProperties: chrome.tabs.UpdateProperties): Promise { + if (IS_MV3) { + return chrome.tabs.update(tabId, updateProperties) + } + return new Promise((resolve) => { + chrome.tabs.update(tabId, updateProperties, (tab) => { + handleError("updateTab") + resolve(tab) + }) + }) +} \ No newline at end of file diff --git a/src/api/chrome/tabGroups.ts b/src/api/chrome/tabGroups.ts index 42f490c87..2a748e6ad 100644 --- a/src/api/chrome/tabGroups.ts +++ b/src/api/chrome/tabGroups.ts @@ -67,18 +67,3 @@ export function isValidGroup(groupId?: number): groupId is number { return false } } - -export const cvtGroupColor = (color?: `${chrome.tabGroups.Color}`): string => { - switch (color) { - case 'grey': return '#5F6369' - case 'blue': return '#1974E8' - case 'yellow': return '#F9AB03' - case 'red': return '#DA3025' - case 'green': return '#198139' - case 'pink': return '#D01984' - case 'purple': return '#A143F5' - case 'cyan': return '#027B84' - case 'orange': return '#FA913E' - default: return '#000' - } -} diff --git a/src/api/chrome/window.ts b/src/api/chrome/window.ts index 94b9f5f1c..3b669d6b9 100644 --- a/src/api/chrome/window.ts +++ b/src/api/chrome/window.ts @@ -1,58 +1,37 @@ -import { IS_ANDROID } from "@util/constant/environment" +import { IS_ANDROID, IS_MV3 } from "@util/constant/environment" import { handleError } from "./common" -export function listAllWindows(): Promise { - if (IS_ANDROID) { - // windows API not supported on Firefox for Android - return Promise.resolve([]) - } - return new Promise(resolve => chrome.windows.getAll(windows => { - handleError("listAllWindows") - resolve(windows || []) - })) -} - -export function isNoneWindowId(windowId: number) { - if (IS_ANDROID) { - return false - } - return !windowId || windowId === chrome.windows.WINDOW_ID_NONE -} - -export async function getFocusedNormalWindow(): Promise { - if (IS_ANDROID) { - return +export async function getLastFocusedId(): Promise { + if (IS_ANDROID) return Promise.resolve(undefined) + if (IS_MV3) { + const window = await chrome.windows.getLastFocused({ windowTypes: ['normal'] }) + return window.id } return new Promise(resolve => chrome.windows.getLastFocused( - // Only find normal window { windowTypes: ['normal'] }, - window => { - const { focused, id } = window - handleError('getFocusedNormalWindow') - if (!focused || !id || isNoneWindowId(id)) { - resolve(undefined) - } else { - resolve(window) - } - } + ({ id }) => { + handleError('getLastFocusedId') + resolve(id) + }, )) } -export async function getWindow(id: number): Promise { - if (IS_ANDROID) { - return - } - return new Promise(resolve => chrome.windows.get(id, win => resolve(win))) +export function getWindow(id: number): Promise { + if (IS_ANDROID) return Promise.resolve(undefined) + return new Promise(resolve => chrome.windows.get(id, window => { + handleError('getWindow') + resolve(window) + })) } -type _Handler = (windowId: number) => void +export function isNoneWindowId(windowId: number | undefined) { + return windowId === undefined || windowId === chrome.windows.WINDOW_ID_NONE +} -export function onNormalWindowFocusChanged(handler: _Handler) { - if (IS_ANDROID) { - return - } +export function onWindowFocusChanged(handler: ArgCallback) { + if (IS_ANDROID) return chrome.windows.onFocusChanged.addListener(windowId => { handleError('onWindowFocusChanged') handler(windowId) }) -} \ No newline at end of file +} diff --git a/src/api/crowdin.ts b/src/api/crowdin.ts index c25f34261..dab5b6b93 100644 --- a/src/api/crowdin.ts +++ b/src/api/crowdin.ts @@ -5,8 +5,16 @@ * https://opensource.org/licenses/MIT */ -import { CROWDIN_PROJECT_ID } from "@util/constant/url" -import { fetchGet } from './http' +import { createArrayGuard, createObjectGuard, isInt, isString, TypeGuard } from 'typescript-guard' +import { CROWDIN_PROJECT_ID } from "../util/constant/url" + +type ListResponse = { + data: { data: T }[] +} + +const createListRespGuard = (itemGuard: TypeGuard) => createObjectGuard>({ + data: createArrayGuard(createObjectGuard({ data: itemGuard })) +}) /** * Used to obtain translation status @@ -21,37 +29,55 @@ export type TranslationStatusInfo = { translationProgress: number } -export type MemberInfo = { +const isStatusResp = createListRespGuard( + createObjectGuard({ + languageId: isString, + translationProgress: isInt, + }) +) + +type MemberInfo = { username: string joinedAt: string avatarUrl: string } +const isMembersResp = createListRespGuard( + createObjectGuard({ + username: isString, + joinedAt: isString, + avatarUrl: isString, + }) +) + export async function getTranslationStatus(): Promise { const limit = 500 - const auth = `Bearer ${PUBLIC_TOKEN}` const url = `https://api.crowdin.com/api/v2/projects/${CROWDIN_PROJECT_ID}/languages/progress?limit=${limit}` - const response = await fetchGet(url, { headers: { "Authorization": auth } }) - const data: { data: { data: TranslationStatusInfo }[] } = await response.json() - return data.data.map(i => i.data) + return await getList(url, isStatusResp) } export async function getMembers(): Promise { const result: MemberInfo[] = [] - const auth = `Bearer ${PUBLIC_TOKEN}` - - const limit = 10 + 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}` - const response = await fetchGet(url, { headers: { "Authorization": auth } }) - const data: { data: { data: MemberInfo }[] } = await response.json() - const newItems = data?.data?.map(i => i.data) ?? [] + const newItems = await getList(url, isMembersResp) result.push(...newItems) - if (newItems.length < limit) break offset += limit } return result -} \ No newline at end of file +} + +async function getList(url: string, respGuard: TypeGuard>): Promise { + const resp = await fetch(url, { + method: 'GET', + headers: { "Authorization": `Bearer ${PUBLIC_TOKEN}` }, + }) + const json = await resp.json() + if (respGuard(json)) return json.data.map(i => i.data) + console.warn('Unexpected response from Crowdin API', json) + return [] +} diff --git a/src/api/gist.ts b/src/api/gist.ts index 86edd8bea..463773d28 100644 --- a/src/api/gist.ts +++ b/src/api/gist.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import FIFOCache from "@util/fifo-cache" +import FIFOCache from "../util/fifo-cache" import { fetchGet, fetchGetWithTry, fetchPost } from "./http" type BaseFile = { @@ -16,7 +16,7 @@ export type FileForm = BaseFile & { content: string } -export type File = BaseFile & { +type File = BaseFile & { type: string language: string raw_url: string @@ -161,5 +161,8 @@ export async function testToken(token: string): Promise { } }) const { status, statusText } = response || {} - return status === 200 ? undefined : statusText || ("ERROR " + status) + if (status === 200) return undefined + if (status === 401) return '[401] Invalid token or no permission to access gist' + if (status === 403) return '[403] Access forbidden, possibly due to rate limit exceeded' + return statusText || `ERROR ${status}` } diff --git a/src/api/http.ts b/src/api/http.ts index 6675ef081..0e3f902df 100644 --- a/src/api/http.ts +++ b/src/api/http.ts @@ -1,10 +1,5 @@ type Option = Omit -export type FetchResult = { - data?: T - statusCode: number -} - export async function fetchGetWithTry(url: string, maxTry: number, option?: Option): Promise { let count = 0 do { diff --git a/src/api/obsidian.ts b/src/api/obsidian.ts index 51207be58..d9a9752b1 100644 --- a/src/api/obsidian.ts +++ b/src/api/obsidian.ts @@ -12,7 +12,7 @@ export const DEFAULT_VAULT = "vault" export const INVALID_AUTH_CODE = 40101 export const NOT_FOUND_CODE = 40400 -export type ObsidianResult = { +type ObsidianResult = { message?: string errorCode?: number } & T diff --git a/src/api/sw/backup.ts b/src/api/sw/backup.ts new file mode 100644 index 000000000..1a9bdd177 --- /dev/null +++ b/src/api/sw/backup.ts @@ -0,0 +1,15 @@ +import { sendMsg2Runtime } from "./common" + +export const syncData = () => sendMsg2Runtime('backup.sync', undefined, 120_000) + +export const checkAuth = () => sendMsg2Runtime('backup.checkAuth') + +export const clearBackup = (cid: string) => sendMsg2Runtime('backup.clear', cid, 60_000) + +export const queryBackup = (param: tt4b.backup.RemoteQuery) => sendMsg2Runtime('backup.query', param, 120_000) + +export const previewBackup = (param: tt4b.backup.RemoteQuery) => sendMsg2Runtime('backup.preview', param, 120_000) + +export const getLastBackUp = (type: tt4b.backup.Type) => sendMsg2Runtime('backup.lastTs', type) + +export const allBackupClients = () => sendMsg2Runtime('backup.clients') diff --git a/src/api/sw/cate.ts b/src/api/sw/cate.ts new file mode 100644 index 000000000..e3f2b5ca2 --- /dev/null +++ b/src/api/sw/cate.ts @@ -0,0 +1,3 @@ +import { sendMsg2Runtime } from "./common" + +export const listAllCategories = () => sendMsg2Runtime('cate.all') diff --git a/src/api/sw/common.ts b/src/api/sw/common.ts new file mode 100644 index 000000000..d674e93ea --- /dev/null +++ b/src/api/sw/common.ts @@ -0,0 +1,63 @@ +import { handleError } from '../chrome/common' + +/** + * Fix proxy data failed to serialized in Firefox + */ +function cloneData(data: T | undefined): T | undefined { + if (data === undefined) return undefined + try { + return JSON.parse(JSON.stringify(data)) + } catch (cloneError) { + console.warn("Failed clone data", cloneError) + return data + } +} + +type RuntimeMsgArgs = [tt4b.mq.ReqData] extends [undefined] + ? [data?: tt4b.mq.ReqData, timeout_ms?: number] + : [data: tt4b.mq.ReqData, timeout_ms?: number] + +export function sendMsg2Runtime( + code: C, + ...args: RuntimeMsgArgs +): Promise> { + const [data, timeout_ms] = args + const request: tt4b.mq.Request = { code, data: cloneData(data) as tt4b.mq.ReqData } + return new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + // timeout: no response from runtime + reject('sendMsg2Runtime timeout') + }, timeout_ms ?? 10_000) + try { + chrome.runtime.sendMessage(request, (response: tt4b.mq.Response) => { + clearTimeout(timeout) + handleError('sendMsg2Runtime') + const resCode = response?.code + if (resCode === 'fail') { + console.warn("Error occurred when querying service-worker", code, data, response?.msg) + return reject(new Error(response?.msg || 'Unknown error')) + } + resCode === 'success' && resolve(response.data as tt4b.mq.ResData) + }) + } catch (e) { + clearTimeout(timeout) + const msg = e instanceof Error ? e.message : 'Unknown error' + reject(`Failed to send message: ${msg}`) + } + }) +} + +/** + * Wrap for hooks, after the extension reloaded or upgraded, the context of current content script will be invalid + * And sending messages to the runtime will be failed + */ +export async function trySendMsg2Runtime( + code: C, + ...args: RuntimeMsgArgs +): Promise | undefined> { + try { + return await sendMsg2Runtime(code, ...args) + } catch { + return undefined + } +} diff --git a/src/api/sw/immigration.ts b/src/api/sw/immigration.ts new file mode 100644 index 000000000..05c10e155 --- /dev/null +++ b/src/api/sw/immigration.ts @@ -0,0 +1,5 @@ +import { sendMsg2Runtime } from "./common" + +export function importOther(query: tt4b.imported.ProcessQuery) { + return sendMsg2Runtime('immigration.importOther', query, 60_000) +} diff --git a/src/api/sw/limit.ts b/src/api/sw/limit.ts new file mode 100644 index 000000000..052e32d84 --- /dev/null +++ b/src/api/sw/limit.ts @@ -0,0 +1,11 @@ +import { sendMsg2Runtime } from "./common" + +export const listLimits = (query?: tt4b.limit.Query) => sendMsg2Runtime('limit.list', query) + +export const getLimitSummary = () => sendMsg2Runtime('limit.summary') + +export const deleteLimits = (ids: number[]) => sendMsg2Runtime('limit.delete', ids) + +export const updateLimits = (rules: tt4b.limit.Rule[]) => sendMsg2Runtime('limit.update', rules) + +export const addLimit = (rule: Omit) => sendMsg2Runtime('limit.add', rule) diff --git a/src/api/sw/merge.ts b/src/api/sw/merge.ts new file mode 100644 index 000000000..6e3f06516 --- /dev/null +++ b/src/api/sw/merge.ts @@ -0,0 +1,7 @@ +import { sendMsg2Runtime } from "./common" + +export const listAllMergeRules = () => sendMsg2Runtime('merge.all') + +export const deleteMergeRule = (origin: string) => sendMsg2Runtime('merge.delete', origin) + +export const addMergeRule = (rule: tt4b.merge.Rule) => sendMsg2Runtime('merge.add', rule) \ No newline at end of file diff --git a/src/api/sw/option.ts b/src/api/sw/option.ts new file mode 100644 index 000000000..d8735a45c --- /dev/null +++ b/src/api/sw/option.ts @@ -0,0 +1,14 @@ +import { sendMsg2Runtime } from "@api/sw/common" + +export const getOption = () => sendMsg2Runtime('option.get') + +export const setOption = (option: Partial) => sendMsg2Runtime('option.set', option) + +export const getWeekStartTime = async (now?: number | Date): Promise => { + let nowTs = typeof now === 'number' ? now : now?.getTime() + nowTs = nowTs ?? Date.now() + const startTs = await sendMsg2Runtime('option.weekStartTime', nowTs) + return new Date(startTs) +} + +export const getWeekStartDay = () => sendMsg2Runtime('option.weekStartDay') diff --git a/src/api/sw/period.ts b/src/api/sw/period.ts new file mode 100644 index 000000000..18b55c9d0 --- /dev/null +++ b/src/api/sw/period.ts @@ -0,0 +1,3 @@ +import { sendMsg2Runtime } from './common' + +export const listPeriods = (param: tt4b.period.Query) => sendMsg2Runtime('period.list', param) \ No newline at end of file diff --git a/src/api/sw/site.ts b/src/api/sw/site.ts new file mode 100644 index 000000000..0109e51ae --- /dev/null +++ b/src/api/sw/site.ts @@ -0,0 +1,31 @@ +import { sendMsg2Runtime } from "./common" + +export const listSites = (param?: tt4b.site.Query) => sendMsg2Runtime('site.list', param) + +export function getSitePage(param?: tt4b.site.Query, page?: tt4b.common.PageQuery) { + return sendMsg2Runtime('site.page', { ...param, ...page }) +} + +export const deleteSites = (...keys: tt4b.site.SiteKey[]) => sendMsg2Runtime('site.delete', keys) + +export function changeSitesCate(cateId: number | undefined, ...keys: tt4b.site.SiteKey[]) { + return sendMsg2Runtime('site.changeCate', { keys, cateId }) +} + +export const deleteSiteIcon = (key: tt4b.site.SiteKey) => sendMsg2Runtime('site.deleteIcon', key) + +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 fillInitialAlias = (keys: tt4b.site.SiteKey[]) => sendMsg2Runtime('site.fillAlias', keys) + +export const getInitialAlias = (host: string) => sendMsg2Runtime('site.initialAlias', host) + +export function changeSiteRun(key: tt4b.site.SiteKey, enabled: boolean) { + return sendMsg2Runtime('site.changeRun', { key, enabled }) +} + +export const searchSite = (query?: string) => sendMsg2Runtime('site.search', query) diff --git a/src/api/sw/stat.ts b/src/api/sw/stat.ts new file mode 100644 index 000000000..534b02f35 --- /dev/null +++ b/src/api/sw/stat.ts @@ -0,0 +1,31 @@ +import { sendMsg2Runtime } from "./common" + +export const listSiteStats = (param?: tt4b.stat.SiteQuery) => sendMsg2Runtime('stat.sites', param) + +export const getSiteStatPage = (param?: tt4b.stat.SitePageQuery) => sendMsg2Runtime('stat.sitePage', param) + +export function deleteSiteStatByHost(host: string, date?: [string?, string?] | string) { + return sendMsg2Runtime('stat.deleteSite', { host, date }) +} + +export function deleteSiteStatByGroup(groupId: number, date?: [string?, string?] | string) { + return sendMsg2Runtime('stat.deleteSite', { groupId, date }) +} + +export const listCateStats = (param?: tt4b.stat.CateQuery) => sendMsg2Runtime('stat.cates', param) + +export const getCateStatPage = (param?: tt4b.stat.CatePageQuery) => sendMsg2Runtime('stat.catePage', param) + +export const listGroupStats = (param?: tt4b.stat.GroupQuery) => sendMsg2Runtime('stat.groups', param) + +export const getGroupStatPage = (param?: tt4b.stat.GroupPageQuery) => sendMsg2Runtime('stat.groupPage', param) + +export const batchDeleteStats = (targets: tt4b.stat.Row[]) => sendMsg2Runtime('stat.batchDelete', targets) + +export function countGroupStatsByIds(groupIds: number[], date: string | [string?, string?]) { + return sendMsg2Runtime('stat.countGroup', { groupIds, date }) +} + +export function countSiteStatsByHosts(hosts: string[], date: string | [string?, string?]) { + return sendMsg2Runtime('stat.countSite', { host: hosts, date }) +} diff --git a/src/api/sw/whitelist.ts b/src/api/sw/whitelist.ts new file mode 100644 index 000000000..3218b7198 --- /dev/null +++ b/src/api/sw/whitelist.ts @@ -0,0 +1,9 @@ +import { sendMsg2Runtime } from "./common" + +export const listWhitelist = () => sendMsg2Runtime('whitelist.all') + +export const addWhitelist = (white: string) => sendMsg2Runtime('whitelist.add', white) + +export const deleteWhitelist = (white: string) => sendMsg2Runtime('whitelist.delete', white) + +export const saveWhitelist = (whitelist: string[]) => sendMsg2Runtime('whitelist.save', whitelist) \ No newline at end of file diff --git a/src/api/version.ts b/src/api/version.ts deleted file mode 100644 index 5fe826ffc..000000000 --- a/src/api/version.ts +++ /dev/null @@ -1,63 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { BROWSER_NAME } from "@util/constant/environment" -import { fetchGet } from "./http" - -/** - * @since 0.1.8 - */ -type FirefoxDetail = { - current_version: { - // Like 0.1.5 - version: string - } - // Like '2021-06-11T08:45:32Z' - last_updated: string -} - -/** - * @since 0.1.8 - */ -type EdgeDetail = { - // Version like 0.1.5, without 'v' prefix - version: string - // Like '1619432502.5944779' - lastUpdateDate: string -} - -async function getFirefoxVersion(): Promise { - const response = await fetchGet('https://addons.mozilla.org/api/v3/addons/addon/2690100') - const detail: FirefoxDetail = await response.json() - return detail?.current_version?.version -} - -async function getEdgeVersion(): Promise { - const response = await fetchGet('https://microsoftedge.microsoft.com/addons/getproductdetailsbycrxid/fepjgblalcnepokjblgbgmapmlkgfahc') - const detail: EdgeDetail = await response.json() - return detail?.version -} - -async function getChromeVersion(): Promise { - // Get info from shields.io - const response = await fetchGet('https://img.shields.io/chrome-web-store/v/dkdhhcbjijekmneelocdllcldcpmekmm?label=Google%20Chrome') - const data = await response.text() - const pattern = /:\sv(\d+\.\d+\.\d+)/ - const matchResult = pattern.exec(data) - return matchResult?.length === 2 ? matchResult?.[1] : undefined -} - -export function getLatestVersion(): Promise { - if (BROWSER_NAME === 'firefox') { - return getFirefoxVersion() - } else if (BROWSER_NAME === 'chrome') { - return getChromeVersion() - } else if (BROWSER_NAME === 'edge') { - return getEdgeVersion() - } - return Promise.resolve(undefined) -} diff --git a/src/api/web-dav.ts b/src/api/web-dav.ts index 39a4eb240..5d22367fa 100644 --- a/src/api/web-dav.ts +++ b/src/api/web-dav.ts @@ -3,7 +3,7 @@ * * Testing with server implemented by https://github.com/svtslv/webdav-cli */ -import { encode } from 'js-base64' +import { encodeBase64 } from '../util/encode' import { fetchDelete, fetchGet } from './http' // Only support password for now @@ -22,7 +22,7 @@ const authHeaders = (auth: WebDAVAuth): Headers => { const type = auth?.type const headers = new Headers() if (type === 'password') { - headers.set('Authorization', `Basic ${encode(`${auth?.username}:${auth?.password}`)}`) + headers.set('Authorization', `Basic ${encodeBase64(`${auth?.username}:${auth?.password}`)}`) } return headers } @@ -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 @@ -49,19 +49,30 @@ export async function judgeDirExist(context: WebDAVContext, dirPath: string): Pr } } -export async function makeDir(context: WebDAVContext, dirPath: string) { +export async function makeDirs(context: WebDAVContext, dirPath: string) { + const normalizedPath = dirPath.startsWith('/') ? dirPath.slice(1) : dirPath + const pathSegments = normalizedPath.split('/').filter(segment => segment.length > 0) + const { auth, endpoint } = context || {} - const url = `${endpoint}/${dirPath}` - const headers = authHeaders(auth) - const response = await fetch(url, { method: 'MKCOL', headers }) - handleWriteResponse(response) + + for (let i = 0; i < pathSegments.length; i++) { + const currentPath = pathSegments.slice(0, i + 1).join('/') + + const exists = await judgeDirExist(context, currentPath) + if (!exists) { + const url = `${endpoint}/${currentPath}` + const headers = authHeaders(auth) + const response = await fetch(url, { method: 'MKCOL', headers, credentials: 'omit' }) + handleWriteResponse(response) + } + } } 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") @@ -76,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) } @@ -95,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/browser-action-manager.ts b/src/background/action.ts similarity index 80% rename from src/background/browser-action-manager.ts rename to src/background/action.ts index 12af14126..b183480dc 100644 --- a/src/background/browser-action-manager.ts +++ b/src/background/action.ts @@ -1,21 +1,21 @@ -/** +/** * Copyright (c) 2021-present Hengyang Zhang * * This software is released under the MIT License. * https://opensource.org/licenses/MIT */ +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_FIREFOX, IS_MV3, IS_SAFARI } from "@util/constant/environment" +import { IS_ANDROID, IS_MV3, IS_SAFARI } from "@util/constant/environment" import { CHANGE_LOG_PAGE, GITHUB_ISSUE_ADD, SOURCE_CODE_PAGE, TU_CAO_PAGE, getAppPageUrl, getGuidePageUrl, } from "@util/constant/url" -import { OPTION_ROUTE, REPORT_ROUTE } from "../pages/app/router/constants" const APP_PAGE_URL = getAppPageUrl() @@ -36,13 +36,6 @@ function titleOf(prefixEmoji: string, title: string) { } } -const sidebarProps: ChromeContextMenuCreateProps = { - id: getRuntimeId() + '_timer_menu_item_sidebar', - title: titleOf('🖱️', t2Chrome(msg => msg.base.sidebar)), - onclick: () => browser.sidebarAction.open(), - ...baseProps, -} - const allFunctionProps: ChromeContextMenuCreateProps = { id: getRuntimeId() + '_timer_menu_item_app_link', title: titleOf('🏷️', t2Chrome(msg => msg.base.allFunction)), @@ -53,7 +46,7 @@ const allFunctionProps: ChromeContextMenuCreateProps = { const optionPageProps: ChromeContextMenuCreateProps = { id: getRuntimeId() + '_timer_menu_item_option_link', title: titleOf('🥰', t2Chrome(msg => msg.base.option)), - onclick: () => createTab(APP_PAGE_URL + '#' + OPTION_ROUTE), + onclick: () => createTab(getAppPageUrl(APP_OPTION_ROUTE)), ...baseProps } @@ -85,9 +78,7 @@ const changeLogProps: ChromeContextMenuCreateProps = { ...baseProps } -function initBrowserAction() { - // Create sidebar item for Firefox - createContextMenu(IS_FIREFOX ? sidebarProps : allFunctionProps) +export function initBrowserAction() { createContextMenu(allFunctionProps) createContextMenu(optionPageProps) createContextMenu(repoPageProps) @@ -97,8 +88,15 @@ function initBrowserAction() { if (IS_ANDROID) { // Forbidden popup page - onIconClick(() => createTab({ url: getAppPageUrl(REPORT_ROUTE) })) + onIconClick(() => createTab({ url: getAppPageUrl(APP_RECORD_ROUTE) })) } } -export default initBrowserAction +export function initSidePanel() { + if (!IS_MV3) return + const sidePanel = chrome.sidePanel + // sidePanel not supported for Firefox + // Avoid `chrome.sidePanel.setOptions` to skip web-ext lint + if (!sidePanel?.setOptions) return + sidePanel.setOptions({ path: "/static/side.html" }) +} diff --git a/src/background/active-tab-listener.ts b/src/background/active-tab-listener.ts deleted file mode 100644 index 6d5fd3ce0..000000000 --- a/src/background/active-tab-listener.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { getTab, onTabActivated } from "@api/chrome/tab" -import { extractHostname, type HostInfo } from "@util/pattern" - -type _Param = { - url: string - tabId: number - host?: string -} - -type _Handler = (params: _Param) => void - -export default class ActiveTabListener { - listener: _Handler[] = [] - - private async processWithTabInfo({ url, id }: ChromeTab) { - if (!url || !id) return - const hostInfo: HostInfo = extractHostname(url) - const host: string = hostInfo.host - const param: _Param = { url, tabId: id, host } - this.listener.forEach(func => func(param)) - } - - register(handler: _Handler): ActiveTabListener { - this.listener.push(handler) - return this - } - - listen() { - onTabActivated(async tabId => { - const tab = await getTab(tabId) - tab && this.processWithTabInfo(tab) - }) - } -} - diff --git a/src/background/alarm-manager.ts b/src/background/alarm-manager.ts index 62cb2d74c..7dd45cf8c 100644 --- a/src/background/alarm-manager.ts +++ b/src/background/alarm-manager.ts @@ -1,10 +1,10 @@ -import { clearAlarm, createAlarm, onAlarm } from "@api/chrome/alarm" +import { clearAlarm, createAlarm, getAlarm, onAlarm } from "@api/chrome/alarm" import { getRuntimeId } from "@api/chrome/runtime" type _AlarmConfig = { handler: _Handler, interval?: number, - when?: () => number, + when?: () => number | null, } type _Handler = (alarm: ChromeAlarm) => void @@ -15,7 +15,7 @@ const ALARM_PREFIX_LENGTH = ALARM_PREFIX.length const getInnerName = (outerName: string) => ALARM_PREFIX + outerName const getOuterName = (innerName: string) => innerName.substring(ALARM_PREFIX_LENGTH) -const calcNextTs = (config: _AlarmConfig): number => { +const calcNextTs = (config: _AlarmConfig): number | null => { const { interval, when } = config if (interval) return Date.now() + interval if (when) return when() @@ -42,29 +42,28 @@ class AlarmManager { return } const innerName = getOuterName(name) - const config: _AlarmConfig = this.alarms[innerName] - if (!config) { - // Not registered, or removed - return - } + const config = this.alarms[innerName] + if (!config) return // Handle alarm event try { - config.handler?.(alarm) + config.handler(alarm) } catch (e) { console.info("Failed to handle alarm event", e) } finally { - const nextTs = calcNextTs(config) // Clear this one await clearAlarm(name) - createAlarm(name, nextTs) + const nextTs = calcNextTs(config) + nextTs && await createAlarm(name, nextTs) } }) } /** * Set a alarm to do sth with interval + * + * @param interval mills */ - setInterval(outerName: string, interval: number, handler: _Handler): void { + async setInterval(outerName: string, interval: number, handler: _Handler): Promise { if (!interval || !handler) { return } @@ -77,13 +76,13 @@ class AlarmManager { // Initialize config this.alarms[outerName] = config // Create new one alarm - createAlarm(getInnerName(outerName), Date.now() + interval) + await createAlarm(getInnerName(outerName), Date.now() + interval) } /** * Set a alarm to do sth if the time arrives */ - setWhen(outerName: string, when: () => number, handler: _Handler): void { + async setWhen(outerName: string, when: () => number | null, handler: _Handler): Promise { if (!when || !handler) { return } @@ -96,15 +95,28 @@ class AlarmManager { // Initialize config this.alarms[outerName] = config // Create new one alarm - createAlarm(getInnerName(outerName), when()) + const next = calcNextTs(config) + next && await createAlarm(getInnerName(outerName), next) } /** * Remove a interval */ - remove(outerName: string) { + async remove(outerName: string): Promise { delete this.alarms[outerName] - clearAlarm(getInnerName(outerName)) + await clearAlarm(getInnerName(outerName)) + } + + /** + * Judge if exist + */ + async getAlarm(outerName: string): Promise { + const innerName = getInnerName(outerName) + const existed = await getAlarm(innerName) + if (!existed && this.alarms[outerName]) { + delete this.alarms[outerName] + } + return existed } } diff --git a/src/background/backup-scheduler.ts b/src/background/backup-scheduler.ts deleted file mode 100644 index 7dc55a9ed..000000000 --- a/src/background/backup-scheduler.ts +++ /dev/null @@ -1,50 +0,0 @@ -/** - * Copyright (c) 2023 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import processor from "@service/backup/processor" -import optionHolder from "@service/components/option-holder" -import { MILL_PER_MINUTE } from "@util/time" -import alarmManager from "./alarm-manager" - -const ALARM_NAME = 'auto-backup-data' - -class BackupScheduler { - needBackup = false - /** - * Interval of milliseconds - */ - interval: number = 0 - - init() { - optionHolder.get().then(opt => this.handleOption(opt)) - optionHolder.addChangeListener(opt => this.handleOption(opt)) - } - - private handleOption(option: timer.option.BackupOption) { - const { autoBackUp, backupType, autoBackUpInterval = 0 } = option || {} - this.needBackup = backupType !== "none" && !!backupType && !!autoBackUp - this.interval = autoBackUpInterval * MILL_PER_MINUTE - if (this.needSchedule()) { - alarmManager.setInterval(ALARM_NAME, this.interval, () => this.doBackup()) - } else { - alarmManager.remove(ALARM_NAME) - } - } - - private needSchedule(): boolean { - return !!this.needBackup && !!this.interval - } - - private async doBackup(): Promise { - const result = await processor.syncData() - if (!result.success) { - console.warn(`Failed to backup ts=${Date.now()}, msg=${result.errorMsg}`) - } - } -} - -export default BackupScheduler \ No newline at end of file diff --git a/src/background/badge-manager.ts b/src/background/badge-manager.ts index b1da2cb06..f39ccab17 100644 --- a/src/background/badge-manager.ts +++ b/src/background/badge-manager.ts @@ -6,17 +6,17 @@ */ import { setBadgeBgColor, setBadgeText } from "@api/chrome/action" -import { listTabs } from "@api/chrome/tab" -import { getFocusedNormalWindow } from "@api/chrome/window" -import statDatabase from "@db/stat-database" -import optionHolder from "@service/components/option-holder" -import whitelistHolder from "@service/whitelist/holder" +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 { MILL_PER_HOUR, MILL_PER_MINUTE, MILL_PER_SECOND } from "@util/time" -import MessageDispatcher from "./message-dispatcher" +import statDatabase from "./database/stat-database" +import type MessageDispatcher from './message-dispatcher' +import optionHolder from "./service/components/option-holder" +import whitelistHolder from "./service/whitelist/holder" -export type BadgeLocation = { +type BadgeLocation = { /** * The tab id of badge text show display with */ @@ -25,7 +25,6 @@ export type BadgeLocation = { * The url of tab */ url: string - focus?: number } function mill2Str(milliseconds: number) { @@ -36,23 +35,16 @@ function mill2Str(milliseconds: number) { // no more than 1 hour return `${Math.round(milliseconds / MILL_PER_MINUTE)}m` } else { - return `${(milliseconds / MILL_PER_HOUR).toFixed(1)}h` + const hours = milliseconds / MILL_PER_HOUR + return hours < 10 ? `${hours.toFixed(1)}h` : `${Math.round(hours)}h` } } -function setBadgeTextOfMills(milliseconds: number | undefined, tabId: number | undefined) { - const text = milliseconds === undefined ? '' : mill2Str(milliseconds) - setBadgeText(text, tabId) -} - -async function findActiveTab(): Promise { - const window = await getFocusedNormalWindow() - if (!window) { - return undefined - } - const tabs = await listTabs({ active: true, windowId: window.id }) - // Fix #131 - // Edge will return two active tabs, including the new tab with url 'edge://newtab/', GG +async function findActiveTab(windowId?: number): Promise { + windowId ??= await getLastFocusedId() + if (isNoneWindowId(windowId)) return undefined + const tabs = await listTabs({ windowId, active: true }) + // Fix #131 — Edge can return two active tabs (e.g. edge://newtab/). for (const { id: tabId, url } of tabs) { if (!tabId || !url || isBrowserUrl(url)) continue return { tabId, url } @@ -63,106 +55,89 @@ async function findActiveTab(): Promise { async function clearAllBadge(): Promise { const tabs = await listTabs() if (!tabs?.length) return - for (const tab of tabs) { - await setBadgeText('', tab?.id) - } -} - -type BadgeState = 'HIDDEN' | 'NOT_SUPPORTED' | 'PAUSED' | 'TIME' | 'WHITELIST' - -interface BadgeManager { - init(dispatcher: MessageDispatcher): void - updateFocus(location?: BadgeLocation): void + for (const { id } of tabs) id != null && await setBadgeText('', id) } -class DefaultBadgeManager { - pausedTabId: number | undefined - current: BadgeLocation | undefined - visible: boolean | undefined - state: BadgeState | undefined +class BadgeManager { + #pausedTabId: number | undefined + #current: BadgeLocation | undefined + #visible = false + #countLocalFiles = false async init(messageDispatcher: MessageDispatcher) { + if (IS_ANDROID) return // do nothing on Android, since badge text is not supported + const option = await optionHolder.get() - this.processOption(option) + await this.processOption(option) optionHolder.addChangeListener(opt => this.processOption(opt)) whitelistHolder.addPostHandler(() => this.render()) - messageDispatcher - .register('cs.idleChange', (isIdle, sender) => { - const tabId = sender?.tab?.id - isIdle ? this.pause(tabId) : this.resume(tabId) - }) - this.updateFocus() + messageDispatcher.register('cs.idleChanged', (isIdle, sender) => { + const tabId = sender?.tab?.id + void (isIdle ? this.pause(tabId) : this.resume(tabId)) + }) + onWindowFocusChanged(async windowId => { + this.#current = await findActiveTab(windowId) + await this.render() + }) + onTabUpdated(async (tabId, { url }, { active }) => { + if (!active || !url) return + this.#current = { tabId, url } + await this.render() + }) + await this.updateFocus() } - /** - * Hide the badge text - */ private async pause(tabId?: number) { - this.pausedTabId = tabId - this.render() + if (typeof tabId !== 'number') return + this.#pausedTabId = tabId + await this.render() } - /** - * Show the badge text - */ - private resume(tabId?: number) { - if (!this.pausedTabId || this.pausedTabId !== tabId) return - this.pausedTabId = undefined - this.render() + private async resume(tabId?: number) { + if (typeof this.#pausedTabId !== 'number') return + if (typeof tabId !== 'number' || this.#pausedTabId !== tabId) return + this.#pausedTabId = undefined + await this.render() } async updateFocus(target?: BadgeLocation) { - this.current = target || await findActiveTab() + this.#current = target ?? await findActiveTab() await this.render() } - private processOption(option: timer.option.AppearanceOption) { - const { displayBadgeText, badgeBgColor } = option || {} - const before = this.visible - this.visible = !!displayBadgeText - !this.visible && before && clearAllBadge() - setBadgeBgColor(badgeBgColor) - } + private async processOption(option: tt4b.option.DefaultOption) { + const { displayBadgeText, badgeBgColor, countLocalFiles } = option - private async render(): Promise { - this.state = await this.processState() - } + const changed = this.#visible !== displayBadgeText || this.#countLocalFiles !== countLocalFiles + this.#countLocalFiles = countLocalFiles + this.#visible = displayBadgeText - private async processState(): Promise { - const { url, tabId, focus } = this.current || {} - if (!this.visible || !url) { - this.state !== 'HIDDEN' && setBadgeText('', tabId) - return 'HIDDEN' - } - if (isBrowserUrl(url)) { - this.state !== 'NOT_SUPPORTED' && setBadgeText('∅', tabId) - return 'NOT_SUPPORTED' + if (!this.#visible) { + await clearAllBadge() + } else { + await setBadgeBgColor(badgeBgColor) + if (changed) await this.render() } - const host = extractHostname(url)?.host - if (whitelistHolder.contains(host, url)) { - this.state !== 'WHITELIST' && setBadgeText('W', tabId) - return 'WHITELIST' - } - if (this.pausedTabId === tabId) { - this.state !== 'PAUSED' && setBadgeText('P', tabId) - return 'PAUSED' - } - const milliseconds = focus || (host ? (await statDatabase.get(host, new Date())).focus : undefined) - setBadgeTextOfMills(milliseconds, tabId) - return 'TIME' } -} -class SilentBadgeManager implements BadgeManager { - init(_dispatcher: MessageDispatcher): void { - // do nothing + private async render(): Promise { + const badgeText = await this.resolveBadgeText() + await setBadgeText(badgeText, this.#current?.tabId) } - updateFocus(_location?: BadgeLocation): void { - // do nothing + + private async resolveBadgeText(): Promise { + if (!this.#current || !this.#visible) return '' + const { url, tabId } = this.#current + if (isBrowserUrl(url)) return '∅' + const { host, protocol } = extractHostname(url) + if (protocol === 'file' && !this.#countLocalFiles) return '∅' + if (whitelistHolder.contains(host, url)) return 'W' + if (this.#pausedTabId === tabId) return 'P' + const { focus } = await statDatabase.get(host, new Date()) + return mill2Str(focus) } } -// Don't display badge on Android -const badgeManager: BadgeManager = IS_ANDROID ? new SilentBadgeManager() : new DefaultBadgeManager() +const badgeManager = new BadgeManager() export default badgeManager diff --git a/src/background/content-script-handler.ts b/src/background/content-script-handler.ts index d31277ac3..3ad13fe4c 100644 --- a/src/background/content-script-handler.ts +++ b/src/background/content-script-handler.ts @@ -5,46 +5,60 @@ * https://opensource.org/licenses/MIT */ -import { executeScript } from "@api/chrome/script" -import { createTab } from "@api/chrome/tab" -import { ANALYSIS_ROUTE, LIMIT_ROUTE } from "@app/router/constants" -import optionHolder from "@service/components/option-holder" -import limitService from "@service/limit-service" -import siteService from "@service/site-service" -import { saveTimelineEvent } from '@service/timeline-service' -import whitelistHolder from "@service/whitelist/holder" -import { getAppPageUrl } from "@util/constant/url" -import { extractFileHost, extractHostname } from "@util/pattern" +import { IS_ANDROID, IS_CHROME, IS_SAFARI } from "@util/constant/environment" +import { extractHostname, isBrowserUrl, isHomepage } from "@util/pattern" +import { extractSiteName } from "@util/site" import badgeManager from "./badge-manager" -import { collectIconAndAlias } from "./icon-and-alias-collector" import MessageDispatcher from "./message-dispatcher" +import { saveAlias, saveIconUrl } from "./service/site-service" +import { incVisitCount } from './track-server/normal' -const handleOpenAnalysisPage = (sender: ChromeMessageSender) => { - const { tab, url } = sender || {} - if (!url) return - const host = extractFileHost(url) || extractHostname(url)?.host - const newTabUrl = getAppPageUrl(ANALYSIS_ROUTE, { host }) +function isUrl(title: string) { + return title.startsWith('https://') || title.startsWith('http://') || title.startsWith('ftp://') +} - const tabIndex = tab?.index - const newTabIndex = tabIndex ? tabIndex + 1 : undefined - createTab({ url: newTabUrl, index: newTabIndex }) +async function collectAlias(key: tt4b.site.SiteKey, tabTitle: string) { + if (!tabTitle) return + if (isUrl(tabTitle)) return + const siteName = extractSiteName(tabTitle, key.host) + siteName && await saveAlias(key, siteName, true) } -const handleOpenLimitPage = (sender: ChromeMessageSender) => { - const { tab, url } = sender || {} - if (!url) return - const newTabUrl = getAppPageUrl(LIMIT_ROUTE, { url }) - const tabIndex = tab?.index - const newTabIndex = tabIndex ? tabIndex + 1 : undefined - createTab({ url: newTabUrl, index: newTabIndex }) +/** + * Process the tab + */ +async function processTabInfo(tab: ChromeTab): Promise { + let { favIconUrl, url, title } = tab + if (!url || !title) return + if (isBrowserUrl(url)) return + const hostInfo = extractHostname(url) + const host = hostInfo.host + if (!host) return + // localhost hosts with Chrome use cache, so keep the favIcon url undefined + IS_CHROME && /^localhost(:.+)?/.test(host) && (favIconUrl = undefined) + const siteKey: tt4b.site.SiteKey = { host, type: 'normal' } + favIconUrl && await saveIconUrl(siteKey, favIconUrl) + !IS_ANDROID + && !isBrowserUrl(url) + && isHomepage(url) + && await collectAlias(siteKey, title) +} + +/** + * Collect the favicon of host + */ +const collectIconAndAlias = async (tab: ChromeTab) => { + if (IS_SAFARI || IS_ANDROID) return + processTabInfo(tab) } const handleInjected = async (sender: ChromeMessageSender) => { - const tabId = sender?.tab?.id - if (!tabId) return - collectIconAndAlias(tabId) - badgeManager.updateFocus() - executeScript(tabId, ['content_scripts.js']) + const { tab, url } = sender + if (!tab) return + await incVisitCount(tab) + await collectIconAndAlias(tab) + const tabId = tab.id + await badgeManager.updateFocus(tabId && url ? { tabId, url } : undefined) } /** @@ -54,25 +68,7 @@ const handleInjected = async (sender: ChromeMessageSender) => { */ export default function init(dispatcher: MessageDispatcher) { dispatcher - // Judge is in whitelist - .register<{ host?: string, url?: string }, boolean>('cs.isInWhitelist', ({ host, url } = {}) => !!host && !!url && whitelistHolder.contains(host, url)) - // Need to print the information of today - .register('cs.printTodayInfo', async () => { - const option = await optionHolder.get() - return !!option.printInConsole - }) - .register('cs.getLimitedRules', url => limitService.getLimited(url)) - .register('cs.getRelatedRules', url => limitService.getRelated(url)) - .register('cs.openAnalysis', (_, sender) => handleOpenAnalysisPage(sender)) - .register('cs.openLimit', (_, sender) => handleOpenLimitPage(sender)) - .register('cs.onInjected', async (_, sender) => handleInjected(sender)) + .register('cs.injected', (_, sender) => handleInjected(sender)) // Get sites which need to count run time - .register('cs.getRunSites', async url => { - const { host } = extractHostname(url) || {} - if (!host) return null - const site: timer.site.SiteKey = { host, type: 'normal' } - const exist = await siteService.get(site) - return exist?.run ? site : null - }) - .register('cs.timelineEv', ev => saveTimelineEvent(ev)) + .register('cs.getAudible', async (_, sender) => !!sender.tab?.audible) } \ No newline at end of file diff --git a/src/background/data-cleaner.ts b/src/background/data-cleaner.ts index 32d6dbf01..18e4f49e7 100644 --- a/src/background/data-cleaner.ts +++ b/src/background/data-cleaner.ts @@ -1,22 +1,21 @@ -import periodService, { type PeriodQueryParam } from "@service/period-service" import { keyOf } from "@util/period" import { getBirthday, getStartOfDay, MILL_PER_DAY } from "@util/time" import alarmManager from "./alarm-manager" +import { batchDeletePeriods } from "./service/period-service" const PERIOD_ALARM_NAME = 'period-cleaner-alarm' const START_DAY = keyOf(getBirthday()) const KEEP_RANGE_DAYS = 366 const cleanPeriodData = async () => { - const endDate = new Date().getTime() - MILL_PER_DAY * KEEP_RANGE_DAYS - const param: PeriodQueryParam = { periodRange: [START_DAY, keyOf(endDate)] } - await periodService.batchDeleteBetween(param) + const endDate = Date.now() - MILL_PER_DAY * KEEP_RANGE_DAYS + await batchDeletePeriods(START_DAY, keyOf(endDate)) } export default function initDataCleaner() { alarmManager.setWhen( PERIOD_ALARM_NAME, - () => getStartOfDay(new Date()).getTime() + MILL_PER_DAY, + () => getStartOfDay(new Date()) + MILL_PER_DAY, cleanPeriodData, ) } \ No newline at end of file diff --git a/src/background/database/backup-database.ts b/src/background/database/backup-database.ts new file mode 100644 index 000000000..c0129cd5e --- /dev/null +++ b/src/background/database/backup-database.ts @@ -0,0 +1,31 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import BaseDatabase from "./common/base-database" +import { REMAIN_WORD_PREFIX } from "./common/constant" + +const PREFIX = REMAIN_WORD_PREFIX + "backup" +const CACHE_KEY = PREFIX + "_cache" + +function cacheKeyOf(type: tt4b.backup.Type) { + return CACHE_KEY + "_" + type +} + +class BackupDatabase extends BaseDatabase { + + async getCache(type: tt4b.backup.Type): Promise { + return (await this.storage.getOne(cacheKeyOf(type))) || {} + } + + async updateCache(type: tt4b.backup.Type, newVal: unknown): Promise { + return this.storage.put(cacheKeyOf(type), newVal as Object) + } +} + +const backupDatabase = new BackupDatabase() + +export default backupDatabase \ No newline at end of file diff --git a/src/database/site-cate-database.ts b/src/background/database/cate-database.ts similarity index 60% rename from src/database/site-cate-database.ts rename to src/background/database/cate-database.ts index feb063b00..da866003d 100644 --- a/src/database/site-cate-database.ts +++ b/src/background/database/cate-database.ts @@ -19,26 +19,13 @@ type Item = { type Items = Record -function migrate(exist: Items, toMigrate: any) { - let idBase = Object.keys(exist).map(parseInt).sort().reverse()?.[0] ?? 0 + 1 - const existLabels = new Set(Object.values(exist).map(e => e.n)) - - Object.values(toMigrate).forEach(value => { - const { n } = (value as Item) || {} - if (!n || existLabels.has(n)) return - - const id = idBase - idBase++ - exist[id] = { n } - }) -} - /** - * Site tag + * Category * * @since 3.0.0 */ -class SiteCateDatabase extends BaseDatabase { +class CateDatabase extends BaseDatabase { + private async getItems(): Promise { return await this.storage.getOne(KEY) || {} } @@ -47,17 +34,17 @@ class SiteCateDatabase extends BaseDatabase { await this.storage.put(KEY, items || {}) } - async listAll(): Promise { + async listAll(): Promise { const items = await this.getItems() return Object.entries(items).map(([id, { n = '' } = {}]) => { return { id: parseInt(id), name: n, - } satisfies timer.site.Cate + } satisfies tt4b.site.Cate }) } - async add(name: string): Promise { + async add(name: string): Promise { const items = await this.getItems() const existId = Object.entries(items).find(([_, v]) => v.n === name)?.[0] if (existId) { @@ -65,8 +52,9 @@ class SiteCateDatabase 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 } @@ -86,15 +74,6 @@ class SiteCateDatabase extends BaseDatabase { await this.saveItems(items) } - async importData(data: any): Promise { - let toImport = data[KEY] as Items - // Not import - if (typeof toImport !== 'object') return - const exists: Items = await this.getItems() - migrate(exists, toImport) - this.setByKey(KEY, exists) - } - async delete(id: number): Promise { const items = await this.getItems() @@ -104,6 +83,6 @@ class SiteCateDatabase extends BaseDatabase { } } -const siteCateDatabase = new SiteCateDatabase() +const cateDatabase = new CateDatabase() -export default siteCateDatabase \ No newline at end of file +export default cateDatabase \ No newline at end of file diff --git a/src/database/common/base-database.ts b/src/background/database/common/base-database.ts similarity index 80% rename from src/database/common/base-database.ts rename to src/background/database/common/base-database.ts index 0137adc43..2c00a4e19 100644 --- a/src/database/common/base-database.ts +++ b/src/background/database/common/base-database.ts @@ -24,12 +24,4 @@ export default abstract class BaseDatabase { protected setByKey(key: string, val: any): Promise { return this.storage.put(key, val) } - - /** - * Import data - * - * @since 0.2.5 - * @param data backup data - */ - abstract importData(data: any): Promise } \ No newline at end of file diff --git a/src/database/common/constant.ts b/src/background/database/common/constant.ts similarity index 100% rename from src/database/common/constant.ts rename to src/background/database/common/constant.ts diff --git a/src/background/database/common/indexed-storage.ts b/src/background/database/common/indexed-storage.ts new file mode 100644 index 000000000..fdc2148c6 --- /dev/null +++ b/src/background/database/common/indexed-storage.ts @@ -0,0 +1,324 @@ +const ALL_TABLES = ['stat', 'timeline'] as const + +export type Table = typeof ALL_TABLES[number] + +export type Key> = keyof T & string + +type IndexConfig> = { + key: Key | Key[] + unique?: boolean +} + +export type Index> = Key | Key[] | IndexConfig + +function normalizeIndex>(index: Index): IndexConfig { + return typeof index === 'string' || Array.isArray(index) ? { key: index } : index +} + +function formatIdxName>(key: IndexConfig['key']): string { + const keyStr = Array.isArray(key) ? [...key].sort().join('_') : key + return `idx_${keyStr}` +} + +export function req2Promise(req: IDBRequest): Promise { + return new Promise((resolve, reject) => { + req.onsuccess = () => resolve(req.result) + req.onerror = (ev) => { + console.error("Failed to request indexed-db", ev, req.error) + reject(req.error) + } + }) +} + +export async function iterateCursor( + req: IDBRequest +): Promise +export async function iterateCursor( + req: IDBRequest, + processor: (cursor: IDBCursorWithValue) => void | Promise +): Promise + +export async function iterateCursor( + req: IDBRequest, + processor?: (cursor: IDBCursorWithValue) => void | Promise +): Promise { + const collectResults = !processor + const results: T[] = [] + + return new Promise((resolve, reject) => { + req.onsuccess = async () => { + const cursor = req.result + if (!cursor) return resolve(collectResults ? results : undefined) + + try { + collectResults && results.push(cursor.value as T) + await processor?.(cursor) + cursor.continue() + } catch (error) { + reject(error) + } + } + + req.onerror = () => reject(req.error) + }) +} + +type TransactionError = 'Connection' | 'StoreNotFound' | 'DataError' | 'Unknown' + +const detectTransactionError = (err: unknown): TransactionError => { + if (!(err instanceof DOMException)) { + console.warn("Non-DOMException error during transaction", err) + return 'Unknown' + } + const { name } = err + switch (name) { + case 'InvalidStateError': + case 'AbortError': return 'Connection' + case 'NotFoundError': return 'StoreNotFound' + case 'DataError': return 'DataError' + default: + console.warn(`Unknown dom exception: name=${name}`) + return 'Unknown' + } +} + +export function closedRangeKey(lower: IDBValidKey | undefined, upper: IDBValidKey | undefined): IDBKeyRange | undefined { + if (lower !== undefined && upper !== undefined) { + if (lower > upper) { + [lower, upper] = [upper, lower] + } + return IDBKeyRange.bound(lower, upper, false, false) + } else if (lower !== undefined) { + return IDBKeyRange.lowerBound(lower, false) + } else if (upper !== undefined) { + return IDBKeyRange.upperBound(upper, false) + } else { + return undefined + } +} + +export type IndexResult = { + cursorReq: IDBRequest + coverage?: FilterCoverage +} + +export abstract class BaseIDBStorage> { + #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 + + let initPromise = BaseIDBStorage.#initPromises.get(this.table) + if (!initPromise) { + initPromise = this.#doInitDb() + BaseIDBStorage.#initPromises.set(this.table, initPromise) + } + + try { + this.#db = await initPromise + this.#setupDbCloseHandler(this.#db) + return this.#db + } catch (error) { + BaseIDBStorage.#initPromises.delete(this.table) + throw error + } + } + + #setupDbCloseHandler(db: IDBDatabase): void { + db.onversionchange = () => db.close() + + db.onclose = () => { + if (this.#db !== db) return + + this.#db = undefined + BaseIDBStorage.#initPromises.delete(this.table) + } + } + + async #doInitDb(): Promise { + const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB + + 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")) + }) + } + + // Only used for testing, be careful when using in production + async clear(): Promise { + await this.withStore(store => store.clear(), 'readwrite') + } + + async upgrade(): Promise { + const factory = typeof window !== 'undefined' ? window.indexedDB : globalThis.indexedDB + + const checkDb = await new Promise((resolve, reject) => { + const checkRequest = factory.open(this.#DB_NAME) + checkRequest.onsuccess = () => resolve(checkRequest.result) + checkRequest.onerror = () => reject(checkRequest.error || new Error("Failed to open database")) + checkRequest.onblocked = () => { + console.warn(`Database check blocked for "${this.table}" (DB: ${this.#DB_NAME}), waiting for other connections to close`) + } + }) + + const storeExisted = checkDb.objectStoreNames.contains(this.table) + const needUpgrade = !storeExisted || this.#needUpgradeIndexes(checkDb) + + if (!needUpgrade) { + checkDb.close() + return + } + + const currentVersion = checkDb.version + checkDb.close() + + return new Promise((resolve, reject) => { + const upgradeRequest = factory.open(this.#DB_NAME, currentVersion + 1) + + upgradeRequest.onupgradeneeded = () => { + try { + const upgradeDb = upgradeRequest.result + const transaction = upgradeRequest.transaction + if (!transaction) { + reject(new Error("Failed to get transaction of upgrading request")) + return + } + + transaction.onerror = () => { + reject(transaction.error || new Error("Transaction failed")) + } + + transaction.onabort = () => { + reject(new Error("Upgrade transaction was aborted")) + } + + let store = upgradeDb.objectStoreNames.contains(this.table) + ? transaction.objectStore(this.table) + : upgradeDb.createObjectStore(this.table, { keyPath: this.key as string | string[] }) + this.#createIndexes(store) + } catch (error) { + console.error("Failed to upgrade database in onupgradeneeded", error) + upgradeRequest.transaction?.abort() + reject(error instanceof Error ? error : new Error(String(error))) + } + } + + upgradeRequest.onsuccess = () => { + console.log(`IndexedDB upgraded for table "${this.table}"`) + upgradeRequest.result.close() + resolve() + } + + upgradeRequest.onerror = (event) => { + console.error("Failed to upgrade database", event, upgradeRequest.error) + reject(upgradeRequest.error || new Error("Failed to upgrade database")) + } + + upgradeRequest.onblocked = () => { + const blockingTables = Array.from(BaseIDBStorage.#initPromises.keys()) + .filter(table => table !== this.table) + console.warn( + `Database upgrade blocked for table "${this.table}" (DB: ${this.#DB_NAME}), ` + + `waiting for other connections to close. ` + + `Other tables with active connections: ${blockingTables.length > 0 ? blockingTables.join(', ') : 'none'}` + ) + } + }) + } + + #needUpgradeIndexes(db: IDBDatabase): boolean { + try { + const transaction = db.transaction(this.table, 'readonly') + const store = transaction.objectStore(this.table) + const indexNames = store.indexNames + + for (const index of this.indexes) { + const { key } = normalizeIndex(index) + const idxName = formatIdxName(key) + if (!indexNames.contains(idxName)) { + return true + } + } + return false + } catch (e) { + console.error("Failed to check indexes", e) + return true + } + } + + #createIndexes(store: IDBObjectStore) { + const existingIndexes = store.indexNames + + for (const index of this.indexes) { + const { key, unique } = normalizeIndex(index) + const idxName = formatIdxName(key) + if (!existingIndexes.contains(idxName)) { + store.createIndex(idxName, key, { unique }) + } + } + } + + protected async withStore(operation: (store: IDBObjectStore) => Awaitable, mode?: IDBTransactionMode): Promise { + let db = await this.initDb() + + for (let retryCount = 0; retryCount < 2; retryCount++) { + let trans: IDBTransaction | undefined + try { + trans = db.transaction(this.table, mode ?? 'readwrite') + const store = trans.objectStore(this.table) + const result = await operation(store) + const transaction = trans + await new Promise((resolve, reject) => { + transaction.oncomplete = () => resolve() + transaction.onerror = () => reject(transaction.error) + transaction.onabort = () => reject(new Error('Transaction aborted')) + }) + return result + } catch (e) { + const errorType = detectTransactionError(e) + + if (errorType === 'Unknown') { + console.error("Failed to process with transaction", e) + if (trans && !trans.error && trans.mode !== 'readonly') { + try { + trans.abort() + } catch (ignored) { } + } + throw e + } + + if (errorType === 'StoreNotFound') { + this.#db?.close() + await this.upgrade() + } + + this.#db = undefined + BaseIDBStorage.#initPromises.delete(this.table) + db = await this.initDb() + } + } + throw new Error("Max retries exceeded") + } + + protected assertIndex(store: IDBObjectStore, key: Key | Key[]): IDBIndex { + const idxName = formatIdxName(key) + try { + return store.index(idxName) + } catch (err) { + console.error(`Failed to query index: table=${this.table}`, err) + throw err + } + } + + protected assertIndexCursor(store: IDBObjectStore, key: Key | Key[], range: IDBKeyRange): IDBRequest { + const index = this.assertIndex(store, key) + return index.openCursor(range) + } +} diff --git a/src/background/database/common/migratable.ts b/src/background/database/common/migratable.ts new file mode 100644 index 000000000..323b9be49 --- /dev/null +++ b/src/background/database/common/migratable.ts @@ -0,0 +1,29 @@ +import { isRecord } from '@util/guard' +import { createObjectGuard, isInt, isString, TypeGuard } from 'typescript-guard' +import type { BrowserMigratableNamespace } from '../types' + +export const isExportData = createObjectGuard>({ + __meta__: createObjectGuard({ + version: isString, + ts: isInt, + }), +}) + +export const isLegacyVersion = (data: unknown): data is tt4b.backup.ExportData => { + if (!isExportData(data)) return false + + const version = data.__meta__.version + const match = version.match(/^(\d+)\.(\d+)\.(\d+)/) + const majorStr = match?.[1] + if (!majorStr) return true + const major = parseInt(majorStr) + + return major < 4 +} + +export const extractNamespace = (data: unknown, namespace: BrowserMigratableNamespace, guard: TypeGuard): T | undefined => { + if (!isRecord(data)) return undefined + if (!(namespace in data)) return undefined + const nsData = data[namespace] + return guard(nsData) ? nsData : undefined +} \ No newline at end of file diff --git a/src/background/database/common/storage-holder.ts b/src/background/database/common/storage-holder.ts new file mode 100644 index 000000000..fdc2194c1 --- /dev/null +++ b/src/background/database/common/storage-holder.ts @@ -0,0 +1,22 @@ +import optionHolder from '@service/components/option-holder' + +export class StorageHolder { + current: Database + delegates: Record + + constructor(delegates: Record) { + this.delegates = delegates + this.current = delegates.classic + optionHolder.get().then(val => this.handleOption(val)) + optionHolder.addChangeListener(val => this.handleOption(val)) + } + + private handleOption(option: tt4b.option.TrackingOption) { + const delegate = this.delegates[option.storage] + delegate && (this.current = delegate) + } + + get(type: tt4b.option.StorageType): Database | null { + return this.delegates[type] ?? null + } +} \ No newline at end of file diff --git a/src/database/common/storage-promise.ts b/src/background/database/common/storage-promise.ts similarity index 96% rename from src/database/common/storage-promise.ts rename to src/background/database/common/storage-promise.ts index 91c8d9c5a..14c47dc97 100644 --- a/src/database/common/storage-promise.ts +++ b/src/background/database/common/storage-promise.ts @@ -44,7 +44,7 @@ export default class StoragePromise { /** * @since 0.5.0 */ - put(key: string, val: Object): Promise { + put(key: string, val: unknown): Promise { return this.set({ [key]: val }) } diff --git a/src/database/limit-database.ts b/src/background/database/limit-database.ts similarity index 57% rename from src/database/limit-database.ts rename to src/background/database/limit-database.ts index f8327bd3e..9ca91d0ce 100644 --- a/src/database/limit-database.ts +++ b/src/background/database/limit-database.ts @@ -5,9 +5,13 @@ * https://opensource.org/licenses/MIT */ +import { isRecord, isVector2 } from '@util/guard' import { formatTimeYMD, MILL_PER_DAY } from "@util/time" +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' +import type { BrowserMigratable } from './types' const KEY = REMAIN_WORD_PREFIX + 'LIMIT' @@ -19,10 +23,30 @@ type DateRecords = { } } -type LimitRecord = timer.limit.Rule & { +export type LimitRecord = tt4b.limit.Rule & { records: DateRecords } +type PartialRule = MakeRequired, 'name' | 'cond'> + +const isValidRow = createObjectGuard({ + id: isOptionalInt, + name: isString, + cond: createArrayGuard(isString), + time: isOptionalInt, + count: isOptionalInt, + weekly: isOptionalInt, + weeklyCount: isOptionalInt, + visitTime: isOptionalInt, + enabled: isOptionalBoolean, + locked: isOptionalBoolean, + weekdays: createOptionalGuard(createArrayGuard(createGuard(val => isInt(val) && val >= 0 && val <= 6))), + allowDelay: isOptionalBoolean, + periods: createOptionalGuard(createArrayGuard(isVector2)), +}) + +const isValidImportRows = createArrayGuard(isValidRow) + type ItemValue = { /** * ID @@ -121,8 +145,10 @@ const cvtItem2Rec = (item: ItemValue): LimitRecord => { type Items = Record -function migrate(exist: Items, toMigrate: any) { - const idBase = Object.keys(exist).map(parseInt).sort().reverse()?.[0] ?? 0 + 1 +function migrate(exist: Items, toMigrate: unknown) { + if (!isRecord(toMigrate)) return + 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 @@ -134,12 +160,24 @@ function migrate(exist: Items, toMigrate: any) { }) } +function cvtRule2Item(rule: tt4b.limit.Rule): ItemValue { + const { id, name, cond, time, count, weekly, weeklyCount, visitTime, periods, enabled, locked, allowDelay, weekdays } = rule + return { + i: id, n: name, c: cond, t: time, ct: count, wt: weekly, wct: weeklyCount, + v: visitTime, p: periods, + e: !!enabled, l: !!locked, ad: !!allowDelay, + wd: weekdays, + } +} + /** * Time limit * * @since 0.2.2 */ -class LimitDatabase extends BaseDatabase { +class LimitDatabase extends BaseDatabase implements BrowserMigratable<'__limit__'> { + namespace: '__limit__' = '__limit__' + private async getItems(): Promise { let items = await this.storage.getOne(KEY) || {} return items @@ -163,41 +201,35 @@ class LimitDatabase extends BaseDatabase { return Object.values(items).map(cvtItem2Rec) } - async save(data: MakeOptional, rewrite?: boolean): Promise { + async batchUpdate(rules: tt4b.limit.Rule[]): Promise { + if (!rules.length) return const items = await this.getItems() - let { - id, name, weekdays, - enabled, locked, allowDelay, - cond, - time, count, - weekly, weeklyCount, - visitTime, periods, - } = data - if (!id) { - const lastId = Object.values(items) - .map(e => e.i) - .filter(i => !!i) - .sort((a, b) => b - a)?.[0] ?? 0 - id = lastId + 1 - } - const existItem = items[id] - if (existItem && !rewrite) return id - items[id] = { - // Can be overridden by existing - ...(existItem || {}), - i: id, n: name, c: cond, wd: weekdays, - e: !!enabled, l: locked, ad: !!allowDelay, - t: time, ct: count, - wt: weekly, wct: weeklyCount, - v: visitTime, p: periods, - } + rules.forEach(rule => { + const id = rule.id + const exist = items[id] + if (!exist) return + items[id] = { ...exist, ...cvtRule2Item(rule) } + }) + await this.update(items) + } + + async add(data: Omit): Promise { + const items = await this.getItems() + + const lastId = Object.values(items) + .map(e => e.i) + .filter(i => !!i) + .sort((a, b) => b - a)?.[0] ?? 0 + const id = lastId + 1 + + items[id] = cvtRule2Item({ id, ...data }) await this.update(items) return id } - async remove(id: number): Promise { + async batchRemove(ids: number[]): Promise { const items = await this.getItems() - delete items[id] + ids.forEach(id => delete items[id]) await this.update(items) } @@ -226,7 +258,7 @@ class LimitDatabase extends BaseDatabase { await this.update(items) } - async updateDelayCount(date: string, toUpdate: timer.limit.Item[]): Promise { + async updateDelayCount(date: string, toUpdate: tt4b.limit.Item[]): Promise { const items = await this.getItems() toUpdate?.forEach(({ id, delayCount }) => { const entry = items[id] @@ -238,35 +270,46 @@ class LimitDatabase extends BaseDatabase { await this.update(items) } - async updateDelay(id: number, allowDelay: boolean) { - const items = await this.getItems() - if (!items[id]) return - items[id].ad = allowDelay - await this.update(items) - } - - async updateEnabled(id: number, enabled: boolean) { - const items = await this.getItems() - if (!items[id]) return - items[id].e = !!enabled - await this.update(items) - } + async importData(data: unknown): Promise { + if (!isExportData(data)) return + if (isLegacyVersion(data)) { + return this.importLegacyData(data) + } - async updateLocked(id: number, locked: boolean) { - const items = await this.getItems() - if (!items[id]) return - items[id].l = !!locked - await this.update(items) + const rows = extractNamespace(data, this.namespace, isValidImportRows) ?? [] + for (const row of rows) { + const toImport: Omit = { + name: row.name, + cond: row.cond, + time: row.time, + count: row.count, + weekly: row.weekly, + weeklyCount: row.weeklyCount, + visitTime: row.visitTime, + periods: row.periods, + enabled: row.enabled ?? true, + locked: row.locked ?? false, + allowDelay: row.allowDelay ?? false, + weekdays: row.weekdays ?? [], + } + await this.add(toImport) + } } - async importData(data: any): Promise { - let toImport = data[KEY] as Items - // Not import - if (typeof toImport !== 'object') return - const exists: Items = await this.getItems() + /** + * @deprecated Only for legacy data, will be removed in future version + */ + private async importLegacyData(data: unknown): Promise { + if (!isRecord(data)) return + let toImport = data[KEY] + const exists = await this.getItems() migrate(exists, toImport) this.setByKey(KEY, exists) } + + exportData(): Promise { + return this.all() + } } const limitDatabase = new LimitDatabase() diff --git a/src/database/memory-detector.ts b/src/background/database/memory-detector.ts similarity index 66% rename from src/database/memory-detector.ts rename to src/background/database/memory-detector.ts index 5579bef5e..e4fc35799 100644 --- a/src/database/memory-detector.ts +++ b/src/background/database/memory-detector.ts @@ -7,20 +7,6 @@ import StoragePromise from "./common/storage-promise" -/** - * User memory of this extension - */ -export type MemoryInfo = { - /** - * Used bytes - */ - used: number - /** - * Total bytes - */ - total: number -} - /** * 'QUOTA_BYTES' Not supported in Firefox */ @@ -31,7 +17,7 @@ const total: number = chrome.storage.local.QUOTA_BYTES || 0 * * @since 0.0.9 */ -export async function getUsedStorage(): Promise { +export async function getUsedStorage(): Promise { const used = await new StoragePromise(chrome.storage.local).getUsedMemory() return { used, total } } \ No newline at end of file diff --git a/src/background/database/merge-rule-database.ts b/src/background/database/merge-rule-database.ts new file mode 100644 index 000000000..7fe275851 --- /dev/null +++ b/src/background/database/merge-rule-database.ts @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { isRecord } from '@util/guard' +import { createArrayGuard, createObjectGuard, createRecordGuard, createUnionGuard, isInt, isString } from 'typescript-guard' +import BaseDatabase from "./common/base-database" +import { REMAIN_WORD_PREFIX } from "./common/constant" +import { extractNamespace, isLegacyVersion } from './common/migratable' +import type { BrowserMigratable } from './types' + +const DB_KEY = REMAIN_WORD_PREFIX + 'MERGE_RULES' + +type MergeRuleSet = { [key: string]: string | number } + +const isMergeValue = createUnionGuard(isString, isInt) + +const isMergeRuleSet = createRecordGuard(isMergeValue) + +const isMergeRule = createObjectGuard({ + origin: isString, + merged: isMergeValue, +}) + +/** + * Rules to merge host + * + * @since 0.1.2 + */ +class MergeRuleDatabase extends BaseDatabase implements BrowserMigratable<'__merge__'> { + namespace: '__merge__' = '__merge__' + + async refresh(): Promise { + const result = await this.storage.getOne(DB_KEY) + return result || {} + } + + private update(data: MergeRuleSet): Promise { + return this.setByKey(DB_KEY, data) + } + + async selectAll(): Promise { + const set = await this.refresh() + return Object.entries(set) + .map(([origin, merged]) => ({ origin, merged } satisfies tt4b.merge.Rule)) + } + + async remove(origin: string): Promise { + const set = await this.refresh() + delete set[origin] + await this.update(set) + } + + /** + * Add to the db + */ + async add(...toAdd: tt4b.merge.Rule[]): Promise { + const set = await this.refresh() + // Not rewrite + toAdd.forEach(({ origin, merged }) => set[origin] = set[origin] ?? merged) + await this.update(set) + } + + async importData(data: unknown): Promise { + if (isLegacyVersion(data)) { + return this.importLegacyData(data) + } + const rules = extractNamespace(data, this.namespace, createArrayGuard(isMergeRule)) ?? [] + await this.add(...rules) + } + + /** + * @deprecated Only for legacy version + */ + private async importLegacyData(data: unknown): Promise { + if (!isRecord(data)) return + const toMigrate = data[DB_KEY] + if (!isMergeRuleSet(toMigrate)) return + const exist = await this.refresh() + Object.entries(toMigrate satisfies MergeRuleSet) + // Not rewrite + .filter(([key]) => !exist[key]) + .forEach(([key, value]) => exist[key] = value) + await this.update(exist) + } + + exportData(): Promise { + return this.selectAll() + } +} + +const mergeRuleDatabase = new MergeRuleDatabase() + +export default mergeRuleDatabase \ No newline at end of file diff --git a/src/background/database/meta-database.ts b/src/background/database/meta-database.ts new file mode 100644 index 000000000..1b4910566 --- /dev/null +++ b/src/background/database/meta-database.ts @@ -0,0 +1,27 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import BaseDatabase from "./common/base-database" +import { META_KEY } from "./common/constant" + +/** + * @since 0.6.0 + */ +class MetaDatabase extends BaseDatabase { + async getMeta(): Promise { + const meta = await this.storage.getOne(META_KEY) + return meta || {} + } + + async update(existMeta: tt4b.ExtensionMeta): Promise { + await this.storage.put(META_KEY, existMeta) + } +} + +const metaDatabase = new MetaDatabase() + +export default metaDatabase \ No newline at end of file diff --git a/src/background/database/option-database.ts b/src/background/database/option-database.ts new file mode 100644 index 000000000..ec93264c9 --- /dev/null +++ b/src/background/database/option-database.ts @@ -0,0 +1,34 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { defaultOption } from "@util/constant/option" +import { mergeObject } from '@util/lang' +import BaseDatabase from "./common/base-database" +import { REMAIN_WORD_PREFIX } from "./common/constant" + +const DB_KEY = REMAIN_WORD_PREFIX + 'OPTION' + +/** + * Database of options + * + * @since 0.3.0 + */ +class OptionDatabase extends BaseDatabase { + + async getOption(): Promise { + const option = await this.storage.getOne(DB_KEY) + return mergeObject(defaultOption(), option) + } + + async setOption(option: tt4b.option.AllOption): Promise { + option && await this.setByKey(DB_KEY, option) + } +} + +const optionDatabase = new OptionDatabase() + +export default optionDatabase \ No newline at end of file diff --git a/src/database/period-database.ts b/src/background/database/period-database.ts similarity index 58% rename from src/database/period-database.ts rename to src/background/database/period-database.ts index 1ff81dd94..e99fca321 100644 --- a/src/database/period-database.ts +++ b/src/background/database/period-database.ts @@ -5,22 +5,20 @@ * https://opensource.org/licenses/MIT */ -import { getDateString, keyOf, MAX_PERIOD_ORDER, MILL_PER_PERIOD } from "@util/period" +import { getDateString, keyOf } from "@util/period" import BaseDatabase from "./common/base-database" import { REMAIN_WORD_PREFIX } from "./common/constant" -type DailyResult = { - /** - * order => milliseconds of focus - */ - [minuteOrder: number]: number -} +/** + * order => milliseconds of focus + */ +type DailyResult = Record const KEY_PREFIX = REMAIN_WORD_PREFIX + 'PERIOD' const KEY_PREFIX_LENGTH = KEY_PREFIX.length const generateKey = (date: string) => KEY_PREFIX + date -function merge(exists: { [dateKey: string]: DailyResult }, toMerge: timer.period.Result[]) { +function merge(exists: { [dateKey: string]: DailyResult }, toMerge: tt4b.period.Result[]) { toMerge.forEach(period => { const { order, milliseconds } = period const key = generateKey(getDateString(period)) @@ -31,8 +29,8 @@ function merge(exists: { [dateKey: string]: DailyResult }, toMerge: timer.period }) } -function db2PeriodInfos(data: { [dateKey: string]: DailyResult }): timer.period.Result[] { - const result: timer.period.Result[] = [] +function db2PeriodInfos(data: { [dateKey: string]: DailyResult }): tt4b.period.Result[] { + const result: tt4b.period.Result[] = [] Object.entries(data).forEach((([dateKey, val]) => { const dateStr = dateKey.substring(KEY_PREFIX_LENGTH) const date = new Date( @@ -40,18 +38,18 @@ function db2PeriodInfos(data: { [dateKey: string]: DailyResult }): timer.period. Number.parseInt(dateStr.substring(4, 6)) - 1, Number.parseInt(dateStr.substring(6, 8)) ) - Object - .entries(val) - .forEach(([order, milliseconds]) => result.push({ - ...keyOf(date, Number.parseInt(order)), - milliseconds - })) + Object.entries(val).forEach(([order, milliseconds]) => result.push({ + ...keyOf(date, Number.parseInt(order)), + milliseconds, + })) })) return result } - /** * @since v0.2.1 + * @deprecated + * Starting with v4, all timeline data will be stored in IndexedDB, and periodic results will be queried using the timeline database. + * Therefore, this will be removed one year after the release of version 4 (around 2027-04-01) */ class PeriodDatabase extends BaseDatabase { @@ -61,7 +59,7 @@ class PeriodDatabase extends BaseDatabase { return result || {} } - async accumulate(items: timer.period.Result[]): Promise { + async accumulate(items: tt4b.period.Result[]): Promise { const dates = Array.from(new Set(items.map(getDateString))) const exists = await this.getBatch0(dates) merge(exists, items) @@ -80,7 +78,7 @@ class PeriodDatabase extends BaseDatabase { return this.storage.get(keys) } - async getBatch(dates: string[]): Promise { + async getBatch(dates: string[]): Promise { const data = await this.getBatch0(dates) return db2PeriodInfos(data) } @@ -89,7 +87,7 @@ class PeriodDatabase extends BaseDatabase { * @since 1.0.0 * @returns all period items */ - async getAll(): Promise { + async getAll(): Promise { const allItems = await this.storage.get() const periodItems: { [dateKey: string]: DailyResult } = {} Object.entries(allItems) @@ -103,35 +101,6 @@ class PeriodDatabase extends BaseDatabase { await this.storage.remove(keys) } - async importData(data: any): Promise { - if (typeof data !== "object") return - const items = await this.storage.get() - const keyReg = new RegExp(`^${KEY_PREFIX}20\\d{2}[01]\\d[0-3]\\d$`) - const toSave: Record = {} - Object.entries(data) - .filter(([key]) => keyReg.test(key)) - .forEach(([key, value]) => toSave[key] = migrate(items[key], value as _Value)) - this.storage.set(toSave) - } -} - -type _Value = { [key: string]: number } - -function migrate(exist: _Value | undefined, toMigrate: _Value) { - const result: _Value = exist || {} - Object.entries(toMigrate) - .filter(([key]) => /^\d{1,2}$/.test(key)) - .forEach(([key, value]) => { - const index = Number.parseInt(key) - if (index < 0 || index > MAX_PERIOD_ORDER) return - let mills: number = (result[key] || 0) + (typeof value === "number" ? value : parseInt(value || "0")) - if (isNaN(mills) || mills <= 0) return - if (mills > MILL_PER_PERIOD) { - mills = MILL_PER_PERIOD - } - result[key] = mills - }) - return result } const periodDatabase = new PeriodDatabase() diff --git a/src/background/database/site-database.ts b/src/background/database/site-database.ts new file mode 100644 index 000000000..0bdaf4d1a --- /dev/null +++ b/src/background/database/site-database.ts @@ -0,0 +1,141 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { toMap } from '@util/array' +import { CATE_NOT_SET_ID } from "@util/site" +import BaseDatabase from "./common/base-database" +import { REMAIN_WORD_PREFIX } from "./common/constant" + +type _Entry = { + /** + * Alias + */ + a?: string + /** + * Icon url + */ + i?: string + /** + * Category ID + */ + c?: number + /** + * Count run time + */ + r?: boolean +} + +const DB_KEY_PREFIX = REMAIN_WORD_PREFIX + 'SITE_' +const HOST_KEY_PREFIX = DB_KEY_PREFIX + 'h' +const VIRTUAL_KEY_PREFIX = DB_KEY_PREFIX + 'v' +const MERGED_FLAG = 'm' + +function cvt2Key({ host, type }: tt4b.site.SiteKey): string { + switch (type) { + case 'virtual': return VIRTUAL_KEY_PREFIX + host + case 'merged': return HOST_KEY_PREFIX + MERGED_FLAG + host + case 'normal': return HOST_KEY_PREFIX + '_' + host + } +} + +function cvt2SiteKey(key: string): tt4b.site.SiteKey { + if (key.startsWith(VIRTUAL_KEY_PREFIX)) { + return { + host: key.substring(VIRTUAL_KEY_PREFIX.length), + type: 'virtual', + } + } else if (key.startsWith(HOST_KEY_PREFIX)) { + return { + host: key.substring(HOST_KEY_PREFIX.length + 1), + type: key.charAt(HOST_KEY_PREFIX.length) === MERGED_FLAG ? 'merged' : 'normal', + } + } else { + // Can't go there + return { host: key, type: 'normal' } + } +} + +function cvt2Entry({ alias, iconUrl, cate, run }: tt4b.site.SiteInfo): _Entry { + const entry: _Entry = { i: iconUrl } + alias && (entry.a = alias) + cate && (entry.c = cate) + run && (entry.r = true) + entry.i = iconUrl + return entry +} + +function cvt2SiteInfo(key: tt4b.site.SiteKey, entry: _Entry | undefined): tt4b.site.SiteInfo { + const { a, i, c, r } = entry ?? {} + const siteInfo: tt4b.site.SiteInfo = { ...key } + siteInfo.alias = a + siteInfo.cate = c ?? CATE_NOT_SET_ID + siteInfo.iconUrl = i + siteInfo.run = !!r + return siteInfo +} + +function buildFilter(condition?: tt4b.site.Query): (site: tt4b.site.SiteInfo) => boolean { + const { fuzzyQuery, cateIds, types } = condition || {} + let cateFilter = typeof cateIds === 'number' ? [cateIds] : (cateIds?.length ? cateIds : undefined) + let typeFilter = typeof types === 'string' ? [types] : (types?.length ? types : undefined) + return site => { + const { host: siteHost, alias: siteAlias, cate, type } = site || {} + if (fuzzyQuery && !(siteHost?.includes(fuzzyQuery) || siteAlias?.includes(fuzzyQuery))) return false + if (cateFilter && (!cateFilter.includes(cate ?? CATE_NOT_SET_ID) || type !== 'normal')) return false + if (typeFilter && !typeFilter.includes(type)) return false + return true + } +} + +class SiteDatabase extends BaseDatabase { + async select(condition?: tt4b.site.Query): Promise { + const filter = buildFilter(condition) + const data = await this.storage.get() + return Object.entries(data) + .filter(([key]) => key.startsWith(DB_KEY_PREFIX)) + .map(([key, value]) => cvt2SiteInfo(cvt2SiteKey(key), value as _Entry)) + .filter(filter) + } + + /** + * Get by key + * + * @returns site info, or undefined + */ + async get(key: tt4b.site.SiteKey): Promise { + const entry = await this.storage.getOne<_Entry>(cvt2Key(key)) + return entry && cvt2SiteInfo(key, entry) + } + + async getBatch(keys: tt4b.site.SiteKey[]): Promise { + const result = await this.storage.get(keys.map(cvt2Key)) + return Object.entries(result) + .map(([key, value]) => cvt2SiteInfo(cvt2SiteKey(key), value as _Entry)) + } + + async save(...sites: tt4b.site.SiteInfo[]): Promise { + if (!sites.length) return + const toSet = toMap(sites, cvt2Key, cvt2Entry) + await this.storage.set(toSet) + } + + async remove(siteKeys: tt4b.site.SiteKey[]): Promise { + if (!siteKeys.length) return + const keys = siteKeys.map(cvt2Key) + await this.storage.remove(keys) + } + + async exist(siteKey: tt4b.site.SiteKey): Promise { + const key = cvt2Key(siteKey) + const entry = await this.storage.getOne<_Entry>(key) + return !!entry + } +} + +const siteDatabase = new SiteDatabase() + +export default siteDatabase \ No newline at end of file diff --git a/src/background/database/stat-database/classic.ts b/src/background/database/stat-database/classic.ts new file mode 100644 index 000000000..9898869b9 --- /dev/null +++ b/src/background/database/stat-database/classic.ts @@ -0,0 +1,273 @@ +import { log } from '@/common/logger' +import { escapeRegExp } from '@util/pattern' +import { isNotZeroResult } from '@util/stat' +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 type { StatCondition, StatDatabase } from './types' + +/** + * Generate the key in local storage by host and date + * + * @param host host + * @param date date + */ +const generateKey = (host: string, date: Date | string) => formatDateStr(date) + host +const generateHostReg = (host: string): RegExp => RegExp(`^\\d{8}${escapeRegExp(host)}$`) + +const generateGroupKey = (groupId: number, date: Date | string) => formatDateStr(date) + cvtGroupId2Host(groupId) +const generateGroupReg = (groupId: number): RegExp => RegExp(`^\\d{8}${escapeRegExp(cvtGroupId2Host(groupId))}$`) + +const isPartialResult = createObjectGuard>({ + focus: isOptionalInt, + time: isOptionalInt, + run: isOptionalInt, +}) + +function filterRow(row: 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]) +} + +/** + * Default implementation by `chrome.storage.local` + */ +export class ClassicStatDatabase extends BaseDatabase implements StatDatabase { + + async refresh(): Promise<{ [key: string]: unknown }> { + const result = await this.storage.get() + const items: Record = {} + Object.entries(result) + .filter(([key]) => !key.startsWith(REMAIN_WORD_PREFIX)) + .forEach(([key, value]) => items[key] = value) + return items + } + + /** + * @param host host + * @since 0.1.3 + */ + accumulate(host: string, date: Date | string, item: tt4b.core.Result): Promise { + const key = generateKey(host, date) + return this.accumulateInner(key, item) + } + + /** + * @param host host + * @since 0.1.3 + */ + accumulateGroup(groupId: number, date: Date | string, item: tt4b.core.Result): Promise { + const key = generateGroupKey(groupId, date) + return this.accumulateInner(key, item) + } + + private async accumulateInner(key: string, item: tt4b.core.Result): Promise { + const exist = await this.storage.getOne(key) + const value = increase(item, exist) + await this.setByKey(key, value) + return value + } + + /** + * Batch accumulate + * + * @param data data: {host=>waste_per_day} + * @param date date + * @since 0.1.8 + */ + async batchAccumulate(data: Record, date: Date | string): Promise> { + const hosts = Object.keys(data) + if (!hosts.length) return {} + const dateStr = formatDateStr(date) + const keys: { [host: string]: string } = {} + hosts.forEach(host => keys[host] = generateKey(host, dateStr)) + + const items = await this.storage.get(Object.values(keys)) + + const toUpdate: Record = {} + const afterUpdated: Record = {} + Object.entries(keys).forEach(([host, key]) => { + const item = data[host] + if (!item) return + const exist = increase(item, items[key] as tt4b.core.Result | undefined) + toUpdate[key] = afterUpdated[host] = exist + }) + await this.storage.set(toUpdate) + return afterUpdated + } + + /** + * Filter by query parameters + */ + private async filter(condition?: StatCondition, onlyGroup?: boolean): Promise { + const cond = processCondition(condition ?? {}) + const items = await this.refresh() + const result: tt4b.core.Row[] = [] + Object.entries(items).forEach(([key, value]) => { + const date = key.substring(0, 8) + let host = key.substring(8) + if (onlyGroup) { + if (host.startsWith(GROUP_PREFIX)) { + host = host.substring(GROUP_PREFIX.length) + } else { + return + } + } else if (host.startsWith(GROUP_PREFIX)) { + return + } + const { focus, time, run } = value as tt4b.core.Result + const row: tt4b.core.Row = { host, date, focus, time } + run !== undefined && (row.run = run) + filterRow(row, cond) && result.push(row) + }) + return result + } + + /** + * Select + * + * @param condition condition + */ + async select(condition?: StatCondition): Promise { + log("select:{condition}", condition) + return this.filter(condition) + } + + async selectGroup(condition?: StatCondition): Promise { + return this.filter(condition, true) + } + + /** + * Get by host and date + * + * @since 0.0.5 + */ + async get(host: string, date: Date | string): Promise { + const key = generateKey(host, date) + const exist = await this.storage.getOne(key) + const result = exist ?? zeroResult() + return { host, date: formatDateStr(date), ...result } + } + + async batchSelect(keys: tt4b.core.RowKey[]): Promise { + if (!keys.length) return [] + const storageKeys = keys.map(({ host, date }) => generateKey(host, date)) + const items = await this.storage.get>(storageKeys) + return keys.map(({ host, date }, i) => { + const sk = storageKeys[i] + const exist = sk ? items[sk] : undefined + const result = exist ?? zeroResult() + return { host, date, ...result } + }) + } + + /** + * Delete by key + * + * @param rows site rows, the host and date mustn't be null + * @since 0.0.9 + */ + async delete(...rows: tt4b.core.RowKey[]): Promise { + const keys: string[] = rows.map(({ host, date }) => generateKey(host, date)) + return this.storage.remove(keys) + } + + async deleteGroup(...rows: [groupId: number, date: string][]): Promise { + const keys: string[] = rows.map(([groupId, date]) => generateGroupKey(groupId, date)) + return this.storage.remove(keys) + } + + /** + * Force update data + * + * @since 1.4.3 + */ + forceUpdate(...rows: tt4b.core.Row[]): Promise { + const toSet = Object.fromEntries(rows.map(({ host, date, time, focus, run }) => { + const key = generateKey(host, date) + const result: tt4b.core.Result = { time, focus } + run && (result.run = run) + return [key, result] + })) + + return this.storage.set(toSet) + } + + forceUpdateGroup(...rows: tt4b.core.Row[]): Promise { + const toSet = Object.fromEntries(rows.map(({ host, date, time, focus, run }) => { + const key = generateGroupKey(Number(host), date) + const result: tt4b.core.Result = { time, focus } + run && (result.run = run) + return [key, result] + })) + + return this.storage.set(toSet) + } + + /** + * @param host host + * @param range [start date (inclusive), end date (inclusive)] + * @returns [dates] + * @since 0.0.7 + */ + async deleteByHost(host: string, range?: string | [string?, string?]): Promise { + const [start, end] = Array.isArray(range) ? range : [range, range] + if (start && start === end) { + // Delete one day + const key = generateKey(host, start) + await this.storage.remove(key) + return + } + + const dateFilter = (date: string) => (start ? start <= date : true) && (end ? date <= end : true) + const items = await this.refresh() + + // Key format: 20201112www.google.com + const keyReg = generateHostReg(host) + const keys: string[] = Object.keys(items) + .filter(key => keyReg.test(key) && dateFilter(key.substring(0, 8))) + + await this.storage.remove(keys) + } + + async deleteByGroup(groupId: number, range?: string | [string, string]): Promise { + const [start, end] = Array.isArray(range) ? range : [range, range] + const startStr = start && formatDateStr(start) + const endStr = end && formatDateStr(end) + const dateFilter = (date: string) => (startStr ? startStr <= date : true) && (endStr ? date <= endStr : true) + const items = await this.refresh() + + const keyReg = generateGroupReg(groupId) + const keys: string[] = Object.keys(items).filter(key => keyReg.test(key) && dateFilter(key.substring(0, 8))) + + await this.storage.remove(keys) + } +} + +/** + * Legacy data extract + * + * @deprecated since 4.0.0, legacy data is not supported for export, this method will be removed in future versions + */ +export function parseImportData(data: unknown): tt4b.core.Row[] { + if (typeof data !== "object" || data === null) return [] + const rows: tt4b.core.Row[] = [] + Object.entries(data) + .filter(([key]) => /^20\d{2}[01]\d[0-3]\d.*/.test(key) && !key.substring(8).startsWith(GROUP_PREFIX)) + .forEach(([key, value]) => { + if (typeof value !== "object") return + if (!isPartialResult(value)) return + const date = key.substring(0, 8) + const host = key.substring(8) + const row: tt4b.core.Row = { host, date, focus: value.focus ?? 0, time: value.time ?? 0 } + isNotZeroResult(row) && rows.push(row) + }) + return rows +} \ No newline at end of file diff --git a/src/background/database/stat-database/common.ts b/src/background/database/stat-database/common.ts new file mode 100644 index 000000000..deea68a82 --- /dev/null +++ b/src/background/database/stat-database/common.ts @@ -0,0 +1,24 @@ +import { formatTimeYMD } from '@util/time' + +export const GROUP_PREFIX = "_g_" + +export const cvtGroupId2Host = (groupId: number): string => `${GROUP_PREFIX}${groupId}` + +export const formatDateStr = (date: string | Date): string => { + if (typeof date === 'string') return date + return formatTimeYMD(date) +} + +export const zeroResult = (): tt4b.core.Result => ({ focus: 0, time: 0 }) + +export const zeroRow = (host: string, date: string): tt4b.core.Row => ({ host, date, focus: 0, time: 0 }) + +export const increase = (a: tt4b.core.Result, b: tt4b.core.Result | undefined) => { + const res: tt4b.core.Result = { + focus: a.focus + (b?.focus ?? 0), + time: a.time + (b?.time ?? 0), + } + const run = (a.run ?? 0) + (b?.run ?? 0) + run && (res.run = run) + return res +} diff --git a/src/background/database/stat-database/condition.ts b/src/background/database/stat-database/condition.ts new file mode 100644 index 000000000..19e70f918 --- /dev/null +++ b/src/background/database/stat-database/condition.ts @@ -0,0 +1,69 @@ +import { judgeVirtualFast } from "@util/pattern" +import type { StatCondition } from './types' + +export type ProcessedCondition = StatCondition & { + useExactDate?: boolean + exactDateStr?: string + startDateStr?: string + endDateStr?: string + timeStart?: number + timeEnd?: number + focusStart?: number + focusEnd?: number +} + +export function filterHost(host: string, keys: ProcessedCondition['keys'], virtual?: boolean): boolean { + if (!virtual && judgeVirtualFast(host)) return false + if (keys === undefined) return true + return typeof keys === 'string' ? host === keys : keys.includes(host) +} + +export function filterDate( + date: string, + { useExactDate, exactDateStr, startDateStr, endDateStr }: ProcessedCondition +): boolean { + if (useExactDate) { + if (exactDateStr !== date) return false + } else { + if (startDateStr && startDateStr > date) return false + if (endDateStr && endDateStr < date) return false + } + return true +} + +export function filterNumberRange(val: number, [start, end]: [start?: number, end?: number]): boolean { + if (start !== null && start !== undefined && start > val) return false + if (end !== null && end !== undefined && end < val) return false + return true +} + +export function processCondition(condition?: StatCondition): ProcessedCondition { + const result: ProcessedCondition = { ...condition } + + const paramDate = condition?.date + if (paramDate) { + if (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]) + } + + return result +} diff --git a/src/background/database/stat-database/idb.ts b/src/background/database/stat-database/idb.ts new file mode 100644 index 000000000..882a68b9b --- /dev/null +++ b/src/background/database/stat-database/idb.ts @@ -0,0 +1,335 @@ +import { BaseIDBStorage, closedRangeKey, IndexResult, iterateCursor, type Key, req2Promise, type Table } from '../common/indexed-storage' +import { cvtGroupId2Host, formatDateStr, increase, zeroRow } from './common' +import { filterDate, filterHost, filterNumberRange, processCondition, type ProcessedCondition } from './condition' +import type { StatCondition, StatDatabase } from './types' + +type StoredRow = tt4b.core.Row & { + // If present, this is a group row + groupId?: number +} + +const INDEXES: (Key | Key[])[] = [ + 'date', 'host', 'groupId', + 'focus', 'time', + ['date', 'host'], +] as const + +const isGroup = (row: StoredRow): boolean => row.groupId !== undefined + +type IndexCoverage = { + date?: boolean + host?: boolean + time?: boolean + focus?: boolean +} + +function buildFilter(cond: ProcessedCondition, coverage: IndexCoverage): (row: StoredRow) => boolean { + return (row: StoredRow) => { + if (!coverage.time && !filterNumberRange(row.time, [cond.timeStart, cond.timeEnd])) { + return false + } + + if (!coverage.focus && !filterNumberRange(row.focus, [cond.focusStart, cond.focusEnd])) { + return false + } + + if (!coverage.date && !filterDate(row.date, cond)) { + return false + } + + // Only check virtual if host keys are not fully covered by index + const keys = coverage.host ? undefined : cond.keys + if (!filterHost(row.host, keys, cond.virtual)) { + return false + } + + return true + } +} + +type StatIndex = typeof INDEXES[number] + +export class IDBStatDatabase extends BaseIDBStorage implements StatDatabase { + table: Table = 'stat' + key: StatIndex = ['date', 'host'] + indexes: StatIndex[] = INDEXES + + get(host: string, date: Date | string): Promise { + return this.withStore(async store => { + const index = super.assertIndex(store, ['date', 'host']) + const dateStr = formatDateStr(date) + const req = index.get([dateStr, host]) + return await req2Promise(req) ?? zeroRow(host, dateStr) + }, 'readonly') + } + + batchSelect(keys: tt4b.core.RowKey[]): Promise { + if (!keys.length) return Promise.resolve([]) + return this.withStore(async store => { + const index = super.assertIndex(store, ['date', 'host']) + const out: tt4b.core.Row[] = [] + for (const { host, date } of keys) { + const req = index.get([date, host]) + const row = await req2Promise(req) ?? zeroRow(host, date) + out.push(row) + } + return out + }, 'readonly') + } + + private judgeIndex(store: IDBObjectStore, cond: ProcessedCondition, expectGroup: boolean): IndexResult { + const keys = typeof cond.keys === 'string' ? [cond.keys] : cond.keys + const { + useExactDate, exactDateStr, + timeStart, timeEnd, + focusStart, focusEnd, + startDateStr, endDateStr, + } = cond + + if (expectGroup) { + const groupId = keys?.length === 1 ? parseInt(keys[0] ?? 'NaN') : NaN + return isNaN(groupId) ? { + cursorReq: this.assertIndexCursor(store, 'groupId', IDBKeyRange.lowerBound(0)), + } : { + cursorReq: this.assertIndexCursor(store, 'groupId', IDBKeyRange.only(groupId)), + coverage: { host: true } + } + } + + if (useExactDate && exactDateStr) { + return keys?.length === 1 ? { + cursorReq: this.assertIndexCursor(store, ['date', 'host'], IDBKeyRange.only([exactDateStr, keys[0]])), + coverage: { date: true, host: true } + } : { + cursorReq: this.assertIndexCursor(store, 'date', IDBKeyRange.only(exactDateStr)), + coverage: { date: true } + } + } + const dateRange = closedRangeKey(startDateStr, endDateStr) + if (dateRange) { + return { + cursorReq: this.assertIndexCursor(store, 'date', dateRange), + coverage: { date: true } + } + } + + const timeRange = closedRangeKey(timeStart, timeEnd) + if (timeRange) { + return { + cursorReq: super.assertIndexCursor(store, 'time', timeRange), + coverage: { time: true } + } + } + + const focusRange = closedRangeKey(focusStart, focusEnd) + if (focusRange) { + return { + cursorReq: super.assertIndexCursor(store, 'focus', focusRange), + coverage: { focus: true } + } + } + + return { + cursorReq: store.openCursor(), + coverage: {} + } + } + + private async selectInternal(store: IDBObjectStore, cond: ProcessedCondition, expectGroup: boolean): Promise { + const allRows: tt4b.core.Row[] = [] + const { cursorReq, coverage = {} } = this.judgeIndex(store, cond, expectGroup) + const filter = buildFilter(cond, coverage) + + const rows = await iterateCursor(cursorReq) + for (const row of rows) { + if (expectGroup !== isGroup(row)) continue + if (!filter(row)) continue + + if (expectGroup) { + allRows.push({ + host: row.groupId?.toString() ?? '', + date: row.date, + time: row.time, + focus: row.focus, + run: row.run, + }) + } else { + allRows.push(row) + } + } + + return allRows + } + + select(condition?: StatCondition): Promise { + return this.withStore(async store => { + const cond = processCondition(condition) + return this.selectInternal(store, cond, false) + }, 'readonly') + } + + accumulate(host: string, date: Date | string, item: tt4b.core.Result): Promise { + return this.withStore(async store => { + const index = super.assertIndex(store, ['date', 'host']) + const dateStr = formatDateStr(date) + const req = index.get([dateStr, host]) + const existing = await req2Promise(req) + const newVal = increase(item, existing) + const newData: StoredRow = { host, date: dateStr, ...newVal } + await req2Promise(store.put(newData)) + return newData + }, 'readwrite') + } + + batchAccumulate(data: Record, date: Date | string): Promise> { + return this.withStore(async store => { + const dateStr = formatDateStr(date) + const cursorReq = super.assertIndexCursor(store, 'date', IDBKeyRange.only(dateStr)) + const toUpdate: Record = {} + + await iterateCursor(cursorReq, cursor => { + const stored = cursor.value as StoredRow | undefined + if (!stored || isGroup(stored)) return + toUpdate[stored.host] = stored + }) + + for (const [host, result] of Object.entries(data)) { + const existing = toUpdate[host] + const newValue: tt4b.core.Row = { host, date: dateStr, ...increase(result, existing) } + toUpdate[host] = newValue + store.put(newValue) + } + return toUpdate + }, 'readwrite') + } + + delete(...rows: tt4b.core.RowKey[]): Promise { + return this.withStore(async store => { + const index = super.assertIndex(store, ['date', 'host']) + for (const { host, date } of rows) { + const dateStr = formatDateStr(date) + const req = index.getKey([dateStr, host]) + const key = await req2Promise(req) + if (key) { + await req2Promise(store.delete(key)) + } + } + }, 'readwrite') + } + + deleteByHost(host: string, range?: string | [string?, string?]): Promise { + const [start, end] = Array.isArray(range) ? range : [range, range] + return this.withStore(async store => { + if (start && start === end) { + // Delete one day + const index = super.assertIndex(store, ['date', 'host']) + const req = index.getKey([start, host]) + const key = await req2Promise(req) + if (key) { + await req2Promise(store.delete(key)) + } + return + } + + // Delete by range + const index = super.assertIndex(store, 'host') + const cursorReq = index.openCursor(IDBKeyRange.only(host)) + const deletedDates = new Set() + + await iterateCursor(cursorReq, cursor => { + const r = cursor.value as StoredRow | undefined + if (!r || isGroup(r)) return + + const dateStr = r.date + const inRange = (!start || start <= dateStr) && (!end || dateStr <= end) + if (inRange) { + cursor.delete() + deletedDates.add(dateStr) + } + }) + }, 'readwrite') + } + + deleteByGroup(groupId: number, range?: string | [string, string]): Promise { + return this.withStore(async store => { + const [start, end] = Array.isArray(range) ? range : [range, range] + const host = cvtGroupId2Host(groupId) + if (start && start === end) { + // Delete one day + const index = super.assertIndex(store, ['date', 'host']) + const req = index.getKey([start, host]) + const key = await req2Promise(req) + if (key) { + await req2Promise(store.delete(key)) + } + return + } + + // Delete by range + const index = super.assertIndex(store, 'host') + const cursorReq = index.openCursor(IDBKeyRange.only(host)) + + await iterateCursor(cursorReq, cursor => { + const r = cursor.value as StoredRow | undefined + if (!r) return + const dateStr = r.date + const inRange = (!start || start <= dateStr) && (!end || dateStr <= end) + inRange && cursor.delete() + }) + }, 'readwrite') + } + + accumulateGroup(groupId: number, date: Date | string, item: tt4b.core.Result): Promise { + return this.withStore(async store => { + const index = super.assertIndex(store, ['date', 'host']) + const dateStr = formatDateStr(date) + const host = cvtGroupId2Host(groupId) + const req = index.get([dateStr, host]) + const existing = await req2Promise(req) + const newVal = increase(item, existing) + const newData: StoredRow = { host, date: dateStr, groupId, ...newVal } + await req2Promise(store.put(newData)) + return newData + }, 'readwrite') + } + + selectGroup(condition?: StatCondition): Promise { + return this.withStore(async store => { + const cond = processCondition(condition) + return this.selectInternal(store, cond, true) + }, 'readonly') + } + + deleteGroup(...rows: [groupId: number, date: string][]): Promise { + return this.withStore(async store => { + const index = super.assertIndex(store, ['date', 'host']) + for (const [groupId, date] of rows) { + const host = cvtGroupId2Host(groupId) + const dateStr = formatDateStr(date) + const req = index.getKey([dateStr, host]) + const key = await req2Promise(req) + if (key) { + await req2Promise(store.delete(key)) + } + } + }, 'readwrite') + } + + forceUpdate(...rows: tt4b.core.Row[]): Promise { + return this.withStore(store => rows.forEach(row => store.put(row)), 'readwrite') + } + + forceUpdateGroup(...rows: tt4b.core.Row[]): Promise { + return this.withStore(store => { + for (const row of rows) { + const { host, date, time, focus, run } = row + const groupId = parseInt(host) + if (isNaN(groupId)) { + throw new Error(`Invalid group host: ${host}`) + } + const newData: StoredRow = { host, date, time, focus, run, groupId } + store.put(newData) + } + }, 'readwrite') + } +} \ No newline at end of file diff --git a/src/background/database/stat-database/index.ts b/src/background/database/stat-database/index.ts new file mode 100644 index 000000000..98a67b0f9 --- /dev/null +++ b/src/background/database/stat-database/index.ts @@ -0,0 +1,151 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { isNotZeroResult } from '@util/stat' +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' +import { ClassicStatDatabase, parseImportData } from './classic' +import { IDBStatDatabase } from './idb' +import type { StatCondition, StatDatabase } from './types' + +type StateDatabaseComposite = + & StatDatabase + & StorageMigratable<[tabs: tt4b.core.Row[], groups: tt4b.core.Row[]]> + & BrowserMigratable<'__stat__'> + +// Only `date` and `host` are required for import, other fields are optional, and will be set to default if not provided +type ValidImportRow = MakeRequired, 'date' | 'host'> + +const isValidImportRow = createObjectGuard({ + focus: isOptionalInt, + time: isOptionalInt, + run: isOptionalInt, + date: isString, + host: isString, +}) + +const isValidImportRows = createArrayGuard(isValidImportRow) + +class StatDatabaseWrapper implements StateDatabaseComposite { + namespace: '__stat__' = '__stat__' + private holder = new StorageHolder({ + classic: new ClassicStatDatabase(), + indexed_db: new IDBStatDatabase(), + }) + + get #current() { + return this.holder.current + } + + get(host: string, date: Date): Promise { + return this.#current.get(host, date) + } + + batchSelect(keys: tt4b.core.RowKey[]): Promise { + return this.#current.batchSelect(keys) + } + + select(condition?: StatCondition): Promise { + return this.#current.select(condition) + } + + accumulate(host: string, date: Date | string, item: tt4b.core.Result): Promise { + return this.#current.accumulate(host, date, item) + } + + batchAccumulate(data: Record, date: Date | string): Promise> { + return this.#current.batchAccumulate(data, date) + } + + accumulateGroup(groupId: number, date: Date | string, item: tt4b.core.Result): Promise { + return this.#current.accumulateGroup(groupId, date, item) + } + + delete(...rows: tt4b.core.RowKey[]): Promise { + return this.#current.delete(...rows) + } + + deleteByHost(host: string, range?: string | [string, string]): Promise { + return this.#current.deleteByHost(host, range) + } + + deleteByGroup(groupId: number, range?: string | [string, string]): Promise { + return this.#current.deleteByGroup(groupId, range) + } + + selectGroup(condition?: StatCondition): Promise { + return this.#current.selectGroup(condition) + } + + deleteGroup(...rows: [groupId: number, date: string][]): Promise { + return this.#current.deleteGroup(...rows) + } + + forceUpdate(...rows: tt4b.core.Row[]): Promise { + return this.#current.forceUpdate(...rows) + } + + forceUpdateGroup(...rows: tt4b.core.Row[]): Promise { + return this.#current.forceUpdateGroup(...rows) + } + + async migrateStorage(type: tt4b.option.StorageType): Promise<[tt4b.core.Row[], tt4b.core.Row[]]> { + const target = this.holder.get(type) + if (!target) return [[], []] + const tabs = await this.select({ virtual: true }) + await target.forceUpdate(...tabs) + const groups = await this.selectGroup() + await target.forceUpdateGroup(...groups) + return [tabs, groups] + } + + async afterStorageMigrated([tabs, groups]: [tt4b.core.Row[], tt4b.core.Row[]]): Promise { + await this.#current.delete(...tabs) + const groupKeys = groups.map(({ host, date }) => [parseInt(host), date] satisfies [number, string]) + await this.#current.deleteGroup(...groupKeys) + } + + async importData(data: unknown): Promise { + const rows = this.parseImportRows(data) + await this.#current.forceUpdate(...rows) + } + + async exportData(): Promise { + return this.#current.select({ virtual: true }) + } + + private parseImportRows(data: unknown): tt4b.core.Row[] { + if (!isExportData(data)) return [] + if (isLegacyVersion(data)) { + return parseImportData(data) ?? [] + } + + if (!(this.namespace in data)) return [] + + const nsData = extractNamespace(data, this.namespace, isValidImportRows) ?? [] + const rows: tt4b.core.Row[] = [] + for (const item of nsData) { + const row: tt4b.core.Row = { + host: item.host, + date: item.date, + time: item.time ?? 0, + focus: item.focus ?? 0, + run: item.run ?? 0, + } + isNotZeroResult(row) && rows.push(row) + } + return rows + } +} + +const statDatabase: StateDatabaseComposite = new StatDatabaseWrapper() + +export default statDatabase + +export * from "./types" diff --git a/src/background/database/stat-database/types.ts b/src/background/database/stat-database/types.ts new file mode 100644 index 000000000..a032c138f --- /dev/null +++ b/src/background/database/stat-database/types.ts @@ -0,0 +1,73 @@ + +export type StatCondition = { + /** + * Date + * {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 + * + * @since 1.6.1 + */ + virtual?: boolean + /** + * Host or groupId, full match + */ + keys?: string[] | string +} + +export interface StatDatabase { + get(host: string, date: Date): Promise + batchSelect(keys: tt4b.core.RowKey[]): Promise + select(condition?: StatCondition): Promise + /** + * Accumulate data + */ + accumulate(host: string, date: Date | string, item: tt4b.core.Result): Promise + batchAccumulate(data: Record, date: Date | string): Promise> + delete(...rows: tt4b.core.RowKey[]): Promise + /** + * Delete by host + * + * @param host host + * @param range date range, inclusive start and end, if null, delete all + * @return dates to deleted + */ + deleteByHost(host: string, range?: string | [string?, string?]): Promise + /** + * Delete group data + * + * @param groupId the id of group + * @param range date range, inclusive start and end, if null, delete all + */ + deleteByGroup(groupId: number, range?: string | [string?, string?]): Promise + /******* GROUP *******/ + /** + * Accumulate data for tab group + */ + accumulateGroup(groupId: number, date: Date | string, item: tt4b.core.Result): Promise + selectGroup(condition?: StatCondition): Promise + deleteGroup(...rows: [groupId: number, date: string][]): Promise + /** + * Force update data with overwriting + */ + forceUpdate(...rows: tt4b.core.Row[]): Promise + + /** + * Force update group data with overwriting + */ + forceUpdateGroup(...rows: tt4b.core.Row[]): Promise +} \ No newline at end of file diff --git a/src/background/database/timeline-database.ts b/src/background/database/timeline-database.ts new file mode 100644 index 000000000..23536ef47 --- /dev/null +++ b/src/background/database/timeline-database.ts @@ -0,0 +1,141 @@ +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' + +type TimelineCondition = { + host?: string + /** + * Start time in milliseconds, inclusive + */ + start?: number +} + +const TIME_LIFE_CYCLE = MILL_PER_DAY * 366 + +// If two tick with the same host is near 1 sec, then merge them to one +const MERGE_THRESHOLD = MILL_PER_SECOND + +const canMerge = (exist: tt4b.timeline.Tick, tick: tt4b.timeline.Tick) => { + const { start: existStart, host: existHost } = exist + const { start, host } = tick + return existHost === host && start >= existStart && start <= existStart + MERGE_THRESHOLD +} + +const isConflict = (item: tt4b.timeline.Tick, tick: tt4b.timeline.Tick) => { + const { start: itemStart, duration: itemDuration } = item + const { start } = tick + return itemStart <= start && start < itemStart + itemDuration +} + +type IndexCoverage = { + host?: boolean + start?: boolean +} + +class CleanThrottle { + private lastTime = 0 + private readonly interval: number = MILL_PER_DAY + + tryClean(doClean: () => void): void { + const now = Date.now() + if (now - this.lastTime >= this.interval) { + this.lastTime = now + doClean() + } + } +} + +class TimelineDatabase extends BaseIDBStorage { + indexes: Index[] = [ + 'host', 'start', + ] + key: Key | Key[] = ['host', 'start'] + table: Table = 'timeline' + private cleanThrottle = new CleanThrottle() + + batchSave(ticks: tt4b.timeline.Tick[]): Promise { + return this.withStore(async store => { + const index = this.assertIndex(store, 'host') + const hosts = Array.from(new Set(ticks.map(tick => tick.host))) + + // Fetch existing records for all hosts + const existByHost = new Map() + await Promise.all(hosts.map(async host => { + const req = index.getAll(IDBKeyRange.only(host)) + const exist = await req2Promise(req) + exist && existByHost.set(host, exist) + })) + + const toSave: tt4b.timeline.Tick[] = [] + const toDelete: tt4b.timeline.Tick[] = [] + ticks.forEach(tick => { + const existForHost = existByHost.get(tick.host) ?? [] + + // Check if there's any conflict + const anyConflict = existForHost.some(exist => isConflict(exist, tick)) + if (anyConflict) return + + // Find a record that can be merged + const mergeTarget = existForHost.find(exist => canMerge(exist, tick)) + if (mergeTarget) { + toDelete.push(mergeTarget) + const { host, start, duration } = tick + const newStart = Math.min(mergeTarget.start, start) + const newEnd = Math.max(mergeTarget.start + mergeTarget.duration, start + duration) + const newDuration = newEnd - newStart + toSave.push({ host, start: newStart, duration: newDuration }) + } else { + // No conflict and no merge, save the new tick + toSave.push(tick) + } + }) + toDelete.forEach(tick => store.delete([tick.host, tick.start])) + toSave.forEach(tick => store.put(tick)) + }, 'readwrite') + } + + async select(cond?: TimelineCondition): Promise { + const rows = await this.withStore(async store => { + const { cursorReq, coverage = {} } = this.#judgeIndex(store, cond) + const rows = await iterateCursor(cursorReq) + const { start: cs, host: ch } = cond ?? {} + return rows.filter(tick => { + const { host, start } = tick + if (cs && !coverage.start && start < cs) return false + if (ch && !coverage.host && host !== ch) return false + return true + }) + }, 'readonly') + + // Cleanup outdated ticks periodically + this.cleanThrottle.tryClean(() => this.withStore(store => { + const index = this.assertIndex(store, 'start') + const req = index.openCursor(IDBKeyRange.upperBound(Date.now() - TIME_LIFE_CYCLE, true)) + iterateCursor(req, cursor => { cursor.delete() }) + }, 'readwrite').catch(e => console.error('Failed to cleanup outdated ticks', e))) + return rows + } + + #judgeIndex(store: IDBObjectStore, cond?: TimelineCondition): IndexResult { + const { host, start } = cond ?? {} + if (host) { + return { + cursorReq: this.assertIndexCursor(store, 'host', IDBKeyRange.only(host)), + coverage: { host: true }, + } + } else if (start !== undefined && start > 0) { + return { + cursorReq: this.assertIndexCursor(store, 'start', IDBKeyRange.lowerBound(start, false)), + coverage: { start: true }, + } + } else { + return { cursorReq: store.openCursor() } + } + } +} + +const timelineDatabase = new TimelineDatabase() + +export default timelineDatabase \ No newline at end of file diff --git a/src/background/database/types.d.ts b/src/background/database/types.d.ts new file mode 100644 index 000000000..5abf2b8c6 --- /dev/null +++ b/src/background/database/types.d.ts @@ -0,0 +1,35 @@ +/** + * Migrate data among storages (chrome.storage.local / IndexedDB) + * + * @since 4.0.0 + */ +export interface StorageMigratable { + /** + * Migrate data to target storage + * + * NOTE: MUST NOT change the inner storage type + * + * @param type the type of target storage + */ + migrateStorage(type: tt4b.option.StorageType): Promise + /** + * Handler after migration finished. Clean the old data here + * + * @param allData + */ + afterStorageMigrated(allData: AllData): Promise +} + +export type BrowserMigratableNamespace = keyof Omit + +/** + * Migrate data among browsers (export / import) + */ +export interface BrowserMigratable { + /** + * The name space for migration + */ + namespace: N + exportData(): Promise[N]> + importData(data: unknown): Promise +} \ No newline at end of file diff --git a/src/database/whitelist-database.ts b/src/background/database/whitelist-database.ts similarity index 54% rename from src/database/whitelist-database.ts rename to src/background/database/whitelist-database.ts index 47aff8c4a..afc61f656 100644 --- a/src/database/whitelist-database.ts +++ b/src/background/database/whitelist-database.ts @@ -5,10 +5,14 @@ * https://opensource.org/licenses/MIT */ +import { isStringArray } from 'typescript-guard' import BaseDatabase from "./common/base-database" import { WHITELIST_KEY } from "./common/constant" +import { extractNamespace, isExportData, isLegacyVersion } from './common/migratable' +import type { BrowserMigratable } from './types' -class WhitelistDatabase extends BaseDatabase { +class WhitelistDatabase extends BaseDatabase implements BrowserMigratable<'__whitelist__'> { + namespace: '__whitelist__' = '__whitelist__' private async update(toUpdate: string[]): Promise { await this.setByKey(WHITELIST_KEY, toUpdate || []) @@ -19,6 +23,10 @@ class WhitelistDatabase extends BaseDatabase { 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 @@ -36,28 +44,28 @@ class WhitelistDatabase extends BaseDatabase { return exist?.includes(white) } + async importData(data: unknown): Promise { + if (!isExportData(data)) return + const toImport = isLegacyVersion(data) + ? this.parseLegacyData(data) + : extractNamespace(data, this.namespace, isStringArray) + + const exist = await this.selectAll() + toImport?.forEach(white => !exist.includes(white) && exist.push(white)) + + await this.update(exist) + } + /** - * Add listener to listen changes - * - * @since 0.1.9 + * @deprecated Only for legacy data, will be removed in future version */ - addChangeListener(listener: (whitelist: string[]) => void) { - const storageListener = ( - changes: { [key: string]: chrome.storage.StorageChange }, - _areaName: chrome.storage.AreaName, - ) => { - const changeInfo = changes[WHITELIST_KEY] - changeInfo && listener(changeInfo.newValue || []) - } - chrome.storage.onChanged.addListener(storageListener) + private parseLegacyData(data: tt4b.backup.ExportData): string[] { + const toMigrate = (data as any)[WHITELIST_KEY] + return isStringArray(toMigrate) ? toMigrate : [] } - async importData(data: any): Promise { - const toMigrate = data[WHITELIST_KEY] - if (!Array.isArray(toMigrate)) return - const exist = await this.selectAll() - toMigrate.forEach(white => !exist.includes(white) && exist.push(white)) - await this.update(exist) + exportData(): Promise { + return this.selectAll() } } diff --git a/src/background/i18n.ts b/src/background/i18n.ts new file mode 100644 index 000000000..3d972d8d4 --- /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) +} \ No newline at end of file diff --git a/src/background/icon-and-alias-collector.ts b/src/background/icon-and-alias-collector.ts deleted file mode 100644 index c3a65cc69..000000000 --- a/src/background/icon-and-alias-collector.ts +++ /dev/null @@ -1,51 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { getTab } from "@api/chrome/tab" -import siteService from "@service/site-service" -import { IS_ANDROID, IS_CHROME, IS_SAFARI } from "@util/constant/environment" -import { extractHostname, isBrowserUrl, isHomepage } from "@util/pattern" -import { extractSiteName } from "@util/site" - -function isUrl(title: string) { - return title.startsWith('https://') || title.startsWith('http://') || title.startsWith('ftp://') -} - -async function collectAlias(key: timer.site.SiteKey, tabTitle: string) { - if (!tabTitle) return - if (isUrl(tabTitle)) return - const siteName = extractSiteName(tabTitle, key.host) - siteName && await siteService.saveAlias(key, siteName, true) -} - -/** - * Process the tab - */ -async function processTabInfo(tab: ChromeTab): Promise { - let { favIconUrl, url, title } = tab - if (!url || !title) return - if (isBrowserUrl(url)) return - const hostInfo = extractHostname(url) - const host = hostInfo.host - if (!host) return - // localhost hosts with Chrome use cache, so keep the favIcon url undefined - IS_CHROME && /^localhost(:.+)?/.test(host) && (favIconUrl = undefined) - const siteKey: timer.site.SiteKey = { host, type: 'normal' } - favIconUrl && await siteService.saveIconUrl(siteKey, favIconUrl) - !isBrowserUrl(url) - && isHomepage(url) - && await collectAlias(siteKey, title) -} - -/** - * Collect the favicon of host - */ -export const collectIconAndAlias = async (tabId: number) => { - if (IS_SAFARI || IS_ANDROID) return - const tab = await getTab(tabId) - tab && processTabInfo(tab) -} \ No newline at end of file diff --git a/src/background/index.ts b/src/background/index.ts index bcf2d4337..8aa6060e7 100644 --- a/src/background/index.ts +++ b/src/background/index.ts @@ -5,32 +5,25 @@ * https://opensource.org/licenses/MIT */ -import { listTabs } from "@api/chrome/tab" -import { isNoneWindowId, onNormalWindowFocusChanged } from "@api/chrome/window" -import optionHolder from "@service/components/option-holder" -import { isBrowserUrl } from "@util/pattern" -import { openLog } from "../common/logger" -import ActiveTabListener from "./active-tab-listener" -import BackupScheduler from "./backup-scheduler" -import badgeTextManager from "./badge-manager" -import initBrowserAction from "./browser-action-manager" +import { trySendMsg2Tab } from "@api/chrome/tab" +import { initBrowserAction, initSidePanel } from './action' +import badgeManager from "./badge-manager" import initCsHandler from "./content-script-handler" import initDataCleaner from "./data-cleaner" -import handleInstall from "./install-handler" +import { initAfterInstalled } from './install-handler' import initLimitProcessor from "./limit-processor" import MessageDispatcher from "./message-dispatcher" -import VersionMigrator from "./migrator" -import initSidePanel from "./side-panel" +import { initScheduler } from './scheduler' +import TabListener from './tab-listener' import initTrackServer from "./track-server" import initWhitelistMenuManager from "./whitelist-menu-manager" -// Open the log of console -openLog() +initAfterInstalled() -// Init side panel +// Initialize side panel initSidePanel() -// Init browser action +// Initialize context menu and icon action initBrowserAction() // Init data cleaner @@ -47,38 +40,20 @@ initCsHandler(messageDispatcher) // Start server initTrackServer(messageDispatcher) -// Process version -new VersionMigrator().init() - -// Backup scheduler -new BackupScheduler().init() +// scheduler +initScheduler() // Manage the context menus initWhitelistMenuManager() // Badge manager -badgeTextManager.init(messageDispatcher) - -// Listen to tab active changed -new ActiveTabListener() - .register(({ url, tabId }) => badgeTextManager.updateFocus({ url, tabId })) - .listen() +badgeManager.init(messageDispatcher) -handleInstall() +// Listen to tab changed +new TabListener() + .onActivated(({ url, tabId }) => badgeManager.updateFocus({ url, tabId })) + .onUpdated((tabId, { audible }) => audible !== undefined && trySendMsg2Tab(tabId, 'syncAudible', audible)) + .start() // Start message dispatcher messageDispatcher.start() - -// Listen window focus changed -onNormalWindowFocusChanged(async windowId => { - if (isNoneWindowId(windowId)) return - const tabs = await listTabs({ windowId, active: true }) - tabs.forEach(tab => { - const { url, id: tabId } = tab - if (!url || isBrowserUrl(url) || !tabId) return - badgeTextManager.updateFocus({ url, tabId }) - }) -}) - -// listen permission change event -optionHolder.listenPermChange() \ No newline at end of file diff --git a/src/background/install-handler/index.ts b/src/background/install-handler/index.ts index de3c44b55..68bdc7433 100644 --- a/src/background/install-handler/index.ts +++ b/src/background/install-handler/index.ts @@ -1,33 +1,42 @@ -import { onInstalled } from "@api/chrome/runtime" +import { locale } from '@/i18n' +import { onInstalled, setUninstallURL } from "@api/chrome/runtime" import { executeScript } from "@api/chrome/script" import { createTabAfterCurrent, listTabs } from "@api/chrome/tab" -import metaService from "@service/meta-service" +import { updateInstallTime } from "@service/meta-service" import { IS_E2E, IS_FROM_STORE } from "@util/constant/environment" -import { getGuidePageUrl } from "@util/constant/url" +import { getGuidePageUrl, UNINSTALL_QUESTIONNAIRE } from "@util/constant/url" import { isBrowserUrl } from "@util/pattern" -import UninstallListener from './uninstall-listener' +import versionManager from './version' async function onFirstInstall() { - metaService.updateInstallTime(new Date()) + updateInstallTime(Date.now()) !IS_E2E && createTabAfterCurrent(getGuidePageUrl()) } async function reloadContentScript() { const files = chrome.runtime.getManifest().content_scripts?.[0]?.js - if (!files?.length) { - return - } + if (!files?.length) return const tabs = await listTabs() tabs.filter(({ url }) => url && !isBrowserUrl(url)) .forEach(({ id: tabId }) => tabId && executeScript(tabId, files)) } -export default function handleInstall() { +function initQuestionnaire() { + try { + setUninstallURL(UNINSTALL_QUESTIONNAIRE[locale] ?? UNINSTALL_QUESTIONNAIRE['en']) + } catch (e) { + console.error("Failed to set uninstall URL", e) + } +} + +export function initAfterInstalled() { onInstalled(async reason => { reason === "install" && await onFirstInstall() // Questionnaire for uninstall - new UninstallListener().listen() + initQuestionnaire() // Reload content-script IS_FROM_STORE && await reloadContentScript() + // Initialize with version + versionManager.handle(reason) }) } \ No newline at end of file diff --git a/src/background/install-handler/uninstall-listener.ts b/src/background/install-handler/uninstall-listener.ts deleted file mode 100644 index 34b9eb16d..000000000 --- a/src/background/install-handler/uninstall-listener.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { setUninstallURL } from "@api/chrome/runtime" -import { locale } from "@i18n" -import { UNINSTALL_QUESTIONNAIRE } from "@util/constant/url" - -async function listen() { - try { - const uninstallUrl = UNINSTALL_QUESTIONNAIRE[locale] || UNINSTALL_QUESTIONNAIRE['en'] - uninstallUrl && setUninstallURL(uninstallUrl) - } catch (e) { - console.error(e) - } -} - -/** - * @since 0.9.6 - */ -export default class UninstallListener { - listen = listen -} diff --git a/src/background/migrator/cate-initializer.ts b/src/background/install-handler/version/cate-initializer.ts similarity index 70% rename from src/background/migrator/cate-initializer.ts rename to src/background/install-handler/version/cate-initializer.ts index d42cb61f4..d7f25be2f 100644 --- a/src/background/migrator/cate-initializer.ts +++ b/src/background/install-handler/version/cate-initializer.ts @@ -1,6 +1,6 @@ -import cateService from "@service/cate-service" -import siteService from "@service/site-service" -import { Migrator } from "./common" +import cateDatabase from '@db/cate-database' +import { batchChangeCate } from '@service/site-service' +import type { Migrator } from "./types" type InitialCate = { name: string @@ -31,10 +31,10 @@ const DEMO_ITEMS: InitialCate[] = [ async function initItem(item: InitialCate) { const { name, hosts } = item - const cate = await cateService.add(name) + const cate = await cateDatabase.add(name) const cateId = cate.id - const siteKeys = hosts.map(host => ({ host, type: 'normal' } satisfies timer.site.SiteKey)) - await siteService.batchSaveCate(cateId, siteKeys) + const siteKeys = hosts.map(host => ({ host, type: 'normal' } satisfies tt4b.site.SiteKey)) + await batchChangeCate(cateId, siteKeys) } export default class CateInitializer implements Migrator { @@ -44,7 +44,6 @@ export default class CateInitializer implements Migrator { } } - onUpdate(version: string): void { - version === '3.0.1' && this.onInstall() + onUpdate(_version: string): void { } } \ No newline at end of file diff --git a/src/background/migrator/host-merge-initializer.ts b/src/background/install-handler/version/host-merge-initializer.ts similarity index 94% rename from src/background/migrator/host-merge-initializer.ts rename to src/background/install-handler/version/host-merge-initializer.ts index 31d5af773..37b54d403 100644 --- a/src/background/migrator/host-merge-initializer.ts +++ b/src/background/install-handler/version/host-merge-initializer.ts @@ -6,7 +6,7 @@ */ import mergeRuleDatabase from "@db/merge-rule-database" -import { type Migrator } from "./common" +import type { Migrator } from "./types" /** * v0.1.2 diff --git a/src/background/migrator/index.ts b/src/background/install-handler/version/index.ts similarity index 74% rename from src/background/migrator/index.ts rename to src/background/install-handler/version/index.ts index 3884040fd..daf126b15 100644 --- a/src/background/migrator/index.ts +++ b/src/background/install-handler/version/index.ts @@ -5,12 +5,11 @@ * https://opensource.org/licenses/MIT */ -import { getVersion, onInstalled } from "@api/chrome/runtime" +import { getVersion } from "@api/chrome/runtime" import CateInitializer from "./cate-initializer" -import { type Migrator } from "./common" import HostMergeInitializer from "./host-merge-initializer" -import LimitRuleMigrator from "./limit-rule-migrator" import LocalFileInitializer from "./local-file-initializer" +import type { Migrator } from "./types" import WhitelistInitializer from "./whitelist-initializer" /** @@ -27,11 +26,10 @@ class VersionManager { new LocalFileInitializer(), new WhitelistInitializer(), new CateInitializer(), - new LimitRuleMigrator(), ) } - private onChromeInstalled(reason: ChromeOnInstalledReason) { + handle(reason: ChromeOnInstalledReason) { const version: string = getVersion() if (reason === 'update') { // Update, process the latest version, which equals to current version @@ -41,10 +39,8 @@ class VersionManager { this.processorChain.forEach(processor => processor.onInstall()) } } - - init() { - onInstalled(reason => this.onChromeInstalled(reason)) - } } -export default VersionManager \ No newline at end of file +const versionManager = new VersionManager() + +export default versionManager \ No newline at end of file diff --git a/src/background/migrator/local-file-initializer.ts b/src/background/install-handler/version/local-file-initializer.ts similarity index 85% rename from src/background/migrator/local-file-initializer.ts rename to src/background/install-handler/version/local-file-initializer.ts index 0ee28e495..4a19e57fa 100644 --- a/src/background/migrator/local-file-initializer.ts +++ b/src/background/install-handler/version/local-file-initializer.ts @@ -7,9 +7,9 @@ import mergeRuleDatabase from "@db/merge-rule-database" import { t2Chrome } from "@i18n/chrome/t" -import siteService from "@service/site-service" +import { saveAlias } from '@service/site-service' import { JSON_HOST, LOCAL_HOST_PATTERN, MERGED_HOST, PDF_HOST, PIC_HOST, TXT_HOST } from "@util/constant/remain-host" -import { type Migrator } from "./common" +import { type Migrator } from "./types" /** * Process the host of local files @@ -27,19 +27,19 @@ export default class LocalFileInitializer implements Migrator { merged: MERGED_HOST, }).then(() => console.log('Local file merge rules initialized')) // Add site name - siteService.saveAlias( + saveAlias( { host: PDF_HOST, type: 'normal' }, t2Chrome(msg => msg.initial.localFile.pdf), ) - siteService.saveAlias( + saveAlias( { host: JSON_HOST, type: 'normal' }, t2Chrome(msg => msg.initial.localFile.json), ) - siteService.saveAlias( + saveAlias( { host: PIC_HOST, type: 'normal' }, t2Chrome(msg => msg.initial.localFile.pic), ) - siteService.saveAlias( + saveAlias( { host: TXT_HOST, type: 'normal' }, t2Chrome(msg => msg.initial.localFile.txt), ) diff --git a/src/background/migrator/common.ts b/src/background/install-handler/version/types.ts similarity index 100% rename from src/background/migrator/common.ts rename to src/background/install-handler/version/types.ts diff --git a/src/background/install-handler/version/whitelist-initializer.ts b/src/background/install-handler/version/whitelist-initializer.ts new file mode 100644 index 000000000..ccce09ae4 --- /dev/null +++ b/src/background/install-handler/version/whitelist-initializer.ts @@ -0,0 +1,11 @@ +import whitelistHolder from '@service/whitelist/holder' +import type { Migrator } from "./types" + +export default class WhitelistInitializer implements Migrator { + onInstall(): void { + whitelistHolder.add('localhost:*/**') + } + + onUpdate(_version: string): void { + } +} \ No newline at end of file diff --git a/src/background/limit-processor.ts b/src/background/limit-processor.ts index 3723b35a8..bb8c72b55 100644 --- a/src/background/limit-processor.ts +++ b/src/background/limit-processor.ts @@ -5,71 +5,36 @@ * https://opensource.org/licenses/MIT */ -import { createTabAfterCurrent, getRightOf, listTabs, resetTabUrl, sendMsg2Tab } from "@api/chrome/tab" -import { LIMIT_ROUTE } from "@app/router/constants" -import limitService from "@service/limit-service" -import { getAppPageUrl } from "@util/constant/url" +import { listTabs, sendMsg2Tab } from "@api/chrome/tab" +import { getSite } from '@service/site-service' import { matches } from "@util/limit" -import { isBrowserUrl } from "@util/pattern" -import { getStartOfDay, MILL_PER_DAY, MILL_PER_SECOND } from "@util/time" +import { extractHostname } from '@util/pattern' +import { getStartOfDay, MILL_PER_DAY } from "@util/time" import alarmManager from "./alarm-manager" import MessageDispatcher from "./message-dispatcher" +import { + createLimitRule, delayLimit, noticeLimitChanged, removeLimitRules, selectLimit, updateLimitRules, +} from "./service/limit-service" -function processLimitWaking(rules: timer.limit.Item[], tab: ChromeTab): void { - const { url, id: tabId } = tab - if (!url || !tabId) return - const anyMatch = rules.map(rule => matches(rule?.cond, url)).reduce((a, b) => a || b, false) - if (!anyMatch) { - return - } - sendMsg2Tab(tabId, 'limitWaking', rules) - .then(() => console.log(`Waked tab[id=${tab.id}]`)) - .catch(err => console.error(`Failed to wake with limit rule: rules=${JSON.stringify(rules)}, msg=${err.msg}`)) -} - -async function processOpenPage(limitedUrl: string, sender: ChromeMessageSender) { - const originTab = sender?.tab - if (!originTab) return - const realUrl = getAppPageUrl(LIMIT_ROUTE, { url: encodeURI(limitedUrl) }) - const baseUrl = getAppPageUrl(LIMIT_ROUTE) - const rightTab = await getRightOf(originTab) - const { id: rightId, url: rightUrl } = rightTab || {} - if (rightId && rightUrl && isBrowserUrl(rightUrl) && rightUrl.includes(baseUrl)) { - // Reset url - await resetTabUrl(rightId, realUrl) - } else { - await createTabAfterCurrent(realUrl, sender?.tab) - } -} function initDailyBroadcast() { // Broadcast rules at the start of each day alarmManager.setWhen( 'limit-daily-broadcast', - () => { - const startOfThisDay = getStartOfDay(new Date()) - return startOfThisDay.getTime() + MILL_PER_DAY - }, - () => limitService.broadcastRules(), + () => getStartOfDay(new Date()) + MILL_PER_DAY, + noticeLimitChanged, ) } -const processMoreMinutes = async (url: string) => { - const rules = await limitService.moreMinutes(url) - - const tabs = await listTabs({ status: 'complete' }) - tabs.forEach(tab => processLimitWaking(rules, tab)) -} - -const processAskHitVisit = async (item: timer.limit.Item) => { +const processAskHitVisit = async (item: tt4b.limit.Item) => { let tabs = await listTabs() - const { visitTime = 0, cond } = item || {} + const { cond } = item for (const { id, url } of tabs) { try { if (!url || !matches(cond, url) || !id) continue - const tabFocus = await sendMsg2Tab(id, "askVisitTime") - if (tabFocus && tabFocus > visitTime * MILL_PER_SECOND) return true + const visitHit = await sendMsg2Tab(id, "askVisitHit", item.id) + if (visitHit) return true } catch { // Ignored } @@ -77,12 +42,27 @@ const processAskHitVisit = async (item: timer.limit.Item) => { return false } +async function querySummary(): Promise { + const tabs = await listTabs({ currentWindow: true, active: true }) + const url = tabs[0]?.url + if (!url) return undefined + + const { host } = extractHostname(url) + const site = await getSite({ host, type: 'normal' }) + const items = await selectLimit({ url, effective: true }) + + return { url, site, items } +} + export default function init(dispatcher: MessageDispatcher) { initDailyBroadcast() + dispatcher - .register('openLimitPage', processOpenPage) - // More minutes - .register('cs.moreMinutes', processMoreMinutes) - // Judge any tag hit the time limit per visit - .register("askHitVisit", processAskHitVisit) + .register('limit.list', selectLimit) + .register('limit.delete', removeLimitRules) + .register('limit.update', updateLimitRules) + .register('limit.add', createLimitRule) + .register('limit.hitVisit', processAskHitVisit) + .register('limit.delay', delayLimit) + .register('limit.summary', querySummary) } \ No newline at end of file diff --git a/src/background/message-dispatcher.ts b/src/background/message-dispatcher.ts index 7ee147690..5e15c1d4c 100644 --- a/src/background/message-dispatcher.ts +++ b/src/background/message-dispatcher.ts @@ -5,35 +5,154 @@ * https://opensource.org/licenses/MIT */ +import { log } from '@/common/logger' import { onRuntimeMessage } from "@api/chrome/runtime" +import cateDatabase from './database/cate-database' +import { getUsedStorage } from './database/memory-detector' +import mergeRuleDatabase from "./database/merge-rule-database" +import siteDatabase from './database/site-database' +import statDatabase from './database/stat-database' +import { check2faCode, prepare2fa } from "./service/2fa-service" +import backupProcessor from "./service/backup/processor" +import { exportData, importData, migrateStorage } from "./service/components/immigration" +import { importOther, previewBackup } from "./service/components/import-processor" +import optionHolder from "./service/components/option-holder" +import { getWeekStartDay, getWeekStartTime } from "./service/components/week-helper" +import { getTodayResult } from './service/item-service' +import { getInstallTime, getLastBackUp } from "./service/meta-service" +import notificationProcessor from './service/notification/processor' +import { selectPeriods } from "./service/period-service" +import { + addSite, batchChangeCate, fillInitialAlias, getInitialAlias, getSite, removeIconUrl, removeSites, saveAlias, + saveSiteRunState, searchSites, selectSitePage, +} from "./service/site-service" +import { + batchDelete, countGroup, countSite, selectCate, selectCatePage, selectGroup, selectGroupPage, selectSite, + selectSitePage as selectStateSitePage, +} from "./service/stat-service" +import timelineThrottler from './service/throttler/timeline-throttler' +import { listTimeline } from "./service/timeline-service" +import whitelistHolder from './service/whitelist/holder' + +function processParam(param: unknown): unknown { + if (param === null || param === undefined) { + return undefined + } + const startTs = Date.now() + // Convert null to undefined, because null can't be serialized in chrome.runtime.sendMessage + const json = JSON.stringify(param) + const result = JSON.parse(json, (_key, val) => val ?? undefined) + log(`Processed param in ${Date.now() - startTs}ms`) + return result +} class MessageDispatcher { - private handlers: Partial<{ - [code in timer.mq.ReqCode]: timer.mq.Handler - }> = {} + private handlers: Partial>> = {} - register(code: timer.mq.ReqCode, handler: timer.mq.Handler): MessageDispatcher { + constructor() { + this.initServiceHandlers() + } + + register(code: C, handler: tt4b.mq.Handler): MessageDispatcher { if (this.handlers[code]) { - throw new Error("Duplicate handler") + throw new Error(`Duplicate handler: code=${code}`) } - this.handlers[code] = handler + this.handlers[code] = handler as unknown as tt4b.mq.Handler return this } - private async handle(message: timer.mq.Request, sender: ChromeMessageSender): Promise> { + private initServiceHandlers() { + this + // Statistics + .register('stat.sites', selectSite) + .register('stat.sitePage', selectStateSitePage) + .register('stat.countSite', countSite) + .register('stat.deleteSite', param => 'host' in param + ? statDatabase.deleteByHost(param.host, param.date) + : statDatabase.deleteByGroup(param.groupId, param.date)) + .register('stat.cates', selectCate) + .register('stat.catePage', selectCatePage) + .register('stat.groups', selectGroup) + .register('stat.groupPage', selectGroupPage) + .register('stat.countGroup', countGroup) + .register('stat.batchDelete', batchDelete) + .register('stat.today', getTodayResult) + .register('item.batch', keys => statDatabase.batchSelect(keys)) + // Site management + .register('site.list', param => siteDatabase.select(param)) + .register('site.page', selectSitePage) + .register('site.add', addSite) + .register('site.delete', removeSites) + .register('site.changeCate', ({ cateId, keys }) => batchChangeCate(cateId, keys)) + .register('site.deleteIcon', removeIconUrl) + .register('site.changeAlias', ({ key, alias }) => saveAlias(key, alias)) + .register('site.fillAlias', fillInitialAlias) + .register('site.initialAlias', getInitialAlias) + .register('site.changeRun', ({ key, enabled }) => saveSiteRunState(key, enabled)) + .register('site.runEnabled', host => getSite({ host, type: 'normal' }).then(s => !!s.run)) + .register('site.search', searchSites) + // Options + .register('option.get', () => optionHolder.get()) + .register('option.set', val => optionHolder.set(val)) + .register('option.changeStorage', migrateStorage) + .register('option.testNotification', () => notificationProcessor.doSend()) + .register('option.weekStartDay', getWeekStartDay) + .register('option.weekStartTime', getWeekStartTime) + // Category + .register('cate.all', () => cateDatabase.listAll()) + .register('cate.add', name => cateDatabase.add(name)) + .register('cate.change', ({ id, name }) => cateDatabase.update(id, name)) + .register('cate.delete', id => cateDatabase.delete(id)) + // Meta information + .register('meta.installTs', getInstallTime) + .register('meta.usedStorage', getUsedStorage) + .register('meta.prepare2fa', prepare2fa) + .register('meta.check2fa', check2faCode) + // Whitelist & Merge Rule + .register('whitelist.contain', ({ host, url }) => whitelistHolder.contains(host, url)) + .register('whitelist.all', () => whitelistHolder.all()) + .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.clear', cid => backupProcessor.clear(cid)) + .register('backup.query', param => backupProcessor.query(param)) + .register('backup.lastTs', getLastBackUp) + .register('backup.clients', () => backupProcessor.listClients()) + .register('backup.preview', previewBackup) + // Period & Timeline + .register('period.list', selectPeriods) + .register('timeline.list', listTimeline) + .register('timeline.tick', ev => timelineThrottler.saveEvent(ev)) + // Data immigration + .register('immigration.import', importData) + .register('immigration.export', exportData) + .register('immigration.importOther', importOther) + } + + private async handle(message: tt4b.mq.Request, sender: ChromeMessageSender): Promise> { const code = message?.code if (!code) { return { code: 'ignore' } } + log(`Received message: ${code} with data: `, message?.data) const handler = this.handlers[code] if (!handler) { + console.warn(`Handler not registered for code: ${code}`) return { code: 'ignore' } } try { - const result = await handler(message.data, sender) - return { code: 'success', data: result } + const data = processParam(message.data) + const result = await handler(data, sender) + return { code: 'success', data: result } as tt4b.mq.Response } catch (error) { - const msg = (error as Error)?.message ?? error?.toString?.() + const msg = error instanceof Error ? error.message : (error?.toString?.() ?? 'Unknown error') return { code: 'fail', msg } } } diff --git a/src/background/migrator/limit-rule-migrator.ts b/src/background/migrator/limit-rule-migrator.ts deleted file mode 100644 index 2dac2a865..000000000 --- a/src/background/migrator/limit-rule-migrator.ts +++ /dev/null @@ -1,35 +0,0 @@ -import limitService from "@service/limit-service" -import { cleanCond } from "@util/limit" -import type { Migrator } from "./common" - -export default class LimitRuleMigrator implements Migrator { - onInstall(): void { - } - - async onUpdate(_version: string): Promise { - const rules = await limitService.select() - if (!rules?.length) return - const needUpdate: timer.limit.Rule[] = [] - const needRemoved: timer.limit.Rule[] = [] - rules.forEach(async rule => { - const { cond } = rule - let changed = false - const newCond: string[] = [] - cond?.forEach(url => { - const clean = cleanCond(url) - changed = changed || clean !== url - clean && newCond.push(clean) - }) - if (!changed) return - if (rule.cond.length) { - rule.cond = newCond - needUpdate.push(rule) - } else { - needRemoved.push(rule) - } - - }) - needRemoved.length && await limitService.remove(...needRemoved) - needUpdate.length && await limitService.update(...needUpdate) - } -} \ No newline at end of file diff --git a/src/background/migrator/whitelist-initializer.ts b/src/background/migrator/whitelist-initializer.ts deleted file mode 100644 index 5b6da1b43..000000000 --- a/src/background/migrator/whitelist-initializer.ts +++ /dev/null @@ -1,12 +0,0 @@ -import whitelistService from "@service/whitelist/service" -import { type Migrator } from "./common" - -export default class WhitelistInitializer implements Migrator { - onInstall(): void { - whitelistService.add('localhost:*/**') - } - - onUpdate(version: string): void { - version === '2.5.7' && this.onInstall() - } -} \ No newline at end of file diff --git a/src/util/psl/index.ts b/src/background/psl/index.ts similarity index 82% rename from src/util/psl/index.ts rename to src/background/psl/index.ts index 18e2f56ed..e2b941cc7 100644 --- a/src/util/psl/index.ts +++ b/src/background/psl/index.ts @@ -1,7 +1,6 @@ -import { toASCII } from "punycode" import rules from "./rules.json" -export type PslNode = { +type PslNode = { // Children c?: PslTree // Is leaf @@ -14,7 +13,15 @@ const TREE: PslTree = rules type Chain = string | [string, boolean] -export const getSuffix = (origin: string): string | null => { +function toASCII(domain: string): string { + try { + return new URL(`http://${domain}`).hostname + } catch { + return domain + } +} + +export const getPslSuffix = (origin: string): string => { if (!origin) return origin const ascii = toASCII(origin) const parts = ascii.split(".") @@ -26,7 +33,7 @@ export const getSuffix = (origin: string): string | null => { return parts.splice(parts.length - partLen, partLen).join('.') } -export const get = (origin: string): string | null => { +export const getPsl = (origin: string): string | null => { if (!origin) return origin const ascii = toASCII(origin) const parts = ascii.split(".") @@ -40,6 +47,7 @@ export const get = (origin: string): string | null => { const get0 = (tree: PslTree, parts: string[], index: number, chains: Chain[]) => { const part = parts[index] + if (!part) return let pslNode = tree[part] if (!pslNode && !tree[`!${part}`]) { pslNode = tree['*'] diff --git a/src/util/psl/rules.json b/src/background/psl/rules.json similarity index 93% rename from src/util/psl/rules.json rename to src/background/psl/rules.json index 1b4489a5f..a98b3336c 100644 --- a/src/util/psl/rules.json +++ b/src/background/psl/rules.json @@ -172,6 +172,7 @@ "c": { "com": 1, "framer": 1, + "kiloapps": 1, "net": 1, "off": 1, "org": 1, @@ -244,12 +245,12 @@ "c": { "adaptable": 1, "aiven": 1, + "base44": 1, "beget": { "c": { "*": 1 } }, - "bookonline": 1, "botdash": 1, "brave": { "c": { @@ -261,9 +262,12 @@ }, "l": 1 }, + "claude": 1, "clerk": 1, "clerkstage": 1, + "cloudflare": 1, "convex": 1, + "corespeed": 1, "csb": { "c": { "preview": 1 @@ -292,12 +296,19 @@ }, "expo": { "c": { - "staging": 1 + "on": 1, + "staging": { + "c": { + "on": 1 + }, + "l": 1 + } }, "l": 1 }, "flutterflow": 1, "framer": 1, + "gadget": 1, "github": 1, "hackclub": 1, "hasura": 1, @@ -310,9 +321,11 @@ "loginline": 1, "lovable": 1, "luyani": 1, + "magicpatterns": 1, "medusajs": 1, "messerli": 1, - "netfy": 1, + "miren": 1, + "mocha": 1, "netlify": 1, "ngrok": 1, "ngrok-free": 1, @@ -325,6 +338,8 @@ "nyat": 1, "on-fleek": 1, "ondigitalocean": 1, + "onhercules": 1, + "pplx": 1, "railway": { "c": { "up": 1 @@ -346,6 +361,7 @@ } } }, + "shiptoday": 1, "snowflake": { "c": { "*": 1, @@ -356,7 +372,8 @@ } } }, - "storipress": 1, + "spawnbase": 1, + "sprites": 1, "streamlit": 1, "telebit": 1, "typedream": 1, @@ -367,7 +384,7 @@ }, "vercel": 1, "wal": 1, - "wdh": 1, + "wasmer": 1, "web": 1, "windsurf": 1, "wnext": 1, @@ -380,7 +397,86 @@ }, "l": 1 }, - "apple": 1, + "apple": { + "c": { + "int": { + "c": { + "cloud": { + "c": { + "*": 1, + "r": { + "c": { + "*": 1, + "ap-north-1": { + "c": { + "*": 1 + } + }, + "ap-south-1": { + "c": { + "*": 1 + } + }, + "ap-south-2": { + "c": { + "*": 1 + } + }, + "eu-central-1": { + "c": { + "*": 1 + } + }, + "eu-north-1": { + "c": { + "*": 1 + } + }, + "us-central-1": { + "c": { + "*": 1 + } + }, + "us-central-2": { + "c": { + "*": 1 + } + }, + "us-east-1": { + "c": { + "*": 1 + } + }, + "us-east-2": { + "c": { + "*": 1 + } + }, + "us-west-1": { + "c": { + "*": 1 + } + }, + "us-west-2": { + "c": { + "*": 1 + } + }, + "us-west-3": { + "c": { + "*": 1 + } + } + } + } + } + } + }, + "l": 1 + } + }, + "l": 1 + }, "aq": 1, "aquarelle": 1, "ar": { @@ -439,7 +535,7 @@ "associates": 1, "at": { "c": { - "4": 1, + "0.0.0.4": 1, "123webseite": 1, "12hp": 1, "2ix": 1, @@ -543,8 +639,7 @@ "hrsn": { "c": { "vps": 1 - }, - "l": 1 + } }, "id": 1, "net": 1, @@ -585,6 +680,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -595,6 +691,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -605,6 +702,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -615,6 +713,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -625,6 +724,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -635,6 +735,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -655,6 +756,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -665,6 +767,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -675,6 +778,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -698,6 +802,11 @@ "transfer-webapp": 1 } }, + "ap-southeast-7": { + "c": { + "transfer-webapp": 1 + } + }, "ca-central-1": { "c": { "airflow": { @@ -705,6 +814,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -725,6 +835,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -745,6 +856,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -755,6 +867,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -775,6 +888,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -785,6 +899,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -795,6 +910,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -825,6 +941,12 @@ "*": 1 } }, + "lambda-url": 1, + "transfer-webapp": 1 + } + }, + "mx-central-1": { + "c": { "transfer-webapp": 1 } }, @@ -835,6 +957,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -845,6 +968,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -855,6 +979,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -877,6 +1002,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } }, @@ -887,6 +1013,7 @@ "*": 1 } }, + "lambda-url": 1, "transfer-webapp": 1 } } @@ -1199,8 +1326,22 @@ "bcn": 1, "bd": { "c": { - "*": 1 - } + "ac": 1, + "ai": 1, + "co": 1, + "com": 1, + "edu": 1, + "gov": 1, + "id": 1, + "info": 1, + "it": 1, + "mil": 1, + "net": 1, + "org": 1, + "sch": 1, + "tv": 1 + }, + "l": 1 }, "be": { "c": { @@ -1217,6 +1358,7 @@ "ezproxy": 1 } }, + "my": 1, "myspreadshop": 1, "transurl": { "c": { @@ -1242,16 +1384,16 @@ }, "bg": { "c": { - "0": 1, - "1": 1, - "2": 1, - "3": 1, - "4": 1, - "5": 1, - "6": 1, - "7": 1, - "8": 1, - "9": 1, + "0.0.0.0": 1, + "0.0.0.1": 1, + "0.0.0.2": 1, + "0.0.0.3": 1, + "0.0.0.4": 1, + "0.0.0.5": 1, + "0.0.0.6": 1, + "0.0.0.7": 1, + "0.0.0.8": 1, + "0.0.0.9": 1, "a": 1, "b": 1, "barsy": 1, @@ -1698,6 +1840,7 @@ }, "build": { "c": { + "shiptoday": 1, "v0": 1, "windsurf": 1 }, @@ -1822,7 +1965,12 @@ }, "l": 1 }, - "case": 1, + "case": { + "c": { + "sav": 1 + }, + "l": 1 + }, "cash": 1, "casino": 1, "cat": 1, @@ -1833,13 +1981,17 @@ "cbre": 1, "cc": { "c": { + "ccwu": 1, "cleverapps": 1, "cloud-ip": 1, "cloudns": 1, "csx": 1, + "ec": 1, + "eu": 1, "fantasyleague": 1, "ftpaccess": 1, "game-server": 1, + "gu": 1, "myphotos": 1, "scrapping": 1, "spawn": { @@ -1847,12 +1999,16 @@ "instances": 1 } }, - "twmail": 1 + "sryze": 1, + "twmail": 1, + "uk": 1, + "us": 1 }, "l": 1 }, "cd": { "c": { + "cc": 1, "gov": 1 }, "l": 1 @@ -1945,6 +2101,7 @@ "net": 1, "or": 1, "org": 1, + "us": 1, "xn--aroport-bya": 1 }, "l": 1 @@ -1980,19 +2137,33 @@ "clothing": 1, "cloud": { "c": { + "antagonist": 1, "axarnet": { "c": { "es-1": 1 } }, - "convex": 1, + "begetcdn": { + "c": { + "*": 1 + } + }, + "convex": { + "c": { + "eu-west-1": 1, + "us-east-1": 1 + }, + "l": 1 + }, "diadem": 1, "elementor": 1, + "emergent": 1, "encoway": { "c": { "eu": 1 } }, + "hstgr": 1, "jelastic": { "c": { "vip": 1 @@ -2015,6 +2186,27 @@ }, "jote": 1, "jotelulu": 1, + "k2": { + "c": { + "elastic": 1, + "ru-msk": { + "c": { + "lb": 1, + "s3": 1, + "website": 1 + } + }, + "ru-spb": { + "c": { + "lb": 1, + "s3": 1, + "website": 1 + } + }, + "s3": 1, + "website": 1 + } + }, "keliweb": { "c": { "cs": 1 @@ -2232,6 +2424,11 @@ "emrnotebooks-prod": 1, "emrstudio-prod": 1, "execute-api": 1, + "rds": { + "c": { + "*": 1 + } + }, "s3": 1, "s3-accesspoint": 1, "s3-deprecated": 1, @@ -2251,6 +2448,11 @@ "emrnotebooks-prod": 1, "emrstudio-prod": 1, "execute-api": 1, + "rds": { + "c": { + "*": 1 + } + }, "s3": 1, "s3-accesspoint": 1, "s3-object-lambda": 1, @@ -2340,6 +2542,7 @@ "jl": 1, "js": 1, "jx": 1, + "khsj": 1, "ln": 1, "mil": 1, "mo": 1, @@ -2402,6 +2605,20 @@ "*": 1 } }, + "rdpa": { + "c": { + "clusters": { + "c": { + "*": 1 + } + }, + "srvrless": { + "c": { + "*": 1 + } + } + } + }, "repl": { "c": { "id": 1 @@ -2415,6 +2632,7 @@ }, "l": 1 }, + "umso": 1, "xmit": { "c": { "*": 1 @@ -2445,6 +2663,7 @@ } }, "180r": 1, + "1cooldns": 1, "1kapp": 1, "3utilities": 1, "4u": 1, @@ -2559,6 +2778,11 @@ "*": 1 } }, + "ap-southeast-7": { + "c": { + "*": 1 + } + }, "ca-central-1": { "c": { "*": 1 @@ -3307,6 +3531,180 @@ "s3-website": 1 } }, + "rds": { + "c": { + "af-south-1": { + "c": { + "*": 1 + } + }, + "ap-east-1": { + "c": { + "*": 1 + } + }, + "ap-east-2": { + "c": { + "*": 1 + } + }, + "ap-northeast-1": { + "c": { + "*": 1 + } + }, + "ap-northeast-2": { + "c": { + "*": 1 + } + }, + "ap-northeast-3": { + "c": { + "*": 1 + } + }, + "ap-south-1": { + "c": { + "*": 1 + } + }, + "ap-south-2": { + "c": { + "*": 1 + } + }, + "ap-southeast-1": { + "c": { + "*": 1 + } + }, + "ap-southeast-2": { + "c": { + "*": 1 + } + }, + "ap-southeast-3": { + "c": { + "*": 1 + } + }, + "ap-southeast-4": { + "c": { + "*": 1 + } + }, + "ap-southeast-5": { + "c": { + "*": 1 + } + }, + "ap-southeast-6": { + "c": { + "*": 1 + } + }, + "ap-southeast-7": { + "c": { + "*": 1 + } + }, + "ca-central-1": { + "c": { + "*": 1 + } + }, + "ca-west-1": { + "c": { + "*": 1 + } + }, + "eu-central-1": { + "c": { + "*": 1 + } + }, + "eu-central-2": { + "c": { + "*": 1 + } + }, + "eu-west-1": { + "c": { + "*": 1 + } + }, + "eu-west-2": { + "c": { + "*": 1 + } + }, + "eu-west-3": { + "c": { + "*": 1 + } + }, + "il-central-1": { + "c": { + "*": 1 + } + }, + "me-central-1": { + "c": { + "*": 1 + } + }, + "me-south-1": { + "c": { + "*": 1 + } + }, + "mx-central-1": { + "c": { + "*": 1 + } + }, + "sa-east-1": { + "c": { + "*": 1 + } + }, + "us-east-1": { + "c": { + "*": 1 + } + }, + "us-east-2": { + "c": { + "*": 1 + } + }, + "us-gov-east-1": { + "c": { + "*": 1 + } + }, + "us-gov-west-1": { + "c": { + "*": 1 + } + }, + "us-northeast-1": { + "c": { + "*": 1 + } + }, + "us-west-1": { + "c": { + "*": 1 + } + }, + "us-west-2": { + "c": { + "*": 1 + } + } + } + }, "s3": 1, "s3-1": 1, "s3-ap-east-1": 1, @@ -3460,7 +3858,8 @@ "s3": 1, "s3-accesspoint": 1, "s3-accesspoint-fips": 1, - "s3-fips": 1 + "s3-fips": 1, + "s3-website": 1 } }, "emrappui-prod": 1, @@ -3482,7 +3881,8 @@ "s3": 1, "s3-accesspoint": 1, "s3-accesspoint-fips": 1, - "s3-fips": 1 + "s3-fips": 1, + "s3-website": 1 } }, "emrappui-prod": 1, @@ -3763,7 +4163,27 @@ }, "l": 1 }, + "atlassian-3p": { + "c": { + "*": 1 + } + }, + "atlassian-3p-us-gov-mod": { + "c": { + "*": 1 + } + }, + "atlassian-isolated-3p": { + "c": { + "*": 1 + } + }, "atmeta": 1, + "auiusercontent": { + "c": { + "*": 1 + } + }, "authgear-staging": 1, "authgearapps": 1, "awsapprunner": { @@ -3776,6 +4196,7 @@ "balena-devices": 1, "barsycenter": 1, "barsyonline": 1, + "base44-sandbox": 1, "blogdns": 1, "blogspot": 1, "blogsyte": 1, @@ -3783,8 +4204,11 @@ "bplaced": 1, "br": 1, "builtwithdark": 1, + "bumbleshrimp": 1, "cafjs": 1, "canva-apps": 1, + "canva-hosted-embed": 1, + "canvacode": 1, "cdn77-storage": 1, "cechire": 1, "cf-ipfs": 1, @@ -3855,8 +4279,10 @@ "dattoweb": 1, "ddnsfree": 1, "ddnsgeek": 1, + "ddnsguru": 1, "ddnsking": 1, "de": 1, + "deployagent": 1, "deus-canvas": 1, "dev-myqnapcloud": 1, "devinapps": { @@ -3883,6 +4309,7 @@ "dopaas": 1, "drayddns": 1, "dreamhosters": 1, + "drive-platform": 1, "dsmynas": 1, "durumis": 1, "dyn-o-saur": 1, @@ -3902,6 +4329,8 @@ "dyndns-wiki": 1, "dyndns-work": 1, "dynns": 1, + "dynuddns": 1, + "dynuhosting": 1, "elasticbeanstalk": { "c": { "af-south-1": 1, @@ -3913,14 +4342,18 @@ "ap-southeast-1": 1, "ap-southeast-2": 1, "ap-southeast-3": 1, + "ap-southeast-5": 1, + "ap-southeast-7": 1, "ca-central-1": 1, "eu-central-1": 1, "eu-north-1": 1, "eu-south-1": 1, + "eu-south-2": 1, "eu-west-1": 1, "eu-west-2": 1, "eu-west-3": 1, "il-central-1": 1, + "me-central-1": 1, "me-south-1": 1, "sa-east-1": 1, "us-east-1": 1, @@ -3932,12 +4365,18 @@ }, "l": 1 }, + "emergentagent": { + "c": { + "preview": 1 + } + }, "encoreapi": 1, "est-a-la-maison": 1, "est-a-la-masion": 1, "est-le-patron": 1, "est-mon-blogueur": 1, "eu": 1, + "eu1-plenit": 1, "evennode": { "c": { "eu-1": 1, @@ -4030,6 +4469,8 @@ "hatenablog": 1, "hatenadiary": 1, "health-carereform": 1, + "hercules-app": 1, + "hercules-dev": 1, "herokuapp": 1, "hk": 1, "hobby-site": 1, @@ -4123,20 +4564,10 @@ "demo": 1 } }, - "jote-dr-lt1": 1, - "jote-rd-lt1": 1, - "joyent": { - "c": { - "cns": { - "c": { - "*": 1 - } - } - } - }, "jpn": 1, "kasserver": 1, "kozow": 1, + "la1-plenit": 1, "ladesk": 1, "likes-pie": 1, "likescandy": 1, @@ -4171,13 +4602,18 @@ "*": 1 } }, + "magicpatternsapp": 1, "massivegrid": { "c": { "paas": 1 } }, - "mazeplay": 1, "messwithdns": 1, + "metaaiusercontent": { + "c": { + "*": 1 + } + }, "meteorapp": { "c": { "eu": 1 @@ -4186,6 +4622,7 @@ }, "mex": 1, "miniserver": 1, + "mochausercontent": 1, "modelscape": 1, "mwcloudnonprod": 1, "myactivedirectory": 1, @@ -4264,13 +4701,13 @@ "outsystemscloud": 1, "ownprovider": 1, "pagespeedmobilizer": 1, - "pagexl": 1, "paywhirl": { "c": { "*": 1 } }, "pgfog": 1, + "pivohosting": 1, "pixolino": 1, "playstation-cloud": 1, "pleskns": 1, @@ -4321,7 +4758,6 @@ } }, "reservd": 1, - "reserve-online": 1, "rhcloud": 1, "rice-labs": 1, "routingthecloud": 1, @@ -4337,7 +4773,7 @@ "c": { "test": { "c": { - "001": { + "0.0.0.1": { "c": { "*": 1 } @@ -4378,7 +4814,6 @@ "simple-url": 1, "simplesite": 1, "sinaapp": 1, - "skygearapp": 1, "smushcdn": 1, "space-to-rent": 1, "stackhero-network": 1, @@ -4422,7 +4857,9 @@ "unusualperson": 1, "upsunapp": 1, "us": 1, + "us1-plenit": 1, "vipsinaapp": 1, + "vivenushop": 1, "vultrobjects": { "c": { "*": 1 @@ -4445,6 +4882,7 @@ "pages": 1 } }, + "wiredbladehosting": 1, "withgoogle": 1, "withyoutube": 1, "wixsite": 1, @@ -4469,6 +4907,7 @@ }, "l": 1 }, + "xtooldevice": 1, "yolasite": 1, "za": 1 }, @@ -4483,7 +4922,12 @@ }, "l": 1 }, - "company": 1, + "company": { + "c": { + "mybox": 1 + }, + "l": 1 + }, "compare": 1, "computer": 1, "comsec": 1, @@ -4657,6 +5101,11 @@ "4lima": 1, "barsy": 1, "bplaced": 1, + "bwcloud-os-instance": { + "c": { + "*": 1 + } + }, "co": 1, "com": 1, "community-pro": 1, @@ -4665,7 +5114,6 @@ "dyn": 1 } }, - "dd-dns": 1, "ddnss": { "c": { "dyn": 1, @@ -4676,14 +5124,10 @@ "diskussionsbereich": 1, "dnshome": 1, "dnsupdater": 1, - "dray-dns": 1, - "draydns": 1, "dyn-berlin": 1, "dyn-ip24": 1, - "dyn-vpn": 1, "dynamisches-dns": 1, "dyndns1": 1, - "dynvpn": 1, "firewall-gateway": 1, "frusky": { "c": { @@ -4726,12 +5170,9 @@ "lima-city": 1, "logoip": 1, "mein-iserv": 1, - "mein-vigor": 1, "my": 1, "my-gateway": 1, "my-router": 1, - "my-vigor": 1, - "my-wan": 1, "myhome-server": 1, "myspreadshop": 1, "rub": 1, @@ -4756,9 +5197,6 @@ }, "square7": 1, "svn-repos": 1, - "syno-ds": 1, - "synology-diskstation": 1, - "synology-ds": 1, "taifun-dns": 1, "test-iserv": 1, "traeumtgerade": 1, @@ -4796,16 +5234,91 @@ }, "dev": { "c": { - "12chars": 1, "barsy": 1, + "bearblog": 1, "botdash": 1, + "brave": { + "c": { + "s": { + "c": { + "*": 1 + } + } + }, + "l": 1 + }, "crm": { "c": { + "aa": { + "c": { + "*": 1 + } + }, + "ab": { + "c": { + "*": 1 + } + }, + "ac": { + "c": { + "*": 1 + } + }, + "ad": { + "c": { + "*": 1 + } + }, + "ae": { + "c": { + "*": 1 + } + }, + "af": { + "c": { + "*": 1 + } + }, + "ci": { + "c": { + "*": 1 + } + }, "d": { "c": { "*": 1 } }, + "pa": { + "c": { + "*": 1 + } + }, + "pb": { + "c": { + "*": 1 + } + }, + "pc": { + "c": { + "*": 1 + } + }, + "pd": { + "c": { + "*": 1 + } + }, + "pe": { + "c": { + "*": 1 + } + }, + "pf": { + "c": { + "*": 1 + } + }, "w": { "c": { "*": 1 @@ -4864,6 +5377,7 @@ } }, "githubpreview": 1, + "grebedoc": 1, "hrsn": 1, "inbrowser": { "c": { @@ -4906,12 +5420,14 @@ "l": 1 }, "mediatech": 1, + "mocha-sandbox": 1, "modx": 1, "myaddr": 1, "ngrok": 1, "ngrok-free": 1, "pages": 1, "panel": 1, + "payload": 1, "platter-app": 1, "r2": 1, "replit": { @@ -4956,7 +5472,18 @@ "*": 1 } }, + "storage": { + "c": { + "t3": 1 + } + }, + "storageapi": { + "c": { + "t3": 1 + } + }, "vercel": 1, + "vivenushop": 1, "webhare": { "c": { "*": 1 @@ -4970,17 +5497,7 @@ "dhl": 1, "diamonds": 1, "diet": 1, - "digital": { - "c": { - "cloudapps": { - "c": { - "london": 1 - }, - "l": 1 - } - }, - "l": 1 - }, + "digital": 1, "direct": { "c": { "libp2p": 1 @@ -4991,7 +5508,13 @@ "discount": 1, "discover": 1, "dish": 1, - "diy": 1, + "diy": { + "c": { + "discourse": 1, + "imagine": 1 + }, + "l": 1 + }, "dj": 1, "dk": { "c": { @@ -5041,7 +5564,6 @@ "drive": 1, "dtv": 1, "dubai": 1, - "dunlop": 1, "dupont": 1, "durban": 1, "dvag": 1, @@ -5175,11 +5697,7 @@ }, "email": { "c": { - "crisp": { - "c": { - "on": 1 - } - }, + "intouch": 1, "tawk": { "c": { "p": 1 @@ -5245,9 +5763,27 @@ }, "eu": { "c": { + "amazonwebservices": { + "c": { + "on": { + "c": { + "eusc-de-east-1": { + "c": { + "cognito-idp": { + "c": { + "auth": 1 + } + } + } + } + } + } + } + }, "barsy": 1, "cloudns": 1, - "diskstation": 1, + "deuxfleurs": 1, + "directwp": 1, "dogado": { "c": { "jelastic": 1 @@ -5258,6 +5794,7 @@ "*": 1 } }, + "prvw": 1, "spdns": 1, "transurl": { "c": { @@ -5356,7 +5893,9 @@ "ac": 1, "biz": 1, "com": 1, + "edu": 1, "gov": 1, + "id": 1, "info": 1, "mil": 1, "name": 1, @@ -5415,7 +5954,6 @@ "chirurgiens-dentistes-en-france": 1, "com": 1, "dedibox": 1, - "en-root": 1, "experts-comptables": 1, "fbx-os": 1, "fbxos": 1, @@ -5425,6 +5963,7 @@ "gouv": 1, "greta": 1, "huissier-justice": 1, + "kdns": 1, "medecin": 1, "myspreadshop": 1, "nom": 1, @@ -5446,7 +5985,15 @@ "frontier": 1, "ftr": 1, "fujitsu": 1, - "fun": 1, + "fun": { + "c": { + "ms": 1, + "vicp": 1, + "yicp": 1, + "zicp": 1 + }, + "l": 1 + }, "fund": 1, "furniture": 1, "futbol": 1, @@ -5517,6 +6064,16 @@ }, "l": 1 }, + "ply": { + "c": { + "at": { + "c": { + "*": 1 + } + }, + "d6": 1 + } + }, "stackit": 1 }, "l": 1 @@ -5588,7 +6145,6 @@ "gold": 1, "goldpoint": 1, "golf": 1, - "goo": 1, "goodyear": 1, "goog": { "c": { @@ -5768,8 +6324,10 @@ "bolt": 1, "cloudaccess": 1, "easypanel": 1, + "emergent": 1, "fastvps": 1, "freesite": 1, + "gadget": 1, "half": 1, "iserv": 1, "jele": 1, @@ -5833,7 +6391,7 @@ }, "hu": { "c": { - "2000": 1, + "0.0.7.208": 1, "agrar": 1, "bolt": 1, "casino": 1, @@ -5877,6 +6435,7 @@ "id": { "c": { "ac": 1, + "ai": 1, "biz": 1, "co": 1, "desa": 1, @@ -5890,6 +6449,7 @@ "ponpes": 1, "sch": 1, "web": 1, + "xn--9tfky": 1, "zone": 1 }, "l": 1 @@ -5953,6 +6513,7 @@ "ac": 1, "ai": 1, "am": 1, + "bank": 1, "barsy": 1, "bihar": 1, "biz": 1, @@ -5968,11 +6529,13 @@ "dr": 1, "edu": 1, "er": 1, + "fin": 1, "firm": 1, "gen": 1, "gov": 1, "gujarat": 1, "ind": 1, + "indevs": 1, "info": 1, "int": 1, "internet": 1, @@ -6044,7 +6607,7 @@ "investments": 1, "io": { "c": { - "2038": 1, + "0.0.7.246": 1, "apigee": 1, "azurecontainer": { "c": { @@ -6102,6 +6665,7 @@ "darklang": 1, "dedyn": 1, "definima": 1, + "drive-platform": 1, "editorx": 1, "edu": 1, "edugit": 1, @@ -6111,6 +6675,7 @@ "id": 1 } }, + "gitbook": 1, "github": 1, "gitlab": 1, "gov": 1, @@ -6139,6 +6704,8 @@ "l": 1 }, "jele": 1, + "keenetic": 1, + "kiloapps": 1, "lair": { "c": { "apps": 1 @@ -6229,7 +6796,6 @@ "client": 1 } }, - "shw": 1, "stolos": { "c": { "*": 1 @@ -6310,7 +6876,6 @@ "it": { "c": { "123homepage": 1, - "12chars": 1, "16-b": 1, "32-b": 1, "64-b": 1, @@ -9001,6 +9566,7 @@ "kfh": 1, "kg": { "c": { + "ae": 1, "com": 1, "edu": 1, "gov": 1, @@ -9014,8 +9580,13 @@ }, "kh": { "c": { - "*": 1 - } + "com": 1, + "edu": 1, + "gov": 1, + "net": 1, + "org": 1 + }, + "l": 1 }, "ki": { "c": { @@ -9093,6 +9664,7 @@ "co": 1, "daegu": 1, "daejeon": 1, + "eliv-api": 1, "eliv-cdn": 1, "eliv-dns": 1, "es": 1, @@ -9237,6 +9809,7 @@ "lincoln": 1, "link": { "c": { + "canva": 1, "cyon": 1, "dweb": { "c": { @@ -9248,6 +9821,8 @@ "*": 1 } }, + "joinmc": 1, + "keenetic": 1, "myfritz": 1, "mypep": 1, "nftstorage": { @@ -9445,12 +10020,18 @@ "filegear": 1, "filegear-sg": 1, "gov": 1, + "hooc": { + "c": { + "seprox": 1 + } + }, "hopto": 1, "i234": 1, "its": 1, "loginto": 1, "lohmus": 1, "mcdir": 1, + "mybox": 1, "myds": 1, "net": 1, "nohost": 1, @@ -9735,7 +10316,9 @@ "c": { "forgot": 1 } - } + }, + "ispmanager": 1, + "keenetic": 1 }, "l": 1 }, @@ -9785,13 +10368,13 @@ "azurefd": 1, "azurestaticapps": { "c": { - "1": 1, - "2": 1, - "3": 1, - "4": 1, - "5": 1, - "6": 1, - "7": 1, + "0.0.0.1": 1, + "0.0.0.2": 1, + "0.0.0.3": 1, + "0.0.0.4": 1, + "0.0.0.5": 1, + "0.0.0.6": 1, + "0.0.0.7": 1, "centralus": 1, "eastasia": 1, "eastus2": 1, @@ -9868,9 +10451,15 @@ "dattolocal": 1, "ddns": 1, "ddns-ip": 1, + "de5": 1, "debian": 1, "definima": 1, - "deno": 1, + "deno": { + "c": { + "sandbox": 1 + }, + "l": 1 + }, "dns-cloud": 1, "dns-dynamic": 1, "dnsalias": 1, @@ -9882,9 +10471,9 @@ "dynalias": 1, "dynathome": 1, "dynu": 1, + "dynuddns": 1, "dynv6": 1, "eating-organic": 1, - "edgeapp": 1, "edgekey": 1, "edgekey-staging": 1, "edgesuite": 1, @@ -10002,12 +10591,13 @@ "myradweb": 1, "mysecuritycamera": 1, "myspreadshop": 1, + "mysynology": 1, "nhlfan": 1, "no-ip": 1, "now-dns": 1, "office-on-the": 1, - "onavstack": 1, "oninferno": 1, + "opik": 1, "ovh": { "c": { "hosting": { @@ -10056,6 +10646,7 @@ "server-on": 1, "shopselect": 1, "siteleaf": 1, + "spryt": 1, "square7": 1, "squares": 1, "srcf": { @@ -10086,9 +10677,24 @@ }, "l": 1 }, + "tunnelmole": 1, "twmail": 1, "uk": 1, "uni5": 1, + "usgovcloudapi": { + "c": { + "core": { + "c": { + "blob": 1, + "file": 1, + "web": 1 + } + }, + "servicebus": 1 + } + }, + "usgovcloudapp": 1, + "usgovtrafficmanager": 1, "vpndns": 1, "vps-host": { "c": { @@ -10108,7 +10714,9 @@ "c": { "core": { "c": { - "blob": 1 + "blob": 1, + "file": 1, + "web": 1 } }, "servicebus": 1 @@ -10136,6 +10744,7 @@ "*": 1 } }, + "appwrite": 1, "arvo": 1, "azimuth": 1, "co": 1, @@ -10324,7 +10933,6 @@ "bievat": 1, "bindal": 1, "birkenes": 1, - "bjarkoy": 1, "bjerkreim": 1, "bjugn": 1, "bodo": 1, @@ -10619,7 +11227,6 @@ "mosjoen": 1, "moskenes": 1, "moss": 1, - "mosvik": 1, "mr": { "c": { "gs": 1 @@ -10966,7 +11573,6 @@ "xn--bhccavuotna-k7a": 1, "xn--bidr-5nac": 1, "xn--bievt-0qa": 1, - "xn--bjarky-fya": 1, "xn--bjddar-pta": 1, "xn--blt-elab": 1, "xn--bmlo-gra": 1, @@ -11221,21 +11827,18 @@ "*": 1 } }, - "service": 1 - }, - "l": 1 - }, - "ong": { - "c": { - "obl": 1 + "service": 1, + "website": 1 }, "l": 1 }, + "ong": 1, "onion": 1, "onl": 1, "online": { "c": { "barsy": 1, + "book": 1, "eero": 1, "eero-stage": 1, "leapcell": 1, @@ -11283,7 +11886,6 @@ "collegefan": 1, "couchpotatofries": 1, "ddnss": 1, - "diskstation": 1, "dnsalias": 1, "dnsdojo": 1, "doesntexist": 1, @@ -11386,6 +11988,7 @@ "freeddns": 1, "freedesktop": 1, "from-me": 1, + "fspages": 1, "game-host": 1, "gotdns": 1, "hatenadiary": 1, @@ -11442,6 +12045,7 @@ "read-books": 1, "readmyblog": 1, "routingthecloud": 1, + "roxa": 1, "selfip": 1, "sellsyourhome": 1, "servebbs": 1, @@ -11507,13 +12111,15 @@ "c": { "aem": 1, "codeberg": 1, + "deuxfleurs": 1, "heyflow": 1, "hlx": 1, + "mybox": 1, "pdns": 1, "plesk": 1, "prvcy": 1, "rocky": 1, - "translated": 1 + "statichost": 1 }, "l": 1 }, @@ -11583,7 +12189,7 @@ "pictet": 1, "pictures": { "c": { - "1337": 1 + "0.0.5.57": 1 }, "l": 1 }, @@ -11882,7 +12488,22 @@ "play": 1, "playstation": 1, "plumbing": 1, - "plus": 1, + "plus": { + "c": { + "playit": { + "c": { + "at": { + "c": { + "*": 1 + } + }, + "with": 1 + }, + "l": 1 + } + }, + "l": 1 + }, "pm": { "c": { "name": 1, @@ -11929,7 +12550,6 @@ "prime": 1, "pro": { "c": { - "12chars": 1, "aaa": 1, "aca": 1, "acct": 1, @@ -11940,6 +12560,7 @@ "cpa": 1, "eng": 1, "jur": 1, + "keenetic": 1, "law": 1, "med": 1, "ngrok": 1, @@ -12157,7 +12778,6 @@ "int": 1, "kalmykia": 1, "kustanai": 1, - "lk3": 1, "marine": 1, "mcdir": { "c": { @@ -12248,6 +12868,7 @@ "*": 1 } }, + "needle": 1, "onporter": 1, "ravendb": 1, "repl": 1, @@ -12338,12 +12959,15 @@ "science": 1, "scot": { "c": { + "co": 1, "gov": { "c": { "service": 1 }, "l": 1 - } + }, + "me": 1, + "org": 1 }, "l": 1 }, @@ -12478,7 +13102,12 @@ }, "shopping": 1, "shouji": 1, - "show": 1, + "show": { + "c": { + "ms": 1 + }, + "l": 1 + }, "si": { "c": { "f5": 1, @@ -12505,11 +13134,19 @@ "*": 1 } }, - "convex": 1, + "co": 1, + "convex": { + "c": { + "eu-west-1": 1, + "us-east-1": 1 + }, + "l": 1 + }, "cpanel": 1, "cyon": 1, "fastvps": 1, "figma": 1, + "figma-gov": 1, "heyflow": 1, "jele": 1, "jouwweb": 1, @@ -12519,12 +13156,14 @@ "novecore": 1, "omniwe": 1, "opensocial": 1, + "piebox": 1, "platformsh": { "c": { "*": 1 } }, "preview": 1, + "sol": 1, "sourcecraft": 1, "square": 1, "srht": 1, @@ -12539,7 +13178,12 @@ "l": 1 }, "sj": 1, - "sk": 1, + "sk": { + "c": { + "org": 1 + }, + "l": 1 + }, "ski": 1, "skin": 1, "sky": 1, @@ -12565,7 +13209,6 @@ "edu": 1, "gouv": 1, "org": 1, - "perso": 1, "univ": 1 }, "l": 1 @@ -12597,6 +13240,7 @@ "space": { "c": { "app-ionos": 1, + "deployagent": 1, "heiyu": 1, "hf": { "c": { @@ -12631,6 +13275,11 @@ }, "st": { "c": { + "cn": { + "c": { + "*": 1 + } + }, "co": 1, "com": 1, "consulado": 1, @@ -12771,7 +13420,8 @@ "sydney": 1, "systems": { "c": { - "knightpoint": 1 + "knightpoint": 1, + "miren": 1 }, "l": 1 }, @@ -12915,12 +13565,13 @@ }, "to": { "c": { - "611": 1, + "0.0.2.99": 1, "com": 1, "edu": 1, "gov": 1, "mil": 1, "net": 1, + "nett": 1, "org": 1, "oya": 1, "quickconnect": { @@ -13319,6 +13970,8 @@ }, "l": 1 }, + "azure-api": 1, + "azurewebsites": 1, "ca": { "c": { "cc": 1, @@ -13544,13 +14197,7 @@ }, "l": 1 }, - "nd": { - "c": { - "cc": 1, - "lib": 1 - }, - "l": 1 - }, + "nd": 1, "ne": { "c": { "cc": 1, @@ -13634,7 +14281,6 @@ }, "l": 1 }, - "platterp": 1, "pointto": 1, "pr": { "c": { @@ -13725,9 +14371,34 @@ }, "wa": { "c": { + "aberdeen": 1, + "bainbridge-isl": 1, + "bellevue": 1, + "bremerton": 1, "cc": 1, + "centralia": 1, + "chehalis": 1, + "forks": 1, + "gig-harbor": 1, + "hoquiam": 1, "k12": 1, - "lib": 1 + "keyport": 1, + "kingston": 1, + "lib": 1, + "olympia": 1, + "port-angeles": 1, + "port-ludlow": 1, + "port-orchard": 1, + "port-townsend": 1, + "poulsbo": 1, + "redmond": 1, + "renton": 1, + "sea": 1, + "seattle": 1, + "sequim": 1, + "shelton": 1, + "silverdale": 1, + "yarrow-point": 1 }, "l": 1 }, @@ -13761,6 +14432,7 @@ "com": 1, "edu": 1, "gub": 1, + "gv": 1, "mil": 1, "net": 1, "org": 1 @@ -13815,6 +14487,7 @@ "firm": 1, "gob": 1, "gov": 1, + "ia": 1, "info": 1, "int": 1, "mil": 1, @@ -13906,6 +14579,7 @@ "haugiang": 1, "health": 1, "hoabinh": 1, + "hue": 1, "hungyen": 1, "id": 1, "info": 1, @@ -14008,9 +14682,13 @@ "wine": 1, "winners": 1, "wme": 1, - "wolterskluwer": 1, "woodside": 1, - "work": 1, + "work": { + "c": { + "imagine-proxy": 1 + }, + "l": 1 + }, "works": 1, "world": 1, "wow": 1, @@ -14254,6 +14932,9 @@ "xyz": { "c": { "botdash": 1, + "caffeine": 1, + "exe": 1, + "opentunnel": 1, "telebit": { "c": { "*": 1 @@ -14334,11 +15015,17 @@ "zone": { "c": { "lima": 1, + "prg1-zerops": 1, "stackit": 1, "triton": { "c": { "*": 1 } + }, + "zerops": { + "c": { + "*": 1 + } } }, "l": 1 diff --git a/src/background/scheduler.ts b/src/background/scheduler.ts new file mode 100644 index 000000000..8504ad578 --- /dev/null +++ b/src/background/scheduler.ts @@ -0,0 +1,91 @@ +import { MILL_PER_MINUTE } from "@util/time" +import alarmManager from "./alarm-manager" +import backupProcessor from "./service/backup/processor" +import optionHolder from './service/components/option-holder' +import notificationProcessor from "./service/notification/processor" + +const BACKUP_ALARM_NAME = 'auto-backup-data' +const NOTIFICATION_ALARM_NAME = 'notification-data' + +const needResetBackup = (newVal: tt4b.option.AllOption, oldVal: tt4b.option.AllOption): boolean => + newVal.autoBackUp !== oldVal.autoBackUp || newVal.autoBackUpInterval !== oldVal.autoBackUpInterval + +const needResetNotification = (newVal: tt4b.option.AllOption, oldVal: tt4b.option.AllOption): boolean => + newVal.notificationCycle !== oldVal.notificationCycle || newVal.notificationOffset !== oldVal.notificationOffset + +export async function initScheduler(): Promise { + optionHolder.addChangeListener((newVal, oldVal) => { + if (needResetBackup(newVal, oldVal)) resetBackup() + if (needResetNotification(newVal, oldVal)) resetNotification() + }) + + const existBackup = await alarmManager.getAlarm(BACKUP_ALARM_NAME) + !existBackup && await resetBackup() + + const existNotification = await alarmManager.getAlarm(NOTIFICATION_ALARM_NAME) + !existNotification && await resetNotification() +} + +async function resetBackup(): Promise { + // MUST read latest option from database + const option = await optionHolder.get() + + await alarmManager.remove(BACKUP_ALARM_NAME) + + const { autoBackUp, backupType, autoBackUpInterval = 0 } = option + if (backupType === 'none' || !autoBackUp || !autoBackUpInterval) { + return + } + + const interval = autoBackUpInterval * MILL_PER_MINUTE + await alarmManager.setInterval(BACKUP_ALARM_NAME, interval, async () => { + const errorMsg = await backupProcessor.syncData() + if (errorMsg) console.warn(`Failed to backup ts=${Date.now()}, msg=${errorMsg}`) + }) +} + +type OffsetHandler = (offsetMin: number) => number +const OFFSET_HANDLERS: Record, OffsetHandler> = { + daily: offset => { + const next = new Date() + next.setHours(0, offset, 0, 0) + const now = new Date() + while (next.getTime() < now.getTime()) { + next.setDate(next.getDate() + 1) + } + return next.getTime() + }, + weekly: offset => { + const next = new Date() + const weekday = next.getDay() + // Convert JS Sunday-based weekday (Sun=0) to Monday-based (Mon=0) + const mondayBasedWeekday = (weekday + 6) % 7 + next.setDate(next.getDate() - mondayBasedWeekday) + next.setHours(0, offset, 0, 0) + const now = new Date() + while (next.getTime() < now.getTime()) { + next.setDate(next.getDate() + 7) + } + return next.getTime() + } +} + +async function resetNotification(): Promise { + await alarmManager.remove(NOTIFICATION_ALARM_NAME) + + const option = await optionHolder.get() + const { notificationCycle: cycle, notificationOffset: offset } = option + + if (cycle === 'none') return + + await alarmManager.setWhen( + NOTIFICATION_ALARM_NAME, + () => OFFSET_HANDLERS[cycle](offset), + async () => { + const errMsg = await notificationProcessor.doSend() + if (errMsg) { + console.warn(`Failed to send notification ts=${Date.now()}, msg=${errMsg}`) + } + } + ) +} \ No newline at end of file diff --git a/src/background/service/2fa-service.ts b/src/background/service/2fa-service.ts new file mode 100644 index 000000000..1bd9ad889 --- /dev/null +++ b/src/background/service/2fa-service.ts @@ -0,0 +1,146 @@ +/** + * 2FA service implementation + * https://datatracker.ietf.org/doc/html/rfc6238 + * + * Copyright (c) 2026 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { getRuntimeName } from '@api/chrome/runtime' +import db from '@db/meta-database' +import { decodeBase32, decodeBase64, encodeBase32, encodeBase64 } from '@util/encode' +import { getCid } from './meta-service' + +/** + * Generate TOTP material and save to the storage + * + * @return otpauth URI + */ +export async function prepare2fa(): Promise { + const secret = generateSecret() + const issuer = getRuntimeName() + const accountName = await getCid() + const uri = buildTotpUri({ issuer, accountName, secret }) + await saveTwoFa(secret) + return uri +} + +function generateSecret(): string { + const randomBytes = new Uint8Array(20) + crypto.getRandomValues(randomBytes) + return encodeBase32(randomBytes).toLowerCase() +} + +function buildTotpUri(params: { issuer: string; accountName: string; secret: string }): string { + const { issuer, accountName, secret } = params + const label = `${issuer}:${accountName}` + const sec = secret.toUpperCase().replace(/\s/g, '') + return `otpauth://totp/${encodeURIComponent(label)}?secret=${sec}&issuer=${encodeURIComponent(issuer)}&algorithm=SHA1&digits=6&period=30` +} + +async function saveTwoFa(secret: string): Promise { + const cid = await getCid() + const meta = await db.getMeta() + meta.twoFa = await encrypt(secret, cid) + await db.update(meta) +} + +async function encrypt(plaintext: string, cid: string): Promise { + const encoder = new TextEncoder() + const dataBuffer = encoder.encode(plaintext) + + const salt = crypto.getRandomValues(new Uint8Array(16)) + const iv = crypto.getRandomValues(new Uint8Array(12)) + + const keyMaterial = await crypto.subtle.importKey( + 'raw', + encoder.encode(cid), + { name: 'PBKDF2' }, + false, + ['deriveKey'] + ) + + const key = await crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: salt, iterations: 100000, hash: 'SHA-256' }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['encrypt', 'decrypt'] + ) + + const cipherText = await crypto.subtle.encrypt( + { name: 'AES-GCM', iv: iv }, + key, + dataBuffer + ) + + return { + salt: encodeBase64(salt), + iv: encodeBase64(iv), + secret: encodeBase64(new Uint8Array(cipherText)), + } +} + +export async function check2faCode(code: string): Promise { + const { twoFa } = await db.getMeta() + if (!twoFa) return false + + const cid = await getCid() + const secret = await decryptSecret(twoFa, cid) + return verifyTotp(secret, code) +} + +async function decryptSecret(twoFa: tt4b.TwoFactorAuth, cid: string): Promise { + const encoder = new TextEncoder() + const keyMaterial = await crypto.subtle.importKey( + 'raw', encoder.encode(cid), { name: 'PBKDF2' }, false, ['deriveKey'] + ) + const key = await crypto.subtle.deriveKey( + { name: 'PBKDF2', salt: decodeBase64(twoFa.salt), iterations: 100000, hash: 'SHA-256' }, + keyMaterial, + { name: 'AES-GCM', length: 256 }, + false, + ['decrypt'] + ) + const plain = await crypto.subtle.decrypt( + { name: 'AES-GCM', iv: decodeBase64(twoFa.iv) }, + key, + decodeBase64(twoFa.secret), + ) + return new TextDecoder().decode(plain) +} + +async function verifyTotp(secretBase32: string, code: string): Promise { + const normalized = code.replace(/\s/g, '') + if (!/^\d{6}$/.test(normalized)) return false + const step = Math.floor(Date.now() / 1000 / 30) + for (const delta of [0, -1, 1]) { + const candidate = await genTotp(secretBase32, step + delta) + if (candidate === normalized) return true + } + return false +} + +async function genTotp(secretBase32: string, step: number): Promise { + const key = decodeBase32(secretBase32) + const msg = new Uint8Array(8) + let t = step + for (let i = 7; i >= 0; i--) { + msg[i] = t & 0xff + t >>>= 8 + } + + const hmacKey = await crypto.subtle.importKey( + 'raw', key.slice(), { name: 'HMAC', hash: 'SHA-1' }, false, ['sign'] + ) + const sigBuffer = await crypto.subtle.sign('HMAC', hmacKey, msg) + const sig = new Uint8Array(sigBuffer) + const offset = sig[sig.length - 1]! & 0x0f + const otp = ((sig[offset]! & 0x7f) << 24 + | sig[offset + 1]! << 16 + | sig[offset + 2]! << 8 + | sig[offset + 3]!) % 1_000_000 + return otp.toString().padStart(6, '0') +} diff --git a/src/service/backup/common.ts b/src/background/service/backup/common.ts similarity index 100% rename from src/service/backup/common.ts rename to src/background/service/backup/common.ts diff --git a/src/service/backup/gist/compressor.ts b/src/background/service/backup/gist/compressor.ts similarity index 87% rename from src/service/backup/gist/compressor.ts rename to src/background/service/backup/gist/compressor.ts index f9e6a045d..f70702255 100644 --- a/src/service/backup/gist/compressor.ts +++ b/src/background/service/backup/gist/compressor.ts @@ -21,14 +21,14 @@ export type GistData = { /** * Row stored in the gist */ -export type GistRow = { +type GistRow = { [host: string]: [ number, // Visit count number, // Browsing time ] } -function calcGroupKey(row: timer.core.Row): string | undefined { +function calcGroupKey(row: tt4b.core.Row): string | undefined { const date = row.date if (!date) { return undefined @@ -41,7 +41,7 @@ function calcGroupKey(row: timer.core.Row): string | undefined { * * @param rows row array */ -function compress(rows: timer.core.Row[]): GistData { +function compress(rows: tt4b.core.Row[]): GistData { const result: GistData = groupBy( rows, row => row.date.substring(6), @@ -59,7 +59,7 @@ function compress(rows: timer.core.Row[]): GistData { * * @returns [bucket, data][] */ -export function divide2Buckets(rows: timer.core.Row[]): [string, GistData][] { +export function divide2Buckets(rows: tt4b.core.Row[]): [string, GistData][] { const grouped: { [yearAndPart: string]: GistData } = groupBy(rows.filter(r => !!r), calcGroupKey, compress) return Object.entries(grouped) } @@ -88,13 +88,13 @@ export function calcAllBuckets(startDate: string | undefined, endDate: string | * @param gistData gistData * @returns rows */ -export function gistData2Rows(yearMonth: string, gistData: GistData): timer.core.Row[] { - const result: timer.core.Row[] = [] +export function gistData2Rows(yearMonth: string, gistData: GistData): tt4b.core.Row[] { + const result: tt4b.core.Row[] = [] Object.entries(gistData).forEach(([dateOfMonth, gistRow]) => { const date = yearMonth + dateOfMonth Object.entries(gistRow).forEach(([host, val]) => { const [time, focus] = val - const row: timer.core.Row = { + const row: tt4b.core.Row = { date, host, time, diff --git a/src/service/backup/gist/coordinator.ts b/src/background/service/backup/gist/coordinator.ts similarity index 83% rename from src/service/backup/gist/coordinator.ts rename to src/background/service/backup/gist/coordinator.ts index 80226a3a9..05aa0fefd 100644 --- a/src/service/backup/gist/coordinator.ts +++ b/src/background/service/backup/gist/coordinator.ts @@ -11,7 +11,7 @@ import { } from "@api/gist" import { SOURCE_CODE_PAGE } from "@util/constant/url" import MonthIterator from "@util/month-iterator" -import { formatTimeYMD } from "@util/time" +import { getBirthday, parseTime } from "@util/time" import { calcAllBuckets, divide2Buckets, gistData2Rows, type GistData } from "./compressor" const TIMER_META_GIST_DESC = "Used for timer to save meta info. Don't change this description :)" @@ -48,7 +48,7 @@ function bucket2filename(bucket: string, cid: string) { return `${bucket}_${cid}.json` } -function filterDate(row: timer.core.Row, start: string, end: string) { +function filterDate(row: tt4b.core.Row, start: string, end: string) { const { date } = row if (!date) return false if (start && date < start) return false @@ -56,7 +56,7 @@ function filterDate(row: timer.core.Row, start: string, end: string) { return true } -function checkTokenExist(context: timer.backup.CoordinatorContext): string { +function checkTokenExist(context: tt4b.backup.CoordinatorContext): string { const token = context.auth?.token if (!token) { throw new Error("Token must not be empty. This can't happen, please contact to the developer") @@ -64,10 +64,10 @@ function checkTokenExist(context: timer.backup.CoordinatorContext): strin return token } -export default class GistCoordinator implements timer.backup.Coordinator { +export default class GistCoordinator implements tt4b.backup.Coordinator { async updateClients( - context: timer.backup.CoordinatorContext, - clients: timer.backup.Client[], + context: tt4b.backup.CoordinatorContext, + clients: tt4b.backup.Client[], ): Promise { const gist = await this.getMetaGist(context) if (!gist) { @@ -82,7 +82,7 @@ export default class GistCoordinator implements timer.backup.Coordinator await updateGist(checkTokenExist(context), gist.id, { description: gist.description, public: false, files }) } - async listAllClients(context: timer.backup.CoordinatorContext): Promise { + async listAllClients(context: tt4b.backup.CoordinatorContext): Promise { const gist = await this.getMetaGist(context) if (!gist) { return [] @@ -91,11 +91,11 @@ export default class GistCoordinator implements timer.backup.Coordinator return file ? await getJsonFileContent(file) ?? [] : [] } - async download(context: timer.backup.CoordinatorContext, startTime: Date, endTime: Date, targetCid?: string): Promise { - const allYearMonth = new MonthIterator(startTime, endTime || new Date()).toArray() - const result: timer.core.Row[] = [] - const start = formatTimeYMD(startTime) - const end = formatTimeYMD(endTime) + async download(context: tt4b.backup.CoordinatorContext, start: string, end: string, targetCid?: string): Promise { + const startTime = parseTime(start) ?? getBirthday() + const endTime = parseTime(end) ?? new Date() + const allYearMonth = new MonthIterator(startTime, endTime).toArray() + const result: tt4b.core.Row[] = [] await Promise.all(allYearMonth.map(async yearMonth => { const filename = bucket2filename(yearMonth, targetCid || context.cid) const gist: Gist = await this.getStatGist(context) @@ -110,7 +110,7 @@ export default class GistCoordinator implements timer.backup.Coordinator return result } - async upload(context: timer.backup.CoordinatorContext, rows: timer.core.Row[]): Promise { + async upload(context: tt4b.backup.CoordinatorContext, rows: tt4b.core.Row[]): Promise { const cid = context.cid const buckets = divide2Buckets(rows) const gist = await this.getStatGist(context) @@ -126,7 +126,7 @@ export default class GistCoordinator implements timer.backup.Coordinator files: files2Update, description: TIMER_DATA_GIST_DESC } - updateGist(checkTokenExist(context), gist.id, gist2update) + await updateGist(checkTokenExist(context), gist.id, gist2update) } private isTargetMetaGist(gist: Gist): boolean { @@ -137,7 +137,7 @@ export default class GistCoordinator implements timer.backup.Coordinator return gist.description === TIMER_DATA_GIST_DESC } - private async getMetaGist(context: timer.backup.CoordinatorContext): Promise { + private async getMetaGist(context: tt4b.backup.CoordinatorContext): Promise { const gistId = context.cache.metaGistId const token = checkTokenExist(context) // 1. Find by id @@ -165,7 +165,7 @@ export default class GistCoordinator implements timer.backup.Coordinator return created } - private async getStatGist(context: timer.backup.CoordinatorContext): Promise { + private async getStatGist(context: tt4b.backup.CoordinatorContext): Promise { const gistId = context.cache.statGistId const token = checkTokenExist(context) // 1. Find by id @@ -192,13 +192,13 @@ export default class GistCoordinator implements timer.backup.Coordinator return created } - async testAuth(auth: timer.backup.Auth): Promise { + async testAuth(auth: tt4b.backup.Auth): Promise { const { token } = auth if (!token) return 'Token is empty' return testToken(token) } - async clear(context: timer.backup.CoordinatorContext, client: timer.backup.Client): Promise { + async clear(context: tt4b.backup.CoordinatorContext, client: tt4b.backup.Client): Promise { // 1. Find the names of file to delete const { minDate, maxDate, id: cid } = client || {} const allBuckets = calcAllBuckets(minDate, maxDate) diff --git a/src/service/backup/markdown.ts b/src/background/service/backup/markdown.ts similarity index 90% rename from src/service/backup/markdown.ts rename to src/background/service/backup/markdown.ts index 2f3c9a961..0165f50eb 100644 --- a/src/service/backup/markdown.ts +++ b/src/background/service/backup/markdown.ts @@ -10,7 +10,7 @@ import { formatPeriodCommon } from "@util/time" export const CLIENT_FILE_NAME = "clients_no_modify.md" -const CLIENT_FIELDS: MarkdownTableField[] = [ +const CLIENT_FIELDS: MarkdownTableField[] = [ { name: "Client Id", formatter: r => r.id, @@ -42,7 +42,7 @@ function genJsonLine(data: any): string { return `` } -export function convertClients2Markdown(clients: timer.backup.Client[]): string { +export function convertClients2Markdown(clients: tt4b.backup.Client[]): string { return genMarkdownTable(clients, CLIENT_FIELDS) } @@ -82,7 +82,7 @@ function genMarkdownTable(list: T[], fields: MarkdownTableField[]): string return lines.join('\n') } -const ROW_FIELDS: MarkdownTableField[] = [ +const ROW_FIELDS: MarkdownTableField[] = [ { name: "Date", formatter: r => r.date, @@ -98,7 +98,7 @@ const ROW_FIELDS: MarkdownTableField[] = [ }, ] -export function divideByDate(rows: timer.core.Row[]): { [date: string]: string } { +export function divideByDate(rows: tt4b.core.Row[]): { [date: string]: string } { return groupBy(rows, row => row.date, list => genMarkdownTable(list, ROW_FIELDS)) } diff --git a/src/service/backup/obsidian/coordinator.ts b/src/background/service/backup/obsidian/coordinator.ts similarity index 76% rename from src/service/backup/obsidian/coordinator.ts rename to src/background/service/backup/obsidian/coordinator.ts index 3a96a8eeb..56263f75f 100644 --- a/src/service/backup/obsidian/coordinator.ts +++ b/src/background/service/backup/obsidian/coordinator.ts @@ -9,10 +9,11 @@ import { updateFile } from "@api/obsidian" import DateIterator from "@util/date-iterator" +import { getBirthday, parseTime } from '@util/time' import { processDir } from "../common" import { CLIENT_FILE_NAME, convertClients2Markdown, divideByDate, parseData } from "../markdown" -function prepareContext(context: timer.backup.CoordinatorContext) { +function prepareContext(context: tt4b.backup.CoordinatorContext) { const { auth, ext, cid } = context const { token } = auth || {} if (!token) { @@ -24,16 +25,16 @@ function prepareContext(context: timer.backup.CoordinatorContext) { return { ctx, dirPath, cid } } -export default class ObsidianCoordinator implements timer.backup.Coordinator { +export default class ObsidianCoordinator implements tt4b.backup.Coordinator { - async updateClients(context: timer.backup.CoordinatorContext, clients: timer.backup.Client[]): Promise { + async updateClients(context: tt4b.backup.CoordinatorContext, clients: tt4b.backup.Client[]): Promise { const { ctx, dirPath } = prepareContext(context) const clientFilePath = `${dirPath}${CLIENT_FILE_NAME}` const content = convertClients2Markdown(clients) await updateFile(ctx, clientFilePath, content) } - async listAllClients(context: timer.backup.CoordinatorContext): Promise { + async listAllClients(context: tt4b.backup.CoordinatorContext): Promise { const { ctx, dirPath } = prepareContext(context) const clientFilePath = `${dirPath}${CLIENT_FILE_NAME}` try { @@ -45,21 +46,23 @@ export default class ObsidianCoordinator implements timer.backup.Coordinator, dateStart: Date, dateEnd: Date, targetCid?: string): Promise { + async download(context: tt4b.backup.CoordinatorContext, start: string, end: string, targetCid?: string): Promise { const { ctx, dirPath, cid } = prepareContext(context) - const dateIterator = new DateIterator(dateStart, dateEnd) - const result: timer.core.Row[] = [] + const startTime = parseTime(start) ?? getBirthday() + const endTime = parseTime(end) ?? new Date() + const dateIterator = new DateIterator(startTime, endTime) + const result: tt4b.core.Row[] = [] await Promise.all(dateIterator.toArray().map(async date => { const filePath = `${dirPath}${targetCid || cid}/${date}.md` const fileContent = await getFileContent(ctx, filePath) - const rows = parseData(fileContent) + const rows = parseData(fileContent) rows?.forEach?.(row => result.push(row)) })) return result } - async upload(context: timer.backup.CoordinatorContext, rows: timer.core.Row[]): Promise { + async upload(context: tt4b.backup.CoordinatorContext, rows: tt4b.core.Row[]): Promise { const { ctx, dirPath, cid } = prepareContext(context) const dateAndContents = divideByDate(rows) @@ -71,7 +74,7 @@ export default class ObsidianCoordinator implements timer.backup.Coordinator { + async testAuth(authInfo: tt4b.backup.Auth, ext: tt4b.backup.TypeExt): Promise { let { endpoint, dirPath, bucket } = ext || {} let { token: auth } = authInfo || {} dirPath = processDir(dirPath) @@ -102,7 +105,7 @@ export default class ObsidianCoordinator implements timer.backup.Coordinator, client: timer.backup.Client): Promise { + async clear(context: tt4b.backup.CoordinatorContext, client: tt4b.backup.Client): Promise { const cid = client.id const { ctx, dirPath } = prepareContext(context) const clientDirPath = `${dirPath}${cid}/` diff --git a/src/background/service/backup/processor.ts b/src/background/service/backup/processor.ts new file mode 100644 index 000000000..23fae06d0 --- /dev/null +++ b/src/background/service/backup/processor.ts @@ -0,0 +1,195 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import syncDb from "@db/backup-database" +import statDb from "@db/stat-database" +import optionHolder from "../components/option-holder" +import { getCid, updateBackUpTime } from "../meta-service" +import GistCoordinator from "./gist/coordinator" +import ObsidianCoordinator from "./obsidian/coordinator" +import WebDAVCoordinator from "./web-dav/coordinator" + +type AuthCheckResult = { + option: tt4b.option.BackupOption + auth: tt4b.backup.Auth + ext: tt4b.backup.TypeExt + type: tt4b.backup.Type + coordinator: tt4b.backup.Coordinator + errorMsg?: string +} + +class CoordinatorContextWrapper implements tt4b.backup.CoordinatorContext { + auth: tt4b.backup.Auth + ext?: tt4b.backup.TypeExt + cache: Cache = {} as unknown as Cache + type: tt4b.backup.Type + cid: string + + constructor(cid: string, auth: tt4b.backup.Auth, ext: tt4b.backup.TypeExt, type: tt4b.backup.Type) { + this.cid = cid + this.auth = auth + this.ext = ext + this.type = type + } + + async init(): Promise> { + this.cache = await syncDb.getCache(this.type) as Cache + return this + } + + handleCacheChanged(): Promise { + return syncDb.updateCache(this.type, this.cache) + } +} + +async function syncFull( + context: tt4b.backup.CoordinatorContext, + coordinator: tt4b.backup.Coordinator, + client: tt4b.backup.Client +): Promise { + // 1. select rows + const rows = await statDb.select() + const allDates = rows.map(r => r.date).sort((a, b) => a == b ? 0 : a > b ? 1 : -1) + client.maxDate = allDates[allDates.length - 1] + client.minDate = allDates[0] + // 2. upload + await coordinator.upload(context, rows) +} + +function filterClient(c: tt4b.backup.Client, excludeLocal: boolean, localClientId: string, start?: string, end?: string) { + // Exclude local client + if (excludeLocal && c.id === localClientId) return false + // Judge range + if (start && c.maxDate && c.maxDate < start) return false + if (end && c.minDate && c.minDate > end) return false + return true +} + +function prepareAuth(option: tt4b.option.BackupOption): tt4b.backup.Auth { + const type = option?.backupType || 'none' + const token = option?.backupAuths?.[type] + const login = option.backupLogin?.[type] + 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(), + } + } + + async syncData(): Promise { + const { option, auth, ext, type, coordinator, errorMsg } = await this.checkAuth() + if (errorMsg) return errorMsg + + const cid = await getCid() + const context: tt4b.backup.CoordinatorContext = await new CoordinatorContextWrapper(cid, auth, ext, type).init() + const client: tt4b.backup.Client = { + id: cid, + name: option.clientName, + minDate: undefined, + maxDate: undefined + } + try { + await syncFull(context, coordinator, client) + const clients = (await coordinator.listAllClients(context)).filter(a => a.id !== cid) + clients.push(client) + await coordinator.updateClients(context, clients) + // Update time + await updateBackUpTime(type, Date.now()) + } catch (e) { + console.error("Error to sync data", e) + return e instanceof Error ? e.message : String(e ?? 'Unknown Error') + } + } + + async listClients(): Promise<(tt4b.backup.Client & { current: boolean })[]> { + const { auth, ext, type, coordinator, errorMsg } = await this.checkAuth() + if (errorMsg) throw new Error(errorMsg) + const cid = await getCid() + const context = await new CoordinatorContextWrapper(cid, auth, ext, type).init() + const clients = await coordinator.listAllClients(context) + return clients.map(c => ({ ...c, current: c.id === cid })) + } + + async checkAuth(): Promise { + const option = await optionHolder.get() + const { backupType: type, backupExts } = option + 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 + try { + errorMsg = await coordinator.testAuth(auth, ext) + } catch (e) { + errorMsg = (e as Error)?.message || '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 { 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() + // 2. query all clients, and filter them + const allClients = (await coordinator.listAllClients(context)) + .filter(c => filterClient(c, !!excludeLocal, localCid, start, end)) + .filter(c => !specCid || c.id === specCid) + // 3. iterate clients + const result: tt4b.backup.Row[] = [] + await Promise.all( + allClients.map(async client => { + const { id, name } = client + const rows = await coordinator.download(context, start, end, id) + rows.forEach(row => result.push({ + ...row, + cid: id, + cname: name, + })) + }) + ) + console.log(`Queried ${result.length} remote items`) + return result + } + + async clear(cid: string): Promise { + const { auth, ext, type, coordinator, errorMsg } = await this.checkAuth() + if (errorMsg) return errorMsg + let localCid = await getCid() + const context: tt4b.backup.CoordinatorContext = await new CoordinatorContextWrapper(localCid, auth, ext, type).init() + // 1. Find the client + const allClients = await coordinator.listAllClients(context) + const client = allClients?.filter(c => c?.id === cid)?.[0] + if (!client) return + // 2. clear + await coordinator.clear(context, client) + // 3. remove client + const newClients = allClients.filter(c => c?.id !== cid) + await coordinator.updateClients(context, newClients) + } +} + +export default new Processor() \ No newline at end of file diff --git a/src/service/backup/web-dav/coordinator.ts b/src/background/service/backup/web-dav/coordinator.ts similarity index 74% rename from src/service/backup/web-dav/coordinator.ts rename to src/background/service/backup/web-dav/coordinator.ts index a8a040aa6..07c116c04 100644 --- a/src/service/backup/web-dav/coordinator.ts +++ b/src/background/service/backup/web-dav/coordinator.ts @@ -1,12 +1,13 @@ import { - deleteDir, judgeDirExist, makeDir, readFile, writeFile, + deleteDir, judgeDirExist, makeDirs, readFile, writeFile, type WebDAVAuth, type WebDAVContext, } from "@api/web-dav" import DateIterator from "@util/date-iterator" +import { getBirthday, parseTime } from '@util/time' import { processDir } from "../common" import { CLIENT_FILE_NAME, convertClients2Markdown, divideByDate, parseData } from "../markdown" -function getEndpoint(ext: timer.backup.TypeExt | undefined): string | undefined { +function getEndpoint(ext: tt4b.backup.TypeExt | undefined): string | undefined { let { endpoint } = ext || {} if (endpoint?.endsWith('/')) { endpoint = endpoint.substring(0, endpoint.length - 1) @@ -14,7 +15,7 @@ function getEndpoint(ext: timer.backup.TypeExt | undefined): string | undefined return endpoint } -function prepareContext(context: timer.backup.CoordinatorContext): WebDAVContext { +function prepareContext(context: tt4b.backup.CoordinatorContext): WebDAVContext { const { auth, ext } = context const endpoint = getEndpoint(ext) if (!endpoint) { @@ -28,8 +29,8 @@ function prepareContext(context: timer.backup.CoordinatorContext): WebDAV return { auth: webDavAuth, endpoint } } -export default class WebDAVCoordinator implements timer.backup.Coordinator { - async updateClients(context: timer.backup.CoordinatorContext, clients: timer.backup.Client[]): Promise { +export default class WebDAVCoordinator implements tt4b.backup.Coordinator { + async updateClients(context: tt4b.backup.CoordinatorContext, clients: tt4b.backup.Client[]): Promise { const dirPath = processDir(context?.ext?.dirPath) const clientFilePath = `${dirPath}${CLIENT_FILE_NAME}` const content = convertClients2Markdown(clients) @@ -37,7 +38,7 @@ export default class WebDAVCoordinator implements timer.backup.Coordinator): Promise { + async listAllClients(context: tt4b.backup.CoordinatorContext): Promise { const dirPath = processDir(context?.ext?.dirPath) const clientFilePath = `${dirPath}${CLIENT_FILE_NAME}` const davContext = prepareContext(context) @@ -50,23 +51,25 @@ export default class WebDAVCoordinator implements timer.backup.Coordinator, dateStart: Date, dateEnd: Date, targetCid?: string): Promise { + async download(context: tt4b.backup.CoordinatorContext, start: string, end: string, targetCid?: string): Promise { const dirPath = processDir(context?.ext?.dirPath) const davContext = prepareContext(context) targetCid = targetCid || context?.cid + const dateStart = parseTime(start) ?? getBirthday() + const dateEnd = parseTime(end) ?? new Date() const dateIterator = new DateIterator(dateStart, dateEnd) - const result: timer.core.Row[] = [] + const result: tt4b.core.Row[] = [] await Promise.all(dateIterator.toArray().map(async date => { const filePath = `${dirPath}${targetCid}/${date}.md` const fileContent = await readFile(davContext, filePath) - const rows = parseData(fileContent) + const rows = parseData(fileContent) rows?.forEach?.(row => result.push(row)) })) return result } - async upload(context: timer.backup.CoordinatorContext, rows: timer.core.Row[]): Promise { + async upload(context: tt4b.backup.CoordinatorContext, rows: tt4b.core.Row[]): Promise { const dateAndContents = divideByDate(rows) const dirPath = processDir(context?.ext?.dirPath) const cid = context?.cid @@ -84,14 +87,11 @@ export default class WebDAVCoordinator implements timer.backup.Coordinator { + async testAuth(auth: tt4b.backup.Auth, ext: tt4b.backup.TypeExt): Promise { const endpoint = getEndpoint(ext) if (!endpoint) { return "The endpoint is blank" @@ -111,16 +111,17 @@ export default class WebDAVCoordinator implements timer.backup.Coordinator, client: timer.backup.Client): Promise { + async clear(context: tt4b.backup.CoordinatorContext, client: tt4b.backup.Client): Promise { const cid = client.id const dirPath = processDir(context.ext?.dirPath) const davContext = prepareContext(context) diff --git a/src/service/components/host-merge-ruler.ts b/src/background/service/components/host-merge-ruler.ts similarity index 82% rename from src/service/components/host-merge-ruler.ts rename to src/background/service/components/host-merge-ruler.ts index 046d06495..96d2e94f2 100644 --- a/src/service/components/host-merge-ruler.ts +++ b/src/background/service/components/host-merge-ruler.ts @@ -5,8 +5,9 @@ * https://opensource.org/licenses/MIT */ +import { getPsl } from '@bg/psl' +import FIFOCache from '@util/fifo-cache' import { isIpAndPort, judgeVirtualFast } from "@util/pattern" -import { get } from "@util/psl" /** * @param origin origin host @@ -42,7 +43,7 @@ const processRegStr = (regStr: string) => regStr .split('**').join('.+') .split('*').join('[^\\.]+') -function convert(dbItem: timer.merge.Rule): RegRuleItem | [string, string | number] { +function convert(dbItem: tt4b.merge.Rule): RegRuleItem | [string, string | number] { const { origin, merged } = dbItem if (origin.includes('*')) { const regStr = processRegStr(origin) @@ -58,9 +59,9 @@ export default class CustomizedHostMergeRuler { private regulars: RegRuleItem[] = [] - private cache: Record = {} + private cache: FIFOCache = new FIFOCache(500) - constructor(rules: timer.merge.Rule[]) { + constructor(rules: tt4b.merge.Rule[]) { rules.map(item => convert(item)) .forEach(rule => Array.isArray(rule) ? (this.noRegMergeRules[rule[0]] = rule[1] || rule[0]) @@ -68,9 +69,10 @@ export default class CustomizedHostMergeRuler { } merge(origin: string): string { - let result = this.cache[origin] + let result = this.cache.get(origin) if (result) return result - result = this.cache[origin] = this.mergeInner(origin) + result = this.mergeInner(origin) + this.cache.set(origin, result) return result } @@ -79,12 +81,10 @@ export default class CustomizedHostMergeRuler { * @returns merged host */ private mergeInner(origin: string): string { - let host = origin + let host: string | undefined = origin if (judgeVirtualFast(origin)) { host = origin.split('/')?.[0] - if (!host) { - return origin - } + if (!host) return origin } // First check the static rules let merged = this.noRegMergeRules[host] @@ -94,9 +94,7 @@ export default class CustomizedHostMergeRuler { matchResult && (merged = matchResult.result) if (merged === undefined) { // No rule matched - return isIpAndPort(host) - ? host - : get(host) || this.merge0(2, host) + return isIpAndPort(host) ? host : (getPsl(host) ?? this.merge0(2, host)) } else { return this.merge0(merged, host) } diff --git a/src/background/service/components/immigration.ts b/src/background/service/components/immigration.ts new file mode 100644 index 000000000..b1cf58c17 --- /dev/null +++ b/src/background/service/components/immigration.ts @@ -0,0 +1,54 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import packageInfo from "@/package" +import limitDatabase from "@db/limit-database" +import mergeRuleDatabase from "@db/merge-rule-database" +import statDatabase from "@db/stat-database" +import type { BrowserMigratable, StorageMigratable } from '@db/types' +import whitelistDatabase from "@db/whitelist-database" + +const BROWSER_MIGRATABLES: BrowserMigratable[] = [ + statDatabase, + limitDatabase, + mergeRuleDatabase, + whitelistDatabase, +] + +const STORAGE_MIGRATABLES: StorageMigratable[] = [ + statDatabase, +] + +export async function exportData(): Promise { + const data: tt4b.backup.ExportData = { + __meta__: { version: packageInfo.version, ts: Date.now() }, + } + for (const migratable of BROWSER_MIGRATABLES) { + const namespace = migratable.namespace + const dataAny = data as any + dataAny[namespace] = await migratable.exportData() + } + return data +} + +export async function importData(data: unknown): Promise { + for (const db of BROWSER_MIGRATABLES) await db.importData(data) +} + +export async function migrateStorage(type: tt4b.option.StorageType): Promise { + const dataList: unknown[] = [] + // 1. migrate all the databases firstly + for (const migratable of STORAGE_MIGRATABLES) { + const data = await migratable.migrateStorage(type) + dataList.push(data) + } + // 2. after migration + for (const migratable of STORAGE_MIGRATABLES) { + const [data] = dataList.splice(0, 1) + await migratable.afterStorageMigrated(data) + } +} diff --git a/src/background/service/components/import-processor.ts b/src/background/service/components/import-processor.ts new file mode 100644 index 000000000..52551a1da --- /dev/null +++ b/src/background/service/components/import-processor.ts @@ -0,0 +1,50 @@ +/** + * Copyright (c) 2023 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import statDatabase from "@db/stat-database" +import { mergeWith } from '@util/stat' +import backupProcessor from "../backup/processor" + +export async function importOther(query: tt4b.imported.ProcessQuery): Promise { + const { data, resolution } = query + if (resolution === 'overwrite') { + return processOverwrite(data) + } + return processAcc(data) +} + +async function processOverwrite(data: tt4b.imported.Data): Promise { + const { rows, focus, time } = data + const exist = await statDatabase.batchSelect(rows) + await mergeWith(rows, exist, async (row, exist) => { + focus && exist?.focus && (row.focus = exist.focus) + time && exist?.time && (row.time = exist.time) + await statDatabase.forceUpdate(row) + }) +} + +async function processAcc(data: tt4b.imported.Data): Promise { + const { rows } = data + await Promise.all(rows.map(async row => { + const { host, date, focus = 0, time = 0 } = row + await statDatabase.accumulate(host, date, { focus, time }) + })) +} + + +export async function previewBackup(param: tt4b.backup.RemoteQuery): Promise { + const remoteRows = await backupProcessor.query(param) + const rows: tt4b.imported.Row[] = remoteRows.map(rr => ({ + date: rr.date, + host: rr.host, + focus: rr.focus, + time: rr.time, + })) + const exists = await statDatabase.batchSelect(rows) + await mergeWith(rows, exists, (r, exist) => { r.exist = exist }) + return rows +} \ No newline at end of file diff --git a/src/background/service/components/option-holder.ts b/src/background/service/components/option-holder.ts new file mode 100644 index 000000000..5a9a8d966 --- /dev/null +++ b/src/background/service/components/option-holder.ts @@ -0,0 +1,40 @@ +import { onPermRemoved } from "@api/chrome/permission" +import db from "@db/option-database" +import { defaultOption } from '@util/constant/option' + +type ChangeListener = (newVal: tt4b.option.DefaultOption, oldVal: tt4b.option.DefaultOption) => void + +class OptionHolder { + private value: tt4b.option.DefaultOption | undefined + private listeners: ChangeListener[] = [] + + constructor() { + onPermRemoved(perm => { + perm.permissions?.includes('tabGroups') && this.set({ countTabGroup: false }) + }) + } + + private async reset(): Promise { + const latest = Object.assign(defaultOption(), await db.getOption()) + this.value = latest + return latest + } + + async get(): Promise { + return this.value ?? await this.reset() + } + + addChangeListener(listener: ChangeListener) { + listener && this.listeners.push(listener) + } + + async set(option: Partial): Promise { + const exist = await this.get() + const toSet = Object.assign(defaultOption(), exist, option) + await db.setOption(toSet) + this.value = toSet + this.listeners.forEach(listener => listener(toSet, exist)) + } +} + +export default new OptionHolder() \ No newline at end of file diff --git a/src/service/components/page-info.ts b/src/background/service/components/page-info.ts similarity index 93% rename from src/service/components/page-info.ts rename to src/background/service/components/page-info.ts index a36fb7621..ccaf0e3f7 100644 --- a/src/service/components/page-info.ts +++ b/src/background/service/components/page-info.ts @@ -11,7 +11,7 @@ const DEFAULT_PAGE_SIZE = 10 /** * Slice the origin list to page */ -export function slicePageResult(originList: T[], pageQuery?: timer.common.PageQuery): timer.common.PageResult { +export function slicePageResult(originList: T[], pageQuery?: tt4b.common.PageQuery): tt4b.common.PageResult { let { num: pageNum = DEFAULT_PAGE_NUM, size: pageSize = DEFAULT_PAGE_SIZE } = pageQuery || {} pageNum < 1 && (pageNum = DEFAULT_PAGE_NUM) pageSize < 1 && (pageSize = DEFAULT_PAGE_SIZE) diff --git a/src/service/components/period-calculator.ts b/src/background/service/components/period-calculator.ts similarity index 50% rename from src/service/components/period-calculator.ts rename to src/background/service/components/period-calculator.ts index 29883c414..5bed6ccd4 100644 --- a/src/service/components/period-calculator.ts +++ b/src/background/service/components/period-calculator.ts @@ -6,14 +6,14 @@ */ import { sum } from "@util/array" -import { after, compare, indexOf, keyOf, lastKeyOfLastDate, rowOf, startOfKey } from "@util/period" +import { after, compare, indexOf, keyOf, rowOf, startOfKey } from "@util/period" /** * @param timestamp current ts * @param milliseconds milliseconds * @returns results, can't be empty if milliseconds is positive */ -export function calculate(timestamp: number, milliseconds: number): timer.period.Result[] { +export function calculate(timestamp: number, milliseconds: number): tt4b.period.Result[] { if (milliseconds <= 0) return [] const key = keyOf(timestamp) @@ -21,7 +21,7 @@ export function calculate(timestamp: number, milliseconds: number): timer.period const currentResult = { ...key, milliseconds: 0 } const extraMill = timestamp - start - const result: timer.period.Result[] = [] + const result: tt4b.period.Result[] = [] if (extraMill < milliseconds) { // milliseconds including before period // 1st. add before ones @@ -37,49 +37,26 @@ export function calculate(timestamp: number, milliseconds: number): timer.period return result } -/** - * Found the max divisible period - * - * @param period key - * @param periodWindowSize divisor - */ -export function getMaxDivisiblePeriod(period: timer.period.Key, periodWindowSize: number): timer.period.Key { - const maxOrder = period.order - let order = -1 - while (order <= maxOrder) order += periodWindowSize - order -= periodWindowSize - if (order === -1) return lastKeyOfLastDate(period) - period.order = order - return period -} +export function merge(periods: tt4b.period.Result[], size: number): tt4b.period.Row[] { + periods = periods.sort(compare) + const first = periods[0] + const last = periods[periods.length - 1] + if (!first || !last) return [] -export type MergeConfig = { - periodSize: number - /** - * Inclusive - */ - start: timer.period.Key - /** - * Inclusive - */ - end: timer.period.Key -} - -export function merge(periods: timer.period.Result[], config: MergeConfig): timer.period.Row[] { - if (!periods?.length) return [] - const result: timer.period.Row[] = [] - let { start, end, periodSize } = config const map: Map = new Map() periods.forEach(p => map.set(indexOf(p), p.milliseconds)) + let mills: number[] = [] - for (; compare(start, end) <= 0; start = after(start, 1)) { + let start: tt4b.period.Key = first + const rows: tt4b.period.Row[] = [] + for (; compare(start, last) <= 0; start = after(start, 1)) { mills.push(map.get(indexOf(start)) ?? 0) - const isEndOfWindow = (start.order % periodSize) === periodSize - 1 + const isEndOfWindow = (start.order % size) === size - 1 if (isEndOfWindow) { - const isFullWindow = mills.length === periodSize - isFullWindow && result.push(rowOf(start, periodSize, sum(mills))) + const isFullWindow = mills.length === size + isFullWindow && rows.push(rowOf(start, size, sum(mills))) mills = [] } } - return result + return rows } \ No newline at end of file diff --git a/src/background/service/components/virtual-site-holder.ts b/src/background/service/components/virtual-site-holder.ts new file mode 100644 index 000000000..def14a533 --- /dev/null +++ b/src/background/service/components/virtual-site-holder.ts @@ -0,0 +1,39 @@ +import db from "@db/site-database" +import { compileAntPattern } from '@util/pattern' + +/** + * The singleton implementation of virtual sites holder + * + * @since 1.6.0 + */ +class VirtualSiteHolder { + hostRegMap: Record = {} + + constructor() { + db.select().then(keys => keys.forEach(key => this.buildWith(key))) + } + + buildWith({ host, type }: tt4b.site.SiteKey) { + if (type !== 'virtual') return + this.hostRegMap[host] = compileAntPattern(host) + } + + onDeleted({ host, type }: tt4b.site.SiteKey) { + if (type !== 'virtual') return + delete this.hostRegMap[host] + } + + /** + * Find the virtual sites which matches the target url + * + * @param url + * @returns virtual sites + */ + findMatched(url: string): string[] { + return Object.entries(this.hostRegMap) + .filter(([_, reg]) => reg.test(url)) + .map(([k]) => k) + } +} + +export default new VirtualSiteHolder() \ No newline at end of file diff --git a/src/background/service/components/week-helper.ts b/src/background/service/components/week-helper.ts new file mode 100644 index 000000000..aba6627e8 --- /dev/null +++ b/src/background/service/components/week-helper.ts @@ -0,0 +1,76 @@ +import { locale } from "@i18n" +import { getStartOfDay, getWeekDay, MILL_PER_DAY } from "@util/time" +import optionHolder from './option-holder' + +function getDefaultWeekStart(localeOpt: tt4b.option.LocaleOption): number { + const parts = navigator.language.split(/[-_]/) + const region = parts[parts.length - 1]?.toLowerCase() ?? '' + switch (locale) { + // Only Venezuela uses Sunday as the first day of week + case 'es': return 've' === region ? 6 : 0 + // Lebanon, Morocco and Tunisia use Monday as the first day of week + case 'ar': return ['la', 'ma', 'tn'].includes(region) ? 0 : 6 + // Other countries or fallbacked to English use Monday as the first day of week + case 'en': + if (['us', 'ca', 'in', 'za', 'jm', 'ph'].includes(region)) { + // US, Canaca, India, South Africa, Jamaica, Philippines use Sunday as the first day of week + return 6 + } else if (['gb', 'au', 'nz'].includes(region)) { + // UK, Australia and New Zealand use Monday as the first day of week + return 0 + } else if (localeOpt === 'en') { + // If locale option is set to English by user, use Sunday as the first day of week + return 6 + } else { + // FALLBACK + return 0 + } + case 'ja': + case 'pt_PT': + // Taiwan, Hong Kong and Macau use Sunday as the first day of week + case 'zh_TW': return 6 + case 'zh_CN': + case 'uk': + case 'de': + case 'fr': + case 'ru': + case 'tr': + case 'pl': + case 'it': return 0 + } +} + +/** + * Week start + * + * @returns 0-6 + */ +export async function getWeekStartDay(): Promise { + const { weekStart, locale: localeOpt } = await optionHolder.get() + return weekStart === 'default' ? getDefaultWeekStart(localeOpt) : weekStart - 1 +} + +/** + * Get the start time and end time of this week + * + * @param now the specific time to calculate + * @returns start time with milliseconds + * + * @since 0.6.0 + */ +export async function getWeekStartTime(now: number): Promise { + const weekStart = await getWeekStartDay() + // Returns 0 - 6 means Monday to Sunday + const weekDayNow = getWeekDay(new Date(now)) + let startDay: number + if (weekDayNow === weekStart) { + startDay = now + } else if (weekDayNow < weekStart) { + const millDelta = (weekDayNow + 7 - weekStart) * MILL_PER_DAY + startDay = now - millDelta + } else { + const millDelta = (weekDayNow - weekStart) * MILL_PER_DAY + startDay = now - millDelta + } + return getStartOfDay(startDay) +} \ No newline at end of file diff --git a/src/service/item-service.ts b/src/background/service/item-service.ts similarity index 58% rename from src/service/item-service.ts rename to src/background/service/item-service.ts index 80ba7bf7f..9650025ba 100644 --- a/src/service/item-service.ts +++ b/src/background/service/item-service.ts @@ -1,5 +1,5 @@ import { isValidGroup } from "@api/chrome/tabGroups" -import db, { type StatCondition } from "@db/stat-database" +import db from "@db/stat-database" import { resultOf } from "@util/stat" import optionHolder from "./components/option-holder" import virtualSiteHolder from "./components/virtual-site-holder" @@ -10,53 +10,42 @@ export type ItemIncContext = { groupId?: number } -async function addFocusTime(context: ItemIncContext, focusTime: number): Promise { +export async function addFocusTime(context: ItemIncContext, focusTime: number): Promise { const { host, url, groupId } = context - const resultSet: Record = { [host]: resultOf(focusTime, 0) } + const resultSet: Record = { [host]: resultOf(focusTime, 0) } const virtualHosts = virtualSiteHolder.findMatched(url) virtualHosts.forEach(virtualHost => resultSet[virtualHost] = resultOf(focusTime, 0)) const now = new Date() - await db.accumulateBatch(resultSet, now) + await db.batchAccumulate(resultSet, now) const { countTabGroup } = await optionHolder.get() countTabGroup && isValidGroup(groupId) && db.accumulateGroup(groupId, now, resultOf(focusTime, 0)) } -async function addRunTime(host: string, dateTime: Record) { +export async function addRunTime(host: string, dateTime: Record) { for (const [date, run] of Object.entries(dateTime)) { await db.accumulate(host, date, { focus: 0, time: 0, run }) } } -async function increaseVisit(context: ItemIncContext) { +export async function increaseVisit(context: ItemIncContext) { const { host, url, groupId } = context const resultSet = { [host]: resultOf(0, 1) } virtualSiteHolder.findMatched(url).forEach(virtualHost => resultSet[virtualHost] = resultOf(0, 1)) const now = new Date() - await db.accumulateBatch(resultSet, now) + await db.batchAccumulate(resultSet, now) const { countTabGroup } = await optionHolder.get() countTabGroup && isValidGroup(groupId) && await db.accumulateGroup(groupId, now, resultOf(0, 1)) } -const getResult = (host: string, date: Date | string) => db.get(host, date) - -const selectItems = (cond: StatCondition) => db.select(cond) - -async function batchDeleteGroupById(groupId: number): Promise { - await db.batchDeleteGroup(groupId) -} - -export default { - addFocusTime, - addRunTime, - increaseVisit, - getResult, - selectItems, - batchDeleteGroupById, +export async function getTodayResult(host: string) { + const option = await optionHolder.get() + if (!option.printInConsole) return undefined + return await db.get(host, new Date()) } \ No newline at end of file diff --git a/src/background/service/limit-service.ts b/src/background/service/limit-service.ts new file mode 100644 index 000000000..3dcc6dadc --- /dev/null +++ b/src/background/service/limit-service.ts @@ -0,0 +1,201 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { listTabs, sendMsg2Tab } from "@api/chrome/tab" +import db, { type LimitRecord } from "@db/limit-database" +import { sum } from "@util/array" +import { hasLimited, isEffective, matches, meetTimeLimit } from "@util/limit" +import { formatTimeYMD, getWeekDay, MILL_PER_MINUTE, MILL_PER_SECOND } from "@util/time" +import optionHolder from "./components/option-holder" +import { getWeekStartTime } from './components/week-helper' +import whitelistHolder from "./whitelist/holder" + +export async function selectLimit(param?: tt4b.limit.Query): Promise { + const { enabled, url, id, limited, effective } = param ?? {} + const now = new Date() + const today = formatTimeYMD(now) + const startTime = await getWeekStartTime(now.getTime()) + const startDate = formatTimeYMD(startTime) + const weekday = getWeekDay(now) + + let list = await db.all() + + if (enabled) list = list.filter(item => item.enabled) + if (id) list = list.filter(item => item.id === id) + if (url) list = list.filter(item => matches(item.cond, url)) + + let items = list.map(rec => cvtRecord2Item(rec, today, startDate)) + + if (limited) { + const { limitDelayDuration } = await optionHolder.get() + items = items.filter(item => hasLimited(item, limitDelayDuration)) + } + if (effective || enabled) items = items.filter(item => item.enabled) + if (effective) items = items.filter(item => isEffective(item.weekdays, weekday)) + + return items +} + +function cvtRecord2Item({ records, ...others }: LimitRecord, today: string, weekStartDate: string) { + const todayRec = records[today] + const thisWeekRec = Object.entries(records) + .filter(([k]) => k >= weekStartDate && k <= today) + .map(([, v]) => v) + const weeklyWaste = sum(thisWeekRec.map(r => r.mill ?? 0)) + const weeklyDelayCount = sum(thisWeekRec.map(r => r.delay ?? 0)) + const weeklyVisit = sum(thisWeekRec.map(r => r.visit ?? 0)) + return { + ...others, + waste: todayRec?.mill ?? 0, + visit: todayRec?.visit ?? 0, + delayCount: todayRec?.delay ?? 0, + weeklyWaste, + weeklyDelayCount, + weeklyVisit, + } +} + +/** + * Fired if the item is removed or disabled + * + * @param item + */ +export async function noticeLimitChanged(): Promise { + const tabs = await listTabs() + tabs.forEach(({ id, url }) => { + if (!id || !url) return + sendMsg2Tab(id, 'limitChanged').catch(err => console.info(err?.message)) + }) +} + +export async function removeLimitRules(ids: number[]): Promise { + if (!ids.length) return + await db.batchRemove(ids) + await noticeLimitChanged() +} + +type IncreaseResult = { + limited: tt4b.limit.Item[] + reminder?: tt4b.limit.ReminderInfo +} + +/** + * Add time + * + * @param url url + * @param focusTime time, milliseconds + * @returns the rules is limit cause of this operation + */ +export async function addLimitFocusTime(host: string, url: string, focusTime: number): Promise { + if (whitelistHolder.contains(host, url)) return { limited: [] } + + const allEffective = await selectLimit({ url, effective: true }) + + const toUpdate: { [cond: string]: number } = {} + const limited: tt4b.limit.Item[] = [] + const needReminder: tt4b.limit.Item[] = [] + + const { limitReminder, limitReminderDuration = 0, limitDelayDuration } = await optionHolder.get() + const durationMill = limitReminder ? limitReminderDuration * MILL_PER_MINUTE : 0 + allEffective.forEach(item => { + const [met, reminder] = addFocusForEach(item, focusTime, durationMill, limitDelayDuration) + met && limited.push(item) + reminder && needReminder.push(item) + toUpdate[item.id] = item.waste + }) + const result: IncreaseResult = { limited } + if (needReminder?.length) { + result.reminder = { + items: needReminder, + duration: limitReminderDuration, + } + } + await db.updateWaste(formatTimeYMD(new Date()), toUpdate) + return result +} + +type TimeLimitState = 'NORMAL' | 'REMINDER' | 'LIMITED' + +type LimitTimeStateResult = { + daily: TimeLimitState + weekly: TimeLimitState +} + +export function calcTimeState(item: tt4b.limit.Item, reminderMills: number, delayDuration: number): LimitTimeStateResult { + const res: LimitTimeStateResult = { daily: 'NORMAL', weekly: 'NORMAL' } + const { + time, waste, delayCount, + weekly, weeklyWaste, weeklyDelayCount, + allowDelay, + } = item || {} + const dailyMs = (time ?? 0) * MILL_PER_SECOND + const weeklyMs = (weekly ?? 0) * MILL_PER_SECOND + const delayDaily = { count: delayCount ?? 0, duration: delayDuration, allow: !!allowDelay } + const delayWeekly = { count: weeklyDelayCount ?? 0, duration: delayDuration, allow: !!allowDelay } + if (meetTimeLimit({ wasted: waste, maxLimit: dailyMs }, delayDaily)) res.daily = 'LIMITED' + else if (reminderMills && meetTimeLimit({ wasted: waste + reminderMills, maxLimit: dailyMs }, delayDaily)) res.daily = 'REMINDER' + if (meetTimeLimit({ wasted: weeklyWaste, maxLimit: weeklyMs }, delayWeekly)) res.weekly = 'LIMITED' + else if (reminderMills && meetTimeLimit({ wasted: weeklyWaste + reminderMills, maxLimit: weeklyMs }, delayWeekly)) res.weekly = 'REMINDER' + return res +} + +function addFocusForEach(item: tt4b.limit.Item, focusTime: number, durationMill: number, delayDuration: number): [met: boolean, reminder: boolean] { + const before = calcTimeState(item, durationMill, delayDuration) + item.waste += focusTime + // Fast increase + item.weeklyWaste += focusTime + const after = calcTimeState(item, durationMill, delayDuration) + const met = (before.daily !== 'LIMITED' && after.daily === 'LIMITED') || (before.weekly !== 'LIMITED' && after.weekly === 'LIMITED') + const reminder = (before.daily === 'NORMAL' && after.daily === 'REMINDER') || (before.weekly === 'NORMAL' && after.weekly === 'REMINDER') + return [met, reminder] +} + +/** + * Increase visit count + * @returns the rules is limited + */ +export async function incLimitVisit(host: string, url: string): Promise { + if (whitelistHolder.contains(host, url)) return [] + + const allEnabled = await selectLimit({ enabled: true, url }) + const { limitDelayDuration: delayDuration } = await optionHolder.get() + const result: tt4b.limit.Item[] = [] + await db.increaseVisit(formatTimeYMD(new Date()), allEnabled.map(item => item.id)) + allEnabled.forEach(item => { + // Fast increase + item.visit++ + item.weeklyVisit++ + + hasLimited(item, delayDuration) && result.push(item) + }) + return result +} + +export async function delayLimit(url: string): Promise { + const limitedItems = await selectLimit({ url, enabled: true, limited: true }) + limitedItems + .filter(item => item.allowDelay) + .forEach(rule => { + rule.delayCount++ + rule.weeklyDelayCount++ + }) + + const date = formatTimeYMD(new Date()) + await db.updateDelayCount(date, limitedItems) + await noticeLimitChanged() +} + +export async function updateLimitRules(rules: tt4b.limit.Rule[]): Promise { + await db.batchUpdate(rules) + await noticeLimitChanged() +} + +export async function createLimitRule(rule: Omit): Promise { + const id = await db.add(rule) + await noticeLimitChanged() + return id +} diff --git a/src/background/service/meta-service.ts b/src/background/service/meta-service.ts new file mode 100644 index 000000000..5572abfce --- /dev/null +++ b/src/background/service/meta-service.ts @@ -0,0 +1,82 @@ +/** + * Copyright (c) 2022 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import db from "@db/meta-database" +import { IS_ANDROID, IS_FIREFOX } from '@util/constant/environment' +import { createArrayGuard, createObjectGuard, isString } from 'typescript-guard' + +export async function getInstallTime(): Promise { + const meta = await db.getMeta() + return meta.installTime ?? Date.now() +} + +export async function updateInstallTime(ts: number) { + const meta = await db.getMeta() + if (meta.installTime) { + // Must not rewrite + return + } + meta.installTime = ts + await db.update(meta) +} + +/** + * @since 1.2.0 + */ +export async function getCid(): Promise { + const meta = await db.getMeta() + const exist = meta.cid + if (exist) return exist + const initial = `${getBrand()}-${Date.now()}` + meta.cid = initial + await db.update(meta) + return initial +} + +// Only exists in chromium browsers +type NavigatorUAData = { + brands: { brand: string }[] + platform: string +} + +const hasUaData = createObjectGuard<{ userAgentData: NavigatorUAData }>({ + userAgentData: createObjectGuard({ + brands: createArrayGuard(createObjectGuard({ brand: isString })), + platform: isString, + }), +}) + +function getBrand() { + if (hasUaData(navigator)) { + const { userAgentData: { brands }, platform } = navigator + const brand = brands.map(e => e.brand) + .filter(brand => brand && brand !== "Chromium" && !brand.includes("Not"))[0]?.replace(' ', '-') + if (brand) return `${platform.toLowerCase()}-${brand.toLowerCase()}` + } + if (IS_FIREFOX) return IS_ANDROID ? 'firefox-android' : 'firefox' + return 'unknown' +} + +/** + * @since 1.4.7 + */ +export async function updateBackUpTime(type: tt4b.backup.Type, time: number) { + const meta = await db.getMeta() + if (!meta.backup) { + meta.backup = {} + } + meta.backup[type] = { ts: time } + await db.update(meta) +} + +/** + * @since 1.4.7 + */ +export async function getLastBackUp(type: tt4b.backup.Type): Promise { + const meta = await db.getMeta() + return meta.backup?.[type]?.ts +} \ No newline at end of file diff --git a/src/background/service/notification/browser/notifier.ts b/src/background/service/notification/browser/notifier.ts new file mode 100644 index 000000000..be31a71b0 --- /dev/null +++ b/src/background/service/notification/browser/notifier.ts @@ -0,0 +1,48 @@ +import { createNotification } from "@api/chrome/notifications" +import { hasPerm, requestPerm } from "@api/chrome/permission" +import { t } from '@bg/i18n' +import { formatPeriodCommon } from '@util/time' +import type { NotificationData, NotificationRequest, Notifier } from '../types' + +/** + * Send notification with `chrome.notifications` API + */ +export default class BrowserNotifier implements Notifier { + /** + * Test if the permission granted, if not granted, then try to grant + */ + private async assertPerm(): Promise { + const hasPermission = await hasPerm('notifications') + if (hasPermission) { + return undefined + } + + const granted = await requestPerm('notifications') + if (!granted) { + return "Notification permission is required but was denied" + } + + return undefined + } + + /** + * Send notification with summary + * + * @param option + * @param data + */ + async send(_: NotificationRequest, data: NotificationData): Promise { + const errMsg = await this.assertPerm() + if (errMsg) return errMsg + + const { cycle, summary: { focus, visit, siteCount } } = data + + 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 }) + + await createNotification('time', { type: 'basic', title, message }) + } +} diff --git a/src/background/service/notification/callback/notifier.ts b/src/background/service/notification/callback/notifier.ts new file mode 100644 index 000000000..c7242d0ee --- /dev/null +++ b/src/background/service/notification/callback/notifier.ts @@ -0,0 +1,61 @@ +import { IS_FIREFOX } from '@util/constant/environment' +import hash from 'hash.js' +import type { NotificationData, NotificationMeta, NotificationRequest, Notifier } from '../types' + +function buildHeaders(meta: NotificationMeta, token: string | undefined): Record { + const headers: Record = { + 'Content-Type': 'application/json', + } + if (token) { + const sign = genSign(meta, token) + headers['Tt4b-Sign'] = sign + } + return headers +} + +function genSign(meta: NotificationMeta, auth: string): string { + return hash.hmac(hash.sha256 as any, auth).update(meta).digest('hex') +} + +export default class CallbackNotifier implements Notifier { + private async assertPerm(): Promise { + // Not need to check data permission if not FF + if (!IS_FIREFOX) return undefined + + const perm = await browser?.permissions?.getAll?.() + const granted = perm?.data_collection?.includes?.('technicalAndInteraction') + if (!granted) { + // Unable to request permissions in FF's Service Worker + // So fast fail + return "Required permission is not granted" + } + } + + async send(req: NotificationRequest, data: NotificationData): Promise { + const errMsg = await this.assertPerm() + if (errMsg) return errMsg + + const { endpoint, authToken } = req + + if (!endpoint) return "Endpoint is required for HTTP callback" + + try { + const url = new URL(endpoint) + if (!['http:', 'https:'].includes(url.protocol)) { + return "Endpoint must use HTTP or HTTPS protocol" + } + } catch (e) { + return "Invalid endpoint URL" + } + + const { meta } = data + const headers = buildHeaders(meta, authToken) + + const response = await fetch(endpoint, { + method: 'POST', headers, + body: JSON.stringify(data), + }) + + return response.ok ? undefined : `Server error: ${response.statusText}` + } +} diff --git a/src/background/service/notification/processor.ts b/src/background/service/notification/processor.ts new file mode 100644 index 000000000..95db875b4 --- /dev/null +++ b/src/background/service/notification/processor.ts @@ -0,0 +1,89 @@ +import { getVersion } from "@api/chrome/runtime" +import db from "@db/stat-database" +import { cvtOption2Locale } from "@i18n" +import { cvtDateRange2Str, formatTimeYMD, MILL_PER_DAY, MILL_PER_WEEK } from "@util/time" +import optionHolder from "../components/option-holder" +import BrowserNotifier from "./browser/notifier" +import CallbackNotifier from "./callback/notifier" +import type { NotificationData, NotificationRequest, Notifier } from "./types" + +const DATE_RANGE_CALCULATORS: Record Date | [Date, Date]> = { + daily: now => new Date(now - MILL_PER_DAY), + weekly: now => [new Date(now - MILL_PER_WEEK), new Date(now - MILL_PER_DAY)], +} + +class Processor { + private notifiers: { + [method in tt4b.notification.Method]: Notifier + } + + constructor() { + this.notifiers = { + browser: new BrowserNotifier(), + callback: new CallbackNotifier(), + } + } + + async doSend(): Promise { + const option = await optionHolder.get() + const { + notificationCycle: cycle, notificationMethod: method, + notificationEndpoint: endpoint, notificationAuthToken: authToken, + } = option + if (cycle === 'none') return undefined + + const notifier = this.notifiers[method] + const req: NotificationRequest = { cycle, method, endpoint, authToken } + const data = await this.buildData(req) + + try { + return await notifier.send(req, data) + } catch (e) { + console.error("Error to send notification", e) + return e instanceof Error ? e.message : String(e) + } + } + + private async buildData(req: NotificationRequest): Promise { + const now = Date.now() + const date = DATE_RANGE_CALCULATORS[req.cycle](now) + + // Query rows + const rows = await db.select({ date: cvtDateRange2Str(date) }) + + // Calculate summary + let totalFocus = 0 + let totalVisit = 0 + const uniqueHosts = new Set() + + rows.forEach(row => { + totalFocus += row.focus + totalVisit += row.time + uniqueHosts.add(row.host) + }) + + const option = await optionHolder.get() + const locale = cvtOption2Locale(option.locale) + + const [dateStart, dateEnd] = Array.isArray(date) ? date : [date, date] + + return { + cycle: req.cycle, + meta: { + locale, + version: getVersion(), + ts: Date.now(), + }, + summary: { + focus: totalFocus, + visit: totalVisit, + siteCount: uniqueHosts.size, + dateStart: formatTimeYMD(dateStart), + dateEnd: formatTimeYMD(dateEnd), + }, + row: rows, + } + } +} + +export default new Processor() diff --git a/src/background/service/notification/types.ts b/src/background/service/notification/types.ts new file mode 100644 index 000000000..780367131 --- /dev/null +++ b/src/background/service/notification/types.ts @@ -0,0 +1,40 @@ +type Summary = { + focus: number + visit: number + siteCount: number + dateStart: string + dateEnd: string +} + +export type NotificationMeta = { + locale: tt4b.Locale + version: string + ts: number +} + +export type NotificationRequest = { + cycle: Exclude + method: tt4b.notification.Method + endpoint?: tt4b.option.NotificationOption['notificationEndpoint'] + authToken?: tt4b.option.NotificationOption['notificationAuthToken'] +} + +/** + * Notification data to be sent + */ +export type NotificationData = { + meta: NotificationMeta + cycle: Exclude + summary: Summary + row: tt4b.core.Row[] +} + +/** + * Notifier interface for different notification methods + */ +export interface Notifier { + /** + * Send notification + */ + send(req: NotificationRequest, data: NotificationData): Promise +} \ No newline at end of file diff --git a/src/background/service/period-service.ts b/src/background/service/period-service.ts new file mode 100644 index 000000000..84797989b --- /dev/null +++ b/src/background/service/period-service.ts @@ -0,0 +1,38 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import db from "@db/period-database" +import { after, compare, getDateString } from "@util/period" +import { merge } from "./components/period-calculator" + +function dateStrBetween(startDate: tt4b.period.Key, endDate: tt4b.period.Key): string[] { + const result: string[] = [] + while (compare(startDate, endDate) <= 0) { + result.push(getDateString(startDate)) + startDate = after(startDate, 1) + } + return result +} + +export async function selectPeriods(param: tt4b.period.Query): Promise { + let { range, size = 1 } = param + if (!Number.isInteger(size) || size <= 1) size = 1 + + if (range === undefined) { + const results = await db.getAll() + return merge(results, size) + } + const [start, end] = range + const allDates = dateStrBetween(start, end) + const results = await db.getBatch(allDates) + return merge(results, size) +} + +export async function batchDeletePeriods(start: tt4b.period.Key, end: tt4b.period.Key): Promise { + const allDates = dateStrBetween(start, end) + await db.batchDelete(allDates) +} diff --git a/src/background/service/site-service.ts b/src/background/service/site-service.ts new file mode 100644 index 000000000..e5fb102a1 --- /dev/null +++ b/src/background/service/site-service.ts @@ -0,0 +1,206 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { listTabs, sendMsg2Tab } from "@api/chrome/tab" +import siteDatabase from "@db/site-database" +import { ALL_HOSTS as ALL_FILE_HOSTS, MERGED_HOST as MERGED_FILE_HOST } from '@util/constant/remain-host' +import { extractHostname, isValidVirtualHost, judgeVirtualFast } from "@util/pattern" +import { SiteMap, supportCategory } from "@util/site" +import { toUnicode as punyCode2Unicode } from "punycode" +import mergeRuleDatabase from '../database/merge-rule-database' +import statDatabase from '../database/stat-database' +import { getPslSuffix } from '../psl' +import CustomizedHostMergeRuler from './components/host-merge-ruler' +import { slicePageResult } from "./components/page-info" +import virtualSiteHolder from './components/virtual-site-holder' + +export async function saveAlias(key: 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 saveSiteRunState(key: tt4b.site.SiteKey, enabled: boolean) { + const exist = await siteDatabase.get(key) + if (!exist) return + exist.run = enabled + await siteDatabase.save(exist) + // send msg to tabs + const tabs = await listTabs() + for (const { id } of tabs) { + try { + id && await sendMsg2Tab(id, 'siteRunChange') + } catch { } + } +} + +export async function addSite(siteInfo: tt4b.site.SiteInfo): Promise { + if (await siteDatabase.exist(siteInfo)) { + return 'Site already exists' + } + if (!supportCategory(siteInfo)) siteInfo.cate = undefined + await siteDatabase.save(siteInfo) + virtualSiteHolder.buildWith(siteInfo) +} + +export async function removeSites(keys: tt4b.site.SiteKey[]): Promise { + await siteDatabase.remove(keys) + keys.forEach(key => virtualSiteHolder.onDeleted(key)) +} + +export async function selectSitePage(param?: tt4b.site.PageQuery): Promise> { + const origin = await siteDatabase.select(param) + return slicePageResult(origin, param) +} + +export async function batchChangeCate(cateId: number | undefined, keys: tt4b.site.SiteKey[]): Promise { + keys = keys?.filter(supportCategory) + if (!keys?.length) return + + const sites = await siteDatabase.getBatch(keys) + const siteMap = SiteMap.identify(sites) + const toSave = keys.map(k => ({ ...siteMap.get(k), ...k, cate: cateId })) + await siteDatabase.save(...toSave) +} + +/** + * @since 0.9.0 + */ +export async function getSite(siteKey: tt4b.site.SiteKey): Promise { + const info = await siteDatabase.get(siteKey) + return info ?? siteKey +} + +function moveToFront(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) + + 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' }) + + 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) + + return [{ host, type: 'normal' }, ...ranked] +} + +function cleanSearchQuery(query: string | undefined): string | undefined { + query = query?.trim?.() + if (!query) return undefined + try { + // Remove protocol and search params, only keep host and path for search + const u = new URL(query) + query = u.host + u.pathname + } catch { } + if (query.endsWith('/')) query += '**' + return query +} + +/** + * Query hosts from stat databases + * + * @param query the part of host + * @since 0.0.8 + */ +async function listHosts(filter: (host: string) => boolean): Promise<[normal: string[], merged: string[]]> { + const rows = await statDatabase.select({ virtual: false }) + const hosts = new Set(rows.map(row => row.host)) + + const mergeRuleItems = await mergeRuleDatabase.selectAll() + const mergeRuler = new CustomizedHostMergeRuler(mergeRuleItems) + + const normal = new Set() + const merged = new Set() + + hosts.forEach(host => { + filter(host) && normal.add(host) + const mergedHost = mergeRuler.merge(host) + filter(mergedHost) && merged.add(mergedHost) + }) + + return [Array.from(normal), Array.from(merged)] +} + +export async function fillInitialAlias(keys: tt4b.site.SiteKey[]) { + const sites = await siteDatabase.getBatch(keys) + const toSave = new SiteMap() + sites.forEach(site => { + if (site.alias) return + const alias = getInitialAlias(site.host) + alias && toSave.put(site, alias) + }) + await batchSaveAlias(toSave) +} + +export function getInitialAlias(host: string): string | undefined { + let parts = host.split('.') + if (parts.length < 2) return + + const suffix = getPslSuffix(host) + const prefix = host.replace(`.${suffix}`, '').replace(/^www\./, '') + parts = prefix.split('.') + return parts.reverse().map(cvt2Alias).join(' ') +} + +function cvt2Alias(part: string): string { + try { + part = punyCode2Unicode(part) + } catch { + } + return part.charAt(0).toUpperCase() + part.slice(1) +} + +async function batchSaveAlias(siteMap: SiteMap): Promise { + if (!siteMap.count()) return + const allSites = await siteDatabase.getBatch(siteMap.keys()) + const existMap = SiteMap.identify(allSites) + + const toSave: tt4b.site.SiteInfo[] = [] + siteMap.forEach((k, alias) => { + const exist = existMap.get(k) + if (exist?.alias) return + toSave.push({ ...exist ?? k, alias }) + }) + await siteDatabase.save(...toSave) +} \ No newline at end of file diff --git a/src/service/stat-service/common.ts b/src/background/service/stat-service/common.ts similarity index 74% rename from src/service/stat-service/common.ts rename to src/background/service/stat-service/common.ts index 6720dafaf..8b3125a8c 100644 --- a/src/service/stat-service/common.ts +++ b/src/background/service/stat-service/common.ts @@ -1,6 +1,6 @@ import { judgeVirtualFast } from "@util/pattern" -export function cvt2SiteRow(rowBase: timer.core.Row): timer.stat.SiteRow { +export function cvt2SiteRow(rowBase: tt4b.core.Row): tt4b.stat.SiteRow { const { host, ...otherFields } = rowBase return { siteKey: { host, type: judgeVirtualFast(host) ? 'virtual' : 'normal' }, diff --git a/src/background/service/stat-service/index.ts b/src/background/service/stat-service/index.ts new file mode 100644 index 000000000..ce3101f40 --- /dev/null +++ b/src/background/service/stat-service/index.ts @@ -0,0 +1,198 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { listAllGroups } from "@api/chrome/tabGroups" +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 { isGroup, isSite } from "@util/stat" +import { slicePageResult } from "../components/page-info" +import { cvt2SiteRow } from "./common" +import { mergeCate } from "./merge/cate" +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) + 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 + } +} + +function compareSortVal(a: string | number, b: string | number, direction?: tt4b.common.SortDirection): number { + if (a === b) return 0 + const val = a > b ? 1 : -1 + return direction === 'DESC' ? -val : val +} + +function filterByCateId(itemCateId: number | undefined, cateIds: number[] | undefined): boolean { + if (!cateIds?.length) return true + return cateIds.includes(itemCateId ?? CATE_NOT_SET_ID) +} + +export async function countSite(param?: tt4b.stat.SiteQuery): Promise { + const rows = await statDatabase.select(param) + return rows.length +} + +export async function selectSite(param?: tt4b.stat.SiteQuery): Promise { + const { + mergeHost: needMerge, mergeDate: needMergeDate, + date, query, host, cateIds, + timeRange, focusRange, + virtual, ignoreSite, inclusiveRemote, + sortKey, sortDirection, + } = param ?? {} + + const condition: StatCondition = { + date, timeRange, focusRange, virtual, + keys: host && !needMerge ? host : undefined, + } + let origin = await statDatabase.select(condition) + let siteRows = origin.map(cvt2SiteRow) + inclusiveRemote && (siteRows = await processRemote(siteRows, param)) + + // Merge with rules + needMerge && (siteRows = await mergeHost(siteRows)) + // Fill site info + if (!ignoreSite || query) await fillSite(siteRows) + // Filter + siteRows = siteRows + .filter(({ siteKey: { host: siteHost } }) => !host || host === siteHost) + .filter(({ siteKey: { host: siteHost }, alias }) => !query || siteHost.includes(query) || !!alias?.includes(query)) + .filter(({ cateId }) => filterByCateId(cateId, cateIds)) + // Merge by date + needMergeDate && (siteRows = mergeDate(siteRows)) + // Sort + if (sortKey) { + const sortVal = (a: tt4b.stat.SiteRow) => sortKey === 'host' ? a.siteKey.host : a[sortKey] ?? 0 + siteRows.sort((a, b) => compareSortVal(sortVal(a), sortVal(b), sortDirection)) + } + return siteRows +} + +export async function selectSitePage(param?: tt4b.stat.SitePageQuery): Promise> { + const rows = await selectSite(param) + return slicePageResult(rows, param) +} + +export async function selectCate(param?: tt4b.stat.CateQuery): Promise { + const { + mergeDate: needMergeDate, + date, query, cateIds, + inclusiveRemote, + sortKey, sortDirection, + } = param ?? {} + + let origin = await statDatabase.select({ date }) + + let siteRows = origin.map(cvt2SiteRow) + inclusiveRemote && (siteRows = await processRemote(siteRows, param)) + + // Fill site info + await fillSite(siteRows) + // Merge sites by date first + if (needMergeDate) siteRows = mergeDate(siteRows) + + const categories = await cateDatabase.listAll() + let cateRows = mergeCate(siteRows, categories) + // Filter + cateRows = cateRows + .filter(({ cateKey }) => !cateIds?.length || cateIds.includes(cateKey)) + .filter(({ cateName }) => !query || cateName?.includes(query)) + // Merge cates by date again + if (needMergeDate) cateRows = mergeDate(cateRows) + + // Sort + if (sortKey) { + cateRows.sort((a, b) => compareSortVal(a[sortKey] ?? 0, b[sortKey] ?? 0, sortDirection)) + } + return cateRows +} + +export async function selectCatePage(query?: tt4b.stat.CatePageQuery): Promise> { + const rows = await selectCate(query) + return slicePageResult(rows, query) +} + +async function fillSite(rows: tt4b.stat.SiteRow[]): Promise { + let keys: tt4b.site.SiteKey[] = [] + extractAllSiteKeys(rows, keys) + keys = distinctSites(keys) + + const sites = await siteDatabase.getBatch(keys) + const siteMap = SiteMap.identify(sites) + + rows.forEach(item => fillRowWithSiteInfo(item, siteMap)) + return true +} + +export async function selectGroup(param?: tt4b.stat.GroupQuery): Promise { + const { + date, query, mergeDate: needMergeDate, + focusRange, timeRange, + sortKey, sortDirection, + } = param ?? {} + const list = await statDatabase.selectGroup({ date, focusRange, timeRange }) + const groups = await listAllGroups() + const groupMap = toMap(groups, g => g.id) + let rows: tt4b.stat.GroupRow[] = list.map(({ date, time, focus, run, host }) => { + const groupKey = parseInt(host) + const { title, color } = groupMap[groupKey] ?? {} + return ({ date, groupKey, title, color, run, focus, time }) + }) + rows = rows.filter(({ title }) => !query || title?.includes(query)) + needMergeDate && (rows = mergeDate(rows)) + if (sortKey) { + rows.sort((a, b) => compareSortVal(a[sortKey] ?? 0, b[sortKey] ?? 0, sortDirection)) + } + return rows +} + +export async function selectGroupPage(param?: tt4b.stat.GroupPageQuery) { + const rows = await selectGroup(param) + return slicePageResult(rows, param) +} + +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 }) + 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/service/stat-service/merge/cate.ts b/src/background/service/stat-service/merge/cate.ts similarity index 76% rename from src/service/stat-service/merge/cate.ts rename to src/background/service/stat-service/merge/cate.ts index a6dfbd727..c57dcff3f 100644 --- a/src/service/stat-service/merge/cate.ts +++ b/src/background/service/stat-service/merge/cate.ts @@ -2,9 +2,9 @@ import { toMap } from "@util/array" import { CATE_NOT_SET_ID } from "@util/site" import { mergeResult } from "./common" -export function mergeCate(origin: timer.stat.SiteRow[], cates: timer.site.Cate[]): timer.stat.CateRow[] { +export function mergeCate(origin: tt4b.stat.SiteRow[], cates: tt4b.site.Cate[]): tt4b.stat.CateRow[] { const cateNameMap = toMap(cates, c => c.id, c => c.name) - const rowMap: Record> = {} + const rowMap: Record> = {} origin.forEach(ele => { if (ele.siteKey.type !== 'normal') return let { date = '', cateId = CATE_NOT_SET_ID } = ele @@ -17,7 +17,7 @@ export function mergeCate(origin: timer.stat.SiteRow[], cates: timer.site.Cate[] time: 0, mergedRows: [], composition: { focus: [], time: [], run: [] }, - } satisfies timer.stat.CateRow + } satisfies tt4b.stat.CateRow } mergeResult(exist, ele) exist.mergedRows.push(ele) diff --git a/src/service/stat-service/merge/common.ts b/src/background/service/stat-service/merge/common.ts similarity index 82% rename from src/service/stat-service/merge/common.ts rename to src/background/service/stat-service/merge/common.ts index 7339f48a1..67ab41994 100644 --- a/src/service/stat-service/merge/common.ts +++ b/src/background/service/stat-service/merge/common.ts @@ -7,9 +7,9 @@ import { isGroup } from "@util/stat" -type _RemoteCompositionMap = Record<'_' | string, timer.stat.RemoteCompositionVal> +type _RemoteCompositionMap = Record<'_' | string, tt4b.stat.RemoteCompositionVal> -function mergeComposition(c1: timer.stat.RemoteComposition | undefined, c2: timer.stat.RemoteComposition | undefined): timer.stat.RemoteComposition { +function mergeComposition(c1: tt4b.stat.RemoteComposition | undefined, c2: tt4b.stat.RemoteComposition | undefined): tt4b.stat.RemoteComposition { const focusMap: _RemoteCompositionMap = {} const timeMap: _RemoteCompositionMap = {} const runMap: _RemoteCompositionMap = {} @@ -28,7 +28,7 @@ function mergeComposition(c1: timer.stat.RemoteComposition | undefined, c2: time return result } -function accCompositionValue(map: _RemoteCompositionMap, value: timer.stat.RemoteCompositionVal) { +function accCompositionValue(map: _RemoteCompositionMap, value: tt4b.stat.RemoteCompositionVal) { if (typeof value === 'number') { const cid = '_' const existVal = map[cid] @@ -48,7 +48,7 @@ function accCompositionValue(map: _RemoteCompositionMap, value: timer.stat.Remot } } -export function mergeResult(target: timer.stat.Row, delta: timer.stat.Row) { +export function mergeResult(target: tt4b.stat.Row, delta: tt4b.stat.Row) { const { focus, time } = delta target.focus += focus ?? 0 target.time += time ?? 0 diff --git a/src/background/service/stat-service/merge/date.ts b/src/background/service/stat-service/merge/date.ts new file mode 100644 index 000000000..055a53665 --- /dev/null +++ b/src/background/service/stat-service/merge/date.ts @@ -0,0 +1,33 @@ +import { identifyTargetKey, isCate, isGroup, isNormalSite, isSite } from "@util/stat" +import { mergeResult } from "./common" + +type MergeRow = + | MakeRequired + | MakeRequired + +export function mergeDate(origin: T[]): T[] { + const map: Record = {} + origin.forEach(ele => { + const { date } = ele + const key = identifyTargetKey(ele) + const exist: MergeRow = map[key] ?? (map[key] = { + ...ele, + focus: 0, + time: 0, + mergedRows: [], + mergedDates: [], + composition: { focus: [], time: [], run: [] }, + }) + mergeResult(exist, ele) + isSite(ele) && isSite(exist) && exist.mergedRows.push(...(ele.mergedRows ?? [])) + isCate(ele) && isCate(exist) && exist.mergedRows.push(...(ele.mergedRows ?? [])) + isGroup(ele) && isGroup(exist) && exist.mergedRows.push(...(ele.mergedRows ?? [])) + date && exist.mergedDates.push(date) + if (isNormalSite(ele) && !isGroup(exist)) { + const { mergedRows, ...toMerge } = ele + exist.mergedRows.push(toMerge) + } + }) + const newRows = Object.values(map) + return newRows as T[] +} \ No newline at end of file diff --git a/src/service/stat-service/merge/host.ts b/src/background/service/stat-service/merge/host.ts similarity index 76% rename from src/service/stat-service/merge/host.ts rename to src/background/service/stat-service/merge/host.ts index 14ad481ed..3a2c6f4d2 100644 --- a/src/service/stat-service/merge/host.ts +++ b/src/background/service/stat-service/merge/host.ts @@ -3,11 +3,11 @@ import CustomizedHostMergeRuler from "@service/components/host-merge-ruler" import { isNormalSite } from "@util/stat" import { mergeResult } from "./common" -export async function mergeHost(origin: timer.stat.SiteRow[]): Promise { - const map: Record> = {} +export async function mergeHost(origin: tt4b.stat.SiteRow[]): Promise { + const map: Record> = {} // Generate ruler - const mergeRuleItems: timer.merge.Rule[] = await mergeRuleDatabase.selectAll() + const mergeRuleItems: tt4b.merge.Rule[] = await mergeRuleDatabase.selectAll() const mergeRuler = new CustomizedHostMergeRuler(mergeRuleItems) origin.forEach(ele => { @@ -25,7 +25,7 @@ export async function mergeHost(origin: timer.stat.SiteRow[]): Promise { - if (!await canReadRemote()) { - return origin - } +export async function processRemote(origin: tt4b.stat.SiteRow[], param?: StatCondition): Promise { // Map to merge - const originMap: Record> = {} + const originMap: Record> = {} origin.forEach(row => originMap[identifyStatKey(row)] = { ...row, composition: { @@ -29,35 +26,24 @@ export async function processRemote(origin: timer.stat.SiteRow[], param?: StatCo const { keys, date } = param ?? {} const keyArr = typeof keys === 'string' ? [keys] : keys const predicate = keyArr?.length - ? ({ host }: timer.core.Row) => keyArr.includes(host) + ? ({ host }: tt4b.core.Row) => keyArr.includes(host) : () => true // 1. query remote - let start: Date | undefined = undefined, end: Date | undefined = undefined - if (date instanceof Array) { + let start: string | undefined, end: string | undefined + if (Array.isArray(date)) { [start, end] = date } else { start = date } - start = start ?? getBirthday() - end = end ?? new Date() + start = start ?? BIRTHDAY + end = end ?? formatTimeYMD(Date.now()) const remote = await processor.query({ excludeLocal: true, start, end }) remote.filter(predicate).forEach(row => processRemoteRow(originMap, row)) return Object.values(originMap) } -/** - * Enabled to read remote backup data - * - * @since 1.2.0 - * @returns T/F - */ -export async function canReadRemote(): Promise { - const { errorMsg } = await processor.checkAuth() - return !errorMsg -} - -function processRemoteRow(rowMap: Record>, remoteBase: timer.core.Row) { +function processRemoteRow(rowMap: Record>, remoteBase: tt4b.core.Row) { const row = cvt2SiteRow(remoteBase) const key = identifyStatKey(row) let exist = rowMap[key] @@ -71,7 +57,7 @@ function processRemoteRow(rowMap: Record) + } satisfies MakeRequired) const { focus = 0, time = 0, run = 0, cid = '', cname } = row diff --git a/src/background/service/throttler/firefox-throttler.ts b/src/background/service/throttler/firefox-throttler.ts new file mode 100644 index 000000000..a2a4b93ab --- /dev/null +++ b/src/background/service/throttler/firefox-throttler.ts @@ -0,0 +1,35 @@ +import { IS_FIREFOX } from '@util/constant/environment' +import { MILL_PER_SECOND } from '@util/time' + +const DEFAULT_INTERVAL = 30 + +export abstract class FirefoxThrottler { + private data: T[] = [] + + constructor(intervalSec?: number) { + if (!IS_FIREFOX) { + return + } + + // it's safety to use setInterval with 60sec interval + // bcz the sw will be unloaded if no events in 10 minutes + intervalSec = Math.min(intervalSec ?? DEFAULT_INTERVAL, 60) + + setInterval(() => { + const toSave = [...this.data] + this.data = [] + if (!toSave.length) return + this.doStore(toSave) + }, intervalSec * MILL_PER_SECOND) + } + + private throttle(data: T[]): void { + this.data.push(...data) + } + + protected abstract doStore(data: T[]): void + + protected save(data: T[]): void { + IS_FIREFOX ? this.throttle(data) : this.doStore(data) + } +} \ No newline at end of file diff --git a/src/background/service/throttler/period-throttler.ts b/src/background/service/throttler/period-throttler.ts new file mode 100644 index 000000000..aeedd63c7 --- /dev/null +++ b/src/background/service/throttler/period-throttler.ts @@ -0,0 +1,18 @@ +import periodDatabase from '@db/period-database' +import { calculate } from '../components/period-calculator' +import { FirefoxThrottler } from './firefox-throttler' + +class PeriodThrottler extends FirefoxThrottler { + public add(timestamp: number, milliseconds: number): void { + const results = calculate(timestamp, milliseconds) + this.save(results) + } + + protected doStore(data: tt4b.period.Result[]): void { + periodDatabase.accumulate(data) + } +} + +const periodThrottler = new PeriodThrottler() + +export default periodThrottler \ No newline at end of file diff --git a/src/service/timeline-service.ts b/src/background/service/throttler/timeline-throttler.ts similarity index 55% rename from src/service/timeline-service.ts rename to src/background/service/throttler/timeline-throttler.ts index 0da113777..c92d98f3e 100644 --- a/src/service/timeline-service.ts +++ b/src/background/service/throttler/timeline-throttler.ts @@ -1,5 +1,22 @@ import timelineDatabase from '@db/timeline-database' import { extractHostname } from '@util/pattern' +import { FirefoxThrottler } from './firefox-throttler' + +class TimelineThrottler extends FirefoxThrottler { + public saveEvent(ev: tt4b.timeline.Event) { + const { start, end, url } = ev + const { host } = extractHostname(url) + if (!host) return + + const durations = split2Durations(start, end) + const ticks: tt4b.timeline.Tick[] = durations.map(([start, duration]) => ({ start, duration, host })) + this.save(ticks) + } + + protected doStore(data: tt4b.timeline.Tick[]): void { + timelineDatabase.batchSave(data) + } +} const split2Durations = (start: number, end: number): [start: number, duration: number][] => { const result: [start: number, duration: number][] = [] @@ -25,12 +42,6 @@ const split2Durations = (start: number, end: number): [start: number, duration: return result } -export async function saveTimelineEvent(ev: timer.timeline.Event): Promise { - const { start, end, url } = ev - const { host } = extractHostname(url) - if (!host) return +const timelineThrottler = new TimelineThrottler() - const durations = split2Durations(start, end) - const ticks: timer.timeline.Tick[] = durations.map(([start, duration]) => ({ start, duration, host })) - await timelineDatabase.batchSave(ticks) -} +export default timelineThrottler \ No newline at end of file diff --git a/src/background/service/timeline-service.ts b/src/background/service/timeline-service.ts new file mode 100644 index 000000000..50b941cc5 --- /dev/null +++ b/src/background/service/timeline-service.ts @@ -0,0 +1,76 @@ +/** + * Copyright (c) 2022-present Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import cateDb from "@db/cate-database" +import mergeDb from '@db/merge-rule-database' +import siteDb from "@db/site-database" +import db from "@db/timeline-database" +import { toMap } from '@util/array' +import { CATE_NOT_SET_ID } from '@util/site' +import CustomizedHostMergeRuler from './components/host-merge-ruler' + +export async function listTimeline(query: tt4b.timeline.Query): Promise { + const ticks = await db.select(query) + const { merge } = query + if (merge === 'domain') { + return mergeByDomain(ticks) + } else if (merge === 'cate') { + return mergeByCate(ticks) + } else { + return fillSiteName(ticks) + } +} + +async function mergeByDomain(ticks: tt4b.timeline.Tick[]): Promise { + const mergeRules = await mergeDb.selectAll() + const merger = new CustomizedHostMergeRuler(mergeRules) + const allHosts = Array.from(new Set(ticks.map(t => t.host))) + const mergedMap = toMap(allHosts, h => h, h => merger.merge(h)) + + const allSiteKeys = Array.from(new Set(Object.values(mergedMap))) + .map((mergedHost) => ({ type: 'merged', host: mergedHost } satisfies tt4b.site.SiteKey)) + const allSites = await siteDb.getBatch(allSiteKeys) + const nameMap = toMap(allSites, s => s.host, s => s.alias) + + return ticks.map(({ start, duration, host }) => { + const seriesKey = mergedMap[host] ?? host + return { + start, duration, + seriesKey, seriesName: nameMap[seriesKey], + } + }) +} + +async function mergeByCate(ticks: tt4b.timeline.Tick[]): Promise { + const cates = await cateDb.listAll() + const cateNameMap = toMap(cates, c => c.id, c => c.name) + const allSiteKeys = Array.from(new Set(ticks.map(t => t.host))) + .map(host => ({ type: 'normal', host } satisfies tt4b.site.SiteKey)) + const allSites = await siteDb.getBatch(allSiteKeys) + const siteCateMap = toMap(allSites, s => s.host, s => s.cate) + + return ticks.map(({ start, duration, host }) => { + const cateId = siteCateMap[host] ?? CATE_NOT_SET_ID + return { + start, duration, + seriesKey: `${cateId}`, + seriesName: cateNameMap[cateId], + } + }) +} + +async function fillSiteName(ticks: tt4b.timeline.Tick[]): Promise { + const allSiteKeys = Array.from(new Set(ticks.map(t => t.host))) + .map(host => ({ type: 'normal', host } satisfies tt4b.site.SiteKey)) + const allSites = await siteDb.getBatch(allSiteKeys) + const nameMap = toMap(allSites, s => s.host, s => s.alias) + + return ticks.map(({ start, duration, host }) => ({ + start, duration, + seriesKey: host, seriesName: nameMap[host], + })) +} diff --git a/src/background/service/whitelist/holder.ts b/src/background/service/whitelist/holder.ts new file mode 100644 index 000000000..3280de659 --- /dev/null +++ b/src/background/service/whitelist/holder.ts @@ -0,0 +1,61 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import db from "@db/whitelist-database" +import WhitelistProcessor from './processor' + +/** + * The singleton implementation of whitelist holder + */ +class WhitelistHolder { + private processor = new WhitelistProcessor() + private postHandlers: ArgCallback[] = [] + + constructor() { + this.rebuild() + } + + private async rebuild(whitelist?: string[]) { + whitelist ??= await db.selectAll() + this.processor.setWhitelist(whitelist) + this.postHandlers.forEach(handler => handler(whitelist)) + } + + addPostHandler(handler: ArgCallback) { + this.postHandlers.push(handler) + } + + async add(white: string): Promise { + await db.add(white) + await this.rebuild() + } + + all(): Promise { + return db.selectAll() + } + + async saveAll(toSave: string[]): Promise { + await db.saveAll(toSave) + await this.rebuild(toSave) + } + + async remove(white: string): Promise { + await db.remove(white) + await this.rebuild() + } + + contains(host: string, url: string): boolean { + return this.processor.contains(host, url) + } + + containsHost(host: string): boolean { + return this.processor.containsHost(host) + } +} + +const whitelistHolder = new WhitelistHolder() +export default whitelistHolder \ No newline at end of file diff --git a/src/service/whitelist/processor.ts b/src/background/service/whitelist/processor.ts similarity index 92% rename from src/service/whitelist/processor.ts rename to src/background/service/whitelist/processor.ts index 27b95b280..fbad9fc93 100644 --- a/src/service/whitelist/processor.ts +++ b/src/background/service/whitelist/processor.ts @@ -30,4 +30,8 @@ export default class WhitelistProcessor { if (this.exclude.some(r => r.test(url))) return false return this.host.includes(host) || this.virtual.some(r => r.test(url)) } + + containsHost(host: string): boolean { + return this.host.includes(host) + } } diff --git a/src/background/side-panel.ts b/src/background/side-panel.ts deleted file mode 100644 index 9d46396cb..000000000 --- a/src/background/side-panel.ts +++ /dev/null @@ -1,13 +0,0 @@ -/** - * Copyright (c) 2024-present Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { IS_MV3 } from "@util/constant/environment" - -export default function initSidePanel() { - if (!IS_MV3) return - chrome.sidePanel?.setOptions?.({ path: "/static/side.html" }) -} \ No newline at end of file diff --git a/src/background/tab-listener.ts b/src/background/tab-listener.ts new file mode 100644 index 000000000..0ed493437 --- /dev/null +++ b/src/background/tab-listener.ts @@ -0,0 +1,49 @@ +/** + * Copyright (c) 2021 Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import { getTab, onTabActivated, onTabUpdated } from "@api/chrome/tab" + +type TabProfile = { + url: string + tabId: number +} + +type UpdatedHandler = (tabId: number, info: ChromeTabUpdatedInfo, tab: ChromeTab) => void + +export default class TabListener { + activatedHandlers: ArgCallback<{ url: string, tabId: number }>[] = [] + updatedHandlers: UpdatedHandler[] = [] + + private async processActivated(tab: ChromeTab) { + const { url, id: tabId } = tab + if (!url || !tabId) return + const tabProf: TabProfile = { url, tabId } + this.activatedHandlers.forEach(func => func(tabProf)) + } + + onActivated(handler: ArgCallback): TabListener { + this.activatedHandlers.push(handler) + return this + } + + onUpdated(handler: UpdatedHandler): TabListener { + this.updatedHandlers.push(handler) + return this + } + + start() { + onTabActivated(async tabId => { + const tab = await getTab(tabId) + tab && this.processActivated(tab) + }) + + onTabUpdated(async (tabId, changeInfo, tab) => { + this.updatedHandlers.forEach(func => func(tabId, changeInfo, tab)) + }) + } +} + diff --git a/src/background/track-server.ts b/src/background/track-server.ts deleted file mode 100644 index 679804c8f..000000000 --- a/src/background/track-server.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { getTab, listTabs, sendMsg2Tab } from "@api/chrome/tab" -import { getWindow } from "@api/chrome/window" -import optionHolder from "@service/components/option-holder" -import itemService, { type ItemIncContext } from "@service/item-service" -import limitService from "@service/limit-service" -import periodService from "@service/period-service" -import whitelistHolder from "@service/whitelist/holder" -import { IS_ANDROID } from "@util/constant/environment" -import { extractHostname } from "@util/pattern" -import { formatTimeYMD, getStartOfDay, MILL_PER_DAY } from "@util/time" -import badgeManager from "./badge-manager" -import MessageDispatcher from "./message-dispatcher" - -async function handleTime(context: ItemIncContext, timeRange: [number, number], tabId: number | undefined): Promise { - const { host, url } = context - const [start, end] = timeRange - const focusTime = end - start - // 1. Save async - await itemService.addFocusTime(context, focusTime) - // 2. Process limit - const { limited, reminder } = await limitService.addFocusTime(host, url, focusTime) - // If time limited after this operation, send messages - limited?.length && sendLimitedMessage(limited) - // If need to reminder, send messages - reminder?.items?.length && tabId && sendMsg2Tab(tabId, 'limitReminder', reminder) - // 3. Add period time - await periodService.add(start, focusTime) - return focusTime -} - -async function handleTrackTimeEvent(event: timer.core.Event, sender: ChromeMessageSender): Promise { - const { url, start, end, ignoreTabCheck } = event - const { id: tabId, windowId, groupId } = sender?.tab || {} - if (!ignoreTabCheck) { - if (await windowNotFocused(windowId)) return - if (await tabNotActive(tabId)) return - } - const { protocol, host } = extractHostname(url) || {} - const option = await optionHolder.get() - - if (protocol === "file" && !option?.countLocalFiles) return - if (whitelistHolder.contains(host, url)) return - - await handleTime({ host, url, groupId }, [start, end], tabId) - if (tabId) { - const winTabs = await listTabs({ active: true, windowId }) - const firstActiveTab = winTabs?.[0] - // Cause there is no way to determine whether this tab is selected in screen-split mode - // So only show badge for first tab for screen-split mode - // @see #246 - firstActiveTab?.id === tabId && badgeManager.updateFocus({ tabId, url }) - } -} - -async function windowNotFocused(winId: number | undefined): Promise { - if (IS_ANDROID) return false - if (!winId) return true - const window = await getWindow(winId) - return !window?.focused -} - -async function tabNotActive(tabId: number | undefined): Promise { - if (!tabId) return true - const tab = await getTab(tabId) - return !tab?.active -} - -async function sendLimitedMessage(items: timer.limit.Item[]) { - const tabs = await listTabs() - if (!tabs?.length) return - for (const tab of tabs) { - try { - const { id } = tab - id && await sendMsg2Tab(id, 'limitTimeMeet', items) - } catch { - /* Ignored */ - } - } -} - -async function handleVisit(context: ItemIncContext) { - await itemService.increaseVisit(context) - const { host, url } = context - const metLimits = await limitService.incVisit(host, url) - // If time limited after this operation, send messages - metLimits?.length && sendLimitedMessage(metLimits) -} - -async function handleIncVisitEvent(param: { host: string, url: string }, sender: ChromeMessageSender): Promise { - const { host, url } = param || {} - const { groupId } = sender?.tab ?? {} - const { protocol } = extractHostname(url) || {} - const option = await optionHolder.get() - if (protocol === "file" && !option.countLocalFiles) return - await handleVisit({ host, url, groupId }) -} - -function splitRunTime(start: number, end: number): Record { - const res: Record = {} - while (start < end) { - const startOfNextDay = getStartOfDay(start).getTime() + MILL_PER_DAY - const newStart = Math.min(end, startOfNextDay) - const runTime = newStart - start - runTime && (res[formatTimeYMD(start)] = runTime) - start = newStart - } - return res -} - -const RUN_TIME_END_CACHE: { [host: string]: number } = {} - -async function handleTrackRunTimeEvent(event: timer.core.Event): Promise { - const { start, end, url, host } = event || {} - if (!host || !start || !end) return - if (whitelistHolder.contains(host, url)) return - const realStart = Math.max(RUN_TIME_END_CACHE[host] ?? 0, start) - const byDate = splitRunTime(realStart, end) - if (!Object.keys(byDate).length) return - await itemService.addRunTime(host, byDate) - RUN_TIME_END_CACHE[host] = Math.max(end, realStart) -} - -function handleTabGroupRemove(group: chrome.tabGroups.TabGroup) { - itemService.batchDeleteGroupById(group.id) -} - -function handleTabGroupEnabled() { - try { - chrome.tabGroups.onRemoved.removeListener(handleTabGroupRemove) - chrome.tabGroups.onRemoved.addListener(handleTabGroupRemove) - } catch (e) { - console.warn('failed to handle event: enableTabGroup', e) - } -} - -export default function initTrackServer(messageDispatcher: MessageDispatcher) { - messageDispatcher - .register('cs.trackTime', handleTrackTimeEvent) - .register('cs.trackRunTime', handleTrackRunTimeEvent) - .register<{ host: string, url: string }, void>('cs.incVisitCount', handleIncVisitEvent) - .register('cs.getTodayInfo', host => itemService.getResult(host, new Date())) - .register('enableTabGroup', handleTabGroupEnabled) -} diff --git a/src/background/track-server/file-tracker.ts b/src/background/track-server/file-tracker.ts new file mode 100644 index 000000000..5f352f817 --- /dev/null +++ b/src/background/track-server/file-tracker.ts @@ -0,0 +1,92 @@ +import { getTab, onTabActivated, onTabUpdated } from '@api/chrome/tab' +import { onWindowFocusChanged } from '@api/chrome/window' +import optionHolder from '@service/components/option-holder' +import { extractFileHost } from '@util/pattern' +import { handleTrackTimeEvent } from './normal' + +type Context = { + host: string + tab: ChromeTab + // Start timestamp of this tick + start: number +} + +async function convertContext(tabOrId: number | ChromeTab): Promise { + const tab = typeof tabOrId === 'number' ? await getTab(tabOrId) : tabOrId + if (!tab) return null + const { active, url } = tab + if (!active || !url) return null + const fileHost = extractFileHost(url) + if (!fileHost) return null + return { + host: fileHost, tab, + start: Date.now(), + } +} + +/** + * Local file tracker for firefox + */ +class FileTracker { + #enabled = false + #current: Context | null = null + // Context saved when window loses focus, restored when focus returns + #suspended: Context | null = null + #windowFocused = true + + async init() { + optionHolder.get() + .then(v => this.#enabled = v.countLocalFiles) + .catch(e => console.info("Failed to get countLocalFiles:", e)) + optionHolder.addChangeListener(v => this.#enabled = v.countLocalFiles) + + onTabActivated(async tabId => { + this.#tick() + this.#current = await convertContext(tabId) + this.#suspended = null + }) + + onTabUpdated(async (_tabId, changeInfo, tab) => { + if (!changeInfo.url || !tab.active) return + const newContext = await convertContext(tab) + if (this.#current?.host !== newContext?.host) { + // File host changed or navigated away from file URL + this.#tick() + this.#current = newContext + this.#suspended = null + } + }) + + onWindowFocusChanged(async windowId => { + if (windowId === chrome.windows.WINDOW_ID_NONE) { + this.#tick() + this.#suspended = this.#current + this.#current = null + this.#windowFocused = false + } else if (!this.#windowFocused) { + this.#windowFocused = true + if (this.#suspended) { + // Re-validate: tab may have been closed or navigated away during blur + const suspendedTabId = this.#suspended.tab.id + this.#suspended = null + this.#current = suspendedTabId ? await convertContext(suspendedTabId) : null + } + } + }) + + // NOTE: if migrate to MV3, this line won't work expectedly + setInterval(() => this.#tick(), 1000) + } + + #tick() { + if (!this.#current) return + const { host, tab, start } = this.#current + const end = Date.now() + if (this.#enabled) { + void handleTrackTimeEvent({ host, start, end, ignoreTabCheck: true }, tab) + } + this.#current.start = end + } +} + +export default FileTracker \ No newline at end of file diff --git a/src/background/track-server/group.ts b/src/background/track-server/group.ts new file mode 100644 index 000000000..f72a14e7c --- /dev/null +++ b/src/background/track-server/group.ts @@ -0,0 +1,22 @@ +import db from "@db/stat-database" +import optionHolder from '../service/components/option-holder' + +const handleRemove = (group: chrome.tabGroups.TabGroup) => db.deleteByGroup(group.id) + +function handleTabGroupsEnabled(option: tt4b.option.TrackingOption) { + // Do nothing if not enabled + if (!option.countTabGroup) return + try { + chrome.tabGroups.onRemoved.removeListener(handleRemove) + chrome.tabGroups.onRemoved.addListener(handleRemove) + } catch (e) { + console.info('failed to handle event: enableTabGroup', e) + } +} + +export async function initTabGroup() { + const option = await optionHolder.get() + handleTabGroupsEnabled(option) + + optionHolder.addChangeListener(newVal => handleTabGroupsEnabled(newVal)) +} \ No newline at end of file diff --git a/src/background/track-server/index.ts b/src/background/track-server/index.ts new file mode 100644 index 000000000..2301afbc0 --- /dev/null +++ b/src/background/track-server/index.ts @@ -0,0 +1,24 @@ +import { IS_ANDROID, IS_FIREFOX } from '@util/constant/environment' +import { isFileUrl } from '@util/pattern' +import type MessageDispatcher from "../message-dispatcher" +import FileTracker from './file-tracker' +import { initTabGroup } from './group' +import { handleTrackTimeEvent } from './normal' +import { handleTrackRunTimeEvent } from './runtime' + +export default function initTrackServer(messageDispatcher: MessageDispatcher) { + messageDispatcher + .register('track.time', async (ev, { tab }) => { + const url = tab?.url + // Not to process event from FF file tab + if (IS_FIREFOX && url && isFileUrl(url)) return + await handleTrackTimeEvent(ev, tab) + }) + .register('track.runTime', (ev, { url }) => handleTrackRunTimeEvent(ev, url)) + + initTabGroup() + + // Track file time in background script for FF + // Not accurate, since can't detect if the tabs are active or not + IS_FIREFOX && !IS_ANDROID && new FileTracker().init() +} diff --git a/src/background/track-server/normal.ts b/src/background/track-server/normal.ts new file mode 100644 index 000000000..0ace38c9c --- /dev/null +++ b/src/background/track-server/normal.ts @@ -0,0 +1,98 @@ +import { listTabs, sendMsg2Tab } from "@api/chrome/tab" +import { getWindow } from '@api/chrome/window' +import optionHolder from "@service/components/option-holder" +import { + addFocusTime as addItemFocusTime, increaseVisit as increaseItemVisit, type ItemIncContext, +} from "@service/item-service" +import { addLimitFocusTime, incLimitVisit } from '@service/limit-service' +import periodThrottler from '@service/throttler/period-throttler' +import whitelistHolder from "@service/whitelist/holder" +import { IS_ANDROID } from "@util/constant/environment" +import { extractHostname } from "@util/pattern" +import badgeManager from "../badge-manager" + +async function handleTime(context: ItemIncContext, timeRange: [number, number], tabId: number | undefined): Promise { + const { host, url } = context + const [start, end] = timeRange + const focusTime = end - start + // 1. Save async + await addItemFocusTime(context, focusTime) + // 2. Process limit + const { limited, reminder } = await addLimitFocusTime(host, url, focusTime) + // If time limited after this operation, send messages + limited.length && void sendLimitedMessage(limited) + // If need to reminder, send messages + reminder?.items?.length && tabId && void sendMsg2Tab(tabId, 'limitReminder', reminder) + // 3. Add period time + periodThrottler.add(start, focusTime) + return focusTime +} + +export async function handleTrackTimeEvent(event: tt4b.core.Event, tab: ChromeTab | undefined): Promise { + if (!tab) return + const { id: tabId, windowId, groupId, url, active } = tab + if (!url) return + + const { start, end, ignoreTabCheck } = event + if (!ignoreTabCheck) { + if (await windowNotFocused(windowId)) return + if (!active) return + } + const { protocol, host } = extractHostname(url) + + const { countLocalFiles } = await optionHolder.get() + if (protocol === "file" && !countLocalFiles) return + + if (whitelistHolder.contains(host, url)) return + + await handleTime({ host, url, groupId }, [start, end], tabId) + if (tabId) { + if (!ignoreTabCheck) { + // Cause there is no way to determine whether this tab is selected in screen-split mode + // So only show badge for first tab for screen-split mode + // @see #246 + const winTabs = await listTabs({ active: true, windowId }) + const firstActiveTab = winTabs[0] + if (firstActiveTab?.id !== tabId) return + } + void badgeManager.updateFocus({ tabId, url }) + } +} + +async function windowNotFocused(winId: number | undefined): Promise { + if (IS_ANDROID) return false + if (!winId) return true + const window = await getWindow(winId) + return !window?.focused +} + +async function sendLimitedMessage(items: tt4b.limit.Item[]) { + const tabs = await listTabs() + if (!tabs.length) return + for (const tab of tabs) { + try { + const { id } = tab + id && await sendMsg2Tab(id, 'limitTimeMeet', items) + } catch { + /* Ignored */ + } + } +} + +async function handleVisit(context: ItemIncContext) { + await increaseItemVisit(context) + const { host, url } = context + const metLimits = await incLimitVisit(host, url) + // If time limited after this operation, send messages + metLimits.length && await sendLimitedMessage(metLimits) +} + +export async function incVisitCount(tab: ChromeTab | undefined): Promise { + const { groupId, url } = tab ?? {} + if (!url) return + const { protocol, host } = extractHostname(url) + const option = await optionHolder.get() + if (protocol === "file" && !option.countLocalFiles) return + await handleVisit({ host, url, groupId }) +} + diff --git a/src/background/track-server/runtime.ts b/src/background/track-server/runtime.ts new file mode 100644 index 000000000..506e203bc --- /dev/null +++ b/src/background/track-server/runtime.ts @@ -0,0 +1,29 @@ +import whitelistHolder from "@service/whitelist/holder" +import FIFOCache from '@util/fifo-cache' +import { formatTimeYMD, getStartOfDay, MILL_PER_DAY } from "@util/time" +import { addRunTime } from '../service/item-service' + +function splitRunTime(start: number, end: number): Record { + const res: Record = {} + while (start < end) { + const startOfNextDay = getStartOfDay(start) + MILL_PER_DAY + const newStart = Math.min(end, startOfNextDay) + const runTime = newStart - start + runTime && (res[formatTimeYMD(start)] = runTime) + start = newStart + } + return res +} + +const RUN_TIME_END_CACHE = new FIFOCache(500) + +export async function handleTrackRunTimeEvent(event: tt4b.core.Event, url: string | undefined): Promise { + const { start, end, host } = event + if (!host || !start || !end || !url) return + if (whitelistHolder.contains(host, url)) return + const realStart = Math.max(RUN_TIME_END_CACHE.get(host) ?? 0, start) + const byDate = splitRunTime(realStart, end) + if (!Object.keys(byDate).length) return + await addRunTime(host, byDate) + RUN_TIME_END_CACHE.set(host, Math.max(end, realStart)) +} \ No newline at end of file diff --git a/src/background/whitelist-menu-manager.ts b/src/background/whitelist-menu-manager.ts index 11c59d9e3..a9b058d03 100644 --- a/src/background/whitelist-menu-manager.ts +++ b/src/background/whitelist-menu-manager.ts @@ -8,20 +8,15 @@ import { createContextMenu, updateContextMenu } from "@api/chrome/context-menu" import { getRuntimeId } from "@api/chrome/runtime" import { getTab, onTabActivated, onTabUpdated } from "@api/chrome/tab" -import db from "@db/whitelist-database" import { t2Chrome } from "@i18n/chrome/t" -import { type ContextMenusMessage } from "@i18n/message/common/context-menus" -import optionHolder from "@service/components/option-holder" import { IS_ANDROID } from "@util/constant/environment" import { extractHostname, isBrowserUrl } from "@util/pattern" +import optionHolder from "./service/components/option-holder" +import whitelistHolder from './service/whitelist/holder' const menuId = '_timer_menu_item_' + getRuntimeId() let currentActiveId: number -let whitelist: string[] = [] - -const removeOrAdd = (removeOrAddFlag: boolean, white: string) => removeOrAddFlag ? db.remove(white) : db.add(white) - const menuInitialOptions: ChromeContextMenuCreateProps = { contexts: ['page', 'frame', 'selection', 'link', 'editable', 'image', 'video', 'audio'], id: menuId, @@ -31,39 +26,29 @@ const menuInitialOptions: ChromeContextMenuCreateProps = { } async function updateContextMenuInner(param: ChromeTab | number | undefined): Promise { - if (typeof param === 'number') { - // If number, get the tabInfo first - const tab: ChromeTab = await getTab(currentActiveId) - tab && await updateContextMenuInner(tab) + const tab = typeof param === 'number' ? await getTab(currentActiveId) : param + const { url } = tab ?? {} + + const targetHost = url && !isBrowserUrl(url) ? extractHostname(url).host : undefined + const visible = (await optionHolder.get())?.displayWhitelistMenu + const changeProp: ChromeContextMenuUpdateProps = {} + if (targetHost && visible) { + const exist = whitelistHolder.containsHost(targetHost) + changeProp.visible = visible + changeProp.title = t2Chrome(root => root.contextMenus[exist ? 'removeFromWhitelist' : 'add2Whitelist']) + .replace('{host}', targetHost) + changeProp.onclick = () => exist ? whitelistHolder.remove(targetHost) : whitelistHolder.add(targetHost) } else { - const { url } = param || {} - const targetHost = url && !isBrowserUrl(url) ? extractHostname(url).host : '' - const changeProp: ChromeContextMenuUpdateProps = {} - if (!targetHost) { - // If not a valid host, hide this menu - changeProp.visible = false - } else { - // Else change the title - const visible = (await optionHolder.get())?.displayWhitelistMenu - const existsInWhitelist = whitelist.includes(targetHost) - changeProp.visible = true && visible - const titleMsgField: keyof ContextMenusMessage = existsInWhitelist ? 'removeFromWhitelist' : 'add2Whitelist' - changeProp.title = t2Chrome(root => root.contextMenus[titleMsgField]).replace('{host}', targetHost) - changeProp.onclick = () => removeOrAdd(existsInWhitelist, targetHost) - } - await updateContextMenu(menuId, changeProp) + // If not a valid host, hide this menu + changeProp.visible = false } + await updateContextMenu(menuId, changeProp) } -const handleListChange = (newWhitelist: string[]) => { - whitelist = newWhitelist - updateContextMenuInner(currentActiveId) -} - -const handleTabUpdated = (tabId: number, changeInfo: ChromeTabChangeInfo, tab?: ChromeTab) => { +const handleTabUpdated = (tabId: number, updatedInfo: ChromeTabUpdatedInfo, tab?: ChromeTab) => { // Current active tab updated tabId === currentActiveId - && changeInfo.status === 'loading' + && updatedInfo.status === 'loading' && updateContextMenuInner(tab) } @@ -77,7 +62,7 @@ async function initWhitelistMenuManager() { createContextMenu(menuInitialOptions) onTabUpdated(handleTabUpdated) onTabActivated((_tabId, activeInfo) => handleTabActivated(activeInfo)) - db.addChangeListener(handleListChange) + whitelistHolder.addPostHandler(() => updateContextMenuInner(currentActiveId)) } export default initWhitelistMenuManager \ No newline at end of file diff --git a/src/common/logger.ts b/src/common/logger.ts index 7f4189b14..105cdb512 100644 --- a/src/common/logger.ts +++ b/src/common/logger.ts @@ -17,14 +17,6 @@ function initOpenLog() { } catch (ignored) { } } -function updateLocalStorage(openState: boolean) { - try { - openState - ? localStorage.setItem(STORAGE_KEY, STORAGE_VAL) - : localStorage.removeItem(STORAGE_KEY) - } catch (ignored) { } -} - initOpenLog() /** @@ -34,19 +26,3 @@ initOpenLog() export function log(...args: any) { OPEN_LOG && console.log(...args) } - -/** - * @since 0.0.4 - */ -export function openLog(): string { - updateLocalStorage(OPEN_LOG = true) - return 'Opened the log manually.' -} - -/** - * @since 0.0.8 - */ -export function closeLog(): string { - updateLocalStorage(OPEN_LOG = false) - return 'Closed the log manually.' -} \ No newline at end of file diff --git a/src/common/timer.ts b/src/common/timer.ts deleted file mode 100644 index be79c5f0d..000000000 --- a/src/common/timer.ts +++ /dev/null @@ -1,49 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { getUsedStorage } from "@db/memory-detector" -import { closeLog, openLog } from "./logger" - -/** - * Show the memory info - * - * @since 0.0.9 - */ -export function showMemory() { - getUsedStorage().then(({ used, total }) => { - console.log(`\t${used} / ${total} = ${Math.round(used * 100.0 / total * 100) / 100}%`) - }) - return 'Memory used:' -} - -export type Timer = { - openLog: () => string - closeLog: () => string - showMemory: () => void -} - -/** - * @since 0.0.8 - */ -const timer = { - openLog, - closeLog, - showMemory -} as Timer - -declare global { - interface Window { - timer: Timer - } -} - -/** - * Manually open and close the log - * - * @since 0.0.8 - */ -window.timer = timer diff --git a/src/content-script/dispatcher.ts b/src/content-script/dispatcher.ts new file mode 100644 index 000000000..bb19f920d --- /dev/null +++ b/src/content-script/dispatcher.ts @@ -0,0 +1,62 @@ +/** + * Copyright (c) 2022-present Hengyang Zhang + * + * This software is released under the MIT License. + * https://opensource.org/licenses/MIT + */ + +import type { AudibleChangeHandler } from './types' + +type Handler = (data: tt4b.tab.ReqData) => tt4b.tab.ResData + +class Dispatcher { + private handlers: Partial>> = {} + private audibleChangeHandlers: AudibleChangeHandler[] = [] + + constructor() { + // Be careful!!! + // Can't use await/async in callback parameter + chrome.runtime.onMessage.addListener((message: tt4b.tab.Request, _, sendResponse: tt4b.tab.Callback) => { + this.handle(message) + .then(sendResponse) + .catch((err: unknown) => { + const msg = err instanceof Error ? err.message : String(err) + console.error('onTabMessage handler error', err) + sendResponse({ code: 'fail', msg }) + }) + // 'return true' will force chrome to wait for the response processed in the above promise. + // @see https://github.com/mozilla/webextension-polyfill/issues/130 + return true + }) + + this.register('syncAudible', audible => void this.audibleChangeHandlers.forEach(h => h.onAudibleChange(audible))) + } + + register(code: Code, handler: Handler): Dispatcher { + this.handlers[code] = handler + return this + } + + registerAudibleChange(handler: AudibleChangeHandler): Dispatcher { + this.audibleChangeHandlers.push(handler) + return this + } + + private async handle(message: tt4b.tab.Request): Promise> { + const code = message?.code + if (!code) { + return { code: 'ignore' } + } + const handler = this.handlers[code] + if (!handler) return { code: 'ignore' } + try { + const res = handler(message.data as tt4b.tab.ReqData) + return { code: "success", data: res as tt4b.tab.ResData } + } catch (error) { + const msg = error instanceof Error ? error.message : (error?.toString?.() ?? 'Unknown error') + return { code: 'fail', msg } + } + } +} + +export default Dispatcher diff --git a/src/content-script/index.ts b/src/content-script/index.ts index 11e3da50d..c99134ca8 100644 --- a/src/content-script/index.ts +++ b/src/content-script/index.ts @@ -5,8 +5,9 @@ * https://opensource.org/licenses/MIT */ -import { sendMsg2Runtime } from "@api/chrome/runtime" +import { trySendMsg2Runtime } from '@api/sw/common' import { initLocale } from "@i18n" +import Dispatcher from './dispatcher' import processLimit from "./limit" import printInfo from "./printer" import processTimeline from './timeline' @@ -21,8 +22,7 @@ const FLAG_ID = '__TIMER_INJECTION_FLAG__' + chrome.runtime.id function getOrSetFlag(): boolean { const pre = document?.getElementById(FLAG_ID) if (!pre) { - const flag = document.createElement('a') - flag.href = '#' + const flag = document.createElement('span') flag.style && (flag.style.visibility = 'hidden') flag && (flag.id = FLAG_ID) @@ -39,45 +39,34 @@ function getOrSetFlag(): boolean { return !!pre } -/** - * Wrap for hooks, after the extension reloaded or upgraded, the context of current content script will be invalid - * And sending messages to the runtime will be failed - */ -async function trySendMsg2Runtime(code: timer.mq.ReqCode, data?: Req): Promise { - try { - return await sendMsg2Runtime(code, data) - } catch { - // ignored - } -} - async function main() { - // Execute in every injections + const dispatcher = new Dispatcher() + + // Execute in every injection const normalTracker = new NormalTracker({ - onReport: data => trySendMsg2Runtime('cs.trackTime', data), - onResume: reason => reason === 'idle' && trySendMsg2Runtime('cs.idleChange', false), - onPause: reason => reason === 'idle' && trySendMsg2Runtime('cs.idleChange', true), + onReport: data => trySendMsg2Runtime('track.time', data), + onResume: reason => reason === 'idle' && trySendMsg2Runtime('cs.idleChanged', false), + onPause: reason => reason === 'idle' && trySendMsg2Runtime('cs.idleChanged', true), }) normalTracker.init() - const runTimeTracker = new RunTimeTracker(url) - runTimeTracker.init() + dispatcher.registerAudibleChange(normalTracker) + new RunTimeTracker(url).init(dispatcher) // Execute only one time for each dom if (getOrSetFlag()) return if (!host) return - const isWhitelist = await sendMsg2Runtime('cs.isInWhitelist', { host, url }) + const isWhitelist = await trySendMsg2Runtime('whitelist.contain', { host, url }) if (isWhitelist) return - await initLocale() - const needPrintInfo = await sendMsg2Runtime('cs.printTodayInfo') - !!needPrintInfo && printInfo(host) - await processLimit(url) + void initLocale() + void printInfo(host) + await processLimit(url, dispatcher) processTimeline() // Increase visit count at the end - await sendMsg2Runtime('cs.incVisitCount', { host, url }) + await trySendMsg2Runtime('cs.injected') } -main() \ No newline at end of file +void main() diff --git a/src/content-script/limit/common.ts b/src/content-script/limit/common.ts index 37fe3abae..8f9cc2ecf 100644 --- a/src/content-script/limit/common.ts +++ b/src/content-script/limit/common.ts @@ -1,10 +1,4 @@ -export type LimitReason = - & RequiredPick - & PartialPick - & { - type: timer.limit.ReasonType - getVisitTime?: () => number - } +import type { LimitReason } from './types' export function isSameReason(a: LimitReason, b: LimitReason): boolean { if (a?.id !== b?.id || a?.type !== b?.type) return false @@ -15,23 +9,6 @@ export function isSameReason(a: LimitReason, b: LimitReason): boolean { return true } -export interface MaskModal { - addReason(...reasons: LimitReason[]): void - removeReason(...reasons: LimitReason[]): void - removeReasonsByType(...types: timer.limit.ReasonType[]): void - addDelayHandler(handler: () => void): void -} - -export type ModalContext = { - url: string - modal: MaskModal -} - -export interface Processor { - handleMsg(code: timer.mq.ReqCode, data: unknown): timer.mq.Response | Promise - init(): void | Promise -} - export async function exitFullscreen(): Promise { if (!document?.fullscreenElement) return if (!document?.exitFullscreen) return diff --git a/src/content-script/limit/element.ts b/src/content-script/limit/element.ts deleted file mode 100644 index f7bb6ea11..000000000 --- a/src/content-script/limit/element.ts +++ /dev/null @@ -1,7 +0,0 @@ -export const TAG_NAME = 'extension-time-tracker-overlay' - -export class RootElement extends HTMLElement { - constructor() { - super() - } -} diff --git a/src/content-script/limit/index.ts b/src/content-script/limit/index.ts index 8180df1b1..f3e28fd97 100644 --- a/src/content-script/limit/index.ts +++ b/src/content-script/limit/index.ts @@ -1,42 +1,35 @@ -import { onRuntimeMessage } from "@api/chrome/runtime" -import { allMatch } from "@util/array" -import { type MaskModal, type ModalContext, type Processor } from "./common" -import ModalInstance from "./modal" -import MessageAdaptor from "./processor/message-adaptor" +import { getOption } from '@api/sw/option' +import Dispatcher from '../dispatcher' +import ModalInstance from "./modal/instance" +import MessageAdaptor from './processor/message-adaptor' import PeriodProcessor from "./processor/period-processor" import VisitProcessor from "./processor/visit-processor" -import Reminder from "./reminder" +import Reminder from './reminder' +import type { ModalContext, Processor } from './types' -export default async function processLimit(url: string) { - const modal: MaskModal = new ModalInstance(url) +export default async function processLimit(url: string, dispatcher: Dispatcher) { + const { limitDelayDuration: delayDuration } = await getOption() + const modal = new ModalInstance(url) const context: ModalContext = { modal, url } + const messageAdaptor = new MessageAdaptor(context, delayDuration) + const visitProcessor = new VisitProcessor(context, delayDuration) + const processors: Processor[] = [ - new MessageAdaptor(context), + messageAdaptor, + visitProcessor, new PeriodProcessor(context), - new VisitProcessor(context), - new Reminder(), ] - await Promise.all(processors.map(p => p.init())) - onRuntimeMessage(async msg => { - const results = await Promise.all(processors.map(async p => { - const { code, data } = msg || {} - return await p.handleMsg(code, data) - })) + const reminder = new Reminder() - const allIgnore = allMatch(results, r => r.code === "ignore") - if (allIgnore) return { code: "ignore" } + dispatcher + .register('limitChanged', () => void processors.forEach(p => p.onLimitChanged())) + .register('limitTimeMeet', items => void messageAdaptor.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) - const anyFail = allMatch(results, r => r.code === "fail") - if (anyFail) return { code: "fail" } - // Merge data of all the handlers - const items = results - .filter(r => r.code === "success") - .map(r => r.data) - .filter(r => r !== undefined && r !== null) - const data = items.length <= 1 ? items[0] : items - return { code: "success", data } - }) + return visitProcessor.tracker } diff --git a/src/content-script/limit/modal/Main.tsx b/src/content-script/limit/modal/Main.tsx index f43e6ee74..ba9653a9a 100644 --- a/src/content-script/limit/modal/Main.tsx +++ b/src/content-script/limit/modal/Main.tsx @@ -1,9 +1,11 @@ +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 { provideRule } from "./context" -import "./style/index.sass" +import { provideRule } from './context' +import "./style/element-base.css" +import "./style/modal.css" const _default = defineComponent(() => { provideRule() diff --git a/src/content-script/limit/modal/bridge.ts b/src/content-script/limit/modal/bridge.ts new file mode 100644 index 000000000..3934c666e --- /dev/null +++ b/src/content-script/limit/modal/bridge.ts @@ -0,0 +1,91 @@ +import type { BridgeCode, BridgeHandler, BridgeRequest, BridgeResponse } from './types' + +type RpcBase = { + code: C + requestId: string +} + +type RpcRequest = RpcBase & { + kind: 'request' + data: BridgeRequest +} + +type RpcResponse = RpcBase & { + kind: 'response' + data: BridgeResponse +} + +const isRpcRequest = (payload: unknown): payload is RpcRequest => { + if (typeof payload !== 'object' || payload === null) return false + // Just judge the kind + return 'kind' in payload && payload.kind === 'request' +} + +const isRpcResponse = (payload: unknown): payload is RpcResponse => { + if (typeof payload !== 'object' || payload === null) return false + // Just judge the kind + return 'kind' in payload && payload.kind === 'response' +} + +export class ModalBridge { + private readonly pendingCache = new Map>>() + private readonly handlers = new Map>() + private readonly onMessageBound: (ev: MessageEvent) => void + + constructor(private origin: string, private peer: () => Window | undefined) { + this.onMessageBound = this.onMessage.bind(this) + window.addEventListener('message', this.onMessageBound) + } + + dispose(): void { + window.removeEventListener('message', this.onMessageBound) + this.pendingCache.clear() + this.handlers.clear() + } + + register(code: C, handler: BridgeHandler): ModalBridge { + this.handlers.set(code, handler as unknown as BridgeHandler) + return this + } + + request(code: C, req: BridgeRequest): Promise> { + const requestId = `${code}-${Date.now()}-${Math.random().toString(12).slice(2)}` + if (!this.peer()) return Promise.reject("Peer window is not available") + return new Promise>((resolve, reject) => { + const t = window.setTimeout(() => { + this.pendingCache.delete(requestId) + reject("Timeout") + }, 1_000) + this.pendingCache.set(requestId, msg => { + if (msg.kind !== 'response' || msg.code !== code) return + clearTimeout(t) + this.pendingCache.delete(requestId) + resolve(msg.data) + }) + this.send({ kind: 'request', code, requestId, data: req }) + }) + } + + private send(payload: RpcRequest | RpcResponse): void { + try { + this.peer()?.postMessage(payload, this.origin) + } catch { + // ignored + } + } + + private async onMessage(ev: MessageEvent) { + if (this.peer() !== ev.source) return + const { data: payload } = ev + if (isRpcRequest(payload)) { + const { code, data: reqData, requestId } = payload + const handler = this.handlers.get(code) + if (!handler) return + const data = await handler(reqData) + this.send({ kind: 'response', requestId, code, data }) + } else if (isRpcResponse(payload)) { + const pending = this.pendingCache.get(payload.requestId) + pending?.(payload) + } + } +} diff --git a/src/content-script/limit/modal/components/Alert.tsx b/src/content-script/limit/modal/components/Alert.tsx index 048e76b97..247398a05 100644 --- a/src/content-script/limit/modal/components/Alert.tsx +++ b/src/content-script/limit/modal/components/Alert.tsx @@ -1,25 +1,35 @@ -import { getUrl } from "@api/chrome/runtime" +import { getIconUrl } from "@api/chrome/runtime" +import { getOption } from "@api/sw/option" import { t } from "@cs/locale" -import { useRequest } from "@hooks/useRequest" -import optionHolder from "@service/components/option-holder" +import { useRequest, 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 ICON_URL = getUrl('static/images/icon.png') - const _default = defineComponent(() => { const defaultPrompt = t(msg => msg.modal.defaultPrompt) const { data: prompt } = useRequest(async () => { - const option = await optionHolder.get() - return option?.limitPrompt || defaultPrompt + const option = await getOption() + return option?.limitPrompt ?? defaultPrompt }, { defaultValue: defaultPrompt }) + + const isXs = useXsState() + return () => ( -
-

- - {t(msg => msg.meta.name)?.toUpperCase()} -

-

{prompt.value}

-
+ + + + {t(msg => msg.meta.name)?.toUpperCase()} + + + {prompt.value} + + ) }) diff --git a/src/content-script/limit/modal/components/Footer.tsx b/src/content-script/limit/modal/components/Footer.tsx index bb741df15..a1c212e16 100644 --- a/src/content-script/limit/modal/components/Footer.tsx +++ b/src/content-script/limit/modal/components/Footer.tsx @@ -1,80 +1,79 @@ -import { sendMsg2Runtime } from "@api/chrome/runtime" -import Trend from "@app/Layout/icons/Trend" -import { judgeVerificationRequired, processVerification } from "@app/util/limit" -import { TAG_NAME } from "@cs/limit/element" +import { APP_ANALYSIS_ROUTE, APP_LIMIT_ROUTE, type AppAnalysisQuery, type AppLimitQuery } from '@/shared/route' +import { trySendMsg2Runtime } from '@api/sw/common' +import { processVerification } from '@app/util/limit' import { t } from "@cs/locale" import { Plus, Timer } from "@element-plus/icons-vue" -import optionHolder from "@service/components/option-holder" +import Flex from '@pages/components/Flex' +import { Trend } from "@pages/icons" +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 { useDelayHandler, useReason, useRule } from "../context" - -async function handleMore5Minutes(rule: timer.limit.Item | null, callback: () => void) { - let promise: Promise | undefined = undefined - const ele = document.querySelector(TAG_NAME)?.shadowRoot?.querySelector('body') - if (rule && await judgeVerificationRequired(rule)) { - const option = await optionHolder.get() - promise = processVerification(option, { appendTo: ele ?? undefined }) - promise ? promise.then(callback).catch(() => { }) : callback() - } else { - callback() - } -} +import { useApp, useRule } from '../context' const _default = defineComponent(() => { - const reason = useReason() + const { reason, visitTime: currVisitTime, bridge, url, delayDuration } = useApp() + + const analysisUrl = getAppPageUrl(APP_ANALYSIS_ROUTE, { url } satisfies AppAnalysisQuery) + const ruleUrl = getAppPageUrl(APP_LIMIT_ROUTE, { url: encodeURI(url) } satisfies AppLimitQuery) + const rule = useRule() const showDelay = computed(() => { - const { type, allowDelay, delayCount = 0 } = reason.value || {} + const reasonVal = reason.value + if (!reasonVal) return false + const { type, allowDelay, delayCount = 0 } = reasonVal if (!allowDelay) return false - const { time, weekly, visitTime, waste, weeklyWaste } = rule.value || {} - let realLimit = 0, realWaste = 0 + const { time, weekly, visitTime, waste, weeklyWaste } = rule.value ?? {} + let maxLimitMs = 0, wasted = 0 if (type === 'DAILY') { - realLimit = time ?? 0 - realWaste = waste ?? 0 + maxLimitMs = (time ?? 0) * MILL_PER_SECOND + wasted = waste ?? 0 } else if (type === 'WEEKLY') { - realLimit = weekly ?? 0 - realWaste = weeklyWaste ?? 0 + maxLimitMs = (weekly ?? 0) * MILL_PER_SECOND + wasted = weeklyWaste ?? 0 } else if (type === 'VISIT') { - realLimit = visitTime ?? 0 - realWaste = reason.value?.getVisitTime?.() ?? 0 + maxLimitMs = (visitTime ?? 0) * MILL_PER_SECOND + wasted = currVisitTime.value } else { return false } - return meetTimeLimit(realLimit, realWaste, allowDelay, delayCount) + return meetTimeLimit( + { wasted, maxLimit: maxLimitMs }, + { count: delayCount, duration: delayDuration.value, allow: !!allowDelay }, + ) }) - const delayHandler = useDelayHandler() + const handleDelay = async () => { + const option = await trySendMsg2Runtime('option.get') + try { + if (option) await processVerification(option) + await bridge.request('delay', undefined) + } catch { + } + } return () => ( - + + + {t(msg => msg.modal.ruleDetail)} + + + ) }) diff --git a/src/content-script/limit/modal/components/Reason.tsx b/src/content-script/limit/modal/components/Reason.tsx index 7db01b007..4fc4a95dc 100644 --- a/src/content-script/limit/modal/components/Reason.tsx +++ b/src/content-script/limit/modal/components/Reason.tsx @@ -1,13 +1,22 @@ import { t } from "@cs/locale" -import { useRequest } from "@hooks/useRequest" +import { useXsState } from '@hooks' import Flex from "@pages/components/Flex" import { matchCond, meetLimit, meetTimeLimit, period2Str } from "@util/limit" import { formatPeriodCommon, MILL_PER_SECOND } from "@util/time" import { ElDescriptions, ElDescriptionsItem, ElTag } from 'element-plus' -import { computed, defineComponent } from "vue" -import { useGlobalParam, useReason, useRule } from "../context" +import { computed, defineComponent, type StyleValue } from "vue" +import { useApp, useRule } from '../context' -const renderBaseItems = (rule: timer.limit.Rule | null, url: string) => <> +const useDescriptions = () => { + const isXs = useXsState() + const style = computed(() => ({ + width: isXs.value ? '90vw' : '400px', + } satisfies StyleValue)) + const size = computed(() => isXs.value ? 'small' : undefined) + return { style, size } +} + +const renderBaseItems = (rule: tt4b.limit.Rule | undefined, url: string) => <> msg.limit.item.name)} labelAlign="right"> {rule?.name ?? '-'} @@ -16,85 +25,82 @@ const renderBaseItems = (rule: timer.limit.Rule | null, url: string) => <> -const TimeDescriptions = defineComponent({ - props: { - // Seconds - time: Number, - // Milliseconds - waste: Number, - count: Number, - visit: Number, - ruleLabel: String, - dataLabel: String, - }, - setup(props) { - const rule = useRule() - const reason = useReason() - const { url } = useGlobalParam() +type DescriptionProps = { + time?: number + waste?: number + count?: number + visit?: number + ruleLabel?: string + dataLabel?: string +} - const timeLimited = computed(() => meetTimeLimit(props.time ?? 0, props.waste ?? 0, !!reason.value?.allowDelay, reason.value?.delayCount ?? 0)) - const visitLimited = computed(() => meetLimit(props.count ?? 0, props.visit ?? 0)) +const TimeDescriptions = defineComponent(props => { + const { reason, url, delayDuration } = useApp() + const rule = useRule() + const { style, size } = useDescriptions() - return () => ( - - {renderBaseItems(rule.value, url)} - - - {formatPeriodCommon((props.time ?? 0) * MILL_PER_SECOND)} - {`${props.count ?? 0} ${t(msg => msg.limit.item.visits)}`} - - - - - - {formatPeriodCommon(props.waste ?? 0)} - - - {`${props.visit ?? 0} ${t(msg => msg.limit.item.visits)}`} - - - - msg.limit.item.delayCount)} - labelAlign="right" - > - {reason.value?.delayCount ?? 0} - - - ) - }, -}) + const timeLimited = computed(() => meetTimeLimit( + { wasted: props.waste ?? 0, maxLimit: (props.time ?? 0) * MILL_PER_SECOND }, + { + count: reason.value?.delayCount ?? 0, + duration: delayDuration.value, + allow: !!reason.value?.allowDelay, + }, + )) + const visitLimited = computed(() => meetLimit(props.count ?? 0, props.visit ?? 0)) + + return () => ( + + {renderBaseItems(rule.value, url)} + + + {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 reason = useReason() - const rule = useRule() - const { url } = useGlobalParam() + const { reason, visitTime, url } = useApp() const type = computed(() => reason.value?.type) + const rule = useRule() - const { data: browsingTime, refresh: refreshBrowsingTime } = useRequest(() => { - const { getVisitTime, type } = reason.value || {} - if (type !== 'VISIT') return - return getVisitTime?.() || 0 - }) - - setInterval(refreshBrowsingTime, 1000) + const { style, size } = useDescriptions() return () => ( -
+ msg.limit.item.daily)} + ruleLabel={t(msg => msg.shared.limit.daily)} dataLabel={t(msg => msg.calendar.range.today)} /> { count={rule.value?.weeklyCount} waste={rule.value?.weeklyWaste} visit={rule.value?.weeklyVisit} - ruleLabel={t(msg => msg.limit.item.weekly)} + ruleLabel={t(msg => msg.shared.limit.weekly)} dataLabel={t(msg => msg.calendar.range.thisWeek)} /> - + {renderBaseItems(rule.value, url)} msg.limit.item.visitTime)} labelAlign="right"> {formatPeriodCommon((rule.value?.visitTime ?? 0) * MILL_PER_SECOND) || '-'} msg.modal.browsingTime)} labelAlign="right"> - {browsingTime.value ? formatPeriodCommon(browsingTime.value) : '-'} + {visitTime.value ? formatPeriodCommon(visitTime.value) : '-'} { {reason.value?.delayCount ?? 0} - + {renderBaseItems(rule.value, url)} - msg.limit.item.period)} labelAlign="right"> - { - rule.value?.periods?.length - ?
- {rule.value?.periods.map(p => {period2Str(p)})} -
- : '-' + msg.shared.limit.period)} labelAlign="right"> + {rule.value?.periods?.length + ?
+ {rule.value.periods.map(p => {period2Str(p)})} +
+ : '-' }
-
+ ) }) diff --git a/src/content-script/limit/modal/context.ts b/src/content-script/limit/modal/context.ts index 1da91a102..bf2fc47fa 100644 --- a/src/content-script/limit/modal/context.ts +++ b/src/content-script/limit/modal/context.ts @@ -1,53 +1,65 @@ -import { useRequest } from '@hooks/useRequest' -import { useWindowFocus } from '@hooks/useWindowFocus' -import limitService from "@service/limit-service" -import { type App, inject, provide, type Ref, shallowRef, watch } from "vue" -import { type LimitReason } from "../common" - -const REASON_KEY = "display_reason" -const RULE_KEY = "display_rule" -const GLOBAL_KEY = "delay_global" -const DELAY_HANDLER_KEY = 'delay_handler' - -type GlobalParam = { +import { listLimits } from "@api/sw/limit" +import { getOption } from '@api/sw/option' +import { useDocumentVisibility, useRequest } from '@hooks' +import { type App, inject, provide, ref, type Ref, type ShallowRef, watch } from "vue" +import { ModalBridge } from './bridge' +import type { LimitReasonData } from './types' + +const GLOBAL_KEY = "global" +const RULE_KEY = 'rule' + +type AppContext = { + reason: ShallowRef + visitTime: ShallowRef + bridge: ModalBridge url: string + delayDuration: Ref } -export const provideGlobalParam = (app: App, gp: GlobalParam) => { - app.provide(GLOBAL_KEY, gp) -} +export const provideApp = (app: App, bridge: ModalBridge, url: string) => { + const reason = ref() + const visitTime = ref(0) + const delayDuration = ref(5) + getOption().then(({ limitDelayDuration }) => delayDuration.value = limitDelayDuration).catch(() => { }) + + bridge.register('reason', data => { reason.value = data }) + + const updateVisitTime = async () => { + bridge.request('visitTime', undefined) + .then(val => visitTime.value = val) + .catch(() => visitTime.value = 0) + } -export const useGlobalParam = () => inject(GLOBAL_KEY) as GlobalParam + watch(reason, updateVisitTime, { immediate: true }) + const intervalId = window.setInterval(updateVisitTime, 1000) -export const provideReason = (app: App): Ref => { - const reason = shallowRef() - app.provide(REASON_KEY, reason) - return reason + const _unmount = app.unmount.bind(app) + app.unmount = () => { + bridge.dispose() + clearInterval(intervalId) + _unmount() + } + + app.provide(GLOBAL_KEY, { reason, visitTime, bridge, url, delayDuration }) } -export const useReason = () => inject(REASON_KEY) as Ref +export const useApp = () => inject(GLOBAL_KEY) as AppContext export const provideRule = () => { - const reason = useReason() - const windowFocus = useWindowFocus() + const { reason } = useApp() + const visibility = useDocumentVisibility() const { data: rule, refresh } = useRequest(async () => { - if (!windowFocus.value) return null + if (visibility.value !== 'visible') return undefined const reasonId = reason.value?.id - if (!reasonId) return null - const rules = await limitService.select({ id: reasonId, filterDisabled: false }) - return rules?.[0] + if (!reasonId) return undefined + const rules = await listLimits({ id: reasonId }) + return rules[0] ?? undefined }) - watch([reason, windowFocus], refresh) + watch([reason, visibility], refresh) provide(RULE_KEY, rule) } -export const useRule = () => inject(RULE_KEY) as Ref - -export const provideDelayHandler = (app: App, handlers: () => void) => { - app?.provide(DELAY_HANDLER_KEY, handlers) -} - -export const useDelayHandler = () => inject(DELAY_HANDLER_KEY) as () => void \ No newline at end of file +export const useRule = () => inject>(RULE_KEY) as ShallowRef \ No newline at end of file diff --git a/src/content-script/limit/modal/element.ts b/src/content-script/limit/modal/element.ts new file mode 100644 index 000000000..58f573f53 --- /dev/null +++ b/src/content-script/limit/modal/element.ts @@ -0,0 +1,18 @@ +export const TAG_NAME = 'extension-time-tracker-overlay' + +export class RootElement extends HTMLElement { + constructor() { + super() + } +} + +export function createRootElement(): RootElement { + const element = document.createElement(TAG_NAME) as RootElement + element.style.display = 'block' + element.style.position = 'fixed' + element.style.inset = '0' + element.style.width = '100vw' + element.style.height = '100vh' + element.style.zIndex = String(Number.MAX_SAFE_INTEGER) + return element +} diff --git a/src/content-script/limit/modal/index.ts b/src/content-script/limit/modal/index.ts index a6534c3f9..663762f67 100644 --- a/src/content-script/limit/modal/index.ts +++ b/src/content-script/limit/modal/index.ts @@ -1,208 +1,32 @@ -import { getRuntimeId, getUrl, sendMsg2Runtime } from '@api/chrome/runtime' -import optionService from '@service/option-service' -import { init as initTheme, toggle } from '@util/dark-mode' -import { createApp, Ref, type App } from 'vue' -import { exitFullscreen, isSameReason, type LimitReason, type MaskModal } from '../common' -import { TAG_NAME, type RootElement } from '../element' +import { initDarkTheme } from '@pages/util/dark-mode' +import 'element-plus/es/components/input/style/css' +import 'element-plus/es/components/message/style/css' +import { createApp } from 'vue' import Main from './Main' -import { provideDelayHandler, provideGlobalParam, provideReason } from './context' +import { ModalBridge } from './bridge' +import { provideApp } from './context' -function pauseAllVideo(): void { - const elements = document?.getElementsByTagName('video') - if (!elements) return - Array.from(elements).forEach(video => { - try { - video?.pause?.() - } catch { } - }) -} - -function pauseAllAudio(): void { - const elements = document?.getElementsByTagName('audio') - if (!elements) return - Array.from(elements).forEach(audio => { - try { - audio?.pause?.() - } catch { } - }) -} - -const TYPE_SORT: { [reason in timer.limit.ReasonType]: number } = { - PERIOD: 0, - VISIT: 1, - DAILY: 2, - WEEKLY: 3, -} - -const createHeader = () => { - const header = document.createElement('header') - // Style script - const style = document.createElement('link') - style.type = 'text/css' - style.rel = 'stylesheet' - style.href = getUrl('content_scripts.css') - header.append(style) - return header -} - -class ScreenLocker { - private styleId = `time-tracker-style-${getRuntimeId()}` - private lockedCls = `time-tracker-locked-${getRuntimeId()}` - - lock() { - this.insertStyle() - document?.documentElement?.classList?.add?.(this.lockedCls) - } - - unlock() { - document?.documentElement?.classList?.remove(this.lockedCls) - } - - private insertStyle() { - if (!document) return - if (document.getElementById(this.styleId)) return - const style = document.createElement('style') - style.id = this.styleId - const css = ` - .${this.lockedCls} { - overflow: hidden !important; - } - ` - style.appendChild(document.createTextNode(css)) - document.head?.appendChild(style) +function parsePageUrl(): string { + const raw = new URLSearchParams(window.location.search).get('url') + if (!raw) return '' + try { + return decodeURIComponent(raw) + } catch { + return raw } } -class ModalInstance implements MaskModal { - url: string - rootElement: RootElement | undefined - body: HTMLBodyElement | undefined - delayHandlers: (() => void)[] = [ - () => sendMsg2Runtime('cs.moreMinutes', this.url), - ] - reasons: LimitReason[] = [] - reason: Ref | undefined - app: App | undefined - screenLocker = new ScreenLocker() - - constructor(url: string) { - (window as any)['__modal__'] = this - this.url = url - } - - addReason(...reasons2Add: LimitReason[]): void { - reasons2Add = reasons2Add.filter(r => { - const anyExist = this.reasons?.some(reason => isSameReason(r, reason)) - return !anyExist - }) - if (!reasons2Add?.length) return - this.reasons.push(...reasons2Add) - // Sort - this.reasons.sort((a, b) => TYPE_SORT[a.type] - TYPE_SORT[b.type]) - this.refresh() - } - - removeReason(...reasons2Remove: LimitReason[]): void { - if (!reasons2Remove?.length) return - this.reasons = this.reasons?.filter(reason => { - const anyRemove = reasons2Remove.some(r => isSameReason(reason, r)) - return !anyRemove - }) - this.refresh() - } - - removeReasonsByType(...types: timer.limit.ReasonType[]): void { - if (!types?.length) return - this.reasons = this.reasons?.filter(r => !types?.includes(r.type)) - this.refresh() - } - - addDelayHandler(handler: () => void): void { - if (!handler) return - if (this.delayHandlers?.includes(handler)) return - this.delayHandlers?.push(handler) - } - - private refresh() { - setTimeout(() => { - // update vue ref in another micro task - const reason = this.reasons?.[0] - reason ? this.show(reason) : this.hide() - }) - } - - private async init() { - // 1. Create mask element - const root = await this.prepareRoot() - const html = document.createElement('html') - root?.append(html) - - // header - const header = createHeader() - html.append(header) +function main() { + initDarkTheme() - // body - this.body = document.createElement('body') - html.append(this.body) + const bridge = new ModalBridge('*', () => window.parent) + const app = createApp(Main) + provideApp(app, bridge, parsePageUrl()) - // 2. Init dark mode - initTheme(html) - optionService.isDarkMode().then(val => toggle(val, html)) - - // 3. Init vue app instance - this.initApp() - } - - private initApp() { - this.app = createApp(Main) - this.reason = provideReason(this.app) - provideGlobalParam(this.app, { url: this.url }) - provideDelayHandler(this.app, () => this.delayHandlers?.forEach(h => h?.())) - this.body && this.app.mount(this.body) - } - - private async prepareRoot(): Promise { - const inner = (): ShadowRoot | null => { - const exist = this.rootElement || document.querySelector(TAG_NAME) as RootElement - if (exist) { - this.rootElement = exist - return exist.shadowRoot - } - this.rootElement = document.createElement(TAG_NAME) as RootElement - document.body.appendChild(this.rootElement) - return this.rootElement.attachShadow({ mode: 'open' }) - } - if (document.body) { - return inner() - } else { - return new Promise(resolve => { - window.addEventListener('load', () => resolve(inner())) - }) - } - } - - private async show(reason: LimitReason) { - if (!this.rootElement) { - await this.init() - } - await exitFullscreen() - // Scroll to top - scrollTo(0, 0) - pauseAllVideo() - pauseAllAudio() - - this.rootElement && (this.rootElement.style.visibility = 'visible') - this.reason && (this.reason.value = reason) - this.screenLocker.lock() - this.body && (this.body.style.display = 'block') - } - - private hide() { - this.rootElement && (this.rootElement.style.visibility = 'hidden') - this.screenLocker.unlock() - this.body && (this.body.style.display = 'none') - this.reason && (this.reason.value = undefined) - } + const el = document.createElement('div') + document.body.append(el) + el.id = 'app' + app.mount(el) } -export default ModalInstance +main() diff --git a/src/content-script/limit/modal/instance.ts b/src/content-script/limit/modal/instance.ts new file mode 100644 index 000000000..c97aef856 --- /dev/null +++ b/src/content-script/limit/modal/instance.ts @@ -0,0 +1,191 @@ +import { getRuntimeId, getUrl } from '@api/chrome/runtime' +import { trySendMsg2Runtime } from '@api/sw/common' +import { exitFullscreen, isSameReason } from '../common' +import type { LimitReason, MaskModal } from '../types' +import { ModalBridge } from './bridge' +import { createRootElement, TAG_NAME, type RootElement } from './element' +import type { LimitReasonData } from './types' + +const MODAL_URL = getUrl('static/limit.html') +const MSG_ORIGIN = new URL(MODAL_URL).origin + +function pauseAllVideo(): void { + const elements = document?.getElementsByTagName('video') + if (!elements) return + Array.from(elements).forEach(video => { + try { + video?.pause?.() + } catch { } + }) +} + +function pauseAllAudio(): void { + const elements = document?.getElementsByTagName('audio') + if (!elements) return + Array.from(elements).forEach(audio => { + try { + audio?.pause?.() + } catch { } + }) +} + +const TYPE_SORT: { [reason in tt4b.limit.ReasonType]: number } = { + PERIOD: 0, + VISIT: 1, + DAILY: 2, + WEEKLY: 3, +} + +class ScreenLocker { + private styleId = `time-tracker-style-${getRuntimeId()}` + private lockedCls = `time-tracker-locked-${getRuntimeId()}` + + lock() { + this.insertStyle() + document?.documentElement?.classList?.add?.(this.lockedCls) + } + + unlock() { + document?.documentElement?.classList?.remove(this.lockedCls) + } + + private insertStyle() { + if (!document) return + if (document.getElementById(this.styleId)) return + const style = document.createElement('style') + style.id = this.styleId + const css = ` + .${this.lockedCls} { + overflow: hidden !important; + } + ` + style.appendChild(document.createTextNode(css)) + document.head?.appendChild(style) + } +} + +class ModalInstance implements MaskModal { + url: string + rootElement: RootElement | undefined + iframe: HTMLIFrameElement | undefined + delayHandlers: NoArgCallback[] = [ + () => trySendMsg2Runtime('limit.delay', this.url), + ] + reasons: LimitReason[] = [] + reason: LimitReason | undefined + screenLocker = new ScreenLocker() + private bridge: ModalBridge + + constructor(url: string) { + (window as any)['__modal__'] = this + this.url = url + this.bridge = new ModalBridge(MSG_ORIGIN, () => this.iframe?.contentWindow ?? undefined) + .register('visitTime', () => this.reason?.getVisitTime?.() ?? 0) + .register('delay', () => this.delayHandlers.forEach(handler => handler())) + } + + addReason(...reasons2Add: LimitReason[]): void { + reasons2Add = reasons2Add.filter(r => !this.reasons.some(reason => isSameReason(r, reason))) + if (!reasons2Add.length) return + this.reasons.push(...reasons2Add) + // Sort + this.reasons.sort((a, b) => TYPE_SORT[a.type] - TYPE_SORT[b.type]) + this.refresh() + } + + removeReason(...reasons2Remove: LimitReason[]): void { + if (!reasons2Remove?.length) return + this.reasons = this.reasons?.filter(reason => { + const anyRemove = reasons2Remove.some(r => isSameReason(reason, r)) + return !anyRemove + }) + this.refresh() + } + + removeReasonsByType(...types: tt4b.limit.ReasonType[]): void { + if (!types.length) return + this.reasons = this.reasons.filter(r => !types.includes(r.type)) + this.refresh() + } + + addDelayHandler(handler: NoArgCallback): void { + if (this.delayHandlers.includes(handler)) return + this.delayHandlers.push(handler) + } + + private refresh() { + // Change reason in new microtask + setTimeout(() => { + const reason = this.reasons[0] + reason ? this.show(reason) : this.hide() + }) + } + + private async init(): Promise { + const root = await this.prepareRoot() + if (!root) return + const iframe = document.createElement('iframe') + iframe.src = `${MODAL_URL}?url=${encodeURIComponent(this.url)}` + iframe.style.width = '100vw' + iframe.style.height = '100vh' + iframe.style.border = 'none' + root.append(iframe) + + this.iframe = iframe + + return new Promise(resolve => iframe.onload = () => resolve(undefined)) + } + + private async prepareRoot(): Promise { + const inner = (): ShadowRoot | null => { + const exist = this.rootElement ?? document.querySelector(TAG_NAME) as RootElement + if (exist) { + this.rootElement = exist + return exist.shadowRoot + } + this.rootElement = createRootElement() + document.body.appendChild(this.rootElement) + return this.rootElement.attachShadow({ mode: 'open' }) + } + if (document.body) return inner() + + return new Promise(resolve => { + window.addEventListener('load', () => resolve(inner())) + }) + } + + private async show(reason: LimitReason) { + if (!this.rootElement) { + await this.init() + } + await exitFullscreen() + pauseAllVideo() + pauseAllAudio() + + this.rootElement && (this.rootElement.style.visibility = 'visible') + this.setReason(reason) + this.screenLocker.lock() + this.iframe && (this.iframe.style.visibility = 'visible') + } + + private hide() { + this.rootElement && (this.rootElement.style.visibility = 'hidden') + this.screenLocker.unlock() + this.iframe && (this.iframe.style.visibility = 'hidden') + this.setReason(undefined) + } + + private setReason(reason: LimitReason | undefined) { + if (!this.iframe?.contentWindow) return + this.reason = reason + this.bridge.request('reason', extractReason(reason)).catch(() => { }) + } +} + +const extractReason = (reason: LimitReason | undefined): LimitReasonData | undefined => { + if (!reason) return undefined + const { getVisitTime: _, ...rest } = reason + return rest +} + +export default ModalInstance diff --git a/src/content-script/limit/modal/style/index.sass b/src/content-script/limit/modal/style/index.sass deleted file mode 100644 index c33f4ab8e..000000000 --- a/src/content-script/limit/modal/style/index.sass +++ /dev/null @@ -1,59 +0,0 @@ -@use "sass:meta" -@import url("./element-base.css") -@include meta.load-css("../../../../pages/element-ui/dark-theme") -@import url("element-plus/theme-chalk/el-descriptions.css") -@import url("element-plus/theme-chalk/el-descriptions-item.css") -@import url("element-plus/theme-chalk/el-button.css") -@import url("element-plus/theme-chalk/el-button-group.css") -@import url("element-plus/theme-chalk/el-message.css") -@import url("element-plus/theme-chalk/el-message-box.css") -@import url("element-plus/theme-chalk/el-input.css") -@import url("element-plus/theme-chalk/el-tag.css") - -#app - width: 100% - height: 100% - text-align: center - display: flex - justify-content: space-around - flex-direction: column - align-items: center - color: var(--el-text-color-primary) - .alert-container - margin-bottom: 80px - .name-line - margin-block-end: 0.67em - img - width: 1.4em - height: 1.4em - margin-inline-end: .4em - img,span - vertical-align: middle - line-height: 2em - h1 - font-size: 2.7em - max-width: 50vw - margin: auto - margin-block-start: 0.67em - margin-block-end: 0.67em - .reason-container - margin-bottom: 30px - .el-descriptions - margin: auto - width: 400px - .footer-container - margin-bottom: 60px -body - height: 100vh - width: 100vw - padding: 0 - margin: 0 - position: absolute - top: 0 - left: 0 - z-index: 999999 - min-height: 600px - background-color: var(--el-fill-color-blank) -html[data-theme=dark] - body - background-color: var(--el-fill-color-dark) diff --git a/src/content-script/limit/modal/style/modal.css b/src/content-script/limit/modal/style/modal.css new file mode 100644 index 000000000..bc3da790d --- /dev/null +++ b/src/content-script/limit/modal/style/modal.css @@ -0,0 +1,31 @@ +#app { + width: 100vw; + height: 100vh; + display: flex; + justify-content: space-around; + flex-direction: column; + align-items: center; + color: var(--el-text-color-primary); +} + +body { + height: 100vh; + width: 100vw; + padding: 0; + margin: 0; + position: fixed; + top: 0; + left: 0; + z-index: 999999; + min-height: 600px; + background-color: var(--el-fill-color-blank); +} + +html[data-theme=dark] body { + background-color: var(--el-fill-color-dark); +} + +/* Fix message-box buttons */ +.el-message-box__btns { + gap: 12px; +} \ No newline at end of file diff --git a/src/content-script/limit/modal/types.ts b/src/content-script/limit/modal/types.ts new file mode 100644 index 000000000..05b2a85de --- /dev/null +++ b/src/content-script/limit/modal/types.ts @@ -0,0 +1,17 @@ +import type { LimitReason } from '../types' + +export type LimitReasonData = Omit + +type MakeRegistry = { + [K in Code]: { req: Req; res: Res } +} + +type BridgeRegistry = + & MakeRegistry<'reason', LimitReasonData | undefined, void> + & MakeRegistry<'visitTime', void, number> + & MakeRegistry<'delay', void, void> + +export type BridgeCode = keyof BridgeRegistry +export type BridgeRequest = BridgeRegistry[C]['req'] +export type BridgeResponse = BridgeRegistry[C]['res'] +export type BridgeHandler = (req: BridgeRequest) => Awaitable> \ No newline at end of file diff --git a/src/content-script/limit/processor/message-adaptor.ts b/src/content-script/limit/processor/message-adaptor.ts index 0cea7bf7c..e64d33484 100644 --- a/src/content-script/limit/processor/message-adaptor.ts +++ b/src/content-script/limit/processor/message-adaptor.ts @@ -1,66 +1,42 @@ -import { sendMsg2Runtime } from "@api/chrome/runtime" +import { trySendMsg2Runtime } from '@api/sw/common' import { hasDailyLimited, hasWeeklyLimited, matches } from "@util/limit" -import { type LimitReason, type ModalContext, type Processor } from "../common" +import type { LimitReason, ModalContext, Processor } from '../types' -const cvtItem2AddReason = (item: timer.limit.Item): LimitReason[] => { +const cvtItem2AddReason = (item: tt4b.limit.Item, delayDuration: number): LimitReason[] => { const { cond, allowDelay, id, delayCount, weeklyDelayCount } = item const reasons2Add: LimitReason[] = [] - hasDailyLimited(item) && reasons2Add.push({ type: "DAILY", cond, allowDelay, id, delayCount }) - hasWeeklyLimited(item) && reasons2Add.push({ type: 'WEEKLY', cond, allowDelay, id, delayCount: weeklyDelayCount }) + hasDailyLimited(item, delayDuration) && reasons2Add.push({ type: "DAILY", cond, allowDelay, id, delayCount }) + hasWeeklyLimited(item, delayDuration) && reasons2Add.push({ type: 'WEEKLY', cond, allowDelay, id, delayCount: weeklyDelayCount }) return reasons2Add } -const cvtItem2RemoveReason = (item: timer.limit.Item): LimitReason[] => { - const { cond, allowDelay, id, delayCount, weeklyDelayCount } = item - const reasons2Remove: LimitReason[] = [] - !hasDailyLimited(item) && reasons2Remove.push({ type: 'DAILY', cond, allowDelay, id, delayCount }) - !hasWeeklyLimited(item) && reasons2Remove.push({ type: 'WEEKLY', cond, allowDelay, id, delayCount: weeklyDelayCount }) - return reasons2Remove -} - class MessageAdaptor implements Processor { - private context: ModalContext + constructor(private readonly context: ModalContext, private readonly delayDuration: number) { } - constructor(context: ModalContext) { - this.context = context + onLimitChanged(): void { + this.initRules() } - handleMsg(code: timer.mq.ReqCode, data: unknown): timer.mq.Response | Promise { - let items = data as timer.limit.Item[] - if (code === "limitTimeMeet") { - if (!items?.length) { - return { code: "fail" } - } - items.filter(item => matches(item?.cond, this.context.url)) - .flatMap(cvtItem2AddReason) - .forEach(reason => reason && this.context.modal.addReason(reason)) - return { code: "success" } - } else if (code === "limitChanged") { - this.context.modal.removeReasonsByType("DAILY", "WEEKLY") - items?.flatMap(cvtItem2AddReason) - ?.forEach(reason => reason && this.context.modal.addReason(reason)) - return { code: "success" } - } else if (code === "limitWaking") { - const reasons2Remove = items?.flatMap(cvtItem2RemoveReason) - reasons2Remove?.length && this.context.modal.removeReason(...reasons2Remove) - return { code: "success" } - } - return { code: "ignore" } + onLimitTimeMeet(items: tt4b.limit.Item[]): void { + if (!items.length) return + items.filter(({ cond }) => matches(cond, this.context.url)) + .flatMap(item => cvtItem2AddReason(item, this.delayDuration)) + .forEach(reason => this.context.modal.addReason(reason)) } async init(): Promise { - this.initRules?.() - this.context.modal?.addDelayHandler(() => this.initRules()) + this.initRules() + this.context.modal.addDelayHandler(() => this.initRules()) } async initRules(): Promise { - this.context.modal?.removeReasonsByType?.('DAILY', 'WEEKLY') - const limitedRules = await sendMsg2Runtime('cs.getLimitedRules', this.context.url) + this.context.modal.removeReasonsByType('DAILY', 'WEEKLY') + const limitedRules = await trySendMsg2Runtime('limit.list', { limited: true, effective: true, url: this.context.url }) + if (!limitedRules?.length) return - limitedRules - ?.flatMap?.(cvtItem2AddReason) - ?.forEach(reason => this.context.modal.addReason(reason)) + const reasons = limitedRules.flatMap(item => cvtItem2AddReason(item, this.delayDuration)) + this.context.modal.addReason(...reasons) } } -export default MessageAdaptor \ No newline at end of file +export default MessageAdaptor diff --git a/src/content-script/limit/processor/period-processor.ts b/src/content-script/limit/processor/period-processor.ts index e22c7f29c..fe0a84bab 100644 --- a/src/content-script/limit/processor/period-processor.ts +++ b/src/content-script/limit/processor/period-processor.ts @@ -1,16 +1,17 @@ -import { sendMsg2Runtime } from "@api/chrome/runtime" +import { trySendMsg2Runtime } from '@api/sw/common' import { date2Idx } from "@util/limit" import { MILL_PER_SECOND } from "@util/time" -import { type LimitReason, type ModalContext, type Processor } from "../common" +import type { LimitReason, ModalContext, Processor } from '../types' -function processRule(rule: timer.limit.Rule, nowSeconds: number, context: ModalContext): NodeJS.Timeout[] { +function processRule(rule: tt4b.limit.Rule, nowSeconds: number, context: ModalContext): ReturnType[] { const { cond, periods, id } = rule - return periods?.flatMap?.(p => { + if (!periods?.length) return [] + return periods.flatMap(p => { const [s, e] = p const startSeconds = s * 60 const endSeconds = (e + 1) * 60 const reason: LimitReason = { id, cond, type: "PERIOD" } - const timers: NodeJS.Timeout[] = [] + const timers: ReturnType[] = [] if (nowSeconds < startSeconds) { timers.push(setTimeout(() => context.modal.addReason(reason), (startSeconds - nowSeconds) * MILL_PER_SECOND)) timers.push(setTimeout(() => context.modal.removeReason(reason), (endSeconds - nowSeconds) * MILL_PER_SECOND)) @@ -19,36 +20,26 @@ function processRule(rule: timer.limit.Rule, nowSeconds: number, context: ModalC timers.push(setTimeout(() => context.modal.removeReason(reason), (endSeconds - nowSeconds) * MILL_PER_SECOND)) } return timers - }) ?? [] + }) } class PeriodProcessor implements Processor { - private context: ModalContext - private timers: NodeJS.Timeout[] = [] + private timers: ReturnType[] = [] - constructor(context: ModalContext) { - this.context = context - } + constructor(private readonly context: ModalContext) { } - async handleMsg(code: timer.mq.ReqCode, data: timer.limit.Item[]): Promise { - if (code === "limitChanged") { - this.timers?.forEach(clearTimeout) - await this.init0(data) - return { code: "success" } - } - return { code: "ignore" } + async onLimitChanged(): Promise { + await this.init() } - init(): Promise { - return this.init0() - } - - private async init0(rules?: timer.limit.Item[]) { - rules = rules || await sendMsg2Runtime("cs.getRelatedRules", this.context.url) + async init(): Promise { // Clear first + this.timers.forEach(clearTimeout) this.context.modal.removeReasonsByType("PERIOD") + + const rules = await trySendMsg2Runtime('limit.list', { effective: true, url: this.context.url }) ?? [] const nowSeconds = date2Idx(new Date()) - this.timers = rules?.flatMap?.(r => processRule(r, nowSeconds, this.context)) || [] + this.timers = rules.flatMap(r => processRule(r, nowSeconds, this.context)) } } diff --git a/src/content-script/limit/processor/visit-processor.ts b/src/content-script/limit/processor/visit-processor.ts index 9514eb442..80637ccd4 100644 --- a/src/content-script/limit/processor/visit-processor.ts +++ b/src/content-script/limit/processor/visit-processor.ts @@ -1,41 +1,35 @@ -import { sendMsg2Runtime } from "@api/chrome/runtime" +import { trySendMsg2Runtime } from '@api/sw/common' import NormalTracker from "@cs/tracker/normal" -import { DELAY_MILL } from "@util/limit" -import { MILL_PER_SECOND } from "@util/time" -import { type ModalContext, type Processor } from "../common" +import { MILL_PER_MINUTE, MILL_PER_SECOND } from "@util/time" +import type { ModalContext, Processor } from '../types' class VisitProcessor implements Processor { - - private context: ModalContext private focusTime: number = 0 - private rules: timer.limit.Rule[] = [] - private tracker: NormalTracker | undefined + private rules: tt4b.limit.Rule[] = [] + tracker: NormalTracker private delayCount: number = 0 - constructor(context: ModalContext) { - this.context = context + constructor(private readonly context: ModalContext, private readonly delayDuration: number) { + this.tracker = new NormalTracker({ + onReport: data => this.handleTracker(data), + }) } - async handleMsg(code: timer.mq.ReqCode): Promise { - if (code === "limitChanged") { - this.initRules() - return { code: "success" } - } else if (code === "askVisitTime") { - return { code: "success", data: this.focusTime } - } - return { code: "ignore" } + onLimitChanged(): void { + this.initRules() } - hasLimited(rule: timer.limit.Rule): boolean { - const { visitTime } = rule || {} + private hasLimited(rule: tt4b.limit.Rule): boolean { + const { visitTime } = rule if (!visitTime) return false - return visitTime * MILL_PER_SECOND + this.delayCount * DELAY_MILL < this.focusTime + const afterDelayed = visitTime * MILL_PER_SECOND + this.delayCount * this.delayDuration * MILL_PER_MINUTE + return afterDelayed < this.focusTime } - async handleTracker(data: timer.core.Event) { - const diff = (data?.end ?? 0) - (data?.start ?? 0) + private async handleTracker({ start, end }: tt4b.core.Event) { + const diff = end - start this.focusTime += diff - this.rules?.forEach?.(rule => { + this.rules.forEach(rule => { if (!this.hasLimited(rule)) return const { id, cond, allowDelay } = rule this.context.modal.addReason({ @@ -49,22 +43,19 @@ class VisitProcessor implements Processor { }) } - async initRules() { - this.rules = await sendMsg2Runtime("cs.getRelatedRules", this.context.url) ?? [] + private async initRules() { + this.rules = await trySendMsg2Runtime("limit.list", { effective: true, url: this.context.url }) ?? [] this.context.modal.removeReasonsByType("VISIT") } async init(): Promise { - this.tracker = new NormalTracker({ - onReport: data => this.handleTracker(data), - }) this.tracker.init() this.initRules() - this.context.modal.addDelayHandler(() => this.processMore5Minutes()) + this.context.modal.addDelayHandler(() => this.processDelay()) } - private processMore5Minutes() { - this.delayCount = (this.delayCount ?? 0) + 1 + private processDelay() { + this.delayCount++ this.context.modal.removeReasonsByType("VISIT") } } diff --git a/src/content-script/limit/reminder/component.ts b/src/content-script/limit/reminder/component.ts index 06378f176..f7e36c367 100644 --- a/src/content-script/limit/reminder/component.ts +++ b/src/content-script/limit/reminder/component.ts @@ -1,4 +1,4 @@ -import { getUrl } from "@api/chrome/runtime" +import { getIconUrl } from "@api/chrome/runtime" import { t } from "@cs/locale" const containerStyle = (dark: boolean): Partial => ({ @@ -63,7 +63,7 @@ function createIcon(): HTMLImageElement { const icon = document.createElement('img') icon.width = 32 icon.height = 32 - icon.src = getUrl('static/images/icon.png') + icon.src = getIconUrl() return icon } @@ -91,7 +91,7 @@ function createCloseBtn(dark: boolean, onClose: () => void): HTMLElement { return btn } -function createGroup(dark: boolean, data: timer.limit.ReminderInfo, onClose: () => void): HTMLDivElement { +function createGroup(dark: boolean, data: tt4b.limit.ReminderInfo, onClose: () => void): HTMLDivElement { const group = document.createElement('div') mountStyle(group, GROUP_STYLE) @@ -115,7 +115,7 @@ function createGroup(dark: boolean, data: timer.limit.ReminderInfo, onClose: () return group } -export function createComponent(dark: boolean, data: timer.limit.ReminderInfo, onClose: () => void) { +export function createComponent(dark: boolean, data: tt4b.limit.ReminderInfo, onClose: () => void) { const el = document.createElement('div') mountStyle(el, containerStyle(dark)) diff --git a/src/content-script/limit/reminder/index.ts b/src/content-script/limit/reminder/index.ts index 44f1663ca..f5ac439e5 100644 --- a/src/content-script/limit/reminder/index.ts +++ b/src/content-script/limit/reminder/index.ts @@ -1,22 +1,15 @@ -import optionService from "@service/option-service" +import { getOption } from '@api/sw/option' +import { processDarkMode } from '@pages/util/dark-mode' import { MILL_PER_MINUTE } from "@util/time" -import { exitFullscreen, type Processor } from "../common" +import { exitFullscreen } from "../common" import { createComponent } from "./component" -class Reminder implements Processor { +class Reminder { private id = 0 private el: HTMLElement | undefined private darkMode: boolean = false - handleMsg(code: timer.mq.ReqCode, data: unknown): timer.mq.Response | Promise { - if (code !== 'limitReminder') { - return { code: 'ignore' } - } - this.show(data as timer.limit.ReminderInfo) - return { code: 'success' } - } - - private async show(data: timer.limit.ReminderInfo) { + public async show(data: tt4b.limit.ReminderInfo) { if (!document?.body || this.el) return await exitFullscreen() @@ -38,7 +31,8 @@ class Reminder implements Processor { } async init(): Promise { - this.darkMode = await optionService.isDarkMode() + const option = await getOption() + this.darkMode = processDarkMode(option) } } diff --git a/src/content-script/limit/types.ts b/src/content-script/limit/types.ts new file mode 100644 index 000000000..cddfcb183 --- /dev/null +++ b/src/content-script/limit/types.ts @@ -0,0 +1,27 @@ + +export type LimitReason = + & RequiredPick + & PartialPick + & { + type: tt4b.limit.ReasonType + getVisitTime?: () => number + } + +export interface MaskModal { + readonly reasons: LimitReason[] + + addReason(...reasons: LimitReason[]): void + removeReason(...reasons: LimitReason[]): void + removeReasonsByType(...types: tt4b.limit.ReasonType[]): void + addDelayHandler(handler: NoArgCallback): void +} + +export type ModalContext = { + url: string + modal: MaskModal +} + +export interface Processor { + init(): Awaitable + onLimitChanged(): void +} \ No newline at end of file diff --git a/src/content-script/printer.ts b/src/content-script/printer.ts index 11f619d9c..53b023d07 100644 --- a/src/content-script/printer.ts +++ b/src/content-script/printer.ts @@ -5,7 +5,7 @@ * https://opensource.org/licenses/MIT */ -import { sendMsg2Runtime } from "@api/chrome/runtime" +import { trySendMsg2Runtime } from '@api/sw/common' import { formatPeriodCommon } from "@util/time" import { t } from "./locale" @@ -13,8 +13,9 @@ import { t } from "./locale" * Print info of today */ export default async function printInfo(host: string) { - const waste = await sendMsg2Runtime('cs.getTodayInfo', host) - const { time, focus } = waste || {} + const data = await trySendMsg2Runtime('stat.today', host) + if (!data) return + const { time, focus } = data const param = { time: `${time ?? '-'}`, diff --git a/src/content-script/skeleton.ts b/src/content-script/skeleton.ts deleted file mode 100644 index ed5d80338..000000000 --- a/src/content-script/skeleton.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { sendMsg2Runtime } from "@api/chrome/runtime" - -function awaitDocumentReady() { - if (document.readyState === 'complete') { - return Promise.resolve() - } else { - return new Promise(resolve => { - document.addEventListener('DOMContentLoaded', resolve, { once: true }) - }) - } -} - -const main = async () => { - await awaitDocumentReady() - sendMsg2Runtime('cs.onInjected') -} - -main() diff --git a/src/content-script/timeline.ts b/src/content-script/timeline.ts index 47eddc29d..136117391 100644 --- a/src/content-script/timeline.ts +++ b/src/content-script/timeline.ts @@ -1,4 +1,4 @@ -import { sendMsg2Runtime } from '@api/chrome/runtime' +import { trySendMsg2Runtime } from '@api/sw/common' class TimelineCollector { private startTime: number | null = null @@ -7,22 +7,23 @@ class TimelineCollector { * Bind page visibility and focus events */ init(): void { - document.addEventListener('visibilitychange', () => { - if (document.hidden) { - this.collect() - } else { + const onStateChange = () => { + if (!document.hidden && document.hasFocus()) { this.startTracking() + } else { + this.collect() } - }) + } - window.addEventListener('focus', () => this.startTracking()) - window.addEventListener('blur', () => this.collect()) + document.addEventListener('visibilitychange', onStateChange) + window.addEventListener('focus', onStateChange) + window.addEventListener('blur', onStateChange) window.addEventListener('beforeunload', () => this.collect()) if (document.readyState === 'complete') { - this.startTracking() + onStateChange() } else { - window.addEventListener('load', () => this.startTracking()) + window.addEventListener('load', onStateChange) } } @@ -30,6 +31,8 @@ class TimelineCollector { * Start tracking current page */ public startTracking(): void { + if (document.hidden || !document.hasFocus()) return + if (this.startTime !== null) return this.startTime = Date.now() } @@ -39,18 +42,14 @@ class TimelineCollector { private collect(): void { if (!this.startTime) return const url = document?.location?.href + if (!url) return - url && sendMsg2Runtime('cs.timelineEv', { - start: this.startTime, - end: Date.now(), - url, - } satisfies timer.timeline.Event) + trySendMsg2Runtime('timeline.tick', { start: this.startTime, end: Date.now(), url }) this.startTime = null } } - export default function processTimeline() { const collector = new TimelineCollector() collector.init() diff --git a/src/content-script/tracker/normal/idle-detector.ts b/src/content-script/tracker/normal/idle-detector.ts index 26dec2210..127804c9a 100644 --- a/src/content-script/tracker/normal/idle-detector.ts +++ b/src/content-script/tracker/normal/idle-detector.ts @@ -1,7 +1,10 @@ -import optionHolder from "@service/components/option-holder" +import { trySendMsg2Runtime } from '@api/sw/common' +import { getOption } from '@api/sw/option' export default class IdleDetector { fullScreen: boolean = false + // default to true, try not to affect tracking + audible: boolean = true autoPauseTracking: boolean = false // By milliseconds @@ -9,15 +12,11 @@ export default class IdleDetector { lastActiveTime: number = Date.now() userActive: boolean = true - pauseTimeout: NodeJS.Timeout | undefined + pauseTimeout: ReturnType | undefined - onIdle: () => void - onActive: () => void - - constructor({ onIdle, onActive }: { onIdle: () => void, onActive: () => void }) { + constructor(private readonly onIdle: NoArgCallback, private readonly onActive: NoArgCallback) { this.onIdle = onIdle this.onActive = onActive - this.init() } needTimeout(): boolean { @@ -25,19 +24,16 @@ export default class IdleDetector { } isIdle() { - return this.lastActiveTime + this.autoPauseInterval <= Date.now() && !this.fullScreen + if (this.fullScreen || this.audible) return false + return this.lastActiveTime + this.autoPauseInterval <= Date.now() } - private async init() { - const option = await optionHolder.get() - - this.processOption(option) - this.resetTimeout() - - optionHolder.addChangeListener(opt => { - this.processOption(opt) - this.resetTimeout() - }) + async init() { + this.reset() + document.addEventListener('visibilitychange', () => document.visibilityState === 'visible' && this.reset()) + const pollInterval = setInterval(() => this.reset(), 60_000) + const stopPoll = () => clearInterval(pollInterval) + window.addEventListener('beforeunload', stopPoll) const handleActive = () => { this.lastActiveTime = Date.now() @@ -45,30 +41,34 @@ export default class IdleDetector { if (!this.needTimeout()) return if (!this.pauseTimeout) { - // Paused, so activate - this.onActive?.() + this.onActive() this.resetTimeout() } } window.addEventListener('mousedown', handleActive) window.addEventListener('mousemove', handleActive) - window.addEventListener('keypress', handleActive) + window.addEventListener('keydown', handleActive) window.addEventListener('scroll', handleActive) window.addEventListener('wheel', handleActive) document?.addEventListener('fullscreenchange', () => { this.fullScreen = !!document?.fullscreenElement handleActive() }) + + trySendMsg2Runtime('cs.getAudible').then(val => this.audible = !!val) } - private processOption(option: timer.option.StatisticsOption) { - this.autoPauseTracking = !!option?.autoPauseTracking - this.autoPauseInterval = option?.autoPauseInterval * 1000 + private reset() { + getOption().then(({ autoPauseTracking, autoPauseInterval }) => { + this.autoPauseTracking = autoPauseTracking + this.autoPauseInterval = autoPauseInterval * 1000 + + this.resetTimeout() + }).catch(() => { }) } private resetTimeout() { - if (!!this.pauseTimeout) { clearTimeout(this.pauseTimeout) this.pauseTimeout = undefined @@ -92,8 +92,7 @@ export default class IdleDetector { if (!this.needTimeout()) return if (this.isIdle()) { - // Idle interval meets - this.onIdle?.() + this.onIdle() } else { this.resetTimeout() } diff --git a/src/content-script/tracker/normal/index.ts b/src/content-script/tracker/normal/index.ts index 9adb167ab..bf529f4ee 100644 --- a/src/content-script/tracker/normal/index.ts +++ b/src/content-script/tracker/normal/index.ts @@ -1,3 +1,4 @@ +import type { AudibleChangeHandler } from '@cs/types' import IdleDetector from "./idle-detector" const INTERVAL = 1000 @@ -7,20 +8,18 @@ type StateChangeReason = 'visible' | 'idle' | 'initial' class TrackContext { docVisible: boolean = false idleDetector: IdleDetector - onPause: (reason: StateChangeReason) => void - onResume: (reason: StateChangeReason) => void - - constructor({ onPause, onResume }: { onPause: (reason: StateChangeReason) => void, onResume: (reason: StateChangeReason) => void }) { - this.onPause = onPause - this.onResume = onResume + constructor( + private readonly onPause: ArgCallback, + private readonly onResume: ArgCallback, + ) { this.detectDocVisible() document?.addEventListener('visibilitychange', () => this.detectDocVisible()) - this.idleDetector = new IdleDetector({ - onIdle: () => this.onPause?.('idle'), - onActive: () => this.docVisible && this.onResume?.('idle') - }) + this.idleDetector = new IdleDetector( + () => this.onPause('idle'), + () => this.docVisible && this.onResume('idle'), + ) } private detectDocVisible() { @@ -38,8 +37,8 @@ class TrackContext { } } -export type NormalTrackerOption = { - onReport: (ev: timer.core.Event) => Promise +type NormalTrackerOption = { + onReport: (ev: tt4b.core.Event) => Promise onResume?: (reason: StateChangeReason) => void onPause?: (reason: StateChangeReason) => void } @@ -47,25 +46,24 @@ export type NormalTrackerOption = { /** * Normal tracker */ -export default class NormalTracker { - context: TrackContext | undefined +export default class NormalTracker implements AudibleChangeHandler { + context: TrackContext start: number = Date.now() - option: NormalTrackerOption - constructor(option: NormalTrackerOption) { - this.option = option + constructor(private readonly option: NormalTrackerOption) { + this.context = new TrackContext( + reason => this.pause(reason), + reason => this.resume(reason), + ) } init() { // Resume if idle before reloading this.resume('idle') + this.context.idleDetector.init() - this.context = new TrackContext({ - onPause: reason => this.pause(reason), - onResume: reason => this.resume(reason), - }) setInterval(() => { - if (!this.context?.isActive()) return + if (!this.context.isActive()) return this.collect() }, INTERVAL) @@ -81,10 +79,9 @@ export default class NormalTracker { return } - const data: timer.core.Event = { + const data: tt4b.core.Event = { start: lastTime, end: now, - url: location?.href, ignoreTabCheck: !!ignoreTabCheck } try { @@ -103,4 +100,8 @@ export default class NormalTracker { this.start = Date.now() } + + onAudibleChange(audible: boolean) { + this.context.idleDetector.audible = audible + } } diff --git a/src/content-script/tracker/run-time.ts b/src/content-script/tracker/run-time.ts index 9b1a0a487..fbbe42ec0 100644 --- a/src/content-script/tracker/run-time.ts +++ b/src/content-script/tracker/run-time.ts @@ -1,52 +1,45 @@ -import { onRuntimeMessage, sendMsg2Runtime } from "@api/chrome/runtime" +import { trySendMsg2Runtime } from '@api/sw/common' +import { extractHostname } from '@util/pattern' +import Dispatcher from '../dispatcher' class RunTimeTracker { private start: number = Date.now() - private url: string // Real host, including builtin hosts private host: string | undefined - constructor(url: string) { - this.url = url - this.start = Date.now() + constructor(private readonly url: string) { } - init(): void { + init(dispatcher: Dispatcher): void { this.fetchSite() - - onRuntimeMessage(async req => { - if (req.code === 'siteRunChange') { - this.fetchSite() - return { code: 'success' } - } - return { code: 'ignore' } - }) - + dispatcher.register('siteRunChange', () => void this.fetchSite()) setInterval(() => this.collect(), 1000) } - private fetchSite() { - sendMsg2Runtime('cs.getRunSites', this.url) - .then((site: timer.site.SiteKey) => this.host = site?.host) - // Extension reloaded, so terminate - .catch(() => this.host = undefined) + private async fetchSite() { + const { host } = extractHostname(this.url) + if (!host) return + const enabled = await trySendMsg2Runtime('site.runEnabled', host) + this.host = enabled ? host : undefined } - private collect() { + private async collect() { const now = Date.now() const lastTime = this.start - const event: timer.core.Event = { - start: lastTime, - end: now, - url: this.url, - ignoreTabCheck: false, - host: this.host, + try { + if (this.host) { + const event: tt4b.core.Event = { + start: lastTime, + end: now, + ignoreTabCheck: false, + host: this.host, + } + await trySendMsg2Runtime('track.runTime', event) + } + this.start = now + } catch { } - - sendMsg2Runtime('cs.trackRunTime', event) - .then(() => this.start = now) - .catch(() => { }) } } diff --git a/src/content-script/types.ts b/src/content-script/types.ts new file mode 100644 index 000000000..f7423260c --- /dev/null +++ b/src/content-script/types.ts @@ -0,0 +1,3 @@ +export interface AudibleChangeHandler { + onAudibleChange(audible: boolean): void +} \ No newline at end of file diff --git a/src/database/backup-database.ts b/src/database/backup-database.ts deleted file mode 100644 index 082cdca46..000000000 --- a/src/database/backup-database.ts +++ /dev/null @@ -1,47 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import BaseDatabase from "./common/base-database" -import { REMAIN_WORD_PREFIX } from "./common/constant" - -const PREFIX = REMAIN_WORD_PREFIX + "backup" -const SNAPSHOT_KEY = PREFIX + "_snap" -const CACHE_KEY = PREFIX + "_cache" - -function cacheKeyOf(type: timer.backup.Type) { - return CACHE_KEY + "_" + type -} - -class BackupDatabase extends BaseDatabase { - - async getSnapshot(type: timer.backup.Type): Promise { - const cache = await this.storage.getOne(SNAPSHOT_KEY) - return cache?.[type] - } - - async updateSnapshot(type: timer.backup.Type, snapshot: timer.backup.Snapshot): Promise { - const cache = await this.storage.getOne(SNAPSHOT_KEY) || {} - cache[type] = snapshot - await this.storage.put(SNAPSHOT_KEY, cache) - } - - async getCache(type: timer.backup.Type): Promise { - return (await this.storage.getOne(cacheKeyOf(type))) || {} - } - - async updateCache(type: timer.backup.Type, newVal: unknown): Promise { - return this.storage.put(cacheKeyOf(type), newVal as Object) - } - - async importData(_data: any): Promise { - // Do nothing - } -} - -const backupDatabase = new BackupDatabase() - -export default backupDatabase \ No newline at end of file diff --git a/src/database/merge-rule-database.ts b/src/database/merge-rule-database.ts deleted file mode 100644 index 82fd23307..000000000 --- a/src/database/merge-rule-database.ts +++ /dev/null @@ -1,69 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import BaseDatabase from "./common/base-database" -import { REMAIN_WORD_PREFIX } from "./common/constant" - -const DB_KEY = REMAIN_WORD_PREFIX + 'MERGE_RULES' - -type MergeRuleSet = { [key: string]: string | number } - -/** - * Rules to merge host - * - * @since 0.1.2 - */ -class MergeRuleDatabase extends BaseDatabase { - - async refresh(): Promise { - const result = await this.storage.getOne(DB_KEY) - return result || {} - } - - private update(data: MergeRuleSet): Promise { - return this.setByKey(DB_KEY, data) - } - - async selectAll(): Promise { - const set = await this.refresh() - return Object.entries(set) - .map(([origin, merged]) => ({ origin, merged } satisfies timer.merge.Rule)) - } - - async remove(origin: string): Promise { - const set = await this.refresh() - delete set[origin] - await this.update(set) - } - - /** - * Add to the db - */ - async add(...toAdd: timer.merge.Rule[]): Promise { - const set = await this.refresh() - // Not rewrite - toAdd.forEach(({ origin, merged }) => set[origin] = set[origin] ?? merged) - await this.update(set) - } - - async importData(data: any): Promise { - const toMigrate = data?.[DB_KEY] - if (!toMigrate) return - const exist = await this.refresh() - const valueTypes = ['string', 'number'] - Object.entries(toMigrate as MergeRuleSet) - .filter(([_key, value]) => valueTypes.includes(typeof value)) - // Not rewrite - .filter(([key]) => !exist[key]) - .forEach(([key, value]) => exist[key] = value) - this.update(exist) - } -} - -const mergeRuleDatabase = new MergeRuleDatabase() - -export default mergeRuleDatabase \ No newline at end of file diff --git a/src/database/meta-database.ts b/src/database/meta-database.ts deleted file mode 100644 index 9475bdc50..000000000 --- a/src/database/meta-database.ts +++ /dev/null @@ -1,42 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import BaseDatabase from "./common/base-database" -import { META_KEY } from "./common/constant" - -/** - * @since 0.6.0 - */ -class MetaDatabase extends BaseDatabase { - async getMeta(): Promise { - const meta = await this.storage.getOne(META_KEY) - return meta || {} - } - - async importData(data: any): Promise { - const meta: timer.ExtensionMeta = data[META_KEY] as timer.ExtensionMeta - if (!meta) return - - const existMeta = await this.getMeta() - const { popupCounter = {}, appCounter = {} } = existMeta - popupCounter._total = (popupCounter._total ?? 0) + (popupCounter._total ?? 0) - if (meta.appCounter) { - Object.entries(meta.appCounter).forEach(([routePath, count]) => { - appCounter[routePath] = (appCounter[routePath] ?? 0) + count - }) - } - await this.update({ ...existMeta, popupCounter, appCounter }) - } - - async update(existMeta: timer.ExtensionMeta): Promise { - await this.storage.put(META_KEY, existMeta) - } -} - -const metaDatabase = new MetaDatabase() - -export default metaDatabase \ No newline at end of file diff --git a/src/database/option-database.ts b/src/database/option-database.ts deleted file mode 100644 index 72ceac680..000000000 --- a/src/database/option-database.ts +++ /dev/null @@ -1,55 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { defaultOption } from "@util/constant/option" -import BaseDatabase from "./common/base-database" -import { REMAIN_WORD_PREFIX } from "./common/constant" - -const DB_KEY = REMAIN_WORD_PREFIX + 'OPTION' - -/** - * Database of options - * - * @since 0.3.0 - */ -class OptionDatabase extends BaseDatabase { - async importData(data: any): Promise { - const newVal = data[DB_KEY] - const exist = await this.getOption() - if (exist) { - Object.entries(exist).forEach(([key, value]) => (exist as any)[key] = value) - } - await this.setOption(newVal) - } - - async getOption(): Promise { - const option = await this.storage.getOne(DB_KEY) - return option || defaultOption() - } - - async setOption(option: timer.option.AllOption): Promise { - option && await this.setByKey(DB_KEY, option) - } - - /** - * @since 0.3.2 - */ - addOptionChangeListener(listener: (newVal: timer.option.AllOption) => void) { - const storageListener = ( - changes: { [key: string]: chrome.storage.StorageChange }, - _areaName: chrome.storage.AreaName, - ) => { - const optionInfo = changes[DB_KEY] - optionInfo && listener(optionInfo.newValue || {} as timer.option.AllOption) - } - chrome.storage.onChanged.addListener(storageListener) - } -} - -const optionDatabase = new OptionDatabase() - -export default optionDatabase \ No newline at end of file diff --git a/src/database/site-database.ts b/src/database/site-database.ts deleted file mode 100644 index 19419cdf9..000000000 --- a/src/database/site-database.ts +++ /dev/null @@ -1,228 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { CATE_NOT_SET_ID } from "@util/site" -import BaseDatabase from "./common/base-database" -import { REMAIN_WORD_PREFIX } from "./common/constant" - -export type SiteCondition = { - /** - * Fuzzy query of host or alias - */ - fuzzyQuery?: string - /** - * @since 3.0.0 - */ - cateIds?: number | number[] - types?: timer.site.Type | timer.site.Type[] -} - -type _Entry = { - /** - * Alias - */ - a?: string - /** - * Icon url - */ - i?: string - /** - * Category ID - */ - c?: number - /** - * Count run time - */ - r?: boolean -} - -const DB_KEY_PREFIX = REMAIN_WORD_PREFIX + 'SITE_' -const HOST_KEY_PREFIX = DB_KEY_PREFIX + 'h' -const VIRTUAL_KEY_PREFIX = DB_KEY_PREFIX + 'v' -const MERGED_FLAG = 'm' - -function cvt2Key({ host, type }: timer.site.SiteKey): string { - if (type === 'virtual') { - return VIRTUAL_KEY_PREFIX + host - } else if (type === 'merged') { - return HOST_KEY_PREFIX + MERGED_FLAG + host - } else { - return HOST_KEY_PREFIX + '_' + host - } -} - -function cvt2SiteKey(key: string): timer.site.SiteKey { - if (key.startsWith(VIRTUAL_KEY_PREFIX)) { - return { - host: key.substring(VIRTUAL_KEY_PREFIX.length), - type: 'virtual', - } - } else if (key.startsWith(HOST_KEY_PREFIX)) { - return { - host: key.substring(HOST_KEY_PREFIX.length + 1), - type: key.charAt(HOST_KEY_PREFIX.length) === MERGED_FLAG ? 'merged' : 'normal', - } - } else { - // Can't go there - return { host: key, type: 'normal' } - } -} - -function cvt2Entry({ alias, iconUrl, cate, run }: timer.site.SiteInfo): _Entry { - const entry: _Entry = { i: iconUrl } - alias && (entry.a = alias) - cate && (entry.c = cate) - run && (entry.r = true) - entry.i = iconUrl - return entry -} - -function cvt2SiteInfo(key: timer.site.SiteKey, entry: _Entry | undefined): timer.site.SiteInfo { - const { a, i, c, r } = entry || {} - const siteInfo: timer.site.SiteInfo = { ...key } - siteInfo.alias = a - siteInfo.cate = c ?? CATE_NOT_SET_ID - siteInfo.iconUrl = i - siteInfo.run = !!r - return siteInfo -} - -//////////////////////////////////////////////////////////////////////////// -///////////////////////// ///////////////////////// -///////////////////////// PUBLIC METHODS START ///////////////////////// -///////////////////////// ///////////////////////// -//////////////////////////////////////////////////////////////////////////// - -/** - * Select by condition - * - * @returns list not be undefined, maybe empty - */ -async function select(this: SiteDatabase, condition?: SiteCondition): Promise { - const filter = buildFilter(condition) - const data = await this.storage.get() - return Object.entries(data) - .filter(([key]) => key.startsWith(DB_KEY_PREFIX)) - .map(([key, value]) => cvt2SiteInfo(cvt2SiteKey(key), value as _Entry)) - .filter(filter) -} - -function buildFilter(condition?: SiteCondition): (site: timer.site.SiteInfo) => boolean { - const { fuzzyQuery, cateIds, types } = condition || {} - let cateFilter = typeof cateIds === 'number' ? [cateIds] : (cateIds?.length ? cateIds : undefined) - let typeFilter = typeof types === 'string' ? [types] : (types?.length ? types : undefined) - return site => { - const { host: siteHost, alias: siteAlias, cate, type } = site || {} - if (fuzzyQuery && !(siteHost?.includes(fuzzyQuery) || siteAlias?.includes(fuzzyQuery))) return false - if (cateFilter && (!cateFilter.includes(cate ?? CATE_NOT_SET_ID) || type !== 'normal')) return false - if (typeFilter && !matchType(typeFilter, site)) return false - return true - } -} - -function matchType(types: timer.site.Type[], site: timer.site.SiteInfo): boolean { - const { type } = site || {} - if (type === 'virtual') { - return types.includes('virtual') - } else if (type === 'merged') { - return types.includes('merged') - } else { - return types.includes('normal') - } -} - -/** - * Get by key - * - * @returns site info, or undefined - */ -async function get(this: SiteDatabase, key: timer.site.SiteKey): Promise { - const entry = await this.storage.getOne<_Entry>(cvt2Key(key)) - return entry ? cvt2SiteInfo(key, entry) : null -} - -async function getBatch(this: SiteDatabase, keys: timer.site.SiteKey[]): Promise { - const result = await this.storage.get(keys.map(cvt2Key)) - return Object.entries(result) - .map(([key, value]) => cvt2SiteInfo(cvt2SiteKey(key), value as _Entry)) -} - -/** - * Save site info - */ -async function save(this: SiteDatabase, ...sites: timer.site.SiteInfo[]): Promise { - if (!sites?.length) return - const toSet: Record = {} - sites?.forEach(s => toSet[cvt2Key(s)] = cvt2Entry(s)) - await this.storage.set(toSet) -} - -async function remove(this: SiteDatabase, ...siteKeys: timer.site.SiteKey[]): Promise { - const keys = siteKeys?.map(s => cvt2Key(s)) - if (!keys?.length) return - await this.storage.remove(keys) -} - -async function exist(this: SiteDatabase, siteKey: timer.site.SiteKey): Promise { - const key = cvt2Key(siteKey) - const entry = await this.storage.getOne<_Entry>(key) - return !!entry -} - -async function existBatch(this: SiteDatabase, siteKeys: timer.site.SiteKey[]): Promise { - const keys = siteKeys.map(cvt2Key) - const items = await this.storage.get(keys) - return Object.entries(items).map(([key]) => cvt2SiteKey(key)) -} - -async function importData(this: SiteDatabase, _data: any) { - throw new Error("Method not implemented.") -} - -//////////////////////////////////////////////////////////////////////////// -///////////////////////// ///////////////////////// -///////////////////////// PUBLIC METHODS END ///////////////////////// -///////////////////////// ///////////////////////// -//////////////////////////////////////////////////////////////////////////// - -class SiteDatabase extends BaseDatabase { - select = select - get = get - getBatch = getBatch - save = save - remove = remove - exist = exist - existBatch = existBatch - importData = importData - - /** - * Add listener to listen changes - * - * @since 1.6.0 - */ - addChangeListener(listener: (oldAndNew: [timer.site.SiteInfo, timer.site.SiteInfo][]) => void) { - const storageListener = ( - changes: { [key: string]: chrome.storage.StorageChange }, - _areaName: chrome.storage.AreaName, - ) => { - const changedSites: [timer.site.SiteInfo, timer.site.SiteInfo][] = Object.entries(changes) - .filter(([k]) => k.startsWith(DB_KEY_PREFIX)) - .map(([k, v]) => { - const siteKey = cvt2SiteKey(k) - const oldVal = cvt2SiteInfo(siteKey, v?.oldValue as _Entry) - const newVal = cvt2SiteInfo(siteKey, v?.newValue as _Entry) - return [oldVal, newVal] - }) - changedSites.length && listener?.(changedSites) - } - chrome.storage.onChanged.addListener(storageListener) - } -} - -const siteDatabase = new SiteDatabase() - -export default siteDatabase \ No newline at end of file diff --git a/src/database/stat-database/constants.ts b/src/database/stat-database/constants.ts deleted file mode 100644 index d1ca9f5e4..000000000 --- a/src/database/stat-database/constants.ts +++ /dev/null @@ -1 +0,0 @@ -export const GROUP_PREFIX = "_g_" \ No newline at end of file diff --git a/src/database/stat-database/filter.ts b/src/database/stat-database/filter.ts deleted file mode 100644 index d91c894dc..000000000 --- a/src/database/stat-database/filter.ts +++ /dev/null @@ -1,134 +0,0 @@ -import { judgeVirtualFast } from "@util/pattern" -import { formatTimeYMD } from "@util/time" -import { type StatCondition, type StatDatabase } from "." -import { GROUP_PREFIX } from "./constants" - -type _StatCondition = StatCondition & { - // Use exact date condition - useExactDate?: boolean - // date str - exactDateStr?: string - startDateStr?: string - endDateStr?: string - // time range - timeStart?: number - timeEnd?: number - focusStart?: number - focusEnd?: number -} - -type _FilterResult = { - host: string - date: string - value: timer.core.Result -} - -function filterHost(host: string, condition: _StatCondition): boolean { - const { keys, virtual } = condition - const keyArr = typeof keys === 'string' ? [keys] : keys - // 1. virtual - if (!virtual && judgeVirtualFast(host)) return false - // 2. host - if (keyArr?.length && !keyArr.includes(host)) return false - return true -} - -function filterDate( - date: string, - { useExactDate, exactDateStr, startDateStr, endDateStr }: _StatCondition -): boolean { - if (useExactDate) { - if (exactDateStr !== date) return false - } else { - if (startDateStr && startDateStr > date) return false - if (endDateStr && endDateStr < date) return false - } - return true -} - -function filterNumberRange(val: number, [start, end]: [start?: number, end?: number]): boolean { - if (start !== null && start !== undefined && start > val) return false - if (end !== null && end !== undefined && end < val) return false - return true -} - -/** - * Filter by query parameters - * - * @param date date of item - * @param host host of item - * @param val val of item - * @param condition query parameters - * @return true if valid, or false - */ -function filterByCond(result: _FilterResult, condition: _StatCondition): boolean { - const { host, date, value } = result - const { focus, time } = value - const { timeStart, timeEnd, focusStart, focusEnd } = condition - - return filterHost(host, condition) - && filterDate(date, condition) - && filterNumberRange(time, [timeStart, timeEnd]) - && filterNumberRange(focus, [focusStart, focusEnd]) -} - - -function processDateCondition(cond: _StatCondition, paramDate?: Date | [Date, Date?]) { - if (!paramDate) return - - if (paramDate instanceof Date) { - cond.useExactDate = true - cond.exactDateStr = formatTimeYMD(paramDate as Date) - } else { - let startDate: Date | undefined = undefined - let endDate: Date | undefined = undefined - const dateArr = paramDate as [Date, Date] - dateArr && dateArr.length >= 2 && (endDate = dateArr[1]) - dateArr && dateArr.length >= 1 && (startDate = dateArr[0]) - cond.useExactDate = false - startDate && (cond.startDateStr = formatTimeYMD(startDate)) - endDate && (cond.endDateStr = formatTimeYMD(endDate)) - } -} - -function processParamTimeCondition(cond: _StatCondition, paramTime?: [number, number?]) { - if (!paramTime) return - paramTime.length >= 2 && (cond.timeEnd = paramTime[1]) - paramTime.length >= 1 && (cond.timeStart = paramTime[0]) -} - -function processParamFocusCondition(cond: _StatCondition, paramFocus?: Vector<2>) { - if (!paramFocus) return - paramFocus.length >= 2 && (cond.focusEnd = paramFocus[1]) - paramFocus.length >= 1 && (cond.focusStart = paramFocus[0]) -} - -function processCondition(condition: StatCondition): _StatCondition { - const result: _StatCondition = { ...condition } - processDateCondition(result, condition.date) - processParamTimeCondition(result, condition.timeRange) - processParamFocusCondition(result, condition.focusRange) - return result -} - -/** - * Filter by query parameters - */ -export async function filter(this: StatDatabase, condition?: StatCondition, onlyGroup?: boolean): Promise<_FilterResult[]> { - const cond = processCondition(condition ?? {}) - const items = await this.refresh() - const result: _FilterResult[] = [] - Object.entries(items).forEach(([key, value]) => { - const date = key.substring(0, 8) - let host = key.substring(8) - if (onlyGroup) { - if (host.startsWith(GROUP_PREFIX)) { - host = host.substring(GROUP_PREFIX.length) - result.push({ date, host, value: value as timer.core.Result }) - } - } else if (!host.startsWith(GROUP_PREFIX)) { - result.push({ date, host, value: value as timer.core.Result }) - } - }) - return result.filter(item => filterByCond(item, cond)) -} diff --git a/src/database/stat-database/index.ts b/src/database/stat-database/index.ts deleted file mode 100644 index de05a1d8a..000000000 --- a/src/database/stat-database/index.ts +++ /dev/null @@ -1,316 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { escapeRegExp } from "@util/pattern" -import { isNotZeroResult } from "@util/stat" -import { formatTimeYMD } from "@util/time" -import { log } from "../../common/logger" -import BaseDatabase from "../common/base-database" -import { REMAIN_WORD_PREFIX } from "../common/constant" -import { GROUP_PREFIX } from "./constants" -import { filter } from "./filter" - -export type StatCondition = { - /** - * Date - * {y}{m}{d} - */ - date?: Date | [Date, Date?] - /** - * Focus range, milliseconds - * - * @since 0.0.9 - */ - focusRange?: Vector<2> - /** - * Time range - * - * @since 0.0.9 - */ - timeRange?: [number, number?] - /** - * Whether to include virtual sites - * - * @since 1.6.1 - */ - virtual?: boolean - /** - * Host or groupId, full match - */ - keys?: string[] | string -} - -function increase(a: timer.core.Result, b: timer.core.Result) { - const res: timer.core.Result = { - focus: (a?.focus ?? 0) + (b?.focus ?? 0), - time: (a?.time ?? 0) + (b?.time ?? 0), - } - const run = (a?.run ?? 0) + (b?.run ?? 0) - run && (res.run = run) - return res -} - -function createZeroResult(): timer.core.Result { - return { focus: 0, time: 0 } -} - -function mergeMigration(exist: timer.core.Result | undefined, another: any) { - exist = exist || createZeroResult() - return increase(exist, { focus: another.focus ?? 0, time: another.time ?? 0, run: another.run ?? 0 }) -} - -/** - * Generate the key in local storage by host and date - * - * @param host host - * @param date date - */ -function generateKey(host: string, date: Date | string) { - const str = typeof date === 'object' ? formatTimeYMD(date as Date) : date - return str + host -} - -const generateHostReg = (host: string): RegExp => RegExp(`^\\d{8}${escapeRegExp(host)}$`) - -function generateGroupKey(groupId: number, date: Date | string) { - const str = typeof date === 'object' ? formatTimeYMD(date as Date) : date - return str + GROUP_PREFIX + groupId -} - -const generateGroupReg = (groupId: number): RegExp => RegExp(`^\\d{8}${escapeRegExp(`${GROUP_PREFIX}${groupId}`)}$`) - -function migrate(exists: { [key: string]: timer.core.Result }, data: any): Record { - const result: Record = {} - Object.entries(data) - .filter(([key]) => /^20\d{2}[01]\d[0-3]\d.*/.test(key) && !key.substring(8).startsWith(GROUP_PREFIX)) - .forEach(([key, value]) => { - if (typeof value !== "object") return - const exist = exists[key] - const merged = mergeMigration(exist, value) - merged && isNotZeroResult(merged) && (result[key] = mergeMigration(exist, value)) - }) - return result -} - -export class StatDatabase extends BaseDatabase { - - async refresh(): Promise<{ [key: string]: unknown }> { - const result = await this.storage.get() - const items: Record = {} - Object.entries(result) - .filter(([key]) => !key.startsWith(REMAIN_WORD_PREFIX)) - .forEach(([key, value]) => items[key] = value) - return items - } - - /** - * @param host host - * @since 0.1.3 - */ - accumulate(host: string, date: Date | string, item: timer.core.Result): Promise { - const key = generateKey(host, date) - return this.accumulateInner(key, item) - } - - /** - * @param host host - * @since 0.1.3 - */ - accumulateGroup(groupId: number, date: Date | string, item: timer.core.Result): Promise { - const key = generateGroupKey(groupId, date) - return this.accumulateInner(key, item) - } - - private async accumulateInner(key: string, item: timer.core.Result): Promise { - let exist = await this.storage.getOne(key) - exist = increase(exist || createZeroResult(), item) - await this.setByKey(key, exist) - return exist - } - - /** - * Batch accumulate - * - * @param data data: {host=>waste_per_day} - * @param date date - * @since 0.1.8 - */ - async accumulateBatch(data: Record, date: Date | string): Promise> { - const hosts = Object.keys(data) - if (!hosts.length) return {} - const dateStr = typeof date === 'string' ? date : formatTimeYMD(date) - const keys: { [host: string]: string } = {} - hosts.forEach(host => keys[host] = generateKey(host, dateStr)) - - const items = await this.storage.get(Object.values(keys)) - - const toUpdate: Record = {} - const afterUpdated: Record = {} - Object.entries(keys).forEach(([host, key]) => { - const item = data[host] - const exist: timer.core.Result = increase(items[key] as timer.core.Result || createZeroResult(), item) - toUpdate[key] = afterUpdated[host] = exist - }) - await this.storage.set(toUpdate) - return afterUpdated - } - - filter = filter - - /** - * Select - * - * @param condition condition - */ - async select(condition?: StatCondition): Promise { - log("select:{condition}", condition) - const filterResults = await this.filter(condition) - return filterResults.map(({ date, host, value }) => { - const { focus, time, run } = value - return { date, host, focus, time, run } - }) - } - - async selectGroup(condition?: StatCondition): Promise { - const filterResults = await this.filter(condition, true) - return filterResults.map(({ date, host, value }) => { - const { focus, time, run } = value - return { date, host, focus, time, run } - }) - } - - /** - * Get by host and date - * - * @since 0.0.5 - */ - async get(host: string, date: Date | string): Promise { - const key = generateKey(host, date) - const exist = await this.storage.getOne(key) - return exist || createZeroResult() - } - - /** - * Delete the record - * - * @param host host - * @param date date - * @since 0.0.5 - */ - async deleteByUrlAndDate(host: string, date: Date | string): Promise { - const key = generateKey(host, date) - return this.storage.remove(key) - } - - async deleteByGroupAndDate(groupId: number, date: Date | string): Promise { - const key = generateGroupKey(groupId, date) - return this.storage.remove(key) - } - - /** - * Delete by key - * - * @param rows site rows, the host and date mustn't be null - * @since 0.0.9 - */ - async delete(rows: timer.core.RowKey[]): Promise { - const keys: string[] = rows.map(({ host, date }) => generateKey(host, date)) - return this.storage.remove(keys) - } - - async deleteGroup(rows: [groupId: number, date: string][]): Promise { - const keys: string[] = rows.map(([groupId, date]) => generateGroupKey(groupId, date)) - return this.storage.remove(keys) - } - - async batchDeleteGroup(groupId: number): Promise { - const keyReg = generateGroupReg(groupId) - const items = await this.refresh() - const keys: string[] = Object.keys(items).filter(key => keyReg.test(key)) - await this.storage.remove(keys) - } - - /** - * Force update data - * - * @since 1.4.3 - */ - forceUpdate({ host, date, time, focus, run }: timer.core.Row): Promise { - const key = generateKey(host, date) - const result: timer.core.Result = { time, focus } - run && (result.run = run) - return this.storage.put(key, result) - } - - /** - * @param host host - * @param start start date, inclusive - * @param end end date, inclusive - * @since 0.0.7 - */ - async deleteByUrlBetween(host: string, start?: Date, end?: Date): Promise { - const startStr = start ? formatTimeYMD(start) : undefined - const endStr = end ? formatTimeYMD(end) : undefined - const dateFilter = (date: string) => (startStr ? startStr <= date : true) && (endStr ? date <= endStr : true) - const items = await this.refresh() - - // Key format: 20201112www.google.com - const keyReg = generateHostReg(host) - const keys: string[] = Object.keys(items) - .filter(key => keyReg.test(key) && dateFilter(key.substring(0, 8))) - - await this.storage.remove(keys) - return keys.map(k => k.substring(0, 8)) - } - - async deleteByGroupBetween(groupId: number, start?: Date, end?: Date): Promise { - const startStr = start ? formatTimeYMD(start) : undefined - const endStr = end ? formatTimeYMD(end) : undefined - const dateFilter = (date: string) => (startStr ? startStr <= date : true) && (endStr ? date <= endStr : true) - const items = await this.refresh() - - const keyReg = generateGroupReg(groupId) - const keys: string[] = Object.keys(items).filter(key => keyReg.test(key) && dateFilter(key.substring(0, 8))) - - await this.storage.remove(keys) - } - - /** - * Delete the record - * - * @param host host - * @since 0.0.5 - */ - async deleteByUrl(host: string): Promise { - const items = await this.refresh() - - // Key format: 20201112www.google.com - const keyReg = generateHostReg(host) - const keys: string[] = Object.keys(items).filter(key => keyReg.test(key)) - await this.storage.remove(keys) - - return keys.map(k => k.substring(0, 8)) - } - - async deleteByGroup(groupId: number): Promise { - const items = await this.refresh() - const keyReg = generateGroupReg(groupId) - const keys: string[] = Object.keys(items).filter(key => keyReg.test(key)) - await this.storage.remove(keys) - } - - async importData(data: any): Promise { - if (typeof data !== "object") return - const items = await this.storage.get() - const toSave = migrate(items, data) - this.storage.set(toSave) - } -} - -const statDatabase = new StatDatabase() - -export default statDatabase diff --git a/src/database/timeline-database.ts b/src/database/timeline-database.ts deleted file mode 100644 index 98f270f2d..000000000 --- a/src/database/timeline-database.ts +++ /dev/null @@ -1,101 +0,0 @@ -import { formatTimeYMD, MILL_PER_DAY } from '@util/time' -import BaseDatabase from './common/base-database' -import { REMAIN_WORD_PREFIX } from './common/constant' - -const DB_KEY = REMAIN_WORD_PREFIX + 'TL' - -type Item = { - // start - s: number - // duration - d: number -} - -type TimelineData = { - [date: string]: { - [host: string]: Item[] - } -} - -// If two tick with the same host is near 1 sec, then merge them to one -const MERGE_THRESHOLD = 1000 - -const canMerge = (item: Item, tick: timer.timeline.Tick) => { - const { s: is, d: id } = item - const { start } = tick - return start >= is + id - && start <= id + MERGE_THRESHOLD -} - -const isConflict = (item: Item, tick: timer.timeline.Tick) => { - const { s: is, d: id } = item - const { start } = tick - return is <= start && start < is + id -} - -const merge = (data: TimelineData, tick: timer.timeline.Tick) => { - const { start, duration, host } = tick - const date = formatTimeYMD(start) - const hostData = data[date] ?? {} - const items = hostData[host] ?? [] - items.sort((a, b) => (a?.s ?? 0) - (b?.s ?? 0)) - for (const item of items) { - if (isConflict(item, tick)) { - return - } - if (canMerge(item, tick)) { - item.d = start + duration - item.s - return - } - } - // normal tick - items.push({ s: start, d: duration }) - hostData[host] = items - data[date] = hostData -} - -export const TIMELINE_LIFE_CYCLE = 3 - -const removeOutdated = (data: TimelineData, currTime: number) => { - const minDate = formatTimeYMD(currTime - MILL_PER_DAY * (TIMELINE_LIFE_CYCLE - 1)) - const keys = Object.keys(data).filter(k => k < minDate) - keys.forEach(key => delete data[key]) -} - -class TimelineDatabase extends BaseDatabase { - private async getData(): Promise { - const data = await this.storage.getOne(DB_KEY) - return data ?? {} - } - - private setData(data: TimelineData): Promise { - return this.setByKey(DB_KEY, data) - } - - async batchSave(ticks: timer.timeline.Tick[]) { - const data = await this.getData() - ticks.forEach(tick => { - merge(data, tick) - removeOutdated(data, tick.start) - }) - await this.setData(data) - } - - async getAll(): Promise { - const data = await this.getData() - const result: timer.timeline.Tick[] = [] - Object.values(data).forEach(hostData => { - Object.entries(hostData).forEach(([host, items]) => { - items.forEach(({ s: start, d: duration }) => result.push({ host, start, duration })) - }) - }) - return result - } - - async importData(_: any): Promise { - // do nothing - } -} -const timelineDatabase = new TimelineDatabase() - -export default timelineDatabase \ No newline at end of file diff --git a/src/i18n/chrome/index.ts b/src/i18n/chrome/index.ts index b74238065..26c9980d9 100644 --- a/src/i18n/chrome/index.ts +++ b/src/i18n/chrome/index.ts @@ -25,6 +25,9 @@ const _default: { [locale in FakedLocale]: any } = { fr: compile(messages.fr), ru: compile(messages.ru), ar: compile(messages.ar), + tr: compile(messages.tr), + pl: compile(messages.pl), + it: compile(messages.it), } export default _default \ No newline at end of file diff --git a/src/i18n/chrome/message.ts b/src/i18n/chrome/message.ts index e891d2df4..5c2904f74 100644 --- a/src/i18n/chrome/message.ts +++ b/src/i18n/chrome/message.ts @@ -36,12 +36,13 @@ const placeholder: ChromeMessage = { marketName: '', }, base: { - sidebar: '', allFunction: '', guidePage: '', changeLog: '', option: '', sourceCode: '', + limit: '', + helpUs: '', }, contextMenus: { add2Whitelist: '', diff --git a/src/i18n/chrome/t.ts b/src/i18n/chrome/t.ts index 01cd2963c..8790c872c 100644 --- a/src/i18n/chrome/t.ts +++ b/src/i18n/chrome/t.ts @@ -9,7 +9,7 @@ import { getMessage } from "@api/chrome/i18n" import { t } from ".." import messages, { router, type ChromeMessage } from "./message" -export const keyPathOf = (key: (root: ChromeMessage) => string) => key(router) +const keyPathOf = (key: (root: ChromeMessage) => string) => key(router) export const t2Chrome = (key: (root: ChromeMessage) => string) => { if (getMessage) { diff --git a/src/i18n/element.ts b/src/i18n/element.ts index 71c03d822..036c778d2 100644 --- a/src/i18n/element.ts +++ b/src/i18n/element.ts @@ -1,10 +1,8 @@ -import ElementPlus from 'element-plus' import { type Language } from "element-plus/es/locale" -import { type App } from "vue" import { locale, t } from "." import calendarMessages from "./message/common/calendar" -const LOCALES: { [locale in timer.Locale]: () => Promise<{ default: Language }> } = { +const LOCALES: Record Promise<{ default: Language }>> = { zh_CN: () => import('element-plus/es/locale/lang/zh-cn'), zh_TW: () => import('element-plus/es/locale/lang/zh-tw'), en: () => import('element-plus/es/locale/lang/en'), @@ -16,12 +14,13 @@ const LOCALES: { [locale in timer.Locale]: () => Promise<{ default: Language }> fr: () => import('element-plus/es/locale/lang/fr'), ru: () => import('element-plus/es/locale/lang/ru'), ar: () => import('element-plus/es/locale/lang/ar'), + tr: () => import('element-plus/es/locale/lang/tr'), + pl: () => import('element-plus/es/locale/lang/pl'), + it: () => import('element-plus/es/locale/lang/it'), } -export const initElementLocale = async (app: App) => { - const module = await LOCALES[locale]?.() - const EL_LOCALE = module?.default - app.use(ElementPlus, { locale: EL_LOCALE }) +export async function initElementLocale(): Promise { + return (await LOCALES[locale]()).default } export const dateFormat = () => t(calendarMessages, { key: msg => msg.dateFormat, param: { y: 'YYYY', m: 'MM', d: 'DD' } }) diff --git a/src/i18n/i18n.d.ts b/src/i18n/i18n.d.ts index cd2e6cbd6..f16415931 100644 --- a/src/i18n/i18n.d.ts +++ b/src/i18n/i18n.d.ts @@ -1,9 +1,9 @@ type RequiredMessages = { - [locale in timer.RequiredLocale]: M + [locale in tt4b.RequiredLocale]: M } type OptionalMessages = { - [locale in timer.OptionalLocale]?: EmbeddedPartial + [locale in tt4b.OptionalLocale]?: EmbeddedPartial } type Messages = RequiredMessages & OptionalMessages diff --git a/src/i18n/index.ts b/src/i18n/index.ts index 6790e8a97..02b60b0a3 100644 --- a/src/i18n/index.ts +++ b/src/i18n/index.ts @@ -5,26 +5,26 @@ * https://opensource.org/licenses/MIT */ -import { getUILanguage } from "@api/chrome/i18n" -import optionHolder from "@service/components/option-holder" -import { setDir, setLocale } from "@util/document" +import { getUILanguage } from "../api/chrome/i18n" +import { getOption } from '../api/sw/option' +import { setDir, setLocale } from "../util/document" import { ALL_LOCALES as _ALL_LOCALES } from "./message/merge" /** * Not to import this one if not necessary */ -export type FakedLocale = timer.Locale +export type FakedLocale = tt4b.Locale + /** * @since 0.2.2 */ -export const FEEDBACK_LOCALE: timer.Locale = "en" - -export const ALL_LOCALES: timer.Locale[] = _ALL_LOCALES +const FEEDBACK_LOCALE: tt4b.Locale = "en" -export const defaultLocale: timer.Locale = "zh_CN" +export const ALL_LOCALES: tt4b.Locale[] = _ALL_LOCALES // Standardize the locale code according to the Chrome locale code -const chrome2I18n: { [key: string]: timer.Locale } = { +const chrome2I18n: { [key: string]: tt4b.Locale } = { + 'zh': 'zh_CN', 'zh-CN': "zh_CN", 'zh-TW': "zh_TW", 'en': 'en', @@ -38,18 +38,20 @@ const chrome2I18n: { [key: string]: timer.Locale } = { 'fr': 'fr', 'fr-CA': 'fr', 'fr-CH': 'fr', + 'ar': 'ar', + 'ru': 'ru', + 'tr': 'tr', + 'pl': 'pl', } -const translationChrome2I18n: { [key: string]: timer.TranslatingLocale } = { +const translationChrome2I18n: { [key: string]: tt4b.TranslatingLocale } = { ko: 'ko', - pl: 'pl', it: 'it', sv: 'sv', fi: 'fi', da: 'da', hr: 'hr', id: 'id', - tr: 'tr', cs: 'cs', ro: 'ro', nl: 'nl', @@ -62,21 +64,22 @@ const translationChrome2I18n: { [key: string]: timer.TranslatingLocale } = { * Codes returned by getUILanguage() are defined by Chrome browser * @see https://github.com/unicode-cldr/cldr-localenames-modern/blob/master/main/en/languages.json * But supported locale codes in Chrome extension - * @see https://developer.chrome.com/docs/extensions/reference/api/i18n#locales + * @see https://developer.chrome.com/docs/extensions/reference/i18n#locales * * They are different, so translate */ -export function chromeLocale2ExtensionLocale(chromeLocale: string): timer.Locale { - if (!chromeLocale) { - return defaultLocale - } - return chrome2I18n[chromeLocale] || FEEDBACK_LOCALE +function chromeLocale2ExtensionLocale(chromeLocale: string): tt4b.Locale { + if (!chromeLocale) return FEEDBACK_LOCALE + const code2 = chromeLocale.substring(0, 2) + return chrome2I18n[chromeLocale] ?? chrome2I18n[code2] ?? FEEDBACK_LOCALE } +const browserUiLocale: tt4b.Locale = chromeLocale2ExtensionLocale(getUILanguage()) + /** * @since 0.9.0 */ -export let localeSameAsBrowser: timer.Locale = chromeLocale2ExtensionLocale(getUILanguage()) +export const localeSameAsBrowser: tt4b.Locale = browserUiLocale /** * @since 1.5.0 @@ -92,16 +95,16 @@ export function isTranslatingLocale(): boolean { /** * Real locale with locale option */ -export let locale: timer.Locale = localeSameAsBrowser +export let locale: tt4b.Locale = browserUiLocale -function cvtOption2Locale(option: timer.option.LocaleOption): timer.Locale { +export function cvtOption2Locale(option: tt4b.option.LocaleOption): tt4b.Locale { if (!option || option === 'default') { return chromeLocale2ExtensionLocale(getUILanguage()) } return option } -export function handleLocaleOption(option: timer.option.LocaleOption) { +export function handleLocaleOption(option: tt4b.option.LocaleOption) { locale = cvtOption2Locale(option) setLocale(locale) @@ -113,14 +116,14 @@ export function handleLocaleOption(option: timer.option.LocaleOption) { * @since 0.8.0 */ export async function initLocale() { - const option = await optionHolder.get() - handleLocaleOption(option?.locale) + const option = await getOption() + handleLocaleOption(option.locale) } function tryGetOriginalI18nVal( messages: Messages, keyPath: I18nKey, - specLocale?: timer.Locale + specLocale?: tt4b.Locale ) { try { return keyPath(messages[specLocale || locale] as MessageType) @@ -129,10 +132,10 @@ function tryGetOriginalI18nVal( } } -export function getI18nVal( +function getI18nVal( messages: Messages, keyPath: I18nKey, - specLocale?: timer.Locale + specLocale?: tt4b.Locale ): string { const result = tryGetOriginalI18nVal(messages, keyPath, specLocale) || keyPath(messages[FEEDBACK_LOCALE] as MessageType) @@ -140,7 +143,7 @@ export function getI18nVal( return typeof result === 'string' ? result : JSON.stringify(result) } -export type TranslateProps = { +type TranslateProps = { key: I18nKey, param?: { [key: string]: string | number } } @@ -155,7 +158,7 @@ function fillWithParam(result: string, param: { [key: string]: string | number } return result } -export function t(messages: Messages, props: TranslateProps, specLocale?: timer.Locale): string { +export function t(messages: Messages, props: TranslateProps, specLocale?: tt4b.Locale): string { const { key, param } = props const result: string = getI18nVal(messages, key, specLocale) return param ? fillWithParam(result, param) : result @@ -170,7 +173,6 @@ const findParamAndReplace = (resultArr: I18nResultItem[], [key, val const temp: I18nResultItem[] = [] resultArr.forEach((item) => { if (typeof item === 'string' && item.includes(paramPlacement)) { - // 将 string 替换成具体的 VNode let splits: I18nResultItem[] = (item as string).split(paramPlacement) splits = splits.reduce[]>((left, right) => left.length ? left.concat(value, right) : left.concat(right), []) temp.push(...splits) @@ -181,7 +183,7 @@ const findParamAndReplace = (resultArr: I18nResultItem[], [key, val return temp } -export type NodeTranslateProps = { +type NodeTranslateProps = { key: I18nKey, param: { [key: string]: I18nResultItem } } @@ -200,13 +202,7 @@ export const tN = (messages: Messages, props: No return resultArr } -export const getNumberSeparator = () => { - try { - const jsLocale = locale?.substring(0, 2) - const str = (1000).toLocaleString(jsLocale) - if (str.length === 4) return '' - return str.substring(1, 2) - } catch { - return '' - } +export const tNum = (val: number) => { + const jsLocale = locale?.substring(0, 2) + return val.toLocaleString(jsLocale) } diff --git a/src/i18n/message/app/about-resource.json b/src/i18n/message/app/about-resource.json index a26e85e1b..5de54704c 100644 --- a/src/i18n/message/app/about-resource.json +++ b/src/i18n/message/app/about-resource.json @@ -69,19 +69,19 @@ }, "de": { "label": { - "name": "Namen", - "version": "Aktuelle Version", + "name": "Titel", + "version": "Version", "website": "Offizielle Website", "installation": "Installation", - "thanks": "Dank", + "thanks": "Danksagungen", "privacy": "Datenschutz", "license": "Lizenz", - "support": "Kundendienst" + "support": "Support" }, "text": { - "greet": "Gefällt Ihnen die Erweiterung?", - "rate": "Mit 5 Sternen bewerten!", - "feedback": "Feedback willkommen!" + "greet": "Gefällt dir diese Erweiterung?", + "rate": "Bewerte uns mit 5 Sternen, um uns zu unterstützen!", + "feedback": "Sag uns deine Meinung!" } }, "ja": { @@ -130,9 +130,9 @@ "support": "Soutien" }, "text": { - "greet": "Vous aimez cette extension?", - "rate": "Notez-la 5 étoiles pour nous soutenir!", - "feedback": "Vos retours et suggestions sont les bienvenus!" + "greet": "Vous aimez cette extension ?", + "rate": "Notez-la 5 étoiles pour nous soutenir !", + "feedback": "Vos retours et suggestions sont les bienvenus !" } }, "es": { @@ -185,5 +185,56 @@ "rate": "قيِّمها بـ 5 نجوم لتساعد الآخرين على معرفتها~~", "feedback": "أيضًا، نرحب بتقديم ملاحظاتك وطلبات المميزات الجديدة !!" } + }, + "tr": { + "label": { + "name": "Proje Adı", + "version": "Versiyon", + "website": "Web Sitesi", + "installation": "Kurulabilecek Platformlar", + "thanks": "Kullanılan Teknolojiler", + "privacy": "Gizlilik Politikası", + "license": "Lisans", + "support": "Destek" + }, + "text": { + "greet": "Bu uzantıyı beğendiniz mi?", + "rate": "Bizi desteklemek için 5 yıldız verin!", + "feedback": "Fikirleriniz bizi daha iyi yapıyor!" + } + }, + "pl": { + "label": { + "name": "Nazwa", + "version": "Obecna wersja", + "website": "Oficjalna strona", + "installation": "Strona instalacji", + "thanks": "Podziękowania", + "privacy": "Polityka prywatności", + "license": "Licencja Open-source", + "support": "Wsparcie techniczne" + }, + "text": { + "greet": "Lubisz to rozszerzenie?", + "rate": "Oceń 5 gwiazdek, aby pomóc innym dowiedzieć się o tym~~", + "feedback": "Zapraszamy do przesłania opinii i próśb o funkcje !!" + } + }, + "it": { + "label": { + "name": "Nome", + "version": "Versione", + "website": "Sito Web", + "installation": "Installa", + "thanks": "Riconoscimenti", + "privacy": "Informativa Privacy", + "license": "Licenza", + "support": "Supporto" + }, + "text": { + "greet": "Ti piace questa estensione?", + "rate": "Valuta 5 stelle per supportarci!", + "feedback": "Le tue idee ci rendono migliori!" + } } } \ No newline at end of file diff --git a/src/i18n/message/app/analysis-resource.json b/src/i18n/message/app/analysis-resource.json index 71a2606da..3b5841056 100644 --- a/src/i18n/message/app/analysis-resource.json +++ b/src/i18n/message/app/analysis-resource.json @@ -217,6 +217,10 @@ } }, "de": { + "target": { + "site": "Webseite", + "cate": "Kategorie" + }, "common": { "focusTotal": "Gesamte Browsingzeit", "visitTotal": "Gesamtbesuche", @@ -275,6 +279,10 @@ } }, "ru": { + "target": { + "site": "Сайт", + "cate": "Категория" + }, "common": { "focusTotal": "Общее время просмотра", "visitTotal": "Всего посещений", @@ -331,5 +339,98 @@ "focusTitle": "اتجاهات وقت التصفح", "visitTitle": "زيارة الاتجاهات" } + }, + "tr": { + "target": { + "site": "Web Sitesi", + "cate": "Kategori" + }, + "common": { + "focusTotal": "Toplam gezinme süresi", + "visitTotal": "Toplam Ziyaretler", + "merged": "Birleştirilmiş", + "virtual": "Sanal", + "hostPlaceholder": "Site veya kategoriyi seçin", + "emptyDesc": "Seçilen öğe yok" + }, + "summary": { + "title": "Özet", + "day": "Toplam aktif gün", + "firstDay": "İlk ziyaret {value}", + "calendarTitle": "Son Haftalarda Gerçekleştirilen Aktiviteler" + }, + "trend": { + "title": "Eğilimler", + "activeDay": "Aktif Günler", + "totalDay": "Periyot günleri", + "maxFocus": "Günlük maksimum gezinme süresi", + "averageFocus": "Günlük ortalama gezinme süresi", + "maxVisit": "Günlük maksimum ziyaret sayısı", + "averageVisit": "Günlük ortalama ziyaret sayısı", + "focusTitle": "Gezinme Zamanı Eğilimleri", + "visitTitle": "Ziyaret Eğilimleri" + } + }, + "pl": { + "target": { + "site": "Strona", + "cate": "Kategoria" + }, + "common": { + "focusTotal": "Całkowity czas przeglądania", + "visitTotal": "Łącznie wizyt", + "merged": "Złączone", + "virtual": "Wirtualne", + "hostPlaceholder": "Wyszukaj stronę do analizy", + "emptyDesc": "Nie wybrano strony" + }, + "summary": { + "title": "Podsumowanie", + "day": "Całkowita liczba dni aktywnych", + "firstDay": "Pierwsza wizyta {value}", + "calendarTitle": "Aktywność w ostatnich tygodniach" + }, + "trend": { + "title": "Trendy", + "activeDay": "Aktywnych dni", + "totalDay": "Okres dni", + "maxFocus": "Maksymalny dzienny czas przeglądania", + "averageFocus": "Średni dzienny czas przeglądania", + "maxVisit": "Maksymalna dzienna ilość wizyt", + "averageVisit": "Średnia dzienna ilość wizyt", + "focusTitle": "Trendy czasu przeglądania", + "visitTitle": "Trendy wizyt" + } + }, + "it": { + "target": { + "site": "Sito web", + "cate": "Categoria" + }, + "common": { + "focusTotal": "Tempo totale di navigazione", + "visitTotal": "Visite totali", + "merged": "Uniti", + "virtual": "Virtuale", + "hostPlaceholder": "Cerca un sito da analizzare", + "emptyDesc": "Nessun sito selezionato" + }, + "summary": { + "title": "Riepilogo", + "day": "Giorni attivi in totale", + "firstDay": "Prima visita {value}", + "calendarTitle": "Attività nelle ultime settimane" + }, + "trend": { + "title": "Tendenze", + "activeDay": "Giorni attivi", + "totalDay": "Periodo: Giorni", + "maxFocus": "Tempo di navigazione massimo giornaliero", + "averageFocus": "Tempo medio di navigazione giornaliero", + "maxVisit": "Visite massime giornaliere", + "averageVisit": "Visite medie giornaliere", + "focusTitle": "Tendenze di Navigazione nel periodo", + "visitTitle": "Tendenze Di Visite" + } } } \ No newline at end of file diff --git a/src/i18n/message/app/dashboard-resource.json b/src/i18n/message/app/dashboard-resource.json index 23c25849f..37206c034 100644 --- a/src/i18n/message/app/dashboard-resource.json +++ b/src/i18n/message/app/dashboard-resource.json @@ -76,38 +76,48 @@ }, "ja": { "heatMap": { - "title0": "過去1年間に {hour} 時間以上オンラインで過ごした", - "title1": "過去 1 年間にオンラインで費やした時間は 1 時間未満" + "title0": "去年は {hour} 時間以上閲覧した", + "title1": "過去1年間の閲覧時間は1時間未満です" }, "topK": { "title": "過去 {day} 日間に最も拜訪された TOP {k}" }, "indicator": { - "installedDays": "使用 {number} 日", + "installedDays": "{number}日間使用しました", "visitCount": "{site} つのサイトへの合計 {visit} 回の拜訪", "browsingTime": "{minute} 分以上ウェブを閲覧する", "mostUse": "{start}:00 から {end}:00 までのお気に入りのインターネットアクセス" }, "monthOnMonth": { "title": "閲覧時間の月間推移" + }, + "timeline": { + "focusScore": "フォーカス度" } }, "pt_PT": { "heatMap": { - "title0": "Navegou {hour}h no último ano", - "title1": "Navegou <1h no último ano" + "title0": "Navegou por {hour} no último ano", + "title1": "Navegou menos que 1 hora no último ano" }, "topK": { - "title": "TOP {k} mais visitados em {day} dias" + "title": "TOP {k} mais visitados nos últimos {day} dias" }, "indicator": { "installedDays": "Instalado há {number} dias", - "visitCount": "{visit} visitas a {site} sites", - "browsingTime": "Navegou {minute} min", - "mostUse": "Prefere navegar entre {start}h-{end}h" + "visitCount": "Visitou sites {site} {visit} vezes", + "browsingTime": "Navegou mais de {minute} minutos", + "mostUse": "Prefere navegar entre {start} h às {end} h" }, "monthOnMonth": { "title": "Tendência mensal de tempo de navegação" + }, + "timeline": { + "title": "Linha do tempo dos últimos {n} dias", + "busyScore": "Ocupado", + "busyScoreDesc": "Relacionado ao tempo de navegação total e o número de sites visitados por hora. Veja código-fonte para a fórmula", + "focusScore": "Focado", + "focusScoreDesc": "Relacionado a contagem do tempo contínuo total navegado no mesmo site. Veja o código-fonte para a fórmula do cálculo" } }, "uk": { @@ -126,6 +136,13 @@ }, "monthOnMonth": { "title": "Порівняння часу перегляду останніх місяців" + }, + "timeline": { + "title": "Хронологія останніх {n} днів", + "busyScore": "Зайнятість", + "busyScoreDesc": "Загальний час перегляду і кількість вебсайтів за годину. Формулу обчислення можна переглянути в програмному коді.", + "focusScore": "Зосередженість", + "focusScoreDesc": "Загальний час безперервного перегляду одного вебсайту. Формулу обчислення можна переглянути в програмному коді." } }, "es": { @@ -144,6 +161,13 @@ }, "monthOnMonth": { "title": "Tendencia mes a mes del tiempo de navegación" + }, + "timeline": { + "title": "Línea de tiempo de los últimos {n} días", + "busyScore": "Ocupación", + "busyScoreDesc": "Relacionado con el tiempo total de navegación y el número de sitios web por hora. Vea el código fuente para la fórmula usada", + "focusScore": "Enfoque", + "focusScoreDesc": "Relacionado con el tiempo total de navegación continua en la misma página web. Vea el código fuente para la fórmula de cálculo" } }, "de": { @@ -162,11 +186,18 @@ }, "monthOnMonth": { "title": "Monatlicher Trend der Browsing-Zeit" + }, + "timeline": { + "title": "Zeitleiste der letzten {n} Tage", + "busyScore": "Geschäftigkeit", + "busyScoreDesc": "Verwandt mit der gesamten Browsingzeit und der Anzahl der Webseiten pro Stunde. Siehe Quellcode für Berechnungsformel", + "focusScore": "Fokussierung", + "focusScoreDesc": "Verwandt mit der anhaltenden Browsingzeit auf derselben Webseite. Siehe Quellcode für Berechnungsformel" } }, "fr": { "heatMap": { - "title0": "Plus de {hour} heures parcourues au cours de l'année dernière", + "title0": "Visité pendant plus de {hour} heures durant l'année dernière", "title1": "Parcouru moins d'une heure l'année dernière" }, "topK": { @@ -174,12 +205,19 @@ }, "indicator": { "installedDays": "Installé depuis {number} jours", - "visitCount": "Visité {site} sites Web {visit} fois", - "browsingTime": "Parcouru pendant {minute} minutes", - "mostUse": "Navigation favorite entre {start} et {end}" + "visitCount": "{site} sites visités {visit} fois", + "browsingTime": "Visité pendant {minute} minutes", + "mostUse": "Navigation la plus fréquente entre {start} et {end} heure" }, "monthOnMonth": { "title": "Tendance mensuelle du temps de navigation" + }, + "timeline": { + "title": "Chronologie des derniers {n} jours", + "busyScore": "Niveau d'occupation", + "busyScoreDesc": "En rapport avec le temps total de navigation et le nombre de sites visités par heure. Voir le code source pour la formule du calcul", + "focusScore": "Niveau de concentration", + "focusScoreDesc": "Lié à la durée totale de la navigation continue sur le même site. Voir le code source pour la formule du calcul" } }, "ru": { @@ -198,6 +236,13 @@ }, "monthOnMonth": { "title": "Время просмотра за последние 30 дней" + }, + "timeline": { + "title": "Лента времени последних {n} дней", + "busyScore": "Занятость", + "busyScoreDesc": "Относится к общему времени просмотра и количеству веб-сайтов в час. Смотрите исходный код для формулы расчета", + "focusScore": "Сфокусированность", + "focusScoreDesc": "Относится к общему времени непрерывного просмотра того же веб-сайта. Смотрите исходный код для формулы расчета" } }, "ar": { @@ -217,5 +262,80 @@ "monthOnMonth": { "title": "اتجاه وقت التصفح من شهر إلى شهر" } + }, + "tr": { + "heatMap": { + "title0": "Geçen yıldan {hour} saat daha fazla gezindiniz", + "title1": "Geçen yıl 1 saatten daha az gezindiniz" + }, + "topK": { + "title": "Son {day} günde en çok ziyaret ettiğin {k} site" + }, + "indicator": { + "installedDays": "{number} gündür kurulu", + "visitCount": "{site} web sitelerini {visit} kez ziyaret ettin", + "browsingTime": "{minute} dakikadan fazla göz attın", + "mostUse": "{start} ile {end} arası favori gezinti saatlerin" + }, + "monthOnMonth": { + "title": "Aylık gezinme süresi eğilimin" + }, + "timeline": { + "title": "Son {n} günün zaman çizelgesi", + "busyScore": "Yoğunluk", + "busyScoreDesc": "Toplam gezinme süresi ve saat başına ziyaret edilen web sitesi sayısı ile ilgilidir. Hesaplama formülü için kaynak koduna bakın", + "focusScore": "Odaklanma", + "focusScoreDesc": "Aynı web sitesinde sürekli gezinme toplam süresi ile ilgilidir. Hesaplama formülü için kaynak koduna bakın" + } + }, + "pl": { + "heatMap": { + "title0": "Przeglądano przez {hour} godzin w zeszłym roku", + "title1": "Przeglądano mniej niż 1 godzinę w ubiegłym roku" + }, + "topK": { + "title": "TOP {k} najczęściej odwiedzanych stron w ostatnich {day} dniach" + }, + "indicator": { + "installedDays": "Zainstalowane przez {number} dni", + "visitCount": "Odwiedzono {site} różnych stron, łącznie {visit} razy", + "browsingTime": "Przeglądano przez {minute} minut(y)", + "mostUse": "Najchętniej przeglądano w godzinach od {start} do {end}" + }, + "monthOnMonth": { + "title": "Miesięczny trend czasu przeglądania" + }, + "timeline": { + "title": "Oś czasu ostatnich {n} dni", + "busyScore": "Aktywność", + "busyScoreDesc": "Powiązana z całkowitym czasem przeglądania i liczbą stron odwiedzonych na godzinę. Wzór znajduje się w kodzie źródłowym", + "focusScore": "Skupienie", + "focusScoreDesc": "Powiązane z całkowitym czasem ciągłego przebywania na tej samej stronie. Wzór znajduje się w kodzie źródłowym" + } + }, + "it": { + "heatMap": { + "title0": "Navigato per {hour} ore nell'ultimo anno", + "title1": "Navigato meno di 1 ora l'anno scorso" + }, + "topK": { + "title": "TOP {k} più visitati negli ultimi {day} giorni" + }, + "indicator": { + "installedDays": "Installato per {number} giorni", + "visitCount": "Visitato {site} {visit} volte", + "browsingTime": "Navigato per {minute} minuti", + "mostUse": "Navigazione preferita tra le {start} e le {end}" + }, + "monthOnMonth": { + "title": "Tendenza del tempo di navigazione mese per mese" + }, + "timeline": { + "title": "Cronologia degli ultimi {n} giorni", + "busyScore": "Frenesia", + "busyScoreDesc": "Relativo al tempo totale di navigazione e al numero di siti web per ora. Guarda il codice sorgente per la formula di calcolo", + "focusScore": "Focalizzazione", + "focusScoreDesc": "Relativo al tempo totale di navigazione continua dello stesso sito web. Guarda il codice sorgente per la formula di calcolo" + } } } \ No newline at end of file diff --git a/src/i18n/message/app/data-manage-resource.json b/src/i18n/message/app/data-manage-resource.json index f0e04ad62..0ab562c98 100644 --- a/src/i18n/message/app/data-manage-resource.json +++ b/src/i18n/message/app/data-manage-resource.json @@ -3,6 +3,7 @@ "totalMemoryAlert": "浏览器为每个扩展提供 {size}MB 来存储本地数据", "totalMemoryAlert1": "无法确定浏览器允许的最大可用内存", "usedMemoryAlert": "当前已使用 {size}MB", + "idbAlert": "可以将数据移动到 IndexedDB 来减少存储空间占用", "operationAlert": "您可以删除那些无关紧要的数据,来减小内存空间", "filterItems": "数据筛选", "filterFocus": "当日阅览时间在 {start} 秒至 {end} 秒之间。", @@ -26,12 +27,16 @@ "paramError": "参数错误,请检查!", "deleteConfirm": "共筛选出 {count} 条数据,是否全部删除?", "migrationAlert": "使用导入/导出在不同浏览器之间迁移数据", - "importError": "文件格式错误" + "importError": "文件格式错误", + "exportData": "导出数据", + "restoreData": "导入数据", + "restoreFromOther": "导入 {ext} 数据" }, "zh_TW": { "totalMemoryAlert": "瀏覽器為每個擴充功能提供 {size}MB 本地儲存空間", "totalMemoryAlert1": "無法取得瀏覽器允許的最大儲存空間", "usedMemoryAlert": "目前已使用 {size}MB", + "idbAlert": "將追蹤資料移到IndexedDB以釋放儲存空間", "operationAlert": "您可以刪除不重要的資料來釋放儲存空間", "filterItems": "資料篩選", "filterFocus": "當日瀏覽時間:{start}~{end} 秒", @@ -61,6 +66,7 @@ "totalMemoryAlert": "The browser provides {size}MB to store local data for each extension", "totalMemoryAlert1": "Unable to determine the maximum storage available allowed by the browser", "usedMemoryAlert": "{size}MB is currently used", + "idbAlert": "Move tracking data to IndexedDB to reduce storage usage", "operationAlert": "You can delete those unimportant data to reduce storage usage", "filterItems": "Filter data", "filterFocus": "The browsing time of the day is between {start} seconds and {end} seconds", @@ -84,7 +90,10 @@ "paramError": "The parameter is wrong, please check!", "deleteConfirm": "A total of {count} records have been filtered out. Do you want to delete them all?", "migrationAlert": "Migrate data between browsers using import and export", - "importError": "Wrong file extension" + "importError": "Wrong file extension", + "exportData": "Export data", + "restoreData": "Restore data", + "restoreFromOther": "Restore from {ext}" }, "ja": { "totalMemoryAlert": "ブラウザは、データを保存するために各拡張機能に {size}MB のメモリを提供します", @@ -105,7 +114,7 @@ "conflictTip": "インポートされたデータがローカルデータと競合する場合の対処方法", "overwrite": "上書き", "accumulate": "累積する", - "imported": "輸入された", + "imported": "インポート済み", "local": "ローカル", "fileNotSelected": "ファイルが選択されていません", "conflictNotSelected": "競合する解像度が選択されていません" @@ -148,6 +157,7 @@ "totalMemoryAlert": "Браузер надає кожному розширенню {size} МБ для зберігання локальних даних", "totalMemoryAlert1": "Не вдалося визначити максимальну кількість доступної пам'яті браузера", "usedMemoryAlert": "Використано {size} МБ", + "idbAlert": "Перемістити дані відстеження до IndexedDB для зменшення використання сховища", "operationAlert": "Ви можете видалити ці неважливі дані, щоб зменшити використання пам'яті", "filterItems": "Фільтрувати дані", "filterFocus": "Час перегляду для дня – між {start} секунд і {end} секунд", @@ -171,7 +181,10 @@ "paramError": "Хибний параметр. Будь ласка, перевірте!", "deleteConfirm": "Всього відфільтровано {count} записів. Ви хочете видалити їх усі?", "migrationAlert": "Переносьте дані між браузерами за допомогою імпорту й експорту", - "importError": "Неправильне розширення файлу" + "importError": "Неправильне розширення файлу", + "exportData": "Експортувати дані", + "restoreData": "Відновити дані", + "restoreFromOther": "Відновити з {ext}" }, "es": { "totalMemoryAlert": "El navegador proporciona {size}MB para almacenar datos locales para cada extensión", @@ -206,6 +219,7 @@ "totalMemoryAlert": "Der Browser stellt {size}MB zur Verfügung, um lokale Daten für jede Erweiterung zu speichern", "totalMemoryAlert1": "Der vom Browser maximal verfügbare Speicher kann nicht ermittelt werden", "usedMemoryAlert": "Derzeit werden {size}MB verwendet", + "idbAlert": "Bewege Tracking Daten nach IndexedDB um Speichernutzung zu Reduzieren", "operationAlert": "Sie können diese unwichtigen Daten löschen, um den Speicherverbrauch zu reduzieren", "filterItems": "Daten filtern", "filterFocus": "Die Browsing-Zeit am Tag liegt zwischen {start} Sekunden und {end} Sekunden", @@ -216,7 +230,7 @@ "step2": "Daten bestätigen", "dataSource": "Datenquelle", "file": "Datendatei", - "selectFileBtn": "Wählen", + "selectFileBtn": "Auswählen", "conflictType": "Konfliktlösung", "conflictTip": "Was tun, wenn importierte Daten mit lokalen Daten in Konflikt geraten", "overwrite": "Überschreiben", @@ -229,15 +243,19 @@ "paramError": "Parameterfehler, bitte überprüfen!", "deleteConfirm": "Insgesamt wurden {count} Datensätze herausgefiltert. Möchten Sie sie alle löschen?", "migrationAlert": "Migrieren Sie Daten zwischen Browsern mithilfe von Import und Export", - "importError": "Falsche Dateierweiterung" + "importError": "Falsche Dateierweiterung", + "exportData": "Daten exportieren", + "restoreData": "Daten wiederherstellen", + "restoreFromOther": "Wiederherstellen aus {ext}" }, "fr": { "totalMemoryAlert": "Le navigateur fournit {size}Mo pour stocker des données locales pour chaque extension", "totalMemoryAlert1": "Impossible de déterminer la mémoire maximale disponible autorisée par le navigateur", "usedMemoryAlert": "{size}Mo sont actuellement utilisés", + "idbAlert": "Déplacer les données de suivi vers IndexedDB pour réduire l'utilisation du stockage", "operationAlert": "Vous pouvez supprimer ces données sans importance afin de réduire l'utilisation de la mémoire", "filterItems": "Filtrer les données", - "filterFocus": "Le temps de navigation de la journée est compris entre {start} secondes et {end} secondes", + "filterFocus": "Le temps de navigation de la journée est compris entre {start} et {end} secondes", "filterTime": "Le nombre de visites du jour se situe entre {start} et {end}", "filterDate": "Enregistré entre {picker}", "importOther": { @@ -258,12 +276,16 @@ "paramError": "Le paramètre est incorrect, veuillez vérifier!", "deleteConfirm": "Un total de {count} enregistrements a été filtré. Souhaitez-vous tous les supprimer ?", "migrationAlert": "Migrer les données entre les navigateurs à l'aide de l'importation et de l'exportation", - "importError": "Mauvaise extension de fichier" + "importError": "Mauvaise extension de fichier", + "exportData": "Exporter les données", + "restoreData": "Restaurer les données", + "restoreFromOther": "Restaurer depuis {ext}" }, "ru": { "totalMemoryAlert": "Браузер предоставляет {size}MB для хранения локальных данных по каждому расширению", "totalMemoryAlert1": "Невозможно определить максимальный объём памяти, доступный браузером", "usedMemoryAlert": "{size}MB сейчас используется", + "idbAlert": "Переместить данные для отслеживания в IndexedDB для уменьшения использования хранилища", "operationAlert": "Вы можете удалить эти неважные данные для уменьшения использования памяти", "filterItems": "Фильтровать данные", "filterFocus": "Время просмотра дня между {start} секунд и {end} секунд", @@ -317,5 +339,101 @@ "deleteConfirm": "تم تصفية إجمالي {count} من السجلات. هل تريد حذفها جميعًا؟", "migrationAlert": "نقل البيانات بين المتصفحات باستخدام الاستيراد والتصدير", "importError": "ملحق الملف خاطئ" + }, + "tr": { + "totalMemoryAlert": "Tarayıcı, her uzantı için yerel verileri depolamak üzere {size} MB alan sağlar", + "totalMemoryAlert1": "Tarayıcı tarafından izin verilen maksimum depolama alanı belirlenemiyor", + "usedMemoryAlert": "Şu anda {size} MB kullanılıyor", + "idbAlert": "Depolama kullanımını azaltmak için verilerinizi IndexedDB'ye taşı", + "operationAlert": "Depolama alanını azaltmak için önemsiz verileri silebilirsiniz", + "filterItems": "Verileri filtrele", + "filterFocus": "Günün gezinme süresi {start} saniye ile {end} saniye arasındadır", + "filterTime": "Günün ziyaret sayısı {start} ile {end} arasındadır", + "filterDate": "{picker} arasında kaydedildi", + "importOther": { + "step1": "Verileri seçin", + "step2": "Verileri onaylayın", + "dataSource": "Veri kaynağı", + "file": "Veri dosyası", + "selectFileBtn": "Seçin", + "conflictType": "Çakışma çözümü", + "conflictTip": "İçe aktarılan veriler yerel verilerle çakışırsa ne yapmalı", + "overwrite": "Üzerine yaz", + "accumulate": "Biriktir", + "imported": "İçe aktarılan", + "local": "Yerel", + "fileNotSelected": "Dosya seçilmedi", + "conflictNotSelected": "Çakışma çözümü seçilmedi" + }, + "paramError": "Parametre yanlış, lütfen kontrol edin!", + "deleteConfirm": "Toplam {count} kayıt filtrelenmiştir. Hepsini silmek istiyor musunuz?", + "migrationAlert": "İçe aktarma ve dışa aktarma özelliğini kullanarak tarayıcılar arasında verinizi aktarın", + "importError": "Yanlış dosya uzantısı" + }, + "pl": { + "totalMemoryAlert": "Przeglądarka udostępnia {size} MB do przechowywania lokalnych danych dla każdego rozszerzenia", + "totalMemoryAlert1": "Nie można określić maksymalnej ilości pamięci udostępnianej przez przeglądarkę", + "usedMemoryAlert": "{size} MB jest obecnie używanych", + "idbAlert": "Przenieś dane do IndexedDB, aby zmniejszyć zużycie pamięci", + "operationAlert": "Możesz usunąć te nieważne dane, aby zmniejszyć zużycie pamięci", + "filterItems": "Filtruj dane", + "filterFocus": "Czas przeglądania w ciągu dnia wynosi od {start} do {end} sekund", + "filterTime": "Liczba odwiedzin w ciągu dnia wynosi od {start} do {end}", + "filterDate": "Zebrane pomiędzy {picker}", + "importOther": { + "step1": "Wybierz dane", + "step2": "Potwierdź dane", + "dataSource": "Źródło danych", + "file": "Plik danych", + "selectFileBtn": "Wybierz", + "conflictType": "Rozwiązywanie konfliktów", + "conflictTip": "Co zrobić, jeśli importowane dane są sprzeczne z lokalnymi danymi", + "overwrite": "Nadpisz", + "accumulate": "Zgromadź", + "imported": "Zaimportowano", + "local": "Lokalne", + "fileNotSelected": "Nie wybrano pliku", + "conflictNotSelected": "Rozwiązywanie konfliktów nie zostało zaznaczone" + }, + "paramError": "Sprawdź poprawność parametrów!", + "deleteConfirm": "Wyfiltrowano w sumie {count} wpisów. Czy chcesz je usunąć?", + "migrationAlert": "Przenieś dane między przeglądarkami za pomocą importu i eksportu", + "importError": "Błędne rozszerzenie pliku", + "exportData": "Eksportuj dane", + "restoreData": "Przywróć dane", + "restoreFromOther": "Przywróć z {ext}" + }, + "it": { + "totalMemoryAlert": "Il browser fornisce {size}MB per memorizzare i dati locali per ogni estensione", + "totalMemoryAlert1": "Impossibile determinare la memoria massima disponibile consentita dal browser", + "usedMemoryAlert": "{size}MB è attualmente in uso", + "idbAlert": "Sposta i dati di tracciamento su IndexedDB per ridurre l'utilizzo dello storage", + "operationAlert": "È possibile eliminare i dati non importanti per ridurre l'utilizzo della memoria", + "filterItems": "Filtro dati", + "filterFocus": "L'ora di navigazione del giorno è tra {start} secondi e {end} secondi", + "filterTime": "Il numero di visite per il giorno è compreso tra {start} e {end}", + "filterDate": "Registrato tra {picker}", + "importOther": { + "step1": "Seleziona dati", + "step2": "Conferma dei dati", + "dataSource": "Origine dati", + "file": "File di dati", + "selectFileBtn": "Seleziona", + "conflictType": "Risoluzione dei conflitti", + "conflictTip": "Cosa fare se i dati importati sono in conflitto con i dati locali", + "overwrite": "Sovrascrivere", + "accumulate": "Accumulare", + "imported": "Importato", + "local": "Locali", + "fileNotSelected": "File non selezionato", + "conflictNotSelected": "Risoluzione dei conflitti non selezionata" + }, + "paramError": "Il parametro è errato, per favore controlla!", + "deleteConfirm": "Un totale di {count} record sono stati filtrati. Vuoi eliminarli tutti?", + "migrationAlert": "Migrare i dati tra browser utilizzando importazione ed esportazione", + "importError": "Estensione del file sbagliata", + "exportData": "Esporta dati", + "restoreData": "Ripristino dati", + "restoreFromOther": "Ripristina da {ext}" } } \ No newline at end of file diff --git a/src/i18n/message/app/data-manage.ts b/src/i18n/message/app/data-manage.ts index 95fc17a18..c1abbec0c 100644 --- a/src/i18n/message/app/data-manage.ts +++ b/src/i18n/message/app/data-manage.ts @@ -11,6 +11,7 @@ export type DataManageMessage = { totalMemoryAlert: string totalMemoryAlert1: string usedMemoryAlert: string + idbAlert: string operationAlert: string filterItems: string filterFocus: string @@ -33,8 +34,11 @@ export type DataManageMessage = { fileNotSelected: string conflictNotSelected: string } & { - [resolution in timer.imported.ConflictResolution]: string + [resolution in tt4b.imported.ConflictResolution]: string } + exportData: string + restoreData: string + restoreFromOther: string } const _default: Messages = resource diff --git a/src/i18n/message/app/habit-resource.json b/src/i18n/message/app/habit-resource.json index 5bf107794..54b156163 100644 --- a/src/i18n/message/app/habit-resource.json +++ b/src/i18n/message/app/habit-resource.json @@ -270,18 +270,18 @@ "focusAverage": "Tagesdurchschnitt {value}" }, "period": { - "title": "Gewohnheiten jeden Augenblick", - "busiest": "Die geschäftigste Zeit des Tages", - "idle": "Längste Leerlaufzeit", + "title": "Nutzung nach Tageszeit", + "busiest": "Aktivste Tageszeit", + "idle": "Längste Ruhezeit", "chartType": { - "average": "Täglicher Durchschnitt", + "average": "Tagesdurchschnitt", "trend": "Trend", "stack": "Stapel" }, "sizes": { "fifteen": "Pro 15 Minuten", "halfHour": "Pro halbe Stunde", - "hour": "Pro eine Stunde", + "hour": "Pro Stunde", "twoHour": "Pro zwei Stunden" } }, @@ -416,5 +416,119 @@ "siteCount": "عدد المواقع" } } + }, + "tr": { + "common": { + "focusAverage": "Günde ortalama {value}" + }, + "period": { + "title": "Zaman dilimleri alışkanlığı", + "busiest": "Günün en yoğun saati", + "idle": "En uzun bekleme süresi", + "chartType": { + "average": "Günlük ortalama", + "trend": "Eğilim", + "stack": "Yığın" + }, + "sizes": { + "fifteen": "15 dakikada bir", + "halfHour": "Yarım saat başına", + "hour": "Saat başına", + "twoHour": "İki saat başına" + } + }, + "site": { + "title": "Web sitelerinin alışkanlığı", + "histogramTitle": "En çok ziyaret edilen {n} site", + "exclusiveToday": "Bugünün verileri ortalamaya dahil edilmemiştir", + "countTotal": "Toplam ziyaret/site sayısı", + "siteAverage": "Günde ortalama {value} web sitesi ziyaret ettin", + "distribution": { + "title": "Günlük ortalama sıklık dağılımı", + "aveTime": "Günlük ortalama gezinme süresi", + "aveVisit": "Günlük ortalama ziyaret sayısı", + "tooltip": "Toplam {value} site" + }, + "trend": { + "title": "Günlük eğilim", + "siteCount": "Web sitesi sayısı" + } + } + }, + "pl": { + "common": { + "focusAverage": "Średnio {value} dziennie" + }, + "period": { + "title": "Nawyki w określonym przedziale czasowym", + "busiest": "Najbardziej aktywny czas dnia", + "idle": "Najdłuższy czas bezczynności", + "chartType": { + "average": "Średnia dzienna", + "trend": "Trend", + "stack": "Stos" + }, + "sizes": { + "fifteen": "Co 15 minut", + "halfHour": "Co pół godziny", + "hour": "Co godzinę", + "twoHour": "Co dwie godziny" + } + }, + "site": { + "title": "Nawyk stron internetowych", + "histogramTitle": "TOP {n} najczęściej odwiedzanych", + "exclusiveToday": "Dzisiejsze dane nie są uwzględniane w średniej", + "countTotal": "Całkowita liczba wizyt/stron", + "siteAverage": "Średnia liczba odwiedzin na stronach {value} dziennie", + "distribution": { + "title": "Dzienny średni rozkład częstotliwości", + "aveTime": "Średni dzienny czas przeglądania", + "aveVisit": "Średnia dzienna liczba odwiedzin", + "tooltip": "Łącznie {value} stron" + }, + "trend": { + "title": "Dzienne trendy", + "siteCount": "Liczba stron" + } + } + }, + "it": { + "common": { + "focusAverage": "{value} medio giornaliera" + }, + "period": { + "title": "Abitudine per periodi di tempo", + "busiest": "Ora più attiva della giornata", + "idle": "Periodo d'inattività più lungo", + "chartType": { + "average": "Media giornaliera", + "trend": "Tendenza", + "stack": "Stack" + }, + "sizes": { + "fifteen": "Per 15 minuti", + "halfHour": "Per mezz'ora", + "hour": "Per un'ora", + "twoHour": "Per due ore" + } + }, + "site": { + "title": "Abitudine dei siti", + "histogramTitle": "TOP {n} più visitati", + "exclusiveToday": "I dati di oggi non sono inclusi nella media", + "countTotal": "Totale visite/siti", + "siteAverage": "Visite medie a {value} siti web al giorno", + "distribution": { + "title": "Distribuzione di frequenza media giornaliera", + "aveTime": "Tempo medio di navigazione giornaliero", + "aveVisit": "Numero medio di visite giornaliere", + "tooltip": "Totale: {value} siti" + }, + "trend": { + "title": "Tendenze quotidiane", + "siteCount": "Conteggio siti web" + } + } } } \ No newline at end of file diff --git a/src/i18n/message/app/help-us-resource.json b/src/i18n/message/app/help-us-resource.json index 68956ce56..c417ed569 100644 --- a/src/i18n/message/app/help-us-resource.json +++ b/src/i18n/message/app/help-us-resource.json @@ -2,10 +2,9 @@ "zh_CN": { "title": "欢迎一起来改善本地化翻译!", "alert": { - "l1": "由于作者的语言能力,该扩展原生只支持简体中文和英语,其他语言要么缺失,要么就严重依赖机器翻译。", - "l2": "为了能够提供更好的用户体验,我将其他语言的翻译任务托管在了 Crowdin 上。Crowdin 是一个对开源软件免费的翻译管理系统。", - "l3": "如果您觉得这个扩展对您有用,并且您愿意完善它的文本翻译的话,可以点击下方按钮前往 Crowdin 上的项目主页。", - "l4": "当某种语言的翻译进度达到 50% 之后,我将会考虑在扩展中支持它。" + "l1": "为了能够提供更好的用户体验,我将除英语外的翻译任务托管在了 Crowdin 上。", + "l2": "如果您觉得这个扩展对您有用,并且您愿意完善它的文本翻译的话,可以点击下方按钮前往 Crowdin 上的项目主页。", + "l3": "当某种语言的翻译进度达到 50% 之后,我将会考虑在扩展中支持它。" }, "button": "前往 Crowdin", "loading": "正在查询翻译进度...", @@ -14,10 +13,9 @@ "en": { "title": "Feel free to help improve the extension's localization translations!", "alert": { - "l1": "Due to the author's language ability, the extension only supports Simplified Chinese and English natively, and other languages are either missing or rely heavily on machine translation.", - "l2": "In order to provide a better user experience, I host the translation tasks for other languages on Crowdin.Crowdin is a translation management system free for open source software.", - "l3": "If you find this extension useful to you and you are willing to improve its translation,you can click the button below to go to the project home page on Crowdin.", - "l4": "When the translation progress of a language reaches 50%, I will consider supporting it in this extension." + "l1": "In order to provide a better user experience, I host the translation tasks on Crowdin.", + "l2": "If you find this extension useful to you and you are willing to improve its translation, you can click the button below to go to the project home page on Crowdin.", + "l3": "When the translation progress of a language reaches 50%, I will consider supporting it in this extension." }, "button": "Go Crowdin", "loading": "Checking translation progress...", @@ -26,10 +24,9 @@ "zh_TW": { "title": "歡迎協助我們完善擴充功能的本地化翻譯!", "alert": { - "l1": "受限於開發者的語言能力,本擴充功能目前僅完整支援簡體中文和英文介面。", - "l2": "其他語言的翻譯可能尚未完成,或仍依賴機器翻譯結果。", - "l3": "若您認為此擴充功能實用且願意協助改善翻譯品質,歡迎點擊下方按鈕前往 Crowdin 翻譯平台參與貢獻。", - "l4": "當任一語言的翻譯進度達 50% 以上時,我們將會在正式版本中新增支援。" + "l1": "為了提供更好的使用者體驗,我將翻譯任務託管在 Crowdin 上。", + "l2": "如果您覺得此擴充功能對您有幫助,且願意協助改善其翻譯,可以點擊下方按鈕前往 Crowdin 上的專案首頁。", + "l3": "當某種語言的翻譯進度達到 50% 時,我會考慮在本擴充功能中支援該語言。" }, "button": "前往 Crowdin 平台", "loading": "正在載入翻譯進度...", @@ -38,10 +35,9 @@ "uk": { "title": "Допоможіть поліпшити переклад розширення!", "alert": { - "l1": "У зв'язку з мовою автора розширення, підтримуються лише спрощена китайська та англійська мови. Інші мови наразі недоступні або потребують перекладу.", - "l2": "Щоб інші користувачі могли зручно користуватися розширенням своєю мовою, переклад можна зробити на платформі Crowdin.", - "l3": "Якщо вам подобається розширення і ви бажаєте поліпшити його переклад, натисніть кнопку нижче, щоб відкрити сторінку проєкту на Crowdin.", - "l4": "Коли прогрес перекладу сягне 50%, цю мову буде активовано в розширенні." + "l1": "Для забезпечення доступності для ширшого кола користувачів я пропоную можливість перекладу на Crowdin.", + "l2": "Якщо вам корисне це розширення і ви бажаєте покращити його переклад, натисніть кнопку нижче, щоб перейти на домашню сторінку проєкту на Crowdin.", + "l3": "Коли прогрес перекладу мови досягне 50%, я розгляну можливість її активації в цьому розширенні." }, "button": "Перейти на Crowdin", "loading": "Перевірка перекладу...", @@ -50,10 +46,9 @@ "es": { "title": "¡Siéntete libre de ayudar a mejorar las traducciones de la extensión!", "alert": { - "l1": "Debido a la capacidad lingüística del autor, la extensión sólo soporta el chino simplificado y el inglés de manera nativa y otros idiomas no se encuentran o dependen en gran medida de la traducción automática.", - "l2": "Con el fin de ofrecer una mejor experiencia a los usuarios, guardo las tareas de traducción de otros idiomas en Crowdin. Crowdin es un sistema de administración de traducciones gratuito para software de código abierto.", - "l3": "Si esta extensión te resulta útil y estás deseas mejorar su traducción, puedes dar clic en el botón de abajo para ir a la página principal del proyecto en Crowdin.", - "l4": "Cuando el progreso de traducción de un idioma llegue a 50%, consideraré añadirlo a esta extensión." + "l1": "Para ofrecer una mejor experiencia de usuario, alojo las tareas de traducción en Crowdin.", + "l2": "Si consideras útil esta extensión y deseas mejorar su traducción, puedes hacer clic en el botón de abajo para ir a la página del proyecto en Crowdin.", + "l3": "Cuando el progreso de traducción de un idioma alcance el 50%, consideraré incluirlo en esta extensión." }, "button": "Ir a Crowdin", "loading": "Comprobando el progreso de la traducción...", @@ -62,10 +57,9 @@ "de": { "title": "Hilf mit die Übersetzungen der Erweiterung zu verbessern!", "alert": { - "l1": "Aufgrund der Sprachfähigkeiten des Autors unterstützt die Erweiterung nur vereinfachtes Chinesisch und Englisch nativ, während andere Sprachen fehlen oder stark auf maschinelle Übersetzung angewiesen sind.", - "l2": "Um eine bessere Benutzererfahrung zu ermöglichen, habe ich die Übersetzungen für andere Sprachen auf Crowdin ausgelagert. Crowdin ist ein Übersetzungsmanagementsystem, das für Open-Source-Software kostenlos ist.", - "l3": "Wenn du diese Erweiterung nützlich für dich findest, und bereit bist, die Übersetzung zu verbessern, klicke auf die Schaltfläche unten, um zur Projektseite auf Crowdin zu gelangen.", - "l4": "Wenn der Übersetzungsfortschritt einer Sprache 50% erreicht, werde ich die Unterstützung in dieser Erweiterung erwägen." + "l1": "Um ein besseres Benutzererlebnis zu bieten, hoste ich die Übersetzungsaufgaben auf Crowdin.", + "l2": "Wenn Sie diese Erweiterung für nützlich halten und zur Verbesserung der Übersetzung beitragen möchten, können Sie auf die Schaltfläche unten klicken, um zur Projektseite auf Crowdin zu gelangen.", + "l3": "Sobald der Übersetzungsfortschritt einer Sprache 50 % erreicht, werde ich erwägen, sie in dieser Erweiterung zu unterstützen." }, "button": "Zu Crowdin gehen", "loading": "Übersetzungsfortschritt wird überprüft...", @@ -74,10 +68,9 @@ "fr": { "title": "N'hésitez pas à aider à améliorer la traduction de l'extension !", "alert": { - "l1": "En raison des compétences linguistiques de l'auteur, l'extension ne prend en charge que le chinois simplifié et l'anglais en mode natif, les autres langues étant soit absentes, soit fortement tributaires de la traduction automatique.", - "l2": "Afin de fournir une meilleure expérience d'utilisateur, j'héberge les tâches de traduction pour d'autres langues sur Crowdin. rowdin est un système de gestion de la traduction gratuit pour les logiciels open source.", - "l3": "Si vous trouvez cette extension utile pour vous et que vous êtes prêt à améliorer sa traduction, ou peut cliquer sur le bouton ci-dessous pour aller à la page d'accueil du projet sur Crowdin.", - "l4": "Lorsque la progression de la traduction d'une langue atteindra 50 %, j'envisagerai de la soutenir dans cette extension." + "l1": "Afin d’offrir une meilleure expérience utilisateur, j’héberge les tâches de traduction sur Crowdin.", + "l2": "Si vous trouvez cette extension utile et que vous souhaitez améliorer sa traduction, vous pouvez cliquer sur le bouton ci-dessous pour accéder à la page du projet sur Crowdin.", + "l3": "Lorsqu’une langue atteint un taux de traduction de 50 %, j’envisagerai de la prendre en charge dans cette extension." }, "button": "Allez sur Crowdin", "loading": "Vérification de la progression de la traduction...", @@ -86,10 +79,9 @@ "ja": { "title": "拡張機能のローカライズ翻訳を改善するために自由にご協力ください!", "alert": { - "l1": "著者の言語能力のために、拡張機能はネイティブに簡体字中国語と英語のみをサポートしています。 他の言語は欠落しているか機械翻訳に大きく依存しています", - "l2": "より良いユーザー エクスペリエンスを提供するために、他の言語の翻訳タスクを Crowdin でホストしています。Crowdin は、オープン ソース ソフトウェアの無料翻訳管理システムです。", - "l3": "この拡張機能があなたにとって有用であり、翻訳を改善したいと考えている場合。 下のボタンをクリックすると、Crowdinのプロジェクトのホームページに移動できます。", - "l4": "言語の翻訳進捗率が50%に達したら、この拡張機能をサポートすることを検討します。" + "l1": "より良いユーザー体験を提供するため、翻訳タスクをCrowdin上でホストしています。", + "l2": "この拡張機能が役に立ち、翻訳の改善にご協力いただける場合は、下のボタンをクリックしてCrowdin上のプロジェクトページへアクセスできます。", + "l3": "ある言語の翻訳進捗が50%に達した場合、その言語をこの拡張機能でサポートすることを検討します。" }, "button": "Crowdin に移動", "loading": "翻訳の進捗を確認しています...", @@ -98,10 +90,9 @@ "pt_PT": { "title": "Ajude a melhorar as traduções da extensão!", "alert": { - "l1": "Devido às limitações linguísticas do autor, esta extensão só suporta Chinês Simplificado e Inglês nativamente. Outras línguas podem estar incompletas ou conter traduções automáticas.", - "l2": "Para melhorar a experiência de todos, disponibilizei as traduções no Crowdin - um sistema de gestão de traduções gratuito para software open source.", - "l3": "Se esta extensão lhe for útil e quiser ajudar a melhorar as traduções, clique no botão abaixo para aceder à página do projeto no Crowdin.", - "l4": "Quando uma língua atingir 50% de tradução, considerarei adicionar suporte nativo na extensão." + "l1": "De modo a proporcionar uma melhor experiência ao utilizador, alojo as tarefas de tradução na Crowdin.", + "l2": "Se considerar que esta extensão lhe é útil e quiser melhorar a sua tradução, pode clicar no botão abaixo para aceder à página inicial do projeto na Crowdin.", + "l3": "Quando o progresso da tradução de um idioma atingir os 50%, considerarei apoiá-lo nesta extensão." }, "button": "Ir para o Crowdin", "loading": "A verificar progresso das traduções...", @@ -110,10 +101,9 @@ "ru": { "title": "Не стесняйтесь помочь улучшить локализацию расширений!", "alert": { - "l1": "Из-за возможности автора, расширение поддерживает только упрощенный китайский и английский языки, и другие языки либо отсутствуют, либо сильно зависят от машинного перевода.", - "l2": "Чтобы обеспечить лучший пользовательский опыт, я размещаю задачи перевода для других языков на Crowdin. rowdin - это бесплатная система управления переводами для программного обеспечения с открытым исходным кодом.", - "l3": "Если вы считаете это расширение полезным для вас и вы готовы улучшить его перевод, ou можете нажать на кнопку ниже, чтобы перейти на домашнюю страницу проекта на Crowdin.", - "l4": "Когда прогресс перевода языка достигает 50%, я подумаю о его поддержке в этом расширении." + "l1": "Чтобы обеспечить лучший пользовательский опыт, я размещаю задачи по переводу на Crowdin.", + "l2": "Если вам полезно это расширение и вы хотите улучшить его перевод, вы можете нажать кнопку ниже, чтобы перейти на домашнюю страницу проекта на Crowdin.", + "l3": "Когда прогресс перевода языка достигнет 50%, я рассмотрю возможность его поддержки в этом расширении." }, "button": "Перейти в Crowdin", "loading": "Проверка перевода...", @@ -122,13 +112,45 @@ "ar": { "title": "ساهم في تحسين ترجمة هذه الإضافة!", "alert": { - "l1": "نظرًا لقدرات المطور اللغوية، يدعم هذا الامتداد حاليا اللغة الصينية والإنجليزية فقط. اللغات الأخرى إما غير مدعومة أو تعتمد بشكل كبير على الترجمة الآلية.", - "l2": "لتوفير تجربة مستخدم أفضل، نقوم باستضافة مهام الترجمة للغات الأخرى على منصة Crowdin، نظام إدارة ترجمة مجاني للبرمجيات المفتوحة المصدر.", - "l3": "إذا وجدت هذه الإضافة مفيدة لك وترغب في تحسين ترجمتها، يمكنك النقر على الزر أدناه للذهاب إلى صفحة المشروع على Crowdin.", - "l4": "عند وصول تقدم ترجمة لغة معينة إلى 50%، سنقوم بدعمها رسميًا في هذا الامتداد." + "l1": "من أجل تقديم تجربة مستخدم أفضل، أقوم باستضافة مهام الترجمة على Crowdin.", + "l2": "إذا وجدت هذا الامتداد مفيدًا لك وكنت راغبًا في تحسين ترجمته، يمكنك النقر على الزر أدناه للانتقال إلى الصفحة الرئيسية للمشروع على Crowdin.", + "l3": "عندما يصل تقدم ترجمة لغة ما إلى 50%، سأفكر في دعمها في هذا الامتداد." }, "button": "الذهاب إلى Crowdin", "loading": "التحقق من تقدم الترجمة...", "contributors": "قائمة المساهمين" + }, + "tr": { + "title": "Uzantının yerelleştirme çevirilerini iyileştirmeye yardımcı olmaktan çekinmeyin!", + "alert": { + "l1": "Daha iyi bir kullanıcı deneyimi sunmak amacıyla, çeviri görevlerini Crowdin üzerinde barındırıyorum.", + "l2": "Bu eklentiyi sizin için faydalı buluyorsanız ve çevirisini geliştirmek istiyorsanız, Crowdin'deki proje ana sayfasına gitmek için aşağıdaki butona tıklayabilirsiniz.", + "l3": "Bir dilin çeviri ilerlemesi %50'ye ulaştığında, bu eklentide ilgili dili desteklemeyi değerlendireceğim." + }, + "button": "Crowdin'e git", + "loading": "Çevirinin ilerleyişi kontrol ediliyor...", + "contributors": "Katkıda Bulunanlar" + }, + "pl": { + "title": "Możesz pomóc w ulepszaniu tłumaczeń tego rozszerzenia!", + "alert": { + "l1": "W celu zapewnienia lepszego doświadczenia użytkownikom hostuję pliki do przetłumaczenia na platformie Crowdin.", + "l2": "Jeżeli uważasz, że to rozszerzenie jest dla Ciebie przydatne i chciałbyś poprawić jego tłumaczenie, kliknij przycisk poniżej, aby przejść do strony głównej projektu na Crowdin.", + "l3": "Kiedy postęp w tłumaczeniu języka osiągnie 50%, rozważę poparcie go w tym rozszerzeniu." + }, + "button": "Przejdź do Crowdin", + "loading": "Sprawdzanie postępu tłumaczenia...", + "contributors": "Lista współtwórców" + }, + "it": { + "title": "Sentitevi liberi di contribuire a migliorare le traduzioni di localizzazione dell'estensione!", + "alert": { + "l1": "Al fine di fornire una migliore esperienza utente, ospito le attività di traduzione su Crowdin.", + "l2": "Se trovate questa estensione utile per voi e siete disposti a migliorare la sua traduzione, è possibile fare clic sul pulsante qui sotto per andare alla home page del progetto su Crowdin.", + "l3": "Quando il progresso della traduzione di una lingua raggiunge il 50 per cento, considererò di sostenerla in questa estensione." + }, + "button": "Vai A Crowdin", + "loading": "Controllo avanzamento traduzione...", + "contributors": "Lista Contributori" } } \ No newline at end of file diff --git a/src/i18n/message/app/help-us.ts b/src/i18n/message/app/help-us.ts index 0a3ed59a0..4cbf4fc9b 100644 --- a/src/i18n/message/app/help-us.ts +++ b/src/i18n/message/app/help-us.ts @@ -11,7 +11,6 @@ type _AlertLine = | 'l1' | 'l2' | 'l3' - | 'l4' export type HelpUsMessage = { title: string diff --git a/src/i18n/message/app/index.ts b/src/i18n/message/app/index.ts index 77cb8a8a6..da6e63755 100644 --- a/src/i18n/message/app/index.ts +++ b/src/i18n/message/app/index.ts @@ -5,12 +5,12 @@ * https://opensource.org/licenses/MIT */ -import buttonMessages, { type ButtonMessage } from "@i18n/message/common/button" -import calendarMessages, { type CalendarMessage } from "@i18n/message/common/calendar" -import itemMessages, { type ItemMessage } from "@i18n/message/common/item" -import metaMessages, { type MetaMessage } from "@i18n/message/common/meta" -import sharedMessages, { type SharedMessage } from "@i18n/message/common/shared" import baseMessages, { type BaseMessage } from "../common/base" +import buttonMessages, { type ButtonMessage } from "../common/button" +import calendarMessages, { type CalendarMessage } from "../common/calendar" +import itemMessages, { type ItemMessage } from "../common/item" +import metaMessages, { type MetaMessage } from "../common/meta" +import sharedMessages, { type SharedMessage } from '../common/shared' import limitModalMessages, { type ModalMessage } from "../cs/modal" import { merge, type MessageRoot } from "../merge" import aboutMessages, { type AboutMessage } from "./about" @@ -21,22 +21,20 @@ import habitMessages, { type HabitMessage } from "./habit" import helpUsMessages, { type HelpUsMessage } from "./help-us" import limitMessages, { type LimitMessage } from "./limit" import menuMessages, { type MenuMessage } from "./menu" -import mergeRuleMessages, { type MergeRuleMessage } from "./merge-rule" import operationMessages, { type OperationMessage } from './operation' import optionMessages, { type OptionMessage } from "./option" -import reportMessages, { type ReportMessage } from "./report" +import recordMessages, { type RecordMessage } from "./record" +import ruleMessages, { type RuleMessage } from "./rule" import siteManageManages, { type SiteManageMessage } from "./site-manage" import timeFormatMessages, { type TimeFormatMessage } from "./time-format" -import whitelistMessages, { type WhitelistMessage } from "./whitelist" export type AppMessage = { about: AboutMessage dataManage: DataManageMessage item: ItemMessage shared: SharedMessage - report: ReportMessage - whitelist: WhitelistMessage - mergeRule: MergeRuleMessage + record: RecordMessage + rule: RuleMessage option: OptionMessage analysis: AnalysisMessage menu: MenuMessage @@ -59,9 +57,8 @@ const MESSAGE_ROOT: MessageRoot = { dataManage: dataManageMessages, item: itemMessages, shared: sharedMessages, - report: reportMessages, - whitelist: whitelistMessages, - mergeRule: mergeRuleMessages, + record: recordMessages, + rule: ruleMessages, option: optionMessages, analysis: analysisMessages, menu: menuMessages, diff --git a/src/i18n/message/app/limit-resource.json b/src/i18n/message/app/limit-resource.json index 381a39a42..6f630036a 100644 --- a/src/i18n/message/app/limit-resource.json +++ b/src/i18n/message/app/limit-resource.json @@ -1,23 +1,19 @@ { "zh_CN": { - "filterDisabled": "过滤无效规则", + "onlyEffective": "仅生效中", "wildcardTip": "您可以使用通配符来匹配子域名或子页面,使用\"+\"作为前缀来排除子页面!", + "emptyTips": "点击这里创建一条规则!", "item": { "name": "规则名称", "condition": "限制网址", - "daily": "每日上限", - "weekly": "每周上限", "weekStartInfo": "每周的第一天是【{weekStart}】,你可以在统计选项中修改该值", "delayCount": "延时次数", "detail": "规则详情", "visitTime": "单次访问最长时间", - "period": "不可访问的时间段", "enabled": "启用", "locked": "锁定", "effectiveDay": "生效日期", - "delayAllowed": "可延时", - "delayAllowedInfo": "上网时间超过限制时,点击【再看 5 分钟】短暂延时。如果关闭该功能则不能延时。", - "visits": "次访问", + "allowDelay": "可延时", "or": "或者", "notEffective": "未生效" }, @@ -27,17 +23,14 @@ "rule": "设置规则" }, "button": { - "test": "网址测试", - "option": "全局设置" + "test": "网址测试" }, "message": { - "noUrl": "未配置限制网址", + "noUrl": "未添加任何限制网址", "noRule": "未填写任何规则", "deleteConfirm": "是否要删除规则 [{name}]?", "lockConfirm": "锁定后,即使未触发此规则,所有操作也需要验证", - "noPermissionFirefox": "请先在插件管理页[about:addons]开启该插件的粘贴板权限", "inputTestUrl": "请先输入需要测试的网址链接", - "clickTestButton": "输入完成后请点击【{buttonText}】按钮", "noRuleMatched": "该网址未命中任何规则", "rulesMatched": "该网址命中以下规则:", "timeout": "倒计时已结束 XD" @@ -49,30 +42,27 @@ "strictTip": "时限规则已触发,不允许手动解锁!", "incorrectPsw": "密码错误", "incorrectAnswer": "回答错误", + "twoFaInputTip": "规则已被触发或锁定。请从您的身份验证程序中输入6位数字代码以继续。", + "incorrect2fa": "2FA 验证码错误", "pi": "圆周率 π 的小数部分第 {startIndex} 位到第 {endIndex} 位的共 {digitCount} 位数字", "confession": "一寸光阴一寸金,寸金难买寸光阴" }, "reminder": "距离时间限制不到 {min} 分钟!" }, "zh_TW": { - "filterDisabled": "過濾無效規則", "wildcardTip": "你可以使用萬用字元來匹配子網域或子頁面,使用\"+\"作為前置詞來排除子頁面!", + "emptyTips": "點擊此處新增規則!", "item": { "name": "規則名稱", "condition": "限制網址", - "daily": "每日上限", - "weekly": "每週上限", "weekStartInfo": "每週起始日為「{weekStart}」,您可於統計設定中調整", "delayCount": "延遲次數", "detail": "規則詳細內容", "visitTime": "單次造訪時長限制", - "period": "限制時段", "enabled": "是否啟用", "locked": "已鎖定", "effectiveDay": "生效日期", - "delayAllowed": "允許延遲", - "delayAllowedInfo": "當使用時間超過限制時,可點擊【再看5分鐘】暫時延長。若關閉此功能則無法延時。", - "visits": "次造訪", + "allowDelay": "允許延遲", "or": "或", "notEffective": "未生效" }, @@ -90,7 +80,6 @@ "deleteConfirm": "確定要刪除規則「{name}」嗎?", "lockConfirm": "如果鎖定,所有操作都需要驗證,即使規則未觸發也一樣。", "inputTestUrl": "請輸入要測試的網址", - "clickTestButton": "輸入完成後請點擊【{buttonText}】按鈕", "noRuleMatched": "此網址未符合任何規則", "rulesMatched": "此網址符合以下規則:", "timeout": "時間到啦!XDDD" @@ -108,26 +97,23 @@ "reminder": "距離時間限制僅剩 {min} 分鐘!" }, "en": { - "filterDisabled": "Only enabled", + "onlyEffective": "Only Effective", "wildcardTip": "You can use wildcards to match subdomains or subpages, and use \"+\" as a prefix to exclude subpages!", + "emptyTips": "Click here to create one rule!", "item": { "name": "Rule name", "condition": "Restricted URL", - "daily": "Daily limit", - "weekly": "Weekly limit", - "weekStartInfo": "The first day of each week is {weekStart}, you can change this value in the statistics options", + "weekStartInfo": "The first day of each week is {weekStart}, you can change this value in the tracking options", "delayCount": "Delay count", "detail": "Rule detail", "visitTime": "Time limit per visit", - "period": "Blocked periods", "enabled": "Enabled", "locked": "Locked", "effectiveDay": "Effective On", - "delayAllowed": "Delayable", - "delayAllowedInfo": "If it times out, allow a temporary delay of 5 minutes", - "visits": "visits", + "allowDelay": "Allow Delay", "or": "or", - "notEffective": "Not effective" + "notEffective": "Not effective", + "unlimited": "Unlimited" }, "step": { "base": "Basic Information", @@ -138,12 +124,11 @@ "test": "Test URL" }, "message": { - "noUrl": "No restriction URLs configured", + "noUrl": "No URL added", "noRule": "No rules filled in", "deleteConfirm": "Do you want to delete the rule [{name}]?", "lockConfirm": "If locked, all operations will require verification even if the rule is not triggered.", "inputTestUrl": "Please enter the URL link to be tested first", - "clickTestButton": "After inputting, please click the button ({buttonText})", "noRuleMatched": "The URL does not hit any rules", "rulesMatched": "The URL hits the following rules:", "timeout": "Time is up! XD" @@ -155,29 +140,26 @@ "strictTip": "Triggered, no operation is allowed before release!", "incorrectPsw": "Incorrect password", "incorrectAnswer": "Incorrect answer", + "twoFaInputTip": "The rule has been triggered or locked. Enter the 6-digit code from your authenticator app to continue.", + "incorrect2fa": "Incorrect 2FA code", "pi": "{digitCount} digits from {startIndex} to {endIndex} of the decimal part of π", "confession": "Time is fleeting" }, "reminder": "Less than {min} minutes until the time limit!" }, "ja": { - "filterDisabled": "有效", "wildcardTip": "ワイルドカードを使用してサブドメインまたはサブページに一致させることができ、「+」をプレフィックスとしてサブページを除外することができます!", + "emptyTips": "ここをクリックしてルールを一つ作成!", "item": { "name": "規則名", "condition": "制限 URL", - "daily": "1日の限度", - "weekly": "週間の限度", "weekStartInfo": "各週の最初の日は「{weekStart}」, 統計オプションでこの値を変更することができます", "delayCount": "遅延回数", "detail": "規則明細", "visitTime": "訪問ごとの制限", - "period": "許可されない期間", "enabled": "有效", + "locked": "ロック済み", "effectiveDay": "発効日", - "delayAllowed": "さらに5分間閲覧する", - "delayAllowedInfo": "時間が経過した場合は、一時的に5分遅らせることができます", - "visits": "訪問数", "or": "や", "notEffective": "効果がない" }, @@ -194,9 +176,9 @@ "noRule": "ルールが記入されていません", "deleteConfirm": "ルール [{name}] を削除しますか?", "inputTestUrl": "最初にテストする URL リンクを入力してください", - "clickTestButton": "入力後、ボタン({buttonText})をクリックしてください", "noRuleMatched": "URL がどのルールとも一致しません", - "rulesMatched": "URL は次のルールに一致します。" + "rulesMatched": "URL は次のルールに一致します。", + "timeout": "時間切れです! XD" }, "verification": { "inputTip": "ルールがトリガーされたかロックされました。続行するには、次の質問に対する回答を {second} 秒以内に入力してください: {prompt}", @@ -211,24 +193,19 @@ "reminder": "制限時間まで {min} 分未満!" }, "pt_PT": { - "filterDisabled": "Apenas ativos", "wildcardTip": "Pode usar wildcards para corresponder a subdomínios ou subpáginas, e usar o \"+\" como prefixo para excluir subpáginas!", + "emptyTips": "Clica aqui para criar uma regra!", "item": { "name": "Nome da regra", "condition": "URL restrito", - "daily": "Limite diário", - "weekly": "Limite semanal", "weekStartInfo": "O primeiro dia da semana é {weekStart}. Pode alterar nas opções de estatísticas", "delayCount": "Atrasos permitidos", "detail": "Detalhe da regra", "visitTime": "Tempo limite por visita", - "period": "Períodos bloqueados", "enabled": "Ativo", "locked": "Bloqueado", "effectiveDay": "Dias de aplicação", - "delayAllowed": "Permitir atraso", - "delayAllowedInfo": "Se expirar, permite um atraso temporário de 5 minutos", - "visits": "visitas", + "allowDelay": "Permitir atraso", "or": "ou", "notEffective": "Não aplicável" }, @@ -246,7 +223,6 @@ "deleteConfirm": "Eliminar a regra [{name}]?", "lockConfirm": "Se bloqueado, exigirá verificação mesmo sem ser acionado", "inputTestUrl": "Introduza primeiro o URL a testar", - "clickTestButton": "Clique no botão ({buttonText}) após introduzir", "noRuleMatched": "O URL não corresponde a nenhuma regra", "rulesMatched": "O URL corresponde às seguintes regras:", "timeout": "Tempo esgotado! XD" @@ -264,24 +240,22 @@ "reminder": "Menos de {min} minutos até ao limite!" }, "uk": { - "filterDisabled": "Лише увімкнені", + "onlyEffective": "Лише дійсні", + "wildcardTip": "Можна використовувати символи підставлення для пошуку піддоменів або підсторінок, а \"+\" як префікс для виключення підсторінки!", + "emptyTips": "Натисніть тут, щоб створити одне правило!", "item": { "name": "Назва правила", "condition": "Обмежена URL-адреса", - "daily": "Денний ліміт", - "weekly": "Тижневий ліміт", "weekStartInfo": "Перший день кожного тижня {weekStart}. Ви можете змінити це значення у налаштуваннях статистики", "delayCount": "Лічильник затримок", "detail": "Подробиці правила", "visitTime": "Ліміт на відвідування", - "period": "Недозволені періоди", "enabled": "Увімкнено", + "locked": "Заблоковано", "effectiveDay": "Діє", - "delayAllowed": "Ще 5 хвилин", - "delayAllowedInfo": "Якщо час вичерпано, дозволити тимчасову затримку 5 хвилин", - "visits": "візитів", + "allowDelay": "Дозволити затримку", "or": "або", - "notEffective": "Неефективний" + "notEffective": "Не діє" }, "step": { "base": "Загальна інформація", @@ -294,41 +268,39 @@ "message": { "noUrl": "Не заповнена обмежена URL-адреса", "noRule": "Не заповнено жодного правила", - "deleteConfirm": "Ви дійсно хочете видалити правило {cond}?", + "deleteConfirm": "Ви хочете видалити правило {name}?", + "lockConfirm": "Якщо заблоковано, всі операції потребують перевірки, навіть якщо правило не спрацьовує.", "inputTestUrl": "Спочатку введіть URL-адресу посилання для перевірки", - "clickTestButton": "Після введення натисніть кнопку ({buttonText})", "noRuleMatched": "URL не відповідає жодному правилу", - "rulesMatched": "URL-адреса отримує такі правила:" + "rulesMatched": "URL-адреса отримує такі правила:", + "timeout": "Час вийшов! XD" }, "verification": { + "inputTip": "Правило було ініційовано або заблоковано. Щоб продовжити, введіть відповідь на зазначене запитання протягом {second} секунд: {prompt}", + "inputTip2": "Правило було ініційовано або заблоковано. Щоб продовжити, введіть зазначене протягом {second} секунд: {answer}", "pswInputTip": "Правило обмеження вже було запущено. Щоб продовжити, введіть пароль", "strictTip": "Правило обмеження вже активовано і ручне розблокування не дозволено!", "incorrectPsw": "Неправильний пароль", "incorrectAnswer": "Неправильна відповідь", + "twoFaInputTip": "Правило було ініційовано або заблоковано. Щоб продовжити, введіть код із 6 цифр зі своєї програми автентифікації.", + "incorrect2fa": "Неправильний код 2FA", "pi": "{digitCount} цифр від {startIndex} до {endIndex} десяткової частини числа π", "confession": "Час спливає" }, - "reminder": "Менше ніж {min} хвилин до ліміту часу!" + "reminder": "Менш як {min} хв до ліміту часу!" }, "es": { - "filterDisabled": "Solo habilitados", "wildcardTip": "¡Puedes usar comodines para coincidir con subdominios o subpáginas, y usar el \"+\" como prefijo para excluir subpáginas!", "item": { "name": "Nombre de la regla", "condition": "URL restringida", - "daily": "Límite diario", - "weekly": "Límite semanal", "weekStartInfo": "El primer día de cada semana es {weekStart}, puedes cambiar este valor en las opciones de estadísticas", - "delayCount": "Contagem de atraso", + "delayCount": "Contador de retraso", "detail": "Detalle de la regla", "visitTime": "Límite por visita", - "period": "Periodos no permitidos", "enabled": "Habilitado", "locked": "Bloqueado", "effectiveDay": "En vigor él", - "delayAllowed": "Más de 5 minutos", - "delayAllowedInfo": "Si se pausa, permite un retraso temporal de 5 minutos", - "visits": "visitas", "or": "o", "notEffective": "No efectivo" }, @@ -343,10 +315,9 @@ "message": { "noUrl": "URL restringida sin completar", "noRule": "No hay reglas llenadas", - "deleteConfirm": "¿Deseas eliminar la regla de {cond}?", + "deleteConfirm": "¿Deseas eliminar la regla dé {name}?", "lockConfirm": "Si está bloqueado, todas las operaciones requerirán verificación incluso si no se activa la regla.", "inputTestUrl": "Por favor, introduce primero el enlace URL a ser probado", - "clickTestButton": "Después de ingresarla, haz clic en el botón ({buttonText})", "noRuleMatched": "La URL no sigue ninguna regla", "rulesMatched": "La URL sigue las siguientes reglas:", "timeout": "¡Tiempo se acabó! XD" @@ -364,23 +335,20 @@ "reminder": "¡Menos de {min} minutos hasta el límite de tiempo!" }, "de": { - "filterDisabled": "Nur Aktivierte", + "onlyEffective": "Nur wirksam", + "wildcardTip": "Sie können Platzhalter verwenden, um Subdomains oder Unterseiten zuzuordnen, und \"+\" als Präfix verwenden, um Unterseiten auszuschließen!", + "emptyTips": "Klicke hier, um eine Regel zu erstellen!", "item": { "name": "Regelname", "condition": "Eingeschränkte URL", - "daily": "Tägliches Limit", - "weekly": "Wöchentliches Limit", "weekStartInfo": "Der erste Tag jeder Woche ist {weekStart}, Sie können diesen Wert in den Statistikoptionen ändern", "delayCount": "Anzahl der Verspätungen", "detail": "Regeldetail", "visitTime": "Limit pro Besuch", - "period": "Unzulässiger Zeitraum", "enabled": "Aktiviert", "locked": "Gesperrt", "effectiveDay": "Wirksam auf", - "delayAllowed": "Weitere 5 Minuten", - "delayAllowedInfo": "Wenn es zu einer Zeitüberschreitung kommt, erlauben Sie eine vorübergehende Verzögerung von 5 Minuten", - "visits": "Besuche", + "allowDelay": "Verzögerung zulassen", "or": "oder", "notEffective": "Nicht wirksam" }, @@ -390,7 +358,7 @@ "rule": "Regel konfigurieren" }, "button": { - "test": "Test-URL" + "test": "URL testen" }, "message": { "noUrl": "Nicht ausgefüllte eingeschränkte URL", @@ -398,7 +366,6 @@ "deleteConfirm": "Möchten Sie die Regel [{name}] löschen?", "lockConfirm": "Wenn gesperrt, müssen alle Operationen geprüft werden, auch wenn die Regel nicht ausgelöst wird.", "inputTestUrl": "Bitte geben Sie zunächst den zu testenden URL-Link ein", - "clickTestButton": "Klicken Sie nach der Eingabe bitte auf die Schaltfläche ({buttonText}).", "noRuleMatched": "Die URL entspricht keinen Regeln", "rulesMatched": "Die URL erfüllt die folgenden Regeln:", "timeout": "Zeit ist abgelaufen! XD" @@ -410,30 +377,28 @@ "strictTip": "Die Limit-Regel wurde bereits ausgelöst und eine manuelle Entsperrung ist nicht zulässig!", "incorrectPsw": "Falsches Passwort", "incorrectAnswer": "Falsche Antwort", + "twoFaInputTip": "Die Regel wurde ausgelöst oder gesperrt. Geben Sie den 6-stelligen Code aus Ihrer Authentifizierungs-App ein, um fortzufahren.", + "incorrect2fa": "Falscher 2FA-Code", "pi": "{digitCount} Ziffern von {startIndex} bis {endIndex} des Dezimalteils von π", "confession": "Zeit vergeht" }, "reminder": "Weniger als {min} Minuten bis zum Zeitlimit!" }, "fr": { - "filterDisabled": "Activé uniquement", - "wildcardTip": "Vous pouvez utiliser des wildcards pour correspondre à des sous-domaines ou sous-pages, et utiliser le \"+\" comme préfixe pour exclure des sous-pages !", + "onlyEffective": "Seulement en vigueur", + "wildcardTip": "Vous pouvez utiliser des caractères génériques pour cibler des sous-domaines ou des sous-pages, et le préfixe \"+\" pour en exclure !", + "emptyTips": "Cliquez ici pour créer une règle !", "item": { "name": "Nom de règle", "condition": "URL restreinte", - "daily": "Limite quotidienne", - "weekly": "Limite hebdomadaire", "weekStartInfo": "Le premier jour de chaque semaine est {weekStart}, vous pouvez modifier cette valeur dans les options de statistiques", "delayCount": "Nombre de retards", "detail": "Détail des règles", "visitTime": "Limite par visite", - "period": "Périodes bloquées", "enabled": "Activé", "locked": "Verrouillé", "effectiveDay": "Effectif le", - "delayAllowed": "Plus de 5 minutes", - "delayAllowedInfo": "Si le délai est écoulé, autorisez un délai temporaire de 5 minutes", - "visits": "visites", + "allowDelay": "Retardable", "or": "ou", "notEffective": "Non efficace" }, @@ -448,10 +413,9 @@ "message": { "noUrl": "Aucune URL de restriction configurée", "noRule": "Aucune règle remplie", - "deleteConfirm": "Voulez-vous supprimer la règle [{name}]?", + "deleteConfirm": "Voulez-vous supprimer la règle [{name}] ?", "lockConfirm": "Si verrouillé, toutes les opérations nécessiteront une vérification, même si la règle n'est pas activée.", "inputTestUrl": "Veuillez entrer le lien URL à tester en premier", - "clickTestButton": "Après l'entrée, cliquez sur le bouton ({buttonText})", "noRuleMatched": "L'URL ne correspond à aucune règle", "rulesMatched": "L'URL atteint les règles suivantes :", "timeout": "Temps écoulé ! XD" @@ -463,26 +427,30 @@ "strictTip": "La règle de limite a déjà été déclenchée et le déverrouillage manuel n'est pas autorisé !", "incorrectPsw": "Mot de passe incorrect", "incorrectAnswer": "Réponse incorrecte", + "twoFaInputTip": "La règle a été déclenchée ou verrouillée. Entrez le code à 6 chiffres de votre application d'authentification pour continuer.", + "incorrect2fa": "Code d’authentification à deux facteurs incorrect", "pi": "{digitCount} chiffres de {startIndex} à {endIndex} de la partie décimale de π", "confession": "Le temps, c'est de l'argent" }, "reminder": "Moins de {min} minutes jusqu'à la limite de temps !" }, "ru": { - "filterDisabled": "Только включен", + "onlyEffective": "Только эффективный режим", + "wildcardTip": "Вы можете использовать шаблоны для совпадения с поддоменами или подстраницами, а также использовать «+» в качестве префикса, чтобы исключить подстраницы!", + "emptyTips": "Нажмите здесь, чтобы создать новое правило!", "item": { "name": "Имя правила", "condition": "Ограниченный URL", - "weekly": "Недельный лимит", "weekStartInfo": "Первый день каждой недели {weekStart}, вы можете изменить это значение в настройках статистики", "delayCount": "Отложенный", "detail": "Детали правила", "visitTime": "Лимит за посещение", - "period": "Заблокированное время", "enabled": "Включено", + "locked": "Заблокировано", "effectiveDay": "Эффективный", - "delayAllowed": "Еще 5 минут", - "delayAllowedInfo": "Если время истекло, разрешите временную задержку 5 минут" + "allowDelay": "Разрешить задержку", + "or": "или", + "notEffective": "Не активировано" }, "step": { "base": "Основная информация", @@ -496,10 +464,11 @@ "noUrl": "Ограничение URL-адресов не настроено", "noRule": "Нет заполненных правил", "deleteConfirm": "Вы хотите удалить правило [{name}]?", + "lockConfirm": "Если заблокировано, то все операции потребуют проверки, даже если правило не срабатывает.", "inputTestUrl": "Пожалуйста, введите ссылку для тестирования", - "clickTestButton": "После ввода информации, пожалуйста, нажмите кнопку ({buttonText})", "noRuleMatched": "URL не содержит правил", - "rulesMatched": "URL попадает в следующие правила:" + "rulesMatched": "URL попадает в следующие правила:", + "timeout": "Время вышло! XD" }, "verification": { "inputTip": "Правило было активировано или заблокировано. Чтобы продолжить, введите ответ на следующий вопрос в течение {second} секунд: {prompt}", @@ -508,29 +477,25 @@ "strictTip": "Ограниченное правило уже было вызвано и разблокировка вручную запрещена!", "incorrectPsw": "Неправильный пароль", "incorrectAnswer": "Неправильный ответ", + "twoFaInputTip": "Правило было вызвано или заблокировано. Введите 6-значный код из вашего приложения-аутентификатора, чтобы продолжить.", + "incorrect2fa": "Неверный код двухфакторной авторизации", "pi": "{digitCount} цифр от {startIndex} до {endIndex} десятичной части числа π", "confession": "Время быстротечно" - } + }, + "reminder": "Осталось меньше чем {min} минут до конца лимита времени!" }, "ar": { - "filterDisabled": "تم التمكين فقط", "wildcardTip": "يمكنك استخدام الرموز البديلة لتطابق النطاقات الفرعية أو الصفحات الفرعية، ويمكنك استخدام العلامة \"+\" كبادئة لاستبعاد الصفحات الفرعية!", "item": { "name": "اسم القاعدة", "condition": "عنوان URL مقيد", - "daily": "الحد اليومي", - "weekly": "الحد الأسبوعي", "weekStartInfo": "اليوم الأول من كل أسبوع هو: {weekStart}، يمكنك تغيير هذه القيمة في خيارات الإحصائيات", "delayCount": "عدد التأخير", "detail": "تفاصيل القاعدة", "visitTime": "الحد الزمني لكل زيارة", - "period": "فترات محظورة", "enabled": "مُمَكَّن", "locked": "مقفل", "effectiveDay": "ساري المفعول على", - "delayAllowed": "5 دقائق إضافية", - "delayAllowedInfo": "إذا انتهت المهلة، اسمح بتأخير مؤقت لمدة 5 دقائق", - "visits": "الزيارات", "or": "أو", "notEffective": "غير فعال" }, @@ -548,7 +513,6 @@ "deleteConfirm": "هل تريد حذف القاعدة [{name}]؟", "lockConfirm": "إذا تم القَفل، فإن جميع العمليات ستتطلب التحقق حتى وإن لم يتم تفعيل القاعدة.", "inputTestUrl": "الرجاء إدخال رابط URL ليتم اختباره أولاً", - "clickTestButton": "بعد الإدخال، الرجاء الضغط على الزر ({buttonText})", "noRuleMatched": "عنوان URL لا يصطدم بأي قواعد", "rulesMatched": "يتوافق عنوان URL مع القواعد التالية:", "timeout": "انتهى الوقت! XD" @@ -564,5 +528,152 @@ "confession": "الوقت سريع الزوال" }, "reminder": "أقل من {min} دقيقة على انتهاء الوقت المحدد!" + }, + "tr": { + "wildcardTip": "Alt alan adlarını veya alt sayfaları eşleştirmek için joker karakterler kullanabilir ve alt sayfaları hariç tutmak için “+” ön ekini kullanabilirsiniz!", + "emptyTips": "Bir kural oluşturmak için burayı tıklayın!", + "item": { + "name": "Kural adı", + "condition": "Kısıtlanmış URL", + "weekStartInfo": "Her haftanın ilk günü {weekStart}, bu değeri istatistik seçeneklerinden değiştirebilirsiniz", + "delayCount": "Gecikme sayısı", + "detail": "Kural detayı", + "visitTime": "Ziyaret başına zaman sınırı", + "enabled": "Etkinleştirildi", + "locked": "Kilitli", + "effectiveDay": "Geçerli Olduğu Gün(ler)", + "allowDelay": "Ertelenebilir", + "or": "yada", + "notEffective": "Geçerli değil" + }, + "step": { + "base": "Temel bilgiler", + "url": "Yapılandırma URL'si", + "rule": "Yapılandırma kuralı" + }, + "button": { + "test": "Test URL" + }, + "message": { + "noUrl": "URL eklenemedi", + "noRule": "Hiçbir kural girilmemiş", + "deleteConfirm": "{name} kuralını silmek istiyor musunuz?", + "lockConfirm": "Kilitliyse, kural tetiklenmemiş olsa bile tüm işlemler doğrulama gerektirecektir.", + "inputTestUrl": "Lütfen önce test edilecek URL bağlantısını girin", + "noRuleMatched": "URL hiçbir kurala uymuyor", + "rulesMatched": "URL aşağıdaki kurallara uygundur:", + "timeout": "Zaman doldu!" + }, + "verification": { + "inputTip": "Kural tetiklendi veya kilitlendi. Devam etmek için lütfen {second} saniye içinde sorunun cevabını giriniz: {prompt}", + "inputTip2": "Kural tetiklendi veya kilitlendi. Devam etmek için lütfen {second} saniye içinde olduğu gibi girin: {answer}", + "pswInputTip": "Kural tetiklendi veya kilitlendi. Devam etmek için lütfen şifrenizi girin", + "strictTip": "Tetiklendiğinde, serbest bırakılmadan önce hiçbir işlem yapılmasına izin verilmez!", + "incorrectPsw": "Hatalı şifre", + "incorrectAnswer": "Hatalı cevap", + "pi": "π sayısının ondalık kısmının {startIndex} ile {endIndex} arasındaki {digitCount} basamağı girin", + "confession": "Zaman akıp gidiyor" + }, + "reminder": "Zaman sınırı dolmasına {min} dakikadan az kaldı!" + }, + "pl": { + "onlyEffective": "Tylko efektywne", + "wildcardTip": "Możesz używać symbolu \"*\", aby dopasowywać subdomeny lub podstrony, a także użyć znaku „+” jako prefiksu, aby wykluczyć podstrony!", + "emptyTips": "Kliknij tutaj, aby utworzyć zasadę!", + "item": { + "name": "Nazwa zasady", + "condition": "Limitowany URL", + "weekStartInfo": "Pierwszym dniem każdego tygodnia jest {weekStart}, możesz zmienić tę wartość w opcjach śledzenia", + "delayCount": "Licznik opóźnień", + "detail": "Szczegóły zasady", + "visitTime": "Limit czasu na wizytę", + "enabled": "Aktywne", + "locked": "Zablokowany", + "effectiveDay": "Aktywna w", + "allowDelay": "Możliwe do przedłużenia", + "or": "lub", + "notEffective": "Nieskuteczny" + }, + "step": { + "base": "Podstawowe informacje", + "url": "URL konfiguracji", + "rule": "Reguła konfiguracji" + }, + "button": { + "test": "Przetestuj URL" + }, + "message": { + "noUrl": "Nie dodano URL", + "noRule": "Nie wpisano żadnych reguł", + "deleteConfirm": "Na pewno chcesz usunąć regułę [{name}]?", + "lockConfirm": "Jeśli zablokowane, wszystkie operacje będą wymagały weryfikacji, nawet jeśli reguła nie zostanie uruchomiona.", + "inputTestUrl": "Wpisz URL do przetestowania", + "noRuleMatched": "Podany URL nie aktywuje żadnej reguły", + "rulesMatched": "Podany URL aktywuje następujące reguły:", + "timeout": "Czas minął! XD" + }, + "verification": { + "inputTip": "Reguła została aktywowana lub zablokowana. Aby kontynuować, odpowiedz na podane pytanie w {second} sekund: {prompt}", + "inputTip2": "Reguła została uruchomiona lub zablokowana. Aby kontynuować, wprowadź ją w ciągu {second} sekund: {answer}", + "pswInputTip": "Reguła została uruchomiona lub zablokowana. Aby kontynuować, wprowadź hasło odblokowania", + "strictTip": "Aktywowano, żadna operacja nie jest dozwolona przed zwolnieniem!", + "incorrectPsw": "Nieprawidłowe hasło", + "incorrectAnswer": "Niepoprawna odpowiedź", + "twoFaInputTip": "Reguła została uruchomiona lub zablokowana. Wprowadź 6-cyfrowy kod z aplikacji uwierzytelniającej, aby kontynuować.", + "incorrect2fa": "Nieprawidłowy kod 2FA", + "pi": "{digitCount} cyfr od {startIndex} do {endIndex} części dziesiętnej liczby π", + "confession": "Czas ucieka" + }, + "reminder": "Mniej niż {min} minut do osiągnięcia limitu!" + }, + "it": { + "onlyEffective": "Solo Efficace", + "wildcardTip": "Puoi usare caratteri jolly per abbinare sotto-domini o sotto-pagine, e usare \"+\" come prefisso per escludere le sotto-pagine!", + "emptyTips": "Clicca qui per creare una regola!", + "item": { + "name": "Nome della regola", + "condition": "URL Ristretto", + "weekStartInfo": "Il primo giorno di ogni settimana è {weekStart}, puoi cambiare questo valore nelle opzioni di tracciamento", + "delayCount": "Ritardo conteggio", + "detail": "Dettagli regola", + "visitTime": "Limite di tempo per visita", + "enabled": "Attivata", + "locked": "Bloccato", + "effectiveDay": "Effettivo On", + "allowDelay": "Ritardabile", + "or": "o", + "notEffective": "Non valido" + }, + "step": { + "base": "Informazione Base", + "url": "URL Di Configurazione", + "rule": "Regola di configurazione" + }, + "button": { + "test": "Test URL" + }, + "message": { + "noUrl": "Nessuna URL aggiunta", + "noRule": "Nessuna regola inserita", + "deleteConfirm": "Vorresti eliminare la regola [{name}]?", + "lockConfirm": "Se bloccato, tutte le operazioni richiederanno la verifica anche se la regola non viene attivata.", + "inputTestUrl": "Si prega d'inserire il link URL da testare per primo", + "noRuleMatched": "L'URL non soddisfa alcuna regola", + "rulesMatched": "L'URL soddisfa le seguenti regole:", + "timeout": "Tempo scaduto! XD" + }, + "verification": { + "inputTip": "La regola è stata attivata o bloccata. Per continuare, inserisci la risposta alla seguente domanda entro {second} secondi: {prompt}", + "inputTip2": "La regola è stata attivata o bloccata. Per continuare, inseriscila così com'è entro {second} secondi: {answer}", + "pswInputTip": "La regola è stata attivata o bloccata. Per continuare, inserisci la password", + "strictTip": "Attivato, nessuna operazione è consentita prima del rilascio!", + "incorrectPsw": "Password non e corretta", + "incorrectAnswer": "Risposta sbagliata", + "twoFaInputTip": "La regola è stata attivata o bloccata. Inserisci il codice a 6 cifre dall'app di autenticazione per continuare.", + "incorrect2fa": "Codice 2FA non valido", + "pi": "{digitCount} cifre da {startIndex} a {endIndex} della parte decimale di π", + "confession": "Il tempo è fugace" + }, + "reminder": "Meno di {min} minuti fino al limite!" } } \ No newline at end of file diff --git a/src/i18n/message/app/limit.ts b/src/i18n/message/app/limit.ts index e01ff833c..e2593de18 100644 --- a/src/i18n/message/app/limit.ts +++ b/src/i18n/message/app/limit.ts @@ -8,8 +8,9 @@ import resource from './limit-resource.json' export type LimitMessage = { - filterDisabled: string + onlyEffective: string wildcardTip: string + emptyTips: string step: { base: string url: string @@ -18,21 +19,17 @@ export type LimitMessage = { item: { name: string condition: string - daily: string - weekly: string weekStartInfo: string visitTime: string - period: string enabled: string locked: string effectiveDay: string - delayAllowed: string - delayAllowedInfo: string + allowDelay: string delayCount: string detail: string - visits: string or: string notEffective: string + unlimited: string } button: { test: string @@ -43,7 +40,6 @@ export type LimitMessage = { deleteConfirm: string lockConfirm: string inputTestUrl: string - clickTestButton: string noRuleMatched: string rulesMatched: string timeout: string @@ -55,21 +51,14 @@ export type LimitMessage = { strictTip: string incorrectPsw: string incorrectAnswer: string + twoFaInputTip: string + incorrect2fa: string pi: string confession: string } reminder: string } -export const verificationMessages: Messages = { - en: resource.en?.verification, - zh_CN: resource.zh_CN?.verification, - zh_TW: resource.zh_TW?.verification, - ja: resource.ja?.verification, - pt_PT: resource.pt_PT?.verification, - uk: resource.uk?.verification, -} - const _default: Messages = resource export default _default diff --git a/src/i18n/message/app/menu-resource.json b/src/i18n/message/app/menu-resource.json index a9f496b1d..d6df05a9b 100644 --- a/src/i18n/message/app/menu-resource.json +++ b/src/i18n/message/app/menu-resource.json @@ -2,188 +2,186 @@ "zh_CN": { "dashboard": "仪表盘", "data": "我的数据", - "dataReport": "报表明细", + "record": "报表明细", "siteAnalysis": "站点分析", "dataClear": "数据管理", - "additional": "附加功能", - "siteManage": "网站管理", - "whitelist": "白名单管理", - "mergeRule": "子域名合并", "behavior": "上网行为", "habit": "上网习惯", - "limit": "时间限制", + "additional": "附加功能", + "siteManage": "网站管理", + "rule": "规则管理", "other": "其他", - "helpUs": "帮助翻译", "about": "关于" }, "zh_TW": { "dashboard": "数据总览", "data": "我的資料", - "dataReport": "報表明細", + "record": "報表明細", "siteAnalysis": "網站分析", "dataClear": "儲存狀況", "behavior": "用戶行爲", "habit": "習慣分析", - "limit": "時間限制", "additional": "附加功能", "siteManage": "網站管理", - "whitelist": "白名單", - "mergeRule": "網站合併規則", "other": "其他", - "helpUs": "協助翻譯", "about": "關於" }, "en": { "dashboard": "Dashboard", "data": "My Data", - "dataReport": "Record", + "record": "Record", "siteAnalysis": "Site Analysis", "dataClear": "Storage", "behavior": "User Behavior", "habit": "Habits", - "limit": "Time Limit", "additional": "Additional Features", "siteManage": "Site Management", - "whitelist": "Whitelist", - "mergeRule": "Merge-site Rules", + "rule": "Rules", "other": "Other Features", - "helpUs": "Help Translation", "about": "About" }, "ja": { "dashboard": "ダッシュボード", "data": "私のデータ", - "dataReport": "報告する", + "record": "報告する", "siteAnalysis": "ウェブサイト分析", "dataClear": "記憶状況", "behavior": "ユーザーの行動", "habit": "閲覧の習慣", - "limit": "時間制限", "additional": "その他の機能", "siteManage": "ウェブサイト管理", - "whitelist": "Webホワイトリスト", - "mergeRule": "ドメイン合併", "other": "その他の機能", - "helpUs": "協力する", "about": "について" }, "pt_PT": { "dashboard": "Painel", "data": "Os Meus Dados", - "dataReport": "Registos", + "record": "Registos", "siteAnalysis": "Análise de Sites", "dataClear": "Armazenamento", "behavior": "Comportamento", "habit": "Hábitos", - "limit": "Limite de Tempo", "additional": "Funcionalidades", "siteManage": "Gestão de Sites", - "whitelist": "Lista Branca", - "mergeRule": "Regras de Agrupamento", "other": "Outras Opções", - "helpUs": "Ajudar a Traduzir", "about": "Sobre" }, "uk": { "dashboard": "Огляд", "data": "Мої дані", - "dataReport": "Записи", + "record": "Записи", "siteAnalysis": "Аналіз сайту", "dataClear": "Стан пам'яті", "behavior": "Поведінка", "habit": "Звички", - "limit": "Обмеження часу", "additional": "Додаткові функції", "siteManage": "Керування сайтами", - "whitelist": "Білий список", - "mergeRule": "Правила об'єднання сайтів", "other": "Інші функції", - "helpUs": "Допомогти нам", "about": "Про нас" }, "es": { "dashboard": "Tablero", "data": "Mis datos", - "dataReport": "Registro", + "record": "Registro", "siteAnalysis": "Análisis de sitios", "dataClear": "Estado de la memoria", "behavior": "Comportamiento del usuario", "habit": "Hábitos", - "limit": "Límite de tiempo", "additional": "Funciones adicionales", "siteManage": "Gestión de sitios", - "whitelist": "Lista blanca", - "mergeRule": "Reglas de fusión de sitios", "other": "Otras Funciones", - "helpUs": "Ayúdanos", "about": "Acerca de" }, "de": { - "dashboard": "Armaturenbrett", + "dashboard": "Übersicht", "data": "Meine Daten", - "dataReport": "Aufzeichnen", + "record": "Aufzeichnen", "siteAnalysis": "Analysebericht", "dataClear": "Speicherstatus", "behavior": "Nutzerverhalten", "habit": "Gewohnheit", - "limit": "Zeitlimit", "additional": "Zusatzfunktionen", "siteManage": "Websites", - "whitelist": "Whitelist", - "mergeRule": "Regeln zusammenführen", "other": "Andere Eigenschaften", - "helpUs": "Hilfe bei der Übersetzung", "about": "Über uns" }, "fr": { "dashboard": "Tableau de bord", "data": "Mes données", - "dataReport": "Enregistrement", + "record": "Enregistrement", "siteAnalysis": "Analyse du site", "dataClear": "Situation de la mémoire", "behavior": "Comportement de l'utilisateur", "habit": "Habitudes", - "limit": "Limite de temps", "additional": "Fonctionnalités supplémentaires", "siteManage": "Gestion des sites", - "whitelist": "Whitelist", - "mergeRule": "Fusionner les règles du site", + "rule": "Règles", "other": "Autres fonctionnalités", - "helpUs": "Aider à la traduction", "about": "À propos" }, "ru": { "dashboard": "Панель Данных", "data": "Мои Данные", - "dataReport": "Рекорд", + "record": "Рекорд", "siteAnalysis": "Анализ сайта", "dataClear": "Память о ситуации", "behavior": "Поведение", "habit": "Привычки", - "limit": "Ограничение по времени", "additional": "Дополнительный", "siteManage": "Управление сайтом", - "whitelist": "Белый список", - "mergeRule": "Объединение сайтов", "other": "Другие особенности", - "helpUs": "Помогите перевести", "about": "О нас" }, "ar": { "dashboard": "لوحة التحكم", "data": "بياناتي", - "dataReport": "تسجيل", + "record": "تسجيل", "siteAnalysis": "تحليل الموقع", "dataClear": "حالة الذاكرة", "behavior": "سلوك المستخدم", "habit": "العادات", - "limit": "المهلة", "additional": "ميزات إضافية", "siteManage": "أدوار النظام", - "whitelist": "القائمة البيضاء", - "mergeRule": "دمج قواعد الموقع", "other": "مميزات اخزي", - "helpUs": "ساعدني في التَّرْجَمَةً", "about": "حول" + }, + "tr": { + "dashboard": "Kontrol Paneli", + "data": "Verilerim", + "record": "Kayıtlı Veriler", + "siteAnalysis": "Site Analizi", + "dataClear": "Depolama", + "behavior": "Kullanıcı Davranışları", + "habit": "Alışkanlıklar", + "additional": "Ek Özellikler", + "siteManage": "Site Yönetimi", + "other": "Diğer Özellikler", + "about": "Hakkımızda" + }, + "pl": { + "dashboard": "Panel", + "data": "Moje dane", + "record": "Wpisy", + "siteAnalysis": "Analiza strony", + "dataClear": "Pamięć", + "behavior": "Zachowanie użytkownika", + "habit": "Nawyki", + "additional": "Dodatkowe funkcje", + "siteManage": "Zarządzanie stronami", + "rule": "Reguła", + "other": "Pozostałe funkcje", + "about": "O rozszerzeniu" + }, + "it": { + "dashboard": "Dashboard", + "data": "Miei Dati", + "siteAnalysis": "Analisi Sito", + "dataClear": "Memoria", + "behavior": "Comportamento Dell'Utente", + "habit": "Abitudini", + "additional": "Funzionalità aggiuntive", + "siteManage": "Gestione del sito", + "other": "Altre funzioni", + "about": "Info su" } } \ No newline at end of file diff --git a/src/i18n/message/app/menu.ts b/src/i18n/message/app/menu.ts index 72b61bdb8..3f34f82bf 100644 --- a/src/i18n/message/app/menu.ts +++ b/src/i18n/message/app/menu.ts @@ -10,18 +10,15 @@ import resource from './menu-resource.json' export type MenuMessage = { dashboard: string data: string - dataReport: string + record: string siteAnalysis: string dataClear: string behavior: string habit: string - limit: string additional: string siteManage: string - whitelist: string - mergeRule: string + rule: string other: string - helpUs: string about: string } diff --git a/src/i18n/message/app/merge-rule-resource.json b/src/i18n/message/app/merge-rule-resource.json deleted file mode 100644 index 15a99189f..000000000 --- a/src/i18n/message/app/merge-rule-resource.json +++ /dev/null @@ -1,211 +0,0 @@ -{ - "zh_CN": { - "removeConfirmMsg": "自定义合并规则 {origin} 将被移除", - "originPlaceholder": "原域名", - "mergedPlaceholder": "合并后域名", - "errorOrigin": "原域名格式错误", - "duplicateMsg": "合并规则已存在:{origin}", - "addConfirmMsg": "将为 {origin} 设置自定义合并规则", - "infoAlertTitle": "该页面可以配置子域名的合并规则", - "infoAlert0": "点击新增按钮,会弹出原域名和合并后域名的输入框,填写并保存规则", - "infoAlert1": "原域名可填具体的域名或者正则表达式,比如 www.baidu.com,*.baidu.com,*.google.com.*。以此确定哪些域名在合并时会使用该条规则", - "infoAlert2": "合并后域名可填具体的域名,或者填数字,或者不填", - "infoAlert3": "如果填数字,则表示合并后域名的级数。比如存在规则【 *.*.edu.cn >>> 3 】,那么 www.hust.edu.cn 将被合并至 hust.edu.cn", - "infoAlert4": "如果不填,则表示原域名不会被合并", - "infoAlert5": "如果没有命中任何规则,则默认会合并至 {psl} 的前一级", - "tagResult": { - "blank": "不合并", - "level": "{level} 级域名" - } - }, - "zh_TW": { - "removeConfirmMsg": "自訂合併規則 {origin} 將被刪除", - "originPlaceholder": "原始網域", - "mergedPlaceholder": "合併後網域", - "errorOrigin": "原始網域格式錯誤", - "duplicateMsg": "合併規則已存在:{origin}", - "addConfirmMsg": "將為 {origin} 設定自訂合併規則", - "infoAlertTitle": "此頁面可設定子網域合併規則", - "infoAlert0": "點擊「新增」按鈕後,需填寫原始網域與合併後網域", - "infoAlert1": "原始網域可填寫具體網域或正規表示式,例如:www.baidu.com、*.baidu.com、*.google.com.*", - "infoAlert2": "合併後網域可填寫:具體網域/數字/留空", - "infoAlert3": "若填數字,代表合併後的網域層級數。例如規則【 *.*.edu.cn >>> 3 】會將 www.hust.edu.cn 合併為 hust.edu.cn", - "infoAlert4": "若留空,表示原始網域將保持不變", - "infoAlert5": "若未匹配任何規則,預設會合併至 {psl} 的上一層級", - "tagResult": { - "blank": "保持原網域", - "level": "{level} 層網域" - } - }, - "en": { - "removeConfirmMsg": "{origin} will be removed from customized merge rules.", - "originPlaceholder": "Original site", - "mergedPlaceholder": "Merged", - "errorOrigin": "The format of original site is invalid.", - "duplicateMsg": "The rule already exists: {origin}", - "addConfirmMsg": "Customized merge rules will be set for {origin}", - "infoAlertTitle": "the merge rules when counting sites on this page", - "infoAlert0": "Click the [New One] button, the input boxes of the source site and the merge site will be displayed, fill in and save the rule", - "infoAlert1": "The original site can be filled with a specific site or regular expression, such as www.baidu.com, *.baidu.com, *.google.com.*, to determine which sites will match this rule while merging", - "infoAlert2": "The merged site can be filled with a specific site, a number or blank", - "infoAlert3": "A number means the level of merged site. For example, there is a rule '*.*.edu.cn >>> 3', then 'www.hust.edu.cn' will be merged to 'hust.edu.cn'", - "infoAlert4": "Blank means the original site will not be merged", - "infoAlert5": "If no rule is matched, it will default to the level before {psl}", - "tagResult": { - "blank": "Not Merge", - "level": "Keep Level {level}" - } - }, - "ja": { - "removeConfirmMsg": "カスタム マージ ルール {origin} は削除されます", - "originPlaceholder": "独自ドメイン名", - "mergedPlaceholder": "統計的ドメイン名", - "errorOrigin": "元のドメイン名の形式が間違っています", - "duplicateMsg": "ルールはすでに存在します:{origin}", - "addConfirmMsg": "カスタム マージ ルールが {origin} に設定されます", - "infoAlertTitle": "このページでは、サブドメインのマージ ルールを設定できます", - "infoAlert0": "[追加] ボタンをクリックすると、元のドメイン名と結合されたドメイン名の入力ボックスがポップアップし、ルールを入力して保存します。", - "infoAlert1": "元のドメイン名には、特定のドメイン名または正規表現 (www.baidu.com、*.baidu.com、*.google.com.* など) を入力できます。 マージ時にこのルールを使用するドメインを決定するには", - "infoAlert2": "統合されたドメイン名の後、特定のドメイン名を入力するか、番号を入力するか、空白のままにすることができます", - "infoAlert3": "数字を記入する場合は、ドメイン名のレベルが予約されていることを意味します。 たとえば、ルール [*.*.edu.cn >>> 3 ] がある場合、www.hust.edu.cn は hust.edu.cn にマージされます。", - "infoAlert4": "記入しない場合は、元のドメイン名が統合されないことを意味します", - "infoAlert5": "一致するルールがない場合、デフォルトで {psl} より前のレベルになります", - "tagResult": { - "blank": "不合并", - "level": "{level} 次ドメイン" - } - }, - "pt_PT": { - "removeConfirmMsg": "{origin} será removido das regras de agrupamento personalizadas.", - "originPlaceholder": "Site original", - "mergedPlaceholder": "Agrupado", - "errorOrigin": "Formato do site original inválido.", - "duplicateMsg": "A regra já existe: {origin}", - "addConfirmMsg": "Será criada uma regra de agrupamento para {origin}", - "infoAlertTitle": "Regras de agrupamento para contagem de sites nesta página", - "infoAlert0": "Clique em [Novo] para mostrar os campos do site original e do site agrupado", - "infoAlert1": "O site original pode ser um URL específico ou expressão regular (ex: www.baidu.com, *.baidu.com, *.google.com.*)", - "infoAlert2": "O site agrupado pode ser um URL específico, um número ou ficar em branco", - "infoAlert3": "Um número indica o nível de agrupamento. Ex: com a regra '*.*.edu.cn >>> 3', 'www.hust.edu.cn' será agrupado como 'hust.edu.cn'", - "infoAlert4": "Em branco significa que o site original não será agrupado", - "infoAlert5": "Se nenhuma regra for encontrada, usará o nível anterior a {psl}", - "tagResult": { - "blank": "Não Agrupar", - "level": "Manter Nível {level}" - } - }, - "uk": { - "removeConfirmMsg": "{origin} буде вилучено з налаштованих правил об'єднання.", - "originPlaceholder": "Оригінальний сайт", - "mergedPlaceholder": "Об'єднуваний", - "errorOrigin": "Неприпустимий формат оригінального сайту.", - "duplicateMsg": "Правило вже існує: {origin}", - "addConfirmMsg": "Для {origin} буде встановлено користувацьке правило об'єднання", - "infoAlertTitle": "На цій сторінці ви можете встановити правила об’єднання для статистики використання сайтів", - "infoAlert0": "Натисніть кнопку [Нове], заповніть поля оригінального та об'єднуваного сайту, після чого збережіть правило", - "infoAlert1": "Оригінальний сайт можна ввести як URL-адресу або регулярний вираз, як-от www.wikipedia.org, *.wikipedia.org, *.google.com.*, щоб визначити відповідні сайти для об'єднання", - "infoAlert2": "Об'єднуваний сайт можна ввести як URL-адресу, число, або залишити пустим", - "infoAlert3": "Число означає рівень об'єднуваного сайту. Наприклад, для правила '*.*.edu.cn >>> 3' об'єднуваний сайт 'www.hust.edu.cn' буде об'єднано з 'hust.edu.cn'", - "infoAlert4": "Пусте поле означає, що сайт не буде об'єднано", - "infoAlert5": "Якщо жодне правило не збігається, його буде встановлено до типового рівня {psl}", - "tagResult": { - "blank": "Не об'єднувати", - "level": "Зберегти рівень {level}" - } - }, - "es": { - "removeConfirmMsg": "{origin} será eliminado de las reglas de fusión personalizadas.", - "originPlaceholder": "Sitio original", - "mergedPlaceholder": "Fusionado", - "errorOrigin": "El formato del sitio original no es válido.", - "duplicateMsg": "La regla ya existe: {origin}", - "addConfirmMsg": "Las reglas de fusión personalizadas se establecerán para {origin}", - "infoAlertTitle": "Puedes establecer las reglas de fusión al contar sitios en esta página", - "infoAlert0": "Haz clic en el botón [New One], se mostrarán los recuadros del sitio fuente y el sitio fusionado, llénalos y guarda la regla", - "infoAlert1": "El sitio original puede ser llenado con un sitio específico o una expresión regular, tal como www.baidu.com, *.baidu.com, *.google.com.*, para determinar qué sitios seguirán esta regla al fusionarse", - "infoAlert2": "El sitio fusionado puede ser llenado con un sitio específico, un número o en dejarse en blanco", - "infoAlert3": "Un número significa el nivel del sitio fusionado. Por ejemplo, hay una regla '*.*.edu.cn >>> 3', entonces 'www.hust.edu.cn' se fusionará con 'hust.edu.cn'", - "infoAlert4": "Dejar en blanco significa que el sitio original no se fusionará", - "infoAlert5": "Si no se sigue ninguna regla, se establecerá por defecto al nivel antes de {psl}", - "tagResult": { - "blank": "No fusionar", - "level": "Mantener en nivel {level}" - } - }, - "de": { - "removeConfirmMsg": "{origin} wird aus den benutzerdefinierten Zusammenführen Regeln entfernt.", - "originPlaceholder": "Ursprüngliche Website", - "mergedPlaceholder": "Zusammengeführt", - "errorOrigin": "Das Format der Original-Website ist ungültig.", - "duplicateMsg": "Die Regel existiert bereits: {origin}", - "addConfirmMsg": "Für {origin} werden benutzerdefinierte Zusammenführungsregeln festgelegt", - "infoAlertTitle": "Auf dieser Seite können Sie Zusammenführen Regeln für die Zählung von Websites festlegen", - "infoAlert0": "Klicken Sie auf die Schaltfläche [Erstellen]. Die Eingabefelder der ursprünglichen Website und der zusammengeführten Website werden angezeigt. Füllen Sie die Regel aus und speichern Sie sie", - "infoAlert1": "Die ursprüngliche Website kann mit einer bestimmten Website oder einem regulären Ausdruck wie www.baidu.com, *.baidu.com, *.google.com.* gefüllt werden, um zu bestimmen, welche Websites beim Zusammenführen dieser Regel entsprechen", - "infoAlert2": "Die zusammengeführte Website kann mit einer bestimmten Site, einer Zahl oder einem Leerzeichen gefüllt werden", - "infoAlert3": "Eine Zahl gibt die Ebene der zusammengeführten Website an. Gibt es beispielsweise eine Regel „*.*.edu.cn >>> 3“, dann wird „www.hust.edu.cn“ als „hust.edu.cn“ zusammengeführt", - "infoAlert4": "Leer bedeutet, dass die ursprüngliche Website nicht zusammengeführt wird", - "infoAlert5": "Wenn keine Regel zutrifft, wird standardmäßig die Ebene vor {psl} verwendet", - "tagResult": { - "blank": "Nicht zusammenführen", - "level": "Level behalten {level}" - } - }, - "fr": { - "removeConfirmMsg": "{origin} sera supprimé des règles de fusion personnalisées.", - "originPlaceholder": "Site original", - "mergedPlaceholder": "Fusionné", - "errorOrigin": "Le format du site original est invalide.", - "duplicateMsg": "La règle existe déjà : {origin}", - "addConfirmMsg": "Les règles de fusion personnalisées seront définies pour {origin}", - "infoAlertTitle": "Vous pouvez définir les règles de fusion lorsque vous comptez des sites sur cette page", - "infoAlert0": "Cliquez sur le bouton [Nouveau], les boîtes de saisie du site source et le site de fusion sera affiché, remplissez et enregistrez la règle", - "infoAlert1": "Le site d'origine peut être rempli avec un site spécifique ou une expression régulière, telle que www.baidu.com, *.baidu.com, *.google.com.*, pour déterminer quels sites correspondront à cette règle lors de la fusion.", - "infoAlert2": "Le site fusionné peut être rempli avec un site spécifique, un numéro ou vide", - "infoAlert3": "Un nombre signifie le niveau du site fusionné. Par exemple, il existe une règle '*.*.edu.cn >>> 3', alors 'www.hust.edu.cn' sera fusionné avec 'hust.edu.cn'", - "infoAlert4": "Vide signifie que le site d'origine ne sera pas fusionné", - "infoAlert5": "Si aucune règle ne correspond, le niveau par défaut sera celui d'avant {psl}.", - "tagResult": { - "blank": "Non Fusionner", - "level": "Garder le niveau {level}" - } - }, - "ru": { - "removeConfirmMsg": "{origin} будет удален из настроенных правил слияния.", - "originPlaceholder": "Оригинальный сайт", - "mergedPlaceholder": "Соединено", - "errorOrigin": "Недопустимый формат оригинального сайта.", - "duplicateMsg": "Это правило уже существует: {origin}", - "addConfirmMsg": "Для {origin} будут установлены индивидуальные правила слияния", - "infoAlertTitle": "правила слияния при подсчете сайтов на этой странице", - "infoAlert0": "Нажмите кнопку [Новый], будут показаны поля ввода сайта источника и будут отображаться слияния, заполнять и сохранять правило", - "infoAlert1": "Исходный сайт может быть заполнен определенным сайтом или регулярным выражением, например www.baidu.com, *.baidu.com, *.google.com.*, чтобы определить, какие сайты будут соответствовать этому правилу при слиянии", - "infoAlert2": "Объединенный сайт может быть заполнен определенным сайтом, номером или пустым местом", - "infoAlert3": "Число означает уровень слияния сайта. Например, правило '*.*.edu.cn >>> 3', то 'www.hust.edu.cn' будет объединено с 'hust.edu.cn'", - "infoAlert4": "Пустое значение означает, что оригинальный сайт не будет объединён", - "infoAlert5": "Если правило не совпадает, оно будет по умолчанию на уровне до {psl}", - "tagResult": { - "blank": "Не объединять", - "level": "Сохранять уровень {level}" - } - }, - "ar": { - "removeConfirmMsg": "سيتم إزالة {origin} من قواعد الدمج المخصصة.", - "originPlaceholder": "الموقع الأصلي", - "mergedPlaceholder": "تم الدمج", - "errorOrigin": "تنسيق الموقع الأصلي غير صالح.", - "duplicateMsg": "القاعدة موجودة فعلًا: {origin}", - "addConfirmMsg": "سيتم تعيين قواعد الدمج المخصصة لـ {origin}", - "infoAlertTitle": "قواعد الدمج عند حساب المواقع الموجودة على هذه الصفحة", - "infoAlert0": "انقر فوق الزر [جديد]، سيتم عرض مربعات الإدخال الخاصة بموقع المصدر وموقع الدمج، املأ القاعدة واحفظها", - "infoAlert1": "يمكن ملء الموقع الأصلي بموقع معين أو تعبير عادي، مثل www.baidu.com، و*.baidu.com، و*.google.com.*، لتحديد المواقع التي ستطابق هذه القاعدة أثناء الدمج", - "infoAlert2": "يمكن ملء الموقع المدمج بموقع محدد أو رَقَم أو مساحة فارغة", - "infoAlert3": "يشير الرقم إلى مستوى الموقع المدمج. على سبيل المثال، هناك قاعدة '*.*.edu.cn >>> 3'، ثم سيتم دمج 'www.hust.edu.cn' في 'hust.edu.cn'", - "infoAlert4": "الفراغ يعني أن الموقع الأصلي لن يتم دمجه", - "infoAlert5": "إذا لم يتم مطابقة أي قاعدة، فسيتم تعيينها افتراضيًا على المستوى قبل {psl}", - "tagResult": { - "blank": "لا دمج", - "level": "الحفاظ على المستوى {level}" - } - } -} \ No newline at end of file diff --git a/src/i18n/message/app/merge-rule.ts b/src/i18n/message/app/merge-rule.ts deleted file mode 100644 index 173ba961a..000000000 --- a/src/i18n/message/app/merge-rule.ts +++ /dev/null @@ -1,32 +0,0 @@ -/** - * Copyright (c) 2021-present Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import resource from './merge-rule-resource.json' - -export type MergeRuleMessage = { - removeConfirmMsg: string - originPlaceholder: string - mergedPlaceholder: string - errorOrigin: string - duplicateMsg: string - addConfirmMsg: string - infoAlertTitle: string - infoAlert0: string - infoAlert1: string - infoAlert2: string - infoAlert3: string - infoAlert4: string - infoAlert5: string - tagResult: { - blank: string - level: string - } -} - -const mergeRuleMessages: Messages = resource - -export default mergeRuleMessages \ No newline at end of file diff --git a/src/i18n/message/app/operation-resource.json b/src/i18n/message/app/operation-resource.json index 5937ba133..ff1eed3c6 100644 --- a/src/i18n/message/app/operation-resource.json +++ b/src/i18n/message/app/operation-resource.json @@ -42,5 +42,17 @@ "ar": { "confirmTitle": "تأكيد", "successMsg": "بنجاح!" + }, + "tr": { + "confirmTitle": "Doğrulama", + "successMsg": "Başarılı oldu!" + }, + "pl": { + "confirmTitle": "Potwierdzenie", + "successMsg": "Pomyślnie!" + }, + "it": { + "confirmTitle": "Conferma", + "successMsg": "Con successo!" } } \ No newline at end of file diff --git a/src/i18n/message/app/option-resource.json b/src/i18n/message/app/option-resource.json index 948e2f754..5ee7cdd9d 100644 --- a/src/i18n/message/app/option-resource.json +++ b/src/i18n/message/app/option-resource.json @@ -2,12 +2,10 @@ "zh_CN": { "yes": "是", "no": "否", + "on": "始终开启", + "off": "始终关闭", "followBrowser": "跟随浏览器", - "popup": { - "title": "弹窗页", - "max": "只显示前 {input} 条数据,剩下的条目合并显示", - "displaySiteName": "{input} 显示时是否使用网站名称来代替域名" - }, + "permGrantConfirm": "该功能需要授予相关权限", "appearance": { "title": "外观", "displayWhitelist": "{input} 是否在 {contextMenu} 里,显示 {whitelist} 相关功能", @@ -29,30 +27,27 @@ }, "darkMode": { "label": "夜间模式 {input}", - "options": { - "on": "始终开启", - "off": "始终关闭", - "timed": "定时开启" - } + "timed": "定时开启" }, - "animationDuration": "图表初始动画的时长 {input}" + "animationDuration": "图表初始动画的时长 {input}", + "sidePanel": "{input} 是否启用侧边栏视图" }, - "statistics": { + "tracking": { "title": "统计", "autoPauseTrack": "{input} 如果 {maxTime} 内未检测到任何活动 {info},则暂停统计", - "noActivityInfo": "鼠标和键盘处于非活动状态,并且未处于全屏模式", + "noActivityInfo": "鼠标和键盘处于非活动状态,并且未处于全屏模式,也没有声音播放", "countLocalFiles": "{input} 是否统计使用浏览器 {localFileTime} {info}", - "countTabGroup": "{input} 是否统计标签组的时间 {info}", - "tabGroupInfo": "删除标签组后,数据也会被删除", - "tabGroupsPermGrant": "该功能需要授予相关权限", "localFileTime": "阅读本地文件的时间", "localFilesInfo": "支持 PDF、图片、txt 以及 json 等格式", + "countTabGroup": "{input} 是否统计标签组的时间 {info}", + "tabGroupInfo": "删除标签组后,数据也会被删除", "fileAccessDisabled": "目前不允许访问文件网址,请先在管理界面开启", - "fileAccessFirefox": "很抱歉,该功能在 Firefox 中不支持", "weekStart": "每周的第一天 {input}", - "weekStartAsNormal": "按照惯例" + "weekStartAsNormal": "按照惯例", + "storage": "将数据存储在 {input} 中", + "storageConfirm": "是否要将存储类型更改为 {type}?" }, - "dailyLimit": { + "limit": { "prompt": "受限时显示的提示文本 {input}", "reminder": "{input} 在到期前 {minInput} 分钟发送提醒", "level": { @@ -71,24 +66,26 @@ "strictTitle": "危险操作", "strictContent": "当您选择这个选项之后,如果某个站点触发了每日限制,除了等到第二天自动解锁以外,不允许您手动解锁。如果规则设置不当,很有可能会阻碍您的日常工作!", "pswFormLabel": "解锁密码", - "pswFormAgain": "再次输入" - } + "pswFormAgain": "再次输入", + "2fa": "必须使用 2FA 解锁", + "twoFaTitle": "启用 2FA 验证", + "twoFaScanHint": "使用身份验证器应用扫描二维码或将设置链接导入支持TOTP的密码管理器。", + "twoFaCopyLink": "复制链接", + "twoFaVerifyLabel": "请输入您的身份程序里的 6 位编码以验证正确导入" + }, + "delayDuration": "每次延迟 {input} 分钟" }, "backup": { "title": "数据备份", "type": "远端类型 {input}", "client": "客户端标识 {input}", "meta": { - "none": { - "label": "不开启备份" - }, "gist": { "authInfo": "需要创建一个至少包含 gist 权限的 token" }, "obsidian_local_rest_api": { "endpointInfo": "因为无法为浏览器插件配置跨域,所以只能使用 HTTP 协议" - }, - "web_dav": {} + } }, "label": { "endpoint": "服务地址 {info} {input}", @@ -96,11 +93,9 @@ "account": "账号 {input}", "password": "密码 {input}" }, - "lastTimeTip": "上次备份时间: {lastTime}", "operation": "备份数据", "download": { "btn": "下载数据", - "step2": "确认数据", "willDownload": "待下载数据", "confirmTip": "将下载来自【{clientName}】的 {size} 条数据" }, @@ -108,12 +103,14 @@ "btn": "清除数据", "confirmTip": "将删除【{clientName}】所追踪的 {hostCount} 个站点的 {rowCount} 条数据!" }, + "confirmStep": "确认数据", "clientTable": { "selectTip": "选择客户端", "dataRange": "已备份数据区间", "notSelected": "未选择客户端", "current": "当前" }, + "lastTimeTip": "上次备份时间: {lastTime}", "auto": { "label": "是否开启自动备份", "interval": "每 {input} 分钟备份一次" @@ -123,6 +120,22 @@ "title": "无障碍功能", "chartDecal": "{input} 是否显示图表的贴花图案" }, + "notification": { + "title": "消息推送", + "cycle": { + "label": "消息推送周期 {input}", + "daily": "每天", + "weekly": "每周" + }, + "method": { + "label": "消息推送方式 {input}", + "browser": "浏览器通知", + "callback": { + "label": "HTTP 回调", + "url": "回调 URL {input}" + } + } + }, "resetButton": "恢复默认", "resetSuccess": "成功重置为默认值", "exportButton": "导出设置", @@ -137,12 +150,10 @@ "zh_TW": { "yes": "是", "no": "否", + "on": "始終開啟", + "off": "始終關閉", "followBrowser": "跟隨瀏覽器", - "popup": { - "title": "彈出視窗", - "max": "僅顯示前 {input} 筆資料,其餘項目合併", - "displaySiteName": "{input} 顯示時是否使用網站名稱而非域名" - }, + "permGrantConfirm": "此功能需要相關權限才能運作", "appearance": { "title": "外觀設定", "displayWhitelist": "{input} 是否在 {contextMenu} 顯示 {whitelist} 功能", @@ -164,30 +175,27 @@ }, "darkMode": { "label": "深色模式 {input}", - "options": { - "on": "始終開啟", - "off": "始終關閉", - "timed": "定時開啟" - } + "timed": "定時開啟" }, - "animationDuration": "圖表動畫持續時間 {input}" + "animationDuration": "圖表動畫持續時間 {input}", + "sidePanel": "{input}是否開啟側邊面板" }, - "statistics": { + "tracking": { "title": "統計設定", "autoPauseTrack": "{input} 若 {maxTime} 內無活動 {info} 則暫停追蹤", - "noActivityInfo": "滑鼠與鍵盤無動作且非全螢幕模式時", + "noActivityInfo": "滑鼠跟鍵盤非使用中,未處於全螢幕模式,且沒有聲音撥放中", "countLocalFiles": "{input} 是否統計瀏覽器 {localFileTime} {info}", "localFileTime": "讀取本機檔案時間", "localFilesInfo": "支援 PDF、圖片、txt 及 json 等格式", "countTabGroup": "{input} 是否追蹤分頁群組的時間 {info}", "tabGroupInfo": "刪除分頁群組時,相關的時間追蹤數據也會一併清除。", - "tabGroupsPermGrant": "此功能需要相關權限才能運作", "fileAccessDisabled": "目前不允許存取檔案 URL,請至管理頁面啟用", - "fileAccessFirefox": "抱歉,Firefox 不支援此功能", "weekStart": "每週起始日 {input}", - "weekStartAsNormal": "依慣例" + "weekStartAsNormal": "依慣例", + "storage": "將追蹤資料儲存至{input}", + "storageConfirm": "您想將儲存格式改成{type}嗎?" }, - "dailyLimit": { + "limit": { "prompt": "限制時顯示提示文字 {input}", "reminder": "{input} 時間結束前 {minInput} 分鐘提醒", "level": { @@ -214,9 +222,6 @@ "type": "雲端服務類型 {input}", "client": "用戶端識別碼 {input}", "meta": { - "none": { - "label": "關閉備份" - }, "gist": { "authInfo": "需建立至少包含 gist 權限的 token" }, @@ -233,7 +238,6 @@ "operation": "備份資料", "download": { "btn": "下載", - "step2": "確認資料", "willDownload": "待下載資料", "confirmTip": "將從【{clientName}】下載 {size} 筆資料" }, @@ -241,6 +245,7 @@ "btn": "刪除資料", "confirmTip": "將刪除【{clientName}】追蹤的 {hostCount} 個網站共 {rowCount} 筆資料!" }, + "confirmStep": "確認資料", "clientTable": { "selectTip": "選擇用戶端", "dataRange": "資料範圍", @@ -257,6 +262,22 @@ "title": "無障礙設定", "chartDecal": "{input} 是否顯示圖表裝飾" }, + "notification": { + "title": "通知", + "cycle": { + "label": "通知頻率{input}", + "daily": "每日", + "weekly": "每週" + }, + "method": { + "label": "通知方式 {input}", + "browser": "瀏覽器", + "callback": { + "label": "HTTP 回應", + "url": "回應 URL {input}" + } + } + }, "resetButton": "重設", "resetSuccess": "已恢復預設值!", "exportButton": "匯出設定", @@ -271,12 +292,10 @@ "en": { "yes": "Yes", "no": "No", + "on": "Always on", + "off": "Always off", "followBrowser": "Follow browser", - "popup": { - "title": "Popup Page", - "max": "Show the first {input} data items", - "displaySiteName": "{input} Whether to display the website name instead of URL" - }, + "permGrantConfirm": "This feature requires relevant permissions", "appearance": { "title": "Appearance", "displayWhitelist": "{input} Whether to display {whitelist} in {contextMenu}", @@ -298,30 +317,27 @@ }, "darkMode": { "label": "Dark mode {input}", - "options": { - "on": "Always on", - "off": "Always off", - "timed": "Timed on" - } + "timed": "Timed on" }, - "animationDuration": "The duration of the chart's initial animation {input}" + "animationDuration": "The duration of the chart's initial animation {input}", + "sidePanel": "{input} Whether to enable the side panel" }, - "statistics": { + "tracking": { "title": "Tracking", "autoPauseTrack": "{input} Pause tracking if no activity detected {info} for {maxTime}", - "noActivityInfo": "The mouse and keyboard are inactive and not in full screen mode", + "noActivityInfo": "The mouse and keyboard are inactive, not in full-screen mode, and there is no sound playing", "countLocalFiles": "{input} Whether to track the time when the browser reads {localFileTime} {info}", "localFileTime": "local files", "localFilesInfo": "Supports files of types such as PDF, image, txt and json.", "countTabGroup": "{input} Whether to track the time of tab groups {info}", - "tabGroupInfo": "When you delete a tag group, the data will also be deleted.", - "tabGroupsPermGrant": "This feature requires relevant permissions", + "tabGroupInfo": "When you delete a tab group, the data will also be deleted.", "fileAccessDisabled": "Access to file URLs is currently not allowed. Please enable it on the manage page first", - "fileAccessFirefox": "Sorry, this feature is not supported in Firefox", "weekStart": "The first day for each week {input}", - "weekStartAsNormal": "As Normal" + "weekStartAsNormal": "As Normal", + "storage": "Store the tracking data in {input}", + "storageConfirm": "Do you want to change the storage type to {type}?" }, - "dailyLimit": { + "limit": { "prompt": "Prompt displayed when restricted {input}", "reminder": "{input} Reminder {minInput} minutes before time is up", "level": { @@ -340,17 +356,21 @@ "strictTitle": "Operation confirm", "strictContent": "When you select this option, if a site triggers daily limit, you will not be allowed to manually unblock it other than waiting until the next day. If rules are not set up properly, they can very well hinder your routines!", "pswFormLabel": "Password", - "pswFormAgain": "Re-enter" - } + "pswFormAgain": "Re-enter", + "2fa": "Must use 2FA code to unlock", + "twoFaTitle": "Enable 2FA", + "twoFaScanHint": "Scan the QR code with an authenticator app or import the setup link into a password manager that supports TOTP.", + "twoFaCopyLink": "Copy link", + "twoFaVerifyLabel": "Enter the 6-digit code to verify" + }, + "delayDuration": "Delay for {input} minutes per time" }, "backup": { "title": "Data Backup", "type": "Remote type {input}", "client": "Client name {input}", "meta": { - "none": { - "label": "Always off" - }, + "none": {}, "gist": { "authInfo": "One token with at least gist permission is required" }, @@ -368,7 +388,6 @@ "operation": "Backup", "download": { "btn": "Download", - "step2": "Confirm data", "willDownload": "To be downloaded", "confirmTip": "{size} pieces of data from [{clientName}] will be downloaded" }, @@ -376,6 +395,7 @@ "btn": "Clear", "confirmTip": "{rowCount} pieces of data for {hostCount} sites tracked by [{clientName}] will be deleted!" }, + "confirmStep": "Confirm data", "clientTable": { "selectTip": "Select client", "dataRange": "Range of data", @@ -392,6 +412,22 @@ "title": "Accessibility", "chartDecal": "{input} Whether to display the chart decal" }, + "notification": { + "title": "Notification", + "cycle": { + "label": "Notification cycle {input}", + "daily": "Daily", + "weekly": "Weekly" + }, + "method": { + "label": "Notification method {input}", + "browser": "Browser", + "callback": { + "label": "HTTP Callback", + "url": "Callback URL {input}" + } + } + }, "resetButton": "Reset", "resetSuccess": "Reset to default successfully!", "exportButton": "Export Settings", @@ -406,12 +442,9 @@ "ja": { "yes": "はい", "no": "いいえ", + "on": "常にオン", + "off": "常にオフ", "followBrowser": "ブラウザと同じ", - "popup": { - "title": "ポップアップページ", - "max": "最初の {input} 個のデータのみを表示し、残りのエントリは結合されます", - "displaySiteName": "{input} ドメインの代わりにウェブサイト名を表示するかどうか" - }, "appearance": { "title": "外観", "displayWhitelist": "{input} {contextMenu} に {whitelist} を表示するかどうか", @@ -433,27 +466,22 @@ }, "darkMode": { "label": "ダークモード {input}", - "options": { - "on": "常にオン", - "off": "常にオフ", - "timed": "時限スタート" - } + "timed": "時限スタート" }, "animationDuration": "チャートの初期アニメーションの持続時間 {input}" }, - "statistics": { + "tracking": { "title": "統計", "autoPauseTrack": "{input} アクティビティが検出されなかった場合、{info} {maxTime} の追跡を一時停止します", - "noActivityInfo": "マウスとキーボードが非アクティブで、全画面モードになっていません", "countLocalFiles": "{input} ブラウザで {localFileTime} {info} に費やされた時間をカウントするかどうか", "localFileTime": "ローカルファイルの読み取り", "localFilesInfo": "PDF、画像、txt、jsonを含む", "fileAccessDisabled": "ファイル URL へのアクセスは現在許可されていません。まず管理ページで有効にしてください。", - "fileAccessFirefox": "申し訳ありませんが、この機能はFirefoxではサポートされていません", "weekStart": "週の最初の日 {input}", - "weekStartAsNormal": "いつものように" + "weekStartAsNormal": "いつものように", + "storageConfirm": "ストレージタイプを {type}に変更しますか?" }, - "dailyLimit": { + "limit": { "prompt": "制限時に表示されるプロンプト {input}", "reminder": "{input} 時間切れの {minInput} 分前にリマインダーを送信します", "level": { @@ -480,9 +508,6 @@ "type": "バックアップ方法 {input}", "client": "クライアント名 {input}", "meta": { - "none": { - "label": "バックアップを有効にしない" - }, "gist": { "authInfo": "少なくとも gist 権限を持つトークンが 1 つ必要です" }, @@ -499,7 +524,6 @@ "operation": "バックアップ", "download": { "btn": "ダウンロード", - "step2": "データを確認", "willDownload": "ダウンロードするデータ", "confirmTip": "[{clientName}] から {size} 個のデータがダウンロードされます" }, @@ -507,6 +531,7 @@ "btn": "削除", "confirmTip": "[{clientName}] によって追跡されている {hostCount} サイトの {rowCount} 個のデータが削除されます。" }, + "confirmStep": "データを確認", "clientTable": { "selectTip": "クライアントの選択", "dataRange": "データ範囲", @@ -523,6 +548,20 @@ "title": "ユーザー補助機能", "chartDecal": "{input} チャートデカールを表示するかどうか" }, + "notification": { + "title": "通知", + "cycle": { + "daily": "毎日", + "weekly": "毎週" + }, + "method": { + "label": "通知方法{input}", + "browser": "ブラウザ", + "callback": { + "label": "HTTP Callback" + } + } + }, "resetButton": "リセット", "resetSuccess": "デフォルトに正常にリセット", "exportButton": "設定をエクスポート", @@ -537,12 +576,10 @@ "pt_PT": { "yes": "Sim", "no": "Não", + "on": "Sempre ativo", + "off": "Sempre inativo", "followBrowser": "Usar do navegador", - "popup": { - "title": "Página Pop-up", - "max": "Mostrar os primeiros {input} itens", - "displaySiteName": "{input} Se deve apresentar o nome do site em vez do domínio" - }, + "permGrantConfirm": "Esta funcionalidade precisa de permissões relevantes", "appearance": { "title": "Aparência", "displayWhitelist": "{input} Mostrar {whitelist} no {contextMenu}", @@ -564,27 +601,25 @@ }, "darkMode": { "label": "Modo escuro {input}", - "options": { - "on": "Sempre ativo", - "off": "Sempre inativo", - "timed": "Ativo por tempo" - } + "timed": "Ativo por tempo" }, - "animationDuration": "Duração da animação inicial {input}" + "animationDuration": "Duração da animação inicial {input}", + "sidePanel": "{input} Se ativar ou não o painel lateral" }, - "statistics": { + "tracking": { "title": "Estatísticas", "autoPauseTrack": "{input} Pausar sem atividade {info} por {maxTime}", - "noActivityInfo": "Sem atividade do rato/teclado e não em ecrã completo", + "noActivityInfo": "O mouse e o teclado estão inativos, não está em modo tela cheia, e nenhum som sendo reproduzido", "countLocalFiles": "{input} Contar tempo a {localFileTime} {info}", "localFileTime": "ler ficheiros locais", "localFilesInfo": "Suporta PDF, imagens, txt e json.", + "countTabGroup": "{input} Se quiser acompanhar o tempo da aba{info}", + "tabGroupInfo": "Quando você excluir um grupo de tags, os dados também serão excluídos.", "fileAccessDisabled": "Acesso a URLs de ficheiro não permitido. Ative na página de gestão.", - "fileAccessFirefox": "Não suportado no Firefox", "weekStart": "Primeiro dia da semana {input}", "weekStartAsNormal": "Normal" }, - "dailyLimit": { + "limit": { "prompt": "Aviso quando restrito {input}", "reminder": "{input} Aviso {minInput} minutos antes", "level": { @@ -611,9 +646,6 @@ "type": "Tipo remoto {input}", "client": "Nome do cliente {input}", "meta": { - "none": { - "label": "Sempre inativo" - }, "gist": { "authInfo": "Necessário token com permissão gist" }, @@ -630,7 +662,6 @@ "operation": "Cópia", "download": { "btn": "Transferir", - "step2": "Confirmar dados", "willDownload": "Dados a transferir", "confirmTip": "{size} dados de [{clientName}] serão transferidos" }, @@ -638,6 +669,7 @@ "btn": "Limpar", "confirmTip": "{rowCount} dados de {hostCount} sites de [{clientName}] serão eliminados!" }, + "confirmStep": "Confirmar dados", "clientTable": { "selectTip": "Selecionar cliente", "dataRange": "Intervalo de dados", @@ -647,7 +679,7 @@ "lastTimeTip": "Última cópia: {lastTime}", "auto": { "label": "Cópia automática", - "interval": "executar a cada {input} minutos" + "interval": "e executar a cada {input} minutos" } }, "accessibility": { @@ -656,17 +688,22 @@ }, "resetButton": "Repor", "resetSuccess": "Reposição concluída!", + "exportButton": "Exportar configurações", + "importButton": "Importar configurações", + "exportSuccess": "Configurações exportadas com sucesso", + "importSuccess": "Configurações importadas com sucesso\n", + "importError": "Falha na Importação: Documento de configurações inválido", + "importConfirm": "Configurações importadas com sucesso, recarregue a página para aplicar às mudanças!", + "reloadButton": "Recarregar", "defaultValue": "Predefinido: {default}" }, "uk": { "yes": "Так", "no": "Ні", + "on": "Завжди ввімкнено", + "off": "Завжди вимкнено", "followBrowser": "Як у браузері", - "popup": { - "title": "Вікно розширення", - "max": "Кількість записів для показу: {input}", - "displaySiteName": "{input} Чи відображати назву веб-сайту замість домену" - }, + "permGrantConfirm": "Ця функція потребує відповідних дозволів", "appearance": { "title": "Зовнішній вигляд", "displayWhitelist": "{input} Показувати {whitelist} в {contextMenu}", @@ -688,27 +725,23 @@ }, "darkMode": { "label": "Темний режим: {input}", - "options": { - "on": "Увімкнено", - "off": "Вимкнено", - "timed": "За розкладом" - } + "timed": "За розкладом" }, "animationDuration": "Тривалість початкової анімації діаграми {input}" }, - "statistics": { + "tracking": { "title": "Статистика", - "autoPauseTrack": "{input} Призупинити відстеження, якщо не виявлено активності {info} протягом {maxTime}", - "noActivityInfo": "Миша та клавіатура неактивні та не в повноекранному режимі", - "countLocalFiles": "{input} Враховувати час {localFileTime} {info} в браузері", + "autoPauseTrack": "{input} Призупинити відстеження, якщо не виявлено активності протягом {maxTime} {info}", + "countLocalFiles": "{input} Враховувати час {localFileTime} в браузері {info}", "localFileTime": "перегляду локального файлу", "localFilesInfo": "Підтримуються файли PDF, зображення, текстові та формат json", + "countTabGroup": "{input} Відстежувати час груп вкладок {info}", + "tabGroupInfo": "Якщо видалити групу вкладок, дані також видаляться.", "fileAccessDisabled": "Доступ до URL-адрес файлу наразі не дозволено. Спершу ввімкніть на сторінці керування", - "fileAccessFirefox": "На жаль, ця функція не підтримується у Firefox", "weekStart": "Перший день тижня: {input}", "weekStartAsNormal": "Типово" }, - "dailyLimit": { + "limit": { "prompt": "Запит, який відображається під час обмеження {input}", "reminder": "{input} Нагадування за {minInput} хвилин до закінчення часу", "level": { @@ -727,7 +760,7 @@ "strictTitle": "Підтвердження операції", "strictContent": "Коли ви вибираєте цю опцію, якщо сайт активує щоденний ліміт, ви не зможете розблокувати його вручну до наступного дня. Якщо не налаштувати правила належним чином, вони можуть дуже заважати вашій роботі!", "pswFormLabel": "Пароль", - "pswFormAgain": "Змінити" + "pswFormAgain": "Повторне введення" } }, "backup": { @@ -735,9 +768,6 @@ "type": "Тип резервного копіювання {input}", "client": "Назва клієнта {input}", "meta": { - "none": { - "label": "Завжди вимкнено" - }, "gist": { "authInfo": "Потрібно вказати токен для доступу gist" }, @@ -754,7 +784,6 @@ "operation": "Зробити резервну копію", "download": { "btn": "Завантажити", - "step2": "Підтвердження даних", "willDownload": "До завантаження", "confirmTip": "Буде завантажено {size} елементів даних із [{clientName}]" }, @@ -762,6 +791,7 @@ "btn": "Очистити", "confirmTip": "Буде видалено {rowCount} елементів даних для {hostCount} сайтів, записаних у [{clientName}]!" }, + "confirmStep": "Підтвердження даних", "clientTable": { "selectTip": "Вибір клієнта", "dataRange": "Діапазон даних", @@ -776,21 +806,26 @@ }, "accessibility": { "title": "Доступність", - "chartDecal": "{input} Чи відображати наклейку діаграми" + "chartDecal": "{input} Показувати наклейку діаграми" }, "resetButton": "Скинути", "resetSuccess": "Скидання виконано успішно!", + "exportButton": "Експортувати налаштування", + "importButton": "Імпортувати налаштування", + "exportSuccess": "Налаштування успішно експортовано", + "importSuccess": "Налаштування успішно імпортовано", + "importError": "Помилка імпорту: неприпустимий файл налаштувань", + "importConfirm": "Налаштування успішно імпортовано. Перезавантажте сторінку для застосування змін!", + "reloadButton": "Перезавантажити", "defaultValue": "Типово: {default}" }, "es": { "yes": "Sí", "no": "No", + "on": "Siempre encendido", + "off": "Siempre apagado", "followBrowser": "Igual que el navegador", - "popup": { - "title": "Página emergente", - "max": "Mostrar los primeros {input} elementos de datos", - "displaySiteName": "{input} Si se debe mostrar el nombre del sitio web en lugar del dominio" - }, + "permGrantConfirm": "Esta función requiere permisos pertinentes", "appearance": { "title": "Apariencia", "displayWhitelist": "{input} Mostrar {whitelist} en {contextMenu}", @@ -812,30 +847,23 @@ }, "darkMode": { "label": "Modo oscuro {input}", - "options": { - "on": "Siempre encendido", - "off": "Siempre apagado", - "timed": "Cronometrado" - } + "timed": "Cronometrado" }, "animationDuration": "Duración de la animación inicial del gráfico {input}" }, - "statistics": { + "tracking": { "title": "Estadísticas", "autoPauseTrack": "{input} Pausar el seguimiento si no se detecta actividad {info} durante {maxTime}", - "noActivityInfo": "El mousse y el teclado están inactivos y no en modo de pantalla completa", "countLocalFiles": "{input} Contar el tiempo para {localFileTime} {info} en el navegador", "localFileTime": "leer un archivo local", "localFilesInfo": "Soporta archivos de tipos como PDF, imagen, TXT y JSON", "countTabGroup": "{input} Rastrear el tiempo de los grupos de pestañas {info}", "tabGroupInfo": "Al eliminar un grupo de pestañas, sus datos también se borrarán.", - "tabGroupsPermGrant": "Esta función requiere permisos pertinentes", "fileAccessDisabled": "Actualmente no se permite el acceso a las URL de archivos. Habilítelo primero en la página de administración", - "fileAccessFirefox": "Lo sentimos, esta función no es compatible con Firefox", "weekStart": "El primer día de cada semana {input}", "weekStartAsNormal": "Como normalmente" }, - "dailyLimit": { + "limit": { "prompt": "Punto mostrado cuando está restringido {input}", "reminder": "{input} Recordatorio {minInput} minutos antes de que se acabe el tiempo", "level": { @@ -862,9 +890,6 @@ "type": "Tipo remoto {input}", "client": "Nombre del cliente {input}", "meta": { - "none": { - "label": "Siempre apagado" - }, "gist": { "authInfo": "Se requiere un token con al menos los permisos esenciales" }, @@ -881,7 +906,6 @@ "operation": "Copia de seguridad", "download": { "btn": "Descargar", - "step2": "Confirmar datos", "willDownload": "Para ser descargado", "confirmTip": "Se descargarán {size} piezas de datos de [{clientName}]" }, @@ -889,6 +913,7 @@ "btn": "Limpiar", "confirmTip": "¡Se eliminarán {rowCount} piezas de datos para {hostCount} sitios rastreados por [{clientName}]!" }, + "confirmStep": "Confirmar datos", "clientTable": { "selectTip": "Seleccionar cliente", "dataRange": "Rango de datos", @@ -919,12 +944,10 @@ "de": { "yes": "Ja", "no": "Nein", + "on": "Immer an", + "off": "Immer aus", "followBrowser": "Browser verfolgen", - "popup": { - "title": "Popup-Seite", - "max": "Zeige die ersten {input} Datenelemente", - "displaySiteName": "{input} Ob der Websitename anstelle der Domäne angezeigt werden soll" - }, + "permGrantConfirm": "Dieses Feature benötigt entsprechende Berechtigungen", "appearance": { "title": "Aussehen", "displayWhitelist": "{input} {whitelist} in {contextMenu} anzeigen", @@ -932,8 +955,8 @@ "contextMenu": "Kontextmenü", "displayBadgeText": "{input} {timeInfo} in {icon} anzeigen", "badgeBgColor": "Die Hintergrundfarbe des Textes auf dem Symbol {input}", - "icon": "das Symbol der Erweiterung", - "badgeTextContent": "die Besuchszeit der aktuellen Webseite", + "icon": "Icon der Erweiterung", + "badgeTextContent": "Besuchszeit der aktuellen Webseite", "locale": { "label": "Sprache {input}", "changeConfirm": "Die Sprache wurde erfolgreich geändert. Bitte lade diese Seite neu!", @@ -946,27 +969,29 @@ }, "darkMode": { "label": "Dunkler Modus {input}", - "options": { - "on": "Immer an", - "off": "Immer aus", - "timed": "Zeitgesteuert" - } - } + "timed": "Zeitsteuerung aktiv" + }, + "animationDuration": "Die Dauer der ersten Animation des Diagramms {input}", + "sidePanel": "{input} Ob das Seitenpanel aktiviert wird" }, - "statistics": { + "tracking": { "title": "Statistik", "autoPauseTrack": "{input} Tracking pausieren, wenn {maxTime} keine Aktivität erkannt wird {info}", - "noActivityInfo": "Die Maus und die Tastatur sind inaktiv und nicht im Vollbildmodus", + "noActivityInfo": "Maus und Tastatur sind inaktiv, nicht im Vollbildmodus und es gibt kein Audio", "countLocalFiles": "{input} Zeit an {localFileTime} {info} im Browser zählen", "localFileTime": "eine lokale Datei lesen", "localFilesInfo": "Unterstützt Dateitypen, wie PDF, Bilder, .txt und .json", + "countTabGroup": "{input} Ob die Zeit der Tab-Gruppen {info} verfolgt werden soll", + "tabGroupInfo": "Wenn Sie eine Tag-Gruppe löschen, werden auch die Daten gelöscht.", "fileAccessDisabled": "Der Zugriff auf Datei-URLs ist derzeit nicht erlaubt. Bitte aktiviere ihn zuerst auf der Verwaltungsseite", - "fileAccessFirefox": "Leider wird diese Funktion in Firefox nicht unterstützt", "weekStart": "Erster Tag der Woche {input}", - "weekStartAsNormal": "Wie normal" + "weekStartAsNormal": "Wie normal", + "storage": "Speichern der Trackingdaten in {input}", + "storageConfirm": "Möchten Sie die Speicherart ändern zu {type}?" }, - "dailyLimit": { + "limit": { "prompt": "Eingabeaufforderung wird angezeigt, wenn eingeschränkt {input}", + "reminder": "{input} Erinnerung {minInput} Minuten bevor die Zeit abgelaufen ist", "level": { "label": "Wie man bei eingeschränktem {input} entsperrt", "nothing": "Erlaube das direkte Entsperren auf der Admin-Seite", @@ -981,17 +1006,22 @@ }, "strict": "Entsperren trotzdem nicht zulassen", "strictTitle": "Operation bestätigen", - "strictContent": "Wenn Sie diese Option wählen, wenn eine Site Tageslimit auslöst, Sie dürfen die Blockierung nur bis zum nächsten Tag manuell entsperren. Wenn Regeln nicht richtig eingerichtet sind, können sie sehr gut Ihre Routinen behindern!" - } + "strictContent": "Wenn Sie diese Option wählen, wenn eine Site Tageslimit auslöst, Sie dürfen die Blockierung nur bis zum nächsten Tag manuell entsperren. Wenn Regeln nicht richtig eingerichtet sind, können sie sehr gut Ihre Routinen behindern!", + "pswFormLabel": "Passwort", + "pswFormAgain": "Erneut eingeben", + "2fa": "Zum Entsperren ist ein 2FA‑Code erforderlich", + "twoFaTitle": "Zwei Faktor Authentifikation - 2FA nutzen", + "twoFaScanHint": "Scanne den QR‑Code mit einer Authentifikation‑App oder importiere den Einrichtungslink in einen TOTP‑fähigen Passwortmanager.", + "twoFaCopyLink": "Link kopieren", + "twoFaVerifyLabel": "Zur Bestätigung den 6‑stelligen Code eingeben" + }, + "delayDuration": "Verzögerung für {input} Minuten pro Vorgang" }, "backup": { "title": "Datensicherung", "type": "Remote Typ {input}", "client": "Kundenname {input}", "meta": { - "none": { - "label": "Immer aus" - }, "gist": { "authInfo": "Ein Token mit mindestens gist Berechtigung ist erforderlich" }, @@ -1008,7 +1038,6 @@ "operation": "Backup", "download": { "btn": "Herunterladen", - "step2": "Daten bestätigen", "willDownload": "Zum Herunterladen", "confirmTip": "Lädt {size} Datenstücke von [{clientName}] herunter" }, @@ -1016,6 +1045,7 @@ "btn": "Löschen", "confirmTip": "{rowCount} Datenelemente für {hostCount} Websites, die von [{clientName}] verfolgt werden, werden gelöscht!" }, + "confirmStep": "Daten bestätigen", "clientTable": { "selectTip": "Wählen Sie ein Terminal aus", "dataRange": "Datenreichweite", @@ -1032,25 +1062,46 @@ "title": "Barrierefreiheit", "chartDecal": "{input} Ob das Diagramm-Aufkleber angezeigt werden soll" }, + "notification": { + "title": "Benachrichtigungen", + "cycle": { + "label": "Benachrichtigungszyklus {input}", + "daily": "Täglich", + "weekly": "Wöchentlich" + }, + "method": { + "label": "Nachrichtenmethode {input}", + "browser": "Browser", + "callback": { + "label": "HTTP Callback", + "url": "Callback URL {input}" + } + } + }, "resetButton": "Zurücksetzen", "resetSuccess": "Auf Standardwerte zurückgesetzt!", + "exportButton": "Einstellungen exportieren", + "importButton": "Einstellungen importieren", + "exportSuccess": "Einstellungen erfolgreich exportiert", + "importSuccess": "Einstellungen erfolgreich importiert", + "importError": "Import fehlgeschlagen: Ungültige Konfigurationsdatei", + "importConfirm": "Einstellungen erfolgreich importiert. Bitte laden Sie die Seite neu, um die Änderungen zu übernehmen!", + "reloadButton": "Neu laden", "defaultValue": "Standard: {default}" }, "fr": { "yes": "Oui", "no": "Non", + "on": "Toujours activé", + "off": "Toujours éteint", "followBrowser": "Suivre le navigateur", - "popup": { - "title": "Page pop-up", - "max": "Afficher les {input} premiers éléments de données", - "displaySiteName": "{input} S'il faut afficher le nom du site Web au lieu du domaine" - }, + "permGrantConfirm": "Cette fonctionnalité nécessite des autorisations appropriées", "appearance": { "title": "Apparence", - "displayWhitelist": "{input} S'il faut afficher là {whitelist} dans {contextMenu}", - "whitelistItem": "whitelist related shortcuts", - "contextMenu": "le menu contextuel.", - "displayBadgeText": "{input} S'il faut afficher là {timeInfo} dans {icon}", + "displayWhitelist": "{input} S'il faut afficher les {whitelist} dans {contextMenu}", + "whitelistItem": "raccourcis liés à la liste blanche", + "contextMenu": "le menu contextuel", + "displayBadgeText": "{input} S'il faut afficher {timeInfo} dans {icon}", "badgeBgColor": "La couleur de fond du texte sur l'icône {input}", "icon": "l'icône de l'extension", "badgeTextContent": "le temps de navigation du site actuel", @@ -1060,40 +1111,37 @@ "reloadButton": "Redémarrer" }, "printInConsole": { - "label": "{input} S'il faut imprimer {info} dans là {console}", + "label": "{input} S'il faut afficher {info} dans la {console}", "console": "console", "info": "le nombre de visites du site actuel aujourd'hui" }, "darkMode": { "label": "Mode sombre {input}", - "options": { - "on": "Toujours On", - "off": "Toujours éteint", - "timed": "Horaire" - } + "timed": "Horaire" }, - "animationDuration": "La durée de l'animation initiale du graphique {input}" + "animationDuration": "La durée de l'animation initiale du graphique {input}", + "sidePanel": "{input} Autoriser l'affichage du panneau latéral" }, - "statistics": { + "tracking": { "title": "Statistiques", "autoPauseTrack": "{input} Pause de suivi si aucune activité n'a détecté {info} pour {maxTime}", - "noActivityInfo": "La souris et le clavier sont inactifs et non en mode plein écran", - "countLocalFiles": "{input} S'il faut compter le temps jusqu'à {localFileTime} {info} dans le navigateur", + "noActivityInfo": "La souris et le clavier sont inactifs, pas en plein écran, et il n'y a pas de son joué", + "countLocalFiles": "{input} S'il faut compter le temps passé à {localFileTime} {info} dans le navigateur", "localFileTime": "lire un fichier local", "localFilesInfo": "Prend en charge les fichiers de types tels que PDF, image, txt et json.", "countTabGroup": "{input} Suivre le temps des groupes d'onglets {info}", "tabGroupInfo": "Lorsque vous supprimez un groupe d'onglets, les données seront également supprimées.", - "tabGroupsPermGrant": "Cette fonctionnalité nécessite des autorisations pertinentes", - "fileAccessDisabled": "L'accès aux URL des fichiers n'est actuellement pas autorisé. Veuillez d'abord l'activer dans la page de gestion.", - "fileAccessFirefox": "Désolé, cette fonctionnalité n'est pas prise en charge dans Firefox", + "fileAccessDisabled": "L'accès aux URL des fichiers n'est actuellement pas autorisé. Veuillez d'abord l'activer dans la page de gestion", "weekStart": "Le premier jour de chaque semaine {input}", - "weekStartAsNormal": "Comme d'habitude" + "weekStartAsNormal": "Comme d'habitude", + "storage": "Stocker les données de suivi dans {input}", + "storageConfirm": "Voulez-vous changer le type de stockage en {type} ?" }, - "dailyLimit": { + "limit": { "prompt": "Invite affichée en cas de restriction {input}", "reminder": "{input} Rappel {minInput} minutes avant la fin du temps", "level": { - "label": "Comment déverrouiller en étant restreint {input}", + "label": "Comment déverrouiller en étant limité {input}", "nothing": "Autoriser le déverrouillage direct sur la page d'administration", "password": "Vous devez entrer le mot de passe pour déverrouiller", "verification": "Vous devez entrer le code de vérification pour déverrouiller", @@ -1104,21 +1152,24 @@ "hard": "Strict", "disgusting": "Dégoûtant" }, - "strict": "Ne pas autoriser le déblocage quand même", + "strict": "Ne pas autoriser le déblocage", "strictTitle": "Confirmation de l'opération", "strictContent": "Lorsque vous sélectionnez cette option, si un site déclenche une limite quotidienne, vous ne serez pas autorisé à le débloquer manuellement sauf en attendant le lendemain. Si les règles ne sont pas correctement configurées, elles peuvent très bien gêner vos routines !", "pswFormLabel": "Mot de passe", - "pswFormAgain": "Retapez" - } + "pswFormAgain": "Retapez", + "2fa": "Utilisez le code 2FA pour déverrouiller", + "twoFaTitle": "Activer l'authentification à deux facteurs (A2F)", + "twoFaScanHint": "Scannez le code QR avec une application d'authentification ou importez le lien d'installation dans un gestionnaire de mots de passe qui prend en charge TOTP.", + "twoFaCopyLink": "Copier le lien", + "twoFaVerifyLabel": "Entrez le code à 6 chiffres pour verifier" + }, + "delayDuration": "Délai additionnel de {input} minutes à chaque fois" }, "backup": { "title": "Sauvegarde des données", - "type": "Type distant {input}", + "type": "Choix de la sauvegarde {input}", "client": "Nom du client {input}", "meta": { - "none": { - "label": "Toujours éteint" - }, "gist": { "authInfo": "Un jeton avec au moins une permission de gist est requis" }, @@ -1135,7 +1186,6 @@ "operation": "Sauvegarder", "download": { "btn": "Télécharger", - "step2": "Confirmer les données", "willDownload": "À télécharger", "confirmTip": "{size} éléments de données de [{clientName}] seront téléchargés" }, @@ -1143,6 +1193,7 @@ "btn": "Effacer", "confirmTip": "Les données {rowCount} pour les sites {hostCount} suivis par [{clientName}] seront supprimées !" }, + "confirmStep": "Confirmer les données", "clientTable": { "selectTip": "Sélectionnez le client", "dataRange": "Plage de données", @@ -1157,7 +1208,23 @@ }, "accessibility": { "title": "Accessibilité", - "chartDecal": "{input} S'il faut afficher l'autocollant de la carte" + "chartDecal": "{input} afficher les motifs de la carte" + }, + "notification": { + "title": "Notification", + "cycle": { + "label": "Cycle des notifications {input}", + "daily": "Quotidien", + "weekly": "Hebdomadaire" + }, + "method": { + "label": "Méthode de notification {input}", + "browser": "Navigateur Web", + "callback": { + "label": "HTTP Callback", + "url": "Callback URL {input}" + } + } }, "resetButton": "Réinitialiser", "resetSuccess": "Remise à zéro avec succès !", @@ -1173,11 +1240,9 @@ "ru": { "yes": "Да", "no": "Нет", - "popup": { - "title": "Всплывающее окно", - "max": "Показать первые {input} элементов данных", - "displaySiteName": "{input} Отображать ли имя веб-сайта вместо домена" - }, + "on": "Всегда включен", + "off": "Всегда выключен", + "followBrowser": "Как в браузере", "appearance": { "title": "Появление", "displayWhitelist": "{input} Отображать ли {whitelist} в {contextMenu}", @@ -1189,43 +1254,130 @@ "badgeTextContent": "время просмотра текущего веб-сайта", "locale": { "label": "Язык {input}", + "changeConfirm": "Язык был успешно изменён, пожалуйста, перезагрузите эту страницу!", "reloadButton": "Обновить" }, "printInConsole": { - "console": "консоль" + "label": "{input} Отображать ли {info} в {console}", + "console": "консоль", + "info": "посещений текущего сайта за сегодня" }, "darkMode": { "label": "Тёмный режим {input}", - "options": { - "on": "Всегда включен" - } + "timed": "По времени" }, - "animationDuration": "Длительность начальной анимации графика {input}" + "animationDuration": "Длительность начальной анимации графика {input}", + "sidePanel": "{input} включает ли боковую панель" }, - "statistics": { + "tracking": { "title": "Статистика", - "localFilesInfo": "Поддержка типов файлов, таких как PDF, изображения, txt и json." + "autoPauseTrack": "{input} Приостановить отслеживание, если нет активности {info} в течение {maxTime}", + "noActivityInfo": "Мышь и клавиатура неактивны, не в полноэкранном режиме, и нет воспроизводимого звука.", + "countLocalFiles": "{input} Отслеживать ли время, когда браущер читает {localFileTime} {info}", + "localFilesInfo": "Поддержка типов файлов, таких как PDF, изображения, txt и json.", + "countTabGroup": "{input} Отслеживать время для групп вкладок {info}", + "tabGroupInfo": "При удалении группы вкладок данные также будут удалены.", + "fileAccessDisabled": "Доступ к URL-адресам файлов в настоящее время запрещен. Пожалуйста, сначала включите его на странице управления", + "weekStart": "Первый день каждой недели {input}", + "weekStartAsNormal": "Как обычно (Воскресенье)", + "storage": "Сохранять данные отслеживания в {input}", + "storageConfirm": "Вы хотите изменить тип хранилища на {type}" + }, + "limit": { + "prompt": "Шаблон, отображаемый при ограниченных {input}", + "reminder": "{input} Напоминать за {minInput} минут до окончания времени", + "level": { + "label": "Как разблокировать при ограничении {input}", + "nothing": "Разрешить прямую разблокировку на странице администратора", + "password": "Необходимо ввести пароль для разблокировки", + "verification": "Необходимо ввести проверочный код для разблокировки", + "passwordLabel": "Пароль для разблокировки {input}", + "verificationLabel": "Сложность проверочного кода {input}", + "verificationDifficulty": { + "easy": "Лёгкий", + "hard": "Сложный", + "disgusting": "Отвратительный" + }, + "strict": "Не разрешать разблокировку при любых обстоятельствах", + "strictTitle": "Подтверждение операции", + "strictContent": "При выборе этой опции, если сайт вызывает ежедневное ограничение, вам не будет разрешено разблокировать его вручную, кроме ожидания, до следующего дня. Если правила настроены неправильно, они могут очень затруднять ваши процедуры!", + "pswFormLabel": "Пароль", + "pswFormAgain": "Введите повторно" + } }, "backup": { "title": "Резервное копирование данных", + "type": "Дистанционный тип {input}", + "client": "Наименование клиента {input}", + "meta": { + "gist": { + "authInfo": "Необходим хотя бы один Gist токен с разрешением" + }, + "obsidian_local_rest_api": { + "endpointInfo": "Доступен только HTTP, так как CORS не может быть настроен для страниц расширений" + } + }, + "label": { + "endpoint": "Конечный адрес {info} {input}", + "path": "Путь к директории {input}", + "account": "Имя пользователя {input}", + "password": "Пароль {input}" + }, "operation": "Резервное копирование", "download": { - "btn": "Скачать" + "btn": "Скачать", + "willDownload": "Для скачивания", + "confirmTip": "{size} фрагментов данных из [{clientName}] будут загружены" + }, + "clear": { + "btn": "Очистить", + "confirmTip": "{rowCount} фрагментов данных для {hostCount} сайтов, отслеживаемых [{clientName}] будут удалены!" + }, + "confirmStep": "Подтвердить данные", + "clientTable": { + "selectTip": "Выбрать клиент", + "dataRange": "Диапазон данных", + "notSelected": "Клиент не выбран", + "current": "Текущий" + }, + "lastTimeTip": "Время последнего резервного копирования: {lastTime}", + "auto": { + "label": "Включить ли автоматическое резервное копирование?", + "interval": "и запускать каждые {input} минут" + } + }, + "accessibility": { + "title": "Специальные возможности", + "chartDecal": "{input} Отображать клетки на графике" + }, + "notification": { + "title": "Уведомление", + "cycle": { + "label": "Цикл уведомлений {input}", + "daily": "Ежедневно", + "weekly": "Еженедельно" + }, + "method": { + "label": "Метод оповещения {input}" } }, "resetButton": "Сброс", "resetSuccess": "Сброс к настройкам по умолчанию выполнен успешно!", + "exportButton": "Экспорт настроек", + "importButton": "Импорт настроек", + "exportSuccess": "Настройки успешно экспортированы", + "importSuccess": "Настройки успешно импортированы", + "importError": "Не удалось импортировать: недопустимый файл настроек", + "importConfirm": "Настройки успешно импортированы, пожалуйста, перезагрузите страницу, чтобы применить изменения!", + "reloadButton": "Обновить", "defaultValue": "По умолчанию: {default}" }, "ar": { "yes": "نعم", "no": "لا", + "on": "تفعيل دائم", + "off": "إيقاف دائم", "followBrowser": "نفس وضع المتصفح", - "popup": { - "title": "صفحة منبثقة", - "max": "عرض أول {input} عنصر من البيانات", - "displaySiteName": "{input} هل سيتم عرض اسم الموقع بدلاً من النطاق" - }, "appearance": { "title": "المظهر", "displayWhitelist": "{input} هل يتم عرض {whitelist} في {contextMenu}", @@ -1247,30 +1399,23 @@ }, "darkMode": { "label": "الوضع الداكن {input}", - "options": { - "on": "تفعيل دائم", - "off": "إيقاف دائم", - "timed": "تفعيل مؤقت" - } + "timed": "تفعيل مؤقت" }, "animationDuration": "مدة تحريك الرسوم البيانية {input}" }, - "statistics": { + "tracking": { "title": "الإحصائيات", "autoPauseTrack": "{input} إيقاف تتبع الوقت إذا لم يكن هناك تفاعل {info} لمدة {maxTime}", - "noActivityInfo": "الفأرة ولوحة المفاتيح غير نشطة والشاشة ليست في وضع ملء الشاشة", "countLocalFiles": "{input} هل يتم حساب الوقت عند فتح {localFileTime} في المتصفح {info}", "localFileTime": "الملفات المحلية", "localFilesInfo": "يدعم ملفات مثل PDF، الصور، txt و json.", "countTabGroup": "{input} هل يتم تتبع وقت مجموعات علامات التبويب {info}", "tabGroupInfo": "عند حذف مجموعة علامات تبويب، سيتم حذف البيانات أيضًا.", - "tabGroupsPermGrant": "هذه الميزة تتطلب أذونات ذات صلة", "fileAccessDisabled": "الوصول إلى عناوين الملفات غير مسموح به حاليًا. يرجى تفعيل الخاصية من صفحة الإدارة أولاً", - "fileAccessFirefox": "عذراً، هذه الميزة غير مدعومة في فايرفوكس", "weekStart": "اليوم الأول لكل أسبوع {input}", "weekStartAsNormal": "بشكل عادي" }, - "dailyLimit": { + "limit": { "prompt": "رسالة التنبيه عند التقييد {input}", "reminder": "{input} تذكير {minInput} دقائق قبل انتهاء الوقت", "level": { @@ -1297,9 +1442,6 @@ "type": "نوع الربط الخارجي {input}", "client": "اسم العميل {input}", "meta": { - "none": { - "label": "معطّل دائمًا" - }, "gist": { "authInfo": "مطلوب رمز وصول واحد على الأقل مع صلاحية Gist" }, @@ -1316,7 +1458,6 @@ "operation": "إنشاء نسخة إحتياطية", "download": { "btn": "تحميل", - "step2": "تأكيد البيانات", "willDownload": "ليتم تحميله", "confirmTip": "سيتم تنزيل {size} من البيانات من [{clientName}]" }, @@ -1324,6 +1465,7 @@ "btn": "مسح", "confirmTip": "سيتم حذف {rowCount} من البيانات لـ {hostCount} موقعًا التي يتتبعها [{clientName}]!" }, + "confirmStep": "تأكيد البيانات", "clientTable": { "selectTip": "اختر عميلاً", "dataRange": "مجال البيانات", @@ -1350,5 +1492,437 @@ "importConfirm": "تم استيراد الإعدادات بنجاح، يرجى إعادة تحميل الصفحة لتطبيق التغييرات!", "reloadButton": "إعادة التحميل", "defaultValue": "الوضع الإفتراضي: {default}" + }, + "tr": { + "yes": "Evet", + "no": "Hayır", + "on": "Her zaman açık", + "off": "Her zaman kapalı", + "followBrowser": "Tarayıcıyı takip et", + "permGrantConfirm": "Bu özellik ilgili izinleri gerektirir", + "appearance": { + "title": "Görünüm", + "displayWhitelist": "{input} {contextMenu} içinde {whitelist} gösterilip gösterilmeyeceğini seçin", + "whitelistItem": "beyaz liste ile ilgili kısayollar", + "contextMenu": "içerik menüsü", + "displayBadgeText": "{input} {timeInfo} öğesini {icon} içinde gösterip göstermemeyi seçin", + "badgeBgColor": "Simge üzerindeki metnin arka plan rengi {input}", + "icon": "uzantı simgesi", + "badgeTextContent": "mevcut web sitesinin gezinme süresi", + "locale": { + "label": "Dil {input}", + "changeConfirm": "Dil başarıyla değiştirildi, lütfen bu sayfayı yeniden yükleyin!", + "reloadButton": "Yeniden yükle" + }, + "printInConsole": { + "label": "{input} {console} içinde {info} yazdırılıp yazdırılmayacağını seçin", + "console": "konsol", + "info": "bugün mevcut web sitesinin ziyaret sayısı" + }, + "darkMode": { + "label": "Karanlık mod {input}", + "timed": "Zamanlanmış" + }, + "animationDuration": "Grafiğin ilk animasyonunun süresi {input}", + "sidePanel": "{input} Yan panelin gösterilip gösterilmeyeceğini seçin" + }, + "tracking": { + "title": "İzleme", + "autoPauseTrack": "{input} {maxTime} süresince herhangi bir etkinlik algılanmazsa izlemeyi duraklat {info}", + "noActivityInfo": "Fare ve klavye etkin değil, tam ekran modunda değil ve ses çalınmıyor", + "countLocalFiles": "{input} Tarayıcının {localFileTime} {info} dosyasını okuduğu zamanı izlemek isteyip istemediğinizi seçin", + "localFileTime": "yerel dosyalar", + "localFilesInfo": "PDF, resim, txt ve json gibi dosya türlerini destekler.", + "countTabGroup": "{input} Sekme gruplarının zamanını takip etmek isteyip istemediğinizi seçin {info}", + "tabGroupInfo": "Bir etiket grubunu sildiğinizde, veriler de silinir.", + "fileAccessDisabled": "Dosya URL'lerine erişim şu anda izin verilmiyor. Lütfen önce yönetim sayfasında bunu etkinleştirin", + "weekStart": "Her haftanın ilk günü {input}", + "weekStartAsNormal": "Normal", + "storage": "İzleme verilerini {input} içinde saklayın.", + "storageConfirm": "Depolama türünü {type} olarak değiştirmek ister misiniz?" + }, + "limit": { + "prompt": "Kısıtlı olduğunda gösterilecek uyarı {input}", + "reminder": "{input} Süre dolmadan {minInput} dakika önce hatırlatma", + "level": { + "label": "Kısıtlıyken kilit nasıl açılsın {input}", + "nothing": "Yönetici sayfasında doğrudan kilit açmaya izin ver", + "password": "Kilidi açmak için şifre girilmelidir", + "verification": "Kilidi açmak için doğrulama kodunu girmeniz gerekir", + "passwordLabel": "Kilidi açmak için şifre {input}", + "verificationLabel": "Doğrulama kodunun zorluğu {input}", + "verificationDifficulty": { + "easy": "Kolay", + "hard": "Zor", + "disgusting": "İğrenç" + }, + "strict": "Her halükarda kilidin açılmasına izin verme", + "strictTitle": "İşlemi onayla", + "strictContent": "Bu seçeneği seçtiğinizde, bir site günlük sınırı tetiklediğinde, ertesi güne kadar beklemek dışında manuel olarak engellemeyi kaldırmanız mümkün olmayacaktır. Kurallar doğru şekilde ayarlanmazsa, günlük rutinlerinizi büyük ölçüde engelleyebilirler!", + "pswFormLabel": "Şifre", + "pswFormAgain": "Yeniden girin" + } + }, + "backup": { + "title": "Veri Yedekleme", + "type": "Uzaktan yedekleme tipi {input}", + "client": "Uygulama adı {input}", + "meta": { + "gist": { + "authInfo": "Gist iznine sahip bir token gereklidir" + }, + "obsidian_local_rest_api": { + "endpointInfo": "Uzantı sayfaları için CORS yapılandırılamadığından yalnızca HTTP kullanılabilir" + } + }, + "label": { + "endpoint": "Uç nokta adresi {info} {input}", + "path": "Dizin yolu {input}", + "account": "Kullanıcı Adı {input}", + "password": "Şifre {input}" + }, + "operation": "Yedekleme", + "download": { + "btn": "İndir", + "willDownload": "İndirilecek", + "confirmTip": "[{clientName}] {size} adet veri indirilecektir" + }, + "clear": { + "btn": "Temizle", + "confirmTip": "[{clientName}] tarafından izlenen {hostCount} siteye ait {rowCount} adet veri silinecek!" + }, + "confirmStep": "Verileri onaylayın", + "clientTable": { + "selectTip": "Uygulama seç", + "dataRange": "Veri aralığı", + "notSelected": "Uygulama seçilmedi", + "current": "Şimdiki" + }, + "lastTimeTip": "Son yedekleme zamanı: {lastTime}", + "auto": { + "label": "Otomatik yedeklemeyi etkinleştirip etkinleştirmemeyi seçin", + "interval": "ve her {input} dakikada bir çalıştırın" + } + }, + "accessibility": { + "title": "Erişilebilirlik", + "chartDecal": "{input} Grafik çıkartmasını gösterip göstermemeyi seçin" + }, + "notification": { + "title": "Bildirim", + "cycle": { + "label": "Bildirim döngüsü {input}", + "daily": "Günlük", + "weekly": "Haftalık" + }, + "method": { + "label": "Bildirim methodu {input}", + "browser": "Tarayıcı", + "callback": { + "label": "HTTP Callback", + "url": "Callback URL {input}" + } + } + }, + "resetButton": "Sıfırla", + "resetSuccess": "Varsayılan ayarlara sıfırlama işlemi başarıyla tamamlandı!", + "exportButton": "Ayarları Dışa Aktar", + "importButton": "Ayarları İçe Aktar", + "exportSuccess": "Ayarlar başarıyla dışa aktarıldı", + "importSuccess": "Ayarlar başarıyla içe aktarıldı", + "importError": "İçe aktarma başarısız: Geçersiz ayar dosyası", + "importConfirm": "Ayarlar başarıyla içe aktarıldı, değişiklikleri uygulamak için lütfen sayfayı yeniden yükleyin!", + "reloadButton": "Yeniden yükle", + "defaultValue": "Varsayılan: {default}" + }, + "pl": { + "yes": "Tak", + "no": "Nie", + "on": "Zawsze włączone", + "off": "Zawsze wyłączone", + "followBrowser": "Śledź przeglądarkę", + "permGrantConfirm": "Ta opcja wymaga odpowiednich uprawnień", + "appearance": { + "title": "Wygląd", + "displayWhitelist": "{input} Wyświetlanie {whitelist} w {contextMenu}", + "whitelistItem": "skrótów związanych z whitelistą", + "contextMenu": "menu kontekstowym", + "displayBadgeText": "{input} Wyświetlanie {timeInfo} na {icon}", + "badgeBgColor": "Kolor tła tekstu na ikonie paska rozszerzeń {input}", + "icon": "ikonie rozszerzenia", + "badgeTextContent": "czasu przeglądania bieżącej strony", + "locale": { + "label": "Język {input}", + "changeConfirm": "Język został zmieniony pomyślnie, proszę odświeżyć tę stronę!", + "reloadButton": "Przeładuj" + }, + "printInConsole": { + "label": "{input} Wyświetlanie {info} w {console}", + "console": "konsoli", + "info": "liczby dzisiejszych wizyt na bieżącej stronie" + }, + "darkMode": { + "label": "Tryb ciemny {input}", + "timed": "Czas na" + }, + "animationDuration": "Długość trwania początkowej animacji wykresu {input}", + "sidePanel": "{input} - czy włączyć panel boczny" + }, + "tracking": { + "title": "Monitorowanie", + "autoPauseTrack": "{input} Wstrzymaj monitorowanie, jeżeli nie wykryto aktywności {info} przez {maxTime}", + "noActivityInfo": "Mysz i klawiatura są nieaktywne, nie są w trybie pełnoekranowym i nie ma dźwięku", + "countLocalFiles": "{input} Śledzenie czasu, kiedy przeglądarka odczytuje {localFileTime} {info}", + "localFileTime": "pliki lokalne", + "localFilesInfo": "Obsługuje pliki takich typów jak PDF, obrazy, txt lub JSON.", + "countTabGroup": "{input} Śledzenie czasu w grupach kart {info}", + "tabGroupInfo": "Po usunięciu grupy tagów, dane również zostaną usunięte.", + "fileAccessDisabled": "Dostęp do adresów URL plików jest obecnie niedozwolony. Proszę najpierw włączyć go na stronie zarządzania", + "weekStart": "Pierwszy dzień tygodnia {input}", + "weekStartAsNormal": "Normalnie", + "storage": "Przechowuj dane śledzenia w {input}", + "storageConfirm": "Czy chcesz zmienić typ pamięci na {type}?" + }, + "limit": { + "prompt": "Wyświetlane zapytanie, gdy ograniczono {input}", + "reminder": "Przypomnienie {input} {minInput} minut(y) przed limitem czasu", + "level": { + "label": "Jak odblokować podczas ograniczenia {input}", + "nothing": "Zezwalaj na bezpośrednie odblokowanie na stronie administratora", + "password": "Musisz wprowadzić hasło, aby odblokować", + "verification": "Musisz wprowadzić kod weryfikacyjny, aby odblokować", + "passwordLabel": "Hasło odblokowujące {input}", + "verificationLabel": "Trudność kodu weryfikacyjnego {input}", + "verificationDifficulty": { + "easy": "Łatwy", + "hard": "Trudny", + "disgusting": "Odrzucający" + }, + "strict": "Nie zezwalaj na odblokowanie mimo to", + "strictTitle": "Potwierdzenie operacji", + "strictContent": "Gdy wybierzesz tę opcję, jeśli witryna osiągnie dzienny limit, nie będzie wolno jej odblokować ręcznie inaczej, niż czekając do następnego dnia. Jeśli reguły nie są poprawnie skonfigurowane, mogą one skutecznie utrudnić twoją rutynę!", + "pswFormLabel": "Hasło", + "pswFormAgain": "Wprowadź ponownie" + } + }, + "backup": { + "title": "Kopia zapasowa danych", + "type": "Zdalny typ: {input}", + "client": "Nazwa klienta {input}", + "meta": { + "gist": { + "authInfo": "Wymagany jest przynajmniej jeden token z gist" + }, + "obsidian_local_rest_api": { + "endpointInfo": "Tylko HTTP jest dostępny, ponieważ CORS nie może być skonfigurowany dla stron rozszerzenia" + } + }, + "label": { + "endpoint": "Adres punktu końcowego {info} {input}", + "path": "Ścieżka do folderu {input}", + "account": "Nazwa użytkownika {input}", + "password": "Hasło {input}" + }, + "operation": "Kopia zapasowa", + "download": { + "btn": "Pobierz", + "willDownload": "Do pobrania", + "confirmTip": "{size} części danych z [{clientName}] zostaną pobrane" + }, + "clear": { + "btn": "Wyczyść", + "confirmTip": "{rowCount} części danych dla stron {hostCount} śledzonych przez [{clientName}] zostaną usunięte!" + }, + "confirmStep": "Potwierdź dane", + "clientTable": { + "selectTip": "Wybierz klienta", + "dataRange": "Zakres danych", + "notSelected": "Nie wybrano klienta", + "current": "Aktualny" + }, + "lastTimeTip": "Ostatnia kopia zapasowa: {lastTime}", + "auto": { + "label": "Automatyczne kopie zapasowe", + "interval": "co {input} minut" + } + }, + "accessibility": { + "title": "Ułatwienia dostępu", + "chartDecal": "{input} - czy wyświetlić wykres typu decal" + }, + "notification": { + "title": "Powiadomienie", + "cycle": { + "label": "Cykl powiadomień {input}", + "daily": "Codziennie", + "weekly": "Co tydzień" + }, + "method": { + "label": "Metoda powiadomienia {input}", + "browser": "Przeglądarka", + "callback": { + "label": "HTTP Callback", + "url": "Callback URL {input}" + } + } + }, + "resetButton": "Reset", + "resetSuccess": "Pomyślnie przywrócono ustawienia domyślne!", + "exportButton": "Eksportuj ustawienia", + "importButton": "Importuj ustawienia", + "exportSuccess": "Ustawienia wyeksportowane pomyślnie", + "importSuccess": "Ustawienia zaimportowane pomyślnie", + "importError": "Import nie powiódł się: Nieprawidłowy plik ustawień", + "importConfirm": "Ustawienia zaimportowane pomyślnie, proszę odświeżyć stronę aby zastosować zmiany!", + "reloadButton": "Odśwież", + "defaultValue": "Domyślnie: {default}" + }, + "it": { + "yes": "Si", + "no": "No", + "on": "Sempre acceso", + "off": "Sempre spento", + "followBrowser": "Segui browser", + "permGrantConfirm": "Questa funzione richiede autorizzazioni rilevanti", + "appearance": { + "title": "Apparenza", + "displayWhitelist": "{input} Se visualizzare {whitelist} in {contextMenu}", + "whitelistItem": "scorciatoie correlate alla whitelist", + "contextMenu": "il menu contestuale", + "displayBadgeText": "{input} Se visualizzare {timeInfo} in {icon}", + "badgeBgColor": "Il colore di sfondo del testo sull'icona {input}", + "icon": "l'icona dell'estensione", + "badgeTextContent": "il tempo di navigazione del sito web corrente", + "locale": { + "label": "Linguaggio {input}", + "changeConfirm": "La lingua è stata modificata con successo, si prega di aggiorna questa pagina!", + "reloadButton": "Aggiorna" + }, + "printInConsole": { + "label": "{input} Se stampare {info} in {console}", + "console": "console", + "info": "il conteggio delle visite del sito attuale oggi" + }, + "darkMode": { + "label": "Modalità oscura {input}", + "timed": "Sincronizzato" + }, + "animationDuration": "La durata dell'animazione iniziale del grafico {input}", + "sidePanel": "{input} Se abilitare il pannello laterale" + }, + "tracking": { + "title": "Tracciamento", + "autoPauseTrack": "{input} Pausa tracciamento se nessuna attività rilevata {info} per {maxTime}", + "noActivityInfo": "Il mouse e la tastiera sono inattivi, non in modalità a schermo intero, e non c'è suono in riproduzione", + "countLocalFiles": "{input} Se tenere traccia del tempo in cui il browser legge {localFileTime} {info}", + "localFileTime": "file locali", + "localFilesInfo": "Supporta file di tipi come PDF, immagine, txt e json.", + "countTabGroup": "{input} Se tenere traccia dell'orario dei gruppi di schede {info}", + "tabGroupInfo": "Quando si elimina un gruppo di schede, anche i dati verranno eliminati.", + "fileAccessDisabled": "L'accesso agli URL dei file non è attualmente consentito. Si prega di attivarlo prima nella pagina di gestione", + "weekStart": "Il primo giorno di ogni settimana {input}", + "weekStartAsNormal": "Come Normale", + "storage": "Memorizza i dati di tracciamento in {input}", + "storageConfirm": "Vuoi cambiare il tipo di storage in {type}?" + }, + "limit": { + "prompt": "Prompt mostrato quando riservato {input}", + "reminder": "{input} Promemoria {minInput} minuti prima che il tempo sia scaduto", + "level": { + "label": "Come sbloccare durante la limitazione {input}", + "nothing": "Consenti lo sblocco diretto nella pagina di amministrazione", + "password": "È necessario inserire la password per sbloccare", + "verification": "È necessario inserire il codice di verifica per sbloccare", + "passwordLabel": "Password per sbloccare {input}", + "verificationLabel": "La difficoltà del codice di verifica {input}", + "verificationDifficulty": { + "easy": "Facile", + "hard": "Difficile", + "disgusting": "Disgustoso" + }, + "strict": "Non consentire comunque lo sblocco", + "strictTitle": "Conferma operazione", + "strictContent": "Quando si seleziona questa opzione, se un sito attiva il limite giornaliero, non ti sarà permesso di sbloccare manualmente se non aspettare fino al giorno successivo. Se le regole non sono impostate correttamente, possono molto bene ostacolare le tue routine!", + "pswFormLabel": "Password", + "pswFormAgain": "Conferma", + "2fa": "È necessario utilizzare il codice 2FA per sbloccare", + "twoFaTitle": "Abilita 2FA", + "twoFaScanHint": "Scansiona il codice QR con un'app di autenticazione o importa il link di configurazione in un gestore di password che supporta TOTP.", + "twoFaCopyLink": "Copia link", + "twoFaVerifyLabel": "Inserisci il codice a 6 cifre da verificare" + }, + "delayDuration": "Ritardo per {input} minuti per volta" + }, + "backup": { + "title": "Backup Dati", + "type": "Tipo remoto {input}", + "client": "Nome dell' Cliente {input}", + "meta": { + "gist": { + "authInfo": "È richiesto un token con almeno il permesso di gist" + }, + "obsidian_local_rest_api": { + "endpointInfo": "Solo HTTP è disponibile, poiché CORS non può essere configurato per le pagine di estensione" + } + }, + "label": { + "endpoint": "Indirizzo endpoint {info} {input}", + "path": "Il percorso della cartella {input}", + "account": "Nome Utente {input}", + "password": "Password {input}" + }, + "operation": "Backup", + "download": { + "btn": "Scarica", + "willDownload": "Da scaricare", + "confirmTip": "{size} pezzi di dati da [{clientName}] verranno scaricati" + }, + "clear": { + "btn": "Cancella", + "confirmTip": "{rowCount} pezzi di dati per i siti {hostCount} tracciati da [{clientName}] verranno eliminati!" + }, + "confirmStep": "Conferma i dati", + "clientTable": { + "selectTip": "Seleziona cliente", + "dataRange": "Intervallo di dati", + "notSelected": "Client non selezionato", + "current": "Attuale" + }, + "lastTimeTip": "L' Ultimo tempo di backup: {lastTime}", + "auto": { + "label": "Indica se abilitare il backup automatico", + "interval": "e esegui ogni {input} minuti" + } + }, + "accessibility": { + "title": "Accessibilità", + "chartDecal": "{input} Indica se visualizzare la decal del grafico" + }, + "notification": { + "title": "Notifiche", + "cycle": { + "label": "Ciclo di notifica {input}", + "daily": "Giornaliero", + "weekly": "Settimanale" + }, + "method": { + "label": "Metodo di notifica {input}", + "browser": "Browser", + "callback": { + "label": "HTTP Callback", + "url": "Callback URL {input}" + } + } + }, + "resetButton": "Reset", + "resetSuccess": "Reset completato!", + "exportButton": "Impostazioni di esportazione", + "importButton": "Impostazioni di importazione", + "exportSuccess": "Impostazioni esportate con successo", + "importSuccess": "Impostazioni importate con successo", + "importError": "Importazione non riuscita: file delle impostazioni non valido", + "importConfirm": "Impostazioni importate con successo, aggiorna la pagina per applicare le modifiche!", + "reloadButton": "Aggiorna", + "defaultValue": "Default: {default}" } } \ No newline at end of file diff --git a/src/i18n/message/app/option.ts b/src/i18n/message/app/option.ts index 070928947..898d193af 100644 --- a/src/i18n/message/app/option.ts +++ b/src/i18n/message/app/option.ts @@ -9,12 +9,10 @@ import resource from './option-resource.json' export type OptionMessage = { yes: string no: string + on: string + off: string followBrowser: string - popup: { - title: string - max: string - displaySiteName: string - } + permGrantConfirm: string appearance: { title: string // whitelist @@ -38,11 +36,12 @@ export type OptionMessage = { }, darkMode: { label: string - options: Omit, 'default'> + timed: string } animationDuration: string + sidePanel: string } - statistics: { + tracking: { title: string autoPauseTrack: string noActivityInfo: string @@ -51,41 +50,45 @@ export type OptionMessage = { localFilesInfo: string countTabGroup: string tabGroupInfo: string - tabGroupsPermGrant: string fileAccessDisabled: string - fileAccessFirefox: string weekStart: string weekStartAsNormal: string + storage: string + storageConfirm: string } - dailyLimit: { + limit: { prompt: string reminder: string level: { - [level in timer.limit.RestrictionLevel]: string + [level in tt4b.limit.RestrictionLevel]: string } & { label: string passwordLabel: string verificationLabel: string verificationDifficulty: { - [diff in timer.limit.VerificationDifficulty]: string + [diff in tt4b.limit.VerificationDifficulty]: string } strictTitle: string strictContent: string pswFormLabel: string pswFormAgain: string + twoFaTitle: string + twoFaScanHint: string + twoFaCopyLink: string + twoFaVerifyLabel: string } + delayDuration: string } backup: { title: string type: string client: string meta: { - [type in timer.backup.Type]: { - label?: string + [type in tt4b.backup.Type]: { authInfo?: string } } & { - [type in Extract]: { + [type in Extract]: { endpointInfo: string } } @@ -104,7 +107,6 @@ export type OptionMessage = { } download: { btn: string - step2: string willDownload: string confirmTip: string } @@ -112,6 +114,7 @@ export type OptionMessage = { btn: string confirmTip: string } + confirmStep: string lastTimeTip: string auto: { label: string @@ -122,6 +125,22 @@ export type OptionMessage = { title: string chartDecal: string } + notification: { + title: string + cycle: { + label: string + daily: string + weekly: string + } + method: { + label: string + browser: string + callback: { + label: string + url: string + } + } + } resetButton: string resetSuccess: string exportButton: string diff --git a/src/i18n/message/app/report-resource.json b/src/i18n/message/app/record-resource.json similarity index 77% rename from src/i18n/message/app/report-resource.json rename to src/i18n/message/app/record-resource.json index 6defaef3d..9b96fb627 100644 --- a/src/i18n/message/app/report-resource.json +++ b/src/i18n/message/app/record-resource.json @@ -64,6 +64,7 @@ }, "ja": { "exportFileName": "私のウェブ時間データ", + "total": "合計訪問回数: {visit} 回、合計時間: {focus}", "batchDelete": { "noSelectedMsg": "最初にテーブルで削除する行にチェックマークを付けてください", "confirmMsg": "{date} の {example} のようなサイトの {count} レコードは削除されます!", @@ -84,6 +85,7 @@ }, "pt_PT": { "exportFileName": "Meu_Tempo_de_Navegação", + "total": "Visitas Totais: {visit} vezes, total duração: {focus}", "batchDelete": { "noSelectedMsg": "Por favor, selecione a linha que deseja excluir na tabela primeiro", "confirmMsg": "{count} registros de sites como {example} em {date} serão excluídos!", @@ -103,7 +105,7 @@ "noMore": "Não mais" }, "uk": { - "exportFileName": "My_Browsing_Time", + "total": "Всього відвідувань: {visit} разів, загальна тривалість: {focus}", "batchDelete": { "noSelectedMsg": "Спершу виберіть рядок, який ви хочете видалити", "confirmMsg": "{count} записів для сайтів, як-от {example}, за {date} будуть видалені!", @@ -145,6 +147,7 @@ }, "de": { "exportFileName": "Timer_Daten", + "total": "Besuche Gesamt: {visit}, Gesamtdauer: {focus}", "batchDelete": { "noSelectedMsg": "Bitte wählen Sie die Zeile aus, die Sie zuerst in der Tabelle löschen möchten", "confirmMsg": "{count} Einträge für Sites wie {example} auf {date} werden gelöscht!", @@ -160,7 +163,8 @@ "value": "Wert", "percentage": "Prozentsatz" } - } + }, + "noMore": "Nicht mehr" }, "fr": { "exportFileName": "Mon_heure_de_navigation", @@ -223,5 +227,66 @@ } }, "noMore": "لا مزيد" + }, + "tr": { + "exportFileName": "Gezinti_Zamanım", + "total": "Toplam ziyaret sayısı: {visit} kez, toplam süre: {focus}", + "batchDelete": { + "noSelectedMsg": "Lütfen önce tabloda silmek istediğiniz satırı seçin", + "confirmMsg": "{date} tarihine ait [{example}] gibi {count} kayıt silinecektir!", + "confirmMsgAll": "[{example}] gibi {count} kayıt silinecek!", + "confirmMsgRange": "{start} ile {end} arasında [{example}] gibi {count} kayıt silinecek!" + }, + "remoteReading": { + "on": "Uzaktan yedeklenen verileri okuma", + "off": "Uzaktan yedeklenen verileri okumak için tıklayın", + "table": { + "client": "Uygulama adı", + "localData": "Yerel Veri", + "value": "Değeri", + "percentage": "Yüzdesi" + } + }, + "noMore": "Daha Fazla Veri Yok" + }, + "pl": { + "total": "Całkowita liczba wizyt: {visit}, całkowity czas trwania: {focus}", + "batchDelete": { + "noSelectedMsg": "Proszę najpierw wybrać wiersz, który chcesz usunąć z tabeli", + "confirmMsg": "{count} rekord/y/ów takich jak [{example}] z {date} zostaną usunięte!", + "confirmMsgAll": "{count} rekord/y/ów takich jak [{example}] zostaną usunięte!", + "confirmMsgRange": "{count} rekord/y/ów takich jak [{example}] pomiędzy {start} i {end} zostaną usunięte!" + }, + "remoteReading": { + "on": "Czytanie zdalnych kopii zapasowych", + "off": "Kliknij, aby przeczytać zdalne kopie zapasowe", + "table": { + "client": "Nazwa klienta", + "localData": "Lokalne Dane", + "value": "Wartość", + "percentage": "Procent" + } + }, + "noMore": "Nie więcej" + }, + "it": { + "total": "Visite totali: {visit} volte, durata totale: {focus}", + "batchDelete": { + "noSelectedMsg": "Si prega di selezionare la riga che si desidera eliminare nella prima tabella", + "confirmMsg": "{count} record come [{example}] di {date} saranno eliminati!", + "confirmMsgAll": "{count} record come [{example}], saranno eliminati!", + "confirmMsgRange": "{count} record come [{example}] tra {start} ed {end} saranno eliminati!" + }, + "remoteReading": { + "on": "Lettura dei dati di backup remoti", + "off": "Clicca per leggere i dati di backup remoti", + "table": { + "client": "Nome del cliente", + "localData": "Dati locali", + "value": "Valore", + "percentage": "Percentuale" + } + }, + "noMore": "Nient'altro" } } \ No newline at end of file diff --git a/src/i18n/message/app/report.ts b/src/i18n/message/app/record.ts similarity index 82% rename from src/i18n/message/app/report.ts rename to src/i18n/message/app/record.ts index d1be95f6a..c4c17e57e 100644 --- a/src/i18n/message/app/report.ts +++ b/src/i18n/message/app/record.ts @@ -5,9 +5,9 @@ * https://opensource.org/licenses/MIT */ -import resource from './report-resource.json' +import resource from './record-resource.json' -export type ReportMessage = { +export type RecordMessage = { exportFileName: string total: string batchDelete: { @@ -29,6 +29,6 @@ export type ReportMessage = { noMore: string } -const _default: Messages = resource +const _default: Messages = resource export default _default \ No newline at end of file diff --git a/src/i18n/message/app/rule-resource.json b/src/i18n/message/app/rule-resource.json new file mode 100644 index 000000000..80b46d7fe --- /dev/null +++ b/src/i18n/message/app/rule-resource.json @@ -0,0 +1,433 @@ +{ + "zh_CN": { + "white": { + "label": "白名单", + "addConfirmMsg": "{url} 将被加入至白名单", + "removeConfirmMsg": "{url} 将从白名单中移除", + "infoAlertTitle": "你可以在这里配置网站白名单", + "infoAlert0": "白名单内网站的上网时长和打开次数不会被统计", + "infoAlert1": "白名单内网站的上网时间也不会被限制", + "infoAlert2": "您可以使用通配符 (*) 匹配多个站点,例如 *.example.com/**,并使用 + 作为前缀排除站点,例如 +need.example.com/**" + }, + "merge": { + "label": "子域名合并", + "removeConfirmMsg": "自定义合并规则 {origin} 将被移除", + "originPlaceholder": "原域名", + "mergedPlaceholder": "合并后域名", + "errorOrigin": "原域名格式错误", + "duplicateMsg": "合并规则已存在:{origin}", + "addConfirmMsg": "将为 {origin} 设置自定义合并规则", + "infoAlertTitle": "该页面可以配置子域名的合并规则", + "infoAlert0": "点击新增按钮,会弹出原域名和合并后域名的输入框,填写并保存规则", + "infoAlert1": "原域名可填具体的域名或者正则表达式,比如 www.baidu.com,*.baidu.com,*.google.com.*。以此确定哪些域名在合并时会使用该条规则", + "infoAlert2": "合并后域名可填具体的域名,或者填数字,或者不填", + "infoAlert3": "如果填数字,则表示合并后域名的级数。比如存在规则【 *.*.edu.cn >>> 3 】,那么 www.hust.edu.cn 将被合并至 hust.edu.cn", + "infoAlert4": "如果不填,则表示原域名不会被合并", + "infoAlert5": "如果没有命中任何规则,则默认会合并至 {psl} 的前一级", + "tagResult": { + "blank": "不合并", + "level": "{level} 级域名" + } + } + }, + "zh_TW": { + "white": { + "label": "白名單", + "addConfirmMsg": "{url} 將會被加入白名單。", + "removeConfirmMsg": "{url} 將會被白名單移除。", + "infoAlertTitle": "網站白名單設定", + "infoAlert0": "白名單內的網站將不會記錄瀏覽時長與造訪次數", + "infoAlert1": "白名單內的網站不受時間限制功能影響", + "infoAlert2": "您可以使用通配符(*)來匹配多個網站,例如*.example.com/​​**,並使用+作為前綴來排除網站,例如+need.example.com/​**" + }, + "merge": { + "label": "網站合併規則", + "removeConfirmMsg": "自訂合併規則 {origin} 將被刪除", + "originPlaceholder": "原始網域", + "mergedPlaceholder": "合併後網域", + "errorOrigin": "原始網域格式錯誤", + "duplicateMsg": "合併規則已存在:{origin}", + "addConfirmMsg": "將為 {origin} 設定自訂合併規則", + "infoAlertTitle": "此頁面可設定子網域合併規則", + "infoAlert0": "點擊「新增」按鈕後,需填寫原始網域與合併後網域", + "infoAlert1": "原始網域可填寫具體網域或正規表示式,例如:www.baidu.com、*.baidu.com、*.google.com.*", + "infoAlert2": "合併後網域可填寫:具體網域/數字/留空", + "infoAlert3": "若填數字,代表合併後的網域層級數。例如規則【 *.*.edu.cn >>> 3 】會將 www.hust.edu.cn 合併為 hust.edu.cn", + "infoAlert4": "若留空,表示原始網域將保持不變", + "infoAlert5": "若未匹配任何規則,預設會合併至 {psl} 的上一層級", + "tagResult": { + "blank": "保持原網域", + "level": "{level} 層網域" + } + } + }, + "en": { + "white": { + "label": "Whitelist", + "addConfirmMsg": "{url} will be added to the whitelist.", + "removeConfirmMsg": "{url} will be removed from the whitelist.", + "infoAlertTitle": "You can whitelist sites on this page", + "infoAlert0": "Whitelisted sites will not be counted", + "infoAlert1": "Whitelisted sites will not be restricted", + "infoAlert2": "You can use a wildcards(*) to match multiple sites, such as *.example.com/**, and use + as a prefix to exclude sites, such as +need.example.com/**" + }, + "merge": { + "label": "Merge-site Rules", + "removeConfirmMsg": "{origin} will be removed from customized merge rules.", + "originPlaceholder": "Original site", + "mergedPlaceholder": "Merged", + "errorOrigin": "The format of original site is invalid.", + "duplicateMsg": "The rule already exists: {origin}", + "addConfirmMsg": "Customized merge rules will be set for {origin}", + "infoAlertTitle": "the merge rules when counting sites on this page", + "infoAlert0": "Click the [New] button, the input boxes of the source site and the merge site will be displayed, fill in and save the rule", + "infoAlert1": "The original site can be filled with a specific site or regular expression, such as www.baidu.com, *.baidu.com, *.google.com.*, to determine which sites will match this rule while merging", + "infoAlert2": "The merged site can be filled with a specific site, a number or blank", + "infoAlert3": "A number means the level of merged site. For example, there is a rule '*.*.edu.cn >>> 3', then 'www.hust.edu.cn' will be merged to 'hust.edu.cn'", + "infoAlert4": "Blank means the original site will not be merged", + "infoAlert5": "If no rule is matched, it will default to the level before {psl}", + "tagResult": { + "blank": "Not Merge", + "level": "Keep Level {level}" + } + } + }, + "ja": { + "white": { + "label": "ホワイトリスト", + "addConfirmMsg": "{url} がホワイトリストに追加されます。", + "removeConfirmMsg": "{url} はホワイトリストから削除されます", + "infoAlertTitle": "このページでサイトのホワイトリストを設定できます", + "infoAlert0": "ホワイトリストのサイトはカウントされません。", + "infoAlert1": "ホワイトリストのサイトは制限されません。" + }, + "merge": { + "label": "ドメイン合併", + "removeConfirmMsg": "カスタム マージ ルール {origin} は削除されます", + "originPlaceholder": "独自ドメイン名", + "mergedPlaceholder": "統計的ドメイン名", + "errorOrigin": "元のドメイン名の形式が間違っています", + "duplicateMsg": "ルールはすでに存在します:{origin}", + "addConfirmMsg": "カスタム マージ ルールが {origin} に設定されます", + "infoAlertTitle": "このページでは、サブドメインのマージ ルールを設定できます", + "infoAlert0": "[追加] ボタンをクリックすると、元のドメイン名と結合されたドメイン名の入力ボックスがポップアップし、ルールを入力して保存します。", + "infoAlert1": "元のドメイン名には、特定のドメイン名または正規表現 (www.baidu.com、*.baidu.com、*.google.com.* など) を入力できます。 マージ時にこのルールを使用するドメインを決定するには", + "infoAlert2": "統合されたドメイン名の後、特定のドメイン名を入力するか、番号を入力するか、空白のままにすることができます", + "infoAlert3": "数字を記入する場合は、ドメイン名のレベルが予約されていることを意味します。 たとえば、ルール [*.*.edu.cn >>> 3 ] がある場合、www.hust.edu.cn は hust.edu.cn にマージされます。", + "infoAlert4": "記入しない場合は、元のドメイン名が統合されないことを意味します", + "infoAlert5": "一致するルールがない場合、デフォルトで {psl} より前のレベルになります", + "tagResult": { + "blank": "不合并", + "level": "{level} 次ドメイン" + } + } + }, + "pt_PT": { + "white": { + "label": "Lista Branca", + "addConfirmMsg": "{url} será adicionado à lista de permissões.", + "removeConfirmMsg": "{url} será removido da lista de permissões.", + "infoAlertTitle": "Pode adicionar sites à lista de permissões nesta página", + "infoAlert0": "Os sites na lista de permissões não serão contabilizados", + "infoAlert1": "Os sites na lista de permissões não serão restringidos", + "infoAlert2": "Podes usar wildcards(*) para corresponder a vários sites, como *.example.com/**, e usar + como prefixo para excluir sites específicos, como +need.example.com/**" + }, + "merge": { + "label": "Regras de Agrupamento", + "removeConfirmMsg": "{origin} será removido das regras de agrupamento personalizadas.", + "originPlaceholder": "Site original", + "mergedPlaceholder": "Agrupado", + "errorOrigin": "Formato do site original inválido.", + "duplicateMsg": "A regra já existe: {origin}", + "addConfirmMsg": "Será criada uma regra de agrupamento para {origin}", + "infoAlertTitle": "Regras de agrupamento para contagem de sites nesta página", + "infoAlert0": "Clique em [Novo] para mostrar os campos do site original e do site agrupado", + "infoAlert1": "O site original pode ser um URL específico ou expressão regular (ex: www.baidu.com, *.baidu.com, *.google.com.*)", + "infoAlert2": "O site agrupado pode ser um URL específico, um número ou ficar em branco", + "infoAlert3": "Um número indica o nível de agrupamento. Ex: com a regra '*.*.edu.cn >>> 3', 'www.hust.edu.cn' será agrupado como 'hust.edu.cn'", + "infoAlert4": "Em branco significa que o site original não será agrupado", + "infoAlert5": "Se nenhuma regra for encontrada, usará o nível anterior a {psl}", + "tagResult": { + "blank": "Não Agrupar", + "level": "Manter Nível {level}" + } + } + }, + "uk": { + "white": { + "label": "Білий список", + "addConfirmMsg": "{url} буде додано до білого списку.", + "removeConfirmMsg": "{url} буде вилучено з білого списку.", + "infoAlertTitle": "На цій сторінці ви можете налаштувати білий список сайтів", + "infoAlert0": "Сайти в білому списку не враховуватимуться", + "infoAlert1": "Сайти в білому списку не матимуть обмежень", + "infoAlert2": "Можна використовувати змінні (*) для збігів декількох сайтів, наприклад *.example.com/**. Щоб виключити сайти, використовуйте +, наприклад +need.example.com/**" + }, + "merge": { + "label": "Правила об'єднання сайтів", + "removeConfirmMsg": "{origin} буде вилучено з налаштованих правил об'єднання.", + "originPlaceholder": "Оригінальний сайт", + "mergedPlaceholder": "Об'єднуваний", + "errorOrigin": "Неприпустимий формат оригінального сайту.", + "duplicateMsg": "Правило вже існує: {origin}", + "addConfirmMsg": "Для {origin} буде встановлено користувацьке правило об'єднання", + "infoAlertTitle": "На цій сторінці ви можете встановити правила об’єднання для статистики використання сайтів", + "infoAlert0": "Натисніть кнопку [Нове], заповніть поля оригінального та об'єднуваного сайту, після чого збережіть правило", + "infoAlert1": "Оригінальний сайт можна ввести як URL-адресу або регулярний вираз, як-от www.wikipedia.org, *.wikipedia.org, *.google.com.*, щоб визначити відповідні сайти для об'єднання", + "infoAlert2": "Об'єднуваний сайт можна ввести як URL-адресу, число, або залишити пустим", + "infoAlert3": "Число означає рівень об'єднуваного сайту. Наприклад, для правила '*.*.edu.cn >>> 3' об'єднуваний сайт 'www.hust.edu.cn' буде об'єднано з 'hust.edu.cn'", + "infoAlert4": "Пусте поле означає, що сайт не буде об'єднано", + "infoAlert5": "Якщо жодне правило не збігається, його буде встановлено до типового рівня {psl}", + "tagResult": { + "blank": "Не об'єднувати", + "level": "Зберегти рівень {level}" + } + } + }, + "es": { + "white": { + "label": "Lista blanca", + "addConfirmMsg": "{url} se agregará a la lista blanca.", + "removeConfirmMsg": "{url} será eliminado de la lista blanca.", + "infoAlertTitle": "Puedes configurar una lista blanca de sitios en esta página", + "infoAlert0": "Los sitios en la lista blanca no serán contados", + "infoAlert1": "Los sitios en la lista blanca no serán restringidos", + "infoAlert2": "Puedes usar un comodín (*) para coincidir varios sitios a la vez: *.ejemplo.com/**, y usar + como prefijo para excluir sitios, como +meme.ejemplo.com/**" + }, + "merge": { + "label": "Reglas de fusión de sitios", + "removeConfirmMsg": "{origin} será eliminado de las reglas de fusión personalizadas.", + "originPlaceholder": "Sitio original", + "mergedPlaceholder": "Fusionado", + "errorOrigin": "El formato del sitio original no es válido.", + "duplicateMsg": "La regla ya existe: {origin}", + "addConfirmMsg": "Las reglas de fusión personalizadas se establecerán para {origin}", + "infoAlertTitle": "Puedes establecer las reglas de fusión al contar sitios en esta página", + "infoAlert0": "Haz clic en el botón [New One], se mostrarán los recuadros del sitio fuente y el sitio fusionado, llénalos y guarda la regla", + "infoAlert1": "El sitio original puede ser llenado con un sitio específico o una expresión regular, tal como www.baidu.com, *.baidu.com, *.google.com.*, para determinar qué sitios seguirán esta regla al fusionarse", + "infoAlert2": "El sitio fusionado puede ser llenado con un sitio específico, un número o en dejarse en blanco", + "infoAlert3": "Un número significa el nivel del sitio fusionado. Por ejemplo, hay una regla '*.*.edu.cn >>> 3', entonces 'www.hust.edu.cn' se fusionará con 'hust.edu.cn'", + "infoAlert4": "Dejar en blanco significa que el sitio original no se fusionará", + "infoAlert5": "Si no se sigue ninguna regla, se establecerá por defecto al nivel antes de {psl}", + "tagResult": { + "blank": "No fusionar", + "level": "Mantener en nivel {level}" + } + } + }, + "de": { + "white": { + "label": "Whitelist", + "addConfirmMsg": "{url} wird zur Whitelist hinzugefügt.", + "removeConfirmMsg": "{url} wird von der Whitelist entfernt.", + "infoAlertTitle": "Sie können eine Whitelist vonseiten auf dieser Seite festlegen", + "infoAlert0": "Websites auf der Whitelist werden nicht gezählt", + "infoAlert1": "Websites auf der Whitelist werden nicht eingeschränkt", + "infoAlert2": "Sie können einen Platzhalter (*) verwenden, um mehrere Websites wie *.example.com/** zusammenzufassen, und + als Präfix verwenden, um Webseiten wie +need.example.com/** auszuschließen" + }, + "merge": { + "label": "Regeln zusammenführen", + "removeConfirmMsg": "{origin} wird aus den benutzerdefinierten Zusammenführen Regeln entfernt.", + "originPlaceholder": "Ursprüngliche Website", + "mergedPlaceholder": "Zusammengeführt", + "errorOrigin": "Das Format der Original-Website ist ungültig.", + "duplicateMsg": "Die Regel existiert bereits: {origin}", + "addConfirmMsg": "Für {origin} werden benutzerdefinierte Zusammenführungsregeln festgelegt", + "infoAlertTitle": "Auf dieser Seite können Sie Zusammenführen Regeln für die Zählung von Websites festlegen", + "infoAlert0": "Klicken Sie auf die Schaltfläche [Erstellen]. Die Eingabefelder der ursprünglichen Website und der zusammengeführten Website werden angezeigt. Füllen Sie die Regel aus und speichern Sie sie", + "infoAlert1": "Die ursprüngliche Website kann mit einer bestimmten Website oder einem regulären Ausdruck wie www.baidu.com, *.baidu.com, *.google.com.* gefüllt werden, um zu bestimmen, welche Websites beim Zusammenführen dieser Regel entsprechen", + "infoAlert2": "Die zusammengeführte Website kann mit einer bestimmten Site, einer Zahl oder einem Leerzeichen gefüllt werden", + "infoAlert3": "Eine Zahl gibt die Ebene der zusammengeführten Website an. Gibt es beispielsweise eine Regel „*.*.edu.cn >>> 3“, dann wird „www.hust.edu.cn“ als „hust.edu.cn“ zusammengeführt", + "infoAlert4": "Leer bedeutet, dass die ursprüngliche Website nicht zusammengeführt wird", + "infoAlert5": "Wenn keine Regel zutrifft, wird standardmäßig die Ebene vor {psl} verwendet", + "tagResult": { + "blank": "Nicht zusammenführen", + "level": "Level behalten {level}" + } + } + }, + "fr": { + "white": { + "label": "Liste blanche", + "addConfirmMsg": "{url} sera ajouté à la liste blanche.", + "removeConfirmMsg": "{url} sera supprimé de la liste blanche.", + "infoAlertTitle": "Vous pouvez ajouter des sites à la liste blanche sur cette page", + "infoAlert0": "Les sites sur liste blanche ne seront pas comptés", + "infoAlert1": "Les sites sur liste blanche ne seront pas restreints", + "infoAlert2": "Vous pouvez utiliser un signe générique(*) pour cibler plusieurs sites, comme *.example.com/**, et utiliser + comme un préfixe pour exclure des sites, comme +need.example.com/**" + }, + "merge": { + "label": "Fusionner les règles du site", + "removeConfirmMsg": "{origin} sera supprimé des règles de fusion personnalisées.", + "originPlaceholder": "Site original", + "mergedPlaceholder": "Fusionné", + "errorOrigin": "Le format du site original est invalide.", + "duplicateMsg": "La règle existe déjà : {origin}", + "addConfirmMsg": "Les règles de fusion personnalisées seront définies pour {origin}", + "infoAlertTitle": "Vous pouvez définir les règles de fusion lorsque vous comptez des sites sur cette page", + "infoAlert0": "Cliquez sur le bouton [Nouveau], les boîtes de saisie du site source et le site de fusion sera affiché, remplissez et enregistrez la règle", + "infoAlert1": "Le site d'origine peut être rempli avec un site spécifique ou une expression régulière, telle que www.baidu.com, *.baidu.com, *.google.com.*, pour déterminer quels sites correspondront à cette règle lors de la fusion", + "infoAlert2": "Le site fusionné peut être rempli avec un site spécifique, un numéro ou vide", + "infoAlert3": "Un nombre signifie le niveau du site fusionné. Par exemple, il existe une règle '*.*.edu.cn >>> 3', alors 'www.hust.edu.cn' sera fusionné avec 'hust.edu.cn'", + "infoAlert4": "Vide signifie que le site d'origine ne sera pas fusionné", + "infoAlert5": "Si aucune règle ne correspond, le niveau par défaut sera celui d'avant {psl}", + "tagResult": { + "blank": "Non Fusionner", + "level": "Garder le niveau {level}" + } + } + }, + "ru": { + "white": { + "label": "Белый список", + "addConfirmMsg": "{url} будет добавлен в белый список.", + "removeConfirmMsg": "{url} будет удален из белого списка.", + "infoAlertTitle": "Вы можете добавить сайты в белый список на этой странице", + "infoAlert0": "Сайты, внесенные в белый список, не будут учитываться", + "infoAlert1": "Сайты из белого списка не будут ограничены" + }, + "merge": { + "label": "Объединение сайтов", + "removeConfirmMsg": "{origin} будет удален из настроенных правил слияния.", + "originPlaceholder": "Оригинальный сайт", + "mergedPlaceholder": "Соединено", + "errorOrigin": "Недопустимый формат оригинального сайта.", + "duplicateMsg": "Это правило уже существует: {origin}", + "addConfirmMsg": "Для {origin} будут установлены индивидуальные правила слияния", + "infoAlertTitle": "правила слияния при подсчете сайтов на этой странице", + "infoAlert0": "Нажмите кнопку [Новый], будут показаны поля ввода сайта источника и будут отображаться слияния, заполнять и сохранять правило", + "infoAlert1": "Исходный сайт может быть заполнен определенным сайтом или регулярным выражением, например www.baidu.com, *.baidu.com, *.google.com.*, чтобы определить, какие сайты будут соответствовать этому правилу при слиянии", + "infoAlert2": "Объединенный сайт может быть заполнен определенным сайтом, номером или пустым местом", + "infoAlert3": "Число означает уровень слияния сайта. Например, правило '*.*.edu.cn >>> 3', то 'www.hust.edu.cn' будет объединено с 'hust.edu.cn'", + "infoAlert4": "Пустое значение означает, что оригинальный сайт не будет объединён", + "infoAlert5": "Если правило не совпадает, оно будет по умолчанию на уровне до {psl}", + "tagResult": { + "blank": "Не объединять", + "level": "Сохранять уровень {level}" + } + } + }, + "ar": { + "white": { + "label": "القائمة البيضاء", + "addConfirmMsg": "سيتم إضافة {url} إلى القائمة البيضاء.", + "removeConfirmMsg": "سيتم إزالة {url} من القائمة البيضاء.", + "infoAlertTitle": "يمكنك إضافة المواقع إلى القائمة البيضاء في هذه الصفحة", + "infoAlert0": "لن يتم احتساب المواقع المدرجة في القائمة البيضاء", + "infoAlert1": "لن يتم تقييد المواقع المدرجة في القائمة البيضاء" + }, + "merge": { + "label": "دمج قواعد الموقع", + "removeConfirmMsg": "سيتم إزالة {origin} من قواعد الدمج المخصصة.", + "originPlaceholder": "الموقع الأصلي", + "mergedPlaceholder": "تم الدمج", + "errorOrigin": "تنسيق الموقع الأصلي غير صالح.", + "duplicateMsg": "القاعدة موجودة فعلًا: {origin}", + "addConfirmMsg": "سيتم تعيين قواعد الدمج المخصصة لـ {origin}", + "infoAlertTitle": "قواعد الدمج عند حساب المواقع الموجودة على هذه الصفحة", + "infoAlert0": "انقر فوق الزر [جديد]، سيتم عرض مربعات الإدخال الخاصة بموقع المصدر وموقع الدمج، املأ القاعدة واحفظها", + "infoAlert1": "يمكن ملء الموقع الأصلي بموقع معين أو تعبير عادي، مثل www.baidu.com، و*.baidu.com، و*.google.com.*، لتحديد المواقع التي ستطابق هذه القاعدة أثناء الدمج", + "infoAlert2": "يمكن ملء الموقع المدمج بموقع محدد أو رَقَم أو مساحة فارغة", + "infoAlert3": "يشير الرقم إلى مستوى الموقع المدمج. على سبيل المثال، هناك قاعدة '*.*.edu.cn >>> 3'، ثم سيتم دمج 'www.hust.edu.cn' في 'hust.edu.cn'", + "infoAlert4": "الفراغ يعني أن الموقع الأصلي لن يتم دمجه", + "infoAlert5": "إذا لم يتم مطابقة أي قاعدة، فسيتم تعيينها افتراضيًا على المستوى قبل {psl}", + "tagResult": { + "blank": "لا دمج", + "level": "الحفاظ على المستوى {level}" + } + } + }, + "tr": { + "white": { + "label": "Beyaz Liste", + "addConfirmMsg": "{url} beyaz listeye eklenecektir.", + "removeConfirmMsg": "{url} beyaz listeden kaldırılacaktır.", + "infoAlertTitle": "Bu sayfada siteleri beyaz listeye ekleyebilirsiniz", + "infoAlert0": "Beyaz listeye alınan siteler sayılmayacaktır", + "infoAlert1": "Beyaz listeye alınan siteler kısıtlanmayacaktır", + "infoAlert2": "Birden fazla siteyi eşleştirmek için joker karakterler (*) kullanabilirsiniz, örneğin *.example.com/**, ve siteleri hariç tutmak için + ön ekini kullanabilirsiniz, örneğin +need.example.com/**" + }, + "merge": { + "label": "Birleştirme Kuralları", + "removeConfirmMsg": "{origin} özelleştirilmiş birleştirme kurallarından kaldırılacaktır.", + "originPlaceholder": "Orijinal site", + "mergedPlaceholder": "Birleştirildi", + "errorOrigin": "Orijinal sitenin formatı geçersiz.", + "duplicateMsg": "Kural zaten mevcut: {origin}", + "addConfirmMsg": "{origin} için özelleştirilmiş birleştirme kuralları ayarlanacaktır", + "infoAlertTitle": "bu sayfadaki siteleri sayarken birleştirme kuralları", + "infoAlert0": "[Yeni] düğmesine tıklayın, kaynak sitenin ve birleştirme sitesinin giriş kutuları görüntülenecektir, kuralı doldurun ve kaydedin", + "infoAlert1": "Orijinal site, birleştirme sırasında bu kuralla eşleşecek siteleri belirlemek için www.baidu.com, *.baidu.com, *.google.com.* gibi belirli bir site veya düzenli ifade ile doldurulabilir", + "infoAlert2": "Birleştirilen site, belirli bir site, bir sayı veya boşluk ile doldurulabilir", + "infoAlert3": "Bir sayı, birleştirilen sitenin seviyesini ifade eder. Örneğin, ‘*.*.edu.cn >>> 3’ kuralı varsa, ‘www.hust.edu.cn’ sitesi ‘hust.edu.cn’ sitesiyle birleştirilir", + "infoAlert4": "Boşluk, orijinal sitenin birleştirilmeyeceği anlamına gelir", + "infoAlert5": "Eşleşen kural yoksa, varsayılan olarak {psl} öncesindeki seviyeye geçilir", + "tagResult": { + "blank": "Birleştirilemez", + "level": "Seviyeyi {level} koruyun" + } + } + }, + "pl": { + "white": { + "label": "Whitelista", + "addConfirmMsg": "{url} zostanie dodany do whitelisty.", + "removeConfirmMsg": "{url} zostanie usunięty z whitelisty.", + "infoAlertTitle": "Na tej stronie możesz dodawać strony do whitelisty", + "infoAlert0": "Strony znajdujące się na whiteliście nie będą liczone", + "infoAlert1": "Strony znajdujące się na whiteliście nie będą limitowane", + "infoAlert2": "Możesz użyć symboli wieloznacznych(*) do dopasowania wielu witryn, takich jak *.przykład.com/**, i użyć + jako prefiks do wykluczenia witryn, takich jak +potrzebny.przykład.com/**" + }, + "merge": { + "label": "Reguły łączenia stron", + "removeConfirmMsg": "{origin} zostanie usunięty ze spersonalizowanych reguł scalania.", + "originPlaceholder": "Oryginalna strona", + "mergedPlaceholder": "Scalony", + "errorOrigin": "Format oryginalnej strony jest nieprawidłowy.", + "duplicateMsg": "Ta reguła już istnieje: {origin}", + "addConfirmMsg": "Spersonalizowane reguły scalania zostaną ustawione dla {origin}", + "infoAlertTitle": "reguły scalania podczas liczenia witryn na tej stronie", + "infoAlert0": "Kliknij przycisk [New One], pola wprowadzania strony źródłowej oraz strony scalenia zostaną wyświetlone, wypełnij i zapisz regułę", + "infoAlert1": "Oryginalna strona może być wypełniona konkretną stroną lub wyrażeniem regularnym, takim jak *.baidu.com, *.google.com.*, aby określić, które witryny będą odpowiadać tej regule podczas scalania", + "infoAlert2": "Scalona witryna może być wypełniona określoną witryną, liczbą lub zostawiona pusta", + "infoAlert3": "Liczba oznacza poziom scalonej witryny. Na przykład istnieje reguła '*.*.edu.cn >>> 3', a następnie 'www.hust.edu.cn' zostanie połączona z 'hust.edu.cn'", + "infoAlert4": "Puste oznacza, że oryginalna strona nie zostanie połączona", + "infoAlert5": "Jeśli żadna reguła nie jest dopasowana, będzie domyślnie przed {psl}", + "tagResult": { + "blank": "Nie scalaj", + "level": "Zachowaj poziom {level}" + } + } + }, + "it": { + "white": { + "label": "Whitelist", + "addConfirmMsg": "{url} sarà aggiunto alla whitelist.", + "removeConfirmMsg": "{url} sarà rimosso dalla whitelist.", + "infoAlertTitle": "È possibile aggiungere alla whitelist questa pagina", + "infoAlert0": "I siti in whitelist non saranno conteggiati", + "infoAlert1": "I siti in whitelist non saranno conteggiati", + "infoAlert2": "È possibile utilizzare un jolly(*) per abbinare più siti, come *.example.com/**, e utilizzare + come prefisso per escludere i siti, come +need.example.com/**" + }, + "merge": { + "label": "Raggruppa regole dei siti", + "removeConfirmMsg": "{origin} Verrà rimosso dalle regole di unione personalizzate.", + "originPlaceholder": "Sito originale", + "mergedPlaceholder": "Uniti", + "errorOrigin": "Il formato del sito originale non è valido.", + "duplicateMsg": "La regola esiste già: {origin}", + "addConfirmMsg": "Verranno impostate regole di unione personalizzate per {origin}", + "infoAlertTitle": "le regole di unione quando si contano i siti su questa pagina", + "infoAlert0": "Fai clic sul pulsante [Nuovo]; verranno visualizzati i campi d'immissione relativi al sito di origine e al sito di unione; compila i campi e salva la regola", + "infoAlert1": "Il sito originale può essere compilato con un sito specifico o un'espressione regolare, ad esempio www.baidu.com, *.baidu.com, *.google.com*, per determinare quali siti saranno interessati da questa regola durante l'unione", + "infoAlert2": "Il sito unito può contenere il nome di un sito specifico, un numero o essere lasciato vuoto", + "infoAlert3": "Un numero indica il livello del sito a cui viene unito. Ad esempio, se esiste una regola del tipo '*.*.edu.cn >>> 3', allora 'www.hust.edu.cn' verrà unito a 'hust.edu.cn'", + "infoAlert4": "Vuoto significa che il sito originale non verrà unito", + "infoAlert5": "Se non viene individuata alcuna regola corrispondente, verrà utilizzato per impostazione predefinita il livello precedente {psl}", + "tagResult": { + "blank": "Non unite", + "level": "Mantiene Livello {level}" + } + } + } +} \ No newline at end of file diff --git a/src/i18n/message/app/rule.ts b/src/i18n/message/app/rule.ts new file mode 100644 index 000000000..0745cbb49 --- /dev/null +++ b/src/i18n/message/app/rule.ts @@ -0,0 +1,37 @@ +import resource from './rule-resource.json' + +export type RuleMessage = { + white: { + label: string + addConfirmMsg: string + removeConfirmMsg: string + infoAlertTitle: string + infoAlert0: string + infoAlert1: string + infoAlert2: string + } + merge: { + label: string + removeConfirmMsg: string + originPlaceholder: string + mergedPlaceholder: string + errorOrigin: string + duplicateMsg: string + addConfirmMsg: string + infoAlertTitle: string + infoAlert0: string + infoAlert1: string + infoAlert2: string + infoAlert3: string + infoAlert4: string + infoAlert5: string + tagResult: { + blank: string + level: string + } + } +} + +const _default: Messages = resource satisfies Messages + +export default _default \ No newline at end of file diff --git a/src/i18n/message/app/site-manage-resource.json b/src/i18n/message/app/site-manage-resource.json index b65a5c259..745564ff2 100644 --- a/src/i18n/message/app/site-manage-resource.json +++ b/src/i18n/message/app/site-manage-resource.json @@ -302,6 +302,7 @@ }, "de": { "deleteConfirmMsg": "{host} wird gelöscht", + "genAliasConfirmMsg": "Sollen die Namen der Seite automatisch in Batches vervollständigt werden?", "column": { "type": "Website-Typ", "alias": "Site-Name", @@ -336,7 +337,7 @@ "msg": { "hostExistWarn": "{host} existiert", "existedTag": "EXISTIERT", - "noSelected": "Kein Standort ausgewählt", + "noSelected": "Keine Website ausgewählt", "noSupported": "Die ausgewählten Sites können keine Kategorien festlegen", "disassociatedMsg": "Möchten Sie die Kategorien aller ausgewählten Sites löschen?", "batchDeleteMsg": "Möchten Sie alle ausgewählten Sites löschen?" @@ -406,13 +407,21 @@ "info": "подсчитайте любой URL в формате Ant Pattern, вы можете добавить пользовательский сайт в правом верхнем углу" } }, + "cate": { + "name": "Наименование", + "relatedMsg": "Эта категория была связана с {siteCount} сайтами и не может быть удалена", + "removeConfirm": "Вы уверены, что хотите удалить категорию: {category}?", + "batchChange": "Изменить категории" + }, "form": { "emptyAlias": "Введите название сайта", "emptyHost": "Введите URL-адрес сайта" }, "msg": { "hostExistWarn": "{host} существует", - "existedTag": "ВЫПОЛНЕНО" + "existedTag": "ВЫПОЛНЕНО", + "noSelected": "Сайт не выбран", + "batchDeleteMsg": "Вы уверенны, что хотите удалить выбранные сайты?" } }, "ar": { @@ -457,5 +466,134 @@ "disassociatedMsg": "هل تريد إخلاء فئات جميع المواقع المحددة؟", "batchDeleteMsg": "هل تريد حذف جميع المواقع المحددة؟" } + }, + "tr": { + "deleteConfirmMsg": "{host} silinecektir", + "genAliasConfirmMsg": "Site adlarını toplu olarak otomatik olarak tamamlayacak mısınız?", + "column": { + "type": "Site Türü", + "alias": "Site Adı", + "cate": "Site Kategorisi", + "icon": "İkon" + }, + "type": { + "normal": { + "name": "normal", + "info": "alan adına göre istatistikler" + }, + "merged": { + "name": "birleştirildi", + "info": "birden fazla ilgili alan adının istatistiklerini birleştirir ve birleştirme kuralları özelleştirilebilir" + }, + "virtual": { + "name": "sanal", + "info": "Ant Pattern formatındaki herhangi bir URL'yi saymak için, sağ üst köşeye özel bir site ekleyebilirsiniz" + } + }, + "cate": { + "name": "Adı", + "relatedMsg": "Bu kategori {siteCount} siteyle ilişkilendirilmiştir ve silinemez", + "removeConfirm": "Kategoriyi silmek için onaylayın: {category}?", + "batchChange": "Kategorileri değiştir", + "batchDisassociate": "Kategorileri ayır" + }, + "form": { + "emptyAlias": "Lütfen site adını girin", + "emptyHost": "Lütfen site URL'sini girin" + }, + "msg": { + "hostExistWarn": "{host} zaten mevcut", + "existedTag": "MEVCUT", + "noSelected": "Seçilmiş site yok", + "noSupported": "Seçilen siteler için kategori ayarlanamıyor", + "disassociatedMsg": "Seçilen tüm sitelerin kategorilerini temizlemek ister misiniz?", + "batchDeleteMsg": "Seçili tüm siteleri silmek istiyor musunuz?" + } + }, + "pl": { + "deleteConfirmMsg": "{host} zostanie usunięty", + "genAliasConfirmMsg": "Czy automatycznie uzupełniać nazwy witryny w seriach?", + "column": { + "type": "Typ witryny", + "alias": "Nazwa strony", + "cate": "Kategoria witryny", + "icon": "Ikona" + }, + "type": { + "normal": { + "name": "normalny", + "info": "statystyki według nazwy domeny" + }, + "merged": { + "name": "scalone", + "info": "łączenie statystyk wielu powiązanych nazw stron i reguły łączenia mogą być dostosowywane" + }, + "virtual": { + "name": "wirtualne", + "info": "policz dowolny adres URL w formacie Ant Pattern, możesz dodać niestandardową stronę w prawym górnym rogu" + } + }, + "cate": { + "name": "Nazwa", + "relatedMsg": "Ta kategoria została przypisana do witryn {siteCount} i nie może zostać usunięta", + "removeConfirm": "Potwierdzasz usunięcie kategorii: {category}?", + "batchChange": "Zmień kategorie", + "batchDisassociate": "Odłącz kategorie" + }, + "form": { + "emptyAlias": "Wprowadź nazwę witryny", + "emptyHost": "Wprowadź adres URL witryny" + }, + "msg": { + "hostExistWarn": "{host} istnieje", + "existedTag": "WYŁĄCZONE", + "noSelected": "Nie wybrano żadnej witryny", + "noSupported": "Wybrane witryny nie mogą ustawić kategorii", + "disassociatedMsg": "Czy chcesz wyczyścić kategorie wszystkich wybranych witryn?", + "batchDeleteMsg": "Czy chcesz usunąć wszystkie wybrane witryny?" + } + }, + "it": { + "deleteConfirmMsg": "{host} sarà eliminato", + "genAliasConfirmMsg": "Indica se completare automaticamente i nomi dei siti nei batch?", + "column": { + "type": "Site Type", + "alias": "Nome del sito", + "cate": "Categoria di Sito", + "icon": "Icone" + }, + "type": { + "normal": { + "name": "normale", + "info": "statistiche in base al nome dominio" + }, + "merged": { + "name": "uniti", + "info": "fonde le statistiche di più nomi di dominio correlati, e le regole di fusione possono essere personalizzate" + }, + "virtual": { + "name": "virtuale", + "info": "conta qualsiasi URL in formato Ant Pattern, è possibile aggiungere un sito personalizzato nell'angolo in alto a destra" + } + }, + "cate": { + "name": "Nome", + "relatedMsg": "Questa categoria è stata associata ai siti {siteCount} e non può essere eliminata", + "removeConfirm": "Confermi di voler eliminare la categoria: {category}?", + "batchChange": "Cambia categorie", + "batchDisassociate": "Dissocia categorie" + }, + "form": { + "emptyAlias": "Inserisci il nome del sito", + "emptyHost": "Inserisci l'URL del sito" + }, + "msg": { + "hostExistWarn": "{host} esiste", + "existedTag": "ESISTEVA", + "noSelected": "Nessun sito selezionato", + "noSupported": "I siti selezionati non possono impostare categorie", + "disassociatedMsg": "Vuoi cancellare le categorie di tutti i siti selezionati?", + "batchDeleteMsg": "Vuoi eliminare tutti i siti selezionati?" + } } } \ No newline at end of file diff --git a/src/i18n/message/app/site-manage.ts b/src/i18n/message/app/site-manage.ts index 3b4ba75c0..7f274756d 100644 --- a/src/i18n/message/app/site-manage.ts +++ b/src/i18n/message/app/site-manage.ts @@ -16,7 +16,7 @@ export type SiteManageMessage = { cate: string icon: string } - type: Record> + type: Record> cate: { name: string relatedMsg: string diff --git a/src/i18n/message/app/time-format-resource.json b/src/i18n/message/app/time-format-resource.json index 01b896c3a..5be1d0fed 100644 --- a/src/i18n/message/app/time-format-resource.json +++ b/src/i18n/message/app/time-format-resource.json @@ -48,7 +48,7 @@ "second": "Anzeige in Sekunden" }, "fr": { - "default": "Format d'heure par défaut", + "default": "Format de temps par défaut", "hour": "Afficher en heures", "minute": "Afficher en minutes", "second": "Afficher en secondes" @@ -64,5 +64,23 @@ "hour": "عرض في ساعات", "minute": "عرض في دقائق", "second": "العرض بالثواني" + }, + "tr": { + "default": "Varsayılan tarih formatı", + "hour": "Saat cinsinden göster", + "minute": "Dakika cinsinden göster", + "second": "Saniye cinsinden göster" + }, + "pl": { + "default": "Domyślny format czasu", + "hour": "Wyświetl w godzinach", + "minute": "Wyświetl w minutach", + "second": "Wyświetl w sekundach" + }, + "it": { + "default": "Formato orario predefinito", + "hour": "Visualizza in ore", + "minute": "Visualizza in minuti", + "second": "Visualizza in secondi" } } \ No newline at end of file diff --git a/src/i18n/message/app/time-format.ts b/src/i18n/message/app/time-format.ts index 9a8c456e7..35e17abf8 100644 --- a/src/i18n/message/app/time-format.ts +++ b/src/i18n/message/app/time-format.ts @@ -7,7 +7,7 @@ import resource from './time-format-resource.json' -export type TimeFormatMessage = { [key in timer.app.TimeFormat]: string } +export type TimeFormatMessage = { [key in tt4b.ui.TimeFormat]: string } const _default: Messages = resource diff --git a/src/i18n/message/app/whitelist-resource.json b/src/i18n/message/app/whitelist-resource.json deleted file mode 100644 index e55f0e154..000000000 --- a/src/i18n/message/app/whitelist-resource.json +++ /dev/null @@ -1,104 +0,0 @@ -{ - "zh_CN": { - "addConfirmMsg": "{url} 将被加入至白名单", - "removeConfirmMsg": "{url} 将从白名单中移除", - "duplicateMsg": "已存在白名单中", - "infoAlertTitle": "你可以在这里配置网站白名单", - "infoAlert0": "白名单内网站的上网时长和打开次数不会被统计", - "infoAlert1": "白名单内网站的上网时间也不会被限制", - "infoAlert2": "您可以使用通配符 (*) 匹配多个站点,例如 *.example.com/**,并使用 + 作为前缀排除站点,例如 +need.example.com/**", - "errorInput": "域名格式错误" - }, - "zh_TW": { - "addConfirmMsg": "{url} 將會被加入白名單。", - "removeConfirmMsg": "確定要將 {url} 從白名單移除嗎?", - "duplicateMsg": "此網址已存在於白名單中", - "infoAlertTitle": "網站白名單設定", - "infoAlert0": "白名單內的網站將不會記錄瀏覽時長與造訪次數", - "infoAlert1": "白名單內的網站不受時間限制功能影響", - "errorInput": "網域名稱格式錯誤" - }, - "en": { - "addConfirmMsg": "{url} will be added to the whitelist.", - "removeConfirmMsg": "{url} will be removed from the whitelist.", - "duplicateMsg": "Duplicated", - "infoAlertTitle": "You can whitelist sites on this page", - "infoAlert0": "Whitelisted sites will not be counted", - "infoAlert1": "Whitelisted sites will not be restricted", - "infoAlert2": "You can use a wildcards(*) to match multiple sites, such as *.example.com/**, and use + as a prefix to exclude sites, such as +need.example.com/**", - "errorInput": "Invalid site URL" - }, - "ja": { - "addConfirmMsg": "{url} がホワイトリストに追加されます。", - "removeConfirmMsg": "{url} はホワイトリストから削除されます", - "duplicateMsg": "繰り返される", - "infoAlertTitle": "このページでサイトのホワイトリストを設定できます", - "infoAlert0": "ホワイトリストのサイトはカウントされません。", - "infoAlert1": "ホワイトリストのサイトは制限されません。", - "errorInput": "無効なURL" - }, - "pt_PT": { - "addConfirmMsg": "{url} será adicionado à lista de permissões.", - "removeConfirmMsg": "{url} será removido da lista de permissões.", - "duplicateMsg": "Duplicado", - "infoAlertTitle": "Pode adicionar sites à lista de permissões nesta página", - "infoAlert0": "Os sites na lista de permissões não serão contabilizados", - "infoAlert1": "Os sites na lista de permissões não serão restringidos", - "errorInput": "URL do site inválido" - }, - "uk": { - "addConfirmMsg": "{url} буде додано до білого списку.", - "removeConfirmMsg": "{url} буде вилучено з білого списку.", - "duplicateMsg": "Дублікат", - "infoAlertTitle": "На цій сторінці ви можете налаштувати білий список сайтів", - "infoAlert0": "Сайти в білому списку не враховуватимуться", - "infoAlert1": "Сайти в білому списку не матимуть обмежень", - "infoAlert2": "Можна використовувати змінні (*) для збігів декількох сайтів, наприклад *.example.com/**. Щоб виключити сайти, використовуйте +, наприклад +need.example.com/**", - "errorInput": "Неприпустима URL-адреса сайту" - }, - "es": { - "addConfirmMsg": "{url} se agregará a la lista blanca.", - "removeConfirmMsg": "{url} será eliminado de la lista blanca.", - "duplicateMsg": "Duplicado", - "infoAlertTitle": "Puedes configurar una lista blanca de sitios en esta página", - "infoAlert0": "Los sitios en la lista blanca no serán contados", - "infoAlert1": "Los sitios en la lista blanca no serán restringidos", - "errorInput": "URL del sitio inválida" - }, - "de": { - "addConfirmMsg": "{url} wird zur Whitelist hinzugefügt.", - "removeConfirmMsg": "{url} wird von der Whitelist entfernt.", - "duplicateMsg": "Dupliziert", - "infoAlertTitle": "Sie können eine Whitelist vonseiten auf dieser Seite festlegen", - "infoAlert0": "Websites auf der Whitelist werden nicht gezählt", - "infoAlert1": "Websites auf der Whitelist werden nicht eingeschränkt", - "errorInput": "Ungültige Seiten-URL" - }, - "fr": { - "addConfirmMsg": "{url} sera ajouté à la liste blanche.", - "removeConfirmMsg": "{url} sera supprimé de la liste blanche.", - "duplicateMsg": "Doublon", - "infoAlertTitle": "Vous pouvez ajouter des sites à la liste blanche sur cette page", - "infoAlert0": "Les sites sur liste blanche ne seront pas comptés", - "infoAlert1": "Les sites sur liste blanche ne seront pas restreints", - "errorInput": "URL du site invalide" - }, - "ru": { - "addConfirmMsg": "{url} будет добавлен в белый список.", - "removeConfirmMsg": "{url} будет удален из белого списка.", - "duplicateMsg": "Дублированный", - "infoAlertTitle": "Вы можете добавить сайты в белый список на этой странице", - "infoAlert0": "Сайты, внесенные в белый список, не будут учитываться", - "infoAlert1": "Сайты из белого списка не будут ограничены", - "errorInput": "Неверный URL-адрес сайта" - }, - "ar": { - "addConfirmMsg": "سيتم إضافة {url} إلى القائمة البيضاء.", - "removeConfirmMsg": "سيتم إزالة {url} من القائمة البيضاء.", - "duplicateMsg": "مكررة", - "infoAlertTitle": "يمكنك إضافة المواقع إلى القائمة البيضاء في هذه الصفحة", - "infoAlert0": "لن يتم احتساب المواقع المدرجة في القائمة البيضاء", - "infoAlert1": "لن يتم تقييد المواقع المدرجة في القائمة البيضاء", - "errorInput": "عنوان الموقع غير صالح" - } -} \ No newline at end of file diff --git a/src/i18n/message/app/whitelist.ts b/src/i18n/message/app/whitelist.ts deleted file mode 100644 index 54741f6f5..000000000 --- a/src/i18n/message/app/whitelist.ts +++ /dev/null @@ -1,23 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import resource from './whitelist-resource.json' - -export type WhitelistMessage = { - addConfirmMsg: string - removeConfirmMsg: string - duplicateMsg: string - infoAlertTitle: string - infoAlert0: string - infoAlert1: string - infoAlert2: string - errorInput: string -} - -const _default: Messages = resource - -export default _default \ No newline at end of file diff --git a/src/i18n/message/bg/index.ts b/src/i18n/message/bg/index.ts new file mode 100644 index 000000000..acb729e5a --- /dev/null +++ b/src/i18n/message/bg/index.ts @@ -0,0 +1,18 @@ +import calendarMessages, { type CalendarMessage } from '../common/calendar' +import metaMessages, { type MetaMessage } from '../common/meta' +import { merge, MessageRoot } from '../merge' +import notificationMessages, { type NotificationMessage } from './notification' + +export type BgMessage = { + meta: MetaMessage + calendar: CalendarMessage + notification: NotificationMessage +} + +const CHILD_MESSAGES: MessageRoot = { + meta: metaMessages, + calendar: calendarMessages, + notification: notificationMessages, +} + +export default merge(CHILD_MESSAGES) \ No newline at end of file diff --git a/src/i18n/message/bg/notification-resource.json b/src/i18n/message/bg/notification-resource.json new file mode 100644 index 000000000..d302817f6 --- /dev/null +++ b/src/i18n/message/bg/notification-resource.json @@ -0,0 +1,5 @@ +{ + "en": { + "dailySummary": "Focus time: {focus}, Visits: {visit}, Sites: {siteCount}" + } +} \ No newline at end of file diff --git a/src/i18n/message/bg/notification.ts b/src/i18n/message/bg/notification.ts new file mode 100644 index 000000000..edb0e44a3 --- /dev/null +++ b/src/i18n/message/bg/notification.ts @@ -0,0 +1,7 @@ +import resources from "./notification-resource.json" + +export type NotificationMessage = { + dailySummary: string +} + +export default resources satisfies Messages \ No newline at end of file diff --git a/src/i18n/message/common/base-resource.json b/src/i18n/message/common/base-resource.json index 298afed45..43190cda2 100644 --- a/src/i18n/message/common/base-resource.json +++ b/src/i18n/message/common/base-resource.json @@ -3,9 +3,11 @@ "sidebar": "Sidebar", "allFunction": "All Features", "guidePage": "User Guide", - "option": "Settings", + "option": "Options", "sourceCode": "Source Code", - "changeLog": "Release Notes" + "changeLog": "Release Notes", + "limit": "Time Limit", + "helpUs": "Help Translation" }, "zh_CN": { "sidebar": "侧边栏", @@ -13,7 +15,9 @@ "guidePage": "使用指南", "option": "设置", "sourceCode": "源代码", - "changeLog": "版本日志" + "changeLog": "版本日志", + "limit": "时间限制", + "helpUs": "帮助翻译" }, "zh_TW": { "sidebar": "側邊欄", @@ -21,7 +25,9 @@ "guidePage": "使用指南", "option": "設定", "sourceCode": "原始碼", - "changeLog": "版本紀錄" + "changeLog": "版本紀錄", + "limit": "時間限制", + "helpUs": "協助翻譯" }, "ja": { "sidebar": "サイドバー", @@ -29,7 +35,9 @@ "guidePage": "使い方ガイド", "option": "設定", "sourceCode": "ソースコード", - "changeLog": "リリースノート" + "changeLog": "リリースノート", + "limit": "時間制限", + "helpUs": "協力する" }, "pt_PT": { "sidebar": "Barra Lateral", @@ -37,7 +45,9 @@ "guidePage": "Guia do Utilizador", "option": "Definições", "sourceCode": "Código Fonte", - "changeLog": "Notas de Lançamento" + "changeLog": "Notas de Lançamento", + "limit": "Limite de Tempo", + "helpUs": "Ajudar a Traduzir" }, "uk": { "sidebar": "Бічна панель", @@ -45,7 +55,9 @@ "guidePage": "Посібник", "option": "Налаштування", "sourceCode": "Програмний код", - "changeLog": "Журнал змін" + "changeLog": "Журнал змін", + "limit": "Обмеження часу", + "helpUs": "Допомогти нам" }, "es": { "sidebar": "Barra Lateral", @@ -53,7 +65,9 @@ "guidePage": "Guía de Usuario", "option": "Ajustes", "sourceCode": "Código Fuente", - "changeLog": "Notas de Versión" + "changeLog": "Notas de Versión", + "limit": "Límite de tiempo", + "helpUs": "Ayúdanos" }, "de": { "sidebar": "Seitenleiste", @@ -61,7 +75,9 @@ "guidePage": "Benutzeranleitung", "option": "Einstellungen", "sourceCode": "Quellcode", - "changeLog": "Versionshinweise" + "changeLog": "Versionshinweise", + "limit": "Zeitlimit", + "helpUs": "Hilfe bei der Übersetzung" }, "fr": { "sidebar": "Barre Latérale", @@ -69,7 +85,9 @@ "guidePage": "Guide d'Utilisation", "option": "Paramètres", "sourceCode": "Code Source", - "changeLog": "Notes de Version" + "changeLog": "Notes de Version", + "limit": "Limite de temps", + "helpUs": "Aider à la traduction" }, "ru": { "sidebar": "Боковая панель", @@ -77,7 +95,9 @@ "guidePage": "Руководство", "option": "Настройки", "sourceCode": "Исходный код", - "changeLog": "Версионные изменения" + "changeLog": "Версионные изменения", + "limit": "Ограничение по времени", + "helpUs": "Помогите перевести" }, "ar": { "sidebar": "الشريط الجانبي", @@ -85,6 +105,38 @@ "guidePage": "دليل الاستخدام", "option": "الإعدادات", "sourceCode": "الكود المصدري", - "changeLog": "ملاحظات الإصدار" + "changeLog": "ملاحظات الإصدار", + "limit": "المهلة", + "helpUs": "ساعدني في التَّرْجَمَةً" + }, + "tr": { + "sidebar": "Kenar Çubuğu", + "allFunction": "Tüm Özellikler", + "guidePage": "Kullanım Kılavuzu", + "option": "Seçenekler", + "sourceCode": "Kaynak Kodu", + "changeLog": "Sürüm Notları", + "limit": "Zaman Sınırı", + "helpUs": "Çeviriye yardım et" + }, + "pl": { + "sidebar": "Pasek boczny", + "allFunction": "Wszystkie funkcje", + "guidePage": "Podręcznik użytkownika", + "option": "Ustawienia", + "sourceCode": "Kod źródłowy", + "changeLog": "Lista Zmian", + "limit": "Limit czasu", + "helpUs": "Pomóż w tłumaczeniu" + }, + "it": { + "sidebar": "Barra laterale", + "allFunction": "Tutte le funzionalità", + "guidePage": "Guida per l'utente", + "option": "Opzioni", + "sourceCode": "Codice sorgente", + "changeLog": "Note di Rilascio", + "limit": "Limite di tempo", + "helpUs": "Aiuto Traduzione" } } \ No newline at end of file diff --git a/src/i18n/message/common/base.ts b/src/i18n/message/common/base.ts index b64d19b24..f14c4ebd7 100644 --- a/src/i18n/message/common/base.ts +++ b/src/i18n/message/common/base.ts @@ -8,12 +8,13 @@ import resource from './base-resource.json' export type BaseMessage = { - sidebar: string allFunction: string guidePage: string changeLog: string option: string sourceCode: string + limit: string + helpUs: string } /** diff --git a/src/i18n/message/common/button-resource.json b/src/i18n/message/common/button-resource.json index df855cc55..991cae1c1 100644 --- a/src/i18n/message/common/button-resource.json +++ b/src/i18n/message/common/button-resource.json @@ -12,14 +12,17 @@ "cancel": "Cancel", "previous": "Back", "next": "Next", - "okey": "OK", + "okay": "OK", "dont": "No", "operation": "Actions", "configuration": "Settings", "clear": "Clear", "enable": "Enable", "batchEnable": "Batch Enable", - "batchDisable": "Batch Disable" + "batchDisable": "Batch Disable", + "collapse": "Collapse", + "expand": "Expand", + "copy": "Copy" }, "zh_CN": { "create": "新建", @@ -34,14 +37,17 @@ "cancel": "取消", "previous": "上一步", "next": "下一步", - "okey": "确定", + "okay": "确定", "dont": "取消", "operation": "操作", "configuration": "设置", "clear": "清除", "enable": "启用", "batchEnable": "批量启用", - "batchDisable": "批量禁用" + "batchDisable": "批量禁用", + "collapse": "折叠", + "expand": "展开", + "copy": "复制" }, "zh_TW": { "create": "新增", @@ -56,17 +62,20 @@ "cancel": "取消", "previous": "上一步", "next": "下一步", - "okey": "確定", + "okay": "確定", "dont": "取消", "operation": "操作", "configuration": "設定", "clear": "清除", "enable": "啟用", "batchEnable": "批次啟用", - "batchDisable": "批次停用" + "batchDisable": "批量停用", + "collapse": "收合", + "expand": "展開" }, "ja": { "create": "新規", + "add": "追加", "delete": "削除", "batchDelete": "一括削除", "modify": "編集", @@ -77,13 +86,16 @@ "cancel": "キャンセル", "previous": "戻る", "next": "次へ", - "okey": "OK", + "okay": "OK", "dont": "いいえ", "operation": "操作", "configuration": "設定", "clear": "クリア", + "enable": "有効化", "batchEnable": "一括有効", - "batchDisable": "一括無効" + "batchDisable": "一括無効", + "collapse": "折りたたむ", + "expand": "展開" }, "pt_PT": { "create": "Criar", @@ -98,14 +110,15 @@ "cancel": "Cancelar", "previous": "Anterior", "next": "Seguinte", - "okey": "OK", "dont": "Não", "operation": "Ações", "configuration": "Configurações", "clear": "Limpar", "enable": "Ativar", "batchEnable": "Ativar em Lote", - "batchDisable": "Desativar em Lote" + "batchDisable": "Desativar em Lote", + "collapse": "Colapsar", + "expand": "Expandir" }, "uk": { "create": "Новий", @@ -120,7 +133,7 @@ "cancel": "Скасувати", "previous": "Назад", "next": "Далі", - "okey": "Гаразд", + "okay": "Гаразд", "dont": "НІ", "operation": "Дії", "configuration": "Налаштування", @@ -142,17 +155,19 @@ "cancel": "Cancelar", "previous": "Anterior", "next": "Siguiente", - "okey": "OK", - "dont": "¡NO!", + "dont": "No", "operation": "Acciones", "configuration": "Configuración", "clear": "Limpiar", "enable": "Activar", "batchEnable": "Habilitar en Lote", - "batchDisable": "Deshabilitar en Lote" + "batchDisable": "Deshabilitar en Lote", + "collapse": "Colapsar", + "expand": "Expandir" }, "de": { "create": "Neu", + "add": "Hinzufügen", "delete": "Löschen", "batchDelete": "Stapelverarbeitung löschen", "modify": "Bearbeiten", @@ -163,13 +178,17 @@ "cancel": "Abbrechen", "previous": "Zurück", "next": "Weiter", - "okey": "OK", + "okay": "OK", "dont": "Nein", "operation": "Aktionen", "configuration": "Einstellungen", "clear": "Leeren", + "enable": "Aktivieren", "batchEnable": "Stapelaktivierung", - "batchDisable": "Stapeldeaktivierung" + "batchDisable": "Stapeldeaktivierung", + "collapse": "Schließen", + "expand": "Aufklappen", + "copy": "Kopieren" }, "fr": { "create": "Nouveau", @@ -184,17 +203,21 @@ "cancel": "Annuler", "previous": "Retour", "next": "Suivant", - "okey": "OK", + "okay": "OK", "dont": "Non", "operation": "Opérations", "configuration": "Paramètres", "clear": "Effacer", "enable": "Activer", "batchEnable": "Activation par Lot", - "batchDisable": "Désactivation par Lot" + "batchDisable": "Désactivation par Lot", + "collapse": "Réduire", + "expand": "Développer", + "copy": "Copier" }, "ru": { "create": "Создать", + "add": "Добавить", "delete": "Удалить", "batchDelete": "Массовое удаление", "modify": "Редактировать", @@ -205,11 +228,12 @@ "cancel": "Отменить", "previous": "Назад", "next": "Далее", - "okey": "ОК", + "okay": "ОК", "dont": "Нет", "operation": "Действия", "configuration": "Настройки", "clear": "Очистить", + "enable": "Включить", "batchEnable": "Массовое включение", "batchDisable": "Массовое отключение" }, @@ -226,7 +250,7 @@ "cancel": "إلغاء", "previous": "السابق", "next": "التالي", - "okey": "موافق", + "okay": "موافق", "dont": "لا", "operation": "إجراءات", "configuration": "إعدادات", @@ -234,5 +258,78 @@ "enable": "تفعيل", "batchEnable": "تمكين جماعي", "batchDisable": "تعطيل جماعي" + }, + "tr": { + "create": "Yeni", + "add": "Ekle", + "delete": "Sil", + "batchDelete": "Toplu Sil", + "modify": "Düzenle", + "save": "Kaydet", + "test": "Test", + "paste": "Yapıştır", + "confirm": "Onayla", + "cancel": "İptal Et", + "previous": "Geri", + "next": "İleri", + "okay": "Tamam", + "dont": "Hayır", + "operation": "Eylemler", + "configuration": "Ayarlar", + "clear": "Temizle", + "enable": "Etkinleştir", + "batchEnable": "Toplu Etkinleştirme", + "batchDisable": "Toplu Devre Dışı Bırakma", + "collapse": "Daralt", + "expand": "Genişlet" + }, + "pl": { + "create": "Nowe", + "add": "Dodaj", + "delete": "Usuń", + "batchDelete": "Usuń wiele", + "modify": "Edytuj", + "save": "Zapisz", + "test": "Test", + "paste": "Wklej", + "confirm": "Potwierdź", + "cancel": "Anuluj", + "previous": "Wstecz", + "next": "Dalej", + "okay": "Ok", + "dont": "Nie", + "operation": "Działania", + "configuration": "Ustawienia", + "clear": "Wyczyść", + "enable": "Włącz", + "batchEnable": "Włączanie serii", + "batchDisable": "Seria wyłączenia", + "collapse": "Zwiń", + "expand": "Rozwiń" + }, + "it": { + "create": "Nuovo", + "add": "Aggiungi", + "delete": "Elimina", + "batchDelete": "Elimina batch", + "modify": "Modifica", + "save": "Salva", + "test": "Test", + "paste": "Incolla", + "confirm": "Conferma", + "cancel": "Cancella", + "previous": "Indietro", + "next": "Avanti", + "okay": "OK", + "dont": "No", + "operation": "Azioni", + "configuration": "Impostazioni", + "clear": "Cancella", + "enable": "Abilita", + "batchEnable": "Attiva Batch", + "batchDisable": "Disabilita Batch", + "collapse": "Riduci", + "expand": "Espandi", + "copy": "Copia" } } \ No newline at end of file diff --git a/src/i18n/message/common/button.ts b/src/i18n/message/common/button.ts index eb5f4fe12..054b834a5 100644 --- a/src/i18n/message/common/button.ts +++ b/src/i18n/message/common/button.ts @@ -19,7 +19,7 @@ export type ButtonMessage = { cancel: string previous: string next: string - okey: string + okay: string dont: string operation: string configuration: string @@ -27,6 +27,9 @@ export type ButtonMessage = { enable: string batchEnable: string batchDisable: string + collapse: string + expand: string + copy: string } const _default: Messages = resource diff --git a/src/i18n/message/common/calendar-resource.json b/src/i18n/message/common/calendar-resource.json index d81852141..057201a00 100644 --- a/src/i18n/message/common/calendar-resource.json +++ b/src/i18n/message/common/calendar-resource.json @@ -16,6 +16,7 @@ "everyday": "每天", "thisWeek": "本周", "thisMonth": "本月", + "lastWeek": "上周", "lastDays": "最近{n}天", "tillYesterday": "截至昨天", "tillDaysAgo": "截至{n}天前", @@ -39,6 +40,7 @@ "everyday": "每天", "thisWeek": "本週", "thisMonth": "本月", + "lastWeek": "上週", "lastDays": "最近{n}天", "tillYesterday": "截至昨天", "tillDaysAgo": "截至{n}天前", @@ -62,6 +64,7 @@ "everyday": "Everyday", "thisWeek": "This week", "thisMonth": "This month", + "lastWeek": "Last week", "lastDays": "Last {n} days", "tillYesterday": "Through yesterday", "tillDaysAgo": "Through {n} days ago", @@ -85,6 +88,7 @@ "everyday": "毎日", "thisWeek": "今週", "thisMonth": "今月", + "lastWeek": "先週", "lastDays": "過去{n}日間", "tillYesterday": "昨日まで", "tillDaysAgo": "{n}日前まで", @@ -154,6 +158,7 @@ "everyday": "Diario", "thisWeek": "Esta semana", "thisMonth": "Este mes", + "lastWeek": "Semana pasada", "lastDays": "Últimos {n} días", "tillYesterday": "Hasta ayer", "tillDaysAgo": "Hasta hace {n} días", @@ -177,6 +182,7 @@ "everyday": "Täglich", "thisWeek": "Diese Woche", "thisMonth": "Diesen Monat", + "lastWeek": "Letzte Woche", "lastDays": "Letzte {n} Tage", "tillYesterday": "Bis gestern", "tillDaysAgo": "Bis vor {n} Tagen", @@ -200,6 +206,7 @@ "everyday": "Quotidien", "thisWeek": "Cette semaine", "thisMonth": "Ce mois-ci", + "lastWeek": "Dernière semaine", "lastDays": "{n} derniers jours", "tillYesterday": "Jusqu'à hier", "tillDaysAgo": "Jusqu'à {n} jours", @@ -251,5 +258,77 @@ "tillDaysAgo": "حتى منذ {n} يوم", "allTime": "كل الفترة" } + }, + "tr": { + "weekDays": "Pzt|Salı|Çar|Per|Cum|Cts|Paz", + "months": "Oca|Şub|Mar|Nis|May|Haz|Tem|Ağu|Eyl|Eki|Kas|Ara", + "dateFormat": "{d}/{m}/{y}", + "monthDateFormat": "{d}/{m}", + "timeFormat": "{d}/{m}/{y} {h}:{i}:{s}", + "simpleTimeFormat": "{d}/{m} {h}:{i}", + "label": { + "startDate": "Başlangıç ​​tarihi", + "endDate": "Bitiş tarihi" + }, + "range": { + "today": "Bugün", + "yesterday": "Dün", + "everyday": "Her gün", + "thisWeek": "Bu hafta", + "thisMonth": "Bu ay", + "lastWeek": "Geçen hafta", + "lastDays": "Son {n} gün", + "tillYesterday": "Dün boyunca", + "tillDaysAgo": "{n} gün önce", + "allTime": "Tüm zamanlar" + } + }, + "pl": { + "weekDays": "Pn|Wt|Śr|Cz|Pt|Sb|Nd", + "months": "Sty|Lut|Mar|Kwi|Maj|Cze|Lip|Sie|Wrz|Paź|Lis|Gru", + "dateFormat": "{d}/{m}/{y}", + "monthDateFormat": "{d}/{m}", + "timeFormat": "{d}/{m}/{y} {h}:{i}:{s}", + "simpleTimeFormat": "{m}/{d} {h}:{i}", + "label": { + "startDate": "Data rozpoczęcia", + "endDate": "Data zakończenia" + }, + "range": { + "today": "Dzisiaj", + "yesterday": "Wczoraj", + "everyday": "Codziennie", + "thisWeek": "W tym tygodniu", + "thisMonth": "W tym miesiącu", + "lastWeek": "W zeszłym tygodniu", + "lastDays": "W ciągu ostatnich {n} dni", + "tillYesterday": "Wczoraj", + "tillDaysAgo": "Przez {n} dni temu", + "allTime": "Od początku" + } + }, + "it": { + "weekDays": "Lun|Mar|Mer|Gio|Ven|Sab|Dom", + "months": "Gen|Feb|Mar|Apr|Mag|Giu|Lug|Ago|Set|Ott|Nov|Dic", + "dateFormat": "{d}/{m}/{y}", + "monthDateFormat": "{d}/{m}", + "timeFormat": "{d}/{m}/{y} {h}:{i}:{s}", + "simpleTimeFormat": "{d}/{m} {h}:{i}", + "label": { + "startDate": "Data Inizio", + "endDate": "Data di fine" + }, + "range": { + "today": "Oggi", + "yesterday": "Ieri", + "everyday": "Tutti i giorni", + "thisWeek": "Questa settimana", + "thisMonth": "Questo mese", + "lastWeek": "Ultima settimana", + "lastDays": "Ultimi {n} giorni", + "tillYesterday": "Fino a ieri", + "tillDaysAgo": "Fino a {n} giorni fa", + "allTime": "Sempre" + } } } \ No newline at end of file diff --git a/src/i18n/message/common/calendar.ts b/src/i18n/message/common/calendar.ts index 8e11c891d..6e838d44c 100644 --- a/src/i18n/message/common/calendar.ts +++ b/src/i18n/message/common/calendar.ts @@ -24,6 +24,7 @@ export type CalendarMessage = { yesterday: string thisWeek: string thisMonth: string + lastWeek: string lastDays: string tillYesterday: string tillDaysAgo: string diff --git a/src/i18n/message/common/context-menus-resource.json b/src/i18n/message/common/context-menus-resource.json index b3cea4cd7..3c1f9813b 100644 --- a/src/i18n/message/common/context-menus-resource.json +++ b/src/i18n/message/common/context-menus-resource.json @@ -53,5 +53,20 @@ "add2Whitelist": "أضف {host} إلى القائمة البيضاء", "removeFromWhitelist": "إزالة {host} من القائمة البيضاء", "feedbackPage": "مشكلات" + }, + "tr": { + "add2Whitelist": "{host} adresini beyaz listeye ekle", + "removeFromWhitelist": "{host} adresini beyaz listeden çıkart", + "feedbackPage": "Sorunlar" + }, + "pl": { + "add2Whitelist": "Dodaj {host} do Whitelisty", + "removeFromWhitelist": "Usuń {host} z Whitelisty", + "feedbackPage": "Zgłoszenia" + }, + "it": { + "add2Whitelist": "Aggiungi {host} alla whitelist", + "removeFromWhitelist": "Rimuovi {host} dalla whitelist", + "feedbackPage": "Problemi" } } \ No newline at end of file diff --git a/src/i18n/message/common/initial-resource.json b/src/i18n/message/common/initial-resource.json index 855a8edcf..ebecb809a 100644 --- a/src/i18n/message/common/initial-resource.json +++ b/src/i18n/message/common/initial-resource.json @@ -86,5 +86,29 @@ "pic": "الصور", "txt": "نص" } + }, + "tr": { + "localFile": { + "json": "JSON", + "pdf": "PDF", + "pic": "Resimler", + "txt": "Metin" + } + }, + "pl": { + "localFile": { + "json": "JSON", + "pdf": "PDF", + "pic": "Zdjęcia", + "txt": "Tekst" + } + }, + "it": { + "localFile": { + "json": "JSON", + "pdf": "PDF", + "pic": "Immagini", + "txt": "Testo" + } } } \ No newline at end of file diff --git a/src/i18n/message/common/item-resource.json b/src/i18n/message/common/item-resource.json index e8b26c461..e44637776 100644 --- a/src/i18n/message/common/item-resource.json +++ b/src/i18n/message/common/item-resource.json @@ -11,10 +11,7 @@ "analysis": "分析", "deleteConfirmMsgAll": "将删除 {url} 的所有访问记录", "deleteConfirmMsgRange": "将删除 {url} 在 {start} 至 {end} 期间的访问记录", - "deleteConfirmMsg": "将删除 {url} 在 {date} 的访问记录", - "exportWholeData": "导出数据", - "importWholeData": "导入数据", - "importOtherData": "导入其他插件数据" + "deleteConfirmMsg": "将删除 {url} 在 {date} 的访问记录" } }, "zh_TW": { @@ -29,10 +26,7 @@ "analysis": "分析", "deleteConfirmMsgAll": "將刪除 {url} 的所有瀏覽紀錄", "deleteConfirmMsgRange": "將刪除 {url} 在 {start} 至 {end} 期間的瀏覽紀錄", - "deleteConfirmMsg": "將刪除 {url} 在 {date} 的瀏覽紀錄", - "exportWholeData": "匯出資料", - "importWholeData": "匯入資料", - "importOtherData": "匯入其他擴充功能資料" + "deleteConfirmMsg": "將刪除 {url} 在 {date} 的瀏覽紀錄" } }, "en": { @@ -47,15 +41,13 @@ "analysis": "Analyze", "deleteConfirmMsgAll": "All visit records for [{url}] will be deleted", "deleteConfirmMsgRange": "Visit records for [{url}] between {start} and {end} will be deleted", - "deleteConfirmMsg": "Visit record for [{url}] of {date} will be deleted", - "exportWholeData": "Export", - "importWholeData": "Import", - "importOtherData": "Import from Extensions" + "deleteConfirmMsg": "Visit record for [{url}] of {date} will be deleted" } }, "ja": { "date": "日付", "host": "ドメイン", + "group": "タブグループ", "focus": "閲覧時間", "run": "実行時間", "time": "訪問回数", @@ -64,15 +56,13 @@ "analysis": "分析", "deleteConfirmMsgAll": "{url} の全ての訪問記録を削除します", "deleteConfirmMsgRange": "{url} の {start} から {end} までの訪問記録を削除します", - "deleteConfirmMsg": "{url} の {date} の訪問記録を削除します", - "exportWholeData": "エクスポート", - "importWholeData": "インポート", - "importOtherData": "拡張機能からインポート" + "deleteConfirmMsg": "{url} の {date} の訪問記録を削除します" } }, "pt_PT": { "date": "Data", "host": "Domínio", + "group": "Grupo de Separadores", "focus": "Duração", "run": "Tempo de Execução", "time": "Visitas", @@ -81,10 +71,7 @@ "analysis": "Analisar", "deleteConfirmMsgAll": "Todos os registos de visitas de {url} serão eliminados", "deleteConfirmMsgRange": "Os registos de visitas de {url} entre {start} e {end} serão eliminados", - "deleteConfirmMsg": "O registo de visita de {url} de {date} será eliminado", - "exportWholeData": "Exportar", - "importWholeData": "Importar", - "importOtherData": "Importar de Extensões" + "deleteConfirmMsg": "O registo de visita de {url} de {date} será eliminado" } }, "uk": { @@ -99,10 +86,7 @@ "analysis": "Аналіз", "deleteConfirmMsgAll": "Усі записи для {url} будуть видалені!", "deleteConfirmMsgRange": "Усі записи для {url} між {start} і {end} будуть видалені!", - "deleteConfirmMsg": "Запис для {url} {date} буде видалено!", - "exportWholeData": "Експортувати дані", - "importWholeData": "Імпортувати дані", - "importOtherData": "Імпорт з інших розширень" + "deleteConfirmMsg": "Запис для {url} {date} буде видалено!" } }, "es": { @@ -117,26 +101,22 @@ "analysis": "Analizar", "deleteConfirmMsgAll": "Se eliminarán todos los registros de {url}", "deleteConfirmMsgRange": "Se eliminarán los registros de {url} entre {start} y {end}", - "deleteConfirmMsg": "Se eliminará el registro de {url} del {date}", - "exportWholeData": "Exportar", - "importWholeData": "Importar", - "importOtherData": "Importar de extensiones" + "deleteConfirmMsg": "Se eliminará el registro de {url} del {date}" } }, "de": { "date": "Datum", "host": "Webseite", + "group": "Tab-Gruppe", "focus": "Dauer", + "run": "Laufzeit", "time": "Besuche", "operation": { "add2Whitelist": "Whitelist", "analysis": "Analyse", "deleteConfirmMsgAll": "Alle Datensätze von {url} werden gelöscht", "deleteConfirmMsgRange": "Alle Einträge von {url} zwischen {start} und {end} werden gelöscht", - "deleteConfirmMsg": "Der Eintrag von {url} am {date} wird gelöscht", - "exportWholeData": "Exportieren", - "importWholeData": "Importieren", - "importOtherData": "Aus Erweiterungen importieren" + "deleteConfirmMsg": "Der Eintrag von {url} am {date} wird gelöscht" } }, "fr": { @@ -147,18 +127,17 @@ "run": "Durée d'exécution", "time": "Visites", "operation": { - "add2Whitelist": "Whitelist", + "add2Whitelist": "Liste blanche", "analysis": "Analyser", "deleteConfirmMsgAll": "Tous les enregistrements de {url} seront supprimés", "deleteConfirmMsgRange": "Les enregistrements de {url} entre {start} et {end} seront supprimés", - "exportWholeData": "Exporter", - "importWholeData": "Importer", - "importOtherData": "Depuis d'autres extensions" + "deleteConfirmMsg": "L'enregistrement des visites pour [{url}] du [{date}] seront supprimés" } }, "ru": { "date": "Дата", "host": "Сайт", + "group": "Группа вкладок", "focus": "Длительность", "run": "Время работы", "time": "Визиты", @@ -167,10 +146,7 @@ "analysis": "Анализ", "deleteConfirmMsgAll": "Все записи {url} будут удалены", "deleteConfirmMsgRange": "Записи {url} с {start} по {end} будут удалены", - "deleteConfirmMsg": "Запись {url} за {date} будет удалена", - "exportWholeData": "Экспорт", - "importWholeData": "Импорт", - "importOtherData": "Импорт из дополнений" + "deleteConfirmMsg": "Запись {url} за {date} будет удалена" } }, "ar": { @@ -185,10 +161,52 @@ "analysis": "تحليل", "deleteConfirmMsgAll": "سيتم حذف جميع سجلات {url}", "deleteConfirmMsgRange": "سيتم حذف سجلات {url} بين {start} و{end}", - "deleteConfirmMsg": "سيتم حذف سجل {url} بتاريخ {date}", - "exportWholeData": "تصدير", - "importWholeData": "استيراد", - "importOtherData": "استيراد من إضافات أخرى" + "deleteConfirmMsg": "سيتم حذف سجل {url} بتاريخ {date}" + } + }, + "tr": { + "date": "Tarih", + "host": "Alan adı", + "group": "Sekme Grubu", + "focus": "Süre", + "run": "Çalışma Süresi", + "time": "Ziyaretler", + "operation": { + "add2Whitelist": "Beyaz Liste", + "analysis": "Analiz et", + "deleteConfirmMsgAll": "[{url}] adresine ait tüm ziyaret kayıtları silinecektir", + "deleteConfirmMsgRange": "{start} ile {end} arasındaki [{url}] ziyaret kayıtları silinecektir", + "deleteConfirmMsg": "{date} tarihindeki [{url}] için ziyaret kaydı silinecektir" + } + }, + "pl": { + "date": "Data", + "host": "Domena", + "group": "Grupa zakładek", + "focus": "Czas trwania", + "run": "Czas pracy", + "time": "Liczba wizyt", + "operation": { + "add2Whitelist": "Whitelista", + "analysis": "Analizuj", + "deleteConfirmMsgAll": "Wszystkie wpisy wizyt dla [{url}] zostaną usunięte", + "deleteConfirmMsgRange": "Sprawdź wpisy dla [{url}] pomiędzy {start} a {end} zostaną usunięte", + "deleteConfirmMsg": "Wpis wizyt dla [{url}] z {date} zostanie usunięty" + } + }, + "it": { + "date": "Data", + "host": "Dominio", + "group": "Gruppo Di Schede", + "focus": "Durata", + "run": "Tempo Di Esecuzione", + "time": "Visite", + "operation": { + "add2Whitelist": "Whitelist", + "analysis": "Analizza", + "deleteConfirmMsgAll": "Tutti i record di visita per [{url}] verranno eliminati", + "deleteConfirmMsgRange": "Record di visite per [{url}] tra {start} ed {end} saranno eliminati", + "deleteConfirmMsg": "Visita record per [{url}] di {date} sarà eliminato" } } } \ No newline at end of file diff --git a/src/i18n/message/common/item.ts b/src/i18n/message/common/item.ts index 0ed7cf70b..755c0ac6b 100644 --- a/src/i18n/message/common/item.ts +++ b/src/i18n/message/common/item.ts @@ -17,12 +17,9 @@ export type ItemMessage = { deleteConfirmMsgRange: string deleteConfirmMsg: string analysis: string - exportWholeData: string - importWholeData: string - importOtherData: string } } & { - [dimension in timer.core.Dimension]: string + [dimension in tt4b.core.Dimension]: string } const _default: Messages = resource diff --git a/src/i18n/message/common/locale-resource.json b/src/i18n/message/common/locale-resource.json index f8bc66eeb..d2c129694 100644 --- a/src/i18n/message/common/locale-resource.json +++ b/src/i18n/message/common/locale-resource.json @@ -43,15 +43,21 @@ "name": "عربي", "comma": " " }, + "tr": { + "name": "Türkçe", + "comma": ", " + }, "pl": { - "name": "Polski" + "name": "Polski", + "comma": ", " + }, + "it": { + "name": "italiano", + "comma": ", " }, "ko": { "name": "한국인" }, - "it": { - "name": "italiano" - }, "sv": { "name": "Sverige" }, @@ -67,9 +73,6 @@ "id": { "name": "bahasa Indonesia" }, - "tr": { - "name": "Türkçe" - }, "cs": { "name": "čeština" }, @@ -90,5 +93,11 @@ }, "hi": { "name": "हिन्दी" + }, + "nb": { + "name": "norsk bokmål" + }, + "hu": { + "name": "magyar" } } \ No newline at end of file diff --git a/src/i18n/message/common/locale.ts b/src/i18n/message/common/locale.ts index ec1d4045a..012af62f6 100644 --- a/src/i18n/message/common/locale.ts +++ b/src/i18n/message/common/locale.ts @@ -20,11 +20,11 @@ type Meta = MetaBase & { * * @since 0.8.0 */ -export type LocaleMessages = +type LocaleMessages = { - [locale in timer.Locale]: Meta + [locale in tt4b.Locale]: Meta } & { - [translatingLocale in timer.TranslatingLocale]: MetaBase + [translatingLocale in tt4b.TranslatingLocale]: MetaBase } const _default: LocaleMessages = resource diff --git a/src/i18n/message/common/meta-resource.json b/src/i18n/message/common/meta-resource.json index c87684ac2..8a2c5d558 100644 --- a/src/i18n/message/common/meta-resource.json +++ b/src/i18n/message/common/meta-resource.json @@ -1,57 +1,72 @@ { "zh_CN": { "name": "网费很贵", - "marketName": "网费很贵 - 上网时间统计", - "description": "追踪时间,分析习惯,改善行为,提高效率" + "marketName": "网费很贵 - 屏幕习惯分析 & 成瘾网站拦截", + "description": "追踪网页时间,分析浏览习惯,防止网站沉迷,提高工作效率" }, "zh_TW": { "name": "網費很貴", - "marketName": "網費很貴 - 上網時間統計", - "description": "追蹤時間,分析習慣,改善行為,提高效率" + "marketName": "網費很貴 - 螢幕習慣分析 & 成癮網站攔截", + "description": "追蹤上網時間,分析瀏覽習慣,避免沉迷網站,提升工作效率" }, "ja": { - "name": "Web時間統計", - "marketName": "Web時間統計", - "description": "統計、分析、改善、効率向上" + "name": "タイムトラッカー", + "marketName": "タイムトラッカー - Web習慣ビルダー", + "description": "時間を追跡し、習慣を分析し、依存性のあるサイトをブロック" }, "en": { - "name": "Time Tracker for Browser", - "marketName": "Time Tracker for Browser - Web Habit Builder", - "description": "Track, analyze, control, then improve efficiency" + "name": "Time Tracker", + "marketName": "Time Tracker - Web Habit Builder", + "description": "Track time, analyze your habits and block addictive sites" }, "pt_PT": { - "name": "Monitor de Tempo para Navegador", - "marketName": "Monitor de Tempo para Navegador - Criador de Hábitos Web", - "description": "Monitorize, analise, controle e melhore a eficiência" + "name": "Rastreador de Tempo", + "marketName": "Rastreador de Tempo - Construtor de Hábitos na Web", + "description": "Acompanhe o tempo, analise seus hábitos e bloqueie sites viciantes" }, "uk": { "name": "Веб-трекер часу", - "marketName": "Веб-трекер часу", - "description": "Відстежуйте, аналізуйте, контролюйте, а потім підвищуйте ефективність" + "marketName": "Веб-трекер часу - Творець веб-звичок", + "description": "Відстежуйте час, аналізуйте звички та блокуйте сайти, що викликають залежність" }, "es": { "name": "Rastreador de tiempo web", - "marketName": "Rastreador de tiempo web", - "description": "Realice un seguimiento, analice, controle y luego mejore la eficiencia" + "marketName": "Rastreador de tiempo web - Creador de hábitos web", + "description": "Realice un seguimiento del tiempo, analice sus hábitos y bloquee sitios adictivos" }, "de": { "name": "Zeiterfassung", - "marketName": "Zeiterfassung", - "description": "Verfolgen, analysieren, kontrollieren und dann die Effizienz verbessern" + "marketName": "Zeiterfassung - Web-Gewohnheitstrainer", + "description": "Verfolgen Sie die Zeit, analysieren Sie Ihre Gewohnheiten und blockieren Sie süchtig machende Websites" }, "fr": { "name": "Suivi du temps Web", - "marketName": "Suivi du temps Web - Online Habit Formers", - "description": "Suivre, analyser, contrôler, puis améliorer l’efficacité" + "marketName": "Suivi du temps Web - Créateur d'habitudes web", + "description": "Suivez votre temps, analysez vos habitudes et bloquez les sites addictifs" }, "ru": { "name": "Трекер времени", - "marketName": "Трекер времени", - "description": "Отслеживайте, анализируйте, контролируйте, а затем повышайте эффективность" + "marketName": "Трекер времени — Формирование веб-привычек", + "description": "Отслеживайте время, анализируйте свои привычки и блокируйте вызывающие зависимость сайты" }, "ar": { "name": "متتبع الوقت", "marketName": "متتبع الوقت - منشئ عادات الويب", - "description": "تتبع وتحليل ومراقبة ثم تحسين الكفاءة" + "description": "تتبع الوقت، حلل عاداتك، واحظر المواقع الإدمانية" + }, + "tr": { + "name": "Zaman Takipçisi", + "marketName": "Zaman Takipçisi – Alışkanlık Oluşturucu ve Site Engelleyici", + "description": "Zamanınızı takip edin, alışkanlıklarınızı analiz edin, bağımlılık yapan siteleri engelleyin" + }, + "pl": { + "name": "Zegar Czasu", + "marketName": "Zegar Czasu - Kreator Nawyków Internetowych", + "description": "Śledź czas, analizuj swoje nawyki i blokuj uzależniające strony" + }, + "it": { + "name": "Tracker del Tempo", + "marketName": "Tracker del Tempo - Costruttore di Abitudini Web", + "description": "Traccia il tempo, analizza le tue abitudini e blocca i siti che creano dipendenza" } } \ No newline at end of file diff --git a/src/i18n/message/common/shared-resource.json b/src/i18n/message/common/shared-resource.json index 363fba2dd..d930cbf0d 100644 --- a/src/i18n/message/common/shared-resource.json +++ b/src/i18n/message/common/shared-resource.json @@ -12,6 +12,12 @@ }, "cate": { "notSet": "Not Set" + }, + "limit": { + "daily": "Daily limit", + "weekly": "Weekly limit", + "period": "Blocked periods", + "visits": "{n} visits" } }, "zh_CN": { @@ -27,6 +33,12 @@ }, "cate": { "notSet": "未分类" + }, + "limit": { + "daily": "每日上限", + "weekly": "每周上限", + "period": "不可访问的时间段", + "visits": "{n}次访问" } }, "ja": { @@ -36,11 +48,18 @@ "date": "日期", "domain": "URL", "cate": "カテゴリー", + "group": "タブグループ", "notMerge": "不合并" } }, "cate": { "notSet": "未設定" + }, + "limit": { + "daily": "1日の限度", + "weekly": "週間の限度", + "period": "許可されない期間", + "visits": "{n}訪問" } }, "zh_TW": { @@ -56,6 +75,12 @@ }, "cate": { "notSet": "未設定" + }, + "limit": { + "daily": "每日上限", + "weekly": "每週上限", + "period": "限制時段", + "visits": "{n}次造訪" } }, "pt_PT": { @@ -65,11 +90,18 @@ "date": "Data", "domain": "URL", "cate": "Categoria", + "group": "Grupo de Separadores", "notMerge": "Não Agrupar" } }, "cate": { "notSet": "Não Definido" + }, + "limit": { + "daily": "Limite diário", + "weekly": "Limite semanal", + "period": "Períodos bloqueados", + "visits": "{n} visitas" } }, "uk": { @@ -85,6 +117,33 @@ }, "cate": { "notSet": "Не встановлено" + }, + "limit": { + "daily": "Денний ліміт", + "weekly": "Тижневий ліміт", + "period": "Недозволені періоди", + "visits": "{n} відвідування" + } + }, + "it": { + "merge": { + "mergeBy": "Unisci per", + "mergeMethod": { + "date": "Data", + "domain": "URL", + "cate": "Categoria", + "group": "Gruppo Di Schede", + "notMerge": "Non Unire" + } + }, + "cate": { + "notSet": "Non impostato" + }, + "limit": { + "daily": "Limite giornaliero", + "weekly": "Limite settimanale", + "period": "Periodi bloccati", + "visits": "{n} visite" } }, "es": { @@ -100,20 +159,33 @@ }, "cate": { "notSet": "No establecido" + }, + "limit": { + "daily": "Límite diario", + "weekly": "Límite semanal", + "period": "Periodos no permitidos", + "visits": "{n} visitas" } }, "de": { "merge": { - "mergeBy": "Merge nach", + "mergeBy": "Zusammenführen durch", "mergeMethod": { "date": "Datum", "domain": "URL", "cate": "Kategorie", + "group": "Tab-Gruppe", "notMerge": "Nicht zusammenführen" } }, "cate": { "notSet": "Nicht festgelegt" + }, + "limit": { + "daily": "Tägliches Limit", + "weekly": "Wöchentliches Limit", + "period": "Unzulässiger Zeitraum", + "visits": "{n} Besuche" } }, "fr": { @@ -129,6 +201,12 @@ }, "cate": { "notSet": "Non Défini" + }, + "limit": { + "daily": "Limite quotidienne", + "weekly": "Limite hebdomadaire", + "period": "Périodes bloquées", + "visits": "{n} visites" } }, "ru": { @@ -138,11 +216,18 @@ "date": "Дата", "domain": "URL", "cate": "Категория", + "group": "Группа вкладок", "notMerge": "Не объединять" } }, "cate": { "notSet": "Не задано" + }, + "limit": { + "daily": "Дневной лимит", + "weekly": "Недельный лимит", + "period": "Заблокированное время", + "visits": "{n} посещения" } }, "ar": { @@ -158,6 +243,54 @@ }, "cate": { "notSet": "غير مضبوط" + }, + "limit": { + "daily": "الحد اليومي", + "weekly": "الحد الأسبوعي", + "period": "فترات محظورة", + "visits": "{n} زيارة" + } + }, + "tr": { + "merge": { + "mergeBy": "Birleştirme biçimi", + "mergeMethod": { + "date": "Tarih", + "domain": "URL", + "cate": "Kategori", + "group": "Sekme Grubu", + "notMerge": "Birleştirilemez" + } + }, + "cate": { + "notSet": "Ayarlanmadı" + }, + "limit": { + "daily": "Günlük limit", + "weekly": "Haftalık limit", + "period": "Engellenen periyotlar", + "visits": "{n} ziyaretler" + } + }, + "pl": { + "merge": { + "mergeBy": "Połącz przez", + "mergeMethod": { + "date": "Data", + "domain": "Adres URL", + "cate": "Kategoria", + "group": "Grupa zakładek", + "notMerge": "Nie scalaj" + } + }, + "cate": { + "notSet": "Nie ustawiono" + }, + "limit": { + "daily": "Dzienny limit", + "weekly": "Limit tygodniowy", + "period": "Godziny blokowania", + "visits": "{n} wizyt" } } } \ No newline at end of file diff --git a/src/i18n/message/common/shared.ts b/src/i18n/message/common/shared.ts index c4e0e4d01..41ea1c830 100644 --- a/src/i18n/message/common/shared.ts +++ b/src/i18n/message/common/shared.ts @@ -3,11 +3,17 @@ import resource from "./shared-resource.json" export type SharedMessage = { merge: { mergeBy: string - mergeMethod: Record & { notMerge: string } + mergeMethod: Record & { notMerge: string } } cate: { notSet: string } + limit: { + daily: string + weekly: string + period: string + visits: string + } } const sharedMessages = resource satisfies Messages diff --git a/src/i18n/message/cs/console-resource.json b/src/i18n/message/cs/console-resource.json index f32de0deb..73ee5c041 100644 --- a/src/i18n/message/cs/console-resource.json +++ b/src/i18n/message/cs/console-resource.json @@ -42,5 +42,17 @@ "ar": { "consoleLog": "اليوم فتحت {host} {time} مرة, أنفقت {focus} أثناء تصفحها.", "closeAlert": "يمكنك تعطيل هذه الإشعارات في إعدادات [{appName}]" + }, + "tr": { + "consoleLog": "Bugün {host} {time} saat(ler) açtınız ve {focus} saniye boyunca göz attınız.", + "closeAlert": "Bu bildirimleri [{appName}] ayarlarından devre dışı bırakabilirsiniz!" + }, + "pl": { + "consoleLog": "Dziś otworzyłeś {host} {time} raz(y) i spędziłeś {focus} przeglądając go.", + "closeAlert": "Możesz wyłączyć te powiadomienia w ustawieniach [{appName}]!" + }, + "it": { + "consoleLog": "Oggi hai aperto {host} {time} volta(e), spendendo {focus} navigandolo.", + "closeAlert": "Puoi disabilitare queste notifiche nelle impostazioni di [{appName}]!" } } \ No newline at end of file diff --git a/src/i18n/message/cs/index.ts b/src/i18n/message/cs/index.ts index 7bdb9f4dc..791f279f3 100644 --- a/src/i18n/message/cs/index.ts +++ b/src/i18n/message/cs/index.ts @@ -2,6 +2,7 @@ import limitMessages, { type LimitMessage } from "../app/limit" import menuMessages, { type MenuMessage } from "../app/menu" import calendarMessages, { type CalendarMessage } from "../common/calendar" import metaMessages, { type MetaMessage } from "../common/meta" +import sharedMessages, { type SharedMessage } from '../common/shared' import { merge, type MessageRoot } from "../merge" import consoleMessages, { type ConsoleMessage } from "./console" import modalMessages, { type ModalMessage } from "./modal" @@ -11,6 +12,7 @@ export type CsMessage = { modal: ModalMessage meta: MetaMessage limit: LimitMessage + shared: SharedMessage menu: MenuMessage calendar: CalendarMessage } @@ -20,6 +22,7 @@ const CHILD_MESSAGES: MessageRoot = { modal: modalMessages, meta: metaMessages, limit: limitMessages, + shared: sharedMessages, menu: menuMessages, calendar: calendarMessages, } diff --git a/src/i18n/message/cs/modal-resource.json b/src/i18n/message/cs/modal-resource.json index 5c3c72aec..2615fb8e7 100644 --- a/src/i18n/message/cs/modal-resource.json +++ b/src/i18n/message/cs/modal-resource.json @@ -1,68 +1,86 @@ { "zh_CN": { "defaultPrompt": "古希腊时间与浏览器之神正在阻止您访问该页面", - "more5Minutes": "延长5分钟", + "delay": "延长{n}分钟", "browsingTime": "当前浏览时长", "ruleDetail": "规则详情" }, "en": { "defaultPrompt": "This page was blocked!", - "more5Minutes": "5 more minutes", + "delay": "{n} more minutes", "browsingTime": "Browsing time", "ruleDetail": "Rule details" }, "ja": { "defaultPrompt": "このページはブロックされました!", - "more5Minutes": "5分延長", + "delay": "{n}分延長", "browsingTime": "閲覧時間", "ruleDetail": "ルール詳細" }, "zh_TW": { "defaultPrompt": "古希臘時間與瀏覽器之神暫時封鎖此頁面", - "more5Minutes": "延長5分鐘", + "delay": "延長{n}分鐘", "browsingTime": "目前瀏覽時長", "ruleDetail": "規則詳情" }, "pt_PT": { "defaultPrompt": "Esta página foi bloqueada!", - "more5Minutes": "Mais 5 minutos", + "delay": "Mais {n} minutos", "browsingTime": "Tempo de Navegação", "ruleDetail": "Detalhes da Regra" }, "uk": { "defaultPrompt": "Цю сторінку заблоковано!", - "more5Minutes": "Ще 5 хвилин", + "delay": "Ще {n} хвилин", "browsingTime": "Час перегляду", "ruleDetail": "Подробиці правила" }, "fr": { - "defaultPrompt": "Cette page a été bloquée!", - "more5Minutes": "5 minutes supplémentaires", + "defaultPrompt": "Cette page a été bloquée !", + "delay": "{n} minutes supplémentaires", "browsingTime": "Temps de navigation", "ruleDetail": "Détails des règles" }, "es": { "defaultPrompt": "¡Esta página fue bloqueada!", - "more5Minutes": "5 minutos más", + "delay": "{n} minutos más", "browsingTime": "Tiempo de navegación", "ruleDetail": "Detalles de la regla" }, "de": { "defaultPrompt": "Diese Seite wurde gesperrt!", - "more5Minutes": "Noch 5 Minuten", + "delay": "Noch {n} Minuten", "browsingTime": "Browsing-Zeit", "ruleDetail": "Regeldetails" }, "ru": { "defaultPrompt": "Эта страница заблокирована!", - "more5Minutes": "Ещё 5 минут", + "delay": "Ещё {n} минут", "browsingTime": "Время просмотра", "ruleDetail": "Подробности правила" }, "ar": { "defaultPrompt": "تم حظر هذه الصفحة!", - "more5Minutes": "5 دقائق إضافية", + "delay": "{n} دقائق إضافية", "browsingTime": "وقت التصفح", "ruleDetail": "تفاصيل القاعدة" + }, + "tr": { + "defaultPrompt": "Bu sayfa engellendi!", + "delay": "{n} dakika daha", + "browsingTime": "Gezinme süresi", + "ruleDetail": "Kural Detayları" + }, + "pl": { + "defaultPrompt": "Ta strona została zablokowana!", + "delay": "jeszcze {n} minut", + "browsingTime": "Czas przeglądania", + "ruleDetail": "Szczegóły zasad" + }, + "it": { + "defaultPrompt": "Questa pagina è stata bloccata!", + "delay": "{n} altri minuti", + "browsingTime": "Tempo di navigazione", + "ruleDetail": "Dettagli regola" } } \ No newline at end of file diff --git a/src/i18n/message/cs/modal.ts b/src/i18n/message/cs/modal.ts index 8e8765ddc..3054c7fa6 100644 --- a/src/i18n/message/cs/modal.ts +++ b/src/i18n/message/cs/modal.ts @@ -2,7 +2,7 @@ import resource from './modal-resource.json' export type ModalMessage = { defaultPrompt: string - more5Minutes: string + delay: string browsingTime: string ruleDetail: string } diff --git a/src/i18n/message/merge.ts b/src/i18n/message/merge.ts index 06acdd627..92157272f 100644 --- a/src/i18n/message/merge.ts +++ b/src/i18n/message/merge.ts @@ -1,4 +1,4 @@ -const ALL_LOCALE_VALIDATOR: { [locale in timer.Locale]: 0 } = { +const ALL_LOCALE_VALIDATOR: { [locale in tt4b.Locale]: 0 } = { en: 0, zh_CN: 0, ja: 0, @@ -10,14 +10,17 @@ const ALL_LOCALE_VALIDATOR: { [locale in timer.Locale]: 0 } = { fr: 0, ru: 0, ar: 0, + tr: 0, + pl: 0, + it: 0, } -export const ALL_LOCALES: timer.Locale[] = Object.keys(ALL_LOCALE_VALIDATOR) as timer.Locale[] +export const ALL_LOCALES: tt4b.Locale[] = Object.keys(ALL_LOCALE_VALIDATOR) as tt4b.Locale[] export type MessageRoot = { [key in keyof T]: Messages } export function merge(messageRoot: MessageRoot): Required> { - const result: Partial>> = {} + const result: Partial>> = {} ALL_LOCALES.forEach(locale => { const message = messageOfRoot(locale, messageRoot) result[locale] = message as T & EmbeddedPartial @@ -25,8 +28,8 @@ export function merge(messageRoot: MessageRoot): Required> { return result as Required> } -function messageOfRoot(locale: timer.Locale, messageRoot: MessageRoot): T { - const entries: [string, any][] = Object.entries(messageRoot).map(([key, val]) => ([key, (val as Record>)[locale]])) +function messageOfRoot(locale: tt4b.Locale, messageRoot: MessageRoot): T { + const entries: [string, any][] = Object.entries(messageRoot).map(([key, val]) => ([key, (val as Record>)[locale]])) const result = Object.fromEntries(entries) as T return result } \ No newline at end of file diff --git a/src/i18n/message/popup/content-resource.json b/src/i18n/message/popup/content-resource.json index 0017f07d3..9b98da656 100644 --- a/src/i18n/message/popup/content-resource.json +++ b/src/i18n/message/popup/content-resource.json @@ -9,15 +9,25 @@ "lastDays": "近 {n} 天数据", "allTime": "全部数据" }, - "saveAsImageTitle": "保存", - "averageTime": "平均每天 {value}", + "shareTitle": "分享", + "installTip": "扫描安装 👉", "averageCount": "平均每天 {value} 次", + "averageTime": "平均每天 {value}", "totalTime": "共 {totalTime}", "totalCount": "共 {totalCount} 次", "otherLabel": "其他{count}个网站" }, "ranking": { "includingCount": "含 {siteCount} 个网站" + }, + "limit": { + "noData": "此URL无限制规则", + "newOne": "新建", + "timeUsed": "已使用 {percent}", + "visitUsed": "已使用 {used} 次", + "remain": "剩余 {remaining}", + "noLimit": "无限制", + "notHit": "未命中" } }, "zh_TW": { @@ -30,7 +40,6 @@ "lastDays": "近{n}天紀錄", "allTime": "全部紀錄" }, - "saveAsImageTitle": "儲存圖片", "averageCount": "平均每日 {value} 次", "averageTime": "平均每日 {value}", "totalTime": "總計 {totalTime}", @@ -51,7 +60,8 @@ "lastDays": "Last {n} days' data", "allTime": "All data" }, - "saveAsImageTitle": "Snapshot", + "shareTitle": "Share", + "installTip": "Scan to install", "averageCount": "{value} times per day on average", "averageTime": "{value} per day on average", "totalTime": "Total {totalTime}", @@ -60,6 +70,15 @@ }, "ranking": { "includingCount": "Including {siteCount} sites" + }, + "limit": { + "noData": "No limit rule effective for this URL", + "newOne": "New One", + "timeUsed": "{percent} used", + "visitUsed": "{used} count visited", + "remain": "{remaining} remaining", + "noLimit": "No limit", + "notHit": "Not in effect" } }, "ja": { @@ -72,7 +91,6 @@ "lastDays": "過去 {n} 日間のデータ", "allTime": "すべてのデータ" }, - "saveAsImageTitle": "スクリーンショット", "averageCount": "1日平均 {value} 回", "averageTime": "1日平均 {value}", "totalTime": "合計 {totalTime}", @@ -81,6 +99,15 @@ }, "ranking": { "includingCount": "{siteCount} サイトを含む" + }, + "limit": { + "noData": "このURLには制限が適用されません", + "newOne": "追加", + "timeUsed": "{percent} 使用済み", + "visitUsed": "訪問回数{used}回", + "remain": "残り {remaining}", + "noLimit": "制限なし", + "notHit": "無効です" } }, "pt_PT": { @@ -93,7 +120,6 @@ "lastDays": "Dados dos últimos {n} dias", "allTime": "Todos os dados" }, - "saveAsImageTitle": "Captura", "averageCount": "Média de {value} vezes por dia", "averageTime": "Média de {value} por dia", "totalTime": "Total: {totalTime}", @@ -114,7 +140,8 @@ "lastDays": "Дані за минулі {n} днів", "allTime": "Всі дані" }, - "saveAsImageTitle": "Знімок", + "shareTitle": "Поділитися", + "installTip": "Сканувати для встановлення", "averageCount": "{value} разів на день у середньому", "averageTime": "{value} на день у середньому", "totalTime": "Всього {totalTime}", @@ -123,6 +150,15 @@ }, "ranking": { "includingCount": "Включаючи {siteCount} сайтів" + }, + "limit": { + "noData": "Для цієї URL-адреси не призначене правило обмеження", + "newOne": "Створити", + "timeUsed": "{percent} використано", + "visitUsed": "{used} кількість відвіданих", + "remain": "{remaining} залишилося", + "noLimit": "Без обмежень", + "notHit": "Не задіяно" } }, "es": { @@ -135,7 +171,6 @@ "lastDays": "Datos de los últimos {n} días", "allTime": "Todos los datos" }, - "saveAsImageTitle": "Captura de pantalla", "averageCount": "{value} veces al día en promedio", "averageTime": "{value} al día en promedio", "totalTime": "Total {totalTime}", @@ -156,15 +191,25 @@ "lastDays": "Daten der letzten {n} Tage", "allTime": "Alle Daten" }, - "saveAsImageTitle": "Bildschirmfoto", + "shareTitle": "Teilen", + "installTip": "Zum Installieren scannen", "averageCount": "{value} mal pro Tag", "averageTime": "{value} pro Tag im Durchschnitt", - "totalTime": "Gesamt {totalTime}", - "totalCount": "Gesamt {totalCount} mal", + "totalTime": "Insgesamt {totalTime}", + "totalCount": "Insgesamt {totalCount} mal", "otherLabel": "{count} andere Websites" }, "ranking": { "includingCount": "Einschließlich {siteCount} Websites" + }, + "limit": { + "noData": "Für diese URL ist keine Regel aktiv", + "newOne": "Neu", + "timeUsed": "{percent} verwendet", + "visitUsed": "{used} mal besucht", + "remain": "{remaining} verbleibend", + "noLimit": "Kein Limit", + "notHit": "Nicht aktiv" } }, "fr": { @@ -177,7 +222,8 @@ "lastDays": "Les données de ces {n} derniers jours", "allTime": "Toutes les données" }, - "saveAsImageTitle": "Capture d'écran", + "shareTitle": "Partager", + "installTip": "Scanner pour installer", "averageCount": "{value} fois par jour en moyenne", "averageTime": "{value} par jour en moyenne", "totalTime": "{totalTime} au total", @@ -186,6 +232,15 @@ }, "ranking": { "includingCount": "Y compris {siteCount} sites" + }, + "limit": { + "noData": "Pas de règle de limite en vigueur pour cette URL", + "newOne": "Créer Nouvelle", + "timeUsed": "{percent} utilisé", + "visitUsed": "{used} visites", + "remain": "{remaining} restantes", + "noLimit": "Aucune limite", + "notHit": "Pas en vigueur" } }, "ru": { @@ -198,7 +253,6 @@ "lastDays": "Данные за последние {n} дней", "allTime": "Все данные" }, - "saveAsImageTitle": "Снимок", "averageCount": "{value} раз в день в среднем", "averageTime": "{value} в день в среднем", "totalTime": "Всего {totalTime}", @@ -219,7 +273,6 @@ "lastDays": "بيانات آخر {n} يوم", "allTime": "كل البيانات" }, - "saveAsImageTitle": "لقطة", "averageCount": "متوسط {value} زيارة يومياً", "averageTime": "متوسط {value} يومياً", "totalTime": "الإجمالي {totalTime}", @@ -229,5 +282,76 @@ "ranking": { "includingCount": "بما في ذلك {siteCount} موقعاً" } + }, + "tr": { + "percentage": { + "title": { + "today": "Bugünkü Veriler", + "yesterday": "Dünkü Veriler", + "thisWeek": "Bu Haftaki Veriler", + "thisMonth": "Bu Ayki Veriler", + "lastDays": "Son {n} günün verileri", + "allTime": "Tüm Veriler" + }, + "averageCount": "Günde ortalama {value} kez", + "averageTime": "Günde ortalama {value}", + "totalTime": "Toplam Zaman {totalTime}", + "totalCount": "Toplam {totalCount} kez", + "otherLabel": "Diğer {count} site" + }, + "ranking": { + "includingCount": "{siteCount} site dahil" + } + }, + "pl": { + "percentage": { + "title": { + "today": "Dzisiejsze dane", + "yesterday": "Dane wczorajsze", + "thisWeek": "Dane w tym tygodniu", + "thisMonth": "Dane z tego miesiąca", + "lastDays": "Ostatnie dane z {n} dnia/i", + "allTime": "Wszystkie dane" + }, + "averageCount": "Średnio {value} razy co dzień", + "averageTime": "Średnio {value} razy dziennie", + "totalTime": "Ogółem {totalTime}", + "totalCount": "Razem: {totalCount}", + "otherLabel": "Pozostałe strony: {count}" + }, + "ranking": { + "includingCount": "Wliczając {siteCount} stron" + } + }, + "it": { + "percentage": { + "title": { + "today": "Dati di oggi", + "yesterday": "Data di Ieri", + "thisWeek": "Dati di questa Settimana", + "thisMonth": "Dati di questo Mese", + "lastDays": "Ultimo {n} giorni dati", + "allTime": "Tutti i dati" + }, + "shareTitle": "Condividi", + "installTip": "Scansiona per installare", + "averageCount": "{value} al giorno in media", + "averageTime": "{value} al giorno in media", + "totalTime": "Totale {totalTime}", + "totalCount": "{totalCount} volte Totale", + "otherLabel": "Altri siti {count}" + }, + "ranking": { + "includingCount": "Inclusi siti {siteCount}" + }, + "limit": { + "noData": "Nessuna regola di limite effettiva per questo URL", + "newOne": "Nuova", + "timeUsed": "{percent} usato", + "visitUsed": "Conteggio visite {used}", + "remain": "{remaining} rimanente", + "noLimit": "Nessun limite", + "notHit": "Non funzione" + } } } \ No newline at end of file diff --git a/src/i18n/message/popup/content.ts b/src/i18n/message/popup/content.ts index cc61dc8a9..c5e5d976c 100644 --- a/src/i18n/message/popup/content.ts +++ b/src/i18n/message/popup/content.ts @@ -7,10 +7,16 @@ import resource from './content-resource.json' +type PopupDuration = + | "today" | "yesterday" | "thisWeek" | "thisMonth" + | "lastDays" + | "allTime" + export type ContentMessage = { percentage: { - title: { [key in timer.option.PopupDuration]: string } - saveAsImageTitle: string + title: { [key in PopupDuration]: string } + shareTitle: string + installTip: string averageTime: string averageCount: string totalTime: string @@ -20,8 +26,17 @@ export type ContentMessage = { ranking: { includingCount: string } + limit: { + noData: string + newOne: string + timeUsed: string + visitUsed: string + remain: string + noLimit: string + notHit: string + } } -const contentMessages = resource as Messages +const contentMessages = resource satisfies Messages export default contentMessages \ No newline at end of file diff --git a/src/i18n/message/popup/footer-resource.json b/src/i18n/message/popup/footer-resource.json index 46fffbae8..2de89ae5e 100644 --- a/src/i18n/message/popup/footer-resource.json +++ b/src/i18n/message/popup/footer-resource.json @@ -58,5 +58,23 @@ "percentage": "التوزيع", "ranking": "الترتيب" } + }, + "tr": { + "route": { + "percentage": "Dağılım", + "ranking": "Sıralama" + } + }, + "pl": { + "route": { + "percentage": "Rozkład", + "ranking": "Ranking" + } + }, + "it": { + "route": { + "percentage": "Distribuzione", + "ranking": "Classifica" + } } } \ No newline at end of file diff --git a/src/i18n/message/popup/footer.ts b/src/i18n/message/popup/footer.ts index f76486e33..252c4e5cf 100644 --- a/src/i18n/message/popup/footer.ts +++ b/src/i18n/message/popup/footer.ts @@ -5,11 +5,13 @@ * https://opensource.org/licenses/MIT */ -import { type PopupRoute } from '@popup/router' import resource from './footer-resource.json' export type FooterMessage = { - route: Record + route: { + percentage: string + ranking: string + } } const footerMessages = resource satisfies Messages diff --git a/src/i18n/message/popup/header-resource.json b/src/i18n/message/popup/header-resource.json index 696574d0d..5af352876 100644 --- a/src/i18n/message/popup/header-resource.json +++ b/src/i18n/message/popup/header-resource.json @@ -1,68 +1,76 @@ { "en": { - "updateVersion": "Update", - "updateVersionInfo": "Latest version: {version}", - "updateVersionInfo4Firefox": "Please update to {version} via about:addons", - "rate": "Rate Us" + "rating": "Submit rating", + "discord": "Join Discord", + "showSiteName": "Display site name", + "showTopN": "Display top {n}", + "donutChart": "Displayed as donut charts" }, "zh_CN": { - "updateVersion": "可更新", - "updateVersionInfo": "最新版本:{version}", - "updateVersionInfo4Firefox": "请前往 about:addons 更新至 {version}", - "rate": "评分" + "rating": "提交评价", + "discord": "加入 Discord", + "showSiteName": "显示网站名称", + "showTopN": "显示前 {n} 名", + "donutChart": "以圆环图显示" }, "zh_TW": { - "updateVersion": "可更新", - "updateVersionInfo": "最新版本:{version}", - "updateVersionInfo4Firefox": "請前往 about:addons 更新至 {version}", - "rate": "評分" + "rating": "送出評分", + "showSiteName": "顯示網站名稱", + "showTopN": "顯示前{n}个", + "donutChart": "以圓餅圖顯示" }, "ja": { - "updateVersion": "更新可能", - "updateVersionInfo": "最新バージョン: {version}", - "updateVersionInfo4Firefox": "about:addons で {version} に更新してください", - "rate": "評価する" + "rating": "評価を送信", + "showSiteName": "サイト名を表示", + "showTopN": "トップ {n} を表示", + "donutChart": "ドーナツグラフとして表示" }, "pt_PT": { - "updateVersion": "Atualizar", - "updateVersionInfo": "Versão mais recente: {version}", - "updateVersionInfo4Firefox": "Atualize para {version} através do about:addons", - "rate": "Avaliar" - }, - "uk": { - "updateVersion": "Оновити", - "updateVersionInfo": "Найновіша версія: {version}", - "updateVersionInfo4Firefox": "Оновіться до {version} через about:addons", - "rate": "Оцінити" + "showSiteName": "Mostrar nome do site", + "donutChart": "Mostrado como gráfico estilo donut" }, + "uk": {}, "es": { - "updateVersion": "Actualizar", - "updateVersionInfo": "Última versión: {version}", - "updateVersionInfo4Firefox": "Actualice a {version} en about:addons", - "rate": "Calificar" + "rating": "Enviar Puntuación", + "showSiteName": "Mostrar nombre del sitio", + "showTopN": "Mostrar primeros {n}", + "donutChart": "Mostrar como gráfico de donas" }, "de": { - "updateVersion": "Aktualisierbar", - "updateVersionInfo": "Neueste Version: {version}", - "updateVersionInfo4Firefox": "Bitte aktualisieren Sie auf {version} über about:addons", - "rate": "Bewerten" + "rating": "Bewertung absenden", + "showSiteName": "Seitenname anzeigen", + "showTopN": "Top {n} anzeigen", + "donutChart": "Als Donut-Diagramme angezeigt" }, "fr": { - "updateVersion": "MAJ", - "updateVersionInfo": "Dernière version: {version}", - "updateVersionInfo4Firefox": "Veuillez mettre à jour vers {version} via about:addons", - "rate": "Noter" + "rating": "Donner une note", + "discord": "Rejoignez notre Discord", + "showSiteName": "Afficher le nom du site", + "showTopN": "Afficher les {n} premiers", + "donutChart": "Affichés sous forme de graphiques en anneau" }, "ru": { - "updateVersion": "Обновить", - "updateVersionInfo": "Последняя версия: {version}", - "updateVersionInfo4Firefox": "Обновитесь до {version} через about:addons", - "rate": "Оценить" + "rating": "Отправить оценку", + "showSiteName": "Отобразить имя сайта", + "showTopN": "Отображать заголовок {n}" + }, + "ar": {}, + "tr": { + "rating": "Değerlendirmeyi Gönder", + "showSiteName": "Site adını göster", + "showTopN": "En iyi {n} göster", + "donutChart": "Halka grafik olarak göster" + }, + "pl": { + "rating": "Prześlij ocenę", + "showSiteName": "Wyświetl nazwę witryny", + "showTopN": "Wyświetl górną część {n}", + "donutChart": "Wyświetlane jako wykresy" }, - "ar": { - "updateVersion": "تحديث متاح", - "updateVersionInfo": "أحدث إصدار: {version}", - "updateVersionInfo4Firefox": "الرجاء التحديث إلى {version} عبر about:addons", - "rate": "قيمنا" + "it": { + "rating": "Invia il tuo voto", + "showSiteName": "Visualizza nome del sito", + "showTopN": "Mostra top {n}", + "donutChart": "Visualizzati come grafici di ciambella" } } \ No newline at end of file diff --git a/src/i18n/message/popup/header.ts b/src/i18n/message/popup/header.ts index 44205dcdd..ba3017ebe 100644 --- a/src/i18n/message/popup/header.ts +++ b/src/i18n/message/popup/header.ts @@ -8,10 +8,11 @@ import resource from './header-resource.json' export type HeaderMessage = { - updateVersion: string - updateVersionInfo: string - updateVersionInfo4Firefox: string - rate: string + rating: string + discord: string + donutChart: string + showSiteName: string + showTopN: string } const headerMessages = resource satisfies Messages diff --git a/src/i18n/message/popup/index.ts b/src/i18n/message/popup/index.ts index 890f031b0..3d7501dfd 100644 --- a/src/i18n/message/popup/index.ts +++ b/src/i18n/message/popup/index.ts @@ -5,7 +5,6 @@ * https://opensource.org/licenses/MIT */ -import menuMessages, { type MenuMessage } from "../app/menu" import baseMessages, { type BaseMessage } from "../common/base" import calendarMessages, { type CalendarMessage } from "../common/calendar" import itemMessages, { type ItemMessage } from "../common/item" @@ -16,6 +15,7 @@ import contentMessages, { type ContentMessage } from "./content" import footerMessages, { type FooterMessage } from "./footer" import headerMessages, { type HeaderMessage } from "./header" + export type PopupMessage = { content: ContentMessage item: ItemMessage @@ -23,7 +23,6 @@ export type PopupMessage = { base: BaseMessage header: HeaderMessage footer: FooterMessage - menu: MenuMessage calendar: CalendarMessage shared: SharedMessage } @@ -35,7 +34,6 @@ const MESSAGE_ROOT: MessageRoot = { base: baseMessages, header: headerMessages, footer: footerMessages, - menu: menuMessages, calendar: calendarMessages, shared: sharedMessages, } diff --git a/src/i18n/message/side/list-resource.json b/src/i18n/message/side/list-resource.json index 722d26893..e09528796 100644 --- a/src/i18n/message/side/list-resource.json +++ b/src/i18n/message/side/list-resource.json @@ -9,7 +9,7 @@ }, "de": { "searchPlaceholder": "Domain oder URL eingeben", - "title": "Browser-Analyse" + "title": "Nutzungsanalyse" }, "ja": { "searchPlaceholder": "ドメインまたはURLを入力", @@ -42,5 +42,17 @@ "ar": { "searchPlaceholder": "أدخل النطاق أو الرابط", "title": "تحليل التصفح" + }, + "tr": { + "searchPlaceholder": "Alan adı veya URL giriniz", + "title": "Gezinme Analizi" + }, + "pl": { + "searchPlaceholder": "Wprowadź domenę lub adres URL", + "title": "Analiza przeglądania " + }, + "it": { + "searchPlaceholder": "Inserisci dominio o URL", + "title": "Analisi Di Esplorazione" } } \ No newline at end of file diff --git a/src/manifest-firefox.ts b/src/manifest-firefox.ts index 6a52f7877..95fcd8bee 100644 --- a/src/manifest-firefox.ts +++ b/src/manifest-firefox.ts @@ -6,16 +6,16 @@ */ /** - * Build the manifest.json in chrome extension directory via this file + * Build the manifest.json in Firefox extension directory via this file * * @author zhy * @since 0.0.1 */ -// Not use path alias in manifest.json -import packageInfo from "./package" -const { version, author: { name: authorName }, homepage } = packageInfo +import packageJson from "../package.json" -const _default: chrome.runtime.ManifestFirefox = { +const { version, author: { name: authorName }, homepage } = packageJson + +const _default: browser._manifest.WebExtensionManifest = { name: '__MSG_meta_marketName__', description: "__MSG_meta_description__", version, @@ -23,10 +23,11 @@ const _default: chrome.runtime.ManifestFirefox = { default_locale: 'en', homepage_url: homepage, manifest_version: 2, + minimum_opera_version: '140', icons: { - 16: "static/images/icon.png", - 48: "static/images/icon.png", - 128: "static/images/icon.png", + 16: "static/images/icon-16.png", + 48: "static/images/icon-48.png", + 128: "static/images/icon-128.png", }, background: { scripts: ['background.js'], @@ -38,7 +39,7 @@ const _default: chrome.runtime.ManifestFirefox = { "" ], js: [ - "content_scripts_skeleton.js", + "content_scripts.js", ], run_at: "document_start" } @@ -51,11 +52,21 @@ const _default: chrome.runtime.ManifestFirefox = { '', ], optional_permissions: [ - "tabGroups", + 'tabGroups', + 'notifications', ], browser_action: { default_popup: "static/popup_skeleton.html", - default_icon: "static/images/icon.png", + default_icon: "static/images/icon-128.png", + }, + browser_specific_settings: { + gecko: { + id: '{a8cf72f7-09b7-4cd4-9aaa-7a023bf09916}', + data_collection_permissions: { + required: ['none'], + optional: ['technicalAndInteraction'], + }, + }, }, sidebar_action: { default_icon: "static/images/icon.png", diff --git a/src/manifest.ts b/src/manifest.ts index ecba682e6..caa0e1d65 100644 --- a/src/manifest.ts +++ b/src/manifest.ts @@ -11,10 +11,9 @@ * @author zhy * @since 0.0.1 */ -// Not use path alias in manifest.json -import packageInfo from "./package" -import { OPTION_ROUTE } from "./pages/app/router/constants" -const { version, author: { email }, homepage } = packageInfo +import packageJson from "../package.json" +import { APP_OPTION_ROUTE } from "./shared/route" +const { version, author: { email }, homepage } = packageJson const _default: chrome.runtime.ManifestV3 = { name: '__MSG_meta_marketName__', @@ -25,9 +24,9 @@ const _default: chrome.runtime.ManifestV3 = { homepage_url: homepage, manifest_version: 3, icons: { - 16: "static/images/icon.png", - 48: "static/images/icon.png", - 128: "static/images/icon.png", + 16: "static/images/icon-16.png", + 48: "static/images/icon-48.png", + 128: "static/images/icon-128.png", }, background: { service_worker: 'background.js' @@ -38,7 +37,7 @@ const _default: chrome.runtime.ManifestV3 = { "" ], js: [ - "content_scripts_skeleton.js", + "content_scripts.js", ], run_at: "document_start" } @@ -53,6 +52,7 @@ const _default: chrome.runtime.ManifestV3 = { ], optional_permissions: [ 'tabGroups', + 'notifications', ], host_permissions: [ "", @@ -60,9 +60,11 @@ const _default: chrome.runtime.ManifestV3 = { web_accessible_resources: [{ resources: [ 'content_scripts.js', - 'content_scripts.css', + 'content_scripts_limit.js', + 'vendor/*.js', 'static/images/*', 'static/popup.html', + 'static/limit.html', ], matches: [""], }], @@ -73,7 +75,7 @@ const _default: chrome.runtime.ManifestV3 = { /** * @since 0.4.0 */ - options_page: 'static/app.html#' + OPTION_ROUTE + options_page: 'static/app.html#' + APP_OPTION_ROUTE } -export default _default \ No newline at end of file +export default _default diff --git a/src/pages/app/Layout/VersionTag.tsx b/src/pages/app/Layout/VersionTag.tsx deleted file mode 100644 index 81b08baef..000000000 --- a/src/pages/app/Layout/VersionTag.tsx +++ /dev/null @@ -1,28 +0,0 @@ -/** - * Copyright (c) 2021 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ -import packageInfo from "@src/package" -import type { FunctionalComponent, StyleValue } from "vue" - -const STYLE: StyleValue = { - position: 'fixed', - width: '100px', - bottom: '-10px', - right: '10px', - textAlign: 'right', - color: '#888', - fontSize: '8px', -} - -const VersionTag: FunctionalComponent = () => ( -
-

- {`v${packageInfo.version}`} -

-
-) - -export default VersionTag \ No newline at end of file diff --git a/src/pages/app/Layout/icons/About.tsx b/src/pages/app/Layout/icons/About.tsx deleted file mode 100644 index a284cd7dc..000000000 --- a/src/pages/app/Layout/icons/About.tsx +++ /dev/null @@ -1,18 +0,0 @@ -/** - * Copyright (c) 2024 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import { type FunctionalComponent } from "vue" - -const About: FunctionalComponent = () => ( - - - - - -) - -export default About \ No newline at end of file diff --git a/src/pages/app/Layout/icons/Database.tsx b/src/pages/app/Layout/icons/Database.tsx deleted file mode 100644 index e3544ede2..000000000 --- a/src/pages/app/Layout/icons/Database.tsx +++ /dev/null @@ -1,19 +0,0 @@ -/** - * Copyright (c) 2024 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { FunctionalComponent } from "vue" - -const Database: FunctionalComponent = () => ( - - - - - - -) - -export default Database \ No newline at end of file diff --git a/src/pages/app/Layout/icons/Table.tsx b/src/pages/app/Layout/icons/Table.tsx deleted file mode 100644 index 7575d2896..000000000 --- a/src/pages/app/Layout/icons/Table.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) 2024 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { FunctionalComponent } from "vue" - -const Table: FunctionalComponent = () => ( - - - -) - -export default Table \ No newline at end of file diff --git a/src/pages/app/Layout/icons/Trend.tsx b/src/pages/app/Layout/icons/Trend.tsx deleted file mode 100644 index a726c0997..000000000 --- a/src/pages/app/Layout/icons/Trend.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) 2022 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { FunctionalComponent } from "vue" - -const Trend: FunctionalComponent = () => ( - - - -) - -export default Trend \ No newline at end of file diff --git a/src/pages/app/Layout/icons/Website.tsx b/src/pages/app/Layout/icons/Website.tsx deleted file mode 100644 index 5132f081d..000000000 --- a/src/pages/app/Layout/icons/Website.tsx +++ /dev/null @@ -1,16 +0,0 @@ -/** - * Copyright (c) 2024 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { FunctionalComponent } from "vue" - -const Website: FunctionalComponent = () => ( - - - -) - -export default Website \ No newline at end of file diff --git a/src/pages/app/Layout/icons/Whitelist.tsx b/src/pages/app/Layout/icons/Whitelist.tsx deleted file mode 100644 index bd8faabbd..000000000 --- a/src/pages/app/Layout/icons/Whitelist.tsx +++ /dev/null @@ -1,17 +0,0 @@ -/** - * Copyright (c) 2024 Hengyang Zhang - * - * This software is released under the MIT License. - * https://opensource.org/licenses/MIT - */ - -import type { FunctionalComponent } from "vue" - -const Whitelist: FunctionalComponent = () => ( - - - - -) - -export default Whitelist \ No newline at end of file diff --git a/src/pages/app/Layout/index.tsx b/src/pages/app/Layout/index.tsx index d55db02a0..1bf49c714 100644 --- a/src/pages/app/Layout/index.tsx +++ b/src/pages/app/Layout/index.tsx @@ -5,38 +5,57 @@ * https://opensource.org/licenses/MIT */ -import { initAppContext } from "@app/context" -import { CLZ_HIDDEN_MD_AND_UP, CLZ_HIDDEN_SM_AND_DOWN } from "@pages/element-ui/style" -import { ElAside, ElContainer, ElHeader, ElScrollbar } from "element-plus" +import { css, injectGlobal } from '@emotion/css' +import { ElAside, ElContainer, ElHeader, useNamespace } from "element-plus" import { defineComponent, type StyleValue } from "vue" import { RouterView } from "vue-router" +import { initAppContext } from "../context" import HeadNav from "./menu/Nav" import SideMenu from "./menu/Side" -import "./style.sass" -import VersionTag from "./VersionTag" + +const HEADER_STYLE: StyleValue = { + padding: 0, + height: 'fit-content', +} + +const CONTENT_CLS = css` + width: 100%; + margin: auto; + background: var(--el-fill-color-blank); + + html[data-theme='dark'] & { + background: var(--el-fill-color-lighter); + } +` + +const injectCss = () => { + const containerNs = useNamespace('container') + injectGlobal` + .${containerNs.b()} { + height: 100%; + overflow-y: auto; + } + ` +} const _default = defineComponent(() => { - initAppContext() + const { layout } = initAppContext() + + injectCss() return () => ( - - + + - - - - + + - + - ) }) diff --git a/src/pages/app/Layout/menu/Nav.tsx b/src/pages/app/Layout/menu/Nav.tsx index 1bc0198be..c143e5e38 100644 --- a/src/pages/app/Layout/menu/Nav.tsx +++ b/src/pages/app/Layout/menu/Nav.tsx @@ -5,73 +5,97 @@ * https://opensource.org/licenses/MIT */ -import { getUrl } from "@api/chrome/runtime" -import { t } from "@app/locale" -import { CloseBold, Link, Menu } from "@element-plus/icons-vue" -import { useSwitch } from "@hooks" -import { ElBreadcrumb, ElBreadcrumbItem, ElIcon, ElMenu, ElMenuItem } from "element-plus" +import { getIconUrl } from "@api/chrome/runtime" +import { t } from '@app/locale' +import { CloseBold, Menu } from "@element-plus/icons-vue" +import { css } from '@emotion/css' +import { useSwitch } from '@hooks' +import Flex from '@pages/components/Flex' +import Img from '@pages/components/Img' +import { ElBreadcrumb, ElBreadcrumbItem, ElIcon, ElMenu, ElMenuItem, useNamespace } from "element-plus" import { defineComponent, h, onBeforeMount, ref, watch } from "vue" import { useRouter } from "vue-router" -import { NAV_MENUS } from "./item" +import { indexOfItem, type MenuItem, navMenus } from "./item" import { handleClick, initTitle } from "./route" +import { colorMenu } from './style' -const findTitle = (routePath: string): string => { - const title = NAV_MENUS.find(v => routePath === v.route)?.title +const HEADER_HEIGHT = 'var(--el-header-height)' + +const useStyle = () => { + const menuNs = useNamespace('menu') + const containerCls = css` + height: 100%; + background: var(${colorMenu('bg')}); + color: var(${colorMenu('text')}); + padding-inline: 20px; + ` + const menuWrapperCls = css` + position: absolute; + top: calc(${HEADER_HEIGHT} - 1px); + left: 0; + width: 100vw; + z-index: 9999; + max-height: calc(100vh - ${HEADER_HEIGHT}); + & .${menuNs.b()} { + padding-bottom: 10px; + } + ` + return { containerCls, menuWrapperCls } +} + +const findTitle = (routePath: string, menus: MenuItem[]): string => { + const title = menus.find(v => 'route' in v && routePath === v.route)?.title return title ? t(title) : '' } -const _default = defineComponent(() => { +const _default = defineComponent<{}>(() => { + const menus = navMenus() const router = useRouter() const title = ref('') const [showMenu, , closeMenu, toggleMenu] = useSwitch(false) + const handleItemClick = (item: MenuItem) => { + handleClick(item, router) + closeMenu() + } const syncRouter = () => { const route = router.currentRoute.value - route && (title.value = findTitle(route.path)) + route && (title.value = findTitle(route.path, menus)) } watch(router.currentRoute, syncRouter) onBeforeMount(() => initTitle(router)) + const { containerCls, menuWrapperCls } = useStyle() return () => ( -
-